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

二进制读写只有两个函数:out.write((char *)&变量, sizeof(变量)) 和 in.read((char *)&变量, sizeof(变量))。 本质一句话:把内存里的那段字节,原样搬到磁盘上(读的时候原样搬回来)。 三个必守规矩:① 打开要加 ios::binary;② 参数必须 (char *) 强转;③ 长度用 sizeof。 而最大的坑是"能不能写结构体":能,但有四个前提 ——否则写出来的文件换一次运行就是垃圾。 一句话概括:二进制写的是"内存的复本",所以内存里有哪些坑(对齐、指针),文件里就有哪些坑。

📝 本节和前面几节的关系

15.3.2 / 15.3.3 走了文本路,本节走二进制路,是本章技术上最"深"的一节: - 8.1.5 的"结构体大小 ≠ 成员相加"(对齐)在这里变成真实存在的文件内容; - 10.2.2 的"浅拷贝之雷"在这里换个形式重演:写入 string/指针成员=写入一个地址; - 15.4.4 的"随机访问"要建立在本节的基础上(定长记录才能直接跳到第 N 条); - 15.3.5 的文件复制是本节技术的直接应用。 一句话:本节讲"字节级搬运",这是"文件"最本来的面目。

一、文本模式 vs 二进制模式:一个能看见的差别

先做一个 10 秒就能验证的实验:

{ ofstream t("t.txt");                 t << "A\nB\n"; }   // 文本模式
{ ofstream b("b.txt", ios::binary);    b << "A\nB\n"; }   // 二进制模式

看两个文件的大小(Windows 上):

文件 大小 磁盘上真实的内容
t.txt(文本) 6 字节 A \r \n B \r \n
b.txt(二进制) 4 字节 A \n B \n

你在代码里只写了 4 个字符,文本模式却存了 6 个字节 ——因为 Windows 的换行惯例是 \r\n,文本模式自动帮你把 \n 换成了 \r\n。

读的时候它也会反过来转换(\r\n → \n)——所以用文本模式读文本,你完全感觉不到这件事。

但对二进制数据,这个转换就是灾难:

一个 .png 里如果恰好有一个字节是 0x0A(正好等于 \n),文本模式会往里插一个 0x0D ——图片就废了。

结论:处理非文本内容(图片、压缩包、exe、结构体、定长记录),必须 ios::binary。

二、write / read 的用法

// 写:从 s 开始,原样写 n 个字节
out.write(const char *s, streamsize n);

// 读:从 s 开始,原样读 n 个字节
in.read(char *s, streamsize n);

三个值都得你自己给:

给什么 怎么写 为什么
数据的地址 (char *)&变量 它只认字节,不认类型
数据的长度 sizeof(变量) 要搬几个字节
打开模式 ios::binary 别让系统改你的字节

最基本的用法:

int n = 12345;
out.write((char *)&n, sizeof(n));         // 写 4 个字节

int m = 0;
in.read((char *)&m, sizeof(m));           // 读回来恰好还是 12345

注意 &:写的是"变量所在的那段内存",不是变量的"值"。这就是二进制文件的核心:它保存的是内存的复本。

一次写一个数组(更好用,因为数组本来就在内存里连续排着):

int arr[5] = {1, 2, 3, 4, 5};
out.write((char *)arr, sizeof(arr));      // 一次写 20 字节

注意这里不需要 & ——数组名本身就是首地址。

三、能不能直接写结构体?能,但有四个前提

这是本节最需要想清楚的地方。 先看一个能跑的版本:

struct Record {
    int    id;
    char   name[16];        // ★ 用定长字符数组,不用 string
    double score;
};

Record r = {1, "Ada", 95.5};
out.write((char *)&r, sizeof(r));         // 合法、能跑、能读回来

它能成立的前提有四个:

前提一:不能有指针成员(否则写进去的是"地址")

struct Bad { int *p; };                   // ❌ 千万不能这么写文件

写进去的是"那个地址的数值"(比如 0x7ffd1234)。下次运行程序,内存布局全变了,这个地址指向的东西完全不确定 ——读回来的就是一个野指针,用了就崩。

前提二:不能用 string / vector 成员(它们内部就有指针)

struct AlsoBad { string name; };          // ❌ 同样不行

string 对象本身只有"指针 + 长度 + 容量"这几个字段(实际数据的字符在堆上)。你把它写进文件,写进去的是那些内部字段,不是字符本身。 ——读回来必然崩。

所以真正能写文件的结构体,里面的"变长东西"必须换成"定长数组"(像上面那样 char name[16])。这就是 C 时代"姓名最多 15 个字符"这种限制的由来。

前提三:对齐填充字节也会被写进去(文件比你想的大)

cout << sizeof(Record);          // 32
// 成员相加 = 4 (int) + 16 (char[16]) + 8 (double) = 28

多出来的 4 个字节是"对齐填充"(8.1.5 讲过规则:double 要放在 8 的倍数偏移处,所以 name 后面被塞了 4 个填充字节)。

偏移  0            4                    20    24            32
      ┌────────────┬────────────────────┬─────┬─────────────┐
      │  int id(4) │  char name[16]     │填充4│ double(8)   │
      └────────────┴────────────────────┴─────┴─────────────┘

填充字节里是什么? 是垃圾值(从未初始化)。

所以"同样的数据,不同编译器写出来的文件可能不一样" ——文件里掺了"内存的随机残渣"。 严格来说,这不是错误(读的时候会被忽略),但说明"二进制文件不跨编译器"。

前提四:字节序和类型长度由平台决定

结论:

二进制文件适合"同一个程序自己读自己写"(存档、缓存、临时数据)。 不适合"跨平台交换" ——要跨平台,就用文本 + 明确的格式(JSON / CSV),或者自己定义字节序(网络协议里就是这么做的)。

四、gcount():这一次到底读到了几个字节

in.read((char *)&r, sizeof(r));
cout << in.gcount();          // 这一次 read 真的读到了几个字节

它解决什么问题? "文件长度不是记录长度的整数倍"的情况。

比如文件有 98 字节,记录是 32 字节 ——第 3 条读了一半就没了:

所以 while (in.read(...)) 是二进制读的标准写法,和 while (getline(...)) 一个道理:把读操作当条件。

五、实例06:使用 read 读取文件

教材这道题要做的:用 read 把二进制文件里的记录读出来。

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

struct Record {
    int    id;
    char   name[16];
    double score;
};

int main() {
    const string fname = "records.dat";

    // ══ ① 写 3 条记录 ══════════════════════════
    {
        Record recs[3] = {
            {1, "Ada", 95.5},
            {2, "Bob", 88.0},
            {3, "Cid", 72.5}
        };
        ofstream out(fname, ios::binary);
        if (!out) { cerr << "写不了 " << fname << endl; return 1; }
        out.write((char *)recs, sizeof(recs));      // 一次把 3 条都写出去
        out.close();
    }

    cout << "① sizeof(Record) = " << sizeof(Record) << " 字节" << endl;
    cout << "   文件里应该有 3 条 × 32 = 96 字节" << endl;

    // ══ ② 一条一条读回来 ════════════════════════
    {
        ifstream in(fname, ios::binary);
        if (!in) { cerr << "读不了 " << fname << endl; return 1; }

        Record r;
        int idx = 0;
        while (in.read((char *)&r, sizeof(r))) {    // ★ 读一条、判一条
            idx++;
            cout << "② 第 " << idx << " 条(读到 " << in.gcount() << " 字节): "
                 << "id=" << r.id
                 << " name=" << r.name
                 << " score=" << r.score << endl;
        }
        cout << "③ 循环结束后 gcount() = " << in.gcount()
             << "(最后一次读失败,一个字节都没读到)" << endl;
        in.close();
    }
    return 0;
}

看到什么算成功:

① sizeof(Record) = 32 字节
   文件里应该有 3 条 × 32 = 96 字节
② 第 1 条(读到 32 字节): id=1 name=Ada score=95.5
② 第 2 条(读到 32 字节): id=2 name=Bob score=88
② 第 3 条(读到 32 字节): id=3 name=Cid score=72.5
③ 循环结束后 gcount() = 0(最后一次读失败,一个字节都没读到)

数字怎么来的(本 Demo 最值得琢磨的地方):

读结果:

  1. ② 里的 name 直接 cout 就能打印 ——因为它是定长字符数组(char name[16]),内容是 "Ada" 加上后面的 '\0'(write 连那些 '\0' 一起写进去了)。
  2. while (in.read((char *)&r, sizeof(r))) 这个写法请记住 ——和 while (getline(...)) 是同一个道理:把读操作当条件,读成功就处理、失败就退出。
  3. score=88(不是 88.0) ——这是 cout 对 double 的默认输出,和文件无关(文件里就是那 8 个字节)。

两个值得做的小实验:

六、30 秒实验

  1. 把 ios::binary 从两处都删掉,两端仍然用 write/read → 在这个例子上可能"照样对"(因为数据里没有 \n 那个字节)。但这是运气。 把 name 填成含 \n 的字符串再试 → 大小会变、内容会错。永远别省 binary。
  2. 用记事本打开 records.dat → 乱码。用十六进制查看器打开 → 你会看到:01 00 00 00(小端存的 1)+ Ada + 一堆 00 + 8 字节的 95.5。亲眼看到"内存的复本"长什么样。
  3. 把文件手动截短几个字节(在十六进制编辑器里)再跑读程序 → 第 3 条不再被打印,while (in.read(...)) 正确地停下了。证明"读操作当条件"在数据损坏时也安全。
  4. 把 sizeof(recs) 改成 sizeof(Record)(只写一条) → 文件只有 32 字节,只能读出 1 条。

七、常见误区

  1. "二进制文件就是加密文件" ——不是。它只是"没有转成文字",内容和内存一模一样。打开是乱码,但只要按同样的结构读,就能完整还原。
  2. "结构体可以随便 write 出来" ——不能。有指针、有 string/vector 成员的结构体写出去就是"埋了个雷"(读回来是野指针)。
  3. "write 不用写 sizeof,它自己知道多大" ——不知道。它只认字节,多大完全由你说。 ——这也是为什么它参数是 char *。
  4. "二进制文件比文本文件小,所以一定更优" ——不一定:二进制不跨平台、不能手工改、不抗版本变化(换个结构体定义就读不出来了)。它是"高性能",不是"更高级"。
  5. "写了 ios::binary 只是'更快'" ——不是速度问题,是"准不准"的问题。不加 binary,Windows 上你的字节会被改。
  6. "读二进制时也用 while (!in.eof()) 就行" ——同样的错(15.3.3 / 15.4.3)。要用 while (in.read(...))。
自测三问(点开对答案) 1. **二进制读写用哪两个函数?三个必须写对的东西是什么?** ——**`write` / `read`**。**① 地址要 `(char *)&变量` 强转;② 长度用 `sizeof(变量)`;③ 打开时必须加 `ios::binary`。** 2. **为什么含 `string` 或指针成员的结构体不能直接 `write` 到文件?** ——**因为 `string` 和指针的"内容"不在对象自身内部,而在别的地方(堆/别处)**:**写进去的只是"那个地址的数值"**。**读回来时内存布局已变,这个地址就是野指针**,用一下就崩。 3. **`sizeof(Record)` 是 32,但成员相加只有 28,多出来的 4 个字节是什么?会写进文件吗?** ——**是对齐填充**(`double` 需要 8 字节对齐,所以 `char[16]` 后面补了 4 个字节)。**会写进文件**——**所以文件比"数据本身"大**。**填充字节里是未初始化的垃圾值**,这也说明二进制文件"不跨编译器"。

相关笔记