IO流(C++)

109 阅读27分钟

文章目录

1. C语言的输入与输出

早在学习C语言的第一堂课,我们使用printf(“Hello World!”)开启认识世界的大门,即便那时我们只知道这是一个可以将双引号中的内容打印到屏幕上的东西。

C语言提供了跨平台的数据输入输出函数scanf()printf()函数,它们可以按照指定的格式来解析常见的数据类型,例如整数、浮点数、字符和字符串等等。数据输入的来源可以是文件、控制台以及网络,而输出的终端可以是控制台、文件甚至是网页。

scanf()函数主要用于读取数据(通常来源于文件或者是用户从键盘的输入),并且按照指定的格式精确匹配。printf()函数发送格式化输出到标准输出(屏幕)。

一般地,我们将计算机与外部设备之间进行数据交换的过程称作IO(输入/输出)。输入指的是从外部设备(如键盘、鼠标、硬盘等)读取数据到计算机内存中,而输出则指的是将计算机内存中的数据写入到外部设备(如显示器、打印机、硬盘等)。

1.1 缓冲区

C语言中,标准输入输出库函数(如scanf()printf())会自动使用缓冲区来提高输入输出的效率。当我们调用这些函数时,数据并不是立即从输入设备读取或者写入到输出设备,而是先存储在缓冲区中。当缓冲区被填满或者满足某些条件时(例如遇到换行符),才会进行实际的输入输出操作。

缓冲区的意义在于它能够提高输入输出的效率。**由于输入输出设备的速度通常远低于CPU和内存,如果每次读写都要直接与输入输出设备进行交互,那么程序的执行速度将会受到严重影响。**通过使用缓冲区,可以减少与输入输出设备交互的次数,从而提高程序的执行效率。

CPU的执行速度高出输入输出设备好几个数量级,而它们直接的速度差距会造成CPU会花很多时间在「等待」数据的传输。例如:当你使用键盘或鼠标这类低速设备时,它们是通过中断请求的方式进行I/O操作的。即当键盘上按下一个按键时,键盘会发出一个中断信号,中断信号经过中断控制器传到CPU,然后CPU根据不同的中断号执行不同的中断响应程序,然后进行相应的I/O操作,把按下的按键编码读到寄存器(或者鼠标的操作),最后放入内存中。

所以要用缓冲区,等积累到一定量的数据以后再一次性传输给CPU,以此减少IO的次数,提高效率。就像生活中的快递,没有快递公司会为几个人单独运送货物,为了成本肯定会装满卡车运输的。

请添加图片描述

使用缓冲区有两个好处:

  1. 减少实际的物理读写次数;
  2. 缓冲区在创建时就被分配内存,这块内存区域一直被重用,可以减少动态分配和释放内存的开销,更容易地实现可移植程序;
  3. 可以实现“行”读取,即按行读取文件内容。这种方式可以方便地处理文本文件中每一行的数据,而不需要考虑每一行数据长度不同等问题。

实际上,“行”的概念是由人主动赋予计算机的,机器本身没有“行”的概念,只要按照一定的规则解析缓冲区中的内容,边能得到一个“行”的内容。

2. 流(stream)

在计算机科学中,流(stream)是对一种有序连续且有方向性的数据的抽象描述。

I/O 发生在流中。流就是一串二进制编码。可以将流理解为一个系统与一个程序之间形成的一个通道,就像两地之间的河道一样。那么流本身就是一堆由01组成的二进制编码。

  • 输入:字节流是从设备(如键盘、磁盘驱动器、网络连接等)流向内存;
  • 输出:字节流是从内存流向设备。

我们都知道大小端机器,大端和小端就是字节序的两种形式,那么字节序列本质上就是一串二进制编码。

流的特点:

  • 具有方向性;
  • 有序且连续;
  • 输入流、输出流是相对的。

为了实现流,C++提供了I/O标准库,其中每个类都被称为流或流类。

C++ IO流

C++提供了流输入输出库<ios>,所有的类都继承自ios_base ,它是 C++ 标准程序库中的一个类,定义于 <ios> 头文件中。它封装了 C++ 标准中的流输入输出中不依赖于读写的数据的类型的基本信息,如格式化信息、异常状态、事件回调函数等。

其中,iosistreamostream的父类,其他类都是直接或间接派生自ios类。istreamostream 分别表示输入流和输出流,它们是 C++ iostream 库的基础。为了允许双向的输入/输出,由 istreamostream 派生出了 iostream 类 。

3. C++标准IO流

C++标准库提供了4个全局流对象,分别是:

  • cout:标准输出,数据从内存流向控制台(显示器)。
  • cin:标准输入,数据通过键盘输入到程序中。
  • cerr:标准错误的输出。
  • clog:日志的输出。

实际上用的最多的是cout,使用上无区别。

click on an element for detailed information

4个全局流对象和istreamostream之间的关系如上。

图片来源于:legacy.cplusplus.com/reference/i…

3.1 iostream

头文件<iostream>包含了4个全局流对象,在使用它们时,必须包含该头文件,例如:

#include <iostream>
using namespace std;

int main()
{
    std:: cout << "hello world" << std::endl;

    int a = 0;
    cin >> a;
    cout << a << endl;
    
    return 0;
}

使用上必须指定它们所属的命名空间std::,为了方便,常常使用using namespace std; 指明命名空间,后续再使用就不用再显式地说明了。

其中,流输入操作符>>和流提取操作符<<用于从输入流中读取数据和向输出流中写入数据。

例如,使用cin >> x;可以从标准输入流中读取一个值并将其存储在变量x中。使用cout << x;可以将变量x的值写入标准输出流。

这些操作符可以链接起来,以便一次性读取或写入多个值。例如,使用cin >> x >> y;可以从标准输入流中读取两个值并分别存储在变量x和y中。使用cout << x << " " << y;可以将变量x和y的值以及一个空格字符写入标准输出流。

endl是一个操作符,用于在输出流中插入一个换行符并刷新该流。例如,使用cout << "Hello" << endl;可以在标准输出流中输出字符串"Hello",然后换行并刷新该流。在日常使用中,常用来当做换行符使用。

3.2 cin和cout的配合

cin是标准输入流对象,它一般使用行缓冲机制。当输入接收到回车的时候就会进行数据的刷新。程序的输入都建有一个缓冲区,即输入缓冲区。一次输入过程是这样的,当一次键盘输入结束时会将输入的数据存入输入缓冲区,而cin函数直接从输入缓冲区中取数据。

在执行cout语句时,先把插入的数据顺序存放在输出缓冲区中,直到输出缓冲区满或遇到cout语句中的endl(或\n, ends, flush)为止,此时将缓冲区中已有的数据一起输出,并清空缓冲区。

以空格或回车为分隔输入输出

cincout利用缓冲区进行输入输出操作,我们用cin输入时如果按空格或回车分隔数据,那么cout也会按相同规则读取,例如:

int main()
{
    int a = 0, b = 0;
    cin >> a;
    cout << a << endl;
    
    cin >> b;
    cout << b << endl;
    return 0;
}

这段代码中,虽然形式上是分别输入和输出,但实际上如果一次性输入变量a和变量b的值,并以空格分隔,那么cout两次都会从同一个缓冲区按空格为分隔读取。

(一次性)输入:

1 2

输出:

1
2

但是因为这个规则,当使用cin输入的数据类型是字符或字符串时,其内容不能包含空格,回车符也无法读入。如果包含空格,用cout输出时只会输出空格之前的内容:

int main()
{
    string str;
    cin >> str;
    cout << str;
    return 0;
}

输入:

hello world

输出:

hello

但实际上字符串中经常会出现空格,需要使用getline()读取。

getline() 函数有两种形式,一种是头文件 <istream> 中输入流成员函数;一种在头文件 <string> 中普通函数。它用于从输入流中读取多行用户输入,直到找到分隔符(即'\n')为止。

int main()
{
    string str;
    getline(cin, str);
    cout << str;
    return 0;
}

输入:

hello world

输出:

hello world

getline() 函数有两个必需的参数和一个可选参数。第一个参数是输入流,它指定从哪个输入流中读取文本。第二个参数是字符串变量,它指定将读取的文本存储在哪个字符串变量中。

第三个可选参数是分隔符字符,它指定在遇到哪个字符时停止读取文本。如果未指定分隔符,则默认为换行符(\n)。

例如,在上面给出的 getline() 示例中,我们使用 std::cin 作为输入流,line 作为字符串变量,并使用默认的换行符作为分隔符。

补充:scanf的格式化输入并不需要显式地表示出空格,只要输入时以空格分隔,依然可以按顺序读取:

int main()
{
    int a = 0, b = 0;
    scanf("%d%d", &a, &b);

    cout << a << endl << b << endl;
    return 0;
}

输入:

1 2

输出:

1
2

重载流插入和流提取运算符

之所以流插入和流提取运算符能支持各种内置类型的变量输入和输出,是因为标准库中重载了所有内置类型的流插入和流提取运算符版本。

在这里插入图片描述
在这里插入图片描述

对于自定义类型,只要在类的内部重载了流插入和流提取运算符,就可以使用>><<进行输入和输出。

要想流插入 << 运算符和流提取 >> 运算符能针对自定义的对象,那么我们就需要重载针对该对象的 ostream 类的 << 运算符 和 istream 的 >> 运算符,并且只能重载成全局的函数。

可以在类内部直接重载输入、输出流运算符,但是在使用时只能通过 Obj>>cin。使用起来不像标准库那样直观。

对于重载流插入运算符和流提取运算符,它们必须声明为全局函数,因为这些运算符的左操作数是一个输入或输出流对象,而不是用户定义的类型的对象。如果将它们声明为成员函数,则左操作数将是用户定义的类型的对象,这不符合我们期望的行为。

插入运算符(<<)和流提取运算符(>>)通常被声明为友元函数,因为它们需要访问类的私有成员。例如,如果想要重载插入运算符以打印类的私有数据成员,则必须将其声明为友元函数,以便它可以访问这些私有数据成员。

友元机制允许一个类将对其非公有成员的访问权授予指定的函数或者类。友元函数是定义在类外部,但有权访问类的所有私有(private)成员和保护(protected)成员。尽管友元函数的原型有在类的定义中出现过,但是友元函数并不是成员函数。

这是一个简单的例子,演示如何重载插入运算符(<<)来打印类的私有数据成员。

#include <iostream>
using namespace std;

class Point
{
public:
    Point(int x = 0, int y = 0)
        : _x(x)
        , _y(y)
    {}
    friend ostream& operator<<(ostream& os, const Point& p);
private:
    int _x;
    int _y;
};

ostream& operator<<(ostream& os, const Point& p)
{
    os << "(" << p._x << ", " << p._y << ")";
    return os;
}

int main()
{
    Point p(1, 2);
    cout << p << endl;
}

输出:

(1, 2)

在上面的代码中,我们重载了插入运算符(<<),以便它可以打印类Point的私有数据成员x和y。为了实现这一点,我们将其声明为友元函数,以便它可以访问这些私有数据成员。

除此之外,规定格式中无空格,scanf也可以按规定长度读取。还能按string类型读取,然后用atoi手动分割为整型等。

scanf("%4d%2d%2d", &year, &month, &day);

C++只能用string读取,然后用string类型对象的substr接口分割,通过stoi转整型。

substr(pos, num);

多行测试用例

当我们刚开始做OJ题时,常常对多行测试用例的处理方式感到疑惑。

C语言的方式:

while (scanf("%d", &a) != EOF)
{
	//...
}

C++的方式:

while (cin >> a)
{
	//...
}

当我们不想让它继续读取时,Ctrl+C+回车,相当于读到结尾;而Ctrl+C是直接杀掉进程。

C语言的循环判断语句很好理解,它会不断读取输入直到遇到文件结束符EOFEOFEnd Of File 的缩写,表示文件结束符。它是一个特殊的字符,用于标识输入流的结束。在 C 语言中,EOF 被宏定义为一个整数常量,通常为 -1。当读取函数(如 scanfgetchar)遇到文件结束符时,它们会返回 EOF 来表示输入流已经结束。

cin和cout适用于自定义类型

如上面的例子,当使用 cin >> a 时,cin 对象会调用其成员函数 operator>> 来执行读取操作。这个函数会从输入流中读取数据,并将其存储在变量 a 中。如果读取成功,该函数会返回 cin 对象本身,否则返回一个处于错误状态的输入流对象。

当输入流对象被用作条件表达式时(如在 while 循环中),它会隐式调用成员函数 operator bool 来进行类型转换。这个函数会检查输入流的状态,如果处于正常状态,则返回 true;否则(如遇到文件结束符EOF或Ctrl+Z等)返回 false

因此,cin >> a 表达式的返回值是 bool 类型,因为它实际上返回的是 cin 对象本身。在 C++ 中,输入流对象(如 cin)可以被隐式转换为 bool 类型。当输入流处于正常状态时,转换结果为 true;当遇到错误(如文件结束符)时,转换结果为 false。因此,可以使用表达式 cin >> a 的返回值来判断输入是否成功。

那么像在 while 循环中使用>>时,对于自定义类型就必须支持隐式类型转换。原先括号()是强制类型转换的操作符,但是它被仿函数占用了,所以C++使用了operator bool()

C++98:operator void*() const;

C++11:explicit operator bool() const;

explicit 关键字用于防止隐式转换。当你在 operator bool() 前加上 explicit 关键字时,就意味着你不能隐式地将类的实例转换为 bool 类型。相反,你需要显式地进行转换,例如使用 static_cast<bool>(instance)

const 关键字用于指定成员函数不会修改类的实例。当你在成员函数的参数列表后面加上 const 关键字时,就意味着该成员函数不能修改类的实例。这样可以保证在调用该成员函数时不会意外地修改类的实例。

在C++中,可以通过定义类型转换运算符来实现自定义类型到内置类型的转换。这种转换可以是显式的,也可以是隐式的,具体取决于如何定义类型转换运算符。如果使用关键字 explicit 来定义类型转换运算符,则该转换为显式转换;否则为隐式转换。

如果你在类中定义了一个转换函数,它将允许隐式类型转换。例如,如果你想允许将A类对象隐式转换为int类型,你可以在A类中定义一个名为operator int()的转换函数。

但是,如果你希望禁止隐式类型转换并强制执行显式类型转换,则可以使用关键字explicit来修饰转换函数。这样,在进行类型转换时必须使用显式的强制类型转换语法。

下面将介绍以上用法。

内置类型->自定义类型

class 
{
public:
    A(int a)
            :_a(a)
    {}

    int _a;
};
int main()
{
    // 内置类型int->自定义类型A[隐式类型转换]
    A a1 = 1;
    
    cout << a1._a << endl;
    return 0;
}

输出:

1

为了方便打印成员变量_a的值,将其设置为公有成员。

在main函数中,创建了一个名为a1的A类对象,并使用隐式类型转换将整数1转换为A类对象。然后,程序输出a1对象的_a成员变量的值,即1。

关于优化:对于A a1 = 1,原本是将用1构造一个A类型的临时对象,然后再调用拷贝构造,构造出对象a1。编译器(特指Visual Studio,其他也是类似的)优化以后,直接调用构造函数用1构造a1,节省调用一次构造函数的开销。

自定义类型->内置类型

class A
{
public:
    A(int a)
        :_a(a)
    {}
    operator int()
    {
        return _a;
    }
private:
    int _a;
};
int main()
{
    // 内置类型int->自定义类型A[隐式类型转换]
    A a1 = 1;
    const A& a2 = 2;
    A&& a3 = 3;

    // 自定义类型A->内置类型int
    //int i1 = a1;
    int i11 = (int)a1; // 由于explicit修饰operator bool, 所以用强制类型转换
    int i2 = static_cast<int>(a1);

    return 0;
}

这段中定义了一个名为A的类,该类具有一个构造函数,该构造函数接受一个int类型的参数,并且还定义了一个int类型的转换运算符。在main函数中,通过隐式类型转换将内置类型int转换为自定义类型A,并使用转换运算符将自定义类型A转换为内置类型int。此外,还演示了使用static_cast进行显式类型转换。

operator int()是一个类型转换运算符,它允许将类A的对象隐式转换为int类型。在这段代码中,当我们尝试将类A的对象赋值给int类型变量时,例如int i1 = a1;,编译器会自动调用这个转换运算符来完成从类A到int类型的转换。

类型转换运算符是一种特殊的成员函数,它允许将一个类的对象隐式转换为其他类型。它的语法形式为operator typename(),其中typename是要转换的目标类型。类型转换运算符没有参数,并且必须返回目标类型。

类型转换运算符通常在需要将一个类的对象隐式转换为其他类型时被调用。例如,当我们尝试将一个类的对象赋值给另一种类型的变量,或者将一个类的对象传递给接受另一种类型参数的函数时,编译器会自动调用相应的类型转换运算符来完成转换。

在上面给出的代码中,当我们尝试将类A的对象赋值给int类型变量时,例如int i1 = a1;,编译器会自动调用类A定义的int类型转换运算符operator int()来完成从类A到int类型的转换。

4. C++文件IO流

要在 C++ 中进行文件处理,必须在 C++ 源代码文件中包含头文件 <iostream><fstream>。C++ 中有三个用于文件操作的文件类:

  • ifstream 类:派生自istream类,用于支持从磁盘文件的输入。
  • ofstream 类:派生自ostream类,用于支持向磁盘文件的输出。
  • fstream 类:派生自iostream类,用于支持对磁盘文件的输入输出。

4.1 二进制文件和文本文件

C++根据文件内容的数据格式将文件分为:

  • 二进制文件:由字符的ASCII码的形式存储,用read和write进行读写;
  • 文本文件:由二进制数据形式存储,通过fstream、ifstream、ofstream进行操作,用 << 和 >> 进行读写。

二进制文件和文本文件各有其优缺点:

  • 二进制文件的优点是内存如何存储,就如何写入磁盘中,因此二进制文件占用内存空间小,便于检索,且读写速度快。但是,它的缺点是不易于人类阅读和编辑。
  • 文本文件的优点是具有较高的兼容性,易于人类阅读和编辑,写入的就是我们看到的文件,例如我们看到的是中文,那么写入到文件中的也是中文。但是,它的缺点是存储一批纯数值信息时需要在数据之间添加分隔符,输入输出过程中系统要对内外存数据格式进行相应的转换,转换的过程会消耗时间,并且不便于对数据进行随机访问。

因此,在选择使用哪种类型的文件时,需要根据实际情况来决定。

4.2 文件操作步骤

在C++中对文件进行操作分为以下几个步骤:建立文件流对象; 打开或建立文件; 进行读写操作; 关闭文件。

建立文件流对象

用于文件IO操作的文件流对象主要有三个:

  • fstream (输入输出文件流):读和写;
  • ifstream (输入文件流):只读;
  • ofstream (输出文件流):只写。

而这三个类都包含在头文件fstream中,所以程序中对文件进行操作必须包含该头文件。

注意:

读和写、输入和输出是相对于程序来说的。

  • 读取文件:将文件中的数据从磁盘读入到程序中;
  • 写入文件:将程序中的数据写入到磁盘文件中。

同理:

  • 输入:从外部(如键盘、鼠标、文件等)获取数据到程序中;
  • 输出:将程序中的数据输出到外部(如屏幕、打印机、文件等)。

所以,当我们从一个文件读取数据时,这个过程可以被称为“文件输入”,因为数据从外部文件输入到了程序中;而当我们向一个文件写入数据时,这个过程可以被称为“文件输出”,因为数据从程序内部输出到了外部文件。

打开或建立文件

调用文件流对象中的接口,首先,要打开磁盘文件以建立文件流对象和磁盘文件之间的联系;其次,要指定打开文件的方式。

open() 是C++文件流对象(fstreamifstreamofstream)的一个成员函数,用于打开文件。

它的语法格式如下:

void open(const char* filename, ios::openmode mode);

其中,filename 是要打开的文件的名称(包括路径),而 mode 是打开文件的模式。这些模式可以是以下几种之一:

  • ios::in:以读的方式打开文件
  • ios::out:以写的方式打开文件
  • ios::app:以追加的方式对文件进行写入
  • ios::ate:输出位置从文件的末尾开始
  • ios::binary:以二进制模式打开文件
  • ios::trunc:先将文件内容清空再打开文件

你可以用|或运算组合使用这些模式。例如,如果你想要以二进制模式读取一个文件,可以这样写:

std::ifstream inFile;
inFile.open("example.bin", std::ios::in | std::ios::binary);

注意:

使用ofstream类对象的open函数时,若不指定打开方式,则默认以写的方式打开文件;使用ifstream类对象的open函数时,若不指定打开方式,则默认以读的方式打开文件;使用fstream类对象的open函数时,若不指定打开方式,则默认以读和写的方式打开文件。

进行读写操作

在 C++ 编程中,我们使用流提取运算符( >> )和 流插入运算符( << )从文件读取或写入信息,就像使用它们从键盘输入信息或输出信息到屏幕上一样。唯一不同的是,在这里使用的是 ifstream/ofstream 或 fstream 对象,而不是 cin 或 cout 对象。

put插入一个字符到文件
write插入一段字符到文件
get从文件提取字符
read从文件提取多个字符
tellg获取当前字符在文件当中的位置
seekg设置对文件进行操作的位置
>>运算符重载将数据形象地以“流”的形式进行输入
<<运算符重载将数据形象地以“流”的形式进行输出

put

put函数用于插入一个字符到流中。

原型:

ostream& put (char c);

其中:

  • c:要写入的字符。

write

write函数可以将字符数组中的内容写入到数据流中。

原型:

basic_ostream& write (const char_type* s, std::streamsize count)

其中:

  • s指向要写入的字符数组;
  • count表示要写入的字符数。

get

get函数是cin输入流对象的成员函数,用于从指定的输入流中提取一个字符(包括空白字符)。

它有3种形式:无参数的、有1个参数的和有3个参数的。

例如,不带参数的get函数调用形式为cin.get(),其返回值就是读入的字符。

read

read函数是C++中用于从文件中读取数据的函数。它实际上继承自istream类,其功能正好和write方法相反,即从文件中读取指定数量的字节。

原型:

istream & read (char* buffer, int count)

其中:

  • buffer用于指定读取字节的起始位置;
  • count指定读取字节的个数。

tellg和seekg

tellg函数是C++文件流操作中用于获取流指针的函数。它返回当前定位指针的位置,也代表着输入流的大小。tellg函数不需要带参数,它返回当前定位指针的位置,也代表着输入流的大小。

原型:

streampos tellg();

其中返回值类型为streampos(暂时不需要知道它是什么),表示流中的位置。

所有输入/输出流对象都有至少一个流指针,例如ifstream类似于istream,有一个被称为get pointer的指针,指向下一个将被读取的元素。


seekg用于设置输入流中的读位置。它有两个版本:

  • istream& seekg (streampos pos);
  • istream& seekg (streamoff off, ios_base::seekdir way);

第一个版本接受一个streampos类型的参数,表示要定位到的绝对位置。第二个版本接受两个参数:一个streamoff类型的偏移量和一个枚举值,表示偏移量是相对于文件开头、当前位置还是文件结尾计算的。

例如,如果您想将文件读指针定位到文件开头,可以这样做:

ifstream in("test.txt");
in.seekg(0);

上面的代码中,我们使用了seekg函数的第一个版本,并将其参数设置为0,表示将文件读指针定位到文件开头。


示例:

#include <iostream>
#include <fstream>
using namespace std;

int main() 
{
    ifstream in("test.txt");
    in.seekg(0, ios::end);
    streampos size = in.tellg();
    cout << "Size of file: " << size << endl;
    return 0;
}

上面的代码中,我们首先使用seekg函数将文件读指针定位到文件尾部,然后使用tellg函数获取文件读指针的位置,此位置即为文件长度。

关闭文件

在C++中,当你对任何类型的文件进行读写操作后,都应该调用close函数来关闭文件。这个函数可以关闭与对象相关联的文件。close() 函数是 fstream、ifstream 和 ofstream 对象所关联的函数,它使用ofstream库来关闭文件。

例如,如果使用ofstream对象打开了一个文件,可以这样关闭它:

ofstream out("test.txt");
// 写入一些数据
out.close();

上面的代码中,我们使用了ofstream对象的close成员函数来关闭文件。

当然,在C++中,当文件流对象(如ifstreamofstreamfstream)离开其作用域时,它们的析构函数会自动调用以关闭与之关联的文件。因此,在许多情况下,不需要显式地调用close函数来关闭文件。

4.3 示例

对二进制文件读写

这是一个简单的例子,演示如何使用 C++ 的 read() 和 write() 方法读写二进制文件:

#include <iostream>
#include <fstream>
using namespace std;
int main()
{
    // 写入二进制文件
    ofstream outfile; // 定义文件流对象
    outfile.open("test.bin", ios::out | ios::binary); // 以二进制写入的方式打开
    char out[] = "hello world";
    outfile.write(out, strlen(out)); // 将变量x的值写入文件
    outfile.close();

    // 读取二进制文件
    ifstream infile;
    infile.open("test.bin", ios::binary);
    char in[36];
    infile.read(in, strlen(out));
    infile.close();

    cout << in << endl;

    return 0;
}

这段代码是用来写入和读取二进制文件的。它首先创建一个名为 test.bin 的二进制文件,然后将字符串 "hello world" 写入该文件。接着,它再次打开该文件并读取刚才写入的字符串,最后将其输出到屏幕上(这不是必要的,只是为了打印读取的内容)。

.bin 文件是二进制文件的一种,它的扩展名为 “.bin”。bin 是英文 binary 的缩写。

在生成的可执行文件的当前文件夹内,会新增一个名为"test.bin"的二进制文件,用文本编辑器打开它,其内容正是刚才写入的:

在这里插入图片描述

C语言的文件IO操作告诉我们,如果要打开的文件不存在,会新增一个同名的新文件。在C++中,如果使用 ofstream 类的 open 函数以写入模式打开一个不存在的文件,那么会自动创建一个新文件。但是,如果使用 ifstream 类的 open 函数以读取模式打开一个不存在的文件,则不会创建新文件,而是会导致打开失败。

除此之外,write函数限制第一个参数是C风格的字符串,并非只能传入字符串。有了C++的类型转换操作符(const_cast、dynamic_cast、reinterpret_cast 和 static_cast),我们可以传入其他内置类型的参数:

#include <iostream>
#include <fstream>

int main()
{
    // 写入二进制文件
    ofstream outfile("test.bin", ios::binary);
    int x = 12345;
    outfile.write(reinterpret_cast<char*>(&x), sizeof(x));
    outfile.close();

    // 读取二进制文件
    ifstream infile("test.bin", ios::binary);
    int y;
    infile.read(reinterpret_cast<char*>(&y), sizeof(y));
    infile.close();

    cout << y << endl; // 输出 12345

    return 0;

}

上面实例化流对象的方式和之前不太一样,可以直接用参数构造对象,其本质是调用对象的构造函数按指定方式打开文件。

关于C++类型转换,可以参考笔者的文档:类型转换(C++)

对文本文件读写

和对二进制文件读写是类似的,只要注意类型匹配即可:

#include <iostream>
#include <fstream>

using namespace std;

int main()
{
    // 写入文本文件
    ofstream outfile; // 定义文件流对象
    outfile.open("text.txt"); // 默认以写入的方式打开文件
    char out[] = "hello world";
    outfile.write(out, strlen(out));
    outfile.put('!');
    outfile.close();

    ifstream infile;
    infile.open("text.txt");
    infile.seekg(0, infile.end); // 跳转到文件末尾处
    int len = infile.tellg(); // 获取当前字符在文件中的位置,即文件字符数
    infile.seekg(0, infile.beg); // 跳转到文件开头
    char in[36];
    infile.read(in, len);
    infile.close();

    cout << in << endl; 

    return 0;
}

其中,可以利用seekg配合流对象的end和beg接口跳转,得到文件中的总字符数,有的时候我们打开文件时,文件可能本来就有内容。

除此之外,对于文本文件还可以用 ifstream 类型对象中的getline接口按行读取文本文件中的内容,其返回值是string类型,如果数据本身是其他类型,需要将string类型转换成其他类型。

实际上,getline() 函数有两个不同的版本。一个版本是定义在 <string> 库中的,它用于从输入流中读取一行文本并将其存储在 std::string 对象中。另一个版本是定义在 <istream> 库中的,它用于从输入流中读取一行文本并将其存储在字符数组中。

定义在 <istream> 库中的 getline() 函数原型如下:

istream& getline (char* s, streamsize n );
istream& getline (char* s, streamsize n, char delim );
  • s:一个字符数组,用于存储从输入流中读取到的文本。
  • n:一个 streamsize 类型的整数,用于指定字符数组的大小。函数会确保不会向字符数组中写入超过其大小的数据。这样,函数就可以确保不会向字符数组中写入超过其大小的数据,从而避免缓冲区溢出。
  • delim:一个字符,用于指定自定义的分隔符。函数会一直读取字符,直到遇到该分隔符为止。

注意,其他两个文件流对象中没有getline接口。

#include <iostream>
#include <fstream>
using namespace std;

int main()
{
    ifstream infile("text.txt"); // 文件中的内容是"hello world"
    char in[100];
    infile.getline(in, 100);

    infile.close();
    cout << in << endl;
    return 0;
}

输出:

hello world

补充:

定义在 <istream> 库中的 getline()

getline() 是一个用于从输入流中读取一行文本的函数。它有两个重载版本,分别为:

istream& getline (istream& is, string& str);
istream& getline (istream& is, string& str, char delim);

第一个版本有两个参数:

  • is:一个输入流对象,用于指定从哪个输入流中读取数据。
  • str:一个字符串对象,用于存储从输入流中读取到的文本。
  • delim:一个字符,用于指定自定义的分隔符。函数会一直读取字符,直到遇到该分隔符为止。

两个版本都返回一个对输入流 is 的引用,这样可以方便地进行链式调用。

4.4 使用 << 和 >> 读写文本文件

二进制文件的读写操作显然是C语言风格的,使用起来并不方便。

C++ 标准库中,提供了 2 套读写文件的方法组合,分别是:

  • 文本文件:使用 >><< 读写;
  • 二进制文件:使用 read()write() 成员函数读写。

>><< 操作符用于读写文本文件。如果你想以二进制形式读写文件,那么你需要使用 read()write() 成员方法。

要使用 >><< 读写文本文件,你需要使用 fstream 或者 ifstream 类来实现对文件的读取,它们内部都对 >> 输出流运算符做了重载;同样,fstream 和 ofstream 类负责实现对文件的写入,它们的内部也都对 << 输出流运算符做了重载。

当你使用>>运算符从输入流(如ifstream对象)中提取数据时,它会自动忽略任何前导空白字符(如空格、制表符和换行符),并读取直到遇到下一个空白字符为止的数据。例如:

std::ifstream infile("example.txt");
int x;
infile >> x;

上面的代码将打开名为example.txt的文件,并从中读取一个整数值,存储在变量x中。

类似地,当你使用<<运算符向输出流(如ofstream对象)插入数据时,它会自动将数据转换为文本形式并写入文件。例如:

std::ofstream outfile("example.txt");
int x = 42;
outfile << x;

上面的代码将创建一个名为 example.txt 的文件,并向其中写入整数值 42 的文本表示形式。

示例

下面的代码演示了如何使用 <<>> 运算符以文本形式读写文件。

#include <iostream>
#include <fstream>
#include <string>

int main()
{
    string filename = "example.txt";
    ofstream outfile(filename);
    outfile << "This is an example text file." << endl;
    outfile.close();

    ifstream infile(filename);
    string line;
    while (getline(infile, line))
    {
        cout << line << endl;
    }
    infile.close();

    return 0;
}

输出:

This is an example text file.

这段代码首先创建了一个名为example.txt的文本文件,并向其中写入一行文本。然后,它打开该文件并逐行读取内容,将其输出到控制台。

优点

>><<运算符被重载以提供一种简洁、易读的语法来从输入流中提取数据和向输出流中插入数据。

使用这些运算符可以让你的代码更加简洁易读,因为它们允许你直接在表达式中执行读写操作,而不需要调用特定的成员函数。而且相比于C语言风格的文件操作,省去了调用 read()write() 成员函数的步骤。例如,下面两行代码是等效的:

std::cin >> x;
std::cin.operator>>(x);

第一行使用>>运算符从标准输入流(std::cin)中提取一个值并存储在变量x中。第二行使用成员函数调用实现相同的功能,但语法更加冗长。

此外,由于这些运算符可以链接在一起,所以你可以在一行代码中执行多个读写操作。例如:

std::cin >> x >> y >> z;
std::cout << "x = " << x << ", y = " << y << ", z = " << z << std::endl;

上面的代码分别从标准输入流中提取三个值并存储在变量 x, y, 和 z 中,并将它们的值输出到标准输出流(std::cout)。

对于自定义类型,只要在类中重载了>><<运算符,就能方便地进行文件读写操作。

补充

当使用>>运算符从输入流中提取数据时,它会自动忽略任何前导空白字符,包括换行符。这意味着如果输入流中的下一个非空白字符位于新的一行,则>>运算符会自动跳过前面的换行符并读取该字符。

例如,假设有一个名为text.txt的文本文件,其内容如下:

42
3.14
hello

你可以使用以下代码从该文件中读取数据:

#include <fstream>
int main()
{
    ifstream infile("test.txt");
    int x;
    double y;
    string z;
    infile >> x >> y >> z;

    cout << x << endl << y << endl  << z << endl;
    return 0;
}

输出:

42
3.14
hello

在上面的代码中,第一个>>运算符将读取文件中的第一个整数值 42 并将其存储在变量 x 中。然后,它会自动跳过换行符并读取下一行中的浮点值 3.14 ,将其存储在变量 y 中。最后,它会再次跳过换行符并读取下一行中的字符串值 "hello" ,将其存储在变量 z 中。

当使用<<运算符向输出流插入数据时,它不会自动添加任何换行符。==如果你希望在输出中添加换行符,则需要显式地插入它们。==例如:

#include <fstream>
int main()
{
    ofstream outfile("test.txt");
    int x = 4;
    double y = 3.14;
    string z = "world";
    outfile << x << y << z << endl;

    return 0;
}

test.txt文件中的内容:

43.14world

显式地插入换行符:

outfile << x << endl << y << endl << z << endl;

endl等价于'\n'

test.txt文件中的内容:

4
3.14
world

5. stringstream

5.1 C语言的字符串转换

stringstream 是 C++ 标准库中的一个类,它可以用来进行字符串和其他数据类型之间的转换。它和 C 语言中的 string 没有直接关系。stringstream 存在的意义是为了提供一种简单、类型安全和可扩展性高的方式来实现类型转换。

例如,C语言要系那个实现整型数据转化为字符串类型:

  1. 使用itoa函数:

    int a = 10;
    char arr[10];
    itoa(a, arr, 10); // 将整型变量a以十进制转换为字符串arr
    
  2. 使用sprintf函数

    int a = 10;
    char arr[10];
    sprintf(arr, "%d", a); // 将整型变量a转换为字符串arr
    

缺点:使用这些函数进行类型转换时,需要注意缓冲区溢出的问题。如果缓冲区不够大、格式不匹配,还可能导致程序崩溃或安全漏洞。

5.2 C++的字符串转换

C++提供的stringstream类可以解决此问题,使用它之前需包含头文件<sstream>,在此头文件下,有三个类:

  • ostringstream:输出
  • istringstream:输入(还可以用于分割被空格、制表符等符号分割的字符串。)
  • stringstream:输入和输出

stringstream可以用来进行数据类型转换。它使用string对象来代替字符数组,避免缓冲区溢出的危险;而且,因为传入参数和目标对象的类型会被自动推导出来,所以不存在错误的格式化符的问题。简单说,相比C语言库的数据类型转换而言,<sstream> 更加安全、自动和直接。

5.3 示例

假如有这样一个结构体ChatInfo,其中成员表示联系人、编号和信息。

struct ChatInfo
{
    string _name;
    int _id;
    string _msg;
};

如何把离散的个人信息转化为整体的字符串呢?

ChatInfo c = { "小明", 2021304, "明天一起自习吧?" };

在实际应用中,这种需求是常见的:数据发送方将分散的数据按照一定规则打包发送给接收方,接收方通过对称的规则解包,得到分散的数据。例如json。

  • 序列化:不管是什么数据类型,都将其转化为字符串,然后用流插入运算符 >> 将这些字符串插入到流中。再用ostringstream中的str()接口转成字符串。
  • 反序列化:和序列化相反。

序列化是指将数据结构或对象转换为一种格式,以便存储或传输。在C++中,可以使用stringstream类来实现序列化。

stringstream是一个流类,它可以同时支持输入和输出操作。它的工作原理与其他流类(如cincout)相似,只不过它的输入和输出都是针对内存中的字符串。

这段代码演示了如何使用C++中的输出字符串流来序列化一个简单的结构体:

#include <iostream>
#include <sstream>
using namespace std;

int main()
{
    ChatInfo winfo = { "小明", 2021304, "明天一起自习吧?" };
    // 序列化
    ostringstream oss;
    oss << winfo._name << endl;
    oss << winfo._id << endl;
    oss << winfo._msg << endl;

    string str = oss.str();
    cout << str << endl;;
    
    return 0;
}

输出:

小明
2021304
明天一起自习吧?

程序用了一个名为ostringstream oss;的输出字符串流对象来序列化结构体实例。这个对象可以像其他输出流一样使用插入运算符(<<)将数据写入到内存中的字符串缓冲区中。序列化过程就是将结构体中的数据按照一定顺序写入到字符串流中。最后,程序使用了字符串流对象的.str()方法来获取序列化后的字符串,并将其打印验证。

其中:stringstream=istringstream+ostringstream,所以ostringstream oss等价于stringstream oss

总之,序列化就是将数据转换为一种可存储或传输的格式,在C++中可以使用stringstream类来实现这一目标。反序列化是指将序列化后的数据还原为原始的数据结构或对象。它与序列化是相反的过程。

将上述代码以反序列化的形式输出(在一个main函数中测试):

int main()
{
    ChatInfo winfo = { "小明", 2021304, "明天一起自习吧?" };
    // 序列化
    ostringstream oss;
    oss << winfo._name << endl;
    oss << winfo._id << endl;
    oss << winfo._msg << endl;

    string str = oss.str();
    cout << str << endl;

    // 反序列化
    ChatInfo rInfo;
    istringstream iss(str);
    iss >> rInfo._name;
    iss >> rInfo._id;
    iss >> rInfo._msg;
    
    cout << rInfo._name << endl;
    cout << rInfo._id << endl;
    cout << rInfo._msg << endl;


    return 0;
}

输出:

小明
2021304
明天一起自习吧?

小明
2021304
明天一起自习吧?

这样就可以从字符串 str 中读取数据并存储到 ChatInfo 对象中了。

5.4 补充

  • stringstream是一个类模板,它实现了对基于字符串的流的输入和输出操作。它有效地存储了一个std::basic_string的实例,并对其执行输入和输出操作。在底层,该类本质上将std::basic_stringbuf的原始字符串设备实现包装到std::basic_iostream的更高级别接口中。

  • 可以使用s.str(“”)的方式将stringstream底层的string对象设置为空字符串,否则多次转换时,会将结果全部累积在底层string对象中。

  • 有两种方式可以获取stringstream转换后的字符串:

    • 使用 >> 运算符
    • 使用stringstream类对象(包括其子类)的obj.str()
  • stringstream的意义在于它能够将一个string对象与一个流相关联,允许像读取流(如cin)一样从字符串中读取。它非常有用于解析输入。它可以让你像处理流一样处理字符串对象,并对其使用所有流函数和操作符。

  • 因为stringstream使用string类对象代替字符数组,可以避免缓冲区溢出的危险,而且会对参数类型进行推演,不需要格式化控制,也不会存在格式化失败的风险,因此使用更方便,更安全。