第十五章讲的是"怎么让数据活过这一次运行"。 一条主线:
流(15.1)→ 打开(15.2)→ 读写(15.3)→ 定位(15.4)→ 分离(15.5)→ 删除(15.6)。
整章一句话串起来:文件只是"另一条流",所以 << >> 照用;但文件比屏幕多了三样东西——"要不要清空"(打开方式)、"从哪儿读写"(位置)、"读到哪儿了"(状态)。
整章最重要的一条纪律:文件操作"失败不吭声" ——每次打开都检查、每次读完都看状态,这不是谨慎,是基本功。
收束整章。它是全课程里第一次"和外部世界打交道"的一章:
- 第二章的 cin/cout 在这里被"放大"成了通用能力(同一套语法,接了文件);
- 第十章的构造/析构在这里结了实账(流对象析构自动 close() ——14.3 留的"资源交给对象管"(RAII)第一次真正兑现);
- 第八章的结构体、第十三章的定长思路在 15.3.4 / 15.4.4 合成了"文件数据库的雏形";
- 第七章的 argc/argv在 15.3.5 让"文件复制"变成了真正的命令行工具。
往前看:本章之后你已经能写"有实际用途"的小工具了(日志、配置、数据导出、简易数据库)。本章留下的账:"资源一定要交给对象管"的正式名字 RAII 仍没登场;vector 中间插删要搬家、越界该抛异常(13.5 留的)也仍在。
一、本章知识地图
| 块 | 小节 | 一句话要点 |
|---|---|---|
| 流的概念 | 15.1 | 流=数据传输通道(抽象);文件就是一条流;缓冲与 flush 的时机 |
| 流类库 | 15.1.1 | ios 管公共规则 → istream/ostream 分方向 → ifstream/ofstream/fstream 干活;iostream 是双继承 |
| 使用的五步 | 15.1.2 | 包含头文件 → 建对象 → 打开 → 检查 → 读写 → 关闭;"检查"绝不能省 |
| 枚举常量 | 15.1.3 | 三组常量:打开方式(in/out/app/ate/trunc/binary)· 定位基准(beg/cur/end)· 格式标志(hex/fixed/boolalpha…);它们是位标志,用 | 组合 |
| 流的输入输出 | 15.1.4 | << / >> 是重载的函数(所以能连写、能给自定义类重载);字符相加走 ASCII(实例01) |
| 文件的打开 | 15.2 | 两种时机(构造时 / open());打开后必须 if (!in);"没打开"和"打开失败"状态一样 |
| 打开方式 | 15.2.1 | 六个常量的选择;app vs ate(强制末尾 vs 只开局末尾);trunc vs app(清空 vs 保留) |
| 默认打开方式 | 15.2.2 | ifstream → in;ofstream → out \| trunc(会清空!);fstream → in \| out(不建文件) |
| 创建文件 | 15.2.3 | 写模式打开不存在的文件 → 顺带创建(open 那一刻就建好了);不会建目录(实例02) |
| 两条路 | 15.3 | 文本(给人看、跨平台、能用记事本改)vs 二进制(给程序、快、小、不跨平台) |
| 文件流 | 15.3.1 | 三个类的分工;getline + istringstream 是处理文本的标准姿势(实例03) |
| 写文本文件 | 15.3.2 | << 写各种类型;格式控制照用;endl 刷盘、"\n" 不刷(实例04) |
| 读文本文件 | 15.3.3 | 三种读法(>> 逐词 / getline 逐行 / get 逐字符);>> 与 getline 混用要先清干净(实例05) |
| 二进制读写 | 15.3.4 | write/read + (char *) + sizeof;结构体写文件的四个前提(无指针、无 string、注意对齐填充、字节序依平台)(实例06) |
| 文件复制 | 15.3.5 | 三种方案;只有"二进制块"能复制任意文件;别忘处理"最后没读满的零头" |
| 文件指针移动 | 15.4 | 两个位置:读位置(seekg/tellg)· 写位置(seekp/tellp);求文件大小三行 |
| 文件错误与状态 | 15.4.1 | 四盏灯:good/eof/fail/bad;!in ≡ fail();clear() 是唯一的重启键 |
| 文件的追加 | 15.4.2 | ios::app(写永远在末尾,seekp 拉不回来) vs ate;in \| out 打开后写位置在开头 |
| 文件结尾的判断 | 15.4.3 | eof() 是"事后报告"不是"事前预告";用 while (读操作);想预判用 peek() |
| 指定位置读写 | 15.4.4 | 随机访问=定长记录 + i × sizeof(记录);读位置和写位置要分别 seek |
| 关联与分离 | 15.5 | open 关联、close 分离;一个对象能先后处理多文件,但必须"先关再开";析构会自动关 |
| 删除文件 | 15.6 | std::remove(<cstdio>,成功返回 0);删前必须先 close;C++17 的 <filesystem> 才管目录 |
二、必须记住的十个结论
- 文件只是一条流 ——
ifstream是istream的子类、ofstream是ostream的子类,所以<<>>原样可用;"写给屏幕"和"写给文件"能共用同一个函数(参数写ostream &)。 - 打开之后必须检查 ——
if (!in) { ... }。失败不报错、不崩溃,只是"流变成假的" ——后面所有读写全部无效,程序照样跑下去。 ofstream的默认打开会清空文件 ——因为它的默认模式是ios::out | ios::trunc。想接着写必须显式ios::app。 这是本章第一号"数据不见了"事故。fstream的默认模式不创建文件 ——默认是ios::in | ios::out,含in就要求文件已存在。要"不存在就建",可以先用ofstream+app温和地"碰"一下。- 写完就
close()——close()会把缓冲区刷进磁盘。"写完立刻读读不到"、"写完别的程序看不到",十有八九是没关(或没刷)。 - 两条路按"谁来读"选 ——给人看 / 要跨平台 / 要能手工改 → 文本;只给程序读 / 要快、要小 → 二进制。
- 二进制必须加
ios::binary,而且有四个前提 ——① 不能有指针成员;② 不能用string/vector成员(换成定长数组);③ 注意对齐填充(sizeof比成员相加大);④ 字节序和类型长度依平台 ——所以只适合"自己写自己读"。 - 读文件永远用"读操作当条件" ——
while (getline(in, line))/while (in >> x)/while (in.read(...))。绝不要写while (!in.eof())(它会多处理一次)。 eof()是"事后报告" ——它表示"上一次读碰到了末尾",不是"下一次会碰到末尾"。正确用法是循环结束后判断"是正常读完、还是格式错了";要"事前预判"用peek()。- 随机访问=定长记录 + 偏移计算 ——第
i条的偏移 =i × sizeof(记录);"读-改-写"时读位置和写位置要分别seek(seekg之后还要seekp)。
三、动手 Demo:两关自测
第一关(写代码):一个"学生成绩文件管理系统"
要求:只用本章(+前面课程)的东西,做一个能反复运行、数据能留下来的小系统。
| 功能 | 该用什么 | 对应小节 |
|---|---|---|
| 记录结构(定长,能写文件) | struct { int id; char name[16]; double score; } |
15.3.4 |
| 首次运行建库、之后保留数据 | 先判断文件是否已存在,不存在才初始化 | 15.2.3 |
| 把一批记录写进文件 | write((char *)recs, sizeof(recs)) + ios::binary |
15.3.4 |
| 追加一条新记录 | ios::app \| ios::binary,末尾写一条 |
15.4.2 |
| 全部读出来显示 | while (in.read((char *)&r, sizeof(r))) |
15.3.4 |
| 按 id 直接查第 N 条 | seekg(i * sizeof(Rec)) + read |
15.4.4 |
| 修改某一条的分数 | seekg → read → 改 → seekp → write |
15.4.4 |
| 求平均分 | 读的过程中累加,最后 (double)sum / n |
15.4.3 |
| 每一步都检查失败 | if (!in) { ... } / 检查 remove 的返回值 |
15.1.2 / 15.6 |
| 收尾:把临时文件删掉 | remove("tmp.dat")(先确保流已关) |
15.6 |
参考实现(自己先写,写不出来再看):
#include <iostream>
#include <fstream>
#include <string>
#include <cstdio>
using namespace std;
struct Rec { int id; char name[16]; double score; };
const string DB = "students.dat";
bool fileExists(const string &f) {
ifstream in(f);
return (bool)in;
}
// 初始化:只建一次,之后不再覆盖
void initDB() {
if (fileExists(DB)) { cout << "-- 数据库已存在,保留原数据 --" << endl; return; }
Rec recs[3] = { {1, "Ada", 60.0}, {2, "Bob", 70.0}, {3, "Cid", 80.0} };
ofstream o(DB, ios::binary);
if (!o) { cerr << "建库失败" << endl; return; }
o.write((char *)recs, sizeof(recs));
o.close();
cout << "-- 已初始化 3 条记录 --" << endl;
}
// 全部列出 + 统计
void listAll() {
ifstream in(DB, ios::binary);
if (!in) { cerr << "打不开数据库" << endl; return; }
Rec r;
int n = 0; double sum = 0;
cout << "-- 全部记录 --" << endl;
while (in.read((char *)&r, sizeof(r))) {
cout << " id=" << r.id << " name=" << r.name << " score=" << r.score << endl;
sum += r.score; n++;
}
if (n) cout << " 共 " << n << " 条,平均 " << sum / n << endl;
in.close();
}
// 追加一条
void append(const Rec &r) {
ofstream o(DB, ios::binary | ios::app);
if (!o) { cerr << "追加失败" << endl; return; }
o.write((char *)&r, sizeof(r));
o.close();
cout << "-- 已追加 " << r.name << " --" << endl;
}
// 按"第 i 条(从 0 开始)"修改分数
void modify(int i, double newScore) {
fstream f(DB, ios::in | ios::out | ios::binary);
if (!f) { cerr << "打不开数据库" << endl; return; }
long long pos = (long long)i * sizeof(Rec);
Rec r;
f.seekg(pos, ios::beg);
if (!f.read((char *)&r, sizeof(r))) { cout << "-- 第 " << i << " 条不存在 --" << endl; f.close(); return; }
r.score = newScore;
f.seekp(pos, ios::beg); // ★ 写位置要单独定位
f.write((char *)&r, sizeof(r));
f.close();
cout << "-- 已把第 " << i << " 条(" << r.name << ")改成 " << newScore << " --" << endl;
}
int main() {
cout << "记录大小 = " << sizeof(Rec) << " 字节" << endl;
initDB();
listAll();
Rec nr = {4, "Dee", 90.0};
append(nr);
modify(1, 100.0); // 把第 2 条(Bob)改成 100
listAll();
return 0;
}
看到什么算成功(第一次运行):
记录大小 = 32 字节
-- 已初始化 3 条记录 --
-- 全部记录 --
id=1 name=Ada score=60
id=2 name=Bob score=70
id=3 name=Cid score=80
共 3 条,平均 70
-- 已追加 Dee --
-- 已把第 1 条(Bob)改成 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
共 4 条,平均 82.5
数字怎么来的:
- 平均 70 = (60 + 70 + 80) / 3 = 210 / 3;
- 平均 82.5 = (60 + 100 + 80 + 90) / 4 = 330 / 4。
第二次运行(数据已经在):
记录大小 = 32 字节
-- 数据库已存在,保留原数据 --
-- 全部记录 --
id=1 name=Ada score=60
id=2 name=Bob score=100
id=3 name=Cid score=80
id=4 name=Dee score=90
共 4 条,平均 82.5
-- 已追加 Dee --
...
这就是"数据活过了程序" ——第一次运行的结果(Bob 改成了 100),第二次运行还在。
四道顺手的练习题:
- 把
write后面的ios::binary删掉 → 在这个数据上可能看不出差别(没有\n字节)——但把name填成含换行的字符串再试,就会看到字节数变了。永远别省binary。 - 把
char name[16]改成string name→ 程序多半直接崩(15.3.4 的老雷)。 - 把
modify里的seekp删掉 → 看Ada那条被Bob的数据覆盖(这是 15.4.4 的教训,证明两个位置是分开的)。 - 加一个"删除最后一条"的功能 → 思路:① 读出全部记录;② 用
ios::trunc重写(少写一条);或者用 C++17 的fs::resize_file截短(二进制定长记录才能这么做)。
第二关(找错):十五个判断,逐个说明对错与原因
| # | 代码 / 说法 | 对/错 | 为什么 |
|---|---|---|---|
| ① | ofstream out("data.txt");(本意:接着昨天继续写) |
错 | 默认模式含 trunc,会把昨天的内容全清掉。要用 ios::app |
| ② | ifstream in("no.txt"); 之后直接 in >> x;,不检查 |
错 | 打开失败时流处于失败状态,读操作无效,x 保持原值(垃圾/旧值),而且不报错 |
| ③ | fstream f("new.txt");(文件不存在,想让它建一个) |
错 | fstream 默认含 in,不创建文件 → 打开失败。要先用 ofstream + app 碰一下,或显式加 out |
| ④ | 写完文件不 close(),立刻用记事本打开 |
不一定看得到 | 数据可能还在缓冲区。close() / flush() / 程序正常结束才会刷出去 |
| ⑤ | struct S { string name; }; out.write((char *)&s, sizeof(s)); |
错 | string 内部有指针,写进去的是"地址的数值",读回来是野指针 → 崩 |
| ⑥ | ofstream out("a.dat"); out.write((char *)&obj, sizeof(obj)); |
错 | 没加 ios::binary:文本模式会做换行符转换,二进制内容会被改坏 |
| ⑦ | while (!in.eof()) { getline(in, line); cout << line; } |
错 | eof() 是"事后报告",进循环时可能还是 false → 多跑一轮,输出一个空行(或重复一个值) |
| ⑧ | 读到末尾后直接 in.seekg(0, ios::beg); 想重读 |
错 | 失败状态的流会忽略 seekg。要先 in.clear() 再 seekg(0) |
| ⑨ | in >> n; getline(in, line);(想读"下一行") |
错 | >> 停在分隔符前,换行还留在流里 → getline 读到空串。要先 getline 一次吃掉残余 |
| ⑩ | if (remove("a.txt") == 1) cout << "删除成功"; |
错 | remove 成功返回 0,非 0 才是失败 |
| ⑪ | 文件还被 ofstream 打开着就 remove |
多半失败 | Windows 上"文件被占用"删不掉。要先 close() 分离(15.5) |
| ⑫ | ofstream out("a.txt"); out.open("b.txt");(没先关) |
错 | 同一流对象同时只能连一个文件,没 close 就 open 会失败(并置 failbit) |
| ⑬ | 判断"还能不能继续读",写 while (in) 和写 while (in >> x) |
都对 | in 当条件 ≡ !fail();把读操作当条件更精确(一次完成"读"和"判")。后者是首选 |
| ⑭ | in.seekg(0, ios::end); long n = in.tellg(); 之后接着读文件 |
错 | 读位置停在末尾,不 seekg(0) 归位就读不出东西 |
| ⑮ | 文本文件也能可靠地 seekg 到"第 N 行" |
错 | 行长不固定,"第 N 行"在第几字节算不出来;文本模式还可能有换行符转换。随机访问要用二进制 + 定长记录 |
30 秒实验:把 ① ② ⑤ ⑦ ⑧ ⑩ ⑪ 逐个敲一遍。特别看 ① 和 ⑪:一个让你丢数据、一个让你删不掉文件,都是"看起来没错"的代码。 重点看 ②⑦:它们"不报错、安静地错" ——这类错误才是真正花时间的。
四、常见误区(本章汇总)
- "文件打不开,程序会提醒我" ——不会。标准库选择"安静失败"(点灯不喊人)。必须自己写
if (!in)。 - "
ofstream只是打开来写,不会动旧内容" ——默认会清空(含trunc)。这一条造成的数据损失比本章其他所有坑加起来还多。 - "
endl和"\n"一样,随便用" ——endl每次都要刷盘。批量写数据用"\n",要实时可见(日志)才用endl。 - "读完文件想再读一遍,
seekg(0)就行" ——不行,还要先clear()(读到末尾置了failbit,失败的流不理会seekg)。 - "二进制文件比文本文件高级" ——它们只是不同的工具:二进制快、小,但不跨平台、不能手工改、扛不住结构变化;文本慢一点,但能读、能改、能跨工具。选工具看需求,不看"高级"。
- "结构体可以直接写文件" ——只有在"无指针、无
string、同编译器、同平台"的前提下才行。否则你写下的是一堆"只在这一次运行里有效的字节"。 - "
eof()为真说明出错了" ——"读完"是正常结束,不是错误(只是fail()会跟着亮)。eof()的正确用途是"事后归因"。 - "文件操作就是语法,记住函数名就行" ——恰恰相反:文件操作 90% 的难点在"状态判断"和"模式选择"上,不在函数名上。 本章所有重要结论,几乎都是"某件事不按你以为的方式发生":默认会清空、失败不报错、读完会置错、数据留在缓冲里、删除要文件没被占用。
五、写完这一章,你会什么了
这一章之后,你的程序"记得住事了":
- 能写文件、能读回来、能验证内容对得上(15.3.1);
- 知道什么时候用文本、什么时候用二进制(15.3);
- 能写出一个"数据能活过下一次运行"的小系统(本节第一关);
- 能写一个真正的命令行工具(15.3.5 的文件复制 + 7.8.3 的
argv); - 能直接跳到文件里第 N 条记录去读、去改(15.4.4,这就是数据库的底层);
- 知道"追加"和"覆盖"差在哪一个常量上(15.4.2);
- 能一眼看出"这个循环会多跑一轮"(15.4.3);
- 知道"文件被占用就删不掉"是为什么(15.6)。
同时拿到三张能一直用的"判断表":
| 判断 | 出处 | 一句话 |
|---|---|---|
| "用文本还是二进制?" | 15.3 | 有人要读、要跨平台、要能手工改 → 文本;只给程序读、要快要小 → 二进制 |
| "用什么打开方式?" | 15.2.1 / 15.2.2 | 读 → in;覆盖写 → out \| trunc;追加 → out \| app;读写不清空 → in \| out;二进制一律加 binary |
| "怎么判断读完了?" | 15.4.3 | while (读操作) ——别问 eof(),直接读一次;eof() 只在读完之后用来"归因" |
自测十问(点开对答案)
1. **为什么说"文件只是一条流"?这句话有什么实际好处?** ——因为**文件被包装成了流对象**(`ifstream`/`ofstream`),而它们**继承自 `istream`/`ostream`**(15.1.1)。好处:**同一套 `<<` `>>` / `getline` / `get` 语法两边通用**;**函数参数写 `ostream &`,就能"既能写屏幕、又能写文件"**(15.1 的 Demo)。 2. **`ifstream` / `ofstream` / `fstream` 的默认打开方式分别是什么?各埋着什么坑?** ——**`ios::in`**(读,**文件不存在就失败**);**`ios::out \| ios::trunc`**(写,**会清空已有文件** ——**最大的坑**);**`ios::in \| ios::out`**(读写,**不清空、但也不创建文件**)。 3. **用文件流的五步是什么?"检查"为什么不能省?** ——**包含头文件 → 建对象 → 打开 → 检查 → 读写 → 关闭。** **因为打开失败时标准库"安静失败"**(不抛异常、不报错,只把流置为失败状态),**之后所有读写全部无效却照常往下跑** ——**不检查就会在坏数据上继续算。** 4. **文本文件和二进制文件各用什么函数读写?各适合什么场景?** ——**文本:写 `<<` / `put`,读 `>>` / `getline` / `get`** ——适合**配置、日志、CSV、要给人看的报表**;**二进制:写 `write`、读 `read`(必须 `ios::binary`)** ——适合**图片、可执行文件、定长记录、存档**。 5. **`write` / `read` 为什么参数是 `char *`?"能不能写结构体"的四个前提是什么?** ——**因为它们做的是"按字节搬运",不认任何类型**。**四个前提:① 无指针成员;② 无 `string`/`vector` 等"内部含指针"的成员(用定长数组代替);③ 接受对齐填充字节被写进去(`sizeof` 大于成员相加);④ 接受"字节序/类型长度依平台",即只自己读自己写。** 6. **复制文件的三种实现是什么?为什么只有一种能复制任意文件?** ——**逐字符、逐行、二进制块**。**只有二进制块(`ios::binary` + `read`/`write` 循环)能复制任意文件** ——**因为文本模式会做换行符转换,把 `\n` 换成 `\r\n`,破坏非文本内容。** **另外别忘了处理"最后一块没读满"的零头(用 `gcount()`)。** 7. **流里为什么有"两个位置"?分别由哪几个函数管?** ——**因为 `fstream` 要同时读写,共用一个位置会互相踩脚。** **读位置:`seekg` / `tellg`;写位置:`seekp` / `tellp`**(**g = get,p = put**)。**"读-改-写"的流程里两个都要管**(`read` 只推动读位置)。 8. **四个状态位是什么?"读到末尾"会点亮哪几盏灯?为什么不能用 `eof()` 判断"出没出错"?** ——**`goodbit` / `eofbit` / `failbit` / `badbit`。** **读到末尾会同时点亮 `eofbit` 和 `failbit`。** **而"格式错"和"打开失败"只点亮 `failbit`** ——**所以 `eof()` 说明不了"有没有出错",判断"能不能继续用"要看 `fail()`(也就是 `!in`)。** 9. **想"追加"写文件有哪两条路?`app` 和 `ate` 的差别是什么?** ——**路一:打开时用 `ios::app`**(**写永远在末尾,连 `seekp` 都拉不回来**);**路二:打开后 `seekp(0, ios::end)`**(**搬一次写位置,之后还能自由挪**)。**`app` = "以后所有写都追加";`ate` = "只在打开时站到末尾"。** 10. **"随机访问"的核心公式是什么?"读-改-写"一条记录要注意什么?** ——**第 `i` 条的偏移 = `i × sizeof(记录)`**(**前提:二进制模式 + 定长记录**)。**注意:`seekg` + `read` 只动了读位置,写回之前必须再 `seekp(同一偏移)`** ——**否则会写到错误的位置(通常毁掉文件开头那条记录)。**- ☑ 第十六章 坦克动荡游戏 —— 已完结:本章总结(游戏项目:窗口 + 游戏循环 + GDI+ + 对象体系;带走了游戏循环、对象分工、状态管理三个思路)