4.2 救助输入输出流

这些问题清楚地表明I/O是标准C++类库最重要的内容之一。由于“hello, world”几乎是每个程序员学习一门新语言时所编写的第1个程序,并且实际上每个程序都会用到I/O,所以C++中的I/O类库必须非常易于使用。更大的挑战在于I/O类库必须适用于任何新的类。如此一来,这个基础类库在设计时就需要一些技巧。除了能够学习到处理I/O和格式化操作用到的多种方法并提高其使用的准确性外之外,在本章中读者还会看到一个真正功能强大的C++类库是如何工作的。

4.2.1 插入符和提取符

流是一个传送和格式化固定宽度(fixed width)字符的对象。读者可以获得一个输入流(通过istream类的子类)、一个输出流(使用ostream对象)或者同时实现两种功能的流(使用从iostream派生的对象)。输入输出流类库提供了下面几种不同的类:用于文件输入输出的ifstream、ofstream和fstream,用于标准C++中string类输入输出的istringstream、ostringstream和stringstream。所有的这些流类拥有几乎相同的接口,所以能够以统一的方式使用这些流类,不管操作对象是文件、标准I/O、内存区,还是string对象。这样单一的接口同样支持扩充和增加一些新定义的类。某些函数实现格式化命令,而某些函数以非格式化方式读写字符。

前面提到的流类实际上是模板的特化(template specialization),就像标准string类是basic_string模板的特化[1]。下图描述了输入输出流类继承体系中的基本类:

4.2 救助输入输出流 - 图1

类ios_base声明了所有流类共有的内容,不依赖于流所处理的字符类型。这些声明大部分是常量以及处理这些常量的函数,它们会在本章反复出现。其他类是以基础字符类型为参数的模板。例如类istream,定义如下:

4.2 救助输入输出流 - 图2

定义本章前面提及的类时,都使用了相似的类型定义(type definition)。另外,C++中也用wchar_t(第3章中介绍的宽字符类型)来替换char定义了所有的输入输出流类。这在本章末尾可以看到。模板basic_ios定义了输入和输出通用的函数,但是这依赖于基础字符类型(几乎不使用它们)。模板basic_istream定义了一般的输入函数,basic_ostream定义了一般的输出函数。后面介绍的文件流类和字符串流类增加了特殊的流处理功能。

在输入输出流类库中,重载了两种运算符以简化输入输出流的使用。运算符<<常用作输入输出流的插入符(inserter),运算符>>常用作提取符(extractor)。

提取符按照目标对象的类型解析输入信息。举例说明,可以使用cin对象,它是输入流,相当于C中的stdin,即可重定向标准输入(redirectable standard input)。在代码中包含头文件<iostream>时,就会预定义这个对象。

4.2 救助输入输出流 - 图3

所有的内置数据类型都重载了operator>>。读者自己也可以重载operator>>,这将在后面看到。

为了显示不同变量中的内容,读者可以与插入符<<一起使用cout对象(相当于标准输出(standard output);同样地,cerr对象相当于标准错误输出(standard error)):

4.2 救助输入输出流 - 图4

尽管增强了类型检查功能,但是这样写出来的代码很乏味,而且似乎比用printf()写出来的代码没有多大的提高。幸运的是,重载的插入符和提取符可以连续使用,构成复杂的表达式,使得写(和读)更容易:

4.2 救助输入输出流 - 图5

为自己的类定义插入符和提取符,就是重载相关的运算符以完成正确的操作,即:

·第1个参数定义成流(输入为istream,输出为ostream)的非const引用。

·执行向/从流中插入/提取数据的操作(通过处理对象的组成元素)。

·返回流的引用。

输入输出流应该是非常量,因为处理流数据将改变流的状态。通过返回流,如前所述,可以将这些流操作链接成单一的语句。

举个例子,考虑如何输出一个MM-DD-YYYY格式的Date类对象。下面的代码使用了插入符:

4.2 救助输入输出流 - 图6

这个函数不能设为Date类的成员函数,因为运算符<<左边的操作数必须是输出流。ostream的成员函数fill()用于更换填充字符(padding character),当输出域(field)的宽度大于输出数据长度时,使用填充字符填充超出部分,域宽由操纵算子(manipulator)setw()决定。使用“0”作为前导填充字符,所以显示10月之前的月份时,如显示9月份为“09”。函数fill()返回原有的填充字符(默认为一个空格符),以便在后面使用操纵算子setfill()恢复这个填充字符。本章后面将深入讨论操纵算子。

使用提取符需要注意输入数据错误。通过设置流的失败标志位(fail bit)可以表明产生了流错误,如下所示:

4.2 救助输入输出流 - 图7

一旦流的失败标志位被设置,则在流恢复到有效状态之前,此外所有的流操作都会被忽略(简要说明一下)。这就是为什么即使设置了ios:failbit,上述代码也继续进行提取操作。这种实现有些宽松,因为它允许在日期字符串的数字和短线之间插入空格(因为在默认情况下>>运算符在读取内置数据类型时会跳过空格)。对提取符来说,下面是合法的日期字符串:

4.2 救助输入输出流 - 图8

下面是非法的日期字符串:

4.2 救助输入输出流 - 图9

将会在4.3节处理流错误部分深入讨论流状态。

4.2.2 通常用法

正如Date提取符所展示的,必须防止错误的输入。如果输入一个非法的值,处理过程就会出现错误,并且很难恢复。另外,默认情况下的格式化输入使用空格作为界定符。把前面的代码片断合成一个单独的程序,看看会发生什么情况:

4.2 救助输入输出流 - 图10

给出如下输入:

4.2 救助输入输出流 - 图11

预期的输出:

4.2 救助输入输出流 - 图12

但是输出和预期的有些不同:

4.2 救助输入输出流 - 图13

注意,buf仅得到了第1个单词,因为输入机制是通过寻找空格来分割输入的,而空格出现在"this"的后面。另外,如果连续输入的字符串长度超过buf的存储空间,就会发生buf溢出现象。

实际上在交互程序中,经常需要一次输入一行字符序列,当这些字符安全地存储到缓冲区后再进行扫描和转换工作。使用这种方法,读者不必担心输入程序的执行因非法数据的出现而阻塞。

另一个需要考虑的内容是在命令行界面的输入输出。这仅在过去控制台比一台打字机强不了多少的时候才有意义,但是现在图形用户界面(graphical user interface, GUI)迅速占据了统治地位。在这样的环境下使用控制台I/O是否还有意义呢?除了很简单的例子或测试外,可以完全忽略cin并采用下面的方法:

1)如果程序需要输入,则从文件中读入数据—读者很快就会看到通过输入输出流来使用文件非常容易。文件输入输出流在图形用户界面下也能很好地工作。

2)正如我们建议的那样,读取输入但不试图对其进行转换。当输入数据被保存到某处,并且在转换时不会造成错误时,才可以安全地扫描它。

3)输出的情况有所不同。如果使用图形用户界面,就不需要用cout,必须把数据输出到文件(和输出到cout是一样的),或者使用图形用户界面应用程序实现数据显示。否则,把数据输出到cout便很有意义。在这两种情况下,输入输出流的输出格式化功能十分有用。

在大型项目中,另一个常用的方法可以节省编译时间。例如,看看本章前面是如何在头文件中声明Date流操作符的。仅需要包含函数的原型,不需要在Date.h中包含整个<iostream>头文件。标准的方法是像下面这样仅声明类:

4.2 救助输入输出流 - 图14

这是一种将接口从实现中分离的早就在频繁使用的技巧,称作前置声明((forward declaration),在其出现的位置上,ostream应当被视为未完成的类型,因为这时编译器还没有看到类的定义)。

然而,这样的声明不能正常工作,有两个原因:

1)流类是在名字空间std中定义的。

2)这些流类是模板。

正确的声明应该是:

4.2 救助输入输出流 - 图15

(正如读者所看到的,就像string类,流类使用了第3章中提到的字符特征类。)由于为所有需要引用的流类编写代码是十分枯燥乏味的,C++标准提供了头文件<iosfwd>来完成这些工作。Date头文件如下所示:

4.2 救助输入输出流 - 图16

4.2 救助输入输出流 - 图17

4.2.3 按行输入

有3种可选的方法来实现按行输入:

·成员函数get()

·成员函数getline()

·定义在头文件<string>中的全局函数getline()

前两个函数有3个参数:

1)指向字符缓冲区的指针,用于保存结果。

2)缓冲区的大小(为了保证缓冲区不会溢出)。

3)结束字符,根据结束字符判断何时停止读入操作。

结束字符(terminating character)默认情况下为'\n',这是常用的结束字符。当在输入过程中遇到结束字符时,这两个函数都会在结果缓冲区末尾存储一个零。

那么,get()和getline()两个函数有什么不同呢?细微而重要的区别在于:当遇到输入流中的界定符(delimiter,即结束字符)时get()停止执行,但是并不从输入流中提取界定符。这时,如果再次调用get()会遇到同一个界定符,函数将立即返回而不会提取输入。(为了解决这个问题,据推测,需要在下一个get()函数中使用不同的界定符或使用不同的输入函数。)函数getline()则相反,它将从输入流中提取界定符,但仍然不会把它存储到结果缓冲区中。

<string>中定义的函数getline()使用起来很方便。它不是成员函数,而是在名字空间std中声明的独立函数。这个函数仅有两个非默认参数,输入流和string对象。从函数名可以看出,它从输入流中读取字符直到遇到第1个界定符(默认为'\n')并且丢弃这个界定符。这个函数的优点在于它把数据读入一个string对象中,所以不用担心缓冲区的大小。

一般来说,当采用按行输入的方式处理文本文件时,需要使用其中的一个getline()函数。

1.get()函数的重载版本

函数get()也有另外3个重载版本:其中一个版本没有参数,使用int作为返回值类型,返回下一个字符;另一个版本使用char类型的引用作为参数,函数从流中读取一个字符放到这个参数中;还有一个版本则把流类对象直接存储到基础的缓冲区结构。本章后面将对最后一个版本进行深入研究。

2.读原始字节

如果准确知道正在处理的数据并需要把字节直接移动到内存中一个变量、数组或结构中,可以使用非格式化的I/O函数read()。这个函数的第1个参数是指向目标内存的指针,第2个参数是需要读入的字节数。如果预先把信息保存在文件中,例如使用输出流的write()成员函数将信息保存为二进制形式(当然,使用相同的编译器),那么这个函数就十分有用。在后面读者会看到这些函数的例子。

[1]在第5章中深入讨论。