程序跑完数据就没了,这是很多初学者的痛点。把数据存进文件、下次启动再读回来,靠的就是文件流。C++ 的 ifstream、ofstream、fstream 用起来和 cin/cout 几乎一样,本文用一个成绩管理的小场景,把文本、CSV、二进制三种读写方式一次讲透,顺带解决"读改写"这类混合需求。
1. 文件流三兄弟
先记住分工:ifstream 只管读(input),ofstream 只管写(output),fstream 既能读又能写。它们都在头文件 <fstream> 里,用法和 cin/cout 完全同构:运算符 << 写入、>> 读出,状态用 if (流对象) 判断。最简单的写文件:
#include <fstream>
#include <iostream>
int main() {
std::ofstream out("hello.txt");
if (!out) {
std::cerr << "文件打开失败\n";
return 1;
}
out << "Hello, CodeLab!\n";
out << "第二行:流式输出\n";
// out 在 main 结束时自动关闭(RAII)
std::cout << "写入完成\n";
return 0;
}
std::ofstream out("hello.txt") 会创建(或覆盖)文件;如果路径不可写,流对象进入失败状态。判断失败用 if (!out),这比猜返回值靠谱得多。关于缓冲区:ofstream 默认带缓冲区,数据先攒在内存里,凑够一批或遇到 flush 才真正写盘。所以写完想立刻让别的程序看到内容,可以调用 out.flush() 或直接 close();程序异常退出时缓冲区可能来不及落盘,重要数据记得及时 flush。文件流对象析构时会自动 close(),所以不写 close() 一般也没事——这是 RAII 的功劳:对象离开作用域,资源自动释放,异常也拦不住。不过,当你马上要"换一个文件"或者"立刻读回刚写的内容"时,显式 close() 仍是好习惯。
2. 按行读取文本
读文件最常用的是 getline,它按行读并丢掉换行符:
#include <fstream>
#include <iostream>
#include <string>
int main() {
std::ifstream in("hello.txt");
if (!in) {
std::cerr << "打开失败\n";
return 1;
}
std::string line;
int no = 0;
while (std::getline(in, line)) {
std::cout << ++no << ": " << line << "\n";
}
return 0;
}
重点理解 while (std::getline(in, line)):getline 读到内容时返回流本身(可转 bool,为真),读到文件末尾返回"假",循环自然结束。千万不要用 while (!in.eof()),因为 eof 标志要等"读操作失败"之后才置位,那样循环体会多跑一次,读到空行。这也是一条通用经验:用读操作的返回值判断循环,不要先查 eof。性能提示:getline 每次只读一行,适合处理大文件——你不需要把整个文件读进内存;如果文件按固定分隔符组织(比如每行一个 IP),getline 配 std::stringstream 就是最顺手的组合。
3. 读取数值:成绩求和
文本里存了一串数字,用 >> 运算符读,它会自动跳过空白符(空格、换行、制表符):
#include <fstream>
#include <iostream>
int main() {
std::ofstream("scores.txt") << "85 92 78\n60 88\n"; // 先造数据
std::ifstream in("scores.txt");
int score, sum = 0, count = 0;
while (in >> score) {
sum += score;
++count;
}
std::cout << "共 " << count << " 个分数,总和 " << sum
<< ",平均 " << (count ? static_cast<double>(sum) / count : 0.0)
<< "\n";
return 0;
}
文件里写了两行共 5 个数,in >> score 不关心换行,一路读到末尾返回假。static_cast<double>(sum) 把整数转成浮点,否则整数除法会丢掉小数。另外这里展示了 ofstream 的"临时对象"写法,适合一次性造测试数据——临时对象用完立刻析构关闭,文件马上落盘。
4. CSV 实战:解析并统计
CSV(逗号分隔值)是数据交换最常见的格式,每行形如 张三,85。解析思路:按行读,再按逗号拆分:
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
int main() {
std::ofstream("students.csv") << "姓名,成绩\n"
<< "张三,85\n"
<< "李四,92\n"
<< "王五,78\n";
std::ifstream in("students.csv");
std::string line, header;
std::getline(in, header); // 跳过表头
std::vector<std::pair<std::string, int>> rows;
while (std::getline(in, line)) {
std::stringstream ss(line);
std::string name, score_str;
std::getline(ss, name, ','); // 按逗号拆分
std::getline(ss, score_str, ',');
rows.push_back({name, std::stoi(score_str)});
}
int sum = 0;
for (const auto& r : rows) sum += r.second;
std::cout << "共 " << rows.size() << " 名学生,平均分 "
<< static_cast<double>(sum) / rows.size() << "\n";
return 0;
}
核心技巧是 std::getline(ss, name, ','):给 getline 传第三个参数,它就变成"读到逗号为止",比手动找逗号再 substr 干净得多。这里 std::stringstream 把一行字符串包装成"内存里的流",于是所有流操作都能复用。std::stoi 把字符串转成 int,遇到非法字符会抛异常(异常处理的文章里会细讲)。注意真实世界的 CSV 可能有引号、转义和空字段,这个简易版够演示,生产环境建议换专业解析库。
5. 打开模式、追加与读写混合
追加写入
默认 ofstream 是"截断"模式,每次打开都会清空旧内容。想保留旧内容追加,要用打开模式 std::ios::app:
#include <fstream>
#include <iostream>
int main() {
std::ofstream log("log.txt", std::ios::app); // 追加模式
log << "2026-08-10 19:00 启动\n";
log << "2026-08-10 19:01 任务完成\n";
std::cout << "已追加两条日志\n";
std::ifstream in("log.txt");
std::string line;
while (std::getline(in, line)) std::cout << line << "\n";
return 0;
}
常用模式组合:std::ios::app 追加、std::ios::binary 二进制、std::ios::trunc 截断(默认)、std::ios::in/std::ios::out 指定方向。多个模式用 | 连接,比如 std::fstream f("data.bin", std::ios::in | std::ios::out | std::ios::binary)。日志场景几乎都用追加模式,程序重启也不会丢历史记录。
读改写:fstream 混合模式
有些场景要"打开一个文件,读一点、改一点、写回去"。这时用 fstream 以 in | out 打开,配合 seekp 把写指针拨回指定位置:
#include <fstream>
#include <iostream>
int main() {
// 造一个初始文件:第一行是个计数器
std::ofstream("counter.txt") << "42\n";
// 以"读+写"模式打开,不清空文件
std::fstream fs("counter.txt", std::ios::in | std::ios::out);
if (!fs) {
std::cerr << "打开失败\n";
return 1;
}
int value;
fs >> value; // 读出 42,文件指针停在数字后面
++value;
fs.seekp(0); // 把写指针拨回文件开头
fs << value << "\n"; // 覆盖写回
fs.close();
std::ifstream in("counter.txt");
int check;
in >> check;
std::cout << "新值: " << check << "\n"; // 输出:43
return 0;
}
读指针和写指针是独立的:>> 移动读指针,seekp 移动写指针。注意写回的内容长度不能超过原内容,否则会"顶掉"后面还没读的数据——对固定格式(比如定长记录)用这种方式很稳,变长内容建议先全部读进内存、改完再整体写回。
6. 二进制读写
文本格式人眼能读,但解析慢、占空间大;二进制直接按内存布局存取,适合保存结构体数组。写端和读端必须严格对应:
#include <fstream>
#include <iostream>
struct Record {
int id;
double score;
};
int main() {
Record records[] = {{1, 88.5}, {2, 92.0}, {3, 76.5}};
std::ofstream out("records.bin", std::ios::binary);
out.write(reinterpret_cast<const char*>(records),
sizeof(records));
out.close();
Record back[3];
std::ifstream in("records.bin", std::ios::binary);
in.read(reinterpret_cast<char*>(back), sizeof(back));
for (const auto& r : back)
std::cout << r.id << " " << r.score << "\n";
return 0;
}
write/read 接收 char* 和字节数,所以要用 reinterpret_cast 把结构体指针转成字节指针。sizeof(records) 一次算出整个数组的字节数,非常省事。坑在于:如果结构体里含 std::string 或指针,直接存内存布局是错的(存的是堆地址),二进制方案只适合 POD(平凡可复制)类型;而且不同平台字节序不同,跨机器传输要自己处理。怎么选文本还是二进制?一句话:给别人看、要跨平台交换的用文本(CSV、JSON);自己程序内部用、追求速度和体积的用二进制。二进制文件一旦格式变化,旧文件可能读不出来,所以要给文件格式加版本号,这是工程里的常见做法。
7. 常见坑与工程习惯
最后汇总几个高频坑:
- 路径错误不报错:打开失败时流只是进入失败态,程序不会崩。一定要
if (!in)检查,否则后面读到一堆"0"。 - 混合使用
>>和getline:>>读完数字会留下换行符,紧接着的getline会读到空行。中间加一句in.ignore()吃掉残留字符。 - Windows 换行:文本模式下 Windows 的
\r\n会被自动转换,一般不用管;但二进制模式下\r会原样保留,按行处理时要注意。 - 写完忘 close 就删文件:流对象还握着文件句柄时,某些平台上删除/重命名会失败。写完立刻
close()最稳妥。
💡 调试文件程序时,先确认"当前工作目录"是哪:程序里写相对路径,读的是运行目录下的文件,不是 .cpp 所在的目录。不确定就打印 std::filesystem::current_path()。
8. 总结与练习
本文从写文件、按行读、数值读,到 CSV 解析、追加模式、读改写、二进制读写,覆盖了日常 90% 的文件操作场景。核心就三条:用 getline 按行读、用 >> 读数值、用 if (!stream) 检查状态。文件流是 RAII 的典型应用——对象析构自动关文件,你只管用。
练习题:
- 把第 4 节的 CSV 程序改成:读入后按成绩从高到低排序,再写回一个新的 CSV 文件。
- 写一个"通讯录"程序:交互式输入姓名和电话,追加到
contacts.txt,启动时自动加载并打印。 - 给第 6 节的二进制程序加一个"查询"功能:输入 id,在文件中定位并读出对应记录(提示:用
seekg跳到id * sizeof(Record)处)。