前言
C++ 的输入输出是一套基于流(stream)的抽象。相比 C 的printf/scanf,它的优势是类型安全和可扩展性 —— 你可以为自己的类型定义operator<<,让它像内置类型一样被输出。
但代价是:流的内部状态是一台小状态机,很多人不会管理它,于是踩出经典的一串 bug:
cin >> n; getline(cin, s);为什么s是空的?- 为什么读文件循环多做了一次?
- 为什么输入一个字母后,后面所有
cin >>全失败? - 为什么读二进制文件总是差几个字节?
答案都指向同一处:流状态位(stream state flags)。本文按"标准 IO → 文件 IO → 字符串 IO"的顺序讲清楚。
一、流体系总览
所有流都继承自同一套模板基类:
ios_base ← 格式标志、精度、区域设置 └── basic_ios<CharT> ← 状态位、缓冲区、绑定流 ├── basic_istream ← 输入 │ ├── basic_ifstream (文件输入) │ └── basic_istringstream (字符串输入) └── basic_ostream ← 输出 ├── basic_ofstream (文件输出) └── basic_ostringstream (字符串输出)| 头文件 | 提供 | 用途 |
|---|---|---|
<iostream> | cin/cout/cerr/clog | 标准 IO |
<fstream> | ifstream/ofstream/fstream | 文件 IO |
<sstream> | istringstream/ostringstream/stringstream | 字符串 IO |
<iomanip> | setw/setprecision/setfill/hex | 格式化 |
关键洞察:三者用的是同一套操作符和方法。你在cin上学到的>>、getline、状态位,在ifstream和istringstream上完全一样。这就是设计上的复用。
二、流状态位:一切坑的根源
2.1 四个状态位
basic_ios里有四个位(iostate):
| 状态位 | 含义 | 触发条件 |
|---|---|---|
goodbit | 一切正常(值为 0) | 默认 |
eofbit | 到达输入末尾 | 读到文件末尾 |
failbit | 操作失败(可恢复) | 格式不匹配,如把abc读进int |
badbit | 流已损坏(不可恢复) | 磁盘错误、缓冲区失效 |
重要细节:
eofbit置位时failbit也会同时置位(读取没能成功取到数据)- 一旦
failbit或badbit置位,后续所有读写都被直接忽略,直到调用clear() good()等价于rdstate() == 0
#include <iostream> int main() { int n; std::cout << "请输入一个整数: "; std::cin >> n; // 输入 "abc" std::cout << std::boolalpha; std::cout << "good() = " << std::cin.good() << '\n'; // false std::cout << "fail() = " << std::cin.fail() << '\n'; // true std::cout << "eof() = " << std::cin.eof() << '\n'; // false int m; std::cin >> m; // 流已"卡死",直接失败 std::cout << "第二次失败: " << std::cin.fail() << '\n'; // true }状态转换:
+---------+ 读到末尾 +---------+ 继续读 +---------+ | goodbit | ---------> | eofbit | --------> | failbit | +---------+ +---------+ +---------+ | ^ | 格式不匹配 / 打开失败 | +--------------------------------------------+2.2 正确的输入校验模式
❌ 错误写法:
int n; std::cin >> n; // 用户输入 "abc",n 被置 0,静默错误 std::cout << n;✅ 正确写法:把读取表达式放进条件里,>>返回流对象,流能隐式转换为bool(等价于!fail())。
#include <iostream> #include <limits> int main() { int n; while (true) { std::cout << "请输入整数: "; if (std::cin >> n) break; // 读取成功 std::cout << "输入无效,请重试\n"; std::cin.clear(); // 1. 清状态位 std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); } // 2. 丢残渣 std::cout << "你输入了 " << n << '\n'; }这两步是固定搭配。只clear()不ignore(),那个导致失败的abc还留在缓冲区里,下一轮又失败,形成死循环。
三、标准 IO 实战
3.1 格式化输出
#include <iostream> #include <iomanip> int main() { double pi = 3.14159265358979; std::cout << std::setprecision(3) << pi << '\n'; // 3.14 std::cout << std::fixed << std::setprecision(2) << pi << '\n'; // 3.14 std::cout << std::scientific << pi << '\n'; // 3.14e+00 std::cout << std::setfill('0') << std::setw(8) << 42 << '\n'; // 00000042 std::cout << std::setw(8) << 43 << '\n'; // 00000043 std::cout << std::hex << 255 << '\n'; // ff std::cout << std::showbase << std::hex << 255 << '\n'; // 0xff std::cout << std::dec << std::boolalpha << true << '\n'; // true }核心记忆点:setw是一次性的,只影响紧接着的一次输出;而setprecision、setfill、hex、fixed是持久的,一直保持到下次被改。
3.2 读取整行与分割
#include <iostream> #include <sstream> #include <string> int main() { std::string line; std::getline(std::cin, line); // 读整行(不含换行符) std::istringstream iss(line); int x, sum = 0; while (iss >> x) sum += x; std::cout << "总和 = " << sum << '\n'; }这是"整行读取 + 流解析"的标准组合拳:getline一次拿到一行(避免残留换行符问题),istringstream按空白切分。处理配置文件和 CSV 时极其常用。
3.3cout/cerr/clog
| 流 | 缓冲 | 用途 |
|---|---|---|
cout | 有缓冲区,可重定向 | 正常输出 |
cerr | 无缓冲(unitbuf) | 错误信息,立即可见 |
clog | 有缓冲 | 日志 |
调试时如果程序崩溃、cout最后几行没打出来,那是缓冲区没刷—— 换成cerr就能看到。这是排查崩溃位置的经典技巧。
四、文件 IO 实战
4.1 打开、读写、关闭
#include <fstream> #include <string> #include <iostream> int main() { { // ---------- 写 ---------- std::ofstream ofs("data.txt"); // 默认 trunc(截断) if (!ofs) { std::cerr << "无法打开文件写\n"; return 1; } ofs << "Tom 18\n" << "Jerry 20\n"; } // 析构自动关闭 { // ---------- 读 ---------- std::ifstream ifs("data.txt"); if (!ifs) { std::cerr << "无法打开文件读\n"; return 1; } std::string name; int age; while (ifs >> name >> age) // ✅ 把读取写进条件 std::cout << name << " 今年 " << age << " 岁\n"; } }while (ifs >> name >> age)为什么对:>>返回流引用,流在fail()时转换为false。读到文件尾时>>失败并置位,循环自动结束,不会多执行一次循环体。
4.2 打开模式
| 模式 | 含义 |
|---|---|
ios::in | 读(ifstream默认) |
ios::out | 写(ofstream默认,隐含trunc) |
ios::app | 追加,每次写都到末尾 |
ios::ate | 打开后定位到末尾(但可任意写) |
ios::trunc | 打开时清空文件 |
ios::binary | 二进制模式(Windows 上不做\n↔\r\n转换) |
std::ofstream ofs("log.txt", std::ios::app); // 追加日志 std::ofstream bin("data.bin", std::ios::binary); // 二进制写 std::fstream rw("data.txt", std::ios::in | std::ios::out); // 读写app与ate的区别:app每次写之前强制定位到末尾;ate只在打开时定位到末尾,之后可以seekp到任意位置。
4.3 二进制读写
#include <fstream> #include <vector> #include <iostream> struct Record { int id; char name[16]; double score; }; int main() { std::vector<Record> recs = {{1, "Alice", 95.5}, {2, "Bob", 88.0}}; { // 写:字节数 = 元素数 * 元素大小 std::ofstream ofs("recs.bin", std::ios::binary); ofs.write(reinterpret_cast<const char*>(recs.data()), static_cast<std::streamsize>(recs.size() * sizeof(Record))); } { // 读 std::ifstream ifs("recs.bin", std::ios::binary | std::ios::ate); std::streamsize bytes = ifs.tellg(); // 文件总字节数 ifs.seekg(0); // 回到开头 std::vector<Record> in(static_cast<size_t>(bytes) / sizeof(Record)); ifs.read(reinterpret_cast<char*>(in.data()), bytes); for (const auto& r : in) std::cout << r.id << ' ' << r.name << ' ' << r.score << '\n'; } }注意reinterpret_cast<char*>—— 二进制 IO 把对象当字节序列处理。这也意味着这种写法不可移植:结构体对齐(padding)、字节序(endianness)在不同平台不同。
4.4 定位:seekg/tellg
#include <fstream> #include <iostream> int main() { std::ifstream ifs("data.txt"); if (!ifs) return 1; ifs.seekg(0, std::ios::end); // 跳到末尾 std::streamsize size = ifs.tellg(); // 取位置 = 文件大小 ifs.seekg(0, std::ios::beg); // 回到开头 std::cout << "文件大小 = " << size << " 字节\n"; ifs.seekg(5, std::ios::beg); // 跳过前 5 字节 std::string rest; std::getline(ifs, rest); std::cout << rest << '\n'; }| 定位基准 | 说明 |
|---|---|
ios::beg | 从开头算 |
ios::cur | 从当前位置算 |
ios::end | 从末尾算 |
seekg用于输入流(g= get),seekp用于输出流(p= put)。
五、字符串 IO 实战
5.1ostringstream:拼接字符串
❌ 低效写法:
std::string s = "id=" + std::to_string(id) + ", name=" + name + ", score=" + std::to_string(score); // 多次临时 string 构造✅ 更清晰的写法:
#include <sstream> #include <iomanip> #include <iostream> int main() { int id = 7; std::string name = "Tom"; double score = 92.5; std::ostringstream oss; oss << "id=" << id << ", name=" << name << ", score=" << std::fixed << std::setprecision(1) << score; std::string s = oss.str(); std::cout << s << '\n'; // id=7, name=Tom, score=92.5 }好处:一次分配,且能直接用<<的所有重载,包括setprecision这类格式化器。
5.2istringstream:解析字符串
#include <sstream> #include <string> #include <iostream> int main() { // 用自定义分隔符切分 std::istringstream iss("1,Tom,95.5"); std::string field; while (std::getline(iss, field, ',')) std::cout << '[' << field << ']'; std::cout << '\n'; // [1][Tom][95.5] // 格式化解析 std::istringstream iss2("2026 09 28"); int y, m, d; if (iss2 >> y >> m >> d) std::cout << y << '-' << m << '-' << d << '\n'; }std::getline(iss, field, delim)的第三个参数是自定义分隔符,这是解析 CSV / 日志的利器。
5.3 类型转换
#include <sstream> #include <string> #include <iostream> template <class T> bool from_string(const std::string& s, T& out) { std::istringstream iss(s); // 吃掉尾部空白后必须恰好到末尾,才能拒绝 "12abc" return static_cast<bool>(iss >> out) && (iss >> std::ws).eof(); } int main() { int n; if (from_string("123", n)) std::cout << "解析成功: " << n << '\n'; if (!from_string("12abc", n)) std::cout << "解析失败(有残留字符)\n"; }C++17 起有更简单、无分配、不受 locale 影响的std::from_chars:
#include <charconv> int n; auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), n);六、完整实战:一个可运行的读文件统计器
#include <fstream> #include <sstream> #include <string> #include <iostream> // 去掉首尾空白 static std::string trim(const std::string& s) { const auto b = s.find_first_not_of(" \t\r\n"); if (b == std::string::npos) return ""; const auto e = s.find_last_not_of(" \t\r\n"); return s.substr(b, e - b + 1); } int main() { { // 准备一个数据文件 std::ofstream ofs("words.txt"); ofs << "apple 3\nbanana 5\ncherry 7\n"; } std::ifstream ifs("words.txt"); if (!ifs) { std::cerr << "打开失败\n"; return 1; } std::string line; int lineno = 0, total = 0; while (std::getline(ifs, line)) { // 整行读 ++lineno; line = trim(line); if (line.empty() || line[0] == '#') continue; // 跳过空行和注释 std::istringstream ls(line); std::string word; int n; if (ls >> word >> n) total += n; else std::cerr << "第 " << lineno << " 行格式错误: " << line << '\n'; } std::cout << "共 " << lineno << " 行,合计 " << total << '\n'; // 15 }g++ -std=c++17 -Wall -Wextra stat.cpp -o stat && ./stat常见坑点
坑 1:cin >>与getline混用,getline读到空串
❌ 错误写法:
int age; std::string name; std::cin >> age; // 用户输入 "18\n",'\n' 留在缓冲区 std::getline(std::cin, name); // 立刻读到那个 '\n',name 为空!✅ 正确写法(二选一):
// 方案 A:读完后丢掉换行符 std::cin >> age; std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); std::getline(std::cin, name); // 方案 B:全程用 getline,再解析(更稳) std::string line; std::getline(std::cin, line); age = std::stoi(line); std::getline(std::cin, name);方案 B 更稳,因为它不依赖"缓冲区里恰好剩一个换行符"这个假设。
坑 2:while (!ifs.eof())多读一次
❌ 错误写法:
while (!ifs.eof()) { ifs >> name >> age; std::cout << name << age << '\n'; // 最后一次会重复输出! }原因:eof()是在读取失败之后才置位的。读到最后一行后eof()仍是false,循环再执行一次,这次读取失败,name/age保持上次的值被重复输出。
✅ 正确写法:
while (ifs >> name >> age) { // 读取失败时流为 false std::cout << name << age << '\n'; }黄金法则:不要在循环条件里检查eof(),要把读取本身作为条件。
坑 3:failbit粘住,后续读取全部失败
int n; std::cin >> n; // 输入 "abc" → failbit 置位 std::cin >> n; // 直接失败,流已"卡死" std::cin >> n; // 还是失败✅ 恢复需要两步:
std::cin.clear(); // 1. 清状态位 std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 2. 丢残渣坑 4:stringstream复用前忘记clear()
❌ 错误写法:
std::stringstream ss; ss << "123"; int a; ss >> a; // 读到 eof,eofbit 置位 ss << "456"; ss >> a; // ❌ 失败,状态位还留着✅ 正确写法:
ss.str(""); // 清内容 ss.clear(); // 清状态位 —— 两步不能少 ss << "456"; ss >> a;str("")和clear()是两个不同操作:前者清内容,后者清状态。漏掉任何一个都会出问题。
坑 5:printf与cout混用导致输出顺序错乱
❌ 错误写法:
std::cout << "A"; printf("B"); // C 的 stdout 缓冲和 C++ 的 cout 缓冲独立 std::cout << "C"; // 可能输出 "BAC" 或 "ACB"✅ 正确写法:统一用一种;必须混用时手动刷:
std::cout << "A" << std::flush; printf("B"); fflush(stdout);默认情况下cin/cout与stdin/stdout是同步的,混用安全但有性能损失。一旦调用std::ios::sync_with_stdio(false)提速,就绝不能再混用printf。
坑 6:读二进制文件被 Windows 换行转换破坏
❌std::ifstream ifs("image.png");—— 文本模式会把0x0D0A转成一个0x0A。
✅std::ifstream ifs("image.png", std::ios::binary);
Windows 文本模式还会把0x1A当 EOF。只要不是纯文本,一律加ios::binary。
坑 7:write/read用错长度
ofs.write(reinterpret_cast<const char*>(v.data()), v.size() * sizeof(int)); // ✅ 字节数 = 元素数 * 元素大小 ofs.write(reinterpret_cast<const char*>(v.data()), v.size()); // ❌ 只写了 size() 字节坑 8:ofstream忘记检查是否打开成功
std::ofstream ofs("/不存在的目录/x.txt"); ofs << "hello"; // 静默失败,什么都没写✅ 正确写法:
if (!ofs) { std::cerr << "打开失败\n"; return 1; }流对象不会因为失败而抛异常(除非设了ofs.exceptions()),它只会安静地置位failbit。
坑 9:std::endl在循环里造成性能灾难
for (int i = 0; i < 1000000; ++i) ofs << i << std::endl; // ❌ 每次 flush,慢十倍以上 for (int i = 0; i < 1000000; ++i) ofs << i << '\n'; // ✅ 只在缓冲区满时真正写盘std::endl等价于'\n'+flush()。
总结
| 内容 | 关键结论 |
|---|---|
| 状态位 | eof会连带fail,fail/bad置位后所有操作被忽略 |
| 恢复 | clear()清标志 +ignore()清缓冲,两步缺一不可 |
| 输入循环 | 把读取写进条件:while (cin >> x),不要判eof() |
>>与getline | 混用会残留换行符,用ignore或全程getline |
| 文件模式 | 二进制必加ios::binary;追加用ios::app |
| 字符串 IO | istringstream+getline(iss, f, ',')是解析利器 |
stringstream复用 | str("")和clear()都要调 |
| 格式化 | setw一次性,setprecision/hex持久 |
| 性能 | 用'\n'不用std::endl;不要混用printf |
一句话收束:C++ 流编程的核心不是记住操作符,而是管理好状态位和缓冲区。把"读取写进条件""失败后clear+ignore""文本用文本模式、字节用二进制模式"这三条养成习惯,绝大多数 IO bug 都不会再出现。