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

"随机访问"的全部秘密就一个公式: 第 i 条记录的起始位置 = i × 每条记录的字节数 ——只要每条记录长度一样(定长记录),就能"不算数、直接跳"到第 i 条。 三个前提:① 二进制模式;② 定长记录(用 char name[16] 这种定长数组,不用 string);③ 读写位置要分别 seek。 一句话概括:"定长记录 + 按偏移定位"就是"文件数据库"的雏形 ——从本节起,你的文件能"跳着读"了。

📝 本节和前面几节的关系

本节是整章技术的"总装配": - 15.3.4 的 read/write + 15.4 的 seekg/seekp = 随机访问; - 15.4 的"两个位置" 在这里第一次显出必要性(读位置和写位置要分别定位); - 13.1.1 的 vector 可以配合使用(把一批记录读进 vector 再处理); - 8.1.5 的 sizeof 对齐 在这里变成"除法分母"(每条记录的字节数)。 一句话:本节是"文件"这一章最有"工程感"的一节——它告诉你数据库最底层的样子。

一、为什么"定长"是关键

先看两个事实:

如果每条记录长度都一样:

偏移  0                32               64               96
      ┌────────────────┬────────────────┬────────────────┬────────────────┐
      │  第 0 条(32)  │  第 1 条(32)  │  第 2 条(32)  │  第 3 条(32)  │
      └────────────────┴────────────────┴────────────────┴────────────────┘

于是:

第 i 条的偏移 = i × 32

一个乘法就能算出来 ——不用从头读一遍、数过去。

如果记录长度不一样(比如用 string name):

┌──────┬─────────────┬───────┬────────────────┬──────┐
│ 第0条│   第1条      │ 第2条 │     第3条       │ ...  │   ← 长度不同,第 i 条在哪儿?
└──────┴─────────────┴───────┴────────────────┴──────┘

你就只能"从头一条一条读过去"(这叫顺序访问),或者另建一个"索引表"记下每条的位置 ——那已经接近"数据库"的做法了。

所以本节的第一个结论:想随机访问,就得把变长的东西变定长 ——string 换成 char name[16],vector 换成定长数组。 这正是 15.3.4 "结构体能不能写文件"的第三个前提。

二、读位置和写位置:本节第一次必须分开处理

"改一条记录"其实包含四个动作:

// ① 把读位置移到第 i 条
f.seekg(i * sizeof(Rec), ios::beg);
// ② 读出这一条
f.read((char *)&r, sizeof(r));

// ③ 在内存里改它
r.score = 100;

// ④ 把写位置移到第 i 条(★ 单独定位!)
f.seekp(i * sizeof(Rec), ios::beg);
// ⑤ 写回去
f.write((char *)&r, sizeof(r));

为什么第 ④ 步不能省?

因为 f 是 fstream(可读可写),它内部有两个独立的位置(15.4 讲过):

这就是"两个位置"存在的意义:读和写可以各走各的 ——但代价是你得记住"两边都要管"。

三、文本模式下为什么不要随机访问

你可能想:文本文件能不能也 seekg 到"第 3 行"?

不能可靠地做到,原因有两个:

  1. "第 3 行"在第几个字节,取决于前两行有多长 ——而每行的长度是可以不一样的(这是文本的特点);
  2. 文本模式可能做换行符转换(Windows 上 \n ↔ \r\n)——你算的"字节数"和磁盘上的"实际字节数"可能对不上。

所以:

要随机访问 → 用二进制模式 + 定长记录。 文本文件 → 老老实实顺序读。

四、动手 Demo:一个"迷你文件数据库"

功能:写入 4 条记录 → 直接跳到第 3 条读 → 改第 2 条的分数 → 全部读回来验证。

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

struct Rec {
    int    id;
    char   name[16];        // ★ 定长:随机访问的前提
    double score;
};

// ── 第 i 条记录的字节偏移 ──
long long offsetOf(int i) {
    return (long long)i * sizeof(Rec);
}

int main() {
    const string fname = "db.dat";
    const int N = 4;

    // ══ ① 初始化:写 4 条记录 ══════════════════
    {
        Rec recs[N] = {
            {1, "Ada", 60.0},
            {2, "Bob", 70.0},
            {3, "Cid", 80.0},
            {4, "Dee", 90.0}
        };
        ofstream o(fname, ios::binary | ios::trunc);
        if (!o) { cerr << "写不了 " << fname << endl; return 1; }
        o.write((char *)recs, sizeof(recs));
        o.close();
        cout << "① 已写入 " << N << " 条,每条 " << sizeof(Rec)
             << " 字节,共 " << sizeof(recs) << " 字节" << endl;
    }

    // ══ ② 不算数、直接跳到第 3 条(下标 2)═══
    {
        ifstream in(fname, ios::binary);
        if (!in) { cerr << "读不了" << endl; return 1; }
        Rec r;
        in.seekg(offsetOf(2), ios::beg);       // ★ 一句话跳过去
        in.read((char *)&r, sizeof(r));
        cout << "② 直接跳到第 3 条: id=" << r.id
             << " name=" << r.name
             << " score=" << r.score << endl;
        in.close();
    }

    // ══ ③ 修改第 2 条(下标 1)的分数 ════════
    {
        fstream f(fname, ios::in | ios::out | ios::binary);
        if (!f) { cerr << "打不开" << endl; return 1; }

        long long pos = offsetOf(1);
        Rec r;
        f.seekg(pos, ios::beg);                // ① 定位读位置
        f.read((char *)&r, sizeof(r));         // ② 读出来
        cout << "③ 修改前: name=" << r.name << " score=" << r.score << endl;

        r.score = 100.0;                       // ③ 在内存里改

        f.seekp(pos, ios::beg);                // ④ ★ 单独定位写位置
        f.write((char *)&r, sizeof(r));        // ⑤ 写回去
        f.close();
        cout << "③ 已把第 2 条的分数改成 100" << endl;
    }

    // ══ ④ 全部读一遍,验证修改生效 ════════════
    {
        ifstream in(fname, ios::binary);
        Rec r;
        cout << "④ 全部记录:" << endl;
        while (in.read((char *)&r, sizeof(r)))
            cout << "   id=" << r.id << " name=" << r.name
                 << " score=" << r.score << endl;
        in.close();
    }
    return 0;
}

看到什么算成功:

① 已写入 4 条,每条 32 字节,共 128 字节
② 直接跳到第 3 条: id=3 name=Cid score=80
③ 修改前: name=Bob score=70
③ 已把第 2 条的分数改成 100
④ 全部记录:
   id=1 name=Ada score=60
   id=2 name=Bob score=100
   id=3 name=Cid score=80
   id=4 name=Dee score=90

数字怎么来的:

读结果:

  1. ② 是"随机访问"的一句话证明:没有从头读,直接跳到第 64 字节 ——这就是"文件数据库"能秒查第 N 条的原因。
  2. ③ 证明了"读位置和写位置是分开的":read 之后读位置已经走到 pos+32,而写位置还在原地 ——所以必须 seekp(pos) 把它拉过来(如果把 seekp 删掉,写回去的内容会落在文件开头,Ada 那条就被毁了)。
  3. ④ 里 Bob 的分数变成 100,其他三条没动 ——这就是"就地修改"(只改了 32 个字节,没有重写整个文件)。

这道题值得亲手跑三遍:

五、30 秒实验

  1. 把 ② 改成 offsetOf(0) / offsetOf(3) → 分别读到第 1 条和第 4 条。体会"一个乘法就能跳到任意一条"。
  2. 把 ① 里写入的条数改成 5(数组要跟着改),然后只跑到 ② 读第 3 条 → 照样能直接跳到(不需要先写完或先读完)。这就是"随机"两个字的含义。
  3. 用 fseek 的思路想一下"倒数第 1 条"怎么定位 → offsetOf(N-1),或者 seekg(-sizeof(Rec), ios::end)(末尾往前一条)。后者不依赖 N,更通用。
  4. 把文件用记事本打开 → 乱码。用十六进制查看器 → 能看到 4 段 32 字节的"整齐方块" ——这个"整齐"就是"定长"的视觉证据。

六、常见误区

  1. "随机访问就是'读得快'" ——不是速度问题,是"能不能不从头读"的能力问题。顺序访问也能读得快,但要读第 100 万条,它得先读完前 99 万条。
  2. "文本文件也能 seekg 到第 N 行" ——定位到某个字节可以,定位到"第 N 行"不行。因为行长不固定,而且文本模式还有换行符转换。
  3. "seekp 和 seekg 差不多,用哪个都行" ——一个动读位置、一个动写位置,在"读-改-写"流程里两个都要用(本节 Demo ③ 就是教训)。
  4. "写了 ios::binary 就不能用 getline 了" ——能用的。二进制模式只是"不做换行转换",getline 认的是 \n 这个字符,不受影响。
  5. "记录下标从 1 开始" ——从 0 开始(和数组一致,7.1 讲过)。"第 3 条"是下标 2,偏移是 2 × 32 = 64 ——这一条最容易写错。
  6. "定长记录的写法太土了,现代应该用数据库" ——对,但也得知道底下是什么。 SQLite、MySQL 这些"真数据库"底层就是"定长/变长页 + 索引 + 偏移定位" ——你把这一节学会了,就理解了它们最核心的那一层。
自测三问(点开对答案) 1. **"随机访问"能成立的前提是什么?核心公式是什么?** ——**前提:二进制模式 + 定长记录**(**记录里不能有 `string`/指针这类变长或不定内容**)。**核心公式:第 `i` 条的起始偏移 = `i × sizeof(记录)`。** 2. **"读出一条记录、改一下、再写回去"的完整步骤是什么?为什么 `seekp` 不能省?** ——**`seekg(偏移)` → `read` → 在内存里改 → `seekp(偏移)` → `write`。** **`seekp` 不能省,因为 `read` 只推动"读位置",写位置还停在原处** ——**不重新定位,就会写到错误的位置(通常毁掉文件开头那条记录)。** 3. **为什么文本文件不适合随机访问?** ——**① 每行长度不一定相同,"第 N 行"在第几个字节无法直接算;② 文本模式可能做换行符转换(Windows 上 `\n` 变 `\r\n`),你算的字节数和磁盘上的对不上。**

相关笔记