跳转至

现代C++工程实践——从零开始写个文件拷贝器(下):核心实现与实战测试

接着上回说

上一篇我们把框架搭好了,文件也打开了,缓冲区也准备好了,就差最关键的读写循环了。这一篇我们来把剩下的核心逻辑实现完,然后写个测试程序跑跑看。说实话,写完代码不测试就跟做菜不尝味道一样,总感觉不踏实。

核心读写循环:简单但不简陋

主循环的设计思路

文件拷贝的核心就是个循环:读一块,写一块,直到读完为止。听起来简单,但细节挺多。我们先看看整体结构:

while (in) {
  in.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));
  std::streamsize read_bytes = in.gcount();
  if (read_bytes <= 0)
    break;

  out.write(buffer.data(), read_bytes);
  if (!out) {
    std::cerr << "Write error while writing to: " << dst_path << "\n";
    return false;
  }

  copied += static_cast<std::uintmax_t>(read_bytes);

  // 进度更新逻辑...
}

循环条件是while (in),这里用到了流对象的operator bool()。只要输入流还处于良好状态(没遇到错误或EOF),就继续循环。这比写成while (!in.eof())要好,因为后者只检查EOF标志,不检查其他错误状态。

read和gcount的配合使用

in.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));
std::streamsize read_bytes = in.gcount();

read方法会尝试读取指定数量的字节,但不一定能读满。比如文件只剩1KB了,你让它读8KB,它也只能读1KB。所以紧接着要调用gcount()获取实际读取的字节数。

这里有个类型转换的小细节:buffer.size()返回的是size_t,而read要求的是std::streamsize(通常是long long)。虽然大多数情况下隐式转换没问题,但显式转换能避免编译器警告,也让代码意图更明确。

read_bytes <= 0的判断是个保险措施。正常情况下,如果流状态变坏,while (in)会退出循环,但多一层检查总没坏处。文件末尾的处理就是这样:最后一次read可能读到0字节并设置EOF标志,然后gcount()返回0,我们就break掉。

write和错误检查

out.write(buffer.data(), read_bytes);
if (!out) {
  std::cerr << "Write error while writing to: " << dst_path << "\n";
  return false;
}

写入用的是实际读到的字节数read_bytes,而不是buffer.size()。这点很关键,否则最后一块数据会写多余的垃圾字节。

每次写完立即检查流状态,一旦发现写入失败就立刻返回。写入失败的原因可能是磁盘满了,权限不够,或者设备出错。早点发现,早点停止,避免继续写入造成更多问题。

进度统计

copied += static_cast<std::uintmax_t>(read_bytes);

每写成功一块,就把字节数累加到copied上。这个值后续会用来计算进度百分比和速度。类型转换还是为了匹配std::uintmax_t,虽然read_bytes不会是负数,但编译器不知道,显式转换能让它安心。

进度条:让等待不那么煎熬

ProgressBar类的设计

进度条单独封装成一个类,职责单一,好维护:

class ProgressBar {
public:
  explicit ProgressBar(int width = 20) : bar_width_(width) {}

  void update(std::uintmax_t copied, std::uintmax_t total,
              double speed_bytes_per_s) const;

private:
  int bar_width_;
};

width是进度条的字符宽度,默认20个字符。太窄了不够直观,太宽了占地方,20是个折中值。update方法接受已拷贝字节数、总字节数和当前速度,负责在终端上画出进度条。

注意updateconst方法,因为它只是显示信息,不修改对象状态。这种const正确性在大型项目里很重要,能避免很多意外的修改。

进度条的绘制逻辑

void update(std::uintmax_t copied, std::uintmax_t total,
            double speed_bytes_per_s) const {
  double fraction = (total == 0) ? 1.0 : static_cast<double>(copied) / total;
  int filled = static_cast<int>(fraction * bar_width_);

  std::cout << "[";
  for (int i = 0; i < filled; ++i)
    std::cout << "=";
  if (filled < bar_width_)
    std::cout << ">";
  for (int i = filled + 1; i < bar_width_; ++i)
    std::cout << " ";
  std::cout << "] ";

  // ...
}

先算出完成的比例fraction,然后乘以宽度得到应该填充多少个字符。这里处理了除零的情况——空文件的话直接当作100%完成。

进度条的样式是[=====> ],已完成的用=,当前位置用>,未完成的用空格。三个循环分别画这三部分,简单直接。虽然可以用std::string拼接然后一次性输出,但对于这种频繁更新的场景,直接输出反而更高效。

百分比和大小显示

double percent = fraction * 100.0;
double copied_mb = static_cast<double>(copied) / (1024.0 * 1024.0);
double total_mb = static_cast<double>(total) / (1024.0 * 1024.0);

std::cout << std::fixed << std::setprecision(1) << percent << "% | "
          << copied_mb << "MB/" << total_mb << "MB | "
          << (speed_bytes_per_s / (1024.0 * 1024.0)) << "MB/s | ETA: ";

字节数转成MB显示,更人性化。std::fixedstd::setprecision(1)让浮点数保留一位小数,比如45.3%而不是45.283746%。这些IO manipulator是C++的老朋友了,虽然语法有点啰嗦,但功能很实用。

速度也是除以1024.0 * 1024.0转成MB/s。注意这里用的是1024而不是1000,因为计算机里的"兆"是二进制的,1MB = 1024KB = 1024*1024字节。虽然现在也有用1000的IEC标准(MiB vs MB),但对于这种内部显示,用1024更符合程序员的习惯。

ETA计算:剩余时间估算

double eta_seconds = 0.0;
if (speed_bytes_per_s > 1e-6 && copied < total)
  eta_seconds = static_cast<double>(total - copied) / speed_bytes_per_s;

if (copied >= total) {
  std::cout << "0s";
} else if (eta_seconds >= 3600) {
  int h = static_cast<int>(eta_seconds) / 3600;
  int m = (static_cast<int>(eta_seconds) % 3600) / 60;
  std::cout << h << "h " << m << "m";
} else if (eta_seconds >= 60) {
  int m = static_cast<int>(eta_seconds) / 60;
  int s = static_cast<int>(eta_seconds) % 60;
  std::cout << m << "m " << s << "s";
} else {
  int s = static_cast<int>(eta_seconds + 0.5);
  std::cout << s << "s";
}

ETA(Estimated Time of Arrival)就是用剩余字节数除以当前速度。这个估算会随着速度波动而波动,但总体上能给用户一个心理预期。

检查speed_bytes_per_s > 1e-6避免除零错误。1e-6是个足够小的数,基本上只要有速度就会大于它。

显示格式分三种情况:超过1小时显示"Xh Ym",超过1分钟显示"Xm Ys",否则只显示秒数。这种分级显示比统一用秒数要直观得多——你更愿意看到"2h 15m"还是"8100s"?

回车符的妙用

std::cout << '\r' << std::flush;

整个update方法最后输出一个回车符\r而不是换行符\n。回车符会让光标回到行首,下次输出就会覆盖这一行,这就是进度条能"动态更新"的秘密。

std::flush强制刷新输出缓冲区,否则输出可能被缓存起来,用户看不到实时的进度变化。

时间和速度计算

控制更新频率

auto now = std::chrono::steady_clock::now();
std::chrono::duration<double> since_last = now - last_report;
if (since_last.count() >= 0.1 || copied == total) {
  std::chrono::duration<double> elapsed = now - t_start;
  double speed = (elapsed.count() > 1e-9)
                     ? (static_cast<double>(copied) / elapsed.count())
                     : 0.0;
  bar.update(copied, total_size, speed);
  last_report = now;
}

不是每读写一块就更新进度条,而是至少间隔0.1秒才更新一次。为什么?因为进度条更新本身有开销,太频繁反而会拖慢拷贝速度。而且人眼也分辨不出那么高的更新频率,0.1秒(每秒10次)已经足够流畅了。

now - last_report得到一个duration对象,调用count()得到秒数(double类型)。chrono库的类型安全在这里体现出来了:不同的时间点和时间段有不同的类型,不会搞混。

速度计算是用已拷贝的字节数除以总耗时。注意检查elapsed.count() > 1e-9,虽然理论上不会是0,但浮点运算嘛,防御性编程总是好的。

特殊处理copied == total的情况,确保拷贝完成时一定会更新一次进度条,显示100%。

收尾工作

刷新和关闭

out.flush();
out.close();
in.close();

写完所有数据后,显式调用flush()确保缓冲区的内容都写入磁盘。虽然close()会自动flush,但显式调用更保险,万一flush失败也能及时发现。

close()其实不是必须的,因为析构函数会自动关闭文件。但显式关闭能让代码意图更清晰,而且可以提前释放文件句柄,在某些操作系统上这很重要。

最终进度和验证

auto t_end = std::chrono::steady_clock::now();
std::chrono::duration<double> total_elapsed = t_end - t_start;
double avg_speed = (total_elapsed.count() > 1e-9)
                      ? (static_cast<double>(copied) / total_elapsed.count())
                      : 0.0;
bar.update(copied, total_size, avg_speed);
std::cout << "\n";

std::uintmax_t dst_size = fs::file_size(dst_path);
if (dst_size != total_size) {
  std::cerr << "Size mismatch after copy. src=" << total_size
            << " dst=" << dst_size << "\n";
  return false;
}

最后再更新一次进度条,用平均速度,然后换行。这样进度条会保留在屏幕上,用户能看到最终的统计信息。

验证阶段比较简单,就是检查目标文件的大小是否和源文件一致。这不是万无一失的(理论上可能数据损坏但大小相同),但对于大多数错误场景够用了。如果要求更高,可以计算MD5或SHA-256校验和,但那会显著增加耗时。

实战使用

编写main函数

我们需要一个简单的测试程序来调用这个拷贝器:

// --- File: main.cpp ---
#include "fcopy.h"
#include <iostream>

int main(int argc, char* argv[]) {
  if (argc != 3) {
    std::cerr << "Usage: " << argv[0] << " <source> <destination>\n";
    return 1;
  }

  FileCopier copier;

  std::cout << "Copying " << argv[1] << " to " << argv[2] << "...\n";

  if (copier.copy(argv[1], argv[2])) {
    std::cout << "Copy succeeded!\n";
    return 0;
  } else {
    std::cerr << "Copy failed!\n";
    return 1;
  }
}

就这么简单。检查命令行参数个数,创建FileCopier对象,调用copy方法,根据返回值决定退出码。标准的Unix程序风格:成功返回0,失败返回非零。

编译命令

假设你的文件结构是这样的:

fcopy.h        // FileCopier类声明
fcopy.cpp      // FileCopier实现(包括ProgressBar)
main.cpp       // 测试程序

编译命令:

g++ -std=c++17 -O2 -Wall -Wextra main.cpp fcopy.cpp -o fcopy

几个编译选项说明下:-std=c++17指定C++17标准(因为用了filesystem),-O2开启优化,-Wall -Wextra打开警告(帮你发现潜在问题),-o指定输出文件名。

如果你用的是比较老的GCC版本(9.0之前),可能需要额外链接stdc++fs:

g++ -std=c++17 -O2 -Wall -Wextra main.cpp fcopy.cpp -o fcopy -lstdc++fs

Clang用户把g++换成clang++就行,其他都一样。

基本测试

先测试拷贝一个小文件:

./fcopy /etc/hosts hosts_backup

你应该能看到进度条一闪而过(文件太小了),然后显示"Copy succeeded!"。用ls -lh对比一下大小,或者diff命令验证内容是否一致:

diff /etc/hosts hosts_backup

没输出就说明一模一样,完美。

测试大文件

小文件测不出什么,咱们得找个大点的。如果手头没有,可以用dd命令生成一个:

dd if=/dev/urandom of=test_1gb.dat bs=1M count=1024

这会创建一个1GB的随机数据文件。然后拷贝它:

./fcopy test_1gb.dat test_1gb_copy.dat

现在你能看到进度条慢慢走,速度显示,ETA倒计时,整个体验就像下载管理器一样。拷贝完成后,验证一下:

md5sum test_1gb.dat test_1gb_copy.dat

两个MD5值应该完全一致。

边界情况测试

好的测试要覆盖边界情况:

空文件:

touch empty.txt
./fcopy empty.txt empty_copy.txt

应该能正常处理,进度条直接显示100%。

不存在的源文件:

./fcopy nonexistent.txt output.txt

应该输出"Source file does not exist"并返回失败。

没有写权限的目标:

./fcopy /etc/hosts /root/cannot_write.txt

应该输出"Failed to open destination file for writing"(假设你不是root)。

磁盘空间不足: 这个不太好模拟,但如果真遇到了,写入阶段会失败并返回错误。

性能测试

想知道这个拷贝器性能如何?可以跟系统的cp命令比较:

time ./fcopy test_1gb.dat copy1.dat
time cp test_1gb.dat copy2.dat

在我的机器上测试,两者速度差不多,都在1-2GB/s左右(取决于磁盘性能)。这说明我们的实现效率还行,没有明显的性能损失。

如果你想优化,可以试试调大chunk_size:

FileCopier copier(1024 * 1024);  // 1MB chunk

在某些场景下,更大的块能减少系统调用次数,提升性能。但也不是越大越好,太大了内存压力大,而且如果中途中断,已写入的数据会比较"粗糙"。

一个完整的测试脚本

写个shell脚本自动化这些测试:

#!/bin/bash

echo "=== File Copier Test Suite ==="

# Create test files
echo "Creating test files..."
dd if=/dev/zero of=test_small.dat bs=1K count=100 2>/dev/null
dd if=/dev/urandom of=test_medium.dat bs=1M count=100 2>/dev/null

# Test 1: Small file
echo -e "\n[Test 1] Small file (100KB)"
./fcopy test_small.dat test_small_copy.dat
if diff test_small.dat test_small_copy.dat > /dev/null; then
  echo "✓ Small file test passed"
else
  echo "✗ Small file test failed"
fi

# Test 2: Medium file
echo -e "\n[Test 2] Medium file (100MB)"
./fcopy test_medium.dat test_medium_copy.dat
md5_orig=$(md5sum test_medium.dat | awk '{print $1}')
md5_copy=$(md5sum test_medium_copy.dat | awk '{print $1}')
if [ "$md5_orig" = "$md5_copy" ]; then
  echo "✓ Medium file test passed"
else
  echo "✗ Medium file test failed"
fi

# Test 3: Empty file
echo -e "\n[Test 3] Empty file"
touch test_empty.dat
./fcopy test_empty.dat test_empty_copy.dat
if [ -f test_empty_copy.dat ] && [ ! -s test_empty_copy.dat ]; then
  echo "✓ Empty file test passed"
else
  echo "✗ Empty file test failed"
fi

# Test 4: Non-existent source
echo -e "\n[Test 4] Non-existent source"
if ! ./fcopy nonexistent.dat output.dat 2>/dev/null; then
  echo "✓ Error handling test passed"
else
  echo "✗ Error handling test failed"
fi

# Cleanup
echo -e "\n Cleaning up..."
rm -f test_*.dat test_*_copy.dat

echo -e "\n=== All tests completed ==="

保存为test_fcopy.sh,加上执行权限:chmod +x test_fcopy.sh,然后运行:./test_fcopy.sh。几秒钟内你就能知道所有功能是否正常。

可能的改进方向

虽然这个拷贝器已经挺实用了,但如果要继续优化,可以考虑:

多线程:可以一个线程读,一个线程写,用队列传递缓冲区,理论上能提升性能。但要注意同步开销,不一定总是更快。

内存映射:mmap(或Windows的等价API)把文件映射到内存,让操作系统来优化读写。不过这对超大文件可能有问题,而且跨平台性不如fstream

校验和:计算MD5/SHA-256确保数据完整性。可以在读写的同时进行计算,不会增加太多时间。

断点续传:记录已拷贝的位置,如果中断可以从断点继续。对超大文件很有用,但实现比较复杂。

批量拷贝:支持一次拷贝多个文件,或者整个目录树。这就需要递归遍历目录,创建对应的目录结构。

不过对于一个教学示例,我们现在的实现已经足够了。它简洁、健壮、性能合理,代码量也不大,正适合理解文件IO和现代C++特性。

总结

两篇文章下来,我们从需求分析到接口设计,从核心实现到测试验证,完整地实现了一个文件拷贝器。虽然只有两百多行代码,但麻雀虽小五脏俱全:错误处理、进度反馈、性能优化、边界情况,该考虑的都考虑到了。

更重要的是,我们用上了不少现代C++特性:std::filesystem简化路径操作,std::chrono精确测量时间,std::vector管理缓冲区,RAII自动释放资源,异常处理优雅报错。这些特性让C++写起来不再那么"硬核",代码可读性和安全性都上了一个台阶。

下次遇到类似的文件操作需求,你就知道该怎么下手了。记住:先想清楚需求,设计好接口,选对工具,然后一步步实现,最后好好测试。工程化思维就是这么来的,不是追求多花哨的技术,而是把每个环节都做扎实。