1. 项目概述:为什么文件操作是C++程序员的必修课?
在C++的世界里,无论你是开发一个简单的日志记录工具,还是一个复杂的数据库系统,最终都绕不开一个核心环节:与磁盘打交道。文件操作,就是程序与外部世界进行数据持久化沟通的桥梁。很多初学者觉得文件操作无非就是“打开、读写、关闭”,但真正深入后会发现,这里面藏着无数的细节和“坑”。比如,为什么我的程序在Windows上运行正常,到了Linux上就乱码?为什么大文件读写会这么慢?为什么明明写了数据,文件里却看不到?这些问题,都源于对文件操作函数和底层机制的理解不够透彻。
这篇文章,我将以一个从业十多年的老码农视角,带你系统性地拆解C++标准库(主要是<fstream>)中的文件操作。我不会仅仅罗列函数原型,而是会结合我踩过的无数个坑,告诉你每个函数在什么场景下用、为什么这么用、以及背后隐藏的“潜规则”。我们的目标是:让你读完这篇文章后,不仅能写出正确的文件操作代码,更能写出高效、健壮、跨平台兼容的代码。无论你是正在准备面试的应届生,还是工作中需要处理复杂数据流的工程师,这篇文章都将是你手边一份可靠的参考手册。
2. 核心基石:理解C++文件流的三层架构
在动手写代码之前,我们必须先建立起正确的认知模型。C++的文件操作并非孤立的几个函数,而是一个基于“流”概念的完整体系。理解这个体系,是避免后续混乱的关键。
2.1 流(Stream)的核心思想
你可以把“流”想象成一条连接程序和文件(或内存、网络等)的数据管道。程序通过向管道的一端(输出流)扔数据,或者从管道的另一端(输入流)取数据,来完成信息的交换。C++标准库用类的继承关系优雅地实现了这一思想:
ios_base: 所有流类的基类,定义了格式标志、状态标志等最基础的属性。basic_ios: 管理流缓冲区(streambuf)和状态(如good(),eof(),fail(),bad())。流状态是你调试文件操作时最重要的工具,没有之一。basic_istream/basic_ostream: 分别对应输入流和输出流,提供了operator>>和operator<<等格式化输入输出操作。basic_ifstream/basic_ofstream/basic_fstream: 这是我们直接打交道的文件流类,分别对应文件输入、文件输出和文件输入输出。
对于普通字符,我们通常使用它们的char类型特化版本:ifstream,ofstream,fstream。
注意:很多新手会混淆“文件不存在”和“打开失败”。
ifstream打开一个不存在的文件用于读取,会触发failbit;而ofstream打开一个不存在的文件用于写入,默认会创建该文件(除非指定了特定的打开模式阻止创建)。判断操作是否成功,一定要检查流状态,而不是想当然。
2.2 文件打开模式:那些标志位的组合艺术
打开文件时,你需要向构造函数或open()函数传递一个“打开模式”参数。它由ios_base中定义的位掩码常量组合而成。理解每个标志的准确含义,是写出正确代码的第一步。
| 模式标志 | 含义 | 常见误解与注意事项 |
|---|---|---|
ios::in | 为读取打开 | 用于ifstream或fstream。文件必须存在,否则失败。 |
ios::out | 为写入打开 | 用于ofstream或fstream。默认会清空文件原有内容! |
ios::app | 追加模式 | 所有写入都追加到文件末尾。与ios::out同时使用时,out的清空特性被覆盖。 |
ios::ate | 打开后定位到文件尾 | 只是初始定位,后续读写可以移动。与app不同,它不强制所有写入都在末尾。 |
ios::trunc | 如果文件存在,先截断它 | 通常与out联用,但out本身默认就隐含了trunc,除非同时指定了app。 |
ios::binary | 二进制模式 | 这是最重要的模式之一。缺省时为文本模式,涉及换行符转换(\n<=>\r\n)和特定字符解释,处理非文本数据必须用此模式。 |
组合示例与深层解析:
ios::out | ios::trunc: 这是ofstream的默认行为。如果你想打开一个文件并从头开始写,覆盖旧内容,就用这个或直接用ios::out。ios::out | ios::app: 以追加方式打开文件写入。如果文件不存在,会创建;如果存在,写入指针初始就在末尾,且无法用seekp移动到其他地方写入。这是写日志文件的黄金搭档。ios::in | ios::out: 以读写方式打开文件。文件必须存在。这是fstream的常见打开方式。ios::in | ios::out | ios::trunc: 以读写方式打开,并清空文件。如果文件不存在,会创建。ios::in | ios::out | ios::ate: 以读写方式打开,并先将指针移到文件末尾。适合需要先读取一些内容,再在末尾追加的场景。
实操心得:我强烈建议,即使你很清楚默认行为,也在代码中显式地写出打开模式。例如,写ofstream outFile("log.txt", ios::out | ios::app);比只写ofstream outFile("log.txt");要清晰得多。三个月后,你或你的同事再看这段代码时,意图一目了然。对于二进制文件,ios::binary标志永远不要省略。
3. 核心函数全解与避坑指南
现在,让我们进入实战环节,逐一拆解每个核心成员函数。我会按照“函数原型 -> 功能说明 -> 参数详解 -> 返回值与状态 -> 经典坑点与最佳实践”的顺序来讲解。
3.1 文件的打开与关闭:open()与close()
构造函数与open(): 文件流对象可以在构造时直接打开文件,也可以先构造,再调用open()打开。
// 方式一:构造时打开(推荐,更简洁) std::ifstream inFile("data.txt", std::ios::in); // 方式二:先构造,后打开 std::ofstream outFile; outFile.open("output.bin", std::ios::out | std::ios::binary);为什么推荐方式一?因为方式二在open()调用前,对象处于一个“未关联文件”的状态。如果你不小心在open()前进行了读写操作,会导致未定义行为。而方式一将初始化和资源绑定合二为一,更安全。
close(): 当文件流对象离开作用域时,其析构函数会自动调用close()。那么,我们还需要手动close()吗?答案是:视情况而定。
- 不需要手动
close()的情况:对象即将销毁,且你不需要立即知道关闭是否成功(比如因为磁盘已满导致关闭刷新缓冲区失败)。让析构函数处理即可。 - 需要手动
close()的情况:- 你需要立即释放文件锁。比如,你写完一个配置文件,希望另一个进程能立刻读取它。手动
close()会刷新缓冲区并释放系统资源,让其他进程可以访问。 - 你需要检查关闭操作是否成功。析构函数不提供错误检查机制。对于关键数据,手动
close()后可以检查流状态。 - 你想重用同一个流对象打开另一个文件。在调用新的
open()之前,必须先对已打开的文件调用close()。
- 你需要立即释放文件锁。比如,你写完一个配置文件,希望另一个进程能立刻读取它。手动
std::fstream file("important.dat", std::ios::out | std::ios::binary); // ... 写入关键数据 ... file.close(); // 手动关闭,确保数据落盘 if (!file) { // 检查关闭/刷新是否成功 std::cerr << "Flush/close failed! Data may be lost." << std::endl; } // 现在可以安全地让 file 对象去打开另一个文件,或者直接销毁经典坑点:路径分隔符
- Windows: 使用反斜杠
\,但在C++字符串中,\是转义字符,所以要写成"C:\\Users\\Project\\data.txt"或使用原始字符串字面量R"(C:\Users\Project\data.txt)"。更推荐使用正斜杠"C:/Users/Project/data.txt",Windows API 也认。 - Linux/macOS: 使用正斜杠
/。最佳实践:为了代码跨平台,一律使用正斜杠/作为路径分隔符。或者使用C++17的std::filesystem::path,它能自动处理路径差异。
3.2 状态查询函数:你的程序健康检查仪
文件操作中,大量的错误源于对流状态的忽视。这四个状态函数是你的第一道防线。
good(): 如果所有错误状态标志(eofbit,failbit,badbit)都未设置,则返回true。表示流完全健康,可以进行IO操作。eof(): 如果最近一次输入操作因为到达文件末尾而失败,则设置eofbit,此函数返回true。重要:eof()为true并不意味着上一次读取失败了,只意味着“下一次”读取会失败。fail(): 如果最近一次IO操作失败(如类型不匹配、打开文件失败),但流未被严重破坏,则设置failbit,此函数返回true。eof()为真时,fail()通常也为真。bad(): 如果流缓冲区发生严重错误(如磁盘空间不足、读写权限问题),导致流完全不可用,则设置badbit,此函数返回true。
如何正确使用?
- 操作前,用
good()或!fail()判断流是否可用。 - 操作后,用
!fail()判断本次操作是否成功。对于输入,典型的循环模式是:
绝对不要这样写:int value; while (inFile >> value) { // operator>> 返回流引用,在布尔上下文中等价于 !fail() // 成功读取一个value,处理它 } // 循环结束,要么是EOF,要么是读取失败(如非数字字符) if (inFile.eof()) { std::cout << "Reached end of file normally." << std::endl; } else if (inFile.fail()) { std::cout << "Input stopped due to format mismatch." << std::endl; inFile.clear(); // 清除错误状态,以便后续操作(如读取一行) }while (!inFile.eof()) { // 错误!eof() 在尝试读取超过末尾后才被设置 inFile >> value; // 当文件内容恰好读完,eof() 还是 false,会进入循环多执行一次, // 导致 value 使用上一次的旧值,引发逻辑错误。 }
clear()函数:用于重置流的错误状态标志。在发生非严重错误(如格式读取失败)后,如果你想忽略错误继续使用这个流(比如换一种方式读取),就必须先调用clear()来清除failbit。
3.3 文件定位:随机访问的钥匙seekg()/seekp()与tellg()/tellp()
对于文本文件,随机访问意义不大(因为字符长度可变)。但对于二进制文件(尤其是数据库、索引文件),这是核心功能。
seekg()/seekp(): 分别设置“读指针”和“写指针”的位置。g代表 get(读),p代表 put(写)。tellg()/tellp(): 分别返回当前“读指针”和“写指针”的位置。返回值类型是std::streampos。
参数详解: 这两个函数有两个重载版本:
seekg(pos_type pos)/seekp(pos_type pos): 将指针绝对定位到距文件开头的pos处。pos通常来自tellg()的返回值。seekg(off_type off, std::ios_base::seekdir dir)/seekp(...): 相对定位。off: 偏移量,可正可负。dir: 基准位置,有三种:std::ios::beg: 文件开头。std::ios::cur: 当前位置。std::ios::end: 文件末尾。
示例:在二进制文件中更新一条记录假设我们有一个存储Student结构体的二进制文件,每个记录固定大小。我们知道第5条记录(索引从0开始)需要修改。
struct Student { int id; char name[20]; double score; }; std::fstream file("students.dat", std::ios::in | std::ios::out | std::ios::binary); if (!file) { /* 处理错误 */ } Student stu; int recordIndex = 5; std::streampos recordPos = recordIndex * sizeof(Student); // 1. 定位读指针,读取旧数据(可选,用于验证) file.seekg(recordPos, std::ios::beg); file.read(reinterpret_cast<char*>(&stu), sizeof(Student)); if (file.gcount() != sizeof(Student)) { /* 处理读取不完整 */ } // 2. 准备新数据 stu.score = 95.5; // 3. 定位写指针到同一位置(注意:读/写指针是独立的!) file.seekp(recordPos, std::ios::beg); // 关键!必须重新定位写指针 file.write(reinterpret_cast<const char*>(&stu), sizeof(Student)); if (!file) { /* 处理写入失败 */ } file.close();关键提示:
seekg和seekp移动的是两个独立的指针。读完之后想在同一位置写,必须显式调用seekp重新定位写指针。这是很多人的思维盲区。
3.4 核心读写函数:read()/write()与get()/put()/getline()
二进制读写:read()和write()这是处理非文本数据(结构体、数组、自定义二进制格式)的利器。
- 原型:
istream& read(char* s, streamsize n); ostream& write(const char* s, streamsize n); - 参数:
s是内存缓冲区的指针(需转换为char*),n是要读写的字节数。 - 返回值:返回流本身的引用,便于链式调用和状态检查。
- 重要成员函数
gcount(): 仅用于输入流,返回上一次read()操作成功读取的字符数。这对于判断是否读取了完整数据块至关重要。
示例:安全地读取一个数据块
const int BUFFER_SIZE = 1024; char buffer[BUFFER_SIZE]; file.read(buffer, BUFFER_SIZE); std::streamsize bytesRead = file.gcount(); // 必须在任何其他输入操作前调用! if (bytesRead > 0) { // 成功读取了 bytesRead 个字节 processBuffer(buffer, bytesRead); } if (bytesRead < BUFFER_SIZE) { // 可能读到了文件末尾,或者发生了错误 if (file.eof()) { std::cout << "Reached end of file." << std::endl; } else if (file.fail()) { std::cout << "Read failed before reaching EOF." << std::endl; } }文本读写:格式化与未格式化操作
operator<<和operator>>: 格式化输入输出。它们会根据数据类型进行解析和格式化。对于文件,和cout/cin用法一致。缺点是性能相对较低,且对空白字符敏感。get(): 未格式化输入,读取单个字符。有多个重载,常用的是int get();(返回字符或EOF)和istream& get(char& c);。put(char c): 输出单个字符。getline():这是读取文本行的首选方法。它读取字符直到遇到行分隔符(默认\n),丢弃分隔符,并将结果存入字符串。比>>更安全,因为>>遇到空格会停止。std::string line; while (std::getline(inFile, line)) { // 正确用法:将 getline 作为循环条件 std::cout << "Read line: " << line << std::endl; }getline的坑:它有两个常见版本,一个是全局函数模板std::getline(istream&, string&),另一个是流成员函数istream::getline(char*, streamsize)。前者更安全(使用std::string),后者需要指定字符数组大小,容易缓冲区溢出。无脑选择std::getline(istream&, string&)版本。
4. 高级议题与性能优化实战
掌握了基本函数,我们来看看如何让文件操作更稳健、更高效。
4.1 错误处理与异常
默认情况下,文件流在错误时只设置状态位,不抛出异常。但你可以通过exceptions()成员函数让流在特定错误发生时抛出std::ios_base::failure异常。
std::ifstream file; // 设置当 failbit 或 badbit 被设置时抛出异常 file.exceptions(std::ifstream::failbit | std::ifstream::badbit); try { file.open("important_config.cfg"); // ... 文件操作 ... } catch (const std::ios_base::failure& e) { std::cerr << "File IO error: " << e.what() << std::endl; std::cerr << "Error code: " << e.code() << std::endl; // C++11 }使用建议:对于简单的脚本或快速原型,检查状态位!file更轻量。对于需要严格错误处理、多层调用的应用程序,使用异常可以避免错误状态在函数间传递,让代码更清晰。但要注意,异常处理本身有一定开销。
4.2 缓冲区与性能:rdbuf()与手动缓冲
文件流内部有一个缓冲区(streambuf)。频繁的小数据读写(如每次写一个字符)会带来巨大的系统调用开销。通过操作缓冲区,可以极大提升性能。
- 使用
<<和>>的格式化IO: 性能一般,但方便。对于大量数据,考虑批量处理。 - 使用
write()和read(): 性能较好,一次读写一大块数据。 - 直接操作流缓冲区
rdbuf(): 这是性能最高的方式,相当于直接操作底层字节流。std::ifstream src("source.bin", std::ios::binary); std::ofstream dst("dest.bin", std::ios::binary); if (src && dst) { // 将 src 的整个缓冲区内容“倾倒”到 dst,非常高效 dst << src.rdbuf(); } - 自定义缓冲区大小: 默认缓冲区大小可能不适合你的场景(比如处理超大文件)。你可以创建自己的
streambuf或使用pubsetbuf()(注意:此函数可能在流打开前调用才有效,具体实现有差异)。const size_t MY_BUFFER_SIZE = 64 * 1024; // 64KB char myBuffer[MY_BUFFER_SIZE]; std::ifstream bigFile; bigFile.rdbuf()->pubsetbuf(myBuffer, MY_BUFFER_SIZE); bigFile.open("huge_file.dat", std::ios::binary); // 现在 bigFile 使用我们提供的大缓冲区进行读取警告:
pubsetbuf的行为在标准中未严格规定,特别是对于已经打开的文件。最可靠的做法是在打开文件之前调用它,并且要知道它可能被实现忽略。对于绝对性能要求,考虑使用操作系统原生的文件API(如Linux的read/write,Windows的ReadFile/WriteFile)或内存映射文件。
4.3 跨平台与字符编码的幽灵
这是文件操作中最棘手的部分之一。
- 文本模式 vs 二进制模式: 在Windows上,文本模式(默认)会将输出时的
\n转换为\r\n,输入时将\r\n转换回\n。在Linux/macOS上,无此转换。处理任何非纯ASCII文本文件(如配置文件、数据文件)时,强烈建议使用二进制模式打开,由程序自己处理行结束符和编码。 - 字符编码: C++标准库的流在底层处理的是
char(窄字符)或wchar_t(宽字符),但文件存储的是字节。char流默认不进行编码转换。如果你要处理UTF-8、GBK等编码的文本,流本身是无能为力的。- 解决方案1(推荐): 在内存中使用
std::string或std::wstring存储UTF-8或宽字符数据,以二进制模式读写文件。编码转换使用专门的库,如ICU、iconv,或C++11的<codecvt>(已在C++17弃用,但许多编译器仍支持)。 - 解决方案2: 使用第三方库,如Boost.Nowide或fmt库,它们提供了更好的跨平台本地化文件支持。
- 解决方案1(推荐): 在内存中使用
示例:读取UTF-8编码的文本文件(简单版)
#include <fstream> #include <string> #include <iostream> int main() { // 以二进制模式打开,防止系统篡改字节 std::ifstream file("utf8_text.txt", std::ios::in | std::ios::binary); if (!file) return 1; // 读取整个文件到字符串(适用于不大的文件) std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>()); // 此时 content 中存储的是原始的UTF-8字节序列 // 如果你只是要输出到控制台,而控制台支持UTF-8,可以直接输出 std::cout << content << std::endl; // 如果需要将其作为UTF-8字符串处理,可以这样(C++11后,std::string可以存储UTF-8) // 但注意:std::string的length()返回的是字节数,不是字符数! std::cout << "File size in bytes: " << content.size() << std::endl; // 要获取字符数,需要解码UTF-8,这里不展开。 return 0; }5. 综合案例:实现一个简单的配置管理器
让我们用一个完整的、贴近实际的小项目来串联所有知识点。我们将实现一个ConfigManager类,用于读写键值对格式的配置文件(如key = value)。
需求:
- 支持从文件加载配置到内存(
std::map<std::string, std::string>)。 - 支持将内存中的配置保存到文件。
- 处理简单的错误(如文件不存在、格式错误)。
- 忽略空行和以
#开头的注释行。 - 值部分支持包含空格的字符串(用引号包围,或不用引号但内部无空格)。
5.1 类设计与头文件
config_manager.h
#ifndef CONFIG_MANAGER_H #define CONFIG_MANAGER_H #include <string> #include <map> #include <fstream> class ConfigManager { public: // 从指定文件路径加载配置 bool load(const std::string& filename); // 将配置保存到指定文件路径 bool save(const std::string& filename) const; // 获取配置值,如果不存在返回默认值 std::string get(const std::string& key, const std::string& defaultVal = "") const; // 设置配置值 void set(const std::string& key, const std::string& value); // 检查配置项是否存在 bool has(const std::string& key) const; // 打印所有配置(调试用) void printAll() const; private: std::map<std::string, std::string> configMap_; // 辅助函数:修剪字符串两端的空白字符 static std::string trim(const std::string& str); // 辅助函数:解析一行配置 static bool parseLine(const std::string& line, std::string& key, std::string& value); }; #endif // CONFIG_MANAGER_H5.2 核心实现:加载与解析
config_manager.cpp(部分关键函数)
#include "config_manager.h" #include <iostream> #include <cctype> // for isspace #include <algorithm> // for find_if bool ConfigManager::load(const std::string& filename) { std::ifstream inFile(filename); // 文本模式打开 if (!inFile.is_open()) { std::cerr << "Error: Cannot open config file: " << filename << std::endl; return false; } configMap_.clear(); // 清空旧配置 std::string line; int lineNum = 0; bool hasError = false; while (std::getline(inFile, line)) { ++lineNum; std::string trimmedLine = trim(line); // 跳过空行和注释行 if (trimmedLine.empty() || trimmedLine[0] == '#') { continue; } std::string key, value; if (!parseLine(trimmedLine, key, value)) { std::cerr << "Parse error at line " << lineNum << ": " << line << std::endl; hasError = true; // 可以选择继续解析后续行,还是直接返回失败 continue; // 这里选择跳过错误行,继续解析 } // 存储到map中,后出现的覆盖先出现的 configMap_[key] = value; } // 检查是否因为错误而非EOF结束 if (inFile.bad()) { // 发生严重错误 std::cerr << "A serious I/O error occurred while reading the file." << std::endl; return false; } // fail() 在遇到EOF时也会为true,所以需要结合eof()判断 if (!inFile.eof() && inFile.fail()) { std::cerr << "Error: Failed to read the entire file (possibly format issue)." << std::endl; return false; } if (hasError) { std::cerr << "Config file loaded with some parsing errors." << std::endl; // 根据业务需求决定:有错误行是否算加载成功? // 这里假设部分错误可以容忍,返回true。 } return true; } std::string ConfigManager::trim(const std::string& str) { auto start = str.find_first_not_of(" \t\r\n"); if (start == std::string::npos) { return ""; // 全是空白字符 } auto end = str.find_last_not_of(" \t\r\n"); return str.substr(start, end - start + 1); } bool ConfigManager::parseLine(const std::string& line, std::string& key, std::string& value) { size_t eqPos = line.find('='); if (eqPos == std::string::npos) { return false; // 没有等号,不是有效的键值对 } key = trim(line.substr(0, eqPos)); value = trim(line.substr(eqPos + 1)); // 处理引号包围的值(简单处理,不支持转义引号) if (!value.empty() && (value.front() == '"' || value.front() == '\'')) { char quote = value.front(); if (value.back() == quote) { value = value.substr(1, value.length() - 2); // 去掉首尾引号 } else { // 有开头引号无结尾引号,格式错误 return false; } } if (key.empty()) { return false; // 键不能为空 } return true; }5.3 保存配置与使用示例
bool ConfigManager::save(const std::string& filename) const { std::ofstream outFile(filename); // 文本模式,默认会清空文件 if (!outFile.is_open()) { std::cerr << "Error: Cannot open file for writing: " << filename << std::endl; return false; } // 可以写一个头注释 outFile << "# Auto-generated configuration file\n# Do not edit manually unless necessary\n\n"; for (const auto& pair : configMap_) { // 如果值包含空格或特殊字符,用双引号包围 const std::string& val = pair.second; if (val.find(' ') != std::string::npos || val.find('#') != std::string::npos) { outFile << pair.first << " = \"" << val << "\"\n"; } else { outFile << pair.first << " = " << val << "\n"; } } outFile.flush(); // 确保数据写入磁盘 if (!outFile) { std::cerr << "Error: Failed to write all data to file." << std::endl; return false; } return true; } // 其他成员函数实现(get, set, has, printAll)较为简单,此处省略。 // ---------- 使用示例 ---------- int main() { ConfigManager config; // 1. 加载配置 if (!config.load("myapp.cfg")) { std::cout << "Failed to load config, using defaults." << std::endl; // 设置一些默认值 config.set("server.host", "127.0.0.1"); config.set("server.port", "8080"); config.set("log.level", "info"); } // 2. 读取配置 std::string host = config.get("server.host", "localhost"); int port = std::stoi(config.get("server.port", "80")); // 注意:stoi可能抛出异常,生产环境需处理 std::string logLevel = config.get("log.level", "warning"); std::cout << "Connecting to " << host << ":" << port << " with log level " << logLevel << std::endl; // 3. 修改并保存配置 config.set("log.level", "debug"); if (config.save("myapp_updated.cfg")) { std::cout << "Config saved successfully." << std::endl; } config.printAll(); return 0; }5.4 案例总结与扩展思考
这个案例涵盖了文件打开、读取(getline)、状态检查、写入(<<)、错误处理等多个核心点。在实际项目中,你还可以扩展它:
- 支持节(Section): 如
[database],host=localhost。 - 更复杂的值解析: 支持转义字符、多行值。
- 类型安全的获取: 提供
getInt,getBool,getDouble等方法,内部进行转换和错误处理。 - 热重载: 监控文件变化,自动重新加载配置(需要平台相关API,如
inotifyon Linux)。 - 性能: 如果配置文件很大,可以考虑一次性读入内存再解析,而不是逐行
getline。
通过这个从理论到实践的过程,你应该对C++文件操作有了一个立体而深入的理解。记住,文件操作是I/O密集型任务,代码的健壮性远比精巧性重要。始终检查流状态,考虑异常情况,明确文件打开模式,并在性能敏感的场景下选择合适的读写方式。把这些原则变成你的编码习惯,你就能写出让人放心的、与文件打交道的C++代码。