IO库
掌握C++的输入输出体系——iostream/fstream/sstream三种流,文件读写、字符串流格式化、流状态管理
学习目标
- 能比较并选择正确的流类型(iostream/fstream/sstream)完成输入输出——打开文件、逐行读取、用格式化操纵符对齐输出到控制台
- 能分析并恢复流状态——通过
good()/fail()/eof()判断读是否成功,并在读到 EOF 后用clear()+seekg()重新读取 - 能回答:用
while (file >> num)读文件里的整数,循环为什么会在读到末尾时自动停止?如果下一次再跑同样的循环会怎样?怎么修正?
机制总览
IO库:机制路径
- 1
直觉:程序靠什么和外界说话?
你已经用 cin 读键盘输入、用 cout 往屏幕上打字——这是程序和外界的两次对话。但真实的程序不会只和键盘屏幕打交道——它要读配置文件、写日志文件、解析 CSV 数据、把内存里的结果拼成格式化报告。
- 2
IO 流家族:一张继承树看清三条分支
在 C++ 里, 流(stream) 是你读写数据的统一通道。所有流类都源自同一个基础——一张继承树把三条分支清楚分开:
- 3
官方 Chapter 8 的三层 IO 契约
原书把这一章分成 IO 类、文件输入输出和 string 流三层。统一接口只是表面,真正需要掌握的是“操作、缓冲、条件状态”如何协作:一次提取可能成功并同时抵达文件尾,状态位也可能组合出现,输出进入缓冲并不代表外部设备已经持久化。
章级决策实验
IO库:机制与证据
切换《IO库》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 直觉:程序靠什么和外界说话?
你已经用 cin 读键盘输入、用 cout 往屏幕上打字——这是程序和外界的两次对话。但真实的程序不会只和键盘屏幕打交道——它要读配置文件、写日志文件、解析 CSV 数据、把内存里的结果拼成格式化报告。
可核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:程序靠什么和外界说话?」的契约。
学完《IO库》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
IO库:失效与核验
直觉:程序靠什么和外界说话?
典型失效
若把「直觉:程序靠什么和外界说话?」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:程序靠什么和外界说话?」的契约。
IO 流家族:一张继承树看清三条分支
典型失效
若把「IO 流家族:一张继承树看清三条分支」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「IO 流家族:一张继承树看清三条分支」的契约。
官方 Chapter 8 的三层 IO 契约
典型失效
若把「官方 Chapter 8 的三层 IO 契约」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「官方 Chapter 8 的三层 IO 契约」的契约。
直觉:程序靠什么和外界说话?
你已经用 cin 读键盘输入、用 cout 往屏幕上打字——这是程序和外界的两次对话。但真实的程序不会只和键盘屏幕打交道——它要读配置文件、写日志文件、解析 CSV 数据、把内存里的结果拼成格式化报告。
想象一条工厂流水线:原料从进料口送进来,经过加工,成品从出料口送出去。进料口可以是手推车(键盘输入)、集装箱(文件)、小料斗(字符串)——但流水线上的加工步骤完全一样。C++ 提供的就是这样一条通用输送带——无论数据从哪来、去哪,你用的读写方式都一样。
没有这条通用输送带会怎样? 读写文件要学一套独有的做法、操作字符串又是另一套——每种数据源都是全新的用法,代码不能复用。这一章教你用同一套读写方式操作所有数据源——从此「在哪读、读到哪」是选的,不是重写的。
IO 流家族:一张继承树看清三条分支
在 C++ 里,↡C++ 输入输出的抽象概念。流是一个字符序列,你可以从中读取字符或向其写入字符。标准库提供统一的提取、插入和 getline 接口,不同设备可复用同一套操作。 是你读写数据的统一通道。所有流类都源自同一个基础——一张继承树把三条分支清楚分开:
看上面的继承树——ios_base 是根,管理格式状态(精度、进制、宽度)和流状态(正常/失败/结束等)。ios 继承它,增加了对缓冲区的管理。从 ios 分出两条腿:istream(只负责读)和 ostream(只负责写)。然后三条分支各自展开:
-
↡标准控制台 IO 流,包含 cin、cout、cerr 和 clog。cerr 默认设置 unitbuf,每次输出操作后刷新;clog 通常使用常规缓冲。引入 iostream 头文件即可使用。 分支:
iostream同时继承istream和ostream——既能读又能写。你每天用的cin就是istream对象,cout就是ostream对象。cerr的准确说法是默认启用unitbuf自动刷新,而不是承诺底层完全没有缓冲。 -
↡文件 IO 流。ifstream 从文件读取,ofstream 写入文件,fstream 同时读写同一个文件。可通过构造函数或 open 打开,析构时关闭,并支持 in、out、app、ate、trunc 和 binary 模式。 分支:
ifstream(读文件)继承istream,ofstream(写文件)继承ostream,fstream(读写文件)继承iostream。所以ifstream有>>和getline——和cin的用法一模一样。 -
↡字符串 IO 流。istringstream 从字符串中读取,ostringstream 把各种类型的数据拼接输出到字符串,stringstream 同时支持读写。它们把字符串当作流的「数据源」或「数据目标」——用法和 cin/cout 完全一致。 分支:
istringstream(读字符串)继承istream,ostringstream(写字符串)继承ostream,stringstream(读写字符串)继承iostream。所以istringstream和cin用法一样——只是数据来源变成了一段内存里的字符串。
三层共用同一个接口的根源:它们都继承自 ios_base 的格式状态和流状态管理,继承自 istream/ostream 的操作接口(>>/<</getline 等),还共用同一套 ↡流通过 streambuf 管理的内存区域批量传递字符,减少直接访问设备的次数。输出缓冲可在缓冲满、显式 flush、关联流操作或文件关闭时提交。 机制——这就是为什么对 cin 学会的东西在文件和字符串上完全能用。缓冲策略由具体流和设备决定,不能用固定字节数或固定倍数概括;通用 ostream 的析构也不等于一次可检查的成功刷新。
官方 Chapter 8 的三层 IO 契约
原书把这一章分成 IO 类、文件输入输出和 string 流三层。统一接口只是表面,真正需要掌握的是“操作、缓冲、条件状态”如何协作:一次提取可能成功并同时抵达文件尾,状态位也可能组合出现,输出进入缓冲并不代表外部设备已经持久化。
IO 类、条件状态与输出缓冲
IO 对象不能拷贝或赋值,所以接受或返回流的函数通常使用引用。读写函数也返回流引用,这才允许 out << a << b 连续操作。流的条件状态是位掩码而不是互斥枚举:eofbit、failbit、badbit 可以同时存在;只有状态值恰好为 goodbit 时 good() 才为真,而 fail() 会检查 failbit 或 badbit。
流在布尔上下文中的判断基于 !fail()。因此 while (in >> value) 的准确含义是:先尝试一次提取,再检查提取后的流是否仍可用。它不等价于只检查状态而不读取的 while (!in.fail()),后者若把提取写在循环体内,很容易多处理一次旧值。
输出缓冲有几种常见提交时机:程序正常结束刷新标准输出、缓冲区满、使用 endl 或 flush、设置 unitbuf,以及读取与之绑定的输入流。cin 默认绑定 cout,所以读取前会刷新提示文字。endl 同时写换行并刷新,普通 '\n' 只写换行;无需立刻显示或持久化时,不应为每行强制刷新。
std::cout << "name: " << std::flush; // 立刻显示提示
std::cin >> name;
auto *old = std::cin.tie(nullptr); // 解除绑定,需自行管理刷新
std::cin.tie(old); // 恢复原绑定对文件输出,析构会关闭文件并尝试提交缓冲,但析构阶段无法让调用者可靠处理写入失败。需要确认数据落盘时,应显式 flush() 或 close(),随后检查流状态;磁盘已满、设备错误等情况不能被“RAII 一定不丢数据”掩盖。
文件流:把数据存到硬盘、从硬盘读回来
程序跑完数据就没了——除非你把它写进文件。↡创建 ifstream/ofstream/fstream 对象时传给构造函数的第二个参数(默认有合理值)。共六种:in(读)、out(写,默认截断)、app(追加,每次写前自动 seek 到文件末尾)、ate(打开时 seek 到末尾)、trunc(截断,打开时清空文件内容)、binary(禁止换行符转换,Windows 尤其重要)。多个模式用 | 组合(如 ios::in | ios::binary)。 告诉流你要对文件做什么操作——只读?追加?还是覆盖?
模式组合有约束:trunc 需要同时启用 out;app 隐含输出且不能与 trunc 同用;ifstream 只能以输入为主,ofstream 只能以输出为主,fstream 才能组合 in | out。仅使用 out 打开现有文件通常会截断;要保留内容,应明确选择 app,或在 fstream 上组合 in | out 并自行定位。ate 只在打开时定位到末尾,之后仍可移动;app 则在每次写之前回到末尾。
用文件流做三件事:打开、读写、关闭。整个过程像一个闭环——打开把文件句柄领到手,读写操作数据,关闭归还句柄。看下面的 Stepper 演示每一步做了什么事。
文件流生命周期:一步一步走
先预测:文件只含
10 20且末尾没有换行时,第二次成功读出20后eof()和fail()分别是什么?循环为了确认再无下一个整数而执行第三次提取后,两者又是什么?先写下答案,再用后面的状态图和rdstate()验证;关键是区分“已抵达末尾”和“本次没有提取到值”。
打开文件
先把 ifstream 对象构造起来——传入文件名和打开模式。构造瞬间系统去磁盘上找这个文件、准备好读取通道。
#include <fstream> // 文件流的头文件
#include <iostream>
int main() {
std::ifstream file("data.txt"); // ① 构造即打开:打开名为 data.txt 的文件
if (!file) { // ② 必须检查:文件不存在→构造失败→流进入fail状态
std::cerr << "无法打开文件!\n";
return 1;
}
// 现在 file 就绪——可以读了打开模式可以组合——ios::in | ios::binary 同时指定读模式 + 禁止换行符转换(二进制数据必须加):
std::ifstream binFile("data.bin", std::ios::binary); // 二进制读模式
std::ofstream log("app.log", std::ios::app); // 追加写模式:新内容接在文件末尾字符串流:字符串就是你的「临时控制台」
你需要把三个数字拼成 "scores: 85, 92, 78" 这种格式;你拿到一段 "Bob 85 90\r\n" 这样的字符串需要提取名字和分数——这些场景下,↡ostringstream 对象的成员函数 str(),返回当前流中已写入的全部内容的 string 副本。等价于「把往控制台打的东西全部收集成一整根字符串」。 是钥匙。
#include <sstream>
#include <iostream>
#include <string>
// 场景一:把多种类型拼成格式字符串——像 cout 一样往 string 里写
std::ostringstream oss;
oss << "scores: " << 85 << ", " << 92 << ", " << 78;
std::string result = oss.str(); // "scores: 85, 92, 78"
std::cout << result << std::endl;
// 场景二:从字符串中提取格式化数据——像 cin 一样从 string 里读
std::string raw = "Bob 85 90";
std::istringstream iss(raw);
std::string name;
int a, b;
iss >> name >> a >> b; // 单词 "Bob"、数字 85、数字 90
std::cout << name << " 总分: " << a + b << std::endl; // Bob 总分: 175
// 场景三:逐行处理文本后用 stringstream 拆词
std::string line = "apple banana cherry";
std::istringstream words(line);
std::string w;
while (words >> w) // 逐个读出三个词
std::cout << "[" << w << "] "; // [apple] [banana] [cherry]用 stringstream 代替直接字符串拼接的三大好处:① 自动处理类型转换——oss << 3.14159 不需要 to_string;② 格式化操纵符全支持——fixed << setprecision(2) 照样用;③ 测试方便——用 istringstream 模拟文件输入,不需要写临时文件。
流状态:每一步都知道「刚才读成功了吗」
你已经用了 while (file >> num) 这种写法——它为什么能在文件读完时自动停?因为每次 IO 操作后流内部有一组标志位记录着刚才的操作是否成功——这组标志构成了流的 ↡C++ 流对象内部的一组标志位(iostate 枚举),用于跟踪最近一次 IO 操作的结果。四种可能:goodbit(一切正常)、eofbit(读到文件末尾)、failbit(操作失败但流可恢复)、badbit(流已崩溃、不可恢复)。通过 rdstate() 获取当前掩码,good()/eof()/fail()/bad() 分别检查各个位,clear() 重置所有位。。
四种状态各司其职:
goodbit(值为 0):一切正常——good()返回 true。上次操作成功,可以继续读。eofbit:读到文件末尾——eof()返回 true。不是错误——是「数据没更多了」的通知。failbit:操作失败但流可恢复——比如试图把"abc"读进int、或者已经 EOF 后再读。fail()返回 true。badbit:流崩溃、不可恢复——底层系统 IO 错误、缓冲区损坏。bad()返回 true。这个流基本废了。
状态管理只靠三个操作:
std::ifstream file("data.txt");
auto state = file.rdstate(); // 读取当前状态(返回 iostate 掩码)
if (file.eof()) {
file.clear(); // 重置所有状态位——流恢复正常
file.seekg(0, std::ios::beg); // 把文件读取位置移回开头
// 现在可以重新读一遍文件了
}
file.setstate(std::ios::failbit); // 手动设置 failbit——模拟失败场景失败提取后应先 clear() 再重新定位:C++11 的 seekg 会先清除 eofbit,但不会替你清掉 failbit 或 badbit;这些失败位仍在时定位不会按预期工作。稳定顺序是先恢复状态,再 seekg() 移位置,并检查定位结果。
格式化输出:让数字和文字排版整齐
往控制台或文件输出数据时——原始数字挤在一起根本没法看。C++ 的 ↡插入或提取到 IO 流的特殊函数或对象,用来改变格式行为。例如 setw 设字段宽度、fixed 设固定小数格式、hex 切换十六进制;带参数的工具位于 iomanip 头文件。 就是排版工具——控制宽度、精度、进制、对齐方式。
操纵符分两类——记忆规则就是「width 只用一次,其他持久生效」:
仅一次生效(用完就忘):
#include <iomanip>
std::cout << std::setw(6) << 42 << std::setw(6) << 100;
// 输出: 42 100(每个字段占 6 个字符宽度,右对齐)持久生效(设一次影响后续所有输出):
std::cout << std::fixed << std::setprecision(3); // 这一行设好格式
std::cout << 3.14159 << '\n'; // 3.142(固定小数 + 3 位精度)
std::cout << 2.71828 << '\n'; // 2.718(格式持续生效)
std::cout << std::defaultfloat; // 恢复默认浮点格式最常见的格式化输出模板——靠左排名字、靠右排分数,每列宽度固定:
#include <iostream>
#include <iomanip>
#include <string>
int main() {
struct { std::string name; int score; } data[] = {
{"Alice", 92}, {"Bob", 85}, {"Charlie", 78}
};
// 表头
std::cout << std::left << std::setw(12) << "姓名"
<< std::right << std::setw(8) << "分数\n";
std::cout << std::string(20, '-') << '\n';
// 数据行
for (auto &d : data)
std::cout << std::left << std::setw(12) << d.name
<< std::right << std::setw(8) << d.score << '\n';
}
// 输出:
// 姓名 分数
// --------------------
// Alice 92
// Bob 85
// Charlie 78实战:一个完整的文件处理程序
把本章所有知识点拧在一起——写一个程序:读取 input.txt 文件,统计每个单词出现的次数,按词频降序输出到 output.txt,带格式化的对齐表格。
#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include <map>
#include <vector>
#include <algorithm>
#include <iomanip>
int main() {
// 第一步:打开输入文件(ifstream 构造 + 立即检查)
std::ifstream in("input.txt");
if (!in) {
std::cerr << "无法打开 input.txt\n";
return 1;
}检查文件是否打开成功是第一条纪律——if (!in) 在构造后立即判断。所有 IO 对象都可以放在布尔上下文中——等于 !fail()。
// 第二步:逐行读取,每行用 stringstream 拆词
std::map<std::string, int> freq;
std::string line;
while (std::getline(in, line)) {
std::istringstream iss(line);
std::string word;
while (iss >> word) // sstream 的 >> 自动按空白切词
freq[word]++; // map::operator[] 不存在的 key 默认值 0
}
in.close(); // 读完就关——尽早释放文件句柄getline(文件, line) → istringstream(line) → iss >> word 是逐行处理的标准三板斧。比起直接在文件上 >>,这样能逐行把控——一行出错不会影响其他行。
// 第三步:排序——map 按 key 排,需要按 value 排就转 vector
std::vector<std::pair<std::string, int>> items(freq.begin(), freq.end());
std::sort(items.begin(), items.end(),
[](auto &a, auto &b) { return a.second > b.second; });std::map 自动按 key(单词)排序——要按 value(词频)排序,把它倒进 vector<pair> 再用自定义比较函数。
// 第四步:格式化输出到文件
std::ofstream out("output.txt");
if (!out) {
std::cerr << "无法创建 output.txt\n";
return 1;
}
out << std::left << std::setw(20) << "单词"
<< std::right << std::setw(8) << "次数\n";
out << std::string(28, '-') << '\n';
for (auto &[word, count] : items)
out << std::left << std::setw(20) << word
<< std::right << std::setw(8) << count << '\n';
// out 离开作用域自动析构——自动 close + flush
}left 让单词左对齐、right 让数字右对齐、setw 给每列固定宽度——一套操纵符把原始数据变成可阅读的表格。ofstream 对象不用手动 close——析构函数替你做了。
容易踩的坑
小结
- C++ IO 库由 iostream(控制台)、fstream(文件)、sstream(字符串)三族组成——都继承自 ios_base/ios/istream/ostream 体系,共享
>>/<</getline等相同接口 - 文件流通过构造打开、析构关闭——打开后立即检查
!file,读模式用>>和getline,二进制模式加ios::binary并用write()/read()代替<</>> - 字符串流让字符串拥有流操作能力——
ostringstream像 cout 一样把数据拼进 string,istringstream像 cin 一样从 string 提取数据 - 流状态用 good()/fail()/eof()/bad() 检测——eof 后重新读需先
clear()再seekg(),while (file >> x)是最高频的正确读法 - 格式化操纵符控制输出外观——setw 只影响下一次,fixed/hex/left 等持久生效;
setprecision的含义在 defaultfloat 和 fixed 下完全不同
练习
问题 1(改代码型) 下面这段代码从文件读取整数并求和——但它有一个常见 bug:第一行是标题 "Scores:",导致程序不能正确处理。找出 bug 并写出修正后的代码。
#include <iostream>
#include <fstream>
int main() {
std::ifstream file("scores.txt"); // 第一行: "Scores:"
int total = 0, count = 0, val;
while (file >> val) {
total += val;
count++;
}
std::cout << "总和: " << total << ", 平均: "
<< static_cast<double>(total) / count << '\n';
return 0;
}问题 2(独立实现题) 写一个程序:读取 data.txt 文件(每行是 姓名 数学 语文 英语 的格式,如 "Alice 92 88 95"),计算每个人的平均分,把结果输出到 results.txt——格式化成整齐的表格(名字左对齐 12 字符、各科右对齐 6 字符、平均分右对齐 8 字符且保留一位小数)。输出示例:"Alice 92 88 95 91.7"。
问题 3(问答型) 本章 §5 Stepper 演示了文件流生命周期——从打开、读取、处理到关闭。如果把整个程序改成「所有数据源一律从 istream & 引用参数传入」,文件的读法和键盘的读法在一个函数里怎么统一?写出函数签名和简短的调用示例。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 流(stream)
C++ 输入输出的抽象概念。流是一个字符序列——你可以从中读取字符(输入流)或向其写入字符(输出流)。C++ 标准库提供了统一的流接口(
>>和<<运算符、getline 等),无论数据来自键盘、文件还是内存字符串,操作方式完全一致。详见本章「IO 流家族」一节的继承树图。- iostream
标准控制台 IO 流,包含 cin(标准输入)、cout(标准输出)、cerr 和 clog(标准错误/日志)。cerr 默认启用 unitbuf,每次输出操作后刷新,但不应简单等同于底层完全无缓冲。引入 iostream 头文件即可使用。详见本章「IO 流家族」。
- fstream
文件 IO 流。ifstream 从文件读取,ofstream 写入文件,fstream 同时读写同一个文件。通过构造函数的文件名参数打开文件,析构时自动关闭。支持多种打开模式(in/out/app/ate/trunc/binary,可用 | 组合)。对 ifstream 用 >> 和 getline 的方式和 cin 完全一样——因为它们都继承自 istream。详见本章「文件流」一节和 Stepper 演示。
- sstream
字符串 IO 流。istringstream 从字符串中读取(像 cin 从键盘读一样从内存字符串读),ostringstream 把各种类型的数据拼接输出到字符串(像 cout 打到屏幕一样打到 string),stringstream 同时支持读写。用法和 cin/cout 完全一致——只是数据源/目标从键盘屏幕换成了字符串。详见本章「字符串流」一节的代码示例。
- str() 方法
ostringstream/stringstream 对象的成员函数。
ostringstream::str()返回流中全部已写入内容的 string 副本——相当于把输出流里的所有东西收成一根字符串。详见本章「字符串流」一节的代码示例。- 打开模式(open mode)
打开文件时指定的位掩码。in 读、out 写、app 在每次写前定位到末尾、ate 只在打开后定位一次、trunc 清空、binary 禁止文本模式转换。app 并不自动提供跨进程写入原子性;并发写文件仍需额外同步。多个模式可用按位或组合。详见「文件流」。
- 流状态(stream state)
C++ 流对象内部的一组标志位(iostate 枚举),用于跟踪最近一次 IO 操作的结果。四种可能:goodbit(一切正常)、eofbit(读到文件末尾)、failbit(操作失败但流可恢复——如格式错)、badbit(流已崩溃、不可恢复——如底层 IO 错误)。通过 rdstate() 获取当前掩码,good()/eof()/fail()/bad() 分别检查各个位,clear() 重置所有状态位恢复为可用。详见本章「流状态」一节的 StreamStateDiagram 图示。
- 缓冲区(buffer)
由 streambuf 管理、位于程序值与外部设备之间的字符区域。它减少设备访问并维护读写位置;输出可因缓冲满、flush、unitbuf、关联流操作或文件关闭而提交。缓冲大小与策略由实现和设备决定,析构关闭也不能证明外部写入必然成功。
- 格式化操纵符(manipulator)
对 IO 流插入(
<<)或提取(>>)的特殊函数或对象,用来改变流的格式行为。如 std::setw(n) 设字段宽度、std::fixed 固定小数格式、std::hex 切换进制、std::left/right 控制对齐方向。定义在 <iomanip> 头文件。注意 setw 只对下一次输出生效(用完自动重置为 0),其余(fixed/hex/left 等)一旦设置就持久生效。详见本章「格式化输出」一节的 IOManipulatorTable 速查表。