🔷 第十五章 文件操作
📄 一句话概括

复制文件有三种实现:逐字符、逐行、二进制块。 前两种简单、好懂,但只能复制文本文件;二进制块那一种才是"通用复制" ——图片、音乐、exe、压缩包,全靠它。 一句话判据:"要复制的文件是不是纯文本?" ——是 → 三种都行(选最省事的);不是 → 必须二进制块。 一句话概括:复制文件的本质是"搬运字节",所以"一字节都不许改"是底线——而只有二进制模式能保证这一点。

📝 本节和前面几节的关系

本节是 15.3 组的收尾,把前面五节的知识合起来用: - 15.3.1 的读写闭环 + 15.3.4 的 read/write = 三种复制方案; - 15.4 即将讲的 seekg/tellg 在本节先露个脸("求文件大小"就是复制程序要的第一个能力); - 13.1.1 的 vector 可以当缓冲区用(可选方案,比固定数组灵活)。 一句话:本节是"小工具程序"的第一次成型——从这以后,你能写出有用的命令行工具了。

一、三种方案,一段代码看清差别

方案一:逐字符(最简单,最慢)

char ch;
while (in.get(ch)) out.put(ch);

优点:短、好懂、不会漏字节(连 '\0' 都读)。 缺点:一个字符一次读写 ——对一个 100 MB 的文件,就是 1 亿次循环。

方案二:逐行(对文本文件更"语义化")

string line;
while (getline(in, line)) out << line << "\n";

优点:行的结构清楚了(想顺手改点什么很方便,比如跳过空行、加行号)。 缺点:① 一样慢;② 有个隐蔽的 Bug ——getline 把换行符丢掉了,你得自己补 "\n";如果原文件最后一行没有换行符,复制出来会多一个换行。

方案三:二进制块(最快,最通用)

const int BUF = 4096;
char buf[BUF];
while (in.read(buf, BUF)) out.write(buf, BUF);        // 整块搬运
long long rest = in.gcount();                          // 最后没满的那一块
if (rest > 0) out.write(buf, rest);

优点:

缺点:多了一点"处理零头"的代码(最后一块常常读不满)。

关键点就在那个"零头":

文件 10000 字节,块 4096:
  第 1 次 read:读到 4096  ✓
  第 2 次 read:读到 4096  ✓
  第 3 次 read:想读 4096,但只剩 1808 → read 失败,但读到了 1808  ✓
  → in.gcount() = 1808,必须把这 1808 字节单独写出去

漏掉这一步,复制出来的文件会"缺一小截尾巴" ——这是"手写文件复制"最常见的 Bug。

二、为什么"文本方式"不能复制任意文件

因为文本模式会"顺手改你的字节"(15.3.4 讲过):

文本模式 二进制模式
写入时 \n → \r\n(Windows) 原样
读取时 \r\n → \n 原样
后果 凡是内容里有 \n 的文件,复制后都会"变大" 一样大、内容完全一致

对纯文本:读时变回去、写时又变回来 →结果是对的,你感觉不到。 对图片/压缩包/exe:里面只要有一个字节恰好是 0x0A,就会被多插一个字节 → 文件损坏。

所以:

复制文件 = 搬运字节 = 必须用二进制模式。 文本模式只在"你知道它是文本、而且不介意转换"时才用。

三、动手 Demo:三种复制 + 一个"致命对照"

#include <iostream>
#include <fstream>
#include <string>
using namespace std;

// ── 工具:取文件字节数(15.4 会讲 seekg/tellg)──
long long fileSize(const string &f) {
    ifstream in(f, ios::binary);
    if (!in) return -1;
    in.seekg(0, ios::end);
    return (long long)in.tellg();
}

// ── 方案一:逐字符 ──────────────────────────
bool copyByChar(const string &src, const string &dst) {
    ifstream in(src);
    ofstream out(dst);
    if (!in || !out) return false;
    char ch;
    while (in.get(ch)) out.put(ch);
    return true;
}

// ── 方案二:逐行 ────────────────────────────
bool copyByLine(const string &src, const string &dst) {
    ifstream in(src);
    ofstream out(dst);
    if (!in || !out) return false;
    string line;
    while (getline(in, line)) out << line << "\n";
    return true;
}

// ── 方案三:二进制块(通用)─────────────────
bool copyBinary(const string &src, const string &dst, long long &total) {
    ifstream in(src, ios::binary);
    ofstream out(dst, ios::binary);
    if (!in || !out) return false;

    const int BUF = 4096;
    char buf[BUF];
    total = 0;
    while (in.read(buf, BUF)) {                 // 满块:整块搬
        out.write(buf, BUF);
        total += BUF;
    }
    long long rest = (long long)in.gcount();    // 零头:单独搬
    if (rest > 0) {
        out.write(buf, rest);
        total += rest;
    }
    return true;
}

// ── 工具:按行打出来看 ──────────────────────
void show(const string &f) {
    ifstream in(f, ios::binary);
    cout << "   [" << f << "] ";
    string line;
    int n = 0;
    while (getline(in, line)) cout << "(" << ++n << ")" << line << " ";
    cout << endl;
}

int main() {
    // ══ 第一部分:复制一个文本文件,三种方式内容都对 ══
    {
        ofstream o("src.txt", ios::binary);     // 用二进制写,保证磁盘字节精确
        o << "line 1\nline 2\nline 3\n";
        o.close();
    }
    cout << "源文件 src.txt 大小 = " << fileSize("src.txt") << " 字节" << endl;
    cout << "── 三种方式复制的结果 ──" << endl;
    copyByChar("src.txt", "cp_char.txt");  show("cp_char.txt");
    copyByLine("src.txt", "cp_line.txt");  show("cp_line.txt");
    long long n = 0;
    copyBinary("src.txt", "cp_bin.txt", n); show("cp_bin.txt");

    // ══ 第二部分:致命对照——复制一个"含 0x0A 字节"的文件 ══
    {
        char data[6] = {'A', '\n', 'B', '\n', 'C', '\0'};
        ofstream o("bin_src.dat", ios::binary);
        o.write(data, 6);
        o.close();
    }
    cout << "\n── 关键对照:复制一个含换行字节的二进制文件 ──" << endl;
    cout << "原文件   bin_src.dat          = " << fileSize("bin_src.dat") << " 字节" << endl;

    copyByChar("bin_src.dat", "bin_cp_text.dat");
    cout << "④ 文本方式复制(逐字符)    = " << fileSize("bin_cp_text.dat")
         << " 字节   ← 字节被改了!" << endl;

    copyBinary("bin_src.dat", "bin_cp_bin.dat", n);
    cout << "⑤ 二进制方式复制(块搬运)  = " << fileSize("bin_cp_bin.dat")
         << " 字节   ← 一字不差" << endl;
    return 0;
}

看到什么算成功(Windows 上):

源文件 src.txt 大小 = 21 字节
── 三种方式复制的结果 ──
   [cp_char.txt] (1)line 1 (2)line 2 (3)line 3 
   [cp_line.txt] (1)line 1 (2)line 2 (3)line 3 
   [cp_bin.txt]  (1)line 1 (2)line 2 (3)line 3 

── 关键对照:复制一个含换行字节的二进制文件 ──
原文件   bin_src.dat          = 6 字节
④ 文本方式复制(逐字符)    = 8 字节   ← 字节被改了!
⑤ 二进制方式复制(块搬运)  = 6 字节   ← 一字不差

数字怎么来的(都验算过):

读结果:

  1. 第一部分三个文件的内容完全一样 ——对文本文件来说,三种方式都能用。
  2. 第二部分才是本节的结论:同一个"逐字符复制"函数,用在二进制文件上就把文件改坏了(6 → 8 字节)。这就是"文本模式不能复制任意文件"的实证。
  3. 注意 ④ 用的还是"方案一"那段完全正确的代码 ——代码没错,错的是"用文本模式去搬二进制"。

补充说明:这组数字(21 / 6 / 8)是 Windows 上的结果。在 Linux/macOS 上文本模式不做换行转换,④ 也会是 6 字节。 "行为依平台而变"本身就是"不要用文本模式复制二进制"的另一个理由。

四、大文件怎么复制得更好

三个可以继续优化的点:

优化 做法 收益
加大块 BUF 从 4096 提到 64 KB 或 1 MB 系统调用更少,通常更快
用 vector 当缓冲区 vector<char> buf(BUF); → in.read(&buf[0], BUF) 缓冲区大小可以运行时决定(13.1.1)
先求大小、一次分配 用 fileSize 求总长,一次 read 读全 对中等大小的文件最快;对超大文件会吃光内存

真实工具(copy、robocopy、cp)的做法:大缓冲区 + 二进制模式 + 循环搬运 ——就是你写的那个 copyBinary。

再加一个实用改进:让"命令行参数"决定源和目标(7.8.3 讲过的 argc / argv):

int main(int argc, char *argv[]) {
    if (argc != 3) {
        cout << "用法: program <源文件> <目标文件>" << endl;
        return 1;
    }
    long long n = 0;
    if (copyBinary(argv[1], argv[2], n))
        cout << "已复制 " << n << " 字节" << endl;
    else
        cout << "复制失败" << endl;
    return 0;
}

这样就变成了一个真正能用的命令行工具(在 Dev-C++ 里通过「运行 → 参数」传参)。

五、30 秒实验

  1. 把 copyBinary 里"处理零头"的那三行删掉,再跑 → cp_bin.txt 会少最后 21 字节(因为 21 < 4096,第一次 read 就没读满 → 一块都没写出去)。这就是"漏掉零头"的 Bug。
  2. 把源文件换成一张图片(从网上下一个) → ① 逐字符复制出来的图片打不开(文本模式改坏了),③ 二进制复制的能正常打开。这是最有说服力的实验。
  3. 把 BUF 改成 1,再复制一个大文件 → 慢得肉眼可见。证明"块越大越快"。
  4. 用复制出来的 cp_bin.txt 当新源、再复制一次,反复几轮 → 大小一直不变(二进制复制是"无损"的)。换成文本方式反复复制 → 每轮都变大一些(\n 被越插越多)。这个实验最能说明问题。

六、常见误区

  1. "复制文件只要读字符、写字符就行" ——对纯文本行,对任何含"非文本字节"的文件不行。文本模式会改字节。
  2. "加 ios::binary 会让 getline 读不到行" ——不会。getline 认的是 \n 这个字符,跟模式无关。二进制模式只是"不做转换",所以你读到的 \n 还是 \n(在 Windows 上文本模式会把 \r\n 变成 \n,二进制模式会原样读成两个字符)。
  3. "read 一次读不满就是出错了" ——不一定。最后一次读不满是正常的(文件长度不是块长的整数倍) ——要用 gcount() 取出真正读到的字节数。
  4. "复制完不用检查" ——要检查。源文件打不开、目标写不了(磁盘满/无权限)都会失败,不检查就会得到一个"看起来成功、其实是空的"目标文件。
  5. "复制出来的文件大小应该和源一样" ——二进制模式复制一定一样;文本模式复制可能不一样(换行符转换)。大小不一样本身就是一个警报:你用了错误的模式。
  6. "逐行复制比逐字符复制更准" ——不一定:getline 丢掉了换行符,要自己补 \n,补得不对(比如原文件最后一行没有换行)就会多/少内容。逐字符和二进制块"漏字节"的风险更小。
自测三问(点开对答案) 1. **复制文件的三种实现是什么?哪一种能复制任意文件?为什么?** ——**逐字符、逐行、二进制块**。**只有二进制块能复制任意文件** ——**因为它用 `ios::binary` 打开,不做任何字节转换**;前两种是文本模式,**Windows 上会把 `\n` 变成 `\r\n`,破坏非文本文件。** 2. **二进制块复制时,为什么必须处理"最后一次没读满"的情况?** ——**因为文件的长度通常不是块长的整数倍**:**最后一次 `read` 会失败(没读满),但已经读到了若干个字节**,这些字节要用 **`gcount()` 取出来、单独写出去**。**不处理就会丢掉文件末尾一小截。** 3. **逐行复制有什么隐蔽的坑?** ——**`getline` 会把换行符读走丢掉**,**所以写的时候必须自己补 `"\n"`**;而且**如果源文件最后一行没有换行符,复制出来会多一个换行**(多一字节)。

相关笔记