首页 09-C++标准库之IO库
文章
取消

09-C++标准库之IO库

C++语言不直接处理输入输出,而是通过一族定义在标准库中的类型来处理IO.这些类型支持从设备读取数据、向设备写入数据的IO操作,设备可以是文件、控制台窗口等。还有一些类型允许内存IO,即,从string读取数据,向string写入数据。

IO库定义了读写内置类型值的操作。此外,一些类,如string, 通常也会定义类似的IO操作,来读写自己的对象。

我们曾经用过的很多IO库设施,列举一下大部分用过的IO库设施:

  • istream(输入流)、ostream(输出流)类型,提供输入输出操作。
  • cin、cout,分别对应istreamostream对象,从标准输入读取数据,向标准输出写入数据。
  • cerr,一个ostream对象,通常用于输出程序错误消息,写入到标准错误。
  • >>运算符,用来从一个istream对象读取输入数据;<<运算符,用来向一个ostream对象写入输出数据。
  • getline函数,从一个给定的istream读取一行数据,存入一个给定的string对象中。

IO类

到目前为止,我们已经使用过的IO类型和对象都是操纵char数据的。默认情况下,这些对象都是关联到用户的控制台窗口,同时,使用IO操作处理string中的字符会很方便。此外,应用程序还可能读写需要宽字符支持的语言。(什么是宽字符?比如中文)

概念上,设备类型和字符大小都不会影响我们要执行的IO操作。例如,我们可以用>>读取数据,而不需要管是从一个控制台窗口,一个磁盘文件,还是一个string读取。

标准库使我们能忽略这些不同类型的流之间的差异,这部分是通过继承机制(inheritance)实现的。

利用模板,我们可以使用具有继承关系的类,而不必了解继承机制如何工作的细节。

如果涉及到写入读取的各种细节机制,比如iostream、fstream、sstream等头文件所对应的各种应用场景,那将是非常繁杂的处理机制。

下面将针对IO对象进行细节描述:

  • IO对象无拷贝或赋值

    1
    2
    3
    4
    5
    6
    
      ofstream out1, out2;
      out1 = out2;                // 错误:不能对流对象赋值
    
      // 下面的print函数估计都没有传引用参,这份笔记不是那么完整
      ofstream print(ofstream);   // 错误:不能初始化ofstream参数,因为不能赋值
      out2 = print(out2);         // 错误:不能拷贝流对象
    
  • 不能将形参或者返回类型设置为流类型,因为无法对其进行拷贝,进行IO操作的函数通常以引用方式传递和返回流;

  • 读写一个IO对象会改变其状态,因此传递和返回的引用不能是const

流(stream)的条件状态(condition state)

IO操作可能发生错误,有些是可恢复的,有些则是发生于操作系统内部,已经超出了应用程序可修正的范围。

书本中有一系列关于IO库条件状态的细节展示,在这里暂且不表。举一个简单的例子:
1
2
3
int ival;
cin >> ival;    // 如果键入的是char类型或者string,读操作就会失败
// cin将进入错误状态,如果输入的是一个文件结束标识,cin同样进入错误状态。

一个流一旦发生错误,其后续的IO操作都会失败,只有一个流处于无错状态时,我们才可以从它读取数据,向它写入数据。由于流可能处于错误状态,因此代码通常应该在使用一个流之前检查它是否处于良好状态。

1
while (cin >> word) // 正常则....

查询流的状态

将流作为条件使用,可以让我们知晓流是否有效,而无法告诉我们具体发生了什么,为了更进一步了解到流失败的具体原因,IO库定义了一个与机器无关的iostate类型,它提供了表达流状态的完整功能。IO库定义了4个iostate类型的constexpr值,表示特定的位模式。这些值用来表示特定类型的IO条件,可以与位运算符一起使用来一次性检测或设置多个标志位。

  • badbit表示系统级错误,如不可恢复的读写错误,一旦发生将被置位。
  • failbit表示可恢复错误,一旦发生将被置位,如期望读取数值却读出一个字符。
  • eofbit表示读取到了文件结束符位置,一旦到达,eofbitfailbit都会被置位。
  • goodbit的值为0,表示流未发生错误,前三者任何一个被置位,则检测流状态的条件会失败。

标准库还定义了一组函数来查询这些标志位的状态。操作good应该是一个成员函数在所有错误位均未置位的情况下返回true,而bad、fail、eof则在对应错误位被置位时返回true。

  • 因为各自所表示的都是标志位,错误标志位返回信息意味着出现了对应的错误;

此外,badbit被置位时fail也会返回true。因此使用good或fail是确定流的总体状态的正确方法。而eofbad操作只能表示特定的错误。

管理条件状态

主要是以下几个方面:

  • 流对象的rdstate成员返回一个iostate值,对应流的当前状态。
  • setstate操作将给定条件位置位,表示发生了对应错误。
  • clear成员是一个重载的成员:分为不接受参数的版本以及接受一个iostate类型的参数。

    • 不接受参数的版本清除(复位)所有错误标志位,调用good后会返回true

      1
      2
      3
      4
      
      auto old_state = cin.rdstate();	// 记住cin的当前状态
      cin.clear();			// 清除所有错误标志位
      process_input(cin);		// 使用一次cin
      cin.setstate(old_state);	// 将cin置为原有状态
      
    • 带参数的版本接受一个iostate值,表示流的新状态。一般是先用rdstate读出当前的条件状态,然后用位操作将所需位复位来生成新的状态。

      1
      2
      3
      4
      5
      6
      
      // 先前的理解一直有误,在此重新更新
      // failbit是一个固定常数,以8位二进制为例,他代表的是00000001,这个二进制对应了failbit
      // 对他取反,再做与运算,意味着将对应位(也就是最后一位)进行复位,该位重新变为0
      // badbit同样是一个固定数,它代表的是00000100,取反再做与运算,意味着倒数第三位的会变成0
      // 就是这么简单,之前想复杂了
      cin.clear(cin.rdstate() & ~cin.failbit & ~cin.badbit);
      

      怎么理解某个位被置位呢,以badbit为例,出现了系统级错误,那么位状态的第三个位置就被置为1,则如果我们想要将该位进行复位,就需要通过上述的位操作运算来进行处置。为了保证所有的处置可以正常进行,IO库设置了一些特定的枚举状态,比如说badbit对应的枚举状态就是00000100,也就是我们所说的4。

管理输出缓冲

每个输出流都管理一个缓冲区,用来保存程序读写的数据,通过缓冲机制,操作系统可以将程序的多个输出操作组合成单一的系统级写操作,从而带来性能提升。导致缓冲刷新的原因有很多,详细情形以下将做出一个大体的介绍:

  • 刷新输出缓冲区

    endl操纵符完成换行并刷新缓冲区的工作,类似的操纵符:flushends

    flush刷新缓冲区,但不输出任何额外的字符;

    ends向缓冲区插入一个空字符,然后刷新。

    1
    2
    3
    
    cout << "hi!" << endl;	//刷新,换行
    cout << "hi!" << flush;	//刷新,不附加任何额外字符
    cout << "hi!" << ends;	//刷新,输出一个空字符
    
  • unitbuf操纵符

    unitbuf操纵符可以在每次输出操作后都刷新缓冲区,它告诉流在接下来的每次写操作之后都进行一次flush操作。

    nounitbuf则重置流,将使用正常的系统管理的刷新机制。

    Notes: 如果程序崩溃,输出缓冲区将不会被刷新,也就是说,它所输出的数据很可能停留在输出缓冲区中等待打印,这一点在调试的过程中尤为注意。

  • 关联输入和输出流

    当一个输入流被关联到一个输出流时,任何试图从输入流读取数据的操作都会先刷新关联的输出流。

    标准库将cout和cin相互关联,cin操作会导致cout缓冲区被刷新,反之类似。关联操作通过tie函数实现,它有两个重载的版本:

    • 不带参数的版本返回指向输出流的指针,如果本对象当前关联到一个输出流,则返回的就是指向这个流的指针,若未关联到流则返回空指针。
    • 带参数的版本接受一个指向ostream的指针,将自己关联到此ostream.

      1
      2
      3
      4
      
      cin.tie(&cout);     // 仅仅用来展示,将cout与cin关联
      ostream *old_tie = cin.tie(nullptr);    // 不关联或者说取消关联
      cin.tie(&cerr);     // 将cin与cerr关联,不是个好主意
      cin.tie(old_tie);   // 重建cin和ostream对象(cout)之间的正常关联
      

文件输入输出

头文件fstream(文件流)定义了三个类型来支持文件IO:

  • ifstream(读取文件流)从一个给定文件读取数据;
  • ofstream(输出文件流)向一个给定文件写入数据;
  • fstream(文件流)则可以读写给定文件。

这些类型可以用IO运算符(<<>>)来读写文件,可以用getline从一个ifstream读取数据。除了继承自iostream类型的行为之外,fstream中定义的类型还增加了一些新的成员来管理与流相关的文件。

使用文件流对象

当我们想要读写一个文件时,可以定义一个文件流对象,并将对象与文件关联起来。每个文件流类都定义了一个名为open的成员函数,它完成一些系统相关的工作,来定位给定的文件,并视情况打开为读或写模式。

创建文件流对象时,我们可以提供文件名(可选)。如果提供了一个文件名,则open会自动被调用:

1
2
ifstream in(ifile); // 构造一个ifstream并打开给定文件,infile在这里一般是文件路径
ofstream out;       // 输出文件流未关联到任何文件,先定义了一个输出流

代码定义了一个输入流in,它被初始化为从文件读取数据,文件名由string类型的参数ifile指定。第二条语句定义了一个输出流out,未与任何文件关联。

在新C++标准中,文件名既可以是库类型string对象,也可以是C风格字符数组,旧版本的标准库只允许C风格字符数组。

fstream代替iostream&

在要求使用基类型对象的地方,我们可以用继承类型的对象来替代,接受一个iostream类型引用(或指针)参数的函数,可以用一个对应的fstream类型调用;如果接受一个ostream&参数,在调用这个函数时,可以传递给它一个ofstream对象。

这里涉及到的是对象的继承策略,iostream应该是IO对象的基类。

自动构造和析构

考虑一个程序,它的main函数接受一个要处理的文件列表:

1
2
3
4
5
6
7
8
9
// 第一个参数默认应该是程序名字
for (auto p = argv + 1; p != argv + argc; ++p)
{
    ifstream input(*p); // 创建输出流并打开文件,p为指针,代表文件地址,*p代表某文件
    if (input) {
        process(input);
    } else
        cerr << "couldn't open: " + string(*p);
}   // 每个循环步input都会离开作用域,因此会被销毁

因为inputwhile循环的局部变量,它在每个循环步中都要创建和销毁一次,当一个fstream对象被销毁时,close会自动被调用。

成员函数

  1. 成员函数openclose

    1
    2
    3
    4
    
     ifstream in(ifile);         // 构筑一个ifstream并打开给定文件
     ofstream out;               // 输出文件流未与任何文件相关联(也是定义了一个文件输出流对象)
     out.open(ifile + ".copy");  // 要通过这个输出对象,打开指定文件
     if(out)                     // 检测open是否成功,调用失败,failbit会被置位
    

    一旦一个文件流已经打开,他就保持与对应文件的关联对一个已经打开的文件流调用open会失败,并会导致fallbit被置位。随后的试图使用文件流的操作都会失败。

    为了将文件流关联到另外一个文件,首先关闭已经关联的文件。一旦文件成功关闭,我们可以打开新的文件:

    1
    2
    
     in.close();		// 关闭文件,这一步确实重要
     in.open(ifile + "2");	// 打开另一个文件,open成功,则good()为true.
    

文件模式

每个流都有一个关联的文件模式(file mode),用来指出如何使用文件。

文件模式
in:以读方式打开
out:以写方式打开
app:每次写操作前均定位到文件末尾
ate:打开文件后立即定位到文件末尾
trunc:截断文件
binary:以二进制方式进行IO

无论用哪种方式打开文件,指定文件模式都有限制:

  • 只可以对ofstreamfstream对象设定out模式(out理解为将缓冲区内的内容->文件,即写入模式)
  • 只可以对ifstreamfstream对象设定in模式(in理解为将文件的内容->缓冲区,即读取模式)
  • 只有当out也被设定时才可设定trunc模式(截断模式一般配合写方式)
  • 只要trunc没被设定,就可以设定app模式,app模式下,即使没有显式指定out模式,文件也总以输出方式被打开
  • 默认情况下,即便没有指定trunc,以out模式打开的文件也会被截断。为了保留以out模式打开的文件的内容,我们必须同时指定app模式,这样只会将数据追加写到文件末尾;或者同时指定in模式,打开文件的同时进行读写操作
  • atebinary模式可用于任何类型的文件流对象,且可以与其他任何文件模式组合使用。

每个文件流类型都定义了一个默认的文件模式,当我们未指定文件模式时,就使用此默认模式。

  • ifstream关联的文件默认以in模式打开;与ofstream关联的文件默认以out模式打开;与fstream关联的文件默认以in和out模式打开。

以out模式打开文件会丢弃已有数据

默认情况下,当我们打开一个ofstream时,文件的内容会被丢弃(写方式)。阻止一个ofstream清空给定文件内容,解决方案:

1
2
3
4
5
ofstream out("file1");  // 隐含以输出模式打开文件并截断文件
ofstream out2("file1", ofstream::out);	// 等价于上
ofstream out3("file1", ofstream::out | ofstream::trunc);    // 明摆着打开并截断文件
ofstream app("file2", ofstream::app);   // 隐含为追加写模式
ofstream app2("file2", ofstream::out | ofstream::app);  // 显式追加

在上述代码中,|符号所表示的含义

  • 理解为或者的关系即可;

每次调用open时都会确定文件模式

对于一个给定流,每当打开文件时,都可以改变其文件模式。

1
2
3
4
5
ofstream out;   // 未指定文件打开模式
out.open("scratchpad"); // 模式隐含设置为输出和截断
out.close();    // 关闭out,以便我们将其用于其他文件
out.open("precious", ofstream::app);    // 模式为输出和追加
out.close();

第一个open调用未显式指定输出模式,文件隐式地以out模式打开,也意味着同时使用了trunc模式;当打开precious文件时,我们指定了app模式。文件中已有的数据都得以保留,所有写操作都在文件末尾进行。

Notes:在每次打开文件时,都要设置文件模式,这一点是较重要的步骤。

string流

sstream头文件定义了三个类型来支持内存IO,这些类型可以向string写入、读取数据,类似一个IO流。

  • istringstream类型:从string读取数据;
  • ostringstream类型:从string写入数据;
  • stringstream类型:既可以从string读数据也可向string写数据,类似fstream的功能;

fstream类型类似,头文件sstream中定义的类型都继承自我们已经使用过的iostream头文件中定义的类型。

除了继承得来的操作,还增加了一些成员来管理与流相关联的stringstringstream有一些特有的操作!

使用istringstream

当我们的某些工作是对整行文本进行处理,而其他的一些工作是处理行内的单个单词时,通常考虑使用istringstream

1
2
3
4
5
# 比如我们考虑这么一个场景:
# 有一份文件,列出了一些人和他们的电话号码。某些人只有一个号码,另一些人有多个,那么输入文件可能是:
# morgan 2015552368 8625550123
# drew 9735550130
# lee 6095550132 2015550175 8005550000

我们需要针对上述人的号码进行针对性保存,因此考虑设计这么一个类:

1
2
3
4
struct PersonInfo {
    string name;  // 定义人名
    vector<string> phones;  // 定义号码数组,数组中每个元素都是号码
};

设计一个读取数据文件的程序,创建一个PersonInfovectorvector中每个元素对应文件中的一条记录。

1
2
3
4
5
6
7
8
9
10
string line, word;  // 分别保存来自输入的一行和号码(word代表号码)
vector<PersonInfo> people;  // 保存来自输入的所有记录
// 逐行从输入读取数据,直至cin遇到文件尾(或其他错误)
while (getline(cin, line)) {
    PersonInfo info;            // 创建一个对象保存读取的记录
    istringstream record(line); // 将此记录绑定到刚读入的行
    record >> info.name;        // 读取名字,我们可以理解为一个进度条,先读名字
    while (record >> word)	info.phones.push_back(word);  // 保存读取的号码
    people.push_back(info);     // 最后保存该记录的信息
}

上述内层的while循环在string中的数据全部读出后,即会触发文件结束信号,则record下一个操作就会失败。

istringstream对象并不从屏幕读入数据,而是从一个string对象中读取数据。

使用ostringstream

当我们逐步构造输出,希望最后一起打印时,ostringstream是比较有用的。接着上述案例,如果号码都是有效的,我们希望输出一个新的文件,包含改变格式后的号码。对于无效号码,我们将不会输出,而是打印一条包含人名和无效号码的错误信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for (const auto &entry : people) {    // 对people中的每一项
    ostringstream formatted, badNums; // 每个循环步创建的对象
    for (const auto &nums : entry.phones) { // 对每个号码
        if(!valid(nums)) {
            badNums << " " << nums; // 将数的字符串形式存入badNums(有点像cin的用法)
        } else
           	formatted << " " << format(nums);
    }
    if (badNums.str().empty())  // 如果所有号码中都没有错误
        os << entry.name << " " // 打印名字
        	<< formatted.str() << endl; // 和格式化的数
    else
        cerr << "input error: " << entry.name
        	<< " invalid number(s) " << badNums.str() << endl;
}

有两个假定的函数:validformat,完成号码验证以及改变格式的功能。

上述代码对字符串流formattedbadNums的使用比较有趣,使用标准输出运算符<<向这些对象写入数据,但写入操作实际上转换为string操作,分别向formattedbadNums中的string对象添加字符。

ostringstream对象并不直接输出到屏幕,而是将其内部的字符串缓存起来,可以通过str()方法获取这个字符串,再通过其他方式将其输出到屏幕或者其他地方。

总结:

  • 我们称istringstream为输入string流,就是读入string数据信息,读入的信息保存到istringstream对象中;
  • ostringstream为输出string流,就是将要输出的信息保存至ostringstream对象中,因此会有<<>>

IO库的几个要点:

  • iostream处理控制台IO;fstream处理文件内容IO;stringstream完成内存string的IO。
  • fstreamstringstream继承自类iostream;其中输入类继承自istream,输出类继承至ostream
  • 因此可以在istream对象上执行的操作,也可在ifstreamistringstream对象上执行;

有关IO流的一些使用说明,见Github源码

本文由作者按照 CC BY 4.0 进行授权

特别介绍-vector与string

10-C++标准库之顺序容器