"随机访问"的全部秘密就一个公式:
第 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 对齐 在这里变成"除法分母"(每条记录的字节数)。
一句话:本节是"文件"这一章最有"工程感"的一节——它告诉你数据库最底层的样子。
一、为什么"定长"是关键
先看两个事实:
- 文件里的内容是一排连续字节(15.3.4 讲过:二进制文件就是"内存的复本");
- "第 i 条记录"在哪儿,取决于"前 i 条一共占了多少字节"。
如果每条记录长度都一样:
偏移 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); - 第 ② 步的
read还会把读位置再往后推 32 字节; - 写位置从头到尾没动过(还停在打开时的初始位置)——不重新
seekp,写回去的内容就落在错误的地方。
这就是"两个位置"存在的意义:读和写可以各走各的 ——但代价是你得记住"两边都要管"。
三、文本模式下为什么不要随机访问
你可能想:文本文件能不能也 seekg 到"第 3 行"?
不能可靠地做到,原因有两个:
- "第 3 行"在第几个字节,取决于前两行有多长 ——而每行的长度是可以不一样的(这是文本的特点);
- 文本模式可能做换行符转换(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
数字怎么来的:
- 每条 32 字节(
int4 +char[16]16 +double8 = 28,加 4 个对齐填充 = 32,15.3.4 算过); - 4 条共 128 字节;
- 第 3 条(下标 2)的偏移 = 2 × 32 = 64 ——直接从第 64 字节开始读,正是
Cid那一条。
读结果:
- ② 是"随机访问"的一句话证明:没有从头读,直接跳到第 64 字节 ——这就是"文件数据库"能秒查第 N 条的原因。
- ③ 证明了"读位置和写位置是分开的":
read之后读位置已经走到pos+32,而写位置还在原地 ——所以必须seekp(pos)把它拉过来(如果把seekp删掉,写回去的内容会落在文件开头,Ada那条就被毁了)。 - ④ 里
Bob的分数变成 100,其他三条没动 ——这就是"就地修改"(只改了 32 个字节,没有重写整个文件)。
这道题值得亲手跑三遍:
- 第一遍:正常跑,看结果;
- 第二遍:把 ③ 的
f.seekp(pos, ios::beg);注释掉 → 看Ada那条被毁掉(变成Bob的数据); - 第三遍:把
char name[16]改成string name→ 程序很可能直接崩(15.3.4 的老雷)。
五、30 秒实验
- 把 ② 改成
offsetOf(0)/offsetOf(3)→ 分别读到第 1 条和第 4 条。体会"一个乘法就能跳到任意一条"。 - 把 ① 里写入的条数改成 5(数组要跟着改),然后只跑到 ② 读第 3 条 → 照样能直接跳到(不需要先写完或先读完)。这就是"随机"两个字的含义。
- 用
fseek的思路想一下"倒数第 1 条"怎么定位 →offsetOf(N-1),或者seekg(-sizeof(Rec), ios::end)(末尾往前一条)。后者不依赖 N,更通用。 - 把文件用记事本打开 → 乱码。用十六进制查看器 → 能看到 4 段 32 字节的"整齐方块" ——这个"整齐"就是"定长"的视觉证据。
六、常见误区
- "随机访问就是'读得快'" ——不是速度问题,是"能不能不从头读"的能力问题。顺序访问也能读得快,但要读第 100 万条,它得先读完前 99 万条。
- "文本文件也能
seekg到第 N 行" ——定位到某个字节可以,定位到"第 N 行"不行。因为行长不固定,而且文本模式还有换行符转换。 - "
seekp和seekg差不多,用哪个都行" ——一个动读位置、一个动写位置,在"读-改-写"流程里两个都要用(本节 Demo ③ 就是教训)。 - "写了
ios::binary就不能用getline了" ——能用的。二进制模式只是"不做换行转换",getline认的是\n这个字符,不受影响。 - "记录下标从 1 开始" ——从 0 开始(和数组一致,7.1 讲过)。"第 3 条"是下标 2,偏移是
2 × 32 = 64——这一条最容易写错。 - "定长记录的写法太土了,现代应该用数据库" ——对,但也得知道底下是什么。 SQLite、MySQL 这些"真数据库"底层就是"定长/变长页 + 索引 + 偏移定位" ——你把这一节学会了,就理解了它们最核心的那一层。
自测三问(点开对答案)
1. **"随机访问"能成立的前提是什么?核心公式是什么?** ——**前提:二进制模式 + 定长记录**(**记录里不能有 `string`/指针这类变长或不定内容**)。**核心公式:第 `i` 条的起始偏移 = `i × sizeof(记录)`。** 2. **"读出一条记录、改一下、再写回去"的完整步骤是什么?为什么 `seekp` 不能省?** ——**`seekg(偏移)` → `read` → 在内存里改 → `seekp(偏移)` → `write`。** **`seekp` 不能省,因为 `read` 只推动"读位置",写位置还停在原处** ——**不重新定位,就会写到错误的位置(通常毁掉文件开头那条记录)。** 3. **为什么文本文件不适合随机访问?** ——**① 每行长度不一定相同,"第 N 行"在第几个字节无法直接算;② 文本模式可能做换行符转换(Windows 上 `\n` 变 `\r\n`),你算的字节数和磁盘上的对不上。**- ☐ 下一节(15.5 文件和流的关联与分离)
相关笔记
- 文件结尾的判断 —— 上一节:
eof()的正确用法 - 文件和流的关联与分离 —— 下一节:
open/close让流"换个对象" - 二进制文件的读-写 ——
read/write与结构体写文件的四个前提 - 文件指针移动操作 —— 两个位置的由来
- 结构体数组 —— 记录数组的写法
- 结构体的大小 ——
sizeof = 32里的那 4 个填充字节 - 🔷 C++基础 · 索引 —— 全课路线图