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

C++ 的流不是"一个类",而是一整个家族。整个家族挂在一棵继承树上: ios(公共祖先)→ istream / ostream(方向分家)→ 各个具体流(文件 / 字符串 / 键盘屏幕)。 看得懂这棵树,就明白了两件事:① 为什么换个对象、代码不用改;② 为什么 ios 里那些"状态检查"和"格式控制"哪个流都能用。 一句话概括:"通道"这个抽象,是用继承实现的——上层管通用规则,下层管具体去哪儿。

📝 本节和前面几节的关系

15.1 说了"流是通道",本节把这棵树的骨架画出来,为下面几件具体的事做铺垫: - 15.1.3 讲的 ios 枚举常量,就藏在树根那一层——所以它是"所有流共享"的; - 15.4.1 讲的状态位(good/fail/eof)也在树根那一层——同样所有流共享; - 第十一章的多重继承(11.3)在这里第一次见到"标准库也在用":iostream 就是 istream + ostream 双继承来的。 一句话:本节是"流"的族谱;后面所有小节,都是在族谱上找对应的人。

一、四个头文件,各管一段

流家族的第一件事是分家——不同用途的东西放在不同头文件里:

头文件 提供的东西 你用的场景
<iostream> cin / cout / cerr / clog 键盘与屏幕(2.5 就在用)
<fstream> ifstream / ofstream / fstream 文件(本章主角)
<sstream> istringstream / ostringstream / stringstream 把字符串当流用(拆解、拼接文本)
<iomanip> setw / setprecision / fixed / setfill 控制"怎么写好看"(2.5.3 用过)

四件套的分工,一句话记住:

一个常见的报错:#include <iostream> 之后写 ifstream,报 'ifstream' was not declared in this scope。 因为 ifstream 不在 <iostream> 里,必须单独 #include <fstream>。 这个错误在本章会不止一次遇到。

二、族谱:流类库的继承树

这是本章最重要的一张图,请务必看懂它。

                       ios            ← 所有流的公共祖先(状态 · 格式 · 缓冲)
              ┌─────────┴─────────┐
           istream              ostream        ← 第二层:方向分家(只读 / 只写)
        ┌─────┴─────┐        ┌─────┴─────┐
    ifstream   istring-   ofstream   ostring-
   (读文件)    stream   (写文件)    stream
              (读串)                (写串)
        └─────┬─────┘        └─────┬─────┘
              └──── iostream ──────┘      ← 由 istream 与 ostream 双继承而来
                        │
              ┌─────────┴─────────┐
          fstream            stringstream    ← 又能读又能写
        (读写文件)          (读写串)

从上往下看,记住四件事:

① 树根:ios —— 所有流的公共祖先

"状态检查"和"格式控制"是在这一层定义的,所以任何流都能用:

in.good();  in.fail();  in.eof();          // 状态检查 —— 15.4.1 细讲
cout.precision(2);  cout.width(8);         // 格式控制 —— 2.5.3 用过

这一条最实用:你只需要学一次 fail(),cin、ifstream、istringstream 上全都一样用。

② 第二层:istream / ostream —— 方向在此分家

"读"和"写"的能力在这一层就分开了——所以你不可能对一个 ifstream 写 <<(编译不过),这是编译器帮你挡住的错误,比运行期出错好得多。

③ 第三层:具体干活的三对类

你要读/写的地方 只读 只写 读写
磁盘文件 ifstream ofstream fstream
内存字符串 istringstream ostringstream stringstream
键盘 / 屏幕 istream(cin) ostream(cout) —

看这张表能悟出一件事:"文件"和"字符串"用的是同一套命名法 ——i/o 前缀 + 目标词 + stream。

这不是巧合:设计者故意让它们长得像,因为它们在代码里就是可以互相替换的(只要在 istream&/ostream& 这一层使用)。

④ 第四层:iostream —— 双继承的产物

fstream 和 stringstream 需要同时又读又写,所以标准库造了一个 iostream 类:

iostream 由 istream 和 ostream 两个类多重继承而来。

这是 11.3「多重继承」在标准库里的真实应用——教材里那个"看起来没什么用"的语法,在整个 C++ 标准库里服务了几十年。

三、藏在这棵树里的一个秘密:这些类其实是模板

严格说,你在代码里写的 ifstream 不是一个真正的类名,它是:

typedef basic_ifstream<char> ifstream;      // 我们在用的版本
// basic_ifstream<wchar_t> 是另一个版本(处理宽字符)

12.2 的类模板在这里藏得很深:整个流家族对 char 和 wchar_t 各有一份,靠模板做出来的(所以标准库不用把每个类写两遍)。

对你的影响:日常写 ifstream 就够了;只是当你在报错信息里看到 std::basic_ifstream<char> 这种长名字时,别慌,那就是 ifstream 的全名。

四、为什么要这么设计:把"通道"和"内容"拆开

流类库的设计里,有两件互相独立的事:

维度 管什么 换它意味着
通道(用哪个流对象) 数据从哪儿来 / 到哪儿去 屏幕 ←→ 文件 ←→ 字符串
内容(用哪个成员函数/运算符) 数据怎么解析 / 怎么格式化 >> / getline / get / 格式控制

关键:这两件事是正交的(可以自由组合)。

int x;
cin  >> x;                    // 键盘 + 按空白解析
in   >> x;                    // 文件 + 按空白解析(同一套解析规则)

换个通道,解析规则不变;换个解析方式,通道不变。

这就是 15.1 说的"接口统一"在类结构上的落地——统一,靠的是继承:

只要函数/运算符写在父类上,所有子类自动获得。

五、动手 Demo:同一个函数,喂三种流

#include <iostream>
#include <fstream>
#include <sstream>      // ← 字符串流在这里
#include <string>
using namespace std;

// 只认 ostream,完全不管对方是谁
void dump(ostream &out, const string &tag) {
    out << "[" << tag << "] " << 3.14159 << endl;
}

int main() {
    dump(cout, "screen");                 // ① 屏幕

    ofstream file("lib_demo.txt");        // ② 文件
    if (!file) {
        cout << "打不开文件" << endl;
        return 1;
    }
    dump(file, "file");
    file.close();

    ostringstream buf;                    // ③ 内存里的字符串流
    dump(buf, "memory");
    string s = buf.str();                 // 取出攒好的那串字
    cout << "缓冲里拿到: " << s;

    // 验证文件真的写进去了
    ifstream in("lib_demo.txt");
    string line;
    getline(in, line);
    cout << "文件里读到: " << line << endl;
    in.close();
    return 0;
}

看到什么算成功:

[screen] 3.14159
缓冲里拿到: [memory] 3.14159
文件里读到: [file] 3.14159

同时文件夹里多出 lib_demo.txt,内容是:

[file] 3.14159

读结果:

  1. 一个 dump 函数,在三个不同的地方干活(屏幕 / 文件 / 内存),代码一个字没改 ——因为三者的共同父类都是 ostream。
  2. buf.str() 只有 ostringstream 有,父类 ostream 没有。想用子类特有的功能,就得写出子类的名字 ——这正好印证了 11.1.1 的"吸收与不继承"。
  3. 注意输出的顺序:[screen] 先出现,然后是"缓冲里拿到",最后是"文件里读到"。因为 buf 把内容攒在内存里,是后来 cout 打出来的。

六、30 秒实验

  1. 把 #include <sstream> 删掉 → ostringstream 报 was not declared。四个头文件各管一段,缺一不可。
  2. 把 dump 的形参类型改成 ofstream &out → dump(cout, ...) 和 dump(buf, ...) 同时报错。父子关系是单向的。
  3. 删掉 file.close();,直接跑 → 这次能读到内容(因为程序结束时析构自动刷新)。但你不能指望这个 ——15.5 会讲为什么"该关就关"。

七、常见误区

  1. "ifstream 是 istream 的子类,所以 istream& 能用的它都能用" ——方向对了:父类有的子类全有。但反过来不成立:istream& 拿不到 open() / close()(那是 ifstream 自己加的)。
  2. "fstream 是 ifstream 和 ofstream 的父类" ——正好反了。fstream 是它俩的"兄弟":三者都从 istream/ostream 派生,fstream 靠"双继承"同时拿到两边能力。
  3. "<iostream> 里已经有文件流了" ——没有。cout 能用不代表 ifstream 能用,必须 #include <fstream>。
  4. "字符串流是玩具,学习用的" ——它是最常用的文本工具之一:把一行 "Ada,95" 拆成两段、把数字拼成字符串、清洗空格,都靠 stringstream(13.4.2 里的 istringstream 就是这个家族的人)。
  5. "这棵树很重要,我要背下来" ——不用背。只要记住三层:ios 管公共规则 → istream/ostream 管方向 → 具体类管去哪儿。真要查的时候,看一眼类名就知道它属于哪一支。
自测三问(点开对答案) 1. **只读文件、只写文件、读写文件,分别用哪个类?它们各属于 `istream` 还是 `ostream` 支?** ——`ifstream`(属 `istream` 支)/ `ofstream`(属 `ostream` 支)/ `fstream`(**双支**,因为它是 `iostream` 的子类)。**进错支的代价是编译报错**——比如对 `ifstream` 写 `<<` 编不过。 2. **为什么 `good()` / `fail()` / `eof()` 在 `cin`、`ifstream`、`istringstream` 上都能用?** ——**因为它们都在树根 `ios` 这一层定义**,**子类自动继承**。同理,`precision()` / `width()` 这些格式控制也在这一层。 3. **`iostream` 这个类是怎么来的?它解决了什么问题?** ——**由 `istream` 和 `ostream` 多重继承而来**(11.3)。**它让一个对象同时具备"读"和"写"两套能力**,`fstream` 和 `stringstream` 就靠它实现"可读可写"。

相关笔记