凌晨的采集任务读入一份测量文件,前两行正常,第三行温度写成了 19.x。如果程序只会“从文件里读几个数”,它可能留下半份报告,也可能在失败后反复读取同一个坏字符。真正可靠的输入输出,需要回答四个问题:数据连到哪里,一条记录在哪里结束,上一次操作是否成功,失败后能否继续。
C++ 把终端、文件和内存字符串放进同一套流接口。掌握这套接口后,解析函数不必知道数据来自键盘还是测试字符串,格式化函数也不必知道结果最后显示在终端还是写进文件。
本文从一条真实的数据管道出发,依次处理流模型、读取边界、状态恢复、输出格式、缓冲、文件模式和字符串流。示例使用 C++17,并把“能跑”与“能发现坏数据”放在同等位置。
流可以看成“字符序列加运行状态”。输入流从某处取得字符,再把字符解析成 int、double、string 等值;输出流把值格式化成字符,再送往某个目的地。
底层设备可能完全不同,但业务代码只需要面对少量稳定接口:
std::cin、std::cout 和 std::cerr 默认连接标准输入、标准输出和标准错误。操作系统可以重定向这些通道,所以“标准输出”并不等于“屏幕”;它也可能被送进文件或另一个进程。

std::istream 与 std::ostream 流引用,就能在终端、文件和内存字符串之间切换。如果函数只需要读取值,参数写成 std::istream&。如果函数只负责输出,参数写成 std::ostream&。这样,同一段处理逻辑可以用于控制台、文件和字符串。
#include <iostream>
#include <sstream>
bool write_sum(std::istream& input, std::ostream& output) {
int value = 0;
long long sum = 0;
int count = 0;
while (input >> value) {
sum += value;
++count;
}
if (!input.eof()) {
return false;
}
output << "count=" << count << ", sum=" << sum << '\n';
return static_cast<bool>(output);
}
int main() {
std::istringstream source{"12 8 30"};
if (!write_sum(source, std::cout)) {
std::cerr << "cannot process values\n";
return 1;
}
}输出:
count=3, sum=50把 source 换成 std::ifstream,write_sum 不需要修改。测试时则可继续使用 std::istringstream,不必为三个数字专门创建文件。
一次读取会移动当前位置,也可能改变错误状态;一次写入会修改缓冲区和格式状态。因此输入输出操作不是 const 操作,参数不能写成 const std::istream& 或 const std::ostream&。
标准流对象也不能复制。文件连接、缓冲区、读写位置和错误状态如果被随意复制,很难定义谁拥有连接以及两份状态如何同步。函数接收引用,正好表达“继续操作同一条管道”。
先把算法写成接收 std::istream& 和 std::ostream& 的函数,再由最外层决定连接控制台、文件还是字符串。这样既减少重复代码,也让错误场景更容易测试。
输入错误经常不是类型问题,而是边界选错了。operator>>、getline 和 get 都能从输入流取字符,但它们对空白和终止位置的处理不同。
格式化提取会按照目标类型解析。读 int 时,42x 可以先得到 42,字符 x 留给下一次操作;读 string 时,空白通常结束当前单词。
getline 把分隔符之前的字符放进字符串,并取走分隔符。若下一个字符恰好就是换行,它会成功读出一个空字符串。这正是混用 >> 和 getline 时最常见的意外。

ignore 到行尾,或用 getline(input >> std::ws, ...) 跳过前导空白,都能继续读取整行,但 std::ws 还会吞掉后续所有前导空白。下面三个字符串流包含相同数据。第一种写法直接切换到 getline;后两种写法先明确处理读取整数后留下的换行。
#include <iostream>
#include <limits>
#include <sstream>
#include <string>
int main() {
const std::string data = "2\nAda Lovelace\nGrace Hopper\n";
int count = 0;
std::string name;
std::istringstream direct{data};
direct >> count;
std::getline(direct, name);
输出:
direct: []
ignore: [Ada Lovelace]
ws: [Ada Lovelace]ignore(max, '\n') 的意思是丢弃当前行剩余内容,直到并包括换行。它适合“先读行首数量,再从下一行开始读完整记录”。
std::ws 会吃掉后续所有空白。代码更短,但下一行开头有意保留的空格也会消失。解析缩进文本时,这个区别不能忽略。
如果每行就是一条业务记录,先用 getline 固定记录边界,再用字符串流拆字段,通常更容易报告行号:
std::string line;
int line_number = 0;
while (std::getline(input, line)) {
++line_number;
std::istringstream row{line};
std::string sensor;
double value = 0.0;
if (!(row >> sensor >> value)) {
// 报告 line_number,而不是只说“读取失败”。
}
}这种结构把两个问题分开:getline 负责找到记录边界,row >> ... 负责解释字段。
不要把 clear() 当成删除输入字符的操作。它只改状态标志;残留换行或坏 token 仍在输入序列里,必须由 ignore、getline 或其他读取操作明确消费。
流对象不只保存字符和位置,还保存一组状态位。每次读写后,程序都可以通过这些状态判断下一步。
这些不是四个互斥枚举值。多个位可以同时出现,bad() 为真时 fail() 也会为真。一次成功读出最后一个值时可能已经设置 eofbit,但这个值仍然有效;下一次再读才会失败。

clear() 清除状态,再用 ignore(...) 消费坏 token,才能继续读取;clear() 本身不会删除字符。几个查询函数的侧重点不同:
good() 只有在没有任何状态位时才为真。eof() 只回答是否碰到了末尾。fail() 检查会阻止普通格式化操作继续的失败状态。bad() 检查严重底层错误。下面的程序故意把 x 读成整数。失败后先清状态,再丢弃坏 token,最后继续读取 23。
#include <iomanip>
#include <iostream>
#include <limits>
#include <sstream>
void print_state(const char* label, const std::istream& input) {
std::cout << label
<< " good=" << input.good()
<< " fail=" << input.fail()
<< " eof=" <<
输出:
first=17
after 17 good=true fail=false eof=false bad=false
after x good=false fail=true eof=false bad=false
recovered=23
after 23 good=false fail=false eof=true bad=false最后一行的 good=false 不代表 23 无效。读取已经成功,只是解析器同时发现它位于输入末尾;fail=false 才说明这次结果可以使用。
下面的实验室把输入缓冲区、读取游标与四个状态位放在同一面板。先故意读取 x,再分别尝试“继续读取”“只清状态”和“清状态后丢弃坏输入”,可以直接比较三种结果。
处理值序列时,把读取操作放进循环条件:
Record record;
while (input >> record) {
process(record);
}
if (input.bad()) {
// 底层读取失败,不能把现有结果当成完整输入。
} else if (!input.eof()) {
// 没到末尾却停止,通常是格式错误。
} else {
// 正常读到末尾。
}不要先写 while (!input.eof())。EOF 只能在一次读取尝试后被发现;提前检查会让循环多执行一次,随后使用旧值或半更新对象。
交互输入若允许重试,常用恢复顺序是:
badbit,停止重试。clear() 解除 failbit。对于不应尝试恢复的底层错误,可以让流在出现 badbit 时抛出 std::ios_base::failure:
input.exceptions(input.exceptions() | std::ios::badbit);这不是所有错误都改用异常。格式错误仍可通过状态检查留在解析边界处理。
输出有两个容易混在一起的状态:字符应该长什么样,以及这些字符何时真正送到目的地。前者由格式标志和操纵符控制,后者与缓冲和刷新有关。
很多操纵符会影响后续所有输出,直到再次修改:
setw 过窄时不会截断数据。一个六位数放进四字符字段,仍会完整输出六位;格式对齐被破坏,总比数值被悄悄改掉更安全。
setprecision 的含义取决于浮点格式。defaultfloat 下通常表示有效数字数,fixed 下表示小数点后的位数。
公共输出函数最好不要把格式状态泄漏给调用者。可以保存原状态,写完后恢复:
#include <iomanip>
#include <iostream>
#include <string>
void write_price(std::ostream& output,
const std::string& item,
double price) {
const auto old_flags = output.flags();
const auto old_precision = output.precision();
const auto
输出:
sensor 12.50
adapter 103.46
ratio=0.333333如果没有恢复 fixed 和精度,最后一行会继续按两位小数输出。
输出流通常先把字符放进缓冲区,再批量交给操作系统。缓冲减少昂贵的底层写操作,因此在循环中频繁刷新会明显拖慢文件输出。
\n 或 '\n':写入换行,不要求立即刷新。std::endl:写入换行,然后刷新。std::flush:不写新字符,只刷新。文件流关闭或离开作用域时会刷新关联缓冲区。普通批量输出使用 \n 即可;只有提示必须立刻可见、即将等待外部事件,或诊断信息必须尽快送出时,才需要明确刷新。
标准输入通常与标准输出绑定。在 std::cin 开始读取前,绑定的输出流会先刷新,因此下面的提示通常能及时出现:
std::cout << "temperature: ";
double value = 0.0;
std::cin >> value;如果为了性能解除绑定,例如调用 std::cin.tie(nullptr),交互提示就应主动 std::flush。std::cerr 面向错误诊断,默认配置也更偏向及时送出信息,但仍应把“已调用写入”与“外部介质已永久保存”区分开。
格式状态决定下一批字符的形状,缓冲状态决定字符何时离开程序。排查“数字格式突然变了”和“提示没有及时出现”时,应分别检查这两条线。
文件在流接口看来是一串按位置排列的字节。std::ifstream 负责读取,std::ofstream 负责写入,std::fstream 同时支持两者。大多数转换任务用一个输入流和一个输出流更容易推理,不必急着使用双向流。
构造文件流时直接传入路径,并立刻检查:
std::ifstream input{input_path};
if (!input) {
std::cerr << "cannot open input\n";
return 1;
}流对象离开作用域时自动关闭文件。这种生命周期管理让异常返回和普通返回都不容易漏掉 close()。
默认 ifstream 面向输入。默认 ofstream 面向输出,并会把已有文件当成重新生成的目标;它不是追加模式。保留旧日志时必须明确写 std::ios::app。
app 与 ate 不等价。app 要求每次写入都落在末尾;ate 只把打开后的初始位置放到末尾,之后仍可定位到别处。
同一路径先以默认模式写入,再以 app 打开,就能明确表达“创建内容后追加日志”。每次打开都在独立作用域中,离开作用域后文件已经关闭,下一次连接不会与旧连接重叠。
{
std::ofstream output{path};
if (!output) throw std::runtime_error{"cannot create file"};
output << "boot\n";
}
{
std::ofstream output{path, std::ios::app};
if (!output) throw std::runtime_error{"cannot append file"};
output << "ready\n";
真实文件测试应把 path 放在专用临时目录中,写完重新用 ifstream 读取核对,并在测试结束时删除该目录。下一节的完整管道会执行这一整套流程。
文本流用约定的字符表示值,便于检查和跨工具交换。二进制模式只解决“按字节传输”这一层,不会自动解决字节序、类型大小、版本、对象布局和兼容性。直接把内存对象的原始字节当长期文件格式,通常不够稳妥。
随机访问时,seekg / tellg 操作读取位置,seekp / tellp 操作写入位置。但能从头到尾顺序处理时,优先顺序处理。修改现有文件时,生成一个新文件并验证成功后再替换,通常比在中间位置原地覆盖更安全。
一个可靠的转换程序不能只检查“文件打开了吗”。它还要验证每条记录、区分正常 EOF 和读取故障、检查最终写入,并避免在中途失败时留下看似完整的目标文件。
假设输入每行包含传感器名和摄氏温度:
alpha 21.5
beta 19.0
gamma 23.2目标是生成逗号分隔报告。处理顺序可以固定下来:
流程是:打开输入和 .part 暂存文件;逐行解析并检查尾随字符;再做取值范围校验;输入与输出都正常结束后,才把暂存文件发布为最终名称。任何一步失败都报告位置并删除暂存文件。

ios::trunc、ios::app、ios::ate 与 ios::binary;下半部分展示用临时文件、输入输出状态检查和重命名发布 CSV 的流程。#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
struct Reading {
std::string sensor;
double celsius = 0.0;
};
bool parse_reading(const std::string& line, Reading& result) {
std::istringstream row{line};
第一次测试故意放入坏记录:
tmpdir="$(mktemp -d)"
printf 'alpha 21.5\nbeta 19.x\n' > "$tmpdir/input.txt"
./pipeline "$tmpdir/input.txt" "$tmpdir/report.csv"
test -e "$tmpdir/report.csv" || printf 'report absent\n'
rm -rf "$tmpdir"输出:
line 2: invalid record
report absent修正输入后再运行:
tmpdir="$(mktemp -d)"
printf 'alpha 21.5\nbeta 19.0\n' > "$tmpdir/input.txt"
./pipeline "$tmpdir/input.txt" "$tmpdir/report.csv"
cat "$tmpdir/report.csv"
rm -rf "$tmpdir"输出:
wrote 2 records
sensor,celsius
alpha,21.5
beta,19.0这里有两层校验。parse_reading 检查字符是否符合记录语法,主循环检查温度是否落在业务允许范围。能解析成 double 不代表值就合理。
暂存文件还解决了“第三行失败但前两行已经写进最终报告”的问题。实际系统若允许覆盖已有目标,还需要根据平台和持久化要求设计替换策略;不要在没有约定时静默删除旧文件。
下面的实验室把打开模式判断、逐行字符串流解析和 .part 发布合成一次练习。默认输入中有一条尾随垃圾记录;修复后重跑,可以看到最终文件只在全部检查通过时变化。
字符串流不访问外部设备,它把内存中的 std::string 接到同一套流接口。它最适合两个位置:把一行文本拆成带类型字段,以及把多个值按规则组合成一个字符串。
普通 input >> value 只保证读出了一个值,不保证整个字符串都属于这个值。输入 42ms 时,整数提取会得到 42,并把 ms 留下。如果配置字段要求纯整数,就必须检查尾部。
#include <iostream>
#include <optional>
#include <sstream>
#include <string>
#include <vector>
std::optional<int> parse_int(const std::string& text) {
std::istringstream input{text};
int value = 0;
if (!(input >> value)) {
return std::nullopt;
输出:
[42] -> 42
[ 42 ] -> 42
[42ms] -> invalid
[] -> invalid这个解析器接受首尾空白,但拒绝数字后面的非空白字符。格式规则若不允许首尾空白,也可以在进入字符串流前单独验证。
std::ostringstream 适合先构造完整消息:
std::ostringstream message;
message << std::fixed << std::setprecision(1)
<< sensor << ": " << temperature << " C";
send_text(message.str());str() 返回当前缓冲内容的字符串副本。用局部 ostringstream 还能把进制、精度和对齐状态限制在函数内部,不会污染共享的 std::cout。
std::stringstream 同时支持读写,但方向越多,状态越难推理。只解析时优先 istringstream,只拼接时优先 ostringstream。
字符串流读到末尾后会留下 EOF 或失败状态。给它换新文本时,str(new_text) 只替换字符,不清旧状态;需要同时调用 clear():
std::istringstream parser;
parser.str("12 18");
parser.clear();
// 读取第一批数据。
parser.str("25 31");
parser.clear();
// 现在才能稳定读取第二批数据。很多时候直接创建新的局部字符串流更清楚,也更不容易忘记状态。
“成功读出一个值”与“整个字段格式正确”是两个判断。配置、标识符和单位字段通常需要严格检查尾随字符,不能把 42ms 自动接受成 42。
稳定的数据处理程序通常分成三层:最外层管理文件和退出码,中间层解析或格式化记录,内层只做业务计算。这样,文件打不开、记录格式错误和业务值不合理不会混成一条长控制流。
读取用户类型时,先填充临时对象,全部字段成功后再更新目标。失败时保留原目标,调用者不会拿到半条新记录。
下面的接口保留流状态,并用临时对象避免半更新:
struct Sample {
std::string id;
double value = 0.0;
};
std::istream& operator>>(std::istream& input, Sample& target) {
Sample candidate;
if (input >> candidate.id >> candidate.value) {
target = candidate;
}
return input;
解析循环仍使用普通流习惯:
Sample sample;
while (input >> sample) {
output << sample << '\n';
}这里只需要抓住流接口的契约:输入操作接收 std::istream&,输出操作接收 std::ostream&,两者都返回原流引用;输入先解析临时对象,成功后再提交。运算符本身的语言规则可以另行学习,不影响先建立可靠的 I/O 边界。
这个输出操作设置了 fixed 和精度,因此会改变传入流的格式状态。若它属于公共库接口,应像前面的 write_price 一样保存并恢复状态,或把格式约定清楚地写进接口契约。
一条好的测试路径至少包括:空输入、一条合法记录、多条合法记录、首行损坏、中间行损坏、末行缺字段、尾随垃圾、输入文件不存在、输出路径不可写。字符串流覆盖解析分支,临时目录覆盖真实文件行为,两者各有作用。