跳转到内容

解密 C++ I/O

程序的基本职责是接受输入并产生输出。完全不产生任何输出的程序不会有任何用处。所有语言都提供某种 I/O 机制,要么作为语言的内置部分,要么通过操作系统特定的 API。一个好的 I/O 系统既要灵活又要易用。灵活的 I/O 系统支持通过各种设备进行输入和输出,例如文件和用户控制台。文件可以是标准文件,但也可以是来自各种来源的数据,如物联网(IoT)设备、Web 服务等。它可能是来自气象设备的气象数据,或来自股票经纪 Web 服务的股票值。灵活的 I/O 系统还支持不同类型数据的读写。I/O 容易出错,因为来自用户的数据可能不正确,或者底层文件系统或其他数据源可能不可访问。因此,一个好的 I/O 系统还必须能够处理错误情况。

如果你熟悉 C 语言,你肯定使用过 printf()scanf()。作为 I/O 机制,printf()scanf() 无疑是灵活的。通过转义码和变量占位符(类似于 第 2 章 “处理字符串和字符串视图” 中讨论的 std::format()print()println() 的格式说明符和替换字段),它们可以定制为读取特殊格式的数据或输出格式代码允许的任何值。支持的类型仅限于整数/字符值、浮点值和字符串。然而,printf()scanf() 在其他好的 I/O 系统指标上表现不佳。例如,如果你告诉它们将浮点数解释为整数,它们会很乐意这样做。此外,它们不够灵活来处理自定义数据类型,不是类型安全的,而且在 C++ 这样的面向对象语言中,它们根本不是面向对象的。

C++ 提供了一种更精细、更灵活、更面向对象的 I/O 方法。被封装在类中,形成了一个用户友好且安全的解决方案。在本章中,你将首先了解什么是流,然后学习如何使用流进行数据输出和输入。你还将学习如何使用流机制从各种来源读取数据并写入各种目标,如用户控制台、文件,甚至字符串。本章涵盖最常用的 I/O 功能。

本书中几乎所有示例都使用 print()println() 将文本打印到用户控制台。另一种方法是使用本章讨论的 I/O 流功能。我建议使用 print()println() 而不是流向标准输出,因为前者更容易阅读,更紧凑,性能也更好。然而,本章会详细讨论 I/O 流,因为在 C++ 中了解它是如何工作的仍然重要,因为你无疑必须使用使用 I/O 流的代码。

本章的最后部分讨论了 C++ 标准库提供的文件系统支持库。这个库允许你处理路径、目录和文件,它很好地补充了流提供的 I/O 机制。

流这个比喻需要一点时间来适应。最初,流可能看起来比传统的 C 风格 I/O(如 printf())更复杂。实际上,它们看起来复杂只是因为流背后有比 printf() 更深的隐喻。不过,不用担心:经过几个例子后,一切都会变得清晰。

第 1 章”C++ 与标准库速成”将 cout 流比作数据滑槽。你把一些变量扔进流里,它们就会被写到用户屏幕上,或控制台。更一般地说,所有流都可以被视为数据滑槽。流的区别在于方向以及关联的源或目标。例如,你已经熟悉的 cout 流是一个输出流,所以它的方向是“输出”。它将数据写到控制台,所以它的关联目标是“控制台”。cout 中的 c 不是你可能期望的“控制台”,而是代表“字符”,因为它是一个基于字符的流。还有另一个标准流叫 cin,从用户那里接受输入。它的方向是“输入”,它的关联源是“控制台”。和 cout 一样,cin 中的 c 代表“字符”。coutcin 都是 std 命名空间中可用的预定义流实例。下表简要描述了 <iostream> 中定义的所有预定义流。

流可以是缓冲的未缓冲的。它们的区别在于,缓冲流不会立即将数据发送到目标,而是缓冲(也就是收集)传入的数据,然后分批发送。另一方面,未缓冲流会立即将数据发送到目标。缓冲通常是为了提高性能,因为某些目标(如文件)在一次写入更大块时表现更好。请注意,你始终可以通过使用 flush() 成员函数刷新缓冲流来强制它将当前缓冲的所有数据发送到目标。本章稍后会更详细地讨论缓冲和刷新。

描述
cin一个输入流,从“输入控制台”读取数据
cout一个缓冲的输出流,写入“输出控制台”
cerr一个未缓冲的输出流,写入“错误控制台”,通常与“输出控制台”相同
clogcerr 的缓冲版本

请记住 第 1 章 中说的,std::print()println() 默认打印到 cout,但如果你想打印到不同的流,例如,可以作为第一个参数传递一个流:

println(cerr, "This is an error printed to cerr.");

还有宽字符 wchar_t 版本可用,这些流的名称以 w 开头:wcinwcoutwcerrwclog。宽字符可用于处理比英语字符更多的语言,如中文。宽字符在 第 21 章”字符串本地化和正则表达式”中讨论。

流的另一个重要方面是它们包含数据,但也有一个当前位置。当前位置是流中下一个读或写操作将发生的位置。

流作为一个概念可以应用于接受数据或发出数据的任何对象。你可以编写一个基于流的网络类或基于流的访问音乐设备数字接口(MIDI)仪器的类。在 C++ 中,流有四个常见的来源和目标:控制台、文件、字符串和固定缓冲区数组。C++23 引入了固定缓冲区数组支持。

你已经看到了许多用户或控制台流的例子。控制台输入流通过允许在运行时从用户输入,使程序具有交互性。控制台输出流向用户提供反馈并输出结果。

望文生义,文件流向文件系统读取数据和写入数据。文件输入流对于读取配置数据和保存的文件,或用于批量处理基于文件的数据很有用。文件输出流对于保存状态和提供输出很有用。如果你熟悉 C 风格输入和输出,那么文件流包含了 C 函数 fprintf()fwrite()fputs() 的输出功能,以及 fscanf()fread()fgets() 的输入功能。由于这些 C 风格函数在 C++ 中不推荐,因此不再进一步讨论。

字符串流是将流隐喻应用于字符串类型。使用字符串流,你可以像处理任何其他流一样处理字符数据。在大多数情况下,这只是一种方便的语法,可以通过 string 类上的成员函数来处理。但是,使用流语法提供了优化的机会,与直接使用 string 类相比,它可能更方便、更高效。字符串流包含了 sprintf()sprintf_s()sscanf() 和其他形式的 C 风格字符串格式化函数的功能,在本 C++ 书中不再进一步讨论。

与固定缓冲区数组一起工作的流允许你在任何内存块上使用流隐喻,无论该缓冲区的内存是如何分配的。

本节的其余部分处理控制台流(cincout)。文件、字符串和固定缓冲区数组流的例子将在本章后面提供。其他类型的流,如打印机输出或网络 I/O,通常依赖于平台,因此本书不涵盖它们。

第 1 章 介绍了使用流的输出。本节简要回顾了一些基础知识,并介绍了更高级的内容。

输出流定义在 <ostream> 中。还有 <iostream>,它又包含了输入流和输出流的功能。<iostream> 还声明了所有预定义流实例:coutcincerrclog 以及宽字符版本。

<< 运算符是使用输出流最简单的方式。C++ 基本类型,如 int指针double字符,都可以使用 << 输出。此外,C++ string 类与 << 兼容,C 风格字符串也能正确输出。以下是使用 << 的一些例子:

int i { 7 };
cout << i << endl;
char ch { 'a' };
cout << ch << endl;
string myString { "Hello World." };
cout << myString << endl;

输出如下:

7
a
Hello World.

cout 流是用于写入控制台的内置流,或标准输出。你可以“链式”使用 << 来输出多条数据。这是因为 operator<< 返回流本身的引用,因此你可以立即在同一个流上再次使用 <<。例子如下:

int j { 11 };
cout << "The value of j is " << j << "!" << endl;

输出如下:

The value of j is 11!

C++ 流能正确解析 C 风格的转义序列,例如包含 \n 的字符串。你也可以使用 std::endl 来开始新行。使用 \nendl 的区别在于,\n 只开始新行,而 endl 还会刷新缓冲区。在性能关键代码(如紧密循环)中谨慎使用 endl,因为过多的刷新可能会损害性能。以下示例使用 endl 用一行代码输出和刷新多行文本:

cout << "Line 1" << endl << "Line 2" << endl << "Line 3" << endl;

输出如下:

Line 1
Line 2
Line 3

endl 会刷新目标缓冲区,因此在性能关键代码中明智地使用它,例如紧密循环。

毫无疑问,<< 是输出流最有用的部分。但还有更多功能可以探索。如果你查看 <ostream> 的内容,你会看到许多行重载 << 运算符的定义,以支持输出各种不同的数据类型。你还会发现一些有用的公共成员函数。

put()write()原始输出成员函数。与接受具有某种定义的输出行为的对象或变量不同,put() 接受单个字符,而 write() 接受字符数组。传递给这些成员函数的数据按原样输出,没有任何特殊格式或处理。例如,以下代码片段展示了如何在不使用 << 运算符的情况下向控制台输出 C 风格字符串:

const char* test { "hello there" };
cout.write(test, strlen(test));

下一个片段展示了如何使用 put() 成员函数向控制台写入单个字符:

cout.put('a');

当你写入输出流时,流不一定立即将数据写入其目标。大多数输出流会缓冲或累积数据,而不是在数据传入后立即写出。这通常是为了提高性能。某些流目标(如文件)在以更大块写入数据时性能会更好,而不是逐字符写入。流刷新或写出累积数据,当以下条件之一发生时:

  • 遇到 endl 操作符。
  • 流超出作用域并被销毁。
  • 流缓冲区已满。
  • 你明确告诉流刷新其缓冲区。
  • 从相应的输入流请求输入时(即当你使用 cin 进行输入时,cout 会刷新)。在“文件流”一节中,你将学习如何建立这种类型的链接。

显式告诉流刷新的一个方法是调用其 flush() 成员函数,如以下代码:

cout << "abc";
cout.flush(); // abc 被写入控制台。
cout << "def";
cout << endl; // def 被写入控制台。

输出错误可能出现在各种场景中。也许你正尝试打开一个不存在的文件。也可能是磁盘错误导致写操作失败,例如磁盘已满。到目前为止你看到的流相关代码都没有考虑这些可能性,主要是为了简洁起见。不过,正确处理出现的错误情况至关重要。

当一个流处于正常可用状态时,就称它是“good”的。你可以直接在流上调用 good() 成员函数,判断该流当前是否处于良好状态:

if (cout.good()) {
cout << "All good" << endl;
}

good() 提供了一种获取流有效性基本信息的简便方式,但它不会告诉你流为什么不可用。还有一个名为 bad() 的成员函数,它能提供更多一点的信息。如果 bad() 返回 true,表示发生了致命错误(相对于文件结束 eof() 之类的非致命情况)。另一个成员函数 fail() 会在最近一次操作失败时返回 true;不过,它并不能说明下一次操作会成功还是失败。例如,在对输出流调用 flush() 之后,你可以调用 fail() 来确认刷新是否成功:

cout.flush();
if (cout.fail()) {
cerr << "Unable to flush to standard out" << endl;
}

流还带有一个可转换为 bool 的转换运算符。这个转换运算符返回的结果与调用 !fail() 相同。因此,前面的代码片段可以改写为:

cout.flush();
if (!cout) {
cerr << "Unable to flush to standard out" << endl;
}

需要知道的重要一点是,当到达文件结束时,good()fail() 都会返回 false。它们之间的关系如下:good() == (!fail() && !eof())

你也可以让流在发生失败时抛出异常。此时你需要编写 catch 处理程序来捕获 ios_base::failure 异常,并可在该异常上使用 what() 成员函数获取错误描述,使用 code() 成员函数获取错误代码。不过,能否得到有用的信息取决于你所使用的标准库实现。

cout.exceptions(ios::failbit | ios::badbit | ios::eofbit);
try {
cout << "Hello World." << endl;
} catch (const ios_base::failure& ex) {
cerr << "Caught exception: " << ex.what()
<< ", error code = " << ex.code() << endl;
}

要重置流的错误状态,请使用 clear():

cout.clear();

与文件输出流或输入流相比,控制台输出流通常较少执行错误检查。这里讨论的成员函数同样适用于其他类型的流,后面在介绍各类流时还会再次提到。

流有一个不太寻常的特性:你不仅可以把数据送进这条“滑槽”。C++ 流还能识别操纵器(manipulator),它们是一些对象,会改变流的行为,而不是仅仅提供数据供流处理,或者除了提供数据之外还附带行为。

你已经见过一个操纵器:endlendl 操纵器同时封装了数据和行为。它告诉流输出一个行结束序列,并刷新其缓冲区。下面列出的是一些其他有用的操纵器(并非完整列表),其中许多都定义在 <ios><iomanip> 中。列表后面会给出一个使用示例:

  • boolalphanoboolalpha: 指示流将 bool 值输出为 true / false (boolalpha) 或 1 / 0 (noboolalpha)。默认值是 noboolalpha
  • hexoctdec: 分别以十六进制、八进制和十进制输出数字。
  • fixedscientificdefaultfloat: 分别使用定点、科学计数法和默认格式输出小数。
  • setprecision: 设置以定点或科学计数法输出小数时的小数位数,或者设置总输出位数。这是一个带参数的操纵器(也就是说,它接受一个参数)。
  • setw: 设置输出数据时的字段宽度。这是一个带参数的操纵器。
  • setfill: 将某个字符设为流新的填充字符。填充字符会根据 setw 设置的宽度对输出进行补齐。这是一个带参数的操纵器。
  • showpointnoshowpoint: 强制流在浮点数没有小数部分时始终显示或不显示小数点。
  • put_money: 一个带参数的操纵器,用于向流中写入格式化后的货币值。
  • put_time: 一个带参数的操纵器,用于向流中写入格式化后的时间。
  • quoted: 一个带参数的操纵器,用于给指定字符串加上引号,并对其中嵌入的引号进行转义。

除了 setw 只对下一次单独输出生效之外,所有这些操纵器都会持续作用于该流后续的输出,直到被重置。下面的例子使用了其中几个操纵器来自定义输出:

// 布尔值
bool myBool { true };
cout << "This is the default: " << myBool << endl;
cout << "This should be true: " << boolalpha << myBool << endl;
cout << "This should be 1: " << noboolalpha << myBool << endl;
// 使用流模拟 println 风格的 "{:6}"
int i { 123 };
println("This should be ' 123': {:6}", i);
cout << "This should be ' 123': " << setw(6) << i << endl;
// 使用流模拟 println 风格的 "{:0>6}"
println("This should be '000123': {:0>6}", i);
cout << "This should be '000123': " << setfill('0') << setw(6) << i << endl;
// 使用 * 填充
cout << "This should be '***123': " << setfill('*') << setw(6) << i << endl;
// 重置填充字符
cout << setfill(' ');
// 浮点值
double dbl { 1.452 };
double dbl2 { 5 };
cout << "This should be ' 5': " << setw(2) << noshowpoint << dbl2 << endl;
cout << "This should be @@1.452: " << setw(7) << setfill('@') << dbl << endl;
// 重置填充字符
cout << setfill(' ');
// 指示 cout 开始根据你的位置格式化数字。
// 第 21 章解释了 imbue() 调用和 locale 对象的细节。
cout.imbue(locale { "" });
// 根据你的位置格式化数字
cout << "This is 1234567 formatted according to your location: " << 1234567
<< endl;
// 货币值。货币值的具体含义取决于你的位置。
// 例如,在美国,120000 的货币值意味着 120000 美分,即 1200.00 美元。
cout << "This should be a monetary value of 120000, "
<< "formatted according to your location: "
<< put_money("120000") << endl;
// 日期和时间
time_t t_t { time(nullptr) }; // 获取当前系统时间。
tm t { *localtime(&t_t) }; // 转换为本地时间。
cout << "This should be the current date and time "
<< "formatted according to your location: "
<< put_time(&t, "%c") << endl;
// 带引号的字符串
cout << "This should be: \"Quoted string with \\\"embedded quotes\\\".\": "
<< quoted("Quoted string with \"embedded quotes\".") << endl;

如果你并不喜欢操纵器这个概念,通常也可以完全不使用它们。流通过等价的成员函数(如 precision())提供了许多相同的功能。例如,看下面这行代码:

cout << "This should be '1.2346': " << setprecision(5) << 1.23456789 << endl;

它可以改写为使用成员函数调用的形式。成员函数调用的优势在于,它们会返回旧值,这样在有需要时你就可以将其恢复。

cout.precision(5);
cout << "This should be '1.2346': " << 1.23456789 << endl;

如果你需要所有流成员函数和操纵器的详细说明,请查阅你喜欢的标准库参考资料。

输入流提供了一种简单方式,用于读入结构化或非结构化数据。本节会在 cin 这个控制台输入流的上下文中讨论输入技术。

使用输入流读取数据有两种简单方式。第一种是与向输出流输出数据的 << 运算符相对应的做法。负责读取数据的对应运算符是 >>。当你使用 >> 从输入流读取数据时,你提供的变量就是接收值的存储位置。例如,下面这个程序从用户那里读取一个单词并将其放入字符串中,然后再把这个字符串输出回控制台:

string userInput;
cin >> userInput;
println("User input was {}.", userInput);

默认情况下,>> 运算符会按空白字符对值进行分词。例如,如果用户运行前面的程序并输入 hello there,那么只有第一个空白字符(这里是空格)之前的字符会被读入 userInput 变量。输出将如下所示:

User input was hello.

如果希望输入中包含空白字符,一种解决办法是使用 get(),本章稍后会讨论。

>> 运算符和 << 运算符一样,可用于不同类型的变量。例如,若要读取一个整数,代码的差别只在变量的类型上:

int userInput;
cin >> userInput;
println("User input was {}.", userInput);

你可以使用输入流读取多个值,并按需混合不同类型。例如,下面这个函数摘自一个餐厅预订系统,它会询问用户的姓氏以及同行人数:

void getReservationData()
{
string guestName;
int partySize;
print("Name and number of guests: ");
cin >> guestName >> partySize;
println("Thank you, {}.", guestName);
if (partySize > 10) {
println("An extra gratuity will apply.");
}
}

请记住,>> 运算符会按空白字符分词,因此 getReservationData() 不允许你输入带空格的姓名。本章稍后会讨论使用 unget() 的解决方案。还要注意,上面第一次使用 cout 时并没有通过 endlflush() 显式刷新缓冲区,但文本仍然会被写到控制台,因为一旦使用 cin,cout 的缓冲区就会立即刷新;它们之间正是以这种方式关联在一起的。

输入流提供了不少成员函数,用于检测异常情况。与输入流有关的大多数错误情况都发生在没有数据可读时。例如,流的末尾(即使对非文件流,也称为文件结束end-of-file)可能已经到达。查询输入流状态最常见的方式,是在条件语句中直接使用它。例如,只要 cin 仍处于良好状态,下面这个循环就会一直继续。这个模式利用了这样一个事实:在条件上下文中求值输入流时,只有当流不处于任何错误状态时,结果才是 true。一旦遇到错误,该流的求值结果就会变为 false。实现这种行为所需的底层转换细节会在 第 15 章“重载 C++ 运算符”中解释。

while (cin) {}

你也可以在同一时间完成输入:

while (cin >> ch) {}

和输出流一样,输入流也可以调用 good()bad()fail() 成员函数。它还有一个 eof() 成员函数,当流到达末尾时返回 true。与输出流类似,当到达文件结束时,good()fail() 都会返回 false。它们之间的关系同样如下:good() == (!fail() && !eof())

你应该养成在读取数据后检查流状态的习惯,这样才能从错误输入中恢复。

下面这个程序展示了一个常见模式:从流中读取数据并处理错误。该程序从标准输入读取数字,并在到达文件结束时显示它们的和。请注意,在命令行环境中,文件结束由用户输入某个特定字符来表示。在 Unix 和 Linux 中,它是 Control+D;在 Windows 中,它是 Control+Z,两者之后都要再按 Enter。具体字符依赖于操作系统,因此你需要知道你的操作系统要求输入什么。

println("Enter numbers on separate lines to add.");
println("Use Control+D followed by Enter to finish (Control+Z in Windows).");
int sum { 0 };
if (!cin.good()) {
println(cerr, "Standard input is in a bad state!");
return 1;
}
while (!cin.bad()) {
int number;
cin >> number;
if (cin.good()) {
sum += number;
} else if (cin.eof()) {
break; // 到达文件结束。
} else if (cin.fail()) {
// 失败!
cin.clear(); // 清除失败状态。
string badToken;
cin >> badToken; // 消耗错误输入。
println(cerr, "WARNING: Bad input encountered: {}", badToken);
}
}
println("The sum is {}.", sum);

下面是该程序的一段示例输出。输出中的 ^Z 字符是在按下 Control+Z 时出现的。

Enter numbers on separate lines to add.
Use Control+D followed by Enter to finish (Control+Z in Windows).
1
2
test
WARNING: Bad input encountered: test
3
^Z
The sum is 6.

与输出流一样,输入流也有若干成员函数,它们提供了比常见的 >> 运算符更底层一些的访问方式。

get() 成员函数允许你从流中原始地读取数据。最简单的 get() 版本会返回流中的下一个字符,当然也还有其他版本,可以一次读取多个字符。get() 最常见的用途是避免 >> 运算符那种自动分词行为。例如,下面这个函数会从输入流中读取一个名字,这个名字可以由多个单词组成,一直读到流结束为止:

string readName(istream& stream)
{
string name;
while (stream) { // 或者:while (!stream.fail()) {
int next { stream.get() };
if (!stream || next == std::char_traits<char>::eof())
break;
name += static_cast<char>(next);// 追加字符。
}
return name;
}

关于这个 readName() 函数,有几个有意思的观察点:

  • 它的参数是 istream 的非常量引用,而不是常量引用。从流中读取数据的成员函数会改变流本身(最明显的是当前位置),因此它们不是 const 成员函数。所以,你不能在常量引用上调用它们。
  • get() 的返回值存储在 int 中,而不是 char 中,因为 get() 可能返回一些特殊的非字符值,例如 std::char_traits<char>::eof()(文件结束)。
  • get() 读取到的换行符和其他转义字符会出现在 readName() 返回的 string 中。如果 Ctrl+DCtrl+Z 不是在一行开头输入的,它们也会出现在返回的 string 中。

readName() 看起来有点奇怪,因为它有两种跳出循环的方式:要么流进入失败状态,要么到达流末尾。更常见的流读取模式会使用 get() 的另一个版本,该版本接受一个字符引用并返回流的引用。在条件上下文中求值输入流时,只有当流不处于任何错误状态时,结果才会是 true。下面这个同一函数的版本稍微更简洁一些:

string readName(istream& stream)
{
string name;
char next;
while (stream.get(next)) {
name += next;
}
return name;
}

对大多数用途来说,正确理解输入流的方式,是把它看成一条单向滑槽。数据沿着滑槽落下,进入变量中。unget() 成员函数则在某种程度上打破了这个模型,因为它允许你把数据重新“推回”滑槽中。

调用 unget() 会让流后退一个位置,本质上就是把上一个读取到的字符重新放回流中。你可以使用 fail() 成员函数查看 unget() 是否成功。例如,如果当前位置已经在流的起始处,unget() 就可能失败。

本章前面展示的 getReservationData() 函数不允许你输入带空格的名字。下面的代码通过 unget() 让名字中可以包含空白字符。代码按字符逐个读取,并检查该字符是否为数字。如果不是数字,就把它追加到 guestName 中;如果是数字,就用 unget() 把该字符放回流中,停止循环,然后使用 >> 运算符读取整数 partySize。输入操纵器 noskipws 会告诉流不要跳过空白字符;也就是说,空白字符会像其他普通字符一样被读取。

void getReservationData()
{
print("Name and number of guests: ");
string guestName;
int partySize { 0 };
// 读取字符直到找到数字
char ch;
cin >> noskipws;
while (cin >> ch) {
if (isdigit(ch)) {
cin.unget();
if (cin.fail()) { println(cerr, "unget() failed."); }
break;
}
guestName += ch;
}
// 如果流不处于错误状态,读取 partySize
if (cin) { cin >> partySize; }
if (!cin) {
println(cerr, "Error getting party size.");
return;
}
println("Thank you '{}', party of {}.", guestName, partySize);
if (partySize > 10) {
println("An extra gratuity will apply.");
}
}

putback() 成员函数和 unget() 一样,允许你在输入流中按字符后退一个位置。区别在于,putback() 接受一个参数,这个参数就是将要放回流中的字符。示例如下:

char c;
cin >> c;
println("Retrieved {}.", c);
cin.putback('e'); // 'e' 将是流中下一个读取到的字符。
println("Called putback('e').");
while (cin >> c) { println("Retrieved {}.", c); }

输出可能如下所示:

wow
Retrieved w.
Called putback('e').
Retrieved e.
Retrieved o.
Retrieved w.

peek() 成员函数允许你预览“如果调用 get() 将会返回什么值”。如果把滑槽隐喻再发挥得稍微远一点,你可以把它理解为朝滑槽里往上看一眼,而并没有真的让某个值掉下来。

在任何需要先向前看一眼、再决定是否读取值的场景里,peek() 都非常理想。例如,下面的代码实现了一个允许名字中包含空白字符的 getReservationData(),不过它使用的是 peek(),而不是 unget():

void getReservationData()
{
print("Name and number of guests: ");
string guestName;
int partySize { 0 };
// 读取字符直到找到数字。
cin >> noskipws;
while (true) {
// '预览' 下一个字符。
char ch { static_cast<char>(cin.peek()) };
if (!cin) { break; }
if (isdigit(ch)) {
// 下一个字符是数字,停止循环。
break;
}
// 下一个字符不是数字,读取它。
cin >> ch;
if (!cin) { break; }
guestName += ch;
}
// 如果流不处于错误状态,读取 partySize。
if (cin) { cin >> partySize; }
if (!cin) {
println(cerr, "Error getting party size.");
return;
}
println("Thank you '{}', party of {}.", guestName, partySize);
if (partySize > 10) {
println("An extra gratuity will apply.");
}
}

从输入流中获取一整行数据是非常常见的操作,以至于标准库专门提供了成员函数来完成它。getline() 成员函数会将一行数据填充到一个字符缓冲区中,最多填充到指定大小。这个指定大小包含 \0 字符。因此,下面的代码会从 cin 中最多读取 BufferSize-1 个字符,或者读取到行结束序列为止:

char buffer[BufferSize] { 0 };
cin.getline(buffer, BufferSize);

调用 getline() 时,它会从输入流中读取一整行,包括行结束序列本身。不过,行结束字符不会出现在结果字符串中。请注意,行结束序列依赖于平台。例如,它可能是 \r\n\n\n\r

还有一种 get() 形式,它执行的操作和 getline() 相同,但会把换行序列保留在输入流中。

还有一个可与 C++ string 一起使用的非成员函数 std::getline()。它定义在 <string> 中,位于 std 命名空间中。它接受一个流和一个 string 引用。使用这个版本的 getline() 的好处是,你不需要指定任何缓冲区的大小。

string myString;
getline(cin, myString);

getline() 成员函数和 std::getline() 函数都接受一个可选的最后参数作为分隔符。默认分隔符是 \n。通过更改该分隔符,这些函数可用于读取多行文本,直到遇到指定分隔符为止。例如,下面的代码会持续读取多行文本,直到读到一个 @ 字符:

print("Enter multiple lines of text. "
"Use an @ character to signal the end of the text.\n> ");
string myString;
getline(cin, myString, '@');
println("Read text: \"{}\"", myString);

下面是一个可能的输出:

Enter multiple lines of text. Use an @ character to signal the end of the text.
> This is some
text on multiple
lines.@
Read text: "This is some
text on multiple
lines."

和输出流一样,输入流也支持若干输入操纵器。你已经见过其中一个:noskipws,它会告诉输入流不要跳过任何空白字符。下面的列表展示了其他一些内建输入操纵器,它们允许你自定义数据的读取方式:

  • boolalphanoboolalpha: 如果使用 boolalpha,字符串 false 会被解释为布尔值 false;除此之外的任何内容都会被当作布尔值 true。如果设置为 noboolalpha,则 0 会被解释为 false,其他任何值都被解释为 true。默认值是 noboolalpha
  • dechexoct: 分别以十进制、十六进制和八进制形式读取数字。例如,十进制数 207 的十六进制表示是 cf,八进制表示是 317
  • skipwsnoskipws: 告诉流在分词时是跳过空白字符,还是将空白字符也作为独立标记读入。默认值是 skipws
  • ws: 一个很方便的操纵器,它会直接跳过流当前位置上的一串空白字符。
  • get_money: 一个带参数的操纵器,用于从流中读取货币值。
  • get_time: 一个带参数的操纵器,用于从流中读取格式化时间。
  • quoted: 一个带参数的操纵器,用于读取被引号包围且其中嵌入引号已转义的字符串。本章稍后会展示它的例子。

输入是与区域设置(locale)相关的。例如,下面的代码会为 cin 启用你的系统区域设置。区域设置会在 第 21 章 中讨论:

cin.imbue(locale { "" });
int i;
cin >> i;

如果你的系统区域设置是美式英语,你可以输入 1,000,它会被解析为 1000。如果你输入 1.000,它会被解析为 1。另一方面,如果你的系统区域设置是比利时荷兰语,你可以输入 1.000 得到 1000,但输入 1,000 则会得到 1。在这两种情况下,你也都可以直接输入不带数字分隔符的 1000,得到值 1000。

如果你熟悉 C 语言里老派的 printf() 输出函数,那你应该知道它既不灵活,也不支持自定义类型。printf() 了解若干种数据类型,但几乎没有办法额外教会它新的类型知识。举个例子,考虑下面这个简单类:

class Muffin final
{
public:
const string& getDescription() const { return m_description; }
void setDescription(string description)
{
m_description = std::move(description);
}
int getSize() const { return m_size; }
void setSize(int size) { m_size = size; }
bool hasChocolateChips() const { return m_hasChocolateChips; }
void setHasChocolateChips(bool hasChips)
{
m_hasChocolateChips = hasChips;
}
private:
string m_description;
int m_size { 0 };
bool m_hasChocolateChips { false };
};

如果你想用 printf() 输出一个 Muffin 类对象,那要是能把它直接作为参数写进去就好了,比如用 %m 作为占位符:

printf("Muffin: %m\n", myMuffin); // 错误!printf 不识别 Muffin。

遗憾的是,printf()Muffin 类型一无所知,无法输出 Muffin 类型对象。更糟的是,由于 printf() 的声明方式就是这样,这会导致运行期错误,而不是编译期错误(不过好的编译器会给出警告)。

对于 printf() 来说,你能做的最好办法,就是给 Muffin 类添加一个新的 output() 成员函数:

class Muffin final
{
public:
void output() const
{
printf("%s, size is %d, %s", getDescription().c_str(), getSize(),
(hasChocolateChips() ? "has chips" : "no chips"));
}
// 为简洁起见省略
};

不过,这种机制用起来很笨拙。如果你想在另一整行文本的中间输出一个 Muffin,那你就得把这一行拆成两次调用,中间夹一个 Muffin::output() 调用,像下面这样:

printf("The muffin is a ");
myMuffin.output();
printf(" -- yummy!\n");

更好的现代做法,是在 第 2 章 所解释的基础上,为 Muffin 对象编写一个自定义 std::formatter 特化。下面给出的是一个简单的 Muffin 自定义 formatter。为了让示例保持简洁,这个 formatter 不支持任何格式说明符。因此,缩写形式的 parse() 成员函数模板不需要解析任何内容,直接返回 begin(context) 即可。

template <>
class std::formatter<Muffin>
{
public:
constexpr auto parse(auto& context) { return begin(context); }
auto format(const Muffin& muffin, auto& ctx) const
{
ctx.advance_to(format_to(ctx.out(), "{}, size is {}, {}",
muffin.getDescription(), muffin.getSize(),
(muffin.hasChocolateChips() ? "has chips" : "no chips")));
return ctx.out();
}
};

有了这个自定义 formatter,你就可以使用现代的 std::print()println() 函数来打印 Muffin。例如:

println("The muffin is a {} -- yummy!", myMuffin);

打印 Muffin 的另一个选择,是重载 << 运算符,这样你就可以像输出 string 一样输出 Muffin——把它作为参数传给 operator<< 即可。此外,你还可以重载 >> 运算符,从输入流中读取 Muffin第 15 章 会详细讲解 <<>> 运算符的重载。

标准库自带了许多内建流操纵器,但如果有需要,你也可以编写自定义操纵器。

编写你自己的无参操纵器很容易。下面是一个简单例子,定义了一个名为 tab 的输出操纵器,它会向给定流输出一个制表符:

ostream& tab(ostream& stream) { return stream << '\t'; }
int main()
{
cout << "Test" << tab << "!" << endl;
}

编写自定义的带参数操纵器会复杂得多。这需要使用 ios_base 暴露出来的功能,例如 xalloc()iword()pword()register_callback()。由于这种操纵器很少需要,本书不再进一步讨论该主题。如果你感兴趣,请查阅你喜欢的标准库参考资料。

字符串流提供了一种把流语义应用到 string 上的方式。借助它,你可以拥有一个表示文本数据的内存内流。例如,在 GUI 应用程序中,你可能希望用流来逐步构造文本数据,但并不是把文本输出到控制台 or 文件,而是想把结果显示在消息框 or 编辑控件这样的 GUI 元素中。另一个例子是,你可能希望把一个字符串流传递给不同的函数,同时保留当前的读取位置,这样每个函数都可以处理流中的下一个部分。字符串流对于解析文本也很有用,因为流本身就内建了分词功能。

std::ostringstream 类用于向 string 写入数据,而 std::istringstream 用于从 string 中读取数据。ostringstream 中的 o 代表输出(output),istringstream 中的 i 代表输入(input)。它们都定义在 <sstream> 中。由于 ostringstreamistringstream 继承了与 ostreamistream 相同的行为,所以使用它们会让人感到非常熟悉。

下面这个程序会向用户请求若干单词,将它们输出到同一个 ostringstream 中,单词之间以逗号分隔并用双引号包裹。程序结束时,整个流会通过 str() 成员函数转换为一个 string 对象,然后输出到控制台。输入标记可以通过输入 done 停止,也可以通过关闭输入流来结束,即在 Unix 下输入 Control+D,在 Windows 下输入 Control+Z

println("Enter tokens. "
"Control+D (Unix) or Control+Z (Windows) followed by Enter to end.");
ostringstream outStream;
bool firstLoop { true };
while (cin) {
string nextToken;
print("Next token: ");
cin >> nextToken;
if (!cin || nextToken == "done") { break; }
if (!firstLoop) { outStream << ", "; }
outStream << '"' << nextToken << '"';
firstLoop = false;
}
println("The end result is: {}", outStream.str());

从字符串流中读取数据也同样很熟悉。下面这个函数会从字符串输入流中创建并填充一个 Muffin 对象(见前面的例子)。流中的数据采用固定格式,因此这个函数可以很容易地把这些值转换成对 Muffin 各个 setter 的调用。这种固定格式是:先是用双引号包裹的 muffin 描述,然后是大小,再然后是一个 truefalse,表示该 muffin 是否含有巧克力碎片。例如,下面这个字符串就是一个合法的 muffin:

"Raspberry Muffin" 12 true

实现如下。请注意,这里使用了 quoted 操纵器,从输入流中读取带引号的字符串。

Muffin createMuffin(istringstream& stream)
{
Muffin muffin;
// 假设数据格式正确:
// "Description" size chips
string description;
int size;
bool hasChips;
// 读取全部三个值。注意 chips 由
// 字符串 "true" 和 "false" 表示。
stream >> quoted(description) >> size >> boolalpha >> hasChips;
if (stream) { // 读取成功。
muffin.setSize(size);
muffin.setDescription(description);
muffin.setHasChocolateChips(hasChips);
}
return muffin;
}

与标准 C++ string 相比,字符串流的一个优点是,除了保存数据,它还知道下一次读或写操作会发生在哪里,这也就是所谓的当前位置

另一个优点是,字符串流支持操纵器和区域设置,因此相比 string 能提供更强大的格式化能力。

最后,如果你需要通过拼接多个较小字符串来构建一个大字符串,那么使用字符串流通常会比直接拼接 string 对象性能更好。

C++23 引入了基于 span 的流(span-based streams),它们定义在 <spanstream> 中,允许你把流这个隐喻应用到任何可用的固定内存缓冲区上。这个缓冲区的内存是如何分配的并不重要。在这种场景下你最常使用的类是用于输入的 ispanstream,用于输出的 ospanstream,以及同时支持输入和输出的 spanstream。从技术上讲,它们分别是类模板 basic_ispanstreambasic_ospanstreambasic_spanstreamchar 实例化版本。也提供了宽字符 wchar_t 版本,分别名为 wispanstreamwospanstreamwspanstream。本章前面已经提到过宽字符,而 第 21 章 会更详细讨论。本节给出的是非宽字符类的示例,因为其他版本的工作方式非常相似。

基于 span 的流类的构造函数都要求传入一个 std::span第 18 章“标准库容器”会详细讨论 span,并解释为什么以及何时使用它,不过这些细节对本节并不重要。在基于 span 的流场景下使用 span 很直接,你很快就会看到。简单来说,span 允许你在一段连续内存上构造一个视图。这有点类似于 第 2 章 中讲过的 std::string_view,后者允许你在任意字符串上创建只读视图。区别在于,span 既可以是只读视图,也可以是可写视图,允许修改底层缓冲区。

下面是一个使用 ispanstream 来解析存储在固定内存缓冲区 fixedBuffer 中的数据的例子。要在该缓冲区上构造一个 span,你只需要使用 span 的构造函数并传入缓冲区的位置即可。

char fixedBuffer[] { "11 2.222 Hello" };
ispanstream stream { span { fixedBuffer } };
int i; double d; string str;
stream >> i >> d >> str;
println("Parsed data: int: {}, double: {}, string: {}", i, d, str);

输出如下:

Parsed data: int: 11, double: 2.222, string: Hello

使用 ospanstream 也同样直接。下面的代码创建了一个由 32 个 char 组成的固定缓冲区,在其上构造一个可写的 ospanstream 视图,通过标准流插入操作向该缓冲区输出一些数据,最后打印结果:

char fixedBuffer[32] {};
ospanstream stream { span { fixedBuffer } };
stream << "Hello " << 2.222 << ' ' << 11;
println("Buffer contents: \"{}\"", fixedBuffer);

输出为:

Buffer contents: "Hello 2.222 11"

文件非常适合流抽象,因为读写文件除了数据本身,总还伴随着一个位置。在 C++ 中,std::ofstreamifstream 类为文件提供输出和输入功能。它们定义在 <fstream> 中。

处理文件系统时,检测和处理错误情况尤其重要。你所操作的文件可能位于一个刚刚离线的网络文件存储中,或者你可能正在尝试写入一个位于已满磁盘上的文件。也可能你正在尝试打开一个当前用户无权限访问的文件。可以使用前面介绍过的标准错误处理机制来检测这些错误情况。

输出文件流与其他输出流之间唯一的主要区别在于,文件流构造函数可以接受文件名以及你希望打开该文件的模式。默认模式是写模式 ios_base::out,这会从文件开头开始写入,并覆盖已有数据。你也可以通过把常量 ios_base::app 作为文件流构造函数的第二个参数,以追加模式打开输出文件流。下表列出了可用的不同常量:

常量描述
ios_base::app打开文件,并在每次写操作之前移动到末尾。
ios_base::ate打开文件,并在打开后立即一次性移动到末尾。
ios_base::binary以二进制模式进行输入输出,而不是文本模式。见下一节。
ios_base::in以输入模式打开,从开头开始读取。
ios_base::out以输出模式打开,从开头开始写入,覆盖已有数据。
ios_base::truncout 的一个选项。删除所有现有数据(截断)。
ios_base::noreplaceout 的一个选项。以独占模式打开。如果文件已存在,打开将失败。

请注意,这些模式可以组合使用。例如,如果你想以二进制模式打开一个输出文件流,同时截断已有数据,则可以这样指定打开模式:

ios_base::out | ios_base::binary | ios_base::trunc

ifstream 会自动包含 ios_base::in 模式,而 ofstream 会自动包含 ios_base::out 模式,即使你没有显式指定 inout

下面这个程序会打开 test.txt 文件,并把程序参数写入其中。ifstreamofstream 的析构函数会自动关闭底层文件,因此无需显式调用 close()

int main(int argc, char* argv[])
{
ofstream outFile { "test.txt", ios_base::trunc };
if (!outFile.good()) {
println(cerr, "Error while opening output file!");
return -1;
}
outFile << "There were " << argc << " arguments to this program." << endl;
outFile << "They are: " << endl;
for (int i { 0 }; i < argc; i++) {
outFile << argv[i] << endl;
}
}

默认情况下,文件流会以文本模式打开。如果你指定 ios_base::binary 标志,则文件会以二进制模式打开。

在二进制模式下,流会把你要求写入的字节原封不动地写入文件。读取时,它也会把文件中的字节原封不动地返回给你。

在文本模式下,则会发生某些隐藏转换:你写入或从文件中读取的每一行都以 \n 结束。然而,一行在文件中究竟如何编码,取决于操作系统。例如,在 Windows 上,一行以 \r\n 结束,而不是单个 \n。因此,当文件以文本模式打开并且你向其中写入以 \n 结尾的一行时,底层实现会在写入前自动将 \n 转换为 \r\n。类似地,从文件读取一行时,文件中读到的 \r\n 也会在返回给你之前自动转换回 \n

所有输入流和输出流上都提供了 seekx()`` 和 tell*x*() 成员函数。`seek`*x*`() 成员函数允许你移动到输入流或输出流中的任意位置。seekx()`` 有多种形式。对于输入流,对应成员函数叫 seekg()(其中 *g* 代表 *get*);对于输出流,它叫 seekp()(其中 *p* 代表 *put*)。你可能会疑惑,为什么会有 seekg()seekp()两个成员函数,而不是只有一个seek()`。原因在于,某些流既能输入又能输出,例如文件流。在这种情况下,流需要同时记住读取位置和独立的写入位置。这也叫双向 I/O,本章后面会介绍。

seekg() 有两个重载,seekp() 也有两个。一个重载接受单个参数——绝对位置——并跳转到该绝对位置。另一个重载接受一个偏移量和一个位置,并从给定位置出发按偏移量进行跳转。位置类型是 std::streampos,偏移类型是 std::streamoff;两者都以字节为单位。可用的三个预定义位置如下表所示:

位置描述
ios_base::beg流的起始处
ios_base::end流的末尾
ios_base::cur流中的当前位置

例如,要在输出流中跳转到一个绝对位置,你可以使用 seekp() 的单参数重载,如下例所示,它使用常量 ios_base::beg 移动到流的开头:

outStream.seekp(ios_base::beg);

在输入流中跳转的方法相同,只不过要使用 seekg() 成员函数:

inStream.seekg(ios_base::beg);

双参数重载会移动到流中的相对位置。第一个参数规定要移动多少个位置,第二个参数提供起始点。若要相对于文件开头移动,使用 ios_base::beg;若要相对于文件结尾移动,使用 ios_base::end;若要相对于当前位置移动,使用 ios_base::cur。例如,下面这行代码会移动到输出流开头之后的第二个字节。请注意,整数会被隐式转换为 streamposstreamoff

outStream.seekp(2, ios_base::beg);

下一个例子会移动到输入流倒数第三个字节的位置:

inStream.seekg(-3, ios_base::end);

你还可以使用 tellx()`` 成员函数查询流的当前位置,它会返回一个 streampos,表示当前位置。你可以用这个结果在执行 seek*x*() 之前记住当前位置标记,或者检查自己是否位于某个特定位置。`tell`*x*`() 同样为输入流和输出流提供了不同版本。输入流使用 tellg(),输出流使用 tellp()

下面的代码通过检查输入流的位置来判断它是否在开头:

streampos curPos { inStream.tellg() };
if (ios_base::beg == curPos) {
println("We're at the beginning.");
}

下面这个示例程序把这些内容组合在一起。该程序会向名为 test.out 的文件写入数据,并执行以下测试:

  1. 将字符串 54321 输出到文件
  2. 验证标记位于流中的位置 5
  3. 移动到输出流中的位置 2
  4. 在位置 2 输出一个 0,然后关闭输出流
  5. test.out 文件上打开一个输入流
  6. 将第一个标记读取为整数
  7. 确认该值为 `54021```cpp ofstream fout { “test.out” }; if (!fout) { println(cerr, “Error opening test.out for writing.”); return 1; }

// 1. 输出字符串 “54321”。 fout << “54321”;

// 2. 验证标记位于位置 5。 streampos curPos { fout.tellp() }; if (curPos == 5) { println(“Test passed: Currently at position 5.”); } else { println(“Test failed: Not at position 5!”); }

// 3. 移动到输出流的位置 2。 fout.seekp(2, ios_base::beg);

// 4. 在位置 2 输出一个 0 并关闭输出流。 fout << 0; fout.close();

// 5. 在 test.out 上打开一个输入流。 ifstream fin { “test.out” }; if (!fin) { println(cerr, “Error opening test.out for reading.”); return 1; }

// 6. 将第一个标记读取为整数。 int testVal; fin >> testVal; if (!fin) { println(cerr, “Error reading from file.”); return 1; }

// 7. 确认值为 54021。 const int expected { 54021 }; if (testVal == expected) { println(“Test passed: Value is {}.”, expected); } else { println(“Test failed: Value is not {} (it was {}).”, expected, testVal); }

### 将流链接在一起
你可以在任意输入流和输出流之间建立一种链接,使其具有*访问时刷新*(flush-on-access)行为。换句话说,当从某个输入流请求数据时,与它链接的输出流会自动刷新。所有流都支持这种行为,但对于可能彼此依赖的文件流来说尤其有用。
流的链接通过 `tie()` 成员函数完成。若要把一个输出流链接到输入流,请在输入流上调用 `tie()`,并传入输出流的地址。若要断开链接,则传入 `nullptr`。
下面的程序将一个文件的输入流绑定到另一个完全不同文件的输出流上。你当然也可以把它绑定到同一文件上的输出流,不过使用双向 I/O(下一节会介绍)也许是同时读写同一文件的一种更优雅方式。
```cpp
ifstream inFile { "input.txt" }; // 注意:input.txt 必须存在。
ofstream outFile { "output.txt" };
// 在 inFile 和 outFile 之间建立链接。
inFile.tie(&outFile);
// 向 outFile 输出一些文本。通常,这
// 不会刷新,因为没有发送 std::endl。
outFile << "Hello there!";
// outFile 尚未刷新。
// 从 inFile 读取一些文本。这将触发 outFile 上的 flush()。
string nextToken;
inFile >> nextToken;
// outFile 已刷新。
```flush()` 成员函数定义在 `ostream` 基类上,因此你也可以把一个输出流链接到另一个输出流。例子如下:
```cpp
outFile.tie(&anotherOutputFile);

这种关系意味着,每次你向一个文件写入时,发送到另一个文件的缓冲数据都会被刷新。你可以用这种机制让两个相关文件保持同步。

这种流链接的一个例子,就是 coutcin 之间的链接。每当你尝试从 cin 输入数据时,cout 都会被自动刷新。cerrcout 之间也存在链接,这意味着任何输出到 cerr 的操作都会导致 cout 刷新。而 clog 则不会链接到 cout。这些流的宽字符版本也有类似链接。

你可以通过把 \0 指定为分隔符,使用 getline() 读取整个文件的内容。只要文件内容中本身不包含任何 \0 字符,这种方法就有效。例如:

ifstream inputFile { "some_data.txt" };
if (inputFile.fail()) {
println(cerr, "Unable to open file for reading.");
return 1;
}
string fileContents;
getline(inputFile, fileContents, '\0');
println("\"{}\"", fileContents);

到目前为止,本章一直将输入流和输出流当作两个彼此关联但相互独立的类来讨论。不过,确实存在一种同时执行输入和输出的流:双向流(bidirectional stream)。

双向流派生自 iostream,而 iostream 又同时派生自 istreamostream,因此它也是一个有用的多重继承例子。正如你所预料的那样,双向流既支持 >> 运算符,也支持 << 运算符,同时还支持输入流和输出流的成员函数。

fstream 类提供双向文件流。fstream 非常适合那些需要在文件内部替换数据的应用程序,因为你可以一直读,直到找到正确位置,然后立刻切换到写pre。例如,想象一个程序,它存储的是 ID 号码与电话号码之间的映射列表。它可能使用如下格式的数据文件:

123 408-555-0394
124 415-555-3422
263 585-555-3490
100 650-555-3434

对这种程序而言,一种合理的思路是:程序打开时把整个数据文件读入内存,程序关闭时再把修改后的内容整体重写回文件。不过,如果数据集很大,你可能无法把所有内容都保存在内存里。使用 iostream 就不需要这样做。你可以很容易地扫描文件来查找一条记录,也可以通过以追加模式打开文件来新增记录pre。若要修改已有记录,你可以像下面这样使用双向流,以下函数会修改给定 ID 对应的电话号码:

bool changeNumberForID(const string& filename, int id, string_view newNumber)
{
fstream ioData { filename };
if (!ioData) {
println(cerr, "Error while opening file {}.", filename);
return false;
}
// 循环直到文件结束。
while (ioData) {
// 读取下一个 ID。
int idRead;
ioData >> idRead;
if (!ioData) { break; }
// 检查当前记录是否是要修改的那条。
if (idRead == id) {
// 将写位置寻址到当前读位置。
ioData.seekp(ioData.tellg());
// 输出一个空格,然后是新号码。
ioData << " " << newNumber;
break;
}
// 读取当前号码以推进流。
string number;
ioData >> number;
}
return true;
}

当然,这种做法只有在数据大小固定时才能正确工作。前面的程序从读取切换到写入时,输出数据会覆盖文件中的其他数据。为了保持文件格式不被破坏,并避免写入覆盖到下一条记录,数据必须恰好具有相同长度。

字符串流也可以通过 stringstream 类以双向方式访问。

C++ 标准库包含了一个文件系统支持库,它定义在 <filesystem> 中,位于 std::filesystem 命名空间下。它允许你编写可移植的文件系统操作代码。你可以用它来查询某个对象是目录还是文件、遍历目录内容、操作路径,以及获取文件信息,例如大小、扩展名、创建时间等等。该库最重要的两部分——路径(path)和目录项(directory entry)——将在接下来的小节中介绍。

这个库的基础组成部分是 path。一个 path 可以是绝对路径,也可以是相对路径,并且还可以选择性地包含文件名。例如,下面的代码定义了几个 path。请注意这里使用了 第 2 章 中介绍过的原始字符串字面量,以避免转义反斜杠:

path p1 { R"(D:\Foo\Bar)" };
path p2 { "D:/Foo/Bar" };
path p3 { "D:/Foo/Bar/MyFile.txt" };
path p4 { R"(..\SomeFolder)" };
path p5 { "/usr/lib/X11" };

调用 string() 可以把一个 path 转换成当前系统上的本机格式字符串。例如:

println("{}", p1.string());
println("{}", p2.string());

在同时支持正斜杠和反斜杠的 Windows 上,输出如下:

D:\Foo\Bar
D:/Foo/Bar

你可以使用 append() 成员函数或者 operator/= 把一个组成部分追加到 path 后面。平台相关的路径分隔符会被自动插入。示例如下:

path p { "D:\\Foo" };
p.append("Bar");
p /= "Bar";
println("{}", p.string());

在 Windows 上,输出是 D:\Foo\Bar\Bar

你也可以使用 concat()operator+= 把一个字符串拼接到现有 path 后面。注意,这种方式不会插入任何路径分隔符!示例如下:

path p { "D:\\Foo" };
p.concat("Bar");
p += "Bar";
println("{}", p.string());

现在在 Windows 上的输出则是 D:\FooBarBar

append()operator/= 会自动插入平台相关的路径分隔符,而 concat()operator+= 不会。

你可以使用基于范围的 for 循环来遍历 path 的各个组成部分。如下所示:

path p { R"(C:\Foo\Bar)" };
for (const auto& component : p) {
println("{}", component.string());
}

在 Windows 上,输出如下:

C:
\
Foo
Bar

path 接口支持诸如 remove_filename()replace_filename()replace_extension()root_name()parent_path()extension()stem()filename()has_extension()is_absolute()is_relative() 等操作。下面的代码片段演示了其中几个:

path p { R"(C:\Foo\Bar\file.txt)" };
println("{}", p.root_name().string());
println("{}", p.filename().string());
println("{}", p.stem().string());
println("{}", p.extension().string());

这段代码在 Windows 上会产生如下结果:

C:
file.txt
file
.txt

有关所有可用功能的完整列表,请查阅你喜欢的标准库参考资料。

path 只是表示文件系统中的某个目录或文件。一个 path 可能指向一个并不存在的目录或文件。如果你想查询文件系统中真实存在的目录或文件,你需要根据 path 构造一个 directory_entrydirectory_entry 接口支持诸如 exists()is_directory()is_regular_file()file_size()last_write_time() 等操作。

下面的例子会根据 path 构造一个 directory_entry,并查询某个文件的大小:

path myPath { "c:/windows/win.ini" };
directory_entry dirEntry { myPath };
if (dirEntry.exists() && dirEntry.is_regular_file()) {
println("File size: {}", dirEntry.file_size());
}

文件系统库同样提供了一整套辅助函数。例如,你可以使用 copy() 复制文件或目录,使用 create_directory() 在文件系统中创建新目录,使用 exists() 查询给定目录或文件是否存在,使用 file_size() 获取文件大小,使用 last_write_time() 获取文件最后修改时间,使用 remove() 删除文件,使用 temp_directory_path() 获取适合存放临时文件的目录,使用 space() 查询文件系统中的可用空间,等等。完整列表请参考标准库参考资料(参见 附录 B“带注释的参考书目”)。

下面的例子会打印某个文件系统的容量以及剩余空闲空间:

space_info s { space("c:\\") };
println("Capacity: {}", s.capacity);
println("Free: {}", s.free);

你还可以在下一节关于目录遍历的内容中看到更多这些辅助函数的例子。

如果你希望递归遍历给定目录中的所有文件和子目录,可以使用 recursive_directory_iterator。为了开始遍历,你需要一个指向第一个 directory_entry 的迭代器。为了知道何时结束遍历,你还需要一个结束迭代器。要创建起始迭代器,只需构造一个 recursive_directory_iterator,并将想要遍历的目录的 path 作为参数传入。要构造结束迭代器,则默认构造一个 recursive_directory_iterator 即可。若要访问某个迭代器所指向的 directory_entry,请使用解引用运算符 *。遍历整个集合的方法很简单:只需不断使用 ++ 运算符递增迭代器,直到它到达结束迭代器。请注意,结束迭代器本身并不再属于集合的一部分,因此它不指向有效的 directory_entry,绝不能对其解引用。

void printDirectoryStructure(const path& p)
{
if (!exists(p)) { return; }
recursive_directory_iterator begin { p };
recursive_directory_iterator end { };
for (auto iter { begin }; iter != end; ++iter) {
const string spacer(iter.depth() * 2, ' ');
auto& entry { *iter }; // 解引用 iter 以访问 directory_entry。
if (is_regular_file(entry)) {
println("{}File: {} ({} bytes)",
spacer, entry.path().string(), file_size(entry));
} else if (is_directory(entry)) {
println("{}Dir: {}", spacer, entry.path().string());
}
}
}

这个函数可以这样调用:

path p { R"(D:\Foo\Bar)" };
printDirectoryStructure(p);

你也可以使用 directory_iterator 来遍历某个目录的内容,并自己实现递归。下面这个例子与前一个例子完成同样的事情,只是它使用的是 directory_iterator,而不是 recursive_directory_iterator:

void printDirectoryStructure(const path& p, unsigned level = 0)
{
if (!exists(p)) { return; }
const string spacer(level * 2, ' ');
if (is_regular_file(p)) {
println("{}File: {} ({} bytes)", spacer, p.string(), file_size(p));
} else if (is_directory(p)) {
println("{}Dir: {}", spacer, p.string());
for (auto& entry : directory_iterator { p }) {
printDirectoryStructure(entry, level + 1);
}
}
}

流提供了一种灵活而面向对象的输入输出方式。本章中最重要的信息,甚至比“如何使用流”本身更重要的,是这一概念。有些操作系统可能拥有自己的文件访问与 I/O 设施,但想要处理任何现代 I/O 系统,理解流以及类流库的工作方式都是必不可少的。

本章最后介绍了文件系统支持库,你可以利用它以平台无关的方式处理文件和目录。

通过完成下面这些练习,你可以练习本章讨论过的内容。所有练习的解答都包含在本书网站 www.wiley.com/go/proc++6e 上的代码下载包中。不过,如果你在某道练习上卡住了,请先回头重读本章的相关部分,尽量自己找到答案,然后再去查看网站上的解答。

  1. 练习 13-1: 让我们重新回到前几章练习中开发过的 Person 类。请使用你在练习 9-2 中的实现,并添加一个 output() 成员函数,把一个人的详细信息写到标准输出控制台上。
  2. 练习 13-2: 上一个练习中的 output() 成员函数总是把一个人的详细信息写到标准输出控制台。请修改这个 output() 成员函数,让它接受一个输出流作为参数,并把这个人的详细信息写到该流中。在 main() 中测试你的新实现,分别把一个人输出到标准输出控制台、字符串流和文件中。注意观察:通过流,只用一个成员函数就可以把一个人输出到各种不同的目标(输出控制台、字符串流、文件等等)。
  3. 练习 13-3: 开发一个名为 Database 的类,用 std::vector 存储若干 Person(来自练习 13-2)。提供一个 add() 成员函数,将一个人添加到数据库中。再提供一个 save() 成员函数,接受一个文件名参数,并将数据库中的所有人保存到该文件中。文件中原有内容应全部移除。再添加一个 load() 成员函数,接受一个文件名参数,让数据库从该文件中加载所有人。提供一个 clear() 成员函数,移除数据库中的所有人。最后,再添加一个 outputAll() 成员函数,对所有 Person 调用 output()。确保你的实现即使面对名字或姓氏中带空格的 Person,也能正常工作。
  4. 练习 13-4: 练习 13-3 中的 Database 会把所有人都存储在同一个文件里。为了练习文件系统支持库,让我们把它改成每个人单独存储在自己的文件中。修改 save()load() 成员函数,让它们接受一个目录作为参数,文件将被保存到该目录中或从该目录中加载。save() 成员函数会把数据库中的每个人保存到各自独立的文件中。每个文件名由该人的名字、一个下划线、再加上姓氏组成。文件扩展名应为 .person。如果文件已存在,则覆盖它。load() 成员函数则遍历给定目录中的所有 .person 文件,并将它们全部加载进来。