C++-高级编程(全)

C++ 高级编程(全)

原文:annas-archive.org/md5/5f35e0213d2f32c832c0e92fd16884c1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于

本节简要介绍了作者、本书的内容、开始所需的技术技能以及完成所有包含的活动和练习所需的硬件和软件要求。

关于本书

C是最广泛使用的编程语言之一,应用于各种领域,从游戏到图形用户界面(GUI)编程,甚至操作系统。如果您希望扩展职业机会,掌握 C的高级特性至关重要。

该书从高级 C概念开始,帮助您解析复杂的 C类型系统,并了解编译的各个阶段如何将源代码转换为目标代码。然后,您将学习如何识别需要使用的工具,以控制执行流程,捕获数据并传递数据。通过创建小模型,您甚至会发现如何使用高级 lambda 和捕获,并在 C++中表达常见的 API 设计模式。随着后续章节的学习,您将探索通过学习内存对齐、缓存访问以及程序运行所需的时间来优化代码的方法。最后一章将帮助您通过了解现代 CPU 分支预测以及如何使您的代码对缓存友好来最大化性能。

通过本书,您将发展出与其他 C++程序员不同的编程技能。

关于作者

加齐汗·阿兰库斯(Gazihan Alankus)在华盛顿大学获得计算机科学博士学位。目前,他是土耳其伊兹密尔经济大学的助理教授。他在游戏开发、移动应用开发和人机交互方面进行教学和研究。他是 Dart 的 Google 开发专家,并与他在 2019 年创立的公司 Gbot 的学生一起开发 Flutter 应用程序。

奥莉娜·利津娜(Olena Lizina)是一名拥有 5 年 C++开发经验的软件开发人员。她具有为国际产品公司开发用于监控和管理远程计算机的系统的实际知识,该系统有大量用户。在过去的 4 年中,她一直在国际外包公司为知名汽车公司的汽车项目工作。她参与了不同项目的复杂和高性能应用程序的开发,如 HMI(人机界面)、导航以及与传感器工作的应用程序。

拉克什·马内(Rakesh Mane)在软件行业拥有 18 年的经验。他曾与来自印度、美国和新加坡的熟练程序员合作。他主要使用 C++、Python、shell 脚本和数据库进行工作。在业余时间,他喜欢听音乐和旅行。此外,他喜欢使用软件工具和代码玩耍、实验和破坏东西。

维韦克·纳加拉贾(Vivek Nagarajan)是一名自学成才的程序员,他在上世纪 80 年代开始使用 8 位系统。他曾参与大量软件项目,并拥有 14 年的 C++专业经验。此外,他还在多年间使用了各种语言和框架。他是一名业余举重运动员、DIY 爱好者和摩托车赛手。他目前是一名独立软件顾问。

布赖恩·普莱斯(Brian Price)在各种语言、项目和行业中拥有 30 多年的工作经验,其中包括 20 多年的 C经验。他曾参与电站模拟器、SCADA 系统和医疗设备的开发。他目前正在为下一代医疗设备开发 C、CMake 和 Python 软件。他喜欢用各种语言解决难题和欧拉项目。

学习目标

通过本书,您将能够:

  • 深入了解 C++的解剖和工作流程

  • 研究在 C++中编码的不同方法的优缺点

  • 测试、运行和调试您的程序

  • 将目标文件链接为动态库

  • 使用模板、SFINAE、constexpr if 表达式和可变模板

  • 应用最佳实践进行资源管理

观众

如果您已经使用 C但想要学习如何充分利用这种语言,特别是对于大型项目,那么这本书适合您。必须具备对编程的一般理解,并且必须具备使用编辑器在项目目录中生成代码文件的知识。还建议具备一些使用强类型语言(如 C 和 C)的经验。

方法

这本快节奏的书旨在通过描述性图形和具有挑战性的练习快速教授您概念。该书将包含“标注”,其中包括关键要点和最常见的陷阱,以保持您的兴趣,同时将主题分解为可管理的部分。

硬件要求

为了获得最佳的学生体验,我们建议以下硬件配置:

  • 任何具有 Windows、Linux 或 macOS 的入门级 PC/Mac 都足够

  • 处理器:双核或等效

  • 内存:4 GB RAM(建议 8 GB)

  • 存储:35 GB 的可用空间

软件要求

您还需要提前安装以下软件:

  • 操作系统:Windows 7 SP1 32/64 位,Windows 8.1 32/64 位,或 Windows 10 32/64 位,Ubuntu 14.04 或更高版本,或 macOS Sierra 或更高版本

  • 浏览器:Google Chrome 或 Mozilla Firefox

安装和设置

在开始阅读本书之前,您需要安装本书中使用的以下库。您将在此处找到安装这些库的步骤。

安装 CMake

我们将使用 CMake 版本 3.12.1 或更高版本。我们有两种安装选项。

选项 1:

如果您使用的是 Ubuntu 18.10,可以使用以下命令全局安装 CMake:

sudo apt install cmake

当您运行以下命令时:

cmake –version

您应该看到以下输出:

cmake version 3.12.1
CMake suite maintained and supported by Kitware (kitware.com/cmake).

如果您在此处看到的版本低于 3.12.1(例如 3.10),则应按照以下说明在本地安装 CMake。

选项 2:

如果您使用的是较旧的 Linux 版本,则可能会获得低于 3.12.1 的 CMake 版本。然后,您需要在本地安装它。使用以下命令:

wget \
https://github.com/Kitware/CMake/releases/download/v3.15.1/cmake-3.15.1-Linux-x86_64.sh
sh cmake-3.15.1-Linux-x86_64.sh

当您看到软件许可证时,请输入y并按Enter。当询问安装位置时,请输入y并再次按 Enter。这应该将其安装到系统中的一个新文件夹中。

现在,我们将将该文件夹添加到我们的路径中。输入以下内容。请注意,第一行有点太长,而且在本文档中换行。您应该将其写成一行,如下所示:

echo "export PATH=\"$HOME/cmake-3.15.1-Linux-x86_64/bin:$PATH\"" >> .bash_profile
source .profile

现在,当您输入以下内容时:

cmake –version

您应该看到以下输出:

cmake version 3.15.1
CMake suite maintained and supported by Kitware (kitware.com/cmake).

在撰写本文时,3.15.1 是当前最新版本。由于它比 3.12.1 更新,这对我们的目的足够了。

安装 Git

通过输入以下内容来测试当前安装情况:

git --version

您应该看到以下行:

git version 2.17.1

如果您看到以下行,则需要安装git

command 'git' not found

以下是如何在 Ubuntu 中安装git

sudo apt install git

安装 g++

通过输入以下内容来测试当前安装情况:

g++ --version

您应该看到以下输出:

g++ (Ubuntu 7.4.0-1ubuntu1~18.04) 7.4.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

如果尚未安装,请输入以下代码进行安装:

sudo apt install g++

安装 Ninja

通过输入以下内容来测试当前安装情况:

ninja --version

您应该看到以下输出:

1.8.2

如果尚未安装,请输入以下代码进行安装:

sudo apt install ninja-build

安装 Eclipse CDT 和 cmake4eclipse

有多种安装 Eclipse CDT 的方法。为了获得最新的稳定版本,我们将使用官方安装程序。转到此网站并下载 Linux 安装程序:www.eclipse.org/downloads/packages/installer

按照那里的说明并安装Eclipse IDE for C/C++ Developers。安装完成后,运行 Eclipse 可执行文件。如果您没有更改默认配置,在终端中输入以下命令将运行它:

~/eclipse/cpp-2019-03/eclipse/eclipse

您将选择一个工作区文件夹,然后将在主 Eclipse 窗口中看到一个欢迎选项卡。

现在,我们将安装cmake4eclipse。一个简单的方法是访问该网站,并将安装图标拖到 Eclipse 窗口中:github.com/15knots/cmake4eclipse#installation。它会要求您重新启动 Eclipse,之后您就可以修改 CMake 项目以在 Eclipse 中使用了。

安装 GoogleTest

我们将在系统中安装GoogleTest,这也将安装其他依赖于它的软件包。写入以下命令:

sudo apt install libgtest-dev google-mock

这个命令安装了GoogleTest的包含文件和源文件。现在,我们需要构建已安装的源文件以创建GoogleTest库。运行以下命令来完成这个步骤:

cd /usr/src/gtest
sudo cmake CMakeLists.txt
sudo make
sudo cp *.a /usr/lib

安装代码包

将该课程的代码包复制到C:/Code文件夹中。

附加资源

本书的代码包也托管在 GitHub 上,网址为github.com/TrainingByPackt/Advanced-CPlusPlus

我们还有其他代码包来自我们丰富的图书和视频目录,可以在github.com/PacktPublishing/找到。去看看吧!

第一章:可移植 C++软件的解剖学

学习目标

在本章结束时,您将能够:

  • 建立代码构建测试流程

  • 描述编译的各个阶段

  • 解密复杂的 C++类型系统

  • 配置具有单元测试的项目

  • 将源代码转换为目标代码

  • 编写可读的代码并调试它

在本章中,我们将学习建立贯穿全书使用的代码构建测试模型,编写优美的代码并进行单元测试。

介绍

C是最古老和最流行的语言之一,您可以使用它来编写高效的代码。它既像 C 一样“接近底层”,又具有高级的面向对象特性,就像 Java 一样。作为一种高效的低级语言,C是效率至关重要的领域的首选语言,例如游戏、模拟和嵌入式系统。同时,作为一种具有高级特性的面向对象语言,例如泛型、引用和无数其他特性,使其适用于由多人开发和维护的大型项目。

几乎任何编程经验都涉及组织您的代码库并使用他人编写的库。C也不例外。除非您的程序很简单,否则您将把代码分发到多个文件中,并且需要组织这些文件,您将使用各种库来完成任务,通常比您的代码更有效和更可靠。不使用任何第三方库的 C项目是不代表大多数项目的边缘情况,大多数项目都使用许多库。这些项目及其库预期在不同的硬件架构和操作系统上工作。因此,如果您要使用 C++开发任何有意义的东西,花时间进行项目设置并了解用于管理依赖关系的工具是很重要的。

大多数现代和流行的高级语言都有标准工具来维护项目、构建项目并处理其库依赖关系。其中许多都有托管库和工具的存储库,可以自动下载并使用这些库。例如,Python 有pip,它负责下载和使用程序员想要使用的库的适当版本。同样,JavaScript 有npm,Java 有maven,Dart 有pub,C#有NuGet。在这些语言中,您列出要使用的库的名称和版本,工具会自动下载并使用兼容版本的库。这些语言受益于程序在受控环境中构建和运行,其中满足一定级别的硬件和软件要求。另一方面,C预期在各种上下文中使用,具有不同的架构,包括非常原始的硬件。因此,当涉及构建程序和执行依赖管理时,C程序员受到的关注较少。

管理 C++项目

在 C世界中,我们有几种工具可帮助管理项目源代码及其依赖关系。例如,pkg-configAutotoolsmakeCMake是社区中最值得注意的工具。与其他高级语言的工具相比,这些工具使用起来要复杂得多。CMake已成为管理 C项目及其依赖关系的事实标准。与make相比,它更具有主观性,并且被接受为大多数集成开发环境(IDE)的直接项目格式。

虽然CMake有助于管理项目及其依赖关系,但体验仍远远不及高级语言,其中您列出要使用的库及其版本,其他一切都会为您处理。使用 CMake,您仍需负责在开发环境中正确安装库,并且您需要使用每个库的兼容版本。在流行的 Linux 发行版中,有广泛的软件包管理器,您可以轻松安装大多数流行库的二进制版本。然而,有时您可能需要自行编译和安装库。这是 C++开发者体验的一部分,您将通过学习更多关于您选择的开发平台的开发平台来了解。在这里,我们将更专注于如何正确设置我们的 CMake 项目,包括理解和解决与库相关的问题。

代码构建测试运行循环

为了以坚实的基础展开讨论,我们将立即从一个实际示例开始。我们将从一个 C代码基础模板开始,您可以将其用作自己项目的起点。我们将看到如何使用 CMake 在命令行上构建和编译它。我们还将为 C/C开发人员设置 Eclipse IDE,并导入我们的 CMake 项目。使用 IDE 将为我们提供便利设施,以便轻松创建源代码,并使我们能够逐行调试我们的程序,查看程序执行过程中到底发生了什么,并以明智的方式纠正错误,而不是靠试错和迷信。

构建一个 CMake 项目

C++项目的事实标准是使用 CMake 来组织和构建项目。在这里,我们将使用一个基本的模板项目作为起点。以下是一个示例模板的文件夹结构:

图 1.1:示例模板的文件夹结构

图 1.1:示例模板的文件夹结构

在上图中,git版本控制系统。这些被忽略的文件包括构建过程的输出,这些文件是在本地创建的,不应在计算机之间共享。

不同平台的make文件中的文件。

使用 CMake 构建项目是一个两步过程。首先,我们让 CMake 生成平台相关的配置文件,用于本地构建系统编译和构建项目。然后,我们将使用生成的文件来构建项目。CMake 可以为平台生成配置文件的构建系统包括UNIX MakefilesNinja build filesNMake MakefilesMinGW Makefiles。选择取决于所使用的平台、这些工具的可用性和个人偏好。UNIX MakefilesUnixLinux的事实标准,而NMake是其WindowsVisual Studio的对应物。另一方面,MinGWWindows中的Unix-like 环境,也在使用MakefilesNinja是一个现代的构建系统,与其他构建系统相比速度异常快,同时支持多平台,我们选择在这里使用。此外,除了这些命令行构建系统,我们还可以为Visual StudioXCodeEclipse CDT等生成 IDE 项目,并在 IDE 中构建我们的项目。因此,CMake是一个元工具,将为另一个实际构建项目的系统创建配置文件。在下一节中,我们将解决一个练习,其中我们将使用CMake生成Ninja build files

练习 1:使用 CMake 生成 Ninja 构建文件

在这个练习中,我们将使用CMake生成Ninja build files,用于构建 C++项目。我们将首先从git存储库下载我们的源代码,然后使用 CMake 和 Ninja 来构建它。这个练习的目的是使用 CMake 生成 Ninja 构建文件,构建项目,然后运行它们。

注意

GitHub 仓库的链接可以在这里找到:github.com/TrainingByPackt/Advanced-CPlusPlus/tree/master/Lesson1/Exercise01/project

执行以下步骤完成练习:

  1. 在终端窗口中,输入以下命令,将CxxTemplate仓库从 GitHub 下载到本地系统:
git clone https://github.com/TrainingByPackt/Advanced-CPlusPlus/tree/master/Lesson1/Exercise01/project

上一个命令的输出类似于以下内容:

图 1.2:从 GitHub 检出示例项目

图 1.2:从 GitHub 检出示例项目

现在你已经在CxxTemplate文件夹中有了源代码。

  1. 通过在终端中输入以下命令,进入CxxTemplate文件夹:
cd CxxTemplate
  1. 现在你可以通过在终端中输入以下命令来列出项目中的所有文件:
find .
  1. CxxTemplate文件夹中使用cmake命令生成我们的 Ninja 构建文件。为此,输入以下命令:
cmake -Bbuild -H. -GNinja

上一个命令的输出如下:

图 1.3:生成 Ninja 构建文件

图 1.3:生成 Ninja 构建文件

让我们解释一下上一个命令的部分。使用-Bbuild,我们告诉 CMake 使用build文件夹来生成构建产物。由于这个文件夹不存在,CMake 会创建它。使用-H.,我们告诉 CMake 使用当前文件夹作为源。通过使用单独的build文件夹,我们将保持我们的源文件干净,所有的构建产物都将存放在build文件夹中,这得益于我们的.gitignore文件而被 Git 忽略。使用-GNinja,我们告诉 CMake 使用 Ninja 构建系统。

  1. 运行以下命令来列出项目文件并检查在build文件夹中创建的文件:
ls
ls build

上一个命令将在终端中显示以下输出:

图 1.4:构建文件夹中的文件

图 1.4:构建文件夹中的文件

很明显,上一个文件将存在于构建文件夹中。上一个输出中的build.ninjarules.ninja是 Ninja 构建文件,实际上可以在这个平台上构建我们的项目。

注意

通过使用 CMake,我们不必编写 Ninja 构建文件,并避免了对 Unix 平台的提交。相反,我们有一个可以为其他平台生成低级构建文件的元构建系统,比如 UNIX/Linux、MinGW 和 Nmake。

  1. 现在,进入build文件夹,并通过在终端中输入以下命令来构建我们的项目:
cd build
ninja

你应该看到最终输出如下:

图 1.5:使用 ninja 构建

图 1.5:使用 ninja 构建
  1. CxxTemplate可执行文件中键入ls或不键入:
ls

上一个命令在终端中产生以下输出:

图 1.6:运行 ninja 后构建文件夹中的文件

图 1.6:运行 ninja 后构建文件夹中的文件

在上一个图中,你可以看到CxxTemplate可执行文件已经生成。

  1. 在终端中,输入以下命令来运行CxxTemplate可执行文件:
./CxxTemplate

终端中的上一个命令将提供以下输出:

图 1.7:运行可执行文件

src/CxxTemplate.cpp文件中的以下行负责写入上一个输出:

std::cout << "Hello CMake." << std::endl;

现在你已经成功在 Linux 中构建了一个 CMake 项目。Ninja 和 CMake 在一起工作得很好。你只需要运行一次 CMake,Ninja 就会检测是否需要再次调用 CMake,并会自动为你调用。例如,即使你向CMakeLists.txt文件中添加新的源文件,你只需要在终端中输入ninja命令,它就会自动运行 CMake 来更新 Ninja 构建文件。现在你已经了解了如何在 Linux 中构建 CMake 项目,在下一节中,我们将看看如何将 CMake 项目导入 Eclipse CDT。

将 CMake 项目导入 Eclipse CDT

Ninja 构建文件对于在 Linux 中构建我们的项目非常有用。但是,CMake 项目是可移植的,并且也可以与其他构建系统和 IDE 一起使用。许多 IDE 接受 CMake 作为其配置文件,并在您修改和构建项目时提供无缝体验。在本节中,我们将讨论如何将 CMake 项目导入 Eclipse CDT,这是一款流行的跨平台 C/C++ IDE。

使用 Eclipse CDT 与 CMake 有多种方法。CMake 提供的默认方法是单向生成 IDE 项目。在这里,您只需创建一次 IDE 项目,对 IDE 项目进行的任何修改都不会改变原始的 CMake 项目。如果您将项目作为 CMake 项目进行管理,并且只在 Eclipse CDT 中进行一次性构建,则这很有用。但是,如果您想在 Eclipse CDT 中进行开发,则不是理想的方法。

使用 Eclipse CDT 与 CMake 的另一种方法是使用自定义的cmake4eclipse插件。使用此插件时,您不会放弃您的CMakeLists.txt文件并单向切换到 Eclipse CDT 的项目管理器。相反,您将继续通过CMakeLists.txt文件管理项目,该文件将继续是项目的主要配置文件。Eclipse CDT 会积极与您的CMakeLists.txt文件合作构建项目。您可以在CMakeLists.txt中添加或删除源文件并进行其他更改,cmake4eclipse插件会在每次构建时将这些更改应用于 Eclipse CDT 项目。您将拥有良好的 IDE 体验,同时保持您的 CMake 项目处于最新状态。这种方法的好处是您始终可以停止使用 Eclipse CDT,并使用您的CMakeLists.txt文件切换到另一个构建系统(如 Ninja)。我们将在以下练习中使用这种第二种方法。

练习 2:将 CMake 文件导入 Eclipse CDT

在上一个练习中,您开发了一个 CMake 项目,并希望开始使用 Eclipse CDT IDE 来编辑和构建该项目。在本练习中,我们将使用cmake4eclipse插件将我们的 CMake 项目导入 Eclipse CDT IDE。执行以下步骤完成练习:

  1. 打开 Eclipse CDT。

  2. 在当前项目的位置(包含CMakeLists.txt文件和src文件夹的文件夹)中创建一个新的 C++项目。转到文件 | 新建 | 项目。将出现一个类似以下截图的新建项目对话框:图 1.8:新建项目对话框

图 1.8:新建项目对话框
  1. 选择C++项目选项,然后点击下一步按钮。将出现一个类似以下截图的C++项目对话框:图 1.9:C++项目对话框
图 1.9:C++项目对话框
  1. 接受一切,包括切换到 C/C++视角,然后点击完成

  2. 点击左上角的还原按钮查看新创建的项目:图 1.10:还原按钮

图 1.10:还原按钮
  1. 点击CxxTemplate项目。转到项目 | 属性,然后在左侧窗格下选择C/C++构建下的工具链编辑器,将当前构建器设置为CMake Builder (portable)。然后,点击应用并关闭按钮:图 1.11:项目属性
图 1.11:项目属性
  1. 然后,选择项目 | 构建全部菜单项来构建项目:图 1.12:构建项目
图 1.12:构建项目
  1. 在接下来的make all中实际构建我们的项目:图 1.13:构建输出
图 1.13:构建输出
  1. 如果在之前的步骤中没有出现任何错误,您可以使用菜单项运行 | 运行来运行项目。如果给出了一些选项,请选择本地 C/C++应用程序CxxTemplate作为可执行文件:图 1.14:运行项目
图 1.14:运行项目
  1. 当运行时,你会在控制台窗格中看到程序的输出如下:

图 1.15:项目的输出

图 1.15:项目的输出

你已经成功地使用 Eclipse CDT 构建和运行了一个 CMake 项目。在下一个练习中,我们将通过添加新的源文件和新类来频繁地更改我们的项目。

练习 3:向 CMake 和 Eclipse CDT 添加新的源文件

随着 C++项目的不断扩大,你会倾向于向其中添加新的源文件,以满足预期的要求。在这个练习中,我们将向我们的项目中添加一个新的.cpp.h文件对,并看看 CMake 和 Eclipse CDT 如何处理这些更改。我们将使用新类向项目中添加这些文件,但你也可以使用任何其他文本编辑器创建它们。执行以下步骤将新的源文件添加到 CMake 和 Eclipse CDT 中:

  1. 首先,打开我们一直在使用的项目。在左侧的项目资源管理器窗格中,展开根条目CxxTemplate,你会看到我们项目的文件和文件夹。右键单击src文件夹,从弹出菜单中选择新建 | 图 1.16:创建一个新类
图 1.16:创建一个新类
  1. 在打开的对话框中,为类名输入ANewClass。当你点击完成按钮时,你会看到src文件夹下生成了ANewClass.cppANewClass.h文件。

  2. 现在,让我们在ANewClass类中写一些代码,并从ANewClass.cpp中访问它,并更改文件的开头以匹配以下内容,然后保存文件:

#include "ANewClass.h"
#include <iostream>
void ANewClass::run() {
    std::cout << "Hello from ANewClass." << std::endl;
}

你会看到 Eclipse 用ANewClass.h文件警告我们。这些警告是由 IDE 中的分析器实现的,非常有用,因为它们可以在你输入代码时帮助你修复代码,而无需运行编译器。

  1. 打开ANewClass.h文件,添加以下代码,并保存文件:
public:
    void run(); // we added this line
    ANewClass();

你应该看到.cpp文件中的错误消失了。如果没有消失,可能是因为你可能忘记保存其中一个文件。你应该养成按Ctrl + S保存当前文件的习惯,或者按Shift + Ctrl + S保存你编辑过的所有文件。

  1. 现在,让我们从我们的另一个类CxxTemplate.cpp中使用这个类。打开该文件,进行以下修改,并保存文件。在这里,我们首先导入头文件,在CxxApplication的构造函数中,我们向控制台打印文本。然后,我们创建了ANewClass的一个新实例,并调用了它的run方法:
#include "CxxTemplate.h"
#include "ANewClass.h"
#include <string>
...
CxxApplication::CxxApplication( int argc, char *argv[] ) {
  std::cout << "Hello CMake." << std::endl;
  ::ANewClass anew;
  anew.run();
}

注意

这个文件的完整代码可以在这里找到:github.com/TrainingByPackt/Advanced-CPlusPlus/blob/master/Lesson1/Exercise03/src/CxxTemplate.cpp

  1. 尝试通过点击CMakeLists.txt文件来构建项目,进行以下修改,并保存文件:
add_executable(CxxTemplate
  src/CxxTemplate.cpp  
  src/ANewClass.cpp
)

尝试再次构建项目。这次你不应该看到任何错误。

  1. 使用运行 | 运行菜单选项运行项目。你应该在终端中看到以下输出:

图 1.18:程序输出

图 1.18:程序输出

你修改了一个 CMake 项目,向其中添加了新文件,并成功地运行了它。请注意,我们在src文件夹中创建了文件,并让CMakeLists.txt文件知道了 CPP 文件。如果你不使用 Eclipse,你可以继续使用通常的 CMake 构建命令,你的程序将成功运行。到目前为止,我们已经从 GitHub 检出了示例代码,并且用纯 CMake 和 Eclipse IDE 构建了它。我们还向 CMake 项目中添加了一个新类,并在 Eclipse IDE 中重新构建了它。现在你知道如何构建和修改 CMake 项目了。在下一节中,我们将进行一个活动,向项目添加一个新的源文件-头文件对。

活动 1:向项目添加新的源文件-头文件对

在开发 C++项目时,随着项目的增长,您会向其中添加新的源文件。您可能出于各种原因想要添加新的源文件。例如,假设您正在开发一个会计应用程序,在其中需要在多个地方计算利率,并且您希望创建一个单独的文件中的函数,以便在整个项目中重用它。为了保持简单,在这里我们将创建一个简单的求和函数。在这个活动中,我们将向项目添加一个新的源文件和头文件对。执行以下步骤完成该活动:

  1. 在 Eclipse IDE 中打开我们在之前练习中创建的项目。

  2. SumFunc.cppSumFunc.h文件对添加到项目中。

  3. 创建一个名为sum的简单函数,它返回两个整数的和。

  4. CxxTemplate类构造函数中调用该函数。

  5. 在 Eclipse 中构建并运行项目。

预期输出应该类似于以下内容:

图 1.19:最终输出

图 1.19:最终输出

注意

此活动的解决方案可在第 620 页找到。

在接下来的部分中,我们将讨论如何为我们的项目编写单元测试。将项目分成许多类和函数,并让它们一起工作以实现期望的目标是很常见的。您必须使用单元测试来管理这些类和函数的行为,以确保它们以预期的方式运行。

单元测试

单元测试在编程中是一个重要的部分。基本上,单元测试是使用我们的类在各种场景下进行测试的小程序,预期结果是在我们的项目中的一个并行文件层次结构中,不会最终出现在实际的可执行文件中,而是在开发过程中由我们单独执行,以确保我们的代码以预期的方式运行。我们应该为我们的 C++程序编写单元测试,以确保它们在每次更改后都能按照预期的方式运行。

为单元测试做准备

有几个 C++测试框架可以与 CMake 一起使用。我们将使用Google Test,它比其他选项有几个优点。在下一个练习中,我们将准备我们的项目以便使用 Google Test 进行单元测试。

练习 4:为单元测试准备我们的项目

我们已经安装了 Google Test,但我们的项目还没有设置好以使用 Google Test 进行单元测试。除了安装之外,在我们的 CMake 项目中还需要进行一些设置才能进行 Google Test 单元测试。按照以下步骤执行此练习:

  1. 打开 Eclipse CDT,并选择我们一直在使用的 CxxTemplate 项目。

  2. 创建一个名为tests的新文件夹,因为我们将在那里执行所有的测试。

  3. 编辑我们的基本CMakeLists.txt文件,以允许在GTest包中进行测试,该包为 CMake 带来了GoogleTest功能。我们将在此之后添加我们的新行:

find_package(GTest)
if(GTEST_FOUND)
set(Gtest_FOUND TRUE)
endif()
if(GTest_FOUND)
include(GoogleTest)
endif()
# add these two lines below
enable_testing()
add_subdirectory(tests)

这就是我们需要添加到我们主要的CMakeLists.txt文件中的所有内容。

  1. 在我们主要的CMakeLists.txt文件中的add_subdirectory(tests)行内创建另一个CMakeLists.txt文件。这个tests/CMakeLists.txt文件将管理测试源代码。

  2. tests/CMakeLists.txt文件中添加以下代码:

include(GoogleTest)
add_executable(tests CanTest.cpp)
target_link_libraries(tests GTest::GTest)
gtest_discover_tests(tests)

让我们逐行解析这段代码。第一行引入了 Google Test 功能。第二行创建了tests可执行文件,其中将包括所有我们的测试源文件。在这种情况下,我们只有一个CanTest.cpp文件,它将验证测试是否有效。之后,我们将GTest库链接到tests可执行文件。最后一行标识了tests可执行文件中的所有单独测试,并将它们添加到CMake作为一个测试。这样,各种测试工具将能够告诉我们哪些单独的测试失败了,哪些通过了。

  1. 创建一个tests/CanTest.cpp文件。添加这段代码来简单验证测试是否运行,而不实际测试我们实际项目中的任何内容:
#include "gtest/gtest.h"
namespace {
class CanTest: public ::testing::Test {};
TEST_F(CanTest, CanReallyTest) {
  EXPECT_EQ(0, 0);
}
}  
int main(int argc, char **argv) {
  ::testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

TEST_F行是一个单独的测试。现在,EXPECT_EQ(0, 0)正在测试零是否等于零,如果我们实际运行测试,它将始终成功。稍后,我们将在这里添加我们自己类的结果,以便对各种值进行测试。现在我们的项目中已经具备了 Google Test 的必要设置。接下来,我们将构建和运行这些测试。

构建、运行和编写单元测试

现在,我们将讨论如何构建、运行和编写单元测试。到目前为止,我们所拥有的示例是一个简单的虚拟测试,已准备好进行构建和运行。稍后,我们将添加更有意义的测试,并查看通过和失败测试的输出。在接下来的练习中,我们将为上一个练习中创建的项目构建、运行和编写单元测试。

练习 5:构建和运行测试

到目前为止,您已经创建了一个设置好的GoogleTest的项目,但没有构建或运行我们创建的测试。在这个练习中,我们将构建和运行我们创建的测试。由于我们使用add_subdirectory添加了我们的tests文件夹,构建项目将自动构建测试。运行测试将需要更多的努力。执行以下步骤完成练习:

  1. 在 Eclipse CDT 中打开我们的 CMake 项目。

  2. 构建测试,只需像以前一样构建项目即可。以下是在 Eclipse 中进行完整构建后再次构建项目的输出,使用Project | Build All图 1.20:构建操作及其输出

图 1.20:构建操作及其输出
  1. 如果您没有看到此输出,则可能是因为您的控制台处于错误的视图中。您可以按照以下图示进行更正:图 1.21:查看正确的控制台输出
图 1.21:查看正确的控制台输出

图 1.22:查看正确的控制台输出

图 1.22:查看正确的控制台输出

如您所见,我们的项目现在有两个可执行目标。它们都位于build文件夹中,与任何其他构建产物一样。它们的位置分别是build/Debug/CxxTemplatebuild/Debug/tests/tests。由于它们是可执行文件,我们可以直接运行它们。

  1. 我们之前运行了CxxTemplate,现在不会看到任何额外的输出。通过在项目文件夹中输入以下命令,我们可以运行其他可执行文件:
./build/Debug/tests/tests

前面的代码在终端中生成了以下输出:

图 1.23:运行测试可执行文件

图 1.23:运行测试可执行文件

这是我们的tests可执行文件的简单输出。如果您想查看测试是否通过,您可以简单地运行它。但是,测试远不止于此。

  1. 您可以通过使用ctest命令之一来运行测试。在项目文件夹中的终端中输入以下命令。我们进入tests可执行文件所在的文件夹,运行ctest,然后返回:
cd build/Debug/tests
ctest
cd ../../..

以下是您将看到的输出:

图 1.24:运行 ctest

图 1.24:运行 ctest

注意

ctest命令可以使用多种选项运行您的tests可执行文件,包括自动将测试结果提交到在线仪表板的功能。在这里,我们将简单地运行ctest命令;其更多功能留给感兴趣的读者作为练习。您可以输入ctest --help或访问在线文档以了解更多关于ctest的信息,网址为cmake.org/cmake/help/latest/manual/ctest.1.html#

  1. 另一种运行测试的方法是在 Eclipse 中以漂亮的图形报告格式运行它们。为此,我们将创建一个测试感知的运行配置。在 Eclipse 中,单击Run | Run Configurations…,在左侧右键单击C/C++ Unit,然后选择New Configuration

  2. 将名称从CxxTemplate Debug更改为CxxTemplate Tests如下所示:图 1.25:更改运行配置的名称

图 1.25:更改运行配置的名称
  1. C/C++ Application下,选择Search Project选项:图 1.26:运行配置
图 1.26:运行配置
  1. 在新对话框中选择tests图 1.27:创建测试运行配置并选择测试可执行文件
图 1.27:创建测试运行配置并选择测试可执行文件
  1. 接下来,转到C/C++ Testing选项卡,并在下拉菜单中选择Google Tests Runner。点击对话框底部的Apply,然后点击第一次运行的测试的Run选项:图 1.28:运行配置
图 1.28:运行配置
  1. 在即将进行的运行中,您可以单击工具栏中播放按钮旁边的下拉菜单,或选择Run | Run History来选择CxxTemplate Tests

图 1.29:完成运行配置设置并选择要运行的配置

图 1.29:完成运行配置设置并选择要运行的配置

结果将类似于以下截图:

图 1.30:单元测试的运行结果

图 1.30:单元测试的运行结果

这是一个很好的报告,包含了所有测试的条目,现在只有一个。如果您不想离开 IDE,您可能会更喜欢这个。此外,当您有许多测试时,此界面可以帮助您有效地对其进行过滤。现在,您已经构建并运行了使用 Google Test 编写的测试。您以几种不同的方式运行了它们,包括直接执行测试,使用ctest和使用 Eclipse CDT。在下一节中,我们将解决一个练习,其中我们将实际测试我们代码的功能。

练习 6:测试代码功能

您已经运行了简单的测试,但现在您想编写有意义的测试来测试功能。在初始活动中,我们创建了SumFunc.cpp,其中包含sum函数。现在,在这个练习中,我们将为该文件编写一个测试。在这个测试中,我们将使用sum函数来添加两个数字,并验证结果是否正确。让我们回顾一下之前包含sum函数的以下文件的内容:

  • src/SumFunc.h
#ifndef SRC_SUMFUNC_H_
#define SRC_SUMFUNC_H_
int sum(int a, int b);
#endif /* SRC_SUMFUNC_H_ */
  • src/SumFunc.cpp
#include "SumFunc.h"
#include <iostream>
int sum(int a, int b) {
  return a + b;
}
  • CMakeLists.txt的相关行:
add_executable(CxxTemplate
  src/CxxTemplate.cpp  
  src/ANewClass.cpp
  src/SumFunc.cpp
)

另外,让我们回顾一下我们的CantTest.cpp文件,它包含了我们单元测试的main()函数:

#include "gtest/gtest.h"
namespace {
class CanTest: public ::testing::Test {};
TEST_F(CanTest, CanReallyTest) {
  EXPECT_EQ(0, 0);
}
}  
int main(int argc, char **argv) {
  ::testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

执行以下步骤完成练习:

  1. 在 Eclipse CDT 中打开我们的 CMake 项目。

  2. 添加一个新的测试源文件(tests/SumFuncTest.cpp),内容如下:

#include "gtest/gtest.h"
#include "../src/SumFunc.h"
namespace {
  class SumFuncTest: public ::testing::Test {};
  TEST_F(SumFuncTest, CanSumCorrectly) {
    EXPECT_EQ(7, sum(3, 4));
  }
}

请注意,这里没有main()函数,因为CanTest.cpp有一个,它们将被链接在一起。其次,请注意,这包括SumFunc.h,它在测试中使用了sum(3, 4)。这是我们在测试中使用项目代码的方式。

  1. tests/CMakeLists.txt文件中进行以下更改以构建测试:
include(GoogleTest)
add_executable(tests CanTest.cpp SumFuncTest.cpp ../src/SumFunc.cpp) # added files here
target_link_libraries(tests GTest::GTest)
gtest_discover_tests(tests)

请注意,我们将测试(SumFuncTest.cpp)和它测试的代码(../src/SumFunc.cpp)都添加到可执行文件中,因为我们的测试代码正在使用实际项目中的代码。

  1. 构建项目并像以前一样运行测试。您应该看到以下报告:图 1.31:运行测试后的输出
图 1.31:运行测试后的输出

我们可以将这样的测试添加到我们的项目中,所有这些测试都将显示在屏幕上,就像前面的截图所示的那样。

  1. 现在,让我们添加一个实际失败的测试。在tests/SumFuncTest.cpp文件中,进行以下更改:
TEST_F(SumFuncTest, CanSumCorrectly) {
  EXPECT_EQ(7, sum(3, 4));
}
// add this test
TEST_F(SumFuncTest, CanSumAbsoluteValues) {
  EXPECT_EQ(6, sum(3, -3));
}

请注意,此测试假定输入的绝对值被求和,这是不正确的。这次调用的结果是0,但在这个例子中预期是6。这是我们在项目中必须做的唯一更改,以添加这个测试。

  1. 现在,构建项目并运行测试。您应该会看到这个报告:图 1.32:构建报告
图 1.32:构建报告

如前图所示,前两个测试通过了,最后一个测试失败了。当我们看到这个输出时,有两种选择:要么我们的项目代码有问题,要么测试有问题。在这种情况下,我们的测试有问题。这是因为我们的6等于sum(3, -3)。这是因为我们假设我们的函数对提供的整数的绝对值求和。然而,事实并非如此。我们的函数只是简单地添加给定的数字,无论它们是正数还是负数。因此,这个测试有一个错误的假设,所以失败了。

  1. 让我们改变测试并修复它。修改测试,使我们期望-33的和为0。重命名测试以反映这个测试实际上做了什么:
TEST_F(SumFuncTest, CanSumCorrectly) {
  EXPECT_EQ(7, sum(3, 4));
}
// change this part
TEST_F(SumFuncTest, CanUseNegativeValues) {
  EXPECT_EQ(0, sum(3, -3));
}
  1. 现在运行它,并观察报告中所有测试是否都通过了:

图 1.33:测试执行成功

图 1.33:测试执行成功

最后,我们已经在系统和项目中使用 CMake 设置了 Google Test。我们还使用 Google Test 编写、构建和运行了单元测试,无论是在终端还是在 Eclipse 中。理想情况下,您应该为每个类编写单元测试,并覆盖每种可能的用法。您还应该在每次重大更改后运行测试,并确保不会破坏现有代码。在下一节中,我们将执行一个添加新类及其测试的活动。

活动 2:添加新类及其测试

在开发 C++项目时,随着项目的增长,我们会向其中添加新的源文件。我们还会为它们编写测试,以确保它们正常工作。在这个活动中,我们将添加一个模拟1D线性运动的新类。该类将具有positionvelocity的 double 字段。它还将有一个advanceTimeBy()方法,接收一个 double dt参数,根据velocity的值修改position。对于 double 值,请使用EXPECT_DOUBLE_EQ而不是EXPECT_EQ。在这个活动中,我们将向项目中添加一个新类及其测试。按照以下步骤执行此活动:

  1. 在 Eclipse IDE 中打开我们创建的项目。

  2. LinearMotion1D.cppLinearMotion1D.h文件对添加到包含LinearMotion1D类的项目中。在这个类中,创建两个 double 字段:positionvelocity。另外,创建一个advanceTimeBy(double dt)函数来修改position

  3. tests/LinearMotion1DTest.cpp文件中为此编写测试。编写两个代表两个不同方向运动的测试。

  4. 在 Eclipse IDE 中构建并运行它。

  5. 验证测试是否通过。

最终的测试结果应该类似于以下内容:

图 1.34:最终测试结果

图 1.34:最终测试结果

注意

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

在 C开发中,添加新类及其测试是一项非常常见的任务。我们出于各种原因创建类。有时,我们有一个很好的软件设计计划,我们创建它所需的类。其他时候,当一个类变得过大和单一时,我们以有意义的方式将一些责任分离到另一个类中。使这项任务变得实际是很重要的,以防止拖延和最终得到庞大的单一类。在接下来的部分中,我们将讨论编译和链接阶段发生了什么。这将让我们更好地了解 C程序底层发生了什么。

理解编译、链接和目标文件内容

使用 C的主要原因之一是效率。C使我们能够控制内存管理,这就是为什么理解对象在内存中的布局很重要的原因。此外,C源文件和库被编译为目标硬件的对象文件,并进行链接。通常,C程序员必须处理链接器问题,这就是为什么理解编译步骤并能够调查对象文件很重要的原因。另一方面,大型项目是由团队在长时间内开发和维护的,这就是为什么创建清晰易懂的代码很重要的原因。与任何其他软件一样,C项目中会出现错误,需要通过观察程序行为来仔细识别、分析和解决。因此,学习如何调试 C代码也很重要。在接下来的部分中,我们将学习如何创建高效、与其他代码协作良好且易于维护的代码。

编译和链接步骤

C++项目是一组源代码文件和项目配置文件,用于组织源文件和库依赖关系。在编译步骤中,这些源文件首先被转换为对象文件。在链接步骤中,这些对象文件被链接在一起,形成项目的最终输出可执行文件。项目使用的库也在这一步中被链接。

在即将进行的练习中,我们将使用现有项目来观察编译和链接阶段。然后,我们将手动重新创建它们以更详细地查看这个过程。

练习 7:识别构建步骤

您一直在构建项目而没有调查构建操作的详细信息。在这个练习中,我们将调查我们项目的构建步骤的详细信息。执行以下操作完成练习:

  1. 打开终端。

  2. 通过输入以下命令导航到build文件夹,其中我们的Makefile文件位于其中:

cd build/Debug
  1. 使用以下命令清理项目并以VERBOSE模式运行构建:
make clean 
make VERBOSE=1 all

您将在终端中获得构建过程的详细输出,可能会显得有点拥挤:

图 1.35:构建过程第 1 部分

图 1.35:构建过程第 1 部分

图 1.36:构建过程第 2 部分

图 1.36:构建过程第 2 部分

图 1.37:完整的构建输出

图 1.37:完整的构建输出

以下是此输出中的一些行。以下行是与主可执行文件的编译和链接相关的重要行:

/usr/bin/c++    -g   -pthread -std=gnu++1z -o CMakeFiles/CxxTemplate.dir/src/CxxTemplate.cpp.o -c /home/username/Packt/Cpp2019/CxxTemplate/src/CxxTemplate.cpp
/usr/bin/c++    -g   -pthread -std=gnu++1z -o CMakeFiles/CxxTemplate.dir/src/ANewClass.cpp.o -c /home/username/Packt/Cpp2019/CxxTemplate/src/ANewClass.cpp
/usr/bin/c++    -g   -pthread -std=gnu++1z -o CMakeFiles/CxxTemplate.dir/src/SumFunc.cpp.o -c /home/username/Packt/Cpp2019/CxxTemplate/src/SumFunc.cpp
/usr/bin/c++    -g   -pthread -std=gnu++1z -o CMakeFiles/CxxTemplate.dir/src/LinearMotion1D.cpp.o -c /home/username/Packt/Cpp2019/CxxTemplate/src/LinearMotion1D.cpp
/usr/bin/c++  -g   CMakeFiles/CxxTemplate.dir/src/CxxTemplate.cpp.o CMakeFiles/CxxTemplate.dir/src/ANewClass.cpp.o CMakeFiles/CxxTemplate.dir/src/SumFunc.cpp.o CMakeFiles/CxxTemplate.dir/src/LinearMotion1D.cpp.o  -o CxxTemplate -pthread 
  1. 这里的c++命令只是g++编译器的符号链接。要查看它实际上是一系列符号链接,输入以下命令:
namei /usr/bin/c++

您将看到以下输出:

图 1.38:/usr/bin/c++的符号链接链

图 1.38:/usr/bin/c++的符号链接链

因此,在我们的讨论中,我们将交替使用c++g++。在我们之前引用的构建输出中,前四行是编译每个.cpp源文件并创建相应的.o对象文件。最后一行是将这些对象文件链接在一起以创建CxxTemplate可执行文件。以下图形形象地展示了这个过程:

图 1.39:C++项目的执行阶段

图 1.39:C++项目的执行阶段

如前面的图所示,作为目标的一部分添加到 CMake 中的 CPP 文件以及它们包含的头文件被编译为对象文件,然后将它们链接在一起以创建目标可执行文件。

  1. 为了进一步了解这个过程,让我们自己执行编译步骤。在终端中,转到项目文件夹并使用以下命令创建一个名为mybuild的新文件夹:
cd ~/CxxTemplate
mkdir mybuild
  1. 然后,运行以下命令将 CPP 源文件编译为对象文件:
/usr/bin/c++ src/CxxTemplate.cpp -o mybuild/CxxTemplate.o -c 
/usr/bin/c++ src/ANewClass.cpp -o mybuild/ANewClass.o -c 
/usr/bin/c++ src/SumFunc.cpp -o mybuild/SumFunc.o -c 
/usr/bin/c++ src/LinearMotion1D.cpp -o mybuild/LinearMotion1D.o -c 
  1. 进入mybuild目录,并使用以下命令查看其中的内容:
cd mybuild
ls 

我们看到了预期的以下输出。这些是我们的目标文件:

图 1.40:已编译的目标文件

图 1.40:已编译的目标文件
  1. 在下一步中,将目标文件链接在一起形成我们的可执行文件。输入以下命令:
/usr/bin/c++  CxxTemplate.o ANewClass.o SumFunc.o LinearMotion1D.o  -o CxxTemplate 
  1. 现在,通过输入以下命令,让我们在文件列表中看到我们的可执行文件:
ls 

这显示了以下图中的新CxxTemplate文件:

图 1.41:链接可执行文件

图 1.41:链接可执行文件
  1. 现在,通过输入以下命令运行我们的可执行文件:
./CxxTemplate

然后看看我们之前的输出:

图 1.42:可执行文件输出

图 1.42:可执行文件输出

现在您已经检查了构建过程的细节,并自己重新创建了它们,在下一节中,让我们探索链接过程。

链接步骤

在本节中,让我们看一下两个源文件之间的联系以及它们如何最终出现在同一个可执行文件中。看看以下图中的sum函数:

图 1.43:链接过程

图 1.43:链接过程

sum函数的主体在SumFunc.cpp中定义。它在SumFunc.h中有一个前向声明。这样,想要使用sum函数的源文件可以了解其签名。一旦它们知道了它的签名,它们就可以调用它,并相信在运行时将会有实际的函数定义,而实际上并没有与SumFunc.cpp交互。

编译后,调用sum函数的CxxTemplate.cpp将该调用传递到其目标文件中。但它不知道函数定义在哪里。SumFunc.cpp的目标文件具有该定义,但与CxxTemplate.o无关。

在链接步骤中,链接器将CxxTemplate.o中的调用与SumFunc.o中的定义进行匹配。结果,可执行文件中的调用正常工作。如果链接器找不到sum函数的定义,它将产生链接器错误。

链接器找到了无法解析符号错误。

这使我们经历了构建过程的两个阶段:编译链接。请注意,与手动编译源文件时相比,我们使用了相当简单的命令。随时输入man g++以查看所有选项。稍后,我们将讨论链接以及符号是如何解析的。我们还讨论了链接步骤可能出现的问题。在下一节中,我们将学习有关目标文件的知识。

深入挖掘:查看目标文件

为了使链接步骤能够正常工作,我们需要使所有符号引用与符号定义匹配。大多数情况下,我们可以通过查看源文件来分析解决方案将如何解析。有时,在复杂情况下,我们可能难以理解为什么符号未能解析。在这种情况下,查看目标文件的内容以调查引用和定义可能有助于解决问题。除了链接器错误外,了解目标文件的内容以及链接工作的一般原理对于 C++程序员来说是有用的。了解底层发生的事情可能有助于程序员更好地理解整个过程。

当我们的源代码编译为目标文件时,我们的语句和表达式将转换为汇编代码,这是 CPU 理解的低级语言。汇编中的每条指令都包含一个操作,后跟寄存器,这些寄存器是 CPU 的寄存器。有指令用于将数据加载到寄存器中并从寄存器中加载数据,并对寄存器中的值进行操作。Linux 中的objdump命令可帮助我们查看这些目标文件的内容。

注意

我们将利用 Compiler Explorer,这是一个很好用的在线工具,您可以在左侧窗口上编写代码,在右侧可以看到编译后的汇编代码。这是 Compiler Explorer 的链接:godbolt.org

练习 8:探索编译代码

在这个练习中,我们将使用 Compiler Explorer 编译一些简单的 C++代码,其中我们定义并调用一个函数。我们将调查编译后的汇编代码,以了解名称是如何解析和调用是如何进行的。这将让我们更好地理解发生了什么以及我们的代码在可执行格式中是如何工作的。执行以下步骤完成练习:

  1. call sum(int, int)行中添加以下代码可以实现您的预期:它调用前面的sum函数并将参数放入一些寄存器中。这里的重要一点是,函数是通过它们的名称和参数类型按顺序标识的。链接器会寻找具有这个签名的适当函数。请注意,返回值不是签名的一部分。

  2. 禁用_Z,数字告诉我们函数名的长度,以便正确解释后面的字母。在函数名之后,我们有v表示没有参数,i表示一个int参数。您可以更改这些函数签名以查看其他可能的类型。

  3. 现在,让我们看看类是如何编译的。将以下代码添加到Compiler Explorer的现有代码下:

class MyClass {
private:
    int a = 5;
    int myPrivateFunc(int i) {
        a = 4;
        return i + a;
    }
public:
    int b = 6;
    int myFunc(){ 
        return sum(1, myPrivateFunc(b));
    }
};
MyClass myObject;
int main() {
    myObject.myFunc();
}

这是这些添加行的编译版本:

图 1.46:编译版本

图 1.46:编译版本

您可能会惊讶地发现编译代码中没有类定义。这些方法类似于全局函数,但有一个变化:它们的混淆名称包含类名,并将对象实例作为参数接收。创建实例只是为类的字段分配空间。

在链接器阶段,这些混淆的函数名用于将调用者与被调用者匹配。对于找不到被调用者的调用者,我们会得到链接器错误。大多数链接器错误可以通过仔细检查源代码来解决。然而,在某些情况下,使用objdump查看目标文件内容可以帮助找到问题的根源。

调试 C++代码

在开发 C++项目时,您可能会遇到不同级别的问题:

  • 首先,您可能会收到编译器错误。这可能是因为您在语法上犯了错误,或者选择了错误的类型等。编译器是您必须跨越的第一个障碍,它会捕捉到您可能犯的一些错误。

  • 第二个障碍是链接器。在那里,一个常见的错误是使用声明但实际上未定义的内容。当您使用错误的库头文件时,这种情况经常发生——头文件宣传了某个不存在于任何源文件或库中的签名。一旦您也通过了链接器的障碍,您的程序就准备好执行了。

  • 现在,下一个要跨越的障碍是避免任何运行时错误。您的代码可能已经编译和链接成功,但可能会出现一些不起作用的情况,比如解引用空指针或除以零。

要查找和修复运行时错误,您必须以某种方式与正在运行的应用程序进行交互和监视。一个经常使用的技术是向代码中添加print语句,并监视它生成的日志,希望将应用程序行为与日志相关联,以确定代码中存在问题的区域。虽然这对某些情况有效,但有时您需要更仔细地查看执行情况。

调试器是一个更好的工具来解决运行时错误。调试器可以让你逐行运行代码,继续运行并在你想要的行上暂停,调查内存的值,并在错误上暂停,等等。这让你可以在程序运行时观察内存的具体情况,并确定导致不良行为的代码行。

gdb是一个经典的命令行调试器,可以调试 C++程序。然而,它可能难以使用,因为调试本质上是一项视觉任务——你希望能够同时查看代码行、变量值和程序的输出。幸运的是,Eclipse CDT 包含了一个易于使用的可视化调试器。

练习 9:使用 Eclipse CDT 进行调试

你之前只是简单地运行项目并查看输出。现在你想要学习如何详细调试你的代码。在这个练习中,我们将探索 Eclipse CDT 的调试能力。按照以下步骤完成练习:

  1. 在 Eclipse CDT 中打开 CMake 项目。

  2. 为了确保我们有一个现有的运行配置,点击运行 | 运行配置。在那里,你应该在C/C++应用程序下看到一个CxxTemplate条目。

注意

由于我们之前运行了项目,它应该在那里。如果没有,请返回并重新创建。

  1. 关闭对话框以继续。

  2. 要启动调试器,找到看起来像昆虫(虫子)的工具栏条目,并点击旁边的下拉菜单。选择main()函数,它在代码视图中央显示为绿色高亮和箭头。在左侧,我们看到正在运行的线程,其中只有一个。在右侧,我们看到在这个上下文中可访问的变量。在底部,我们看到 Eclipse 在后台使用的gdb输出来实际调试可执行文件。现在,我们的主函数没有太多需要调试的地方。

  3. 点击libc-start.c库,它是main函数的调用者。当完成后,你可以关闭它并切换到你的源文件。当你不再看到红色停止按钮时,你就知道程序执行结束了。

  4. 通过添加以下代码编辑我们的main函数:

int i = 1, t = 0;
do {
  t += i++;
} while (i <= 3);
std::cout << t << std::endl;

后增量运算符与偶尔的do-while循环对一些人来说可能是一个难题。这是因为我们试图在脑海中执行算法。然而,我们的调试器完全能够逐步运行它,并显示在执行过程中到底发生了什么。

  1. 在添加了上述代码后开始调试。点击工具栏上调试按钮旁边的下拉菜单,选择CxxTemplate。按下F6几次来逐步执行代码。它会显示变量的变化以及将要执行的代码行:图 1.48:跳过代码
图 1.48:跳过代码
  1. 在执行每行代码后看到变量的变化,可以更清楚地理解算法。按下F6,注意在执行t += i++;这行代码后的值:图 1.49:变量状态随时间变化
图 1.49:变量状态随时间变化

前面的输出清楚地解释了值是如何变化的,以及为什么最后打印出6

  1. 探索调试器的其他功能。虽然变量视图很有用,但你也可以悬停在任何变量上并浏览它的值:图 1.50:调试器的视图选项
图 1.50:调试器的视图选项

此外,表达式视图帮助你计算那些从浏览的值中不清楚的东西。

  1. 在右侧点击表达式,然后点击添加按钮:图 1.51:添加表达式
图 1.51:添加表达式
  1. 输入t+i并按Enter。现在你可以在表达式列表中看到总和:图 1.52:带有新表达式的表达式视图
图 1.52:带有新表达式的表达式视图

您可以在工具栏中按下红色方块,或选择运行 | 终止随时停止调试。另一个功能是断点,它告诉调试器每当它到达带有断点的行时暂停。到目前为止,我们一直在逐行执行我们的代码,这在一个大型项目中可能非常耗时。相反,通常您希望继续执行,直到到达您感兴趣的代码。

  1. 现在,不是逐行进行,而是在进行打印的行中添加一个断点。为此,请双击此行行号左侧的区域。在下图中,点表示断点:图 1.53:使用断点
图 1.53:使用断点
  1. 现在启动调试器。通常情况下,它将开始暂停。现在选择运行 | 恢复或单击工具栏按钮。它将运行循环的三次执行,并在我们的断点处暂停。这样,我们通过跳过我们不调查的代码来节省时间:图 1.54:使用调试器
图 1.54:使用调试器
  1. 当我们处理添加的循环时,我们忽略了创建app对象的行。步过命令跳过了这行。但是,我们也有选择进入这行中的构造函数调用的选项。为此,我们将使用运行 | 步入或相应的工具栏按钮。

  2. 停止调试器,然后再次启动。单击步过以转到创建应用程序的行:图 1.55:使用调试器 - 步过选项

图 1.55:使用调试器 - 步过选项
  1. 如果我们再次步过,高亮显示的是下一行将执行的行。相反,按下步入按钮。这将带我们进入构造函数调用:

图 1.56:使用调试器 - 步入选项

图 1.56:使用调试器 - 步入选项

这是一个方便的功能,可以更深入地了解函数,而不仅仅是跳过它。还要注意左侧调试视图中的调用堆栈。您可以随时单击较低的条目以再次查看调用者的上下文。

这是对 Eclipse CDT 调试器的简要介绍,它在内部使用 GDB 为您提供可视化调试体验。在尝试更好地理解运行时错误并纠正导致这些错误的错误时,您可能会发现调试非常有用。

编写可读的代码

虽然可视化调试器非常有用,可以识别和消除运行时错误或意外的程序行为,但更好的做法是编写更不太可能出现问题的代码。其中一种方法是努力编写更易读和理解的代码。然后,在代码中找问题更像是识别英语句子之间的矛盾,而不是解决神秘的谜题。当您以一种易于理解的方式编写代码时,您的错误通常在制造时就会显现出来,并且在您回来解决滑过的问题时更容易发现。

经历了一些令人不愉快的维护经验后,你意识到你编写的程序的主要目的不是让计算机按照你的意愿去做,而是告诉读者程序运行时计算机将会做什么。这通常意味着你需要输入更多的内容,而集成开发环境可以帮助你。这也可能意味着你有时会编写在执行时间或内存使用方面不是最优的代码。如果这与你所学的知识相悖,考虑到你可能在以微不足道的效率换取错误的风险。在我们拥有的庞大处理能力和内存的情况下,你可能会使你的代码变得不必要地晦涩,可能会在追求效率的虚无之中产生错误。在接下来的章节中,我们将列出一些经验法则,这些法则可能会帮助你编写更易读的代码。

缩进和格式化

C++代码,就像许多其他编程语言一样,由程序块组成。一个函数有一组语句组成它的主体作为一个块。循环的块语句将在迭代中执行。如果给定条件为真,则if语句的块将执行,相应的else语句的块将在条件为假时执行。

花括号,或者对于单语句块的缺失,通知计算机,而缩进形式的空白则通知人类读者关于块结构。缺乏缩进或者误导性的缩进会使读者非常难以理解代码的结构。因此,我们应该努力保持我们的代码缩进良好。考虑以下两个代码块:

// Block 1
if (result == 2) 
firstFunction();
secondFunction();
// Block 2
if (result == 2) 
  firstFunction();
secondFunction();

虽然从执行的角度来看它们是相同的,但在第二个示例中更清楚地表明firstFunction()只有在result2的情况下才会被执行。现在考虑以下代码:

if (result == 2) 
  firstFunction();
  secondFunction();

这只是误导。如果读者不小心,他们可能会很容易地假设secondFunction()只有在result2的情况下才会被执行。然而,从执行的角度来看,这段代码与前两个示例是相同的。

如果你觉得纠正缩进在减慢你的速度,你可以使用编辑器的格式化工具来帮助你。在 Eclipse 中,你可以选择一段代码并使用源码 | 纠正缩进来修复该选择的缩进,或者使用源码 | 格式化来修复代码的其他格式问题。

除了缩进之外,其他格式规则,比如将花括号放在正确的行上,在二元运算符周围插入空格,以及在每个逗号后插入一个空格,也是非常重要的格式规则,你应该遵守这些规则,以保持你的代码格式良好,易于阅读。

在 Eclipse 中,你可以在窗口 | 首选项 | C/C++ | 代码样式 | 格式化程序中为每个工作空间设置格式化规则,或者在项目 | 属性 | C/C++常规 | 格式化程序中为每个项目设置格式化规则。你可以选择行业标准样式,比如 K&R 或 GNU,或者修改它们并创建自己的样式。当你使用源码 | 格式化来格式化你的代码时,这变得尤为重要。例如,如果你选择使用空格进行缩进,但 Eclipse 的格式化规则设置为制表符,你的代码将成为制表符和空格的混合体。

使用有意义的标识符名称

在我们的代码中,我们使用标识符来命名许多项目——变量、函数、类名、类型等等。对于计算机来说,这些标识符只是一系列字符,用于区分它们。然而,对于读者来说,它们更重要。标识符应该完全且明确地描述它所代表的项目。同时,它不应该过长。此外,它应该遵守正在使用的样式标准。

考虑以下代码:

studentsFile File = runFileCheck("students.dat");
bool flag = File.check();
if (flag) {
    int Count_Names = 0;
    while (File.CheckNextElement() == true) {
        Count_Names += 1;
    }
    std::cout << Count_Names << std::endl;
}

虽然这是一段完全有效的 C++代码,但它很难阅读。让我们列出它的问题。首先,让我们看看标识符的风格问题。studentsFile类名以小写字母开头,而应该是大写字母。File变量应该以小写字母开头。Count_Names变量应该以小写字母开头,而且不应该有下划线。CheckNextElement方法应该以小写字母开头。虽然这些规则可能看起来是武断的,但在命名上保持一致会携带关于名称的额外信息——当你看到一个以大写字母开头的单词时,你立刻明白它必须是一个类名。此外,拥有不遵守使用标准的名称只会分散注意力。

现在,让我们超越风格,检查名称本身。第一个有问题的名称是runFileCheck函数。方法是返回值的动作:它的名称应该清楚地解释它的作用以及它的返回值。 “Check”是一个过度使用的词,在大多数情况下都太模糊了。是的,我们检查了,它在那里——那么我们接下来该怎么办呢?在这种情况下,似乎我们实际上读取了文件并创建了一个File对象。在这种情况下,runFileCheck应该改为readFile。这清楚地解释了正在进行的操作,返回值是你所期望的。如果你想对返回值更具体,readAsFile可能是另一种选择。同样,check方法太模糊了,应该改为existsCheckNextElement方法也太模糊了,应该改为nextElementExists

另一个过度使用的模糊词是flag,通常用于布尔变量。名称暗示了一个开/关的情况,但并没有提示其值的含义。在这种情况下,它的true值表示文件存在,false值表示文件不存在。命名布尔变量的技巧是设计一个问题或语句,当变量的值为true时是正确的。在这个例子中,fileExistsdoesFileExist是两个不错的选择。

我们下一个命名不当的变量是Count_Names,或者正确的大写形式countNames。这对于整数来说是一个糟糕的名称,因为名称并没有暗示一个数字,而是暗示导致一个数字的动作。相反,诸如numNamesnameCount这样的标识符会清楚地传达内部数字的含义。

保持算法清晰简单

当我们阅读代码时,所采取的步骤和流程应该是有意义的。间接进行的事情——函数的副产品,为了效率而一起执行的多个操作等等——这些都会让读者难以理解你的代码。例如,让我们看看以下代码:

int *input = getInputArray();
int length = getInputArrayLength();
int sum = 0;
int minVal = 0;
for (int i = 0; i < length; ++i) {
  sum += input[i];
  if (i == 0 || minVal > input[i]) {
    minVal = input[i];
  }
  if (input[i] < 0) {
    input[i] *= -1;
  }
}

在这里,我们有一个在循环中处理的数组。乍一看,很难确定循环到底在做什么。变量名帮助我们理解正在发生的事情,但我们必须在脑海中运行算法,以确保这些名称所宣传的确实发生在这里。在这个循环中进行了三种不同的操作。首先,我们找到所有元素的总和。其次,我们找到数组中的最小元素。第三,我们在这些操作之后取每个元素的绝对值。

现在考虑这个替代版本:

int *input = getInputArray();
int length = getInputArrayLength();
int sum = 0;
for (int i = 0; i < length; ++i) {
  sum += input[i];
}
int minVal = 0;
for (int i = 0; i < length; ++i) {
  if (i == 0 || minVal > input[i]) {
    minVal = input[i];
  }
}
for (int i = 0; i < length; ++i) {
  if (input[i] < 0) {
    input[i] *= -1;
  }
}

现在一切都清晰多了。第一个循环找到输入的总和,第二个循环找到最小的元素,第三个循环找到每个元素的绝对值。虽然现在更清晰、更易理解,但你可能会觉得自己在做三个循环,因此浪费了 CPU 资源。创造更高效的代码的动力可能会促使你合并这些循环。请注意,这里的效率提升微乎其微;你的程序的时间复杂度仍然是 O(n)。

在创建代码时,可读性和效率是经常竞争的两个约束条件。如果你想开发可读性强、易于维护的代码,你应该始终优先考虑可读性。然后,你应该努力开发同样高效的代码。否则,可读性低的代码可能难以维护,甚至可能存在难以识别和修复的错误。当你的程序产生错误结果或者添加新功能的成本变得太高时,程序的高效性就变得无关紧要了。

练习 10:使代码更易读

以下代码存在样式和缩进问题。空格使用不一致,缩进不正确。此外,关于单语句if块是否使用大括号的决定也不一致。以下代码存在缩进、格式、命名和清晰度方面的问题:

//a is the input array and Len is its length
void arrayPlay(int *a, int Len) { 
    int S = 0;
    int M = 0;
    int Lim_value = 100;
    bool flag = true;
    for (int i = 0; i < Len; ++i) {
    S += a[i];
        if (i == 0 || M > a[i]) {
        M = a[i];
        }
        if (a[i] >= Lim_value) {            flag = true;
            }
            if (a[i] < 0) {
            a[i] *= 2;
        }
    }
}

让我们解决这些问题,使其符合常见的 C++代码风格。执行以下步骤完成这个练习:

  1. 打开 Eclipse CDT。

  2. 创建一个新的a,其长度为Len。对这些更好的命名应该是inputinputLength

  3. 让我们首先做出这个改变,将a重命名为input。如果你正在使用 Eclipse,你可以选择Len并将其重命名为inputLength

  4. 更新后的代码将如下所示。请注意,由于参数名是不言自明的,我们不再需要注释:

void arrayPlay(int *input, int inputLength) {
    int S = 0;
    int M = 0;
    int Lim_value = 100;
    bool flag = true;
    for (int i = 0; i < inputLength; ++i) {
        S += input[i];
        if (i == 0 || M > input[i]) {
            M = input[i];
        }
        if (input[i] >= Lim_value) {
            flag = true;
        }
        if (input[i] < 0) {
            input[i] *= 2;
        }
    }
}
  1. 在循环之前我们定义了一些其他变量。让我们试着理解它们。它似乎只是将每个元素添加到S中。因此,S必须是sum。另一方面,M似乎是最小的元素——让我们称它为smallest

  2. Lim_value似乎是一个阈值,我们只是想知道它是否被越过。让我们将其重命名为topThreshold。如果越过了这个阈值,flag变量被设置为 true。让我们将其重命名为isTopThresholdCrossed。在这些更改后,代码的状态如下所示:重构 | 重命名

void arrayPlay(int *input, int inputLength) {
    int sum = 0;
    int smallest = 0;
    int topThreshold = 100;
    bool isTopThresholdCrossed = true;
    for (int i = 0; i < inputLength; ++i) {
        sum += input[i];
        if (i == 0 || smallest > input[i]) {
            smallest = input[i];
        }
        if (input[i] >= topThreshold) {
            isTopThresholdCrossed = true;
        }
        if (input[i] < 0) {
            input[i] *= 2;
        }
    }
}

现在,让我们看看如何使这段代码更简单、更易理解。前面的代码正在做这些事情:计算输入元素的总和,找到最小的元素,确定是否越过了顶部阈值,并将每个元素乘以 2。

  1. 由于所有这些都是在同一个循环中完成的,现在算法不太清晰。修复这个问题,将其分为四个独立的循环:
void arrayPlay(int *input, int inputLength) {
    // find the sum of the input
    int sum = 0;
    for (int i = 0; i < inputLength; ++i) {
        sum += input[i];
    }
    // find the smallest element
    int smallest = 0;
    for (int i = 0; i < inputLength; ++i) {
        if (i == 0 || smallest > input[i]) {
            smallest = input[i];
        }
    }
    // determine whether top threshold is crossed
    int topThreshold = 100;
    bool isTopThresholdCrossed = true;
    for (int i = 0; i < inputLength; ++i) {
        if (input[i] >= topThreshold) {
            isTopThresholdCrossed = true;
        }
    }
    // multiply each element by 2
    for (int i = 0; i < inputLength; ++i) {
        if (input[i] < 0) {
            input[i] *= 2;
        }
    }
}

现在代码清晰多了。虽然很容易理解每个块在做什么,但我们还添加了注释以使其更清晰。在这一部分,我们更好地理解了我们的代码是如何转换为可执行文件的。然后,我们讨论了识别和解决可能的代码错误的方法。我们最后讨论了如何编写可读性更强、更不容易出现问题的代码。在下一部分,我们将解决一个活动,我们将使代码更易读。

活动 3:使代码更易读

你可能有一些难以阅读并且包含错误的代码,要么是因为你匆忙写成的,要么是因为你从别人那里收到的。你想改变代码以消除其中的错误并使其更易读。我们有一段需要改进的代码。逐步改进它并使用调试器解决问题。执行以下步骤来实施这个活动:

  1. 下面是SpeedCalculator类的源代码。将这两个文件添加到你的项目中。

  2. 在你的main()函数中创建这个类的一个实例,并调用它的run()方法。

  3. 修复代码中的风格和命名问题。

  4. 简化代码以使其更易理解。

  5. 运行代码并观察运行时的问题。

  6. 使用调试器来解决问题。

这是SpeedCalculator.cppSpeedCalculator.h的代码,你将把它们添加到你的项目中。你将修改它们作为这个活动的一部分:

// SpeedCalculator.h
#ifndef SRC_SPEEDCALCULATOR_H_
#define SRC_SPEEDCALCULATOR_H_
class SpeedCalculator {
private:
    int numEntries;
    double *positions;
    double *timesInSeconds;
    double *speeds;
public:
    void initializeData(int numEntries);
    void calculateAndPrintSpeedData();
};
#endif /* SRC_SPEEDCALCULATOR_H_ */

//SpeedCalculator.cpp
#include "SpeedCalculator.h"
#include <cstdlib>
#include <ctime>
#include <iostream>
#include <cassert>
void SpeedCalculator::initializeData(int numEntries) {
    this->numEntries = numEntries;
    positions = new double[numEntries];
    timesInSeconds = new double[numEntries];
    srand(time(NULL));
    timesInSeconds[0] = 0.0;
    positions[0] = 0.0;
    for (int i = 0; i < numEntries; ++i) {
    positions[i] = positions[i-1] + (rand()%500);
    timesInSeconds[i] = timesInSeconds[i-1] + ((rand()%10) + 1);
    }
}
void SpeedCalculator::calculateAndPrintSpeedData() {
    double maxSpeed = 0;
    double minSpeed = 0;
    double speedLimit = 100;
    double limitCrossDuration = 0;
    for (int i = 0; i < numEntries; ++i) {
        double dt = timesInSeconds[i+1] - timesInSeconds[i];
        assert (dt > 0);
        double speed = (positions[i+1] - positions[i]) / dt;
            if (maxSpeed < speed) {
                maxSpeed = speed;
            }
            if (minSpeed > speed) {
                minSpeed = speed;
            }
        if (speed > speedLimit) {
            limitCrossDuration += dt;
        }
        speeds[i] = speed;
    }
    std::cout << "Max speed: " << maxSpeed << std::endl;
        std::cout << "Min speed: " << minSpeed << std::endl;
        std::cout << "Total duration: " << 
timesInSeconds[numEntries - 1] - timesInSeconds[0] << " seconds" << std::endl;
    std::cout << "Crossed the speed limit for " << limitCrossDuration << " seconds"<< std::endl;
    delete[] speeds;
}

注意

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

总结

在本章中,我们学习了如何创建可移植和可维护的 C项目。我们首先学习了如何创建 CMake 项目以及如何将它们导入到 Eclipse CDT,从而使我们可以选择使用命令行或者 IDE。本章的其余部分侧重于消除项目中的各种问题。首先,我们学习了如何向项目添加单元测试,以及如何使用它们来确保我们的代码按预期工作。然后,我们讨论了代码经历的编译和链接步骤,并观察了目标文件的内容,以更好地理解可执行文件。接着,我们学习了如何在 IDE 中以可视化方式调试我们的代码,以消除运行时错误。我们用一些经验法则结束了这个讨论,这些法则有助于创建可读、易懂和可维护的代码。这些方法将在你的 C之旅中派上用场。在下一章中,我们将更多地了解 C++的类型系统和模板。

第二章:禁止鸭子 - 类型和推断

学习目标

通过本章结束时,您将能够:

  • 实现自己的类,使其行为类似于内置类型

  • 实现控制编译器创建的函数的类(零规则/五规则)

  • 使用 auto 变量开发函数,就像你一直做的那样

  • 通过使用强类型编写更安全的代码来实现类和函数

本章将为您提供对 C++类型系统的良好基础,并使您能够编写适用于该系统的自己的类型。

引言

C是一种强类型、静态类型的语言。编译器使用与使用的变量相关的类型信息以及它们所用的上下文来检测和防止某些类别的编程错误。这意味着每个对象都有一个类型,而且该类型永远不会改变。相比之下,Python 和 PHP 等动态类型语言将类型检查推迟到运行时(也称为后期绑定),变量的类型可能在应用程序执行过程中发生变化。这些语言使用鸭子测试而不是变量类型 - 也就是说,“如果它走起来像鸭子,叫起来像鸭子,那么它一定是鸭子。”C等静态类型语言依赖于类型来确定变量是否可以用于特定目的,而动态类型语言依赖于某些方法和属性的存在来确定其适用性。

C最初被描述为“带类的 C”。这是什么意思?基本上,C 提供了一组内置的基本类型 - int、float、char 等 - 以及这些项的指针和数组。您可以使用 struct 将这些聚合成相关项的数据结构。C将此扩展到类,以便您可以完全定义自己的类型,包括可以用来操作它们的运算符,从而使它们成为语言中的一等公民。自其谦卑的开始以来,C++已经发展成为不仅仅是“带类的 C”,因为它现在可以表达面向对象范式(封装、多态、抽象和继承)、函数范式和泛型编程(模板)。

在本书中,我们将重点关注 C支持面向对象范式的含义。随着您作为开发人员的经验增长,并且接触到像 Clojure、Haskell、Lisp 和其他函数式语言,它们将帮助您编写健壮的 C代码。动态类型语言如 Python、PHP 和 Ruby 已经影响了我们编写 C代码的方式。随着 C17 的到来,引入了std::variant类 - 一个在编译时保存我们选择的任何类型,并且在动态语言中的变量类似。

在上一章中,我们学习了如何使用 CMake 创建可移植和可维护的 C++项目。我们学习了如何在项目中加入单元测试,以帮助编写正确的代码,并在出现问题时进行调试。我们了解了工具链如何将我们的代码通过一系列程序流水线处理,以生成可执行文件。最后,我们总结了一些经验法则,帮助我们创建可读性强、理解性好、易于维护的代码。

在本章中,我们将快速浏览 C++类型系统,声明和使用我们自己的类型。

C++类型

作为一种强类型和静态类型的语言,C++提供了几种基本类型,并能够根据需要定义自己的类型,以解决手头的问题。本节将首先介绍基本类型,初始化它们,声明变量,并将类型与之关联。然后我们将探讨如何声明和定义新类型。

C++基本类型

C包括几种基本类型内置类型。C标准定义了每种类型在内存中的最小大小和它们的相对大小。编译器识别这些基本类型,并具有内置规则来定义可以对它们执行哪些操作和不能执行哪些操作。还有关于类型之间的隐式转换的规则;例如,从 int 类型到 float 类型的转换。

注意

有关所有内置类型的简要描述,请参阅en.cppreference.com/w/cpp/language/types中的基本类型部分。

C++文字量

C++文字量用于告诉编译器您希望在声明变量或对其进行赋值时与变量关联的值。前一节中的每种内置类型都有与之关联的文字量形式。

注意

有关每种类型的文字量的简要描述,请参阅en.cppreference.com/w/cpp/language/expressions中的文字量部分。

指定类型 - 变量

由于 C++是一种静态类型语言,在声明变量时需要指定变量的类型。当声明函数时,需要指定返回类型和传递给它的参数的类型。在声明变量时,有两种选择可以指定类型:

  • 显式:您作为程序员正在明确指定类型。

  • 隐式(使用 auto):您告诉编译器查看用于初始化变量的值并确定其类型。这被称为(auto)类型推导

标量变量的声明一般形式如下之一:

type-specifier var;                       // 1\. Default-initialized variable
type-specifier var = init-value;          // 2\. Assignment initialized variable
type-specifier var{init-value};           // 3\. Brace-initialize variable

type-specifier指示您希望将var变量与之关联的类型(基本类型或用户定义类型)。所有三种形式都会导致编译器分配一些存储空间来保存值,并且将来对var的所有引用都将引用该位置。init-value用于初始化存储位置。默认初始化对内置类型无效,并将根据函数重载解析调用用户定义类型的构造函数来初始化存储。

编译器必须知道要分配多少内存,并提供一个运算符来确定类型或变量有多大 - sizeof

根据我们的声明,编译器将在计算机的内存中留出空间来存储变量引用的数据项。考虑以下声明:

int value = 42;     // declare value to be an integer and initialize to 42
short a_value{64};  // declare a_value to be a short integer and initialize
                    //    to 64
int bad_idea;       // declare bad_idea to be an integer and DO NOT 
                    // initialize it. Use of this variable before setting
                    // it is UNDEFINED BEHAVIOUR.
float pi = 3.1415F; // declare pi to be a single precision floating point
                    // number and initialize it to pi.
double e{2.71828};  // declare e to be a double precision floating point
                    // number and initialize it to natural number e.
auto title = "Sir Robin of Loxley"; // Let the compiler determine the type

如果这些是在函数范围内声明的,那么编译器会从所谓的堆栈中为它们分配内存。这可能看起来像以下的内存布局:

图 2A.1:变量的内存布局

图 2A.1:变量的内存布局

编译器将按照我们声明变量的顺序分配内存。未使用的内存是因为编译器分配内存,以便基本类型通常是原子访问的,并且为了效率而对齐到适当的内存边界。请注意,titleconst char *类型,是const。**"Sir Robin of Loxley"**字符串将存储在程序加载时初始化的内存的不同部分。我们将在后面讨论程序内存。

标量声明语法的轻微修改给我们提供了声明值数组的语法:

type-specifier ary[count];                          // 1\. Default-initialized 
type-specifier ary[count] = {comma-separated list}; // 2\. Assignment initialized 
type-specifier ary[count]{comma-separated list};    // 3\. Brace-initialized

这可以用于多维数组,如下所示:

type-specifier ary2d[countX][countY]; 
type-specifier ary3d[countX][countY][countZ];
// etc...

请注意,前述声明中的countcountX和其他项目在编译时必须评估为常量,否则将导致错误。此外,逗号分隔的初始化列表中的项目数必须小于或等于count,否则将再次出现编译错误。在下一节中,我们将在练习中应用到目前为止学到的概念。

注意

在本章的任何实际操作之前,下载本书的 GitHub 存储库(github.com/TrainingByPackt/Advanced-CPlusPlus),并在 Eclipse 中导入 Lesson 2A 文件夹,以便您可以查看每个练习和活动的代码。

练习 1:声明变量和探索大小

这个练习将为本章的所有练习设置,并让您熟悉声明和初始化内置类型的变量。您还将介绍auto 声明数组sizeof。让我们开始吧:

  1. 打开 Eclipse(在第一章 可移植 C++软件的解剖中使用),如果出现启动窗口,请点击启动。

  2. 转到File,在New 下选择Project…,然后转到选择 C++ Project(而不是 C/C++ Project)。

  3. 点击Next >,清除Use default location复选框,并输入Lesson2A作为Project name

  4. 选择Empty Project作为Project Type。然后,点击**Browse…**并导航到包含 Lesson2A 示例的文件夹。

  5. 点击打开以选择文件夹并关闭对话框。

  6. 点击Next >Next >,然后点击Finish

  7. 为了帮助您进行练习,我们将配置工作区在构建之前自动保存文件。转到Window,选择Preferences。在General下,打开Workspace并选择Build

  8. 勾选Save automatically before build框,然后点击Apply and Close

  9. 就像第一章 可移植 C++软件的解剖一样,这是一个基于 CMake 的项目,所以我们需要更改当前的构建器。在Project资源管理器中点击Lesson2A,然后在Project菜单下点击Properties。在左侧窗格中选择 C/C++ Build 下的 Tool Chain Editor,并将 Current builder 设置为 Cmake Build(portable)。

  10. 点击Apply and Close。然后,选择Project | Build All菜单项来构建所有练习。默认情况下,屏幕底部的控制台将显示CMake Console [Lesson2A]图 2A.2:CMake 控制台输出

图 2A.2:CMake 控制台输出
  1. 在控制台的右上角,点击Display Selected Console按钮,然后从列表中选择CDT Global Build Console图 2A.3:选择不同的控制台
图 2A.3:选择不同的控制台

这将显示构建的结果 - 应该显示 0 个错误和 3 个警告:

图 2A.4:构建过程控制台输出

图 2A.4:构建过程控制台输出
  1. 由于构建成功,我们希望运行 Exercise1。在窗口顶部,点击下拉列表,选择No Launch Configurations图 2A.5:启动配置菜单
图 2A.5:启动配置菜单
  1. 点击New Launch Configuration…。保持默认设置,然后点击Next >

  2. Name更改为Exercise1,然后点击Search Project图 2A.6:Exercise1 启动配置

图 2A.6:Exercise1 启动配置
  1. 从 Binaries 窗口中显示的程序列表中,点击Exercise1,然后点击OK

  2. 点击Finish。这将导致 exercise1 显示在启动配置下拉框中:图 2A.7:更改启动配置

图 2A.7:更改启动配置
  1. 要运行Exercise1,点击Run按钮。Exercise1 将在控制台中执行并显示其输出:图 2A.8:exercise1 的输出
图 2A.8:exercise1 的输出

该程序没有任何价值 - 它只输出系统中各种类型的大小。但这表明程序是有效的并且可以编译。请注意,您系统的数字可能会有所不同(特别是 sizeof(title)的值)。

  1. 在“项目资源管理器”中,展开“Lesson2A”,然后展开“Exercise01”,双击“Exercise1.cpp”以在编辑器中打开此练习的文件:
int main(int argc, char**argv)
{
    std::cout << "\n\n------ Exercise 1 ------\n";
    int value = 42;     // declare value to be an integer & initialize to 42
    short a_value{64};  // declare a_value to be a short integer & 
                        // initialize to 64
    int bad_idea;       // declare bad_idea to be an integer and DO NOT 
                        // initialize it. Use of this variable before 
                        // setting it is UNDEFINED BEHAVIOUR.
    float pi = 3.1415F; // declare pi to be a single precision floating 
                        // point number and initialize it to pi.

    double e{2.71828};  // declare e to be a double precision floating point
                        // number and initialize it to natural number e.
    auto title = "Sir Robin of Loxley"; 
                        // Let the compiler determine the type
    int ary[15]{};      // array of 15 integers - zero initialized
    // double pi = 3.14159;  // step 24 - remove comment at front
    // auto speed;           // step 25 - remove comment at front
    // value = "Hello world";// step 26 - remove comment at front
    // title = 123456789;    // step 27 - remove comment at front
    // short sh_int{32768};  // step 28 - remove comment at front
    std::cout << "sizeof(int) = " << sizeof(int) << "\n";
    std::cout << "sizeof(short) = " << sizeof(short) << "\n";
    std::cout << "sizeof(float) = " << sizeof(float) << "\n";
    std::cout << "sizeof(double) = " << sizeof(double) << "\n";
    std::cout << "sizeof(title) = " << sizeof(title) << "\n";
    std::cout << "sizeof(ary) = " << sizeof(ary)
              << " = " << sizeof(ary)/sizeof(ary[0]) 
              << " * " << sizeof(ary[0]) << "\n";
    std::cout << "Complete.\n";
    return 0;
}

关于前面的程序,需要注意的一点是,主函数的第一条语句实际上是可执行语句,而不是声明。 C++允许您几乎可以在任何地方声明变量。 它的前身 C 最初要求所有变量必须在任何可执行语句之前声明。

最佳实践

尽可能靠近将要使用的位置声明变量并初始化它。

  1. 在编辑器中,通过删除行开头的分隔符(//)取消注释标记为“步骤 24”的行:
double pi = 3.14159;  // step 24 - remove comment at front    
// auto speed;           // step 25 - remove comment at front
// value = "Hello world";// step 26 - remove comment at front
// title = 123456789;    // step 27 - remove comment at front
// short sh_int{32768};  // step 28 - remove comment at front
  1. 再次单击“运行”按钮。 这将导致再次构建程序。 这一次,构建将失败,并显示错误:图 2A.9:工作区中的错误对话框
图 2A.9:工作区中的错误对话框
  1. 单击“取消”关闭对话框。 如果未显示“CDT 构建控制台[Lesson2A]”,则将其选择为活动控制台:图 2A.10:重复声明错误
图 2A.10:重复声明错误

这一次,构建失败,因为我们尝试重新定义变量 pi 的类型。 编译器提供了有关我们需要查找以修复错误的位置的有用信息。

  1. 将注释分隔符恢复到行的开头。 在编辑器中,通过删除行开头的分隔符(//)取消注释标记为“步骤 25”的行:
// double pi = 3.14159;  // step 24 - remove comment at front    
auto speed;           // step 25 - remove comment at front
// value = "Hello world";// step 26 - remove comment at front
// title = 123456789;    // step 27 - remove comment at front
// short sh_int{32768};  // step 28 - remove comment at front
  1. 再次单击“运行”按钮。 当“工作区中的错误”对话框出现时,单击“取消”:图 2A.11:自动声明错误-无初始化
图 2A.11:自动声明错误-无初始化

再次构建失败,但这次我们没有给编译器足够的信息来推断速度的类型-自动类型的变量必须初始化。

  1. 将注释分隔符恢复到行的开头。 在编辑器中,通过删除注释起始分隔符(//)取消注释标记为“步骤 26”的行:
// double pi = 3.14159;  // step 24 - remove comment at front    
// auto speed;           // step 25 - remove comment at front
value = "Hello world";// step 26 - remove comment at front
// title = 123456789;    // step 27 - remove comment at front
// short sh_int{32768};  // step 28 - remove comment at front
  1. 单击“值”。

  2. 将注释分隔符恢复到行的开头。 在编辑器中,通过删除行开头的分隔符(//)取消注释标记为“步骤 27”的行:

// double pi = 3.14159;  // step 24 - remove comment at front    
// auto speed;           // step 25 - remove comment at front
// value = "Hello world";// step 26 - remove comment at front
title = 123456789;    // step 27 - remove comment at front
// short sh_int{32768};  // step 28 - remove comment at front
  1. 单击int,以标题,这是一个const char*。 这里非常重要的一点是,title是用auto类型声明的。 编译器生成的错误消息告诉我们,title被推断为const char*类型。

  2. 将注释分隔符恢复到行的开头。 在编辑器中,通过删除行开头的分隔符(//)取消注释标记为“步骤 28”的行:

// double pi = 3.14159;  // step 24 - remove comment at front    
// auto speed;           // step 25 - remove comment at front
// value = "Hello world";// step 26 - remove comment at front
// title = 123456789;    // step 27 - remove comment at front
short sh_int{32768};  // step 28 - remove comment at front
  1. 单击sh_int与(short类型。 短占用两个字节的内存,被认为是 16 位的有符号数量。 这意味着可以存储在短中的值的范围是-2^(16-1)2^(16-1)-1,或-3276832767

  2. 将值从short更改。

  3. 将值从short更改。

  4. 将注释分隔符恢复到行的开头。 在编辑器中,尝试使用任何基本类型及其相关文字来探索变量声明,然后尽可能多地单击“运行”按钮。 检查“构建控制台”的输出是否有任何错误消息,因为这可能会帮助您找到错误。

在这个练习中,我们学习了如何设置 Eclipse 开发,实现变量声明,并解决声明中的问题。

指定类型-函数

现在我们可以声明一个变量为某种类型,我们需要对这些变量做些什么。 在 C++中,我们通过调用函数来做事情。 函数是一系列语句,产生结果。 结果可能是数学计算(例如,指数)然后发送到文件或写入终端。

函数允许我们将解决方案分解为更易于管理和理解的语句序列。当我们编写这些打包的语句时,我们可以在合适的地方重复使用它们。如果我们需要根据上下文使其以不同方式运行,那么我们会传入一个参数。如果它返回一个结果,那么函数需要一个返回类型。

由于 C++是一种强类型语言,我们需要指定与我们实现的函数相关的类型 - 函数返回的值的类型(包括无返回)以及传递给它的参数的类型(如果有的话)。

以下是一个典型的 hello world 程序:

#include <iostream>
void hello_world()
{
  std::cout << "Hello world\n"; 
}
int main(int argc, char** argv)
{
  std::cout << "Starting program\n";
  hello_world();
  std::cout << "Exiting program\n";
  return 0;
}

在上面的例子中声明了两个函数 - hello_world()main()main()函数是每个 C++程序的入口点,并返回一个传递给主机系统的int值。它被称为退出代码。

从返回类型的声明到开括号({)之间的所有内容都被称为函数原型。它定义了三件事,即返回类型、函数的名称和参数的数量和类型。

对于第一个函数,返回类型是void - 也就是说,它不返回任何值;它的名称是hello_world,不需要参数:

图 2A.15:不带参数并且不返回任何内容的函数声明

图 2A.15:不带参数并且不返回任何内容的函数声明

第二个函数返回一个int值,名称为main,并带有两个参数。这些参数分别是argcargv,类型分别为intchar类型的指针的指针

图 2A.16:带有两个参数并返回 int 的函数声明

图 2A.16:带有两个参数并返回 int 的函数声明

函数原型之后的所有内容都被称为函数体。函数体包含变量声明和要执行的语句。

函数在使用之前必须声明 - 也就是说,编译器需要知道它的参数和返回类型。如果函数在调用它的文件中定义在它之后,那么可以通过在使用之前提供函数的前向声明来解决这个问题。

通过在调用之前的文件中放置以分号终止的函数原型来进行前向声明。对于hello_world(),可以这样做:

void hello_world();

对于主函数,可以这样做:

int main(int, char**);

函数原型不需要参数的名称,只需要类型。但是,为了帮助函数的用户,保留参数是个好主意。

在 C++中,函数的定义可以在一个文件中,需要从另一个文件中调用。那么,第二个文件如何知道它希望调用的函数的原型?这是通过将前向声明放入一个名为头文件的单独文件中并在第二个文件中包含它来实现的。

练习 2:声明函数

在这个练习中,我们将测试编译器在遇到函数调用时需要了解的内容,并实现一个前向声明来解析未知的函数。让我们开始吧。

  1. 在 Eclipse 中打开Lesson2A项目,然后在Project Explorer中展开Lesson2A,然后展开Exercise02,双击Exercise2.cpp以在编辑器中打开此练习的文件。

  2. 单击Launch Configuration下拉菜单,选择New Launch Configuration…

  3. Exercise2配置为以名称Exercise2运行。完成后,它将成为当前选择的启动配置。

  4. 单击Run按钮。练习 2 将运行并产生以下输出:图 2A.17:exercise2 程序的输出

图 2A.17:exercise2 程序的输出
  1. 进入编辑器,通过将gcd函数移动到main之后来更改代码。它应该如下所示:
int main(int argc, char**argv)
{
    std::cout << "\n\n------ Exercise 2 ------\n";
    std::cout << "The greatest common divisor of 44 and 121 is " << gcd(44, 121) << "\n";
    std::cout << "Complete.\n";
    return 0;
}
int gcd(int x, int y)
{
    while(y!=0)
    {
        auto c{x%y};
        x = y;
        y = c;
    }
    return x;
}
  1. 点击gcd()函数。在需要调用它的时候,它对该函数没有任何了解,即使它在相同的文件中定义,但是在调用之后。

  2. 在编辑器中,将前向声明放在主函数定义之前。同时在末尾添加一个分号(;):

int gcd(int x, int y);
  1. 再次点击运行按钮。这次,程序编译并恢复原始输出。

在这个练习中,我们学习了如何提前声明函数并解决编译器错误,这些错误发生在使用函数之前未声明的情况下。

在早期的 C 编译器版本中,这是可以接受的。程序会假定函数存在并返回一个 int。函数的参数可以从调用中推断出来。然而,在现代 C++中并非如此,因为您必须在使用之前声明函数、类、变量等。在下一节中,我们将学习指针类型。

指针类型

由于 C 语言的起源,即编写高效的系统并直接访问硬件,C++允许您将变量声明为指针类型。其格式如下:

type-specifier* pvar = &var;

这与以前一样,只有两个不同之处:

  • 使用特殊声明符星号(*)指示名为 pvar 的变量指向内存中的位置或地址。

  • 它使用特殊运算符和号(&)进行初始化,在这种情况下告诉编译器返回var变量的地址。

由于 C 是一种高级语言,但具有低级访问权限,指针允许用户直接访问内存,这在我们希望向硬件提供输入/输出并控制硬件时非常有帮助。指针的另一个用途是允许函数访问共同的数据项,并在调用函数时消除大量数据的复制需求,因为它默认为按值传递。要访问指针指向的值,使用特殊运算符星号(*)来解引用位置:

int five = 5;                // declare five and initialize it
int *pvalue = &five;         // declare pvalue as pointer to int and have it
                            // point to the location of five
*pvalue = 6;                // Assign 6 into the location five.

下图显示了编译器分配内存的方式。pvalue需要内存来存储指针,而five需要内存来存储整数值 5:

图 2A.19:指针变量的内存布局

图 2A.19:指针变量的内存布局

当通过指针访问用户定义的类型时,还有第二个特殊运算符(->)用于解引用成员变量和函数。在现代 C中,这些指针被称为原始指针,它们的使用方式发生了显著变化。在 C 和 C中使用指针一直是程序员面临的挑战,它们的错误使用是许多问题的根源,最常见的是资源泄漏。资源泄漏是指程序获取了资源(内存、文件句柄或其他系统资源)供其使用,但在使用完毕后未释放。这些资源泄漏可能导致性能问题、程序失败,甚至系统崩溃。在现代 C中使用原始指针来管理资源的所有权现已被弃用,因为智能指针在 C11 中出现。智能指针(在 STL 中实现为类)现在执行所需的清理工作,以成为主机系统中的良好组成部分。关于这一点将在第三章能与应该之间的距离-对象、指针和继承中进行更多介绍。

在上面的代码中,当声明pvalue时,编译器只分配内存来存储它将引用的内存的地址。与其他变量一样,您应始终确保在使用指针之前对其进行初始化,因为对未初始化的指针进行解引用会导致未定义的行为。存储指针的内存量取决于编译器设计的系统以及处理器支持的位数。但是,无论它们指向什么类型,所有指针的大小都将相同。

指针也可以传递给函数。这允许函数访问指向的数据并可能修改它。考虑以下 swap 的实现:

void swap(int* data1, int* data2)
{
    int temp{*data1};         // Initialize temp from value pointed to by data1
    *data1 = *data2;          // Copy data pointed to by data2 into location 
                              // pointed to by data1
    *data2 = temp;            // Store the temporarily cached value from temp
                              // into the location pointed to by data2
}

这展示了如何将指针声明为函数的参数,如何使用解引用运算符*从指针获取值,以及如何通过解引用运算符设置值。

以下示例使用 new 运算符从主机系统中分配内存,并使用 delete 运算符将其释放回主机系统:

char* name = new char[20];    // Allocate 20 chars worth of memory and assign it
                              // to name.
  Do something with name
delete [] name;

在上面的代码中,第一行使用 new 运算符的数组分配形式创建了一个包含 20 个字符的数组。它向主机系统发出调用,为我们分配 20 * sizeof(char)字节的内存。分配多少内存取决于主机系统,但保证至少为 20 * sizeof(char)字节。如果无法分配所需的内存,则会发生以下两种情况之一:

  • 它会抛出一个异常

  • 它将返回nullptr。这是 C11 中引入的特殊文字。早期,C使用 0 或 NULL 表示无效指针。C++11 也将其作为强类型值。

在大多数系统上,第一个结果将是结果,并且您需要处理异常。第二个结果可能来自两种情况——调用 new 的 nothrow 变体,即new(std::nothrow) int [250],或者在嵌入式系统上,异常处理的开销不够确定。

最后,请注意,delete 的调用使用了 delete 运算符的数组形式,即带有方括号[]。重要的是确保与 new 和 delete 运算符一起使用相同的形式。当 new 用于用户定义的类型(将在下一节中讨论)时,它不仅仅是分配内存:

MyClass* object = new MyClass;

在上面的代码中,对 new 的调用分配了足够的内存来存储 MyClass,如果成功,它会继续调用构造函数来初始化数据:

MyClass* objects = new MyClass[12];

在上面的代码中,对 new 的调用分配了足够的内存来存储 12 个 MyClass 的副本,如果成功,它会继续调用构造函数 12 次来初始化每个对象的数据。

请注意,在上面代码片段中声明的objectobjectsobjects应该是指向 MyClass 数组的指针,但实际上它是 MyClass 实例的指针。objects指向 MyClass 数组中的第一个实例。

考虑以下代码摘录:

void printMyClasses(MyClass* objects, size_t number)
{
  for( auto i{0U} ; i<number ; i++ ) { 
    std::cout << objects[i] << "\n";
  }
}
void process()
{
    MyClass objects[12];

    // Do something with objects
    printMyClasses(objects, sizeof(objects)/sizeof(MyClass));
}

在 process()函数中,objects是"包含 12 个 MyClass 项的数组"类型,但当它传递给printMyClasses()时,它被(由编译器)转换为"指向 MyClass 的指针"类型。这是有意设计的(从 C 继承而来),并且被称为printMyClasses()如下:

void printMyClasses(MyClass objects[12], size_t number)

这仍然会受到数组衰减的影响,因为编译器将参数对象更改为 MyClass*;在这种情况下,它不保留维度信息。数组衰减是我们需要将数字传递给printMyClasses()函数的原因:这样我们就知道数组中有多少项。C++提供了两种处理数组衰减的机制:

  • 使用迭代器将范围传递到方法中。STL 容器(参见第 2B 章中的C++预打包模板部分,不允许鸭子-模板和推断)提供begin()end()方法,以便我们可以获得允许算法遍历数组或其部分的迭代器。

注意

对于 C++20,ISO 标准委员会正在考虑包含一种称为 Ranges 的概念,它将允许同时捕获起始和结束迭代器的对象。

  • 使用模板(参见第 2B 章,不允许鸭子-模板和推断中的非类型模板参数部分)。

练习 3:声明和使用指针

在这个练习中,我们将实现接受指针和数组作为参数并比较它们的行为,同时考虑数组衰减的函数。让我们开始吧:

  1. 在 Eclipse 中打开Lesson2A项目,然后在项目资源管理器中展开Lesson2A,然后Exercise03,双击Exercise3.cpp以在编辑器中打开此练习的文件。

  2. 点击Launch Configuration下拉菜单,选择New Launch Configuration…。配置Exercise3以运行名称Exercise3。完成后,它将成为当前选择的 Launch Configuration。

  3. 点击Run按钮。练习 3 将运行并产生以下输出:图 2A.20:练习 3 输出

图 2A.20:练习 3 输出
  1. 在编辑器中的某个地方插入一行空行,然后点击Run按钮。(通过更改文件,它将强制构建系统重新编译Exercise3.cpp。)

  2. 如果我们现在看print_array_size2()int*类型,并且由警告说明sizeof将返回'int*'的大小所证实:图 2A.22:练习 3 部分输出

图 2A.22:练习 3 部分输出

sizeof(ary)/sizeof(arg[0])的计算应返回数组中的元素数。elements in (ary) = 10是从 main 函数生成的,ary 声明为ary[10],所以是正确的。在---print_array_size2---横幅下的elements in (ary) = 2显示了数组衰减的问题,以及为什么编译器生成了警告。为什么值是 2?在测试 PC 上,指针占用 8 字节(64 位),而 int 只占用 4 字节,所以我们得到 8/4 = 2。

  1. 在编辑器中,找到 main()中声明 ary 的行,并将其更改为以下内容:
int ary[15]{};
  1. 点击int ary[15]会导致错误或至少警告,因为参数原型不匹配。正如我们之前所述,编译器将参数视为int* ary,因此函数也可以声明如下:
void print_array_size2(int* ary)
  1. 在编辑器中,将print_array_size2的名称全部更改为print_array_size。点击int* aryint ary[10]。这是确认,当作为函数参数使用时,int ary[10]生成的结果与声明int* ary 时相同。

  2. 将文件恢复到其原始状态。

  3. main()函数中,找到带有Step 11注释的行,并删除该行开头的注释。点击title以使其为const char*,p 的类型为char*。const 很重要。p 指针允许我们更改其指向的值。

  4. 看一下以下行:

p = title; 

将其更改为以下内容:

title = p;
  1. 点击Run按钮。这次,它构建并正确运行。将非 const 指针分配给 const 指针是可以的。

在这个练习中,我们学到了当将数组传递到函数中时,需要小心处理数组,因为关键信息(数组的大小)将在调用中丢失。

创建用户类型

C++的伟大之处在于您可以使用structclassenumunion创建自己的类型,编译器将在整个代码中将其视为基本类型。在本节中,我们将探讨创建自己的类型以及我们需要编写的方法来操纵它,以及编译器将为我们创建的一些方法。

枚举

最简单的用户定义类型是枚举。C++11 对枚举进行了改进,使它们更加类型安全,因此我们必须考虑两种不同的声明语法。在看如何声明它们之前,让我们弄清楚为什么需要它们。考虑以下代码:

int check_file(const char* name)
{
  FILE* fptr{fopen(name,"r")};
  if ( fptr == nullptr)
    return -1;
  char buffer[120];
  auto numberRead = fread(buffer, 1, 30, fptr);
  fclose(fptr);
  if (numberRead != 30)
    return -2;
  if(is_valid(buffer))
    return -3;
  return 0;
}

这是许多 C 库函数的典型特征,其中返回状态代码,您需要主页知道它们的含义。在前述代码中,-1-2-30被称为魔术数字。您需要阅读代码以了解每个数字的含义。现在,考虑以下版本的代码:

FileCheckStatus check_file(const char* name)
{
  FILE* fptr{fopen(name,"r")};
  if ( fptr == nullptr)
    return FileCheckStatus::NotFound;
  char buffer[30];
  auto numberRead = fread(buffer, 1, 30, fptr);
  fclose(fptr);
  if (numberRead != 30)
    return FileCheckStatus::IncorrectSize;
  if(is_valid(buffer))
    return FileCheckStatus::InvalidContents;
  return FileCheckStatus::Good;
}

这使用枚举类来传达结果并将含义附加到值的名称上。函数的用户现在可以使用枚举,因为代码更容易理解和使用。因此,魔术数字(与状态相关)已被替换为具有描述性标题的枚举值。让我们通过以下代码片段了解FileCheckStatus的声明:

enum FileCheckStatus             // Old-style enum declaration
{
  Good,                         // = 0 - Value defaults to 0
  NotFound,                     // = 1 - Value set to one more than previous
  IncorrectSize,                // = 2 - Value set to one more than previous
  InvalidContents,              // = 3 - Value set to one more than previous
};

如果我们想使用魔术数字的值,那么我们会这样声明它们:

enum FileCheckStatus             // Old-style enum declaration
{
  Good = 0, 
  NotFound = -1,
  IncorrectSize = -2,
  InvalidContents = -3,
};

或者,通过改变顺序,我们可以设置第一个值,编译器会完成其余部分:

enum FileCheckStatus             // Old-style enum declaration
{
  InvalidContents = -3,          // Force to -3
  IncorrectSize,                 // set to -2(=-3+1)
  NotFound,                      // Set to -1(=-2+1)
  Good,                          // Set to  0(=-1+1)
};

前述函数也可以写成如下形式:

FileCheckStatus check_file(const char* name)
{
  FILE* fptr{fopen(name,"r")};
  if ( fptr == nullptr)
    return NotFound;
  char buffer[30];
  auto numberRead = fread(buffer, 1, 30, fptr);
  fclose(fptr);
  if (numberRead != 30)
    return IncorrectSize;
  if(is_valid(buffer))
    return InvalidContents;
  return Good;
}

请注意,代码中缺少作用域指令FileCheckStatus::,但它仍将编译并工作。这引发了作用域的问题,我们将在第 2B 章可见性、生命周期和访问部分中详细讨论。现在,知道每种类型和变量都有一个作用域,旧式枚举的问题在于它们的枚举器被添加到与枚举相同的作用域中。假设我们有两个枚举定义如下:

enum Result 
{
    Pass,
    Fail,
    Unknown,
};
enum Option
{
    Keep,
    Discard,
    Pass,
    Play
};

现在我们有一个问题,Pass枚举器被定义两次并具有两个不同的值。旧式枚举还允许我们编写有效的编译器,但显然毫无意义的代码,例如以下代码:

Option option{Keep};
Result result{Unknown};
if (option == result)
{
    // Do something
}

由于我们试图开发清晰明了的代码,易于理解,将结果与选项进行比较是没有意义的。问题在于编译器会隐式将值转换为整数,从而能够进行比较。

C++11 引入了一个被称为枚举类作用域枚举的新概念。前述代码的作用域枚举定义如下:

enum class Result 
{
    Pass,
    Fail,
    Unknown,
};
enum class Option
{
    Keep,
    Discard,
    Pass,
    Play
};

这意味着前述代码将不再编译:

Option option{Keep};          // error: must use scope specifier Option::Keep
Result result{Unknown};       // error: must use scope specifier Result::Unknown
if (option == result)         // error: can no longer compare the different types
{
    // Do something
}

正如其名称所示,作用域枚举将枚举器放置在枚举名称的作用域内。此外,作用域枚举将不再被隐式转换为整数(因此 if 语句将无法编译通过)。您仍然可以将枚举器转换为整数,但需要进行类型转换:

int value = static_cast<int>(Option::Play);

练习 4:枚举-新旧学校

在这个练习中,我们将实现一个程序,使用枚举来表示预定义的值,并确定当它们更改为作用域枚举时所需的后续更改。让我们开始吧:

  1. 在 Eclipse 中打开Lesson2A项目,然后在Project Explorer中展开Lesson2A,然后展开Exercise04,双击Exercise4.cpp以在编辑器中打开此练习的文件。

  2. 单击启动配置下拉菜单,然后选择新建启动配置…。配置Exercise4以使用名称Exercise4运行。

  3. 完成后,它将成为当前选择的启动配置。

  4. 单击运行按钮。练习 4 将运行并产生以下输出:图 2A.25:练习 4 输出

图 2A.25:练习 4 输出
  1. 检查编辑器中的代码。目前,我们可以比较苹果和橙子。在printOrange()的定义处,将参数更改为Orange
void printOrange(Orange orange)
  1. 单击运行按钮。当出现工作区中的错误对话框时,单击取消图 2A.26:无法转换错误
图 2A.26:无法转换错误

通过更改参数类型,我们迫使编译器强制执行传递给函数的值的类型。

  1. 通过在初始调用中传递orange enum变量并在第二次调用中传递apple变量,两次调用printOrange()函数:
printOrange(orange);
printOrange(apple);

这表明编译器会将橙色和苹果隐式转换为int,以便调用该函数。还要注意关于比较AppleOrange的警告。

  1. 通过采用 int 参数并将orange enum的定义更改为以下内容来恢复printOrange()函数:
enum class Orange;
  1. 单击运行按钮。当出现工作区中的错误对话框时,单击取消图 2A.27:作用域枚举更改的多个错误
图 2A.27:作用域枚举更改的多个错误
  1. 找到此构建的第一个错误:图 2A.28:第一个作用域枚举错误
图 2A.28:第一个作用域枚举错误
  1. 关于作用域枚举的第一件事是,当您引用枚举器时,它们必须具有作用域限定符。因此,在编辑器中,转到并更改此行如下:
Orange orange{Orange::Hamlin};
  1. 单击Orange类型。因为这涉及基于模板的类(我们稍后会讨论),错误消息变得非常冗长。花一分钟时间查看从此错误到下一个错误(红线)出现的所有消息。它向您展示了编译器试图做什么以能够编译该行。

  2. 更改指定的行以读取如下内容:

std::cout << "orange = " << static_cast<int>(orange) << "\n";
  1. 单击Orange::作用域限定符。

  2. 留给你的练习是使用orange作为作用域枚举重新编译文件。

在这个练习中,我们发现作用域枚举改进了 C++的强类型检查,如果我们希望将它们用作整数值,那么我们需要对它们进行转换,而非作用域枚举则会隐式转换。

故障排除编译器错误

从前面的练习中可以看出,编译器可以从一个错误生成大量的错误和警告消息。这就是为什么建议找到第一个错误并首先修复它。在 IDE 中开发或使用着色错误的构建系统可以使这更容易。

结构和类

枚举是用户定义类型中的第一个,但它们并没有真正扩展语言,以便我们可以以适当的抽象级别表达问题的解决方案。然而,结构和类允许我们捕获和组合数据,然后关联方法以一致和有意义的方式来操作这些数据。

如果我们考虑两个矩阵的乘法,A(m x n)B(n x p),其结果是矩阵C(m x p),那么 C 的第 i 行和第 j 列的方程如下:

图 2A.31:第 i 行和第 j 列的方程

如果我们每次都必须这样写来乘两个矩阵,我们最终会得到许多嵌套的 for 循环。但是,如果我们可以将矩阵抽象成一个类,那么我们可以像表达两个整数或两个浮点数的乘法一样来表达它:

Matrix a;
Matrix b;
// Code to initialize the matrices
auto c = a * b;

这就是面向对象设计的美妙之处 - 数据封装和概念的抽象被解释在这样一个层次上,以至于我们可以轻松理解程序试图实现的目标,而不会陷入细节。一旦我们确定矩阵乘法被正确实现,那么我们就可以自由地专注于以更高层次解决我们的问题。

接下来的讨论涉及类,但同样适用于结构体,大部分适用于联合体。在学习如何定义和使用类之后,我们将概述类、结构体和联合体之间的区别。

分数类

为了向您展示如何定义和使用类,我们将致力于开发Fraction类来实现有理数。一旦定义,我们可以像使用任何其他内置类型一样使用Fraction(加法、减法、乘法、除法),而不必担心细节 - 这就是抽象。现在我们只需在更高的抽象层次上思考和推理分数。

Fraction类将执行以下操作:

  • 包含两个整数成员变量,m_numeratorm_denominator

  • 提供方法来复制自身,分配给自身,相乘,相除,相加和相减

  • 提供一种方法写入输出流

为了实现上述目标,我们有以下定义:

图 2A.32:操作的定义

图 2A.32:操作的定义

此外,我们执行的操作将需要将分数归一化为最低项。为此,分子和分母都要除以它们的最大公约数(GCD)。

构造函数、初始化和析构函数

类定义在 C++代码中表达的是用于在内存中创建对象并通过它们的方法操作对象的模式。我们需要做的第一件事是告诉编译器我们希望声明一个新类型 - 一个类。要声明Fraction类,我们从以下开始:

class Fraction
{
};

我们将这放在一个头文件Fraction.h中,因为我们希望在代码的其他地方重用这个类规范。

我们需要做的下一件事是引入要存储在类中的数据,在这种情况下是m_numeratorm_denominator。这两者都是 int 类型:

class Fraction
{
  int m_numerator;
  int m_denominator;
};

我们现在已经声明了要存储的数据,并为它们赋予了任何熟悉数学的人都能理解的名称,以了解每个成员变量存储的内容:

图 2A.33:分数的公式

由于这是一个类,默认情况下,声明的任何项目都被假定为private。这意味着没有外部实体可以访问这些变量。正是这种隐藏(使数据私有,以及某些方法)使得 C中的封装成为可能。C有三种类访问修饰符:

  • public:这意味着成员(变量或函数)可以从类外部的任何地方访问。

  • private:这意味着成员(变量或函数)无法从类外部访问。事实上,甚至无法查看。私有变量和函数只能从类内部或通过友元方法或类访问。私有成员(变量和函数)由公共函数使用以实现所需的功能。

  • protected:这是私有和公共之间的交叉。从类外部来看,变量或函数是私有的。但是,对于从声明受保护成员的类派生的任何类,它们被视为公共的。

在我们定义类的这一点上,这并不是很有用。让我们将声明更改为以下内容:

class Fraction
{
public:
  int m_numerator;
  int m_denominator;
};

通过这样做,我们可以访问内部变量。Fraction number;变量声明将导致编译器执行两件事:

  • 分配足够的内存来容纳数据项(取决于类型,这可能涉及填充,即包括或添加未使用的内存以对齐成员以实现最有效的访问)。sizeof运算符可以告诉我们为我们的类分配了多少内存。

  • 通过调用默认构造函数来初始化数据项。

这些步骤与编译器为内置类型执行的步骤相同,即步骤 2 什么也不做,导致未初始化的变量。但是默认构造函数是什么?它做什么?

首先,默认构造函数是一个特殊成员函数。它是许多可能构造函数中的一个,其中三个被视为特殊成员函数。构造函数可以声明零个、一个或多个参数,就像任何其他函数一样,但它们不指定返回类型。构造函数的特殊目的是将所有成员变量初始化,将对象置于一个明确定义的状态。如果成员变量本身是一个类,那么可能不需要指定如何初始化变量。如果成员变量是内置类型,那么我们需要为它们提供初始值。

类特殊成员函数

当我们定义一个新类型(结构体或类)时,编译器会为我们创建多达六个(6)个特殊成员函数:

  • Fraction::Fraction()): 当没有提供参数时调用(例如在前面的部分中)。这可以通过构造函数没有参数列表或为所有参数定义默认值来实现,例如Fraction(int numerator=0, denominator=1)。编译器提供了一个implicit inline默认构造函数,执行成员变量的默认初始化 - 对于内置类型,这意味着什么也不做。

  • Fraction::~Fraction()): 这是一个特殊成员函数,当对象的生命周期结束时调用。它的目的是释放对象在其生命周期中分配和保留的任何资源。编译器提供了一个public inline成员函数,调用成员变量的析构函数。

  • Fraction::Fraction(const Fraction&)): 这是另一个构造函数,其中第一个参数是Fraction&的形式,没有其他参数,或者其余参数具有默认值。第一个参数的形式是Fraction&const Fraction&volatile Fraction&const volatile Fraction&。我们将在后面处理const,但在本书中不处理volatile。编译器提供了一个non-explicit public inline成员函数,通常形式为Fraction::Fraction(const Fraction&),按初始化顺序复制每个成员变量。

  • Fraction& Fraction::operator=(Fraction&)): 这是一个成员函数,名称为operator=,第一个参数可以是值,也可以是类的任何引用类型,在这种情况下是FractionFraction&const Fraction&volatile Fraction&const volatile Fraction&。编译器提供了一个public inline成员函数,通常形式为Fraction::Fraction(const Fraction&),按初始化顺序复制每个成员变量。

  • Fraction::Fraction(Fraction&&)): 这是 C++11 中引入的一种新类型的构造函数,第一个参数是Fraction&&的形式,没有其他参数,或者其余参数具有默认值。第一个参数的形式是Fraction&&const Fraction&&volatile Fraction&&const volatile Fraction&&。编译器提供了一个non-explicit public inline成员函数,通常形式为Fraction::Fraction(Fraction&&),按初始化顺序移动每个成员变量。

  • Fraction& Fraction::operator=(Fraction&&)): 这是 C++11 中引入的一种新类型的赋值运算符,是一个名为operator=的成员函数,第一个参数是允许移动构造函数的任何形式之一。编译器提供了一个public inline成员函数,通常采用Fraction::Fraction(Fraction&&)的形式,按初始化顺序复制每个成员变量。

除了默认构造函数外,这些函数处理了该类拥有的资源的管理-即如何复制/移动它们以及如何处理它们。另一方面,默认构造函数更像是接受值的任何其他构造函数-它只初始化资源。

我们可以声明任何这些特殊函数,强制它们被默认(即,让编译器生成默认版本),或者强制它们不被创建。关于这些特殊函数在其他特殊函数存在时何时自动生成也有一些规则。前四个函数在概念上相对直接,但是两个“移动”特殊成员函数需要额外的解释。我们将在第三章“可以和应该之间的距离-对象、指针和继承”中详细讨论移动语义,但现在它基本上就是它所指示的意思-将某物从一个对象移动到另一个对象。

隐式构造函数与显式构造函数

前面的描述讨论了编译器生成隐式或非显式构造函数。如果存在可以用一个参数调用的构造函数,例如复制构造函数或移动构造函数,默认情况下,编译器可以在必要时调用它,以便将其从一种类型转换为另一种类型,从而允许对表达式、函数调用或赋值进行编码。这并不总是期望的行为,我们可能希望阻止隐式转换,并确保如果我们类的用户真的希望进行转换,那么他们必须在程序中写出来。为了实现这一点,我们可以在构造函数的声明前加上explicit关键字,如下所示:

explicit Fraction(int numerator, int denominator = 1);

explicit关键字也可以应用于其他运算符,编译器可能会用它进行类型转换。

类特殊成员函数-编译器生成规则

首先,如果我们声明了任何其他形式的构造函数-默认、复制、移动或用户定义的构造函数,就不会生成Default Constructor。其他特殊成员函数都不会影响它的生成。

其次,如果声明了析构函数,则不会生成Destructor。其他特殊成员函数都不会影响它的生成。

其他四个特殊函数的生成取决于析构函数或其他特殊函数的声明的存在,如下表所示:

图 2A.34:特殊成员函数生成规则

默认和删除特殊成员函数

在 C++11 之前,如果我们想要阻止使用复制构造函数或复制赋值成员函数,那么我们必须将函数声明为私有,并且不提供函数的定义:

class Fraction
{
public:
  Fraction();
private:
  Fraction(const Fraction&);
  Fraction& operator=(const Fraction&);
};

通过这种方式,我们确保如果有人试图从类外部访问复制构造函数或复制赋值,那么编译器将生成一个错误,说明该函数不可访问。这仍然声明了这些函数,并且它们可以从类内部访问。这是一种有效的方法,但并不完美,以防止使用这些特殊成员函数。

但是自 C++11 引入了两种新的声明形式,允许我们覆盖编译器的默认行为,如前述规则所定义。

首先,我们可以通过使用= delete后缀来声明方法,强制编译器不生成该方法,如下所示:

Fraction(const Fraction&) = delete;

注意

如果参数没有被使用,我们可以省略参数的名称。对于任何函数或成员函数都是如此。实际上,根据编译器设置的警告级别,它甚至可能会生成一个警告,表明参数没有被使用。

或者,我们可以通过使用= default后缀来强制编译器生成特殊成员函数的默认实现,就像这样:

Fraction(const Fraction&) = default;

如果这只是函数的声明,那么我们也可以省略参数的名称。尽管如此,良好的实践规定我们应该命名参数以指示其用途。这样,我们类的用户就不需要查看调用函数的实现。

注意

使用默认后缀声明特殊成员函数被视为用户定义的成员函数,用于上述规则的目的。

三五法则和零法则

正如我们之前讨论过的,除了默认构造函数之外,特殊成员函数处理了管理该类拥有的资源的语义 - 即如何复制/移动它们以及如何处理它们。这导致了 C++社区内关于处理特殊函数的两个“规则”。

在 C++11 之前,有“三法则”,它涉及复制构造函数、复制赋值运算符和析构函数。基本上它表明我们需要实现其中一个方法,因为封装资源的管理是非平凡的。

随着 C++11 中移动构造函数和移动赋值运算符的引入,这个规则扩展为“五法则”。规则的本质没有发生变化。简单地说,特殊成员函数的数量增加到了五个。记住编译器生成规则,确保所有五个特殊方法都被实现(或通过= default 强制),这是一个额外的原因,如果编译器无法访问移动语义函数,它将尝试使用复制语义函数,这可能不是所期望的。

注意

有关更多详细信息,请参阅 C.ctor:C++核心指南中的构造函数、赋值和析构函数部分,网址为:isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines

构造函数 - 初始化对象

构造函数的主要任务是将对象置于稳定状态,以便通过其成员函数对对象执行的任何操作都会产生一致的定义行为。虽然前面的陈述对于复制和移动构造函数是正确的,但它们通过不同的语义(从另一个对象复制或移动)来实现这一点。

我们有四种不同的机制可以控制对象的初始状态。C对于在这种情况下使用哪种初始化有很多规则。我们不会详细讨论 C标准的默认初始化、零初始化、值初始化、常量初始化等等。只需知道最好的方法是明确地初始化您的变量。

第一种,也是最不受欢迎的初始化机制是在构造函数的主体中为成员变量赋值,就像这样:

Fraction::Fraction()
{
  this->m_numerator = 0;
  this->m_denominator = 1;
}
Fraction::Fraction(int numerator, int denominator)
{
  m_numerator = numerator;
  m_denominator = denominator;
}

清楚地知道了用于初始化变量的值。严格来说,这不是类的初始化 - 根据标准,当构造函数的主体被调用时,初始化才算完成。这在这个类中很容易维护。对于有多个构造函数和许多成员变量的较大类,这可能是一个维护问题。如果更改一个构造函数,您将需要更改它们所有。它还有一个问题,如果成员变量是引用类型(我们稍后会讨论),那么它就不能在构造函数的主体中完成。

默认构造函数使用this指针。每个成员函数,包括构造函数和析构函数,都带有一个隐式参数(即使它从未声明过)- this指针。this指向对象的当前实例。->操作符是另一个解引用操作符,在这种情况下是简写,即*(this).m_numerator。使用this->是可选的,可以省略。其他语言,如 Python,要求声明和使用隐式指针/引用(Python 中的约定是称为self)。

第二种机制是使用成员初始化列表,其在使用中有一个警告。对于我们的 Fraction 类,我们有以下内容:

Fraction::Fraction() : m_numerator(0), m_denominator(1)
{
}
Fraction::Fraction(int numerator, int denominator) :
  m_numerator(numerator), m_denominator(denominator)
{
}

冒号:后面和左花括号{前面的代码部分(m_numerator(0), m_denominator(1)m_numerator(numerator), m_denominator(denominator))是成员初始化列表。我们可以在成员初始化列表中初始化引用类型。

成员初始化列表顺序

无论您在成员初始化列表中放置成员的顺序如何,编译器都将按照它们在类中声明的顺序初始化成员。

第三种和推荐的初始化是 C++11 中引入的默认成员初始化。我们在变量声明时使用赋值或大括号初始化器定义默认初始值:

class Fraction
{
public:
  int m_numerator = 0;     // equals initializer
  int m_denominator{1};    // brace initializer
};

如果构造函数没有定义成员变量的初始值,则将使用此默认值来初始化变量。这样做的好处是确保所有构造函数产生相同的初始化,除非它们在构造函数的定义中被明确修改。

C++11 还引入了第四种初始化样式,称为构造函数委托。它是成员初始化列表的修改,其中不是列出成员变量及其初始值,而是调用另一个构造函数。以下示例是人为的,您不会以这种方式编写类,但它显示了构造函数委托的语法:

Fraction::Fraction(int numerator) : m_numerator(numerator), m_denominator(1)
{
}
Fraction::Fraction(int numerator, int denominator) : Fraction(numerator)
{
  auto factor = std::gcd(numerator, denominator);
  m_numerator /= factor;
  m_denominator = denominator / factor;
}

您从具有两个参数的构造函数中调用单参数构造函数。

练习 5:声明和初始化分数

在这个练习中,我们将使用不同的技术实现类成员初始化,包括构造函数委托。让我们开始吧:

  1. 在 Eclipse 中打开Lesson2A项目,然后在Project Explorer中展开Lesson2A,然后展开Exercise05,双击Exercise5.cpp以在编辑器中打开此练习的文件。

  2. 单击启动配置下拉菜单,然后选择新启动配置…。将Exercise5配置为以名称 Exercise5 运行。

  3. 完成后,它将成为当前选择的启动配置。

  4. 单击运行按钮。练习 5将运行并产生类似以下输出:

图 2A.35:练习 5 典型输出

报告的分数值来自以任何方式初始化成员变量。如果再次运行,您很可能会得到不同的分数。

  1. 点击运行按钮几次。您会看到分数发生变化。

  2. 在编辑器中,将构造函数更改为如下所示:

Fraction() : m_numerator{0}, m_denominator{1}
{
}
  1. 单击运行按钮并观察输出:
图 2A.36:修改后的练习 5 输出

这次,分数值由我们在成员初始化列表中指定的值定义。

  1. 在编辑器中,添加以下两个构造函数
Fraction(int numerator) : m_numerator(numerator), m_denominator(1)
{
}
Fraction(int numerator, int denominator) : Fraction(numerator)
{
  auto factor = std::gcd(numerator, denominator);
  m_numerator /= factor;
  m_denominator = denominator / factor;
}
  1. 在主函数中,更改fraction的声明以包括初始化:
Fraction fraction{3,2};
  1. 点击运行按钮并观察输出:
图 2A.37:构造函数委托示例

在这个练习中,我们使用成员初始化列表和构造函数委托实现了成员变量的初始化。我们将在练习 7 中返回到分数,为分数类添加运算符。

值与引用和常量

到目前为止,我们只处理了值类型,也就是变量保存了对象的值。指针保存了我们感兴趣的值(即对象的地址)(或 nullptr)。但这可能导致效率低下和资源管理问题。我们将在这里讨论如何解决效率低下的问题,但在第三章可以和应该之间的距离-对象、指针和继承中解决资源管理问题。

考虑以下问题..我们有一个 10×10 的双精度矩阵,我们希望为其编写一个反转函数。该类声明如下:

class Matrix10x10
{
private:
  double m_data[10][10];
};

如果我们要取sizeof(Matrix10x10),我们会得到sizeof(double) x 10 x 10 = 800 字节。现在,如果我们要为此实现一个矩阵反转函数,其签名可能如下所示:

Matrix10x10 invert(Matrix10x10 lhs);
Matrix10x10 mat;
// set up mat
Matrix10x10 inv = invert(mat);

首先,这意味着编译器需要将mat持有的值传递给invert()函数,并将 800 字节复制到堆栈上。然后函数执行其需要执行的操作来反转矩阵(L-U 分解、计算行列式-无论实现者选择的方法是什么),然后将 800 字节的结果复制回inv变量。在堆栈上传递大量值从来都不是一个好主意,原因有两个:

  • 堆栈是主机操作系统给我们程序的有限资源。

  • 在系统中复制大量值是低效的。

这种方法被称为按值传递。也就是说,我们希望处理的项目的值被复制到函数中。

在 C(和 C++)中,通过使用指针来解决这个限制。上面的代码可能变成下面这样:

void invert(Matrix10x10* src, Matrix10x10* inv);
Matrix10x10 mat;
Matrix10x10 inv;
// set up mat
invert(&mat, &inv);

在这里,我们只是传递了 src 和 target 的地址作为两个指针的逆结果(这是少量字节)。不幸的是,这导致函数内部的代码在每次使用srcinv时都必须使用解引用操作符(*),使得代码更难阅读。此外,指针的使用导致了许多问题。

C++引入了一个更好的方法-变量别名或引用。引用类型是用和号(&)操作符声明的。因此,我们可以将 invert 方法声明如下:

void invert(Matrix10x10& src, Matrix10x10& inv);
Matrix10x10 mat;
Matrix10x10 inv;
// set up mat
invert(mat, inv);

请注意,调用该方法不需要特殊的操作符来传递引用。从编译器的角度来看,引用仍然是一个带有一个限制的指针-它不能保存 nullptr。从程序员的角度来看,引用允许我们在不必担心在正确的位置使用解引用操作符的情况下推理我们的代码。这被称为按引用传递

我们看到引用被传递给了复制构造函数和复制赋值方法。当用于它们的移动等价物时,引用的类型被称为右值引用运算符,将在第三章可以和应该之间的距离-对象、指针和继承中解释。

按值传递的一个优点是我们不能无意中修改传递给方法的变量的值。现在,如果我们按引用传递,我们就不能再保证我们调用的方法不会修改原始变量。为了解决这个问题,我们可以将 invert 方法的签名更改为如下所示:

void invert(const Matrix10x10& src, Matrix10x10& inv);

const 关键字告诉编译器,在处理invert()函数的定义时,将值引用到src的任何部分都是非法的。如果该方法尝试修改 src,编译器将生成一个错误。

在指定类型-变量部分,我们发现auto title的声明导致titleconst char *类型。现在,我们可以解释const部分了。

title变量是指向常量字符的指针。换句话说,我们不能改变指向的内存中存储的数据的值。因此,我们不能执行以下操作:

*title = 's';

这是因为编译器将生成与更改常量值相关的错误。然而,我们可以改变指针中存储的值。我们可以执行以下操作:

title = "Maid Marian";

我们现在已经介绍了引用作为函数参数类型的用法,但它们也可以用作成员变量而不是指针。引用和指针之间有区别:

引用必须引用实际对象(没有 nullptr 的等价物)。一旦初始化,引用就不能被改变(这意味着引用必须要么是默认成员初始化的,要么出现在成员初始化列表中)。对象必须存在,只要对它的引用存在(如果对象可以在引用被销毁之前被销毁,那么如果尝试访问对象就有潜在的未定义行为)。

练习 6:声明和使用引用类型

在这个练习中,我们将声明和使用引用类型,以使代码更高效、更易读。让我们开始吧:

  1. 在 Eclipse 中打开Lesson2A项目,然后在Project Explorer中展开Lesson2A,然后展开Exercise06,双击Exercise6.cpp以在编辑器中打开此练习的文件。

  2. 点击Launch Configuration下拉菜单,选择New Launch Configuration…。配置Exercise6以使用名称 Exercise6 运行。

  3. 完成后,它将成为当前选择的启动配置。

  4. 点击rvalue变量允许我们操纵(读取和写入)存储在value变量中的数据。我们有一个对value变量的引用rvalue。我们还可以看到swap()函数交换了ab变量中存储的值。

  5. 在编辑器中,更改 swap 函数的函数定义:

void swap(const int& lhs, const int& rhs)
  1. 点击Run按钮。当出现工作区中的错误对话框时,点击Cancel。编译器报告的第一个错误如下所示:

图 2A.39:赋值时的只读错误

图 2A.39:赋值时的只读错误

通过将参数从int& lhs更改为const int& lhs,我们告诉编译器在此函数内部参数不应该被改变。因为我们在函数中对 lhs 进行了赋值,所以编译器生成了关于 lhs 为只读的错误并终止了程序。

实现标准运算符

要像内置类一样使用分数,我们需要使它们能够使用标准数学运算符(+,-,*,/)及其赋值对应物(+=,-=,*=,/=)。如果您不熟悉赋值运算符,请考虑以下两个表达式 - 它们产生相同的输出:

a = a + b;
a += b;

为 Fraction 声明这两个运算符的语法如下:

// member function declarations
Fraction& operator+=(const Fraction& rhs);
Fraction operator+(const Fraction& rhs) const;
// normal function declaration of operator+
Fraction operator+(const Fraction& lhs, const Fraction& rhs);

因为operator+=方法修改了左侧变量的内容(将 a 添加到 b 然后再次存储在 a 中),建议将其实现为成员变量。在这种情况下,由于我们没有创建新值,我们可以直接返回对现有 lhs 的引用。

另一方面,operator+方法不应修改 lhs 或 rhs 并返回一个新对象。实现者可以自由地将其实现为成员函数或自由函数。在前面的代码中都展示了这两种方法,但只应存在一种。关于成员函数实现的有趣之处在于声明末尾的 const 关键字。这告诉编译器,当调用这个成员函数时,它不会修改对象的内部状态。虽然这两种方法都是有效的,但如果可能的话,operator+应该作为一个普通函数实现,而不是类的一部分。

相同的方法也可以用于其他运算符-(减法)*(乘法)/(除法)。前面的方法实现了标准数学运算符的语义,并使我们的类型像内置类型一样工作。

实现输出流操作符(<<)

C++将输入/输出(I/O)抽象为标准库中的流类层次结构(我们将在第 2B 章不允许鸭子 - 模板和推断中讨论)。在练习 5声明和初始化分数中,我们看到我们可以将分数插入到输出流中,如下所示:

std::cout << "fraction = " << fraction.getNumerator() << "/" 
                           << fraction.getDenominator() << "\n";

到目前为止,对于我们的分数类,我们已经通过使用getNumerator()getDenominator()方法从外部访问数据值来写出了分子和分母的值,但有更好的方法。作为使我们的类在 C++中成为一等公民的一部分,在合适的情况下,我们应该重载 I/O 运算符。在本章中,我们只会看输出运算符<<,也称为插入运算符。这样,我们可以用更清晰的版本替换以前的代码:

std::cout << "fraction = " << fraction << "\n";

我们可以将运算符重载为友元函数或普通函数(如果类提供我们需要插入的数据的 getter 函数)。对于我们的目的,我们将其定义为普通函数:

inline std::ostream& operator<< (std::ostream &out, const Fraction &rhs)
{
    out << rhs.getNumerator() << " / " << rhs.getDenominator();
    return out;
}

我们的代码结构

在我们深入练习之前,我们需要讨论一下我们的类的各个部分放在哪里 - 声明和定义。声明是我们的类的蓝图,指示它需要什么数据存储和将实现的方法。定义是每个方法的实际实现细节。

在 Java 和 C#等语言中,声明和定义是一样的,它们必须存在于一个文件(Java)或跨多个文件(C#部分类)中。在 C++中,取决于类和您希望向其他类公开多少,声明必须出现在头文件中(可以在其他文件中#include使用),定义可以出现在三个地方之一 - 内联在定义中,在相同文件中的inline定义,或在单独的实现文件中。

头文件通常以.hpp 扩展名命名,而实现文件通常是*.cpp*.cxx之一。实现文件也称为翻译单元。通过将函数定义为内联,我们允许编译器以函数可能甚至不存在于最终程序中的方式优化代码 - 它已经将我们放入函数中的步骤替换为我们从中调用函数的位置。

练习 7:为分数类添加运算符

在这个练习中,我们的目标是使用单元测试在我们的分数类中实现运算符功能。这使我们的分数类成为一个真正的类型。让我们开始吧:

  1. 在 Eclipse 中打开Lesson2A项目,然后在项目资源管理器中展开Lesson2A,然后Exercise07,双击Exercise7.cpp以在编辑器中打开此练习的文件。

  2. 单击启动配置下拉菜单,然后选择新启动配置…。配置 Exercise7 以使用名称 Exercise7 运行。

  3. 完成后,它将成为当前选择的启动配置。

  4. 我们还需要配置一个单元测试。在 Eclipse 中,单击名为运行 | 运行配置…的菜单项,在左侧右键单击C/C++单元,然后选择新配置

  5. 将名称从Lesson2A Debug更改为Exercise7 Tests

  6. C/C++应用程序下,选择搜索项目选项,并在新对话框中选择tests

  7. 接下来,转到C/C++测试选项卡,并在下拉菜单中选择Google 测试运行器。点击对话框底部的应用,然后点击我们第一次运行的测试选项:图 2A.40:失败的测试 - 乘法

图 2A.40:失败的测试 - 乘法
  1. 打开operator*=函数。更新它的代码如下:
Fraction& Fraction::operator*=(const Fraction& rhs)
{
  Fraction tmp(m_numerator*rhs.m_numerator, m_denominator*rhs.m_denominator);
  *this = tmp;
  return *this;
}
  1. 点击运行按钮重新运行测试。这次,所有的测试都通过了:图 2A.41:通过测试
图 2A.41:通过测试
  1. 在 IDE 中打开operator*=(),同时测试其他的operator*()。修复operator*=()如何修复operator*()?如果在编辑器中打开 Fraction.hpp,你会发现operator*()函数是通过调用operator*=()来实现的,也就是说,它被标记为内联函数,是一个普通函数而不是成员函数。一般来说,当重载这些运算符时,修改调用它的对象的函数是成员函数,而生成新值的函数是调用成员函数的普通函数。

  2. 在编辑器中打开Fraction.hpp,并将文件顶部的行更改为以下内容:

#define EXERCISE7_STEP  11
  1. 点击AddFractionsAddFractions2图 2A.42:额外的失败测试
图 2A.42:额外的失败测试
  1. Function.cpp文件中找到operator+=函数。

  2. 对函数进行必要的更改,然后点击实现operator*=()

  3. 在编辑器中打开Fraction.hpp,并将文件顶部的行更改为以下内容:

#define EXERCISE7_STEP  15
  1. 点击SubtractFractionsSubtractFractions2

  2. 在 Function.cpp 文件中找到operator-=函数。

  3. 对函数进行必要的更改,然后点击运行按钮,直到测试通过。

  4. 在编辑器中打开Fraction.hpp,并将文件顶部的行更改为以下内容:

#define EXERCISE7_STEP  19
  1. 点击运行按钮重新运行测试 - 这次,我们添加了两个失败的测试 - DivideFractionsDivideFractions2

  2. Function.cpp文件中找到operator/=函数。

  3. 对函数进行必要的更改,然后点击运行按钮,直到测试通过。

  4. 在编辑器中打开Fraction.hpp,并将文件顶部的行更改为以下内容:

#define EXERCISE7_STEP  23
  1. 点击插入运算符

  2. 在 Function.hpp 文件中找到operator<<函数。

  3. 对函数进行必要的更改,然后点击运行按钮,直到测试通过。

  4. 启动配置中选择Exercise7,然后点击运行按钮。这将产生以下输出:

图 2A.43:功能性分数类

图 2A.43:功能性分数类

这完成了我们对Fraction类的实现。当我们考虑第三章中的异常时,我们将再次返回它,可以和应该之间的距离 - 对象、指针和继承,这样我们就可以处理分数中的非法值(分母为 0)。

函数重载

C++支持一种称为函数重载的特性,即两个或多个函数具有相同的名称,但它们的参数列表不同。参数的数量可以相同,但至少一个参数类型必须不同。或者,它们可以具有不同数量的参数。因此,多个函数的函数原型是不同的。但是,两个函数不能具有相同的函数名称、相同的参数类型和不同的返回类型。以下是一个重载的示例:

std::ostream& print(std::ostream& os, int value) {
   os << value << " is an int\n";
   return os;
}
std::ostream& print(std::ostream& os, float value) {
   os << value << " is a single precision float\n";
   return os;
}
std::ostream& print(std::ostream& os, double value) {
   os << value << " is a double precision float \n";
   return os;
}
// The next function causes the compiler to generate an error
// as it only differs by return type.
void print(std::ostream& os, double value) {
   os << value << " is a double precision float!\n";
}

到目前为止,Fraction上的多个构造函数和重载的算术运算符都是编译器在遇到这些函数时必须引用的重载函数的示例。考虑以下代码:

int main(int argc, char** argv) {
   print(42);
}

当编译器遇到print(42)这一行时,它需要确定调用先前定义的函数中的哪一个,因此执行以下过程(大大简化):

图 2A.44:函数重载解析(简化)

图 2A.44:函数重载解析(简化)

C++标准定义了编译器根据如何操作(即转换)参数来确定最佳候选函数的规则。如果不需要转换,则该函数是最佳匹配。

类,结构体和联合

当您定义一个类并且不指定访问修饰符(public,protected,private)时,默认情况下所有成员都将是 private 的:

class Fraction
{
  Fraction() {};            // All of these are private
  int m_numerator;
  int m_denominator;
};

当您定义一个结构体并且不指定访问修饰符(public,protected,private)时,默认情况下所有成员都将是 public 的:

struct Fraction
{
  Fraction() {};            // All of these are public
  int m_numerator;
  int m_denominator;
};

还有另一个区别,我们将在解释继承和多态性之后进行讨论。联合是一种与结构体和类不同但又相同的数据构造类型。联合是一种特殊类型的结构声明,其中所有成员占用相同的内存,并且在给定时间只有一个成员是有效的。union声明的一个示例如下:

union variant
{
  int m_ivalue;
  float m_fvalue;
  double m_dvalue;
};

当您定义一个联合并且不指定访问修饰符(public,protected,private)时,默认情况下所有成员都将是 public 的。

联合的主要问题是没有内在的方法来知道在任何给定时间哪个值是有效的。这通过定义所谓的标记联合来解决 - 即一个包含联合和一个枚举的结构,用于标识它是有效值。联合还有其他限制(例如,只有一个成员可以有默认成员初始化程序)。我们不会在本书中深入探讨联合。

活动 1:图形处理

在现代计算环境中,矩阵被广泛用于解决各种问题 - 解决同时方程,分析电力网格或电路,对图形渲染对象进行操作,并提供机器学习的实现。在图形世界中,无论是二维(2D)还是三维(3D),您希望对对象执行的所有操作都可以通过矩阵乘法来完成。您的团队被要求开发点,变换矩阵的表示以及您可能希望对它们执行的操作。按照以下步骤来实现这一点:

  1. Lesson2A/Activity01文件夹加载准备好的项目。

  2. 创建一个名为Point3d的类,可以默认构造为原点,或使用三个或四个值的初始化列表(数据直接存储在类中)来构造。

  3. 创建一个名为Matrix3d的类,可以默认构造为单位矩阵,或使用嵌套初始化列表来提供所有值(数据直接存储在类中)来构造。

  4. operator()上,以便它接受(index)参数以返回x(0)y(1)z(2)w(3)处的值。

  5. operator()上接受(row, col)参数,以便返回该值。

  6. 添加单元测试以验证所有上述功能。

  7. Matrix3d类中添加operator*=(const Matrix3d&)operator==(const Matrix3d&),以及它们的单元测试。

  8. 添加用于将两个Matrix3d对象相乘以及将Matrix3d对象乘以Point3d对象的自由函数,并进行单元测试。

  9. 添加用于创建平移,缩放和旋转矩阵(围绕 x,y,z 轴)及其单元测试的独立方法。

在实现上述步骤之后,预期输出如下:

图 2A.45:成功运行活动程序

在本次活动中,我们不会担心索引超出范围的可能性。我们将在第三章“能与应该之间的距离-对象、指针和继承”中讨论这个问题。单位矩阵是一个方阵(在我们的例子中是 4x4),对角线上的所有值都设置为 1,其他值都为 0。

在处理 3D 图形时,我们使用增广矩阵来表示点(顶点)和变换,以便所有的变换(平移、缩放、旋转)都可以通过乘法来实现。

一个n × m矩阵是一个包含 n 行 m 个数字的数组。例如,一个2 x 3矩阵可能如下所示:

图 2A.46:2x3 矩阵

图 2A.46:2x3 矩阵

三维空间中的顶点可以表示为一个三元组(x,y,z)。然而,我们用另一个坐标w(对于顶点为 1,对于方向为 0)来增强它,使其成为一个四元组(x,y,z,1)。我们不使用元组,而是将其放在一个4 x 1矩阵中,如下所示:

图 2A.47:4x1 矩阵

图 2A.47:4x1 矩阵

如果我们将4 x 1矩阵(点)乘以4 x 4矩阵(变换),我们可以操纵这个点。如果Ti表示一个变换,那么我们可以将变换相乘,以实现对点的某种操纵。

图 2A.48:乘法变换

图 2A.48:乘法变换

要将一个转换矩阵相乘,A x P = B,我们需要做以下操作:

图 2A.49:乘法变换矩阵

图 2A.49:乘法变换矩阵

我们也可以这样表达:

图 2A.50:乘法变换表达式

图 2A.50:乘法变换表达式

同样,两个4 x 4矩阵也可以相乘,AxB=C

图 2A.51:4x4 矩阵乘法表达式:

图 2A.51:4x4 矩阵乘法表达式:

变换的矩阵如下:

图 2A.52:变换矩阵列表

图 2A.52:变换矩阵列表

注意

本次活动的解决方案可以在第 635 页找到。

总结

在本章中,我们学习了 C中的类型。首先,我们介绍了内置类型,然后学习了如何创建行为类似于内置类型的自定义类型。我们学习了如何声明和初始化变量,了解了编译器从源代码生成的内容,变量的存储位置,链接器如何将其组合,以及在计算机内存中的样子。我们学习了一些关于 C的部落智慧,比如零规则和五规则。这些构成了 C的基本组成部分。在下一章中,我们将学习如何使用 C模板创建函数和类,并探索模板类型推导的更多内容。

第三章:不允许鸭子-模板和推导

学习目标

通过本章结束时,您将能够:

  • 使用继承和多态将自己的类发挥到更大的效果

  • 实现别名以使您的代码更易于阅读

  • 使用 SFINAE 和 constexpr 开发模板以简化您的代码

  • 使用 STL 实现自己的解决方案,以利用通用编程

  • 描述类型推导的上下文和基本规则

本章将向您展示如何通过继承,多态和模板来定义和扩展您的类型。

介绍

在上一章中,我们学习了如何通过单元测试开发自己的类型(类),并使它们表现得像内置类型。我们介绍了函数重载,三/五法则和零法则。

在本章中,我们将学习如何进一步扩展类型系统。我们将学习如何使用模板创建函数和类,并重新讨论函数重载,因为它受到模板的影响。我们将介绍一种新技术SFINAE,并使用它来控制我们模板中包含在生成代码中的部分。

继承,多态和接口

在我们的面向对象设计和 C++的旅程中,我们已经专注于抽象和数据封装。现在我们将把注意力转向继承多态。什么是继承?什么是多态?我们为什么需要它?考虑以下三个对象:

图 2B.1:车辆对象

图 2B.1:车辆对象

在上图中,我们可以看到有三个非常不同的对象。它们有一些共同之处。它们都有轮子(不同数量),发动机(不同大小,功率或配置),启动发动机,驾驶,刹车,停止发动机等,我们可以使用这些来做一些事情。

因此,我们可以将它们抽象成一个称为车辆的东西,展示这些属性和一般行为。如果我们将其表达为 C++类,可能会看起来像下面这样:

class Vehicle
{
public:
  Vehicle() = default;
  Vehicle(int numberWheels, int engineSize) : 
          m_numberOfWheels{numberWheels}, m_engineSizeCC{engineSize}
  {
  }
  bool StartEngine()
  {
    std::cout << "Vehicle::StartEngine " << m_engineSizeCC << " CC\n";
    return true;
  };
  void Drive()
  {
    std::cout << "Vehicle::Drive\n";
  };
  void ApplyBrakes()
  {
    std::cout << "Vehicle::ApplyBrakes to " << m_numberOfWheels << " wheels\n";
  };
  bool StopEngine()
  {
    std::cout << "Vehicle::StopEngine\n";
    return true;
  };
private:
  int m_numberOfWheels {4};
  int m_engineSizeCC{1000};
};

Vehicle类是MotorcycleCarTruck的更一般(或抽象)表达。我们现在可以通过重用 Vehicle 类中已有的内容来创建更专业化的类型。我们将通过继承来重用 Vehicle 的属性和方法。继承的语法如下:

class DerivedClassName : access_modifier BaseClassName
{
  // Body of DerivedClass
};

我们之前遇到过publicprotectedprivate等访问修饰符。它们控制我们如何访问基类的成员。Motorcycle 类将派生如下:

class Motorcycle : public Vehicle
{
public:
  Motorcycle(int engineSize) : Vehicle(2, engineSize) {};
};

在这种情况下,Vehicle 类被称为基类超类,而 Motorcycle 类被称为派生类子类。从图形上看,我们可以表示为下面的样子,箭头从派生类指向基类:

图 2B.2:车辆类层次结构

图 2B.2:车辆类层次结构

但摩托车的驾驶方式与通用车辆不同。因此,我们需要修改Motorcycle类,使其行为不同。更新后的代码将如下所示:

class Motorcycle : public Vehicle
{
public:
  Motorcycle(int engineSize) : Vehicle(2, engineSize) {};
  void Drive()
  {
    std::cout << "Motorcycle::Drive\n";
  };
};

如果我们考虑面向对象设计,这是关于以对象协作的方式对问题空间进行建模。这些对象通过消息相互通信。现在,我们有两个类以不同的方式响应相同的消息(Drive()方法)。发送消息的人不知道会发生什么,也不真的在乎,这就是多态的本质。

注意

多态来自希腊词 poly 和 morph,其中poly表示许多,morph表示形式。因此,多态意味着具有多种形式

我们现在可以使用这些类来尝试多态:

#include <iostream>
int main()
{
  Vehicle vehicle;
  Motorcycle cycle{1500};
  Vehicle* myVehicle{&vehicle};
  myVehicle->StartEngine();
  myVehicle->Drive();
  myVehicle->ApplyBrakes();
  myVehicle->StopEngine();
  myVehicle = &cycle;
  myVehicle->StartEngine();
  myVehicle->Drive();
  myVehicle->ApplyBrakes();
  myVehicle->StopEngine();
  return 0;
}

如果我们编译并运行此程序,我们会得到以下输出:

图 2B.3:车辆程序输出

图 2B.3:车辆程序输出

在前面的屏幕截图中,在Vehicle::StartEngine 1500 cc之后的行都与Motorcycle有关。但是Drive行仍然显示Vehicle::Drive,而不是预期的Motorcycle::Drive。出了什么问题?问题在于我们没有告诉编译器Vehicle类中的Drive方法可以被派生类修改(或覆盖)。我们需要在代码中做出一些改变:

virtual void Drive()
{
  std::cout << "Vehicle::Drive\n";
};

通过在成员函数声明之前添加virtual关键字,我们告诉编译器派生类可以(但不一定)覆盖或替换该函数。如果我们进行此更改,然后编译并运行程序,将得到以下输出:

图 2B.4:带有虚方法的车辆程序输出

图 2B.4:带有虚方法的车辆程序输出

现在,我们已经了解了继承和多态性。我们使用Vehicle类的指针来控制Motorcycle类。作为最佳实践的一部分,应该对代码进行另一个更改。我们还应该更改MotorcyleDrive函数的声明如下:

void Drive() override
{
  std::cout << "Motorcycle::Drive\n";
};

C++11 引入了override关键字,作为向编译器的提示,说明特定方法应具有与其父树中某个方法相同的函数原型。如果找不到,则编译器将报告错误。这是一个非常有用的功能,可以帮助您节省数小时的调试时间。如果编译器有办法报告错误,请使用它。缺陷检测得越早,修复就越容易。最后一个变化是,每当我们向类添加虚函数时,必须声明其析构函数为virtual

class Vehicle
{
public:
  // Constructors - hidden 
  virtual ~Vehicle() = default;  // Virtual Destructor
  // Other methods and data -- hidden
};

在将Drive()函数设为虚函数之前,我们已经看到了这一点。当通过指向 Vehicle 的指针调用析构函数时,需要知道调用哪个析构函数。因此,将其设为虚函数可以实现这一点。如果未能这样做,可能会导致资源泄漏或对象被切割。

继承和访问说明符

正如我们之前提到的,从超类继承一个子类的一般形式如下:

class DerivedClassName : access_modifier BaseClassName

当我们从 Vehicle 类派生 Motorcycle 类时,我们使用以下代码:

class Motorcycle : public Vehicle

访问修饰符是可选的,是我们之前遇到的publicprotectedprivate之一。在下表中,您可以看到基类成员的可访问性。如果省略 access_modifier,则编译器会假定指定了 private。

图 2B.5:派生类中基类成员的可访问性

图 2B.5:派生类中基类成员的可访问性

抽象类和接口

到目前为止,我们谈论过的所有类都是具体类 - 它们可以实例化为变量的类型。还有另一种类型的类 - 抽象类 - 它包含至少一个纯虚成员函数。纯虚函数是一个在类中没有定义(或实现)的虚函数。由于它没有实现,该类是畸形的(或抽象的),无法实例化。如果尝试创建抽象类型的变量,则编译器将生成错误。

要声明纯虚成员函数,将函数原型声明结束为= 0。要将Drive()作为 Vehicle 类中的纯虚函数声明,我们将其声明如下:

virtual void Drive() = 0;

现在,为了能够将派生类用作变量类型(例如Motorcycle类),它必须定义Drive()函数的实现。

但是,您可以声明变量为抽象类的指针或引用。在任何一种情况下,它必须指向或引用从抽象类派生的某个非抽象类。

在 Java 中,有一个关键字接口,允许你定义一个全是纯虚函数的类。在 C++中,通过声明一个只声明公共纯虚函数(和虚析构函数)的类来实现相同的效果。通过这种方式,我们定义了一个接口。

注意

在本章中解决任何实际问题之前,请下载本书的 GitHub 存储库(github.com/TrainingByPackt/Advanced-CPlusPlus)并在 Eclipse 中导入 Lesson 2B 文件夹,以便查看每个练习和活动的代码。

练习 1:使用多态实现游戏角色

在这个练习中,我们将演示继承、接口和多态。我们将从一个临时实现的角色扮演游戏开始,然后将其演变为更通用和可扩展的形式。让我们开始吧:

  1. 打开 Eclipse,并使用Lesson2B示例文件夹中的文件创建一个名为Lesson2B的新项目。

  2. 由于这是一个基于 CMake 的项目,将当前构建器更改为Cmake Build (portable)

  3. 转到项目 | 构建所有菜单以构建所有练习。默认情况下,屏幕底部的控制台将显示CMake 控制台[Lesson2B]

  4. 配置一个名为L2BExercise1新启动配置,运行Exercise1二进制文件,然后点击运行以构建和运行Exercise 1。你将收到以下输出:图 2B.6:练习 1 默认输出

图 2B.6:练习 1 默认输出
  1. 直接打开speak()act()。对于一个小程序来说这是可以的。但是当游戏扩大到几十甚至上百个角色时,就会变得难以管理。因此,我们需要将所有角色抽象出来。在文件顶部添加以下接口声明:
class ICharacter
{
public:
    ~ICharacter() {
        std::cout << "Destroying Character\n";
    }
    virtual void speak() = 0;
    virtual void act() = 0;
};

通常,析构函数将是空的,但在这里,它有日志来显示行为。

  1. 从这个接口类派生WizardHealerWarrior类,并在每个类的speak()act()函数声明末尾添加override关键字:
class Wizard : public Icharacter { ...
  1. 点击运行按钮重新构建和运行练习。现在我们将看到在派生类的析构函数之后也调用了基类的析构函数:图 2B.7:修改后程序的输出
图 2B.7:修改后程序的输出
  1. 创建角色并在容器中管理它们,比如vector。在main()函数之前在文件中创建以下两个方法:
void createCharacters(std::vector<ICharacter*>& cast)
{
    cast.push_back(new Wizard("Gandalf"));
    cast.push_back(new Healer("Glenda"));
    cast.push_back(new Warrior("Ben Grimm"));
}
void freeCharacters(std::vector<ICharacter*>& cast)
{
    for(auto* character : cast)
    {
        delete character;
    }
    cast.clear();
}
  1. 用以下代码替换main()的内容:
int main(int argc, char**argv)
{
    std::cout << "\n------ Exercise 1 ------\n";
    std::vector<ICharacter*> cast;
    createCharacters(cast);
    for(auto* character : cast)
    {
        character->speak();
    }
    for(auto* character : cast)
    {
        character->act();
    }
    freeCharacters(cast);
    std::cout << "Complete.\n";
    return 0;
}
  1. 点击运行按钮重新构建和运行练习。以下是生成的输出:图 2B.8:多态版本的输出
图 2B.8:多态版本的输出

从上面的截图中可以看出,“销毁巫师”等日志已经消失了。问题在于容器保存了指向基类的指针,并且不知道如何在每种情况下调用完整的析构函数。

  1. 为了解决这个问题,只需将ICharacter的析构函数声明为虚函数:
virtual ~ICharacter() {
  1. 点击运行按钮重新构建和运行练习。输出现在如下所示:

图 2B.9:完整多态版本的输出

图 2B.9:完整多态版本的输出

我们现在已经为我们的ICharacter角色实现了一个接口,并通过在容器中存储基类指针简单地调用speak()act()方法进行了多态使用。

类、结构体和联合体再讨论

之前我们讨论过类和结构体的区别是默认访问修饰符 - 类的为私有,结构体的为公共。这个区别更进一步 - 如果基类没有指定任何内容,它将应用于基类:

class DerivedC : Base  // inherits as if "class DerivedC : private Base" was used
{
};
struct DerivedS : Base // inherits as if "struct DerivedS : public Base" was used
{
};

应该注意的是,联合既不能是基类,也不能从基类派生。如果结构和类之间本质上没有区别,那么我们应该使用哪种类型?本质上,这是一种惯例。结构用于捆绑几个相关的元素,而可以执行操作并具有责任。结构的一个例子如下:

struct Point     // A point in 3D space
{
  double m_x;
  double m_y;
  double m_z;
};

在前面的代码中,我们可以看到它将三个坐标组合在一起,这样我们就可以推断出三维空间中的一个点。这个结构可以作为一个连贯的数据集传递给需要点的方法,而不是每个点的三个单独的参数。另一方面,类模拟了一个可以执行操作的对象。看看下面的例子:

class Matrix
{
public:
  Matrix& operator*(const Matrix& rhs)
  {
     // nitty gritty of the multiplication
  }
private:
  // Declaration of the 2D array to store matrix.
};

经验法则是,如果至少有一个私有成员,则应使用类,因为这意味着实现的细节将在公共成员函数的后面。

可见性、生命周期和访问

我们已经讨论了创建自己的类型和声明变量和函数,主要关注简单函数和单个文件。现在我们将看看当有多个包含类和函数定义的源文件(翻译单元)时会发生什么。此外,我们将检查哪些变量和函数可以从源文件的其他部分可见,变量的生存周期有多长,并查看内部链接和外部链接之间的区别。在第一章可移植 C++软件的解剖学中,我们看到了工具链是如何工作的,编译源文件并生成目标文件,链接器将其全部组合在一起形成可执行程序。

当编译器处理源文件时,它会生成一个包含转换后的 C代码和足够信息的目标文件,以便链接器解析已编译源文件到另一个源文件的任何引用。在第一章,*可移植 C软件的解剖学*中,sum()SumFunc.cpp文件中定义。当编译器构建目标文件时,它创建以下段:

  • 代码段(也称为文本):这是 C++函数翻译成目标机器指令的结果。

  • 数据段:这包含程序中声明的所有变量和数据结构,不是本地的或从堆栈分配的,并且已初始化。

  • BSS 段:这包含程序中声明的所有变量和数据结构,不是本地的或从堆栈分配的,并且未初始化(但将初始化为零)。

  • 导出符号数据库:此对象文件中的变量和函数列表及其位置。

  • 引用符号数据库:此对象文件需要从外部获取的变量和函数列表以及它们的使用位置。

注意

BSS 用于命名未初始化的数据段,其名称历史上源自 Block Started by Symbol。

然后,链接器将所有代码段、数据段和BSS段收集在一起形成程序。它使用两个数据库(DB)中的信息将所有引用的符号解析为导出的符号列表,并修补代码段,使其能够正确运行。从图形上看,这可以表示如下:

图 2B.10:目标文件和可执行文件的部分

图 2B.10:目标文件和可执行文件的部分

为了后续讨论的目的,BSS 和数据段将简称为数据段(唯一的区别是 BSS 未初始化)。当程序执行时,它被加载到内存中,其内存看起来有点像可执行文件布局 - 它包含文本段、数据段、BSS 段以及主机系统分配的空闲内存,其中包含所谓的堆栈。堆栈通常从内存顶部开始并向下增长,而堆从 BSS 结束的地方开始并向上增长,朝向堆栈:

图 2B.11:CxxTemplate 运行时内存映射

图 2B.11:CxxTemplate 运行时内存映射

变量或标识符可访问的程序部分称为作用域。作用域有两个广泛的类别:

  • {}). 变量可以在大括号内部访问。就像块可以嵌套一样,变量的作用域也可以嵌套。这通常包括局部变量和函数参数,这些通常存储在堆栈中。

  • 全局/文件作用域:这适用于在普通函数或类之外声明的变量,以及普通函数。可以在文件中的任何地方访问变量,并且如果链接正确,可能还可以从其他文件(全局)访问。这些变量由链接器在数据段中分配内存。标识符被放入全局命名空间,这是默认命名空间。

命名空间

我们可以将命名空间看作是变量、函数和用户定义类型的名称字典。对于小型程序,使用全局命名空间是可以的,因为很少有可能创建多个具有相同名称并发生名称冲突的变量。随着程序变得更大,并且包含了更多的第三方库,名称冲突的机会增加。因此,库编写者将他们的代码放入一个命名空间(希望是唯一的)。这允许程序员控制对命名空间中标识符的访问。通过使用标准库,我们已经在使用 std 命名空间。命名空间的声明如下:

namespace name_of_namespace {  // put declarations in here }

通常,name_of_namespace 很短,命名空间可以嵌套。

注意

在 boost 库中可以看到命名空间的良好使用:www.boost.org/

变量还有另一个属性,即寿命。有三种基本寿命;两种由编译器管理,一种由程序员选择:

  • 自动寿命:局部变量在声明时创建,并在退出其所在的作用域时被销毁。这些由堆栈管理。

  • 永久寿命:全局变量和静态局部变量。编译器在程序开始时(进入 main()函数之前)创建全局变量,并在首次访问静态局部变量时创建它们。在这两种情况下,变量在程序退出时被销毁。这些变量由链接器放置在数据段中。

  • newdelete)。这些变量的内存是从堆中分配的。

我们将考虑的变量的最终属性是链接。链接指示编译器和链接器在遇到具有相同名称(或标识符)的变量和函数时会执行什么操作。对于函数,实际上是所谓的重载名称 - 编译器使用函数的名称、返回类型和参数类型来生成重载名称。有三种类型的链接:

  • 无链接:这意味着标识符只引用自身,并适用于局部变量和本地定义的用户类型(即在块内部)。

  • 内部链接:这意味着可以在声明它的文件中的任何地方访问该标识符。这适用于静态全局变量、const 全局变量、静态函数以及文件中匿名命名空间中声明的任何变量或函数。匿名命名空间是一个没有指定名称的命名空间。

  • 外部链接:这意味着在正确的前向声明的情况下,可以从所有文件中访问它。这包括普通函数、非静态全局变量、extern const 全局变量和用户定义类型。

虽然这些被称为链接,但只有最后一个实际上涉及链接器。其他两个是通过编译器排除导出标识符数据库中的信息来实现的。

模板-泛型编程

作为计算机科学家或编程爱好者,您可能在某个时候不得不编写一个(或多个)排序算法。在讨论算法时,您可能并不特别关心正在排序的数据类型,只是该类型的两个对象可以进行比较,并且该域是一个完全有序的集合(也就是说,如果一个对象与任何其他对象进行比较,您可以确定哪个排在前面)。不同的编程语言为这个问题提供了不同的解决方案:

  • swap函数。

  • void 指针size_t大小定义了每个对象的大小,而compare()函数定义了如何比较这两个对象。

  • std::sort()是标准库中提供的一个函数,其中一个签名如下:

template< class RandomIt > void sort( RandomIt first, RandomIt last );

在这种情况下,类型的细节被捕获在名为RandomIt的迭代器类型中,并在编译时传递给方法。

在下一节中,我们将简要定义泛型编程,展示 C++如何通过模板实现它们,突出语言已经提供的内容,并讨论编译器如何推断类型,以便它们可以用于模板。

什么是泛型编程?

当您开发排序算法时,您可能最初只关注对普通数字的排序。但一旦建立了这一点,您就可以将其抽象为任何类型,只要该类型具有某些属性,例如完全有序集(即比较运算符<在我们正在排序的域中的所有元素之间都有意义)。因此,为了以泛型编程的方式表达算法,我们在算法中为需要由该算法操作的类型定义了一个占位符。

泛型编程是开发一种类型不可知的通用算法。通过传递类型作为参数,可以重用该算法。这样,算法被抽象化,并允许编译器根据类型进行优化。

换句话说,泛型编程是一种编程方法,其中算法是以参数化的类型定义的,当实例化算法时指定了参数。许多语言提供了不同名称的泛型编程支持。在 C++中,泛型编程是通过模板这种语言特性来支持的。

介绍 C++模板

模板是 C++对泛型编程的支持。把模板想象成一个饼干模具,我们给它的类型参数就像饼干面团(可以是巧克力布朗尼、姜饼或其他美味口味)。当我们使用饼干模具时,我们得到的饼干实例形式相同,但口味不同。因此,模板捕获了泛型函数或类的定义,当指定类型参数时,编译器会根据我们手动编码的类型来为我们编写类或函数。它有几个优点,例如:

  • 您只需要开发一次类或算法,然后进行演化。

  • 您可以将其应用于许多类型。

  • 您可以将复杂细节隐藏在简单的接口后,编译器可以根据类型对生成的代码进行优化。

那么,我们如何编写一个模板呢?让我们从一个模板开始,它允许我们将值夹在从lohi的范围内,并且能够在intfloatdouble或任何其他内置类型上使用它:

template <class T>
T clamp(T val, T lo, T hi)
{
  return (val < lo) ? lo : (hi < val) ? hi : val;
}

让我们来分解一下:

  • template <class T>声明接下来是一个模板,并使用一个类型,模板中有一个T的占位符。

  • T被替换。它声明函数 clamp 接受三个类型为T的参数,并返回类型为T的值。

  • <运算符,然后我们可以对三个值执行 clamp,使得lo <= val <= hi。这个算法对所有可以排序的类型都有效。

假设我们在以下程序中使用它:

#include <iostream>
int main()
{
    std::cout << clamp(5, 3, 10) << "\n";
    std::cout << clamp(3, 5, 10) << "\n";
    std::cout << clamp(13, 3, 10) << "\n";
    std::cout << clamp(13.0, 3.0, 10.1) << "\n";
    std::cout << clamp<double>(13.0, 3, 10.2) << "\n";
    return 0;
}

我们将得到以下预期输出:

图 2B.12:Clamp 程序输出

图 2B.12:Clamp 程序输出

在最后一次调用 clamp 时,我们在<>之间传递了 double 类型的模板。但是我们没有对其他四个调用遵循相同的方式。为什么?原来编译器随着年龄的增长变得越来越聪明。随着每个标准的发布,它们改进了所谓的类型推导。因为编译器能够推断类型,我们不需要告诉它使用什么类型。这是因为类的三个参数没有模板参数,它们具有相同的类型 - 前三个都是 int,而第四个是 double。但是我们必须告诉编译器使用最后一个的类型,因为它有两个 double 和一个 int 作为参数,这导致编译错误说找不到函数。但是然后,它给了我们关于为什么不能使用模板的信息。这种形式,你强制类型,被称为显式模板参数规定

C++预打包模板

C++标准由两个主要部分组成:

  • 语言定义,即关键字、语法、词法定义、结构等。

  • 标准库,即编译器供应商提供的所有预先编写的通用函数和类。这个库的一个子集是使用模板实现的,被称为标准模板库STL)。

STL 起源于 Ada 语言中提供的泛型,该语言由 David Musser 和 Alexander Stepanov 开发。Stepanov 是泛型编程作为软件开发基础的坚定支持者。在 90 年代,他看到了用新语言 C来影响主流开发的机会,并建议 ISO C委员会应该将 STL 作为语言的一部分包含进去。其余的就是历史了。

STL 由四类预定义的通用算法和类组成:

  • 容器:通用序列(vector,list,deque)和关联容器(set,multiset,map)

  • begin()end())。请注意,STL 中的一个基本设计选择是end()指向最后一项之后的位置 - 在数学上,即begin()end())。

  • 算法:涵盖排序、搜索、集合操作等 100 多种不同算法。

  • find_if().

我们之前实现的 clamp 函数模板是简单的,虽然它适用于支持小于运算符的任何类型,但它可能不太高效 - 如果类型具有较大的大小,可能会导致非常大的副本。自 C++17 以来,STL 包括一个std::clamp()函数,声明更像这样:

#include <cassert>
template<class T, class Compare>
const T& clamp( const T& v, const T& lo, const T& hi, Compare comp )
{
    return assert( !comp(hi, lo) ),
        comp(v, lo) ? lo : comp(hi, v) ? hi : v;
}
template<class T>
const T& clamp( const T& v, const T& lo, const T& hi )
{
    return clamp( v, lo, hi, std::less<>() );
}

正如我们所看到的,它使用引用作为参数和返回值。将参数更改为使用引用减少了需要传递和返回的堆栈上的内容。还要注意,设计者们努力制作了模板的更通用版本,这样我们就不会依赖于类型存在的<运算符。然而,我们可以通过传递 comp 来定义排序。

从前面的例子中,我们已经看到,像函数一样,模板可以接受多个逗号分隔的参数。

类型别名 - typedef 和 using

如果您使用了std::string类,那么您一直在使用别名。有一些与字符串相关的模板类需要实现相同的功能。但是表示字符的类型是不同的。例如,对于std::string,表示是char,而std::wstring使用wchar_t。还有一些其他的用于char16_tchar32_t。任何功能上的变化都将通过特性或模板特化来管理。

在 C++11 之前,这将从std::basic_string基类中进行别名处理,如下所示:

namespace std {
  typedef basic_string<char> string;
}

这做了两件主要的事情:

  • 减少声明变量所需的输入量。这是一个简单的情况,但是当你声明一个指向字符串到对象的映射的唯一指针时,可能会变得非常长,你会犯错误:
typedef std::unique_ptr<std::map<std::string,myClass>> UptrMapStrToClass;
  • 提高了可读性,因为现在你在概念上将其视为一个字符串,不需要担心细节。

但是 C++11 引入了一种更好的方式 - 别名声明 - 它利用了using关键字。前面的代码可以这样实现:

namespace std {
  using string = basic_string<char>;
}

前面的例子很简单,别名,无论是 typedef 还是 using,都不太难理解。但是当别名涉及更复杂的表达式时,它们也可能有点难以理解 - 特别是函数指针。考虑以下代码:

typedef int (*FunctionPointer)(const std::string&, const Point&); 

现在,考虑以下代码:

using FunctionPointer = int (*)(const std::string&, const Point&);

C++11 中有一个新功能,即别名声明可以轻松地并入模板中 - 它们可以被模板化。typedef不能被模板化,虽然可以通过typedef实现相同的结果,但别名声明(using)是首选方法,因为它会导致更简单、更易于理解的模板代码。

练习 2:实现别名

在这个练习中,我们将使用 typedef 实现别名,并看看通过使用引用使代码变得更容易阅读和高效。按照以下步骤实现这个练习:

  1. 在 Eclipse 中打开Lesson2B项目,然后在项目资源管理器中展开Lesson2B,然后展开Exercise02,双击Exercise2.cpp以在编辑器中打开此练习的文件。

  2. 单击启动配置下拉菜单,然后选择新启动配置...。配置L2BExercise2以使用名称Exercise2运行。完成后,它将成为当前选择的启动配置。

  3. 单击运行按钮。Exercise 2将运行并产生类似以下输出:

图 2B.13:练习 2 输出

图 2B.13:练习 2 输出 1.

在编辑器中,在printVector()函数的声明之前,添加以下行:

typedef std::vector<int> IntVector;    
  1. 现在,将文件中所有的std::vector<int>更改为IntVector

  2. 单击运行按钮。输出应与以前相同。

  3. 在编辑器中,更改之前添加的行为以下内容:

using IntVector = std::vector<int>;    
  1. 单击运行按钮。输出应与以前相同。

  2. 在编辑器中,添加以下行:

using IntVectorIter = std::vector<int>::iterator;    
  1. 现在,将IntVector::iterator的一个出现更改为IntVectorIter

  2. 单击运行按钮。输出应与以前相同。

在这个练习中,typedef 和使用别名似乎没有太大区别。在任何一种情况下,使用一个命名良好的别名使得代码更容易阅读和理解。当涉及更复杂的别名时,using提供了一种更容易编写别名的方法。在 C++11 中引入,using现在是定义别名的首选方法。它还比typedef有其他优点,例如能够在模板内部使用它。

模板 - 不仅仅是泛型

编程模板还可以提供比泛型编程更多的功能(一种带有类型的模板)。在泛型编程的情况下,模板作为一个不能更改的蓝图运行,并为指定的类型或类型提供模板的编译版本。

模板可以被编写以根据涉及的类型提供函数或算法的特化。这被称为模板特化,并不是我们先前使用的意义上的通用编程。只有当它使某些类型在给定上下文中表现得像我们期望它们在某个上下文中表现得一样时,它才能被称为通用编程。当用于所有类型的算法被修改时,它不能被称为通用编程。检查以下专业化代码的示例:

#include <iostream>
#include <type_traits>
template <typename T, std::enable_if_t<sizeof(T) == 1, int> = 0>
void print(T val){
    printf("%c\n", val);
}
template <typename T, std::enable_if_t<sizeof(T) == sizeof(int), int> = 0>
void print(T val){    
    printf("%d\n", val);
}
template <typename T, std::enable_if_t<sizeof(T) == sizeof(double), int> = 0>
void print(T val){    
    printf("%f\n", val);
}
int main(int argc, char** argv){    
    print('c');    
    print(55);    
    print(32.1F);    
    print(77.3);
}

它定义了一个模板,根据使用std::enable_if_t<>sizeof()的模板的特化,调用printf()并使用不同的格式字符串。当我们运行它时,会生成以下输出:

图 2B.14:错误的打印模板程序输出

图 2B.14:错误的打印模板程序输出

替换失败不是错误 - SFINAE

对于32.1F打印的值(-1073741824)与数字毫不相干。如果我们检查编译器为以下程序生成的代码,我们会发现它生成的代码就好像我们写了以下内容(以及更多):

template<typename int, int=0>
void print<int,0>(int val)
{
    printf("%d\n",val);
}
template<typename float, int=0>
void print<float,0>(float val)
{
    printf("%d\n", val);
}

为什么会生成这段代码?前面的模板使用了 C++编译器的一个特性,叫做std::enable_if_t<>,并访问了所谓的类型特征来帮助我们。首先,我们将用以下代码替换最后一个模板:

#include <type_traits>
template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void print(T val)
{
    printf("%f\n", val);
}

这需要一些解释。首先,我们考虑std::enable_if_t的定义,实际上是一个类型别名:

template<bool B, class T = void>
struct enable_if {};
template<class T>
struct enable_if<true, T> { typedef T type; };
template< bool B, class T = void >
using enable_if_t = typename enable_if<B,T>::type;

enable_if的第一个模板将导致定义一个空的结构体(或类)。enable_if的第二个模板是对 true 的第一个模板参数的特化,将导致具有 typedef 定义的类。enable_if_t的定义是一个帮助模板,它消除了我们在使用它时需要在模板末尾输入::type的需要。那么,这是如何工作的呢?考虑以下代码:

template <typename T, std::enable_if_t<condition, int> = 0>
void print(T val) { … }

如果在编译时评估的条件导致enable_if_t模板将导致一个看起来像这样的模板:

template <typename T, int = 0>
void print(T val) { … }

这是有效的语法,函数被添加到符号表作为候选函数。如果在编译时计算的条件导致enable_if_t模板将导致一个看起来像这样的模板:

template <typename T, = 0>
void print(T val) { … }

这是格式错误的代码,现在被丢弃了 - SFINAE 在起作用。

std::is_floating_point_v<T>是另一个访问std::is_floating_point<T>模板的::value成员的帮助类。它的名字说明了一切 - 如果 T 是浮点类型(float、double、long double),它将为 true;否则,它将为 false。如果我们进行这个改变,那么编译器(GCC)会生成以下错误:

图 2B.15:修改后的打印模板程序的编译器错误

图 2B.15:修改后的打印模板程序的编译器错误

现在的问题是,当类型是浮点数时,我们有两个可以满足的模板:

template <typename T, std::enable_if_t<sizeof(T) == sizeof(int), int> = 0>
void print(T val)
{
    printf("%d\n", val);
}
template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void print(T val)
{
    printf("%f\n", val);
}

事实证明,通常情况下sizeof(float) == sizeof(int),所以我们需要做另一个改变。我们将用另一个类型特征std::is_integral_v<>替换第一个条件:

template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
void print(T val)
{
    printf("%d\n", val);
}

如果我们进行这个改变,那么编译器(GCC)会生成以下错误:

图 2B.16:修改后的打印模板程序的第二个编译器错误

图 2B.16:修改后的打印模板程序的第二个编译器错误

我们解决了浮点数的歧义,但这里的问题是std::is_integral_v(char)返回 true,再次生成了具有相同原型的模板函数。原来传递给std::enable_if_t<>的条件遵循标准 C++逻辑表达式。因此,为了解决这个问题,我们将添加一个额外的条件来排除字符:

template <typename T, std::enable_if_t<std::is_integral_v<T> && sizeof(T) != 1, int> = 0>
void print(T val)
{
    printf("%d\n", val);
}

如果我们现在编译程序,它完成编译并链接程序。如果我们运行它,它现在会产生以下(预期的)输出:

图 2B.17:修正的打印模板程序输出

图 2B.17:修正的打印模板程序输出

浮点表示

32.099998不应该是32.1吗?这是传递给函数的值。在计算机上执行浮点运算的问题在于,表示自动引入了误差。实数形成一个连续(无限)的域。如果你考虑实域中的数字 1 和 2,那么它们之间有无限多个实数。不幸的是,计算机对浮点数的表示量化了这些值,并且无法表示所有无限数量的数字。用于存储数字的位数越多,值在实域上的表示就越好。因此,long double 比 double 好,double 比 float 好。对于存储数据来说,真正取决于您的问题域。回到32.099998。计算机将单精度数存储为 2 的幂的和,然后将它们移位一个幂因子。整数通常很容易,因为它们可以很容易地表示为2^n的和(n>=0)。在这种情况下的小数部分,即 0.1,必须表示为2^(-n) (n>0)的和。我们添加更多的 2 的幂分数,以尝试使数字更接近目标,直到我们用完了单精度浮点数中的 24 位精度。

注意

如果您想了解计算机如何存储浮点数,请研究定义它的 IEEE 754 标准。

Constexpr if 表达式

C++17 引入了constexpr if表达式到语言中,大大简化了模板编写。我们可以将使用 SFINAE 的前面三个模板重写为一个更简单的模板:

#include <iostream>
#include <type_traits>
template <typename T>
void print(T val)
{
   if constexpr(sizeof(T)==1) {
      printf("%c",val);
   }
   else if constexpr(std::is_integral_v<T>) {
      printf("%d",val);
   }
   else if constexpr(std::is_floating_point_v<T>) {
      printf("%f",val);
   }
   printf("\n");
}
int main(int argc, char** argv)
{
    print('c');
    print(55);
    print(32.1F);
    print(77.3);
}

对于对print(55)的调用,编译器生成的函数调用如下:

template<>
void print<int>(int val)
{
    printf("%d",val);
    printf("\n");
}

if/else if 语句发生了什么?constexpr if表达式的作用是,编译器在上下文中确定条件的值,并将其转换为布尔值(true/false)。如果评估的值为 true,则 if 条件和 else 子句被丢弃,只留下 true 子句生成代码。同样,如果为 false,则留下 false 子句生成代码。换句话说,只有第一个 constexpr if 条件评估为 true 时,才会生成其子句的代码,其余的都会被丢弃。

非类型模板参数

到目前为止,我们只看到了作为模板参数的类型。还可以将整数值作为模板参数传递。这允许我们防止函数的数组衰减。例如,考虑一个计算sum的模板函数:

template <class T>
T sum(T data[], int number)
{
  T total = 0;
  for(auto i=0U ; i<number ; i++)
  {
    total += data[i];
  }
  return total;
}

在这种情况下,我们需要在调用中传递数组的长度:

float data[5] = {1.1, 2.2, 3.3, 4.4, 5.5};
auto total = sum(data, 5);

但是,如果我们只能调用以下内容会不会更好呢?

auto total = sum(data);

我们可以通过对模板进行更改来实现,就像下面的代码一样:

template <class T, std::size_t size>
T sum(T (&data)[size])
{
  T total = 0;
  for(auto i=0U ; i< size; i++)
  {
    total += data[i];
  }
  return total;
}

在这里,我们将数据更改为对模板传递的特定大小的数组的引用,因此编译器会自行解决。我们不再需要函数调用的第二个参数。这个简单的例子展示了如何直接传递和使用非类型参数。我们将在模板类型推导部分进一步探讨这个问题。

练习 3:实现 Stringify - 专用与 constexpr

在这个练习中,我们将利用 constexpr 实现一个 stringify 模板,以生成一个更易读和更简单的代码版本。按照以下步骤实现这个练习:

注意

可以在isocpp.org/wiki/faq/templates#template-specialization-example找到 stringify 专用模板。

  1. 在 Eclipse 中打开Lesson2B项目,然后在项目资源管理器中展开Lesson2B,然后展开Exercise03,双击Exercise3.cpp以在编辑器中打开此练习的文件。

  2. 单击启动配置下拉菜单,选择新启动配置...。配置L2BExercise3以使用名称Exercise3运行。

  3. 单击运行按钮。练习 3将运行并产生以下输出:图 2B.18:练习 3 特化模板输出

图 2B.18:练习 3 特化模板输出
  1. Exercise3.cpp中,将 stringify 模板的所有特化模板注释掉,同时保留原始的通用模板。

  2. 单击运行按钮。输出将更改为将布尔型打印为数字,将双精度浮点数打印为仅有两位小数:图 2B.19:练习 3 仅通用模板输出

图 2B.19:练习 3 仅通用模板输出
  1. 我们现在将再次为布尔类型“特化”模板。在其他#includes中添加#include <type_traits>指令,并修改模板,使其如下所示:
template<typename T> std::string stringify(const T& x)
{
  std::ostringstream out;
  if constexpr (std::is_same_v<T, bool>)
  {
      out << std::boolalpha;
  }
  out << x;
  return out.str();
}
  1. 单击运行按钮。布尔型的 stringify 输出与以前一样:图 2B.20:针对布尔型定制的 stringify
图 2B.20:针对布尔型定制的 stringify
  1. 我们现在将再次为浮点类型(floatdoublelong double)“特化”模板。修改模板,使其如下所示:
template<typename T> std::string stringify(const T& x)
{
  std::ostringstream out;
  if constexpr (std::is_same_v<T, bool>)
  {
      out << std::boolalpha;
  }
  else if constexpr (std::is_floating_point_v<T>)
  {
      const int sigdigits = std::numeric_limits<T>::digits10;
      out << std::setprecision(sigdigits);
  }
  out << x;
  return out.str();
}
  1. 单击运行按钮。输出恢复为原始状态:图 2B.21:constexpr if 版本模板输出
图 2B.21:constexpr if 版本模板输出
  1. 如果您将多个模板的原始版本与最终版本进行比较,您会发现最终版本更像是一个普通函数,更易于阅读和维护。

在这个练习中,我们学习了在 C++17 中使用新的 constexpr if 结构时,模板可以变得更简单和紧凑。

函数重载再探讨

当我们首次讨论函数重载时,我们只考虑了函数名称来自我们手动编写的函数列表的情况。现在,我们需要更新这一点。我们还可以编写可以具有相同名称的模板函数。就像以前一样,当编译器遇到print(55)这一行时,它需要确定调用先前定义的函数中的哪一个。因此,它执行以下过程(大大简化):

图 2B.22:模板的函数重载解析(简化版)

图 2B.22:模板的函数重载解析(简化版)

模板类型推断

当我们首次介绍模板时,我们涉及了模板类型推断。现在,我们将进一步探讨这一点。我们将从考虑函数模板的一般声明开始:

template<typename T>
void function(ParamType parameter);

此调用可能如下所示:

function(expression);              // deduce T and ParamType from expression

当编译器到达这一行时,它现在必须推断与模板相关的两种类型—TParamType。由于 T 在 ParamType 中附加了限定符和其他属性(例如指针、引用、const 等),它们通常是不同的。这些类型是相关的,但推断的过程取决于所使用的expression的形式。

显示推断类型

在我们研究不同形式之前,如果我们能让编译器告诉我们它推断出的类型,那将非常有用。我们有几种选择,包括 IDE 编辑器显示类型、编译器生成错误和运行时支持(由于 C++标准的原因,这不一定有效)。我们将使用编译器错误来帮助我们探索一些类型推断。

我们可以通过声明一个没有定义的模板来实现类型显示器。任何尝试实例化模板都将导致编译器生成错误消息,因为没有定义,以及它正在尝试实例化的类型信息:

template<typename T>
struct TypeDisplay;

让我们尝试编译以下程序:

template<typename T>
class TypeDisplay;
int main()
{
    signed int x = 1;
    unsigned int y = 2;
    TypeDisplay<decltype(x)> x_type;
    TypeDisplay<decltype(y)> y_type;
    TypeDisplay<decltype(x+y)> x_y_type;
    return 0;
}

编译器输出以下错误:

图 2B.23:显示推断类型的编译器错误

图 2B.23:显示推断类型的编译器错误

请注意,在每种情况下,被命名的聚合包括被推断的类型 - 对于 x,它是一个 int,对于 y,是一个 unsigned int,对于 x+y,是一个 unsigned int。还要注意,TypeDisplay 模板需要其参数的类型,因此使用decltype()函数来获取编译器提供括号中表达式的类型。

还可以使用内置的typeid(T).name()运算符在运行时显示推断的类型,它返回一个 std::string,或者使用名为 type_index 的 boost 库。

注意

有关更多信息,请访问以下链接:www.boost.org/doc/libs/1_70_0/doc/html/boost_typeindex.html

由于类型推断规则,内置运算符将为您提供类型的指示,但会丢失引用(&&&)和任何 constness 信息(const 或 volatile)。如果需要在运行时,考虑使用boost::type_index,它将为所有编译器产生相同的输出。

模板类型推断 - 详细信息

让我们回到通用模板:

template<typename T>
void function(ParamType parameter);

假设调用看起来像这样:

function(expression);             // deduce T and ParamType from expression

类型推断取决于 ParamType 的形式:

  • ParamType 是值(T):按值传递函数调用

  • *ParamType 是引用或指针(T&或 T)**:按引用传递函数调用

  • ParamType 是右值引用(T&&):按引用传递函数调用或其他内容

情况 1:ParamType 是按值传递(T)

template<typename T>
void function(T parameter);

作为按值传递的调用,这意味着参数将是传入内容的副本。因为这是对象的新实例,所以以下规则适用于表达式:

  • 如果表达式的类型是引用,则忽略引用部分。

  • 如果在步骤 1 之后,剩下的类型是 const 和/或 volatile,则也忽略它们。

剩下的是 T。让我们尝试编译以下文件代码:

template<typename T>
class TypeDisplay;
template<typename T>
void function(T parameter)
{
    TypeDisplay<T> type;
}
void types()
{
    int x = 42;
    function(x);
}

编译器产生以下错误:

图 2B.24:显示按类型推断类型的编译器错误

图 2B.24:显示按类型推断类型的编译器错误

因此,类型被推断为int。同样,如果我们声明以下内容,我们将得到完全相同的错误:

const int x = 42;
function(x);

如果我们声明这个版本,同样的情况会发生:

int x = 42;
const int& rx = x;
function(rx);

在所有三种情况下,根据先前规定的规则,推断的类型都是int

情况 2:ParamType 是按引用传递(T&)

作为按引用传递的调用,这意味着参数将能够访问对象的原始存储位置。因此,生成的函数必须遵守我们之前忽略的 constness 和 volatileness。类型推断适用以下规则:

  • 如果表达式的类型是引用,则忽略引用部分。

  • 模式匹配表达式类型的剩余部分与 ParamType 以确定 T。

让我们尝试编译以下文件:

template<typename T>
class TypeDisplay;
template<typename T>
void function(T& parameter)
{
    TypeDisplay<T> type;
}
void types()
{
    int x = 42;
    function(x);
}

编译器将生成以下错误:

图 2B.25:显示按引用传递推断类型的编译器错误

图 2B.25:显示按引用传递推断类型的编译器错误

从这里,我们可以看到编译器将 T 作为int,从 ParamType 作为int&。将 x 更改为 const int 不会有任何意外,因为 T 被推断为const int,从 ParamType 作为const int&

图 2B.26:显示按 const 引用传递推断类型的编译器错误

图 2B.26:传递 const 引用时显示推断类型的编译器错误

同样,像之前一样引入 rx 作为对 const int 的引用,不会有令人惊讶的地方,因为 T 从 ParamType 作为const int&推断为const int

void types()
{
    const int x = 42;
    const int& rx = x;
    function(rx);
}

图 2B.27:传递 const 引用时显示推断类型的编译器错误

图 2B.27:传递 const 引用时显示推断类型的编译器错误

如果我们改变声明以包括一个 const,那么编译器在从模板生成函数时将遵守 constness:

template<typename T>
void function(const T& parameter)
{
    TypeDisplay<T> type;
}

这次,编译器报告如下

  • int x:T 是 int(因为 constness 将被尊重),而参数的类型是const int&

  • const int x:T 是 int(const 在模式中,留下 int),而参数的类型是const int&

  • const int& rx:T 是 int(引用被忽略,const 在模式中,留下 int),而参数的类型是const int&

如果我们尝试编译以下内容,我们期望会发生什么?通常,数组会衰减为指针:

int ary[15];
function(ary);

编译器错误如下:

图 2B.28:传递数组参数时显示推断类型的编译器错误传递引用时

图 2B.28:传递引用时显示数组参数的推断类型的编译器错误

这次,数组被捕获为引用,并且大小也被包括在内。因此,如果 ary 声明为ary[10],那么将得到一个完全不同的函数。让我们将模板恢复到以下内容:

template<typename T>
void function(T parameter)
{
    TypeDisplay<T> type;
}

如果我们尝试编译数组调用,那么错误报告如下:

图 2B.29:传递数组参数时显示推断类型的编译器错误传递值时

图 2B.29:传递值时显示数组参数的推断类型的编译器错误

我们可以看到,在这种情况下,数组已经衰减为传递数组给函数时的通常行为。当我们谈论非类型模板参数时,我们看到了这种行为。

情况 3:ParamType 是右值引用(T&&)

T&&被称为右值引用,而 T&被称为左值引用。C不仅通过类型来表征表达式,还通过一种称为值类别的属性来表征。这些类别控制编译器中表达式的评估,包括创建、复制和移动临时对象的规则。C17 标准中定义了五种表达式值类别,它们具有以下关系:

图 2B.30:C++值类别

图 2B.30:C++值类别

每个的定义如下:

  • 决定对象身份的表达式是glvalue

  • 评估初始化对象或操作数的表达式是prvalue。例如,文字(除了字符串文字)如 3.1415,true 或 nullptr,this 指针,后增量和后减量表达式。

  • 具有资源并且可以被重用(因为它的生命周期即将结束)的 glvalue 对象是xvalue。例如,返回类型为对象的右值引用的函数调用,如std::move()

  • 不是 xvalue 的 glvalue 是lvalue。例如,变量的名称,函数或数据成员的名称,或字符串文字。

  • prvalue 或 xvalue 是一个rvalue

不要紧,如果你不完全理解这些,因为接下来的解释需要你知道什么是左值,以及什么不是左值:

template<typename T>
void function(T&& parameter)
{
    TypeDisplay<T> type;
}

这种 ParamType 形式的类型推断规则如下:

  • 如果表达式是左值引用,那么 T 和 ParamType 都被推断为左值引用。这是唯一一种类型被推断为引用的情况。

  • 如果表达式是一个右值引用,那么适用于情况 2 的规则。

SFINAE 表达式和尾返回类型

C++11 引入了一个名为尾返回类型的功能,为模板提供了一种通用返回类型的机制。一个简单的例子如下:

template<class T>
auto mul(T a, T b) -> decltype(a * b) 
{
    return a * b;
}

这里,auto用于指示定义尾返回类型。尾返回类型以->指针开始,在这种情况下,返回类型是通过将ab相乘返回的类型。编译器将处理 decltype 的内容,如果它格式不正确,它将从函数名的查找中删除定义,与往常一样。这种能力打开了许多可能性,因为逗号运算符“,”可以在decltype内部使用来检查某些属性。

如果我们想测试一个类是否实现了一个方法或包含一个类型,那么我们可以将其放在 decltype 内部,将其转换为 void(以防逗号运算符已被重载),然后在逗号运算符的末尾定义一个真实返回类型的对象。下面的程序示例中展示了这种方法:

#include <iostream>
#include <algorithm>
#include <utility>
#include <vector>
#include <set>
template<class C, class T>
auto contains(const C& c, const T& x) 
             -> decltype((void)(std::declval<C>().find(std::declval<T>())), true)
{
    return end(c) != c.find(x);
}
int main(int argc, char**argv)
{
    std::cout << "\n\n------ SFINAE Exercise ------\n";
    std::set<int> mySet {1,2,3,4,5};
    std::cout << std::boolalpha;
    std::cout << "Set contains 5: " << contains(mySet,5) << "\n";
    std::cout << "Set contains 15: " << contains(mySet,15) << "\n";
    std::cout << "Complete.\n";
    return 0;
}

当编译并执行此程序时,我们将获得以下输出:

图 2B.31:SFINAE 表达式的输出

图 2B.31:SFINAE 表达式的输出

返回类型由以下代码给出:

decltype( (void)(std::declval<C>().find(std::declval<T>())), true)

让我们来分解一下:

  • decltype的操作数是一个逗号分隔的表达式列表。这意味着编译器将构造但不评估表达式,并使用最右边的值的类型来确定函数的返回类型。

  • std::declval<T>()允许我们将 T 类型转换为引用类型,然后可以使用它来访问成员函数,而无需实际构造对象。

  • 与所有基于 SFINAE 的操作一样,如果逗号分隔列表中的任何表达式无效,则函数将被丢弃。如果它们都有效,则将其添加到查找函数列表中。

  • 将 void 转换是为了防止用户重载逗号运算符可能引发的任何问题。

  • 基本上,这是在测试C类是否有一个名为find()的成员函数,该函数以class Tclass T&const class T&作为参数。

这种方法适用于std::set,它具有一个接受一个参数的find()方法,但对于其他容器来说会失败,因为它们没有find()成员方法。

如果我们只处理一种类型,这种方法效果很好。但是,如果我们有一个需要根据类型生成不同实现的函数,就像我们以前看到的那样,if constexpr方法更清晰,通常更容易理解。要使用if constexpr方法,我们需要生成在编译时评估为truefalse的模板。标准库提供了这方面的辅助类:std::true_typestd::false_type。这两个结构都有一个名为 value 的静态常量成员,分别设置为truefalse。使用 SFINAE 和模板重载,我们可以创建新的检测类,这些类从这些类中派生,以给出我们想要的结果:

template <class T, class A0>
auto test_find(long) -> std::false_type;
template <class T, class A0>
auto test_find(int) 
-> decltype(void(std::declval<T>().find(std::declval<A0>())), std::true_type{});
template <class T, class A0>
struct has_find : decltype(test_find<T,A0>(0)) {};

test_find的第一个模板创建了将返回类型设置为std::false_type的默认行为。注意它的参数类型是long

test_find的第二个模板创建了一个专门测试具有名为find()的成员函数并具有std::true_type返回类型的类的特化。注意它的参数类型是int

has_find<T,A0>模板通过从test_find()函数的返回类型派生自身来工作。如果 T 类没有find()方法,则只会生成std::false_type版本的test_find(),因此has_find<T,A0>::value值将为 false,并且可以在if constexpr()中使用。

有趣的部分是,如果 T 类具有find()方法,则两个test_find()方法都会生成。但是专门的版本使用int类型的参数,而默认版本使用long类型的参数。当我们使用零(0)“调用”函数时,它将匹配专门的版本并使用它。参数的差异很重要,因为您不能有两个具有相同参数类型但仅返回类型不同的函数。如果要检查此行为,请将参数从 0 更改为 0L 以强制使用长版本。

类模板

到目前为止,我们只处理了函数模板。但是模板也可以用于为类提供蓝图。模板类声明的一般结构如下:

template<class T>
class MyClass {
   // variables and methods that use T.
};

而模板函数允许我们生成通用算法,模板类允许我们生成通用数据类型及其相关行为。

当我们介绍标准模板库时,我们强调它包括容器的模板-vectordequestack等。这些模板允许我们存储和管理任何我们想要的数据类型,但仍然表现得像我们期望的那样。

练习 4:编写类模板

在计算科学中,最常用的两种数据结构是堆栈和队列。目前,STL 中已经有了它们的实现。但是为了尝试使用模板类,我们将编写一个可以用于任何类型的堆栈模板类。让我们开始吧:

  1. 在 Eclipse 中打开Lesson2B项目,然后在Project Explorer中展开Lesson2B,然后展开Exercise04,双击Exercise4.cpp以在编辑器中打开此练习的文件。

  2. 配置一个新的Launch ConfigurationL2BExercise4,以运行名称为Exercise4的配置。

  3. 还要配置一个新的 C/C++单元运行配置,L2BEx4Tests,以运行L2BEx4tests。设置Google Tests Runner

  4. 单击运行选项以运行测试,这是我们第一次运行:图 2B.32:堆栈的初始单元测试

图 2B.32:堆栈的初始单元测试
  1. 打开#pragma once),告诉编译器如果再次遇到此文件要被#include,它就不需要了。虽然不严格属于标准的一部分,但几乎所有现代 C++编译器都支持它。最后,请注意,为了本练习的目的,我们选择将项目存储在 STL 向量中。

  2. 在编辑器中,在Stack类的public部分中添加以下声明:

bool empty() const
{
  return m_stack.empty();
}
  1. 在文件顶部,将EXERCISE4_STEP更改为值10。单击运行按钮。练习 4 的测试应该运行并失败:图 2B.33:跳转到失败的测试
图 2B.33:跳转到失败的测试
  1. 单击失败测试的名称,即empty()报告为 false。

  2. ASSERT_FALSE更改为ASSERT_TRUE并重新运行测试。这一次,它通过了,因为它正在测试正确的事情。

  3. 我们接下来要做的是添加一些类型别名,以便在接下来的几个方法中使用。在编辑器中,在empty()方法的上面添加以下行:

using value_type = T;
using reference = value_type&;
using const_reference = const value_type&;
using size_type = std::size_t;
  1. 单击运行按钮重新运行测试。它们应该通过。在进行测试驱动开发时,口头禅是编写一个小测试并看到它失败,然后编写足够的代码使其通过。在这种情况下,我们实际上测试了我们是否正确获取了别名的定义,因为编译失败是一种测试失败的形式。我们现在准备添加 push 函数。

  2. 在编辑器中,通过在empty()方法的下面添加以下代码来更改Stack.hpp

void push(const value_type& value)
{
    m_stack.push_back(value);
}
  1. 在文件顶部,将EXERCISE4_STEP更改为值15。单击PushOntoStackNotEmpty,在StackTests.cpp中证明了 push 对使堆栈不再为空做了一些事情。我们需要添加更多方法来确保它已经完成了预期的工作。

  2. 在编辑器中,更改push()方法并将EXERCISE4_STEP更改为值16

size_type size() const
{
    return m_stack.size();
}
  1. 单击运行按钮运行测试。现在应该有三个通过的测试。

  2. 在编辑器中,更改push()方法并将EXERCISE4_STEP更改为18的值:

void pop()
{
    m_stack.pop_back();
}
  1. 单击运行按钮运行测试。现在应该有四个通过的测试。

  2. 在编辑器中,更改pop()方法并将EXERCISE4_STEP更改为20的值:

reference top()
{
    m_stack.back();
}
const_reference top() const
{
    m_stack.back();
}
  1. 单击运行按钮运行测试。现在有五个通过的测试,我们已经实现了一个堆栈。

  2. 从启动配置下拉菜单中,选择L2BExercise4,然后单击运行按钮。练习 4 将运行并产生类似以下输出:

图 2B.34:练习 4 输出

图 2B.34:练习 4 输出

检查现在在std::stack模板中的代码,它带有两个参数,第二个参数定义要使用的容器 - vector 可以是第一个。检查StackTests.cpp中的测试。测试应该被命名以指示它们的测试目标,并且它们应该专注于做到这一点。

活动 1:开发一个通用的“contains”模板函数

编程语言 Python 有一个称为“in”的成员运算符,可以用于任何序列,即列表、序列、集合、字符串等。尽管 C有 100 多种算法,但它没有相应的方法来实现相同的功能。C 20 在std::set上引入了contains()方法,但这对我们来说还不够。我们需要创建一个contains()模板函数,它可以与std::setstd::stringstd::vector和任何提供迭代器的其他容器一起使用。这是通过能够在其上调用 end()来确定的。我们的目标是获得最佳性能,因此我们将在任何具有find()成员方法的容器上调用它(这将是最有效的),否则将退回到在容器上使用std::end()。我们还需要将std::string()区别对待,因为它的find()方法返回一个特殊值。

我们可以使用通用模板和两个特化来实现这一点,但是这个活动正在使用 SFINAE 和 if constexpr 的技术来使其工作。此外,这个模板必须只能在支持end(C)的类上工作。按照以下步骤实现这个活动:

  1. Lesson2B/Activity01文件夹加载准备好的项目。

  2. 定义辅助模板函数和类来检测 std:string 情况,使用npos成员。

  3. 定义辅助模板函数和类,以检测类是否具有find()方法。

  4. 定义包含模板函数,使用 constexpr 来在三种实现中选择一种 - 字符串情况、具有 find 方法的情况或一般情况。

在实现了上述步骤之后,预期输出应如下所示:

图 2B.35:包含成功实现的输出

图 2B.35:包含成功实现的输出

注意

此活动的解决方案可在第 653 页找到。

总结

在本章中,我们学习了接口、继承和多态,这扩展了我们对类型的操作技能。我们首次尝试了 C模板的泛型编程,并接触了语言从 C标准库(包括 STL)中免费提供给我们的内容。我们探索了 C的一个功能,即模板类型推断,它在使用模板时使我们的生活更加轻松。然后我们进一步学习了如何使用 SFINAE 和 if constexpr 控制编译器包含的模板部分。这些构成了我们进入 C之旅的基石。在下一章中,我们将重新讨论堆栈和堆,并了解异常是什么,发生了什么,以及何时发生。我们还将学习如何在异常发生时保护我们的程序免受资源损失。

第四章:不允许泄漏-异常和资源

学习目标

在本章结束时,您将能够:

  • 开发管理资源的类

  • 开发异常健壮的代码,以防止资源通过 RAII 泄漏

  • 实现可以通过移动语义传递资源所有权的类

  • 实现控制隐式转换的类

在本章中,您将学习如何使用类来管理资源,防止泄漏,并防止复制大量数据。

介绍

第 2A 章中,不允许鸭子-类型和推断,我们简要涉及了一些概念,如智能指针和移动语义。在本章中,我们将进一步探讨它们。事实证明,这些主题与资源管理和编写健壮的代码(经常运行并长时间运行而没有问题的代码)非常密切相关。

为了理解发生了什么,我们将探讨变量在内存中的放置位置,以及当它们超出范围时发生了什么。

我们将查看编译器为我们输入的汇编代码生成了什么,并探讨当异常发生时所有这些都受到了什么影响。

变量范围和生命周期

第 2B 章中,不允许鸭子-模板和推断,我们讨论了变量范围和生命周期。让我们快速浏览它们的不同类型:

范围

  • {})。

  • 全局/文件范围:这适用于在普通函数或类之外声明的变量,也适用于普通函数。

寿命

  • 自动寿命:在这里,局部变量在声明时创建,并在退出其所在范围时销毁。这些由堆栈管理。

  • 永久寿命:在这里,全局和静态局部变量具有永久寿命。

  • newdelete操作符)。这些变量的内存是从堆中分配的。

我们将使用以下程序来澄清局部变量的行为-具有自动寿命和具有动态寿命的变量:

图 3.1:变量范围和生命周期的测试程序

当我们运行上述程序时,将生成以下输出:

图 3.2:生命周期测试程序的输出

图 3.2:生命周期测试程序的输出

在上述输出中的十六进制数字(0xNNNNNNNN)是正在构造或销毁的 Int 对象的地址。我们的程序从第 46 行进入main()函数开始。此时,程序已经进行了大量初始化,以便我们随时可以使用一切。下面的图表指的是两个堆栈-PC 堆栈数据堆栈

这些是帮助我们解释幕后发生的事情的抽象概念。PC 堆栈程序计数器堆栈)用于记住程序计数器的值(指向需要运行的下一条指令的寄存器),而数据堆栈保存我们正在操作的值或地址。尽管这是两个单独的堆栈,在实际 CPU 上,它很可能会被实现为一个堆栈。让我们看看以下表格,其中我们使用缩写OLn来引用上述程序输出的行号:

图 3.3:测试程序执行的详细分析(第 1 部分)

图 3.3:测试程序执行的详细分析(第 1 部分)

以下是测试程序执行详细分析的第二部分:

图 3.4:测试程序执行的详细分析(第 2 部分)

图 3.4:测试程序执行的详细分析(第 2 部分)

以下是测试程序执行详细分析的第三部分:

图 3.5:测试程序执行的详细分析(第 3 部分)

图 3.5:测试程序执行的详细分析(第 3 部分)

从这个简单的程序中,我们学到了一些重要的事实:

  • 当我们按值传递时,会调用复制构造函数(就像在这种情况下所做的那样)。

  • 返回类型只会调用一个构造函数(不是两个构造函数 - 一个用于创建返回对象,一个用于存储返回的数据) - C++将其称为复制省略,现在在标准中是强制性的。

  • 在作用域终止时(闭合大括号'}'),任何超出作用域的变量都会调用其析构函数。如果这是真的,那么为什么地址0x6000004d0没有显示析构函数调用(~Int())?这引出了下一个事实。

  • calculate()方法的析构函数中,我们泄漏了一些内存。

了解和解决资源泄漏问题的最后两个事实是重要的。在我们处理 C++中的异常之后,我们将研究资源管理。

C++中的异常

我们已经看到了 C++如何管理具有自动和动态生命周期的局部作用域变量。当变量超出作用域时,它调用具有自动生命周期的变量的析构函数。我们还看到了原始指针在超出作用域时被销毁。由于它不清理动态生命周期变量,我们会失去它们。这是我们后来构建资源获取即初始化RAII)的故事的一部分。但首先,我们需要了解异常如何改变程序的流程。

异常的必要性

第 2A 章不允许鸭子 - 类型和推断中,我们介绍了枚举作为处理check_file()函数的魔术数字的一种方式:

FileCheckStatus check_file(const char* name)
{
  FILE* fptr{fopen(name,"r")};
  if ( fptr == nullptr)
    return FileCheckStatus::NotFound;
  char buffer[30];
  auto numberRead = fread(buffer, 1, 30, fptr);
  fclose(fptr);
  if (numberRead != 30)
    return FileCheckStatus::IncorrectSize;
  if(is_valid(buffer))
    return FileCheckStatus::InvalidContents;
  return FileCheckStatus::Good;
}

前面的函数使用了一种称为状态错误代码的技术来报告操作的结果。这是 C 风格编程中使用的方法,其中与POSIX APIWindows API相关的错误被处理。

注意

POSIX代表可移植操作系统接口。这是 Unix 变体和其他操作系统之间软件兼容性的 IEEE 标准。

这意味着,方法的调用者必须检查返回值,并针对每种错误类型采取适当的操作。当您可以推断代码将生成的错误类型时,这种方法效果很好。但并非总是如此。例如,可能存在输入到程序的数据存在问题。这会导致程序中的异常状态无法处理。具有处理错误逻辑的代码部分被从检测问题的代码部分中移除。

虽然可能编写处理此类问题的代码,但这会增加处理所有错误条件的复杂性,从而使程序难以阅读,难以推断函数应该执行的操作,并因此难以维护。

对于错误处理,异常比错误代码提供以下优点:

  • 错误代码可以被忽略 - 异常强制处理错误(或程序终止)。

  • 异常可以沿着堆栈流向最佳方法来响应错误。错误代码需要传播到每个中间方法之外。

  • 异常将错误处理与主程序流程分离,使软件易于阅读和维护。

  • 异常将检测错误的代码与处理错误的代码分离。

只要遵循最佳实践并将异常用于异常条件,使用异常不会有(时间)开销。这是因为一个实现良好的编译器将提供 C++的口号 - 你不为你不使用的东西付费。它可能会消耗一些内存,你的代码可能会变得稍微庞大,但运行时间不应受影响。

C++使用异常来处理运行时异常。通过使用异常,我们可以检测错误,抛出异常,并将错误传播回可以处理它的位置。让我们修改前面的程序,引入divide()函数并更改calculate()函数以调用它。我们还将在main()函数中添加日志记录,以便探索异常的行为方式:

图 3.6:用于调查异常的修改测试程序

图 3.6:用于调查异常的修改测试程序

当我们编译并运行前面的程序时,将生成以下输出:

图 3.7:测试程序的输出

图 3.7:测试程序的输出

在前面的代码中,您可以看到注释已添加到右侧。现在,我们从程序中的result2行中删除注释,重新编译程序并重新运行。生成的新输出如下所示:

图 3.8:测试程序的输出 - result2

图 3.8:测试程序的输出 - result2

通过比较输出,我们可以看到每个输出的前八行是相同的。前面输出的接下来两行是因为divide()函数被调用了两次。最后一行指示抛出了异常并且程序被终止。

第二次调用divide()函数尝试除以零 - 这是一种异常操作。这导致异常。如果整数被零除,那么会导致浮点异常。这与在POSIX系统中生成异常的方式有关 - 它使用了称为信号的东西(我们不会在这里详细介绍信号的细节)。当整数被零除时,POSIX系统将其映射到称为浮点错误的信号,但现在是更通用的算术错误

注意

根据 C++标准,如果零出现为除数,无论是'/'运算符(除法)还是'%'运算符(取模),行为都是未定义的。大多数系统会选择抛出异常。

因此,我们从前面的解释中学到了一个重要的事实:未处理的异常将终止程序(在内部调用std::terminate())。我们将修复未定义行为,捕获异常,并查看输出中的变化。为了修复未定义行为,我们需要在文件顶部添加#include <stdexcept>并修改divide()函数:

Int divide(Int a, Int b )
{
    if (b.m_value == 0)
        throw std::domain_error("divide by zero error!");
    return a.m_value/b.m_value;
}

当我们重新编译并运行程序时,我们得到以下输出:

图 3.9:当我们抛出异常时的输出

图 3.9:当我们抛出异常时的输出

从前面的输出中可以看到,没有太多变化。只是我们不再得到浮点异常(核心转储)- 程序仍然终止但不会转储核心。然后我们在main()函数中添加了一个try/catch块,以确保异常不再是未处理的。

图 3.10:捕获异常

图 3.10:捕获异常

重新编译程序并运行以获得以下输出:

图 3.11:捕获异常的程序输出

图 3.11:捕获异常的程序输出

在前面的输出中,异常在第二行抛出,注释为“复制 a 以调用 divide”。之后的所有输出都是异常处理的结果。

我们的代码已将程序控制转移到main()函数中的catch()语句,并执行了在try子句中进行调用时在堆栈上构造的所有变量的析构函数。

堆栈展开

C语言所保证的销毁所有本地函数变量的过程被称为堆栈展开。在异常出现时,堆栈展开时,C使用其明确定义的规则来销毁作用域中的所有对象。

当异常发生时,函数调用堆栈从当前函数开始线性搜索,直到找到与异常匹配的异常处理程序(由catch块表示)。

如果找到异常处理程序,则进行堆栈展开,销毁堆栈中所有函数的本地变量。对象按创建顺序的相反顺序销毁。如果找不到处理抛出异常的处理程序,则程序将终止(通常不会警告用户)。

练习 1:在 Fraction 和 Stack 中实现异常

在这个练习中,我们将回到第 2A 章第 2B 章中我们所做的两个类,不允许鸭子 - 类型和推断不允许鸭子 - 模板和推断 - FractionStack,它们都可能出现运行时异常。我们将更新它们的代码,以便在检测到任何问题时都能引发异常。按照以下步骤执行此练习:

  1. 打开 Eclipse,并使用Lesson3示例文件夹中的文件创建一个名为Lesson3的新项目。

  2. 由于这是一个基于 CMake 的项目,因此将当前构建器更改为CMake Build (portable)

  3. 转到项目 | 构建所有菜单以构建所有练习。默认情况下,屏幕底部的控制台将显示CMake Console [Lesson3]

  4. 配置一个新的启动配置L3Exercise1,以运行名称为Exercise1的项目。

  5. 还要配置一个新的 C/C++单元运行配置,L3Ex1Tests,以运行L3Ex1tests。设置Google Tests Runner

  6. 点击运行选项,对现有的18个测试进行运行和通过。图 3.12:现有测试全部通过(运行次数:18)

图 3.12:现有测试全部通过(运行次数:18)
  1. 在编辑器中打开Fraction.hpp,并更改文件顶部的行,使其读起来像这样:
#define EXERCISE1_STEP  14
  1. 点击Fraction,其中分母为零。测试期望抛出异常:图 3.13:新的失败测试 ThrowsDomainErrorForZeroDenominator
图 3.13:新的失败测试 ThrowsDomainErrorForZeroDenominator
  1. 点击失败的测试名称 - “预期…抛出 std::domain_error 类型的异常”,下一行显示“实际:它没有抛出任何异常”。

  2. 双击消息,它将带您到以下测试:图 3.14:失败的测试

图 3.14:失败的测试

ASSERT_THROW()宏需要两个参数。由于Fraction 初始化器中有一个逗号,因此需要在第一个参数的外面再加一组括号。第二个参数预期从这个构造函数中获得一个std::domain_error。内部的try/catch结构用于确认预期的字符串是否被捕获在异常对象中。如果我们不想检查这一点,那么我们可以简单地这样编写测试:

ASSERT_THROW(({Fraction f1{1,0}; }), std::domain_error);
  1. 在编辑器中打开文件Fraction.cpp。在文件顶部附近插入以下行:
#include <stdexcept> 
  1. 修改构造函数,如果使用零分母创建,则抛出异常:
Fraction::Fraction(int numerator, int denominator) 
                       : m_numerator{numerator}, m_denominator{denominator}
{
    if(m_denominator == 0) 
    {
        throw std::domain_error("Zero Denominator");
    }
}
  1. 点击运行按钮重新运行测试。现在有19个测试通过。

  2. 在编辑器中打开Fraction.hpp,并更改文件顶部附近的行,使其读起来像这样:

#define EXERCISE1_STEP  20
  1. 点击ThrowsRunTimeErrorForZeroDenominator失败。

  2. 点击失败的测试名称 - “预期…抛出 std::runtime_error 类型的异常”,下一行显示“实际:它抛出了不同类型的异常”。

  3. 再次双击消息以打开失败的测试:图 3.15:另一个失败的测试

图 3.15:另一个失败的测试

此测试验证除法赋值运算符对零进行除法时会抛出异常。

  1. 打开operator/=()函数。您会看到,在这个函数内部,它实际上使用了std::domain_error的构造函数。

  2. 现在修改operator/=()以在调用构造函数之前检测此问题,以便抛出带有预期消息的std::runtime_error

  3. 通过添加一个将检测除法运算符的域错误来修改Fraction.cpp

Fraction& Fraction::operator/=(const Fraction& rhs)
{
    if (rhs.m_numerator == 0)
    {
        throw std::runtime_error("Fraction Divide By Zero");
    }
    Fraction tmp(m_numerator*rhs.m_denominator, 
m_denominator*rhs.m_numerator);
    *this = tmp;
    return *this;
}
  1. 点击Run按钮重新运行测试。所有20个测试通过。

  2. 在编辑器中打开Stack.hpp并更改文件顶部附近的行,使其读起来像这样:

#define EXERCISE1_STEP  27
  1. 点击FractionTest以折叠测试列表并显示StackTest图 3.16:pop Stack 测试失败
图 3.16:pop Stack 测试失败
  1. 在文件顶部使用#include <stdexcept>,然后更新pop()函数,使其如下所示:
void pop()
{
    if(empty())
        throw std::underflow_error("Pop from empty stack");
    m_stack.pop_back();
} 
  1. 点击Run按钮重新运行测试。现在21个测试通过了。

  2. 在编辑器中打开Stack.hpp并更改文件顶部的行,使其读起来像这样:

#define EXERCISE1_STEP  31
  1. 点击TopEmptyStackThrowsUnderFlowException,失败。

  2. 使用top()方法,使其如下所示:

reference top()
{
    if(empty())
        throw std::underflow_error("Top from empty stack");
    return m_stack.back();
}
  1. 点击Run按钮重新运行测试。22个测试通过。

  2. 在编辑器中打开Stack.hpp并更改文件顶部的行,使其读起来像这样:

#define EXERCISE1_STEP  35
  1. 点击TopEmptyConstStackThrowsUnderFlowException,失败。

  2. 使用top()方法,使其如下所示:

const_reference top() const
{
    if(empty())
        throw std::underflow_error("Top from empty stack");
    return m_stack.back();
}
  1. 点击Run按钮重新运行测试。现在所有23个测试都通过了。

在这个练习中,我们为使用我们的FractionStack类的正常操作的前提条件添加了运行时检查。当违反前提条件之一时,此代码将仅执行以抛出异常,表明数据或程序执行方式存在问题。

当抛出异常时会发生什么?

在某个时刻,我们的程序执行以下语句:

throw expression;

通过执行此操作,我们正在发出发生错误的条件,并且我们希望它得到处理。接下来发生的事情是一个临时对象,称为异常对象,在未指定的存储中构造,并从表达式进行复制初始化(可能调用移动构造函数,并可能受到复制省略的影响)。异常对象的类型从表达式中静态确定,去除 const 和 volatile 限定符。数组类型会衰减为指针,而函数类型会转换为函数的指针。如果表达式的类型格式不正确或抽象,则会发生编译器错误。

在异常对象构造之后,控制权连同异常对象一起转移到异常处理程序。被选择的异常处理程序是与异常对象最匹配的类型,因为堆栈展开。异常对象存在直到最后一个 catch 子句退出,除非它被重新抛出。表达式的类型必须具有可访问的复制构造函数析构函数

按值抛出还是按指针抛出

知道临时异常对象被创建,传递,然后销毁,抛出表达式应该使用什么类型?一个还是一个指针

我们还没有详细讨论在 catch 语句中指定类型。我们很快会做到。但是现在,请注意,要捕获指针类型(被抛出的),catch 模式也需要是指针类型。

如果抛出对象的指针,那么抛出方必须确保异常对象将指向的内容(因为它将是指针的副本)在异常处理之前保持活动,即使通过堆栈展开也是如此。

指针可以指向静态变量、全局变量或从堆中分配的内存,以确保在处理异常时被指向的对象仍然存在。现在,我们已经解决了保持异常对象存活的问题。但是当处理程序完成后,捕获者该怎么办?

异常的捕获者不知道异常对象的创建(全局静态),因此不知道是否应该删除接收到的指针。因此,通过指针抛出异常不是推荐的异常抛出方法。

被抛出的对象将被复制到创建的临时异常对象中,并交给处理程序。当异常被处理后,临时对象将被简单地销毁,程序将继续执行。对于如何处理它没有歧义。因此,最佳实践是通过值抛出异常。

标准库异常

C++标准库将std::exception定义为所有标准库异常的基类。标准定义了以下第一级层次的异常/错误(括号中的数字表示从该类派生的异常数量):

图 3.17:标准库异常层次结构(两级)

图 3.17:标准库异常层次结构(两级)

这些异常在 C++标准库中被使用,包括 STL。创建自己的异常类的最佳实践是从标准异常中派生它。接下来我们会看到,你的特殊异常可以被标准异常的处理程序捕获。

捕获异常

在讨论异常的需要时,我们介绍了抛出异常的概念,但并没有真正看看 C++如何支持捕获异常。异常处理的过程始于将代码段放在try块中以进行异常检查。try 块后面是一个或多个 catch 块,它们是异常处理程序。当在 try 块内执行代码时发生异常情况时,异常被抛出,控制转移到异常处理程序。如果没有抛出异常,那么所有异常处理程序都将被跳过,try 块中的代码完成,正常执行继续。让我们在代码片段中表达这些概念:

void SomeFunction()
{
  try {
    // code under exception inspection
  }
  catch(myexception e)         // first handler – catch by value
  {
    // some error handling steps
  }
  catch(std::exception* e)     // second handler – catch by pointer
  {
    // some other error handling steps
  }
  catch(std::runtime_error& e) // third handler – catch by reference
  {
    // some other error handling steps
  }
  catch(...)                   // default exception handler – catch any exception
  {
    // some other error handling steps
  }
  // Normal programming continues from here
}

前面的片段展示了必要的关键字 - trycatch,并介绍了三种不同类型的捕获模式(不包括默认处理程序):

  • 通过值捕获异常:这是一种昂贵的机制,因为异常处理程序像任何其他函数一样被处理。通过值捕获意味着必须创建异常对象的副本,然后传递给处理程序。第二个副本的创建减慢了异常处理过程。这种类型也可能受到对象切片的影响,其中子类被抛出,而 catch 子句是超类。然后 catch 子句只会接收到失去原始异常对象属性的超类对象的副本。因此,我们应避免使用通过值捕获异常处理程序。

  • 通过指针捕获异常:如在讨论通过值抛出时所述,通过指针抛出,这种异常处理程序只能捕获指针抛出的异常。由于我们只想通过值抛出,应避免使用通过指针捕获异常处理程序。

  • 通过值抛出通过引用捕获

当存在多个 catch 块时,异常对象类型用于匹配按指定顺序的处理程序。一旦找到匹配的处理程序,它就会被执行,并且剩余的异常处理程序将被忽略。这与函数解析不同,编译器将找到最佳匹配的参数。因此,异常处理程序(catch 块)应该从更具体到更一般的定义。例如,默认处理程序(catch(...))应该始终在定义中的最后一个。

练习 2:实现异常处理程序

在这个练习中,我们将实现一系列异常处理程序的层次结构,以管理异常的处理方式。按照以下步骤实现这个练习:

  1. 打开e。该变量的作用域仅限于它声明的 catch 块。

  2. 单击启动配置下拉菜单,然后选择新启动配置…。从搜索项目菜单配置L3Exercise2应用程序以使用名称L3Exercise2运行它。

  3. 完成后,它将是当前选择的启动配置

  4. 点击运行按钮。练习 2 将运行并产生以下输出:图 3.18:练习 2 输出-默认处理程序捕获了异常

图 3.18:练习 2 输出-默认处理程序捕获了异常
  1. 在控制台窗口中,单击CMake文件设置-fpermissive标志,当它编译此目标时。)

  2. 在编辑器中,将默认异常处理程序catch(...)移动到std::domain_error处理程序后面。点击运行按钮。练习 2 将运行并产生以下输出:图 3.19:已使用 std::exception 处理程序

图 3.19:已使用 std::exception 处理程序
  1. 在编辑器中,将std::exception处理程序移动到std::domain_error处理程序后面。点击std::logic_error处理程序按预期执行。

  2. 在编辑器中,将std:: logic_error处理程序移动到std::domain_error处理程序后面。点击std:: domain_error处理程序被执行,这实际上是我们所期望的。

  3. 现在将throw行更改为std::logic_error异常。点击std::logic_error处理程序按预期执行。

  4. 现在将throw行更改为std::underflow_error异常。点击std::exception处理程序按预期执行。std::exception是所有标准库异常的基类。

在这个练习中,我们实现了一系列异常处理程序,并观察了异常处理程序的顺序如何影响异常的捕获以及异常层次结构如何被使用。

CMake 生成器表达式

在使用CMake时,有时需要调整变量的值。CMake是一个构建生成系统,可以为许多构建工具和编译器工具链生成构建文件。由于这种灵活性,如果要在编译器中启用某些功能,只需将其应用于特定类型。这是因为不同供应商之间的命令行选项是不同的。例如,g编译器启用 C17 支持的命令行选项是-std=c++17,但对于msvc来说是/std:c++17。如果打开add_excutable,那么以下行将在其后:

target_compile_options(L3Exercise2 PRIVATE $<$<CXX_COMPILER_ID:GNU>:-fpermissive>)

这使用$<CXX_COMPILER_ID:GNU>变量查询来检查它是否是 GCC 编译器。如果是,则生成 1(true),否则生成 0(false)。它还使用$<condition:true_string>条件表达式将-fpermissive添加到target_compile_options的编译器选项或通过一个调用。

注意

有关生成器表达式的更多信息,请查看以下链接:cmake.org/cmake/help/v3.15/manual/cmake-generator-expressions.7.html

异常使用指南

在 C++代码中使用异常时,请记住以下几点:

  • 口号:按值抛出,按引用捕获

  • 不要将异常用于正常程序流。如果函数遇到异常情况并且无法满足其(功能性)义务,那么只有在这种情况下才抛出异常。如果函数可以解决异常情况并履行其义务,那么这不是异常。它们之所以被称为异常,是有原因的,如果不使用它们,就不会产生任何处理开销。

  • 不要在析构函数中抛出异常。请记住,由于堆栈展开,局部变量的析构函数将被执行。如果在堆栈展开过程中调用了析构函数并抛出了异常,那么程序将终止。

  • 不要吞没异常。不要使用默认的 catch 处理程序,也不要对异常做任何处理。异常被抛出是为了指示存在问题,你应该对此做些什么。忽视异常可能会导致以后难以排查的故障。这是因为任何有用的信息都真正丢失了。

  • 异常对象是从抛出中复制的

资源管理(在异常世界中)

到目前为止,我们已经看过局部变量作用域,以及当变量超出作用域时如何处理自动动态生命周期变量 - 自动生命周期变量(放在堆栈上的变量)将被完全析构,而动态生命周期变量(由程序员分配到堆上的变量)不会被析构:我们只是失去了对它们的任何访问。我们也看到,当抛出异常时,会找到最近匹配的处理程序,并且在堆栈展开过程中将析构抛出点和处理程序之间的所有局部变量。

我们可以利用这些知识编写健壮的资源管理类,这些类将使我们不必跟踪资源(动态生命周期变量、文件句柄、系统句柄等),以确保在使用完它们后将它们释放(释放到野外)。在正常操作和异常情况下管理资源的技术被称为资源获取即初始化RAII)。

资源获取即初始化

RAII 是另一个命名不好的概念的好例子(另一个是SFINAE)。RAIIResource Acquisition is Initialization描述了一个用于管理资源的类的行为。如果它被命名为File类并展示了 RAII 如何提高可读性和我们对函数操作的理解能力,可能会更好。

考虑以下代码:

void do_something()
{
    FILE* out{};
    FILE* in = fopen("input.txt", "r");
    try 
    {
        if (in != nullptr)
        {
            // UNSAFE – an exception here will create a resource leak
            out = fopen("output.txt", "w");
            if (out != nullptr)
            {
                // Do some work
                // UNSAFE – an exception here will create resource leaks
                fclose(out);
            }
            fclose(in);
        }
    }
    catch(std::exception& e)
    {
        // Respond to the exception
    }
}

这段代码展示了资源管理的两个潜在问题:

  • 最重要的是,在打开和关闭文件之间发生异常会导致资源泄漏。如果这是系统资源,许多这样的情况可能导致系统不稳定或应用程序性能受到不利影响,因为它会因资源匮乏而受到影响。

  • 此外,在一个方法中管理多个资源可能会导致由于错误处理而产生深度嵌套的子句。这对代码的可读性有害,因此也影响了代码的理解和可维护性。很容易忘记释放资源,特别是当有多个退出点时。

那么,我们如何管理资源,以便有异常安全和更简单的代码?这个问题不仅仅是 C独有的,不同的语言以不同的方式处理它。JavaC#Python使用垃圾回收方法,在对象创建后清理它们,当它们不再被引用时。但是 C没有垃圾回收,那么解决方案是什么呢?

考虑以下类:

class File {
public:
    File(const char* name, const char* access) {
        m_file = fopen(name, access);
        if (m_file == nullptr) {
            throw std::ios_base::failure("failed to open file");
        }
    }
    ~File() {
        fclose(m_file);
    }
    operator FILE*() {
        return m_file;
    }
private:
    FILE* m_file{};
};

这个类实现了以下特征:

  • 构造函数获取资源。

  • 如果资源没有在构造函数中获取,那么会抛出异常。

  • 当类被销毁时,资源被释放。

如果我们在do_something()方法中使用这个类,那么它看起来像这样:

void do_something()
{
    try 
    {
        File in("input.txt", "r");
        File out("output.txt", "w");
        // Do some work
    }
    catch(std::exception& e)
    {
        // Respond to the exception
    }
}

如果在执行此操作时发生异常,那么 C++保证将调用所有基于堆栈的对象的析构函数(堆栈展开),从而确保文件被关闭。这解决了在发生异常时资源泄漏的问题,因为现在资源会自动清理。此外,这种方法非常容易阅读,因此我们可以理解逻辑流程,而不必担心错误处理。

这种技术利用File对象的生命周期来获取和释放资源,确保资源不会泄漏。资源在管理类的构造(初始化)期间获取,并在管理类的销毁期间释放。正是这种作用域绑定资源的行为导致了Resource Acquisition Is Initialization的名称。

前面的例子涉及管理系统资源的文件句柄。它适用于任何在使用前需要获取,然后在完成后放弃的资源。RAII 技术可以应用于各种资源 - 打开文件,打开管道,分配的堆内存,打开套接字,执行线程,数据库连接,互斥锁/临界区的锁定 - 基本上是主机系统中供应不足的任何资源,并且需要进行管理。

练习 3:为内存和文件句柄实现 RAII

在这个练习中,我们将实现两个不同的类,使用 RAII 技术来管理内存或文件。按照以下步骤来实现这个练习:

  1. 在 Eclipse 中打开Lesson3项目。然后在Project Explorer中展开Lesson3,然后展开Exercise03,双击Exercise3.cpp以打开此练习的文件到编辑器中。

  2. 点击Launch Configuration下拉菜单,选择New Launch Configuration…。从搜索项目菜单中配置L3Exercise3应用程序以使用名称L3Exercise3运行它。

  3. monitor被析构时,点击main()函数,它会转储分配和释放的内存报告,以及打开但从未关闭的文件。

  4. 在编辑器中,输入以下内容到File类中:

class File {
public:
    File(const char* name, const char* access) {
        m_file = fopen(name, access);
        if (m_file == nullptr) {
            throw std::ios_base::failure(""failed to open file"");
        }
    }
    ~File() {
        fclose(m_file);
    }
    operator FILE*() {
        return m_file;
    }
private:
    FILE* m_file{};
};
  1. 点击Run按钮运行 Exercise 3 - 它仍然泄漏文件和内存,但代码是正确的。

  2. 找到LeakFiles()函数,并修改它以使用新的File类(就像前面的代码一样)以防止文件泄漏:

void LeakFiles()
{
    File fh1{"HelloB1.txt", "w"};
    fprintf(fh1, "Hello B2\n");
    File fh2{"HelloB2.txt", "w"};
    fprintf(fh2, "Hello B1\n");
}
  1. 正确点击LeakFiles(),然后输出将如下所示:图 3.21:没有文件泄漏
图 3.21:没有文件泄漏
  1. 现在在CharPointer类中:
class CharPointer
{
public:
    void allocate(size_t size)
    {
        m_memory = new char[size];
    }
    operator char*() { return m_memory;}
private:
    char* m_memory{};
};
  1. 修改LeakPointers()如下所示:
void LeakPointers()
{
    CharPointer memory[5];
    for (auto i{0} ; i<5 ; i++)
    {
        memory[i].allocate(20); 
        std::cout << "allocated 20 bytes @ " << (void *)memory[i] << "\n";
    }
}
  1. 点击Run按钮运行 Exercise 3 - 它仍然有内存泄漏,但代码是正确的。

  2. 现在,向CharPointer添加以下析构函数。请注意,delete操作符使用数组[]语法:

~CharPointer()
{
    delete [] m_memory;
}
  1. 再次点击Run按钮运行 Exercise 3 - 这次,您应该看到监视器报告没有泄漏:

图 3.22:没有泄漏 - 内存或文件

图 3.22:没有泄漏 - 内存或文件

FileCharPointer的实现符合RAII设计方法,但在设计这些方法时还有其他考虑因素。例如,我们是否需要复制构造函数或复制赋值函数?在这两种情况下,仅仅从一个对象复制资源到另一个对象可能会导致关闭文件句柄或删除内存的两次尝试。通常,这会导致未定义的行为。接下来,我们将重新审视特殊成员函数,以实现FileCharPointer等资源管理对象。

特殊编码技术

练习 3的代码,为内存和文件句柄实现 RAII,已经特别编写,以便我们可以监视内存和文件句柄的使用,并在退出时报告任何泄漏。访问monitor.hmonitor.cpp文件,并检查用于使监视器可能的两种技术:

  • 如果包括SendMessageASendMessageW,则SendMessage

  • 定义我们自己的新处理程序:这是一种高级技术,除非你编写嵌入式代码,否则你不太可能需要它。

C++不需要最终

其他支持异常抛出机制的语言(C#JavaVisual Basic.NET)具有try/catch/finally范式,其中finally块中的代码在退出 try 块时被调用 - 无论是正常退出还是异常退出。C++没有finally块,因为它有更好的机制,可以确保我们不会忘记释放资源 - RAII。由于资源由本地对象表示,本地对象的析构函数将释放资源。

这种设计模式的附加优势是,如果正在管理大量资源,则finally块的大小也相应较大。RAII 消除了对 finally 的需求,并导致更易于维护的代码。

RAII 和 STL

标准模板库(STL)在许多模板和类中使用 RAII。例如,C++11 中引入的智能指针,即std::unique_ptrstd::shared_ptr,通过确保在使用完毕后释放内存,或者确保在其他地方使用时不释放内存,帮助避免了许多问题。STL 中的其他示例包括std::string(内存)、std::vector(内存)和std::fstream(文件句柄)。

谁拥有这个对象?

通过前面对FileCharPointer的实现,我们已经测试了使用 RAII 进行资源管理。让我们进一步探讨。首先,我们将定义一个不仅拥有一个资源的类:

class BufferedWriter
{
public:
    BufferedWriter(const char* filename);
    ~BufferedWriter();
    bool write(const char* data, size_t length);
private:
    const size_t BufferSize{4096};
    FILE* m_file{nullptr};
    size_t m_writePos{0};
    char* m_buffer{new char[BufferSize]};
};

该类用于缓冲写入文件。

注意

当使用 iostream 派生类时,这通常是不必要的,因为它们已经提供了缓冲。

每次调用write()函数都会将数据添加到分配的缓冲区,直到达到BufferSize,此时数据实际写入文件,并且缓冲区被重置。

但是如果我们想要将BufferedWriter的这个实例分配给另一个实例或复制它呢?什么是正确的行为?

如果我们只是让默认的复制构造函数/复制赋值做它们的事情,我们会得到项目的成员复制。这意味着我们有两个BufferedWriter的实例,它们持有相同的文件句柄和缓冲区指针。当对象的第一个实例被销毁时,作为优秀的程序员,我们将通过关闭文件和删除内存来清理文件。第二个实例现在有一个失效的文件句柄和一个指向我们已告诉操作系统为下一个用户恢复的内存的指针。任何尝试使用这些资源,包括销毁它们,都将导致未定义的行为,很可能是程序崩溃。默认的复制构造函数/复制赋值运算符执行所谓的浅复制 - 也就是说,它按位复制所有成员(但不是它们所指的内容)。

我们拥有的两个资源可以被不同对待。首先,应该只有一个类拥有m_buffer。在处理这个问题时有两个选择:

  • 防止类的复制,因此也防止内存。

  • 执行深复制,其中第二个实例中的缓冲区是由构造函数分配的,并且复制了第一个缓冲区的内容

其次,应该只有一个类拥有文件句柄(m_file)。在处理这个问题时有两个选择:

  • 防止类的复制,因此也防止文件句柄的复制

  • 所有权从原始实例转移到第二个实例,并将原始实例标记为无效或空(无论这意味着什么)

实现深拷贝很容易,但如何转移资源的所有权呢?为了回答这个问题,我们需要再次看看临时对象和值类别。

临时对象

在将结果存储到变量(或者只是忘记)之前,创建临时对象来存储表达式的中间结果。表达式是任何返回值的代码,包括按值传递给函数,从函数返回值,隐式转换,文字和二进制运算符。临时对象是rvalue 表达式,它们有内存,为它们分配了临时位置,以放置表达式结果。正是这种创建临时对象和在它们之间复制数据导致了 C11 之前的一些性能问题。为了解决这个问题,C11 引入了rvalue 引用,以实现所谓的移动语义。

移动语义

一个rvalue 引用(用双&&表示)是一个只分配给rvalue的引用,它将延长rvalue的生命周期,直到rvalue 引用完成为止。因此,rvalues可以在定义它的表达式之外存在。有了rvalue 引用,我们现在可以通过移动构造函数和移动赋值运算符来实现移动语义。移动语义的目的是从被引用对象中窃取资源,从而避免昂贵的复制操作。当移动完成时,被引用对象必须保持在稳定状态。换句话说,被移动的对象必须保持在一个状态,不会在销毁时引起任何未定义的行为或程序崩溃,也不应该影响从中窃取的资源。

C++11 还引入了一个转换运算符std::move(),它将一个lvalue转换为一个rvalue,以便调用移动构造函数或移动赋值运算符来'移动'资源。std::move()方法实际上并不移动数据。

一个意外的事情要注意的是,在移动构造函数和移动赋值运算符中,rvalue引用实际上是一个lvalue。这意味着如果你想确保在方法内发生移动语义,那么你可能需要再次在成员变量上使用std::move()

随着 C11 引入了移动语义,它还更新了标准库以利用这种新的能力。例如,std::stringstd::vector已经更新以包括移动语义。要获得移动语义的好处,你只需要用最新的 C编译器重新编译你的代码。

实现智能指针

智能指针是一个资源管理类,它在资源超出范围时持有指向资源的指针并释放它。在本节中,我们将实现一个智能指针,观察它作为一个支持复制的类的行为,使其支持移动语义,最后移除其对复制操作的支持:

#include <iostream>
template<class T>
class smart_ptr
{
public:
  smart_ptr(T* ptr = nullptr) :m_ptr(ptr)
  {
  }
  ~smart_ptr()
  {
    delete m_ptr;
  }
  // Copy constructor --> Do deep copy
  smart_ptr(const smart_ptr& a)
  {
    m_ptr = new T;
    *m_ptr = *a.m_ptr;      // use operator=() to do deep copy
  }
  // Copy assignment --> Do deep copy 
  smart_ptr& operator=(const smart_ptr& a)
  {
    // Self-assignment detection
    if (&a == this)
      return *this;
    // Release any resource we're holding
    delete m_ptr;
    // Copy the resource
    m_ptr = new T;
    *m_ptr = *a.m_ptr;
    return *this;
  }
  T& operator*() const { return *m_ptr; }
  T* operator->() const { return m_ptr; }
  bool is_null() const { return m_ptr == nullptr; }
private:
  T* m_ptr{nullptr};
};
class Resource
{
public:
  Resource() { std::cout << "Resource acquired\n"; }
  ~Resource() { std::cout << "Resource released\n"; }
};
smart_ptr<Resource> createResource()
{
    smart_ptr<Resource> res(new Resource);                       // Step 1
    return res; // return value invokes the copy constructor     // Step 2
}
int main()
{
  smart_ptr<Resource> the_res;
  the_res = createResource(); // assignment invokes the copy assignment Step 3/4

  return 0; // Step 5
}

当我们运行这个程序时,会生成以下输出:

图 3.23:智能指针程序输出

图 3.23:智能指针程序输出

对于这样一个简单的程序,获取和释放资源的操作很多。让我们来分析一下:

  1. createResource()内部的局部变量 res 是在堆上创建并初始化的(动态生命周期),导致第一个“获取资源”消息。

  2. 编译器可能创建另一个临时对象来返回值。然而,编译器已经执行了复制省略来删除复制(也就是说,它能够直接在调用函数分配的堆栈位置上构建对象)。编译器有返回值优化RVO)和命名返回值优化NRVO)优化,它可以应用,并且在 C++17 中,在某些情况下这些优化已经成为强制性的。

  3. 临时对象通过复制赋值分配给main()函数中的the_res变量。由于复制赋值正在进行深拷贝,因此会获取资源的另一个副本。

  4. 当赋值完成时,临时对象超出范围,我们得到第一个"资源释放"消息。

  5. main()函数返回时,the_res超出范围,释放第二个 Resource。

因此,如果资源很大,我们在main()中创建the_res局部变量的方法非常低效,因为我们正在创建和复制大块内存,这是由于复制赋值中的深拷贝。然而,我们知道当createResource()创建的临时变量不再需要时,我们将丢弃它并释放其资源。在这些情况下,将资源从临时变量转移(或移动)到类型的另一个实例中将更有效。移动语义使我们能够重写我们的smart_ptr模板,而不是进行深拷贝,而是转移资源。

让我们为我们的smart_ptr类添加移动语义:

// Move constructor --> transfer resource
smart_ptr(smart_ptr&& a) : m_ptr(a.m_ptr)
{
  a.m_ptr = nullptr;    // Put into safe state
}
// Move assignment --> transfer resource
smart_ptr& operator=(smart_ptr&& a)
{
  // Self-assignment detection
  if (&a == this)
    return *this;
  // Release any resource we're holding
  delete m_ptr;
  // Transfer the resource
  m_ptr = a.m_ptr;
  a.m_ptr = nullptr;    // Put into safe state
  return *this;
}

重新运行程序后,我们得到以下输出:

图 3.24:使用移动语义的智能指针程序输出

图 3.24:使用移动语义的智能指针程序输出

现在,因为移动赋值现在可用,编译器在这一行上使用它:

the_res = createResource(); // assignment invokes the copy assignment Step 3/4

第 3 步现在已经被移动赋值所取代,这意味着深拷贝现在已经被移除。

第 4 步不再释放资源,因为带有注释“//”的行将其置于安全状态——它不再具有要释放的资源,因为其所有权已转移。

另一个需要注意的地方是移动构造函数移动赋值的参数在它们的拷贝版本中是 const 的,而在它们的移动版本中是非 const的。这被称为所有权的转移,这意味着我们需要修改传入的参数。

移动构造函数的另一种实现可能如下所示:

// Move constructor --> transfer resource
smart_ptr(smart_ptr&& a) 
{
  std::swap(this->m_ptr, a.m_ptr);
}

基本上,我们正在交换资源,C++ STL 支持许多特化的模板交换。这是因为我们使用成员初始化将m_ptr设置为nullptr。因此,我们正在交换nullptr和存储在a中的值。

现在我们已经解决了不必要的深拷贝问题,我们实际上可以从smart_ptr()中删除复制操作,因为实际上我们想要的是所有权的转移。如果我们将非临时smart_ptr的实例复制到另一个非临时smart_ptr实例中,那么当它们超出范围时会删除资源,这不是期望的行为。为了删除(深)复制操作,我们改变了成员函数的定义,如下所示:

smart_ptr(const smart_ptr& a) = delete;
smart_ptr& operator=(const smart_ptr& a) = delete;

我们在第 2A 章中看到的= delete的后缀告诉编译器,尝试访问具有该原型的函数现在不是有效的代码,并导致错误。

STL 智能指针

与其编写自己的smart_ptr,不如使用 STL 提供的类来实现我们对象的 RAII。最初的是std::auto_ptr(),它在 C++ 11 中被弃用,并在 C++ 17 中被移除。它是在rvalue引用支持之前创建的,并且因为它使用复制来实现移动语义而导致问题。C++ 11 引入了三个新模板来管理资源的生命周期和所有权:

  • 通过指针管理单个对象,并在unique_ptr超出范围时销毁该对象。它有两个版本:用new创建的单个对象和用new[]创建的对象数组。unique_ptr与直接使用底层指针一样高效。

  • std::shared_ptr:通过指针保留对象的共享所有权。它通过引用计数管理资源。每个分配给 shared_ptr 的 shared_ptr 的副本都会更新引用计数。当引用计数变为零时,这意味着没有剩余所有者,资源被释放/销毁。

  • shared_ptr,但不修改计数器。可以检查资源是否仍然存在,但不会阻止资源被销毁。如果确定资源仍然存在,那么可以用它来获得资源的shared_ptr。一个使用场景是多个shared_ptrs最终形成循环引用的情况。循环引用会阻止资源的自动释放。weak_ptr用于打破循环并允许资源在应该被释放时被释放。

std::unique_ptr

std::unique_ptr()在 C++ 11 中引入,以取代std::auto_ptr(),并为我们提供了smart_ptr所做的一切(以及更多)。我们可以将我们的smart_ptr程序重写如下:

#include <iostream>
#include <memory>
class Resource
{
public:
  Resource() { std::cout << "Resource acquired\n"; }
  ~Resource() { std::cout << "Resource released\n"; }
};
std::unique_ptr<Resource> createResource()
{
  std::unique_ptr<Resource> res(new Resource);
  return res; 
}
int main()
{
  std::unique_ptr<Resource> the_res;
  the_res = createResource(); // assignment invokes the copy assignment
  return 0;
}

我们可以进一步进行,因为 C++ 14 引入了一个辅助方法,以确保在处理unique_ptrs时具有异常安全性:

std::unique_ptr<Resource> createResource()
{
  return std::make_unique<Resource>(); 
}

*为什么这是必要的?*考虑以下函数调用:

some_function(std::unique_ptr<T>(new T), std::unique_ptr<U>(new U));

问题在于编译器可以自由地以任何顺序对参数列表中的操作进行排序。它可以调用new T,然后new U,然后std::unique_ptr<T>(),最后std::unique_ptr<U>()。这个顺序的问题在于,如果new U抛出异常,那么由调用new T分配的资源就没有被放入unique_ptr中,并且不会自动清理。使用std::make_unique<>()可以保证调用的顺序,以便资源的构建和unique_ptr的构建将一起发生,不会泄漏资源。在 C++17 中,对这些情况下的评估顺序的规则已经得到了加强,因此不再需要make_unique。然而,使用make_unique<T>()方法仍然可能是一个好主意,因为将来转换为 shared_ptr 会更容易。

名称unique_ptr清楚地表明了模板的意图,即它是指向对象的唯一所有者。这在auto_ptr中并不明显。同样,shared_ptr清楚地表明了它的意图是共享资源。unique_ptr模板提供了对以下操作符的访问:

  • *T get()**:返回指向托管资源的指针。

  • 如果实例管理资源,则为trueget() != nullptr)。

  • 对托管资源的lvalue引用。与*get()相同。

  • get()

  • unique_ptr(new []),它提供对托管数组的访问,就像它本来是一个数组一样。返回一个lvalue引用,以便可以设置和获取值。

std::shared_ptr

当您想要共享资源的所有权时,可以使用共享指针。为什么要这样做?有几种情况适合共享资源,比如在 GUI 程序中,您可能希望共享字体对象、位图对象等。GoF 飞行权重设计模式就是另一个例子。

std::shared_ptr提供了与std::unique_ptr相同的所有功能,但因为现在必须为对象跟踪引用计数,所以有更多的开销。所有在std::unique_ptr中描述的操作符都可以用在std::shared_ptr上。一个区别是创建std::shared_ptr的推荐方法是调用std::make_shared<>()

在编写库或工厂时,库的作者并不总是知道用户将如何使用已创建的对象,因此建议从工厂方法返回unique_ptr<T>。原因是用户可以通过赋值轻松地将std::unique_ptr转换为std::shared_ptr

std::unique_ptr<MyClass> unique_obj = std::make_unique<MyClass>();
std::shared_ptr<MyClass> shared_obj = unique_obj;

这将转移所有权并使unique_obj为空。

注意

一旦资源被作为共享资源,就不能将其恢复为唯一对象。

std::weak_ptr

弱指针是共享指针的一种变体,但它不持有资源的引用计数。因此,当计数降为零时,它不会阻止资源被释放。考虑以下程序结构,它可能出现在正常的图形用户界面(GUI)中:

#include <iostream>
#include <memory>
struct ScrollBar;
struct TextWindow;
struct Panel
{
    ~Panel() {
        std::cout << "--Panel destroyed\n";
    }
    void setScroll(const std::shared_ptr<ScrollBar> sb) {
        m_scrollbar = sb;
    }
    void setText(const std::shared_ptr<TextWindow> tw) {
        m_text = tw;
    }
    std::weak_ptr<ScrollBar> m_scrollbar;
    std::shared_ptr<TextWindow> m_text;
};
struct ScrollBar
{
    ~ScrollBar() {
        std::cout << "--ScrollBar destroyed\n";
    }
    void setPanel(const std::shared_ptr<Panel> panel) {
        m_panel=panel;
    }
    std::shared_ptr<Panel> m_panel;
};
struct TextWindow
{
    ~TextWindow() {
        std::cout << "--TextWindow destroyed\n";
    }
    void setPanel(const std::shared_ptr<Panel> panel) {
        m_panel=panel;
    }
    std::shared_ptr<Panel> m_panel;
};
void run_app()
{
    std::shared_ptr<Panel> panel = std::make_shared<Panel>();
    std::shared_ptr<ScrollBar> scrollbar = std::make_shared<ScrollBar>();
    std::shared_ptr<TextWindow> textwindow = std::make_shared<TextWindow>();
    scrollbar->setPanel(panel);
    textwindow->setPanel(panel);
    panel->setScroll(scrollbar);
    panel->setText(textwindow);
}
int main()
{
    std::cout << "Starting app\n";
    run_app();
    std::cout << "Exited app\n";
    return 0;
}

执行时,输出如下:

图 3.25:弱指针程序输出

图 3.25:弱指针程序输出

这表明当应用程序退出时,面板和textwindow都没有被销毁。这是因为它们彼此持有shared_ptr,因此两者的引用计数不会降为零并触发销毁。如果我们用图表表示结构,那么我们可以看到它有一个shared_ptr循环:

图 3.26:弱指针和共享指针循环

图 3.26:弱指针和共享指针循环

智能指针和调用函数

现在我们可以管理我们的资源了,我们如何使用它们?我们传递智能指针吗?当我们有一个智能指针(unique_ptrshared_ptr)时,在调用函数时有四个选项:

  • 通过值传递智能指针

  • 通过引用传递智能指针

  • 通过指针传递托管资源

  • 通过引用传递托管资源

这不是一个详尽的列表,但是主要考虑的。我们如何传递智能指针或其资源的答案取决于我们对函数调用的意图:

  • 函数的意图是仅仅使用资源吗?

  • 函数是否接管资源的所有权?

  • 函数是否替换托管对象?

如果函数只是要使用资源,那么它甚至不需要知道它正在使用托管资源。它只需要使用它,并且应该通过指针、引用(甚至值)调用资源:

do_something(Resource* resource);
do_something(Resource& resource);
do_something(Resource resource);

如果你想要将资源的所有权传递给函数,那么函数应该通过智能指针按值调用,并使用std::move()调用:

do_something(std::unique_ptr<Resource> resource);
auto res = std::make_unique<Resource>();
do_something (std::move(res));

do_something()返回时,res变量将为空,资源现在由do_something()拥有。

如果你想要替换托管对象(一个称为重新安置的过程),那么你通过引用传递智能指针:

do_something(std::unique_ptr<Resource>& resource);

以下程序将所有内容整合在一起,演示了每种情况以及如何调用函数:

#include <iostream>
#include <memory>
#include <string>
#include <sstream>
class Resource
{
public:
  Resource() { std::cout << "+++Resource acquired ["<< m_id <<"]\n"; }
  ~Resource() { std::cout << "---Resource released ["<< m_id <<"]\n"; }
  std::string name() const {
      std::ostringstream ss;
      ss << "the resource [" << m_id <<"]";
      return ss.str();
  }
  int m_id{++m_count};
  static int m_count;
};
int Resource::m_count{0};
void use_resource(Resource& res)
{
    std::cout << "Enter use_resource\n";
    std::cout << "...using " << res.name() << "\n";
    std::cout << "Exit use_resource\n";
}
void take_ownership(std::unique_ptr<Resource> res)
{
    std::cout << "Enter take_ownership\n";
    if (res)
        std::cout << "...taken " << res->name() << "\n";
    std::cout << "Exit take_ownership\n";
}
void reseat(std::unique_ptr<Resource>& res)
{
    std::cout << "Enter reseat\n";
    res.reset(new Resource);
    if (res)
        std::cout << "...reseated " << res->name() << "\n";
    std::cout << "Exit reseat\n";
}
int main()
{
  std::cout << "Starting...\n";
  auto res = std::make_unique<Resource>();
  // Use - pass resource by reference
  use_resource(*res);               
  if (res)
    std::cout << "We HAVE the resource " << res->name() << "\n\n";
  else
    std::cout << "We have LOST the resource\n\n";
  // Pass ownership - pass smart pointer by value
  take_ownership(std::move(res));    
  if (res)
    std::cout << "We HAVE the resource " << res->name() << "\n\n";
  else
    std::cout << "We have LOST the resource\n\n";
  // Replace (reseat) resource - pass smart pointer by reference
  reseat(res);                      
  if (res)
    std::cout << "We HAVE the resource " << res->name() << "\n\n";
  else
    std::cout << "We have LOST the resource\n\n";
  std::cout << "Exiting...\n";
  return 0;
}

当我们运行这个程序时,我们会收到以下输出:

图 3.27:所有权传递程序输出

注意

C++核心指南有一个完整的部分涉及资源管理、智能指针以及如何在这里使用它们:isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#S-resource。我们只触及了指南涵盖的最重要的方面。

练习 4:使用 STL 智能指针实现 RAII

在这个练习中,我们将实现一个传感器工厂方法,通过unique_ptr返回传感器资源。我们将实现一个unique_ptr来持有一个数组,然后开发代码将unique_ptr转换为共享指针,然后再分享它。按照以下步骤实现这个练习:

  1. 在 Eclipse 中打开Lesson3项目。然后在项目资源管理器中展开Lesson3,然后Exercise04,双击Exercise4.cpp以将此练习的文件打开到编辑器中。

  2. 单击启动配置下拉菜单,选择新启动配置...。从搜索项目菜单中配置L3Exercise4应用程序,以便它以名称L3Exercise4运行。

  3. 单击运行按钮运行练习 4。这将产生以下输出:图 3.28:练习 4 输出

图 3.28:练习 4 输出
  1. 在编辑器中,检查代码,特别是工厂方法,即createSensor(type)
std::unique_ptr<ISensor>
createSensor(SensorType type)
{
    std::unique_ptr<ISensor> sensor;
    if (type == SensorType::Light)
    {
        sensor.reset(new LightSensor);
    }
    else if (type == SensorType::Temperature)
    {
        sensor.reset(new TemperatureSensor);
    }
    else if (type == SensorType::Pressure)
    {
        sensor.reset(new PressureSensor);
    }
    return sensor;
}

这将创建一个名为 sensor 的空 unique 指针,然后根据传入的type重置包含的指针以获取所需的传感器。

  1. 在编辑器中打开 Exercise4.cpp,并将文件顶部附近的行更改为以下内容:
#define EXERCISE4_STEP  5
  1. 点击unique_ptrshared_ptr是不允许的。

  2. 找到报告错误的行,并将其更改为以下内容:

SensorSPtr light2 = std::move(light);
  1. 点击light(一个unique_ptr)到light2(一个shared_ptr)。问题实际上是模板方法:
template<typename SP>
void printSharedPointer(SP sp, const char* message)

第一个参数是按值传递的,这意味着将创建shared_ptr的新副本并传递给方法进行打印。

  1. 现在让我们通过将模板更改为按引用传递来修复这个问题。点击Run按钮编译和运行程序。生成以下输出:图 3.31:已更正的 printSharedPointer 输出
图 3.31:已更正的 printSharedPointer 输出
  1. 在编辑器中打开Exercise4.cpp,并将文件顶部附近的行更改为以下内容:
#define EXERCISE4_STEP  12
  1. 点击Run按钮编译和运行程序。生成以下输出:
图 3.32:Exercise 4 的注释步骤 12 输出
  1. 将输出与testSensors()方法中的代码进行比较。我们会发现可以轻松地将空的unique_ptrlight)分配给另一个,并且可以在不需要在任何情况下使用std::move()的情况下从一个shared_ptr分配给另一个(light3 = light2)。

  2. 在编辑器中打开Exercise4.cpp,并将文件顶部附近的行更改为以下内容:

#define EXERCISE4_STEP  15
  1. 点击Run按钮编译和运行程序。输出切换为以下内容:图 3.33:在 unique_ptr 中管理数组
图 3.33:在 unique_ptr 中管理数组
  1. 在编辑器中找到testArrays()方法:
void testArrays()
{
    std::unique_ptr<int []> board = std::make_unique<int []>(8*8);
    for(int i=0  ; i<8 ; i++)
        for(int j=0 ; j<8 ; j++)
            board[i*8+j] = 10*(i+1)+j+1;
    for(int i=0  ; i<8 ; i++)
    {
        char sep{' '};
        for(int j=0 ; j<8 ; j++)
            std::cout << board[i*8+j] << sep;
        std::cout << "\n";
    }
}

在这段代码中有几点需要注意。首先,类型声明为int[]。我们在这个练习中选择了int,但它可以是几乎任何类型。其次,当使用unique_ptr(自 C++ 17 以来也是shared_ptr)来管理数组时,定义了operator[]。因此,我们通过从二维索引的board[i*8+j]计算出一维索引来模拟二维数组。

  1. 编辑方法的第一行并声明auto类型:
auto board = std::make_unique<int []>(8*8);
  1. 点击make_unique()调用。

在这个练习中,我们实现了一个工厂函数,使用unique_ptr来管理传感器的生命周期。然后,我们实现了将其从unique_ptr更改为共享到多个对象。最后,我们开发了一种使用单一维数组来管理多维数组的unique_ptr技术。

零/五法则-不同的视角

当我们引入BufferedWriter时,它管理了两个资源:内存和文件。然后我们讨论了默认编译器生成的浅拷贝操作。我们谈到了我们可以以不同的方式管理资源-停止复制,执行深拷贝,或者转移所有权。在这些情况下我们决定如何做被称为资源管理策略。您选择的策略将影响您如何执行零/五法则

在资源管理方面,一个类可以管理零个资源,管理可以复制但不能移动的资源,管理可以移动但不能复制的资源,或者管理不应复制也不应移动的资源。以下类显示了如何表达这些类别:

struct NoResourceToManage
{
    // use compiler generated copy & move constructors and operators
};
struct CopyOnlyResource
{
    ~CopyOnlyResource()                                      {/* defined */ }
    CopyOnlyResource(const CopyOnlyResource& rhs)            {/* defined */ }
    CopyOnlyResource& operator=(const CopyOnlyResource& rhs) {/* defined */ }
    CopyOnlyResource(CopyOnlyResource&& rhs) = delete;
    CopyOnlyResource& operator=(CopyOnlyResource&& rhs) = delete;
};
struct MoveOnlyResource
{
    ~MoveOnlyResource()                                      {/* defined */ }
    MoveOnlyResource(const MoveOnlyResource& rhs)             = delete;
    MoveOnlyResource& operator=(const MoveOnlyResource& rhs)  = delete;
    MoveOnlyResource(MoveOnlyResource&& rhs)                 {/* defined */ }  
    MoveOnlyResource& operator=(MoveOnlyResource&& rhs)      {/* defined */ }
};
struct NoMoveOrCopyResource
{
    ~NoMoveOrCopyResource()                                  {/* defined */ }
    NoMoveOrCopyResource(const NoMoveOrCopyResource& rhs)             = delete;
    NoMoveOrCopyResource& operator=(const NoMoveOrCopyResource& rhs)  = delete;
    NoMoveOrCopyResource(NoMoveOrCopyResource&& rhs)                  = delete;
    NoMoveOrCopyResource& operator=(NoMoveOrCopyResource&& rhs)       = delete;
};

由于在所有上下文和异常下管理资源的复杂性,最佳实践是,如果一个类负责管理资源,那么该类只负责管理该资源。

活动 1:使用 RAII 和 Move 实现图形处理

第 2A 章不允许鸭子-类型和推断中,您的团队努力工作并实现了Point3dMatrix3d。现在,您的公司希望在推出之前对库进行两项重大改进:

  • 公司的类必须在一个命名空间中,即 Advanced C Plus Plus Inc.因此,图形的命名空间将是accp::gfx

  • Point3dMatrix3d中矩阵的存储是类的固有部分,因此它是从堆栈而不是堆中分配的。作为库矩阵支持的演变,我们需要从堆中分配内存。因为我们正在努力实现更大的矩阵在未来的版本中,我们还希望在我们的类中引入移动语义。

按照以下步骤实现这一点:

  1. 从我们当前版本的库开始(可以在acpp::gfx命名空间中找到。

  2. 修复所有因更改而失败的测试。(失败可能意味着编译失败,而不仅仅是运行测试。)

  3. Matrix3d中,从在类中直接声明矩阵切换到堆分配的存储器。

  4. 通过实现复制构造函数和复制赋值运算符的深度复制实现来修复失败的测试。进行其他必要的更改以适应新的内部表示。请注意,您不需要修改任何测试来使其通过,因为它们只访问公共接口,这意味着我们可以更改内部结构而不影响客户端。

  5. 通过在CreateTranslationMatrix()中使用std::move强制调用移动构造函数来触发另一个失败。在Matrix3d类中引入所需的移动操作以使测试能够编译并通过。

  6. 重复步骤 3 到 4,针对Point3d

在实现上述步骤后,预期的输出看起来与开始时没有变化:

图 3.34:成功转换为使用 RAII 后的活动 1 输出

图 3.34:成功转换为使用 RAII 后的活动 1 输出

注意

此活动的解决方案可以在第 657 页找到。

何时调用函数?

C++程序执行的所有操作本质上都是函数调用(尽管编译器可能会将这些优化为内联操作序列)。但是,由于a = 2 + 5,你可能不会意识到自己在进行函数调用,实际上你在调用operator=(&a, operator+(2, 5))。只是语言允许我们写第一种形式,但第二种形式允许我们重载运算符并将这些功能扩展到用户定义的类型。

以下机制会导致对函数的调用:

  • 显式调用函数。

  • 所有运算符,如+,-,*,/,%,以及 new/delete。

  • 变量的声明-如果存在初始化值,则会导致对带有参数的构造函数的调用。

  • 用户定义的字面量-我们还没有处理这些,但基本上,我们为type operator "" name(argument)定义了一个重载。然后我们可以写诸如 10_km 之类的东西,这样可以使我们的代码更容易理解,因为它携带了语义信息。

  • 从一个值转换为另一个值(static_cast<>const_cast<>reinterpret_cast<>dynamic_cast<>)。再次,我们有另一个运算符重载,允许我们将一种类型转换为另一种类型。

  • 在函数重载期间,可能需要将一种类型转换为另一种类型,以使其与函数原型匹配。它可以通过调用具有正确参数类型的构造函数来创建临时对象,或者通过隐式调用的转换运算符来实现。

每一个结果都会让编译器确定必须调用一个函数。确定需要调用一个函数后,必须找到与名称和参数匹配的函数。这是我们将在下一节讨论的内容。

调用哪个函数

第 2A 章不允许鸭子 - 类型和推断中,我们看到函数重载解析是按以下方式执行的:

图 3.35:函数重载解析

图 3.35:函数重载解析

我们真正没有深入研究的是名称查找的概念。在某个时刻,编译器将遇到对func函数的以下调用:

func(a, b);

当这种情况发生时,它必须将其名称与引入它的声明关联起来。这个过程称为名称查找。这种名称查找对程序中的所有项目(变量、命名空间、类、函数、函数模板和模板)都适用。为了使程序编译通过,变量、命名空间和类的名称查找过程必须产生一个单一的声明。然而,对于函数和函数模板,编译器可以将多个声明与相同的名称关联起来 - 主要是通过函数重载,可以通过参数依赖查找ADL)考虑到额外的函数。

标识符

根据 C++标准的定义,标识符是一系列大写和小写拉丁字母、数字、下划线和大多数 Unicode 字符。有效的标识符必须以非数字字符开头,长度任意长且区分大小写。每个字符都是有意义的。

名称

名称用于引用实体或标签。名称可以是以下形式之一:

  • 标识符

  • 函数符号重载的运算符名称(例如 operator-,operator delete)

  • 模板名称后跟其参数列表(vector

  • 用户定义的转换函数名称(operator float)

  • 用户定义的字面量运算符名称(operator ""_ms)

每个实体及其名称都是由声明引入的,而标签的名称是由goto语句或标记语句引入的。一个名称可以在一个文件(或翻译单元)中多次使用,以依赖于作用域而引用不同的实体。一个名称也可以用来引用跨多个文件(翻译单元)相同的实体,或者根据链接性引用不同的实体。编译器使用名称查找通过名称查找将引入名称的声明与程序中的未知名称关联起来。

名称查找

名称查找过程是两种之一,并且是根据上下文选择的:

  • ::,或者可能在::之后,跟着template关键字。限定名可以指代命名空间成员、类成员或枚举器。::运算符左边的名称定义了要从中查找名称的作用域。如果没有名称,那么就使用全局命名空间。

  • 未经限定名称查找:其他所有情况。在这种情况下,名称查找检查当前作用域和所有封闭作用域。

如果未经限定的名称位于函数调用运算符'()'的左侧,则使用参数依赖查找。

依赖参数的查找

查找未经限定的函数名的规则集称为“参数依赖查找”(简称 ADL),或者“Koenig 查找”(以 Andrew Koenig 命名,他定义了它,并且是 C++标准委员会的资深成员)。未经限定的函数名可以出现为函数调用表达式,也可以作为对重载运算符的隐式函数调用的一部分。

ADL 基本上表示,在未经限定名称查找期间考虑的作用域和命名空间之外,还考虑所有参数和模板参数的“关联命名空间”。考虑以下代码:

#include <iostream>
#include <string>
int main()
{
    std::string welcome{"Hello there"};
    std::cout << welcome;
    endl(std::cout);
}

当我们编译这段代码并运行它时,输出结果如预期的那样:

$ ./adl.exe
Hello there
$

这是一种不寻常的编写程序的方式。通常,它会被这样编写:

#include <iostream>
#include <string>
int main()
{
    std::string welcome{"Hello there"};
    std::cout << welcome << std::endl;
}

我们使用调用endl()来展示 ADL 的奇怪方法。但是这里发生了两次 ADL 查找。

第一个经历 ADL 的函数调用是std::cout << welcome,编译器认为这是operator<<(std::cout, welcome)。现在,操作符<<在可用范围和其参数的命名空间std中被查找。这个额外的命名空间将名称解析为自由方法,即在字符串头文件中声明的std::operator<<(ostream& os, string& s)

第二个调用更明显endl(std::cout)。同样,编译器可以访问 std 命名空间来解析这个名称查找,并在头文件ostream(包含在iostream中)中找到std::endl模板。

没有 ADL,编译器无法找到这两个函数,因为它们是由 iostream 和 string 包提供的自由函数。插入操作符(<<)的魔力将会丢失,如果我们被迫写std::operator<<(std::cout, welcome),对程序员来说将会很繁琐。如果考虑到链式插入,情况会更糟。或者,您可以写"using namespace std;"。这两种选项都不理想,这就是为什么我们需要 ADL(Koenig 查找)。

买家当心

我们已经看到 ADL 通过包含与函数参数类型相关的命名空间,使程序员的生活更加轻松。然而,这种查找能力并非没有风险,大部分情况下我们可以将风险降到最低。考虑以下示例代码:

#include <iostream>
namespace mylib 
{
void is_substring(std::string superstring, std::string substring)
{
    std::cout << "mylib::is_substring()\n";
}
void contains(std::string superstring, const char* substring) {
    is_substring(superstring, substring);
}
}
int main() {
    mylib::contains("Really long reference", "included");
}

当我们编译和运行上述程序时,我们得到了预期的输出:

图 3.36:ADL 示例程序输出

图 3.36:ADL 示例程序输出

C++标准委员会随后决定引入一个is_substring()函数,看起来像这样:

namespace std {
void is_substring(std::string superstring, const char* substring)
{
    std::cout << "std::is_substring()\n";
}
}

如果我们将其添加到文件顶部,编译并重新运行,现在我们得到以下输出:

图 3.37:ADL 问题程序输出

图 3.37:ADL 问题程序输出

由于 ADL,(下一个 C++标准)编译器选择了不同的实现作为is_substring()的未限定函数调用的更好选择。并且由于参数的隐式转换,它不会导致歧义和编译器错误。它只是悄悄地采用了新的方法,这可能会导致细微且难以发现的错误,如果参数顺序不同。编译器只能检测类型和语法差异,而不能检测语义差异。

注意

为了演示 ADL 的工作原理,我们已将我们的函数添加到 std 命名空间中。命名空间有一个分离关注点的目的,特别是添加到别人的命名空间,特别是标准库命名空间std)是不好的做法。

那么,为什么要买家注意(买家当心)?如果您在开发中使用第三方库(包括 C++标准库),那么当您升级库时,您需要确保接口的更改不会因为 ADL 而导致问题。

练习 5:实现模板以防止 ADL 问题

在这个练习中,我们将演示 C17 STL 中的一个破坏性变化,这可能会在实际中引起问题。C11 引入了std::begin(type)std::end(type)的模板。作为开发人员,这是一种对通用接口的吸引人的表达,您可能已经为 size(type)和 empty(type)编写了自己的版本。按照以下步骤实现这个练习:

  1. 在 Eclipse 中打开Lesson3项目。然后在Project Explorer中展开Lesson3,然后Exercise05,双击Exercise5.cpp以将此练习的文件打开到编辑器中。

  2. 单击Launch Configuration下拉菜单,选择New Launch Configuration…。从搜索项目菜单配置L3Exercise5应用程序,以便以L3Exercise5的名称运行。

  3. 单击Run按钮运行 Exercise 5。这将产生以下输出:图 3:38:Exercise 5 成功执行

图 3:38:练习 5 的成功执行
  1. 代码检查发现了两个辅助模板:
template<class T>
bool empty(const T& x)
{
    return x.empty();
}
template<class T>
int size(const T& x)
{
    return x.size();
}
  1. 与所有其他练习不同,此练习已配置为在 C++ 14 下构建。打开Lesson3下的CMakeLists.txt文件,并找到以下行:
set_property(TARGET L3Exercise5 PROPERTY CXX_STANDARD 14)
  1. 14改为17

  2. 单击Run按钮编译练习,现在失败:图 3.39:C++ 17 下编译失败-模棱两可的函数调用

图 3.39:C++ 17 下编译失败-模棱两可的函数调用
  1. 因为empty()size()模板的参数是 std::vector,ADL 引入了新包含的 STL 版本的这些模板,破坏了我们的代码。

  2. empty()和两个生成错误的size()出现之前,在它们(作用域限定符)之前插入两个冒号“::”。

  3. 单击empty()size()函数现在已经有了限定。我们也可以指定std::作用域。

在这个练习中,我们在全局命名空间中实现了两个模板函数,如果我们在 C++ 14 标准下编译程序,它们就可以正常工作。然而,当我们在 C++17 下编译时,我们的实现就会出问题,因为 STL 库发生了变化,我们必须改变我们的实现,以确保编译器定位并使用我们编写的模板。

隐式转换

在确定图 3.36中的函数候选集时,编译器必须查看所有在名称查找期间找到的可用函数,并确定参数数量和类型是否匹配调用点。在确定类型是否匹配时,它还将检查所有可用的转换,以确定是否有一种机制可以将类型 T1 类型(传递的参数类型)转换为 T2 类型(函数参数指定的类型)。如果它可以将所有参数从 T1 转换为 T2,那么它将把函数添加到候选集中。

从类型 T1 到类型 T2 的这种转换被称为隐式转换,当某种类型 T1 在不接受该类型但接受其他类型 T2 的表达式或上下文中使用时发生。这发生在以下情境中:

  • T1 作为参数传递时调用以 T2 为参数声明的函数。

  • T1 用作期望 T2 的运算符的操作数。

  • T1 用于初始化 T2 的新对象(包括返回语句)。

  • T1 在switch语句中使用(在这种情况下,T2 是 int)。

  • T1 在if语句或do-whilewhile循环中使用(其中 T2 为 bool)。

如果存在从 T1 到 2 的明确转换序列,则程序将编译。内置类型之间的转换通常由通常的算术转换确定。

显式-防止隐式转换

隐式转换是一个很好的特性,使程序员能够表达他们的意图,并且大多数时候都能正常工作。然而,编译器在程序员没有提供提示的情况下将一种类型转换为另一种类型的能力并不总是理想的。考虑以下小程序:

#include <iostream>
class Real
{
public:
    Real(double value) : m_value{value} {}
    operator float() {return m_value;}
    float getValue() const {return m_value;}
private:
    double m_value {0.0};
};
void test(bool result)
{
    std::cout << std::boolalpha;
    std::cout << "Test => " << result << "\n";
}
int main()
{
    Real real{3.14159};
    test(real);
    if ( real ) 
    {
        std::cout << "true: " << real.getValue() << "\n";
    }
    else
    {
        std::cout << "false: " << real.getValue() << "\n";
    }
}

当我们编译并运行上述程序时,我们得到以下输出:

图 3.40:隐式转换示例程序输出

图 3.40:隐式转换示例程序输出

嗯,这可能有点出乎意料,它编译并实际产生了输出。real变量是Real类型,它有一个到 float 的转换运算符- operator float()test()函数以bool作为参数,并且if条件也必须产生一个bool。如果值为零,则编译器将任何数值类型转换为值为 false 的boolean类型,如果值不为零,则转换为 true。但是,如果这不是我们想要的行为,我们可以通过在函数声明前加上 explicit 关键字来阻止它。假设我们更改行,使其读起来像这样:

explicit operator float() {return m_value;}

如果我们现在尝试编译它,我们会得到两个错误:

图 3.41:因为隐式转换被移除而导致的编译错误。

图 3.41:因为隐式转换被移除而导致的编译错误。

两者都与无法将 Real 类型转换为 bool 有关 - 首先是对test()的调用位置,然后是 if 条件中。

现在,让我们引入一个 bool 转换操作符来解决这个问题。

operator bool() {return m_value == 0.0;}

现在我们可以再次构建程序。我们将收到以下输出:

图 3.42:引入 bool 运算符替换隐式转换

图 3.42:引入 bool 运算符替换隐式转换

boolean值现在为 false,而以前为 true。这是因为浮点转换返回的值的隐式转换不为零,然后转换为 true。

自 C++ 11 以来,所有构造函数(除了复制和移动构造函数)都被认为是转换构造函数。这意味着如果它们没有声明为显式,则它们可用于隐式转换。同样,任何未声明为显式的转换操作符都可用于隐式转换。

C++核心指南有两条与隐式转换相关的规则:

  • C.46:默认情况下,将单参数构造函数声明为显式

  • C.164:避免隐式转换操作符

上下文转换

如果我们现在对我们的小程序进行进一步的更改,我们就可以进入所谓的上下文转换。让我们将 bool 运算符设置为显式,并尝试编译程序:

explicit operator bool() {return m_value == 0.0;}

我们将收到以下输出:

图 3.43:使用显式 bool 运算符的编译错误

图 3.43:使用显式 bool 运算符的编译错误

这次我们只有一个错误,即对test()的调用位置,但对 if 条件没有错误。我们可以通过使用 C 风格的转换(bool)或 C++ static_cast<bool>(real)(这是首选方法)来修复此错误。当我们添加转换时,程序再次编译和运行。

因此,如果 bool 转换是显式的,那么为什么 if 表达式的条件不需要转换?

C++标准允许在某些情况下,如果期望bool类型并且存在 bool 转换的声明(无论是否标记为显式),则允许隐式转换。这被称为上下文转换为 bool,并且可以出现在以下上下文中:

  • ifwhilefor的条件(或控制表达式)

  • 内置逻辑运算符的操作数:!(非)、&&(与)和||(或)

  • 条件(或条件)运算符?:的第一个操作数。

练习 6:隐式和显式转换

在这个练习中,我们将尝试调用函数、隐式转换、阻止它们以及启用它们。按照以下步骤实施这个练习:

  1. 在 Eclipse 中打开Lesson3项目。然后在Project Explorer中展开Lesson3,然后展开Exercise06,双击Exercise6.cpp以在编辑器中打开此练习的文件。

  2. 单击Launch Configuration下拉菜单,选择New Launch Configuration…。从Search Project菜单中配置L3Exercise6应用程序,以便以L3Exercise6的名称运行。

  3. 单击Run按钮运行练习 6。这将产生以下输出:图 3.44:练习 6 的默认输出

图 3.44:练习 6 的默认输出
  1. 在文本编辑器中,将Voltage的构造函数更改为explicit
struct Voltage
{
    explicit Voltage(float emf) : m_emf(emf) 
    {
    }
    float m_emf;
};
  1. 单击Run按钮重新编译代码 - 现在我们得到以下错误:图 3.45:int 转换为 Voltage 失败
图 3.45:int 转换为 Voltage 失败
  1. 从构造函数中删除显式,并将calculate函数更改为引用:
void calculate(Voltage& v)
  1. 单击Run按钮重新编译代码 - 现在,我们得到以下错误:
图 3.46:将整数转换为电压&失败

同一行出现了我们之前遇到的问题,但原因不同。因此,隐式转换仅适用于值类型

  1. 注释掉生成错误的行,然后在调用use_float(42)之后,添加以下行:
use_float(volts);
  1. 单击Run按钮重新编译代码-现在我们得到以下错误:图 3.47:电压转换为浮点数失败
图 3.47:电压转换为浮点数失败
  1. 现在,将以下转换运算符添加到Voltage类中:
operator float() const
{
    return m_emf;
}
  1. 单击Run按钮重新编译代码并运行它:图 3.48:成功将电压转换为浮点数
图 3.48:成功将电压转换为浮点数
  1. 现在,在我们刚刚添加的转换前面放置explicit关键字,然后单击Run按钮重新编译代码。再次出现错误:图 3.49:无法将电压转换为浮点数
图 3.49:无法将电压转换为浮点数
  1. 通过在转换中添加显式声明,我们可以防止编译器使用转换运算符。将出错的行更改为将电压变量转换为浮点数:
use_float(static_cast<float>(volts));
  1. 单击Run按钮重新编译代码并运行它。

图 3.50:使用转换将电压转换为浮点数再次成功

图 3.50:使用转换将电压转换为浮点数再次成功

在这个练习中,我们已经看到了类型(而不是引用)之间可以发生隐式转换,并且我们可以控制它们何时发生。现在我们知道如何控制这些转换,我们可以努力满足先前引用的指南C.46C.164

活动 2:实现日期计算的类

您的团队负责开发一个库,以帮助处理与日期相关的计算。特别是,我们希望能够确定两个日期之间的天数,并且给定一个日期,添加(或从中减去)一定数量的天数以获得一个新日期。此活动将开发两种新类型并增强它们,以确保程序员不能意外地使它们与内置类型交互。按照以下步骤来实现这一点:

  1. 设计和实现一个Date类,将daymonthyear作为整数存储。

  2. 添加方法来访问内部的天、月和年值。

  3. 定义一个类型date_t来表示自 1970 年 1 月 1 日纪元日期以来的天数。

  4. Date类添加一个方法,将其转换为date_t

  5. 添加一个方法来从date_t值设置Date类。

  6. 创建一个存储天数值的Days类。

  7. Date添加一个接受Days作为参数的加法运算符。

  8. 使用explicit来防止数字的相加。

  9. 添加减法运算符以从两个日期差异返回Days值。

在按照这些步骤之后,您应该收到以下输出:

图 3.51:成功的日期示例应用程序输出

图 3.51:成功的日期示例应用程序输出

注意

此活动的解决方案可在第 664 页找到。

总结

在本章中,我们探讨了变量的生命周期 - 包括自动变量和动态变量,它们存储在何处,以及它们何时被销毁。然后,我们利用这些信息开发了RAII技术,使我们几乎可以忽略资源管理,因为自动变量在被销毁时会清理它们,即使在出现异常的情况下也是如此。然后,我们研究了抛出异常和捕获异常,以便我们可以在正确的级别处理异常情况。从RAII开始,我们进入了关于资源所有权的讨论,以及STL智能指针如何帮助我们在这个领域。我们发现几乎所有东西都被视为函数调用,从而允许操作符重载和隐式转换。我们发现了“参数相关查找”(ADL)的奇妙(或者说糟糕?)世界,以及它如何潜在地在未来使我们陷入困境。我们现在对 C++的基本特性有了很好的理解。在下一章中,我们将开始探讨函数对象以及它们如何使用 lambda 函数实现和实现。我们将进一步深入研究 STL 的功能,并在重新访问封装时探索 PIMPLs。

第五章:关注点的分离 - 软件架构、函数和可变模板

学习目标

在本章结束时,您将能够:

  • 使用 PIMPL 习惯用法来实现对象级封装

  • 使用函数对象、std::function 和 lambda 表达式实现回调系统

  • 使用正确的捕获技术来实现 lambda 表达式

  • 开发可变模板以实现 C#风格的委托以进行事件处理。

本章将向您展示如何实现 PIMPL 习惯用法,以及如何为您自己的程序开发回调机制。

介绍

在上一章中,我们学习了如何实现类来正确管理资源,即使在发生异常时也是如此,使用 RAII。我们还学习了 ADL(Argument Dependent Lookup)以及它如何确定要调用的函数。最后,我们谈到了显式关键字如何可以防止编译器进行类型之间的自动转换,即隐式转换。

在本章中,我们将研究依赖关系,包括物理依赖关系和逻辑依赖关系,以及它们如何对构建时间产生不利影响。我们还将学习如何将可见接口类与实现细节分离,以增加构建时间的速度。然后,我们将学习如何捕获函数和上下文,以便以后可以使用“函数对象”、std::function和“lambda 表达式”来调用它们。最后,我们将实现可变模板以提供基于事件的回调机制。

指向实现的指针(PIMPL)习惯用法

随着 C实现的项目变得越来越大,构建时间增长的速度可能会超过文件数量的增长速度。这是因为 C构建模型使用了文本包含模型。这样做是为了让编译器能够确定类的大小和布局,导致了“调用者”和“被调用者”之间的耦合,但也允许进行优化。请记住,一切都必须在使用之前定义。未来的一个特性叫做“模块”承诺解决这个问题,但现在我们需要了解这个问题以及用来解决问题的技术。

逻辑和物理依赖关系

当我们希望从另一个类中访问一个类时,我们有一个逻辑依赖关系。一个类在逻辑上依赖于另一个类。如果我们考虑我们在第 2A 章“不允许鸭子 - 类型和推导”和第三章“能与应该之间的距离 - 对象、指针和继承”中开发的Graphics类,Point3dMatrix3d,我们有两个逻辑独立的类Matrix3dPoint3d。然而,由于我们如何在两者之间实现了乘法运算符,我们创建了一个编译时或物理依赖关系

图 4.1:Matrix3d 和 Point3d 的物理依赖关系

图 4.1:Matrix3d 和 Point3d 的物理依赖关系

正如我们在这些相对简单的类中所看到的,头文件和实现文件之间的物理依赖关系很快就会变得复杂起来。正是这种复杂性导致了大型项目的构建时间增加,因为物理(和逻辑)依赖关系的数量增长到了成千上万。在前面的图表中,我们只显示了 13 个依赖关系,如箭头所示。但实际上还有更多,因为包含标准库头文件通常会引入一系列包含文件的层次结构。这意味着如果修改了一个头文件,那么直接或间接依赖于它的所有文件都需要重新编译以适应变化。如果更改是对用户甚至无法访问的私有类成员定义的,也会触发重新构建。

为了加快编译时间,我们使用了保护技术来防止头文件被多次处理:

#if !defined(MY_HEADER_INCLUDED)
#define   MY_HEADER_INCLUDED
// definitions 
#endif // !defined(MY_HEADER_INCLUDED)

最近,大多数编译器现在支持#pragma once指令,它可以实现相同的结果。

这些实体(文件、类等)之间的关系被称为耦合。如果对文件/类的更改导致对其他文件/类的更改,则文件/类与另一个文件/类高度耦合。如果对文件/类的更改不会导致对其他文件/类的更改,则文件/类与另一个文件/类松散耦合

高度耦合的代码(文件/类)会给项目带来问题。高度耦合的代码难以更改(不灵活),难以测试和难以理解。另一方面,松散耦合的代码更容易更改(只需修改一个类),更易测试(只需测试正在测试的类)并且更易阅读和理解。耦合反映并与逻辑和物理依赖相关。

指向实现(PIMPL)惯用法

解决这种耦合问题的一种方法是使用“Pimpl 惯用法”(即“指向实现的指针惯用法”)。这也被称为不透明指针、编译器防火墙惯用法,甚至是“切尔西猫技术”。考虑 Qt 库,特别是 Qt 平台抽象(QPA)。这是一个隐藏 Qt 应用程序所托管的操作系统和/或平台细节的抽象层。实现这样一层的方法之一是使用 PIMPL 惯用法,其中公共接口暴露给应用程序开发人员,但功能的实现方式是隐藏的。Qt 实际上使用了 PIMPL 的变体,称为 d-pointer。

例如,GUI 的一个特性是使用对话框,它是一个弹出窗口,用于显示信息或提示用户输入。可以在dialog.hpp中声明如下:

有关 QT 平台抽象(QPA)的更多信息,请访问以下链接:doc.qt.io/qt-5/qpa.html#

#pragma once
class Dialog
{
public:
    Dialog();
    ~Dialog();
    void create(const char* message);
    bool show();
private:
    struct DialogImpl;
    DialogImpl* m_pImpl;
};

用户可以访问使用Dialog所需的所有函数,但不知道它是如何实现的。请注意,我们声明了DialogImpl但没有定义它。一般来说,我们对这样的DialogImpl类做不了太多事情。但有一件事是允许的,那就是声明一个指向它的指针。C++的这个特性允许我们在实现文件中隐藏实现细节。这意味着在这种简单情况下,我们不需要为这个声明包含任何包含文件。

实现文件dialogImpl.cpp可以实现为:

#include "dialog.hpp"
#include <iostream>
#include <string>
struct Dialog::DialogImpl
{
    void create(const char* message)
    {
        m_message = message;
        std::cout << "Creating the Dialog\n";
    }
    bool show()
    {
        std::cout << "Showing the message: '" << m_message << "'\n";
        return true;
    }
    std::string m_message;
};
Dialog::Dialog() : m_pImpl(new DialogImpl)
{
}
Dialog::~Dialog()
{
    delete m_pImpl;
}
void Dialog::create(const char* message)
{
    m_pImpl->create(message);
}
bool Dialog::show()
{
    return m_pImpl->show();
}

我们从中注意到几件事:

  • 在我们定义对话框所需的方法之前,我们先定义实现类DialogImpl。这是必要的,因为Dialog将需要通过m_pImpl来调用这些方法,这意味着它们需要首先被定义。

  • Dialog的构造函数和析构函数负责内存管理。

  • 我们只在实现文件中包含了实现所需的所有必要头文件。这通过最小化Dialog.hpp文件中包含的头文件数量来减少耦合。

该程序可以按以下方式执行:

#include <iostream>
#include "dialog.hpp"
int main()
{
    std::cout << "\n\n------ Pimpl ------\n";
    Dialog dialog;
    dialog.create("Hello World");
    if (dialog.show())
    {
        std::cout << "Dialog displayed\n";
    }
    else
    {
        std::cout << "Dialog not displayed\n";
    }
    std::cout << "Complete.\n";
    return 0;
}

在执行时,上述程序产生以下输出:

图 4.2:示例 Pimpl 实现输出

图 4.2:示例 Pimpl 实现输出

PIMPL 的优缺点

使用 PIMPL 的最大优势是它打破了类的客户端和其实现之间的编译时依赖关系。这样可以加快构建时间,因为 PIMPL 在定义(头)文件中消除了大量的#include指令,而只需要在实现文件中才是必要的。

它还将实现与客户端解耦。现在我们可以自由更改 PIMPL 类的实现,只需重新编译该文件。这可以防止编译级联,其中对隐藏成员的更改会触发客户端的重建。这被称为编译防火墙。

PIMPL 惯用法的一些其他优点如下:

  • 数据隐藏 - 实现的内部细节真正地被隔离在实现类中。如果这是库的一部分,那么它可以用来防止信息的泄露,比如知识产权。

  • DLL.so文件),并且可以自由更改它而不影响客户端代码。

这些优点是有代价的。缺点如下:

  • 维护工作 - 可见类中有额外的代码将调用转发到实现类。这增加了一定复杂性的间接层。

  • 内存管理 - 现在添加了一个指向实现的指针,我们需要管理内存。它还需要额外的存储空间来保存指针,在内存受限的系统中(例如:物联网设备)这可能是关键的。

使用 unique_ptr<>实现 PIMPL

我们当前的 Dialog 实现使用原始指针来持有 PIMPL 实现引用。在第三章中,能与应该之间的距离-对象、指针和继承中,我们讨论了对象的所有权,并引入了智能指针和 RAII。PIMPL 指针指向的隐藏对象是一个需要管理的资源,应该使用RAIIstd::unique_ptr来执行。正如我们将看到的,使用std::unique_ptr实现PIMPL有一些注意事项。

让我们将 Dialog 的实现改为使用智能指针。首先,头文件更改以引入#include <memory>行,并且可以删除析构函数,因为unique_ptr会自动删除实现类。

#pragma once
#include <memory>
class Dialog
{
public:
    Dialog();
    void create(const char* message);
    bool show();
private:
    struct DialogImpl;
    std::unique_ptr<DialogImpl> m_pImpl;
};

显然,我们从实现文件中删除了析构函数,并修改构造函数以使用std::make_unique

Dialog::Dialog() : m_pImpl(std::make_unique<DialogImpl>())
{
}

重新编译我们的新版本时,Dialog.hppDialogImpl.cpp文件没有问题,但我们的客户端main.cpp报告了以下错误(使用 gcc 编译器),如下所示:

图 4.3:使用 unique_ptr 失败的 Pimpl 编译

图 4.3:使用 unique_ptr 失败的 Pimpl 编译

main()函数结束时,第一个错误报告了Dialog。正如我们在第 2A 章中讨论的那样,不允许鸭子-类型和推断编译器将为我们生成一个析构函数(因为我们删除了它)。这个生成的析构函数将调用unique_ptr的析构函数,这就是错误的原因。如果我们看一下line 76,默认unique_ptr使用的deleteroperator()函数(deleterunique_ptr在销毁其指向的对象时调用的函数):

void
operator()(_Tp* __ptr) const
{
    static_assert(!is_void<_Tp>::value, "can't delete pointer to incomplete type");
    static_assert(sizeof(_Tp)>0, "can't delete pointer to incomplete type");
    delete __ptr;
}

我们的代码在第二个static_assert()语句上失败,这会导致编译出错。问题在于编译器试图为std::unique_ptr<DialogImpl>DialogImpl生成析构函数,而DialogImpl是一个不完整的类型。因此,为了解决问题,我们控制生成析构函数的时机,使DialogImpl成为一个完整的类型。

为了做到这一点,我们将析构函数的声明放回类中,并将其实现添加到DialogImpl.cpp文件中。

Dialog::~Dialog()
{
}

当我们编译并运行我们的程序时,它产生的输出与之前完全相同。实际上,如果我们只需要一个空的析构函数,我们可以用以下代码替换上面的代码:

Dialog::~Dialog() = default;

如果我们编译并运行我们的程序,那么将产生以下输出:

图 4.4:示例 unique_ptr Pimpl 实现输出

图 4.4:示例 unique_ptr Pimpl 实现输出

unique_ptr<> PIMPL 特殊函数

由于 PIMPL 通常意味着可见接口类拥有实现类,因此移动语义是一个自然的选择。然而,就像编译器生成的析构函数实现是正确的一样,编译器生成的移动构造函数和移动赋值运算符将产生期望的行为,即对成员unique_ptr执行移动。移动操作都可能需要在分配传输值之前执行删除,因此,与不完整类型的析构函数一样,它们也会遇到相同的问题。解决方案与析构函数相同-在头文件中声明该方法,并在类型完成时实现-在实现文件中。因此,我们的头文件看起来像下面这样:

class Dialog
{
public:
    Dialog();
    ~Dialog();
    Dialog(Dialog&& rhs);
    Dialog& operator=(Dialog&& rhs);
    void create(const char* message);
    bool show();
private:
    struct DialogImpl;
    std::unique_ptr<DialogImpl> m_pImpl;
};

虽然实现看起来像:

Dialog::Dialog() : m_pImpl(std::make_unique<DialogImpl>())
{
}
Dialog::~Dialog() = default;
Dialog::Dialog(Dialog&& rhs) = default;
Dialog& Dialog::operator=(Dialog&& rhs) = default;

根据我们在实现类中隐藏的数据项,我们可能还希望在我们的 PIMPL 类上具有复制功能。在 Dialog 类内部使用std::unique_ptr可以防止自动生成复制构造函数和复制赋值运算符,因为内部成员不支持复制。此外,通过定义移动成员函数,就像我们在第 2A 章中看到的那样,它也阻止编译器生成复制版本。此外,如果编译器为我们生成了复制语义,它只会是浅复制。但由于 PIMPL 实现,我们需要深复制。因此,我们需要编写自己的复制特殊成员函数。同样,定义放在头文件中,实现需要在类型完成的地方完成,即在DialogImpl.cpp文件中。

在头文件中,我们添加以下声明:

Dialog(const Dialog& rhs);
Dialog& operator=(const Dialog& rhs);

实现将如下所示:

Dialog::Dialog(const Dialog& rhs) : m_pImpl(nullptr)
{
    if (this == &rhs)   // do nothing on copying self
    return;
    if (rhs.m_pImpl)    // rhs has something -> clone it
        m_pImpl = std::make_unique<DialogImpl>(*rhs.m_pImpl);
}
Dialog& Dialog::operator=(const Dialog& rhs)
{
    if (this == &rhs)   // do nothing on assigning to self
        return *this;
    if (!rhs.m_pImpl)   // rhs is empty -> delete ours
    {
        m_pImpl.reset();
    }
    else if (!m_pImpl)  // ours is empty -> clone rhs
    {
        m_pImpl = std::make_unique<DialogImpl>(*rhs.m_pImpl);
    }
    else // use copy of DialogImpl
    {
        *m_pImpl = *rhs.m_pImpl;
    }
}

注意if(this == &rhs)子句。这些是为了防止对象不必要地复制自身。还要注意,我们需要检查unique_ptr是否为空,并相应地处理复制。

注意

在本章中解决任何实际问题之前,下载 GitHub 存储库github.com/TrainingByPackt/Advanced-CPlusPlus并在 Eclipse 中导入 Lesson 4 文件夹,以便您可以查看每个练习和活动的代码。

练习 1:使用 unique_ptr<>实现厨房

在这个练习中,我们将通过使用unique_ptr<>实现Pimpl idiom来隐藏厨房处理订单的细节。按照以下步骤来实现这个练习:

  1. 在 Eclipse 中打开Lesson4项目,然后在Project Explorer中展开Lesson4,然后展开Exercise01,双击Exercise1.cpp以将此练习的文件打开到编辑器中。

  2. 由于这是一个基于 CMake 的项目,将当前构建器更改为 CMake Build(便携式)。

  3. 单击Launch Configuration下拉菜单,然后选择New Launch Configuration…。配置L4Exercise1以使用名称Exercise1运行。

  4. 单击Run按钮。练习 1 将运行并产生以下输出:图 4.5:练习 1 程序输出

图 4.5:练习 1 程序输出
  1. 打开Kitchen。我们将把所有私有成员移到一个实现类中并隐藏细节。

  2. #include <memory>指令中获得对unique_ptr的访问。添加析构函数~Kitchen();的声明,然后将以下两行添加到私有部分的顶部:

struct Impl;
std::unique_ptr<Impl> m_impl;
  1. 打开#include指令:
struct Kitchen::Impl
{
};
Kitchen::~Kitchen() = default;
  1. 单击Run按钮重新构建程序。您会看到输出仍然与以前相同。

  2. Kitchen类中的Kitchen::Impl声明中删除除两个新成员之外的所有私有成员。#include <vector>#include "recipe.hpp"#include "dessert.hpp"

#pragma once
#include <string>
#include <memory>
class Kitchen
{
public:
    Kitchen(std::string chef);
    ~Kitchen();
    std::string processOrder(std::string order);
private:
    struct Impl;
    std::unique_ptr<Impl> m_impl;
};
  1. Kitchen::Impl构造函数中:
Kitchen::Impl::Impl(std::string chef) : m_chef{chef}
  1. 对于原始方法的其余部分,将它们更改为作用域为Kitchen::Impl而不是Kitchen::。例如,std::string Kitchen::processOrder(std::string order)变为std::string Kitchen::Impl::processOrder(std::string order)

  2. Kitchen::Impl中,添加一个带有std::string参数和processOrder()方法的构造函数。Kitchen::Impl声明现在应如下所示:

struct Kitchen::Impl
{
    Impl(std::string chef);
    std::string processOrder(std::string order);
    std::string searchForRecipe(std::string order);
    std::string searchForDessert(std::string order);
    std::string cookRecipe(std::string recipe);
    std::string serveDessert(std::string dessert);
    std::vector<Recipe>::iterator getRecipe(std::string recipe);
    std::vector<Dessert>::iterator getDessert(std::string recipe);
    std::string m_chef;
    std::vector<Recipe> m_recipes;
    std::vector<Dessert> m_desserts;
};
  1. #include <vector>#include "recipe.hpp"#include "dessert.hpp"添加到文件顶部。

  2. 单击Kitchen::KitchenKitchen::processOrder

  3. Kitchen::Impl方法定义中,添加以下两个方法:

Kitchen::Kitchen(std::string chef) : m_impl(std::make_unique<Kitchen::Impl>(chef))
{
}
std::string Kitchen::processOrder(std::string order)
{
    return m_impl->processOrder(order);
}
  1. 单击Run按钮重新构建程序。程序将再次运行以产生原始输出。

图 4.6:使用 Pimpl 的厨房程序输出

图 4.6:使用 Pimpl 的厨房程序输出

在这个练习中,我们已经将一个类中的许多细节移到了 PIMPL 类中,以隐藏细节并使用先前描述的技术将接口与实现解耦。

函数对象和 Lambda 表达式

在编程中常用的一种模式,特别是在实现基于事件的处理时,如异步输入和输出,是使用回调。客户端注册他们希望被通知事件发生的情况(例如:数据可供读取,或数据传输完成)。这种模式称为观察者模式订阅者发布者模式。C ++支持各种技术来提供回调机制。

函数指针

第一种机制是使用函数指针。这是从 C 语言继承的传统功能。以下程序显示了函数指针的示例:

#include <iostream>
using FnPtr = void (*)(void);
void function1()
{
    std::cout << "function1 called\n";
}
int main()
{
    std::cout << "\n\n------ Function Pointers ------\n";
    FnPtr fn{function1};
    fn();
    std::cout << "Complete.\n";
    return 0;
}

编译和执行此程序时,将产生以下输出:

图 4.7:函数指针程序输出

图 4.7:函数指针程序输出

严格来说,代码应修改如下:

FnPtr fn{&function1};
if(fn != nullptr)
    fn();

首先要注意的是应使用地址(&)运算符来初始化指针。其次,在调用之前应检查指针是否有效。

#include <iostream>
using FnPtr = void (*)(void);
struct foo
{
    void bar() { std::cout << "foo:bar called\n"; }
};
int main()
{
    std::cout << "\n\n------ Function Pointers ------\n";
    foo object;
    FnPtr fn{&object.bar};
    fn();
    std::cout << "Complete.\n";
    return 0;
}

当我们尝试编译此程序时,会得到以下错误:

图 4.8:编译函数指针程序时出现的错误

图 4.8:编译函数指针程序时出现的错误

第一个错误的文本是this指针。

通过将上述程序更改为以下内容:

#include <iostream>
using FnPtr = void (*)(void);
struct foo
{
    static void bar() { std::cout << "foo:bar called\n"; }
};
int main()
{
    std::cout << "\n\n------ Function Pointers ------\n";
    FnPtr fn{&foo::bar};
    fn();
    std::cout << "Complete.\n";
    return 0;
}

它成功编译并运行:

图 4.9:使用静态成员函数的函数指针程序

图 4.9:使用静态成员函数的函数指针程序

函数指针技术通常用于与使用回调和支持回调的操作系统通知的 C 库进行接口的情况。在这两种情况下,回调通常会接受一个void *参数,该参数是用户注册的数据块指针。数据块指针可以是类的this指针,然后对其进行解引用,并将回调转发到成员函数。

在其他语言中,如 Python 和 C#,捕获函数指针也会捕获调用该函数所需的足够数据(例如:selfthis)是语言的一部分。 C ++具有通过函数调用运算符使任何对象可调用的能力,我们将在下面介绍。

什么是函数对象?

C ++允许重载函数调用运算符operator()。这导致可以使任何对象“可调用”。可调用的对象在以下程序中称为Scaler类实现了functor

struct Scaler
{
    Scaler(int scale) : m_scale{scale} {};
    int operator()(int value)
    {
        return m_scale * value;
    }
    int m_scale{1};
};
int main()
{
    std::cout << "\n\n------ Functors ------\n";
    Scaler timesTwo{2};
    Scaler timesFour{4};
    std::cout << "3 scaled by 2 = " << timesTwo(3) << "\n";
    std::cout << "3 scaled by 4 = " << timesFour(3) << "\n";
    std::cout << "Complete.\n";
    return 0;
}

创建了两个类型为Scaler的对象,并且它们在生成输出的行内被用作函数。上述程序产生以下输出:

图 4.10:functors 程序输出

图 4.10:函数对象程序输出

functors相对于函数指针的一个优点是它们可以包含状态,可以是对象或跨所有实例。另一个优点是它们可以传递给期望函数(例如std::for_each)或操作符(例如std::transform)的 STL 算法。

这样的用法示例可能如下所示:

#include <iostream>
#include <vector>
#include <algorithm>
struct Scaler
{
    Scaler(int scale) : m_scale{scale} {};
    int operator()(int value)
    {
        return m_scale * value;
    }
    int m_scale{1};
};
void PrintVector(const char* prefix, std::vector<int>& values)
{
    const char* sep = "";
    std::cout << prefix << " = [";
    for(auto n : values)
    {
        std::cout << sep << n;
        sep = ", ";
    }
    std::cout << "]\n";
}
int main()
{
    std::cout << "\n\n------ Functors with STL ------\n";
    std::vector<int> values{1,2,3,4,5};
    PrintVector("Before transform", values);
    std::transform(values.begin(), values.end(), values.begin(), Scaler(3));
    PrintVector("After transform", values);
    std::cout << "Complete.\n";
    return 0;
}

如果我们运行这个程序,产生的输出将如下所示:

图 4.11:显示标量转换向量的程序输出

图 4.11:显示标量转换向量的程序输出

练习 2:实现函数对象

在这个练习中,我们将实现两个不同的函数对象,可以与 STL 算法for_each一起使用。

  1. 在 Eclipse 中打开Lesson4项目,然后在项目资源管理器中展开Lesson4,然后展开Exercise02,双击Exercise2.cpp以打开此练习的文件到编辑器中。

  2. 由于这是一个基于 CMake 的项目,将当前构建器更改为 CMake Build(便携式)。

  3. 点击启动配置下拉菜单,选择新启动配置...。配置L4Exercise2以名称Exercise2运行。

  4. 点击运行按钮。练习 2 将运行并产生以下输出:图 4.12:练习 2 的初始输出

图 4.12:练习 2 的初始输出

我们要做的第一件事是通过引入函数对象来修复输出的格式。

  1. 在编辑器中,在main()函数定义之前添加以下类定义:
struct Printer
{
    void operator()(int n)
    {
        std::cout << m_sep << n;
        m_sep = ", ";
    }
    const char* m_sep = "";
};
  1. 在**main()**方法中替换以下代码
std::cout << "Average of [";
for( auto n : values )
    std::cout << n << ", ";
std::cout << "] = ";

带有

std::cout << "Average of [";
std::for_each(values.begin(), values.end(), Printer());
std::cout << "] = ";
  1. 点击运行按钮。练习将运行并产生以下输出:图 4.13:改进的输出格式的练习 2
图 4.13:改进的输出格式的练习 2
  1. Printer类的内部状态允许我们修复格式。现在,引入一个aggregator类,它将允许我们计算average。在文件顶部添加以下类定义:
struct Averager
{
    void operator()(int n)
    {
        m_sum += n;
        m_count++;
    }
    float operator()() const
    {
        return static_cast<float>(m_sum)/(m_count==0?1:m_count);
    }
    int m_count{0};
    int m_sum{0};
};
  1. 修改main()方法以使用Averager类如下:
int main(int argc, char**argv)
{
    std::cout << "\n------ Exercise 2 ------\n";
    std::vector<int> values {1,2,3,4,5,6,7,8,9,10};
    Averager averager = std::for_each(values.begin(), values.end(), 
    Averager());
    std::cout << "Average of [";
    std::for_each(values.begin(), values.end(), Printer());
    std::cout << "] = ";
    std::cout << averager() << "\n";
    std::cout << "Complete.\n";
    return 0;
}
  1. 点击运行按钮。练习将运行并产生以下输出:

图 4.14:带有平均值的练习 2 输出

图 4.14:带有平均值的练习 2 输出

注意,std::for_each()返回传递给它的Averager的实例。这个实例被复制到变量averager中,然后包含了计算平均值所需的数据。在这个练习中,我们实现了两个函数对象或functor类:AveragerPrinter,当传递给 STL 算法for_each时,我们可以将它们用作函数。

std::function<>模板

C++11 引入了一个通用的多态函数包装模板,std::function<>,使得实现回调和其他与函数相关的功能更容易。std::function保存一个可调用对象,称为std::function将导致抛出std::bad_function_call异常。

函数对象可以存储、复制或调用目标,这些目标可以是以下任何可调用对象:函数、函数对象(定义了operator())、成员函数指针或 lambda 表达式。我们将在主题*什么是 Lambda 表达式?*中更多地介绍它。

在实例化std::function对象时,只需要提供函数签名,而不需要初始化它的值,导致一个空实例。实例化如下所示:

图 4.15:std::function 声明的结构

图 4.15:std::function 声明的结构

模板的参数定义了variable存储的目标的function signature。签名以返回类型开始(可以是 void),然后在括号内放置函数将被调用的类型列表。

使用自由函数和std::functionfunctor非常简单。只要签名与传递给std::function模板的参数匹配,我们就可以简单地将自由函数或functor等同于实例。

void FreeFunc(int value);
struct Functor 
{
    void operator()(int value);
};
std::function<void(int)> func;
Functor functor;
func = FreeFunc;                     // Set target as FreeFunc
func(32);                            // Call FreeFunc with argument 32
func = functor;                      // set target as functor
func(42);                            // Call Functor::operator() with argument 42

但是,如果我们想要在对象实例上使用一个方法,那么我们需要使用另一个 STL 辅助模板std::bind()。如果我们运行以下程序:

#include <iostream>
#include <functional>
struct Binder
{
    void method(int a, int b)
    {
        std::cout << "Binder::method(" << a << ", " << b << ")\n";
    }
};
int main()
{
    std::cout << "\n\n------ Member Functions using bind ------\n";
    Binder binder;
    std::function<void(int,int)> func;
    auto func1 = std::bind(&Binder::method, &binder, 1, 2);
    auto func2 = std::bind(&Binder::method, &binder, std::placeholders::_1, std::placeholders::_2);
    auto func3 = std::bind(&Binder::method, &binder, std::placeholders::_2, std::placeholders::_1);
    func = func1;
    func(34,56);
    func = func2;
    func(34,56);
    func = func3;
    func(34,56);
    std::cout << "Complete.\n";
    return 0;
}

然后我们得到以下输出:

图 4.16:使用 std<span>bind()和 std</span>function 的程序输出

图 4.16:使用 stdbind()和 stdfunction 的程序输出

注意几点:

  • 函数method()是使用类作为作用域限定符引用的;

  • Binder实例的地址作为第二个参数传递给std::bind(),这使其成为传递给method()的第一个参数。这是必要的,因为所有非静态成员都有一个隐式的this指针作为第一个参数传递。

  • 使用std::placeholders定义,我们可以绑定调用绑定方法时使用的参数,甚至改变传递的顺序(如func3所示)。

C++11 引入了一些称为 lambda 表达式的语法糖,使得更容易定义匿名函数,还可以用于绑定方法并将它们分配给std::function实例表达式。我们将在*什么是 Lambda 表达式?*主题中更多地涵盖它。

练习 3:使用 std::function 实现回调

在这个练习中,我们将利用std::function<>模板实现函数回调。按照以下步骤实现这个练习:

  1. 在 Eclipse 中打开Lesson4项目,然后在Project Explorer中展开Lesson4,然后展开Exercise03,双击Exercise3.cpp以将此练习的文件打开到编辑器中。

  2. 由于这是一个基于 CMake 的项目,请将当前构建器更改为 CMake Build(便携式)。

  3. 单击启动配置下拉菜单,然后选择新启动配置…。配置L4Exercise3以使用名称Exercise3运行。

  4. 单击运行按钮。练习将运行并产生以下输出:图 4.17:练习 3 输出(调用空的 std::function)

图 4.17:练习 3 输出(调用空的 std::function)
  1. 我们要做的第一件事是防止调用空的TestFunctionTemplate()func(42);,并用以下代码替换它:
if (func)
{
    func(42);
}
else
{
    std::cout << "Not calling an empty func()\n";
}
  1. 单击运行按钮。练习将运行并产生以下输出:图 4.18:练习 3 输出(防止调用空的 std::function)
图 4.18:练习 3 输出(防止调用空的 std::function)
  1. 在函数TestFunctionTemplate()之前的文件中添加FreeFunction()方法:
void FreeFunction(int n)
{
    std::cout << "FreeFunction(" << n << ")\n";
}
  1. TestFunctionTemplate()函数中,在if (func)之前立即添加以下行:
func = FreeFunction;
  1. 单击运行按钮。练习将运行并产生以下输出:图 4.19:练习 3 输出(FreeMethod)
图 4.19:练习 3 输出(FreeMethod)
  1. TestFunctionTemplate()函数之前添加新的类定义:
struct FuncClass
{
    void member(int n)
    {
        std::cout << "FuncClass::member(" << n << ")\n";
    }
    void operator()(int n)
    {
    std::cout << "FuncClass object(" << n << ")\n";
    }
};
  1. 用以下代码替换行func = FreeFunction;
FuncClass funcClass;
func = funcClass;
  1. 单击运行按钮。练习将运行并产生以下输出:4.20:练习 3 输出(对象函数调用覆盖)
4.20:练习 3 输出(对象函数调用覆盖)
  1. 用以下代码替换行func = funcClass;
func = std::bind(&FuncClass::member, &funcClass, std::placeholders::_1);
  1. 单击运行按钮。练习将运行并产生以下输出:图 4.21:练习 3 输出(成员函数)
图 4.21:练习 3 输出(成员函数)
  1. 用以下代码替换行func = std::bind(…);
func = [](int n) {std::cout << "lambda function(" << n << ")\n";};
  1. 单击运行按钮。练习将运行并产生以下输出:

图 4.22:练习 3 输出(lambda 函数)

图 4.22:练习 3 输出(lambda 函数)

在这个练习中,我们使用std::function模板实现了四种不同类型的函数回调-自由方法,类成员函数,类函数调用方法和 Lambda 函数(我们将在下面讨论)。

什么是 Lambda 表达式?

自 C11 以来,C支持匿名函数,也称为lambda 表达式lambda。Lambda 表达式的两种最常见形式是:

图 4.23:Lambda 表达式的最常见形式

图 4.23:Lambda 表达式的最常见形式

在正常情况下,编译器能够根据function_body中的返回语句推断 Lambda 的返回类型(如上图中的形式(1)所示)。然而,如果编译器无法确定返回类型,或者我们希望强制使用不同的类型,那么我们可以使用形式(2)。

[captures]之后的所有内容与普通函数定义相同,只是缺少名称。Lambda 是一种方便的方式,可以在将要使用的位置定义一个简短的方法(只有几行)。Lambda 通常作为参数传递,并且通常不会被重复使用。还应该注意,Lambda 可以分配给一个变量(通常使用 auto 声明)。

我们可以重新编写先前的程序,其中我们使用了Scaler类来使用 Lambda 来实现相同的结果:

#include <iostream>
#include <vector>
#include <algorithm>
void PrintVector(const char* prefix, std::vector<int>& values)
{
    const char* sep = "";
    std::cout << prefix << " = [";
    for(auto n : values)
    {
        std::cout << sep << n;
        sep = ", ";
    }
    std::cout << "]\n";
}
int main()
{
    std::cout << "\n\n------ Lambdas with STL ------\n";
    std::vector<int> values{1,2,3,4,5};
    PrintVector("Before transform", values);
    std::transform(values.begin(), values.end(), values.begin(),
    [] (int n) {return 5*n;}
    );
    PrintVector("After transform", values);
    std::cout << "Complete.\n";
    return 0;
}

当此程序运行时,输出显示向量已被缩放了 5 倍:

图 4.24:使用 lambda 进行缩放的转换

图 4.24:使用 lambda 进行缩放的转换

此程序中的 Lambda 是[](int n){return 5*n;},并且具有空的捕获子句[]。空的捕获子句意味着 Lambda 函数不访问周围范围内的任何变量。如果没有参数传递给 Lambda,则参数子句()是可选的。

捕获数据到 Lambda 中

operator()

捕获子句是零个或多个被捕获变量的逗号分隔列表。还有默认捕获的概念-通过引用或通过值。因此,捕获的基本语法是:

  • [&] - 通过引用捕获作用域内的所有自动存储期变量

  • [=] - 通过值捕获作用域内的所有自动存储期变量(制作副本)

  • [&x, y] - 通过引用捕获 x,通过值捕获 y

编译器将此转换为由匿名functor类的构造函数初始化的成员变量。在默认捕获(&=)的情况下,它们必须首先出现,且仅捕获体中引用的变量。默认捕获可以通过在默认捕获后的捕获子句中放置特定变量来覆盖。例如,[&,x]将默认通过引用捕获除x之外的所有内容,它将通过值捕获x

然而,默认捕获虽然方便,但并不是首选的捕获方法。这是因为它可能导致悬空引用(通过引用捕获和引用的变量在 Lambda 访问时不再存在)或悬空指针(通过值捕获,特别是 this 指针)。明确捕获变量更清晰,而且编译器能够警告您意外效果(例如尝试捕获全局或静态变量)。

C++14 引入了init capture到捕获子句,允许更安全的代码和一些优化。初始化捕获在捕获子句中声明一个变量,并初始化它以在 Lambda 内部使用。例如:

int x = 5;
int y = 6;
auto fn = [z=x*x+y, x, y] ()
            {   
                std::cout << x << " * " << x << " + " << y << " = " << z << "\n"; 
            };
fn();

在这里,z在捕获子句中声明并初始化,以便可以在 Lambda 中使用。如果要在 Lambda 中使用 x 和 y,则它们必须分别捕获。如预期的那样,当调用 Lambda 时,它会产生以下输出:

5 * 5 + 6 = 31

初始化捕获也可以用于将可移动对象捕获到 Lambda 中,或者如下所示复制类成员:

struct LambdaCapture
{
  auto GetTheNameFunc ()
  {
    return [myName = myName] () { return myName.c_str(); };  
  }
  std::string myName;
};

这捕获了成员变量的值,并恰好给它相同的名称以在 lambda 内部使用。

默认情况下,lambda 是一个 const 函数,这意味着它不能改变按值捕获的变量的值。在需要修改值的情况下,我们需要使用下面显示的 lambda 表达式的第三种形式。

图 4.25:另一种 lambda 表达式形式

图 4.25:另一种 lambda 表达式形式

在这种情况下,specifiersmutable替换,告诉编译器我们想要修改捕获的值。如果我们不添加 mutable,并尝试修改捕获的值,那么编译器将产生错误。

练习 4:实现 Lambda

在这个练习中,我们将实现 lambda 以在 STL 算法的上下文中执行多个操作。按照以下步骤实现这个练习:

  1. 在 Eclipse 中打开Lesson4项目,然后在Project Explorer中展开Lesson4,然后双击Exercise04,再双击Exercise4.cpp以将此练习的文件打开到编辑器中。

  2. 由于这是一个基于 CMake 的项目,将当前构建器更改为 CMake Build(便携式)。

  3. 单击Launch Configuration下拉菜单,选择New Launch Configuration…。配置L4Exercise4以使用名称Exercise4运行。

  4. 单击运行按钮。练习将运行并产生以下输出:图 4.26:练习 4 的初始输出

图 4.26:练习 4 的初始输出
  1. 程序PrintVector()main()PrintVector()与我们在*什么是函数对象?*中介绍的版本相同。现在修改它以使用std::for_each()库函数和 lambda,而不是范围 for 循环。更新PrintVector()如下:
void PrintVector(const char* prefix, std::vector<int>& values)
{
    const char* sep = "";
    std::cout << prefix << " = [";
    std::for_each(values.begin(), values.end(),
            [&sep] (int n)
            {
                std::cout << sep << n;
                sep = ", ";
            }
    );
    std::cout << "]\n";
}
  1. 单击运行按钮,我们得到与之前相同的输出。

  2. 检查 lambda,我们通过引用捕获了本地变量sep。从sep中删除&,然后单击运行按钮。这次编译失败,并显示以下错误:图 4.27:由于修改只读变量而导致的编译失败

图 4.27:由于修改只读变量而导致的编译失败
  1. 更改 lambda 声明以包括mutable修饰符:
[sep] (int n) mutable
{
    std::cout << sep << n;
    sep = ", ";
}
  1. 单击运行按钮,我们得到与之前相同的输出。

  2. 但我们可以再进一步。从函数PrintVector()的声明中删除sep,并再次更改 lambda 以包括 init 捕获。编写以下代码来实现这一点:

[sep = ""] (int n) mutable
{
    std::cout << sep << n;
    sep = ", ";
}
  1. 单击PrintVector(),现在看起来更紧凑:
void PrintVector(const char* prefix, std::vector<int>& values)
{
    std::cout << prefix << " = [";
    std::for_each(values.begin(), values.end(), [sep = ""] (int n) mutable
                                  { std::cout << sep << n; sep = ", ";} );
    std::cout << "]\n";
}
  1. main()方法中调用PrintVector()之后,添加以下行:
std::sort(values.begin(), values.end(), [](int a, int b) {return b<a;} );
PrintVector("After sort", values);
  1. 单击运行按钮,现在的输出添加了按降序排序的值列表:图 4.28:按降序排序 lambda 的程序输出
图 4.28:降序排序 lambda 的程序输出
  1. 将 lambda 函数体更改为{return a<b;}。单击运行按钮,现在的输出显示值按升序排序:图 4.29:按升序排序 lambda 的程序输出
图 4.29:按升序排序 lambda 的程序输出
  1. 在调用PrintVector()函数之后,添加以下代码行:
int threshold{25};
auto pred = [threshold] (int a) { return a > threshold; };
auto count = std::count_if(values.begin(), values.end(), pred);
std::cout << "There are " << count << " values > " << threshold << "\n";
  1. 单击值> 25图 4.30:存储在变量中的 count_if lambda 的输出
图 4.30:存储在变量中的 count_if lambda 的输出
  1. 在上述行之后添加以下行,并单击运行按钮:
threshold = 40;
count = std::count_if(values.begin(), values.end(), pred);
std::cout << "There are " << count << " values > " << threshold << "\n";

以下输出将被生成:

图 4.31:通过重用 pred lambda 产生的错误输出

图 4.31:通过重用 pred lambda 产生的错误输出
  1. 程序错误地报告有七(7)个值> 40;应该是三(3)。问题在于当创建 lambda 并将其存储在变量pred中时,它捕获了阈值的当前值,即25。将定义pred的行更改为以下内容:
auto pred = [&threshold] (int a) { return a > threshold; };
  1. 单击运行按钮,现在输出正确报告计数:

图 4.32:重用 pred lambda 的正确输出

图 4.32:重用 pred lambda 的正确输出

在这个练习中,我们使用 lambda 表达式语法的各种特性实现了几个 lambda,包括 init 捕获和 mutable。

使用 lambda

虽然 lambda 是 C++的一个强大特性,但应该适当使用。目标始终是生成可读的代码。因此,虽然 lambda 可能很简短并且简洁,但有时将功能分解为一个命名良好的方法会更好以便于维护。

可变模板

第 2B 章不允许鸭子-模板和推导中,我们介绍了泛型编程和模板。在 C03 之前的 C中,模板一直是 C的一部分。在 C11 之前,模板的参数数量是有限的。在某些情况下,需要变量数量的参数,需要为所需参数数量的每个变体编写模板。或者,有像printf()这样可以接受可变数量参数的可变函数。可变函数的问题在于它们不是类型安全的,因为对参数的访问是通过va_arg宏进行类型转换。C11 通过引入可变模板改变了这一切,其中一个模板可以接受任意数量的参数。C17 通过引入constexpr if 结构改进了可变模板的编写,该结构允许基本情况模板与“递归”模板合并。

最好的方法是实现一个可变模板并解释它是如何工作的。

#include <iostream>
#include <string>
template<typename T, typename... Args>
T summer(T first, Args... args) {
    if constexpr(sizeof...(args) > 0)
          return first + summer(args...);
    else
        return first;
}
int main()
{
    std::cout << "\n\n------ Variadic Templates ------\n";
    auto sum = summer(1, 3, 5, 7, 9, 11);
    std::cout << "sum = " << sum << "\n";
    std::string s1{"ab"};
    std::string s2{"cd"};
    std::string s3{"ef"};
    std::string strsum = summer(s1, s2, s3);
    std::cout << "strsum = " << strsum << "\n";
    std::cout << "Complete.\n";
    return 0;
}

当我们运行这个程序时,我们得到以下输出:

图 4.33:可变模板程序输出

图 4.33:可变模板程序输出

那么,可变模板的部分是什么?我们如何阅读它?考虑上面程序中的模板:

template<typename T, typename... Args>
T summer(T first, Args... args) {
    if constexpr(sizeof...(args) > 0)
        return first + summer(args...);
    else
        return first;
}

在上面的代码中:

  • typename... Args 声明 Args模板参数包

  • Args... args是一个函数参数包,其类型由Args给出。

  • sizeof...(args)返回args中包的元素数量。这是一种特殊形式的包扩展。

  • args...在对summer()的递归调用中展开了包。

或者,您可以将模板视为等效于:

template<typename T, typename T1, typename T2, ..., typename Tn>
T summer(T first, T1 t1, T2, ..., Tn tn) {
    if constexpr(sizeof...( t1, t2, ..., tn) > 0)
        return first + summer(t1, t2, ..., tn);
    else
        return first;
}

当编译器处理样本程序中的summer(1, 3, 5, 7, 9, 11)时,它执行以下操作:

  • 它推断T是 int,Args...是我们的参数包,带有<int, int, int, int, int>。

  • 由于包中有多个参数,编译器生成了first + summer(args...),省略号展开了模板参数,将summer(args...)转换为summer(3,5,7,9,11)

  • 然后编译器生成了summer(3,5,7,9,11)的代码。同样,应用了first + summer(args...),其中summer(5,7,9,11)

  • 这个过程重复进行,直到编译器必须为summer(11)生成代码。在这种情况下,constexpr if 语句的 else 子句被触发,它简单地返回first

由于类型由模板的参数确定,因此我们不限于参数具有相同的类型。我们已经在 STL 中遇到了一些可变模板-std::function和 std::bind。

还有另一种类型的可变模板,它将其参数转发到另一个函数或模板。这种类型的模板本身并不做太多事情,但提供了一种标准的方法。一个例子是make_unique模板,可以实现为:

template<typename T, typename... Args>
unique_ptr<T> make_unique(Args&&... args)
{
    return unique_ptr<T>(new T(std::forward<Args>(args)...));
}

make_unique必须调用 new 运算符来分配内存,然后调用类型的适当构造函数。调用构造函数所需的参数数量可能会有很大的变化。这种形式的可变模板引入了一些额外的包扩展:

  • Args&&...表示我们有一系列转发引用。

  • std::forward<Args>(args)...包含要一起展开的参数包,必须具有相同数量的元素-Args 模板参数包和 args 函数参数包。

每当我们需要在可变参数模板中将一个函数调用转发到另一个函数调用时,就会使用这种模式。

活动 1:实现多播事件处理程序

1992 年,当 C++处于萌芽阶段时,微软首次引入了Microsoft Foundation ClassMFC)。这意味着许多围绕这些类的设计选择受到了限制。例如,事件的处理程序通常通过OnEventXXX()方法路由。这些通常使用宏配置为从 MFC 类派生的类的一部分。您的团队被要求使用模板来实现更像 C#中可用的委托的多播事件处理程序,这些模板体现了函数对象,并导致可变参数模板以实现可变参数列表。

在 C#中,您可以声明委托如下:

delegate int Handler(int parameter);

这使得 Handler 成为一个可以分配值并进行调用的类型。这基本上就是 C++中std::function<>为我们提供的,除了能够进行多播。您的团队被要求开发一个模板类Delegate,可以像 C#委托一样进行操作。

  • Delegate 将接受“可变参数列表”,但只返回void

  • operator+=将用于向委托添加新的回调

  • 它将使用以下语法之一进行调用:delegate.Notify(…)delegate(…)

按照以下步骤开发 Delegate 模板:

  1. Lesson4/Activity01文件夹加载准备好的项目,并为项目配置当前构建器为 CMake Build(Portable)。

  2. 构建项目,配置启动器并运行单元测试(其中一个虚拟测试失败)。建议为测试运行器使用的名称是L4delegateTests

  3. 实现一个Delegate类,可以使用所有必需的方法包装单个处理程序,并支持回调的单个 int 参数。

  4. 更新模板类以支持多播。

  5. Delegate类转换为模板,可以接受定义回调函数使用的参数类型的单个模板参数。

  6. Delegate模板转换为可变参数模板,可以接受零个或多个定义传递给回调函数的类型的参数。

按照上述步骤后,预期输出如下:

图 4.34:Delegate 成功实现的输出

图 4.34:Delegate 成功实现的输出

注意

此活动的解决方案可在第 673 页找到。

摘要

在本章中,我们实现了一种数据和方法隐藏的设计方法 PIMPL,它具有减少依赖关系和减少构建时间的附加好处。然后,我们直接将函数对象实现为自定义类,然后作为 lambda 函数。然后,我们通过深入研究可变参数模板来扩展我们的模板编程技能,最终实现了一个可用于事件回调处理的模板。在下一章中,我们将学习如何使用 C++的特性来开发具有多个线程的程序,并通过并发构造来管理它们的协作。

第六章:哲学家的晚餐——线程和并发

学习目标

在本章结束时,您将能够:

  • 创建同步和异步多线程应用程序

  • 应用同步处理数据危害和竞争条件

  • 使用 C++线程库原语开发高效的多线程代码

  • 使用移动语义创建线程以进行多线程闭包

  • 使用 futures、promises 和 async 实现线程通信

在本章中,我们将澄清多线程编程中基本术语的区别,学习如何编写多线程代码,了解 C++标准库提供的数据访问同步资源,学习如何防止我们的代码遇到竞争条件和死锁。

介绍

在上一章中,我们涵盖了 C中不同类型的依赖和耦合。我们看了一下如何在 C中实现常见的 API 设计模式和习惯用法,以及标准库提供的数据结构及其效果。我们还学习了如何使用函数对象、lambda 和捕获。这些知识将帮助我们学习如何编写清晰和高效的多线程程序。

本章的标题包含了并发编程中最重要的同步问题的名称——哲学家的晚餐。简而言之,这个定义如下。

三位哲学家坐在圆桌旁,桌上有寿司碗。筷子放在每个相邻的哲学家之间。一次只有一个哲学家可以用两根筷子吃寿司。也许每个哲学家都会拿一根筷子,然后等待直到有人放弃另一根筷子。哲学家是三个工作进程的类比,筷子是共享资源。"谁会先拿起两根筷子"象征着竞争条件。当每个哲学家拿着一根筷子并等待另一根筷子可用时,就会导致死锁。这个类比解释了多线程期间出现的问题。

我们将从对主要多线程概念的简要介绍开始本章。我们将考虑同步、异步和线程执行之间的区别。通过清晰简单的例子,我们将从同步、数据危害和竞争条件开始。我们将找出它们为什么出现在我们的代码中以及我们如何管理它们。本章的下一部分专门讨论了用于线程执行的 C++标准库。通过示例,我们将学习如何以及何时使用线程库原语,以及移动语义如何与线程交互。我们还将练习使用futurespromisesasync来从线程中接收结果。

本章将以一个具有挑战性的活动结束,我们将创建一个艺术画廊模拟器,通过模拟访客和画廊工作人员来工作。我们将开发一个多线程生成器,同时创建和移除艺术画廊的访客。接下来,我们将创建一个负责将访客带过画廊的多线程类。他们将使用同步技术相互交互。最后,我们将创建线程安全的存储,这些实例将从不同的线程中访问。

在下一节中,我们将澄清并发编程概念之间微妙的区别:同步异步线程执行。

同步、异步和线程执行

并发编程的概念之间存在微妙的区别:同步异步线程执行。为了澄清这一点,我们将从最基本的开始,从并发和并行程序的概念开始。

并发

并发性的概念不仅仅是同时执行多个任务。并发性并不指定如何实现同时性。它只表示在给定时间内将完成多个任务。任务可以是依赖性的并行的同步的异步的。以下图表显示了并发工作的概念:

图 5.1:并发性的抽象 - 一些人在同一台计算机上工作

图 5.1:并发性的抽象 - 一些人在同一台计算机上工作

在上图中,三个人同时在一台计算机上工作。我们对他们的工作方式不感兴趣,对于这个抽象层级来说并不重要。

并行性

并行性发生在多个任务同时执行时。由于硬件的能力,这些任务可以并行工作。最好的并行性示例是多核处理器。对于并行执行,任务被分成完全独立的子任务,这些子任务在不同的处理器核心上执行。之后,执行的结果可以被合并。看一下以下图表,以了解并行性的概念:

图 5.2:并行性的抽象 - 所有任务都由不同的人执行;他们不相互交互

在上图中,有三个人同时在自己的计算机上工作 - 嗯,他们在并行工作。

注意

并发性并行性并不是一回事。并行性是对并发性的补充。它告诉我们任务是如何执行的:它们彼此独立,并在不同的计算单元上运行,也就是处理器或核心。

现在,我们将平稳地过渡到线程执行的概念。当我们谈论线程时,我们指的是执行线程。这是操作系统的一个抽象,它允许我们同时执行多个任务。请记住,整个程序在一个单独的进程中执行。操作系统分配main()函数。我们可以创建一个新的线程来执行,并分配一个开始函数,这将是这个线程的起始点。

注意

处理器的地址空间和寄存器被称为线程上下文。当操作系统中断线程的工作时,它必须存储当前线程的上下文并加载下一个线程的上下文。

让我们考虑以下示例中的新线程的创建。要创建一个新线程,我们必须包含<thread>头文件。它包含了用于管理线程的类和函数。实际上,有几种可能的方法来创建一个std::thread对象和线程执行,如下所示:

  • 创建一个没有显式初始化的std::thread对象。记住,线程需要一个启动函数来运行它的工作。我们没有指出哪个函数是这个线程的主要函数。这意味着执行线程没有被创建。让我们看一下以下代码示例,其中我们创建一个空的std::thread对象:
#include <thread>
int main()
{
  std::thread myThread;  
  return 0;
}
  • 创建一个std::thread对象,并将一个指向函数的指针作为构造函数参数传递。现在,执行线程将被创建,并将从我们在构造函数中传递的函数开始执行其工作。让我们看一下以下代码示例:
#include <iostream>
#include <thread>
void printHello()
{
    std::cout << "hello" << std::endl;
}
int main()
{
  std::thread myThread(printHello);
  myThread.join();
  return 0;
}

在这里,我们创建了一个std::thread对象,并用函数指针进行了初始化。这是一个简单的返回void并且不带任何参数的函数。然后,我们告诉主线程等待直到新线程完成,使用join()函数。我们总是必须在std::thread对象的作用域结束之前join()detach()一个线程。如果不这样做,我们的应用程序将被操作系统使用std::terminate()函数终止,该函数在std::thread析构函数中被调用。除了函数指针,我们还可以传递任何可调用对象,如lambdastd::function或具有重载的operator()的类。

注意

执行线程可以在std::thread对象销毁之前完成其工作。它也可以在执行线程完成其工作之前被销毁。在销毁std::thread对象之前,始终要join()detach()它。

现在我们知道了创建线程的主要语法,我们可以继续了解下一个重要概念。让我们找出同步、异步和多线程执行的含义。

同步执行

同步执行这个术语意味着每个子任务将按顺序依次执行。换句话说,这意味着如果我们有几个任务要执行,每个任务只能在前一个任务完成工作后才能开始工作。这个术语并没有指定执行任务的方式,或者它们是否将在单个线程或多个线程中执行。它只告诉我们执行顺序。让我们回到哲学家晚餐的例子。在单线程世界中,哲学家们将依次进餐。

第一个哲学家拿起两根筷子吃寿司。然后,第二个哲学家拿起两根筷子吃寿司。他们轮流进行,直到所有人都吃完寿司。看一下以下图表,它表示了在单个线程中同步执行四个任务:

图 5.3:单线程中的同步执行

图 5.3:单线程中的同步执行

在这里,每个任务都等待前一个任务完成。任务也可以在多个线程中同步执行。考虑以下图表,它表示了在多个线程中同步执行四个任务。同样,每个任务都等待前一个任务完成:

图 5.4:多线程中的同步执行

在这种情况下,每个任务在单独的线程中启动,但只有在前一个线程完成其工作后才能启动。在多线程世界中,哲学家们仍然会依次进餐,但有一个小区别。现在,每个人都有自己的筷子,但只能按严格的顺序进餐。

注意

同步执行意味着每个任务的完成时间是同步的。任务的执行顺序是重点。

让我们考虑以下代码示例中的同步执行。当我们在单个线程中运行任务时,我们只需调用通常的函数。例如,我们实现了四个打印消息到终端的函数。我们以同步的单线程方式运行它们:

#include <iostream>
void printHello1()
{
    std::cout << "Hello from printHello1()" << std::endl;    
}
void printHello2()
{
    std::cout << "Hello from printHello2()" << std::endl;    
}
void printHello3()
{
    std::cout << "Hello from printHello3()" << std::endl;    
}
void printHello4()
{
    std::cout << "Hello from printHello4()" << std::endl;    
}
int main()
{
    printHello1();
    printHello2();
    printHello3();
    printHello4();
    return 0;
}

在这里,我们依次调用所有函数,每个下一个函数在前一个函数执行完之后运行。现在,让我们在不同的线程中运行它们:

#include <iostream>
#include <thread>
void printHello1()
{
    std::cout << "Hello from printHello1()" << std::endl;    
}
void printHello2()
{
    std::cout << "Hello from printHello2()" << std::endl;    
}
void printHello3()
{
    std::cout << "Hello from printHello3()" << std::endl;    
}
void printHello4()
{
    std::cout << "Hello from printHello4()" << std::endl;    
}
int main()
{
    std::thread thread1(printHello1);
    thread1.join();
    std::thread thread2(printHello2);
    thread2.join();
    std::thread thread3(printHello3);
    thread3.join();
    std::thread thread4(printHello4);
    thread4.join();
    return 0;
}

在前面的代码示例中,我们创建了四个线程并立即加入它们。因此,每个线程在运行之前都完成了它的工作。正如你所看到的,对于任务来说没有任何变化-它们仍然按严格的顺序执行。

异步执行

这是一种几个任务可以同时执行而不阻塞任何线程执行的情况。通常,主线程启动异步操作并继续执行。执行完成后,结果被发送到主线程。通常,执行异步操作与为其创建一个单独的线程无关。任务可以由其他人执行,比如另一个计算设备、远程网络服务器或外部设备。让我们回到哲学家晚餐的例子。

异步执行的情况下,所有的哲学家都有自己的筷子,可以独立地进餐。当寿司准备好并且服务员端上来时,他们都开始进餐,并且可以按照自己的时间完成。

注意

异步执行中,所有任务相互独立工作,知道每个任务的完成时间并不重要。

看一下以下图表,它表示了在多个线程中异步执行四个任务:

图 5.5:多线程中的异步执行

图 5.5:多线程中的异步执行

它们每一个都在不同的时间开始和结束。让我们用一个代码示例来考虑这种异步执行。例如,我们实现了四个打印消息到终端的函数。我们在不同的线程中运行它们:

#include <iostream>
#include <thread>
#include <chrono>
void printHello1()
{
    std::cout << "Hello from thread: " << std::this_thread::get_id() << std::endl;    
}
void printHello2()
{
    std::cout << "Hello from thread: " << std::this_thread::get_id() << std::endl;    
}
void printHello3()
{
    std::cout << "Hello from thread: " << std::this_thread::get_id() << std::endl;    
}
void printHello4()
{
    std::cout << "Hello from thread: " << std::this_thread::get_id() << std::endl;    
}
int main()
{
    std::thread thread1(printHello1);
    std::thread thread2(printHello2);
    std::thread thread3(printHello3);
    std::thread thread4(printHello4);
    thread1.detach();
    thread2.detach();
    thread3.detach();
    thread4.detach();

    using namespace std::chrono_literals;
    std::this_thread::sleep_for(2s);
    return 0;
}

让我们看看这里发生了什么。我们使用了前面示例中的四个函数,但它们稍作了修改。我们通过使用std::this_thread::get_id()函数添加了线程的唯一 ID 的打印。这个函数返回std::thread::id对象,表示线程的唯一 ID。这个类有重载的操作符用于输出和比较,所以我们可以以不同的方式使用它。例如,我们可以检查线程 ID,如果是主线程的 ID,我们可以执行特殊的任务。在我们的示例中,我们可以将线程 ID 打印到终端上。接下来,我们创建了四个线程并将它们分离。这意味着没有线程会等待其他线程完成工作。从这一刻起,它们成为守护线程

它们将继续它们的工作,但没有人知道。然后,我们使用了std::this_thread::sleep_for(2s)函数让主线程等待两秒。我们这样做是因为当主线程完成它的工作时,应用程序会停止,我们将无法在终端上查看分离线程的输出。下面的截图是终端输出的一个例子:

图 5.6:示例执行的结果

图 5.6:示例执行的结果

在你的 IDE 中,输出可能会改变,因为执行顺序是不确定的。异步执行的一个现实例子可以是一个互联网浏览器,在其中你可以打开多个标签页。当打开一个新标签页时,应用程序会启动一个新线程并将它们分离。虽然线程工作是独立的,但它们可以共享一些资源,比如文件处理程序,用于写日志或执行其他操作。

注意

std::thread有一个成员函数叫做get_id(),它返回std::thread实例的唯一 ID。如果std::thread实例没有初始化,或者已经加入或分离,get_id()会返回一个默认的std::thread::id对象。这意味着当前std::thread实例没有与任何执行线程相关联。

让我们用一些伪代码来展示一个例子,其中计算由另一个计算单元完成。例如,假设我们开发了一个应用程序,用于进行货币兑换的计算。用户输入一种货币的金额,选择另一种货币进行兑换,应用程序会显示他们在那种货币中的金额。在后台,应用程序向保存所有货币兑换率的远程服务器发送请求。

远程服务器计算给定货币的金额并将结果返回。您的应用程序在那时显示一个进度条,并允许用户执行其他操作。当它收到结果时,它会在窗口上显示它们。让我们看一下下面的代码:

#include <thread>
void runMessageLoop()
{
    while (true)
    {
        if (message)
        {
            std::thread procRes(processResults, message);
            procRes.detach();
        }
    }
}
void processResults(Result res)
{
    display();
}
void sendRequest(Currency from, Currency to, double amount)
{
    send();
}
void displayProgress()
{
}
void getUserInput()
{
    Currency from;
    Currency to;
    double amount;
    std::thread progress(displayProgress);
    progress.detach();
    std::thread request(sendRequest, from, to, amount);
    request.detach();
}
int main()
{
    std::thread messageLoop(runMessageLoop);
    messageLoop.detach();

    std::thread userInput(getUserInput);
    userInput.detach();    
    return 0;
}

让我们看看这里发生了什么。在main()函数中,我们创建了一个名为messageLoop的线程,执行runMessageLoop()函数。可以在这个函数中放置一些代码,检查是否有来自服务器的新结果。如果收到新结果,它会创建一个新线程procRes,该线程将在窗口中显示结果。我们还在main()函数中创建了另一个线程userInput,它从用户那里获取货币和金额,并创建一个新线程request,该线程将向远程服务器发送请求。发送请求后,它创建一个新线程progress,该线程将显示一个进度条,直到收到结果。由于所有线程都被分离,它们能够独立工作。当然,这只是伪代码,但主要思想是清楚的-我们的应用程序向远程服务器发送请求,远程服务器为我们的应用程序执行计算。

让我们回顾一下我们通过日常生活中的一个例子学到的并发概念。这是一个背景,在这个背景中,您需要编写一个应用程序,并提供与之相关的所有文档和架构概念:

  • 单线程工作:您自己编写。

  • 多线程工作:您邀请朋友一起编写项目。有人编写架构概念,有人负责文档工作,您专注于编码部分。所有参与者彼此沟通,以澄清任何问题并共享文档,例如规格问题。

  • 并行工作:任务被分开。有人为项目编写文档,有人设计图表,有人编写测试用例,您独立工作。参与者之间根本不沟通。

  • 同步工作:在这种情况下,每个人都无法理解他们应该做什么。因此,您决定依次工作。当架构工作完成时,开发人员开始编写代码。然后,当开发工作完成时,有人开始编写文档。

  • 异步工作:在这种情况下,您雇佣了一个外包公司来完成项目。当他们开发项目时,您将从事其他任务。

现在,让我们将我们学到的知识应用到实践中,并解决一个练习,看看它是如何工作的。

练习 1:以不同的方式创建线程

在这个练习中,我们将编写一个简单的应用程序,创建四个线程;其中两个将以同步方式工作,另外两个将以异步方式工作。它们都将向终端打印一些符号,以便我们可以看到操作系统如何切换线程执行。

注意

在项目设置中添加 pthread 链接器标志,以便编译器知道您将使用线程库。对于 Eclipse IDE,您可以按照以下路径操作:Eclipse 版本:3.8.1,不同版本可能会有所不同。

完成此练习,执行以下步骤:

  1. 包括一些用于线程支持的头文件,即<thread>,流支持,即<iostream>,和函数对象支持,即<functional>
#include <iostream>
#include <thread>
#include <functional>
  1. 实现一个名为printNumbers()的自由函数,在for循环中打印 0 到 100 的数字:
void printNumbers()
{
    for(int i = 0; i < 100; ++i)
    {
        std::cout << i << " ";
    }
    std::cout << std::endl;
}
  1. 实现一个可调用对象,即一个具有重载的operator()Printer类,它在for循环中从 0 到 100000 打印一个"*"符号。每 200 次迭代,打印一个新行符号,以获得更可读的输出:
class Printer
{
    public:
    void operator()()
    {
        for(int i = 0; i < 100000; ++i)
        {
            if (!(i % 200))
            {
                std::cout << std::endl;
            }
            std::cout << "*";
        }
    }
};
  1. 进入main()函数,然后创建一个名为printRevers的 lambda 对象,在for循环中打印 100 到 0 的数字:
int main()
{
    auto printRevers = []()
    {
        for(int i = 100; i >= 0; --i)
        {
            std::cout << i << " ";
        }
        std::cout << std::endl;
    };
    return 0;
}
  1. 实现一个名为printOtherstd::function对象,它在for循环中从0100000打印一个"^"符号。每 200 次迭代,打印一个新行符号,以获得更可读的输出:
std::function<void()> printOther = []()
{
    for(int i = 0; i < 100000; ++i)
    {
        if (!(i % 200))
        {
            std::cout << std::endl;
        }
        std::cout << "^";
    }
};
  1. 创建第一个线程thr1,并将printNumbers自由函数传递给其构造函数。加入它:
std::thread thr1(printNumbers);
thr1.join();
  1. 创建第二个线程thr2,并将printRevers lambda 对象传递给其构造函数。加入它:
std::thread thr2(printRevers);
thr2.join();
  1. 创建一个名为printPrinter类的实例。创建第三个线程thr3,并用print对象初始化它。使用detach()方法将其分离:
Printer print;
std::thread thr3(print);
thr3.detach();
  1. 创建最后一个线程thr4,并用printOther对象初始化它。分离它:
std::thread thr4(printOther);
thr4.detach();
  1. main()函数退出之前添加std::getchar()函数调用。这样可以避免关闭应用程序。我们将有可能看到分离的线程是如何工作的:
std::getchar();
  1. 在编辑器中运行此代码。您将看到thr1开始执行,程序等待。thr1完成后,thr2开始执行,程序等待。这是同步执行的一个例子。thr2完成工作后,线程thr3thr4开始执行。它们被分离,所以程序可以继续执行。在下面的输出中,您将看到符号混合。这是因为操作系统执行中断,线程同时工作。

你的输出将类似于以下内容:

图 5.7:练习执行的结果

在这个练习中,我们实现了四种不同的初始化线程的方式:使用自由函数、使用 lambda 对象、使用可调用对象和使用std::function对象。还有一些更多的初始化线程的方式,但我们将在下一节中考虑它们。我们还回顾了如何在多个线程中实现同步程序。我们还尝试实现了异步程序,并看到线程确实可以同时独立地工作。在下一节中,我们将学习数据危害和竞争条件,以及如何通过使用同步技术来避免它们。

回顾同步、数据危害和竞争条件

多线程编程的关键挑战是了解线程如何处理共享数据。共享数据,也称为资源,不仅是变量,还包括文件描述符和环境变量,甚至是 Windows 注册表。例如,如果线程只是读取数据,那么就没有问题,也不需要同步。但是,如果至少有一个线程编辑数据,就可能出现竞争条件。通常,对数据的操作不是原子的,也就是说,它们需要几个步骤。即使是对数字变量的最简单的增量操作也是在以下三个步骤中完成的:

  1. 读取变量的值。

  2. 增加它。

  3. 写入新值。

由于操作系统的中断,线程在完成操作之前可能会被停止。例如,我们有线程 A 和 B,并且有一个等于 0 的变量。

线程 A 开始增量:

  1. 读取变量的值(var = 0)。

  2. 增加它(tmp = 1)。

  3. 被操作系统中断。

线程 B 开始增量:

  1. 读取变量的值(var = 0)。

  2. 增加它(tmp = 1)。

  3. 写入新值(var = 1)。

  4. 被操作系统中断。

线程 A 继续增量:

  1. 写入新值(var = 1)。

因此,我们期望在工作完成后变量等于 2,但实际上它等于 1。看一下下面的图表,以更好地理解这个例子:

图 5.8:两个线程增加相同的共享变量

图 5.8:两个线程增加相同的共享变量

让我们回到哲学家的晚餐类比。最初的问题是一个哲学家只有一根筷子。如果他们都饿了,那么他们会赶紧抓起两根筷子。第一个抓起两根筷子的哲学家将第一个吃饭,其他人必须等待。他们会争夺筷子。

现在,让我们将我们的知识应用到实践中,并编写一些代码,看看竞争条件如何出现在我们的代码中,并且如何损害我们的数据。

练习 2:编写竞争条件示例

在这个练习中,我们将编写一个简单的应用程序,演示竞争条件的实际情况。我们将创建一个经典的“检查然后执行”竞争条件的例子。我们将创建一个线程,执行两个数字的除法。我们将通过引用传递这些数字。在检查后,如果被除数等于 0,我们将设置一个小的超时。此时在主线程中,我们将将被除数设置为 0。当子线程醒来时,它将执行除以 0 的操作。这将导致应用程序崩溃。我们还将添加一些日志来查看执行流程。

注意

默认情况下,当变量传递给线程时,所有变量都会被复制。要将变量作为引用传递,请使用std::ref()函数。

首先,我们实现没有竞争条件的代码,并确保它按预期工作。执行以下步骤:

  1. 包括线程支持的头文件,即<thread>,流支持的头文件,即<iostream>,和函数对象支持的头文件,即<functional>
#include <iostream>
#include <chrono>
#include <thread>
  1. 实现一个divide()函数,执行两个整数的除法。通过引用传递divisordividend变量。检查被除数是否等于 0。然后,添加日志:
void divide(int& divisor, int& dividend)
{
    if (0 != dividend)
    {
        std::cout << "Dividend = " << dividend << std::endl;
        std::cout << "Result: " << (divisor / dividend) << std::endl;    
    }
    else
    {
        std::cout << "Error: dividend = 0" << std::endl;
    }
}
  1. 进入main()函数,创建两个名为divisordividend的整数,并用任意非零值初始化它们:
int main()
{
    int divisor = 15;
    int dividend = 5;
    return 0;
}
  1. 创建thr1线程,传递divide函数,使用引用传递divisordividend,然后分离线程:
std::thread thr1(divide, std::ref(divisor), std::ref(dividend));
thr1.detach();
std::getchar();

注意

std::this_thread命名空间中有一个名为sleep_for的函数,它可以阻塞线程一段时间。作为参数,它采用std::chrono::duration - 一个表示时间间隔的模板类。

  1. 在编辑器中运行此代码。您将看到divide()函数在thr1中正常工作。输出如下所示:图 5.9:正确练习执行的结果
图 5.9:正确练习执行的结果

现在,我们将继续进行更改,以演示竞争条件。

  1. 返回函数,并在if条件后为子线程设置睡眠时间为2s。添加日志:
if (0 != dividend)
{
    std::cout << "Child thread goes sleep" << std::endl;
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(2s);
    std::cout << "Child thread woke up" << std::endl;
    std::cout << "Dividend = " << dividend << std::endl;
    std::cout << (divisor / dividend) << std::endl;
}
  1. 返回main()函数,将主线程的睡眠时间设置为1s。之后,将dividend变量设置为0。添加日志:
std::cout << "Main thread goes sleep" << std::endl;
using namespace std::chrono_literals;
std::this_thread::sleep_for(1s);
std::cout << "Main thread woke up" << std::endl;
dividend = 0;   
std::cout << "Main thread set dividend to 0" << std::endl;

注意

std::chrono_literals命名空间包含时间表示的字面量:h表示小时min表示分钟s表示ms表示毫秒us表示微秒ns表示纳秒。要使用它们,只需将它们添加到数字的末尾,例如,1s,1min,1h 等。

  1. main()函数退出之前添加std::getchar()函数调用。这样可以避免关闭应用程序,我们将有可能看到分离线程的工作方式:
std::getchar();
  1. 在编辑器中运行此代码。您将看到主线程睡眠了1s。然后,子线程进入if条件并睡眠了2s,这意味着它验证了dividend并且不等于0。然后,主线程醒来并将dividend变量设置为 0。然后,子线程醒来并执行除法。但是因为dividend现在等于0,应用程序崩溃了。如果在调试模式下运行此示例,您将看到一个带有消息“算术异常”的SIGFPE 异常。您将得到以下输出:

图 5.10:带有竞争条件的练习执行结果

图 5.10:带有竞争条件的练习执行结果

在这个练习中,我们考虑了“检查然后执行”类型的竞争条件。我们设置了线程的睡眠时间来模拟操作系统的中断,但在现实世界的程序中,这种情况可能会发生,也可能不会。这完全取决于操作系统及其调度程序。这使得调试和修复竞争条件变得非常困难。为了避免这个例子中的竞争条件,我们可以采取一些措施:

  • 将变量的副本传递给线程函数,而不是传递引用。

  • 使用标准库原语在线程之间同步对共享变量的访问。

  • 在主线程将“被除数”值更改为 0 之前,先加入子线程。

让我们看看修复这种竞争条件的几种方法。所有这些方法都取决于您尝试实现的任务。在下一节中,我们将考虑 C++标准库提供的同步原语。

数据危害

之前,我们考虑了最无害的例子,但有时会出现数据损坏的情况,这会导致未定义的程序行为或异常终止。由于竞争条件或简单的错误设计而导致的数据损坏,通常称为数据危害。一般来说,这个术语意味着一项工作的最终结果取决于线程执行的顺序。如果不同的线程使用共享数据或全局变量,可能会由于不同线程的任务执行顺序不正确,导致结果不断变化。这是由于多线程数据之间的依赖关系。这种依赖问题被有条件地分为三组:

  • 一个真依赖:写入后读取(RAW)

  • 一个反依赖:读取后写入(WAR)

  • 一个输出依赖:写入后写入(WAW)

原始数据依赖

当一个线程计算另一个线程使用的值时,就会发生原始数据依赖。例如,“线程 A”应该完成其工作并将结果写入一个变量。 “线程 B”必须读取此变量的值并完成其工作。在伪代码中,这看起来如下:

Thread A: a = doSomeStuff();
Thread B: b = a - doOtherStuff();

如果“线程 B”先执行,将会出现困难。这将导致“线程 B”读取无效值。线程的执行顺序应该严格保证。“线程 B”必须在“线程 A”写入变量后才能读取其值。否则,将导致未定义的行为。以下图表将帮助您澄清导致数据危害的原始数据依赖:

图 5.11:两个线程之间的原始数据依赖

图 5.11:两个线程之间的原始数据依赖关系

WAR 依赖

“线程 A”必须读取一个变量的值并完成其工作。之后,“线程 B”应该完成其工作并将结果写入一个变量。在伪代码中,这看起来如下:

Thread A: b = a - doSomeStuff();
Thread B: a = doOtherStuff();

如果“线程 B”先执行,将会出现困难。这将导致“线程 B”在“线程 A”读取之前更改值。线程的执行顺序应该严格保证。“线程 B”应该在“线程 A”读取其值后才将新值写入变量。以下图表将帮助您澄清导致数据危害的原始数据依赖:

图 5.12:两个线程之间的 WAR 数据依赖

图 5.12:两个线程之间的 WAR 数据依赖

WAW 依赖

“线程 A”执行其工作并将结果写入一个变量。 “线程 B”读取变量的值并执行其工作。 “线程 C”执行其工作并将结果写入相同的变量。在伪代码中,这看起来如下:

Thread A: a = doSomeStuff();
Thread B: b = a - doOtherStuff();
Thread C: a = doNewStuff();

如果“线程 C”在 A 和 B 线程之前执行,将会出现困难。这将导致“线程 B”读取不应该读取的值。线程的执行顺序应该严格保证。“线程 C”必须在“线程 A”写入其值并且“线程 B”读取其值后才能将新值写入变量。以下图表将帮助您澄清导致数据危害的 WAW 数据依赖:

图 5.13:两个线程之间的 WAW 数据依赖

图 5.13:两个线程之间的 WAW 数据依赖

资源同步

为了防止竞争和数据危害,有一个共享数据锁定机制,其中一个流意图更改或读取这些数据。这种机制称为临界区。同步包括在一个线程进入临界区时阻塞临界区。也意图执行此临界区代码的其他线程将被阻塞。当执行临界区的线程离开时,锁将被释放。然后,故事将在下一个线程中重复。

考虑前面的例子,其中有一个增量,但现在是同步访问。记住我们有线程 A 和 B,并且有一个变量等于 0。

线程 A 开始增加:

  1. 进入临界区并锁定它。

  2. 读取变量的值(var = 0)。

  3. 增加它(tmp = 1)。

  4. 被操作系统中断。

线程 B 开始增加:

  1. 尝试进入临界区;它被锁定,所以线程正在等待。

线程 A 继续增加:

  1. 写入新值(var = 1)。

线程 B 继续增加:

  1. 进入临界区并锁定它。

  2. 读取变量的值(var = 1)。

  3. 增加它(tmp = 2)。

  4. 写入新值(var = 2)。

在两个线程完成后,变量包含正确的结果。因此,同步确保了共享数据不会被破坏。看一下以下图表,以更好地理解这个例子:

图 5.14:两个线程以同步的方式增加相同的共享变量

图 5.14:两个线程以同步的方式增加相同的共享变量

突出显示临界区并预期非同步访问可能造成的后果是一项非常困难的任务。因为过度同步会否定多线程工作的本质。如果两个或三个线程在一个临界区上工作得相当快,然而,在程序中可能有数十个线程,它们都将在临界区中被阻塞。这将大大减慢程序的速度。

事件同步

还有另一种同步线程工作的机制-线程 A,它从另一个进程接收消息。它将消息写入队列并等待新消息。还有另一个线程,线程 B,它处理这些消息。它从队列中读取消息并对其执行一些操作。当没有消息时,线程 B正在睡眠。当线程 A接收到新消息时,它唤醒线程 B并处理它。以下图表清楚地说明了两个线程的事件同步:

图 5.15:两个线程的事件同步

图 5.15:两个线程的事件同步

然而,即使在同步代码中也可能出现另一个竞争条件的原因-类的缺陷接口。为了理解这是什么,让我们考虑以下例子:

class Messages
{
    public:
    Messages(const int& size)
    : ArraySize(size)
    , currentIdx(0)
    , msgArray(new std::string[ArraySize])
    {}
    void push(const std::string& msg)
    {
        msgArray[currentIdx++] = msg;
    }
    std::string pop()
    {
        auto msg = msgArray[currentIdx - 1];
        msgArray[currentIdx - 1] = "";
        --currentIdx;
        return msg;
    }
    bool full()
    {
        return ArraySize == currentIdx;
    }
    bool empty()
    {
        return 0 == currentIdx;
    }
    private:
    const int ArraySize;
    int currentIdx;
    std::string * msgArray;
};

在这里,我们有一个名为Messages的类,它有一个动态分配的字符串数组。在构造函数中,它接受数组的大小并创建给定大小的数组。它有一个名为full()的函数,如果数组已满则返回true,否则返回false。它还有一个名为empty()的函数,如果数组为空则返回true,否则返回false。在推送新值之前和弹出数组中的新值之前,用户有责任检查数组是否已满并检查数组是否为空。这是一个导致竞争条件的类的糟糕接口的例子。即使我们用锁保护push()pop()函数,竞争条件也不会消失。让我们看一下使用Messages类的以下示例:

int main()
{
    Messages msgs(10);
    std::thread thr1([&msgs](){
    while(true)
    {
        if (!msgs.full())
        {
            msgs.push("Hello");
        }
        else
        {
            break;
        }
    }});
    std::thread thr2([&msgs](){
    while(true)
    {
        if (!msgs.empty())
        {
            std::cout << msgs.pop() << std::endl;
        }
        else
        {
            break;
        }
    }});
    thr1.detach();
    thr2.detach();
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(2s);
    return 0;
}

在这里,我们创建了一个msgs变量,然后创建了第一个线程,该线程将值推送到msgs。然后,我们创建了第二个线程,该线程从数组中弹出值并将它们分离。即使我们使用锁定机制保护了所有函数,其中一个线程仍然可以检查数组的大小,并可能被操作系统中断。此时,另一个线程可以更改数组。当第一个线程继续工作时,它可能会尝试向满数组推送或从空数组中弹出。因此,同步只在与良好设计配对时才有效。

死锁

还有一个同步问题。让我们回到哲学家晚餐的例子。最初的问题是一个哲学家只有一根筷子。所以,他们可以通过彼此共享筷子一个接一个地吃寿司。虽然他们要花很长时间才能吃完寿司,但他们都会吃饱。但是,如果每个人同时拿起一根筷子,又不想分享第二根筷子,他们就无法吃到寿司,因为每个人都将永远等待第二根筷子。这会导致死锁。当两个线程等待另一个线程继续执行时,就会发生死锁。死锁的一个原因是一个线程加入另一个线程,而另一个线程加入第一个线程。因此,当两个线程都加入对方时,它们都无法继续执行。让我们考虑以下死锁的例子:

#include <thread>
std::thread* thr1;
std::thread* thr2;
void someStuff()
{
    thr1->join();
}
void someAnotherStuff()
{
    thr2->join();
}
int main()
{
    std::thread t1(someStuff); 
    std::thread t2(someAnotherStuff);
    thr1 = &t1;
    thr2 = &t2;
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(2s);
    return 0;
}

在主函数中,我们有两个线程t1t2。我们使用someStuff()函数初始化了t1线程,该函数执行一些有用的工作。我们还使用someAnotherStuff()函数初始化了t2线程,该函数执行更多有用的工作。我们有这些线程的全局指针,并且在由t2执行的函数中有一个指向t1线程的加入指针。我们还在由t1执行的函数中加入了一个指向t2线程的指针。通过这样做,它们相互加入。这导致了死锁。

在下一节中,我们将考虑 C++线程库原语用于同步和死锁的另一个原因。

多线程闭包的移动语义

std::thread类不能被复制,但是如果我们想要存储几个线程,或者可能是 10 个或 20 个呢?当然,我们可以创建这些线程的数量,然后像这样加入或分离它们:

std::thread thr1(someFunc);
std::thread thr2(someFunc);
std::thread thr3(someFunc);
std::thread thr4(someFunc);
std::thread thr5(someFunc);
thr1.join();
thr2.join();
thr3.join();
thr4.join();
thr5.join();

但是更方便的是将一堆线程存储在STL 容器中,例如线程的向量:

std::vector<std::thread> threads;

不支持std::move()函数的对象不能与 STL 容器一起使用。要在容器中初始化线程,我们可以像下面这样做:

for (int i = 0; i < 10; i++) 
{
    auto t = std::thread([i]()
    {
        std::cout << "thread: " << i << "\n";
    });
    threads.push_back(std::move(t));
}

然后,我们可以加入或分离它们:

for (auto& thr: threads) 
{
    if (thr.joinable())
    {
        thr.join();
    }
}

移动语义在我们将std::thread对象存储为类成员时也很有用。在这种情况下,我们应该仔细设计我们的类,删除复制构造函数和赋值运算符,并实现新的移动构造函数和移动赋值运算符。让我们考虑以下这样一个类的代码示例:

class Handler
{
    std::thread  threadHandler;

public:
    Handler(const Handler&) = delete;
    Handler& operator=(const Handler&) = delete;
    Handler(Handler && obj)
    : threadHandler(std::move(obj.threadHandler))
    {}
    Handler & operator=(Handler && obj)
    {
        if (threadHandler.joinable())
        {
            threadHandler.join();
        }
        threadHandler = std::move(obj.threadHandler);
        return *this;
    }
    ~Handler()
    {
    if (threadHandler.joinable())
        {
            threadHandler.join();
        }
    }
};

在移动赋值运算符中,我们首先检查线程是否可加入。如果是,我们加入它,然后才执行赋值操作。

注意

我们绝不能在没有使用join()detach()的情况下将一个线程对象分配给另一个。这将导致std::terminate()函数调用。

也可以使用std::move()函数将对象移动到线程函数中。这对于复制大对象是有帮助的,这是不可取的。让我们执行一个练习,确保对象可以移动到线程函数中。

练习 3:将对象移动到线程函数

在这个练习中,我们将编写一个简单的应用程序,演示std::move()如何用于std::thread类。我们将创建一个既有复制构造函数又有移动构造函数的类,以查看将该类的对象移动到std::thread函数时将调用哪一个。执行以下步骤完成此练习:

  1. 包括线程支持的头文件,即<thread>,和流支持的头文件,即<iostream>
#include <iostream>
#include <thread>
  1. 实现Handler类,它具有默认构造函数、析构函数、复制构造函数、赋值运算符、移动构造函数和移动赋值运算符。它们除了打印日志外什么都不做:
class Handler
{ 
public:
    Handler()
    {
        std::cout << "Handler()" << std::endl;
    }
    Handler(const Handler&)
    {
        std::cout << "Handler(const Handler&)" << std::endl;
    }
    Handler& operator=(const Handler&)
    {
        std::cout << "Handler& operator=(const Handler&)" << std::endl;
        return *this;
    }
    Handler(Handler && obj)
    {
        std::cout << "Handler(Handler && obj)" << std::endl;
    }
    Handler & operator=(Handler && obj)
    {
        std::cout << "Handler & operator=(Handler && obj)" << std::endl;
        return *this;
    }
    ~Handler()
    {
        std::cout << "~Handler()" << std::endl;
    }
};
  1. 实现doSomeJob()函数,这里实际上什么也不做,只是打印一个日志消息:
void doSomeJob(Handler&& h)
{
    std::cout << "I'm here" << std::endl;
}
  1. 进入main()函数并创建Handler类型的handler变量。创建thr1,传递doSomeJob()函数,并移动处理程序变量:
Handler handler;
std::thread thr1(doSomeJob, std::move(handler));
  1. 分离thr1线程并为主线程添加一个小睡眠,以避免关闭应用程序。我们将能够看到来自分离线程的输出。
thr1.detach();
using namespace std::chrono_literals; 
std::this_thread::sleep_for(5s);
  1. 在编辑器中运行此代码。在终端日志中,从默认构造函数中,您将看到两个从移动运算符中的日志,一个从析构函数中的日志,来自doSomeJob()函数的消息,最后,另外两个从析构函数中的日志。我们可以看到移动构造函数被调用了两次。

您将获得以下输出:

图 5.16:练习执行的结果

正如你所看到的,Handler对象被移动到线程函数中。尽管如此,所有未使用std::ref()函数传递的参数都被复制到线程的内存中。

让我们考虑一个有趣的问题。你可能记得,当我们初始化std::thread时,所有的构造函数参数都会被复制到线程内存中,包括可调用对象 - lambda、函数或 std::function。但是如果我们的可调用对象不支持复制语义怎么办?例如,我们创建了一个只有移动构造函数和移动赋值运算符的类:

class Converter
{
    public:
    Converter(Converter&&)
    {
    }
    Converter& operator=(Converter&&)
    {
        return *this;
    }
    Converter() = default;
    Converter(const Converter&) = delete;
    Converter& operator=(const Converter&) = delete;
    void operator()(const std::string&)
    {
        // do nothing
    }
};

我们如何将其传递给线程构造函数?如果我们按原样传递它,将会得到一个编译器错误;例如:

int main()
{
    Converter convert;
    std::thread convertThread(convert, "convert me");
    convertThread.join();
    return 0;
}

您将获得以下输出:

图 5.17:编译错误的示例

这里有很多奇怪的错误。为了解决这个问题,我们可以使用std::move()函数来移动可调用对象:

std::thread convertThread(std::move(convert), "convert me");

现在一切都很好 - 代码已经编译并且确实做了我们想要的事情。

现在,让我们考虑另一个有趣的例子。例如,您有一个需要捕获不可复制对象的 lambda 函数,例如unique_ptr

auto unique = std::make_unique<Converter>();

从 C++ 14 开始,我们可以使用std::move()来捕获可移动对象。因此,要捕获唯一指针,我们可以使用以下代码:

std::thread convertThread([ unique = std::move(unique) ] { 
        unique->operator()("convert me");
});

正如您所看到的,通过使用std::move在 lambda 中捕获值非常有用。当我们不想复制某些对象时,这也可能很有用,因为它们可能需要很长时间来复制。

现在,让我们将我们的知识付诸实践,并编写一个应用程序示例,演示我们如何在线程中使用std::move

练习 4:创建和使用 STL 线程容器

在这个练习中,我们将编写一个简单的应用程序,我们将在其中使用std::move()与线程。首先,我们将实现一个可移动构造的类。这个类将把小写文本转换为大写文本。然后,我们将创建一个这个类实例的向量。接下来,我们将创建一个std::thread对象的向量。最后,我们将用第一个向量中的对象初始化线程。

执行以下步骤以完成此练习:

  1. 包括线程支持的头文件,即<thread>,流支持的头文件,即<iostream>,和<vector>
#include <iostream>
#include <thread>
#include <vector>
#include <string>
  1. 实现Converter类,它具有const std::vector<std::string>&类型的m_bufferIn私有成员。这是对原始字符串向量的引用。它还具有一个用户构造函数,它接受bufferIn变量。然后,我们删除复制构造函数和赋值运算符。最后,我们定义重载的operator(),在其中将所有小写符号转换为大写。转换后,我们将结果写入结果缓冲区:
class Converter
{
    public:
    Converter(std::vector<std::string>& bufferIn)
        : m_bufferIn(bufferIn)
    {
    }
    Converter(Converter&& rhs)
        : m_bufferIn(std::move(rhs.m_bufferIn))
    {
    }
    Converter(const Converter&) = delete;
    Converter& operator=(const Converter&) = delete;
    Converter& operator=(Converter&&) = delete;
    void operator()(const int idx, std::vector<std::string>& result)
    {
        try
        {
            std::string::const_iterator end = m_bufferIn.at(idx).end();
            std::string bufferOut;
            for (std::string::const_iterator iter = m_bufferIn.at(idx).begin(); iter != end; iter++)
            {
                if (*iter >= 97 && *iter <= 122)
                {
                    bufferOut += static_cast<char>(static_cast<int>(*iter) - 32);
                }
                else
                {
                    bufferOut += *iter;
                }
            }
            result[idx] = bufferOut;
        }
        catch(...)
        {
            std::cout << "Invalid index" << std::endl;
        }
    }
    private:
    const std::vector<std::string>& m_bufferIn;
};
  1. 进入main()函数,创建一个名为numberOfTasks的常量值,并将其设置为5。然后,创建一个Converter对象的向量,并使用numberOfTasks保留其大小。然后,创建一个std::thread对象的向量,并使用numberOfTasks保留其大小:
const int numberOfTasks = 5;
std::vector<Converter> functions;
functions.reserve(numberOfTasks);
std::vector<std::thread> threads;
threads.reserve(numberOfTasks); 
  1. 创建字符串向量textArr,并推入五个不同的大字符串以进行转换:
std::vector<std::string> textArr;
textArr.emplace_back("In the previous topics, we learned almost all that we need to work with threads. But we still have something interesting to consider – how to synchronize threads using future results. When we considered condition variables we didn't cover the second type of synchronization with future results. Now it's time to learn that.");
textArr.emplace_back("First of all, let's consider a real-life example. Imagine, you just passed the exam at the university. You were asked to wait some amount of time for results. So, you have time to coffee with your mates, and every 10-15 mins you check are results available. Then, when you finished all your other activities, you just come to the door of the lecture room and wait for results.");
textArr.emplace_back("In this exercise, we will write a simple application where we will use std::move() with threads. First of all, we will implement a class that is move constructible. This class will convert lowercase text into uppercase text. Then we will create a vector of instances of this class. Next, we will create a vector of std::thread object. Finally, we will initialize threads with an object from the first vector");
textArr.emplace_back("Let's consider one interesting issue. As you remember when we initialize std::thread all constructor arguments are copied into thread memory, including a callable object – lambda, function, std::function. But what if our callable object doesn't support copy semantic? For example, we created a class that has only move constructor and a move assignment operator:");
textArr.emplace_back("Run this code in your editor. You will see in the terminal log from the default constructor, two logs from the move operator, then one log from a destructor, then message from the doSomeJob() function and, finally two other log messages from the destructor. We see that the move constructor is called twice. You will get the output like the following:");
  1. 实现一个for循环,将Converter对象推入函数向量:
for (int i = 0; i < numberOfTasks; ++i)
{
    functions.push_back(Converter(textArr));
}
  1. 创建一个字符串结果向量,并推入五个空字符串。然后,创建一个将作为数组元素索引的变量:
std::vector<std::string> result;
for (int i = 0; i < numberOfTasks; ++i)
{
    result.push_back("");
}
int idx = 0;
  1. 实现另一个for循环,将std::thread对象推入线程向量:
for (auto iter = functions.begin(); iter != functions.end(); ++iter)
{
    std::thread tmp(std::move(*iter), idx, std::ref(result));        
    threads.push_back(std::move(tmp));
    from = to;
    to += step;
}
  1. 实现第三个for循环,其中我们分离std::threads
for (auto iter = threads.begin(); iter != threads.end(); ++iter)
{
     (*iter).detach();
}
  1. 为主线程添加一个小的休眠,以避免关闭应用程序。现在,我们可以看到分离的线程是如何工作的:
using namespace std::chrono_literals; 
std::this_thread::sleep_for(5s);
  1. 最后将结果打印到终端:
for (const auto& str : result)
{
    std::cout << str;
}
  1. 在编辑器中运行此代码。在终端中,您可以看到所有字符串都是大写的,这意味着所有线程都已成功移动和运行。您将得到以下输出:

图 5.18:练习执行的结果

图 5.18:练习执行的结果

在这个练习中,我们练习了如何创建一个只能移动对象的 STL 容器。我们还考虑了如何将不可复制的对象传递给线程构造函数。这些知识将在下一节中帮助我们学习如何从线程中获取结果。

未来、承诺和异步

在前一节中,我们几乎学会了处理线程所需的所有内容。但是我们仍有一些有趣的事情要考虑,即使用未来结果同步线程。当我们考虑条件变量时,我们没有涵盖使用未来结果进行第二种类型的同步。现在,是时候学习一下了。

假设有这样一种情况,我们运行一些线程并继续进行其他工作。当我们需要结果时,我们停下来检查它是否准备好。这种情况描述了未来结果的实际工作。在 C++中,我们有一个名为<future>的头文件,其中包含两个模板类,表示未来结果:std::future<>std::shared_future<>。当我们需要单个未来结果时,我们使用std::future<>,当我们需要多个有效副本时,我们使用std::shared_future<>。我们可以将它们与std::unique_ptrstd::shared_ptr进行比较。

要处理未来的结果,我们需要一个特殊的机制来在后台运行任务并稍后接收结果:std::async()模板函数。它以可调用对象作为参数,并且有启动模式 - 延迟或异步,当然还有可调用对象的参数。启动模式std::launch::asyncstd::launch::deferred表示如何执行任务。当我们传递std::launch::async时,我们期望该函数在单独的线程中执行。当我们传递std::launch::deferred时,函数调用将被延迟,直到我们要求结果。我们也可以同时传递它们,例如std::launch::deferred|std::launch::async。这意味着运行模式将取决于实现。

现在,让我们考虑一个使用std::futurestd::async的示例。我们有一个toUppercase()函数,将给定的字符串转换为大写:

std::string toUppercase(const std::string& bufIn)
{
    std::string bufferOut;
    for (std::string::const_iterator iter = bufIn.begin(); iter != bufIn.end(); iter++)
    {
        if (*iter >= 97 && *iter <= 122)
        {
            bufferOut += static_cast<char>(static_cast<int>(*iter) - 32);
        }
        else
        {
            bufferOut += *iter;
        }
    }
    return bufferOut;
}

然后,在main()函数中,创建一个名为resultstd::future变量,并使用std::async()的返回值进行初始化。然后,我们使用结果对象的get()函数获取结果:

#include <iostream>
#include <future>
int main()
{
    std::future<std::string> result = std::async(toUppercase, "please, make it uppercase");
    std::cout << "Main thread isn't locked" << std::endl;
    std::cout << "Future result = " << result.get() << std::endl;
    return 0;
}

实际上,在这里,我们创建了一个未来对象:

std::future<std::string> result = std::async(toUppercase, "please, make it uppercase");

正如您所看到的,我们没有将启动模式传递给std::async()函数,这意味着将使用默认模式:std::launch::deferred | std::launch::async。您也可以明确这样做:

std::future<std::string> result = std::async(std::launch::async, toUppercase, "please, make it uppercase");

在这里,我们正在等待结果:

std::cout << "Future result = " << result.get() << std::endl;

如果我们的任务需要很长时间,线程将在这里等待直到结束。

通常,我们可以像使用std::thread构造函数一样使用std::async()函数。我们可以传递任何可调用对象。默认情况下,所有参数都会被复制,我们可以移动变量和可调用对象,也可以通过引用传递它们。

std::future对象不受竞争条件保护。因此,为了从不同的线程访问它并保护免受损害,我们应该使用互斥锁。但是,如果我们需要共享 future 对象,最好使用std::shared_future。共享的 future 结果也不是线程安全的。为了避免竞争条件,我们必须在每个线程中使用互斥锁或存储线程自己的std::shared_future副本。

注意

std::future对象的竞争条件非常棘手。当线程调用get()函数时,future 对象变得无效。

我们可以通过将未来移动到构造函数来创建共享的未来:

std::future<std::string> result = std::async(toUppercase, "please, make it uppercase");
std::cout << "Main thread isn't locked" << std::endl;
std::shared_future<std::string> sharedResult(std::move(result));
std::cout << "Future result = " << sharedResult.get() << std::endl;
std::shared_future<std::string> anotherSharedResult(sharedResult);
std::cout << "Future result = " << anotherSharedResult.get() << std::endl;

正如您所看到的,我们从std::future创建了一个std::shared_future变量并进行了复制。两个共享的 future 对象都指向相同的结果。

我们还可以使用std::future对象的share()成员函数来创建共享的 future 对象:

std::future<std::string> result = std::async(toUppercase, "please, make it uppercase");
std::cout << "Main thread isn't locked" << std::endl;
auto sharedResult = result.share();
std::cout << "Future result = " << sharedResult.get() << std::endl;

请注意,在这两种情况下,std::future对象都会变得无效。

我们还可以使用std::packaged_task<>模板类从单独的线程获取未来结果的另一种方法。我们如何使用它们?

  1. 我们创建一个新的std::packaged_task并声明可调用函数签名:
std::packaged_task<std::string(const std::string&)> task(toUppercase);
  1. 然后,我们将未来结果存储在std::future变量中:
auto futureResult = task.get_future();
  1. 接下来,我们在单独的线程中运行此任务或将其作为函数调用:
std::thread thr1(std::move(task), "please, make it uppercase");
thr1.detach();
  1. 最后,我们等待未来的结果准备就绪:
std::cout << "Future result = " << futureResult.get() << std::endl;

注意

std::packaged_task是不可复制的。因此,要在单独的线程中运行它,请使用std::move()函数。

还有一件重要的事情需要注意。如果您不希望从线程中获得任何结果,并且更喜欢等待线程完成工作,可以使用std::future<void>。现在,当您调用future.get()时,您的当前线程将在此处等待。让我们考虑一个例子:

#include <iostream>
#include <future>
void toUppercase(const std::string& bufIn)
{
    std::string bufferOut;
    for (std::string::const_iterator iter = bufIn.begin(); iter != bufIn.end(); iter++)
    {
        if (*iter >= 97 && *iter <= 122)
        {
            bufferOut += static_cast<char>(static_cast<int>(*iter) - 32);
        }
        else
        {
            bufferOut += *iter;
        }
    }
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(2s);
    std::cout << bufferOut << std::endl;
}
int main()
{
    std::packaged_task<void(const std::string&)> task(toUppercase);
    auto futureResult = task.get_future();
    std::thread thr1(std::move(task), "please, make it uppercase");
    thr1.detach();
    std::cout << "Main thread is not blocked here" << std::endl;
    futureResult.get();
    std::cout << "The packaged_task is done" << std::endl;
    return 0;
} 

正如您所看到的,通过等待另一个线程,我们正在利用诸如条件变量、未来结果和 promises 等多种技术。

现在,让我们继续讨论标准库中的下一个重要特性 - std::promise<>模板类。使用这个类,我们可以设置我们想要接收的类型的值,然后使用std::future获取它。我们如何使用它们?为此,我们需要实现一个接受std::promise参数的函数:

void toUppercase(const std::string& bufIn, std::promise<std::string> result)

工作完成后,我们需要使用std::promise初始化一个新值:

result.set_value(bufferOut);

为了在我们将要使用它的地方创建std::promise,我们需要编写以下代码:

std::promise<std::string> stringInUpper;

完成后,我们必须创建std::future并从 promise 获取它:

std::future<std::string> futureRes = stringInUpper.get_future();

我们需要在单独的线程中运行此函数:

std::thread thr(toUppercase, "please, make it uppercase", std::move(stringInUpper));
thr.detach();

现在,我们需要等待直到 future 设置:

futureRes.wait();
std::cout << "Result = " << futureRes.get() << std::endl;

使用 promises 获取结果的完整示例如下:

#include <iostream>
#include <future>
void toUppercase(const std::string& bufIn, std::promise<std::string> result)
{
    std::string bufferOut;
    for (std::string::const_iterator iter = bufIn.begin(); iter != bufIn.end(); iter++)
    {
        if (*iter >= 97 && *iter <= 122)
        {
            bufferOut += static_cast<char>(static_cast<int>(*iter) - 32);
        }
        else
        {
            bufferOut += *iter;
        }
    }
    result.set_value(bufferOut);
}
int main()
{
    std::promise<std::string> stringInUpper;
    std::future<std::string> futureRes = stringInUpper.get_future();
    std::thread thr(toUppercase, "please, make it uppercase", std::move(stringInUpper));
    thr.detach();
    std::cout << "Main thread is not blocked here" << std::endl;
    futureRes.wait();
    std::cout << "Result = " << futureRes.get() << std::endl;
    return 0;
}

所以,我们几乎涵盖了编写多线程应用程序所需的一切,除了一个重要的事情 - 如果在单独的线程中抛出异常会发生什么?例如,您在线程中传递一个函数,它抛出异常。在这种情况下,将为该线程调用std::terminate()。其他线程将继续它们的工作。让我们考虑一个简单的例子。

我们有一个getException()函数,它生成带有线程 ID 的消息并抛出std::runtime_error

#include <sstream>
#include <exception>
#include <iostream>
#include <future>
std::string getException()
{
    std::stringstream ss;
    ss << "Exception from thread: ";
    ss << std::this_thread::get_id();
    throw std::runtime_error(ss.str());
}

我们还有toUppercase()函数。它将给定的字符串转换为大写,并调用getException()函数,该函数会抛出异常:

std::string toUppercase(const std::string& bufIn)
{
    std::string bufferOut;
    for (std::string::const_iterator iter = bufIn.begin(); iter != bufIn.end(); iter++)
    {
        if (*iter >= 97 && *iter <= 122)
        {
            bufferOut += static_cast<char>(static_cast<int>(*iter) - 32);
        }
        else
        {
            bufferOut += *iter;
            getException();
        }
    }
    return bufferOut;
}

这是main()函数,我们在其中在try-catch块中创建一个新线程thr。我们捕获异常并将消息打印到终端:

int main()
{
    try
    {
        std::thread thr(toUppercase, "please, make it uppercase");
        thr.join();
    }
    catch(const std::exception& ex)
    {
        std::cout << "Caught an exception: " << ex.what() << std::endl;
    }
    return 0;
}

如果您在 IDE 中运行此代码,您将看到以下输出:

图 5.19:示例执行的结果

图 5.19:示例执行的结果

我们可以看到在抛出异常后调用了std::terminate()。当您的程序中有很多线程时,很难找到线程终止的正确位置。幸运的是,我们有一些机制可以捕获来自另一个线程的异常。让我们考虑一下它们。

未来结果中的std::exception_ptr并设置就绪标志。然后,当您调用get()时,std::exception_ptr被存储并重新抛出异常。我们所需要做的就是在try-catch块中放置一个get()调用。让我们考虑一个例子。我们将使用上一个例子中的两个辅助函数,即getException()toUppercase()。它们将保持不变。在main()函数中,我们创建了一个名为resultstd::future对象,并使用std::async()函数运行toUppercase()函数。然后,在try-catch块中调用result对象的get()函数并捕获异常:

#include <iostream>
#include <future>
int main()
{
    std::future<std::string> result = std::async(toUppercase, "please, make it uppercase");
    try
    {
        std::cout << "Future result = " << result.get() << std::endl;
    }
    catch(const std::exception& ex)
    {
        std::cout << "Caught an exception: " << ex.what() << std::endl;
    }
    return 0;
}

如果您在 IDE 中运行上述代码,您将得到以下输出:

图 5.20:示例执行的结果

图 5.20:示例执行的结果

如您所见,我们捕获了异常,现在我们可以以某种方式处理它。std::packaged_task<>类以相同的方式处理异常 - 它在未来结果中存储std::exception_ptr,设置就绪标志,然后std::futureget()调用中重新抛出异常。让我们考虑一个小例子。我们将使用上一个例子中的两个辅助函数 - getException()toUppercase()。它们将保持不变。在main()函数中,我们创建了一个名为taskstd::packaged_task对象。通过使用我们的toUppercase()函数的类型,它返回一个整数,并以两个整数作为参数。我们将toUppercase()函数传递给task对象。然后,我们创建了一个名为resultstd::future对象,并使用get_future()函数从 task 对象获取结果。最后,我们在新线程thr中运行 task 对象,并在try-catch块中调用result变量的get()函数:

#include <iostream>
#include <future>
int main()
{
    std::packaged_task<std::string(const std::string&)> task(toUppercase);
    auto result = task.get_future();
    std::thread thr(std::move(task), "please, make it uppercase");
    thr.detach();
    try
    {
        std::cout << "Future result = " << result.get() << std::endl;
    }
    catch(const std::exception& ex)
    {
        std::cout << "Caught an exception: " << ex.what() << std::endl;
    }
    return 0;
}

如果您在 IDE 中运行此代码,您将得到以下输出:

图 5.21:此示例执行的结果

图 5.21:此示例执行的结果

std::promise<>类以另一种方式处理异常。它允许我们使用set_exception()set_exception_at_thread_exit()函数手动存储异常。要在std::promise中设置异常,我们必须捕获它。如果我们不捕获异常,在std::promise的析构函数中将设置错误,作为未来结果中的std::future_errc::broken_promise。当您调用get()函数时,异常将被重新抛出。让我们考虑一个例子。我们将使用上一个例子中的一个辅助函数 - getException()。它保持不变。但是,我们将更改toUppercase()函数并添加第三个参数std::promise。现在,我们将在try块中调用getException()函数,捕获异常,并将其设置为std::promise的值:

void toUppercase(const std::string& bufIn, std::promise<std::string> result)
{
    std::string bufferOut;
    try
    {
        for (std::string::const_iterator iter = bufIn.begin(); iter != bufIn.end(); iter++)
        {
            if (*iter >= 97 && *iter <= 122)
            {
                    bufferOut += static_cast<char>(static_cast<int>(*iter) - 32);
            }
            else
            {
                bufferOut += *iter;
                getException();
            }
        }
    }
    catch(const std::exception& ex)
    {
        result.set_exception(std::make_exception_ptr(ex));
    }
    result.set_value(bufferOut);
}

注意

有几种方法可以将异常设置为 promise。首先,我们可以捕获std::exception并使用std::make_exception_ptr()函数将其转换为std::exception_ptr。您还可以使用std::current_exception()函数,它返回std::exception_ptr对象。

main()函数中,我们创建了一个整数类型的 promise,称为upperResult。我们创建了一个名为futureRes的未来结果,并从upperResult promise 值中设置它。接下来,我们创建一个新线程thr,将toUppercase()函数传递给它,并移动upperResult promise。然后,我们调用futureRes对象的wait()函数,使调用线程等待直到结果变为可用。然后,在try-catch块中,我们调用futureRes对象的get()函数,它重新抛出异常:

#include <iostream>
#include <future>
int main()
{
    std::promise<std::string> upperResult;
    std::future<std::string> futureRes = upperResult.get_future();
    std::thread thr(toUppercase, "please, make it uppercase", std::move(upperResult));
    thr.detach();
    futureRes.wait();
    try
    {
        std::cout << "Result = " << futureRes.get() << std::endl;
    }
    catch(...)
    {
        std::cout << "Caught an exception" << std::endl;
    }
    return 0;
}

注意

当我们创建一个std::promise<>对象时,我们承诺我们将强制设置值或异常。如果我们没有这样做,std::promise的析构函数将抛出异常,即std::future_error - std::future_errc::broken_promise

如果您在 IDE 中运行此代码,您将得到以下输出:

图 5.22:此示例执行的结果

图 5.22:此示例执行的结果

这就是在多线程应用程序中处理异常的全部内容。正如您所看到的,这与我们在单个线程中所做的非常相似。现在,让我们将我们的知识付诸实践,并编写一个简单的应用程序示例,演示我们如何使用不同的 future 结果进行同步。

练习 5:使用 Future 结果进行同步

在这个练习中,我们将编写一个简单的应用程序来演示如何使用 future 结果来接收来自不同线程的值。我们将运行ToUppercase()可调用对象三次。我们将使用std::async()函数执行第一个任务,使用std::packaged_task<>模板类执行第二个任务,并使用std::threadstd::promise执行最后一个任务。

执行以下步骤以完成此练习:

  1. 包括用于线程支持的头文件,即<thread>,用于流支持的头文件,即<iostream>,以及用于 future 结果支持的<future>
#include <iostream>
#include <thread>
#include <future>
  1. 实现一个ToUppercase类,将给定的字符串转换为大写。它有两个重载的运算符,()。第一个operator()接受要转换的字符串并以大写形式返回结果值。第二个operator()接受要转换的字符串和一个std::promise,并将返回值存储在 promise 中:
class ToUppercase
{
    public:
    std::string operator()(const std::string& bufIn)
    {
        std::string bufferOut;
        for (std::string::const_iterator iter = bufIn.begin(); iter != bufIn.end(); iter++)
        {
            if (*iter >= 97 && *iter <= 122)
            {
                bufferOut += static_cast<char>(static_cast<int>(*iter) - 32);
            }
            else
            {
                bufferOut += *iter;
            }
        }
        return bufferOut;
    }
    void operator()(const std::string& bufIn, std::promise<std::string> result)
    {
        std::string bufferOut;
        for (std::string::const_iterator iter = bufIn.begin(); iter != bufIn.end(); iter++)
        {
            if (*iter >= 97 && *iter <= 122)
            {
                bufferOut += static_cast<char>(static_cast<int>(*iter) - 32);
            }
            else
            {
                bufferOut += *iter;
            }
        }
        result.set_value(bufferOut);
    }
};
  1. 现在,创建一个ToUppercase对象,即ptConverter,并创建一个std::packaged_task,即upperCaseResult1,它以ptConverter对象作为参数。创建一个std::future值,并从upperCaseResult1设置它。在一个单独的线程中运行这个任务:
ToUppercase ptConverter;
std::packaged_task<std::string(const std::string&)> upperCaseResult1(ptConverter);
std::future<std::string> futureUpperResult1= upperCaseResult1.get_future();
std::thread thr1(std::move(ptConverter), "This is a string for the first asynchronous task");
thr1.detach(); 
  1. 现在,创建第二个ToUppercase对象,即fConverter。创建一个名为futureUpperResult2std::future对象,并从std::async()设置它:
ToUppercase fConverter;
std::future<std::string> futureUpperResult2 = std::async(fConverter, "This is a string for the asynchronous task"); 
  1. 现在,创建第三个ToUppercase对象,即pConverter。创建一个名为promiseResultstd::promise值。然后,创建一个名为futureUpperResult3std::future值,并从promiseResult设置它。现在,在单独的线程中运行pConverter任务,并将promiseResult作为参数传递:
ToUppercase pConverter;
std::promise<std::string> promiseResult;
std::future<std::string> futureUpperResult3 = promiseResult.get_future();
std::thread thr2(pConverter, "This is a string for the task that returns a promise", std::move(promiseResult));
thr2.detach(); 
  1. 现在,要接收所有线程的结果,请等待futureUpperResult3准备就绪,然后获取所有三个结果并打印它们:
futureUpperResult3.wait();
std::cout  << "Converted strings: "
        << futureUpperResult1.get() << std::endl
        << futureUpperResult2.get() << std::endl
        << futureUpperResult3.get() << std::endl;
  1. 在编辑器中运行此代码。您将看到来自所有三个线程的转换后的字符串。

您将得到以下输出:

图 5.23:此练习执行的结果

图 5.23:此练习执行的结果

那么,我们在这里做了什么?我们将大的计算分成小部分,并在不同的线程中运行它们。对于长时间的计算,这将提高性能。在这个练习中,我们学会了如何从线程中接收结果。在本节中,我们还学会了如何将在单独线程中抛出的异常传递给调用线程。我们还学会了如何通过事件来同步几个线程的工作,不仅可以使用条件变量,还可以使用 future 结果。

活动 1:创建模拟器来模拟艺术画廊的工作

在这个活动中,我们将创建一个模拟器来模拟艺术画廊的工作。我们设置了画廊的访客限制 - 只能容纳 50 人。为了实现这个模拟,我们需要创建一个Person类,代表艺术画廊中的人。此外,我们需要一个Persons类,这是一个线程安全的人员容器。我们还需要一个Watchman类来控制里面有多少人。如果超过了看门人的限制,我们将所有新来的人放到等待列表中。最后,我们需要一个Generator类,它有两个线程 - 一个用于创建新的访客,另一个用于通知我们有人必须离开画廊。因此,我们将涵盖使用线程、互斥锁、条件变量、锁保护和唯一锁。这个模拟器将允许我们利用本章中涵盖的技术。因此,在尝试此活动之前,请确保您已完成本章中的所有先前练习。

要实现此应用程序,我们需要描述我们的类。我们有以下类:

图 5.24:在此活动中使用的类的描述

图 5.24:在此活动中使用的类的描述

在开始实现之前,让我们创建类图。以下图表显示了所有上述类及其关系:

图 5.25:类图

图 5.25:类图

按照以下步骤实现此活动:

  1. 定义并实现 Person 类,除了打印日志外什么也不做。

  2. 为 Person 创建一些线程安全的存储,包装 std::vector 类。

  3. 实现 PersonGenerator 类,在不同的线程中进行无限循环,创建和移除访客,并通知 Watchman 类。

  4. 创建 Watchman 类,在单独的线程中进行无限循环,从 PersonGenerator 类的通知中将访问者从队列移动到另一个队列。

  5. 在 main()函数中声明相应的对象以模拟艺术画廊及其工作方式。

实现这些步骤后,您应该获得以下输出,其中您可以看到所有实现类的日志。确保模拟流程如预期那样进行。预期输出应该类似于以下内容:

图 5.26:应用程序执行的结果

图 5.26:应用程序执行的结果

注意

此活动的解决方案可在第 681 页找到。

摘要

在本章中,我们学习了使用 C++标准库支持的线程。如果我们想编写健壮、快速和清晰的多线程应用程序,这是基础。

我们首先研究了关于并发的一般概念 - 什么是并行、并发、同步、异步和线程执行。对这些概念有清晰的理解使我们能够理解多线程应用程序的架构设计。

接下来,我们看了开发多线程应用程序时遇到的不同问题,如数据危害、竞争条件和死锁。了解这些问题有助于我们为项目构建清晰的同步架构。我们考虑了一些现实生活中的同步概念示例,这使我们对编程线程应用程序时可能遇到的挑战有了很好的理解。

接下来,我们尝试使用不同的标准库原语进行同步。我们试图弄清楚如何处理竞争条件,并通过事件同步和数据同步实现了示例。接下来,我们考虑了移动语义如何应用于多线程。我们了解了哪些来自线程支持库的类是不可复制但可移动的。我们还考虑了移动语义在多线程闭包中的工作方式。最后,我们学会了如何从单独的线程接收结果,以及如何使用期望、承诺和异步来同步线程。

我们通过构建一个艺术画廊模拟器来将所有这些新技能付诸实践。我们构建了一个多线程应用程序,其中包括一个主线程和四个子线程。我们通过使用条件变量之间实现了它们之间的通信。我们通过互斥锁保护了它们的共享数据。总之,我们运用了本章学到的所有内容。

在下一章中,我们将更仔细地研究 C中的 I/O 操作和类。我们将首先查看标准库的 I/O 支持。然后,我们将继续使用流和异步 I/O 操作。接下来,我们将学习线程和 I/O 的交互。我们将编写一个活动,让我们能够掌握 C中的 I/O 工作技能。

第七章:流和 I/O

学习目标

在本章结束时,您将能够:

  • 使用标准 I/O 库向文件或控制台写入和读取数据

  • 使用内存 I/O 接口格式化和解析数据

  • 扩展用户定义类型的标准 I/O 流

  • 开发使用多个线程的 I/O 标准库的应用程序

在本章中,我们将使用 I/O 标准库开发灵活且易于维护的应用程序,处理流,学习 I/O 库如何在多线程应用程序中使用,并最终学会使用标准库格式化和解析数据。

介绍

在上一章中,我们涵盖了 C中最具挑战性的主题之一 - 并发性。我们研究了主要的多线程概念,并区分了 C中的同步、异步和线程执行。我们学习了关于同步、数据危害和竞争条件的关键要点。最后,我们研究了在现代 C++中使用线程。在本章中,我们将深入学习如何处理多线程应用中的 I/O。

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

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

本章将以一个具有挑战性和令人兴奋的活动结束,我们将改进上一章的艺术画廊模拟器项目,并创建一个健壮、清晰、多线程且易于使用的日志记录器。我们将开发一个具有清晰接口的类,可以从项目中的任何地方访问。接下来,我们将使其适应多个线程的工作。最后,我们将把我们的健壮的日志记录器整合到艺术画廊模拟器项目中。

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

审查标准库的 I/O 部分

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

图 6.1:设备之间的 I/O 通信

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

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

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

图 6.2:I/O 抽象接口的类图

图 6.2:I/O 抽象接口的类图

实现类别在逻辑上分为以下几类:文件 I/O字符串 I/O同步 I/OI/O 操纵器和预定义的标准流对象。所有这些类都是从上述抽象类派生而来的。让我们在接下来的部分详细考虑每一个。

预定义的标准流对象

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

std::cout流对象经常与std::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对象作为第一个参数,目标字符串作为第二个参数。

std::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:来自 stdcerr 和 stdclog 流对象的输出

现在,让我们进行一项练习,巩固我们所学的一切。

练习 1:重载左移操作符<<,用于用户定义的类型

在这个练习中,我们将编写一个非常有用的代码部分,您可以在任何地方使用它来输出用户定义的类型。首先,我们将创建一个名为Track的类,表示音乐曲目。它将具有以下私有成员:namesingerlengthdate。然后,我们将重载这个类的左移操作符<<。接下来,我们将创建这个类的一个实例,并使用std::cout流对象输出它。

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

  1. 包括所需的头文件:<iostream> 用于在控制台输出和 <string> 用于字符串支持:
#include <iostream>
#include <string>
  1. 声明Track类,并添加私有部分变量以保存有关track的信息,即m_Namem_Singerm_Datem_LengthInSeconds。在公共部分,添加一个带参数的构造函数,初始化所有私有变量。还要为所有类成员添加public部分的 getter:
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;
};
  1. 现在是练习中最困难的部分:为Track类型编写重载函数。这是一个具有两个类型参数charTTraitstemplate函数:
template <typename charT, typename Traits>
  1. 我们将此函数设置为内联函数,以便让编译器知道我们希望它对此函数进行优化。此函数的返回类型是对std::basic_ostream<charT, Traits>类的引用。此函数的名称是operator <<。此函数接受两个参数:第一个是对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);
  1. 现在,添加函数定义。使用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;
}
  1. 现在,进入main函数,并创建并初始化Track类型的实例track_001。最后,使用std::cout打印track_001的值:
int main()
{
     Track track_001("Summer night city",
                     "ABBA",
                     "1979",
                      213);
     std::cout << track_001 << std::endl;
     return 0;
}
  1. 编译并执行应用程序。运行它。您将获得以下输出:

图 6.4:执行练习 1 的结果

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

文件 I/O 实现类

文件流管理对文件的输入和输出。它们提供了一个接口,实现了输入操作的basic_ifstream,输出操作的basic_ofstream,同时输入和输出操作的basic_fstream,以及用于实现原始文件设备的basic_filebuf。它们都在<fstream>头文件中定义。标准库还提供了 char 和wchar_t类型的 typedefs,即ifstreamfstreamofstream,以及带有w前缀的相同名称,用于宽字符。

我们可以以两种方式创建文件流。第一种方式是在一行中执行此操作,即通过将文件名传递给构造函数来打开文件并将流连接到文件:

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

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

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

注意

IO 流具有 bool 变量:goodbiteofbitfailbitbadbit。它们用于在每次操作后检查流的状态,并指示流上发生了哪种错误。

在对象创建后,我们可以通过检查failbit或检查与打开文件相关联的流来检查流状态。要检查failbit,请在file流上调用fail()函数:

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::fstreamios_base::in|ios_base::out模式打开。ios_base::out|ios_base::trunc模式会在文件不存在时创建文件,或者删除现有文件的所有内容。ios_base::out|ios_base::app模式会在文件不存在时创建文件,或者打开现有文件,并允许您仅在文件末尾写入。上述两种模式都可以与ios_base::in标志结合使用,因此文件将同时以读取和写入模式打开。

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

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::in模式中调用tellg()函数。稍后可以使用它,以便在需要时返回到此位置。我们还可以使用seekp()函数在ios_base::out模式中和seekg()函数在ios_base::in模式中找到文件中的确切位置。它接受两个参数:要移动的字符数以及应从哪个文件位置计数。允许三种位置类型seek: 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>用于字符串支持,<fstream>用于 I/O 文件库支持:
#include <fstream>
#include <iostream>
#include <string>
  1. 实现Book类,它代表书店中的书。在私有部分,使用不言自明的名称定义四个变量:m_Namem_Authorm_Yearm_Price。在公共部分,定义带参数的构造函数,初始化所有类成员。此外,在public部分,为所有类成员定义 getter:
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;
};
  1. 进入main函数并声明pricesFile变量,该变量保存文件名:
std::string pricesFile("prices.txt");
  1. 接下来,创建book类的实例,并用book nameauthor nameyearprice进行初始化:
Book book_001("Brave", "Olena Lizina", 2017, 33.57);
  1. 将此类实例写入文件。创建std::ofstream类的实例。使用pricesFile变量名打开我们的文件。检查流是否成功打开,如果没有,则打印错误消息:
std::ofstream outFile(pricesFile);
if (outFile.fail())
{
      std::cerr << "Failed to open file " << pricesFile << std::endl;
      return 1;
}
  1. 然后,使用 getter 将有关book_001书籍的所有信息写入文件,每个项目之间用空格分隔,并在末尾加上换行符:
outFile << book_001.getName() << " "
        << book_001.getAuthor() << " "
        << book_001.getYear() << " "
        << book_001.getPrice() << std::endl;
  1. 编译并执行应用程序。现在,转到项目文件夹,并找到'prices.txt'文件的位置。在下面的屏幕截图中,您可以看到在项目目录中创建的文件的位置:
图 6.5:创建文件的位置
  1. 记事本中打开它。在下面的屏幕截图中,您可以看到文件的输出是什么样子的:
图 6.6:将用户定义的类型输出到文件的结果
  1. 现在,让我们将这些数据读取到变量中。创建std::ifstream类的实例。打开名为pricesFile的文件。检查流是否成功打开,如果没有,则打印错误消息:
std::ifstream inFile(pricesFile);
if (inFile.fail())
{
     std::cerr << "Failed to open file " << pricesFile << std::endl;
     return 1;
}
  1. 创建将用于从文件输入的本地变量,即nameauthorNameauthorSurnameyearprice。它们的名称不言自明:
std::string name;
std::string authorName;
std::string authorSurname;
int year;
float price;
  1. 现在,按照文件中的顺序将数据读入变量中:
inFile >> name >> authorName >> authorSurname >> year >> price;
  1. 创建一个名为book_002Book实例,并用这些读取的值进行初始化:
Book book_002(name, std::string(authorName + " " + authorSurname), year, price);
  1. 要检查读取操作是否成功执行,请将book_002变量打印到控制台:
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类从文件中读取这些数据到我们的自定义变量,将其输出到控制台,并确保一切都被正确读取。通过这样做,我们学会了如何使用 I/O 标准库向文件读写数据。现在,让我们继续学习 I/O 库的内存部分。

字符串 I/O 实现

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

要创建std::istringstream类的对象,我们应该将初始化字符串作为构造函数参数传递或者稍后使用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. 包括所需的头文件:<iostream>用于输出到终端,<sstream>用于 I/O 字符串支持:
#include <sstream>
#include <iostream>
  1. 实现名为Replacer的可调用类。它只有一个函数 - 重载的括号运算符,即(),它返回一个字符串,并接受三个参数:原始字符串、要替换的单词和用于替换的单词。函数声明如下:
std::string operator()(const std::string& originalString,
                       const std::string& wordToBeReplaced,
                       const std::string& wordReplaceBy);
  1. 接下来,创建istringstream对象,即iss,并将originalString变量设置为输入源:
std::istringstream iss(originalString);
  1. 创建ostringstream对象,即oss,它将保存转换后的字符串:
std::ostringstream oss;
  1. 然后,在循环中,当可能有输入时,执行对单词变量的读取。检查这个单词是否等于wordToBeReplaced变量。如果是,用wordReplaceBy变量替换它,并写入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();
     }
};
  1. 进入main函数。创建一个名为 worker 的Replacer类的实例。定义foodList变量,并将其初始化为包含食物列表的字符串;一些项目应该重复。定义changedList字符串变量,并将其初始化为worker()函数的返回值。使用std::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;
}
  1. 编译、构建并运行练习。结果将如下所示:

图 6.8:执行练习 3 的结果

图 6.8:执行练习 3 的结果

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

I/O 操作符

到目前为止,我们已经学习了使用流进行简单的输入和输出,但在许多情况下这是不够的。对于更复杂的 I/O 数据格式化,标准库有一个大量的 I/O 操作符。它们是为了与移位操作符(<<和>>)一起工作而开发的函数,用于控制流的行为。I/O 操作符分为两种类型 - 一种是无需参数调用的,另一种是需要参数的。其中一些既适用于输入又适用于输出。让我们简要地考虑它们的含义和用法。

用于更改流的数字基数的 I/O 操作符

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

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

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

在这个练习中,我们将开发一个应用程序,在无限循环中,要求用户以十进制、十六进制或八进制中的一种输入一个整数。读取输入后,将以其他数字表示形式显示这个整数。要完成这个练习,完成以下步骤:

  1. 包括<iostream>头文件以支持流。声明名为BASE的枚举并定义三个值:DECIMALOCTALHEXADECIMAL
#include <iostream>
enum BASE
{
      DECIMAL,
      OCTAL,
      HEXADECIMAL
};
  1. 声明一个名为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;
  }
}
  1. 进入main函数并定义将用于读取用户输入的整数变量:
int integer; 
  1. 创建一个无限循环。在循环内部,要求用户输入一个十进制值。将输入读取为十进制整数。将其传递给displayInBases函数。接下来,要求用户输入一个十六进制值。将输入读取为十六进制整数。将其传递给displayInBases函数。最后,要求用户输入一个八进制值。将输入读取为八进制整数。将其传递给displayInBases函数:
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;
}
  1. 构建并运行应用程序。跟随输出并输入,例如,在不同的数字表示中输入 12。输出应该如下所示:图 6.9:执行练习 4,第 1 部分的结果
图 6.9:执行练习 4,第 1 部分的结果
  1. 现在,让我们将std::decstd::octstd::hexstd::setbase()函数中更改,以检查输出是否相同。首先,添加<iomanip>头文件以支持std::setbase()。接下来,在主函数中的循环中,将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;
}
  1. 再次构建并运行应用程序。跟随输出并在不同的数字表示中输入相同的整数(12)。输出应该如下所示:

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

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

现在,比较一下结果。如您所见,输出是相同的。通过这样做,我们确保这些函数是可以互换的。

浮点格式的 I/O 操作符

<ios>头文件中,声明了用于更改浮点数位格式的函数:std::fixedstd::scientificstd::hexfloatstd::defaultfloat。它们在没有参数的情况下被调用,并将floatfield分别设置为固定、科学、固定和科学以及默认值。还有std::showpointstd::noshowpoint函数,用于控制显示浮点数位。它们只影响输出。std::noshowpoint函数只影响没有小数部分的浮点数位。

<iomanip>头文件中,声明了一个std::setprecision函数,它以表示精度的数字调用。当小数点右侧的数字被舍弃时,结果会四舍五入。如果数字太大而无法以正常方式表示,则会忽略精度规范,并以更方便的方式显示数字。您只需要设置一次精度,并且只在需要另一种精度时更改它。当您选择用于存储浮点变量的数据类型时,您应该注意一些技巧。在 C++中,有三种数据类型可以表示浮点值:float、double 和 long double。

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

图 6.11:浮点类型的比较表

图 6.11:浮点类型的比较表

注意

当您需要超过六个有效数字的精度时,请优先选择 double,否则您将得到意外的结果。

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

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

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

  1. 包括<iostream>头文件以支持流和<iomanip>以支持std::setprecision
#include <iostream>
#include <iomanip>
  1. 接下来,声明一个模板formattingPrint函数,它有一个名为FloatingPoint的模板参数,并接受一个此类型的参数变量。接下来,通过调用std::cout对象中的precision()函数,将先前的精度存储在一个 auto 变量中。然后,在终端中以不同的格式显示给定的数字:带小数点,不带小数点,以及固定、科学、十六进制浮点和默认浮点格式。接下来,在 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);
}
  1. 输入main函数。声明一个名为floatNumfloat变量,一个名为doubleNum的双精度变量,以及一个名为longDoubleNum的长双精度变量。然后,在无限循环中,要求用户输入一个浮点数,读取输入到longDoubleNum,并将其传递给formattingPrint函数。接下来,通过使用longDoubleNum的值初始化doubleNum并将其传递给formattingPrint函数。接下来,通过使用longDoubleNum的值初始化floatNum并将其传递给formattingPrint函数:
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;
}
  1. 构建并运行应用程序。跟踪输出并输入具有22个有效数字的浮点值,例如0.2222222222222222222222。我们将得到一个很长的输出。现在,我们需要将其拆分进行分析。这是长双精度值输出的一部分的屏幕截图:

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

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

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

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

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

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

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

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

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

注意

永远不要使用浮点数据类型来表示货币或汇率;你可能会得到错误的结果。

布尔格式化的 I/O 操作符

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

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标志执行的。要在输入操作中使用这些函数,我们需要有一个包含 true/false 单词或 0/1 符号的源字符串。输入操作中的std::boolalphastd::noboolalpha函数调用如下:

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

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

用于字段宽度和填充控制的 I/O 操作符

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

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*

现在,您可以看到空格被星号填充了。既然我们已经考虑了在哪里可以使用格式化宽度和填充输出,那么让我们考虑如何使用 I/O 操作符进行这样的操作。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

其他数字格式的 I/O 操作符

如果您需要输出带有“+”符号的正数值,您可以使用<ios>头文件中的另一个 I/O 操作符——std::showpos函数。相反的意义操作符也存在——std::noshowpos函数。它们都会影响输出。它们的使用非常简单。让我们考虑以下例子:

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::uppercasestd::nouppercase。它们仅适用于输出。让我们考虑一个小例子:

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::uppercase标志,有时不使用。默认情况下,std::nouppercase标志被设置。看一下执行的结果:

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

用于处理空白的 I/O 操纵器

在标准库中,有用于处理空白的函数。<istream>头文件中的std::ws函数只适用于输入流,并丢弃前导空白。<ios>头文件中的std::skipwsstd::noskipws函数用于控制读取和写入前导空白。它们适用于输入和输出流。当设置了std::skipws标志时,流会忽略字符序列前面的空白。默认情况下,std::skipws标志被设置。让我们考虑一下使用这些 I/O 操纵器的例子。首先,我们将用默认格式读取输入并输出我们所读取的内容。接下来,我们将清除我们的字符串,并使用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::quoted。当这个函数应用于输入时,它会用转义字符将给定的字符串包装在引号中。如果输入字符串已经包含转义引号,它也会读取它们。为了理解这一点,让我们考虑一个小例子。我们将用一些没有引号的文本初始化一个源字符串,另一个字符串将用带有转义引号的文本初始化。接下来,我们将使用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::quoted调用做同样的输出:

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\""]

你注意到第一个字符串被引号包裹,第二个字符串中的子字符串"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;

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

创建额外的流

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

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

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

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

如何创建一个额外的流 - 组合

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

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

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

在这个练习中,我们将创建一个自己的流对象,包装std::ostream对象并添加额外的功能。我们将创建一个名为extendedOstream的类,它将向终端输出数据,并在每个输出的数据前插入以下数据:日期和时间以及线程 ID。要完成这个练习,执行以下步骤:

  1. 包括所需的头文件:<iostream>用于std::endl支持,<sstream>用于std::ostream支持,<thread>用于std::this_thread::get_id()支持,<chrono>用于std::chrono::system_clock::now(),和<ctime>用于将时间戳转换为可读表示:

注意

#include <iostream>
#include <sstream>
#include <thread>
#include <chrono>
#include <ctime>
  1. 接下来,声明extendedOstream类。声明名为m_ossstd::ostream变量和名为writeAdditionalInfo的 bool 变量。这个 bool 变量将用于指示是否应该打印扩展数据:
class extendedOstream
{
private:
     std::ostream& m_oss;
     bool writeAdditionalInfo;
};
  1. 接下来,在公共部分,定义一个默认构造函数,并用std::cout初始化m_oss以将输出重定向到终端。用true初始化writeAdditionalInfo
extendedOstream()
     : m_oss(std::cout)
     , writeAdditionalInfo(true)
{
}
  1. 定义一个模板重载的左移操作符<<,它返回对extendedOstream的引用,并带有名为 value 的模板参数。然后,如果writeAdditionalInfotrue,输出时间、线程 ID 和给定的值,然后将writeAdditionalInfo设置为false。如果writeAdditionalInfofalse,只输出给定的值。这个函数将用于所有内置类型的输出:
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;
}
  1. 定义另一个重载的左移操作符,它以函数指针作为参数并返回对std::ostream的引用。在函数体中,将writeAdditionalInfo设置为true,调用给定的函数,并将m_oss作为参数传递。这个重载的操作符将用于预定义函数,如std::endl
extendedOstream&
operator<<(std::ostream& (*pfn)(std::ostream&))
{
     writeAdditionalInfo = true;
     pfn(m_oss);
     return *this;
}
  1. 在私有部分,定义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();
}
  1. 在私有部分,定义threadId()函数,返回一个字符串。获取当前线程的id,格式化它,并返回它:
std::string threadId()
{
     auto id = std::this_thread::get_id();
     std::ostringstream oss;
     oss << "[" << std::dec << id << "]";
     return oss.str();
}
  1. 进入main函数。为了测试我们的流对象如何工作,创建一个名为ossextendedOstream类型的对象。输出不同的数据,例如整数、浮点数、十六进制和布尔值:
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;
  1. 然后,创建一个线程,用 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();
  1. 现在,构建并运行应用程序。你将得到以下输出:

图 6.15:执行练习 6 的结果

考虑输出的每一行。你可以看到输出的下一个格式:"[日期和时间][线程 ID]输出数据"。确保线程 ID 在不同的线程之间不同。然后,数据以预期的格式输出。所以,正如你所看到的,使用标准流的组合实现自己的 I/O 流对象并不太难。

如何创建一个附加流 - 继承

继承意味着你创建自己的流类,并从具有虚拟析构函数的标准流对象中继承它。你的类必须是一个模板类,并且具有模板参数,就像父类一样。要使用你的所有继承函数与你的类的对象,继承应该是公共的。在构造函数中,你应该根据类的类型初始化父类 - 使用文件名、流缓冲区或默认值。接下来,你应该重写那些基本函数,根据你的要求进行更改。

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

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

  1. 通过sputc()函数调用将字符缓冲到内部缓冲区。

  2. 当缓冲区满时,sputc()调用受保护的虚拟成员overflow()

  3. overflow()函数将所有缓冲区内容传输到外部设备。

  4. 调用pubsync()函数时,它会调用受保护的虚拟成员sync()

  5. sync()函数将所有缓冲区内容传输到外部设备。

输出操作的非缓冲字符传输工作略有不同:

  1. 字符传递给sputc()函数。

  2. sputc()函数立即调用被称为overflow()的受保护虚拟成员。

  3. overflow()函数将所有缓冲区内容传输到外部设备。

因此,对于输出操作的缓冲和非缓冲字符传输,我们应该重写overflow()sync()函数,这些函数执行实际工作。

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

  1. sgetc()函数从内部缓冲区读取字符。

  2. sgetc()函数调用sungetc()函数,使已消耗的字符再次可用。

  3. 如果内部缓冲区为空,sgetc()函数会调用underflow()函数。

  4. underflow()函数从外部设备读取字符到内部缓冲区。

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

  1. sbumpc()函数从内部缓冲区读取字符。

  2. sbumpc()函数调用sputbackc()函数,使下一个字符可用于输入。

  3. 如果内部缓冲区为空,sbumpc()函数会调用uflow()函数。

  4. uflow()函数从外部设备读取字符到内部缓冲区。

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

  1. sgetc()函数调用一个被称为underflow()的受保护虚拟成员。

  2. underflow()函数从外部设备读取字符到内部缓冲区。

  3. sbumpc()函数调用一个被称为uflow()的受保护虚拟成员。

  4. uflow()函数从外部设备读取字符到内部缓冲区。

在发生任何错误的情况下,会调用被称为pbackfail()的受保护虚拟成员,该成员处理错误情况。因此,可以看到,要重写std::basic_streambuf类,我们需要重写与外部设备一起工作的虚拟成员。对于输入streambuf,我们应该重写underflow()uflow()pbackfail()成员。对于输出streambuf,我们应该重写overflow()sync()成员。

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

练习 7:继承标准流对象

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

  1. 包括所需的头文件:<iostream>用于支持std::endl<sstream>用于支持std::ostreamstd::basic_streambuf<thread>用于支持std::this_thread::get_id()<chrono>用于支持std::chrono::system_clock::now()<ctime>用于将时间戳转换为可读状态。

  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);
    }
};
  1. 接下来,创建一个名为extended_ostream的模板类,它是从std::basic_ostream类派生而来的。在私有部分,定义一个extended_streambuf类的成员,即缓冲区。用缓冲区成员初始化std::basic_ostream父类。然后,在构造函数体中,使用缓冲区作为参数调用父类的init()函数。还要重载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;
};
  1. extendedOstream类重命名为 logger,以避免与类似名称的误解。保持现有接口不变,但用我们自己的流替换std::ostream&成员,即object - 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;
};
  1. 进入main函数并将extendedOstream对象更改为logger对象。将其余代码保持不变。现在,构建并运行练习。您将看到在上一个练习中给出的输出,但在这种情况下,我们使用了自己的流缓冲区,自己的流对象和一个包装类,为输出添加了额外的信息。查看下面截图中显示的执行结果,并将其与先前的结果进行比较。确保它们是相似的。如果是这样,那就意味着我们做得很好,我们的继承类按预期工作:

图 6.16:执行练习 7 的结果

图 6.16:执行练习 7 的结果

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

利用异步 I/O

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

正如您可能还记得第五章《哲学家的晚餐-线程和并发》,同步操作意味着某个线程调用操作并等待其完成。它可以是单线程或多线程应用程序。关键是线程正在等待 I/O 操作完成。

异步执行发生在操作不阻塞工作线程的情况下。执行异步 I/O 操作的线程发送异步请求并继续执行另一个任务。当操作完成时,初始线程将收到完成通知,并可以根据需要处理结果。

从这个角度看,异步 I/O 似乎比同步更好,但这取决于情况。如果需要执行大量快速的 I/O 操作,由于处理内核 I/O 请求和信号的开销,更适合遵循同步方式。因此,在开发应用程序架构时,需要考虑所有可能的情况。

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

Windows 平台上的异步 I/O

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

因此,在 Windows 中,我们需要打开设备并获取其处理程序。不同的设备以不同的方式打开。要打开文件、目录、驱动器或端口,我们使用<Windows.h>头文件中的CreateFile函数。要打开管道,我们使用CreateNamedPipe函数。要打开套接字,我们使用 socket()和 accept()函数。要打开终端,我们使用CreateConsoleScreenBufferGetStdHandle函数。它们都返回一个设备处理程序,该处理程序用于所有与该设备的操作。

CreateFile函数接受七个参数,用于管理打开设备的操作。函数声明如下所示:

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

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

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

第三个参数dwShareMode管理操作系统在文件已经打开时如何处理所有新的CreateFile调用。它可以取以下值之一:

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通常设置为NULL。第五个参数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接受打开文件的句柄或NULL作为参数。如果传递了文件句柄,CreateFile函数将忽略所有属性和标志,并使用打开文件的属性和标志。

这就是关于CreateFile参数的全部内容。如果无法打开设备,它将返回INVALID_HANDLE_VALUE。以下示例演示了如何打开文件进行读取:

#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;
}

接下来,要执行输入操作,我们使用ReadFile函数。它将文件描述符作为第一个参数,源缓冲区作为第二个参数,要读取的最大字节数作为第三个参数,读取字节数作为第四个参数,NULL值作为同步执行或者指向有效且唯一的 OVERLAPPED 结构的指针作为最后一个参数。如果操作成功,ReadFile返回 true,否则返回 false。以下示例演示了如何从先前打开的文件进行读取输入:

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

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

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

要将缓存数据写入设备,使用FlushFileBuffer函数。它只有一个参数 - 文件描述符。让我们转向异步 I/O。要让操作系统知道您计划异步地使用设备,需要使用FILE_FLAG_OVERLAPPED标志打开它。现在,打开文件进行写入或读取如下所示:

#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;
}

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

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,这意味着操作尚未开始。读取或写入的字节数将写入InternalHigh成员。在异步操作中,OffsetOffsetHigh将被忽略。hEvent成员用于接收有关异步操作完成的事件。

注意

I/O 操作的顺序不能保证,因此您不能依赖于此。如果您计划在一个地方写入文件,并在另一个地方从文件中读取,您不能依赖于顺序。

在异步模式下使用ReadFileWriteFile时有一个不寻常的地方。如果它们以同步方式执行 I/O 请求,则返回一个非零值。如果它们返回FALSE,你需要调用GetLastError函数来检查为什么返回了FALSE。如果错误代码是ERROR_IO_PENDING,这意味着 I/O 请求已成功处理,处于挂起状态,并将在以后执行。

你应该记住的最后一件事是,在 I/O 操作完成之前,不能移动或删除OVERLAPPED对象或数据缓冲区。对于每个 I/O 操作,你应该创建一个新的 OVERLAPPED 对象。

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

WriteFileReadFile函数将设备设置为“占用”状态。当 I/O 操作完成时,驱动程序将设备设置为“空闲”状态。我们可以通过调用WaitForSingleObjectWaitForMultipleObject函数来检查完成的 I/O 操作。以下示例演示了这种方法:

#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;
}

这是检查 I/O 操作是否已完成的最简单方法。但这种方法使调用线程在WaitForSingleObject调用上等待,因此它变成了一个同步调用。此外,你可以为该设备启动几个 I/O 操作,但不能确定线程是否会在需要释放设备时唤醒。

使用CreateEvent函数并将其设置为OVERLAPPED对象。然后,当 I/O 操作完成时,系统通过调用SetEvent函数释放此事件。接下来,当调用线程需要获取正在执行的 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;
}

如果你希望通知调用线程 I/O 操作的结束,这是一个相当简单的方法。但这并不是最理想的做法,因为当有很多这样的操作时,你需要为每个操作创建一个事件对象。

ReadFileExWriteFileEx用于输入/输出。它们类似于标准的ReadFileWriteFile,但我们不传递存储读取或写入字符数的变量,而是传递回调函数的地址。这个回调函数被称为完成例程,并且具有以下声明:

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

ReadFileExWriteFileEx将回调函数的地址传递给设备驱动程序。当设备上的操作完成时,驱动程序将回调函数的地址添加到 APC 队列和 OVERLAPPED 结构的指针。然后,操作系统调用此函数并传递读取或写入的字节数、错误代码和 OVERLAPPED 结构的指针。

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

现在我们已经讨论了不好的地方,让我们看看处理 I/O 结果的最佳方法 - I/O 端口。I/O 完成端口是为与线程池一起使用而开发的。要创建这样一个端口,我们使用CreateIoCompletionPort。该函数的声明如下:

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

此函数创建一个 I/O 完成端口并将设备与此端口关联。要完成此操作,我们需要调用两次。要创建新的完成端口,我们调用CreateIoCompletionPort函数,并将INVALID_HANDLE_VALUE作为第一个参数传递,NULL 作为第二个参数,0 作为第三个参数,并传递此端口的线程数。将 0 作为第四个参数将使线程数等于处理器的数量。

注意

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

接下来,我们需要将此端口与输入/输出设备关联起来。因此,我们第二次调用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 上的异步 I/O 支持对不同设备进行输入和输出,如套接字、管道和 TTY,但不包括文件。是的,这很奇怪,但 Linux 开发人员决定文件的 I/O 操作已经足够快了。

要打开 I/O 设备,我们使用 open()函数。它的声明如下:

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

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

为了执行输入/输出操作,我们使用名为aioPOSIX接口。它们有一组定义好的函数,如aio_readaio_writeaio_fsync等。它们用于启动异步操作。要获取执行结果,我们可以使用信号通知或实例化线程。或者,我们可以选择不被通知。所有这些都在<aio.h>头文件中声明。

几乎所有这些都以aiocb结构(异步 IO 控制块)作为参数。它控制 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成员是 I/O 操作的类型。以下示例演示了如何初始化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;

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

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

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

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

  1. 包括所有必需的头文件:<aio.h>用于异步读写支持,<signal.h>用于信号支持,<fcntl.h>用于文件操作,<unistd.h>用于符号常量支持,<iostream>用于输出到终端,<chrono>用于时间选项,<thread>用于线程支持:
#include <aio.h>
#include <signal.h>
#include <fcntl.h>
#include <unistd.h>
#include <iostream>
#include <chrono>
#include <thread>
  1. 创建一个名为isDone的 bool 变量,用于指示操作何时已完成:
bool isDone{};
  1. 定义将作为我们的信号处理程序的函数,即aioSigHandler。当异步操作完成时将调用它。信号处理程序应具有以下签名:
void name(int number, siginfo_t* si, void* additional)
  1. 第一个参数是信号编号,第二个参数是一个包含有关信号生成原因的信息的结构,最后一个参数是附加信息。它可以转换为ucontext_t结构的指针,以便我们可以接收到被该信号中断的线程上下文。在aioSigHandler中,检查异步 I/O 操作相关的信号是否是常量,使用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;
}
  1. 定义另一个辅助函数,名为initSigAct。它将初始化sigaction结构。该结构定义了在 I/O 操作完成时将发送哪个信号以及应调用哪个处理程序。在这里,我们选择了SIGUSR1 - 一个用户定义的信号。在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;
}
  1. 定义名为fillAiocb的辅助函数,它将使用给定的参数填充aiocb结构。它将以 aiocb 结构的引用、文件描述符、缓冲区指针和缓冲区大小作为参数。在sigev_signo中设置SIGUSR1,这是我们之前初始化的:
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;
}
  1. 进入main函数。定义名为buf_size的变量,其中包含缓冲区大小。创建一个该大小的缓冲区:
constexpr int bufSize = 100;
char* buffer = new char(bufSize);
if (!buffer)
{
     std::cerr << "Failed to allocate buffer" << std::endl;
     return -1;
}
  1. 创建一个名为fileName的变量,其中包含一个名为"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;
  1. 创建一个sigaction结构并使用initSigAct函数进行初始化:
struct sigaction sa;
if (!initSigAct(sa))
{
     std::cerr << "failed registering signal" << std::endl;
     return -1;
}
  1. 创建一个aiocb结构并使用fillAiocb函数进行初始化:
aiocb aiocbObj;
fillAiocb(aiocbObj, descriptor, buffer, bufSize);
  1. 使用aio_read函数执行read操作:
if (-1 == aio_read(&aiocbObj))
{
     std::cerr << "aio_read failed" << std::endl;
}
  1. 接下来,在循环中,评估isDone变量。如果它为 false,则使线程休眠3ms。通过这样做,我们将等待 I/O 操作完成:
while (!isDone)
{
     using namespace std::chrono_literals;
     std::this_thread::sleep_for(3ms);
}
std::cout << "Successfully finished read operation. Buffer: " << std::endl << buffer; 
  1. 在运行此练习之前,在项目目录中创建一个Test.txt文件,并写入不同的符号。例如,我们的文件包含以下数据:
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. 现在,在您的 IDE 中构建并运行此练习。您的输出将类似于以下内容:

图 6.17:执行练习 8 的结果

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

这就是 Linux 系统中的异步 I/O 的全部内容。

注意

您可以通过访问 Linux 的 man 页面了解有关 Linux 中异步 IO 的更多信息:http://man7.org/linux/man-pages/man7/aio.7.html。

现在,让我们了解一下我们可以用于跨平台应用的内容。

异步跨平台 I/O 库

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

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

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

  • 无需线程的并发

  • 线程

  • 缓冲区

  • 协程

  • TCP、UDP 和 ICMP

  • 套接字

  • SSL

  • 定时器

  • 串口

让我们简要地考虑一下它的 I/O 操作接口。我们可以使用Asio库的接口进行同步和异步操作。所有 I/O 操作都始于io_service类,该类提供核心 I/O 功能。它在<boost/asio/io_service.hpp>头文件中声明。同步 I/O 调用io_service对象的run()函数进行单个操作,该操作会阻塞调用线程,直到工作完成。异步 I/O 使用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();

在实际进行 I/O 操作之前,可以运行事件处理程序。使用io_service类的工作类在代码中实现此功能。工作类保证run函数在您决定不会有任何未来的 I/O 操作之前不会返回。例如,您可以将工作类作为另一个类的成员,并在析构函数中将其移除。因此,在您的类的生命周期内,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 操作,我们需要确切的 I/O 设备,例如文件、套接字等。有许多类实现了与不同 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 = =
     {
          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 操作的回调函数。

注意

Boost.Asiohttps://www.boost.org/doc/libs/1_63_0/doc/html/boost_asio.html 上有很好的文档。有许多不同 IO 设备和不同方法的示例。如果您决定在项目中使用Boost.Asio,可以参考此文档。

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

线程和 I/O 的交互

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

让我们稍微谈谈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 库版本。您还必须知道std::coutstd::cin在它们之间是同步的。这意味着调用std::cout总是刷新std::cin流,调用std::cin总是刷新std::cout流。

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

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

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

  1. 包括所有必需的头文件:
#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

  1. 实现一个诸如write()的函数,它将锁定互斥锁并从内部缓冲区输出到std::cout。使用这个函数的方式将如下所示:
mtcout cout;
cout << msg << std::endl;
cout.write();
  1. 我们有一个函数将始终自动调用,并且我们可以将写函数的代码放入其中。这是一个析构函数。在这种情况下,我们将创建和销毁合并为一行。这样一个对象的使用将如下所示:
mtcout{} << msg << std::endl; 
  1. 现在,让我们定义我们的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;
};
  1. 接下来,在类外声明mutex变量。我们这样做是因为我们必须在任何源文件中声明一个静态变量:
std::mutex mtcout::m_mux; 
  1. 输入主函数。创建一个名为func的 lambda。它将测试我们的mtcout类。它以字符串作为参数,并在循环中使用mtcout01000输出这个字符串。使用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;
     }
};
  1. 创建四个线程并将 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();
  1. 首次构建和运行练习。您将获得以下输出:图 6.18:执行练习 9,第 1 部分的结果
图 6.18:执行练习 9,第 1 部分的结果

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

  1. 现在,取消 lambda 中使用std::cout的输出,并注释掉使用mtcout的输出。

  2. 再次构建和运行应用程序。现在,您将获得一个"脏"的、混合的输出,如下所示:图 6.19:执行练习 9,第 2 部分的结果

图 6.19:执行练习 9,第 2 部分的结果

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

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

当然,我们可以在输出之前格式化字符串,但使用 mtcout 类更方便,不必担心格式。您可以为任何流创建类似的包装器,以便安全地执行 I/O 操作。您可以更改输出并添加任何其他信息,例如当前线程的 ID、时间或您需要的任何其他信息。利用我们在第五章中学到的关于同步 I/O 操作、扩展流并使输出对您的需求更有用的东西。

使用宏

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

#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);

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

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

注意

宏定义来自 C 语言。最好使用 const 变量或内联函数。然而,仍然有一些情况下使用宏定义更方便,例如在记录器中定义不同的记录级别时。

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

活动 1:艺术画廊模拟器的日志系统

在这个活动中,我们将开发一个记录器,它将以格式化的形式输出日志到终端。我们将以以下格式输出日志:

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

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

因此,在尝试此活动之前,请确保您已完成本章中的所有先前练习。

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

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

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

我们在美术馆模拟器项目中已经实现了以下类:

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

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

图 6.22:类图

图 6.22:类图

为了以期望的格式接收输出,LoggerUtils类应该具有以下static函数:

图 6.23:LoggerUtils 成员函数的描述

图 6.23:LoggerUtils 成员函数的描述

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

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

  2. 定义并实现StreamLogger类,为终端提供线程安全的输出接口。它应该格式化输出如下:

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

  2. 将实现的记录器集成到美术馆模拟器的类中。

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

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

图 6.24:应用程序执行的结果

图 6.24:应用程序执行的结果

注意

此活动的解决方案可在第 696 页找到。

总结

在本章中,我们学习了 C++中的 I/O 操作。我们考虑了 I/O 标准库,它提供了同步 I/O 操作的接口。此外,我们考虑了与平台相关的异步 I/O 的本机工具,以及Boost.Asio库用于跨平台异步 I/O 操作。我们还学习了如何在多线程应用程序中使用 I/O 流。

我们首先看了标准库为 I/O 操作提供的基本功能。我们了解了预定义的流对象,如std::cinstd::cout。在实践中,我们学习了如何使用标准流并重写移位运算符以便轻松读取和写入自定义数据类型。

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

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

最后,我们学习了如何在多线程应用程序中执行 I/O 操作。我们将所有这些新技能付诸实践,通过构建一个多线程日志记录器。我们创建了一个日志记录抽象,并在艺术画廊模拟器中使用它。结果,我们创建了一个简单、清晰、健壮的日志记录系统,可以通过日志轻松调试应用程序。总之,我们在本章中运用了我们学到的一切。

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

第八章:每个人都会跌倒,重要的是你如何重新站起来——测试和调试

学习目标

通过本章结束时,您将能够:

  • 描述不同类型的断言

  • 实施编译时和运行时断言

  • 实施异常处理

  • 描述并实施单元测试和模拟测试

  • 使用断点和监视点调试 C++代码

  • 在调试器中检查数据变量和 C++对象

在本章中,您将学习如何适当地添加断言,添加单元测试用例以使代码按照要求运行,并学习调试技术,以便您可以找到代码中的错误并追踪其根本原因。

介绍

软件开发生命周期SDLC)中,一旦需求收集阶段完成,通常会进入设计和架构阶段,在这个阶段,项目的高级流程被定义并分解成模块的较小组件。当项目中有许多团队成员时,每个团队成员清楚地被分配了模块的特定部分,并且他们了解自己的要求是必要的。这样,他们可以在隔离的环境中独立编写他们的代码部分,并确保它能正常运行。一旦他们的工作部分完成,他们可以将他们的模块与其他开发人员的模块集成,并确保整个项目按照要求执行。

这个概念也可以应用于小型项目,其中开发人员完全致力于一个需求,将其分解为较小的组件,在隔离的环境中开发组件,确保它按计划执行,集成所有小模块以完成项目,并最终测试以确保整个项目正常运行。

整合整个项目并执行时需要大量的测试。可能会有一个单独的团队(称为IP 地址作为字符串,然后开发人员需要确保它的格式为XXX.XXX.XXX.XXX,其中X0-9之间的数字。字符串的长度必须是有限的。

在这里,开发人员可以创建一个测试程序来执行他们的代码部分:解析文件,提取IP 地址作为字符串,并测试它是否处于正确的格式。同样,如果配置有其他需要解析的参数,并且它们需要以特定格式出现,比如userid/password,日志文件的位置或挂载点等,那么所有这些都将成为该模块的单元测试的一部分。在本章中,我们将解释诸如断言安全嵌套异常处理)、单元测试模拟断点监视点数据可视化等技术,以确定错误的来源并限制其增长。在下一节中,我们将探讨断言技术。

断言

对于上述情景使用测试条件将有助于项目更好地发展,因为缺陷将在基本层面被捕捉到,而不是在后期的 QA 阶段。可能会出现这样的情况,即使编写了单元测试用例并成功执行了代码,也可能会发现问题,比如应用程序崩溃、程序意外退出或行为不如预期。为了克服这种情况,通常开发人员使用调试模式二进制文件来重新创建问题。断言用于确保条件被检查,否则程序的执行将被终止。

这样,问题可以被迅速追踪。此外,在调试模式中,开发人员可以逐行遍历程序的实际执行,并检查代码流程是否如预期那样,或者变量是否设置如预期那样并且是否被正确访问。有时,访问指针变量会导致意外行为,如果它们没有指向有效的内存位置。

在编写代码时,我们可以检查是否满足必要条件。如果不满足,程序员可能不希望继续执行代码。这可以很容易地通过断言来实现。断言是一个宏,用于检查特定条件,如果不满足条件,则调用 abort(停止程序执行)并打印错误消息作为标准错误。这通常是运行时断言。还可以在编译时进行断言。我们将在后面讨论这一点。在下一节中,我们将解决一个练习,其中我们将编写和测试我们的第一个断言。

练习 1:编写和测试我们的第一个断言

在这个练习中,我们将编写一个函数来解析 IP 地址并检查它是否有效。作为我们的要求的一部分,IP 地址将作为字符串文字以XXX.XXX.XXX.XXX的格式传递。在这种格式中,X代表从09的数字。因此,作为测试的一部分,我们需要确保解析的字符串不为空,并且长度小于 16。按照以下步骤来实现这个练习:

  1. 创建一个名为AssertSample.cpp的新文件。

  2. 打开文件并写入以下代码以包括头文件:

#include<iostream>
#include<cassert>
#include<cstring>
using std::cout;
using std::endl;

在上述代码中,#include<cassert>显示我们需要包括定义 assert 的 cassert 文件。

  1. 创建一个名为 checkValidIp()的函数,它将以 IP 地址作为输入,并在 IP 地址满足我们的要求时返回 true 值。编写以下代码来定义该函数:
bool checkValidIp(const char * ip){
    assert(ip != NULL);
    assert(strlen(ip) < 16);
    cout << "strlen: " << strlen(ip) << endl;
    return true;
}

在这里,“assert(ip!= NULL)”显示 assert 宏用于检查传递的ip变量是否不为NULL。如果是NULL,那么它将中止并显示错误消息。另外,“assert(strlen(ip)<16)”显示 assert 用于检查ip是否为 16 个字符或更少。如果不是,则中止并显示错误消息。

  1. 现在,创建一个 main 函数,向我们的 checkValidIp()函数传递一个不同的字符串文字,并确保可以适当地进行测试。编写以下代码以实现 main 函数:
int main(){
    const char * ip;
    ip = NULL;
    bool check = checkValidIp(ip);
    cout << " IP address is validated as :" << (check ? "true" : "false") << endl;
    return 0;
}

在上述代码中,我们故意将 NULL 传递给 ip 变量,以确保调用 assert。

  1. 打开命令提示符并转到 g++编译器的位置,方法是键入以下命令:
g++ AssertSample.cpp

使用此命令生成 a.out 二进制文件。

  1. 通过在编译器中键入以下命令来运行 a.out 二进制文件:
./a.out

您将看到以下输出:

图 7.1:在命令提示符上运行断言二进制文件

图 7.1:在命令提示符上运行断言二进制文件

在上面的屏幕截图中,您可以看到用红色圈出的三段代码。第一个高亮部分显示了.cpp 文件的编译。第二个高亮部分显示了前面编译生成的 a.out 二进制文件。第三个高亮部分显示了对传递的 NULL 值抛出错误的断言。它指示了断言被调用的行号和函数名。

  1. 现在,在 main 函数中,我们将传递长度大于 16 的 ip,并检查这里是否也调用了 assert。编写以下代码来实现这一点:
ip = "111.111.111.11111";

再次打开编译器,编译传递的 ip 长度大于 16。

  1. 现在,为了满足 assert 条件,使二进制文件正常运行,我们需要在 main 函数中更新 ip 的值。编写以下代码来实现这一点:
ip = "111.111.111.111"; 

再次打开编译器,在这里编译 assert,我们没有向 checkValidIP()函数添加任何额外的功能。但是,在异常处理单元测试部分中,我们将使用相同的示例添加更多功能到我们的函数中。

  1. 如果我们不希望可执行文件因为生产或发布环境中的断言而中止,就从代码中删除assert宏调用。首先,我们将更新ip的值,其长度大于16。将以下代码添加到文件中:
ip = "111.111.111.11111";
  1. 现在,在编译时,传递-DNDEBUG宏。这将确保断言在二进制文件中不被调用。在终端中写入以下命令来编译我们的.cpp文件:
g++ -DNDEBUG AssertSample.cpp

在这之后,当我们执行二进制文件时,会生成以下输出:

图 7.4:在命令提示符上运行断言二进制文件

在上述截图中,由于未调用assert,它将显示字符串长度为17,并且true值为 IP 地址将被验证。在这个练习中,我们看到了在执行二进制文件时调用了断言。我们也可以在代码编译时进行断言。这是在 C++ 11 中引入的。它被称为静态断言,我们将在下一节中探讨它。

静态断言

有时,我们可以在编译时进行条件检查,以避免任何未来的错误。例如,在一个项目中,我们可能会使用一个第三方库,其中声明了一些数据结构。我们可以使用这些信息来正确分配或释放内存,并处理其成员变量。这个结构属性可能会在第三方库的不同版本中发生变化。然而,如果我们的项目代码仍然使用早期版本的结构,那么在使用它时就会出现问题。我们可能会在运行二进制文件时的后期阶段遇到错误。我们可以使用static assertion在编译时捕获这个错误。我们可以对静态数据进行比较,比如库的版本号,从而确保我们的代码不会遇到任何问题。在下一节中,我们将解决一个基于此的练习。

练习 2:测试静态断言

在这个练习中,我们将通过进行静态断言来比较两个头文件的版本号。如果版本号小于1,那么静态断言错误将被抛出。执行以下步骤来实现这个练习:

  1. 创建一个名为nameageaddress的头文件。它还有版本号1

  2. 创建另一个名为struct person的头文件,其中包含以下属性:nameageaddressMobile_No。它还有版本号 2。现在,版本 1是旧版本,版本 2是新版本。以下是两个头文件并排的截图:图 7.5:具有不同版本的库文件

图 7.5:具有不同版本的库文件
  1. 创建一个名为doSanityCheck()的文件,用于对库进行版本检查。它使用静态断言,并在编译时执行。代码的第二行显示了doSanityCheck()函数,static_assert()函数检查此库的版本是否大于 1。

注意

如果您的项目需要在版本 2或更高版本的库中定义的person结构才能正确执行,我们需要匹配版本 2的文件,即PERSON_LIB_VERSION至少应设置为2。如果开发人员获得了库的版本 1并尝试为项目创建二进制文件,可能会在执行时出现问题。为了避免这种情况,在项目的主代码中,在构建和执行之前对项目进行健全性检查。

  1. 要在我们的版本 1中包含库的版本 1

  2. 编译我们的static_assert错误,因为库的版本不匹配。

  3. 现在,为了正确编译程序,删除ProgramLibrary的软链接,并创建一个指向version2的新链接,然后再次编译。这次,它将编译成功。在终端中输入以下命令以删除软链接:

rm PersonLibrary.h 
ln -s PersonLibrary_ver2.h PersonLibrary.h
g++ StaticAssertionSample.cpp

以下是相同的屏幕截图:

图 7.7:静态断言编译文件

图 7.7:静态断言编译文件

如您所见,红色标记的区域显示使用了正确版本的PersonLibrary,编译进行顺利。编译后,将创建一个名为“a.exe”的二进制文件。在这个练习中,我们通过比较两个头文件的版本号执行了静态断言。在下一节中,我们将探讨异常处理的概念。

理解异常处理

正如我们之前在调试模式二进制中看到的,我们可以使用运行时断言来中止程序,当某个条件不满足时。但是在发布模式二进制或生产环境中,当客户使用此产品时,突然中止程序并不是一个好主意。最好处理这样的错误条件,并继续执行二进制的下一部分。

最坏的情况发生在二进制需要退出时。它会通过添加正确的日志消息和清理为该进程分配的所有内存来优雅地退出。对于这种情况,使用异常处理。在这里,当发生错误条件时,执行会转移到一个特殊的代码块。异常包括三个部分,如下所示:

  • try 块:在这里,我们检查条件是否符合必要的条件。

  • throw 块:如果条件不符合,它会抛出异常。

  • catch 块:它捕获异常并对该错误条件执行必要的执行。

在下一节中,我们将解决一个练习,在其中我们将对我们的代码执行异常处理。

练习 3:执行异常处理

在这个练习中,我们将在我们的AssertSample.cpp代码上执行异常处理。我们将用我们的异常替换断言条件。执行以下步骤来实现这个练习:

  1. 创建一个名为ExceptionSample.cpp的文件。

  2. 添加以下代码以添加头文件:

#include<iostream>
#include<cstring>
using std::cout;
using std::endl; 
  1. 创建一个checkValidIp()函数,在其中有一个 try-catch 块。如果 try 块中的条件不满足,将抛出异常,并打印 catch 块中的消息。添加以下代码来完成这个操作:
bool checkValidIp(const char * ip){
    try{
        if(ip == NULL)
            throw ("ip is NULL");
        if(strlen(ip) > 15)
            throw int(strlen(ip));
    }
    catch(const char * str){
        cout << "Error in checkValidIp :"<< str << endl;
        return false;
    }
    catch(int len){
        cout << "Error in checkValidIp, ip len:" << len <<" greater than 15 characters, condition fail" << endl;
        return false;
    }
    cout << "strlen: " << strlen(ip) << endl;
    return true;
}

在前面的代码中,您可以看到 try 块,其中检查条件。在 try 块内,如果ipNULL,那么它将抛出(const char *)类型的异常。在下一个条件中,如果ip大于 15,则它将抛出带有 int 参数类型的异常。这个抛出被正确的 catch 捕获,匹配参数(intconst char *)。两个异常都返回带有一些错误消息的false。或者,在catch块中,如果需要进行任何清理或使用在异常中用于比较的变量的默认值,可以执行额外的步骤。

注意

有一个默认的异常;例如,如果有一个嵌套函数抛出一个带有不同参数的错误,它可以作为具有参数的更高级函数捕获(…)。同样,在通用 catch 中,您可以为异常处理创建默认行为。

  1. 创建main()函数,并在其中写入以下代码:
int main(){
    const char * ip;
    ip = NULL;
    if (checkValidIp(ip)) 
        cout << "IP address is correctly validated" << endl;
    else {
        /// work on error condition 
        // if needed exit program gracefully.
        return -1;
    }
    return 0;
}
  1. 打开终端,编译我们的文件,并运行二进制文件。您将看到以下输出:图 7.8:带有异常处理的示例执行代码
图 7.8:带有异常处理的示例执行代码

前面的示例对ipNULL抛出异常并优雅退出。

  1. 现在,在main函数中修改ip的值,提供超过 15 个字符。编写以下代码来执行此操作:
ip = "111.111.111.11111";
  1. 打开终端,编译我们的文件,然后运行二进制文件。您将看到以下输出:图 7.9:异常处理的另一个例子
图 7.9:异常处理的另一个例子

它为“ip 字符串”的“长度不匹配”抛出错误。

  1. 再次修改main函数中ip的值,提供少于15个字符。编写以下代码来实现这一点:
ip = "111.111.111.111";
  1. 打开终端,编译我们的文件,然后运行二进制文件。您将看到以下输出:

图 7.10:二进制文件正常运行,没有抛出异常

图 7.10:二进制文件正常运行,没有抛出异常

如前面的截图所示,二进制文件正常执行,没有抛出任何异常。现在您已经了解了如何处理异常,在下一节中,我们将探讨“单元测试”和“模拟测试”的概念。

单元测试和模拟测试

当开发人员开始编写代码时,他们需要确保在单元级别正确测试代码。可能会出现边界条件被忽略的情况,当代码在客户端站点运行时可能会出现故障。为了避免这种情况,通常最好对代码进行“单元测试”。“单元测试”是在代码的单元级别或基本级别进行的测试,在这里开发人员可以在隔离的环境中测试他们的代码,假设已经满足了运行代码功能所需的设置。通常,将模块分解为小函数并分别测试每个函数是一个很好的实践。

例如,假设功能的一部分是读取配置文件并使用配置文件中的参数设置环境。我们可以创建一个专门的函数来编写这个功能。因此,为了测试这个功能,我们可以创建一组单元测试用例,检查可能失败或行为不正确的各种组合。一旦确定了这些测试用例,开发人员可以编写代码来覆盖功能,并确保它通过所有单元测试用例。这是开发的一个良好实践,您首先不断添加测试用例,然后相应地添加代码,然后运行该函数的所有测试用例,并确保它们的行为是适当的。

有许多可用于编写和集成项目的单元测试用例的工具。其中一些是“Google 测试框架”。它是免费提供的,并且可以与项目集成。它使用xUnit 测试框架,并具有一系列断言,可用于测试用例的条件。在下一节中,我们将解决一个练习,其中我们将创建我们的第一个单元测试用例。

练习 4:创建我们的第一个单元测试用例

在这个练习中,我们将处理与上一节讨论过的相同场景,即开发人员被要求编写一个函数来解析“配置文件”。配置文件中传递了不同的有效参数,例如“产品可执行文件名”、“版本号”、“数据库连接信息”、“连接到服务器的 IP 地址”等。假设开发人员将分解解析文件的所有功能,并在单独的函数中设置和测试各个属性的参数。在我们的情况下,我们假设开发人员正在编写功能,他们已经将“IP 地址”解析为“字符串”,并希望推断出该“字符串”是否是有效的“IP 地址”。目前,使“IP 地址”有效的标准需要满足以下条件:

  • “字符串”不应为空。

  • “字符串”不应包含超过16个字符

  • “字符串”应该是XXX.XXX.XXX.XXX的格式,其中X必须是0-9的数字。

执行以下步骤来实现这个练习:

  1. 创建checkValidIp()来检查IP 地址是否有效。再次,为了理解Google 单元测试,我们将编写最少的代码来理解这个功能。

  2. 创建一个ip不为空,并且长度小于16

#include "CheckIp.h"
#include<string>
#include<sstream>
bool checkValidIp(const char * ip){
    if(ip == NULL){
        cout << "Error : IP passes is NULL " << endl;
        return false;
    }
    if(strlen(ip) > 15){
        cout << "Error: IP size is greater than 15" << endl;
        return false;
    }
    cout << "strlen: " << strlen(ip) << endl;
    return true;
} 

在前面的代码中,如果两个条件都失败,函数将返回false

  1. 调用checkValidIp()函数来创建一个名为checkValidIP()函数的新文件。在其中添加以下代码:
#include"CheckIp.h"
int main(){
    const char * ip;
    //ip = "111.111.111.111";
    ip = "111.111.111.11111";
    if (checkValidIp(ip)) 
        cout << "IP address is correctly validated" << endl;
    else {
        /// work on error condition 
        // if needed exit program gracefully.
        cout << " Got error in valid ip " << endl;
        return -1;
    }
    return 0;
} 
  1. 要创建测试代码,我们将创建我们的第一个checkValidIp函数。在其中写入以下代码:
#include"CheckIp.h"
#include<gtest/gtest.h>
using namespace std;
const char * testIp;
TEST(CheckIp, testNull){
    testIp=NULL;
    ASSERT_FALSE(checkValidIp(testIp));
}
TEST(CheckIp, BadLength){
    testIp = "232.13.1231.1321.123";
    ASSERT_FALSE(checkValidIp(testIp));
}

在前面代码的第二行,我们包含了TEST函数,它接受两个参数:第一个是testsuite名称,第二个是testcase名称。对于我们的情况,我们创建了TestSuite CheckIp。在TEST块中,您将看到我们有Google 测试定义了一个名为ASSERT_FALSEassert,它将检查条件是否为false。如果不是,它将使测试用例失败,并在结果中显示相同的内容。

注意

通常,对于Google 测试用例和测试套件,您可以将它们分组在一个公共命名空间中,并调用RUN_ALL_TESTS宏,该宏运行附加到测试二进制文件的所有测试用例。对于每个测试用例,它调用SetUp函数来初始化(类中的构造函数),然后调用实际的测试用例,最后调用TearDown函数(类中的析构函数)。除非您必须为测试用例初始化某些内容,否则不需要编写SetUpTearDown函数。

  1. 现在,要运行测试用例,我们将创建主RUN_ALL_TESTS宏。或者,我们可以创建一个可执行文件,链接Google Test 库,并调用RUN_ALL_TESTS。对于我们的情况,我们将选择后者。打开终端并运行以下命令以创建一个测试运行二进制文件:
g++ -c CheckIp.cpp

这将包括CheckValidIp函数的对象文件在其中定义。

  1. 现在,输入以下命令以添加必要的库,这些库将被链接以创建一个二进制文件:
g++ CheckIp.o TestCases.cpp -lgtest -lgtest_main -pthread -o TestRun 
  1. 现在,使用以下命令运行二进制文件:
./TestRun

这显示了通过CheckIp testsuite的两个测试用例。第一个测试用例CheckIp.testNull被调用并通过了。第二个测试用例CheckIp.BadLength也被调用并通过了。这个结果在以下截图中可见:

图 7.11:编译和执行测试用例

图 7.11:编译和执行测试用例

注意

Google 测试中,我们也可以使用其他断言,但对于我们的测试用例,我们满意于ASSERT_FALSE,因为我们只检查我们传递的 IP 地址的假条件。

  1. 现在,我们将添加更多的测试用例来使我们的代码更加健壮。这通常是编写代码的良好实践。首先,创建测试用例,并确保代码对新测试用例和旧测试用例以及代码的正确功能都能正常运行。要添加更多的测试用例,将以下代码添加到IP以"."开头。如果IP以"."结尾,则第四个案例应该失败。如果IP之间有空格,则第五个案例应该失败。如果IP包含任何非数字字符,则第六个案例应该失败。如果IP的令牌值小于0且大于255,则第七个案例应该失败。如果IP的令牌计数错误,则最后一个案例应该失败。

  2. 现在,在CheckIp.cpp文件的CheckValidIp()函数中添加以下代码。这段代码是处理新测试用例所必需的:

if(ip[strlen(ip)-1] == '.'){
    cout<<"ERROR : Incorrect token at end"<<endl;
    return false;
}
isstringstream istrstr(ip);
vector<string> tokens;
string token;
regex expression("[⁰-9]");
smatch m;
while(getline(istrstr, token, '.')){
    if(token.empty()){
        cout<<"ERROR : Got empty token"<<endl;
        return false;
    }
    if(token.find(' ') != string::npos){
        cout<<"ERROR : Space character in token"<<endl;
        return false;
    }
    if(regex_search(token,m,expression)){
        cout<<"ERROR : NonDigit character in token"<<endl;
        return false;
    }
    int val = atoi(token.c_str());
    if(val<0 || val>255){
        cout<<"ERROR : Invalid digit in token"<<endl;
        return false;
    }
    tokens.push_back(token);
}
if(tokens.size()!=4){
    cout<<"ERROR : Incorrect IP tokens used"<<endl;
    return false;
}
cout<<"strlen: "<<strlen(ip)<<endl;
return true;
}
  1. 打开终端并输入以下命令以运行二进制文件:
./TestRun

所有测试用例都已执行,如下截图所示:

图 7.12:测试用例运行的输出

图 7.12:测试用例运行的输出

前面的截图显示了CheckIp测试套件中有10个测试用例,并且所有测试用例都运行正常。在下一节中,我们将学习使用模拟对象进行单元测试。

使用模拟对象进行单元测试

当开发人员进行单元测试时,可能会出现在具体操作发生后调用某些接口的情况。例如,正如我们在前面的情景中讨论的,假设项目设计成在执行之前从数据库中获取所有配置信息。它查询数据库以获取特定参数,例如 Web 服务器的IP 地址用户密码。然后尝试连接到 Web 服务器(也许有另一个模块处理与网络相关的任务)或开始对实际项目所需的项目进行操作。之前,我们测试了 IP 地址的有效性。现在,我们将更进一步。假设 IP 地址是从数据库中获取的,并且我们有一个实用类来处理连接到DB和查询IP 地址

现在,为了测试 IP 地址的有效性,我们需要假设数据库连接已经设置好。这意味着应用程序可以正确地查询数据库并获取查询结果,其中之一是IP 地址。只有这样,我们才能测试 IP 地址的有效性。现在,为了进行这样的测试,我们必须假设所有必要的活动都已经完成,并且我们已经得到了一个IP 地址来测试。这就是模拟对象的作用,它就像真实对象一样。它提供了单元测试的功能,以便应用程序认为 IP 地址已经从数据库中获取,但实际上我们是模拟的。要创建一个模拟对象,我们需要从它需要模拟的类中继承。在下一节中,我们将进行一个练习,以更好地理解模拟对象。

练习 5:创建模拟对象

在这个练习中,我们将通过假设所有接口都按预期工作来创建模拟对象。使用这些对象,我们将测试一些功能,比如验证IP 地址,检查数据库连接性,以及检查用户名密码是否格式正确。一旦所有测试都通过了,我们将确认应用程序,并准备好进行QA。执行以下步骤来实现这个练习:

  1. 创建一个名为Misc.h的头文件,并包含必要的库:
#include<iostream>
#include<string>
#include<sstream>
#include<vector>
#include<iterator>
#include<regex>
using namespace std;
  1. 创建一个名为ConnectDatabase的类,它将连接到数据库并返回查询结果。在类内部,声明Dbname,user 和 passwd 变量。还声明一个构造函数和两个虚函数。在这两个虚函数中,第一个必须是析构函数,第二个必须是getResult()函数,它从数据库返回查询结果。添加以下代码来实现这一点:
class ConnectDatabase{
    string DBname;
    string user;
    string passwd;
    public:
        ConnectDatabase() {} 
        ConnectDatabase(string _dbname, string _uname, string _passwd) :
            DBname(_dbname), user(_uname), passwd(_passwd) { }
        virtual ~ConnectDatabase() {} 
        virtual string getResult(string query);
};
  1. 创建另一个名为WebServerConnect的类。在class内部声明三个string变量,分别是Webserverunamepasswd。创建构造函数和两个虚函数。在这两个虚函数中,第一个必须是析构函数,第二个必须是getRequest()函数。添加以下代码来实现这一点:
class WebServerConnect{
    string Webserver;
    string uname;
    string passwd;
    public :
    WebServerConnect(string _sname, string _uname, string _passwd) :
            Webserver(_sname), uname(_uname), passwd(_passwd) { }
        virtual ~WebServerConnect() {}
        virtual string getRequest(string req);
};

注意

由于我们将从前面的类创建一个模拟类并调用这些函数,所以需要虚函数

  1. 创建一个名为App的类。创建构造函数和析构函数并调用所有函数。添加以下代码来实现这一点:
class App {
    ConnectDatabase *DB;
    WebServerConnect *WB;
    public : 
        App():DB(NULL), WB(NULL) {} 
        ~App() { 
            if ( DB )  delete DB;
            if ( WB )  delete WB;
        }
        bool checkValidIp(string ip);
        string getDBResult(string query);
        string getWebResult(string query);
        void connectDB(string, string, string);
        void connectDB(ConnectDatabase *db);
        void connectWeb(string, string, string);
        void run();
};

在前面的代码中,应用程序将首先查询数据库并获取IP 地址。然后,它使用必要的信息连接到 Web 服务器并查询以获取所需的信息。

  1. 创建一个名为gmock的类头文件,这是创建模拟类所需的。此外,MockDB类是从ConnectDatabase类继承的。MOCK_METHOD1(getResult, string(string));这一行表示我们将模拟getResult接口。因此,在单元测试期间,我们可以直接调用getResult函数,并传递所需的结果,而无需创建ConnectDatabase类并运行实际的数据库查询。需要注意的一个重要点是,我们需要模拟的函数必须使用MOCK_METHOD[N]宏进行定义,其中 N 是接口将接受的参数数量。在我们的情况下,getResult接口接受一个参数。因此,它使用MOCK_METHOD1宏进行模拟。

  2. 创建一个名为getResult()getRequest()的函数,其中 DB 查询和WebServer查询返回默认字符串。在这里,App::run()函数假设 DB 连接和 web 服务器连接已经执行,现在它可以定期执行 web 查询。在每次查询结束时,它将默认返回"Webserver returned success"字符串。

  3. 现在,创建一个名为dbnamedbuserdbpasswd的文件。然后,我们查询数据库以获取 IP 地址和其他配置参数。我们已经注释掉了app.checkValidIp(ip)这一行,因为我们假设从数据库中获取的 IP 地址需要进行验证。此外,这个函数需要进行单元测试。使用connectWeb()函数,我们可以通过传递虚拟参数如webnameuserpasswd来连接到 web 服务器。最后,我们调用run()函数,它将迭代运行,从而查询 web 服务器并给出默认输出。

  4. 保存所有文件并打开终端。为了获得执行项目所需的基本功能,我们将构建二进制文件并执行它以查看结果。在终端中运行以下命令:

g++ Misc.cpp RunApp.cpp -o RunApp

上述代码将在当前文件夹中创建一个名为RunApp的二进制文件。

  1. 现在,编写以下命令来运行可执行文件:
./RunApp

上述命令在终端中生成以下输出:

图 7.13:运行应用程序

图 7.13:运行应用程序

如前面的截图所示,二进制文件及时显示输出"Webserver returned success"。到目前为止,我们的应用程序正常运行,因为它假设所有接口都按预期工作。但在将其准备好供 QA 测试之前,我们仍需测试一些功能,如验证IP 地址DB 连接性、检查用户名密码是否符合正确格式等。

  1. 使用相同的基础设施,开始对每个功能进行单元测试。在我们的练习中,我们假设DB 连接已经完成,并已查询以获取IP 地址。之后,我们可以开始单元测试IP 地址的有效性。因此,在我们的测试用例中,需要模拟数据库类,并且getDBResult函数必须返回IP 地址。稍后,这个IP 地址将传递给checkValidIP函数进行测试。为了实现这一点,创建一个名为checkValidIP的类:
#include"MockMisc.h"
using ::testing::_;
using ::testing::Return;
class TestApp : public ::testing::Test {
    protected : 
        App testApp;
        MockDB *mdb;
        void SetUp(){
            mdb = new MockDB();
            testApp.connectDB(mdb);
        }
        void TearDown(){
        }
};
TEST_F(TestApp, NullIP){
    EXPECT_CALL(*mdb, getResult(_)).
                 WillOnce(Return(""));
    ASSERT_FALSE(testApp.checkValidIp(testApp.getDBResult("")));
}
TEST_F(TestApp, SpaceTokenIP){
    EXPECT_CALL(*mdb, getResult(_)).
                 WillOnce(Return("13\. 21.31.68"));
    ASSERT_FALSE(testApp.checkValidIp(testApp.getDBResult("")));
}
TEST_F(TestApp, NonValidDigitIP){
    EXPECT_CALL(*mdb, getResult(_)).
                 WillOnce(Return("13.521.31.68"));
    ASSERT_FALSE(testApp.checkValidIp(testApp.getDBResult("")));
}
TEST_F(TestApp, CorrectIP){
    EXPECT_CALL(*mdb, getResult(_)).
                 WillOnce(Return("212.121.21.45"));
    ASSERT_TRUE(testApp.checkValidIp(testApp.getDBResult("")));
}

在这里,我们使用了测试和testing::Return命名空间来调用模拟类接口,并返回用于测试用例的用户定义的值。在TEST_F函数中,我们使用了EXPECT_CALL函数,其中我们将模拟对象的实例作为第一个参数传递,并将getResult()函数作为第二个参数传递。WillOnce(Return(""))行表示需要调用接口一次,并将返回""和一个空字符串。这是需要传递给checkValidIP函数以测试空字符串的值。这通过ASSERT_FALSE宏进行检查。类似地,可以使用 DB 的模拟对象创建其他测试用例,并将 IP 地址传递给checkValidIP函数。为了创建各种测试用例,TestApp类从testing::Test类继承,其中包含 App 实例和 Database 的模拟对象。在TestApp类中,我们定义了两个函数,即SetUp()TearDown()。在SetUp()函数中,我们创建了一个MockDB实例并将其标记为 testApp 实例。由于TearDown()函数不需要执行任何操作,我们将其保持为空。它的析构函数在App类的析构函数中被调用。此外,我们在TEST_F函数中传递了两个参数。第一个参数是测试类,而第二个参数是测试用例的名称。

  1. 保存所有文件并打开终端。运行以下命令:
g++ Misc.cpp TestApp.cpp -lgtest -lgmock -lgtest_main -pthread -o TestApp

在前面的命令中,我们还链接了gmock 库。现在,输入以下命令来运行测试用例:

./TestApp

前面的命令生成了以下输出:

图 7.14:运行 Gmock 测试

图 7.14:运行 Gmock 测试

从前面的命令中,我们可以看到所有的测试用例都执行并成功通过了。在下一节中,我们将讨论断点观察点数据可视化

断点、观察点和数据可视化

在前面的部分中,我们讨论了在开发人员将代码检入存储库分支之前需要进行单元测试,并且其他团队成员可以看到它,以便他们可以将其与其他模块集成。虽然单元测试做得很好,开发人员检查了代码,但在集成代码并且 QA 团队开始测试时,可能会发现代码中存在错误的机会。通常,在这种情况下,可能会在由于其他模块的更改而导致的模块中抛出错误。团队可能会很难找出这些问题的真正原因。在这种情况下,调试就出现了。它告诉我们代码的行为如何,开发人员可以获得代码执行的细粒度信息。开发人员可以看到函数正在接收的参数以及它返回的值。它可以准确地告诉一个变量或指针分配了什么值,或者内存中的内容是什么。这对于开发人员来说非常有帮助,可以确定代码的哪一部分存在问题。在下一节中,我们将实现一个堆栈并对其执行一些操作。

与堆栈数据结构一起工作

考虑这样一个场景,其中开发人员被要求开发自己的堆栈结构,可以接受任何参数。在这里,要求是堆栈结构必须遵循后进先出LIFO)原则,其中元素被放置在彼此之上,当它们从堆栈中移除时,最后一个元素应该首先被移除。它应该具有以下功能:

  • **push()**将新元素放置在堆栈顶部

  • **top()**显示堆栈的顶部元素(如果有)

  • **pop()**从堆栈中移除最后插入的元素

  • **is_empty()**检查堆栈是否为空

  • **size()**显示堆栈中存在的元素数量

  • **clean()**清空堆栈(如果有任何元素)

以下代码行显示了如何在Stack.h头文件中包含必要的库:

#ifndef STACK_H__
#define STACK_H__
#include<iostream>
using namespace std;

正如我们已经知道的,栈由各种操作组成。为了定义这些函数中的每一个,我们将编写以下代码:

template<typename T>
struct Node{
    T element;
    Node<T> *next;
};
template<typename T>
class Stack{
    Node<T> *head;
    int sz;
    public :
        Stack():head(nullptr), sz(0){}
        ~Stack();

        bool is_empty();
        int size();
        T top();
        void pop();
        void push(T);
        void clean();
};
template<typename T>
Stack<T>::~Stack(){
    if ( head ) clean();
}
template<typename T>
void Stack<T>::clean(){
    Node<T> *tmp;
    while( head ){
        tmp = head;
        head = head -> next;
        delete tmp;
        sz--;
    }
}
template<typename T>
int Stack<T>::size(){
    return sz;
}
template<typename T>
bool Stack<T>::is_empty(){
        return (head == nullptr) ? true : false;
}
template<typename T>
T Stack<T>::top(){
    if ( head == nullptr){
        // throw error ...
        throw(string("Cannot see top of empty stack"));
    }else {
        return head -> element;
    }
}
template<typename T>
void Stack<T>::pop(){
    if ( head == nullptr ){
        // throw error
        throw(string("Cannot pop empty stack"));
    }else {
        Node<T> *tmp = head ;
        head = head -> next;
        delete tmp;
        sz--;
    }
}
template<typename T>
void Stack<T>::push(T val){
    Node<T> *tmp = new Node<T>();
    tmp -> element = val;
    tmp -> next = head;
    head = tmp;
    sz++;
}
// Miscellaneous functions for stack.. 
template<typename T>
void displayStackStats(Stack<T> &st){
    cout << endl << "------------------------------" << endl;
    cout << "Showing Stack basic Stats ...  " << endl;
    cout << "Stack is empty : " << (st.is_empty() ? "true" : "false") << endl;
    cout << "Stack size :" << st.size() << endl;
    cout << "--------------------------------" << endl << endl;
}
#endif 

到目前为止,我们已经看到了如何使用单链表实现栈。每次在 Stack 中调用push时,都会创建一个给定值的新元素,并将其附加到栈的开头。我们称之为头成员变量,它是头部将指向栈中的下一个元素等等。当调用pop时,头部将从栈中移除,并指向栈的下一个元素。

让我们在2242657中编写先前创建的 Stack 的实现。当调用displayStackStats()函数时,它应该声明栈的大小为3。然后,我们从栈中弹出57,顶部元素必须显示426。我们将对 char 栈执行相同的操作。以下是栈的完整实现:

#include"Stack.h"
int main(){
    try {
        Stack<int> si;
        displayStackStats<int>(si);
        si.push(22);
        si.push(426);
        cout << "Top of stack contains " << si.top() << endl;
        si.push(57);
        displayStackStats<int>(si);
        cout << "Top of stack contains " << si.top() << endl;
        si.pop();
        cout << "Top of stack contains " << si.top() << endl;
        si.pop();
        displayStackStats<int>(si);
        Stack<char> sc;
        sc.push('d');
        sc.push('l');
        displayStackStats<char>(sc);
        cout << "Top of char stack contains:" << sc.top() << endl;
    }
    catch(string str){
        cout << "Error : " << str << endl;
    }
    catch(...){
        cout << "Error : Unexpected exception caught " << endl;
    }
    return 0;
}

当我们编译时(使用了-g选项)。因此,如果需要,您可以调试二进制文件:

g++ -g Main.cpp -o Main

我们将写以下命令来执行二进制文件:

./Main

前面的命令生成了以下输出:

图 7.15:使用 Stack 类的主函数

图 7.15:使用 Stack 类的主函数

在前面的输出中,统计函数的第二次调用中的红色墨水显示了在 int 栈中显示三个元素的正确信息。然而,int 栈顶部的红色墨水调用显示了随机或垃圾值。如果程序再次运行,它将显示一些其他随机数字,而不是预期的值57426。同样,对于 char 栈,红色墨水突出显示的部分,即char的顶部,显示了垃圾值,而不是预期的值,即"l"。后来,执行显示了双重释放或损坏的错误,这意味着再次调用了相同的内存位置。最后,可执行文件产生了核心转储。程序没有按预期执行,从显示中可能不清楚实际错误所在。为了调试Main,我们将编写以下命令:

gdb ./Main 

前面的命令生成了以下输出:

图 7.16:调试器显示 – I

图 7.16:调试器显示 – I

在前面的屏幕截图中,蓝色突出显示的标记显示了调试器的使用方式以及它显示的内容。第一个标记显示了使用gdb命令调用调试器。输入gdb命令后,用户进入调试器的命令模式。以下是命令模式中使用的命令的简要信息:

  • b main:这告诉调试器在主函数调用时中断。

  • r:这是用于运行可执行文件的简写。也可以通过传递参数来运行。

  • n:这是下一个命令的简写,告诉我们执行下一个语句。

  • si变量在代码中被调用时,其值会发生变化。调试器将显示使用此变量的代码的内容。

  • step in"命令。

将执行的下一个语句是si.push(22)。由于si已经更新,观察点调用并显示了si的旧值和一个新值,其中显示了si的旧值是带有 NULL 的头部和sz为 0。在si.push之后,头部将更新为新值,并且其执行到了Stack.h文件的第 75 行,这是sz变量增加的地方。如果再次按下Enter键,它将执行。

请注意,执行已自动从主函数移动到Stack::push函数。以下是调试器上继续命令的屏幕截图:

图 7.17:调试器显示 – II

下一个命令显示sz已更新为新值1。按Enter后,代码的执行从Stack::push第 76 行返回到主函数的第 8 行。这在下面的屏幕截图中有所突出。它显示执行停在si.push(426)的调用处。一旦我们进入,Stack::push将被调用。执行移动到Stack.h程序的第 71 行,如红色墨水所示。一旦执行到达第 74 行,如红色墨水所示,watch 被调用,显示si已更新为新值。您可以看到在Stack::push函数完成后,流程回到了主代码。以下是调试器中执行的步骤的屏幕截图:

图 7.18:调试器显示-III

Enter后,您会看到displayStackStats第 11 行被调用。然而,在第 12 行,显示的值是0,而不是预期的值57。这是一个错误,我们仍然无法弄清楚-为什么值会改变?但是,很明显,值可能在前面对主函数的调用中的某个地方发生了变化。因此,这可能不会让我们对继续进行调试感兴趣。但是,我们需要继续并从头开始调试。

以下屏幕截图显示了将用于调试代码的命令:

图 7.19:调试器显示-IV

要从头重新运行程序,我们必须按r,然后按y进行确认和继续,这意味着我们从头重新运行程序。它会要求确认;按y继续。在前面的屏幕截图中,所有这些命令都用蓝色标出。在第 7 行执行时,我们需要运行'display *si.head'命令,它将在执行每条语句后持续显示si.head内存位置的内容。如红色墨水所示,在将22推入堆栈后,head 会更新为正确的值。类似地,对于值42657,在使用 push 将其插入堆栈时,对 head 的调用也会正确更新。

稍后,当调用displayStackStats时,它显示了正确的size3。但是当调用 top 命令时,head 显示了错误的值。这在红色墨水中有所突出。现在,top 命令的代码不会改变 head 的值,因此很明显错误发生在前一条执行语句中,也就是在displayStackStats处。

因此,我们已经缩小了可能存在问题的代码范围。我们可以运行调试器指向displayStackStats并移动到displayStackStats内部,以找出导致堆栈内部值发生变化的原因。以下是同一屏幕截图,用户需要从头开始启动调试器:

图 7.20:调试器显示-IV

图 7.20:调试器显示-IV

重新启动调试器并到达调用displayStackStats的第 11 行执行点后,我们需要进入。流程是进入displayStackStats函数的开头。此外,我们需要执行下一条语句。由于函数中的初始检查是清晰的,它们不会改变 head 的值,我们可以按Enter执行下一步。当我们怀疑下一步可能会改变我们正在寻找的变量的值时,我们需要进入。这是在前面的快照中完成的,用红色标出。后面的执行到达第 97 行,也就是displayStackStats函数的最后一行。

在输入s后,执行移动到析构堆栈并在第 81 行调用清理函数。此清理命令删除了与头部相同值的tmp变量。该函数清空了堆栈,这是不希望发生的。只有displayStackStats函数应该被调用和执行,最终返回到主函数。但是,由于局部变量超出范围,析构函数可能会被调用。在这里,局部变量是在line 92处作为displayStackStats函数的参数声明的变量。因此,当调用displayStackStats函数时,会创建来自主函数的si变量的局部副本。当displayStackStats函数被调用时,该变量调用了 Stack 的析构函数。现在,si变量的指针已被复制到临时变量,并且错误地在最后删除了指针。这不是开发人员的意图。因此,在代码执行结束时,会报告双重释放错误。si变量在超出范围时必须调用 Stack 析构函数,因为它将尝试再次释放相同的内存。为了解决这个问题,很明显displayStackStats函数必须以传递参数作为引用的方式进行调用。为此,我们必须更新Stack.h文件中displayStackStats函数的代码:

template<typename T>
void displayStackStats(Stack<T> &st){
    cout << endl << "------------------------------" << endl;
    cout << "Showing Stack basic Stats ...  " << endl;
    cout << "Stack is empty : " << (st.is_empty() ? "true" : "false") << endl;
    cout << "Stack size :" << st.size() << endl;
    cout << "--------------------------------" << endl << endl;
}

现在,当我们保存并编译Main.cpp文件时,将生成二进制文件:

./Main

前面的命令在终端中生成以下输出:

图 7.21:调试器显示 - IV

图 7.21:调试器显示 - IV

从前面的屏幕截图中,我们可以看到57426的预期值显示在堆栈顶部。displayStackStats函数还显示了 int 和 char 堆栈的正确信息。最后,我们使用调试器找到了错误并进行了修复。在下一节中,我们将解决一个活动,我们将开发用于解析文件并编写测试用例以检查函数准确性的函数。

活动 1:使用测试用例检查函数的准确性并了解测试驱动开发(TDD)

在这个活动中,我们将开发函数,以便我们可以解析文件,然后编写测试用例来检查我们开发的函数的正确性。

一个大型零售组织的 IT 团队希望通过在其数据库中存储产品详情和客户详情来跟踪产品销售作为其对账的一部分。定期,销售部门将以简单的文本格式向 IT 团队提供这些数据。作为开发人员,您需要确保在公司将记录存储在数据库之前,对数据进行基本的合理性检查,并正确解析所有记录。销售部门将提供两个包含客户信息和货币信息的文本文件。您需要编写解析函数来处理这些文件。这两个文件是CurrencyConversionRatio

此项目环境设置的所有必要信息都保存在配置文件中。这也将保存文件名,以及其他参数(如DBRESTAPI等)和文件recordFile中的变量值,以及货币文件,变量名为currencyFile

以下是我们将编写的测试条件,以检查用于解析CurrencyConversion.txt文件的函数的准确性:

  • 第一行应该是标题行,其第一个字段应包含"Currency"字符串。

  • Currency字段应由三个字符组成。例如:"USD","GBP"是有效的。

  • ConversionRatio字段应由浮点数组成。例如,1.20.06是有效的。

  • 每行应该恰好有两个字段。

  • 用于记录的分隔符是"|"。

以下是我们将编写的测试条件,用于检查用于解析RecordFile.txt文件的函数的准确性:

  • 第一行应包含标题行,其第一个字段应包含"Customer Id"字符串。

  • Customer IdOrder IdProduct IdQuantity应该都是整数值。例如,123124531134是有效的。

  • TotalPrice (Regional Currency)TotalPrice (USD)应该是浮点值。例如,2433.343434.11是有效的。

  • RegionalCurrency字段的值应该存在于std::map中。

  • 每行应该有九个字段,如文件的HEADER信息中定义的那样。

  • 记录的分隔符是"|"。

按照以下步骤执行此活动:

  1. 解析parse.conf配置文件,其中包括项目运行的环境变量。

  2. 从步骤 1 正确设置recordFilecurrencyFile变量。

  3. 使用从配置文件中检索的这些变量,解析满足所有条件的货币文件。如果条件不满足,返回适当的错误消息。

  4. 解析满足的所有条件的记录文件。如果不满足条件,则返回错误消息。

  5. 创建一个名为CommonHeader.h的头文件,并声明所有实用函数,即isAllNumbers()isDigit()parseLine()checkFile()parseConfig()parseCurrencyParameters()fillCurrencyMap()parseRecordFile()checkRecord()displayCurrencyMap()displayRecords()

  6. 创建一个名为Util.cpp的文件,并定义所有实用函数。

  7. 创建一个名为ParseFiles.cpp的文件,并调用parseConfig()fillCurrencyMap()parseRecordFile()函数。

  8. 编译并执行Util.cppParseFiles.cpp文件。

  9. 创建一个名为ParseFileTestCases.cpp的文件,并为函数编写测试用例,即trim()isAllNumbers()isDigit()parseCurrencyParameters()checkFile()parseConfig()fillCurrencyMap()parseRecordFile()

  10. 编译并执行Util.cppParseFileTestCases.cpp文件。

以下是解析不同文件并显示信息的流程图:

图 7.22:流程图

从上面的流程图中,我们大致了解了执行流程。在编写代码之前,让我们看看更细节的内容,以便清楚地理解。这将有助于为每个执行块定义测试用例。

对于解析配置文件块,我们可以将步骤分解如下:

  1. 检查配置文件是否存在并具有读取权限。

  2. 检查是否有适当的标题。

  3. 逐行解析整个文件。

  4. 对于每一行,使用'='作为分隔符解析字段。

  5. 如果从上一步中有 2 个字段,则处理以查看它是Currency file还是Record file变量,并适当存储。

  6. 如果从步骤 4 中没有 2 个字段,则转到下一行。

  7. 完全解析文件后,检查上述步骤中的两个变量是否不为空。

  8. 如果为空,则返回错误。

对于解析Currency File块,我们可以将步骤分解如下:

  1. 读取CurrencyFile的变量,看看文件是否存在并且具有读取权限。

  2. 检查是否有适当的标题。

  3. 逐行解析整个文件,使用'|'作为分隔符。

  4. 如果每行找到确切的 2 个字段,将第一个视为Currency field,第二个视为conversion field

  5. 如果从步骤 3 中没有找到 2 个字段,则返回适当的错误消息。

  6. 从步骤 4 开始,对Currency field(应为 3 个字符)和Conversion Field(应为数字)进行所有检查。

  7. 如果从步骤 6 通过,将currency/conversion值存储为具有Currency作为键和数字作为值的映射对。

  8. 如果未从步骤 6 通过,返回说明currency的错误。

  9. 解析完整的Currency文件后,将创建一个映射,其中将为所有货币的转换值。

对于解析Record File块,我们可以将步骤分解为以下步骤:

  1. 读取RecordFile的变量,并查看文件是否存在并具有读取权限。

  2. 检查是否有适当的头部

  3. 逐行解析整个文件,以'|'作为分隔符。

  4. 如果从上述步骤中找不到 9 个字段,请返回适当的错误消息。

  5. 如果找到 9 个字段,请对活动开始时列出的所有字段进行相应的检查。

  6. 如果步骤 5 未通过,请返回适当的错误消息。

  7. 如果步骤 5 通过,请将记录存储在记录的向量中。

  8. 在完全解析记录文件后,所有记录将存储在记录的向量中。

在创建解析所有三个文件的流程时,我们看到所有 3 个文件都重复了一些步骤,例如:

检查文件是否存在且可读

检查文件是否具有正确的头部信息

使用分隔符解析记录

检查字段是否为DigitCurrencyRecord file中是常见的

检查字段是否为NumericCurrencyRecord file中是常见的

上述要点将有助于重构代码。此外,将有一个用于使用分隔符解析字段的常见函数,即trim函数。因此,当我们使用分隔符解析记录时,我们可能会得到带有空格或制表符的值,这可能是不需要的,因此我们需要在解析记录时修剪它一次。

现在我们知道我们有上述常见的步骤,我们可以为它们编写单独的函数。为了开始 TDD,我们首先了解函数的要求,并首先编写单元测试用例来测试这些功能。然后我们编写函数,使其通过单元测试用例。如果有几个测试用例失败,我们迭代更新函数并执行测试用例的步骤,直到它们全部通过。

对于我们的示例,我们可以编写trim函数,

现在我们知道在修剪函数中,我们需要删除第一个和最后一个额外的空格/制表符。例如,如果字符串包含"AA",则修剪应返回"AA"删除所有空格。

修剪函数可以返回具有预期值的新字符串,也可以更新传递给它的相同字符串。

所以现在我们可以编写修剪函数的签名:string trim(string&);

我们可以为此编写以下测试用例:

  • 仅有额外字符(" "),返回空字符串()。

  • 仅以开头的空字符("AA")返回带有结束字符("AA")的字符串

  • 仅以结尾的空字符("AA "),应返回带有开始字符("AA")的字符串

  • 在中间有字符("AA"),返回带有字符("AA")的字符串

  • 在中间有空格("AA BB"),返回相同的字符串("AA BB")

  • 所有步骤 3,4,5 都是单个字符。应返回具有单个字符的字符串。

要创建测试用例,请检查文件trim函数是否在测试套件trim中编写。现在在文件中编写具有上述签名的trim函数。执行trim函数的测试用例并检查是否通过。如果没有适当更改函数并再次测试。重复直到所有测试用例通过。

现在我们有信心在项目中使用trim函数。对于其余的常见函数(isDigitisNumericparseHeader等),请参考Util.cpp文件和ParseFiletestCases.cpp,并测试所有常见函数。

完成常见功能后,我们可以分别编写解析每个文件的函数。要理解和学习的主要内容是如何将模块分解为小函数。找到小的重复任务,并为每个创建小函数,以便进行重构。了解这些小函数的详细功能,并创建适当的单元测试用例。

完整测试单个函数,如果失败,则更新函数直到通过所有测试用例。类似地,完成其他函数。然后编写并执行更大函数的测试用例,这应该相对容易,因为我们在这些更大函数中调用了上面测试过的小函数。

在实施了上述步骤之后,我们将得到以下输出:

图 7.23:所有测试都正常运行

图 7.23:所有测试都正常运行

以下是下一步的屏幕截图:

图 7.24:所有测试都正常运行

图 7.24:所有测试都正常运行

注意

此活动的解决方案可以在第 706 页找到。

摘要

在本章中,我们看了各种通过可执行文件抛出的错误可以在编译时和运行时使用断言来捕获的方法。我们还学习了静态断言。我们了解了异常是如何生成的,以及如何在代码中处理它们。我们还看到单元测试如何可以成为开发人员的救星,因为他们可以在开始时识别代码中的任何问题。我们为需要在测试用例中使用的类使用了模拟对象。然后我们学习了调试器、断点、观察点和数据可视化。我们能够使用调试器找到代码中的问题并修复它们。我们还解决了一个活动,其中我们编写了必要的测试用例来检查用于解析文件的函数的准确性。

在下一章中,我们将学习如何优化我们的代码。我们将回顾处理器如何执行代码并访问内存。我们还将学习如何确定软件执行所需的额外时间。最后,我们将学习内存对齐和缓存访问。

第九章:需要速度-性能和优化

学习目标

通过本章结束时,您将能够:

  • 手动计时代码性能

  • 使用源代码仪器来测量代码执行时间

  • 使用 perf 工具分析程序性能

  • 使用 godbolt 编译器资源管理器工具分析编译器生成的机器代码

  • 使用编译器标志生成更好的代码

  • 应用导致性能的代码习惯

  • 编写缓存友好的代码

  • 将算法级优化应用于实际问题

在本章中,我们将探讨允许我们在一般情况下编写快速代码以及适用于 C++的几种实用技术的概念。

介绍

在当今极其庞大和复杂的软件系统中,稳定性可维护性通常被认为是大多数软件项目的主要目标,而自 2000 年代以来,优化并未被广泛视为一个值得追求的目标。这是因为硬件技术的快速发展超过了软件对定期进步的需求。

多年来,硬件的改进似乎会继续跟上软件的性能需求,但应用程序继续变得更大更复杂。与 C 和 C++等低级本地编译语言相比,易于使用但性能较差的解释语言(如PythonRuby)的流行度下降。

到了 2000 年代末,CPU 晶体管数量(和性能)每 18 个月翻倍的趋势(摩尔定律的结果)停止了,性能改进趋于平稳。由于物理限制和制造成本的限制,人们对 2010 年代普遍可用的 5 到 10 GHz 处理器的期望从未实现。然而,移动设备的快速采用和数据科学和机器学习的高性能计算应用的兴起,突然重新唤起了对快速和高效代码的需求。每瓦性能已成为新的衡量标准,因为大型数据中心消耗了大量电力。例如,2017 年,谷歌在美国的服务器消耗的电力超过了整个英国国家的电力消耗。

到目前为止,在本书中,我们已经了解了 C语言在易用性方面的发展,而不会牺牲传统语言(如 C)的性能潜力。这意味着我们可以在 C中编写快速的代码,而不一定要牺牲可读性或稳定性。在下一节中,我们将学习性能测量的概念。

性能测量

优化最重要的方面是代码执行时间的测量。除非我们使用各种输入数据集来测量应用程序的性能,否则我们将不知道哪一部分花费了最多的时间,我们的优化工作将是一场盲目的射击,没有任何结果的保证。有几种测量方法,其中一些列在这里:

  • 运行时仪器或分析

  • 源代码仪器

  • 手动执行计时

  • 研究生成的汇编代码

  • 通过研究使用的代码和算法进行手动估计

上述列表按测量准确性排序(最准确的排在最前面)。然而,每种方法都有不同的优势。选择采用哪种方法取决于优化工作的目标和范围。在全力以赴地实现最快的可能实现的努力中,可能需要所有这些方法。我们将在以下各节中研究每种方法。

手动估计

当我们用更好的算法替换算法时,性能的最大可能改进发生。例如,考虑一个简单函数的两个版本,该函数对从1n的整数求和:

int sum1(int n)
{
  int ret = 0;
  for(int i = 1; i <= n; ++i)
  {
    ret += i;
  }
  return ret;
}
int sum2(int n)
{
  return (n * (n + 1)) / 2;
}

第一个函数sum1使用简单的循环来计算总和,并且其运行时复杂度与n成正比,而第二个函数sum2使用代数求和公式,独立于n花费恒定的时间。在这个相当牵强的例子中,我们通过使用代数的基本知识来优化了一个函数。

对于每个可想象的操作,都有许多众所周知的算法被证明是最优的。使我们的代码尽可能快地运行的最佳方法是使用算法。

拥有算法词汇是至关重要的。我们不需要成为算法专家,但至少需要意识到各个领域存在高效算法的存在,即使我们无法从头开始实现它们。对算法的略微深入了解将有助于我们找到程序中执行类似的,即使不完全相同的计算的部分。某些代码特性,如嵌套循环或数据的线性扫描,通常是改进的明显候选,前提是我们可以验证这些结构是否在代码的热点内。热点是指运行非常频繁且显著影响性能的代码部分。C++标准库包含许多基本算法,可以用作改进许多常见操作的构建块。

研究生成的汇编代码

汇编语言是二进制机器代码的人类可读表示,实际上在处理器上执行。对于像 C++这样的编译语言的严肃程序员来说,对汇编语言的基本理解是一项重要的资产。

研究程序生成的汇编代码可以让我们对编译器的工作方式和代码效率的估计有一些很好的见解。有许多情况下,这是确定效率瓶颈的唯一可能途径。

除此之外,对汇编语言的基本了解对于能够调试 C++代码是至关重要的,因为一些最难以捕捉的错误与低级生成的代码有关。

用于分析编译器生成代码的一个非常强大和流行的在线工具是我们在本章中将要使用的编译器探索者

注意

Godbolt 编译器探索者可以在godbolt.org找到。

以下是 Godbolt 编译器探索者的屏幕截图:

图 8.1:Godbolt 编译器探索者

正如你所看到的,Godbolt 编译器探索者由两个窗格组成。左侧是我们输入代码的地方,右侧显示生成的汇编代码。左侧窗格有一个下拉菜单,这样我们就可以选择所需的语言。为了我们的目的,我们将使用带有 gcc 编译器的 C++语言。

右侧窗格有选项,我们可以使用它来选择编译器版本。几乎所有流行编译器的版本,如gccclangclMicrosoft C++)都有,包括非 X86 架构的版本,如 ARM。

注意

为了简单起见,我们将把英特尔处理器架构称为x86,尽管正确的定义是x86/64。我们将跳过"64",因为今天几乎所有的处理器都是64 位的。尽管x86是由英特尔发明的,但现在所有的个人电脑处理器制造商都有使用许可。

为了熟悉编译器探索者工具的基础知识,并在基本水平上理解x86汇编代码,让我们来检查编译器为一个简单的从1加到N的整数求和函数生成的汇编代码。下面是需要在编译器探索者的左侧窗格中编写的求和函数:

int sum(int n)
{
  int ret = 0;
  for(int i = 1; i <= n; ++i)
  {
    ret += i;
  }
  return ret;
}

在右侧窗格中,编译器必须设置为x86-64 gcc 8.3,就像这样:

图 8.2:C++编译器

图 8.2:C++编译器

完成后,左侧窗格的代码将自动重新编译,并在右侧窗格生成和显示汇编代码。这里,输出以颜色编码显示,以显示汇编代码的哪些行是从 C++代码的哪些行生成的。以下屏幕截图显示了生成的汇编代码:

图 8.3:汇编结果

图 8.3:汇编结果

让我们简要分析前面的汇编代码。汇编语言中的每条指令由一个操作码和一个或多个操作数组成,可以是寄存器、常量值或内存地址。寄存器是 CPU 中非常快速的存储位置。在 x86 架构中,有八个主要寄存器,即RAXRBXRCXRDXRSIRDIRSPRBP。英特尔 x86/x64 架构使用一种奇特的寄存器命名模式:

  • RAX是一个通用的 64 位整数寄存器。

  • RAX

  • EAX

  • AX

相同的约定适用于其他通用寄存器,如RBXRCXRDXRSIRDIRBP寄存器有 16 位和 32 位版本,但没有 8 位子寄存器。指令的操作码可以是多种类型,包括算术、逻辑、位运算、比较或跳转操作。通常将操作码称为指令。例如,“opcodesum函数:

图 8.4:sum 函数的汇编代码

图 8.4:sum 函数的汇编代码

在前面的屏幕截图中,前几行称为MOV RAX, RBX汇编代码意味着将RBX寄存器中的值移动到RAX寄存器中。

注意

汇编语言通常不区分大小写,因此EAXeax意思相同。

(*(DWORD*)(rbp - 8)) C 表达式。换句话说,内存地址4字节DWORD(内存的双字-32 位)。汇编代码中的方括号表示解引用,就像 C/C++中的*运算符一样。rbp寄存器是始终包含当前执行函数堆栈基址的地址的基址指针。不需要知道这个堆栈帧的工作原理,但请记住,由于堆栈从较高地址开始并向下移动,函数参数和局部变量的地址是从rbp的负偏移开始的。如果看到从rbp的负偏移,它指的是局部变量或参数。

在前面的屏幕截图中,传递的第一个n参数。我们的代码中最后两个ret变量和i循环变量分别设置为01

现在,检查跟随序言和初始化的汇编代码的快照-这是我们的for()循环:

图 8.5:for 循环的汇编代码

图 8.5:for 循环的汇编代码

在前面的屏幕截图中,具有字符串后跟冒号的行称为BASICC/C++Pascal,并且用作goto语句的目标)。

以 J 开头的 x86 汇编指令都是跳转指令,例如使用cmp指令将内存中的i变量与内存中的n值进行比较。

注意

这里的JG指令意味着如果大于则跳转

如果比较大,则执行跳转到**.L2**标签(在循环外)。如果不是,则执行继续下一条指令,如下所示:

图 8.6:下一条指令的汇编代码

图 8.6:下一条指令的汇编代码

在这里,i的值再次重新加载到ret中,然后1被加到i上。最后,执行跳回到for循环并求和整数序列直到n,如下所示:

图 8.7:for 循环的汇编代码

图 8.7:for 循环的汇编代码

这被称为ret,被移动到retsum()函数返回。

注意

上面汇编清单中的“ret”是 RETURN 指令的助记符,不应与我们 C++代码示例中的“ret”变量混淆。

弄清楚一系列汇编指令的作用并不是一件简单的工作,但是通过观察以下几点,可以对源代码和指令之间的映射有一个大致的了解:

  • 代码中的常量值可以直接在汇编中识别。

  • 诸如addsubimulidiv等算术运算可以被识别。

  • 条件跳转映射到循环和条件。

  • 函数调用可以直接读取(函数名出现在汇编代码中)。

现在,让我们观察一下,如果在顶部的编译器选项字段中为编译器添加优化标志,代码的效果会如何:

图 8.8:为优化添加编译器标志

图 8.8:为优化添加编译器标志

在上面的截图中,0从内存中加载到寄存器中。由于内存访问需要几个时钟周期(从5100个时钟周期不等),仅使用寄存器本身就会产生巨大的加速。

当下拉菜单中的编译器更改为x86-64 clang 8.0.0时,汇编代码会发生变化,可以在以下截图中看到:

图 8.9:带有新编译器的汇编代码

图 8.9:带有新编译器的汇编代码

在前面的汇编清单中,注意到没有以J(跳转)开头的指令。因此,根本没有循环结构!让我们来看看编译器是如何计算1n的和的。如果n的值<= 0,那么它跳转到0。让我们分析以下指令:

图 8.10:带有新编译器的汇编代码

图 8.10:带有新编译器的汇编代码

以下代码是前面指令的 C 等效代码。请记住,nEDI寄存器中(因此也在 RDI 寄存器中,因为它们重叠):

eax = n - 1;
ecx = n - 2;
rcx *= rax;
rcx >>= 1;
eax = rcx + 2 * n;
eax--;
return eax;

或者,如果我们将其写成一行,它会是这样的:

return ((n-1) * (n-2) / 2) + (n * 2) - 1;

如果我们简化这个表达式,我们得到以下结果:

((n² - 3n + 2) / 2) + 2n - 1

或者,我们可以用以下格式来写:

((n² - 3n + 2) + 4n - 2) / 2

这可以简化为以下形式:

(n² + n) / 2

或者,我们可以写成以下形式:

(n * (n+1)) / 2

这是求和公式的封闭形式,用于计算1n的数,也是计算它的最快方式。编译器非常聪明——它不仅仅是逐行查看我们的代码,而是推理出我们的循环的效果是计算总和,并且自己找出了代数。它没有找出最简单的表达式,而是找出了一个等价的表达式,需要一些额外的操作。尽管如此,去掉循环使得这个函数非常优化。

如果我们修改for循环中i变量的初始或最终值以创建不同的求和,编译器仍然能够执行必要的代数操作,得出不需要循环的封闭形式解决方案。

这只是编译器变得非常高效并且几乎智能化的一个例子。然而,我们必须明白,这种求和的特定优化已经被编程到了clang编译器中。这并不意味着编译器可以为任何可能的循环计算做出这种技巧——这实际上需要编译器具有通用人工智能,以及世界上所有的数学知识。

让我们通过生成的汇编代码来探索编译器优化的另一个例子。看看以下代码:

#include <vector>
int three()
{ 
  const std::vector<int> v = {1, 2};
  return v[0] + v[1];
}

在编译器选项中,如果我们选择x86-64 clang 8.0.0编译器并添加-O3 -stdlib=libc++,将生成以下汇编代码:

图 8.11:使用新编译器生成的汇编代码

图 8.11:使用新编译器生成的汇编代码

正如您在前面的屏幕截图中所看到的,编译器正确地决定向量与函数无关,并移除了所有的负担。它还在编译时进行了加法运算,并直接使用结果3作为常数。从本节中可以得出的主要观点如下:

  • 在给予正确选项的情况下,编译器在优化代码时可以非常聪明。

  • 研究生成的汇编代码对于获得执行复杂性的高级估计非常有用。

  • 对机器码工作原理的基本理解对于任何 C++程序员都是有价值的。

在下一节中,我们将学习关于手动执行计时的内容。

手动执行计时

这是快速计时小程序的最简单方法。我们可以使用命令行工具来测量程序执行所需的时间。在 Windows 7 及以上版本中,可以使用以下 PowerShell 命令:

powershell -Command "Measure-Command {<your program and arguments here>}"

LinuxMacOS和其他类UNIX系统上,可以使用time命令:

time <your program and arguments here>

在下一节中,我们将实现一个小程序,并检查一般情况下计时程序执行的一些注意事项。

练习 1:计时程序的执行

在这个练习中,我们将编写一个程序来对数组进行求和。这里的想法是计时求和函数。当我们希望测试一个独立编写的函数时,这种方法是有用的。因此,测试程序的唯一目的是执行一个单一的函数。由于计算非常简单,我们需要运行函数数千次才能获得可测量的执行时间。在这种情况下,我们将从main()函数中调用sumVector()函数,传递一个随机整数的std::vector

注意

一个旨在测试单个函数的程序有时被称为驱动程序(不要与设备驱动程序混淆)。

执行以下步骤完成此练习:

  1. 创建一个名为Snippet1.cpp的文件。

  2. 定义一个名为sumVector的函数,它在循环中对每个元素求和:

int sumVector(std::vector<int> &v)
{
  int ret = 0;
  for(int i: v)
  {
    ret += i;
  }

  return ret;
}
  1. 定义main函数。使用 C++11 的随机数生成工具初始化一个包含10,000个元素的向量,然后调用sumVector函数1,000次。编写以下代码来实现这一点:
#include <random>
#include <iostream>
int main()
{
  // Initialize a random number generator
  std::random_device dev;
  std::mt19937 rng(dev());
  // Create a distribution range from 0 to 1000
  std::uniform_int_distribution<std::mt19937::result_type> dist(0,1000); 
  // Fill 10000 numbers in a vector
  std::vector<int> v;
  v.reserve(10000);
  for(int i = 0; i < 10000; ++i)
  {
    v.push_back(dist(rng));
  }
  // Call out function 1000 times, accumulating to a total sum
  double total = 0.0;
  for(int i = 0; i < 1000; ++i)
  {
    total += sumVector(v);
  }
  std::cout << "Total: " << total << std::endl;
}
  1. 使用以下命令在 Linux 终端上编译、运行和计时此程序:
$ g++ Snippet1.cpp
$ time ./a.out

上一个命令的输出如下:

图 8.12:对 Snippet1.cpp 代码进行计时的输出

图 8.12:对 Snippet1.cpp 代码进行计时的输出

从前面的输出中可以看出,对于这个系统,程序在0.122秒内执行(请注意,结果会根据您系统的配置而有所不同)。如果我们反复运行此计时命令,可能会得到结果略有不同,因为程序在第一次运行后将加载到内存中,并且速度会略有提高。最好运行并计时程序约5次,并获得平均值。我们通常对所花费的时间的绝对值不感兴趣,而是对我们优化代码后数值的改善感兴趣。

  1. 使用以下命令来探索使用编译器优化标志的效果:
$ g++ -O3 Snippet1.cpp
$ time ./a.out

输出如下:

图 8.13:使用-O3 编译的 Snippet1.cpp 代码的计时输出

图 8.13:使用-O3 编译的 Snippet1.cpp 代码的计时输出

从前面的输出中,似乎程序变快了约60倍,这似乎令人难以置信。

  1. 将代码更改为执行循环100,000次而不是1,000次:
// Call out function 100000 times
for(int i = 0; i < 100000; ++i)
{
  total += sumVector(v);
}
  1. 重新编译并使用以下命令再次计时:
$ g++ -O3 Snippet1.cpp
$ time ./a.out

执行上一个命令后的输出如下:

图 8.14:对 Snippet1.cpp 代码进行计时,迭代次数为 10,000

图 8.14:对 Snippet1.cpp 代码进行计时,迭代次数为 10,000

从前面的输出中,似乎仍然需要相同的时间。这似乎是不可能的,但实际上发生的是,由于我们从未在程序中引起任何副作用,比如打印总和,编译器可以自由地用空程序替换我们的代码。从功能上讲,根据 C++标准,这个程序和一个空程序是相同的,因为它们都没有运行的副作用。

  1. 打开编译器资源管理器,粘贴整个代码。将编译器选项设置为-O3,并观察生成的代码:图 8.15:在编译器资源管理器中的 Snippet1.cpp 代码
图 8.15:在编译器资源管理器中的 Snippet1.cpp 代码

从前面的截图中可以看到,在for循环内部的行没有颜色编码,并且没有为它们生成任何汇编代码。

  1. 更改代码以确保求和必须通过打印依赖于计算的值来执行以下行:
std::cout<<"Total:"<<total<<std::endl;
  1. 在这里,我们只是将sumVector()的结果加到一个虚拟的双精度值中,并打印它。在更改代码后,打开终端并输入以下命令:
$ g++ -O3 Snippet1.cpp
$ time ./a.out

前面命令的输出如下:

图 8.16:使用打印值的副作用计时 Snippet1.cpp 代码的输出

图 8.16:使用打印值的副作用计时 Snippet1.cpp 代码的输出

在前面的输出中,我们可以看到程序实际上执行了计算,而不仅仅是作为一个空程序运行。将总数打印到cout是一个副作用,会导致编译器不会删除代码。引起副作用(比如打印结果)取决于代码的执行是防止编译器优化器删除代码的一种方法。在接下来的部分,我们将学习如何在没有副作用的情况下计时程序。

在没有副作用的情况下计时程序

如前面的练习所示,我们需要在程序中创建一个副作用(使用cout)以便编译器不会忽略我们编写的所有代码。让编译器相信一段代码具有副作用的另一种技术是将其结果赋给一个volatile变量。volatile 限定符告诉编译器:“这个变量必须始终从内存中读取并写入内存,而不是从寄存器中读取。”volatile 变量的主要目的是访问设备内存,并且这种设备内存访问必须遵循上述规则。实际上,编译器将 volatile 变量视为可能受当前程序之外的影响而发生变化,因此永远不会被优化。我们将在接下来的部分中使用这种技术。

有更高级的方法来规避这个问题,即通过向编译器指定特殊的汇编代码指令,而不是使用副作用。但它们超出了这个入门材料的范围。在接下来的示例中,我们将始终添加代码,以确保函数的结果在副作用中被使用,或者被赋给一个 volatile 变量。在以后的部分中,我们将学习如何检查编译器生成的汇编代码,并检测编译器为了优化目的而省略代码的情况。

源代码插装

插装是一个术语,指的是在不改变程序行为的情况下向程序添加额外的代码,并在执行时捕获信息。这可能包括性能计时(可能还包括其他测量,如内存分配或磁盘使用模式)。在源代码插装的情况下,我们手动添加代码来计时程序的执行,并在程序结束时记录这些数据以进行分析。这种方法的优点是它的可移植性和避免使用任何外部工具。它还允许我们有选择地将计时添加到我们选择的代码的任何部分。

练习 2:编写一个代码计时器类

在这个练习中,我们将创建一个RAII类,允许我们测量单个代码块的执行时间。我们将把这个作为后续练习中代码的主要计时机制。它不像其他性能测量方法那样复杂,但使用起来更加简单,并且可以满足大多数需求。我们类的基本要求如下:

  • 我们需要能够记录代码块所花费的累积时间。

  • 我们需要能够记录调用的次数。

执行以下步骤完成这个练习:

  1. 创建一个名为Snippet2.cpp的文件。

  2. 包括以下头文件:

#include <map>
#include <string>
#include <chrono>
#include <iostream>
#include <cstdint> 
using std::map;
using std::string;
using std::cerr;
using std::endl;
  1. 通过编写以下代码来定义Timer类和类成员函数:
class Timer
{
  static map<string, int64_t> ms_Counts;
  static map<string, int64_t> ms_Times;
  const string &m_sName;
  std::chrono::time_point<std::chrono::high_resolution_clock> m_tmStart;

从上述代码中可以看出,类成员包括名称、起始时间戳和两个static map。这个类的每个实例都用于计时某个代码块。该代码块可以是函数作用域或由花括号分隔的任何其他块。使用模式是在块的顶部定义一个Timer类的实例,同时传入一个名称(可以是函数名或其他方便的标签)。实例化时,记录当前时间戳,当块退出时,该类的析构函数记录了该块的累积经过时间,以及该块执行的次数。时间和次数分别存储在ms_Timesms_Counts这两个static map中。

  1. 通过编写以下代码来定义Timer类的构造函数:
public:
  // When constructed, save the name and current clock time
  Timer(const string &sName): m_sName(sName)
  {
    m_tmStart = std::chrono::high_resolution_clock::now();
  }
  1. 定义Timer类的析构函数,编写以下代码:
  // When destroyed, add the time elapsed and also increment the count under this name
  ~Timer()
  {
    auto tmNow = std::chrono::high_resolution_clock::now();
    auto msElapsed = std::chrono::duration_cast<std::chrono::milliseconds>(tmNow - m_tmStart);
    ms_Counts[m_sName]++;
    ms_Times[m_sName] += msElapsed.count();
  }

在上述代码中,经过时间以毫秒计算。然后,我们将其加到此块名称的累积经过时间中,并增加此块执行的次数。

  1. 定义一个名为dump()static函数,打印出定时结果的摘要:
  // Print out the stats for each measured block/function
  static void dump()
  {
    cerr << "Name\t\t\tCount\t\t\tTime(ms)\t\tAverage(ms)\n";
    cerr << "-----------------------------------------------------------------------------------\n";
    for(const auto& it: ms_Times)
    {
      auto iCount = ms_Counts[it.first];
      cerr << it.first << "\t\t\t" << iCount << "\t\t\t" << it.second << "\t\t\t" << it.second / iCount << "\n";
    }
  }
};

在上述代码中,以表格形式打印名称、执行次数、总时间和平均时间。我们在字段名称和字段值之间使用多个制表符,使它们在控制台上垂直对齐。这个函数可以根据我们的需要进行修改。例如,我们可以修改这段代码,将输出转储为 CSV 文件,以便可以将其导入电子表格进行进一步分析。

  1. 最后,定义static成员以完成这个类:
// Define static members
map<string, int64_t> Timer::ms_Counts;
map<string, int64_t> Timer::ms_Times;
const int64_t N = 1'000'000'000;
  1. 现在我们已经定义了Timer类,定义两个简单的函数作为示例进行计时。一个函数将进行加法,另一个函数将进行乘法。由于这些操作很简单,我们将循环10 亿次,以便可以得到一些可测量的结果。

注意

unsigned int testMul()
{
  Timer t("Mul");

  unsigned int x = 1;
  for(int i = 0; i < N; ++i)
  {
    x *= i;
  }

  return x;
}
unsigned int testAdd()
{
  Timer t("Add");

  unsigned int x = 1;
  for(int i = 0; i < N; ++i)
  {
    x += i;
  }

  return x;
}

在上述代码中,我们使用unsigned int作为我们重复进行add/multiply的变量。我们使用无符号类型,以便在算术运算期间不会发生溢出导致未定义行为。如果我们使用了有符号类型,程序将具有未定义行为,并且不能保证以任何方式工作。其次,我们从testAdd()testMul()函数返回计算的值,以便确保编译器不会删除代码(因为缺乏副作用)。为了计时这两个函数中的每一个,我们只需要在函数开始时声明一个带有合适标签的Timer类的实例。当Timer对象实例化时,计时开始,当该对象超出范围时,计时停止。

  1. 编写main函数,在其中我们将分别调用两个测试函数10次:
int main()
{
  volatile unsigned int dummy;
  for(int i = 0; i < 10; ++i)
    dummy = testAdd();
  for(int i = 0; i < 10; ++i)
    dummy = testMul();
  Timer::dump();
}

如上述代码所示,我们分别调用每个函数10次,以便演示Timer类计时函数的多次运行。将函数的结果赋给一个volatile变量会迫使编译器假定存在全局副作用。因此,它不会删除我们测试函数中的代码。在退出之前,调用Timer::dump静态函数显示结果。

  1. 保存程序并打开终端。使用不同的优化级别编译和运行程序-在gccclang编译器上,这是通过-ON编译器标志指定的,其中N是从13的数字。首先添加-O1编译器标志:
$ g++ -O1 Snippet2.cpp && ./a.out

这段代码生成以下输出:

图 8.17:使用-O1 选项编译时的 Snippet2.cpp 代码性能

图 8.17:使用-O1 选项编译时的 Snippet2.cpp 代码性能
  1. 现在,在终端中添加-O2编译器标志并执行程序:
$ g++ -O2 Snippet2.cpp && ./a.out

这将生成以下输出:

图 8.18:使用-O2 选项编译时的 Snippet2.cpp 代码性能

图 8.18:使用-O2 选项编译时的 Snippet2.cpp 代码性能
  1. 在终端中添加-O3编译器标志并执行程序:
$ g++ -O3 Snippet2.cpp && ./a.out

这将生成以下输出:

图 8.19:使用-O3 选项编译时的 Snippet2.cpp 代码性能

图 8.19:使用-O3 选项编译时的 Snippet2.cpp 代码性能

注意testMul函数只在O3时变得更快,但testAdd函数在O2时变得更快,而在O3时变得更快。我们可以通过多次运行程序并对时间进行平均来验证这一点。没有明显的原因说明为什么有些函数加速而其他函数没有。我们必须详尽地检查生成的代码才能理解原因。不能保证这将在所有不同编译器或甚至编译器版本的系统上发生。主要要点是我们永远不能假设性能,而必须始终测量它,并且如果我们认为我们所做的任何更改会影响性能,就必须重新测量。

  1. 为了更容易使用我们的Timer类来计时单个函数,我们可以编写一个宏。C++ 11 及以上版本支持一个特殊的编译器内置宏,称为__func__,它始终包含当前执行函数的名称作为const char*。使用这个来定义一个宏,这样我们就不需要为我们的Timer实例指定标签,如下所示:
#define TIME_IT Timer t(__func__)
  1. TIME_IT宏添加到两个函数的开头,更改创建 Timer 对象的现有行:
unsigned int testMul()
{
  TIME_IT;
unsigned int testAdd()
{
  TIME_IT;
  1. 保存程序并打开终端。使用以下命令再次编译和运行它:
$ g++ -O3 Snippet2.cpp && ./a.out

前一个命令的输出如下:

图 8.20:使用宏计时时的 Snippet2.cpp 代码输出

图 8.20:使用宏计时时的 Snippet2.cpp 代码输出

在上述输出中,注意现在打印了实际函数名。使用这个宏的另一个优点是,我们可以默认将其添加到所有可能耗时的函数中,并在生产构建中通过简单地更改定义为 no-op 来禁用它,这将导致计时代码永远不会运行-避免了需要大量编辑代码的需要。我们将在后续练习中使用相同的 Timer 类来计时代码。

运行时性能分析

性能分析是一种非侵入式的方法,用于测量程序中函数的性能。性能分析器通过在频繁的间隔(每秒数百次)对程序的当前执行地址进行采样,并记录在此时执行的函数。这是一种统计采样方法,具有合理的准确性。但有时,结果可能会令人困惑,因为程序可能会花费大量时间在操作系统内核的函数上。Linux 上最流行的运行时性能分析工具是perf。在下一节中,我们将利用 perf 来对我们的程序进行性能分析。

练习 3:使用 perf 对程序进行性能分析

perf可以在Ubuntu上安装如下:

apt-get install linux-tools-common linux-tools-generic

为了熟悉使用perf的基础知识,我们将使用perf工具对上一个练习中的程序进行性能分析。执行以下步骤完成此练习:

  1. 打开两个函数中的TIME_IT宏。

  2. 打开终端,使用-O3标志重新编译代码,然后使用perf创建一个配置文件数据样本,如下所示:

$ g++ -O3 Snippet2.cpp
$ perf record ./a.out

前一个命令的输出如下:

图 8.21:使用 perf 命令分析 Snippet2.cpp 中的代码

这将创建一个名为perf.data的文件,可以进行分析或可视化。

  1. 现在,使用以下命令可视化记录的数据:
$ perf report

执行前一个命令后,控制台基于 GUI 将显示以下数据:

图 8.22:使用 perf 命令分析 Snippet2.cpp 中的代码

图 8.22:使用 perf 命令分析 Snippet2.cpp 中的代码

您可以上下移动光标选择一个函数,然后按Enter获取选项列表。

  1. 突出显示testMul,按Enter,并在结果列表中选择Annotate testMul。显示一系列汇编代码,其中包含描述每行代码执行时间百分比的注释,如下所示:

图 8.23:使用 perf 命令查看 Snippet2.cpp 代码的时间统计信息

图 8.23:使用 perf 命令查看 Snippet2.cpp 代码的时间统计信息

注意99%的执行时间。传统上,在x86架构上,整数乘法始终很昂贵,即使在最新一代 CPU 中也是如此。此注释视图在每个跳转或分支指令旁显示箭头,突出显示时显示其关联的比较指令和跳转到的地址以线条绘制。您可以按左箭头键导航到上一个视图,并使用q键退出程序。

到目前为止,我们已经看了几种用于评估程序性能的方法。这是优化的最关键阶段,因为它告诉我们需要将精力放在哪里。在接下来的章节中,我们将探索各种技术,帮助我们优化我们的代码。

优化策略

代码优化可以通过多种方式进行,例如:

  • 基于编译器的优化

  • 源代码微优化

  • 缓存友好的代码

  • 算法优化

在这里,每种技术都有其优缺点。我们将在接下来的章节中详细研究这些方法。粗略地说,这些方法按照所需的工作量和性能潜力排序。我们将在下一节中研究基于编译器的优化。

基于编译器的优化

向编译器传递正确的选项可以获得许多性能优势。这方面的一个现实例子是 Clear Linux 的gccclang系列编译器,优化的最基本选项是-O<N>,其中N123中的一个数字。-O3几乎启用了编译器中的每个优化,但还有一些未通过该标志启用的其他优化可以产生差异。

循环展开

循环展开是编译器可以使用的一种技术,用于减少执行的分支数。每次执行分支时,都会有一定的性能开销。这可以通过多次重复循环体并减少循环执行次数来减少。循环展开可以由程序员在源级别上完成,但现代编译器会自动完成得很好。

尽管现代处理器通过gccclang系列编译器的-funroll-loops命令行标志来减少分支开销。在下一节中,我们将测试启用和未启用循环展开的程序性能。

练习 4:使用循环展开优化

在这个练习中,我们将编写一个简单的程序,使用嵌套循环并测试其性能,启用和未启用循环展开。我们将了解编译器如何实现循环的自动展开。

执行以下步骤完成此练习:

  1. 创建名为Snippet3.cpp的文件。

  2. 编写一个程序,取前10,000个数字,并打印出这些数字中有多少个是彼此的因子(完整代码可以在Snippet3.cpp中找到):

# include <iostream>
int main()
{
  int ret = 0;
  for(size_t i = 1; i < 10000; ++i)
  {
    for(size_t j = 1; j < 10000; ++j)
    {
      if(i % j == 0)
      {
        ++ret;
      }
    }
  }

  std::cout << "Result: " << ret << std::endl;
}
  1. 保存程序并打开终端。首先使用-O3标志编译程序,并使用以下命令计时:
$ g++ -O3 Snippet3.cpp
$ time ./a.out

前一个命令的输出如下:

图 8.24:Snippet3.cpp 代码的输出

图 8.24:Snippet3.cpp 代码的输出
  1. 现在,启用循环展开编译相同的代码并再次计时:
$ g++ -O3 -funroll-loops Snippet3.cpp 
$ time ./a.out 

前一个命令的输出如下:

图 8.25:使用循环展开选项编译的 Snippet3.cpp 代码的输出

图 8.25:使用循环展开选项编译的 Snippet3.cpp 代码的输出
  1. 打开Godbolt 编译器资源管理器,并将前面的完整代码粘贴到左侧。

  2. 在右侧,从编译器选项中选择x86-64 gcc 8.3,并在选项中写入-O3标志。将生成汇编代码。对于 for 循环,你会看到以下输出:图 8.26:for 循环的汇编代码

图 8.26:for 循环的汇编代码

从前面的截图中,你可以清楚地看到RCX10,000进行比较,使用CMP指令,然后是一个条件跳转,JNE(如果不相等则跳转)。就在这段代码之后,可以看到外部循环比较,RSI10,000进行比较,然后是另一个条件跳转到L4标签。总的来说,内部条件分支和跳转执行了100,000,000次。

  1. 现在,添加以下选项:-O3 –funroll-loops。将生成汇编代码。在这段代码中,你会注意到这段代码模式重复了八次(除了LEA指令,其偏移值会改变):

图 8.27:for 循环的汇编代码

图 8.27:for 循环的汇编代码

编译器决定展开循环体八次,将条件跳转指令的执行次数减少了87.5%(约8,300,000次)。这单独就导致执行时间提高了10%,这是一个非常显著的加速。在这个练习中,我们已经看到了循环展开的好处 - 接下来,我们将学习 profile guided optimization。

Profile Guided Optimization

Profile Guided Optimization(PGO)是大多数编译器支持的一个特性。当使用 PGO 编译程序时,编译器会向程序添加插装代码。运行这个启用了 PGO 的可执行文件会创建一个包含程序执行统计信息的日志文件。术语profiling指的是运行程序以收集性能指标的过程。通常,这个 profiling 阶段应该使用真实的数据集运行,以便产生准确的日志。在这个 profiling 运行之后,程序会使用特殊的编译器标志重新编译。这个标志使编译器能够根据记录的统计执行数据执行特殊的优化。采用这种方法可以实现显著的性能提升。让我们解决一个基于 profile guided optimization 的练习,以更好地理解这个过程。

练习 5:使用 Profile Guided Optimization

在这个练习中,我们将在前一个练习的代码上使用 profile guided optimization。我们将了解如何在gcc编译器中使用 profile guided optimization。

执行以下步骤完成这个练习:

  1. 打开终端,并使用启用了 profiling 的前一个练习的代码进行编译。包括我们需要的任何其他优化标志(在本例中为-O3)。编写以下代码来实现这一点:
$ g++ -O3 -fprofile-generate Snippet3.cpp
  1. 现在,通过编写以下命令运行代码的 profiled 版本:
$ ./a.out

程序正常运行并打印结果,没有看到其他输出 - 但它生成了一个包含数据的文件,这将帮助编译器进行下一步。请注意,启用了性能分析后,程序的执行速度会比正常情况下慢几倍。这是在处理大型程序时需要牢记的事情。执行前一个命令后,将生成一个名为Snippet3.gcda的文件,其中包含性能分析数据。在处理大型、复杂的应用程序时,重要的是使用它在生产环境中最常遇到的数据集和工作流来运行程序。通过在这里正确选择数据,最终的性能提升将更高。

  1. 重新编译使用 PGO 优化标志,即-fprofile-use-fprofile-correction,如下所示:
$ g++ -O3 -fprofile-use -fprofile-correction Snippet3.cpp

请注意,除了与之前编译步骤中的与性能相关的编译器选项外,其他选项必须完全相同。

  1. 现在,如果我们计时可执行文件,我们将看到性能大幅提升:
$ time ./a.out

前一个命令的输出如下:

图 8.28:使用 PGO 优化编译的 Snippet3.cpp 代码的时间结果

图 8.28:使用 PGO 优化编译的 Snippet3.cpp 代码的时间结果

在这个练习中,我们已经看到了使用编译器提供的基于性能指导的优化所获得的性能优势。对于这段代码,性能提升约为2.7 倍 - 在更大的程序中,这个提升甚至可能更高。

并行化

如今大多数 CPU 都有多个核心,甚至手机也有四核处理器。我们可以通过简单地使用编译器标志来利用这种并行处理能力,让它生成并行化的代码。一种并行化代码的机制是使用 C/C++语言的OpenMP扩展。然而,这意味着改变源代码并且需要详细了解如何使用这些扩展。另一个更简单的选择是gcc编译器特有的一个特性 - 它提供了一个扩展标准库,实现了大多数算法作为并行算法运行。

注意

这种自动并行化只适用于 gcc 上的 STL 算法,并不是 C标准的一部分。C 17 标准提出了标准库的扩展,用于大多数算法的并行版本,但并不是所有编译器都支持。此外,为了利用这个特性,代码需要进行大量重写。

练习 6:使用编译器并行化

在这个练习中,我们将使用gcc的并行扩展特性来加速标准库函数。我们的目标是了解如何使用gcc的并行扩展。

执行这些步骤来完成这个练习:

  1. 创建一个名为Snippet4.cpp的文件。

  2. 编写一个简单的程序,使用std::accumulate来对初始化的数组进行求和。添加以下代码来实现这一点:

#include <vector>
#include <string>
#include <iostream>
#include <algorithm>
#include <numeric>
#include <cstdint> 
using std::cerr;
using std::endl;
int main()
{
  // Fill 100,000,000 1s in a vector
  std::vector<int> v( 100'000'000, 1);
  // Call accumulate 100 times, accumulating to a total sum
  uint64_t total = 0;
  for(int i = 0; i < 100; ++i)
  {
    total += std::accumulate(v.begin(), v.end(), 0);
  }
  std::cout << "Total: " << total << std::endl;
}
  1. 保存程序并打开终端。正常编译程序并使用以下命令计时执行:
$ g++ -O3 Snippet4.cpp
$ time ./a.out

前一个命令的输出如下:

图 8.29:Snippet4.cpp 代码的输出

图 8.29:Snippet4.cpp 代码的输出
  1. 现在,使用并行化选项编译代码,即-O3 -fopenmp-D_GLIBCXX_PARALLEL
$ g++ -O3 -fopenmp -D_GLIBCXX_PARALLEL Snippet4.cpp
$ time ./a.out

输出如下:

图 8.30:使用并行化选项编译的 Snippet4.cpp 代码的输出

图 8.30:使用并行化选项编译的 Snippet4.cpp 代码的输出

在先前的输出中,user字段显示了累积 CPU 时间,real字段显示了墙时间。两者之间的比率约为7x。这个比率会有所变化,取决于系统有多少个 CPU 核心(在这种情况下,有八个核心)。对于这个系统,如果编译器能够执行100%的并行化,这个比率可能会达到 8 倍。请注意,即使使用了八个核心,实际的执行时间改进只有大约1.3x。这可能是因为向量的分配和初始化占用了大部分时间。这是我们代码中1.3x加速的情况,这是一个非常好的优化结果。

到目前为止,我们已经介绍了一些现代编译器中可用的一些更有影响力的编译器优化特性。除了这些,还有几个其他优化标志,但它们可能不会产生非常大的性能改进。适用于具有许多不同源文件的大型项目的两个特定优化标志是链接时优化链接时代码生成。这些对于大型项目来说是值得启用的。在下一节中,我们将研究源代码微优化。

源代码微优化

这些是涉及在源代码中使用某些习语和模式的技术,通常比它们的等价物更快。在早期,这些微优化非常有成效,因为编译器不是很聪明。但是今天,编译器技术非常先进,这些微优化的效果并不那么明显。尽管如此,使用这些是一个非常好的习惯,因为即使在没有优化的情况下编译,它们也会使代码更快。即使在开发构建中,更快的代码也会在测试和调试时节省时间。我们将在下一节中看一下 std::vector 容器:

高效使用 std::vector 容器

std::vector是标准库中最简单和最有用的容器之一。它与普通的 C 风格数组没有额外开销,但具有增长的能力,以及可选的边界检查。当元素的数量在编译时未知时,几乎总是应该使用std::vector

std::vector一起使用的常见习语是在循环中调用push_back - 随着它的增长,向量重新分配一个新的缓冲区,该缓冲区比现有的缓冲区大一定因子(此增长因子的确切值取决于标准库的实现)。理论上,这种重新分配的成本很小,因为它不经常发生,但实际上,在向量中调整大小的操作涉及将其缓冲区的元素复制到新分配的更大缓冲区中,这可能非常昂贵。

我们可以通过使用reserve()方法来避免这些多次分配和复制。当我们知道一个向量将包含多少元素时,调用reserve()方法来预先分配存储空间会产生很大的差异。让我们在下一节中实现一个练习来优化向量增长。

练习 7:优化向量增长

在这个练习中,我们将计时在循环中使用push_back方法的效果,有无调用 reserve 方法。首先,我们将把我们在前几节中使用的Timer类提取到一个单独的头文件和实现文件中 - 这将允许我们将其用作所有后续代码片段的通用代码。执行以下步骤来完成这个练习:

  1. 创建一个名为Timer.h的头文件。

  2. 包括必要的头文件:

#include <map>
#include <string>
#include <chrono>
#include <cstdint>
  1. 创建一个名为Timer的类。在Timer类中,声明四个变量,分别是ms_Countsms_Timesm_tmStartm_sName。声明一个构造函数、析构函数和dump()方法。添加以下代码来实现这一点:
class Timer
{
  static std::map<std::string, int64_t> ms_Counts;
  static std::map<std::string, int64_t> ms_Times;
  std::string m_sName;
  std::chrono::time_point<std::chrono::high_resolution_clock> m_tmStart;
  public:
    // When constructed, save the name and current clock time
    Timer(std::string sName);
    // When destroyed, add the time elapsed and also increment the count under this name
    ~Timer();
    // Print out the stats for each measured block/function
    static void dump();
};
  1. 定义一个名为TIME_IT的辅助宏,通过编写以下代码来计时函数:
// Helper macro to time functions
#define TIME_IT Timer t(__func__)
  1. 一旦创建了头文件,就在Timer.cpp文件中创建一个名为dump()的新文件。编写以下代码来实现这一点:
#include <string>
#include <iostream>
#include <cstdint> 
#include "Timer.h"
using std::map;
using std::string;
using std::cerr;
using std::endl;
// When constructed, save the name and current clock time
Timer::Timer(string sName): m_sName(sName)
{
  m_tmStart = std::chrono::high_resolution_clock::now();
}
// When destroyed, add the time elapsed and also increment the count under this name
Timer::~Timer()
{
  auto tmNow = std::chrono::high_resolution_clock::now();
  auto msElapsed = std::chrono::duration_cast<std::chrono::milliseconds>(tmNow - m_tmStart);
  ms_Counts[m_sName]++;
  ms_Times[m_sName] += msElapsed.count();
}
// Print out the stats for each measured block/function
void Timer::dump()
{
  cerr << "Name\t\t\tCount\t\t\tTime(ms)\t\tAverage(ms)\n";
  cerr << "-----------------------------------------------------------------------------------\n";
  for(const auto& it: ms_Times)
  {
    auto iCount = ms_Counts[it.first];
    cerr << it.first << "\t\t\t" << iCount << "\t\t\t" << it.second << "\t\t\t" << it.second / iCount << "\n";
  }
}
// Define static members
map<string, int64_t> Timer::ms_Counts;
map<string, int64_t> Timer::ms_Times;
  1. 现在,使用push_back()方法创建一个名为1,000,000的新文件。第二个函数在之前调用了reserve()方法,但第一个函数没有。编写以下代码来实现这一点:
#include <vector>
#include <string>
#include <iostream>
#include "Timer.h"
using std::vector;
using std::cerr;
using std::endl;
const int N = 1000000;
void withoutReserve(vector<int> &v)
{
  TIME_IT;
  for(int i = 0; i < N; ++i)
  {
    v.push_back(i);
  }
}
void withReserve(vector<int> &v)
{
  TIME_IT;
  v.reserve(N);
  for(int i = 0; i < N; ++i)
  {
    v.push_back(i);
  }
}
  1. 现在,编写main函数。请注意使用多余的大括号以确保在循环的每次迭代后销毁v1v2向量:
int main()
{
  {
    vector<int> v1;
    for(int i = 0; i < 100; ++i)
    {
      withoutReserve(v1);
    }
  }
  {
    vector<int> v2;
    for(int i = 0; i < 100; ++i)
    {
      withReserve(v2);
    }
  }
  Timer::dump();
}

我们通过引用传递向量的原因是为了防止编译器优化掉两个函数中的整个代码。如果我们通过值传递向量,函数将没有可见的副作用,编译器可能会完全省略这些函数。

  1. 保存程序并打开终端。编译Timer.cppSnippet5.cpp文件,并按以下方式运行它们:
$ g++ -O3 Snippet5.cpp Timer.cpp
$ ./a.out

输出如下:

图 8.31:Snippet5.cpp 中代码的输出,显示了 vector::reserve()的效果

图 8.31:Snippet5.cpp 中代码的输出,显示了 vector::reserve()的效果

正如我们所看到的,调用reserve()的效果导致执行时间提高了约 4%。在运行时间较长的大型程序中,系统内存通常变得非常碎片化。在这种情况下,通过使用reserve()预先分配内存的改进可能会更好。通常情况下,预留内存通常比在运行时逐步增加内存更快。甚至为了性能原因,Java 虚拟机在启动时使用这种预先分配大块内存的技术。

短路逻辑运算符

&&||逻辑运算符是短路的,这意味着以下内容:

  • 如果||运算符的左侧为true,则不会评估右侧。

  • 如果&&运算符的左侧为false,则不会评估右侧。

通过将不太可能的(或者更便宜的)表达式放在左侧,我们可以减少需要执行的工作量。在下一节中,我们将解决一个练习,并学习如何最优地编写逻辑表达式。

练习 8:优化逻辑运算符

在这个练习中,我们将研究在逻辑运算符与条件表达式一起使用时的顺序对性能的影响。执行以下步骤完成这个练习:

  1. 创建一个名为Snippet6.cpp的新文件。

  2. 通过编写以下代码,包括我们在上一个练习中创建的必要库和 Timer.h 文件:

#include <vector>
#include <string>
#include <iostream>
#include <random>
#include "Timer.h"
using std::vector;
using std::cerr;
using std::endl;
  1. 定义一个名为sum1()的函数,计算介于0N之间的整数的和。只有当满足两个特定条件中的一个时,才对每个数字求和。第一个条件是数字必须小于N/2。第二个条件是当数字除以 3 时,必须返回 2 作为余数。在这里,我们将N设置为100,000,000,以便代码花费一些可测量的时间。编写以下代码来实现这一点:
const uint64_t N = 100000000;
uint64_t sum1()
{
  TIME_IT;
  uint64_t ret = 0;
  for(uint64_t b=0; b < N; ++b)
  {
    if(b % 3 == 2 || b < N/2)
    {
      ret += b;
    }
  }

  return ret;
}
  1. 现在,定义另一个名为sum2()的函数。它必须包含我们为上一个函数sum1()编写的相同逻辑。这里唯一的变化是我们颠倒了if语句的条件表达式的顺序。编写以下代码来实现这一点:
uint64_t sum2()
{
  TIME_IT;
  uint64_t ret = 0;
  for(uint64_t b=0; b < N; ++b)
  {
    if(b < N/2 || b % 3 == 2)
    {
    ret += b;
    }
  }

  return ret;
}

请注意,在sum2函数中,b < N/2条件将一半的时间评估为 true。因此,第二个条件,即b % 3 == 2,只有一半的迭代会被评估。如果我们简单地假设两个条件都需要 1 个单位的时间,那么sum2()所需的总时间将是N/2 + (2 * N/2) = N * 3/2。在sum1()函数的情况下,左侧的条件只有 33%的时间评估为true,剩下的 66%的时间,两个条件都会被评估。因此,预计所需的时间将是N/3 + (2 * N * 2/3) = N * 5/3。我们预计sum1sum2函数之间的时间比率将是5/33/2 - 也就是说,sum1慢了11%

  1. 在主函数中添加以下代码:
int main()
{
  volatile uint64_t dummy = 0;
  for(int i = 0; i < 100; ++i)
  {
    dummy = sum1();
  }
  for(int i = 0; i < 100; ++i)
  {
    dummy = sum2();
  }
  Timer::dump();
}
  1. 保存文件并打开终端。通过编写以下命令,编译并计时前面的程序以及Timer.cpp文件:
$ g++ -O3 Snippet6.cpp Timer.cpp
$ ./a.out

输出如下:

图 8.32:Snippet6.cpp 中代码的输出,显示了优化布尔条件的效果

图 8.32:Snippet6.cpp 中代码的输出

从前面的输出中可以看出,我们最终获得了约 38%的速度提升,这远远超出了预期。为什么会发生这种情况?答案是%运算符执行整数除法,比比较要昂贵得多,但编译器不会为N/2表达式生成除法指令,因为它是一个常量值。

sum1()函数代码对循环的每次迭代执行模运算,整体执行时间由除法主导。总结一下,我们必须始终考虑短路逻辑运算符,并计算表达式的每一侧以及它们出现在表达式中的次数,以选择它们应该出现在表达式中的最佳顺序。这相当于对概率论进行期望值计算。在下一节中,我们将学习有关分支预测的内容。

分支预测

现代处理器使用流水线架构,类似于工厂装配线,其中指令沿着流水线流动,并同时由各种工人处理。每个时钟周期后,指令沿着流水线移动到下一个阶段。这意味着虽然每个指令可能需要多个周期才能从开始到结束,但整体吞吐量是每个周期完成一个指令。

这里的缺点是,如果有条件分支指令,CPU 不知道在此之后要加载哪组指令(因为有两种可能的选择)。这种情况称为流水线停顿,处理器必须等到分支的条件完全评估完毕,浪费宝贵的周期。

为了减轻这一问题,现代处理器使用了所谓的分支预测 - 它们试图预测分支的走向。随着分支遇到的次数增多,它对分支可能走向的方式变得更加自信。

尽管如此,CPU 并不是无所不知的,所以如果它开始加载一个预测的分支的指令,后来条件分支结果是另一种方式,分支后的整个流水线必须被清除,并且实际分支需要从头开始加载。在分支指令之后的“装配线”上所做的所有工作都必须被丢弃,并且任何更改都必须被撤销。

这是性能的一个主要瓶颈,可以避免 - 最简单的方法是尽可能确保分支总是朝着一种方式走 - 就像一个循环一样。

练习 9:分支预测优化

在这个练习中,我们将探讨并展示 CPU 分支预测对性能的影响。为了探索这一点,我们将在一个程序中编写两个函数,两个函数都使用两个嵌套循环进行相同的计算,分别迭代100100,000,000次。两个函数的区别在于,第一个函数中外部循环更大,而第二个函数中外部循环更小。

对于第一个函数,外部循环在退出时只有一次分支预测失败,但内部循环在退出时有100,000,000次分支预测失败。对于第二个函数,外部循环在退出时也只有一次分支预测失败,但内部循环在退出时只有100次分支预测失败。这两个分支预测失败次数之间的因素为1,000,000,导致第一个函数比第二个函数慢。完成这个练习的步骤如下:

  1. 创建一个名为Snippet7.cpp的文件,并包含必要的库:
#include <vector>
#include <string>
#include <iostream>
#include <random>
#include "Timer.h"
using std::vector;
using std::cerr;
using std::endl;
  1. 定义一个名为sum1()的函数,其中包含一个嵌套循环。外部的for循环应该循环N次,而内部的循环应该迭代100次。将N的值设置为100000000。编写以下代码来实现这一点:
const uint64_t N = 100000000;
uint64_t sum1()
{
  TIME_IT;
  uint64_t ret = 0;
  for(int i = 0; i < N; ++i)
  {
    for(int j = 0; j < 100; ++j)
    {
      ret += i ^ j;
    }
  }
  return ret;
}

如果我们假设处理器在循环中预测分支(统计上,循环末尾的分支指令更有可能跳转到循环的开头),那么当 j 达到100时,它将每次都预测错误,换句话说,预测错误了N次。

  1. 定义一个名为sum2()的新函数,其中包含一个嵌套循环。唯一的变化是,我们必须将内部循环计数设置为N,外部循环计数设置为100。添加以下代码来实现这一点:
uint64_t sum2()
{
  TIME_IT;
  uint64_t ret = 0;
  for(int i = 0; i < 100; ++i)
  {
    for(int j = 0; j < N; ++j)
    {
      ret += i ^ j;
    }
  }
  return ret;
}

现在,我们的推理是分支预测只会发生100次。

  1. 在主函数中添加以下代码:
int main()
{
  volatile uint64_t dummy;
  dummy = sum1();
  dummy = sum2();
  Timer::dump();
}
  1. 保存文件并打开终端。使用以下命令编译前面的程序,以及Timer.cpp文件,并使用以下命令计时。请记住,您需要在同一个目录中拥有您之前创建的 Timer.cpp 和 Timer.h 文件:
$ g++ -O3 Snippet7.cpp Timer.cpp
$ ./a.out

执行前面的命令的输出如下:

图 8.33:Snippet7.cpp 中代码的输出显示了分支预测优化的效果分支预测优化

图 8.33:Snippet7.cpp 中代码的输出显示了分支预测优化的效果

从前面的输出中可以看到,由于处理器能够更好地预测sum2函数的分支,速度提高了约2%,虽然提升很小,但显然是显著的。在下一节中,我们将探讨更多的优化技术。

进一步优化

还有一些其他的技术可以在编码时实现;其中一些并不能保证产生更好的代码,但改变编码习惯以自动进行这些改变所需的工作量很小。这些技术中的一些如下:

  • 在可能的情况下,通过const引用传递非原始类型的参数。即使const引用。

  • 在可能的情况下,通过使用前置递增(++i)或前置递减(--i)运算符而不是后置版本。这通常对于整数等简单类型没有用处,但对于具有自定义递增运算符的复杂类型可能有用。养成使用++i而不是i++的习惯是一个好习惯,除非后置递增实际上是期望的行为。除了性能上的好处,这样的代码通过使用正确的运算符更清晰地声明了意图。

  • 尽可能晚地声明变量——在 C 语言中通常会在函数顶部声明每个变量,但在 C++中,由于变量可能具有非平凡的构造函数,只在实际使用它们的块中声明它们是有意义的。

  • 循环提升方面,如果在循环中有任何不随循环迭代而改变的代码或计算,将其移到循环外是有意义的。这包括在循环体中创建对象。通常情况下,更有效的做法是在循环外声明它们一次。现代编译器会自动执行这些操作,但自己这样做并不需要额外的努力。

  • 尽可能使用const。它不会改变代码的含义,但它让编译器对你的代码做出更强的假设,可能会导致更好的优化。除此之外,使用const会使代码更易读和合理。

  • 整数除法、模数和乘法(尤其是非 2 的幂次方的数)是 X86 硬件上可能最慢的操作之一。如果你需要在循环中执行这样的操作,也许你可以进行一些代数操作来摆脱它们。

正如我们提到的,编译器本身可能会进行一些这样的优化,但养成这样的习惯可以使代码在调试模式下也变得更快,这在调试时是一个很大的优势。我们已经研究了一些微优化代码的技巧 - 要做到这一点所需的代码更改程度相对较小,其中一些可以大大提高效率。如果你想写出更快的代码,你应该在一段时间内将这些技巧作为默认的编码风格。在下一节中,我们将学习关于友好缓存的代码。

友好缓存的代码

计算机科学是在 20 世纪中期发展起来的,当时计算机几乎不存在,但尽管如此,到了 20 世纪 80 年代,大部分有用的数据结构和算法都已经被发现和完善。算法复杂性分析是任何学习计算机科学的人都会遇到的一个话题 - 有关数据结构操作复杂性的定义有着公认的教科书定义。然而,50 年过去了,计算机的发展方式与当初的设想大不相同。例如,一个常见的“事实”是,列表数据结构对于插入操作比数组更快。这似乎是常识,因为将元素插入数组涉及将该点之后的所有项目移动到新位置,而将元素插入列表只是一些指针操作。我们将在下面的练习中测试这个假设。

练习 10:探索缓存对数据结构的影响

在这个练习中,我们将研究缓存对 C++标准库中的数组和列表的影响。执行以下步骤来完成这个练习:

  1. 创建一个名为Snippet8.cpp的文件。

  2. 包括必要的库,以及Timer.h头文件。编写以下代码来实现这一点:

#include <vector>
#include <list>
#include <algorithm>
#include <string>
#include <iostream>
#include <random>
#include "Timer.h"
using std::vector;
using std::list;
using std::cerr;
using std::endl;
  1. 创建一个名为N的常量整数变量,并将其值设置为100000
const int N = 100000;
  1. 初始化一个随机数生成器,并创建一个范围从01000的分布。添加以下代码来实现这一点:
std::random_device dev;
std::mt19937 rng(dev());
std::uniform_int_distribution<std::mt19937::result_type> dist(0,N);
  1. 创建一个名为insertRandom()的方法,并将从0N的元素插入到容器的随机位置。添加以下代码来实现这一点:
template<class C> void insertRandom(C &l)
{
  // insert one element to initialize
  l.insert(l.end(), 0);
  for(int i = 0; i < N; ++i)
  {
    int pos = dist(rng) % l.size();
    auto it = l.begin();
    advance(it, pos);
    l.insert(it, i);
  }
}
  1. 创建一个名为insertStart()的方法,并将从0N的元素插入到容器的开头。添加以下代码来实现这一点:
template<class C> void insertStart(C &l)
{
  for(int i = 0; i < N; ++i)
  {
    l.insert(l.begin(), i);
  }
}
  1. 创建一个名为insertEnd()的方法,并将从0N的元素插入到容器的末尾。添加以下代码来实现这一点:
template<class C> void insertEnd(C &l)
{
  for(int i = 0; i < N; ++i)
  {
    l.insert(l.end(), i);
  }
}
  1. main方法中编写以下代码:
int main()
{
  std::list<int> l;
  std::vector<int> v;
  // list
  {
    Timer t("list random");
    insertRandom(l);
  }

  {
    Timer t("list end");
    insertEnd(l);    
  }
  {
    Timer t("list start");
    insertStart(l);
  }
  // vector
  {
    Timer t("vect random");
    insertRandom(v);
  }

  {
    Timer t("vect end");
    insertEnd(v);    
  }
  {
    Timer t("vect start");
    insertStart(v);
  }
  cerr << endl << l.size() << endl << v.size() << endl;
  Timer::dump();
}
  1. 保存文件并打开终端。通过编写以下命令,编译前面的程序以及Timer.cpp文件:
$ g++ -O3 Snippet8.cpp Timer.cpp
$ ./a.out

前面的命令生成以下输出:

图 8.34:Snippet8.cpp 中代码的输出对比 std<span>list 和 std</span>vector 插入的时间 std<span>list 和 std</span>vector 插入

图 8.34:Snippet8.cpp 中代码的输出对比 stdlist 和 stdvector 插入的时间

从前面的输出中可以看出,代码测量了在std::vectorstd::list中在开头、结尾和随机位置插入100000个整数所花费的时间。对于随机情况,向量明显胜出了 100 倍或更多,即使对于向量的最坏情况也比列表的随机情况快 10 倍。

为什么会发生这种情况?答案在于现代计算机架构的演变方式。CPU 时钟速度从 80 年代初的约1 Mhz增加到 2019 年中的5 GHz - 时钟频率提高了5,000x,而最早的 CPU 使用多个周期执行指令,现代 CPU 在单个核上每个周期执行多个指令(由于先进的技术,如流水线处理,我们之前描述过)。

例如,原始的Intel 8088上的IDIV指令需要超过 100 个时钟周期才能完成,而在现代处理器上,它可以在不到 5 个周期内完成。另一方面,RAM 带宽(读取或写入一个字节内存所需的时间)增长非常缓慢。

从历史上看,处理器在 1980 年到 2010 年之间的速度增加了约16,000x。与此同时,RAM 的速度增加幅度要小得多 - 不到 100 倍。因此,可能单个指令对 RAM 的访问导致 CPU 等待大量时钟周期。这将是性能下降无法接受的,因此已经有很多技术来缓解这个问题。在我们探讨这个问题之前,让我们来测量内存访问的影响。

练习 11:测量内存访问的影响

在这个练习中,我们将检查随机访问内存的性能影响。执行以下步骤完成这个练习:

  1. 创建一个名为Snippet9.cpp的新文件。

  2. 包括必要的库,以及SIZEN,并将它们的值设置为100000000。还要创建一个随机数生成器和一个范围分布从0N-1。编写以下代码来实现这一点:

#include <vector>
#include <list>
#include <algorithm>
#include <string>
#include <iostream>
#include <random>
#include "Timer.h"
using std::vector;
using std::list;
using std::cerr;
using std::endl;
const int SIZE = 100'000'000;
const int N = 100'000'000;
std::random_device dev;
std::mt19937 rng(dev());
std::uniform_int_distribution<std::mt19937::result_type> dist(0,SIZE-1);
  1. 创建getPRIndex()函数,返回一个在0SIZE-1之间的伪随机索引,其中SIZE是数组中元素的数量。编写以下代码来实现这一点:

注意

uint64_t getPRIndex(uint64_t i)
{
  return (15485863 * i) % SIZE;
}
  1. 编写一个名为sum1()的函数,它随机访问大量数据的数组并对这些元素求和:
uint64_t sum1(vector<int> &v)
{
  TIME_IT;
  uint64_t sum = 0;
  for(int i = 0; i < N; ++i)
  {
    sum += v[getPRIndex(i)];
  }
  return sum;
}
  1. 编写一个名为sum2()的函数,对随机数进行求和而不进行任何内存访问:
uint64_t sum2()
{
  TIME_IT;
  uint64_t sum = 0;
  for(int i = 0; i < N; ++i)
  {
    sum += getPRIndex(i);
  }
  return sum;
}
  1. 在主函数中,初始化向量,使得v[i] == i,因此,sum1()sum2()之间唯一的区别是sum1()访问内存,而sum2()只进行计算。像往常一样,我们使用volatile来防止编译器删除所有代码,因为它没有副作用。在main()函数中编写以下代码:
int main()
{
  // Allocate SIZE integers
  std::vector<int> v(SIZE, 0);
  // Fill 0 to SIZE-1 values into the vector
  for(int i = 0; i < v.size(); ++i)
  {
    v[i] = i;
  }
  volatile uint64_t asum1 = sum1(v);
  volatile uint64_t asum2 = sum2();
  Timer::dump();
}
  1. 保存程序并打开终端。通过编写以下命令编译和运行程序:
$ g++ -O3 Snippet9.cpp Timer.cpp
$ ./a.out

前面的代码生成了以下输出:

图 8.35:在 Snippet9.cpp 中对比代码的输出时间计算与随机内存访问

图 8.35:在 Snippet9.cpp 中对比计算与随机内存访问的代码输出时间

从前面的输出中,我们可以清楚地看到性能上大约有14x的差异。

  1. 创建一个名为sum3()的新文件,它线性访问内存而不是随机访问。还要编辑主函数。更新后的代码如下:
uint64_t sum3(vector<int> &v)
{
  TIME_IT;
  uint64_t sum = 0;
  for(int i = 0; i < N; ++i)
  {
    sum += v[i];
  }
  return sum;
}
int main()
{
  // Allocate SIZE integers
  std::vector<int> v(SIZE, 0);

  // Fill 0 to SIZE-1 values into the vector
  for(int i = 0; i < v.size(); ++i)
  {
    v[i] = i;
  }
  volatile uint64_t asum1 = sum1(v);
  volatile uint64_t asum2 = sum2();
  volatile uint64_t asum3 = sum3(v);  
  Timer::dump();
}
  1. 保存文件并打开终端。编译并运行程序:
$ g++ -O3 Snippet10.cpp Timer.cpp
$ ./a.out

前面的命令生成了以下输出:

图 8.36:在 Snippet10.cpp 中对比代码的输出时间计算与随机和线性内存访问

图 8.36:在 Snippet10.cpp 中对比计算与随机和线性内存访问的代码输出时间

在前面的输出中,请注意,内存访问现在比以前快了35倍以上,比sum2()中的计算快了2.5倍。我们在sum1()中使用了随机访问模式,以展示线性和随机内存访问之间的对比。线性内存访问为什么比随机访问快得多?答案在于现代处理器中用于缓解缓慢内存效果的两种机制 - 缓存预取 - 我们将在以下部分讨论这两种机制。

缓存

现代处理器在处理器寄存器和 RAM 之间有多层缓存内存。这些缓存被标记为 L1、L2、L3、L4 等,其中 L1 最靠近处理器,L4 最远。每个缓存层比下面的级别更快(通常也更小)。以下是Haswell系列处理器的缓存/内存大小和延迟的示例:

  • L1:32 KB,4 个周期

  • L2:256 KB,12 个周期

  • L3:6 MB,20 个周期

  • L4: 128 MB, 58 个周期

  • RAM:多 GB,115 个周期

缓存如何提高性能的一个简单模型是:当访问内存地址时,首先在 L1 缓存中查找 - 如果找到,则从那里检索。如果没有找到,则在 L2 缓存中查找,如果没有找到,则在 L3 缓存中查找,依此类推 - 如果在任何缓存中都找不到,则从内存中获取。从内存中获取时,它会存储在每个缓存中,以便以后更快地访问。这种方法本身将是相当无用的,因为只有在我们一遍又一遍地访问相同的内存地址时,它才会提高性能。第二个方面,称为预取,是可以使缓存真正得到回报的机制。

预取

预取是一个过程,当执行内存访问时,附近的数据也被提取到缓存中,即使它没有直接被访问。预取的第一个方面与内存总线粒度有关 - 它可以被认为是“RAM 子系统可以发送给处理器的最小数据量是多少?”。在大多数现代处理器中,这是 64 位 - 换句话说,无论您从内存请求单个字节还是 64 位值,都会从 RAM 中读取包含该地址的整个 64 位机器字。这些数据存储在每个缓存层中,以便以后更快地访问。显然,这将立即提高内存性能 - 假设我们读取地址0x1000处的一个字节的内存;我们还将该地址之后的 7 个字节也放入缓存中。如果我们随后访问地址0x1001处的字节,它将来自缓存,避免了昂贵的 RAM 访问。

预取的第二个方面进一步推进了这一点 - 当读取地址处 RAM 的内容时,处理器不仅读取该内存字,还读取更多。在 x86 系列处理器上,这介于 32 到 128 字节之间。这被称为缓存行大小 - 处理器总是以该大小的块写入和读取内存。当 CPU 硬件检测到内存以线性方式被访问时,它根据对随后可能被访问的地址的预测,将内存预取到一个缓存行中。

CPU 非常聪明,可以检测到正向和反向的规律访问模式,并且会有效地进行预取。您还可以使用特殊指令向处理器提供提示,使其根据程序员的指示进行数据预取。这些指令在大多数编译器中提供为内部函数,以避免使用内联汇编语言。当读取或写入不在缓存中的内存地址时,称为缓存未命中,这是一个非常昂贵的事件,应尽量避免。CPU 硬件会尽最大努力减少缓存未命中,但程序员可以分析和修改数据访问模式,以最大程度地减少缓存未命中。这里对缓存的描述是一个简化的模型,用于教学目的 - 实际上,CPU 具有用于指令和数据的 L1 缓存,多个缓存行,以及确保多个处理器可以保持其独立缓存同步的非常复杂的机制。

注意

关于缓存实现(以及关于内存子系统的大量其他信息)的全面描述可以在这篇著名的在线文章中找到:lwn.net/Articles/250967/

缓存对算法的影响

了解了缓存之后,我们现在可以理解为什么我们对向量与列表的第一个示例显示出了令人惊讶的结果 - 从计算机科学的角度来看,以下是真实的:

对于列表

  • 迭代到第 N 个位置的复杂度为 N 阶。

  • 插入或删除元素的复杂度为 1 阶。

对于数组(或向量)

  • 迭代到第 N 个位置的复杂度为 1 阶。

  • 在位置 N 插入或删除元素的复杂度与(S-N)成正比,其中 S 是数组的大小。

然而,对于现代架构,内存访问的成本非常高,但随后访问相邻地址的成本几乎为 0,因为它已经在缓存中。这意味着在std::list中非顺序地定位的元素上进行迭代很可能总是导致缓存未命中,从而导致性能下降。另一方面,由于数组或std::vector的元素总是相邻的,缓存和预取将大大减少将(S-N)个元素复制到新位置的总成本。因此,传统的对两种数据结构的分析声明列表更适合随机插入,虽然在技术上是正确的,但在现代 CPU 硬件的明显复杂的缓存行为下,实际上并不正确。当我们的程序受到数据约束时,算法复杂度的分析必须通过对所谓的数据局部性的理解来加以补充。

数据局部性可以简单地定义为刚刚访问的内存地址与先前访问的内存地址之间的平均距离。换句话说,跨越彼此相距很远的内存地址进行内存访问会严重减慢速度,因为更接近的地址的数据很可能已经被预取到缓存中。当数据已经存在于缓存中时,称为“热”;否则称为“冷”。利用缓存的代码称为缓存友好。另一方面,不友好的缓存代码会导致缓存行被浪费重新加载(称为缓存失效)。在本节的其余部分,我们将探讨如何编写缓存友好代码的策略。

针对缓存友好性进行优化

在过去,代码的优化涉及尝试最小化代码中的机器指令数量,使用更有效的指令,甚至重新排序指令以使流水线保持满状态。到目前为止,编译器执行了所有上述优化,大多数程序员无法做到这一点——尤其是考虑到编译器可以在数亿条指令的整个程序中执行这些优化。即使在今天,程序员的责任仍然是优化数据访问模式,以利用缓存。

任务非常简单——确保内存访问靠近之前访问的内存——但是实现这一点的方法可能需要大量的努力。

注意

著名的游戏程序员和代码优化大师 Terje Mathisen 在 90 年代声称:“所有编程都是缓存的练习。”今天,在 2019 年,这种说法在尝试编写快速代码的子领域中更加适用。

增加缓存友好性有一些基本的经验法则:

  • 栈始终是“热”的,因此我们应尽可能使用局部变量。

  • 动态分配的对象很少具有彼此的数据局部性——避免它们或使用预分配的对象池,使它们在内存中是连续的。

  • 基于指针的数据结构,如树——尤其是列表——由堆上分配的多个节点组成,非常不利于缓存。

  • OO 代码中虚函数的运行时分派会使指令缓存失效——在性能关键代码中避免动态分派。

在下一节中,我们将探讨堆分配的成本。

练习 12:探索堆分配的成本

在这个练习中,我们将检查动态分配内存的性能影响,并检查堆内存如何影响代码的性能。执行以下步骤完成这个练习:

  1. 创建一个名为Snippet11.cpp的文件。

  2. 添加以下代码以包含必要的库:

#include <string>
#include <iostream>
#include <random>
#include "Timer.h"
using std::string;
using std::cerr;
using std::endl;
  1. 声明一个常量变量 N 和一个名为 fruits 的字符数组。为它们赋值:
const int N = 10'000'000;
const char* fruits[] = 
  {"apple", "banana", "cherry", "durian", "guava", "jackfruit", "kumquat", "mango", "orange", "pear"};
  1. 创建一个名为fun1()的函数,只是循环遍历 fruits 中的每个字符串,将其复制到一个字符串中,并计算该字符串的字符总和:
uint64_t fun1()
{
  TIME_IT;
  uint64_t sum = 0;
  string s1;
  for(uint64_t i = 0; i < N; ++i)
  {
    s1 = fruits[i % 10];
    for(int k = 0; k < s1.size(); ++k) sum += s1[k];
  }
  return sum;
}
  1. 创建另一个名为sum2()的函数,该函数使用本地声明的字符数组而不是字符串和循环进行复制:
uint64_t fun2()
{
  TIME_IT;
  uint64_t sum = 0;
  char s1[32];

  for(uint64_t i = 0; i < N; ++i)
  {
    char *ps1 = s1;
    const char *p1 = fruits[i % 10];
    do { *ps1++ = *p1; } while(*p1++);
    for(ps1 = s1; *ps1; ++ps1) sum += *ps1;
  }
  return sum;
}
  1. main()函数内写入以下代码:
int main()
{
  for(int i = 0; i < 10; ++i)
  {
    volatile uint64_t asum1 = fun1();
    volatile uint64_t asum2 = fun2();  
  }
  Timer::dump();
}
  1. 保存文件并打开终端。编译并运行程序:
$ g++ -O3 Snippet11.cpp Timer.cpp
$ ./a.out

上述命令生成以下输出:

图 8.37:在 Snippet11.cpp 中显示堆分配对时间的影响的代码输出

图 8.37:在 Snippet11.cpp 中显示堆分配对时间的影响的代码输出

从上述输出中可以看出,fun2()几乎比fun1()快一倍。

  1. 现在,使用perf命令进行性能分析:
$ perf record ./a.out

上述命令生成以下输出:

图 8.38:使用 perf 命令对 Snippet11.cpp 中的代码进行性能分析的输出

图 8.38:使用 perf 命令对 Snippet11.cpp 中的代码进行性能分析的输出
  1. 现在,我们可以使用以下代码检查性能报告:
$ perf report

我们收到以下输出:

图 8.39:Snippet11.cpp 中的代码的 perf 命令的时间报告输出

在上述输出中,请注意约33%的执行时间被std::string构造函数,strlen()memmove()占用。所有这些都与fun1()中使用的std::string相关。特别是堆分配是最慢的操作。

数组结构模式

在许多程序中,我们经常使用相同类型的对象数组 - 这些可以表示数据库中的记录,游戏中的实体等。一个常见的模式是遍历一个大型结构数组并对一些字段执行操作。即使结构体在内存中是连续的,如果我们只访问少数字段,较大的结构体大小将使缓存效果不佳。

处理器可能会将多个结构预取到缓存中,但程序只访问其中的一小部分数据。由于它没有使用每个结构体的每个字段,大部分缓存数据被丢弃。为了避免这种情况,可以使用另一种数据布局方式 - 不使用结构体数组(AoS)模式,而是使用数组结构(SoA)模式。在下一节中,我们将解决一个练习,其中我们将研究使用 SoA 模式与 AoS 模式的性能优势。

练习 13:使用结构数组模式

在这个练习中,我们将研究使用 SoA 与 AoS 模式的性能优势。执行以下步骤完成这个练习:

  1. 创建一个名为Snippet12.cpp的文件。

  2. 包括必要的库,以及Timer.h头文件。初始化一个随机数生成器,并创建一个从 1 到 N-1 的分布范围。创建一个名为 N 的常量整数变量,并将其初始化为 100,000,000。添加以下代码来实现这一点:

#include <vector>
#include <list>
#include <algorithm>
#include <string>
#include <iostream>
#include <random>
#include "Timer.h"
using std::vector;
using std::list;
using std::cerr;
using std::endl;
const int N = 100'000'000;
std::random_device dev;
std::mt19937 rng(dev());
std::uniform_int_distribution<std::mt19937::result_type> dist(1,N-1);
  1. 写两种不同的数据表示方式 - 结构体数组和数组结构。使用六个uint64_t字段,以便我们可以模拟一个更具代表性的大型结构,这更符合实际程序的情况:
struct Data1
{
  uint64_t field1;
  uint64_t field2;
  uint64_t field3;
  uint64_t field4;
  uint64_t field5;
  uint64_t field6;
};
struct Data2
{
  vector<uint64_t> field1;
  vector<uint64_t> field2;
  vector<uint64_t> field3;
  vector<uint64_t> field4;
  vector<uint64_t> field5;
  vector<uint64_t> field6;
};
struct Sum
{
  uint64_t field1;
  uint64_t field2;
  uint64_t field3;
  Sum(): field1(), field2(), field3() {}
};
  1. 定义两个函数,即sumAOSsumSOA,对前面两种数据结构中的field1field2field3的值进行求和。编写以下代码来实现这一点:
Sum sumAOS(vector<Data1> &aos)
{
  TIME_IT;
  Sum ret;
  for(int i = 0; i < N; ++i)
  {
    ret.field1 += aos[i].field1;
    ret.field2 += aos[i].field2;
    ret.field3 += aos[i].field3;
  }
  return ret;
}
Sum sumSOA(Data2 &soa)
{
  TIME_IT;
  Sum ret;
  for(int i = 0; i < N; ++i) 
  {
    ret.field1 += soa.field1[i];
    ret.field2 += soa.field2[i];
    ret.field3 += soa.field3[i];
  }
  return ret;
}
  1. main函数中编写以下代码:
int main()
{
   vector<Data1> arrOfStruct;
   Data2 structOfArr;

   // Reserve space
   structOfArr.field1.reserve(N);
   structOfArr.field2.reserve(N);
   structOfArr.field3.reserve(N);
   arrOfStruct.reserve(N);
   // Fill random values
   for(int i = 0; i < N; ++i)
   {
     Data1 temp;
     temp.field1 = dist(rng);
     temp.field2  = dist(rng);
     temp.field3 = dist(rng);
     arrOfStruct.push_back(temp);
     structOfArr.field1.push_back(temp.field1);
     structOfArr.field2.push_back(temp.field2);
     structOfArr.field3.push_back(temp.field3);
   }
  Sum s1 = sumAOS(arrOfStruct);
  Sum s2 = sumSOA(structOfArr);
  Timer::dump();
}
  1. 保存程序并打开终端。运行程序以计时,添加以下命令:
$ g++ -O3 Snippet12.cpp Timer.cpp
$ ./a.out

上述代码生成以下输出:

图 8.40:Snippet12.cpp 中代码的输出对比时间 AOS 和 SOA 模式

图 8.40:Snippet12.cpp 中代码的输出对比 AOS 和 SOA 模式的时间

数组结构的方法比结构数组的方法快两倍。考虑到结构体中向量的地址可能相距甚远,我们可能会想知道为什么在 SoA 情况下缓存行为更好。原因是缓存的设计方式 - 而不是将缓存视为单个的单块,它被分成多个行,正如我们之前讨论过的。当访问内存地址时,32 位或 64 位的地址被转换为几位的“标签”,并且与该标签相关联的缓存行被使用。非常接近的内存地址将获得相同的标签并达到相同的缓存行。如果访问高度不同的地址,它将达到不同的缓存行。这种基于行的缓存设计对我们的测试程序的影响是,就好像我们为每个向量有单独的独立缓存一样。

对于缓存行的前述解释是非常简化的,但缓存行的基本概念适用。对于这种数组模式的结构,代码可读性可能会稍微差一些,但考虑到性能的提高,这是非常值得的。当结构的大小变大时,这种特定的优化变得更加有效。此外,请记住,如果字段的大小不同,填充结构可能会使其大小大大增加。我们已经探讨了内存延迟的性能影响,并学习了一些帮助处理器缓存有效的方法。在编写性能关键的程序时,我们应该牢记缓存效果。有时,最好一开始就从更加缓存友好的架构开始。与往常一样,我们在尝试对数据结构进行根本性更改之前,应该先测量代码的性能。优化应该集中在程序中耗时最长的部分,而不是每个部分。

算法优化

算法优化的最简单形式是寻找执行您的任务的库-最受欢迎的库经过高度优化和良好编写。例如,Boost库提供了许多有用的库,可以在许多项目中派上用场,比如Boost.GeometryBoost.GraphBoost.IntervalBoost.Multiprecision等。使用专业编写的库比尝试自己创建它们要容易和明智得多。例如,Boost.Graph实现了十几种处理拓扑图的算法,每个算法都经过高度优化。

许多计算可以简化为一系列组合在一起的标准算法-如果正确完成,这些算法可以产生极其高效的代码-甚至可以由编译器并行化以利用多个核心或 SIMD。在本节的其余部分,我们将采用一个单一程序,并尝试以各种方式对其进行优化-这将是一个具有以下规格的词频统计程序:

  • 为了分离磁盘 I/O 所花费的时间,我们将在处理之前将整个文件读入内存。

  • 我们将忽略 Unicode 支持,并假设 ASCII 中的英文文本。

  • 我们将使用在线提供的大型公共领域文学文本作为测试数据。

练习 14:优化词频统计程序

在这个冗长的练习中,我们将使用各种优化技术来优化程序。我们将对实际程序进行渐进优化。我们将使用的测试数据包括书名为《双城记》的书,已经被合并在一起 512 次。

注意

此练习中使用的数据集在此处可用:github.com/TrainingByPackt/Advanced-CPlusPlus/blob/master/Lesson8/Exercise14/data.7z。您需要提取此 7zip 存档,并将生成的名为 data.txt 的文件复制到您处理此练习的文件夹中。

执行以下步骤完成此练习:

  1. 编写读取文件的基本样板代码(完整代码可以在main()中找到,以获取整体执行时间。

请注意,push_back在末尾添加了一个空格-这确保数据以空格结尾,简化了我们使用的算法。

  1. 编写一个基本的词频统计函数。逻辑非常简单-对于字符串中的每个字符,如果字符不是空格且后面是空格,则这是一个单词的结尾,应该计数。由于我们的样板代码在末尾添加了一个空格,任何最终单词都将被计数。此函数在Snippet13.cpp中定义:
int wordCount(const std::string &s)
{
  int count = 0;
  for(int i = 0, j = 1; i < s.size() - 1; ++i, ++j)
  {
    if(!isspace(s[i]) && isspace(s[j]))
    {
      ++count;
    }
  }
  return count;
}
  1. 让我们编译、运行,并对性能有一个概念。我们将通过比较我们代码的结果与标准wc程序提供的结果来验证它是否正确:
$ g++ -O3 Snippet13.cpp SnippetWC.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.41:Snippet13.cpp 中代码的输出,带有基线单词计数实现

图 8.41:Snippet13.cpp 中代码的输出,带有基线单词计数实现

让我们计时 wc 程序:

$ time wc -w data.txt

我们收到以下输出:

图 8.42:计时 wc 程序的输出

图 8.42:计时 wc 程序的输出

wc程序显示相同的单词计数,即71108096,所以我们知道我们的代码是正确的。我们的代码大约花费了3.6 秒,包括读取文件,比 wc 慢得多。

  1. 我们优化的第一个策略是看看是否有更好的方法来实现isspace()。我们可以使用一个查找表来判断一个字符是否为空格(可以在Snippet14.cpp中找到代码):
int wordCount(const std::string &s)
{
  // Create a lookup table
  bool isSpace[256];
  for(int i = 0; i < 256; ++i)
  {
    isSpace[i] = isspace((unsigned char)i);
  }
  int count = 0;
  int len = s.size() - 1;
  for(int i = 0, j = 1; i < len; ++i, ++j)
  {
    count += !isSpace[s[i]] & isSpace[s[j]];
  }
  return count;
}

请记住,C/C++中的布尔变量取整数值 0 或 1,因此我们可以直接写如下内容:

!isSpace[s[i]] & isSpace[s[j]]

这意味着我们不必写这个:

(!isSpace[s[i]] && isSpace[s[j]]) ? 1 : 0

直接使用布尔值作为数字有时可能会导致更快的代码,因为我们避免了条件逻辑运算符&&和||,这可能会导致分支指令。

  1. 现在编译并测试性能:
$ g++ -O3 Snippet14.cpp SnippetWC.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.43:Snippet14.cpp 中代码的输出

图 8.43:Snippet14.cpp 中代码的输出

我们通过使用查找表的简单原则,为单词计数代码实现了 8 倍的加速。我们能做得比这更好吗?是的 - 我们可以进一步应用查找表的概念 - 对于每一对字符,有四种可能性,应该导致相应的动作:

[空格 空格]:无操作,[非空格 空格]:将计数加 1,[空格 非空格]:无操作,[非空格 非空格]:无操作

因此,我们可以制作一个包含65536个条目(256 * 256)的表,以涵盖所有可能的字符对。

  1. 编写以下代码创建表:
// Create a lookup table for every pair of chars
bool table[65536];
for(int i = 0; i < 256; ++i)
{
  for(int j = 0; j < 256; ++j)
  {
    int idx = j + i * 256;
    table[idx] = !isspace(j) && isspace(i);
  }
}

计算单词的循环变成了以下形式(完整代码可以在memcpy()中找到。编译器足够聪明,可以使用 CPU 内存访问指令,而不是实际调用memcpy()来处理 2 个字节。我们最终得到的循环不包含条件语句,这应该会使它更快。请记住,X86 架构是小端的 - 因此从字符数组中读取的 16 位值将具有第一个字符作为其 LSB,第二个字符作为 MSB。

  1. 现在,计时我们写的代码:
$ g++ -O3 Snippet15.cpp SnippetWC.cpp Timer.cpp
$ ./a.out data.txt

图 8.44:Snippet15.cpp 中代码的输出

图 8.44:Snippet15.cpp 中代码的输出

这个更大的查找表使wordCount()的速度提高了 1.8 倍。让我们退一步,从另一个角度来看待这个问题,这样我们就可以有效地使用现有的标准库。这样做的好处有两个 - 首先,代码不太容易出错,其次,我们可以利用一些编译器提供的并行化功能。

让我们重写使用查找表来进行isspace的程序版本。如果我们看一下计算单词的主循环,我们正在取 2 个字符,并根据一些逻辑,将 1 或 0 累积到count变量中。这是许多代码中常见的模式:

X OP (a[0] OP2 b[0]) OP (a[1] OP2 b[1]) OP (a[2] OP2 b[2]) ... OP (a[N] OP2 b[N])  

这里,ab是大小为N的数组,X是初始值,OPOP2是运算符。有一个标准算法封装了这种模式,叫做std::inner_product - 它接受两个序列,在每对元素之间应用一个运算符(OP2),并在这些元素之间应用另一个运算符(OP),从初始值 X 开始。

  1. 我们可以将函数写成如下形式(完整代码可以在inner_product()调用中找到,它对每个s[n]s[n+1]应用isWordEnd() lambda,并在这些结果之间应用标准的加法函数。实际上,当s[n]s[n+1]在一个单词结束时,我们将总数加 1。

注意

尽管这看起来像一系列嵌套的函数调用,编译器会将所有内容内联,没有开销。

  1. 编译和计时执行这个版本:
$ g++ -O3 Snippet16.cpp SnippetWC.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.45:Snippet16.cpp 代码的输出

图 8.45:Snippet16.cpp 代码的输出

令人惊讶的是,这段代码比我们最初的循环版本Snippet14.cpp稍快。

  1. 我们能否使相同的代码适应大型查找表?的确,我们可以-新函数看起来像这样(完整代码可以在memcpy()中找到)将两个连续的字节转换为一个字,我们使用按位OR运算符将它们组合起来。

  2. 编译和计时代码:

$ g++ -O3 Snippet17.cpp SnippetWC.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.46:Snippet17.cpp 代码的输出

图 8.46:Snippet17.cpp 代码的输出

这段代码不像我们在short中的基于循环的版本那样快,以获取索引,它不需要计算,但在这里,我们使用按位操作将 2 个字节读入short

  1. 现在我们有了大部分工作由标准库函数完成的代码,我们现在可以免费获得自动并行化-编译和测试如下:
$ g++ -O3 -fopenmp -D_GLIBCXX_PARALLEL Snippet17.cpp SnippetWC.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.47:使用并行化标准库的 Snippet17.cpp 代码的输出

图 8.47:使用并行化标准库的 Snippet17.cpp 代码的输出

显然,它不能完全并行化,所以我们在速度方面只获得了大约 2.5 倍的改进,但我们在不对代码做任何修改的情况下获得了这一点。我们是否可以以同样的方式使基于循环的代码可并行化?理论上是的-我们可以手动使用OpenMP指令来实现这一点;然而,这将需要对代码进行更改并且需要知道如何使用 OpenMP。Snippet16.cpp中的版本呢?

$ g++ -O3 -fopenmp -D_GLIBCXX_PARALLEL Snippet16.cpp SnippetWC.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.48:使用并行化标准库的 Snippet16.cpp 代码的输出

图 8.48:使用并行化标准库的 Snippet16.cpp 代码的输出

这个版本也有类似的改进。我们完成了还是可以更快?著名的游戏程序员Michael Abrash创造了缩写TANSTATFC-它代表“没有最快的代码”。他的意思是,经过足够的努力,总是可以使代码更快。这似乎是不可能的,但一次又一次,人们发现了更快和更快的执行计算的方法-我们的代码也不例外,我们还可以再走一点。我们可以进行优化的权衡之一是使代码不那么通用-我们已经对我们的代码加了一些限制-例如,我们只处理ASCII英文文本。通过对输入数据增加一些限制,我们可以做得更好。假设文件中没有不可打印的字符。这对我们的输入数据是一个合理的假设。如果我们假设这一点,那么我们可以简化检测空格的条件-因为所有的空白字符都大于或等于 ASCII 32,我们可以避免查找表本身。

  1. 让我们基于我们之前的想法实现代码(完整代码可以在Snippet18.cpp中找到):
int wordCount(const std::string &s)
{
  auto isWordEnd = & 
  {
    return a > 32 & b < 33; 
  };
  return std::inner_product(s.begin(), s.end()-1, s.begin()+1, 0, std::plus<int>(), isWordEnd);
}
  1. 编译并运行程序:
$ g++ -O3 Snippet18.cpp SnippetWC.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.49:使用简化逻辑检测空格的 Snippet18.cpp 代码的输出

图 8.49:使用简化逻辑检测空格的 Snippet18.cpp 代码的输出

这个版本比并行化的版本快两倍,而且只是几行代码。使用并行化会使它变得更好吗?

$ g++ -O3 -fopenmp -D_GLIBCXX_PARALLEL Snippet18.cpp SnippetWC.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.50:使用并行化标准库的 Snippet18.cpp 代码的输出

图 8.50:使用并行化标准库的 Snippet18.cpp 代码的输出

不幸的是,情况并非如此-实际上更慢了。管理多个线程和线程争用的开销有时比多线程代码的好处更昂贵。此时,我们可以看到文件读取代码占用了大部分时间-我们能对此做些什么吗?

  1. 让我们修改main()函数以计时其各个部分(完整代码可以在SnippetWC2.cpp中找到):
    {
      Timer t("File read");
      buf << ifs.rdbuf(); 
    }
    {
      Timer t("String copy");
      sContent = buf.str();
    }
    {
      Timer t("String push");
      sContent.push_back(' ');
    }
    int wc;
    {
      Timer t("Word count");
      wc = wordCount(sContent);
    }
  1. 编译并运行上述代码:
$ g++ -O3 Snippet18.cpp SnippetWC2.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.51:在 Snippet18.cpp 中对所有操作进行计时的输出

图 8.51:在 Snippet18.cpp 中对所有操作进行计时的输出

大部分时间都用在了push_back()和复制字符串上。由于字符串的大小正好等于文件的大小,push_back()最终会为字符串分配一个新的缓冲区并复制内容。我们如何消除这个push_back()调用呢?我们在末尾添加了一个空格,以便能够一致地计算最后一个单词(如果有的话),因为我们的算法计算的是单词的结尾。有三种方法可以避免这种情况:计算单词的开始而不是结尾;单独计算最后一个单词(如果有的话);使用c_str()函数,这样我们就有了一个NUL字符在末尾。现在让我们依次尝试这些方法。

  1. 首先,编写不使用push_back的主函数(完整代码可以在SnippetWC3.cpp中找到):
{
  Timer t("File read");
  buf << ifs.rdbuf(); 
} 
{
  Timer t("String copy");
  sContent = buf.str();
}
int wc;
{
  Timer t("Word count");
  wc = wordCount(sContent);
}
  1. 通过将 wordCount()中的代码更改为将isWordEnd()重命名为isWordStart()并反转逻辑来更改代码。如果当前字符是空格且后续字符不是空格,则将单词视为开始。此外,如果字符串以非空格开头,则额外计算一个单词(完整代码可以在Snippet19.cpp中找到):
int wordCount(const std::string &s)
{
  auto isWordStart = & 
  {
    return a < 33 & b > 32; 
  };
  // Count the first word if any
  int count = s[0] > 32;
  // count the remaining
  return std::inner_product(s.begin(), s.end()-1, s.begin()+1, count, std::plus<int>(), isWordStart);
}
  1. 现在,编写第二种替代方案-计算最后一个单词(如果有的话)。代码与Snippet18.cpp版本几乎相同,只是我们检查最后一个单词(完整代码可以在Snippet20.cpp中找到):
int count = std::inner_product(s.begin(), s.end()-1, s.begin()+1, 0, std::plus<int>(), isWordEnd);
// count the last word if any
if(s.back() > 32) 
{
  ++count;
}
return count;
  1. 编写使用c_str()的第三个版本-我们只需要改变inner_product()的参数(完整代码可以在c_str()末尾有一个NUL,它的工作方式与以前相同。

  2. 编译和计时所有三个版本:

$ g++ -O3 Snippet19.cpp SnippetWC3.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.52:在 Snippet19.cpp 中代码的输出,该代码计算的是单词的开头而不是结尾

图 8.52:在 Snippet19.cpp 中代码的输出,该代码计算的是单词的开头而不是结尾

现在输入以下命令:

$ g++ -O3 Snippet20.cpp SnippetWC3.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.53:在 Snippet20.cpp 中代码的输出

图 8.53:在 Snippet20.cpp 中代码的输出

现在输入以下命令:

$ g++ -O3 Snippet21.cpp SnippetWC3.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.54:在 Snippet21.cpp 中代码的输出

图 8.54:在 Snippet21.cpp 中代码的输出

所有三个运行时间大致相同-几毫秒的微小差异可以忽略不计。

  1. 现在,我们可以解决字符串复制所花费的时间-我们将直接将文件读入字符串缓冲区,而不是使用std::stringstream(完整代码可以在SnippetWC4.cpp中找到):
string sContent;
{
  Timer t("String Alloc");
  // Seek to end and reserve memory
  ifs.seekg(0, std::ios::end);   
  sContent.resize(ifs.tellg());
}
{
  Timer t("File read");
  // Seek back to start and read data
  ifs.seekg(0, std::ios::beg);
  ifs.read(&sContent[0], sContent.size());
}
int wc;
{
  Timer t("Word count");
  wc = wordCount(sContent);
}  
  1. 编译并运行此版本:
$ g++ -O3 Snippet21.cpp SnippetWC4.cpp Timer.cpp

我们收到以下输出:

图 8.55:在 SnippetWC4.cpp 中更改文件加载代码后的输出

图 8.55:在 SnippetWC4.cpp 中更改文件加载代码后的输出

我们现在将文件读取代码的时间从大约 1000 毫秒减少到 250 毫秒-提高了 4 倍。单词计数代码从大约2500ms开始减少到大约 60 毫秒-提高了 40 倍。整个程序的总体性能提高了 3.6 倍。我们仍然可以问这是否是极限-确实,TANSTATFC 仍然适用,还有一些其他事情可以做:不要将数据读入std::string,而是使用内存映射 I/O来获取直接指向文件的缓冲区。这可能比分配和读取更快-它将需要更改单词计数代码以接受const char*和长度,或者std::string_view。使用不同的、更快的分配器来分配内存。使用-march=native标志为本机 CPU 进行编译。然而,似乎我们不太可能从中获得非常大的性能提升,因为这些优化与单词计数算法本身无关。另一个最后的尝试可能是放弃 C++构造,并使用编译器内置函数编写内联 SIMD 代码(这些函数是编译器直接转换为单个汇编指令的函数)。执行此操作所需的知识超出了本入门材料的范围。

  1. 不过,对于好奇的学生,提供了AVX2(256 位 SIMD)版本的wordCount()(Snippet23.cpp)。这个版本需要输入字符串的长度是 32 的倍数,并且末尾有一个空格。这意味着主函数必须重新编写(SnippetWC5.cpp):
$ g++ -O3 -march=native Snippet22.cpp SnippetWC5.cpp Timer.cpp
$ ./a.out data.txt

我们收到以下输出:

图 8.56:使用 SIMD 内置函数的 Snippet22.cpp 代码的输出

图 8.56:使用 SIMD 内置函数的 Snippet22.cpp 代码的输出

请注意,我们需要使用-march=native标志,以便编译器使用 AVX SIMD 指令集。如果处理器不支持它,将导致编译错误。如果此可执行文件针对 AVX 目标进行编译,并在不支持这些指令的处理器上运行,则程序将以“非法指令”异常崩溃。似乎有一点小小的改进,但不显著-通常优化与汇编器或 SIMD 相关的工作量和学习曲线太高,无法证明其合理性,除非您的应用程序或行业有这些需求。SIMD 版本一次处理 32 字节-然而实际上几乎没有性能提升。实际上,如果您检查编译器资源管理器中常规 C++实现的生成的汇编代码,您将看到编译器本身已经使用了 SIMD-这只是表明编译器在使您的代码快速方面所做的努力。

另一个需要注意的是,我们的文件读取和内存分配现在占用了大部分时间-撇开内存分配不谈,我们可以得出结论,我们的代码已经变得I/O 限制而不是CPU 限制。这意味着无论我们如何快速编写代码,都将受到数据获取速度的限制。我们从一个非常简单的单词计数算法实现开始,增加了其复杂性和速度,最终能够回到一个非常简单的实现,最终成为最快的。算法的整体速度提高了 40 倍。我们使用了许多方法,从稍微重新排列代码到以不同方式重新构想问题,再到执行微优化。没有一种方法可以始终奏效,优化仍然是一种需要想象力和技巧,通常还需要横向思维的创造性努力。随着编译器变得越来越智能,要超越它变得越来越困难-然而,程序员是唯一真正理解代码意图的人,总是有提高代码速度的空间。

活动 1:优化拼写检查算法

在这个活动中,我们将尝试逐步优化一个程序。这个活动是关于一个简单的拼写检查器,它接受一个字典和一个文本文件,并打印出文本中不在字典中的单词列表。在7zip存档中提供了一个基本的程序框架,即activity1.7z

字典取自许多 Linux 发行版提供的 Linux 单词列表。文本文件与我们在上一个练习中使用的文件类似 - 它是我们在单词计数练习中使用的同一个大文件,去除了所有标点并转换为小写。

请注意,字典只是一个示例,因此不要假设所有有效单词都存在其中 - 输出中的许多单词很可能是拼写正确的单词。框架代码读取字典和文本文件,并调用拼写检查代码(您将编写)进行检查。之后,它将比较结果输出与out.txt的内容,并打印程序是否按预期工作。执行拼写检查的函数返回一个不在字典中的单词的索引向量。由于我们只关注拼写检查算法,因此只计时该代码。不考虑读取文件和比较输出所花费的时间。您将开发这个程序的连续更快的版本 - 参考实现在参考文件夹中提供为Speller1.cppSpeller2.cpp等。

在每个步骤中,您只会得到一些提示,告诉您要做哪些更改以使其更快 - 只能修改getMisspelt()函数中的代码,而不是其他任何代码。学生可以自由地实现代码,只要它能产生正确的结果,并且main()中的代码没有改变。

注意

优化是一个创造性和非确定性的过程 - 不能保证学生能够编写与参考实现相同的代码,也不总是可能的。如果您编写的代码性能不如参考实现,这不应该让人感到惊讶。事实上,甚至可能您的代码比参考实现更快。

执行以下步骤来实现这个活动:

复制 Speller.cpp 并将其命名为 Speller1.cpp,然后实现getMisspelt()函数的代码。使用std::set及其count()方法来实现。

  1. 编写程序的下一个版本,命名为 Speller2.cpp,然后像以前一样编译并计时。尝试使用std::unordered_set而不是std::set。使用这种实现应该可以获得大约 2 倍的加速。

在最终版本Speller3.cpp中,使用Bloom filter数据结构来实现拼写检查算法。尝试不同数量的哈希函数和 Bloom 过滤器的大小,看看哪种效果最好。

  1. 对于前面的每个步骤,编译程序并按如下方式运行(根据需要更改输入文件名):
$ g++ -O3 Speller1.cpp Timer.cpp
$ ./a.out

注意

您不应该期望计时与此处显示的完全相同,但如果您正确实现了代码,速度上的相对改进应该接近我们在这里看到的情况。

对于每个步骤执行前面的命令后,将生成以下输出。输出将显示代码的时间和一个初始消息,如果您的输出是正确的。以下是第 1 步的输出:

图 8.57:第 1 步代码的示例输出

图 8.57:第 1 步代码的示例输出

以下是第 2 步的输出:

图 8.58:第 2 步代码的示例输出

图 8.58:第 2 步代码的示例输出

以下是第 3 步的输出:

图 8.59:第 3 步代码的示例输出

图 8.59:第 3 步代码的示例输出

注意

此活动的解决方案可在第 725 页找到。

总结

我们在本章涵盖了许多复杂的内容。优化代码是任何现代 C开发人员都必须掌握的一项困难但必要的技能。机器学习、超逼真的游戏、大数据分析和节能计算的需求使得这是一个非常重要的领域,任何 C专业人士都需要了解。我们了解到性能优化的过程分为两个阶段。

首先,优化始于正确的性能测量策略,测试条件要反映真实世界的数据和使用模式。我们学会了如何通过各种方法来测量性能 - 研究汇编代码、手动计时、源代码插装和使用运行时分析器。一旦我们有了准确的测量数据,我们就可以真正理解我们程序中哪些部分实际上很慢,并集中精力在那里以获得最大的改进。第二阶段涉及实际修改程序 - 我们学习了几种策略,从使用最佳的编译器选项,使用并行化特性,以及使用性能分析数据来帮助编译器,然后进行一些简单的代码转换,产生小但有用的性能提升而不需要进行重大的代码更改。然后,我们学习了如何通过构造循环和条件语句的方式来改善性能,使代码更友好地进行分支预测。

然后,我们了解了缓存对性能的显著和重要影响,并研究了一些技术,比如 SOA 模式,以使我们的代码充分利用现代 CPU 中的缓存。最后,我们将所有这些东西结合起来,以一个实际的单词计数程序和简单的拼写检查器作为例子,来实践我们所学到的知识。本章涵盖了许多其他高级技术和理论,需要在本章材料之上进行学习,但我们在这里所涵盖的内容应该为任何学生打下坚实的未来学习基础。

通过这些章节的学习,你已经探索了许多与使用高级 C相关的主题。在最初的几章中,你学会了如何编写可移植的软件,利用模板来充分利用类型系统,并有效地使用指针和继承。然后你探索了 C标准库,包括流和并发性,这些是构建大型实际应用程序的必要工具。在最后的部分,你学会了如何测试和调试你的程序,并优化你的代码以实现高效运行。在广泛使用的编程语言中,C++也许是最复杂的,同时也是最具表现力的。这本书只是一个开始,它会为你提供一个坚实的平台,以便继续你的学习。

附录

关于

本节旨在帮助学生执行本书中的活动。它包括详细的步骤,学生需要执行这些步骤以实现活动的目标。

第一章 - 可移植 C++软件的解剖

活动 1:向项目添加新的源文件-头文件对

在这个活动中,我们将创建一个包含名为sum的新函数的新源文件-头文件对。它接受两个参数并返回它们的和。这个文件对将被添加到现有项目中。按照以下步骤来实现这个活动:

  1. 首先,打开 Eclipse IDE,并打开我们在练习 3中创建的现有项目,向 CMake 和 Eclipse CDT 添加新源文件。分别右键单击.cpp.h文件,或使用新类向导,然后删除类代码。使用新类向导很方便,因为它还会创建有用的样板代码。

  2. 选择SumFunc,然后点击完成按钮。

  3. 接下来,编辑SumFunc.h文件,使其看起来像以下代码:

#ifndef SRC_SUMFUNC_H_
#define SRC_SUMFUNC_H_
int sum(int a, int b);
#endif /* SRC_SUMFUNC_H_ */

请注意,我们实际上将删除类并提供一个单一函数。我们本可以分别创建这两个文件。但是,add class函数会同时创建它们并添加一些我们将利用的样板代码。在这里,我们的文件以include保护开始和结束,这是一种常见的策略,用于防止双重包含问题。我们有我们函数的前向声明,这样其他文件在包含这个头文件后就可以调用这个函数。

  1. 接下来,编辑SumFunc.cpp文件,如下所示:
#include "SumFunc.h"
#include <iostream>
int sum(int a, int b) {
  return a + b;
}

在这个文件中,我们包括头文件并提供我们函数的主体,它会添加并返回给定的两个整数。

  1. 编辑CMakeFiles.txt文件,使其add_executable部分反映以下代码:
add_executable(CxxTemplate
  src/CxxTemplate.cpp  
  src/ANewClass.cpp
  src/SumFunc.cpp
)

在这里,我们将src/SumFunc.cpp文件添加到可执行源文件列表中,以便将其链接到可执行文件中。

  1. CxxTemplate.cpp中进行以下更改:
#include "CxxTemplate.h"
#include "ANewClass.h"
#include "SumFunc.h" //add this line
...
CxxApplication::CxxApplication( int argc, char *argv[] ) {
  std::cout << "Hello CMake." << std::endl;
  ANewClass anew;
  anew.run();
  std::cout << sum(3, 4) << std::endl; // add this line
}

注意

这个文件的完整代码可以在这里找到:github.com/TrainingByPackt/Advanced-CPlusPlus/blob/master/Lesson1/Activity01/src/CxxTemplate.cpp

在这里,我们添加了一行,其中我们调用sum函数,传入34,并将结果打印到控制台。

  1. 构建和运行项目(项目 | 构建全部 | 运行 | 运行)。您看到的输出应该如下所示:

图 1.57:输出

图 1.57:输出

通过这个活动,您练习了向项目添加新的源文件-头文件对。这些文件对在 C++开发中是非常常见的模式。它们可以承载全局函数,比如我们在这个活动中所做的那样。更常见的是,它们承载类及其定义。在开发过程中,您将向应用程序添加更多的源文件-头文件对。因此,习惯于添加它们并不拖延是很重要的,否则会导致难以维护和测试的大型单片文件。

活动 2:添加新类及其测试

在这个活动中,我们将添加一个模拟1D线性运动的新类。该类将具有positionvelocity的双字段。它还将有一个advanceTimeBy()方法,该方法接收一个双dt参数,根据velocity的值修改position。对于双值,请使用EXPECT_DOUBLE_EQ而不是EXPECT_EQ。在这个活动中,我们将向项目添加一个新类及其测试。按照以下步骤执行这个活动:

  1. 打开我们现有的项目的 Eclipse IDE。要创建一个新类,右键单击LinearMotion1D,然后创建类。

  2. 打开我们在上一步中创建的LinearMotion1D.h文件。将positionvelocitydouble字段添加到其中。还要添加对advanceTimeBy方法的前向引用,该方法以double dt变量作为参数。构造函数和析构函数已经在类中。以下是在LinearMotion1D.h中进行这些更改的最终结果:

#ifndef SRC_LINEARMOTION1D_H_
#define SRC_LINEARMOTION1D_H_
class LinearMotion1D {
public:
  double position;
  double velocity;
  void advanceTimeBy(double dt);
  LinearMotion1D();
  virtual ~LinearMotion1D();
};
#endif /* SRC_LINEARMOTION1D_H_ */
  1. 现在打开LinearMotion1D.cpp,并为advanceTimeBy方法添加实现。我们的velocity是类中的一个字段,时间差是这个方法的一个参数。位置的变化等于速度乘以时间变化,所以我们计算结果并将其添加到位置变量中。我们还使用现有的构造函数代码将positionvelocity初始化为 0。以下是在LinearMotion1D.cpp中进行这些更改的最终结果:
#include "LinearMotion1D.h"
void LinearMotion1D::advanceTimeBy(double dt) {
  position += velocity * dt;
}
LinearMotion1D::LinearMotion1D() {
  position = 0;
  velocity = 0;
}
LinearMotion1D::~LinearMotion1D() {
}
  1. 为这个类创建一个测试。右键单击LinearMotion1DTest.cpp,并创建它。

  2. 现在打开LinearMotion1DTest.cpp。为两个不同方向的运动创建两个测试,左和右。对于每一个,创建一个LinearMotion1D对象,初始化其位置和速度,并调用advanceTimeBy来实际进行运动。然后,检查它是否移动到我们期望的相同位置。以下是在LinearMotion1DTest.cpp中进行这些更改的最终结果:

#include "gtest/gtest.h"
#include "../src/LinearMotion1D.h"
namespace {
class LinearMotion1DTest: public ::testing::Test {};
TEST_F(LinearMotion1DTest, CanMoveRight) {
  LinearMotion1D l;
  l.position = 10;
  l.velocity = 2;
  l.advanceTimeBy(3);
  EXPECT_DOUBLE_EQ(16, l.position);
}
TEST_F(LinearMotion1DTest, CanMoveLeft) {
  LinearMotion1D l;
  l.position = 10;
  l.velocity = -2;
  l.advanceTimeBy(3);
  EXPECT_DOUBLE_EQ(4, l.position);
}
}
  1. 现在修改我们的 CMake 配置文件,以便这些生成的源文件也被使用。对于LinearMotion1D类,将其.cpp文件添加为可执行文件,以便它与其他源文件一起编译和链接。以下是CMakeLists.txtadd_executable部分的变化:
add_executable(CxxTemplate
  src/CxxTemplate.cpp  
  src/ANewClass.cpp
  src/SumFunc.cpp
  src/LinearMotion1D.cpp # added
)
  1. 对于我们刚刚创建的测试,编辑LinearMotion1DTest.cpp,以及它使用的类的源文件LinearMotion1D.cpp。由于它们位于不同的目录中,以../src/LinearMotion1D.cpp的方式访问它们。以下是tests/CMakeLists.txtadd_executable部分的变化:
add_executable(tests 
  CanTest.cpp 
  SumFuncTest.cpp 
  ../src/SumFunc.cpp
  LinearMotion1DTest.cpp # added
  ../src/LinearMotion1D.cpp # added
)
  1. 构建项目并运行测试。我们将看到所有测试都成功:

图 1.58:所有测试都成功

图 1.58:所有测试都成功

通过这个活动,您完成了向项目添加新类及其测试的任务。您创建了一个模拟一维运动的类,并编写了单元测试以确保其正常工作。

活动 3:使代码更易读

在这个活动中,您将练习提高给定代码的质量。按照以下步骤执行此活动:

  1. 打开 Eclipse CDT,并在 Eclipse 中的源文件-头文件对中创建一个类。要做到这一点,请在项目资源管理器中右键单击src文件夹。从弹出菜单中选择新建 |

  2. SpeedCalculator作为头文件名,并单击完成。它将创建两个文件:SpeedCalculator.hSpeedCalculator.cpp。我们提供了上述两个文件的代码。添加为每个文件提供的代码。

  3. 现在我们需要将这个类添加到 CMake 项目中。打开项目根目录(src文件夹之外)中的CMakeLists.txt文件,并对文件进行以下更改:

  src/LinearMotion1D.cpp
  src/SpeedCalculator.cpp # add this line
)
  1. 现在选择文件 | 全部保存以保存所有文件,并通过选择项目 | 全部构建来构建项目。确保没有错误。

  2. 在我们的main()函数中创建SpeedCalculator类的实例,并调用其run()方法。通过添加以下代码打开main函数:

#include "SpeedCalculator.h"
int main( int argc, char *argv[] ) {
  cxxt::CxxApplication app( argc, argv );
  // add these three lines
  SpeedCalculator speedCalculator;
  speedCalculator.initializeData(10);
  speedCalculator.calculateAndPrintSpeedData();
  return 0;
}
  1. 要修复样式,只需使用源代码 | 格式化,并选择格式化整个文件。幸运的是,变量名没有任何问题。

  2. 简化代码以使其更易理解。calculateAndPrintSpeedData中的循环同时执行了几件事。它计算速度,找到了最小和最大值,检查我们是否越过了阈值,并存储了速度。如果速度是一个瞬态值,将其拆分意味着将其存储在某个地方以再次循环。但是,由于我们无论如何都将其存储在速度数组中,我们可以在其上再循环一次以提高代码的清晰度。以下是循环的更新版本:

for (int i = 0; i < numEntries; ++i) {
  double dt = timesInSeconds[i + 1] - timesInSeconds[i];
  assert(dt > 0);
  double speed = (positions[i + 1] - positions[i]) / dt;
  speeds[i] = speed;
}
for (int i = 0; i < numEntries; ++i) {
  double speed = speeds[i];
  if (maxSpeed < speed) {
    maxSpeed = speed;
  }
  if (minSpeed > speed) {
    minSpeed = speed;
  }
}
for (int i = 0; i < numEntries; ++i) {
  double speed = speeds[i];
  double dt = timesInSeconds[i + 1] - timesInSeconds[i];
  if (speed > speedLimit) {
    limitCrossDuration += dt;
  }
}

这在某种程度上是品味的问题,但是使大for循环更轻松有助于提高可读性。此外,它分离了任务并消除了它们在循环迭代期间相互影响的可能性。第一个循环创建并保存速度值。第二个循环找到最小和最大速度值。第三个循环确定超速限的时间。请注意,这是一个稍微不那么高效的实现;但是,它清楚地分离了采取的行动,我们不必在循环的长迭代中精神分离离散的行动。

  1. 运行前述代码并观察运行时的问题。虽然代码现在在风格上更好,但它存在几个错误,其中一些将创建运行时错误。首先,当我们运行应用程序时,在 Eclipse 中看到以下输出:图 1.59:Eclipse CDT 中的程序输出
图 1.59:Eclipse CDT 中的程序输出

注意0,这意味着我们的代码出了问题。

  1. 在控制台手动执行程序。这是我们得到的输出:图 1.60:带有错误的终端程序输出
图 1.60:带有错误的终端程序输出

不幸的是,我们在 Eclipse 中没有得到分段错误输出,因此您必须在 Eclipse 控制台视图中检查退出值。为了找到问题,我们将在下一步中使用调试器。

  1. 在 Eclipse 中按下调试工具栏按钮以启动调试模式下的应用程序。按下继续按钮以继续执行。它将在SpeedCalculator.cpp的第 40 行停止,就在错误即将发生时。如果您将鼠标悬停在speeds上,您会意识到它是一个无效的内存引用:图 1.61:无效的内存引用
图 1.61:无效的内存引用
  1. 经过进一步检查,我们意识到我们从未将speeds指针初始化为任何值。在我们的速度计算器函数中为它分配内存:
void SpeedCalculator::calculateAndPrintSpeedData() {
  speeds = new double[numEntries]; // add this line
  double maxSpeed = 0;
  1. 再次运行。我们得到以下输出:
Hello CMake.
Hello from ANewClass.
7
CxxTemplate: SpeedCalculator.cpp:38: void SpeedCalculator::calculateAndPrintSpeedData(): Assertion `dt > 0' failed.

请注意,这是一个断言,代码必须确保计算出的dt始终大于零。这是我们确信的事情,我们希望它在开发过程中帮助我们捕捉错误。断言语句在生产构建中被忽略,因此您可以在代码中自由地放置它们作为开发过程中捕捉错误的保障。特别是由于 C++缺乏与高级语言相比的许多安全检查,将assert语句放置在潜在不安全的代码中有助于捕捉错误。

  1. 让我们调查一下为什么我们的dt最终没有大于零。为此,我们再次启动调试器。它停在了一个奇怪的地方:图 1.62:调试器停在没有源代码的库
图 1.62:调试器停在没有源代码的库
  1. 实际错误是在库的深处引发的。但是,我们自己的函数仍然在堆栈上,我们可以调查它们在那个时候的状态。单击dt变为itimesInSeconds[10],这是数组的不存在的第十一个元素。进一步思考,我们意识到当我们有 10 个位置时,我们只能有 9 个位置对的减法,因此有 9 个速度。这是一个非常常见且难以捕捉的错误,因为 C++不强制您留在数组内。

  2. 重新设计我们的整个代码以解决这个问题:

void SpeedCalculator::calculateAndPrintSpeedData() {
  speeds = new double[numEntries - 1];
  double maxSpeed = 0;
...
  for (int i = 0; i < numEntries - 1; ++i) {
    double dt = timesInSeconds[i + 1] - timesInSeconds[i];
...
  for (int i = 0; i < numEntries - 1; ++i) {
    double speed = speeds[i];
....
  for (int i = 0; i < numEntries - 1; ++i) {
    double speed = speeds[i];

最后,我们的代码似乎可以在没有任何错误的情况下运行,如下面的输出所示:

图 1.65:程序输出

图 1.65:程序输出
  1. 但是,这里有一个奇怪的地方:0,无论你运行多少次。为了调查,让我们在以下行放一个断点:图 1.66:设置断点
图 1.66:设置断点
  1. 当我们调试代码时,我们看到它从未停在这里。这显然是错误的。经过进一步调查,我们意识到minSpeed最初是 0,而且每个速度值都大于它。我们应该将其初始化为非常大的值,或者我们需要将第一个元素作为最小值。在这里,我们选择第二种方法:
for (int i = 0; i < numEntries - 1; ++i) {
  double speed = speeds[i];
  if (i == 0 || maxSpeed < speed) { // changed
    maxSpeed = speed;
  }
  if (i == 0 || minSpeed > speed) { // changed
    minSpeed = speed;
  }
}

虽然maxSpeed不需要这样做,但保持一致是好的。现在当我们运行代码时,我们看到我们不再得到0作为我们的最小速度:

图 1.67:程序输出

图 1.67:程序输出
  1. 我们的代码似乎运行正常。但是,我们又犯了另一个错误。当我们调试代码时,我们发现我们的第一个元素不是零:图 1.68:变量的值
图 1.68:变量的值
  1. 指针解引用了数组中的第一个元素。我们在这里将元素初始化为零,但它们似乎不是零。这是更新后的代码:
  // add these two lines:
  timesInSeconds[0] = 0.0;
  positions[0] = 0.0;
  for (int i = 0; i < numEntries; ++i) {
    positions[i] = positions[i - 1] + (rand() % 500);
    timesInSeconds[i] = timesInSeconds[i - 1] + ((rand() % 10) + 1);
  }

当我们调查时,我们意识到我们从零开始循环并覆盖了第一个项目。此外,我们尝试访问positions[0 - 1],这是一个错误,也是 C++不强制执行数组边界的另一个例子。当我们让循环从 1 开始时,所有这些问题都消失了:

  timesInSeconds[0] = 0.0;
  positions[0] = 0.0;
  for (int i = 1; i < numEntries; ++i) {
    positions[i] = positions[i - 1] + (rand() % 500);
    timesInSeconds[i] = timesInSeconds[i - 1] + ((rand() % 10) + 1);
  }

这是使用更新后的代码生成的输出:

图 1.69:程序输出

图 1.69:程序输出

仅仅通过查看这段代码,我们无法看出区别。这些都是随机值,看起来与以前没有太大不同。这样的错误很难找到,并且可能导致随机行为,使我们难以跟踪错误。您可以避免此类错误的方法包括在解引用指针时特别小心,特别是在循环中;将代码分离为函数并为其编写单元测试;并且在强制执行编译器或运行时不支持的事物时大量使用assert语句。

第 2A 章 - 不允许鸭子 - 类型和推断

活动 1:图形处理

在这个活动中,我们将实现两个类(Point3dMatrix3d),以及乘法运算符,以便我们可以转换、缩放和旋转点。我们还将实现一些帮助方法,用于创建所需的转换矩阵。按照以下步骤实现此活动:

  1. CMake Build(便携式)中加载准备好的项目。构建和配置启动器并运行单元测试(失败)。建议用于测试运行程序的名称为L2AA1graphicstests

CMake 配置

按照练习 1步骤 9声明变量和探索大小,将项目配置为 CMake 项目。

  1. 添加一个Point3d类的测试,以验证默认构造函数创建一个原点[0, 0, 0, 1]

  2. 打开point3dTests.cpp文件并在顶部添加以下行。

  3. 用以下测试替换失败的现有测试:

TEST_F(Point3dTest, DefaultConstructorIsOrigin)
{
    Point3d pt;
    float expected[4] = {0,0,0,1};
    for(size_t i=0 ; i < 4 ; i++)
    {
        ASSERT_NEAR(expected[i], pt(i), Epsilon) << "cell [" << i << "]";
    }
}

这个测试要求我们编写一个访问操作符。

  1. 用以下代码替换point3d.hpp文件中的当前类定义:
include <cstddef>
class Point3d
{
public:
    static constexpr size_t NumberRows{4};
    float operator()(const int index) const
    {
        return m_data[index];
    }
private:
    float m_data[NumberRows];
};

现在测试可以构建和运行,但是失败了。

  1. Point3d声明中添加默认构造函数的声明:
Point3d();
  1. 将实现添加到point3d.cpp文件中:
Point3d::Point3d()
{
    for(auto& item : m_data)
    {
        item = 0;
    }
    m_data[NumberRows-1] = 1;
}

现在测试可以构建、运行并通过。

  1. 添加下一个测试:
TEST_F(Point3dTest, InitListConstructor3)
{
    Point3d pt {5.2, 3.5, 6.7};
    float expected[4] = {5.2,3.5,6.7,1};
    for(size_t i=0 ; i < 4 ; i++)
    {
        ASSERT_NEAR(expected[i], pt(i), Epsilon) << "cell [" << i << "]";
    }
}

这个测试无法编译。因此,我们需要实现另一个构造函数 - 接受std::initializer_list<>作为参数的构造函数。

  1. 将以下包含添加到头文件中:
#include <initializer_list>
  1. 在头文件中的Point3d类中添加以下构造函数声明:
Point3d(std::initializer_list<float> list);
  1. 将以下代码添加到实现文件中。这段代码忽略了错误处理,这将在第 3 课Can 和 Should 之间的距离-对象、指针和继承中添加:
Point3d::Point3d(std::initializer_list<float> list)
{
    m_data[NumberRows-1] = 1;
    int i{0};
    for(auto it1 = list.begin(); 
        i<NumberRows && it1 != list.end();
        ++it1, ++i)
    {
        m_data[i] = *it1;
    }
}

现在测试应该构建、运行并通过。

  1. 添加以下测试:
TEST_F(Point3dTest, InitListConstructor4)
{
    Point3d pt {5.2, 3.5, 6.7, 2.0};
    float expected[4] = {5.2,3.5,6.7,2.0};
    for(size_t i=0 ; i < 4 ; i++)
    {
        ASSERT_NEAR(expected[i], pt(i), Epsilon) << "cell [" << i << "]";
    }
}

测试应该仍然构建、运行并通过。

  1. 现在是时候通过将验证循环移动到Point3dTest类中的模板函数来重构测试用例了。在这个类中添加以下模板:
template<size_t size>
void VerifyPoint(Point3d& pt, float (&expected)[size])
{
    for(size_t i=0 ; i< size ; i++)
    {
        ASSERT_NEAR(expected[i], pt(i), Epsilon) << "cell [" << i << "]";
    }
}
  1. 这意味着最后一个测试现在可以重写如下:
TEST_F(Point3dTest, InitListConstructor4)
{
    Point3d pt {5.2, 3.5, 6.7, 2.0};
    float expected[4] = {5.2,3.5,6.7,2.0};
    VerifyPoint(pt, expected);
}

与生产代码一样,保持测试的可读性同样重要。

  1. 接下来,通过以下测试添加相等和不相等运算符的支持:
TEST_F(Point3dTest, EqualityOperatorEqual)
{
    Point3d pt1 {1,3,5};
    Point3d pt2 {1,3,5};
    ASSERT_EQ(pt1, pt2);
}
TEST_F(Point3dTest, EqualityOperatorNotEqual)
{
    Point3d pt1 {1,2,3};
    Point3d pt2 {1,2,4};
    ASSERT_NE(pt1, pt2);
}
  1. 为了实现这些,添加以下声明/定义到头文件中:
bool operator==(const Point3d& rhs) const;
bool operator!=(const Point3d& rhs) const
{
    return !operator==(rhs);
}
  1. 现在,在.cpp 文件中添加相等性的实现:
bool Point3d::operator==(const Point3d& rhs) const
{
    for(int i=0 ; i<NumberRows ; i++)
    {
        if (m_data[i] != rhs.m_data[i])
        {
            return false;
        }
    }
    return true;
}
  1. 当我们首次添加Point3d时,我们实现了一个常量访问器。添加以下测试,我们需要一个非常量访问器,以便我们可以将其分配给成员:
TEST_F(Point3dTest, AccessOperator)
{
    Point3d pt1;
    Point3d pt2 {1,3,5};
    pt1(0) = 1;
    pt1(1) = 3;
    pt1(2) = 5;
    ASSERT_EQ(pt1, pt2);
}
  1. 为了使这个测试能够构建,添加以下访问器到头文件中:
float& operator()(const int index)
{
    return m_data[index];
}

注意它返回一个引用。因此,我们可以将其分配给一个成员值。

  1. 为了完成Point3d,在类声明中添加默认复制构造函数和复制赋值:
Point3d(const Point3d&) = default;
Point3d& operator=(const Point3d&) = default;
  1. 现在,添加Matrix3d类。首先,在当前项目的顶层文件夹中创建两个空文件,matrix3d.hppmatrix3d.cpp,然后在 tests 文件夹中添加一个名为matrix3dTests.cpp的空文件。

  2. 打开顶层文件夹中的 CmakeLists.txt 文件,并将matrix3d.cpp添加到以下行:

add_executable(graphics point3d.cpp main.cpp matrix3d.cpp)
  1. 打开../matrix3d.cppSRC_FILES的定义,并添加TEST_FILES
SET(SRC_FILES 
    ../matrix3d.cpp
    ../point3d.cpp)
SET(TEST_FILES 
    matrix3dTests.cpp
    point3dTests.cpp)

如果你正确地进行了这些更改,现有的point3d测试应该仍然能够构建、运行和通过。

  1. matrix3dTests.cpp中添加以下测试管道:
#include "gtest/gtest.h"
#include "../matrix3d.hpp"
class Matrix3dTest : public ::testing::Test
{
public:
};
TEST_F(Matrix3dTest, DummyTest)
{
    ASSERT_TRUE(false);
}
  1. 构建并运行测试。我们刚刚添加的测试应该失败。

  2. Matrix3d类中用以下测试替换 DummyTest。我们现在将在matrix3d.hpp中进行此操作。

  3. matrix3d.hpp中添加以下定义:

class Matrix3d
{
public:
    float operator()(const int row, const int column) const
    {
        return m_data[row][column];
    }
private:
    float m_data[4][4];
};

现在测试将构建,但仍然失败,因为我们还没有创建一个创建单位矩阵的默认构造函数。

  1. Matrix3d的公共部分的头文件中添加默认构造函数的声明:
Matrix3d();
  1. 将此定义添加到matrix3d.cpp中:
#include "matrix3d.hpp"
Matrix3d::Matrix3d()
{
    for (int i{0} ; i< 4 ; i++)
        for (int j{0} ; j< 4 ; j++)
            m_data[i][j] = (i==j);
}

现在测试已经构建并通过。

  1. 稍微重构代码以使其更易读。修改头文件如下:
#include <cstddef>   // Required for size_t definition
class Matrix3d
{
public:
    static constexpr size_t NumberRows{4};
    static constexpr size_t NumberColumns{4};
    Matrix3d();
    float operator()(const int row, const int column) const
    {
    return m_data[row][column];
    }
private:
    float m_data[NumberRows][NumberColumns];
};
  1. 更新matrix3d.cpp文件以使用常量:
Matrix3d::Matrix3d()
{
    for (int i{0} ; i< NumberRows ; i++)
        for (int j{0} ; j< NumberColumns ; j++)
            m_data[i][j] = (i==j);
}
  1. 重新构建测试并确保它们仍然通过。

  2. 现在,我们需要添加初始化程序列表构造函数。为此,添加以下测试:

TEST_F(Matrix3dTest, InitListConstructor)
{
    Matrix3d mat{ {1,2,3,4}, {5,6,7,8},{9,10,11,12}, {13,14,15,16}};
    int expected{1};
    for( int row{0} ; row<4 ; row++)
        for( int col{0} ; col<4 ; col++, expected++)
        {
            ASSERT_FLOAT_EQ(expected, mat(row,col)) << "cell[" << row << "][" << col << "]";
        }
}
  1. 为初始化程序列表支持添加包含文件并在matrix3d.hpp中声明构造函数:
#include <initializer_list>
class Matrix3d
{
public:
    Matrix3d(std::initializer_list<std::initializer_list<float>> list);
  1. 最后,在.cpp 文件中添加构造函数的实现:
Matrix3d::Matrix3d(std::initializer_list<std::initializer_list<float>> list)
{
    int i{0};
    for(auto it1 = list.begin(); i<NumberRows ; ++it1, ++i)
    {
        int j{0};
        for(auto it2 = it1->begin(); j<NumberColumns ; ++it2, ++j)
            m_data[i][j] = *it2;
    }
}
  1. 为了改善我们测试的可读性,在测试框架中添加一个辅助方法。在Matrix3dTest类中声明以下内容:
static constexpr float Epsilon{1e-12};
void VerifyMatrixResult(Matrix3d& expected, Matrix3d& actual);
  1. 添加辅助方法的定义:
void Matrix3dTest::VerifyMatrixResult(Matrix3d& expected, Matrix3d& actual)
{
    for( int row{0} ; row<4 ; row++)
        for( int col{0} ; col<4 ; col++)
        {
        ASSERT_NEAR(expected(row,col), actual(row,col), Epsilon) 
<< "cell[" << row << "][" << col << "]";
        }
}
  1. 编写一个测试,将两个矩阵相乘并得到一个新的矩阵(预期将手动计算):
TEST_F(Matrix3dTest, MultiplyTwoMatricesGiveExpectedResult)
{
    Matrix3d mat1{ {5,6,7,8}, {9,10,11,12}, {13,14,15,16}, {17,18,19,20}};
    Matrix3d mat2{ {1,2,3,4}, {5,6,7,8},    {9,10,11,12},  {13,14,15,16}};
    Matrix3d expected{ {202,228,254,280},
                       {314,356,398,440},
                       {426,484,542,600},
                       {538,612,686,760}};
    Matrix3d result = mat1 * mat2;
    VerifyMatrixResult(expected, result);
}
  1. 在头文件中定义operator*=
Matrix3d& operator*=(const Matrix3d& rhs);

然后,在类声明之外实现operator*的内联版本:

inline Matrix3d operator*(const Matrix3d& lhs, const Matrix3d& rhs)
{
    Matrix3d temp(lhs);
    temp *= rhs;
    return temp;
}
  1. 以及在matrix3d.cpp文件中的实现:
Matrix3d& Matrix3d::operator*=(const Matrix3d& rhs)
{
    Matrix3d temp;
    for(int i=0 ; i<NumberRows ; i++)
        for(int j=0 ; j<NumberColumns ; j++)
        {
            temp.m_data[i][j] = 0;
            for (int k=0 ; k<NumberRows ; k++)
                temp.m_data[i][j] += m_data[i][k] * rhs.m_data[k][j];
        }
    *this = temp;
    return *this;
}
  1. 构建并运行测试-再次,它们应该通过。

  2. 通过在Matrix3dTest类中声明第二个辅助函数来引入测试类的辅助函数:

void VerifyMatrixIsIdentity(Matrix3d& mat);

然后,声明它以便我们可以使用它:

void Matrix3dTest::VerifyMatrixIsIdentity(Matrix3d& mat)
{
for( int row{0} ; row<4 ; row++)
    for( int col{0} ; col<4 ; col++)
    {
        int expected = (row==col) ? 1 : 0;
        ASSERT_FLOAT_EQ(expected, mat(row,col)) 
                             << "cell[" << row << "][" << col << "]";
    }
}
  1. 更新一个测试以使用它:
TEST_F(Matrix3dTest, DefaultConstructorIsIdentity)
{
    Matrix3d mat;
    VerifyMatrixIsIdentity(mat);
}
  1. 编写一个健全性检查测试:
TEST_F(Matrix3dTest, IdentityTimesIdentityIsIdentity)
{
    Matrix3d mat;
    Matrix3d result = mat * mat;
    VerifyMatrixIsIdentity(result);
}
  1. 构建并运行测试-它们应该仍然通过。

  2. 现在,我们需要能够将点和矩阵相乘。添加以下测试:

TEST_F(Matrix3dTest, MultiplyMatrixWithPoint)
{
    Matrix3d mat { {1,2,3,4}, {5,6,7,8},    {9,10,11,12},  {13,14,15,16}};
    Point3d pt {15, 25, 35, 45};
    Point3d expected{350, 830, 1310, 1790};
    Point3d pt2 = mat * pt;
    ASSERT_EQ(expected, pt2);
}
  1. Matrix3d类声明中:
Point3d operator*(const Matrix3d& lhs, const Point3d& rhs);
  1. matrix3d.cpp文件中添加运算符的定义:
Point3d operator*(const Matrix3d& lhs, const Point3d& rhs)
{
    Point3d pt;
    for(int row{0} ; row<Matrix3d::NumberRows ; row++)
    {
        float sum{0};
        for(int col{0} ; col<Matrix3d::NumberColumns ; col++)
        {
            sum += lhs(row, col) * rhs(col);
        }
        pt(row) = sum;
    }
    return pt;
}
  1. 构建并运行测试。它们应该再次全部通过。

  2. matrix3dtests.cpp的顶部,添加包含文件:

#include <cmath>
  1. 开始添加转换矩阵工厂方法。使用以下测试,我们将开发各种工厂方法(测试应逐个添加):
TEST_F(Matrix3dTest, CreateTranslateIsCorrect)
{
    Matrix3d mat = createTranslationMatrix(-0.5, 2.5, 10.0);
    Matrix3d expected {{1.0, 0.0, 0.0, -0.5},
                       {0.0, 1.0, 0.0, 2.5},
                       {0.0, 0.0, 1.0, 10.0},
                       {0.0, 0.0, 0.0, 1.0}
    };
    VerifyMatrixResult(expected, mat);
}
TEST_F(Matrix3dTest, CreateScaleIsCorrect)
{
    Matrix3d mat = createScaleMatrix(3.0, 2.5, 11.0);
    Matrix3d expected {{3.0, 0.0,  0.0, 0.0},
                       {0.0, 2.5,  0.0, 0.0},
                       {0.0, 0.0, 11.0, 0.0},
                       {0.0, 0.0,  0.0, 1.0}
    };	
    VerifyMatrixResult(expected, mat);
}
TEST_F(Matrix3dTest, CreateRotateX90IsCorrect)
{
    Matrix3d mat = createRotationMatrixAboutX(90.0F);
    Matrix3d expected {{1.0, 0.0,  0.0, 0.0},
                       {0.0, 0.0, -1.0, 0.0},
                       {0.0, 1.0,  0.0, 0.0},
                       {0.0, 0.0,  0.0, 1.0}
    };
    VerifyMatrixResult(expected, mat);
}
TEST_F(Matrix3dTest, CreateRotateX60IsCorrect)
{
    Matrix3d mat = createRotationMatrixAboutX(60.0F);
    float sqrt3_2 = static_cast<float>(std::sqrt(3.0)/2.0);
    Matrix3d expected {{1.0, 0.0,     0.0,     0.0},
                       {0.0, 0.5,    -sqrt3_2, 0.0},
                       {0.0, sqrt3_2,  0.5,    0.0},
                       {0.0, 0.0,     0.0,     1.0}
    };
    VerifyMatrixResult(expected, mat);
}
TEST_F(Matrix3dTest, CreateRotateY90IsCorrect)
{
    Matrix3d mat = createRotationMatrixAboutY(90.0F);
    Matrix3d expected {{0.0, 0.0,  1.0, 0.0},
                       {0.0, 1.0,  0.0, 0.0},
                       {-1.0, 0.0, 0.0, 0.0},
                       {0.0, 0.0,  0.0, 1.0}
    };
    VerifyMatrixResult(expected, mat);
}
TEST_F(Matrix3dTest, CreateRotateY60IsCorrect)
{
    Matrix3d mat = createRotationMatrixAboutY(60.0F);
    float sqrt3_2 = static_cast<float>(std::sqrt(3.0)/2.0);
    Matrix3d expected {{0.5,      0.0,   sqrt3_2,  0.0},
                       {0.0,      1.0,    0.0,     0.0},
                       {-sqrt3_2, 0.0,    0.5,     0.0},
                       {0.0,      0.0,    0.0,     1.0}
    };
    VerifyMatrixResult(expected, mat);
}
TEST_F(Matrix3dTest, CreateRotateZ90IsCorrect)
{
    Matrix3d mat = createRotationMatrixAboutZ(90.0F);
    Matrix3d expected {{0.0, -1.0,  0.0, 0.0},
                       {1.0, 0.0,  0.0, 0.0},
                       {0.0, 0.0,  1.0, 0.0},
                       {0.0, 0.0,  0.0, 1.0}
    };
    VerifyMatrixResult(expected, mat);
}
TEST_F(Matrix3dTest, CreateRotateZ60IsCorrect)
{
    Matrix3d mat = createRotationMatrixAboutZ(60.0F);
    float sqrt3_2 = static_cast<float>(std::sqrt(3.0)/2.0);
    Matrix3d expected {{0.5,     -sqrt3_2,   0.0,  0.0},
                       {sqrt3_2,      0.5,   0.0,  0.0},
                       {0.0,          0.0,   1.0,  0.0},
                       {0.0,          0.0,   0.0,  1.0}
    };
    VerifyMatrixResult(expected, mat);
}
  1. 将以下声明添加到 matrix3d 头文件中:
Matrix3d createTranslationMatrix(float dx, float dy, float dz);
Matrix3d createScaleMatrix(float sx, float sy, float sz);
Matrix3d createRotationMatrixAboutX(float degrees);
Matrix3d createRotationMatrixAboutY(float degrees);
Matrix3d createRotationMatrixAboutZ(float degrees);
  1. 在 matrix3d 实现文件的顶部添加#include <cmath>

  2. 最后,将以下实现添加到matrix3d实现文件中:

Matrix3d createTranslationMatrix(float dx, float dy, float dz)
{
    Matrix3d matrix;
    matrix(0, 3) = dx;
    matrix(1, 3) = dy;
    matrix(2, 3) = dz;
    return matrix;
}
Matrix3d createScaleMatrix(float sx, float sy, float sz)
{
    Matrix3d matrix;
    matrix(0, 0) = sx;
    matrix(1, 1) = sy;
    matrix(2, 2) = sz;
    return matrix;
}
Matrix3d createRotationMatrixAboutX(float degrees)
{
    Matrix3d matrix;
    double pi{4.0F*atan(1.0F)};
    double radians = degrees / 180.0 * pi;
    float cos_theta = static_cast<float>(cos(radians));
    float sin_theta = static_cast<float>(sin(radians));
    matrix(1, 1) =  cos_theta;
    matrix(2, 2) =  cos_theta;
    matrix(1, 2) = -sin_theta;
    matrix(2, 1) =  sin_theta;
    return matrix;
}
Matrix3d createRotationMatrixAboutY(float degrees)
{
    Matrix3d matrix;
    double pi{4.0F*atan(1.0F)};
    double radians = degrees / 180.0 * pi;
    float cos_theta = static_cast<float>(cos(radians));
    float sin_theta = static_cast<float>(sin(radians));
    matrix(0, 0) =  cos_theta;
    matrix(2, 2) =  cos_theta;
    matrix(0, 2) =  sin_theta;
    matrix(2, 0) = -sin_theta;
    return matrix;
}
Matrix3d createRotationMatrixAboutZ(float degrees)
{
    Matrix3d matrix;
    double pi{4.0F*atan(1.0F)};
    double radians = degrees / 180.0 * pi;
    float cos_theta = static_cast<float>(cos(radians));
    float sin_theta = static_cast<float>(sin(radians));
    matrix(0, 0) =  cos_theta;
    matrix(1, 1) =  cos_theta;
    matrix(0, 1) = -sin_theta;
    matrix(1, 0) =  sin_theta;
    return matrix;
}
  1. 为了使其编译并通过测试,我们需要在matrix3d的声明中添加一个访问器:
float& operator()(const int row, const int column)
{
    return m_data[row][column];
}
  1. 再次构建并运行所有测试,以显示它们都通过了。

  2. point3d.hpp中,添加<ostream>的包含,并在 Point3d 类末尾添加以下友元声明:

friend std::ostream& operator<<(std::ostream& , const Point3d& );
  1. 在类之后编写操作符的内联实现:
inline std::ostream&
operator<<(std::ostream& os, const Point3d& pt)
{
    const char* sep = "[ ";
    for(auto value : pt.m_data)
    {
        os << sep  << value;
        sep = ", ";
    }
    os << " ]";
    return os;
}
  1. 打开main.cpp文件,并从以下行中删除注释分隔符,//:
//#define ACTIVITY1
  1. 构建并运行名为graphics的应用程序 - 您需要创建一个新的运行配置。如果您的Point3dMatrix3d的实现正确,那么程序将显示以下输出:

图 2A.53:成功运行活动程序

在这个活动中,我们实现了两个类,这两个类是实现 3D 图形渲染所需的所有操作的基础。我们使用运算符重载来实现这一点,以便 Matrix3d 和 Point3d 可以像本机类型一样使用。如果我们希望操作整个对象,这可以很容易地扩展到处理点的向量。

第 2B 章 - 不允许鸭子 - 模板和推断

活动 1:开发通用的“contains”模板函数

在这个活动中,我们将实现几个辅助类,用于检测std::string类情况和std::set情况,然后使用它们来调整包含函数以适应特定容器。按照以下步骤实现此活动:

  1. L2BA1tests加载准备好的项目。

  2. 打开containsTests.cpp文件,并用以下内容替换现有测试:

TEST_F(containsTest, DetectNpos)
{
    ASSERT_TRUE(has_npos_v<std::string>);
    ASSERT_FALSE(has_npos_v<std::set<int>>);
    ASSERT_FALSE(has_npos_v<std::vector<int>>);
}

这个测试要求我们编写一组辅助模板,以检测容器类是否支持名为 npos 的静态成员变量。

  1. 将以下代码添加到contains.hpp文件中:
template <class T>
auto test_npos(int) -> decltype((void)T::npos, std::true_type{});
template <class T>
auto test_npos(long) -> std::false_type;
template <class T>
struct has_npos : decltype(test_npos<T>(0)) {};
template< class T >
inline constexpr bool has_npos_v = has_npos<T>::value;

现在测试运行并通过。

  1. 将以下测试添加到接受一个参数的find()方法中。

  2. 将以下代码添加到contains.hpp文件中:

template <class T, class A0>
auto test_find(int) -> 
       decltype(void(std::declval<T>().find(std::declval<A0>())), 
                                                        std::true_type{});
template <class T, class A0>
auto test_find(long) -> std::false_type;
template <class T, class A0>
struct has_find : decltype(test_find<T,A0>(0)) {};
template< class T, class A0 >
inline constexpr bool has_find_v = has_find<T, A0>::value;

现在测试运行并通过。

  1. 添加通用容器的实现;在这种情况下,是向量。在containsTest.cpp文件中编写以下测试:
TEST_F(containsTest, VectorContains)
{
    std::vector<int> container {1,2,3,4,5};
    ASSERT_TRUE(contains(container, 5));
    ASSERT_FALSE(contains(container, 15));
}
  1. contains的基本实现添加到contains.hpp文件中:
template<class C, class T>
auto contains(const C& c, const T& key) -> decltype(std::end(c), true)
{
        return std::end(c) != std::find(begin(c), end(c), key);
}

现在测试运行并通过。

  1. 下一步是为set特殊情况添加测试到containsTest.cpp
TEST_F(containsTest, SetContains)
{
    std::set<int> container {1,2,3,4,5};
    ASSERT_TRUE(contains(container, 5));
    ASSERT_FALSE(contains(container, 15));
}
  1. 更新contains的实现以测试内置的set::find()方法:
template<class C, class T>
auto contains(const C& c, const T& key) -> decltype(std::end(c), true)
{
    if constexpr(has_find_v<C, T>)
    {
        return std::end(c) != c.find(key);
    }
    else
    {
        return std::end(c) != std::find(begin(c), end(c), key);
    }
}

现在测试运行并通过。

  1. string特殊情况的测试添加到containsTest.cpp文件中:
TEST_F(containsTest, StringContains)
{
    std::string container{"This is the message"};
    ASSERT_TRUE(contains(container, "the"));
    ASSERT_TRUE(contains(container, 'm'));
    ASSERT_FALSE(contains(container, "massage"));
    ASSERT_FALSE(contains(container, 'z'));
}
  1. 添加以下contains的实现以测试npos的存在并调整find()方法的使用:
template<class C, class T>
auto contains(const C& c, const T& key) -> decltype(std::end(c), true)
{
    if constexpr(has_npos_v<C>)
    {
        return C::npos != c.find(key);
    }
    else
    if constexpr(has_find_v<C, T>)
    {
        return std::end(c) != c.find(key);
    }
    else
    {
        return std::end(c) != std::find(begin(c), end(c), key);
    }
}

现在测试运行并通过。

  1. 构建并运行名为contains的应用程序。创建一个新的运行配置。如果您的 contains 模板实现正确,那么程序将显示以下输出:

图 2B.36:包含成功实现的输出

图 2B.36:包含成功实现的输出

在这个活动中,我们使用各种模板技术与 SFINAE 结合使用,根据包含类的能力选择contains()函数的适当实现。我们可以使用通用模板函数和一些专门的模板来实现相同的结果,但我们选择了不太常见的路径,并展示了我们新发现的模板技能。

第三章 - 能与应该之间的距离 - 对象,指针和继承

活动 1:使用 RAII 和 Move 实现图形处理

在这个活动中,我们将开发我们之前的Matrix3dPoint3d类,以使用unique_ptr<>来管理与实现这些图形类所需的数据结构相关联的内存。让我们开始吧:

  1. Lesson3/Activity01文件夹加载准备好的项目,并为项目配置当前构建器为CMake Build (Portable)。构建和配置启动器并运行单元测试。我们建议为测试运行器使用的名称是L3A1graphicstests

  2. 打开acpp::gfx,这是 C++17 的一个新特性。以前,它需要显式使用namespace关键字两次。另外,请注意,为了提供帮助,您友好的邻里 IDE 可能会在您放置命名空间声明的那一行后面立即插入闭括号。

  3. matrix3d.hppmatrix3d.cpppoint3d.cpp执行相同的处理-确保包含文件不包含在命名空间的范围内。

  4. 在各自的文件(main.cppmatrix3dTests.cpppoint3dTests.cpp)中,在完成#include 指令后,插入以下行:

using namespace acpp::gfx;
  1. 现在,运行所有测试。所有18个现有测试应该再次通过。我们已经成功地将我们的类放入了一个命名空间。

  2. 现在我们将转而将Matrix3d类转换为使用堆分配的内存。在#include <memory>行中,以便我们可以访问unique_ptr<>模板。

  3. 接下来,更改声明m_data的类型:

std::unique_ptr<float[]> m_data;
  1. 从这一点开始,我们将使用编译器及其错误来提示我们需要修复的问题。尝试构建测试现在会显示我们在头文件中有以下两个方法存在问题。
float operator()(const int row, const int column) const
{
    return m_data[row][column];
}
float& operator()(const int row, const int column)
{
    return m_data[row][column];
} 

问题在于unique_ptr保存了一个指向单维数组而不是二维数组的指针。因此,我们需要将行和列转换为一个单一的索引。

  1. 添加一个名为get_index()的新方法,以从行和列获取一维索引,并更新前面的函数以使用它:
float operator()(const int row, const int column) const
{
    return m_data[get_index(row,column)];
}
float& operator()(const int row, const int column)
{
    return m_data[get_index(row,column)];
}
private:
size_t get_index(const int row, const int column) const
{
    return row * NumberColumns + column;
}
  1. 重新编译后,编译器给出的下一个错误是关于以下内联函数:
inline Matrix3d operator*(const Matrix3d& lhs, const Matrix3d& rhs)
{
    Matrix3d temp(lhs);   // <=== compiler error – ill formed copy constructor
    temp *= rhs;
    return temp;
}
  1. 以前,默认的复制构造函数对我们的目的已经足够了,它只是对数组的所有元素进行了浅复制,这是正确的。现在我们需要复制的数据有了间接引用,因此我们需要实现一个深复制构造函数和复制赋值。我们还需要处理现有的构造函数。现在,只需将构造函数声明添加到类中(与其他构造函数相邻):
Matrix3d(const Matrix3d& rhs);
Matrix3d& operator=(const Matrix3d& rhs);

尝试构建测试现在将显示我们已解决头文件中的所有问题,并且可以继续进行实现文件。

  1. 修改两个构造函数以初始化unique_ptr如下:
Matrix3d::Matrix3d() : m_data{new float[NumberRows*NumberColumns]}
{
    for (int i{0} ; i< NumberRows ; i++)
        for (int j{0} ; j< NumberColumns ; j++)
            m_data[i][j] = (i==j);
}
Matrix3d::Matrix3d(std::initializer_list<std::initializer_list<float>> list)
    : m_data{new float[NumberRows*NumberColumns]}
{
    int i{0};
    for(auto it1 = list.begin(); i<NumberRows ; ++it1, ++i)
    {
        int j{0};
        for(auto it2 = it1->begin(); j<NumberColumns ; ++it2, ++j)
            m_data[i][j] = *it2;
    }
}
  1. 现在我们需要解决单维数组查找的问题。我们需要将m_data[i][j]类型的语句更改为m_data[get_index(i,j)]。将默认构造函数更改为以下内容:
Matrix3d::Matrix3d() : m_data{new float[NumberRows*NumberColumns]}
{
    for (int i{0} ; i< NumberRows ; i++)
        for (int j{0} ; j< NumberColumns ; j++)
            m_data[get_index(i, j)] = (i==j);          // <= change here
}
  1. 更改初始化列表构造函数如下:
Matrix3d::Matrix3d(std::initializer_list<std::initializer_list<float>> list)
      : m_data{new float[NumberRows*NumberColumns]}
{
    int i{0};
    for(auto it1 = list.begin(); i<NumberRows ; ++it1, ++i)
    {
        int j{0};
        for(auto it2 = it1->begin(); j<NumberColumns ; ++it2, ++j)
            m_data[get_index(i, j)] = *it2;         // <= change here
    }
}
  1. 更改乘法运算符,注意索引:
Matrix3d& Matrix3d::operator*=(const Matrix3d& rhs)
{
    Matrix3d temp;
    for(int i=0 ; i<NumberRows ; i++)
        for(int j=0 ; j<NumberColumns ; j++)
        {
            temp.m_data[get_index(i, j)] = 0;        // <= change here
            for (int k=0 ; k<NumberRows ; k++)
                temp.m_data[get_index(i, j)] += m_data[get_index(i, k)] 
                                          * rhs.m_data[get_index(k, j)];
                                                     // <= change here
        }
    *this = temp;
    return *this;
}
  1. 通过这些更改,我们已经修复了所有的编译错误,但现在我们有一个链接器错误要处理-我们只在第 11 步中声明了复制构造函数。

  2. matrix3d.cpp文件中添加以下定义:

Matrix3d::Matrix3d(const Matrix3d& rhs) : 
    m_data{new float[NumberRows*NumberColumns]}
{
    *this = rhs;
}
Matrix3d& Matrix3d::operator=(const Matrix3d& rhs)
{
    for(int i=0 ; i< NumberRows*NumberColumns ; i++)
        m_data[i] = rhs.m_data[i];
    return *this;
}
  1. 现在测试将会构建,并且所有测试都会通过。下一步是强制移动构造函数。在matrix3d.cpp中找到createTranslationMatrix()方法,并将返回语句更改如下:
return std::move(matrix);
  1. move构造函数中。
Matrix3d(Matrix3d&& rhs);
  1. 重新构建测试。现在,我们得到了一个与移动构造函数不存在相关的错误。

  2. 将构造函数的实现添加到matrix3d.cpp中,并重新构建测试。

Matrix3d::Matrix3d(Matrix3d&& rhs)
{
    //std::cerr << "Matrix3d::Matrix3d(Matrix3d&& rhs)\n";
    std::swap(m_data, rhs.m_data);
}
  1. 重新构建并运行测试。它们都会再次通过。

  2. 为了确认移动构造函数是否被调用,将#include <iostream>添加到cerr中。检查后,再将该行注释掉。

注意

关于移动构造函数的一个快速说明-我们没有像其他构造函数那样显式初始化m_data。这意味着它将被初始化为空,然后与传入的参数交换,这是一个临时的,所以它可以不保存数组在事务之后-它删除了一次内存的分配和释放。

  1. 现在让我们转换Point3d类,以便它可以使用堆分配的内存。在#include <memory>行中添加,以便我们可以访问unique_ptr<>模板。

  2. 接下来,更改m_data的声明类型如下:

std::unique_ptr<float[]> m_data;
  1. 编译器现在告诉我们,在unique_ptr的插入运算符(<<)中存在问题:用以下内容替换实现:
inline std::ostream&
operator<<(std::ostream& os, const Point3d& pt)
{
    const char* sep = "[ ";
    for(int i{0} ; i < Point3d::NumberRows ; i++)
    {
        os << sep << pt.m_data[i];
        sep = ", ";
    }
    os << " ]";
    return os;
} 
  1. 打开unique_ptr并更改初始化循环,因为unique_ptr不能使用范围 for:
Point3d::Point3d() : m_data{new float[NumberRows]}
{
    for(int i{0} ; i < NumberRows-1 ; i++) {
        m_data[i] = 0;
    }
    m_data[NumberRows-1] = 1;
}
  1. 通过初始化unique_ptr修改另一个构造函数:
Point3d::Point3d(std::initializer_list<float> list)
            : m_data{new float[NumberRows]}
  1. 现在所有的测试都运行并通过,就像以前一样。

  2. 现在,如果我们运行原始应用程序L3graphics,那么输出将与原始输出相同,但是该实现使用 RAII 来分配和管理用于矩阵和点的内存。

图 3.52:成功转换为使用 RAII 后的活动 1 输出

活动 2:实现日期计算的类

在这个活动中,我们将实现两个类,DateDays,这将使我们非常容易处理日期和它们之间的时间差异。让我们开始吧:

  1. Lesson3/Activity02文件夹加载准备好的项目,并配置项目的当前构建器为CMake Build (Portable)。构建和配置启动器并运行单元测试。我们建议为测试运行器使用的名称是L3A2datetests。该项目有虚拟文件和一个失败的测试。

  2. 打开Date类以允许访问存储的值:

int Day()   const {return m_day;}
int Month() const {return m_month;}
int Year()  const {return m_year;}
  1. 打开DateTest类:
void VerifyDate(const Date& dt, int yearExp, int monthExp, int dayExp) const
{
    ASSERT_EQ(dayExp, dt.Day());
    ASSERT_EQ(monthExp, dt.Month());
    ASSERT_EQ(yearExp, dt.Year());
}

通常情况下,随着测试的发展,您会重构这个测试,但我们将它提前拉出来。

  1. 用以下测试替换现有测试中的ASSERT_FALSE()
Date dt;
VerifyDate(dt, 1970, 1, 1);
  1. 重建并运行测试-现在它们应该全部通过。

  2. 添加以下测试:

TEST_F(DateTest, Constructor1970Jan2)
{
    Date dt(2, 1, 1970);
    VerifyDate(dt, 1970, 1, 2);
}
  1. 为了进行这个测试,我们需要向Date类添加以下两个构造函数:
Date() = default;
Date(int day, int month, int year) :
        m_year{year}, m_month{month}, m_day{day}
{
}
  1. 现在我们需要引入函数来转换date_t类型。在我们的命名空间内的date.hpp文件中添加以下别名:
using date_t=int64_t;
  1. Date类中,添加以下方法的声明:
date_t ToDateT() const;
  1. 然后,添加以下测试:
TEST_F(DateTest, ToDateTDefaultIsZero)
{
    Date dt;
    ASSERT_EQ(0, dt.ToDateT());
}
  1. 由于我们正在进行(TDD),我们添加方法的最小实现以通过测试。
date_t Date::ToDateT() const
{
    return 0;
}
  1. 现在,我们添加下一个测试:
TEST_F(DateTest, ToDateT1970Jan2Is1)
{
    Date dt(2, 1, 1970);
    ASSERT_EQ(1, dt.ToDateT());
}
  1. 我们继续添加一个测试,然后另一个,一直在不断完善ToDateT()中的算法,首先处理1970年的日期,然后是1971 年 1 月 1 日,然后是1973年的日期,这意味着我们跨越了一个闰年,依此类推。用于开发ToDateT()方法的完整测试集如下:
TEST_F(DateTest, ToDateT1970Dec31Is364)
{
    Date dt(31, 12, 1970);
    ASSERT_EQ(364, dt.ToDateT());
}
TEST_F(DateTest, ToDateT1971Jan1Is365)
{
    Date dt(1, 1, 1971);
    ASSERT_EQ(365, dt.ToDateT());
}
TEST_F(DateTest, ToDateT1973Jan1Is1096)
{
    Date dt(1, 1, 1973);
    ASSERT_EQ(365*3+1, dt.ToDateT());
}
TEST_F(DateTest, ToDateT2019Aug28Is18136)
{
    Date dt(28, 8, 2019);
    ASSERT_EQ(18136, dt.ToDateT());
}
  1. 为了通过所有这些测试,我们向Date类的声明中添加以下内容:
public:
    static constexpr int EpochYear = 1970;
    static constexpr int DaysPerCommonYear = 365;
    static constexpr int YearsBetweenLeapYears = 4;
private:
    int GetDayOfYear(int day, int month, int year) const;
    bool IsLeapYear(int year) const;
    int CalcNumberLeapYearsFromEpoch(int year) const;
  1. date.cppToDateT()的实现和支持方法如下:
namespace {
int daysBeforeMonth[2][12] =
{
    { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 204, 334}, // Common Year
    { 0, 31, 50, 91, 121, 152, 182, 213, 244, 274, 205, 335}  // Leap Year
};
}
namespace acpp::date
{
int Date::CalcNumberLeapYearsFromEpoch(int year) const
{
    return (year-1)/YearsBetweenLeapYears
                                   - (EpochYear-1)/YearsBetweenLeapYears;
}
int Date::GetDayOfYear(int day, int month, int year) const
{
    return daysBeforeMonth[IsLeapYear(year)][month-1] + day;
}
bool Date::IsLeapYear(int year) const
{
    return (year%4)==0;   // Not full story, but good enough to 2100
}
date_t Date::ToDateT() const
{
    date_t value = GetDayOfYear(m_day, m_month, m_year) - 1;
    value += (m_year-EpochYear) * DaysPerCommonYear;
    date_t numberLeapYears = CalcNumberLeapYearsFromEpoch(m_year);
    value += numberLeapYears;
    return value;
}
}
  1. 现在ToDateT()正在工作,我们转向它的反向,即FromDateT()。同样,我们逐个构建测试,以开发一系列日期的算法。使用了以下测试:
TEST_F(DateTest, FromDateT0Is1Jan1970)
{
    Date dt;
    dt.FromDateT(0);
    ASSERT_EQ(0, dt.ToDateT());
    VerifyDate(dt, 1970, 1, 1);
}
TEST_F(DateTest, FromDateT1Is2Jan1970)
{
    Date dt;
    dt.FromDateT(1);
    ASSERT_EQ(1, dt.ToDateT());
    VerifyDate(dt, 1970, 1, 2);
}
TEST_F(DateTest, FromDateT364Is31Dec1970)
{
    Date dt;
    dt.FromDateT(364);
    ASSERT_EQ(364, dt.ToDateT());
    VerifyDate(dt, 1970, 12, 31);
}
TEST_F(DateTest, FromDateT365Is1Jan1971)
{
    Date dt;
    dt.FromDateT(365);
    ASSERT_EQ(365, dt.ToDateT());
    VerifyDate(dt, 1971, 1, 1);
}
TEST_F(DateTest, FromDateT1096Is1Jan1973)
{
    Date dt;
    dt.FromDateT(1096);
    ASSERT_EQ(1096, dt.ToDateT());
    VerifyDate(dt, 1973, 1, 1);
}
TEST_F(DateTest, FromDateT18136Is28Aug2019)
{
    Date dt;
    dt.FromDateT(18136);
    ASSERT_EQ(18136, dt.ToDateT());
    VerifyDate(dt, 2019, 8, 28);
}
  1. 在头文件中添加以下声明:
public:
    void FromDateT(date_t date);
private:
    int CalcMonthDayOfYearIsIn(int dayOfYear, bool IsLeapYear) const;
  1. 使用以下实现,因为之前的测试是逐个添加的:
void Date::FromDateT(date_t date)
{
    int number_years = date / DaysPerCommonYear;
    date = date - number_years * DaysPerCommonYear;
    m_year = EpochYear + number_years;
    date_t numberLeapYears = CalcNumberLeapYearsFromEpoch(m_year);
    date -= numberLeapYears;
    m_month = CalcMonthDayOfYearIsIn(date, IsLeapYear(m_year));
    date -= daysBeforeMonth[IsLeapYear(m_year)][m_month-1];
    m_day = date + 1;
}
int Date::CalcMonthDayOfYearIsIn(int dayOfYear, bool isLeapYear) const
{
    for(int i = 1 ; i < 12; i++)
    {
    if ( daysBeforeMonth[isLeapYear][i] > dayOfYear)
            return i;
    }
    return 12;
}
  1. 现在我们已经准备好支持例程,我们可以实现Date类的真正特性,即两个日期之间的差异,并通过添加一定数量的天来确定新日期。这两个操作都需要一个新类型(类)Days

  2. 将以下Days的实现添加到头文件(在Date之前):

class Days
{
public:
    Days() = default;
    Days(int days) : m_days{days}     {    }
    operator int() const
    {
        return m_days;
    }
private:
    int m_days{0};
};
  1. 第一个运算符将是将Days添加到Date的加法。添加以下方法声明(在Date类的公共部分内):
Date& operator+=(const Days& day);
  1. 然后,在头文件中(在Date类之外)添加内联实现:
inline Date operator+(const Date& lhs, const Days& rhs )
{
    Date tmp(lhs);
    tmp += rhs;
    return tmp;
}
  1. 编写以下测试来验证sum操作:
TEST_F(DateTest, AddZeroDays)
{
    Date dt(28, 8, 2019);
    Days days;
    dt += days;
    VerifyDate(dt, 2019, 8, 28);
}
TEST_F(DateTest, AddFourDays)
{
    Date dt(28, 8, 2019);
    Days days(4);
    dt += days;
    VerifyDate(dt, 2019, 9, 1);
}
  1. sum操作的实际实现仅基于两个支持方法
Date& Date::operator+=(const Days& day)
{
    FromDateT(ToDateT()+day);
    return *this;
}
  1. 添加以下测试:
TEST_F(DateTest, AddFourDaysAsInt)
{
    Date dt(28, 8, 2019);
    dt += 4;
    VerifyDate(dt, 2019, 9, 1);
}
  1. 当我们运行测试时,它们都构建了,并且这个测试通过了。但这不是期望的结果。我们不希望它们能够将裸整数添加到我们的日期中。(将来的版本可能会添加月份和年份,那么添加整数意味着什么?)。为了使其失败并导致构建失败,我们将 Days 构造函数更改为explicit
explicit Days(int days) : m_days{days}     {    }
  1. 现在构建失败了,所以我们需要通过将添加行转换为Days来修复测试,如下所示:
dt += static_cast<Days>(4);

所有测试应该再次通过。

  1. 我们想要的最终功能是两个日期之间的差异。以下是用于验证实现的测试:
TEST_F(DateTest, DateDifferences27days)
{
    Date dt1(28, 8, 2019);
    Date dt2(1, 8, 2019);
    Days days = dt1 - dt2;
    ASSERT_EQ(27, (int)days);
}
TEST_F(DateTest, DateDifferences365days)
{
    Date dt1(28, 8, 2019);
    Date dt2(28, 8, 2018);
    Days days = dt1 - dt2;
    ASSERT_EQ(365, (int)days);
}
  1. 在头文件中的Date类的公共部分中添加以下函数声明:
Days operator-(const Date& rhs) const;
  1. 在头文件中的 Date 类之后添加以下代码:
inline Days Date::operator-(const Date& rhs) const
{
    return Days(ToDateT() - rhs.ToDateT());
}

因为我们使Days构造函数显式,所以必须在返回语句中调用它。在所有这些更改都就位后,所有测试应该都通过。

  1. L3A2date配置为datetools二进制文件,并在编辑器中打开 main.cpp。从ACTIVITY2的定义中删除注释:
#define ACTIVITY2
  1. 构建然后运行示例应用程序。这将产生以下输出:

图 3.53:成功的 Date 示例应用程序的输出

图 3.53:成功的 Date 示例应用程序的输出

我们已经实现了 Date 和 Days 类的所有要求,并通过单元测试交付了它们。单元测试使我们能够实现增量功能,以构建两个复杂算法ToDateTFromDateT,它们构成了我们想要交付的功能的基础支持。

第四章 - 关注点分离 - 软件架构,函数,可变模板

活动 1:实现多播事件处理程序

  1. Lesson4/Activity01文件夹加载准备好的项目,并将项目的当前构建器配置为 CMake Build(Portable)。构建项目,配置启动器并运行单元测试(其中一个虚拟测试失败)。建议为测试运行器使用L4delegateTests

  2. delegateTests.cpp中,用以下测试替换失败的虚拟测试:

TEST_F(DelegateTest, BasicDelegate)
{
    Delegate delegate;
    ASSERT_NO_THROW(delegate.Notify(42));
}
  1. 现在构建失败了,所以我们需要向Delegate添加一个新方法。由于这将演变为一个模板,我们将在头文件中进行所有这些开发。在delegate.hpp中,添加以下定义:
class Delegate
{
public:
    Delegate() = default;
    void Notify(int value) const
    {
    }
};

现在测试运行并通过。

  1. 在现有测试中添加以下行:
ASSERT_NO_THROW(delegate(22));
  1. 再次构建失败,所以我们更新Delegate的定义如下(我们可以让Notify调用operator(),但这样更容易阅读):
void operator()(int value)
{
    Notify(value);
}

测试再次运行并通过。

  1. 在添加下一个测试之前,我们将添加一些基础设施来帮助我们开发测试。处理程序最容易的方法是让它们写入std::cout,为了能够验证它们是否被调用,我们需要捕获输出。为此,通过更改DelegateTest类将标准输出流重定向到不同的缓冲区:
class DelegateTest : public ::testing::Test
{
public:
    void SetUp() override;
    void TearDown() override;
    std::stringstream m_buffer;
    // Save cout's buffer here
    std::streambuf *m_savedBuf{};
};
void DelegateTest::SetUp()
{
    // Save the cout buffer
    m_savedBuf = std::cout.rdbuf();
    // Redirect cout to our buffer
    std::cout.rdbuf(m_buffer.rdbuf());
}
void DelegateTest::TearDown()
{
    // Restore cout buffer to original
    std::cout.rdbuf(m_savedBuf);
}
  1. 还要在文件顶部添加<iostream><sstream><string>的包含语句。

  2. 在支持框架的基础上,添加以下测试:

TEST_F(DelegateTest, SingleCallback)
{
    Delegate delegate;
    delegate += [] (int value) { std::cout << "value = " << value; };
    delegate.Notify(42);
    std::string result = m_buffer.str();
    ASSERT_STREQ("value = 42", result.c_str());
}
  1. 为了使测试再次构建和运行,添加以下代码到delegate.h类中:
Delegate& operator+=(const std::function<void(int)>& delegate)
{
    m_delegate = delegate;
    return *this;
}

随着以下代码:

private:
    std::function<void(int)> m_delegate;

现在测试构建了,但我们的新测试失败了。

  1. 更新Notify()方法为:
void Notify(int value) const
{
    m_delegate(value);
}
  1. 现在测试构建并且我们的新测试通过了,但原始测试现在失败了。调用委托时抛出了异常,所以在调用之前我们需要检查委托是否为空。编写以下代码来实现这一点:
void Notify(int value) const
{
    if(m_delegate)
        m_delegate(value);
}

所有测试现在都运行并通过。

  1. 我们现在需要为Delegate类添加多播支持。添加新的测试:
TEST_F(DelegateTest, DualCallbacks)
{
    Delegate delegate;
    delegate += [] (int value) { std::cout << "1: = " << value << "\n"; };
    delegate += [] (int value) { std::cout << "2: = " << value << "\n"; };
    delegate.Notify(12);
    std::string result = m_buffer.str();
    ASSERT_STREQ("1: = 12\n2: = 12\n", result.c_str());
}
  1. 当然,这个测试现在失败了,因为operator+=()只分配给成员变量。我们需要添加一个列表来存储我们的委托。我们选择 vector,这样我们可以按照添加的顺序调用委托。在delegate.hpp的顶部添加#include <vector>,并更新 Delegate 将m_delegate替换为m_delegates回调的 vector:
class Delegate
{
public:
    Delegate() = default;
    Delegate& operator+=(const std::function<void(int)>& delegate)
    {
        m_delegates.push_back(delegate);
        return *this;
    }
    void Notify(int value) const
    {
        for(auto& delegate : m_delegates)
        {
            delegate(value);
        }
    }
    void operator()(int value)
    {
        Notify(value);
    }
private:
    std::vector<std::function<void(int)>> m_delegates;
};

所有测试现在再次运行并通过。

  1. 我们现在已经实现了基本的多播delegate类。现在我们需要将其转换为基于模板的类。通过在三个测试中将所有Delegate的声明更改为Delegate<int>来更新现有的测试。

  2. 现在通过在类之前添加template<class Arg>来更新 Delegate 类,将其转换为模板,并将四个int的出现替换为Arg

template<class Arg>
class Delegate
{
public:
    Delegate() = default;
    Delegate& operator+=(const std::function<void(Arg)>& delegate)
    {
        m_delegates.push_back(delegate);
        return *this;
    }
    void Notify(Arg value) const
    {
        for(auto& delegate : m_delegates)
        {
            delegate(value);
        }
    }
    void operator()(Arg value)
    {
        Notify(value);
    }
private:
    std::vector<std::function<void(Arg)>> m_delegates;
};
  1. 所有测试现在都运行并通过,因此它仍然适用于处理程序的int参数。

  2. 添加以下测试并重新运行测试以确认模板转换是正确的:

TEST_F(DelegateTest, DualCallbacksString)
{
    Delegate<std::string&> delegate;
    delegate += [] (std::string value) { std::cout << "1: = " << value << "\n"; };
    delegate += [] (std::string value) { std::cout << "2: = " << value << "\n"; };
    std::string hi{"hi"};
    delegate.Notify(hi);
    std::string result = m_buffer.str();
    ASSERT_STREQ("1: = hi\n2: = hi\n", result.c_str());
}
  1. 现在它作为一个接受一个参数的模板运行。我们需要将其转换为接受零个或多个参数的可变模板。使用上一个主题的信息,将模板更新为以下内容:
template<typename... ArgTypes>
class Delegate
{
public:
    Delegate() = default;
    Delegate& operator+=(const std::function<void(ArgTypes...)>& delegate)
    {
        m_delegates.push_back(delegate);
        return *this;
    }
    void Notify(ArgTypes&&... args) const
    {
        for(auto& delegate : m_delegates)
        {
            delegate(std::forward<ArgTypes>(args)...);
        }
    }
    void operator()(ArgTypes&&... args)
    {
        Notify(std::forward<ArgTypes>(args)...);
    }
private:
    std::vector<std::function<void(ArgTypes...)>> m_delegates;
};

测试应该仍然运行并通过。

  1. 添加两个更多的测试 - 零参数测试和多参数测试:
TEST_F(DelegateTest, DualCallbacksNoArgs)
{
    Delegate delegate;
    delegate += [] () { std::cout << "CB1\n"; };
    delegate += [] () { std::cout << "CB2\n"; };
    delegate.Notify();
    std::string result = m_buffer.str();
    ASSERT_STREQ("CB1\nCB2\n", result.c_str());
}
TEST_F(DelegateTest, DualCallbacksStringAndInt)
{
    Delegate<std::string&, int> delegate;
    delegate += [] (std::string& value, int i) {
            std::cout << "1: = " << value << "," << i << "\n"; };
    delegate += [] (std::string& value, int i) {
        std::cout << "2: = " << value << "," << i << "\n"; };
    std::string hi{"hi"};
    delegate.Notify(hi, 52);
    std::string result = m_buffer.str();
    ASSERT_STREQ("1: = hi,52\n2: = hi,52\n", result.c_str());
}

所有测试都运行并通过,显示我们现在已经实现了期望的Delegate类。

  1. 现在,将运行配置更改为执行程序L4delegate。在编辑器中打开main.cpp文件,并更改文件顶部的定义为以下内容,然后运行程序:
#define ACTIVITY_STEP 27

我们得到以下输出:

图 4.35:委托成功实现的输出

图 4.35:委托成功实现的输出

在这个活动中,我们首先实现了一个提供基本单一委托功能的类,然后添加了多播功能。有了这个实现,并且有了单元测试,我们很快就能够转换为一个带有一个参数的模板,然后转换为一个可变模板版本。根据您正在开发的功能,特定实现过渡到一般形式,然后再到更一般形式的方法是正确的。可变模板的开发并不总是显而易见的。

第五章 - 哲学家的晚餐 - 线程和并发

活动 1:创建模拟器来模拟艺术画廊的工作

艺术画廊工作模拟器是一个模拟访客和看门人行为的应用程序。访客数量有限,即画廊内同时只能容纳 50 人。访客不断前来画廊。看门人检查是否超过了访客限制。如果是,它会要求新的访客等待并将他们放在等待列表上。如果没有,它允许他们进入画廊。访客可以随时离开画廊。如果有人离开画廊,看门人会让等待列表中的人进入画廊。

按照以下步骤执行此活动:

  1. 创建一个文件,其中包含我们项目所需的所有常量 - Common.hpp

  2. 添加包含保护和第一个变量CountPeopleInside,它表示访客限制为 50 人:

#ifndef COMMON_HPP
#define COMMON_HPP
constexpr size_t CountPeopleInside = 5;
#endif // COMMON_HPP
  1. 现在,创建Person类的头文件和源文件,即Person.hppPerson.cpp。还要添加包含保护。定义Person类并删除复制构造函数和复制赋值运算符;我们只会使用用户定义的默认构造函数、移动构造函数和移动赋值运算符以及默认析构函数。添加一个名为m_Id的私有变量;我们将用它来记录。还要添加一个名为m_NextId的私有静态变量;它将用于生成唯一的 ID:
#ifndef PERSON_HPP
#define PERSON_HPP
class Person
{
public:
    Person();
    Person& operator=(Person&);
    Person(Person&&);
    ~Person() = default;
    Person(const Person&) = delete;
    Person& operator=(const Person&) = delete;
private:
    int m_Id;
    static int m_NextId;
};
#endif // PERSON_HPP
  1. 在源文件中,定义我们的静态变量m_NextId。然后,在构造函数中,使用m_NextId的值初始化m_Id变量。在构造函数中打印日志。实现移动复制构造函数和移动赋值运算符。现在,为我们的Person对象实现线程安全存储。创建所需的头文件和源文件,即Persons.hppPersons.cpp。还要添加包含保护。包括"Person.hpp"和<mutex><vector>头文件。定义具有用户定义默认构造函数和默认析构函数的Persons类。声明add()函数以添加Personget()以获取Person并从列表中删除它。定义size()函数以获取Person元素的计数,以及removePerson(),它从存储中删除任何人。在私有部分中,声明互斥类型的变量m_Mutex,即m_Persons来存储 Persons 的向量:
#ifndef PERSONS_HPP
#define PERSONS_HPP
#include "Person.hpp"
#include <mutex>
#include <vector>
class Persons
{
public:
    Persons();
    ~Persons() = default;
    void add(Person&& person);
    Person get();
    size_t size() const;
    void removePerson();
private:
    std::mutex m_Mutex;
    std::vector<Person> m_Persons;
};
#endif // PERSONS_HPP
  1. 在源文件中,声明用户定义的构造函数,我们将向量的大小保留为 50 个元素(以避免在增长过程中重新调整大小):
Persons::Persons()
{
    m_Persons.reserve(CountPeopleInside);
}
  1. 声明add()函数,它接受Person类型的 rvalue 参数,锁定互斥锁,并使用std::move()函数将Person添加到向量中:
void Persons::add(Person&& person)
{
    std::lock_guard<std::mutex> m_lock(m_Mutex);
    m_Persons.emplace_back(std::move(person));
}
  1. 声明get()函数,锁定互斥锁并返回最后一个元素,然后从向量中删除它。如果向量为空,它将抛出异常:
Person Persons::get()
{
    std::lock_guard<std::mutex> m_lock(m_Mutex);
    if (m_Persons.empty())
    {
        throw "Empty Persons storage";
    }
    Person result = std::move(m_Persons.back());
    m_Persons.pop_back();
    return result;
}
  1. 声明size()函数,返回向量的大小:
size_t Persons::size() const
{
    return m_Persons.size();
}
  1. 最后,声明removePerson()函数,该函数锁定互斥锁并从向量中删除最后一个项目:
void Persons::removePerson()
{
    std::lock_guard<std::mutex> m_lock(m_Mutex);
    m_Persons.pop_back();
    std::cout << "Persons | removePerson | removed" << std::endl;
}
  1. 现在,实现PersonGenerator类,负责创建和删除Person项。创建相应的头文件和源文件,即PersonGenerator.hppPersonGenerator.cpp。还要添加包含保护。包括"Person.hpp",<thread><condition_variable>头文件。定义PersonGenerator类。在私有部分中,定义两个std::thread变量,即m_CreateThreadm_RemoveThread。在一个线程中,我们将创建新的Person对象,并在另一个线程中异步通知用户删除Person对象。定义对Persons类型的共享变量的引用,即m_CreatedPersons。我们将把每个新人放在其中。m_CreatedPersons将在多个线程之间共享。定义两个std::condition_variable的引用,即m_CondVarAddPersonm_CondVarRemovePerson。它们将用于线程之间的通信。定义两个std::mutex变量的引用,即m_AddLockm_RemoveLock。它们将用于接收对条件变量的访问。最后,在私有部分中,定义两个函数,它们将是我们线程的启动函数 - runCreating()runRemoving()。接下来,定义两个将触发条件变量的函数,即notifyCreated()notifyRemoved()。在公共部分中,定义一个构造函数,它将所有在私有部分中定义的引用作为参数。最后,定义一个析构函数。这将确保其他默认生成的函数被删除:
#ifndef PERSON_GENERATOR_HPP
#define PERSON_GENERATOR_HPP
#include "Persons.hpp"
#include <condition_variable>
#include <thread>
class PersonGenerator
{
public:
    PersonGenerator(Persons& persons,
            std::condition_variable& add_person,
            std::condition_variable& remove_person,
            std::mutex& add_lock,
            std::mutex& remove_lock,
            bool& addNotified,
            bool& removeNotified);
    ~PersonGenerator();
    PersonGenerator(const PersonGenerator&) = delete;
    PersonGenerator(PersonGenerator&&) = delete;
    PersonGenerator& operator=(const PersonGenerator&) = delete;
    PersonGenerator& operator=(PersonGenerator&&) = delete;
private:
    void runCreating();
    void runRemoving();
    void notifyCreated();
    void notifyRemoved();
private:
    std::thread m_CreateThread;
    std::thread m_RemoveThread;
    Persons& m_CreatedPersons;
    // to notify about creating new person
    std::condition_variable& m_CondVarAddPerson;
    std::mutex& m_AddLock;
    bool& m_AddNotified;
    // to notify that person needs to be removed
    std::condition_variable& m_CondVarRemovePerson;
    std::mutex& m_RemoveLock;
    bool& m_RemoveNotified;
};
#endif // PERSON_GENERATOR_HPP
  1. 现在,转到源文件。包括<stdlib.h>文件,以便我们可以访问rand()srand()函数,这些函数用于生成随机数。包括<time.h>头文件,以便我们可以访问time()函数,以及std::chrono命名空间。它们用于处理时间。包括<ratio>文件,用于 typedefs,以便我们可以使用时间库:
#include "PersonGenerator.hpp"
#include <iostream>
#include <stdlib.h>     /* srand, rand */
#include <time.h>       /* time, chrono */
#include <ratio>        /* std::milli */
  1. 声明构造函数并在初始化程序列表中初始化除线程之外的所有参数。在构造函数体中使用适当的函数初始化线程:
PersonGenerator::PersonGenerator(Persons& persons,
                    std::condition_variable& add_person,
                    std::condition_variable& remove_person,
                    std::mutex& add_lock,
                    std::mutex& remove_lock,
                    bool& addNotified,
                    bool& removeNotified)
    : m_CreatedPersons(persons)
    , m_CondVarAddPerson(add_person)
    , m_AddLock(add_lock)
    , m_AddNotified(addNotified)
    , m_CondVarRemovePerson(remove_person)
    , m_RemoveLock(remove_lock)
    , m_RemoveNotified(removeNotified)
{
    m_CreateThread = std::thread(&PersonGenerator::runCreating, this);
    m_RemoveThread = std::thread(&PersonGenerator::runRemoving, this);
}
  1. 声明一个析构函数,并检查线程是否可连接。如果不可连接,则加入它们:
PersonGenerator::~PersonGenerator()
{
    if (m_CreateThread.joinable())
    {
        m_CreateThread.join();
    }
    if (m_RemoveThread.joinable())
    {
        m_RemoveThread.join();
    }
}
  1. 声明runCreating()函数,这是m_CreateThread线程的启动函数。在这个函数中,我们将在一个无限循环中生成一个从 1 到 10 的随机数,并使当前线程休眠这段时间。之后,创建一个 Person 值,将其添加到共享容器,并通知其他线程:
void PersonGenerator::runCreating()
{
    using namespace std::chrono_literals;
    srand (time(NULL));
    while(true)
    {
        std::chrono::duration<int, std::milli> duration((rand() % 10 + 1)*1000);
        std::this_thread::sleep_for(duration);
        std::cout << "PersonGenerator | runCreating | new person:" << std::endl;
        m_CreatedPersons.add(std::move(Person()));
        notifyCreated();
    }
}
  1. 声明runRemoving()函数,这是m_RemoveThread线程的启动函数。在这个函数中,我们将在一个无限循环中生成一个从 20 到 30 的随机数,并使当前线程休眠这段时间。之后,通知其他线程应该移除一些访问者:
void PersonGenerator::runRemoving()
{
    using namespace std::chrono_literals;
    srand (time(NULL));
    while(true)
    {
        std::chrono::duration<int, std::milli> duration((rand() % 10 + 20)*1000);
        std::this_thread::sleep_for(duration);
        std::cout << "PersonGenerator | runRemoving | somebody has left the gallery:" << std::endl;
        notifyRemoved();
    }
}
  1. 声明notifyCreated()notifyRemoved()函数。在它们的主体中,锁定适当的互斥锁,将适当的布尔变量设置为 true,并在适当的条件变量上调用notify_all()函数:
void PersonGenerator::notifyCreated()
{
    std::unique_lock<std::mutex> lock(m_AddLock);
    m_AddNotified = true;
    m_CondVarAddPerson.notify_all();
}
void PersonGenerator::notifyRemoved()
{
    std::unique_lock<std::mutex> lock(m_RemoveLock);
    m_RemoveNotified = true;
    m_CondVarRemovePerson.notify_all();
}
  1. 最后,我们需要为我们的最后一个类 Watchman 创建文件,即Watchman.hppWatchman.cpp。像往常一样,添加包含保护。包括"Persons.hpp"、<thread><mutex><condition_variable>头文件。定义Watchman类。在私有部分,定义两个std::thread变量,即m_ThreadAddm_ThreadRemove。在一个线程中,我们将新的Person对象移动到适当的队列中,并在另一个线程中异步移除Person对象。定义对共享Persons变量的引用,即m_CreatedPeoplem_PeopleInsidem_PeopleInQueue。如果限制未超出,我们将从m_CreatedPeople列表中获取每个新人,并将其移动到m_PeopleInside列表中。否则,我们将把它们移动到m_PeopleInQueue列表中。它们将在多个线程之间共享。定义两个对std::condition_variable的引用,即m_CondVarAddPersonm_CondVarRemovePerson。它们将用于线程之间的通信。定义两个对std::mutex变量的引用,即m_AddMuxm_RemoveMux。它们将用于接收对条件变量的访问。最后,在私有部分中,定义两个函数,它们将成为我们线程的启动函数——runAdd()runRemove()。在公共部分中,定义一个构造函数,它将所有在私有部分中定义的引用作为参数。现在,定义一个析构函数。确保删除所有其他默认生成的函数:
#ifndef WATCHMAN_HPP
#define WATCHMAN_HPP
#include <mutex>
#include <thread>
#include <condition_variable>
#include "Persons.hpp"
class Watchman
{
public:
    Watchman(std::condition_variable&,
            std::condition_variable&,
            std::mutex&,
            std::mutex&,
            bool&,
            bool&,
            Persons&,
            Persons&,
            Persons&);
    ~Watchman();
    Watchman(const Watchman&) = delete;
    Watchman(Watchman&&) = delete;
    Watchman& operator=(const Watchman&) = delete;
    Watchman& operator=(Watchman&&) = delete;
private:
    void runAdd();
    void runRemove();
private:
    std::thread m_ThreadAdd;
    std::thread m_ThreadRemove;
    std::condition_variable& m_CondVarRemovePerson;
    std::condition_variable& m_CondVarAddPerson;
    std::mutex& m_AddMux;
    std::mutex& m_RemoveMux;
    bool& m_AddNotified;
    bool& m_RemoveNotified;
    Persons& m_PeopleInside;
    Persons& m_PeopleInQueue;
    Persons& m_CreatedPeople;
};
#endif // WATCHMAN_HPP
  1. 现在,转到源文件。包括"Common.hpp"头文件,以便我们可以访问m_CountPeopleInside变量和其他必要的头文件:
#include "Watchman.hpp"
#include "Common.hpp"
#include <iostream>
  1. 声明构造函数,并在初始化列表中初始化除线程之外的所有参数。在构造函数的主体中使用适当的函数初始化线程:
Watchman::Watchman(std::condition_variable& addPerson,
            std::condition_variable& removePerson,
            std::mutex& addMux,
            std::mutex& removeMux,
            bool& addNotified,
            bool& removeNotified,
            Persons& peopleInside,
            Persons& peopleInQueue,
            Persons& createdPeople)
    : m_CondVarRemovePerson(removePerson)
    , m_CondVarAddPerson(addPerson)
    , m_AddMux(addMux)
    , m_RemoveMux(removeMux)
    , m_AddNotified(addNotified)
    , m_RemoveNotified(removeNotified)
    , m_PeopleInside(peopleInside)
    , m_PeopleInQueue(peopleInQueue)
    , m_CreatedPeople(createdPeople)
{
    m_ThreadAdd = std::thread(&Watchman::runAdd, this);
    m_ThreadRemove = std::thread(&Watchman::runRemove, this);
}
  1. 声明一个析构函数,并检查线程是否可连接。如果不可连接,则加入它们:
Watchman::~Watchman()
{
    if (m_ThreadAdd.joinable())
    {
        m_ThreadAdd.join();
    }
    if (m_ThreadRemove.joinable())
    {
        m_ThreadRemove.join();
    }
}
  1. 声明runAdd()函数。在这里,我们创建一个无限循环。在循环中,我们正在等待条件变量。当条件变量通知时,我们从m_CreatedPeople列表中取出人员,并将其移动到适当的列表,即m_PeopleInside,或者如果超出限制,则移动到m_PeopleInQueue。然后,我们检查m_PeopleInQueue列表中是否有人,以及m_PeopleInside是否已满,如果是,则将它们移动到这个列表中:
void Watchman::runAdd()
{
    while (true)
    {
        std::unique_lock<std::mutex> locker(m_AddMux);
        while(!m_AddNotified)
        {
            std::cerr << "Watchman | runAdd | false awakening" << std::endl;
            m_CondVarAddPerson.wait(locker);
        }
        std::cout << "Watchman | runAdd | new person came" << std::endl;
        m_AddNotified = false;
        while (m_CreatedPeople.size() > 0)
        {
            try
            {
                auto person = m_CreatedPeople.get();
                if (m_PeopleInside.size() < CountPeopleInside)
                {
                    std::cout << "Watchman | runAdd | welcome in our The Art Gallery" << std::endl;
                    m_PeopleInside.add(std::move(person));
                }
                else
                {
                    std::cout << "Watchman | runAdd | Sorry, we are full. Please wait" << std::endl;
                    m_PeopleInQueue.add(std::move(person));
                }
            }
            catch(const std::string& e)
            {
                std::cout << e << std::endl;
            }
        }
        std::cout << "Watchman | runAdd | check people in queue" << std::endl;
        if (m_PeopleInQueue.size() > 0)
        {
            while (m_PeopleInside.size() < CountPeopleInside)
            {
                try
                {
                    auto person = m_PeopleInQueue.get();
                    std::cout << "Watchman | runAdd | welcome in our The Art Gallery" << std::endl;
                    m_PeopleInside.add(std::move(person));
                }
                catch(const std::string& e)
                {
                    std::cout << e << std::endl;
                }
            }
        }
    }
}
  1. 接下来,声明runRemove()函数,我们将从m_PeopleInside中移除访问者。同样,在无限循环中,我们正在等待m_CondVarRemovePerson条件变量。当它通知线程时,我们从访问者列表中移除人员。接下来,我们将检查m_PeopleInQueue列表中是否有人,以及是否未超出限制,如果是,则将它们添加到m_PeopleInside中:
void Watchman::runRemove()
{
    while (true)
    {
        std::unique_lock<std::mutex> locker(m_RemoveMux);
        while(!m_RemoveNotified)
        {
            std::cerr << "Watchman | runRemove | false awakening" << std::endl;
            m_CondVarRemovePerson.wait(locker);
        }
        m_RemoveNotified = false;
        if (m_PeopleInside.size() > 0)
        {
            m_PeopleInside.removePerson();
            std::cout << "Watchman | runRemove | good buy" << std::endl;
        }
        else
        {
            std::cout << "Watchman | runRemove | there is nobody in The Art Gallery" << std::endl;
        }
        std::cout << "Watchman | runRemove | check people in queue" << std::endl;
        if (m_PeopleInQueue.size() > 0)
        {
            while (m_PeopleInside.size() < CountPeopleInside)
            {
                try
                {
                    auto person = m_PeopleInQueue.get();
                    std::cout << "Watchman | runRemove | welcome in our The Art Gallery" << std::endl;
                    m_PeopleInside.add(std::move(person));
                }
                catch(const std::string& e)
                {
                    std::cout << e << std::endl;
                }
            }
        }
    }
}
  1. 最后,转到main()函数。首先,创建我们在WatchmanPersonGenerator类中使用的所有共享变量。接下来,创建WatchmanPersonGenerator变量,并将这些共享变量传递给构造函数。在主函数的末尾,读取字符以避免关闭应用程序:
int main()
{
    {
        std::condition_variable g_CondVarRemovePerson;
        std::condition_variable g_CondVarAddPerson;
        std::mutex g_AddMux;
        std::mutex g_RemoveMux;
        bool g_AddNotified = false;;
        bool g_RemoveNotified = false;
        Persons g_PeopleInside;
        Persons g_PeopleInQueue;
        Persons g_CreatedPersons;
        PersonGenerator generator(g_CreatedPersons, g_CondVarAddPerson, g_CondVarRemovePerson,
                        g_AddMux, g_RemoveMux, g_AddNotified, g_RemoveNotified);
        Watchman watchman(g_CondVarAddPerson,
                g_CondVarRemovePerson,
                g_AddMux,
                g_RemoveMux,
                g_AddNotified,
                g_RemoveNotified,
                g_PeopleInside,
                g_PeopleInQueue,
                g_CreatedPersons);
    }
    char a;
    std::cin >> a;
    return 0;
}
  1. 编译并运行应用程序。在终端中,您将看到来自不同线程的日志,说明创建和移动人员从一个列表到另一个列表。您的输出将类似于以下屏幕截图:

图 5.27:应用程序执行的结果

图 5.27:应用程序执行的结果

正如您所看到的,所有线程之间都以非常简单和清晰的方式进行通信。我们通过使用互斥锁来保护我们的共享数据,以避免竞争条件。在这里,我们使用异常来警告空列表,并在线程函数中捕获它们,以便我们的线程自行处理异常。我们还在析构函数中检查线程是否可连接之前加入它。这使我们能够避免程序意外终止。因此,这个小项目展示了我们在处理线程时的技能。

第六章-流和 I/O

活动 1 艺术画廊模拟器的记录系统

线程安全的记录器允许我们同时将数据输出到终端。我们通过从std::ostringstream类继承并使用互斥锁进行同步来实现此记录器。我们将实现一个提供格式化输出接口的类,我们的记录器将使用它来扩展基本输出。我们定义了不同日志级别的宏定义,以提供易于使用和清晰的接口。按照以下步骤完成此活动:

  1. 从 Lesson6\中打开项目。

  2. 在**src/**目录中创建一个名为 logger 的新目录。您将获得以下层次结构:图 6.25:项目的层次结构

图 6.25:项目的层次结构
  1. 创建名为LoggerUtils的头文件和源文件。在LoggerUtils.hpp中,添加包括保护。包括<string>头文件以添加对字符串的支持。定义一个名为 logger 的命名空间,然后定义一个嵌套命名空间叫做utils。在utils命名空间中,声明LoggerUtils类。

  2. 在公共部分,声明以下静态函数:getDateTimegetThreadIdgetLoggingLevelgetFileAndLinegetFuncNamegetInFuncNamegetOutFuncName。您的类应如下所示:

#ifndef LOGGERUTILS_HPP_
#define LOGGERUTILS_HPP_
#include <string>
namespace logger
{
namespace utils
{
class LoggerUtils
{
public:
     static std::string getDateTime();
     static std::string getThreadId();
     static std::string getLoggingLevel(const std::string& level);
     static std::string getFileAndLine(const std::string& file, const int& line);
     static std::string getFuncName(const std::string& func);
     static std::string getInFuncName(const std::string& func);
     static std::string getOutFuncName(const std::string& func);
};
} // namespace utils
} // namespace logger
#endif /* LOGGERUTILS_HPP_ */
  1. LoggerUtils.cpp中,添加所需的包括:"LoggerUtils.hpp"头文件,<sstream>用于std::stringstream支持,<ctime>用于日期和时间支持:
#include "LoggerUtils.hpp"
#include <sstream>
#include <ctime>
#include <thread>
  1. 进入loggerutils命名空间。编写所需的函数定义。在getDateTime()函数中,使用localtime()函数获取本地时间。使用strftime()函数将其格式化为字符串。使用std::stringstream将其转换为所需格式:
std::string LoggerUtils::getDateTime()
{
     time_t rawtime;
     struct tm * timeinfo;
     char buffer[80];
     time (&rawtime);
     timeinfo = localtime(&rawtime);
     strftime(buffer,sizeof(buffer),"%d-%m-%YT%H:%M:%S",timeinfo);
     std::stringstream ss;
     ss << "[";
     ss << buffer;
     ss << "]";
     return ss.str();
}
  1. getThreadId()函数中,获取当前线程 ID 并使用std::stringstream将其转换为所需格式:
std::string LoggerUtils::getThreadId()
{
     std::stringstream ss;
     ss << "[";
     ss << std::this_thread::get_id();
     ss << "]";
     return ss.str();
}
  1. getLoggingLevel()函数中,使用std::stringstream将给定的字符串转换为所需格式:
std::string LoggerUtils::getLoggingLevel(const std::string& level)
{
     std::stringstream ss;
     ss << "[";
     ss << level;
     ss << "]";
     return ss.str();
}
  1. getFileAndLine()函数中,使用std::stringstream将给定的文件和行转换为所需格式:
std::string LoggerUtils::getFileAndLine(const std::string& file, const int& line)
{
     std::stringstream ss;
     ss << " ";
     ss << file;
     ss << ":";
     ss << line;
     ss << ":";
     return ss.str();
}
  1. getFuncName()函数中,使用std::stringstream将函数名转换为所需格式:
std::string LoggerUtils::getFuncName(const std::string& func)
{
     std::stringstream ss;
     ss << " --- ";
     ss << func;
     ss << "()";
     return ss.str();
}
  1. getInFuncName()函数中,使用std::stringstream将函数名转换为所需格式。
std::string LoggerUtils::getInFuncName(const std::string& func)
{
     std::stringstream ss;
     ss << " --> ";
     ss << func;
     ss << "()";
     return ss.str();
}
  1. getOutFuncName()函数中,使用std::stringstream将函数名转换为所需格式:
std::string LoggerUtils::getOutFuncName(const std::string& func)
{
     std::stringstream ss;
     ss << " <-- ";
     ss << func;
     ss << "()";
     return ss.str();
}
  1. 创建一个名为LoggerMacroses.hpp的头文件。添加包含保护。为每个LoggerUtils函数创建宏定义:DATETIME用于getDateTime()函数,THREAD_ID用于getThreadId()函数,LOG_LEVEL用于getLoggingLevel()函数,FILE_LINE用于getFileAndLine()函数,FUNC_NAME用于getFuncName()函数,FUNC_ENTRY_NAME用于getInFuncName()函数,FUNC_EXIT_NAME用于getOutFuncName()函数。结果,头文件应如下所示:
#ifndef LOGGERMACROSES_HPP_
#define LOGGERMACROSES_HPP_
#define DATETIME \
     logger::utils::LoggerUtils::getDateTime()
#define THREAD_ID \
     logger::utils::LoggerUtils::getThreadId()
#define LOG_LEVEL( level ) \
     logger::utils::LoggerUtils::getLoggingLevel(level)
#define FILE_LINE \
     logger::utils::LoggerUtils::getFileAndLine(__FILE__, __LINE__)
#define FUNC_NAME \
     logger::utils::LoggerUtils::getFuncName(__FUNCTION__)
#define FUNC_ENTRY_NAME \
     logger::utils::LoggerUtils::getInFuncName(__FUNCTION__)
#define FUNC_EXIT_NAME \
     logger::utils::LoggerUtils::getOutFuncName(__FUNCTION__)
#endif /* LOGGERMACROSES_HPP_ */
  1. 创建一个名为StreamLogger的头文件和源文件。在StreamLogger.hpp中,添加所需的包含保护。包含LoggerMacroses.hppLoggerUtils.hpp头文件。然后,包含<sstream>头文件以支持std::ostringstream,包含<thread>头文件以支持std::thread,以及包含<mutex>头文件以支持std::mutex
#include "LoggerMacroses.hpp"
#include "LoggerUtils.hpp"
#include <sstream>
#include <thread>
#include <mutex>
  1. 进入namespace logger。声明StreamLogger类,它继承自std::ostringstream类。这种继承允许我们使用重载的左移操作符<<进行记录。我们不设置输出设备,因此输出不会执行 - 只是存储在内部缓冲区中。在私有部分,声明一个名为m_mux的静态std::mutex变量。声明常量字符串,以便存储日志级别、文件和行以及函数名。在公共部分,声明一个以日志级别、文件和行以及函数名为参数的构造函数。声明一个类析构函数。类声明应如下所示:
namespace logger
{
class StreamLogger : public std::ostringstream
{
public:
     StreamLogger(const std::string logLevel,
                  const std::string fileLine,
                  const std::string funcName);
     ~StreamLogger();
private:
     static std::mutex m_mux;
     const std::string m_logLevel;
     const std::string m_fileLine;
     const std::string m_funcName;
};
} // namespace logger
  1. StreamLogger.cpp中,包含StreamLogger.hpp<iostream>头文件以支持std::cout。进入logger命名空间。定义构造函数并在初始化列表中初始化所有成员。然后,定义析构函数并进入其作用域。锁定m_mux互斥体。如果内部缓冲区为空,则仅输出日期和时间、线程 ID、日志级别、文件和行以及函数名。结果,我们将得到以下格式的行:[dateTtime][threadId][logLevel][file:line: ][name() --- ]。如果内部缓冲区包含任何数据,则在末尾输出相同的字符串与缓冲区。结果,我们将得到以下格式的行:[dateTtime][threadId][logLevel][file:line: ][name() --- ] | message。完整的源文件应如下所示:
#include "StreamLogger.hpp"
#include <iostream>
std::mutex logger::StreamLogger::m_mux;
namespace logger
{
StreamLogger::StreamLogger(const std::string logLevel,
                  const std::string fileLine,
                  const std::string funcName)
          : m_logLevel(logLevel)
          , m_fileLine(fileLine)
          , m_funcName(funcName)
{}
StreamLogger::~StreamLogger()
{
     std::lock_guard<std::mutex> lock(m_mux);
     if (this->str().empty())
     {
          std::cout << DATETIME << THREAD_ID << m_logLevel << m_fileLine << m_funcName << std::endl;
     }
     else
     {
          std::cout << DATETIME << THREAD_ID << m_logLevel << m_fileLine << m_funcName << " | " << this->str() << std::endl;
     }
}
}
  1. 创建一个名为Logger.hpp的头文件并添加所需的包含保护。包含StreamLogger.hppLoggerMacroses.hpp头文件。接下来,为不同的日志级别创建宏定义:LOG_TRACE()LOG_DEBUG()LOG_WARN()LOG_TRACE()LOG_INFO()LOG_ERROR()LOG_TRACE_ENTRY()LOG_TRACE_EXIT()。完整的头文件应如下所示:
#ifndef LOGGER_HPP_
#define LOGGER_HPP_
#include "StreamLogger.hpp"
#include "LoggerMacroses.hpp"
#define LOG_TRACE() logger::StreamLogger{LOG_LEVEL("Trace"), FILE_LINE, FUNC_NAME}
#define LOG_DEBUG() logger::StreamLogger{LOG_LEVEL("Debug"), FILE_LINE, FUNC_NAME}
#define LOG_WARN() logger::StreamLogger{LOG_LEVEL("Warning"), FILE_LINE, FUNC_NAME}
#define LOG_TRACE() logger::StreamLogger{LOG_LEVEL("Trace"), FILE_LINE, FUNC_NAME}
#define LOG_INFO() logger::StreamLogger{LOG_LEVEL("Info"), FILE_LINE, FUNC_NAME}
#define LOG_ERROR() logger::StreamLogger{LOG_LEVEL("Error"), FILE_LINE, FUNC_NAME}
#define LOG_TRACE_ENTRY() logger::StreamLogger{LOG_LEVEL("Error"), FILE_LINE, FUNC_ENTRY_NAME}
#define LOG_TRACE_EXIT() logger::StreamLogger{LOG_LEVEL("Error"), FILE_LINE, FUNC_EXIT_NAME}
#endif /* LOGGER_HPP_ */
  1. 用适当的宏定义调用替换所有std::cout调用。在Watchman.cpp源文件中包含logger/Logger.hpp头文件。在runAdd()函数中,用不同日志级别的宏定义替换所有std::cout的实例。runAdd()函数应如下所示:
void Watchman::runAdd()
{
     while (true)
     {
          std::unique_lock<std::mutex> locker(m_AddMux);
          while(!m_AddNotified)
          {
               LOG_DEBUG() << "Spurious awakening";
               m_CondVarAddPerson.wait(locker);
          }
          LOG_INFO() << "New person came";
          m_AddNotified = false;
          while (m_CreatedPeople.size() > 0)
          {
               try
               {
                    auto person = m_CreatedPeople.get();
                    if (m_PeopleInside.size() < CountPeopleInside)
                    {
                         LOG_INFO() << "Welcome in the our Art Gallery";
                         m_PeopleInside.add(std::move(person));
                    }
                    else
                    {
                         LOG_INFO() << "Sorry, we are full. Please wait";
                         m_PeopleInQueue.add(std::move(person));
                    }
               }
               catch(const std::string& e)
               {
                    LOG_ERROR() << e;
               }
          }
          LOG_TRACE() << "Check people in queue";
          if (m_PeopleInQueue.size() > 0)
          {
               while (m_PeopleInside.size() < CountPeopleInside)
               {
                    try
                    {
                         auto person = m_PeopleInQueue.get();
                         LOG_INFO() << "Welcome in the our Art Gallery";
                         m_PeopleInside.add(std::move(person));
                    }
                    catch(const std::string& e)
                    {
                         LOG_ERROR() << e;
                    }
               }
          }
     }
}
  1. 注意我们如何使用我们的新记录器。我们用括号调用宏定义,并使用左移操作符:
LOG_ERROR() << e;
Or
LOG_INFO() << "Welcome in the our Art Gallery";
  1. 对代码的其余部分进行相同的替换。

  2. 构建并运行应用程序。在终端中,您将看到来自不同线程的不同日志级别的日志消息,并带有有用的信息。一段时间后,您将获得类似以下的输出:

图 6.26:活动项目的执行结果

图 6.26:活动项目的执行结果

如您所见,阅读和理解日志非常容易。如果需要,您可以轻松地更改StreamLogger类以将日志写入文件系统中的文件。您可以添加任何其他您可能需要用于调试应用程序的信息,例如输出函数参数。您还可以轻松地重写自定义类型的左移操作符以输出调试信息。

在这个项目中,我们运用了本章学到的许多东西。我们为线程安全输出创建了一个额外的流,将输出格式化为所需的表示形式,使用std::stringstream来格式化数据,并使用宏定义方便地记录器使用。因此,这个项目展示了我们在处理并发 I/O 方面的技能。

第七章 - 每个人都会跌倒,重要的是如何重新站起来 - 测试和调试

活动 1:使用测试用例检查函数的准确性并理解测试驱动开发(TDD)

在这个活动中,我们将开发函数来解析RecordFile.txtCurrencyConversion.txt文件,并编写测试用例来检查函数的准确性。按照以下步骤实施此活动:

  1. 创建一个名为parse.conf的配置文件并编写配置。

  2. 请注意,这里只有两个变量是感兴趣的,即currencyFilerecordFile。其余的是为其他环境变量准备的:

CONFIGURATION_FILE
currencyFile = ./CurrencyConversion.txt
recordFile = ./RecordFile.txt
DatabaseServer = 192.123.41.112
UserId = sqluser
Password = sqluser 
RestApiServer = 101.21.231.11
LogFilePath = /var/project/logs
  1. 创建一个名为CommonHeader.h的头文件,并声明所有实用函数,即isAllNumbers()isDigit()parseLine()checkFile()parseConfig()parseCurrencyParameters()fillCurrencyMap()parseRecordFile()checkRecord()displayCurrencyMap()displayRecords()
#ifndef __COMMON_HEADER__H
#define __COMMON_HEADER__H
#include<iostream>
#include<cstring>
#include<fstream>
#include<vector>
#include<string>
#include<map>
#include<sstream>
#include<iterator>
#include<algorithm>
#include<iomanip>
using namespace std;
// Forward declaration of global variables. 
extern string configFile;
extern string recordFile;
extern string currencyFile;
extern map<string, float> currencyMap;
struct record;
extern vector<record>      vecRecord;
//Structure to hold Record Data . 
struct record{
    int     customerId;
    string  firstName;
    string  lastName;
    int     orderId;
    int     productId;
    int     quantity;
    float   totalPriceRegional;
    string  currency;
    float   totalPriceUsd;

    record(vector<string> & in){
        customerId      = atoi(in[0].c_str());
        firstName       = in[1];
        lastName        = in[2];
        orderId         = atoi(in[3].c_str());
        productId       = atoi(in[4].c_str());
        quantity        = atoi(in[5].c_str());
        totalPriceRegional = static_cast<float>(atof(in[6].c_str()));
        currency        = in[7];
        totalPriceUsd   = static_cast<float>(atof(in[8].c_str()));
    }
};
// Declaration of Utility Functions.. 
string trim (string &);
bool isAllNumbers(const string &);
bool isDigit(const string &);
void parseLine(ifstream &, vector<string> &, char);
bool checkFile(ifstream &, string &, string, char, string &);
bool parseConfig();
bool parseCurrencyParameters( vector<string> &);
bool fillCurrencyMap();
bool parseRecordFile();
bool checkRecord(vector<string> &);
void displayCurrencyMap();
ostream& operator<<(ostream &, const record &);
void displayRecords();
#endif
  1. 创建一个名为trim()函数的文件:
#include<CommonHeader.h>
// Utility function to remove spaces and tabs from start of string and end of string.. 
string trim (string &str) { // remove space and tab from string.
    string res("");
    if ((str.find(' ') != string::npos) || (str.find(' ') != string::npos)){ // if space or tab found.. 
        size_t begin, end;
        if ((begin = str.find_first_not_of(" \t")) != string::npos){ // if string is not empty.. 
            end = str.find_last_not_of(" \t");
            if ( end >= begin )
                res = str.substr(begin, end - begin + 1);
        }
    }else{
        res = str; // No space or tab found.. 
    }
    str = res;
    return res;
}
  1. 将以下代码写入以定义isAllNumbers()isDigit()parseLine()函数:
// Utility function to check if string contains only digits ( 0-9) and only single '.' 
// eg . 1121.23 , .113, 121\. are valid, but 231.14.143 is not valid.
bool isAllNumbers(const string &str){ // make sure, it only contains digit and only single '.' if any 
    return ( all_of(str.begin(), str.end(), [](char c) { return ( isdigit(c) || (c == '.')); }) 
             && (count(str.begin(), str.end(), '.') <= 1) );
}
//Utility function to check if string contains only digits (0-9).. 
bool isDigit(const string &str){
    return ( all_of(str.begin(), str.end(), [](char c) { return isdigit(c); }));
}
// Utility function, where single line of file <infile> is parsed using delimiter. 
// And store the tokens in vector of string. 
void parseLine(ifstream &infile, vector<string> & vec, char delimiter){
    string line, token;
    getline(infile, line);
    istringstream ss(line);
    vec.clear();
    while(getline(ss, token, delimiter)) // break line using delimiter
        vec.push_back(token);  // store tokens in vector of string
}
  1. 将以下代码写入以定义parseCurrencyParameters()checkRecord()函数:
// Utility function to check if vector string of 2 strings contain correct 
// currency and conversion ratio. currency should be 3 characters, conversion ratio
// should be in decimal number format. 
bool parseCurrencyParameters( vector<string> & vec){
    trim(vec[0]);  trim(vec[1]);
    return ( (!vec[0].empty()) && (vec[0].size() == 3) && (!vec[1].empty()) && (isAllNumbers(vec[1])) );
}
// Utility function, to check if vector of string has correct format for records parsed from Record File. 
// CustomerId, OrderId, ProductId, Quantity should be in integer format
// TotalPrice Regional and USD should be in decimal number format
// Currecny should be present in map. 
bool checkRecord(vector<string> &split){
    // Trim all string in vector
    for (auto &s : split)
        trim(s);

    if ( !(isDigit(split[0]) && isDigit(split[3]) && isDigit(split[4]) && isDigit(split[5])) ){
        cerr << "ERROR: Record with customer id:" << split[0] << " doesnt have right DIGIT parameter" << endl;
        return false;
    }
    if ( !(isAllNumbers(split[6]) && isAllNumbers(split[8])) ){
        cerr << "ERROR: Record with customer id:" << split[0] << " doesnt have right NUMBER parameter" << endl;
        return false;
    }
    if ( currencyMap.find(split[7]) == currencyMap.end() ){
        cerr << "ERROR: Record with customer id :" << split[0] << " has currency :" << split[7] << " not present in map" << endl;
        return false;
    }
    return true;
}
  1. 将以下代码写入以定义checkFile()函数:
// Function to test initial conditions of file.. 
// Check if file is present and has correct header information. 
bool checkFile(ifstream &inFile, string &fileName, string parameter, char delimiter, string &error){
    bool flag = true;
    inFile.open(fileName);
    if ( inFile.fail() ){
        error = "Failed opening " + fileName + " file, with error: " + strerror(errno);
        flag = false;
    }
    if (flag){
        vector<string> split;
        // Parse first line as header and make sure it contains parameter as first token. 
        parseLine(inFile, split, delimiter);
        if (split.empty()){
            error = fileName + " is empty";
            flag = false;
        } else if ( split[0].find(parameter) == string::npos ){
            error = "In " + fileName + " file, first line doesnt contain header ";
            flag = false;
        }
    }
    return flag;
}
  1. 将以下代码写入以定义parseConfig()函数:
// Function to parse Config file. Each line will have '<name> = <value> format
// Store CurrencyConversion file and Record File parameters correctly. 
bool parseConfig() {
    ifstream coffle;
    string error;
    if (!checkFile(confFile, configFile, "CONFIGURATION_FILE", '=', error)){
        cerr << "ERROR: " << error << endl;
        return false;
    }
    bool flag = true;
    vector<string> split;
    while (confFile.good()){
        parseLine(confFile, split, '=');
        if ( split.size() == 2 ){ 
            string name = trim(split[0]);
            string value = trim(split[1]);
            if ( name == "currencyFile" )
                currencyFile = value;
            else if ( name == "recordFile")
                recordFile = value;
        }
    }
    if ( currencyFile.empty() || recordFile.empty() ){
        cerr << "ERROR : currencyfile or recordfile not set correctly." << endl;
        flag = false;
    }
    return flag;
}
  1. 将以下代码写入以定义fillCurrencyMap()函数:
// Function to parse CurrencyConversion file and store values in Map.
bool fillCurrencyMap() {
    ifstream currFile;
    string error;
    if (!checkFile(currFile, currencyFile, "Currency", '|', error)){
        cerr << "ERROR: " << error << endl;
        return false;
    }
    bool flag = true;
    vector<string> split;
    while (currFile.good()){
        parseLine(currFile, split, '|');
        if (split.size() == 2){
            if (parseCurrencyParameters(split)){
                currencyMap[split[0]] = static_cast<float>(atof(split[1].c_str())); // make sure currency is valid.
            } else {
                cerr << "ERROR: Processing Currency Conversion file for Currency: "<< split[0] << endl;
                flag = false;
                break;
            }
        } else if (!split.empty()){
            cerr << "ERROR: Processing Currency Conversion , got incorrect parameters for Currency: " << split[0] << endl;
            flag = false;
            break;
        }
    }
    return flag;
}
  1. 将以下代码写入以定义parseRecordFile()函数:
// Function to parse Record File .. 
bool parseRecordFile(){
    ifstream recFile;
    string error;
    if (!checkFile(recFile, recordFile, "Customer Id", '|', error)){
        cerr << "ERROR: " << error << endl;
        return false;
    }
    bool flag = true;
    vector<string> split;
    while(recFile.good()){
        parseLine(recFile, split, '|');
        if (split.size() == 9){ 
            if (checkRecord(split)){
                vecRecord.push_back(split); //Construct struct record and save it in vector... 
            }else{
                cerr << "ERROR : Parsing Record, for Customer Id: " << split[0] << endl;
                flag = false;
                break;
            }
        } else if (!split.empty()){
            cerr << "ERROR: Processing Record, for Customer Id: " << split[0] << endl;
            flag = false;
            break;
        }
    }
    return flag;
}
  1. 将以下代码写入以定义displayCurrencyMap()函数:
void displayCurrencyMap(){

    cout << "Currency MAP :" << endl;
    for (auto p : currencyMap)
        cout << p.first <<"  :  " << p.second << endl;
    cout << endl;
}
ostream& operator<<(ostream& os, const record &rec){
    os << rec.customerId <<"|" << rec.firstName << "|" << rec.lastName << "|" 
       << rec.orderId << "|" << rec.productId << "|" << rec.quantity << "|" 
       << fixed << setprecision(2) << rec.totalPriceRegional << "|" << rec.currency << "|" 
       << fixed << setprecision(2) << rec.totalPriceUsd << endl;
    return os;
}
  1. 将以下代码写入以定义displayRecords()函数:
void displayRecords(){
    cout << " Displaying records with '|' delimiter" << endl;
    for (auto rec : vecRecord){
        cout << rec;
    }
    cout << endl;
}
  1. 创建名为parseConfig()fillCurrencyMap()parseRecordFile()函数的文件:
#include <CommonHeader.h>
// Global variables ... 
string configFile = "./parse.conf";
string recordFile;
string currencyFile;
map<string, float>  currencyMap;
vector<record>      vecRecord;
int main(){
    // Read Config file to set global configuration variables. 
    if (!parseConfig()){
        cerr << "Error parsing Config File " << endl;
        return false;
    }
    // Read Currency file and fill map
    if (!fillCurrencyMap()){
        cerr << "Error setting CurrencyConversion Map " << endl;
        return false;
    }
    if (!parseRecordFile()){
        cerr << "Error parsing Records File " << endl;
        return false;
    }
        displayCurrencyMap();
    displayRecords();
    return 0;
}
  1. 打开编译器。编译并执行已生成的Util.oParseFiles文件:图 7.25:生成的新文件
图 7.25:生成的新文件
  1. 运行ParseFiles可执行文件后,我们将收到以下输出:图 7.26:生成的新文件
图 7.26:生成的新文件
  1. 创建一个名为trim函数的文件:
#include<gtest/gtest.h>
#include"../CommonHeader.h"
using namespace std;
// Global variables ... 
string configFile = "./parse.conf";
string recordFile;
string currencyFile;
map<string, float>  currencyMap;
vector<record>      vecRecord;
void setDefault(){
    configFile = "./parse.conf";
    recordFile.clear();
    currencyFile.clear();
    currencyMap.clear();
    vecRecord.clear();
}
// Test Cases for trim function ... 
TEST(trim, empty){
    string str="    ";
    EXPECT_EQ(trim(str), string());
}
TEST(trim, start_space){
    string str = "   adas";
    EXPECT_EQ(trim(str), string("adas"));
}
TEST(trim, end_space){
    string str = "trip      ";
    EXPECT_EQ(trim(str), string("trip"));
}
TEST(trim, string_middle){
    string str = "  hdgf   ";
    EXPECT_EQ(trim(str), string("hdgf"));
}
TEST(trim, single_char_start){
    string str = "c  ";
    EXPECT_EQ(trim(str), string("c"));
}
TEST(trim, single_char_end){
    string str = "   c";
    EXPECT_EQ(trim(str), string("c"));
}
TEST(trim, single_char_middle){
    string str = "      c  ";
    EXPECT_EQ(trim(str), string("c"));
}
  1. isAllNumbers函数编写以下测试用例:
// Test Cases for isAllNumbers function.. 
TEST(isNumber, alphabets_present){
    string str = "11.qwe13";
    ASSERT_FALSE(isAllNumbers(str));
}
TEST(isNumber, special_character_present){
    string str = "34.^%3";
    ASSERT_FALSE(isAllNumbers(str));
}
TEST(isNumber, correct_number){
    string str = "54.765";
    ASSERT_TRUE(isAllNumbers(str));
}
TEST(isNumber, decimal_begin){
    string str = ".624";
    ASSERT_TRUE(isAllNumbers(str));
}
TEST(isNumber, decimal_end){
    string str = "53.";
    ASSERT_TRUE(isAllNumbers(str));
}
  1. isDigit函数编写以下测试用例:
// Test Cases for isDigit funtion... 
TEST(isDigit, alphabet_present){
    string str = "527A";
    ASSERT_FALSE(isDigit(str));
}
TEST(isDigit, decimal_present){
    string str = "21.55";
    ASSERT_FALSE(isDigit(str));
}
TEST(isDigit, correct_digit){
    string str = "9769";
    ASSERT_TRUE(isDigit(str));
}
  1. parseCurrencyParameters函数编写以下测试用例:
// Test Cases for parseCurrencyParameters function
TEST(CurrencyParameters, extra_currency_chararcters){
    vector<string> vec {"ASAA","34.22"};
    ASSERT_FALSE(parseCurrencyParameters(vec));
}
TEST(CurrencyParameters, correct_parameters){
    vector<string> vec {"INR","1.44"};
    ASSERT_TRUE(parseCurrencyParameters(vec));
}
  1. checkFile函数编写以下测试用例:
//Test Cases for checkFile function...
TEST(checkFile, no_file_present){
    string fileName = "./NoFile";
    ifstream infile; 
    string parameter("nothing");
    char delimit =';';
    string err;
    ASSERT_FALSE(checkFile(infile, fileName, parameter, delimit, err));
}
TEST(checkFile, empty_file){
    string fileName = "./emptyFile";
    ifstream infile; 
    string parameter("nothing");
    char delimit =';';
    string err;
    ASSERT_FALSE(checkFile(infile, fileName, parameter, delimit, err));
}
TEST(checkFile, no_header){
    string fileName = "./noHeaderFile";
    ifstream infile; 
    string parameter("header");
    char delimit ='|';
    string err;
    ASSERT_FALSE(checkFile(infile, fileName, parameter, delimit, err));
}
TEST(checkFile, incorrect_header){
    string fileName = "./correctHeaderFile";
    ifstream infile; 
    string parameter("header");
    char delimit ='|';
    string err;
    ASSERT_FALSE(checkFile(infile, fileName, parameter, delimit, err));
}
TEST(checkFile, correct_file){
    string fileName = "./correctHeaderFile";
    ifstream infile; 
    string parameter("Currency");
    char delimit ='|';
    string err;
    ASSERT_TRUE(checkFile(infile, fileName, parameter, delimit, err));
}

注意

在前述函数中用作输入参数的NoFileemptyFilenoHeaderFilecorrectHeaderFile文件可以在此处找到:github.com/TrainingByPackt/Advanced-CPlusPlus/tree/master/Lesson7/Activity01

  1. parseConfig函数编写以下测试用例:
//Test Cases for parseConfig function...
TEST(parseConfig, missing_currency_file){
    setDefault();
    configFile = "./parseMissingCurrency.conf";
    ASSERT_FALSE(parseConfig());
}
TEST(parseConfig, missing_record_file){
    setDefault();
    configFile = "./parseMissingRecord.conf";
    ASSERT_FALSE(parseConfig());
}
TEST(parseConfig, correct_config_file){
    setDefault();
    configFile = "./parse.conf";
    ASSERT_TRUE(parseConfig());
}

注意

在前述函数中用作输入参数的parseMissingCurrency.confparseMissingRecord.confparse.conf文件可以在此处找到:github.com/TrainingByPackt/Advanced-CPlusPlus/tree/master/Lesson7/Activity01

  1. fillCurrencyMap函数编写以下测试用例:
//Test Cases for fillCurrencyMap function...
TEST(fillCurrencyMap, wrong_delimiter){
    currencyFile = "./CurrencyWrongDelimiter.txt";
    ASSERT_FALSE(fillCurrencyMap());
}
TEST(fillCurrencyMap, extra_column){
    currencyFile = "./CurrencyExtraColumn.txt";
    ASSERT_FALSE(fillCurrencyMap());
}
TEST(fillCurrencyMap, correct_file){
    currencyFile = "./CurrencyConversion.txt";
    ASSERT_TRUE(fillCurrencyMap());
}

注意

在前面的函数中用作输入参数的CurrencyWrongDelimiter.txtCurrencyExtraColumn.txtCurrencyConversion.txt文件可以在此处找到:github.com/TrainingByPackt/Advanced-CPlusPlus/tree/master/Lesson7/Activity01

  1. 为 parseRecordFile 函数编写以下测试用例:
//Test Cases for parseRecordFile function...
TEST(parseRecordFile, wrong_delimiter){
    recordFile = "./RecordWrongDelimiter.txt";
    ASSERT_FALSE(parseRecordFile());
}
TEST(parseRecordFile, extra_column){
    recordFile = "./RecordExtraColumn.txt";
    ASSERT_FALSE(parseRecordFile());
}
TEST(parseRecordFile, correct_file){
    recordFile = "./RecordFile.txt";
    ASSERT_TRUE(parseRecordFile());
}

在前面的函数中用作输入参数的RecordWrongDelimiter.txtRecordExtraColumn.txtRecordFile.txt文件可以在此处找到:github.com/TrainingByPackt/Advanced-CPlusPlus/tree/master/Lesson7/Activity01

  1. 打开编译器。通过编写以下命令编译和执行Util.cppParseFileTestCases.cpp文件:
g++ -c -g -Wall ../Util.cpp -I../
g++ -c -g -Wall ParseFileTestCases.cpp 
g++ -g -Wall Util.o ParseFileTestCases.o -lgtest -lgtest_main -pthread -o ParseFileTestCases

以下是此的截图。您将看到所有命令都存储在Test.make脚本文件中。一旦执行,它将创建用于单元测试的二进制程序ParseFileTestCases。您还会注意到在 Project 中创建了一个名为unitTesting的目录。在此目录中,编写了所有与单元测试相关的代码,并创建了一个二进制文件。此外,还通过编译Util.cpp文件来创建项目的依赖库Util.o

图 7.27:执行脚本文件中的所有命令
  1. 键入以下命令以运行所有测试用例:
./ParseFileTestCases

屏幕上的输出将显示总共 31 个测试运行,其中包括 8 个测试套件。它还将显示各个测试套件的统计信息,以及通过/失败的结果:

图 7.28:所有测试都正常运行

图 7.28:所有测试都正常运行

以下是下一个测试的截图:

图 7.29:所有测试都正常运行

图 7.29:所有测试都正常运行

最后,我们通过解析两个文件并使用我们的测试用例来检查我们开发的函数的准确性。这将确保我们的项目在与具有测试用例的不同函数/模块集成时能够正常运行。

第八章 - 需要速度 - 性能和优化

活动 1:优化拼写检查算法

在这个活动中,我们将开发一个简单的拼写检查演示,并尝试逐步加快速度。您可以使用骨架文件Speller.cpp作为起点。执行以下步骤来实现此活动:

  1. 拼写检查的第一个实现(完整代码可以在getMisspelt()函数中找到:
set<string> setDict(vecDict.begin(), vecDict.end());
  1. 循环遍历文本单词,并使用set::count()方法检查不在字典中的单词。将拼写错误的单词添加到结果向量中:
vector<int> ret;
for(int i = 0; i < vecText.size(); ++i)
{
  const string &s = vecText[i];
  if(!setDict.count(s))
  {
    ret.push_back(i);
  }
};
  1. 打开终端。编译程序并按以下方式运行:
$ g++ -O3 Speller1.cpp Timer.cpp
$ ./a.out

将生成以下输出:

图 8.60:第 1 步解决方案的示例输出

图 8.60:第 1 步解决方案的示例输出
  1. 打开程序的unordered_set头文件:
#include <unordered_set>
  1. 接下来,将用于字典的集合类型更改为unordered_set
unordered_set<string> setDict(vecDict.begin(), vecDict.end());
  1. 打开终端。编译程序并按以下方式运行:
$ g++ -O3 Speller2.cpp Timer.cpp
$ ./a.out

将生成以下输出:

图 8.61:第 2 步解决方案的示例输出

图 8.61:第 2 步解决方案的示例输出
  1. 对于第三个也是最终版本,即BKDR函数。添加以下代码来实现这一点:
const size_t SIZE = 16777215;
template<size_t SEED> size_t hasher(const string &s)
{
  size_t h = 0;
  size_t len = s.size();
  for(size_t i = 0; i < len; i++)
  {
    h = h * SEED + s[i];
  }
  return h & SIZE;
}

在这里,我们使用了整数模板参数,以便我们可以使用相同的代码创建任意数量的不同哈希函数。请注意使用16777215常量,它等于2²⁴ - 1。这使我们可以使用快速的按位与运算符,而不是模运算符,以使哈希整数小于SIZE。如果要更改大小,请将其保持为 2 的幂减一。

  1. 接下来,让我们在getMisspelt()中声明一个用于布隆过滤器的vector<bool>,并用字典中的单词填充它。使用三个哈希函数。BKDR 哈希可以使用值如131313131313等进行种子化。添加以下代码来实现这一点:
vector<bool> m_Bloom;
m_Bloom.resize(SIZE);
for(auto i = vecDict.begin(); i != vecDict.end(); ++i)
{
  m_Bloom[hasher<131>(*i)] = true;
  m_Bloom[hasher<3131>(*i)] = true;
  m_Bloom[hasher<31313>(*i)] = true;
}
  1. 编写以下代码创建一个检查单词的循环:
for(int i = 0; i < vecText.size(); ++i)
{
  const string &s = vecText[i];
  bool hasNoBloom = 
          !m_Bloom[hasher<131>(s)] 
      &&  !m_Bloom[hasher<3131>(s)]
      &&  !m_Bloom[hasher<31313>(s)];

  if(hasNoBloom)
  {
    ret.push_back(i);
  }
  else if(!setDict.count(s))
  {
    ret.push_back(i);
  }
}

首先检查布隆过滤器,如果它在字典中找到了这个单词,我们必须像之前一样进行验证。

  1. 打开终端。编译并运行程序如下:
$ g++ -O3 Speller3.cpp Timer.cpp
$ ./a.out

将生成以下输出:

图 8.62:第 3 步解决方案的示例输出

图 8.62:第 3 步解决方案的示例输出

在前面的活动中,我们试图解决一个现实世界的问题并使其更加高效。让我们考虑一下三个步骤中每个实现的一些要点,如下所示:

  • 对于第一个版本,使用std::set的最明显的解决方案是-但是,性能可能会较低,因为集合数据结构是基于二叉树的,查找元素的复杂度为O(log N)

  • 对于第二个版本,我们可以通过切换到使用哈希表作为底层数据结构的std::unordered_set来获得很大的性能提升。如果哈希函数很好,性能将接近O(1)

  • 基于布隆过滤器数据结构的第三个版本需要一些考虑。-布隆过滤器的主要性能优势在于它是一种紧凑的数据结构,实际上并不存储其中的实际元素,因此提供了非常好的缓存性能。

从实现的角度来看,以下准则适用:

  • vector<bool>可以用作后备存储,因为这是一种高效存储和检索位的方式。

  • 布隆过滤器的假阳性百分比应该很小-超过 5%将不高效。

  • 有许多字符串哈希算法-参考实现中使用了BKDR哈希算法。可以在这里找到带有实现的字符串哈希算法的综合列表:www.partow.net/programming/hashfunctions/index.html

  • 所使用的哈希函数数量和布隆过滤器的大小对于获得性能优势非常关键。

  • 在决定布隆过滤器应该使用什么参数时,应考虑数据集的性质-请考虑,在这个例子中,拼写错误的单词很少,大部分都在字典中。

鉴于我们收到的结果,有一些值得探讨的问题:

  • 为什么布隆过滤器的性能改进如此微弱?

  • 使用更大或更小容量的布隆过滤器会有什么影响?

  • 当使用更少或更多的哈希函数时会发生什么?

  • 在什么条件下,这个版本比Speller2.cpp中的版本要快得多?

以下是这些问题的答案:

  • 为什么布隆过滤器的性能改进如此微弱?

std::unordered_set 在达到存储的值之前执行一次哈希操作,可能还有几次内存访问。我们使用的布隆过滤器执行三次哈希操作和三次内存访问。因此,从本质上讲,布隆过滤器所做的工作比哈希表更多。由于我们的字典中只有 31,870 个单词,布隆过滤器的缓存优势就丧失了。这是另一个传统数据结构分析与现实结果不符的案例,因为缓存的原因。

  • 使用更大或更小容量的布隆过滤器会有什么影响?

当使用更大的容量时,哈希冲突的数量减少,假阳性也减少,但缓存行为变差。相反,当使用较小的容量时,哈希冲突和假阳性增加,但缓存行为改善。

  • 当使用更少或更多的哈希函数时会发生什么?

使用的哈希函数越多,误判就越少,反之亦然。

  • 在什么条件下,这个版本比 Speller2.cpp 中的版本快得多?

布隆过滤器在测试少量位的成本低于访问哈希表中的值的成本时效果最好。只有当布隆过滤器的位完全适合缓存而字典不适合时,这一点才成立。

posted @ 2024-05-04 22:45  绝不原创的飞龙  阅读(41)  评论(0编辑  收藏  举报