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

第十五章讲的是"怎么让数据活过这一次运行"。 一条主线: 流(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> 才管目录

二、必须记住的十个结论

  1. 文件只是一条流 ——ifstream 是 istream 的子类、ofstream 是 ostream 的子类,所以 << >> 原样可用;"写给屏幕"和"写给文件"能共用同一个函数(参数写 ostream &)。
  2. 打开之后必须检查 ——if (!in) { ... }。失败不报错、不崩溃,只是"流变成假的" ——后面所有读写全部无效,程序照样跑下去。
  3. ofstream 的默认打开会清空文件 ——因为它的默认模式是 ios::out | ios::trunc。想接着写必须显式 ios::app。 这是本章第一号"数据不见了"事故。
  4. fstream 的默认模式不创建文件 ——默认是 ios::in | ios::out,含 in 就要求文件已存在。要"不存在就建",可以先用 ofstream + app 温和地"碰"一下。
  5. 写完就 close() ——close() 会把缓冲区刷进磁盘。"写完立刻读读不到"、"写完别的程序看不到",十有八九是没关(或没刷)。
  6. 两条路按"谁来读"选 ——给人看 / 要跨平台 / 要能手工改 → 文本;只给程序读 / 要快、要小 → 二进制。
  7. 二进制必须加 ios::binary,而且有四个前提 ——① 不能有指针成员;② 不能用 string/vector 成员(换成定长数组);③ 注意对齐填充(sizeof 比成员相加大);④ 字节序和类型长度依平台 ——所以只适合"自己写自己读"。
  8. 读文件永远用"读操作当条件" ——while (getline(in, line)) / while (in >> x) / while (in.read(...))。绝不要写 while (!in.eof())(它会多处理一次)。
  9. eof() 是"事后报告" ——它表示"上一次读碰到了末尾",不是"下一次会碰到末尾"。正确用法是循环结束后判断"是正常读完、还是格式错了";要"事前预判"用 peek()。
  10. 随机访问=定长记录 + 偏移计算 ——第 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

数字怎么来的:

第二次运行(数据已经在):

记录大小 = 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),第二次运行还在。

四道顺手的练习题:

  1. 把 write 后面的 ios::binary 删掉 → 在这个数据上可能看不出差别(没有 \n 字节)——但把 name 填成含换行的字符串再试,就会看到字节数变了。永远别省 binary。
  2. 把 char name[16] 改成 string name → 程序多半直接崩(15.3.4 的老雷)。
  3. 把 modify 里的 seekp 删掉 → 看 Ada 那条被 Bob 的数据覆盖(这是 15.4.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 秒实验:把 ① ② ⑤ ⑦ ⑧ ⑩ ⑪ 逐个敲一遍。特别看 ① 和 ⑪:一个让你丢数据、一个让你删不掉文件,都是"看起来没错"的代码。 重点看 ②⑦:它们"不报错、安静地错" ——这类错误才是真正花时间的。

四、常见误区(本章汇总)

  1. "文件打不开,程序会提醒我" ——不会。标准库选择"安静失败"(点灯不喊人)。必须自己写 if (!in)。
  2. "ofstream 只是打开来写,不会动旧内容" ——默认会清空(含 trunc)。这一条造成的数据损失比本章其他所有坑加起来还多。
  3. "endl 和 "\n" 一样,随便用" ——endl 每次都要刷盘。批量写数据用 "\n",要实时可见(日志)才用 endl。
  4. "读完文件想再读一遍,seekg(0) 就行" ——不行,还要先 clear()(读到末尾置了 failbit,失败的流不理会 seekg)。
  5. "二进制文件比文本文件高级" ——它们只是不同的工具:二进制快、小,但不跨平台、不能手工改、扛不住结构变化;文本慢一点,但能读、能改、能跨工具。选工具看需求,不看"高级"。
  6. "结构体可以直接写文件" ——只有在"无指针、无 string、同编译器、同平台"的前提下才行。否则你写下的是一堆"只在这一次运行里有效的字节"。
  7. "eof() 为真说明出错了" ——"读完"是正常结束,不是错误(只是 fail() 会跟着亮)。eof() 的正确用途是"事后归因"。
  8. "文件操作就是语法,记住函数名就行" ——恰恰相反:文件操作 90% 的难点在"状态判断"和"模式选择"上,不在函数名上。 本章所有重要结论,几乎都是"某件事不按你以为的方式发生":默认会清空、失败不报错、读完会置错、数据留在缓冲里、删除要文件没被占用。

五、写完这一章,你会什么了

这一章之后,你的程序"记得住事了":

同时拿到三张能一直用的"判断表":

判断 出处 一句话
"用文本还是二进制?" 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(同一偏移)`** ——**否则会写到错误的位置(通常毁掉文件开头那条记录)。**

相关笔记