Skip to content

Latest commit

 

History

History
2215 lines (1674 loc) · 116 KB

07.md

File metadata and controls

2215 lines (1674 loc) · 116 KB

七、流和输入/输出

学习目标

本章结束时,您将能够:

  • 使用标准输入/输出库向/从文件或控制台写入和读取数据
  • 使用内存中的输入/输出接口格式化和解析数据
  • 为用户定义的类型扩展标准输入/输出流
  • 开发使用多线程输入/输出标准库的应用

在本章中,我们将使用输入/输出标准库开发灵活且可维护的应用,处理流,学习如何在多线程应用中使用输入/输出库,最后学习使用标准库格式化和解析数据。

简介

在前一章中,我们讨论了最具挑战性的话题之一——c++ 中的并发性。我们研究了主要的多线程概念,并区分了 C++ 中的同步、异步和线程执行。我们学习了关于同步、数据危险和比赛条件的要点。最后,我们研究了在现代 C++ 中使用线程。在本章中,我们将深入学习如何在多线程应用中处理输入/输出。

本章专门介绍 C++ 中的I/O。输入输出是输入输出操作的一般概念。标准库这一部分的主要目的是提供一个关于数据输入和输出的清晰界面。但这不是唯一的目标。在许多情况下,输入/输出可以帮助我们的应用。很难想象任何应用不把错误或异常情况写入日志文件,目的是把它发送给开发团队进行分析。在图形用户界面应用中,我们总是需要格式化显示的信息或解析用户输入。在复杂的大型应用中,我们通常需要记录内部数据结构,等等。在所有这些情况下,我们使用标准库的输入/输出部分。

我们将以对标准库的输入/输出部分的简单介绍开始本章。我们将了解输入/输出,并探索它们的主要概念和术语。然后,我们将考虑默认支持哪些类型,以及如何将流扩展到用户定义的类型。接下来,我们将研究输入/输出库的结构,并检查可供我们使用的头和类。最后,我们将研究如何处理流、读写文件、创建具有输入和输出操作的多线程应用,以及格式化和解析文本数据。

本章将以一个富有挑战性和激动人心的活动结束,在这个活动中,我们将改进上一章中的美术馆模拟器项目,并创建一个健壮、清晰、多线程且易于使用的记录器。我们将开发一个接口清晰的类,可以从项目中的任何地方访问。接下来,我们将对它进行调整,使其能够与几个线程一起工作。最后,我们将把我们健壮的记录器集成到艺术画廊模拟器项目中。

让我们从查看 C++ 标准库的输入/输出部分开始,了解这套工具为我们提供了哪些机会。

查看标准库的输入/输出部分

在计算机科学中,输入/输出这个术语意味着程序、设备、计算机等之间的通信。在 C++ 中,我们使用标准输入和标准输出术语来描述输入/输出过程。标准输入是指传输到程序中的数据流。为了获得这些数据,程序应该执行读取操作。标准输出是指从程序传输到外部设备的数据流,如文件、显示器、套接字、打印机等。为了输出这些数据,程序应该执行写操作。标准输入和输出流从主进程继承而来,对所有子线程都是通用的。请看下图,以更好地理解所考虑的术语:

图 6.1:设备之间的输入/输出通信

在 C++ 标准库中,大多数输入输出类都是通用的类模板。所有这些逻辑上都分为两类——抽象和实现。我们已经熟悉了抽象类,并且知道我们可以在不重新编译代码的情况下将它们用于不同的目的。输入/输出库也是如此。这里,我们有六个抽象类,它们是 C++ 中输入输出操作的基础。我们不会深入探讨这些接口。通常,我们在操作中使用更多的高级类,只有当我们需要实现自己的派生类时,才会对它们有吸引力。

ios_base 抽象类负责管理流状态标志、格式化标志、回调和私有存储。 basic_streambuf 抽象类提供了缓冲输入或输出操作的接口,并提供了对输入源的访问,如文件、套接字或输出接收器,如字符串或向量。 basic_ios 抽象类实现了从 basic_streambuf 接口使用派生类的工具。 basic_ostreambasic_istreambasic_iostream 抽象类分别是来自 basic_streambuf 接口的派生类的包装器,提供高级输入输出接口。让我们简单考虑一下它们及其关系,如下类图所示。可以看到除了 ios_base 之外,都是模板类。在每个类的名称下,您可以找到定义该类的文件名:

注意

在 UML 符号中,我们使用< <接口> >关键字来表示类是一个抽象类。

Figure 6.2: Class diagram of I/O abstract interfaces

图 6.2:输入输出抽象接口类图

实现类在逻辑上分为以下几类:文件 I/O字符串 I/O同步 I/OI/O 操纵器,以及预定义的标准流对象。它们都是从前面提到的抽象类中派生出来的。让我们在接下来的章节中详细考虑它们。

预定义的标准流对象

我们将从已经熟悉的< iostream >头文件中的std::cout类开始了解输入/输出标准库。我们用它向终端输出数据。您可能还知道用于读取用户输入的std::cin类,但不是每个人都知道std::coutstd::cin是预定义的标准流对象,用于格式化终端的输入和输出。< iostream >头文件还包含std::cerrstd::clog流对象,用于记录错误。和往常一样,宽字符也有它们的类似物,前缀为“w”:wcoutwcinwcerrwclog。所有这些对象都会在系统启动时自动创建和初始化。虽然从多个线程使用这些对象是安全的,但是输出可以是混合的。让我们修改如何使用它们。因为它们只为内置类型重载,所以我们应该为用户定义的类型编写自己的重写。

标准::cout流对象通常与标准::endl操纵器一起使用。它在输出序列中插入一个换行符并刷新它。下面是一个使用它们的例子:

std::string name("Marilyn Monroe");
int age = 18;
std::cout << "Name: " << name << ", age: " << age << std::endl;

最初,std::cin对象逐符号读取所有输入字符序列。但是它有内置类型的重载,可以读取诸如数字字符串字符等值。读弦有一个小技巧;std::cin读取字符串,直到下一个空白或换行符。所以,如果需要它读字符串,就要循环进行,一个字一个字读,或者使用std::getline()函数,该函数以std::cin对象为第一个参数,目的字符串为第二个参数。

注意

标准::cin流对象的右移位运算符> >仅读取一行中的一个单词。使用std::getline(std::cin,str)读取整行。

这里有一个使用不同类型的std::cin的例子;

std::string name;
std::string sex;
int age;
std::cout << "Enter your name: " << std::endl;
std::getline(std::cin, name);
std::cout << "Enter your age: " << std::endl;
std::cin >> age;
std::cout << "Enter your sex (male, female):" << std::endl;
std::cin >> sex;
std::cout << "Your name is " << name << ", your age is " << age << ", your sex is " << sex << std::endl;

如您所见,在这里,我们使用std::getline()函数读取名称,因为用户可以输入两三个单词。我们还使用右移位运算符、> >来读取年龄,然后读取性别,因为我们只需要读取一个单词。然后,我们打印读取的数据,以确保一切顺利。

std::cerrstd::clog 流对象仅在一个方面不同–STD::cerr会立即刷新输出序列,而 std::clog 会对其进行缓冲,并仅在缓冲区已满时进行刷新。说到用法,跟 std::cout 很像。唯一不同的是来自 std::cerrstd::clog 的消息(在大多数 IdE 中)是红色的。

在下面的截图中,您可以看到这些流对象的输出:

图 6.3:标准::cerr 和标准::clog 流对象的输出

现在,让我们做一个练习来巩固我们所学的一切。

练习 1:覆盖用户定义类型的左移位运算符< <

在本练习中,我们将编写一段非常有用的代码,您可以在任何地方使用它来输出用户定义的类型。首先,我们将创建一个名为Track的类,代表一个音乐曲目。它将有以下私人成员:名字歌手长度日期。然后,我们将覆盖左移位运算符,< <,对于这个类。接下来,我们将创建这个类的一个实例,并使用std::cout流对象输出它。

执行以下步骤来执行本练习:

  1. 包括用于输出到控制台的所需标题: < iostream > 和用于字符串支持的字符串<>:

    #include <iostream>
    #include <string>
  2. 声明轨道类,并添加私有节变量来保存轨道的信息,即m_Namem_Singerm_Datem_LengthInSeconds。在公共部分,添加一个构造函数,其参数初始化所有私有变量。另外,为所有类成员添加公共区域获取器:

    class Track
    {
    public:
         Track(const std::string& name,
               const std::string& singer,
               const std::string& date,
               const unsigned int& lengthInSeconds)
               : m_Name(name)
               , m_Singer(singer)
               , m_Date(date)
               , m_LengthInSeconds(lengthInSeconds)
    {
    }
         std::string getName() const { return m_Name; }
         std::string getSinger() const { return m_Singer; }
         std::string getDate() const { return m_Date; }
         unsigned int getLength() const { return m_LengthInSeconds; }
    private:
         std::string m_Name;
         std::string m_Singer;
         std::string m_Date;
         unsigned int m_LengthInSeconds;
    };
  3. 现在是练习中最困难的部分:为轨道类型编写重载函数。这是一个模板函数,有两个类型参数:图表特征 :

    template <typename charT, typename Traits>
  4. 我们内联了这个函数,让编译器知道我们希望它对这个函数进行优化。这个函数的返回类型是对一个std::basic_ostream < charT,Traits >类的引用。这个函数的名字是< <运算符。该函数采用两个参数:第一个是对STD::basic _ ostream<charT,Traits >类的引用,第二个是Track变量的副本。完整的功能声明如下:

    template <typename charT, typename Traits>
    inline std::basic_ostream<charT, Traits>&
    operator<<(std::basic_ostream<charT, Traits>& os, Track trackItem);
  5. 现在,添加函数定义。使用os变量,就像我们使用std::cout对象一样,按照您的意愿格式化输出。然后,从函数中返回os变量。重载运算符< <的完整代码如下:

    template <typename charT, typename Traits>
    inline std::basic_ostream<charT, Traits>&
    operator<<(std::basic_ostream<charT, Traits>& os, Track trackItem)
    {
          os << "Track information: ["
             << "Name: " << trackItem.getName()
             << ", Singer: " << trackItem.getSinger()
             << ", Date of creation: " << trackItem.getDate()
             << ", Length in seconds: " << trackItem.getLength()
             << "]";
          return os;
    }
  6. 现在,进入功能,创建并初始化名称为track_001Track类型的实例。最后,使用std::cout打印track_001值:

    int main()
    {
         Track track_001("Summer night city",
                         "ABBA",
                         "1979",
                          213);
         std::cout << track_001 << std::endl;
         return 0;
    }
  7. 编译并执行应用。运行它。您将获得以下输出:

图 6.4:执行练习 1 的结果

干得好。在这里,我们考虑使用预定义的标准流对象,并学习了如何为用户定义的类型编写自己的重载移位运算符。让我们继续,用 C++ 标准 IO 库检查对文件的读写。

文件输入输出实现类

文件流管理文件的输入和输出。它们提供了一个实现资源获取是初始化 ( RAII )的接口——文件在构建流时打开,在销毁时自动关闭。在标准库中,文件流由以下类表示:basic_ifstream用于输入操作,basic_ofstream用于输出操作,basic _ fsstream用于输入和输出操作,以及basic_filebuf用于原始文件设备的实现。所有这些都在<流>头文件中定义。标准库还为 char 和wchar_t类型提供了类型定义,即ifstreamfsstreamofstream,宽字符的名称前缀为“w”。

我们可以通过两种方式创建文件流。第一种方法是在一行中完成,也就是说,只需将文件名传递给构造函数,就可以打开文件并将流连接到文件:

std::ofstream outFile(filename);
std::ifstream outFile(filename);
std::fstream outFile(filename);

另一种方法是创建一个对象,然后调用open()函数:

std::ofstream outFile;
outFile.open(filename);

注意

IO 流有布尔变量:一个好位、一个坏位、一个坏位和一个坏位。它们用于在每次操作后检查流的状态,并指示流中发生了哪个错误。

对象创建后,我们可以通过检查故障位或检查与打开文件相关联的流来检查流状态。要检查一个故障位,调用文件流上的故障()功能:

if (outFile.fail())
{
    std::cerr << filename << " file couldn't be opened"<< std::endl;
}

要检查流是否与打开的文件相关联,请调用is_open()函数:

if (!outFile.is_open())
{
    std::cerr << filename << " file couldn't be opened"<< std::endl;
}

输入、输出和双向文件流也可以通过使用标志以不同的模式打开。它们在ios_base命名空间中声明。除了ios_base::inios_base::out标志外,我们还有ios_base::ateios_base::appios_base::truncios_base::binary标志。ios_base::trunc标志删除文件内容。ios_base::app标志始终将输出写入文件末尾。即使您决定更改文件中的位置,也不能这样做。ios_base::ate标志将文件描述符的位置设置到文件的末尾,但允许您稍后修改位置。最后,ios_base::binary标志抑制数据的任何格式,以便以“原始”格式读取或写入数据。让我们考虑开放模式的所有可能组合。

默认情况下, std::ofstreamios_base::out 模式下打开, std::ifstreamios_base::in 模式下打开,STD::fsstreamIOs _ base::in | IOs _ base::out模式下打开。如果文件不存在,则IOs _ base::out | IOs _ base::trunc模式会创建该文件,或者从现有文件中删除所有内容。IOs _ base::out | IOs _ base::app模式会在文件不存在的情况下创建文件,或者打开现有文件,只允许在文件末尾写入。上述两种模式都可以与标志中的 ios_base::结合使用,因此文件将以读写模式同时打开。

以下是如何使用上述模式打开文件的示例:

std::ofstream outFile(filename, std::ios_base::out|std::ios_base::trunc);

您还可以执行以下操作:

std::ofstream outFile;
outFile.open(filename, std::ios_base::out|std::ios_base::trunc);

在以所需模式打开文件流后,我们可以开始读取或写入文件。文件流允许我们改变文件中的位置。让我们考虑如何做到这一点。要获取当前文件的位置,我们可以在 ios_base::out 模式下调用 tellp() 函数,在模式下调用IOs _ base::outtellg()函数。它可以在以后使用,以便我们在需要时可以回到这个位置。在 ios_base::out 模式下使用 seekp() 功能,在 ios_base::in 模式下使用 seekg() 功能,也可以找到文件中的确切位置。它需要两个参数:要移动的字符数和应该从哪个文件位置开始计数。查找允许三种位置:std::ios_base::beg 即文件开头, std::ios_base::end 即文件结尾, std::ios_base::cur 即当前位置。下面是调用 seekp() 函数的一个例子:

outFile.seekp(-5, std::ios_base::end);

如您所见,我们要求将当前文件的位置设置在文件末尾的第五个字符处。

要写入文件,我们可以使用重载的左移位运算符,< <,对于一般的格式化输出,put()函数写单个字符,或者write()函数写一个字符块。使用左移位运算符是将数据写入文件的最方便的方法,因为您可以将任何内置类型作为参数传递:

outFile << "This is line No " << 1 << std::endl;

put()write()函数只能用于字符值。

要读取文件,我们可以使用重载的右移位运算符,> >,或者使用一组读取字符的函数,如read()get()getline()。右移位运算符对于所有内置类型都是重载的,我们可以这样使用它:

std::ifstream inFile(filename);		
std::string str;
int num;
float floatNum;
// for data: "book 3 24.5"
inFile >> str >> num >> floatNum;

最后,当执行离开可见性范围时,文件流被关闭,因此我们不需要执行任何额外的操作来关闭文件。

注意

从文件中读取数据时要注意。右移位运算符> >,只读取一个字符串,直到出现一个空白或换行符。要读取完整的字符串,您可以使用循环或在单独的变量中读取每个单词,就像我们在练习 1中为用户定义的类型覆盖左移位运算符<、<一样。

现在,让我们练习使用 C++ IO 标准库向文件读写数据。

练习 2:向文件中读写用户定义的数据类型

在本练习中,我们将为书店编写一段代码。我们需要将图书价格信息存储在一个文件中,然后在需要时从文件中读取这些信息。为了实现这一点,我们将创建一个类,该类表示一本书的名称、作者、出版年份和价格。接下来,我们将创建这个类的一个实例,并将它写入一个文件。稍后,我们将从文件中读取有关书籍的信息,并将其导入书籍类的实例中。执行以下步骤完成本练习:

  1. 包括所需的头文件:< iostream >用于输出到控制台,< string >用于字符串支持,<fsstream>用于 I/O 文件库支持:

    #include <fstream>
    #include <iostream>
    #include <string>
  2. 实现Book类,代表一个书店里的书。在私有部分,用不言自明的名称定义四个变量:m_Namem_Authorm_Yearm_Price。在公共部分,定义一个带有参数的构造函数,该构造函数初始化所有类成员。此外,在公共部分,定义所有类成员的获取者:

    class Book
    {
    public:
          Book(const std::string& name,
               const std::string& author,
               const int year,
               const float price)
         : m_Name(name)
         , m_Author(author)
         , m_Year(year)
         , m_Price(price) {}
         std::string getName() const { return m_Name; }
         std::string getAuthor() const { return m_Author; }
         int getYear() const { return m_Year; }
         float getPrice() const { return m_Price; }
    private:
         std::string m_Name;
         std::string m_Author;
         int m_Year;
         float m_Price;
    };
  3. 进入功能,声明价格文件变量,保存文件名:

    std::string pricesFile("prices.txt");
  4. 接下来,创建图书类的实例,并使用图书名称作者名称年份价格 :

    Book book_001("Brave", "Olena Lizina", 2017, 33.57);

    对其进行初始化

  5. 将此类实例写入文件。创建std::ofstream类的实例。用价格文件变量名打开我们的文件。检查流是否打开成功,如果没有打开,打印错误信息:

    std::ofstream outFile(pricesFile);
    if (outFile.fail())
    {
          std::cerr << "Failed to open file " << pricesFile << std::endl;
          return 1;
    }
  6. 然后,使用 getters 将所有关于book_001的信息写入文件,每个项目之间有空格,末尾有一个换行符:

    outFile << book_001.getName() << " "
            << book_001.getAuthor() << " "
            << book_001.getYear() << " "
            << book_001.getPrice() << std::endl;
  7. Compile and execute the application. Now, go to the project folder and find where the 'prices.txt' file is located. In the following screenshot, you can see the location of the created file in the project directory:

    图 6.5:创建的文件的位置
  8. Open it in Notepad. In the following screenshot, you can see what the output to the file looks like:

    图 6.6:用户定义类型输出到文件的结果
  9. 现在,让我们将这些数据读入变量。创建std::ifstream类的实例。打开文件价格文件。检查流是否已成功打开,如果未打开,则打印错误消息:

    std::ifstream inFile(pricesFile);
    if (inFile.fail())
    {
         std::cerr << "Failed to open file " << pricesFile << std::endl;
         return 1;
    }
  10. 从文件中创建用于输入的局部变量,即名称作者名称作者姓氏年份价格。他们的名字不言自明:

```cpp
std::string name;
std::string authorName;
std::string authorSurname;
int year;
float price;
```
  1. 现在,按照文件中的顺序将文件中的数据读入变量:
```cpp
inFile >> name >> authorName >> authorSurname >> year >> price;
```
  1. 创建一个名为book_002Book实例,并用那些读取的值初始化它:
```cpp
Book book_002(name, std::string(authorName + " " + authorSurname), year, price);
```
  1. 要检查读取操作是否成功执行,请将book_002变量打印到控制台:
```cpp
std::cout  << "Book name: " << book_002.getName() << std::endl
           << "Author name: " << book_002.getAuthor() << std::endl
           << "Year: " << book_002.getYear() << std::endl
           << "Price: " << book_002.getPrice() << std::endl;
```
  1. 再次编译并执行应用。在控制台中,您将看到以下输出:

图 6.7:执行练习 2 的结果

如您所见,我们毫无困难地从文件中写入和读取自定义格式的数据。我们创建了自己的自定义类型,使用std::ofstream类将其写入文件,并检查是否一切都写成功。然后,我们使用std::ifstream类将这些数据从一个文件读取到我们的自定义变量中,将其输出到控制台,并确保所有内容都被正确读取。通过这样做,我们学习了如何使用输入/输出标准库向文件读写数据。现在,让我们继续学习输入/输出库的内存部分。

字符串输入/输出实现

输入/输出标准库不仅允许输入和输出到文件等设备,还允许输入和输出到内存,特别是输入和输出到标准::字符串对象。在这种情况下,字符串可以是输入操作的源,也可以是输出操作的接收器。在<流>头文件中,声明了管理字符串输入和输出的流类。它们和文件流一样,也提供了一个实现 RAII 的接口——字符串在创建流时打开进行读取或写入,在销毁流时关闭。它们在标准库中由以下类表示:basic_stringbuf,它实现了一个原始字符串接口,basic_istringstream用于输入操作,basic_ostringstream用于输出操作,basic_stringstream用于输入和输出操作。标准库还为charwchar_t类型提供了类型定义:streamostringstreamstringstream以及宽字符前缀为“w”的相同名称。

要创建STD::is tingstream类的对象,我们应该将初始值设定项字符串作为构造函数参数传递,或者稍后使用str()函数进行设置:

std::string track("ABBA 1967 Vule");
std::istringstream iss(track);

或者,我们可以执行以下操作:

std::string track("ABBA 1967 Vule");
std::istringstream iss;
iss.str(track);

接下来,要从流中读取值,请使用右移位运算符,> >,该运算符对所有内置类型都是重载的:

std::string group;
std::string name;
int year;
iss >> group >> year >> name;

要创建std::ostringstream类的对象,我们只需声明其类型的变量:

std::ostringstream oss;

接下来,要将数据写入字符串,请使用左移位运算符,< <,该运算符对所有内置类型都是重载的:

std::string group("ABBA");
std::string name("Vule");
int year = 1967;
oss << group << std::endl
    << name << std::endl
    << year << std::endl;

要获取结果字符串,请使用str()函数:

std::cout << oss.str();

std::stringstream对象是双向的,因此它既有默认构造函数,也有接受字符串的构造函数。我们可以通过声明这种类型的变量来创建默认的std::stringstream对象,然后将其用于读写;

std::stringstream ss;
ss << "45";
int count;
ss >> count;

另外,我们可以使用带有字符串参数的构造函数创建std::stringstream。然后,我们可以像往常一样使用它进行阅读和写作:

std::string employee("Alex Ismailow 26");
std::stringstream ss(employee);

或者,我们可以创建一个默认的std::stringstream对象,并通过使用str()函数设置一个字符串来初始化它:

std::string employee("Charlz Buttler 26");
std::stringstream ss;
ss.str(employee);

接下来,我们可以使用 ss 对象进行读写:

std::string name;
std::string surname;
int age;
ss >> name >> surname >> age;

我们也可以为这些类型的流应用开放模式。它们的功能类似于文件流,但略有不同。ios_base::binary在处理字符串流的情况下是不相关的,ios_base::trunc被忽略。因此,我们可以在四种模式下打开任意字符串流:ios_base::appios_base::ateIOs _ base::in/IOs _ base::out

现在,让我们练习使用 C++ IO 标准库向字符串读写数据。

练习 3:为字符串中的替换单词创建函数

在本练习中,我们将实现一个函数,该函数解析给定的字符串并用其他单词替换给定的单词。为了完成这个练习,我们创建了一个可调用的类,该类接受三个参数:原始字符串、要替换的单词和将用于替换的单词。因此,应该返回新字符串。执行以下步骤完成本练习:

  1. 包括输出到终端的必要标题:<输出流>和输入/输出字符串支持的输出流<>T4:

    #include <sstream>
    #include <iostream>
  2. 实现名为Replacer的可调用类。它只有一个函数——一个重载的圆括号运算符,(),它返回一个字符串并接受三个参数:原始字符串、要替换的单词和要用于替换的单词。函数声明如下:

    std::string operator()(const std::string& originalString,
                           const std::string& wordToBeReplaced,
                           const std::string& wordReplaceBy);
  3. 接下来,创建isting stream对象,即iss,并将originalString变量设置为输入源:

    std::istringstream iss(originalString);
  4. 创建排斥流对象,即oss,它将保存转换后的字符串:

    std::ostringstream oss;
  5. 然后,在循环中,当有可能的输入时,执行对单词变量的单词读取。检查这个单词是否等于单词被替换变量。如果是,用变量替换它,并写入 oss 流。如果不相等,将原字写到 oss 流。在每个单词之后,添加一个空白字符,因为 iss 流会截断它们。最后,返回结果。完整的类如下:

    class Replacer
    {
    public:
          std::string operator()(const std::string& originalString,
                                 const std::string& wordToBeReplaced,
                                 const std::string& wordReplaceBy)
         {
               std::istringstream iss(originalString);
               std::ostringstream oss;
               std::string word;
               while (iss >> word)
               {
                    if (0 == word.compare(wordToBeReplaced))
                    {
                         oss << wordReplaceBy << " ";
                    }
                    else
                    {
                         oss << word << " ";
                    }
               }
               return oss.str();
         }
    };
  6. 进入功能。创建一个名为 worker 的Replacer类的实例。定义foodList变量,用包含食物列表的字符串初始化;有些项目应该重复。定义changedList字符串变量,并通过worker()函数的返回值对其进行初始化。使用标准::cout在终端显示结果:

    int main()
    {
          Replacer worker;
          std::string foodList("coffee tomatoes coffee cucumbers sugar");
          std::string changedList(worker(foodList, "coffee", "chocolate"));
          std::cout << changedList;
          return 0;
    }
  7. 编译、构建和运行练习。因此,您将获得以下输出:

Figure 6.8: The result of executing Exercise 3

图 6.8:执行练习 3 的结果

干得好!在这里,我们学习了如何使用字符串流来格式化输入和输出。我们创建了一个应用,可以轻松替换句子中的单词,增强了我们的知识,现在我们准备学习输入/输出操纵器,这样我们就可以提高处理线程的技能。

输入/输出操纵器

到目前为止,我们已经了解了使用流的简单输入和输出,但是在许多情况下它们还不够。对于更复杂的输入/输出数据格式化,标准库有一大套输入/输出操纵器。它们是开发出来与左(<>)移位运算符一起控制流行为的函数。输入/输出操纵器分为两种类型——无参数调用的和需要参数的。其中一些既用于输入又用于输出。让我们简单考虑一下它们的含义和用法。

用于更改流的数值基数的输入/输出操纵器

< ios >头中,有用于更改流的数字基数的声明函数:std::decstd::hexstd::oct。它们在没有参数的情况下被调用,并将流的基数分别设置为十进制、十六进制和八进制。在< iomanip >头中,声明了std::setbase函数,使用以下参数调用:8、10 和 16。它们可以互换,用于输入和输出操作。

< ios >头中,还有std::showbasestd::noshowbase功能,控制显示流的数字基数。它们只影响十六进制和八进制整数输出,除了零值,以及货币输入和输出操作。让我们完成一个练习,并学习如何在实践中使用它们。

练习 4:以不同的数字基数显示输入的数字

在本练习中,我们将开发一个应用,该应用在无限循环中要求用户以下列数字基数之一输入一个整数:十进制、十六进制或八进制。读取输入后,它会以其他数字表示形式显示该整数。要执行本练习,请完成以下步骤:

  1. 包括用于流支持的< iostream >报头。声明名为BASE的枚举,定义三个值:DECIMALOCTAL十六进制 :

    #include <iostream>
    enum BASE
    {
          DECIMAL,
          OCTAL,
          HEXADECIMAL
    };
  2. 声明一个名为displayInBases的函数,该函数接受两个参数——整数和基数。接下来,定义 switch 语句,该语句测试接收到的数字基数,并以另外两种数字表示形式显示给定的整数:

    void displayInBases(const int number, const BASE numberBase)
    {
      switch(numberBase)
      {
      case DECIMAL:
        std::cout << "Your input in octal with base: "
              << std::showbase << std::oct << number
              << ", without base: " 
              << std::noshowbase << std::oct << number << std::endl;
        std::cout << "Your input in hexadecimal with base: "
              << std::showbase << std::hex << number
              << ", without base: " 
              << std::noshowbase << std::hex << number << std::endl;
        break;
      case OCTAL:
        std::cout << "Your input in hexadecimal with base: "
              << std::showbase << std::hex << number
              << ", without base: " 
              << std::noshowbase << std::hex << number << std::endl;
        std::cout << "Your input in decimal with base: "
              << std::showbase << std::dec << number
              << ", without base: " 
              << std::noshowbase << std::dec << number << std::endl;
        break;
      case HEXADECIMAL:
        std::cout << "Your input in octal with base: "
              << std::showbase << std::oct << number
              << ", without base: " 
              << std::noshowbase << std::oct << number << std::endl;
        std::cout << "Your input in decimal with base: "
              << std::showbase << std::dec << number
              << ", without base: " 
              << std::noshowbase << std::dec << number << std::endl;
        break;
      }
    }
  3. 进入功能,定义用于读取用户输入的整数变量:

    int integer; 
  4. 创建一个无限 while 循环。在循环中,要求用户输入一个十进制值。将输入读取为十进制整数。传递给显示基站功能。接下来,要求用户输入一个十六进制值。将输入读取为十六进制整数。传递到显示基站功能。最后,要求用户输入一个八进制值。将输入读取为八进制整数。传递到显示框功能:

    int main(int argc, char **argv)
    {
      int integer;
      while(true)
      {
        std::cout << "Enter the decimal value: ";
        std::cin >> std::dec >> integer;
        displayInBases(integer, BASE::DECIMAL);
        std::cout << "Enter the hexadecimal value: ";
        std::cin >> std::hex >> integer;
        displayInBases(integer, BASE::HEXADECIMAL);
        std::cout << "Enter the octal value: ";
        std::cin >> std::oct >> integer;
        displayInBases(integer, BASE::OCTAL);
      }
      return 0;
    }
  5. Build and run the application. Follow the output and enter, for example, 12 in different numeric representations. The output should be as follows:

    Figure 6.9: The result of executing Exercise 4, part 1

    图 6.9:执行练习 4 第 1 部分的结果
  6. 现在,让我们在std::setbase()功能中更改std::decstd::octstd::hex,检查输出是否相同。首先,为std::setbase()支持添加< iomanip >头。接下来,在循环中的主功能中,将std::dec替换为std::setbase(10)std::hex替换为std::setbase(16),将std::oct替换为std::setbase(8) :

    int main(int argc, char **argv)
    {
      int integer;
      while(true)
      {
        std::cout << "Enter the decimal value: ";
        std::cin >> std::setbase(10) >> integer;
        displayInBases(integer, BASE::DECIMAL);
        std::cout << "Enter the hexadecimal value: ";
        std::cin >> std::setbase(16) >> integer;
        displayInBases(integer, BASE::HEXADECIMAL);
        std::cout << "Enter the octal value: ";
        std::cin >> std::setbase(8) >> integer;
        displayInBases(integer, BASE::OCTAL);
      }
      return 0;
    }
  7. 再次,构建并运行应用。根据输出,在不同的数字表示中输入相同的整数(12)。输出应如下所示:

Figure 6.10: The result of executing Exercise 4, part 2

图 6.10:执行练习 4 第 2 部分的结果

现在,比较结果。如您所见,输出是相同的。通过这样做,我们确保了这些功能是可互换的。

浮点格式的输入/输出操纵器

< ios >表头,有声明的改变浮点数字格式的函数:std::fixedstd::scientificstd::hexfloatstd::defaultfloat。它们在没有参数的情况下被调用,并将浮动字段分别设置为固定值、科学值、固定值和科学值以及默认值。还有std:: showpointstd::noshowpoint功能,控制显示浮点数字。它们只影响产量。std::noshowpoint函数只影响没有小数部分的浮点数字。

< iomanip >头中,有一个声明的std:: setprecision函数,用一个代表精度的数字调用。当点右侧的数字被删除时,结果将被舍入。如果数字太大,无法以正常方式表示,则忽略精度规格,以更方便的方式显示数字。您只需要设置一次精度,并在需要另一个精度时更改它。当您选择存储浮点变量的数据类型时,您应该注意到一些技巧。在 C++ 中,有三种数据类型可以表示浮点值:浮点、双精度和长双精度。

浮点通常是 4 字节,双精度是 8 字节,长双精度是 8、12 或 16 字节。所以,每种方法的精确度都是有限的。浮点型最多可容纳 6-9 个有效数字,双精度型最多可容纳 15-18 个有效数字,长双精度型最多可容纳 33-36 个有效数字。如果您想比较它们之间的差异,请查看下表:

Figure 6.11: Comparison table of the floating-point types

图 6.11:浮点类型对照表

注意

当你需要超过六个有效数字的精度时,请选择双精度,否则你会得到意想不到的结果。

让我们完成一个练习,并学习如何在实践中使用它们。

练习 5:以不同格式显示输入的浮点数

在本练习中,我们将编写一个应用,在无限循环中,要求用户输入一个浮点数。读取输入后,它以不同的格式类型显示该数字。要执行本练习,请完成以下步骤:

  1. 包括用于流支持的< iostream >报头和用于std::setprecision支持的< iomanip >

  2. 接下来,声明一个模板格式打印函数,该函数有一个名为浮点的模板参数,并接受一个这种类型的参数变量。接下来,通过调用std::cout对象中的precision()函数,将先前的精度存储在自动变量中。然后,在终端中以不同的格式显示给定的数字:带点、不带点,以及固定、科学、十六进制和默认浮点格式。接下来,在 for 循环中,从 0 到 22,以精度和循环计数器的大小显示给定的数字。循环退出后,使用我们之前存储的值设置回精度:

    template< typename FloatingPoint >
    void formattingPrint(const FloatingPoint number)
    {
         auto precision = std::cout.precision();
         std::cout << "Default formatting with point: "
                   << std::showpoint << number << std::endl
                   << "Default formatting without point: "
                   << std::noshowpoint << number << std::endl
                   << "Fixed formatting: "
                   << std::fixed << number << std::endl
                   << "Scientific formatting: "
                   << std::scientific << number << std::endl
                   << "Hexfloat formatting: "
                   << std::hexfloat << number << std::endl
                   << "Defaultfloat formatting: "
                   << std::defaultfloat << number << std::endl;
         for (int i = 0; i < 22; i++)
         {
              std::cout << "Precision: " << i 
                        << ", number: " << std::setprecision(i) 
                        << number << std::endl;
         }
         std::cout << std::setprecision(precision);
    }
  3. 进入功能。声明一个名为floatNumfloat变量、一个名为doubleNum的双变量和一个名为longDoubleNum的长双变量。然后在无限 while 循环中,要求用户输入一个浮点数,读取输入到longDoubleNum,传递到formattingPrint功能。接下来,使用longDoubleNum值初始化doubleNum,并将其传递给formating print功能。接下来,使用longDoubleNum值初始化浮动,并将其传递到格式打印功能:

    int main(int argc, char **argv)
    {
         float floatNum;
         double doubleNum;
         long double longDoubleNum;
         while(true)
         {
              std::cout << "Enter the floating-point digit: ";
              std::cin >> std::setprecision(36) >> longDoubleNum;
              std::cout << "long double output" << std::endl;
              formattingPrint(longDoubleNum);
              doubleNum = longDoubleNum;
              std::cout << "double output" << std::endl;
              formattingPrint(doubleNum);
              floatNum = longDoubleNum;
              std::cout << "float output" << std::endl;
              formattingPrint(floatNum);
         }
         return 0;
    }
  4. 构建并运行应用。跟随输出,输入有效位数为22的浮点值,如0.2222222222222222222222222222222222。我们会得到一个长输出。现在,我们需要把它分开来分析。下面是长双精度值输出的一部分截图:

Figure 6.12: The result of executing Exercise 5, part 1

图 6.12:执行练习 5 第 1 部分的结果

我们可以看到,默认情况下,固定和defaultfloat编队只输出六个有效数字。通过科学的格式化,值的输出看起来与预期的一样。当我们调用设定精度(0)设定精度(1)时,我们期望在该点之后不会输出任何数字。但是如果数字小于 1 设定精度,这将在点后留下一个数字。通过这样做,我们将看到直到 21 精度的正确输出。这意味着在我们的系统中,长双精度的最大精度是 20 个有效数字。现在,让我们分析双精度值的输出:

Figure 6.13: The result of executing Exercise 5, part 2

图 6.13:执行练习 5 第 2 部分的结果

在这里,我们可以看到相同的格式化结果,但精度不同。不准确的输出从精度 17 开始。这意味着,在我们的系统中,双精度的最大精度是 16 个有效数字。现在,让我们分析浮点值的输出:

Figure 6.14: The result of executing Exercise 5, part 3

图 6.14:执行练习 5 第 3 部分的结果

在这里,我们可以看到相同的格式化结果,但是精度不同。不准确的输出从精度 8 开始。这意味着,在我们的系统中,浮点数的最大精度是 8 位有效数字。不同系统上的结果应该是不同的。对它们的分析将帮助您为应用选择正确的数据类型。

注意

切勿使用浮动数据类型来表示货币或汇率;你可能会得到错误的结果。

用于布尔格式的输入/输出操纵器

< ios >头文件中,有用于更改布尔格式的声明函数:std::boolalphaSTD::nopoolalpha。它们在没有参数的情况下被调用,并允许我们分别以文本或数字的方式显示布尔值。它们用于输入和输出操作。让我们考虑一个使用这些输入/输出操纵器进行输出操作的例子。我们将把布尔值显示为文本和数字:

std::cout << "Default formatting of bool variables: "
          << "true: " << true
          << ", false: " << false << std::endl;
std::cout << "Formatting of bool variables with boolalpha flag is set: "
          << std::boolalpha
          << "true: " << true
          << ", false: " << false << std::endl;
std::cout << "Formatting of bool variables with noboolalpha flag is set: "
          << std::noboolalpha
          << "true: " << true
          << ", false: " << false << std::endl;

编译并运行此示例后,您将获得以下输出:

Default formatting of bool variables: true: 1, false: 0
Formatting of bool variables with boolalpha flag is set: true: true, false: false
Formatting of bool variables with noboolalpha flag is set: true: 1, false: 0

如您所见,布尔变量的默认格式是使用std::noboolalpha标志执行的。为了在输入操作中使用这些函数,我们需要一个包含真/假单词或 0/1 符号的源字符串。输入操作中的std::boolalphaSTD::nopoolalpha函数调用如下:

bool trueValue, falseValue;
std::istringstream iss("false true");
iss >> std::boolalpha >> falseValue >> trueValue;
std::istringstream iss("0 1");
iss >> std::noboolalpha >> falseValue >> trueValue;

如果您随后输出这些变量,您将看到它们通过读取布尔值被正确初始化。

用于字段宽度和填充控制的输入/输出操纵器

在标准库中,也有通过输出字段的宽度进行操作的功能,当宽度大于输出数据时,应该使用哪些字符,这些填充字符应该插入到哪个位置。当您想要将输出对齐到左侧或右侧位置,或者想要用其他符号替换空格时,这些函数将非常有用。例如,假设您需要在两列中打印价格。如果使用标准格式,您将获得以下输出:

2.33 3.45
2.2 4.55
3.67 3.02

这个看起来不太好,很难读懂。如果我们应用格式,输出将如下所示:

2.33   3.45
2.2     4.55
3.67   3.02

这看起来更好。同样,您可能需要检查哪些字符用于填充空格,哪些字符实际上是您在数字之间插入的空格。例如,让我们将填充字符设置为“*”。您将获得以下输出:

2.33* 3.45*
2.2** 4.55*
3.67* 3.02*

现在,你可以看到空白处布满了星星。现在,我们已经考虑了格式化宽度和填充输出的有用之处,让我们考虑如何使用输入/输出操纵器来实现这一点。std::setwstd::setfill功能在< iomanip >头中声明。std::setw取整数值作为参数,将流的宽度设置为精确的 n 个字符。在少数情况下,宽度将设置为 0。它们如下:

  • 当用std::stringchar调用换挡操作符时
  • 当调用std::put_money()函数时
  • 调用std::quoted()函数时

< ios >表头中,有用于更改填充字符插入位置的声明功能:std::internalstd::leftstd::right。它们仅用于输出操作,并且只影响整数、浮点和货币值。

现在,让我们考虑一个一起使用它们的例子。让我们输出宽度为 10 的正、负、浮点和十六进制值,并用“#”替换填充字符:

std::cout << "Internal fill: " << std::endl
          << std::setfill('#')
          << std::internal
          << std::setw(10) << -2.38 << std::endl
          << std::setw(10) << 2.38 << std::endl
          << std::setw(10) << std::hex << std::showbase << 0x4b << std::endl;
std::cout << "Left fill: " << std::endl
          << std::left
          << std::setw(10) << -2.38 << std::endl
          << std::setw(10) << 2.38 << std::endl
          << std::setw(10) << std::hex << std::showbase << 0x4b << std::endl;
std::cout << "Right fill: " << std::endl
          << std::right
          << std::setw(10) << -2.38 << std::endl
          << std::setw(10) << 2.38 << std::endl
          << std::setw(10) << std::hex << std::showbase << 0x4b << std::endl;

构建并运行此示例后,您将获得以下输出:

Internal fill: 
-#####2.38
######2.38
0x######4b
Left fill: 
-2.38#####
2.38######
0x4b######
Right fill: 
#####-2.38
######2.38
######0x4b

其他数字格式的输入/输出操纵器

如果需要输出一个带“+”号的正数值,可以从< ios >头中使用另一个 I/O 操纵器–STD::show pos功能。也存在与意义相反的操纵器——T4 标准:无显示功能。它们都对产出有影响。它们的使用非常容易。让我们考虑以下示例:

std::cout << "Default formatting: " << 13 << " " << 0 << std::endl;
std::cout << "showpos flag is set: " << std::showpos << 13 << " " << 0 << std::endl;
std::cout << "noshowpos flag is set: " << std::noshowpos << 13 << " " << 0 << std::endl;

这里,我们使用默认格式进行输出,然后使用std::showpos标志,最后使用std::noshowpos标志。如果您构建并运行这个小示例,您会看到,默认情况下,std::noshowpos标志被设置。看看执行的结果:

Default formatting: 13 0
showpos flag is set: +13 +0
noshowpos flag is set: 13 0

您还希望输出浮点或十六进制数字的大写字符,以便您可以使用来自< ios >头:std::大写std::无符号的函数。他们只处理输出。让我们考虑一个小例子:

std::cout << "12345.0 in uppercase with precision 4: "
          << std::setprecision(4) << std::uppercase << 12345.0 << std::endl;
std::cout << "12345.0 in no uppercase with precision 4: "
          << std::setprecision(4) << std::nouppercase << 12345.0 << std::endl;
std::cout << "0x2a in uppercase: "
          << std::hex << std::showbase << std::uppercase << 0x2a << std::endl;
std::cout << "0x2a in nouppercase: "
          << std::hex << std::showbase << std::nouppercase << 0x2a << std::endl;

在这里,我们输出带和不带std::大写标志的浮点和十六进制数字。默认情况下,设置标准::无标签标志。看看执行的结果:

12345.0 in uppercase with precision 4: 1.234E+004
12345.0 in no uppercase with precision 4: 1.234e+004
0x2a in uppercase: 0X2A
0x2a in nouppercase: 0x2a

用于空白处理的输入/输出操纵器

在标准库中,有处理空白的函数。来自<的 **std::ws**函数是流> 头,只对输入流起作用,并丢弃前导空格。来自< ios >头的std::skipwsstd::noskipws功能用于控制前导空格的读写。它们为输入和输出流工作。当设置了std::skipws标志时,流会忽略字符序列输入前面的空白。默认情况下,设置std::skipws标志。让我们考虑一个使用这些输入/输出操纵器的例子。首先,我们将使用默认格式读取输入,并输出我们所读取的内容。接下来,我们将清除字符串并使用std::noskipws标志读取数据:

std::string name;
std::string surname;
std::istringstream("Peppy Ping") >> name >> surname;
std::cout << "Your name: " << name << ", your surname: " << surname << std::endl;
name.clear();
surname.clear();
std::istringstream("Peppy Ping") >> std::noskipws >> name >> surname;
std::cout << "Your name: " << name << ", your surname: " << surname << std::endl;

构建并运行此示例后,我们将获得以下输出:

Your name: Peppy, your surname: Ping
Your name: Peppy, your surname:

从前面的输出可以看出,如果我们设置std::noskipws标志,我们也会读取空白。

< iomanip >表头中,已经声明了此表头的一个不寻常的操纵器:std::报价。当此函数应用于输入时,它用转义字符将给定的字符串用引号括起来。如果输入字符串已经包含转义引号,它也会读取它们。为了理解这一点,让我们考虑一个小例子。我们将使用不带引号的文本初始化一个源字符串,另一个字符串将使用带转义引号的文本初始化。接下来,我们将使用std::ostringstream读取它们,而不设置标志,并通过std::cout提供输出。看看下面的例子:

std::string str1("String without quotes");
std::string str2("String with quotes \"right here\"");
std::ostringstream ss;
ss << str1;
std::cout << "[" << ss.str() << "]" << std::endl;
ss.str("");
ss << str2;
std::cout << "[" << ss.str() << "]" << std::endl; 

因此,我们将获得以下输出:

[String without quotes]
[String with quotes "right here"] 

现在,让我们做同样的输出,但是使用std::报价调用:

std::string str1("String without quotes");
std::string str2("String with quotes \"right here\"");
std::ostringstream ss;
ss << std::quoted(str1);
std::cout << "[" << ss.str() << "]" << std::endl;
ss.str("");
ss << std::quoted(str2);
std::cout << "[" << ss.str() << "]" << std::endl;

现在,我们将有一个不同的结果:

["String without quotes"]
["String with quotes \"right here\""]

您是否注意到第一个字符串用引号括起来,第二个字符串的子字符串“就在这里”用转义字符存储?

现在,您知道如何在引号中包装任何字符串了。当使用std::quoted()时,甚至可以编写自己的包装来减少行数。例如,我们将流的工作转移到一个单独的函数:

std::string quote(const std::string& str)
{
     std::ostringstream oss;
     oss << std::quoted(str);
     return oss.str();
}

然后,当我们需要调用包装器时,我们会执行以下操作:

std::string str1("String without quotes");
std::string str2("String with quotes \"right here\"");
std::coot << "[" << quote(str1) << "]" << std::endl;
std::cout << "[" << quote(str2) << "]" << std::endl;

现在,它看起来好多了。第一个话题已经结束了,让我们复习一下刚刚学过的内容。在实践中,我们了解了预定义流对象的使用、带有内部内存的文件的输入/输出操作、输入/输出格式以及用户定义类型的输入/输出。现在我们已经完全理解了如何在 C++ 中使用 I/O 库,我们将考虑当标准流不够用时该怎么办。

制作附加流

当提供的流接口不足以解决您的任务时,您可能希望创建一个额外的流来重用现有的接口之一。您可能需要从特定的外部设备输出或提供输入,或者您可能需要添加调用输入/输出操作的线程的标识。有几种方法可以做到这一点。您可以创建一个新的类,将现有流中的一个聚合为私有成员。它将通过已经存在的流函数实现所有需要的函数,例如移位运算符。另一种方法是继承一个现有的类,并以你需要的方式覆盖所有的虚函数。

首先,你必须选择合适的类来使用。您的选择应该取决于您想要添加的修改。如果需要修改输入或输出操作,选择std::basic_istreamstd::basic_ostreamstd::basic_iostream。如果要修改状态信息、控制信息、私有存储等,选择std::ios_base。如果您想修改与流缓冲区相关的内容,请选择std::basic_ios。选择正确的基类后,继承前面提到的类之一来创建一个额外的流。

还有一件事你必须知道——如何正确初始化标准流。在文件或字符串流和基本流类的初始化方面,有一些很大的区别。我们来复习一下。要初始化从文件流类派生的类的对象,需要传递文件名。若要初始化从字符串流类派生的类的对象,需要调用默认构造函数。它们都有自己的流缓冲区,因此在初始化时不需要额外的操作。要初始化从基本流类派生的类的对象,需要传递一个指向流缓冲区的指针。您可以创建一个缓冲区变量,也可以使用预定义流对象的缓冲区,如std::coutstd::cerr

让我们详细回顾一下这两种创建附加流的方法。

如何制作附加流–合成

组合意味着您将类的私有部分中的一些标准流对象声明为类成员。当您选择一个合适的标准流类时,转到它的头并注意它有哪个构造函数。然后,您需要在类的构造函数中正确初始化这个成员。要将您的类用作流对象,您需要实现基本函数,如 shift 运算符、str()等。您可能还记得,每个流类都为内置类型重载了移位运算符。他们还为预定义的功能(如标准::endl)提供了过载的换档操作符。您需要能够将您的类用作真正的流对象。我们只需要创建一个模板,而不是声明所有 18 个重载的移位运算符。此外,为了允许使用预定义的操纵器,我们必须声明一个带函数指针的移位运算符。

这看起来并不困难,所以让我们尝试为std::ostream对象实现这样一个“包装器”。

练习 6:在用户定义的类中组成标准流对象

在本练习中,我们将创建自己的流对象,该对象包装了std::ostream对象并添加了附加特征。我们将创建一个名为扩展流的类,该类将向终端输出数据,并在每条输出的前面插入以下数据:日期和时间以及线程标识。要完成本练习,请执行以下步骤:

  1. Include the required headers: <iostream> for std::endl support, <sstream> for std::ostream support, <thread> for std::this_thread::get_id() support, <chrono> for std::chrono::system_clock::now(), and <ctime> for converting timestamps into readable representations:

    注意

    不要忘了在 Eclipse 项目设置中添加 pthread 链接器标志以获得线程支持:项目 - > 属性->C/c++ Build->设置 - > G++ 链接器 - > 杂项 - > 链接器标志输入'-PTT 此路径对 Eclipse 版本 3.8.1 有效;不同版本可能会有所不同。

    #include <iostream>
    #include <sstream>
    #include <thread>
    #include <chrono>
    #include <ctime>
  2. 接下来,声明扩展流类。声明名为m_ossstd::ostream变量和名为的 bool 变量 writeAdditionalInfo。该 bool 变量将用于指示是否应打印扩展数据:

    class extendedOstream
    {
    private:
         std::ostream& m_oss;
         bool writeAdditionalInfo;
    };
  3. 接下来,在公共部分,定义一个默认构造函数并用std::cout初始化m_oss,以将输出重定向到终端。用初始化写附加信息:

    extendedOstream()
         : m_oss(std::cout)
         , writeAdditionalInfo(true)
    {
    }
  4. 定义一个模板重载的左移位运算符,< <,返回对extendedstream的引用,取一个模板参数值。然后如果writeAdditionalInfotrue,输出时间、线程 ID、给定值,然后将writeAdditionalInfo设置为false。如果写附加信息,只输出给定值。该功能将用于所有内置类型的输出:

    template<typename T>
    extendedOstream& operator<<(const T& value)
    {
         if (writeAdditionalInfo)
         {
              std::string time = fTime();
              auto id = threadId();
              m_oss << time << id << value;
              writeAdditionalInfo = false;
         }
         else
         {
              m_oss << value;
         }
         return *this;
    }
  5. 定义另一个重载左移位运算符,该运算符将指向函数的指针作为参数,并返回对std::ostream的引用。在函数体中,将writeaddionaliinfo设置为true,调用给定的函数,并将m_oss作为参数传递。该重载操作符将用于预定义的功能,如std::endl :

    extendedOstream&
    operator<<(std::ostream& (*pfn)(std::ostream&))
    {
         writeAdditionalInfo = true;
         pfn(m_oss);
         return *this;
    }
  6. 在私有部分,定义fTime函数,该函数返回 std::string。它有一个系统时间。将其格式化为可读的表示形式并返回:

    std::string fTime()
    {
         auto now = std::chrono::system_clock::now();
         std::time_t time = std::chrono::system_clock::to_time_t(now);
         std::ostringstream oss;
         std::string strTime(std::ctime(&time));
         strTime.pop_back();
         oss << "[" << strTime << "]";
         return oss.str();
    }
  7. 在私有部分,定义threadId()函数,该函数返回一个字符串。获取当前线程的id,格式化后返回:

    std::string threadId()
    {
         auto id = std::this_thread::get_id();
         std::ostringstream oss;
         oss << "[" << std::dec << id << "]";
         return oss.str();
    }
  8. 进入功能。为了测试我们的流对象如何工作,创建一个名为ossextended stream类型的对象。输出不同的数据,例如整数、浮点、十六进制和 bool:

    extendedOstream oss;
    oss << "Integer: " << 156 << std::endl;
    oss << "Float: " << 156.12 << std::endl;
    oss << "Hexadecimal: " << std::hex << std::showbase 
        << std::uppercase << 0x2a << std::endl;
    oss << "Bool: " << std::boolalpha << false << std::endl;
  9. 然后,创建一个线程,用 lambda 函数初始化它,并将相同的输出放入 lambda 中。别忘了加入线程:

    std::thread thr1([]()
         {
              extendedOstream oss;
              oss << "Integer: " << 156 << std::endl;
              oss << "Float: " << 156.12 << std::endl;
              oss << "Hexadecimal: " << std::hex << std::showbase
                  << std::uppercase << 0x2a << std::endl;
              oss << "Bool: " << std::boolalpha << false << std::endl;
         });
    thr1.join();
  10. 现在,构建并运行应用。您将获得以下输出:

图 6.15:执行练习 6 的结果

考虑输出的每一行。可以看到输出的下一种格式:“[日期和时间][线程 ID]输出数据”。确保线程标识因线程而异。然后,数据以预期的格式输出。因此,如您所见,使用标准流的组合来实现您自己的输入/输出流对象并不太难。

如何制作附加流–继承

继承意味着您创建自己的流类,并从具有虚拟析构函数的标准流对象中继承它。您的类必须是模板类,并且有模板参数,就像在父类中一样。要将所有继承的函数用于类的对象,继承应该是公共的。在构造函数中,您应该初始化父类,这取决于类的类型——用文件名、流缓冲区,或者默认情况下。接下来,您应该覆盖那些根据您的需求而改变的基本功能。

我们需要继承标准流类的最常见情况是,当我们想要为新设备(如套接字或打印机)实现输入/输出操作时。所有定义的标准流类都负责格式化输入和输出,并具有字符串、文件和终端的重载。只有std::basic_streambuf类负责处理设备,所以我们需要继承这个类,编写自己的实现,并将其设置为标准类的流缓冲区。streambuf类的核心功能是传输字符。它可以在刷新之间使用缓冲区存储字符,也可以在每次调用后立即刷新。这些概念被称为缓冲和非缓冲字符传输。

输出操作的缓冲字符传输工作如下:

  1. 字符通过sputc()函数调用缓冲到内部缓冲区。
  2. 当缓冲区已满时,sputc()调用受保护的虚拟成员,即溢出()
  3. 溢出()功能将所有缓冲区内容传输到外部设备。
  4. 当调用pubsync()函数时,它调用被保护的虚拟成员sync()
  5. sync()功能将所有缓冲区内容传输到外部设备。

用于输出操作的无缓冲字符传输的工作方式略有不同:

  1. 字符被传递到sputc()功能。
  2. sputc()函数立即调用被保护的虚拟成员overflow()
  3. 溢出()功能将所有缓冲区内容传输到外部设备。

因此,对于输出操作的缓冲和非缓冲字符传输,我们应该覆盖溢出()和 sync()函数,它们执行实际工作。

输入操作的缓冲字符传输工作如下:

  1. sgetc()函数从内部缓冲区读取字符。
  2. sgetc()功能调用sunetc()功能,使消耗的角色再次可用。
  3. 如果内部缓冲区为空,则sgetc()函数调用下溢()函数。
  4. 下溢()功能将字符从外部设备读取到内部缓冲区。

sgetc()下溢()函数总是返回相同的字符。为了每次读取不同的字符,我们还有另外一对功能:sbumpc()uflow()。用它们读字符的算法是一样的:

  1. sbumpc()函数从内部缓冲区读取字符。
  2. sbumpc()函数调用sputback()函数,使下一个字符可用于输入。
  3. 如果内部缓冲区为空,则sbumpc()函数调用uflow()函数。
  4. uflow()功能将字符从外部设备读取到内部缓冲区。

用于输入操作的无缓冲字符传输的工作原理如下:

  1. sgetc()函数调用被称为下溢()的受保护虚拟成员。
  2. 下溢()功能将字符从外部设备读取到内部缓冲区。
  3. sbumpc()函数调用名为uflow()的受保护虚拟成员。
  4. uflow()功能将字符从外部设备读取到内部缓冲区。

如果出现任何错误,将调用名为pbackfail()的受保护虚拟成员来处理错误情况。如您所见,要覆盖std::basic_streambuf类,我们需要覆盖使用外部设备的虚拟成员。对于输入streambuf,我们应该覆盖下溢()uflow()pbackfail()成员。对于输出streambuf,我们应该覆盖overflow()sync()成员。

让我们更详细地考虑所有这些步骤。

练习 7:继承标准流对象

在本练习中,我们将创建一个名为extended_streambuf的类,该类继承自std::basic_streambuf。我们将使用std::cout流对象的一个缓冲区,并覆盖 overflow()函数,以便我们可以将数据写入外部设备(stdout)。接下来,我们将编写一个继承自std::basic_ostream类的extended_ostream类,并将一个流缓冲区设置为extended_streambuf。最后,我们将对我们的包装类做一些小的修改,并使用extended_ostream作为私有流成员。要完成本练习,请执行以下步骤:

  1. 包括所需的标题: < iostream > 表示 std::endl 支持、T22】s stream>T5 表示 std::ostream 和 std::basic_streambuf 支持、T24】螺纹>T11 表示STD::this _ thread::get _ id()支持、T26】chrono>

  2. 创建一个名为extended_streambuf的模板类,它继承自std::basic_streambuf类。覆盖名为overflow()的公共成员,该成员向输出流中写入一个字符,并返回 EOF 或写入的字符:

    template< class CharT, class Traits = std::char_traits<CharT> >
    class extended_streambuf : public std::basic_streambuf< CharT, Traits >
    {
    public:
        int overflow( int c = EOF ) override
        {
            if (!Traits::eq_int_type(c, EOF))
            {
                return fputc(c, stdout);
            }
            return Traits::not_eof(c);
        }
    };
  3. 接下来,创建一个名为extended_ostream的模板类,它是从std::basic_ostream类派生而来的。在私有部分,定义extended_streambuf类的一个成员,即 buffer。用缓冲成员初始化std::basic_ostream父类。接下来,在构造函数体中,从父类调用init()函数,以 buffer 作为参数。另外,重载rdbuf()函数,该函数返回一个指向缓冲变量的指针:

    template< class CharT, class Traits = std::char_traits<CharT> >
    class extended_ostream : public std::basic_ostream< CharT, Traits >
    {
    public:
        extended_ostream()
            : std::basic_ostream< CharT, Traits >::basic_ostream(&buffer)
            , buffer()
        {
            this->init(&buffer);
        }
        extended_streambuf< CharT, Traits >* rdbuf () const
        {
            return (extended_streambuf< CharT, Traits >*)&buffer;
        }
    private:
        extended_streambuf< CharT, Traits > buffer;
    };
  4. 扩展流类重命名为记录器,以避免类似名称的误解。保持现有界面不变,但是用我们自己的流替换std::ostream &成员,即对象- extended_ostream。完整的类如下所示:

    class logger
    {
    public:
         logger()
              : m_log()
              , writeAdditionalInfo(true)
         {
         }
         template<typename T>
         logger& operator<<(const T& value)
         {
              if (writeAdditionalInfo)
              {
                   std::string time = fTime();
                   auto id = threadId();
                   m_log << time << id << value;
                   writeAdditionalInfo = false;
              }
              else
              {
                   m_log << value;
              }
              return *this;
         }
         logger&
         operator<<(std::ostream& (*pfn)(std::ostream&))
         {
              writeAdditionalInfo = true;
              pfn(m_log);
              return *this;
         }
    private:
         std::string fTime()
         {
              auto now = std::chrono::system_clock::now();
              std::time_t time = std::chrono::system_clock::to_time_t(now);
              std::ostringstream log;
              std::string strTime(std::ctime(&time));
              strTime.pop_back();
              log << "[" << strTime << "]";
              return log.str();
         }
         std::string threadId()
         {
              auto id = std::this_thread::get_id();
              std::ostringstream log;
              log << "[" << std::dec << id << "]";
              return log.str();
         }
    private:
         extended_ostream<char> m_log;
         bool writeAdditionalInfo;
    };
  5. 进入功能,将扩展数据流对象更改为记录器对象。保持代码的其余部分不变。现在,构建并运行该练习。您将看到上一个练习中给出的输出,但是在本例中,我们使用了自己的流缓冲区、自己的流对象和向输出添加附加信息的包装类。查看下面截图中显示的执行结果,并将其与之前的结果进行比较。确保它们相似。如果是的话,这意味着我们做得很好,我们继承的类也像预期的那样工作:

Figure 6.16: The result of executing Exercise 7

图 6.16:执行练习 7 的结果

在这个主题中,我们已经做了很多,并学习了如何以不同的方式创建额外的流。我们考虑了所有适合继承的类,以及哪个类更适合不同的需求。我们还学习了如何从基本 streambuf 类继承来实现与外部设备的工作。现在,我们将学习如何以异步方式使用输入/输出流。

利用异步输入/输出

在很多情况下,输入/输出操作会花费大量时间,例如,创建备份文件、搜索大型数据库、读取大型文件等。您可以使用线程来执行输入/输出操作,而不会阻止应用的执行。但是对于某些应用来说,这不是处理长时间输入/输出的合适方式,例如,当每秒钟有数千个输入/输出操作时。在这种情况下,C++ 开发人员使用异步输入/输出。它节省了线程资源,并确保执行的线程不会被阻塞。让我们考虑一下什么是同步和异步输入/输出。

你可能还记得第 5 章,哲学家的晚餐——线程和并发,同步操作意味着一些线程调用操作并等待它完成。它可以是单线程或多线程应用。要点是线程正在等待输入/输出操作完成。

当操作不阻塞工作线程的执行时,异步执行发生。执行异步输入/输出操作的线程发送一个异步请求,并继续执行另一个任务。当操作完成时,初始线程将被通知完成,并且它可以根据需要处理结果。

由此看来,异步 I/O 比同步好很多,但这要看情况。如果您需要执行大量快速输入/输出操作,由于处理内核输入/输出请求和信号的开销,遵循同步方式会更合适。因此,在为应用开发架构时,您需要考虑所有可能的场景。

标准库不支持异步输入/输出操作。因此,为了利用异步输入/输出,我们需要考虑替代库或编写自己的实现。首先,让我们考虑平台相关的实现。然后,我们将看看跨平台库。

Windows 平台上的异步 I/O

Windows 支持各种设备的 I/O 操作:文件、目录、驱动器、端口、管道、套接字、终端等等。一般来说,我们对所有这些设备使用相同的输入/输出接口,但某些设置因设备而异。让我们考虑在 Windows 中对文件进行输入/输出操作。

因此,在 Windows 中,我们需要打开一个设备,并为其获取一个处理程序。不同的设备以不同的方式打开。要打开文件、目录、驱动器或端口,我们使用 <窗口头中的创建文件功能。要打开管道,我们使用创建命名管道功能。要打开一个套接字,我们使用 socket()和 accept()函数。打开一个终端,我们使用CreateConsoleScreenBuffer和 GetStdHandle 功能。它们都返回一个设备处理程序,该程序用于处理该设备的所有功能。

CreateFile函数采用七个参数来管理打开设备的工作。函数声明如下所示:

HANDLE CreateFile( PCTSTR pszName, 
                   DWORD  dwDesiredAccess, 
                   DWORD  dwShareMode, 
                   PSECURITY_ATTRIBUTES psa, 
                   DWORD  dwCreationDisposition, 
                   DWORD  dwFlagsAndAttributes, 
                   HANDLE hFileTemplate);

第一个参数是pszName–文件的路径。第二个参数调用dwdesireaccess并管理对设备的访问。它可以采用以下值之一:

0 // only for configuration changing
GENERIC_READ // only reading
GENERIC_WRITE // only for writing
GENERIC_READ | GENERIC_WRITE // both for reading and writing

第三个参数dwShareMode管理当文件已经打开时,操作系统应该如何处理所有新的创建文件调用。它可以采用以下值之一:

0 // only one application can open device simultaneously
FILE_SHARE_READ // allows reading by multiple applications simultaneously
FILE_SHARE_WRITE // allows writing by multiple applications simultaneously
FILE_SHARE_READ | FILE_SHARE_WRITE // allows both reading and writing by multiple applications simultaneously
FILE_SHARE_DELETE // allows moving or deleting by multiple applications simultaneously

第四个参数psa通常设置为。第五个参数dwCreationDisposition管理文件是打开还是创建。它可以采用以下值之一:

CREATE_NEW // creates new file or fails if it is existing
CREATE_ALWAYS // creates new file or overrides existing
OPEN_EXISTING // opens file or fails if it is not exists
OPEN_ALWAYS // opens or creates file
TRUNCATE_EXISTING // opens existing file and truncates it or fails if it is not exists

第六个参数dwFlagsAndAttributes,管理缓存或使用文件。它可以采用以下值之一来管理缓存:

FILE_FLAG_NO_BUFFERING // do not use cache
FILE_FLAG_SEQUENTIAL_SCAN // tells the OS that you will read the file sequentially
FILE_FLAG_RANDOM_ACCESS // tells the OS that you will not read the file in sequentially
FILE_FLAG_WR1TE_THROUGH // write without cache but read with

它可以采用以下值之一来管理文件工作:

FILE_FLAG_DELETE_ON_CLOSE // delete file after closing (for temporary files)
FILE_FLAG_BACKUP_SEMANTICS // used for backup and recovery programs
FILE_FLAG_POSIX_SEMANTICS // used to set case sensitive when creating or opening a file
FILE_FLAG_OPEN_REPARSE_POINT // allows to open, read, write, and close files differently
FILE_FLAG_OPEN_NO_RECALL // prevents the system from recovering the contents of the file from archive media
FILE_FLAG_OVERLAPPED // allows to work with the device asynchronously

它可以采用下列文件属性值之一:

FILE_ATTRIBUTE_ARCHIVE // file should be deleted
FILE_ATTRIBUTE_ENCRYPTED // file is encrypted
FILE_ATTRIBUTE_HIDDEN // file is hidden
FILE_ATTRIBUTE_NORMAL // other attributes are not set
FILE_ATTRIBUTE_NOT_CONTENT_ INDEXED // file is being processed by the indexing service
FILE_ATTRIBUTE_OFFLINE // file is transferred to archive media
FILE_ATTRIBUTE_READONLY // only read access
FILE_ATTRIBUTE_SYSTEM // system file
FILE_ATTRIBUTE_TEMPORARY // temporary file

最后一个参数hFileTemplate将打开文件的处理程序或空值作为参数。如果文件处理程序通过,则创建文件功能将忽略所有属性和标志,并使用打开文件的属性和标志。

以上就是关于创建文件参数。如果无法打开设备,则返回无效 _ 句柄 _ 值。以下示例演示如何打开文件进行读取:

#include <iostream>
#include <Windows.h>
int main()
{
     HANDLE hFile = CreateFile(TEXT("Test.txt"), GENERIC_READ, 
                                FILE_SHARE_READ | FILE_SHARE_WRITE, 
                                NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
     if (INVALID_HANDLE_VALUE == hFile)
         std::cout << "Failed to open file for reading" << std::endl;
     else
         std::cout << "Successfully opened file for reading" << std::endl;
     CloseHandle(hFile);
     return 0;
}

接下来,为了执行输入操作,我们使用读取文件功能。它以文件描述符为第一个参数,源缓冲区为第二个参数,最大读取字节数为第三个参数,读取字节数为第四个参数,同步执行的值或指向有效且唯一的重叠结构的指针为最后一个参数。如果操作成功,读取文件返回真,否则返回假。下面的示例演示如何从以前打开的文件中输入内容以供读取:

BYTE pb[20];
DWORD dwNumBytes;
ReadFile(hFile, pb, 20, &dwNumBytes, NULL);

为了执行输出操作,我们使用写文件功能。它的声明与ReadFile相同,但是第三个参数设置了要写入的字节数,第五个参数是写入的字节数。下面的示例演示如何输出到以前打开的文件进行写入:

BYTE pb[20] = "Some information\0";
DWORD dwNumBytes;
WriteFile(hFile, pb, 20, &dwNumBytes, NULL);

要将缓存数据写入设备,请使用FlushFileBuffer功能。它只需要一个参数——文件描述符。让我们转到异步输入/输出。要让操作系统知道您计划与设备异步工作,您需要使用文件 _ 标志 _ 重叠标志打开它。现在,打开文件进行写入或读取,如下所示:

#include <iostream>
#include <Windows.h>
int main()
{
     HANDLE hFile = CreateFile(TEXT("Test.txt"), GENERIC_READ, 
                                FILE_SHARE_READ | FILE_SHARE_WRITE, 
                                NULL, OPEN_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
     if (INVALID_HANDLE_VALUE == hFile)
         std::cout << "Failed to open file for reading" << std::endl;
     else
         std::cout << "Successfully opened file for reading" << std::endl;
     CloseHandle(hFile);
     return 0;
}

我们使用相同的操作来执行文件的读取或写入,即读取文件写入文件,唯一的区别是读取或写入的字节数被设置为空,我们必须传递一个有效且唯一的重叠对象。让我们考虑一下重叠对象的结构:

typedef struct _OVERLAPPED { 
DWORD  Internal; // for error code 
DWORD  InternalHigh; // for number of read bytes 
DWORD  Offset; 
DWORD  OffsetHigh; 
HANDLE hEvent; // handle to an event 
} OVERLAPPED, *LPOVERLAPPED;

内部成员设置为STATUS_PENDING,表示操作还没有开始。读取或写入的字节数将被写入内部高成员。偏移偏移在异步操作中被忽略。hEvent成员用于接收关于异步操作完成的事件。

注意

输入/输出操作的顺序没有保证,因此您不能依赖于此。如果你计划在一个地方写一个文件,在另一个地方读一个文件,你不能依赖这个顺序。

在异步模式下使用读文件写文件有一点不同寻常。如果输入/输出请求是同步执行的,它们将返回非零值。如果他们返回,您需要调用GetLastError功能来检查为什么返回。如果错误代码为ERROR_IO_PENDING,这意味着输入/输出请求已成功处理,处于挂起状态,稍后将执行。

你要记住的最后一点是,在输入输出操作完成之前,你不能移动或移除带有数据的重叠的对象或缓冲区。对于每个输入/输出操作,您应该创建一个新的重叠对象。

最后,让我们考虑系统通知我们完成输入/输出操作的方式。有一些这样的机制:释放设备、释放事件、产生警报和使用输入/输出端口。

“坏”方式:写文件读文件功能将设备设置为“占用”状态。当输入/输出操作完成时,驱动程序将设备设置为“空闲”状态。我们可以检查完成的输入/输出操作是调用等待单对象还是等待多对象功能。以下示例演示了这种方法:

#include <Windows.h>
#include <WinError.h>
int main()
{
     HANDLE hFile = CreateFile(TEXT("Test.txt"), GENERIC_READ,
                                     FILE_SHARE_READ | FILE_SHARE_WRITE, NULL,
                                     OPEN_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
     BYTE bBuffer[100];
     OVERLAPPED o = { 0 };
     BOOL bResult = ReadFile(hFile, bBuffer, 100, NULL, &o);
     DWORD dwError = GetLastError();
     if (bResult && (dwError == ERROR_IO_PENDING))
     {
          WaitForSingleObject(hFile, INFINITE);
          bResult = TRUE;
     }
     CloseHandle(hFile);
     return 0;
}

这是检查输入/输出操作是否已完成的最简单方法。但是这种方法使调用线程等待WaitForSingleObject调用,所以它变成了同步调用。此外,您可以为此设备启动一些输入/输出操作,但您不能确定线程会在设备需要的版本上唤醒。

好一点,但不是最好的做法:你还记得重叠结构的最后一个成员吗?通过调用创建事件功能创建一个事件,并将其设置为重叠对象。然后,当输入输出操作完成时,系统通过调用设置事件功能来释放该事件。接下来,当调用线程需要获得一个正在执行的 I/O 操作的结果时,您调用WaitForSingleObject并传递这个事件的描述符。以下示例演示了这种方法:

#include <Windows.h>
#include <synchapi.h>
int main()
{
     HANDLE hFile = CreateFile(TEXT("Test.txt"), GENERIC_READ, 
                               FILE_SHARE_READ | FILE_SHARE_WRITE,
                               NULL, OPEN_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
     BYTE bInBuffer[10];
     OVERLAPPED o = { 0 };
     o.hEvent = CreateEvent(NULL,TRUE,FALSE,"IOEvent");
     ReadFile(hFile, bInBuffer, 10, NULL, &o);
     ///// do some work
     HANDLE hEvent = o.hEvent;
     WaitForSingleObject(hEvent, INFINITE);
     CloseHandle(hFile);
     return 0;
}

如果您希望通知调用线程输入/输出操作的结束,这是一个非常简单的方法。但是这并不是实现这一点的理想方式,因为当有很多这样的操作时,您需要为每个操作创建一个事件对象。

还有一种不是最好的方法:可报警的输入/输出以下列方式工作。我们将ReadFileExWriteFileEx称为输入/输出。它们类似于标准的ReadFileWriteFile,但是我们不传递存储读或写字符数的变量,我们传递回调函数的地址。这个回调函数被称为完成例程,并具有以下声明:

VOID WINAPI 
CompletionRoutine(DWORD dwError,
                  DWORD dwNumBytes,
                  OVERLAPPED* po);

ReadFileEx``WriteFileEx将回调函数的地址传递给设备驱动。当设备上的操作完成时,驱动程序将回调函数的地址添加到 APC 队列中,并将指针添加到重叠结构中。然后,操作系统调用这个函数,并传递读或写字节数、错误代码和指向重叠结构的指针。

这种方法的主要缺点是编写回调函数和使用大量全局变量,因为回调函数在上下文中只有少量信息。不使用这种方法的另一个原因是,只有调用线程可以接收关于完成的通知。

现在我们已经讨论了糟糕的情况,让我们看看处理输入/输出结果的最佳方法——输入/输出端口。输入/输出完成端口被开发用于线程池。为了创建这样一个端口,我们使用CreateIoCompletionPort。这个函数的声明如下:

HANDLE 
CreateIoCompletionPort(HANDLE hFile,
                       HANDLE hExistingCompletionPort,
                       ULONG_PTR CompletionKey,
                       DWORD dwNumberOfConcurrentThreads);

该函数创建一个输入/输出完成端口,并将设备与该端口相关联。要完成这个动作,我们需要调用两次。为了创建新的完成端口,我们调用CreateIoCompletionPort函数并传递INVALID_HANDLE_VALUE作为第一个参数,NULL 作为第二个参数,0 作为第三个参数,并传递这个端口的线程数。将 0 作为第四个参数传递会将线程数设置为等于处理器数。

注意

对于输入/输出完成端口,建议使用等于处理器数量两倍的线程数量。

接下来,我们需要将这个端口与输入/输出设备相关联。因此,我们第二次调用CreateIoCompletionPort函数,传递一个设备的描述符,一个所创建的完成端口的描述符,一个指示读取或写入设备的常量,以及作为线程数的 0。然后,当我们需要得到完成的结果时,我们从我们的端口描述符中调用GetQueuedCompletionStatus。如果操作完成,函数会立即返回一个结果。如果没有,那么线程等待完成。以下示例演示了这种方法:

#include <Windows.h>
#include <synchapi.h>
int main()
{
    HANDLE hFile = CreateFile(TEXT("Test.txt"), GENERIC_READ,
                              FILE_SHARE_READ | FILE_SHARE_WRITE,
                              NULL, OPEN_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
    HANDLE m_hIOcp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
    CreateIoCompletionPort(hFile, m_hIOcp, 1, 0);

    BYTE bInBuffer[10];
    OVERLAPPED o = { 0 };
    ReadFile(hFile, bInBuffer, 10, NULL, &o);

    DWORD dwNumBytes;
    ULONG_PTR completionKey;
    GetQueuedCompletionStatus(m_hIOcp, &dwNumBytes, &completionKey, (OVERLAPPED**) &o, INFINITE);
    CloseHandle(hFile);
    return 0;
}

Linux 平台上的异步 I/O

Linux 上的异步输入/输出支持对不同设备的输入和输出,如套接字、管道和 TTYs,文件除外。是的,这很奇怪,但是 Linux 开发人员认为对文件的输入/输出操作足够快。

要打开输入/输出设备,我们使用 open()函数。它有以下声明:

int open (const char *filename, int flags[, mode_t mode])

第一个参数是文件名,而第二个参数是控制文件打开方式的位掩码。如果系统无法打开设备,open()将返回-1 值。如果成功,它会返回一个设备描述符。打开模式的可能标志有O_RDONLYO_WRONLYO_RDWR

为了执行输入/输出操作,我们使用名为aioPOSIX接口。它们有一组定义好的功能,例如aio_readaio_writeaio_fsync等等。它们用于启动异步操作。为了得到执行的结果,我们可以使用信号通知或者线程的实例化。或者,我们可以选择完全不被通知。全部在< aio.h >表头声明。

这些几乎都是以aiocb结构(异步 IO 控制块)为参数。它控制输入输出操作。该结构的声明如下:

struct aiocb 
{
    int aio_fildes;
    off_t aio_offset;
    volatile void *aio_buf;
    size_t aio_nbytes;
    int aio_reqprio;
    struct sigevent aio_sigevent;
    int aio_lio_opcode;
};

aio_fildes成员是打开的设备的描述符,而aio_offset成员是设备中应该执行读或写操作的偏移量。aio_buf成员是一个指向要读取或写入的缓冲区的指针。aio_nbytes成员是缓冲区的大小。aio_reqprio成员是该 io 操作执行的优先级。aio_sigevent成员是一个指出应该如何通知调用线程完成的结构。aio_lio_opcode成员是一种输入输出操作。下面的例子演示了如何初始化aiocb结构:

std::string fileContent;
constexpr int BUF_SIZE = 20;
fileContent.resize(BUF_SIZE, 0);
aiocb aiocbObj;
aiocbObj.aio_fildes = open("test.txt", O_RDONLY);
if (aiocbObj.aio_fildes == -1)
{
     std::cerr << "Failed to open file" << std::endl;
     return -1;
}
aiocbObj.aio_buf = const_cast<char*>(fileContent.c_str());
aiocbObj.aio_nbytes = BUF_SIZE;
aiocbObj.aio_reqprio = 0;
aiocbObj.aio_offset = 0;
aiocbObj.aio_sigevent.sigev_notify = SIGEV_SIGNAL;
aiocbObj.aio_sigevent.sigev_signo = SIGUSR1;
aiocbObj.aio_sigevent.sigev_value.sival_ptr = &aiocbObj;

这里,我们创建了一个读取文件内容的缓冲区,即文件内容。然后,我们创建了一个名为aiocbObjaiocb结构。接下来,我们打开一个文件进行读取,并检查该操作是否成功。然后,我们将指针设置为一个缓冲区和一个缓冲区大小。缓冲区大小告诉驱动程序应该读取或写入多少字节。接下来,我们指出,我们将通过将偏移量设置为 0 来从文件的开头读取。然后,我们在SIGEV_SIGNAL中设置通知类型,这意味着我们希望获得关于完成操作的信号通知。然后,我们设置信号编号,这将触发关于完成的通知。在我们的例子中,是sigusr 1–用户定义的信号。接下来,我们将指向aiocb结构的指针设置为信号处理器。

在创建并正确初始化aiocb结构后,我们可以执行输入或输出操作。让我们完成一个练习,了解如何在 Linux 平台上使用异步输入/输出。

练习 8:在 Linux 中异步读取文件

在本练习中,我们将开发一个以异步方式从文件中读取数据并将读取的数据输出到控制台的应用。当执行读取操作时,驱动器使用触发信号通知应用。要进行本练习,请执行以下步骤:

  1. 包括所有必需的头: < aio.h > 支持异步读写, < signal.h > 支持信号, < fcntl.h > 支持文件操作,**T21】unist . h>**支持符号常量, < iostream > 输出到终端, < chrono >

  2. 创建一个名为**的布尔变量,它将指示操作何时完成:

    bool isDone{};
    ```** 
  3. 定义将成为我们的信号处理器的函数,即aioSigHandler。异步操作完成后将调用它。信号处理器应具有以下签名:

    void name(int number, siginfo_t* si, void* additional)
  4. 第一个参数是信号编号,第二个参数是包含信号生成原因信息的结构,最后一个参数是附加信息。它可以被转换成ucontext_t结构的指针,这样我们就可以接收到被这个信号中断的线程上下文。在aioSigHandler中,使用SI_ASYNCIO检查关于异步输入/输出操作的信号是否恒定。如果是,输出一条消息。接下来,将isDone设置为true :

    void
    aioSigHandler(int no, siginfo_t* si, void*)
    {
         std::cout << "Signo: " << no << std::endl;
         if (si->si_code == SI_ASYNCIO)
         {
              std::cout << "I/O completion signal received" << std::endl;
         }
         isDone = true;
    }
  5. 定义另一个名为的帮助功能,启动。它将初始化信号结构。这个结构定义了在输入/输出操作结束时将发送哪个信号,以及应该调用哪个处理程序。这里,我们选择了sigusr 1–一个用户自定义的信号。在sa_flags中,设置我们希望在动作重启或收到信息时发送该信号:

    bool 
    initSigAct(struct sigaction& item)
    {
         item.sa_flags = SA_RESTART | SA_SIGINFO;
         item.sa_sigaction = aioSigHandler;
         if (-1 == sigaction(SIGUSR1, &item, NULL))
         {
              std::cerr << "sigaction usr1 failed" << std::endl;
              return false;
         }
         std::cout << "Successfully set up a async IO handler to SIGUSR1 action" << std::endl;
         return true;
    }
  6. 定义名为fillAiocb的帮助函数,用给定的参数填充aiocb结构。它将引用 aiocb 结构、文件描述符、缓冲区指针和缓冲区大小作为参数。在SIGUSR1中设置sigev_signo,我们之前已经初始化过:

    void 
    fillAiocb(aiocb& item, const int& fileDescriptor,
              char* buffer, const int& bufSize)
    {
         item.aio_fildes = fileDescriptor;
         item.aio_buf = static_cast<void*>(buffer);
         item.aio_nbytes = bufSize;
         item.aio_reqprio = 0;
         item.aio_offset = 0;
         item.aio_sigevent.sigev_notify = SIGEV_SIGNAL;
         item.aio_sigevent.sigev_signo = SIGUSR1;
         item.aio_sigevent.sigev_value.sival_ptr = &item;
    }
  7. 进入功能。定义名为buf_size的变量,它保存缓冲区大小。创建一个这样大小的缓冲区:

    constexpr int bufSize = 100;
    char* buffer = new char(bufSize);
    if (!buffer)
    {
         std::cerr << "Failed to allocate buffer" << std::endl;
         return -1;
    }
  8. 创建一个名为文件名的变量,该变量保存一个名为“Test.txt的文件。然后,以只读方式打开该文件:

    const std::string fileName("Test.txt");
    int descriptor = open(fileName.c_str(), O_RDONLY);
    if (-1 == descriptor)
    {
         std::cerr << "Failed to opene file for reading" << std::endl;
         return -1;
    }
    std::cout << "Successfully opened file for reading" << std::endl;
  9. 创建一个信号结构,并使用初始化信号功能进行初始化:

    struct sigaction sa;
    if (!initSigAct(sa))
    {
         std::cerr << "failed registering signal" << std::endl;
         return -1;
    }
  10. 创建一个aiocb结构,并使用fillaocb函数进行初始化:

```cpp
aiocb aiocbObj;
fillAiocb(aiocbObj, descriptor, buffer, bufSize);
```
  1. 使用aio_read功能执行读取操作:
```cpp
if (-1 == aio_read(&aiocbObj))
{
     std::cerr << "aio_read failed" << std::endl;
}
```
  1. 接下来,在循环中,评估isDone变量。如果是假的,让线程休眠3 毫秒。通过这样做,我们将等待输入/输出操作完成:
```cpp
while (!isDone)
{
     using namespace std::chrono_literals;
     std::this_thread::sleep_for(3ms);
}
std::cout << "Successfully finished read operation. Buffer: " << std::endl << buffer; 
```
  1. Before running this exercise, create a Test.txt file in the project directory and write different symbols. For example, our file contains the following data:
```cpp
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1 
a1a"1 a1\a1 a1	a1
```

这里有字母字符、数字字符、特殊符号、空格、制表字符和换行符。
  1. 现在,在您的集成开发环境中构建并运行这个练习。您的输出将类似于以下内容:

图 6.17:执行练习 8 的结果

您可以看到该文件已成功打开进行读取,并且我们已成功为其设置了SIGUSR1信号和处理程序。然后,我们收到了 30 号信号,也就是SI_ASYNCIO信号。最后,我们可以输出我们所阅读的内容,并将其与文件内容进行比较。通过这样做,我们可以确保所有数据都被正确读取。

这就是 Linux 系统中异步输入/输出的全部内容。

注意

你可以通过 Linux 的手册页找到更多关于 Linux 中异步 IO 的信息:http://man7.org/linux/man-pages/man7/aio.7.html

现在,让我们了解一下我们可以为跨平台应用使用什么。

异步跨平台输入输出库

我们已经考虑了异步 I/O 的特定于平台的决策。现在,要编写一个跨平台的应用,您可以使用这些特定于平台的方法,并将其与预处理器指令一起使用;例如:

#ifdef WIN
#include <WinAIO.hpp>
#else
#include <LinAIO.hpp>
#endif

在这两个头中,您可以为特定于平台的实现声明相同的接口。您还可以实现自己的 AIO 库,它将在单独的线程中使用一些状态机或队列。此外,您可以使用一些实现必要功能的免费库。最受欢迎的库是Boost。Asio。它为异步工作提供了许多接口,例如:

  • 无线程并发
  • 线
  • 缓冲
  • 协同程序
  • TCP、UDP 和 ICMP
  • 套接字
  • 加密套接字协议层
  • 串行端口

让我们简单考虑一下它的输入输出操作接口。我们可以使用Asio库的接口进行同步和异步操作。所有的输入输出操作都从io_service类开始,该类提供核心的输入输出功能。在<boost/asio/io _ service . HPP>头文件中声明。同步输入/输出调用io_service对象的run()函数进行单个操作,该操作阻塞调用线程,直到作业完成。异步输入输出使用run()run_one()poll()poll_one()功能。run()函数运行事件循环来处理请求处理程序。run_one()函数也是如此,但是事件循环应该只处理一个处理程序。poll()函数运行事件循环来执行所有就绪的处理程序。poll_one()做同样的事情,但只针对一个处理程序。以下示例演示了所有这些函数的用法:

boost::asio::io_service io_service1;
io_service1.run();
boost::asio::io_service io_service2;
io_service2.run_one();
boost::asio::io_service io_service3;
io_service3.poll();
boost::asio::io_service io_service4;
io_service4.poll_one();

有可能在调用实际的输入/输出操作之前运行事件处理程序。使用带有io_service类的工作类在代码中实现这个特性。工作类保证运行函数不会返回,直到您决定以后不再有任何输入/输出操作。例如,您可以使工作类成为另一个类的成员,并将其从析构函数中移除。因此,在您的课程期间,io_service将运行:

boost::asio::io_service io_service1;
boost::asio::io_service::work work(io_service1);
io_service1.run();
boost::asio::io_service io_service2;
boost::asio::io_service::work work(io_service2);
io_service2.poll();

接下来,要执行任何输入/输出操作,我们需要输入/输出设备的确切位置,例如,文件、套接字等。实现工作的类有很多,使用不同的 I/O 设备,例如<boost/asio/IP/TCP . HPP>头中的boost::asio::IP::TCP::socket。接下来,为了读写套接字,我们使用boost::asio::async_readboost::asio::async_write。它们以一个套接字、boost::asio::buffer、回调函数为参数。当异步操作被执行时,回调函数被调用。我们可以将 lambda 函数作为回调函数传递,或者使用 boost::bind 函数绑定现有函数。boost::bind创建一个可调用对象。以下示例演示了如何使用Boost::Asio写入套接字:

boost::asio::io_service ioService;
tcp::socket socket;
int length = 15;
char* msg = new char(length);
msg = "Hello, world!";
auto postHandler = [=]()
{
     auto writeHandler = [=](boost::system::error_code ec, std::size_t length)
     {
          if (ec)
          {
               socket_.close();
          }
          else
          {
               // wrote length characters
          }
     };
     boost::asio::async_write(socket, boost::asio::buffer(msg, length), writeHandler);
};
ioService.post(postHandler);

这里,我们使用 lambda 函数作为异步 I/O 操作的回调。

注意

升压。Asiohttps://www . boost . org/doc/libs/1 _ 63 _ 0/doc/html/boost _ asio . html上有详细记录。有很多不同输入输出设备和不同方法的例子。如果您决定使用Boost,可以参考本文档。Asio在你的项目中。

在这里,我们考虑了实现异步输入/输出操作的不同方法。根据您的需求、环境和允许的实用程序,您可以选择适当的方式在应用中实现异步输入/输出。请记住,如果您选择执行许多快速输入/输出操作,最好以同步方式进行,因为它不会占用大量系统资源。既然我们知道了如何利用异步输入/输出,那么让我们学习如何在多线程应用中使用输入/输出。

线程和输入输出的交互

输入/输出标准库不是线程安全的。在标准库的文档中,我们可以找到一种解释,说明对流或流缓冲区的并发访问会导致数据竞争,从而导致未定义的行为。为了避免这种情况,我们应该使用我们在第 5 章哲学家的晚餐-线程和并发中学习的技术来同步对流和缓冲区的访问。

让我们稍微谈谈std::cinstd::cout对象。对它们的每个调用都是线程安全的,但是让我们考虑以下示例:

std::cout << "Counter: " << counter << std::endl;

在这一行中,我们看到std::cout被调用了一次,但是对轮班操作员的每次调用实际上是对std::cout对象的不同调用。因此,我们可以将这一行改写如下:

std::cout << "Counter: ";
std::cout << counter;
std::cout << std::endl;

这段代码和前面的单行代码做得完全一样,也就是说,如果你从不同的线程调用这个单行代码,你的输出将是混合的,不清晰的。您可以对其进行修改,使其真正成为线程安全的,如下所示:

std::stringsream ss;
ss << "Counter: " << counter << std::endl;
std::cout << ss.str();

因此,如果您使用第二种方法输出到终端,您的输出将是清晰的和线程安全的。这种行为可能会有所不同,具体取决于编译器或标准库版本。你也要知道std::coutstd::cin在其中是同步的。这意味着调用std::cout总是刷新std::cin流,调用std::cin总是刷新std::cout流。

最好的方法是将所有输入/输出操作包装在一个保护类中,该类将使用互斥体控制对流的访问。如果您需要使用std::cout从多个线程输出到终端,您可以实现一个非常简单的类,它除了锁定互斥体和调用std::cout之外什么也不做。让我们完成一个练习并创建这样的类。

练习 9:为 std::cout 开发线程安全包装器

在本练习中,我们将开发一个简单的std::cout包装器,它产生线程安全的输出。我们将编写一个小测试函数来检查它是如何工作的。让我们开始并执行以下步骤:

  1. Include all the required headers:

    #include <iostream> // for std::cout
    #include <thread>   // for std::thread
    #include <mutex>    // for std::mutex
    #include <sstream>  // for std::ostringstream

    现在,让我们想想我们的包装。我们可以在某个地方创建这个类的变量,并将其传递给每个创建的线程。然而,这是一个糟糕的决定,因为在复杂的应用中,这将需要大量的努力。我们也可以作为一个单独的个体这样做,这样我们就可以从任何地方访问它。接下来,我们要思考我们课程的内容。实际上,我们可以使用我们在练习 7继承标准流对象中创建的类。在那个练习中,我们重载了std::basic_streambufstd::basic_ostream,并将std::cout设置为输出设备。我们可以给重载函数添加一个互斥体,并按原样使用它。请注意,我们不需要任何额外的逻辑——只需要使用std::cout的输出数据。为此,我们可以创建一个更简单的类。如果我们没有设置输出设备,应用左移位操作符将不会生效,并将待输出的数据存储在内部缓冲区中。太好了。现在,我们需要考虑如何使用std::cout将这个缓冲区输出。

  2. 实现诸如write()这样的函数,该函数将锁定一个互斥体,并从内部缓冲区输出到std::cout。该功能的用法如下:

    mtcout cout;
    cout << msg << std::endl;
    cout.write();
  3. 我们有一个函数总是会被自动调用,我们可以把 write 函数的代码放进去。这是一个析构器。在这种情况下,我们把创造和毁灭结合成一条线。这样一个对象的用法如下:

    mtcout{} << msg << std::endl; 
  4. 现在,让我们定义我们的mtcout(多线程 cout)类。它有一个公共默认构造函数。在私有部分,它有一个静态互斥变量。您可能还记得,静态变量在类的所有实例之间共享。在析构函数中,我们使用 cout 锁定互斥体和输出。在输出中添加一个前缀——当前线程的 ID 和一个空格字符:

    class mtcout : public std::ostringstream
    {
    public:
         mtcout() = default;
         ~mtcout()
         {
         std::lock_guard<std::mutex> lock(m_mux);
              std::cout << std::this_thread::get_id() << " " << this->str();
         }
    private:
         static std::mutex m_mux;
    };
  5. 接下来,在类外声明互斥变量。我们这样做是因为我们必须在任何源文件中声明一个静态变量:

    std::mutex mtcout::m_mux; 
  6. 进入主功能。创建一个名为的函数。它将测试我们的mtcout班。它以字符串为参数,使用mtcout在从01000的循环中输出该字符串。使用std::cout添加相同的输出并注释掉。比较两种情况下的输出:

    auto func = [](const std::string msg)
    {
         using namespace std::chrono_literals;
         for (int i = 0; i < 1000; ++ i)
         {
              mtcout{} << msg << std::endl;
    //          std::cout << std::this_thread::get_id() << " " << msg << std::endl;
         }
    };
  7. 创建四个线程,并传递一个 lambda 函数作为参数。向每个线程传递不同的字符串。最后,连接所有四个线程:

    std::thread thr1(func, "111111111");
    std::thread thr2(func, "222222222");
    std::thread thr3(func, "333333333");
    std::thread thr4(func, "444444444");
    thr1.join();
    thr2.join();
    thr3.join();
    thr4.join();
  8. Build and run the exercise for the first time. You will get the following output:

    Figure 6.18: The result of executing Exercise 9, part 1

    图 6.18:执行练习 9 第 1 部分的结果

    在这里,我们可以看到每个线程都输出自己的消息。该消息没有被中断,输出看起来很清晰。

  9. 现在,用λ中的std::cout取消输出注释,并用mtcout注释输出。

  10. Again, build and run the application. Now, you will get a "dirty", mixed output, like the following:

![Figure 6.19: The result of executing Exercise 9, part 2](img/C14583_06_19.jpg)
图 6.19:执行练习 9 第 2 部分的结果

您可以看到这种混合输出,因为我们不输出单个字符串;相反,我们调用std::cout四次:

std::cout << std::this_thread::get_id();
std::cout << " ";
std::cout << msg;
std::cout << std::endl;

当然,我们可以在输出字符串之前对其进行格式化,但是使用 mtcout 类更方便,并且不必担心格式化问题。您可以为任何流创建类似的包装器,以便安全地执行输入/输出操作。您可以更改输出并添加任何附加信息,例如当前线程的 ID、时间或您需要的任何信息。利用我们在第 5 章哲学家的晚餐——线程和并发中了解到的东西,同步输入/输出操作,扩展流,并使输出对您的需求更加有用。

使用宏

在本章的活动中,我们将使用宏定义来简化和美化我们的代码,所以让我们复习一下如何使用它们。宏定义是预处理器指令。宏定义的语法如下:

#define [name] [expression]

这里,[name]是任何有意义的名称,[expression]是任何小函数或值。

当预处理器面对宏名时,它用表达式替换它。例如,假设您有以下宏:

#define MAX_NUMBER 15

然后,在代码中的一些地方使用它:

if (val < MAX_NUMBER)
while (val < MAX_NUMBER)

预处理器完成工作后,代码如下:

if (val < 15)
while (val < 15)

预处理器对函数做同样的工作。例如,假设您有一个用于获取最大数量的宏:

#define max(a, b) a < b ? b : a

然后,在代码中的一些地方使用它:

int res = max (5, 3);

std::cout << (max (a, b));

预处理器完成工作后,代码如下:

int res = 5 < 3 ? 3 : 5;

std::cout << (a < b ? b : a);

作为表达式,您可以使用任何有效的表达式,如函数调用、内联函数、值等。如果需要在多行中编写表达式,请使用反斜杠运算符“\”。例如,我们可以用两行写的最大定义如下:

#define max(a, b) \
a < b ? b : a

注意

宏定义来自 C 语言。最好使用常量变量或内联函数。然而,仍然有使用宏定义更方便的情况,例如,在记录器中,当您希望定义不同的日志记录级别时。

现在。我们知道完成活动所需的一切。所以,让我们总结一下这一章所学的内容,让我们改进一下我们在第 5 章**哲学家的晚餐——线程和并发中所写的项目。我们将开发一个线程安全的记录器,并将其集成到我们的项目中。

活动 1:美术馆模拟器的记录系统

在本练习中,我们将开发一个记录器,将格式化的日志输出到终端。我们将以以下格式输出日志:

[dateTtime][threadId][logLevel][file:line][function] | message

我们将为不同的日志记录级别实现宏定义,而不是直接调用。这个记录器将是线程安全的,我们将从不同的线程同时调用它。最后,我们将把它整合到项目中——美术馆模拟器。我们将运行模拟并观察漂亮打印的日志。我们将创建一个额外的流,使用并发流,并格式化输出。我们将实现本章中所学的几乎所有内容。我们还将采用上一章中的同步技术。

因此,在尝试本练习之前,请确保您已经完成了本章前面的所有练习。

在实现这个应用之前,让我们描述一下我们的类。我们有以下新创建的类:

Figure 6.20: Descriptions of the classes that should be implemented

图 6.20:应该实现的类的描述

我们还在艺术画廊模拟器项目中实现了以下类:

图 6.21:美术馆模拟器项目中已经实现的类的表

在开始实现之前,让我们将新的类添加到类图中。所有描述的具有关系的类都由下图组成:

Figure 6.22: The class diagram

图 6.22:类图

为了接收所需格式的输出,记录器类应该具有以下静态功能:

Figure 6.23: Descriptions of the LoggerUtils member functions

图 6.23:记录器成员函数的描述

按照以下步骤完成本活动:

  1. 定义并实现记录器类,它提供了一个输出格式化的接口。它包含将给定数据格式化为所需表示形式的静态变量。

  2. 定义并实现StreamLogger类,它为输出到终端提供了一个线程安全的接口。它应该像这样格式化输出:

    [dateTtime][threadId][logLevel][file:line: ][function] | message
  3. 在一个单独的头文件中,声明不同日志记录级别的宏定义,这些宏定义返回StreamLogger类的一个临时对象。

  4. 将实现的记录器集成到艺术画廊模拟器的类中。

  5. 用适当的宏定义调用替换std::cout的所有调用。

在实现了上述步骤之后,您应该在终端上获得一些关于所有实现的类的日志的输出。看一看,确保日志以所需的格式输出。预期产出应如下:

Figure 6.24: The result of the application's execution

图 6.24:应用执行的结果

注意

这项活动的解决方案可以在第 696 页找到。

总结

在这一章中,我们学习了 C++ 中的输入输出操作。我们考虑了输入输出标准库,它为同步输入输出操作提供了一个接口。此外,我们考虑了异步输入/输出的平台相关本机工具和增强。Asio库,用于跨平台异步 I/O 操作。我们还学习了如何在多线程应用中使用输入/输出流。

我们从标准库为输入/输出操作提供的基本特性开始。我们了解了预定义的流对象,例如std::cinstd::cout。在实践中,我们学习了如何使用标准流和重写移位运算符来轻松读写自定义数据类型。

接下来,我们练习了如何创建附加流。我们继承了基本的流类,实现了自己的流缓冲类,并在练习中练习了它们的用法。我们了解了最适合继承的流类,并考虑了它们的优缺点。

然后,我们考虑了在不同操作系统上异步输入/输出操作的方法。我们简要考虑了使用被称为Boost 的跨平台 I/O 库。Asio,提供同步和异步操作的接口。

最后,我们学习了如何在多线程应用中执行输入/输出操作。我们通过构建多线程记录器将所有这些新技能付诸实践。我们创建了一个记录器抽象,并将其用于美术馆模拟器。因此,我们创建了一个简单、清晰和健壮的日志记录系统,允许我们使用日志轻松调试应用。总之,我们利用了本章所学的一切。

在下一章中,我们将更仔细地研究测试和调试应用。我们将从学习断言和安全网开始。然后,我们将练习为接口编写单元测试和模拟。之后,我们将在 IDE 中练习调试应用:我们将使用断点、观察点和数据可视化。最后,我们将编写一个活动来掌握测试代码的技巧。