C++-专家编程:成为熟练的程序员(全)

C++ 专家编程:成为熟练的程序员(全)

原文:annas-archive.org/md5/f9404739e16292672f830e964de1c2e4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将向读者提供关于 C++17 和 C++20 标准的 C++程序的细节,以及它们是如何编译、链接和执行的。它还将涵盖内存管理的工作原理,内存管理问题的最佳实践,类的实现方式,编译器如何优化代码,以及编译器在支持类继承、虚函数和模板方面的方法。

本书还将告诉读者如何将内存管理、面向对象编程、并发和设计模式应用于创建面向世界的生产应用程序。

读者将学习高效数据结构和算法的内部细节,并将了解如何衡量和比较它们,以选择最适合特定问题的内容。

本书将帮助读者将系统设计技能与基本设计模式结合到 C++应用程序中。

此外,本书还介绍了人工智能世界,包括使用 C++编程语言进行机器学习的基础知识。

本书结束时,读者应该有足够的信心,能够使用高效的数据结构和算法设计和构建真实的可扩展 C++应用程序。

本书的读者对象

寻求了解与语言和程序结构相关的细节,或者试图通过深入程序的本质来提高自己的专业知识,以设计可重用、可扩展架构的 C++开发人员将从本书中受益。那些打算使用 C++17 和 C++20 的新特性设计高效数据结构和算法的开发人员也将受益。

本书内容

[第一章]《构建 C++应用程序简介》包括对 C++世界、其应用程序以及语言标准的最新更新的介绍。本章还对 C++涵盖的主题进行了良好的概述,并介绍了代码编译、链接和执行阶段。

[第二章]《使用 C++进行低级编程》专注于讨论 C++数据类型、数组、指针、指针的寻址和操作,以及条件语句、循环、函数、函数指针和结构的低级细节。本章还包括对结构(structs)的介绍。

[第三章]《面向对象编程的细节》深入探讨了类和对象的结构,以及编译器如何实现对象的生命周期。本章结束时,读者将了解继承和虚函数的实现细节,以及 C++中面向对象编程的基本内部细节。

[第四章]《理解和设计模板》介绍了 C++模板、模板函数的示例、模板类、模板特化以及一般的模板元编程。特性和元编程将融入 C++应用程序的魔力。

[第五章]《内存管理和智能指针》深入探讨了内存部分、分配和管理的细节,包括使用智能指针来避免潜在的内存泄漏。

[第六章]《深入 STL 中的数据结构和算法》介绍了数据结构及其 STL 实现。本章还包括对数据结构的比较,并讨论了实际应用的适当性,提供了真实世界的例子。

第七章,函数式编程,着重于函数式编程,这是一种不同的编程范式,使读者能够专注于代码的“功能”而不是“物理”结构。掌握函数式编程为开发人员提供了一种新的技能,有助于为问题提供更好的解决方案。

第八章,并发和多线程,着重于如何通过利用并发使程序运行更快。当高效的数据结构和高效的算法达到程序性能的极限时,并发就会发挥作用。

第九章,设计并发数据结构,着重利用数据结构和并发性来设计基于锁和无锁的并发数据结构。

第十章,设计面向世界的应用程序,着重于将前几章学到的知识融入到使用设计模式设计健壮的现实世界应用程序中。本章还包括了理解和应用领域驱动设计,通过设计一个亚马逊克隆来实现。

第十一章,使用设计模式设计策略游戏,将前几章学到的知识融入到使用设计模式和最佳实践设计策略游戏中。

第十二章,网络和安全,介绍了 C++中的网络编程以及如何利用网络编程技能构建一个 dropbox 后端克隆。本章还包括了如何确保编码最佳实践的讨论。

第十三章,调试和测试,着重于调试 C++应用程序和避免代码中的错误的最佳实践,应用静态代码分析以减少程序中的问题,介绍和应用测试驱动开发和行为驱动开发。本章还包括了行为驱动开发和 TDD 之间的区别以及使用案例。

第十四章,使用 Qt 进行图形用户界面,介绍了 Qt 库及其主要组件。本章还包括了对 Qt 跨平台性质的理解,通过构建一个简单的桌面客户端来延续 dropbox 示例。

第十五章,在机器学习任务中使用 C++,涉及了人工智能概念和领域的最新发展的简要介绍。本章还包括了机器学习和诸如回归分析和聚类等任务的介绍,以及如何构建一个简单的神经网络。

第十六章,实现基于对话框的搜索引擎,涉及将之前章节的知识应用到设计一个高效的搜索引擎中,描述为基于对话框,因为它通过询问(和学习)用户的相应问题来找到正确的文档。

为了充分利用本书

基本的 C++经验,包括对内存管理、面向对象编程和基本数据结构和算法的熟悉,将是一个很大的优势。如果你渴望了解这个复杂程序在幕后是如何工作的,以及想要理解 C++应用设计的编程概念和最佳实践的细节,那么你应该继续阅读本书。

书中涉及的软件/硬件 操作系统要求
g++编译器 Ubuntu Linux 是一个优势,但不是必需的

你还需要在计算机上安装 Qt 框架。相关细节在相关章节中有介绍。

在撰写本书时,并非所有 C++编译器都支持所有新的 C++20 功能,考虑使用最新版本的编译器以测试本章介绍的更多功能。

下载示例代码文件

您可以从您在www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在“搜索”框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩软件解压文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Expert-CPP。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还提供了来自我们丰富书籍和视频目录的其他代码包,网址为github.com/PacktPublishing/。快去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781838552657_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:"前面的代码声明了两个readonly属性,并分配了值"。

代码块设置如下:

Range book = 1..4;
var res = Books[book] ;
Console.WriteLine($"\tElement of array using Range: Books[{book}] => {Books[book]}");

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

private static readonly int num1=5;
private static readonly int num2=6;

任何命令行输入或输出都以以下形式书写:

dotnet --info

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中出现。这是一个例子:"从管理面板中选择系统信息"。

警告或重要提示会以这种形式出现。

提示和技巧会以这种形式出现。

第一部分:C++编程的内部机制

在本节中,读者将学习 C++程序的编译和链接的细节,并深入了解面向对象编程(OOP)、模板和内存管理的细节。

本节包括以下章节:

  • 第一章,构建 C++应用程序简介

  • 第二章,使用 C++进行低级编程

  • 第三章,面向对象编程的细节

  • 第四章,理解和设计模板

  • 第五章,内存管理和智能指针

第一章:构建 C++应用程序的简介

编程语言通过其程序执行模型而有所不同;最常见的是解释型语言和编译型语言。编译器将源代码转换为机器代码,计算机可以在没有中介支持系统的情况下运行。另一方面,解释型语言代码需要支持系统、解释器和虚拟环境才能工作。

C++是一种编译型语言,使得程序运行速度比解释型语言更快。虽然 C++程序应该为每个平台进行编译,但解释型程序可以跨平台操作。

我们将讨论程序构建过程的细节,从编译器处理源代码的阶段开始,到可执行文件的细节(编译器的输出)结束。我们还将了解为什么为一个平台构建的程序在另一个平台上无法运行。

本章将涵盖以下主题:

  • C++20 简介

  • C++预处理器的细节

  • 源代码编译的底层细节

  • 理解链接器及其功能

  • 可执行文件的加载和运行过程

技术要求

使用选项-std=c++2a的 g++编译器用于编译本章中的示例。您可以在本章中找到使用的源文件github.com/PacktPublishing/Expert-CPP

C++20 简介

C++经过多年的发展,现在有了全新的版本,C++20。自 C++11 以来,C++标准大大扩展了语言特性集。让我们来看看新的 C++20 标准中的显著特性。

概念

概念是 C++20 中的一个重要特性,它为类型提供了一组要求。概念背后的基本思想是对模板参数进行编译时验证。例如,要指定模板参数必须具有默认构造函数,我们可以如下使用default_constructible概念:

template <default_constructible T>
void make_T() { return T(); }

在上述代码中,我们错过了typename关键字。相反,我们设置了描述template函数的T参数的概念。

我们可以说概念是描述其他类型的类型 - 元类型,可以这么说。它们允许在类型属性的基础上对模板参数进行编译时验证以及函数调用。我们将在第三章和第四章中详细讨论概念,面向对象编程的细节理解和设计模板

协程

协程是特殊的函数,能够在任何定义的执行点停止并稍后恢复。协程通过以下新关键字扩展了语言:

  • co_await 暂停协程的执行。

  • co_yield 暂停协程的执行,同时返回一个值。

  • co_return 类似于常规的return关键字;它结束协程并返回一个值。看看以下经典示例:

generator<int> step_by_step(int n = 0) {
  while (true) {
    co_yield n++;
  }
}

协程与promise对象相关联。promise对象存储和警报协程的状态。我们将在第八章中深入讨论协程,并发和多线程

范围

ranges库提供了一种新的处理元素范围的方式。要使用它们,您应该包含<ranges>头文件。让我们通过一个例子来看ranges。范围是具有开始和结束的元素序列。它提供了一个begin迭代器和一个end哨兵。考虑以下整数向量:

import <vector>

int main()
{
  std::vector<int> elements{0, 1, 2, 3, 4, 5, 6};
}

范围和范围适配器(|运算符)提供了处理一系列元素的强大功能。例如,查看以下代码:

import <vector>
import <ranges>

int main()
{
  std::vector<int> elements{0, 1, 2, 3, 4, 5, 6};
  for (int current : elements | ranges::view::filter([](int e) { return 
   e % 2 == 0; }))
  {
    std::cout << current << " ";
  }
}

在前面的代码中,我们使用ranges::view::filter()过滤了偶数整数的范围。注意应用于元素向量的范围适配器|。我们将在第七章中讨论范围及其强大的功能,函数式编程

更多的 C++20 功能

C++20 是 C++语言的一个新的重大发布。它包含许多使语言更复杂和灵活的功能。概念范围协程是本书中将讨论的许多功能之一。

最受期待的功能之一是模块,它提供了在模块内声明模块并导出类型和值的能力。您可以将模块视为带有现在多余的包含保护的头文件的改进版本。我们将在本章中介绍 C++20 模块。

除了 C++20 中添加的显着功能之外,还有一系列其他功能,我们将在整本书中讨论:

  • 太空船操作符:operator<=>()。现在可以通过利用operator<=>()来控制运算符重载的冗长。

  • constexpr在语言中占据越来越多的空间。C++20 现在有了consteval函数,constexpr std::vectorstd::string,以及许多其他功能。

  • 数学常数,例如std::number::pistd::number::log2e

  • 线程库的重大更新,包括停止令牌和加入线程。

  • 迭代器概念。

  • 移动视图和其他功能。

为了更好地理解一些新功能,并深入了解语言的本质,我们将从以前的版本开始介绍语言的核心。这将帮助我们找到比旧版本更好的新功能的用途,并且还将有助于支持旧版 C++代码。现在让我们开始了解 C++应用程序的构建过程。

构建和运行程序

您可以使用任何文本编辑器来编写代码,因为最终,代码只是文本。要编写代码,您可以自由选择简单的文本编辑器,如Vim,或者高级的集成开发环境IDE),如MS Visual Studio。情书和源代码之间唯一的区别是后者可能会被称为编译器的特殊程序解释(而情书无法编译成程序,它可能会让您心跳加速)。

为了区分纯文本文件和源代码,使用了特殊的文件扩展名。C++使用.cpp.h扩展名(您可能偶尔也会遇到.cxx.hpp)。在深入细节之前,将编译器视为将源代码转换为可运行程序(称为可执行文件或可执行文件)的工具。从源代码生成可执行文件的过程称为编译。编译 C++程序是一系列复杂任务的序列,最终产生机器代码。机器代码是计算机的本机语言,这就是为什么它被称为机器代码。

通常,C++编译器会解析和分析源代码,然后生成中间代码,对其进行优化,最后生成一个名为目标文件的机器代码文件。您可能已经遇到过目标文件;它们在 Linux 中有单独的扩展名.o,在 Windows 中有单独的扩展名.obj。创建的目标文件包含不仅可以由计算机运行的机器代码。编译通常涉及多个源文件,编译每个源文件会产生一个单独的目标文件。然后,这些目标文件由一个称为链接器的工具链接在一起,形成一个单独的可执行文件。链接器使用存储在目标文件中的附加信息来正确地链接它们(链接将在本章后面讨论)。

以下图表描述了程序构建的阶段:

C++应用程序构建过程包括三个主要步骤:预处理编译链接。所有这些步骤都使用不同的工具完成,但现代编译器将它们封装在一个单一的工具中,为程序员提供了一个更简单的接口。

生成的可执行文件保存在计算机的硬盘上。为了运行它,应将其复制到主内存 RAM 中。复制由另一个名为加载器的工具完成。加载器是操作系统的一部分,它知道应从可执行文件的内容中复制什么和复制到哪里。将可执行文件加载到主内存后,原始可执行文件不会从硬盘中删除。

程序的加载和运行由操作系统(OS)完成。操作系统管理程序的执行,优先级高于其他程序,在完成后卸载程序等。程序的运行副本称为进程。进程是可执行文件的一个实例。

理解预处理

预处理器旨在处理源文件,使其准备好进行编译。预处理器使用预处理器指令,如#define#include等。指令不代表程序语句,而是预处理器的命令,告诉它如何处理源文件的文本。编译器无法识别这些指令,因此每当在代码中使用预处理器指令时,预处理器会在实际编译代码之前相应地解析它们。例如,编译器开始编译之前,以下代码将被更改:

#define NUMBER 41 
int main() { 
  int a = NUMBER + 1; 
  return 0; 
}

使用#define指令定义的所有内容都称为。经过预处理后,编译器以这种形式获得转换后的源代码:

int main() { 
  int a = 41 + 1; 
  return 0;
}

如前所述,预处理器只是处理文本,不关心语言规则或其语法。特别是使用宏定义的预处理器指令,如前面的例子中的#define NUMBER 41,除非你意识到预处理器只是简单地将NUMBER的任何出现替换为41,而不将41解释为整数。对于预处理器来说,以下行都是有效的:

int b = NUMBER + 1; 
struct T {}; // user-defined type 
T t = NUMBER; // preprocessed successfully, but compile error 

这将产生以下代码:

int b = 41 + 1
struct T {};
T t = 41; // error line

当编译器开始编译时,它会发现赋值t = 41是错误的,因为从'int'到'T'没有可行的转换。

甚至使用在语法上正确但存在逻辑错误的宏也是危险的:

#define DOUBLE_IT(arg) (arg * arg) 

预处理器将任何DOUBLE_IT(arg)的出现替换为(arg * arg),因此以下代码将输出16

int st = DOUBLE_IT(4);
std::cout << st;

编译器将接收到这段代码:

int st = (4 * 4);
std::cout << st;

当我们将复杂表达式用作宏参数时会出现问题:

int bad_result = DOUBLE_IT(4 + 1); 
std::cout << bad_result;

直观上,这段代码将产生25,但事实上预处理器只是进行文本处理,而在这种情况下,它会这样替换宏:

int bad_result = (4 + 1 * 4 + 1);
std::cout << bad_result;

这将输出9,显然9不是25

要修复宏定义,需要用额外的括号括住宏参数:

#define DOUBLE_IT(arg) ((arg) * (arg)) 

现在表达式将采用这种形式:

int bad_result = ((4 + 1) * (4 + 1)); 

强烈建议在适用的地方使用const声明,而不是宏定义。

一般来说,应避免使用宏定义。宏容易出错,而 C++提供了一组构造,使得宏的使用已经过时。

如果我们使用constexpr函数,同样的前面的例子将在编译时进行类型检查和处理:

constexpr int double_it(int arg) { return arg * arg; } 
int bad_result = double_it(4 + 1); 

使用constexpr限定符使得能够在编译时评估函数的返回值(或变量的值)。使用const变量重新编写NUMBER定义的示例会更好:

const int NUMBER = 41; 

头文件

预处理器最常见的用法是#include指令,用于在源代码中包含头文件。头文件包含函数、类等的定义:

// file: main.cpp 
#include <iostream> 
#include "rect.h"
int main() { 
  Rect r(3.1, 4.05) 
  std::cout << r.get_area() << std::endl;
}

假设头文件 rect.h 定义如下:

// file: rect.h
struct Rect  
{
private:
  double side1_;
  double side2_;
public:
  Rect(double s1, double s2);
  const double get_area() const;
};

实现包含在 rect.cpp 中:

// file: rect.cpp
#include "rect.h"

Rect::Rect(double s1, double s2)
  : side1_(s1), side2_(s2)
{}

const double Rect::get_area() const {
  return side1_ * side2_;
}

预处理器检查 main.cpprect.cpp 后,将用 main.cpp#include 指令替换为 iostreamrect.h 的相应内容,用 rect.cpp#include 指令替换为 rect.h。C++17 引入了 __has_include 预处理器常量表达式。如果找到指定名称的文件,__has_include 的值为 1,否则为 0

#if __has_include("custom_io_stream.h")
#include "custom_io_stream.h"
#else
#include <iostream>
#endif

在声明头文件时,强烈建议使用所谓的包含保护#ifndef, #define, #endif)来避免双重声明错误。我们将很快介绍这种技术。这些又是预处理指令,允许我们避免以下情况:Square 类在 square*.*h 中定义,它包含 rect.h 以从 Rect 派生 Square

// file: square.h
#include "rect.h"
struct Square : Rect {
  Square(double s);
};

main.cpp 中包含 square.hrect.h 会导致 rect.h 被包含两次:

// file: main.cpp
#include <iostream> 
#include "rect.h" 
#include "square.h"
/* 
  preprocessor replaces the following with the contents of square.h
*/
// code omitted for brevity

预处理后,编译器将以以下形式接收 main.cpp

// contents of the iostream file omitted for brevity 
struct Rect {
  // code omitted for brevity
};
struct Rect {
  // code omitted for brevity
};
struct Square : Rect {
  // code omitted for brevity
};
int main() {
  // code omitted for brevity
}

然后编译器会产生一个错误,因为它遇到了两个类型为 Rect 的声明。头文件应该通过使用包含保护来防止多次包含,方法如下:

#ifndef RECT_H 
#define RECT_H 
struct Rect { ... }; // code omitted for brevity  
#endif // RECT_H 

当预处理器第一次遇到头文件时,RECT_H 未定义,#ifndef#endif 之间的所有内容都将被相应处理,包括 RECT_H 的定义。当预处理器在同一源文件中第二次包含相同的头文件时,它将省略内容,因为 RECT_H 已经被定义。

这些包含保护是控制源文件部分编译的指令的一部分。所有条件编译指令都是 #if, #ifdef, #ifndef, #else, #elif, 和 #endif

条件编译在许多情况下都很有用;其中之一是在所谓的调试模式下记录函数调用。在发布程序之前,建议调试程序并测试逻辑缺陷。您可能想要查看在调用某个函数后代码中发生了什么,例如:

void foo() {
  log("foo() called");
  // do some useful job
}
void start() {
  log("start() called");
  foo();
  // do some useful job
}

每个函数调用 log() 函数,其实现如下:

void log(const std::string& msg) {
#if DEBUG
  std::cout << msg << std::endl;
#endif
}

如果定义了 DEBUGlog() 函数将打印 msg。如果编译项目时启用了 DEBUG(使用编译器标志,如 g++ 中的 -D),那么 log() 函数将打印传递给它的字符串;否则,它将什么也不做。

在 C++20 中使用模块

模块修复了头文件中令人讨厌的包含保护问题。我们现在可以摆脱预处理宏。模块包括两个关键字,importexport。要使用一个模块,我们使用 import。要声明一个模块及其导出的属性,我们使用 export。在列出使用模块的好处之前,让我们看一个简单的使用示例。以下代码声明了一个模块:

export module test;

export int twice(int a) { return a * a; }

第一行声明了名为 test 的模块。接下来,我们声明了 twice() 函数并将其设置为 export。这意味着我们可以有未导出的函数和其他实体,因此它们在模块外部将是私有的。通过导出实体,我们将其设置为模块用户的 public。要使用 module,我们像以下代码中那样导入它:

import test;

int main()
{
  twice(21);
}

模块是 C++ 中期待已久的功能,它在编译和维护方面提供了更好的性能。以下功能使模块在与常规头文件的竞争中更胜一筹:

  • 模块只被导入一次,类似于自定义语言实现支持的预编译头文件。这大大减少了编译时间。未导出的实体对导入模块的翻译单元没有影响。

  • 模块允许通过选择应该导出和不应该导出的单元来表达代码的逻辑结构。模块可以捆绑在一起形成更大的模块。

  • 摆脱之前描述的包含保护等变通方法。我们可以以任何顺序导入模块。不再担心宏的重新定义。

模块可以与头文件一起使用。我们可以在同一个文件中导入和包含头文件,就像下面的例子所示:

import <iostream>;
#include <vector>

int main()
{
  std::vector<int> vec{1, 2, 3};
  for (int elem : vec) std::cout << elem;
}

在创建模块时,您可以在模块的接口文件中导出实体,并将实现移动到其他文件中。逻辑与管理.h.cpp文件相同。

理解编译

C++编译过程由几个阶段组成。一些阶段旨在分析源代码,而其他阶段则生成和优化目标机器代码。以下图表显示了编译的各个阶段:

让我们详细看看这些阶段中的每一个。

标记化

编译器的分析阶段旨在将源代码分割成称为标记的小单元。标记可以是一个单词或一个单一符号,比如=(等号)。标记是源代码的最小单元,对于编译器来说具有有意义的价值。例如,表达式int a = 42;将被分成标记inta=42;。表达式不仅仅是通过空格分割,因为以下表达式也被分成相同的标记(尽管建议不要忘记操作数之间的空格):

int a=42;

使用复杂的方法和正则表达式将源代码分割成标记。这被称为词法分析标记化(分成标记)。对于编译器来说,使用标记化的输入提供了一种更好的方式来构建用于分析代码语法的内部数据结构。让我们看看。

语法分析

在谈论编程语言编译时,我们通常区分两个术语:语法和语义。语法是代码的结构;它定义了标记组合成结构上下文的规则。例如,day nice是英语中的一个语法正确的短语,因为它的标记中没有错误。语义则关注代码的实际含义。也就是说,day nice在语义上是不正确的,应该改为a nice day

语法分析是源代码分析的关键部分,因为标记将被语法和语义地分析,即它们是否具有符合一般语法规则的任何含义。例如,接下来的例子:

int b = a + 0;

对我们来说可能没有意义,因为将零添加到变量不会改变其值,但是编译器在这里并不关心逻辑含义,而是关心代码的语法正确性(缺少分号、缺少右括号等)。检查代码的语法正确性是在编译的语法分析阶段完成的。词法分析将代码分成标记;语法分析检查语法正确性,这意味着如果我们漏掉了一个分号,上述表达式将产生语法错误:

int b = a + 0

g++将报错expected ';' at end of declaration

语义分析

如果前面的表达式是it b = a + 0;,编译器会将其分成标记itb=和其他。我们已经看到it是未知的,但对于编译器来说,这个时候还可以接受。这将导致 g++中的编译错误unknown type name "it"。找到表达式背后的含义是语义分析(解析)的任务。

中间代码生成

在完成所有分析之后,编译器将生成中间代码,这是 C++的轻量级版本,主要是 C。一个简单的例子是:

class A { 
public:
  int get_member() { return mem_; }
private: 
  int mem_; 
};

在分析代码之后,将生成中间代码(这是一个抽象的例子,旨在展示中间代码生成的概念;编译器在实现上可能有所不同)。

struct A { 
  int mem_; 
};
int A_get_member(A* this) { return this->mem_; } 

优化

生成中间代码有助于编译器对代码进行优化。编译器试图大量优化代码。优化不止一次进行。例如,看下面的代码:

int a = 41; 
int b = a + 1; 

在编译期间,这将被优化为这个:

int a = 41; 
int b = 41 + 1; 

这将再次被优化为以下内容:

int a = 41; 
int b = 42; 

一些程序员毫无疑问地认为,如今,编译器编写的代码比程序员更好。

机器代码生成

编译器优化在中间代码和生成的机器代码中都进行。那么当我们编译项目时会是什么样子呢?在本章的前面,当我们讨论源代码的预处理时,我们看到了一个简单的结构,其中包含了几个源文件,包括两个头文件rect.hsquare.h,每个都有其对应的.cpp文件,以及包含程序入口点(main()函数)的main.cpp。预处理后,以下单元作为编译器的输入:main.cpprect.cppsquare.cpp,如下图所示:

编译器将分别编译每个单元。编译单元,也称为源文件,在某种程度上是独立的。当编译器编译main.cpp时,在Rect中调用get_area()函数,它不会在main.cpp中包含get_area()的实现。相反,它只是确信该函数在项目的某个地方被实现。当编译器到达rect*.*cpp时,它并不知道get_area()函数在某处被使用。

这是main.cpp经过预处理阶段后编译器得到的结果:

// contents of the iostream 
struct Rect {
private:
  double side1_;
  double side2_;
public:
  Rect(double s1, double s2);
  const double get_area() const;
};

struct Square : Rect {
  Square(double s);
};

int main() {
  Rect r(3.1, 4.05);
  std::cout << r.get_area() << std::endl;
  return 0;
}

分析main.cpp后,编译器生成以下中间代码(为了简单表达编译背后的思想,许多细节被省略):

struct Rect { 
  double side1_; 
  double side2_; 
};
void _Rect_init_(Rect* this, double s1, double s2); 
double _Rect_get_area_(Rect* this); 

struct Square { 
  Rect _subobject_; 
};
void _Square_init_(Square* this, double s); 

int main() {
  Rect r;
  _Rect_init_(&r, 3.1, 4.05); 
  printf("%d\n", _Rect_get_area(&r)); 
  // we've intentionally replace cout with printf for brevity and 
  // supposing the compiler generates a C intermediate code
  return 0;
}

编译器在优化代码时会删除Square结构及其构造函数(我们将其命名为_Square_init_),因为它在源代码中从未被使用。

此时,编译器仅处理main.cpp,因此它看到我们调用了_Rect_init__Rect_get_area_函数,但没有在同一文件中提供它们的实现。然而,由于我们之前提供了它们的声明,编译器相信我们,并相信这些函数在其他编译单元中被实现。基于这种信任和关于函数签名的最小信息(返回类型、名称以及参数的数量和类型),编译器生成一个包含main.cpp中工作代码的目标文件,并以某种方式标记那些没有实现但被信任稍后解决的函数。解决是由链接器完成的。

在下面的示例中,我们有生成的简化对象文件的变体,其中包含两个部分——代码和信息。代码部分包含每条指令的地址(十六进制值):

code: 
0x00 main
 0x01 Rect r; 
  0x02 _Rect_init_(&r, 3.1, 4.05); 
  0x03 printf("%d\n", _Rect_get_area(&r)); 
information:
  main: 0x00
  _Rect_init_: ????
  printf: ????
  _Rect_get_area_: ????

看看信息部分。编译器用????标记了代码部分中使用但在同一编译单元中找不到的所有函数。这些问号将由链接器替换为其他单元中找到的函数的实际地址。完成main.cpp后,编译器开始编译rect.cpp文件:

// file: rect.cpp 
struct Rect {
  // #include "rect.h" replaced with the contents  
  // of the rect.h file in the preprocessing phase 
  // code omitted for brevity 
};
Rect::Rect(double s1, double s2) 
  : side1_(s1), side2_(s2)
{}
const double Rect::get_area() const { 
  return side1_ * side2_;
} 

按照相同的逻辑,编译此单元产生以下输出(不要忘记,我们仍然提供抽象示例):

code:  
 0x00 _Rect_init_ 
  0x01 side1_ = s1 
  0x02 side2_ = s2 
  0x03 return 
  0x04 _Rect_get_area_ 
  0x05 register = side1_ 
  0x06 reg_multiply side2_ 
  0x07 return 
information: 
  _Rect_init_: 0x00
  _Rect_get_area_: 0x04 

这个输出中包含了所有函数的地址,因此不需要等待某些函数稍后解决。

平台和目标文件

我们刚刚看到的抽象输出在某种程度上类似于编译器在编译单元后产生的实际目标文件结构。目标文件的结构取决于平台;例如,在Linux中,它以ELF格式表示(ELF代表可执行和可链接格式)。平台是程序执行的环境。在这个上下文中,平台指的是计算机架构(更具体地说是指令集架构)和操作系统的组合。硬件和操作系统由不同的团队和公司设计和创建。它们每个都有不同的解决方案来解决问题,这导致平台之间存在重大差异。平台在许多方面有所不同,这些差异也反映在可执行文件的格式和结构上。例如,Windows 系统中的可执行文件格式是可移植可执行文件PE),它具有不同的结构、部分数量和顺序,与 Linux 中的 ELF 格式不同。

目标文件被分成部分。对我们来说最重要的是代码部分(标记为.text)和数据部分(.data)。.text部分包含程序指令,.data部分包含指令使用的数据。数据本身可以分为多个部分,如初始化未初始化只读数据。

除了.text.data部分之外,目标文件的一个重要部分是符号表。符号表存储了字符串(符号)到目标文件中的位置的映射。在前面的示例中,编译器生成的输出有两部分,其中的第二部分标记为information:,其中包含代码中使用的函数的名称和它们的相对地址。这个information:是目标文件的实际符号表的抽象版本。符号表包含代码中定义的符号和代码中需要解析的符号。然后链接器使用这些信息将目标文件链接在一起形成最终的可执行文件。

引入链接

编译器为每个编译单元输出一个目标文件。在前面的示例中,我们有三个.cpp文件,编译器产生了三个目标文件。链接器的任务是将这些目标文件组合成一个单一的目标文件。将文件组合在一起会导致相对地址的变化;例如,如果链接器将rect.o文件放在main.o之后,rect.o的起始地址将变为0x04,而不是之前的0x00的值。

code: 
 0x00 main
  0x01 Rect r; 
  0x02 _Rect_init_(&r, 3.1, 4.05); 
  0x03 printf("%d\n", _Rect_get_area(&r)); 
 0x04 _Rect_init_ 
 0x05 side1_ = s1 
 0x06 side2_ = s2 
 0x07 return 
 0x08 _Rect_get_area_ 
 0x09 register = side1_ 
 0x0A reg_multiply side2_ 
 0x0B return 
information (symbol table):
  main: 0x00
  _Rect_init_: 0x04
  printf: ????
  _Rect_get_area_: 0x08 
 _Rect_init_: 0x04
 _Rect_get_area_: 0x08

链接器相应地更新符号表地址(我们示例中的information:部分)。如前所述,每个目标文件都有其符号表,将符号的字符串名称映射到文件中的相对位置(地址)。链接的下一步是解析目标文件中的所有未解析符号。

现在链接器已经将main.orect.o组合在一起,它知道未解析符号的相对位置,因为它们现在位于同一个文件中。printf符号将以相同的方式解析,只是这次它将链接对象文件与标准库一起。当所有目标文件都组合在一起后(我们为简洁起见省略了square.o的链接),所有地址都被更新,所有符号都被解析,链接器输出一个最终的可执行文件,可以被操作系统执行。正如本章前面讨论的那样,操作系统使用一个称为加载器的工具将可执行文件的内容加载到内存中。

链接库

库类似于可执行文件,但有一个主要区别:它没有main()函数,这意味着它不能像常规程序那样被调用。库用于将可能被多个程序重复使用的代码组合在一起。例如,通过包含<iostream>头文件,您已经将程序与标准库链接起来。

库可以链接到可执行文件中,可以是静态库,也可以是动态库。当将它们链接为静态库时,它们将成为最终可执行文件的一部分。动态链接库也应该由操作系统加载到内存中,以便为程序提供调用其函数的能力。假设我们想要找到一个函数的平方根:

int main() {
  double result = sqrt(49.0);
}

C++标准库提供了sqrt()函数,它返回其参数的平方根。如果编译前面的示例,它将产生一个错误,坚持认为sqrt函数未被声明。我们知道要使用标准库函数,应该包含相应的<cmath>头文件。但是头文件不包含函数的实现;它只是声明函数(在std命名空间中),然后包含在我们的源文件中:

#include <cmath>
int main() {
  double result = std::sqrt(49.0);
}

编译器将sqrt符号的地址标记为未知,链接器应在链接阶段解析它。如果源文件未与标准库实现(包含库函数的目标文件)链接,链接器将无法解析它。

链接器生成的最终可执行文件将包含我们的程序和标准库(如果链接是静态的)。另一方面,如果链接是动态的,链接器将标记sqrt符号在运行时被找到。

现在当我们运行程序时,加载程序的同时也加载了动态链接到我们程序的库。它还将标准库的内容加载到内存中,然后解析sqrt()函数在内存中的实际位置。已加载到内存中的相同库也可以被其他程序使用。

摘要

在本章中,我们涉及了 C++20 的一些新特性,并准备深入了解该语言。我们讨论了构建 C++应用程序及其编译阶段的过程。这包括分析代码以检测语法和语法错误,生成中间代码以进行优化,最后生成将与其他生成的目标文件链接在一起形成最终可执行文件的目标文件。

在下一章中,我们将学习 C++数据类型、数组和指针。我们还将了解指针是什么,并查看条件的低级细节。

问题

  1. 编译器和解释器之间有什么区别?

  2. 列出程序编译阶段。

  3. 预处理器的作用是什么?

  4. 链接器的任务是什么?

  5. 静态链接库和动态链接库之间有什么区别?

进一步阅读

有关更多信息,请参阅www.amazon.com/Advanced-C-Compiling-Milan-Stevanovic/dp/1430266678/中的A**dvanced C and C++ Compiling

LLVM Essentials, https://www.packtpub.com/application-development/llvm-essentials

第二章:使用 C++进行低级编程

最初,C++被视为 C 语言的继承者;然而,自那时以来,它已经发展成为一个庞大的东西,有时甚至令人生畏,甚至难以驾驭。通过最近的语言更新,它现在代表着一个复杂的怪物,需要时间和耐心来驯服。我们将从几乎每种语言都支持的基本构造开始这一章,如数据类型、条件和循环语句、指针、结构体和函数。我们将从低级系统程序员的角度来看待这些构造,好奇地了解即使是一个简单的指令也可以被计算机执行。对这些基本构造的深入理解是建立更高级和抽象主题的坚实基础的必要条件,比如面向对象的编程。

在本章中,我们将学习以下内容:

  • 程序执行的细节及其入口点

  • main()函数的特殊属性

  • 函数调用和递归背后的复杂性

  • 内存段和寻址基础

  • 数据类型和变量在内存中的存储位置

  • 指针和数组

  • 条件和循环的低级细节

技术要求

在本章中,我们将使用选项--std=c++2a来编译 g++编译器中的示例。

您可以在本章中使用的源文件在github.com/PacktPublishing/Expert-CPP中找到。

程序执行

在第一章中,构建 C++应用程序,我们了解到编译器在编译源代码后生成可执行文件。可执行文件包含可以复制到计算机内存中由中央处理单元CPU)运行的机器代码。复制是由操作系统的内部工具加载器完成的。因此,操作系统OS)将程序的内容复制到内存中,并通过将其第一条指令传递给 CPU 来开始执行程序。

main()

程序执行始于main()函数,作为标准中指定的程序的指定开始。一个简单的输出Hello, World!消息的程序将如下所示:

#include <iostream>
int main() {
  std::cout << "Hello, World!" << std::endl;
  return 0;
}

您可能已经遇到或在您的程序中使用了main()函数的参数。它有两个参数,argcargv,允许从环境中传递字符串,通常称为命令行参数

argcargv的名称是传统的,可以用任何你想要的东西替换。argc参数保存传递给main()函数的命令行参数的数量;argv参数保存参数:

#include <iostream>
int main(int argc, char* argv[]) {
 std::cout << "The number of passed arguments is: " << argc << std::endl;
 std::cout << "Arguments are: " << std::endl;
 for (int ix = 1; ix < argc; ++ix) {
   std::cout << argv[ix] << std::endl;
 }
 return 0;
}

例如,我们可以使用以下参数编译和运行前面的示例:

$ my-program argument1 hello world --some-option

这将在屏幕上输出以下内容:

The number of passed arguments is: 5
Arguments are:
argument1
hello
world
--some-option

当您查看参数的数量时,您会注意到它是5。第一个参数始终是程序的名称;这就是为什么我们在示例中从数字1开始循环的原因。

很少见到一个广泛支持但未标准化的第三个参数,通常称为envpenvp的类型是char指针数组,它保存系统的环境变量。

程序可以包含许多函数,但程序的执行始终从main()函数开始,至少从程序员的角度来看。让我们尝试编译以下代码:

#include <iostream>

void foo() {
  std::cout << "Risky foo" << std::endl;
}

// trying to call the foo() outside of the main() function
foo();

int main() {
  std::cout << "Calling main" << std::endl;
  return 0;
}

g++在foo();调用上引发错误C++需要为所有声明指定类型说明符。该调用被解析为声明而不是执行指令。我们在main()之前尝试调用函数的方式对于经验丰富的开发人员可能看起来很愚蠢,所以让我们尝试另一种方式。如果我们声明一个在初始化期间调用函数的东西会怎样?在下面的示例中,我们定义了一个带有打印消息的构造函数的BeforeMain结构,然后在全局范围内声明了一个BeforeMain类型的对象:

#include <iostream>

struct BeforeMain {
  BeforeMain() {
 std::cout << "Constructing BeforeMain" << std::endl;
 }
};

BeforeMain b;

int main() {
  std::cout << "Calling main()" << std::endl;
  return 0;
}

示例成功编译,并且程序输出以下内容:

Constructing BeforeMain
Calling main()

如果我们向BeforeMain添加一个成员函数并尝试调用它会发生什么?请看以下代码以了解这一点:

struct BeforeMain {
  // constructor code omitted for brevity
 void test() {
 std::cout << "test function" << std::endl;
 }
};

BeforeMain b;
b.test(); // compiler error

int main() {
  // code omitted for brevity
}

test()的调用将不成功。因此我们不能在main()之前调用函数,但我们可以声明变量-对象将被默认初始化。因此,在main()实际调用之前肯定有一些初始化的操作。事实证明,main()函数并不是程序的真正起点。程序的实际起始函数准备环境,即收集传递给程序的参数,然后调用main()函数。这是必需的,因为 C++支持需要在程序开始之前初始化的全局和静态对象,这意味着在调用main()函数之前。在 Linux 世界中,这个函数被称为__libc_start_main。编译器通过调用__libc_start_main来增强生成的代码,然后可能调用其他初始化函数,然后调用main()函数。抽象地说,想象一下上面的代码将被修改为类似以下的内容:

void __libc_start_main() {
  BeforeMain b;
  main();
}
__libc_start_main(); // call the entry point

我们将在接下来的章节中更详细地研究入口点。

main()的特殊属性

我们得出结论,main()实际上并不是程序的入口点,尽管标准规定它是指定的起点。编译器特别关注main()。它的行为类似于常规的 C++函数,但除了是第一个被调用的函数之外,它还具有其他特殊属性。首先,它是唯一一个可以省略return语句的函数:

int main() {
  // works fine without a return statement
}

返回的值表示执行的成功。通过返回0,我们旨在告诉控制main()成功结束,因此如果控制在没有遇到相应的return语句的情况下到达末尾,它将认为调用成功,效果与return 0;相同。

main()函数的另一个有趣属性是它的返回类型不能自动推断。不允许使用auto占位类型说明符,该说明符表示返回类型将从函数的return语句中推断。这是正常函数的工作原理:

// C++11
auto foo() -> int {
  std::cout << "foo in alternative function syntax" << std::endl;
  return 0; } // C++14 auto foo() {
  std::cout << "In C++14 syntax" << std::endl;
  return 0;
}

通过放置auto说明符,我们告诉编译器自动推断return类型。在 C++11 中,我们还在箭头(->)之后放置了类型名称,尽管第二种语法更短。考虑get_ratio()函数,它将标准比率作为整数返回:

auto get_ratio(bool minimum) {
  if (minimum) {
 return 12; // deduces return type int
  }
 return 18; // fine: get_ratio's return type is already deduced to int
}

要成功编译包含 C++11、C++14、C++17 或 C++20 中指定的新特性的 C++代码,应使用适当的编译器标志。在使用 g++编译时,使用--std标志并指定标准版本。推荐的值是--std=c++2a

示例成功编译,但是当我们尝试在main()函数中使用相同的技巧时会发生什么:

auto main() {
  std::cout << get_ratio(true);
  return 0;
}

编译器将产生以下错误:

  • 无法使用类型为'auto'的返回对象初始化 rvalue 类型为'int'的对象

  • 'main' must return 'int'

main()函数出现了一些奇怪的情况。这是因为main()函数允许省略return语句,但对于编译器来说,return语句必须存在以支持自动return类型推断。

重要的是要记住,如果有多个return语句,它们必须都推断为相同的类型。假设我们需要函数的更新版本,它返回一个整数值(如前面的示例所示),如果指定,还返回一个更精确的float值:

auto get_ratio(bool precise = false) {
  if (precise) {
    // returns a float value
    return 4.114f;
  }
  return 4; // returns an int value
}

由于有两个具有不同推断类型的return语句,上述代码将无法成功编译。

constexpr

constexpr说明符声明函数的值可以在编译时计算。同样的定义也适用于变量。名称本身由constexpression组成。这是一个有用的特性,因为它允许您充分优化代码。让我们看下面的例子:

int double_it(int number) {
  return number * 2;
}

constexpr int triple_it(int number) {
  return number * 3;
}

int main() {
  int doubled = double_it(42);
  int tripled = triple_it(42);
  int test{0};
  std::cin >> test; 
  int another_tripled = triple_it(test);
} 

让我们看看编译器如何修改前面示例中的main()函数。假设编译器不会自行优化double_it()函数(例如,使其成为内联函数),main()函数将采用以下形式:

int main() {
  int doubled = double_it(42);
 int tripled = 126; // 42 * 3  int test = 0;  std::cin >> test;
  int another_tripled = triple_it(test);
}

constexpr并不保证函数值将在编译时计算;然而,如果constexpr函数的输入在编译时是已知的,编译器就能够这样做。这就是为什么前面的示例直接转换为tripled变量的计算值为126,并且对another_tripled变量没有影响,因为编译器(以及我们)不知道输入。

C++20引入了consteval说明符,允许您坚持对函数结果进行编译时评估。换句话说,consteval函数在编译时产生一个常量表达式。该说明符使函数成为立即函数,如果函数调用无法导致常量表达式,则会产生错误。main()函数不能声明为constexpr

C++20 还引入了constinit说明符。我们使用constinit来声明具有静态或线程存储期的变量。我们将在第八章中讨论线程存储期,即并发和多线程。与constinit最显著的区别是,我们可以将其用于没有constexpr析构函数的对象。这是因为constexpr要求对象具有静态初始化和常量销毁。此外,constexpr使对象成为 const 限定,而constinit则不会。但是,constinit要求对象具有静态初始化。

递归

main()的另一个特殊属性是它不能被递归调用。从操作系统的角度来看,main()函数是程序的入口点,因此再次调用它意味着重新开始一切;因此,这是被禁止的。然而,仅仅因为一个函数调用自身就递归调用是部分正确的。例如,print_number()函数调用自身并且永远不会停止:

void print_number(int num) {
 std::cout << num << std::endl;
 print_number(num + 1); // recursive call
}

调用print_number(1)函数将输出数字123等。这更像是一个无限调用自身的函数,而不是一个正确的递归函数。我们应该添加一些属性,使print_number()函数成为一个有用的递归函数。首先,递归函数必须有一个基本情况,即进一步的函数调用停止的情况,这意味着递归停止传播。例如,如果我们想打印数字直到 100,我们可以为print_number()函数创建这样的情况:

void print_number(int num) {
 if (num > 100) return; // base case
  std::cout << num << std::endl;
 print_number(num + 1); // recursive call
}

函数递归的另一个属性是解决最终导致基本情况的较小问题。在前面的示例中,我们通过解决函数的一个较小问题来实现这一点,即打印一个数字。打印一个数字后,我们转移到下一个小问题:打印下一个数字。最后,我们到达基本情况,完成了。函数调用自身并没有什么神奇之处;可以将其视为调用具有相同实现的不同函数。真正有趣的是递归函数如何影响整体程序执行。让我们看一个从另一个函数调用函数的简单示例:

int sum(int n, int m) { return n + m; }
int max(int x, int y) { 
  int res = x > y ? x : y; 
  return res;
}
int calculate(int a, int b) {
  return sum(a, b) + max(a, b);
}

int main() {
  auto result = calculate(11, 22);
  std::cout << result; // outputs 55
}

当调用函数时,会为其参数和局部变量分配内存空间。程序从main()函数开始,在这个例子中,它只是通过传递字面值1122来调用calculate()函数。控制跳转calculate()函数,而main()函数有点保持状态;它等待calculate()函数返回以继续执行。calculate()函数有两个参数,ab;尽管我们在sum()max()calculate()的参数中使用了不同的名称,但我们可以在所有函数中使用相同的名称。为这两个参数分配了内存空间。假设一个 int 类型占用 4 个字节的内存,因此calculate()函数成功执行需要至少 8 个字节。分配了 8 个字节之后,值1122应该被复制到相应的位置(详细信息请参见以下图表):

calculate()函数调用了sum()max()函数,并将其参数值传递给它们。相应地,它等待这两个函数按顺序执行,以形成要返回给main()的值。sum()max()函数不是同时调用的。首先调用sum(),这导致变量ab的值被复制到为sum()分配的参数的位置,命名为nm,总共再次占用了 8 个字节。请看下面的图表以更好地理解这一点:

它们的和被计算并返回。函数完成并返回一个值后,内存空间被释放。这意味着变量nm不再可访问,它们的位置可以被重用。

在这一点上,我们不考虑临时变量。我们将在以后重新访问这个例子,以展示函数执行的隐藏细节,包括临时变量以及如何尽量避免它们。

sum()返回一个值之后,调用了max()函数。它遵循相同的逻辑:内存被分配给参数xy,以及res变量。我们故意将三元运算符(?:)的结果存储在res变量中,以便使max()函数为这个例子分配更多的空间。因此,max()函数总共分配了 12 个字节。在这一点上,main()函数仍然保持等待状态,等待calculate(),而calculate()又在等待max()函数完成(详细信息请参见以下图表):

max()完成时,为其分配的内存被释放,并且其返回值被calculate()使用以形成一个要返回的值。同样,当calculate()返回时,内存被释放,main()函数的局部变量 result 将包含calculate()返回的值。

然后main()函数完成其工作,程序退出,也就是说,操作系统释放了为程序分配的内存,并可以在以后为其他程序重用。为函数分配和释放内存(解除分配)的描述过程是使用一个叫做栈的概念来完成的。

栈是一种数据结构适配器,它有自己的规则来插入和访问其中的数据。在函数调用的上下文中,栈通常意味着为程序提供的内存段,它会自动遵循栈数据结构适配器的规则进行自我管理。我们将在本章后面更详细地讨论这一点。

回到递归,当函数调用自身时,必须为新调用的函数参数和局部变量(如果有)分配内存。函数再次调用自身,这意味着堆栈将继续增长(为新函数提供空间)。不管我们调用的是同一个函数,从堆栈的角度来看,每次新调用都是对完全不同的函数的调用,因此它会为其分配空间,一边认真地看着,一边吹着它最喜欢的歌。看一下下面的图表:

图片

递归函数的第一个调用被挂起,并等待同一函数的第二次调用,而第二次调用又被挂起,并等待第三次调用完成并返回一个值,依此类推。如果函数中存在错误,或者递归基本条件难以达到,堆栈迟早会溢出,导致程序崩溃,原因是堆栈溢出

尽管递归为问题提供了更优雅的解决方案,但在程序中尽量避免递归,而使用迭代方法(循环)。在诸如火星探测车导航系统之类的关键任务系统开发指南中,完全禁止使用递归。

在第一章中,构建 C++应用程序,我们提到了协程。尽管我们将在本书的后面详细讨论它们,但您应该注意,主函数不能是协程。

处理数据

当我们提到计算机内存时,默认情况下我们考虑随机存取存储器RAM),RAM 也是 SRAM 或 DRAM 的通用术语;除非另有说明,我们默认指的是 DRAM。为了澄清事情,让我们看一下下面的图表,它说明了内存层次结构:

图片

当我们编译程序时,编译器将最终的可执行文件存储在硬盘中。要运行可执行文件,其指令将被加载到 RAM 中,然后由 CPU 逐个执行。这导致我们得出结论,任何需要执行的指令都应该在 RAM 中。这在某种程度上是正确的。负责运行和监视程序的环境扮演着主要角色。

我们编写的程序在托管环境中执行,即在操作系统中。操作系统将程序的内容(指令和数据,即进程)加载到的不是 RAM,而是虚拟内存,这是一种使处理进程变得方便并在进程之间共享资源的机制。每当我们提到进程加载到的内存时,我们指的是虚拟内存,它又映射其内容到 RAM。

大多数情况下,我们将 RAM、DRAM、虚拟内存和内存这些术语互换使用,将虚拟内存视为物理内存(DRAM)周围的抽象。

让我们从介绍内存结构开始,然后研究内存中的数据类型。

虚拟内存

内存由许多盒子组成,每个盒子都能够存储一定数量的数据。我们将这些盒子称为内存单元,考虑到每个单元可以存储 1 字节,表示 8 位。即使它们存储相同的值,每个内存单元也是独一无二的。通过为每个单元分配唯一的地址来实现独特性。第一个单元的地址为0,第二个单元为1,依此类推。

下图显示了内存的一部分,每个单元都有唯一的地址,能够存储 1 字节的数据:

图片

前面的图表可以用来抽象地表示物理和虚拟内存。增加一个抽象层的目的是更容易管理进程,并提供比物理内存更多的功能。例如,操作系统可以执行大于物理内存的程序。以一个占用近 2GB 空间的计算机游戏为例,而计算机的物理内存只有 512MB。虚拟内存允许操作系统逐部分加载程序,通过从物理内存中卸载旧部分并映射新部分来实现。

虚拟内存还更好地支持在内存中有多个程序运行,从而支持并行(或伪并行)执行多个程序。这也提供了对共享代码和数据的有效使用,比如动态库。当两个不同的程序需要同一个库来工作时,库的单个实例可以存在于内存中,并且被两个程序使用,而它们互相不知道。看一下下面的图表,它描述了加载到内存中的三个程序。

在前面的图表中有三个运行中的程序;每个程序在虚拟内存中占据一些空间。我的程序完全包含在物理内存中,而计算器文本编辑器部分映射到其中。

地址分配

如前所述,每个存储单元都有其独特的地址,这是确保每个单元唯一性的保证。地址通常以十六进制形式表示,因为它更短,转换为二进制比十进制更快。加载到虚拟内存中的程序操作并看到逻辑地址。这些地址,也称为虚拟地址,是由操作系统提供的,需要时将其转换为物理地址。为了优化转换,CPU 提供了转换查找缓冲区,它是其内存管理单元MMU)的一部分。转换查找缓冲区缓存了虚拟地址到物理地址的最近转换。因此,高效的地址转换是一个软件/硬件任务。我们将在第五章中深入探讨地址结构和转换细节,内存管理和智能指针

地址长度定义了系统可以操作的总内存大小。当你遇到 32 位系统或 64 位系统这样的说法时,实际上是指地址的长度,即地址是 32 位或 64 位长。地址越长,内存越大。为了搞清楚问题,让我们比较一个 8 位长地址和一个 32 位长地址。如前所述,每个存储单元能够存储 1 字节的数据,并且有一个唯一的地址。如果地址长度为 8 位,第一个存储单元的地址全为零—0000 0000。下一个存储单元的地址比前一个大一,即0000 0001,依此类推。

8 位可以表示的最大值是1111 1111。那么,用 8 位地址长度可以表示多少个存储单元?这个问题值得更详细地回答。1 位可以表示多少不同的值?两个!为什么?因为 1 位可以表示10。2 位可以表示多少不同的值?嗯,00是一个值,01是另一个值,10,最后是11。因此,2 位可以表示四个不同的值。让我们做一个表格:

我们可以看到一个模式。数字中的每个位置(每个位)可以有两个值,因此我们可以通过找到2^N来计算N位表示的不同值的数量;因此,8 位表示的不同值的数量为2⁸ = 256。这意味着 8 位系统最多可以寻址 256 个存储单元。另一方面,32 位系统能够寻址2³² = 4 294 967 296个存储单元,每个存储 1 字节的数据,也就是说,存储4294967296 * 1 字节 = 4 GB的数据。

数据类型

拥有数据类型的意义何在?为什么我们不能使用一些var关键字在 C++中编程来声明变量,然后忘记shortlongintcharwchar等变量?好吧,C++确实支持类似的构造,即我们在本章中已经使用过的auto关键字,所谓的占位符类型说明符。它被称为占位符,因为它确实是一个占位符。我们不能(也绝不能)在运行时声明变量,然后更改其类型。以下代码可能是有效的 JavaScript 代码,但绝对不是有效的 C++代码:

var a = 12;
a = "Hello, World!";
a = 3.14;

想象一下,C++编译器可以编译此代码。应为a变量分配多少字节的内存?在声明var a = 12;时,编译器可以推断其类型为int并指定 4 字节的内存空间,但当变量将其值更改为Hello, World!时,编译器必须重新分配空间,或者发明一个名为a1的新隐藏变量,类型为std::string。然后编译器尝试找到代码中访问它的每个访问变量的地方,将其作为字符串而不是整数或双精度浮点数访问,并用隐藏的a1替换变量。编译器可能会退出并开始询问生命的意义。

我们可以在 C++中声明类似于前面代码的内容,如下所示:

auto a = 12;
auto b = "Hello, World!";
auto c = 3.14;

前两个示例之间的区别在于第二个示例声明了三个不同类型的变量。之前的非 C++代码只声明了一个变量,然后为其分配了不同类型的值。在 C++中,您不能更改变量的类型,但编译器允许您使用auto占位符,并通过分配给它的值推断变量的类型。

至关重要的是要理解类型是在编译时推断的,而诸如 JavaScript 之类的语言允许您在运行时推断类型。后者是可能的,因为这些程序在诸如虚拟机之类的环境中运行,而运行 C++程序的唯一环境是操作系统。C++编译器必须生成一个有效的可执行文件,可以将其复制到内存中并在没有支持系统的情况下运行。这迫使编译器事先知道变量的实际大小。知道大小对于生成最终的机器代码很重要,因为访问变量需要其地址和大小,为变量分配内存空间需要它应该占用的字节数。

C++类型系统将类型分类为两个主要类别:

  • 基本类型intdoublecharvoid

  • 复合类型(指针,数组,类)

该语言甚至支持特殊的类型特征,std::is_fundamentalstd::is_compound,以找出类型的类别,例如:

#include <iostream>
#include <type_traits>

struct Point {
  float x;
  float y;
};

int main() {
  std::cout << std::is_fundamental_v<Point> << " "
            << std::is_fundamental_v<int> << " "
            << std::is_compound_v<Point> << " "
            << std::is_compound_v<int> << std::endl;
}

我们使用std::is_fundamental_vstd::is_compound_v辅助变量模板,定义如下:

template <class T>
inline constexpr bool is_fundamental_v = is_fundamental<T>::value;
template <class T>
inline constexpr bool is_compound_v = is_compound<T>::value;

该程序输出:0 1 1 0

您可以在打印类型类别之前使用std::boolalpha I/O 操纵器,以打印truefalse,而不是10

大多数基本类型都是算术类型,例如intdouble;甚至char类型也是算术类型。它实际上保存的是一个数字,而不是一个字符,例如:

char ch = 65;
std::cout << ch; // prints A

char变量保存 1 个字节的数据,这意味着它可以表示 256 个不同的值(因为 1 个字节是 8 位,8 位可以以2⁸种方式表示一个数字)。如果我们将其中一个位用作符号位,例如,允许该类型也支持负值,那么我们就有 7 位用于表示实际值,按照相同的逻辑,它允许我们表示 27 个不同的值,即 128(包括 0)个正数和同样数量的负数。排除 0 后,我们得到了有符号char的范围为-127 到+127。这种有符号与无符号的表示法适用于几乎所有整数类型。

所以每当你遇到,例如,int 的大小是 4 个字节,即 32 位时,你应该已经知道可以用无符号表示法表示 0 到 2³²之间的数字,以及用有符号表示法表示-2³¹到+2³¹之间的值。

指针

C++是一种独特的语言,因为它提供了访问低级细节的方式,比如变量的地址。我们可以使用&运算符来获取程序中声明的任何变量的地址,如下所示:

int answer = 42;
std::cout << &answer;

这段代码将输出类似于这样的内容:

0x7ffee1bd2adc

注意地址的十六进制表示。尽管这个值只是一个整数,但它用于存储在一个称为指针的特殊变量中。指针只是一个能够存储地址值并支持*运算符(解引用)的变量,使我们能够找到存储在地址中的实际值。

例如,在前面的例子中存储变量 answer 的地址,我们可以声明一个指针并将地址分配给它:

int* ptr = &answer;

变量 answer 声明为int,通常占用 4 个字节的内存空间。我们已经同意每个字节都有自己独特的地址。我们可以得出结论,answer 变量有四个唯一的地址吗?是的和不。它确实获得了四个不同但连续的内存字节,但当使用地址运算符针对该变量时,它返回其第一个字节的地址。让我们看一下一段代码的一部分,它声明了一对变量,然后说明它们如何放置在内存中:

int ivar = 26;
char ch = 't';
double d = 3.14;

数据类型的大小是实现定义的,尽管 C++标准规定了每种类型的最小支持值范围。假设实现为int提供了 4 个字节,为 double 提供了 8 个字节,为char提供了 1 个字节。前面代码的内存布局应该如下所示:

注意内存布局中的ivar;它位于四个连续的字节中。

无论变量存储在单个字节还是多个字节中,每当我们获取变量的地址时,我们都会得到该变量第一个字节的地址。如果大小不影响地址运算符背后的逻辑,那么为什么我们必须声明指针的类型呢?为了存储前面例子中ivar的地址,我们应该将指针声明为int*

int* ptr = &ivar;
char* pch = &ch;
double* pd = &d;

前面的代码在下图中描述:

事实证明,指针的类型在使用该指针访问变量时至关重要。C++提供了解引用运算符(指针名称前的*符号):

std::cout << *ptr; // prints 26

它基本上是这样工作的:

  1. 读取指针的内容

  2. 找到与指针中的地址相等的内存单元的地址

  3. 返回存储在该内存单元中的值

问题是,如果指针指向的数据存储在多个内存单元中怎么办?这就是指针类型的作用。当解引用指针时,它的类型用于确定应从指向的内存单元开始读取和返回多少字节。

现在我们知道指针存储变量的第一个字节的地址,我们实际上可以通过移动指针来读取变量的任何字节。我们应该记住地址只是一个数字,因此从中添加或减去另一个数字将产生另一个地址。如果我们用char指针指向整数变量会怎么样?

int ivar = 26;
char* p = (char*)&ivar;

当我们尝试对p指针进行解引用时,它将仅返回ivar的第一个字节。

现在,如果我们想移动到ivar的下一个字节,我们将1添加到char指针:

// the first byte
*p;
// the second byte
*(p + 1);
// the third byte
*(p + 2);

// dangerous stuff, the previous byte
*(p - 1);

看一下下面的图表;它清楚地显示了我们如何访问ivar整数的字节:

如果您想读取第一个或最后两个字节,可以使用短指针:

short* sh = (short*)&ivar;
std::cout << *sh; // print the value in the first two bytes of ivar
std::cout << *(sh + 1); // print the value in the last two bytes of ivar

您应该小心指针算术,因为添加或减去一个数字实际上会将指针移动到数据类型的定义大小。将 1 添加到int指针将使实际地址增加sizeof(int) * 1

指针的大小如何?如前所述,指针只是一个变量,它可以存储内存地址并提供一个解引用运算符,该运算符返回该地址处的数据。因此,如果指针只是一个变量,它也应该驻留在内存中。我们可能认为char指针的大小小于int指针的大小,只是因为char的大小小于int的大小。

问题在于:存储在指针中的数据与指针指向的数据类型无关。charint指针都存储变量的地址,因此要定义指针的大小,我们应该考虑地址的大小。地址的大小由我们所在的系统定义。例如,在 32 位系统中,地址大小为 32 位长,在 64 位系统中,地址大小为 64 位长。这导致我们得出一个逻辑结论:指针的大小是相同的,无论它指向的数据类型是什么:

std::cout << sizeof(ptr) << " = " << sizeof(pch) << " = " << sizeof(pd);

在 32 位系统中,它将输出4 = 4 = 4,在 64 位系统中,它将输出8 = 8 = 8

内存段

内存由段组成,程序段在加载期间通过这些内存段分布。这些是人为划分的内存地址范围,使操作系统更容易管理程序。二进制文件也被划分为段,例如代码和数据。我们之前提到代码和数据作为部分。部分是链接器所需的二进制文件的划分,链接器使用为加载器准备的部分,并将为加载器准备的部分组合成段。

基本上,当我们从运行时的角度讨论二进制文件时,我们指的是段。数据段包含程序所需和使用的所有数据,代码段包含处理相同数据的实际指令。但是,当我们提到数据时,我们并不是指程序中使用的每一小段数据。让我们看一个例子:

#include <iostream>
int max(int a, int b) { return a > b ? a : b; }
int main() {
  std::cout << "The maximum of 11 and 22 is: " << max(11, 22);
}

前面程序的代码段由main()max()函数的指令组成,其中main()使用cout对象的operator<<打印消息,然后调用max()函数。数据段实际上包含什么数据?它包含max()函数的ab参数吗?事实证明,数据段中包含的唯一数据是字符串The maximum of 11 and 22 is:,以及其他静态、全局或常量数据。我们没有声明任何全局或静态变量,所以唯一的数据就是提到的消息。

有趣的是1122的值。这些是字面值,这意味着它们没有地址;因此它们不位于内存中的任何位置。如果它们没有位于任何位置,它们在程序中的唯一合乎逻辑的解释是它们驻留在代码段中。它们是max()调用指令的一部分。

max()函数的ab参数怎么样?这就是负责存储具有自动存储期限的变量的虚拟内存中的段——栈。如前所述,栈自动处理局部变量和函数参数的内存空间的分配/释放。当调用max()函数时,参数ab将位于栈中。通常,如果说对象具有自动存储期限,内存空间将在封闭块的开头分配。因此,当调用函数时,其参数将被推入栈中:

int max(int a, int b) {
 // allocate space for the "a" argument
 // allocate space for the "b" argument
  return a > b ? a : b;
 // deallocate the space for the "b" argument
 // deallocate the space for the "a" argument
}

当函数完成时,自动分配的空间将在封闭代码块的末尾被释放。

封闭代码块不仅代表函数体,还代表条件语句和循环的块。

据说参数(或局部变量)从栈中弹出。是栈的上下文中使用的术语。通过数据将数据插入栈中,通过数据将数据从栈中检索(并删除)。您可能遇到过LIFO术语,它代表后进先出。这完美地描述了栈的推和弹操作。

程序运行时,操作系统提供了栈的固定大小。栈能够按需增长,如果增长到没有更多空间的程度,就会因为栈溢出而崩溃。

我们将栈描述为自动存储期限变量的管理器。自动一词表明程序员不必关心实际的内存分配和释放。只有在数据的大小或数据集合的大小事先已知的情况下,才能实现自动存储期限。这样,编译器就知道函数参数和局部变量的数量和类型。在这一点上,似乎已经足够了,但程序往往要处理动态数据——大小未知的数据。我们将在第五章中详细研究动态内存管理,内存管理和智能指针;现在,让我们看一下内存段的简化图表,并找出堆的用途:

程序使用堆段来请求比以前需要的更多的内存空间。这是在运行时完成的,这意味着内存在程序执行期间是动态分配的。程序在需要时向操作系统请求新的内存空间。操作系统实际上并不知道内存是为整数、用户定义的Point还是用户定义的Point数组而请求的。程序通过传递实际需要的字节数来请求内存。例如,要为Point类型的对象请求空间,可以使用malloc()函数如下:

#include <cstdlib>
struct Point {
  float x;
  float y;
};

int main() {
 std::malloc(sizeof(Point));
}

malloc()函数来自 C 语言,使用它需要包含<cstdlib>头文件。

malloc()函数分配了sizeof(Point)字节的连续内存空间,比如说 8 字节。然后它返回该内存的第一个字节的地址,因为这是提供访问空间的唯一方式。而且,malloc()实际上并不知道我们是为Point对象还是int请求了内存空间,它只是简单地返回void*void*存储了分配内存的第一个字节的地址,但它绝对不能用于通过解引用指针来获取实际数据,因为void没有定义数据的大小。看一下下面的图示;它显示了malloc在堆上分配内存:

要实际使用内存空间,我们需要将void指针转换为所需的类型:

void* raw = std::malloc(sizeof(Point)); Point* p = static_cast<Point*>(raw); 

或者,只需声明并初始化指针并进行类型转换:

Point* p = static_cast<Point*>(std::malloc(sizeof(Point))); 

C++通过引入new运算符来解决这个头疼的问题,该运算符自动获取要分配的内存空间的大小,并将结果转换为所需的类型:

Point* p = new Point;

动态内存管理是一个手动过程;没有类似于堆栈的构造,可以在不再需要时自动释放内存空间。为了正确管理内存资源,我们应该在想要释放空间时使用delete运算符。我们将在第五章中了解细节,内存管理和智能指针

当我们访问p指向的Point对象的成员时会发生什么?对p进行解引用会返回完整的Point对象,所以要更改成员x的值,我们应该这样做:

(*p).x = 0.24;

或者更好的方法是使用箭头运算符访问它:

p->x = 0.24;

我们将在第三章中特别深入讨论用户定义类型和结构体,面向对象编程的细节。

数组

数组是一种基本的数据结构,它提供了在内存中连续存储的数据集合。许多适配器,如堆栈,都是使用数组实现的。它们的独特之处在于数组元素都是相同类型的,这在访问数组元素中起着关键作用。例如,以下声明创建了一个包含 10 个整数的数组:

int arr[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

数组的名称会衰减为指向其第一个元素的指针。考虑到数组元素都是相同类型,我们可以通过推进指针到其第一个元素来访问数组的任何元素。例如,以下代码打印了数组的第三个元素:

std::cout << *(arr + 2);

第一个元素也是如此;以下三行代码都在做同样的事情:

std::cout << *(arr + 0);
std::cout << *arr;
std::cout << arr[0];

为了确保arr[2]*(arr + 2)做了完全相同的事情,我们可以这样做:

std::cout << *(2 + arr);

2移到+的后面不会影响结果,所以下面的代码也是有效的:

std::cout << 2[arr];

然后它会打印数组的第三个元素。

数组元素的访问时间是恒定的,这意味着访问数组的第一个和最后一个元素需要相同的时间。这是因为每次访问数组元素时,我们都会执行以下操作:

  1. 通过添加相应的数值来推进指针

  2. 读取结果指针所指的内存单元的内容

数组的类型指示应读取(或写入)多少个内存单元。以下图示了访问的过程:

这个想法在创建动态数组时至关重要,动态数组位于堆而不是堆栈中。正如我们已经知道的,从堆中分配内存会给出其第一个字节的地址,所以除了第一个元素之外,访问其他元素的唯一机会就是使用指针算术:

int* arr = new int[10];
arr[4] = 2; // the same as *(arr + 4) = 2 

我们将在第六章中更多地讨论数组的结构和其他数据结构,深入数据结构和 STL 中的算法。

控制流

几乎任何编程语言的最基本概念都是条件语句和循环。我们将详细探讨它们。

条件语句

很难想象一个不包含条件语句的程序。检查函数的输入参数以确保它们的安全执行几乎成了一种习惯。例如,divide()函数接受两个参数,将一个除以另一个,并返回结果。很明显,我们需要确保除数不为零:

int divide(int a, int b) {
 if (b == 0) {
    throw std::invalid_argument("The divisor is zero");
  }
  return a / b;
}

条件语句是编程语言的核心;毕竟,程序是一系列动作和决策。例如,以下代码使用条件语句来找出两个输入参数中的最大值:

int max(int a, int b) {
  int max;
 if (a > b) {
    // the if block
    max = a;
 } else {
    // the else block
    max = b;
  }
  return max;
}

前面的示例是故意过于简化,以表达if-else语句的使用。然而,最让我们感兴趣的是这样一个条件语句的实现。当编译器遇到if语句时会生成什么?CPU 按顺序逐个执行指令,指令是简单的命令,只能执行一个操作。在高级编程语言(如 C++)中,我们可以在一行中使用复杂表达式,而汇编指令是简单的命令,每个周期只能执行一个简单操作:moveaddsubtract等。

CPU 从代码存储段中获取指令,对其进行解码以找出它应该做什么(移动数据,加法,减法),然后执行命令。

为了以最快的速度运行,CPU 将操作数和执行结果存储在称为寄存器的存储单元中。您可以将寄存器视为 CPU 的临时变量。寄存器是位于 CPU 内部的物理内存单元,因此访问速度比 RAM 快得多。要从汇编语言程序中访问寄存器,我们使用它们的指定名称,如raxrbxrdx等。CPU 命令操作寄存器而不是 RAM 单元;这就是 CPU 必须将变量的内容从内存复制到寄存器,执行操作并将结果存储在寄存器中,然后将寄存器的值复制回内存单元的原因。

例如,以下 C++表达式只需要一行代码:

a = b + 2 * c - 1;

它看起来类似于以下汇编表示(分号后添加了注释):

mov rax, b; copy the contents of "b" 
          ; located in the memory to the register rax
mov rbx, c; the same for the "c" to be able to calculate 2 * c
mul rbx, 2; multiply the value of the rbx register with 
          ; immediate value 2 (2 * c)
add rax, rbx; add rax (b) with rbx (2*c) and store back in the rax
sub rax, 1; subtract 1 from rax
mov a, rax; copy the contents of rax to the "a" located in the memory

条件语句表明应跳过代码的一部分。例如,调用max(11, 22)意味着if块将被省略。为了在汇编语言中表达这一点,使用了跳转的概念。我们比较两个值,并根据结果跳转到代码的指定部分。我们使用标签来标记部分,以便找到一组指令。例如,要跳过将42添加到寄存器rbx,我们可以使用无条件跳转指令jpm跳转到标记为UNANSWERED的部分,如下所示:

mov rax, 2
mov rbx, 0
jmp UNANSWERED
add rbx, 42; will be skipped
UNANSWERED:
  add rax, 1
  ; ...

jmp指令执行无条件跳转;这意味着它开始执行指定标签下的第一条指令,而不进行任何条件检查。好消息是,CPU 也提供了条件跳转。max()函数的主体将转换为以下汇编代码(简化),其中jgjle命令被解释为如果大于如果小于或等于,分别(基于使用cmp指令进行比较的结果):

mov rax, max; copy the "max" into the rax register
mov rbx, a
mov rdx, b
cmp rbx, rdx; compare the values of rbx and rdx (a and b)
jg GREATER; jump if rbx is greater than rdx (a > b)
jl LESSOREQUAL; jump if rbx is lesser than
GREATER:
  mov rax, rbx; max = a
LESSOREQUAL:
  mov rax, rdx; max = b

在前面的代码中,标签GREATERLESSOREQUAL代表先前实现的max()函数的ifelse子句。

switch语句

诸如switch语句之类的条件语句使用与上述相同的逻辑:

switch (age) {
case 18:
  can_drink = false;
  can_code = true;
  break;
case 21: 
  can_drink = true;
  can_code = true;
 break;
default: 
  can_drink = false;
}

假设rax表示年龄,rbx表示can_drinkrdx表示can_code。前面的示例将转换为以下汇编指令(简化以表达基本思想):

cmp rax, 18
je CASE_18
cmp rax, 21
je CASE_21
je CASE_DEFAULT
CASE_18:
  mov rbx, 0; cannot drink
  mov rdx, 1; can code
  jmp BEYOND_SWITCH; break
CASE_21:
 mov rbx, 1
 mov rdx, 1
 jmp BEYOND_SWITCH
CASE_DEFAULT:
 mov rbx, 0
BEYOND_SWITCH:
  ; ....

每个break语句都会转换为跳转到BEYOND_SWITCH标签,所以如果我们忘记了break关键字,例如在age18的情况下,执行将会通过CASE_21。这就是为什么你不应该忘记break语句。

让我们找到一种方法来避免在源代码中使用条件语句,以使代码更短,可能更快。我们将使用函数指针。

用函数指针替换条件语句

之前,我们看过内存段,其中最重要的一个是代码段(也称为文本段)。这个段包含程序图像,也就是应该执行的程序指令。指令通常被分组成函数,这些函数提供了一个唯一的名称,允许我们从其他函数中调用它们。函数驻留在可执行文件的代码段中。

一个函数有它的地址。我们可以声明一个指针,取函数的地址,然后稍后使用它来调用该函数:

int get_answer() { return 42; }
int (*fp)() = &get_answer;
// int (*fp)() = get_answer; same as &get_answer

函数指针可以像原始函数一样被调用:

get_answer(); // returns 42
fp(); // returns 42

假设我们正在编写一个程序,从输入中获取两个数字和一个字符,并对数字执行算术运算。操作由字符指定,无论是+-*,还是/。我们实现四个函数,add()subtract()multiply()divide(),并根据字符输入的值调用其中一个。

而不是在一堆if语句或switch语句中检查字符的值,我们将使用哈希表将操作的类型映射到指定的函数:

#include <unordered_map>
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
int divide(int a, int b) { return (b == 0) ? 0 : a / b; }

int main() {
 std::unordered_map<char, int (*)(int, int)> operations;
 operations['+'] = &add;
 operations['-'] = &subtract;
 operations['*'] = &multiply;
 operations['/'] = &divide;
  // read the input 
  char op;
  int num1, num2;
  std::cin >> num1 >> num2 >> op;
  // perform the operation, as follows
 operationsop;
}

正如你所看到的,std::unordered_mapchar映射到一个函数指针,定义为(*)(int, int)。也就是说,它可以指向任何接受两个整数并返回一个整数的函数。

哈希表由<unordered_map>头文件中定义的std::unordered_map表示。我们将在第六章中详细讨论它,深入 STL 中的数据结构和算法

现在我们不需要写以下内容:

if (op == '+') {
  add(num1, num2);
} else if (op == '-') {
  subtract(num1, num2);
} else if (op == '*') {
  ...

相反,我们只需调用由字符映射的函数:

operationsop;

虽然使用哈希表更加美观,看起来更专业,但你应该注意意外情况,比如无效的用户输入。

函数作为类型

unordered_map的第二个参数是int (*)(int, int),它字面上意味着指向接受两个整数并返回一个整数的函数的指针。C++支持类模板std::function作为通用函数包装器,允许我们存储可调用对象,包括普通函数、lambda 表达式、函数对象等。存储的对象被称为std::function的目标,如果它没有目标,那么在调用时将抛出std::bad_function_call异常。这不仅帮助我们使operations哈希表接受任何可调用对象作为它的第二个参数,还帮助我们处理异常情况,比如前面提到的无效字符输入。

以下代码块说明了这一点:

#include <functional>
#include <unordered_map>
// add, subtract, multiply and divide declarations omitted for brevity
int main() {
  std::unordered_map<char, std::function<int(int, int)> > operations;
  operations['+'] = &add;
  // ...
}

注意std::function的参数;它的形式是int(int, int)而不是int(*)(int, int)。使用std::function帮助我们处理异常情况。例如,调用operations'x';将导致创建一个空的std::function映射到字符x

调用它将抛出异常,因此我们可以通过正确处理调用来确保代码的安全性:

// code omitted for brevity
std::cin >> num1 >> num2 >> op;
try {
 operationsop;
} catch (std::bad_function_call e) {
  // handle the exception
  std::cout << "Invalid operation";
}

最后,我们可以使用lambda 表达式 - 在现场构造的未命名函数,能够捕获范围内的变量。例如,我们可以在将其插入哈希表之前创建一个 lambda 表达式,而不是声明前面的函数然后将其插入哈希表中:

std::unordered_map<char, std::function<int(int, int)> > operations;
operations['+'] = [](int a, int b) { return a + b; }
operations['-'] = [](int a, int b) { return a * b; }
// ...
std::cin >> num1 >> num2 >> op;
try {
  operationsop;
} catch (std::bad_functional_call e) {
  // ...
}

Lambda 表达式将在整本书中涵盖。

循环

循环可以被视为可重复的if语句,再次应该被转换为 CPU 比较和跳转指令。例如,我们可以使用while循环计算从 0 到 10 的数字的和:

auto num = 0;
auto sum = 0;
while (num <= 10) {
  sum += num;
  ++num;
}

这将转换为以下汇编代码(简化):

mov rax, 0; the sum
mov rcx, 0; the num
LOOP:
  cmp rbx, 10
  jg END; jump to the END if num is greater than 10
  add rax, rcx; add to sum
  inc rcx; increment num
  jmp LOOP; repeat
END:
  ...

C++17 引入了可以在条件语句和循环中使用的 init 语句。现在,在while循环之外声明的num变量可以移入循环中:

auto sum = 0;
while (auto num = 0; num <= 10) {
  sum += num;
  ++num;
}

相同的规则适用于if语句,例如:

int get_absolute(int num) {
  if (int neg = -num; neg < 0) {
    return -neg;
  }
  return num;
}

C++11 引入了基于范围的for循环,使语法更加清晰。例如,让我们使用新的for循环调用我们之前定义的所有算术操作:

for (auto& op: operations) {
  std::cout << op.second(num1, num2);
}

迭代unordered_map返回一个带有第一个和第二个成员的 pair,第一个是键,第二个是映射到该键的值。C++17 进一步推动我们,允许我们将相同的循环写成如下形式:

for (auto& [op, func]: operations) {
  std::cout << func(num1, num2);
}

了解编译器实际生成的内容对于设计和实现高效软件至关重要。我们涉及了条件语句和循环的低级细节,这些细节是几乎每个程序的基础。

总结

在本章中,我们介绍了程序执行的细节。我们讨论了函数和main()函数及其一些特殊属性。我们了解了递归的工作原理以及main()函数不能递归调用。

由于 C++是为数不多支持低级编程概念(例如通过地址访问内存字节)的高级语言之一,我们研究了数据驻留在内存中的方式以及如何在访问数据时可以使用指针。了解这些细节对于专业的 C++程序员来说是必不可少的。

最后,我们从汇编语言的角度讨论了条件语句和循环的主题。在整个章节中,我们介绍了 C++20 的特性。

在下一章中,我们将更多地了解面向对象编程OOP),包括语言对象模型的内部细节。我们将深入研究虚函数的细节,并了解如何使用多态性。

问题

  1. main()函数有多少个参数?

  2. constexpr限定符用于什么目的?

  3. 为什么建议使用迭代而不是递归?

  4. 堆栈和堆之间有什么区别?

  5. 如果声明为int*ptr的大小是多少?

  6. 为什么访问数组元素被认为是一个常数时间操作?

  7. 如果我们在switch语句的任何情况下忘记了break关键字会发生什么?

  8. 如何将算术操作示例中的multiply()divide()函数实现为 lambda 表达式?

进一步阅读

您可以参考以下书籍,了解本章涵盖的主题的更多信息:C++ High Performance,作者 Viktor Sehr 和 Bjorn Andrist(www.amazon.com/gp/product/1787120953)。

第三章:面向对象编程的细节

设计、实现和维护软件项目的难度取决于项目的复杂性。一个简单的计算器可以使用过程化方法(即过程式编程范式)编写,而使用相同方法实现银行账户管理系统将会太复杂。

C++支持面向对象编程(OOP),这是一种建立在将实体分解为存在于紧密互联网中的对象的范式。想象一下现实世界中的一个简单场景,当你拿遥控器换电视频道时。至少有三个不同的对象参与了这个动作:遥控器,电视,还有最重要的,你。为了用编程语言表达现实世界的对象及其关系,我们并不强制使用类,类继承,抽象类,接口,虚函数等。提到的特性和概念使得设计和编码过程变得更加容易,因为它们允许我们以一种优雅的方式表达和分享想法,但它们并不是强制性的。正如 C++的创造者 Bjarne Stroustrup 所说,“并非每个程序都应该是面向对象的。”为了理解面向对象编程范式的高级概念和特性,我们将尝试看看幕后发生了什么。在本书中,我们将深入探讨面向对象程序的设计。理解对象及其关系的本质,然后使用它们来设计面向对象的程序,是本书的目标之一。

在本章中,我们将详细了解以下主题:

  • 面向对象编程简介

  • C++对象模型

  • 类关系,包括继承

  • 多态

  • 有用的设计模式

技术要求

在本章中,我们将使用带有-std=c++2a选项的 g++编译器来编译示例。

您可以在github.com/PacktPublishing/Expert-CPP找到本章的源文件。

理解对象

大多数时候,我们操作的是以某个名称分组的数据集合,从而形成了抽象。例如is_militaryspeedseats等变量如果单独看并没有太多意义。将它们组合在spaceship这个名称下,改变了我们感知变量中存储的数据的方式。现在我们将许多变量打包成一个单一对象。为此,我们使用抽象;也就是说,我们从观察者的角度收集现实世界对象的各个属性。抽象是程序员工具链中的关键工具,因为它允许他们处理复杂性。C 语言引入了struct作为一种聚合数据的方式,如下面的代码所示:

struct spaceship {
  bool is_military;
  int speed;
  int seats;
};

对于面向对象编程来说,对数据进行分组是有必要的。每组数据都被称为一个对象。

对象的低级细节

C++尽其所能支持与 C 语言的兼容性。虽然 C 结构体只是一种允许我们聚合数据的工具,但 C++使它们等同于类,允许它们拥有构造函数、虚函数、继承其他结构体等。structclass之间唯一的区别是默认的可见性修饰符:结构体是public,类是private。通常使用结构体和类没有太大区别。面向对象编程需要的不仅仅是数据聚合。为了充分理解面向对象编程,让我们看看如果我们只有简单的结构体提供数据聚合而没有其他东西,我们如何将面向对象编程范式融入其中。

像亚马逊或阿里巴巴这样的电子商务市场的中心实体是Product,我们以以下方式表示它:

struct Product {
  std::string name;
  double price;
  int rating;
  bool available;
};

如果需要,我们将向Product添加更多成员。Product类型的对象的内存布局可以像这样:

声明Product对象在内存中占用sizeof(Product)的空间,而声明对象的指针或引用占用存储地址的空间(通常为 4 或 8 个字节)。请参阅以下代码块:

Product book;
Product tshirt;
Product* ptr = &book;
Product& ref = tshirt;

我们可以将上述代码描述如下:

让我们从Product对象在内存中占用的空间开始。我们可以通过总结其成员变量的大小来计算Product对象的大小。boolean变量的大小为 1 个字节。在 C++标准中没有明确规定doubleint的确切大小。在 64 位机器上,double变量通常占用 8 个字节,int变量占用 4 个字节。

std::string的实现在标准中没有指定,因此其大小取决于库的实现。string存储指向字符数组的指针,但也可能存储分配的字符数,以便在调用size()时高效返回。std::string的一些实现占用 8、24 或 32 个字节的内存,但我们将在示例中坚持使用 24 个字节。总结一下,Product的大小如下:

24 (std::string) + 8 (double) + 4 (int) + 1 (bool) = 37 bytes.

打印Product的大小会输出不同的值:

std::cout << sizeof(Product);

它输出40而不是计算出的 37 个字节。冗余字节背后的原因是结构的填充,这是编译器为了优化对对象的各个成员的访问而实践的一种技术。中央处理单元(CPU)以固定大小的字读取内存。字的大小由 CPU 定义(通常为 32 位或 64 位)。如果数据从与字对齐的地址开始,CPU 可以一次访问数据。例如,Productboolean数据成员需要 1 个字节的内存,可以直接放在评级成员后面。事实证明,编译器对数据进行了对齐以加快访问速度。假设字大小为 4 个字节。这意味着如果变量从可被 4 整除的地址开始,CPU 将无需冗余步骤即可访问变量。编译器会提前用额外的字节来对齐结构的成员到字边界地址。

对象的高级细节

我们将对象视为代表抽象结果的实体。我们已经提到了观察者的角色,即根据问题域定义对象的程序员。程序员定义这个过程代表了抽象的过程。让我们以电子商务市场及其产品为例。两个不同的程序员团队可能对同一产品有不同的看法。实现网站的团队关心对象的属性,这些属性对网站访问者:购买者至关重要。我们在Product结构中显示的属性主要是为网站访问者而设,比如销售价格、产品评级等。实现网站的程序员接触问题域,并验证定义Product对象所必需的属性。

负责实现帮助管理仓库中产品的在线工具的团队关心对象的属性,这些属性在产品放置、质量控制和装运方面至关重要。这个团队实际上不应该关心产品的评级甚至价格。这个团队主要关心产品的重量尺寸状态。以下插图显示了感兴趣的属性:

程序员在开始项目时应该做的第一件事是分析问题并收集需求。换句话说,他们应该熟悉问题域并定义项目需求。分析的过程导致定义对象及其类型,比如我们之前讨论的Product。为了从分析中得到正确的结果,我们应该以对象的方式思考,而通过以对象的方式思考,我们指的是考虑对象的三个主要属性:状态行为身份

状态

每个对象都有一个状态,可能与其他对象的状态相同也可能不同。我们已经介绍了Product结构,它代表了一个物理(或数字)产品的抽象。product对象的所有成员共同代表了对象的状态。例如,Product包含诸如available之类的成员,它是一个布尔值;如果产品有库存,则等于true。成员变量的值定义了对象的状态。如果给对象成员分配新值,它的状态将会改变:

Product cpp_book; // declaring the object
...
// changing the state of the object cpp_book
cpp_book.available = true;
cpp_book.rating = 5;

对象的状态是其所有属性和值的组合。

身份

身份是区分一个对象与另一个对象的特征。即使我们试图声明两个在物理上无法区分的对象,它们仍然会有不同的变量名称,也就是不同的身份:

Product book1;
book1.rating = 4;
book1.name = "Book";
Product book2;
book2.rating = 4;
book2.name = "Book";

前面例子中的对象具有相同的状态,但它们的名称不同,即book1book2。假设我们有能力以某种方式创建具有相同名称的对象,就像下面的代码所示:

Product prod;
Product prod; // won't compile, but still "what if?"

如果是这样的话,它们在内存中仍然会有不同的地址:

身份是对象的基本属性,也是我们无法创建对象的原因之一,比如下面的情况:

struct Empty {};

int main() {
 Empty e;
  std::cout << sizeof(e);
}

前面的代码不会像预期的那样输出0。空对象的大小在标准中没有指定;编译器开发人员倾向于为这样的对象分配 1 个字节,尽管您可能也会遇到 4 或 8。两个或更多个Empty的实例在内存中应该有不同的地址,因此编译器必须确保对象至少占用 1 个字节的内存。

行为

在之前的例子中,我们将54分配给了rating成员变量。通过给对象分配无效的值,我们可以很容易地使事情出乎意料地出错,就像这样:

cpp_book.rating = -12;

-12在产品评级方面是无效的,如果允许的话会使用户感到困惑。我们可以通过提供setter函数来控制对对象所做更改的行为:

void set_rating(Product* p, int r) {
  if (r >= 1 && r <= 5) {
 p->rating = r;
 }
  // otherwise ignore
}
...
set_rating(&cpp_book, -12); // won't change the state

对象对来自其他对象的请求作出反应。请求是通过函数调用执行的,否则称为消息:一个对象向另一个对象传递消息。在前面的例子中,将相应的set_rating消息传递给cpp_book对象的对象代表我们调用set_rating()函数的对象。在这种情况下,我们假设从main()中调用函数,实际上main()并不代表任何对象。我们可以说它是全局对象,操作main()函数的对象,尽管在 C++中并没有这样的实体。

我们在概念上区分对象,而不是在物理上。这是以对象思考的主要观点。面向对象编程的一些概念的物理实现并不是标准化的,所以我们可以将Product结构命名为类,并声称cpp_bookProduct实例,并且它有一个名为set_rating()的成员函数。C++的实现几乎是一样的:它提供了语法上方便的结构(类、可见性修饰符、继承等),并将它们转换为简单的结构,例如前面例子中的set_rating()全局函数。现在,让我们深入了解 C++对象模型的细节。

模拟类

结构体允许我们将变量分组,命名它们,并创建对象。类的概念是在对象中包含相应的操作,将适用于该特定数据的数据和操作分组在一起。例如,对于Product类型的对象,直接在对象上调用set_rating()函数将是很自然的,而不是使用一个单独的接受Product对象指针并修改它的全局函数。然而,由于我们同意以 C 方式使用结构体,我们无法负担得起拥有成员函数。为了使用 C 结构体模拟类,我们必须声明与Product对象一起工作的函数作为全局函数,如下面的代码所示:

struct Product {
  std::string name;
  double price;
  int rating;
  bool available;
};

void initialize(Product* p) {
  p->price = 0.0;
  p->rating = 0;
  p->available = false;
}

void set_name(Product* p, const std::string& name) {
  p->name = name;
}

std::string get_name(Product* p) {
  return p->name;
}

void set_price(Product* p, double price) {
  if (price < 0 || price > 9999.42) return;
  p->price = price;
}

double get_price(Product* p) {
  return p->price;
}

// code omitted for brevity

要将结构体用作类,我们应该按正确的顺序手动调用函数。例如,要使用具有正确初始化默认值的对象,我们必须首先调用initialize()函数:

int main() {
  Product cpp_book;
 initialize(&cpp_book);
  set_name(&cpp_book, "Mastering C++ Programming");
  std::cout << "Book title is: " << get_name(&cpp_book);
  // ...
}

这似乎是可行的,但如果添加新类型,前面的代码将很快变成一个无组织的混乱。例如,考虑跟踪产品的Warehouse结构体:

struct Warehouse {
  Product* products;
  int capacity;
  int size;
};

void initialize_warehouse(Warehouse* w) {
  w->capacity = 1000;
  w->size = 0;
  w->products = new Product[w->capacity];
  for (int ix = 0; ix < w->capacity; ++ix) {
    initialize(&w->products[ix]); // initialize each Product object
  }
}

void set_size(int size) { ... }
// code omitted for brevity

首先明显的问题是函数的命名。我们不得不将Warehouse的初始化函数命名为initialize_warehouse,以避免与已声明的Productinitialize()函数发生冲突。我们可能会考虑重命名Product类型的函数,以避免将来可能的冲突。接下来是函数的混乱。现在,我们有一堆全局函数,随着我们添加新类型,这些函数的数量将增加。如果我们添加一些类型的层次结构,它将变得更加难以管理。

尽管编译器倾向于将类翻译为具有全局函数的结构体,正如我们之前展示的那样,但 C++和其他高级编程语言解决了这些问题以及其他未提及的问题,引入了将它们组织成层次结构的平滑机制。从概念上讲,关键字(classpublicprivate)和机制(继承和多态)是为了方便开发人员组织他们的代码,但不会使编译器的生活变得更容易。

使用类进行工作

在处理对象时,类使事情变得更容易。它们在面向对象编程中做了最简单必要的事情:将数据与操作数据的函数结合在一起。让我们使用类及其强大的特性重写Product结构体的示例:

class Product {
public:
  Product() = default; // default constructor
  Product(const Product&); // copy constructor
  Product(Product&&); // move constructor

  Product& operator=(const Product&) = default;
  Product& operator=(Product&&) = default;
  // destructor is not declared, should be generated by the compiler
public:
  void set_name(const std::string&);
  std::string name() const;
  void set_availability(bool);
  bool available() const;
  // code omitted for brevity

private:
  std::string name_;
  double price_;
  int rating_;
  bool available_;
};

std::ostream& operator<<(std::ostream&, const Product&);
std::istream& operator>>(std::istream&, Product&);

类声明似乎更有组织性,尽管它公开的函数比我们用来定义类似结构体的函数更多。这是我们应该如何说明这个类的方式:

前面的图像有些特殊。正如你所看到的,它有组织良好的部分,在函数名称之前有标志等。这种类型的图表被称为统一建模语言(UML)类图。UML 是一种标准化说明类及其关系的方式。第一部分是类的名称(粗体),接下来是成员变量部分,然后是成员函数部分。函数名称前的+(加号)表示该函数是公共的。成员变量通常是私有的,但如果需要强调这一点,可以使用-(减号)。我们可以通过简单地说明类来省略所有细节,如下面的 UML 图所示:

我们将在本书中使用 UML 图表,并根据需要引入新类型的图表。在处理初始化、复制、移动、默认和删除函数以及运算符重载之前,让我们先澄清一些事情。

从编译器的角度看待类

首先,无论与之前的类相比,类似怪物的类看起来多么庞大,编译器都会将其转换为以下代码(我们稍微修改了它以简化):

struct Product {
  std::string name_;
  bool available_;
  double price_;
  int rating_;
};

// we forced the compiler to generate the default constructor
void Product_constructor(Product&); 
void Product_copy_constructor(Product& this, const Product&);
void Product_move_constructor(Product& this, Product&&);
// default implementation
Product& operator=(Product& this, const Product&); 
// default implementation
Product& operator=(Product& this, Product&&); 

void Product_set_name(const std::string&);
// takes const because the method was declared as const
std::string Product_name(const Product& this); 
void Product_set_availability(Product& this, bool b);
bool Product_availability(const Product& this);

std::ostream& operator<<(std::ostream&, const Product&);
std::istream& operator>>(std::istream&, Product&);

基本上,编译器生成了与我们之前介绍的相同的代码,以模仿使用简单结构体来实现类行为的方式。尽管编译器在实现 C++对象模型的技术和方法上有所不同,但前面的例子是编译器开发人员实践的流行方法之一。它在访问对象成员(包括成员函数)的空间和时间效率之间取得了平衡。

接下来,我们应该考虑编译器通过增加和修改来编辑我们的代码。下面的代码声明了全局create_apple()函数,它创建并返回一个具有特定苹果值的Product对象。它还在main()函数中声明了一个书对象:

Product create_apple() {
 Product apple;
  apple.set_name("Red apple");
  apple.set_price("0.2");
  apple.set_rating(5);
  apple.set_available(true);
  return apple;
}

int main() {
 Product red_apple = create_apple();
 Product book;  Product* ptr = &book;
  ptr->set_name("Alice in Wonderland");
  ptr->set_price(6.80);
  std::cout << "I'm reading " << book.name() 
            << " and I bought an apple for " << red_apple.price()
            << std::endl;
}

我们已经知道编译器修改类以将其转换为结构体,并将成员函数移动到全局范围,每个成员函数都以类的引用(或指针)作为其第一个参数。为了支持客户端代码中的这些修改,它还应该修改对所有对象的访问。

客户端代码是声明或使用已声明的类对象的一行或多行代码。

以下是我们假设编译器修改了前面代码的方式(我们使用了“假设”这个词,因为我们试图引入一个编译器抽象而不是特定于编译器的方法):

void create_apple(Product& apple) {
  Product_set_name(apple, "Red apple");
  Product_set_price(apple, 0.2);
  Product_set_rating(apple, 5);
  Product_set_available(apple, true);
  return;
}

int main() {
  Product red_apple;
 Product_constructor(red_apple);
 create_apple(red_apple);
  Product book;
 Product* ptr;
 Product_constructor(book);
 Product_set_name(*ptr, "Alice in Wonderland");
 Product_set_price(*ptr, 6.80);
  std::ostream os = operator<<(std::cout, "I'm reading ");
  os = operator<<(os, Product_name(book));
  os = operator<<(os, " and I bought an apple for ");
  os = operator<<(os, Product_price(red_apple));
  operator<<(os, std::endl);
  // destructor calls are skipped because the compiler 
  // will remove them as empty functions to optimize the code
  // Product_destructor(book);
  // Product_destructor(red_apple);
}

编译器还优化了对create_apple()函数的调用,以避免临时对象的创建。我们将在本章后面讨论编译器生成的隐式临时对象。

初始化和销毁

正如之前所示,对象的创建是一个两步过程:内存分配和初始化。内存分配是对象声明的结果。C++不关心变量的初始化;它分配内存(无论是自动还是手动)就完成了。实际的初始化应该由程序员完成,这就是我们首先需要构造函数的原因。

析构函数也是同样的逻辑。如果我们跳过默认构造函数或析构函数的声明,编译器应该会隐式生成它们,如果它们是空的话也会移除它们(以消除对空函数的冗余调用)。如果声明了带参数的构造函数,包括拷贝构造函数,编译器就不会生成默认构造函数。我们可以强制编译器隐式生成默认构造函数:

class Product {
public:
 Product() = default;
  // ...
};

我们还可以通过使用delete修饰符来强制不生成编译器,如下所示:

class Product {
public:
 Product() = delete;
  // ...
};

这将禁止默认初始化对象的声明,也就是说,Product p; 不会编译。

析构函数的调用顺序与对象声明的顺序相反,因为自动内存分配由堆栈管理,而堆栈是遵循后进先出(LIFO)规则的数据结构适配器。

对象初始化发生在对象创建时。销毁通常发生在对象不再可访问时。当对象在堆上分配时,后者可能会有些棘手。看一下下面的代码;它在不同的作用域和内存段中声明了四个Product对象:

static Product global_prod; // #1

Product* foo() {
  Product* heap_prod = new Product(); // #4
  heap_prod->name = "Sample";
  return heap_prod;
}

int main() {
 Product stack_prod; // #2
  if (true) {
    Product tmp; // #3
    tmp.rating = 3;
  }
  stack_prod.price = 4.2;
  foo();
}

global_prod具有静态存储期,并且放置在程序的全局/静态部分;它在调用main()之前被初始化。当main()开始时,stack_prod被分配在堆栈上,并且在main()结束时被销毁(函数的闭合大括号被视为其结束)。虽然条件表达式看起来很奇怪和太人为,但这是一种表达块作用域的好方法。

tmp对象也将分配在堆栈上,但其存储持续时间限制在其声明的范围内:当执行离开if块时,它将被自动销毁。这就是为什么堆栈上的变量具有自动存储持续时间。最后,当调用foo()函数时,它声明了heap_prod指针,该指针指向在堆上分配的Product对象的地址。

上述代码包含内存泄漏,因为heap_prod指针(它本身具有自动存储持续时间)将在执行到达foo()末尾时被销毁,而在堆上分配的对象不会受到影响。不要混淆指针和它指向的实际对象:指针只包含对象的值,但它并不代表对象。

不要忘记释放在堆上动态分配的内存,可以通过手动调用删除运算符或使用智能指针来实现。智能指针将在第五章中讨论,内存管理和智能指针

当函数结束时,分配在堆栈上的参数和局部变量的内存将被释放,但global_prod将在程序结束时被销毁,也就是在main()函数结束后。当对象即将被销毁时,析构函数将被调用。

复制对象

有两种复制方式:对象的复制和复制。语言允许我们使用复制构造函数赋值运算符来管理对象的复制初始化和赋值。这对程序员来说是一个必要的特性,因为我们可以控制复制的语义。看下面的例子:

Product p1;
Product p2;
p2.set_price(4.2);
p1 = p2; // p1 now has the same price
Product p3 = p2; // p3 has the same price

p1 = p2;这一行是对赋值运算符的调用,而最后一行是对复制构造函数的调用。等号不应该让你困惑,无论是赋值还是复制构造函数调用。每当看到声明后面跟着一个赋值时,都可以将其视为复制构造。新的初始化程序语法(Product p3{p2};)也是如此。

编译器将生成以下代码:

Product p1;
Product p2;
Product_set_price(p2, 4.2);
operator=(p1, p2);
Product p3;
Product_copy_constructor(p3, p2);

复制构造函数(和赋值运算符)的默认实现执行对象的成员逐个复制,如下图所示:

如果成员逐个复制产生无效副本,则需要自定义实现。例如,考虑以下Warehouse对象的复制:

class Warehouse {
public:
  Warehouse() 
    : size_{0}, capacity_{1000}, products_{nullptr}
  {
    products_ = new Products[capacity_];
  }

  ~Warehouse() {
    delete [] products_;
  }

public:
  void add_product(const Product& p) {
    if (size_ == capacity_) { /* resize */ }
    products_[size_++] = p;
  }
  // other functions omitted for brevity

private:
  int size_;
  int capacity_;
  Product* products_;
};

int main() {
  Warehouse w1;
  Product book;
  Product apple;
  // ...assign values to products (omitted for brevity)
  w1.add_product(book);
  Warehouse w2 = w1; // copy
  w2.add_product(apple);
  // something somewhere went wrong...
}

上述代码声明了两个Warehouse对象,然后向仓库添加了两种不同的产品。虽然这个例子有些不自然,但它展示了默认复制实现的危险。以下插图展示了代码中出现的问题:

w1赋给w2会导致以下结构:

默认实现只是将w1的每个成员复制到w2。复制后,w1w2products_成员都指向堆上的相同位置。当我们向w2添加新产品时,w1指向的数组会受到影响。这是一个逻辑错误,可能导致程序中的未定义行为。我们需要进行复制而不是复制;也就是说,我们需要实际创建一个包含 w1 数组副本的新产品数组。

自定义实现复制构造函数和赋值运算符解决了复制的问题:

class Warehouse {
public:
  // ...
  Warehouse(const Warehouse& rhs) {
 size_ = rhs.size_;
 capacity_ = rhs.capacity_;
 products_ = new Product[capacity_];
 for (int ix = 0; ix < size_; ++ix) {
 products_[ix] = rhs.products_[ix];
 }
 }
  // code omitted for brevity
};  

复制构造函数的自定义实现创建一个新数组。然后,它逐个复制源对象的数组元素,从而消除了product_指针指向错误的内存地址。换句话说,我们通过创建一个新数组实现了Warehouse对象的深复制。

移动对象

临时对象在代码中随处可见。大多数情况下,它们是必需的,以使代码按预期工作。例如,当我们将两个对象相加时,会创建一个临时对象来保存operator+的返回值:

Warehouse small;
Warehouse mid;
// ... some data inserted into the small and mid objects
Warehouse large{small + mid}; // operator+(small, mid)

让我们来看看Warehouse对象的全局operator+()的实现:

// considering declared as friend in the Warehouse class
Warehouse operator+(const Warehouse& a, const Warehouse& b) {
  Warehouse sum; // temporary
  sum.size_ = a.size_ + b.size_;
  sum.capacity_ = a.capacity_ + b.capacity_;
  sum.products_ = new Product[sum.capacity_];
  for (int ix = 0; ix < a.size_; ++ix) { sum.products_[ix] = a.products_[ix]; }
  for (int ix = 0; ix < b.size_; ++ix) { sum.products_[a.size_ + ix] = b.products_[ix]; }
  return sum;
}

前面的实现声明了一个临时对象,并在填充必要数据后返回它。在前面的示例中,调用可以被翻译成以下内容:

Warehouse small;
Warehouse mid;
// ... some data inserted into the small and mid objects
Warehouse tmp{operator+(small, mid)};
Warehouse large;
Warehouse_copy_constructor(large, tmp);
__destroy_temporary(tmp);

移动语义,它在 C++11 中引入,允许我们通过移动返回值到Warehouse对象中来跳过临时创建。为此,我们应该为Warehouse声明一个移动构造函数,它可以区分临时对象并有效地处理它们:

class Warehouse {
public:
  Warehouse(); // default constructor
  Warehouse(const Warehouse&); // copy constructor
  Warehouse(Warehouse&&); // move constructor
  // code omitted for brevity
};

移动构造函数的参数是rvalue 引用&&)。

Lvalue 引用

在理解为什么首先引入 rvalue 引用之前,让我们澄清一下关于lvaluesreferenceslvalue-references的事情。当一个变量是 lvalue 时,它可以被寻址,可以被指向,并且具有作用域存储期:

double pi{3.14}; // lvalue
int x{42}; // lvalue
int y{x}; // lvalue
int& ref{x}; // lvalue-reference

ref是一个lvalue引用,相当于可以被视为const指针的变量:

int * const ref = &x;

除了通过引用修改对象的能力,我们还通过引用将重型对象传递给函数,以便优化和避免冗余对象的复制。例如,Warehouseoperator+接受两个对象的引用,因此它复制对象的地址而不是完整对象。

Lvalue引用在函数调用方面优化了代码,但是为了优化临时对象,我们应该转向 rvalue 引用。

Rvalue 引用

我们不能将lvalue引用绑定到临时对象。以下代码将无法编译:

int get_it() {
  int it{42};
  return it;
}
...
int& impossible{get_it()}; // compile error

我们需要声明一个rvalue引用,以便能够绑定到临时对象(包括文字值):

int&& possible{get_it()};

Rvalue引用允许我们尽可能地跳过临时对象的生成。例如,以 rvalue 引用接受结果的函数通过消除临时对象而运行得更快:

void do_something(int&& val) {
  // do something with the val
}
// the return value of the get_it is moved to do_something rather than copied
do_something(get_it()); 

为了想象移动的效果,想象一下前面的代码将被翻译成以下内容(只是为了完全理解移动):

int val;
void get_it() {
  val = 42;
}
void do_something() {
  // do something with the val
}
do_something();

在引入移动之前,前面的代码看起来像这样(带有一些编译器优化):

int tmp;
void get_it() {
  tmp = 42;
}
void do_something(int val) {
  // do something with the val
}
do_something(tmp);

移动构造函数和移动操作符=()一起,当输入参数表示一个rvalue时,具有复制而不实际执行复制操作的效果。这就是为什么我们应该在类中实现这些新函数:这样我们就可以在任何有意义的地方优化代码。移动构造函数可以获取源对象而不是复制它,如下所示:

class Warehouse {
public:
  // constructors omitted for brevity
  Warehouse(Warehouse&& src)
 : size_{src.size_}, 
 capacity_{src.capacity_},
 products_{src.products_}
 {
 src.size_ = 0;
 src.capacity_ = 0;
 src.products_ = nullptr;
 }
};

我们不是创建一个capacity_大小的新数组,然后复制products_数组的每个元素,而是直接获取了数组的指针。我们知道src对象是一个 rvalue,并且它很快就会被销毁,这意味着析构函数将被调用,并且析构函数将删除分配的数组。现在,我们指向新创建的Warehouse对象的分配数组,这就是为什么我们不能让析构函数删除源数组。因此,我们将nullptr赋给它,以确保析构函数不会错过分配的对象。因此,由于移动构造函数,以下代码将被优化:

Warehouse large = small + mid;

+操作符的结果将被移动而不是复制。看一下下面的图表:

前面的图表演示了临时对象如何被移动到大对象中。

运算符重载的注意事项

C++为自定义类型提供了强大的运算符重载机制。使用+运算符计算两个对象的和要比调用成员函数好得多。调用成员函数还涉及在调用之前记住它的名称。它可能是addcalculateSumcalculate_sum或其他名称。运算符重载允许在类设计中采用一致的方法。另一方面,运算符重载会增加代码中不必要的冗长。以下代码片段表示对Money类进行了比较运算符的重载,以及加法和减法:

constexpr bool operator<(const Money& a, const Money& b) { 
  return a.value_ < b.value_; 
}
constexpr bool operator==(const Money& a, const Money& b) { 
  return a.value_ == b.value_; 
}
constexpr bool operator<=(const Money& a, const Money& b) { 
  return a.value_ <= b.value_; 
}
constexpr bool operator!=(const Money& a, const Money& b) { 
  return !(a == b); 
}
constexpr bool operator>(const Money& a, const Money& b) { 
  return !(a <= b); 
}
constexpr bool operator>=(const Money& a, const Money& b) { 
  return !(a < b); 
}
constexpr Money operator+(const Money& a, const Money& b) { 
  return Money{a.value_ + b.value_}; 
}
constexpr Money operator-(const Money& a, const Money& b) { 
  return Money{a.value_ - b.value_}; 
}

正如你所看到的,前面大部分函数直接访问了Money实例的值成员。为了使其工作,我们应该将它们声明为Money的友元。Money将如下所示:

class Money
{
public:
  Money() {}
  explicit Money(double v) : value_{v} {}
  // construction/destruction functions omitted for brevity

public:
  friend constexpr bool operator<(const Money&, const Money&);
 friend constexpr bool operator==(const Money&, const Money&);
 friend constexpr bool operator<=(const Money&, const Money&);
 friend constexpr bool operator!=(const Money&, const Money&);
 friend constexpr bool operator>(const Money&, const Money&);
 friend constexpr bool operator>=(const Money&, const Money&);
 friend constexpr bool operator+(const Money&, const Money&);
 friend constexpr bool operator-(const Money&, const Money&);

private:
  double value_;
}; 

这个类看起来很庞大。C++20 引入了太空船操作符,它允许我们跳过比较运算符的定义。operator<=>(),也被称为三路比较运算符,请求编译器生成关系运算符。对于Money类,我们可以使用默认的operator<=>(),如下所示:

class Money
{
  // code omitted for brevity
 friend auto operator<=>(const Money&, const Money&) = default;
};

编译器将生成==!=<><=>=运算符。太空船运算符减少了运算符的冗余定义,并提供了一种为所有生成的运算符实现通用行为的方法。在为太空船运算符实现自定义行为时,我们应该注意运算符的返回值类型。它可以是以下之一:

  • std::strong_ordering

  • std::weak_ordering

  • std::partial_ordering

  • std::strong_equality

  • std::weak_equality

它们都在<compare>头文件中定义。编译器根据三路运算符的返回类型生成运算符。

封装和公共接口

封装是面向对象编程中的一个关键概念。它允许我们隐藏对象的实现细节,使其对客户端代码不可见。以计算机键盘为例;它有用于字母、数字和符号的按键,每个按键在按下时都会起作用。它的使用简单直观,并隐藏了许多只有熟悉电子设备的人才能处理的低级细节。想象一下一个没有按键的键盘——一个只有裸板和未标记引脚的键盘。你将不得不猜测要按下哪个键才能实现所需的按键组合或文本输入。现在,想象一个没有引脚的键盘——你必须向相应的插座发送正确的信号才能获得特定符号的按键按下事件。用户可能会因为缺少标签而感到困惑,他们也可能会错误地按下或向无效的插座发送信号。我们所知道的键盘通过封装实现了这一点——程序员也通过封装对象来确保用户不会因为冗余成员而负担过重,以及确保用户不会以错误的方式使用对象。

在类中,可见性修饰符通过允许我们定义任何成员的可访问级别来实现这一目的。private修饰符禁止客户端代码使用private成员,这使我们能够通过提供相应的成员函数来控制private成员的修改。一个mutator函数,对许多人来说是一个设置函数,会在测试该特定类的值是否符合指定规则后修改private成员的值。以下代码中可以看到这一点的例子:

class Warehouse {
public:
  // rather naive implementation
  void set_size(int sz) {
 if (sz < 1) throw std::invalid_argument("Invalid size");
 size_ = sz;
 }
  // code omitted for brevity
private:
  int size_;
};

通过mutator函数修改数据成员允许我们控制其值。实际数据成员是私有的,这使得它无法从客户端代码访问,而类本身提供了公共函数来更新或读取其私有成员的内容。这些函数以及构造函数通常被称为类的公共接口。程序员们努力使类的公共接口用户友好。

看一下下面的类,它表示一个二次方程求解器:一个形式为ax² + bx + c = 0的方程。找到判别式并根据判别式(D)的值计算x的值是解决方案之一。以下类提供了五个函数,分别用于设置abc的值,找到判别式,解决并返回x的值:

class QuadraticSolver {
public:
  QuadraticSolver() = default;
  void set_a(double a);
 void set_b(double b);
 void set_c(double c);
 void find_discriminant();
 double solve(); // solve and return the x
private:
  double a_;
  double b_;
  double c_;
  double discriminant_;
};

公共接口包括前面提到的四个函数和默认构造函数。要解决方程2x² + 5x - 8 = 0,我们应该这样使用QuadraticSolver

QuadraticSolver solver;
solver.set_a(2);
solver.set_b(5);
solver.set_c(-8);
solver.find_discriminant();
std::cout << "x is: " << solver.solve() << std::endl;

类的公共接口应该被明智地设计;前面的例子显示了糟糕设计的迹象。用户必须知道协议,也就是确切的调用函数的顺序。如果用户忽略了对find_discriminant()的调用,结果将是未定义或无效的。公共接口强迫用户学习协议,并按正确的顺序调用函数,即设置abc的值,然后调用find_discriminant()函数,最后调用solve()函数以获得x的期望值。一个好的设计应该提供一个直观易用的公共接口。我们可以重写QuadraticSolver,使其只有一个函数,接受所有必要的输入值,计算判别式本身,并返回解决方案:

class QuadtraticSolver {
public:
  QuadraticSolver() = default;
 double solve(double a, double b, double c);
};

前面的设计比之前的更直观。以下代码演示了如何使用QuadraticSolver来找到方程2x2 + 5x - 8 = 0的解:

QuadraticSolver solver;
std::cout << solver.solve(2, 5, -8) << std::endl;

在这里需要考虑的最后一件事是,二次方程可以有多种解法。我们介绍的方法是通过找到判别式来解决的。我们应该考虑,将来我们可能会为这个类添加更多的实现方法。改变函数的名称可能会增加公共接口的可读性,并确保对类的未来更新。我们还应该注意,在前面的例子中,solve()函数接受abc作为参数,我们不需要在类中存储它们,因为解决方案是直接在函数中计算的。

显然,声明一个QuadraticSolver的对象只是为了能够访问solve()函数似乎是一个多余的步骤。类的最终设计将如下所示:

class QuadraticSolver {
public:
  QuadraticSolver() = delete;

  static double solve_by_discriminant(double a, double b, double c);
  // other solution methods' implementations can be prefixed by "solve_by_"
};

我们将solve()函数重命名为solve_by_discriminant(),这也暴露了解决方案的底层方法。我们还将函数设为static,这样用户就可以在不声明类的实例的情况下使用它。然而,我们还将默认构造函数标记为deleted,这再次强制用户不要声明对象:

std::cout << QuadraticSolver::solve_by_discriminant(2, 5, -8) << std::endl;

客户端代码现在使用该类的工作量更少。

C++中的结构体

在 C++中,结构体和类几乎是相同的。它们具有类的所有特性,你可以从结构体继承一个类,反之亦然。classstruct之间唯一的区别是默认可见性。对于结构体,默认可见性修饰符是公共的。它也与继承有关。例如,当你从另一个类继承一个类而不使用修饰符时,它会私有继承。以下类私有地继承自Base

class Base
{
public:
  void foo() {}
};

class Derived : Base
{
  // can access foo() while clients of Derived can't
};

按照相同的逻辑,以下结构体公开继承Base

struct Base
{
  // no need to specify the public section
  void foo() {}
};

struct Derived : Base
{
  // both Derived and clients of Derived can access foo()
};

与继承自结构体的类相关。例如,如果没有直接指定,Derived类会私有地继承Base

struct Base
{
  void foo() {}
};

// Derived inherits Base privately
class Derived: Base
{
  // clients of Derived can't access foo()
};

在 C++中,结构体和类是可以互换的,但大多数程序员更喜欢使用结构体来表示简单类型。C++标准对简单类型给出了更好的定义,并称它们为聚合。如果一个类(结构体)符合以下规则,则它是一个聚合:

  • 没有私有或受保护的非静态数据成员

  • 没有用户声明或继承的构造函数

  • 没有虚拟、私有或受保护的基类

  • 没有虚成员函数

在完成本章后,大多数规则会更加清晰。以下结构是一个聚合的例子:

struct Person
{
  std::string name;
  int age;
  std::string profession;
};

在深入研究继承和虚函数之前,让我们看看聚合在初始化时带来了什么好处。我们可以以以下方式初始化Person对象:

Person john{"John Smith", 22, "programmer"};

C++20 提供了更多初始化聚合的新方法:

Person mary{.name = "Mary Moss", .age{22}, .profession{"writer"}};

注意我们如何通过指示符混合初始化成员。

结构化绑定允许我们声明绑定到聚合成员的变量,如下面的代码所示:

const auto [p_name, p_age, p_profession] = mary;
std::cout << "Profession is: " << p_profession << std::endl;

结构化绑定也适用于数组。

类关系

对象间通信是面向对象系统的核心。关系是对象之间的逻辑链接。我们如何区分或建立类对象之间的适当关系,定义了系统设计的性能和质量。考虑ProductWarehouse类;它们处于一种称为聚合的关系,因为Warehouse包含Products,也就是说,Warehouse聚合了Products

在纯面向对象编程中有几种关系,比如关联、聚合、组合、实例化、泛化等。

聚合和组合

我们在Warehouse类的例子中遇到了聚合。Warehouse类存储了一个产品数组。更一般的说,它可以被称为关联,但为了强调确切的包含性,我们使用聚合组合这个术语。在聚合的情况下,包含其他类的类可以在没有聚合的情况下实例化。这意味着我们可以创建和使用Warehouse对象,而不一定要创建Warehouse中包含的Product对象。

聚合的另一个例子是CarPersonCar可以包含一个Person对象(作为驾驶员或乘客),因为它们彼此相关,但包含性不强。我们可以创建一个没有DriverCar对象,如下所示:

class Person; // forward declaration
class Engine { /* code omitted for brevity */ };
class Car {
public:
  Car();
  // ...
private:
  Person* driver_; // aggregation
  std::vector<Person*> passengers_; // aggregation
  Engine engine_; // composition
  // ...
}; 

强大的包含性由组合来表达。以Car为例,需要一个Engine类的对象才能组成一个完整的Car对象。在这种物理表示中,当创建一个Car时,Engine成员会自动创建。

以下是聚合和组合的 UML 表示:

在设计类时,我们必须决定它们的关系。定义两个类之间的组合关系的最佳方法是有一个关系测试。Car 有一个 Engine,因为汽车有发动机。每当你不能确定关系是否应该以组合的方式表达时,问一下有一个的问题。聚合和组合有些相似;它们只是描述了连接的强度。对于聚合,适当的问题应该是可以有一个;例如,一个Car可以有一个驾驶员(类型为Person);也就是说,包含性是弱的。

继承

继承是一种允许我们重用类的编程概念。编程语言提供了不同的继承实现,但总的规则始终是:类关系应该回答是一个的问题。例如,Car是一个Vehicle,这使我们可以从Vehicle继承Car

class Vehicle {
public:
  void move();
};

class Car : public Vehicle {
public:
  Car();
  // ...
};

Car现在有了从Vehicle继承而来的move()成员函数。继承本身代表了一种泛化/特化的关系,其中父类(Vehicle)是泛化,子类(Car)是特化。

父类可以被称为基类或超类,而子类可以被称为派生类或子类。

只有在绝对必要的情况下才应考虑使用继承。正如我们之前提到的,类应该满足是一个的关系,有时这有点棘手。考虑SquareRectangle类。以下代码以可能的最简形式声明了Rectangle类:

class Rectangle {
public:
  // argument checks omitted for brevity
  void set_width(int w) { width_ = w; }
  void set_height(int h) { height_ = h; }
  int area() const { return width_ * height_; }
private:
  int width_;
  int height_;
};

Square 是一个 Rectangle,所以我们可以很容易地从Rectangle继承它:

class Square : public Rectangle {
public:
  void set_side(int side) {
 set_width(side);
 set_height(side);
  }

 int area() { 
    area_ = Rectangle::area();
    return area_; 
  }
private:
 int area_;
};

Square通过添加一个新的数据成员area_并覆盖area()成员函数的实现来扩展Rectangle。在实践中,area_及其计算方式是多余的;我们这样做是为了演示一个糟糕的类设计,并使Square在一定程度上扩展其父类。很快,我们将得出结论,即在这种情况下,继承是一个糟糕的设计选择。Square是一个Rectangle,所以应该在Rectangle使用的任何地方使用Rectangle,如下所示:

void make_big_rectangle(Rectangle& ref) {
  ref->set_width(870);
  ref->set_height(940);
}

int main() {
  Rectangle rect;
  make_big_rectangle(rect);
  Square sq;
  // Square is a Rectangle
  make_big_rectangle(sq);
}

make_big_rectangle()函数接受Rectangle的引用,而Square继承了它,所以将Square对象发送到make_big_rectangle()函数是完全可以的;Square 是一个 Rectangle。这种成功用其子类型替换类型的示例被称为Liskov 替换原则。让我们找出为什么这种替换在实践中有效,然后决定我们是否通过从Rectangle继承Square而犯了设计错误(是的,我们犯了)。

从编译器的角度来看继承

我们可以这样描述我们之前声明的Rectangle类:

当我们在main()函数中声明rect对象时,函数的本地对象所需的空间被分配在堆栈中。当调用make_big_rectangle()函数时,遵循相同的逻辑。它没有本地参数;相反,它有一个Rectangle&类型的参数,其行为类似于指针:它占用存储内存地址所需的内存空间(在 32 位和 64 位系统中分别为 4 或 8 字节)。rect对象通过引用传递给make_big_rectangle(),这意味着ref参数指的是main()中的本地对象:

以下是Square类的示例:

如前图所示,Square对象包含Rectangle子对象;它部分代表了Rectangle。在这个特定的例子中,Square类没有用新的数据成员扩展矩形。

Square对象被传递给make_big_rectangle(),尽管后者需要一个Rectangle&类型的参数。我们知道在访问底层对象时需要指针(引用)的类型。类型定义了应该从指针指向的起始地址读取多少字节。在这种情况下,ref存储了在main()中声明的本地rect对象的起始地址的副本。当make_big_rectangle()通过ref访问成员函数时,实际上调用的是以Rectangle引用作为第一个参数的全局函数。该函数被转换为以下形式(再次,为了简单起见,我们稍作修改):

void make_big_rectangle(Rectangle * const ref) {
  Rectangle_set_width(*ref, 870);
  Rectangle_set_height(*ref, 940);
}

解引用ref意味着从ref指向的内存位置开始读取sizeof(Rectangle)字节。当我们将Square对象传递给make_big_rectangle()时,我们将sqSquare对象)的起始地址分配给ref。这将正常工作,因为Square对象实际上包含一个Rectangle子对象。当make_big_rectangle()函数解引用ref时,它只能访问对象的sizeof(Rectangle)字节,并且看不到实际Square对象的附加字节。以下图示了ref指向的子对象的部分:

Rectangle继承Square几乎与声明两个结构体相同,其中一个(子类)包含另一个(父类):

struct Rectangle {
 int width_;
 int height_;
};

void Rectangle_set_width(Rectangle& this, int w) {
  this.width_ = w;
}

void Rectangle_set_height(Rectangle& this, int h) {
  this.height_ = h;
}

int Rectangle_area(const Rectangle& this) {
  return this.width_ * this.height_;
}

struct Square {
 Rectangle _parent_subobject_;
 int area_; 
};

void Square_set_side(Square& this, int side) {
  // Rectangle_set_width(static_cast<Rectangle&>(this), side);
 Rectangle_set_width(this._parent_subobject_, side);
  // Rectangle_set_height(static_cast<Rectangle&>(this), side);
 Rectangle_set_height(this._parent_subobject_, side);
}

int Square_area(Square& this) {
  // this.area_ = Rectangle_area(static_cast<Rectangle&>(this));
 this.area_ = Rectangle_area(this._parent_subobject_); 
  return this.area_;
}

上述代码演示了编译器支持继承的方式。看一下Square_set_sideSquare_area函数的注释代码。我们实际上并不坚持这种实现,但它表达了编译器处理面向对象编程代码的完整思想。

组合与继承

C++语言为我们提供了方便和面向对象的语法,以便我们可以表达继承关系,但编译器处理它的方式更像是组合而不是继承。实际上,在适用的地方使用组合而不是继承甚至更好。Square类及其与Rectangle的关系被认为是一个糟糕的设计选择。其中一个原因是子类型替换原则,它允许我们以错误的方式使用Square:将其传递给一个将其作为Rectangle而不是Square修改的函数。这告诉我们是一个关系并不正确,因为Square毕竟不是Rectangle。它是Rectangle的一种适应,而不是Rectangle本身,这意味着它实际上并不代表Rectangle;它使用Rectangle来为类用户提供有限的功能。

Square的用户不应该知道它可以被用作Rectangle;否则,在某个时候,他们会向Square实例发送无效或不支持的消息。无效消息的例子是调用set_widthset_height函数。Square实际上不应该支持两个不同的成员函数来分别修改它的边,但它不能隐藏这一点,因为它宣布它是从Rectangle继承而来的:

class Square : public Rectangle {
  // code omitted for brevity
};

如果我们将修饰符从 public 改为 private 会怎么样?嗯,C++支持公有和私有继承类型。它还支持受保护的继承。当从类私有继承时,子类打算使用父类并且可以访问其公共接口。然而,客户端代码并不知道它正在处理一个派生类。此外,从父类继承的公共接口对于子类的用户来说变成了私有的。似乎Square将继承转化为组合:

class Square : private Rectangle {
public:
  void set_side(int side) {
    // Rectangle's public interface is accessible to the Square
    set_width(side);
 set_height(side);
  }
  int area() {
    area_ = Rectangle::area();
    return area_;
  }
private:
  int area_;
};

客户端代码无法访问从Rectangle继承的成员:

Square sq;
sq.set_width(14); // compile error, the Square has no such public member
make_big_rectangle(sq); // compile error, can't cast Square to Rectangle

通过在Square的私有部分声明一个Rectangle成员也可以实现相同的效果:

class Square {
public: 
  void set_side(int side) {
 rectangle_.set_width(side);
 rectangle_.set_height(side);
  }
  int area() {
 area_ = rectangle_.area();
    return area_;
  }
private:
 Rectangle rectangle_;
  int area_;
};

你应该仔细分析使用场景,并完全回答是一个问题,以便毫无疑问地使用继承。每当你在组合和继承之间做出选择时,选择组合。

在私有继承时,我们可以省略修饰符。类的默认访问修饰符是 private,所以class Square : private Rectangle {};class Square : Rectangle {};是一样的。相反,结构体的默认修饰符是 public。

受保护的继承

最后,我们有protected访问修饰符。它指定了类成员在类体中使用时的访问级别。受保护成员对于类的用户来说是私有的,但对于派生类来说是公共的。如果该修饰符用于指定继承的类型,它对于派生类的用户的行为类似于私有继承。私有继承隐藏了基类的公共接口,而受保护继承使其对派生类的后代可访问。

很难想象一个需要受保护继承的场景,但你应该将其视为一个可能在意料之外的明显设计中有用的工具。假设我们需要设计一个栈数据结构适配器。栈通常是基于向量(一维数组)、链表或双端队列实现的。

栈符合 LIFO 规则,即最后插入栈的元素将首先被访问。同样,首先插入栈的元素将最后被访问。我们将在第六章中更详细地讨论数据结构和 STL 中的数据结构适配器和算法

栈本身并不代表一个数据结构;它位于数据结构的顶部,并通过限制、修改或扩展其功能来适应其使用。以下是表示整数一维数组的Vector类的简单声明:

class Vector {
public:
  Vector();
  Vector(const Vector&);
  Vector(Vector&&);
  Vector& operator=(const Vector&);
  Vector& operator=(Vector&&);
  ~Vector();

public:
  void push_back(int value);
  void insert(int index, int value);
  void remove(int index);
  int operator[](int index);
  int size() const;
  int capacity() const;

private:
  int size_;
  int capacity_;
  int* array_;
};

前面的Vector不是一个具有随机访问迭代器支持的 STL 兼容容器;它只包含动态增长数组的最低限度。可以这样声明和使用它:

Vector v;
v.push_back(4);
v.push_back(5);
v[1] = 2;

Vector类提供operator[],允许我们随机访问其中的任何项,而Stack禁止随机访问。Stack提供pushpop操作,以便我们可以插入值到其底层数据结构中,并分别获取该值:

class Stack : private Vector {
public:
  // constructors, assignment operators and the destructor are omitted for brevity
 void push(int value) {
 push_back(value);
 }
 int pop() {
 int value{this[size() - 1]};
 remove(size() - 1);
 return value;
 }
};

Stack可以以以下方式使用:

Stack s;
s.push(5);
s.push(6);
s.push(3);
std::cout << s.pop(); // outputs 3
std::cout << s.pop(); // outputs 6
s[2] = 42; // compile error, the Stack has no publicly available operator[] defined

适配Vector并提供两个成员函数,以便我们可以访问它。私有继承允许我们使用Vector的全部功能,并且隐藏继承信息,不让Stack的用户知道。如果我们想要继承Stack来创建其高级版本怎么办?假设AdvancedStack类提供了min()函数,以常数时间返回栈中包含的最小值。

私有继承禁止AdvancedStack使用Vector的公共接口,因此我们需要一种方法来允许Stack的子类使用其基类,但是隐藏基类的存在。受保护的继承可以实现这一目标,如下所示:

class Stack : protected Vector {
  // code omitted for brevity
};

class AdvancedStack : public Stack {
  // can use the Vector
};

通过从Vector继承Stack,我们允许Stack的子类使用Vector的公共接口。但是StackAdvancedStack的用户将无法将它们视为Vector

多态

多态是面向对象编程中的另一个关键概念。它允许子类对从基类派生的函数进行自己的实现。假设我们有Musician类,它有play()成员函数:

class Musician {
public:
  void play() { std::cout << "Play an instrument"; }
};

现在,让我们声明Guitarist类,它有play_guitar()函数:

class Guitarist {
public:
  void play_guitar() { std::cout << "Play a guitar"; }
};

这是继承的明显案例,因为Guitarist明显表明它是一个MusicianGuitarist自然不应该通过添加新函数(如play_guitar())来扩展Musician;相反,它应该提供其自己从Musician派生的play()函数的实现。为了实现这一点,我们使用虚函数

class Musician {
public:
  virtual void play() { std::cout << "Play an instrument"; }
};

class Guitarist : public Musician {
public:
  void play() override { std::cout << "Play a guitar"; }
};

现在,显然Guitarist类提供了play()函数的自己的实现,客户端代码可以通过使用指向基类的指针来访问它:

Musician armstrong;
Guitarist steve;
Musician* m = &armstrong;
m->play();
m = &steve;
m->play();

前面的例子展示了多态的实际应用。虚函数的使用虽然很自然,但实际上除非我们正确使用它,否则并没有太多意义。首先,Musicianplay()函数根本不应该有任何实现。原因很简单:音乐家应该能够在具体的乐器上演奏,因为他们不能同时演奏多个乐器。为了摆脱实现,我们通过将0赋值给它将函数设置为纯虚函数

class Musician {
public:
 virtual void play() = 0;
};

当客户端代码尝试声明Musician的实例时,会导致编译错误。当然,这必须导致编译错误,因为不应该能够创建具有未定义函数的对象。Musician只有一个目的:它必须只能被其他类继承。存在供继承的类称为抽象类。实际上,Musician被称为接口而不是抽象类。抽象类是半接口半类,可以具有有和无实现的函数。

回到我们的例子,让我们添加Pianist类,它也实现了Musician接口:

class Pianist : public Musician {
public: 
 void play() override { std::cout << "Play a piano"; }
};

为了表达多态性的全部功能,假设我们在某处声明了一个函数,返回吉他手或钢琴家的集合:

std::vector<Musician*> get_musicians();

从客户端代码的角度来看,很难解析get_musicians()函数的返回值,并找出对象的实际子类型是什么。它可能是吉他手钢琴家,甚至是纯粹的音乐家。关键是客户端不应该真正关心对象的实际类型,因为它知道集合包含音乐家,而音乐家对象具有play()函数。因此,为了让它们发挥作用,客户端只需遍历集合,并让每个音乐家演奏其乐器(每个对象调用其实现):

auto all_musicians = get_musicians();
for (const auto& m: all_musicians) {
 m->play();
}

前面的代码表达了多态性的全部功能。现在,让我们了解语言如何在低级别上支持多态性。

底层的虚函数

虽然多态性不限于虚函数,但我们将更详细地讨论它们,因为动态多态性是 C++中最流行的多态性形式。而且,更好地理解一个概念或技术的最佳方法是自己实现它。无论我们在类中声明虚成员函数还是它具有具有虚函数的基类,编译器都会用额外的指针增强类。指针指向的表通常被称为虚函数表,或者简称为虚表。我们还将指针称为虚表指针

假设我们正在为银行客户账户管理实现一个类子系统。假设银行要求我们根据账户类型实现取款。例如,储蓄账户允许每年取款一次,而支票账户允许客户随时取款。不涉及Account类的任何不必要的细节,让我们声明最少的内容,以便理解虚拟成员函数。让我们看一下Account类的定义:

class Account
{
public:
 virtual void cash_out() {
 // the default implementation for cashing out 
 }  virtual ~Account() {}
private:
  double balance_;
};

编译器将Account类转换为一个具有指向虚函数表的指针的结构。以下代码表示伪代码,解释了在类中声明虚函数时发生的情况。与往常一样,请注意,我们提供的是一般性的解释,而不是特定于编译器的实现(名称修饰也是以通用形式进行的;例如,我们将cash_out重命名为Account_cash_out):

struct Account
{
 VTable* __vptr;
  double balance_;
};

void Account_constructor(Account* this) {
 this->__vptr = &Account_VTable;
}

void Account_cash_out(Account* this) {
  // the default implementation for cashing out
}

void Account_destructor(Account* this) {}

仔细看前面的伪代码。Account结构的第一个成员是__vptr。由于先前声明的Account类有两个虚函数,我们可以将虚表想象为一个数组,其中有两个指向虚成员函数的指针。请参阅以下表示:

VTable Account_VTable[] = {
 &Account_cash_out,
 &Account_destructor
};

有了我们之前的假设,让我们找出当我们在对象上调用虚函数时编译器将生成什么代码:

// consider the get_account() function as already implemented and returning an Account*
Account* ptr = get_account();
ptr->cash_out();

以下是我们可以想象编译器为前面的代码生成的代码:

Account* ptr = get_account();
ptr->__vptr[0]();

虚函数在层次结构中使用时显示其功能。SavingsAccountAccount类继承如下:

class SavingsAccount : public Account
{
public:
 void cash_out() override {
 // an implementation specific to SavingsAccount
 }
  virtual ~SavingsAccount() {}
};

当我们通过指针(或引用)调用cash_out()时,虚函数是根据指针指向的目标对象调用的。例如,假设get_savings_account()SavingsAccount作为Account*返回。以下代码将调用SavingsAccountcash_out()实现:

Account* p = get_savings_account();
p->cash_out(); // calls SavingsAccount version of the cash_out

这是编译器为SavingsClass生成的内容:

struct SavingsAccount
{
  Account _parent_subobject_;
  VTable* __vptr;
};

VTable* SavingsAccount_VTable[] = {
  &SavingsAccount_cash_out,
  &SavingsAccount_destructor,
};

void SavingsAccount_constructor(SavingsAccount* this) {
  this->__vptr = &SavingsAccount_VTable;
}

void SavingsAccount_cash_out(SavingsAccount* this) {
  // an implementation specific to SavingsAccount
}

void SavingsAccount_destructor(SavingsAccount* this) {}

所以,我们有两个不同的虚拟函数表。当我们创建一个Account类型的对象时,它的__vptr指向Account_VTable,而SavingsAccount类型的对象的__vptr指向SavingsAccount_VTable。让我们看一下以下代码:

p->cash_out();

前面的代码转换成了这样:

p->__vptr[0]();

现在很明显,__vptr[0]解析为正确的函数,因为它是通过p指针读取的。

如果SavingsAccount没有覆盖cash_out()函数会怎么样?在这种情况下,编译器会将基类实现的地址放在与SavingsAccount_VTable相同的位置,如下所示:

VTable* SavingsAccount_VTable[] = {
  // the slot contains the base class version 
  // if the derived class doesn't have an implementation
 &Account_cash_out,
  &SavingsAccount_destructor
};

编译器以不同的方式实现和管理虚拟函数的表示。一些实现甚至使用不同的模型,而不是我们之前介绍的模型。我们采用了一种流行的方法,并以通用的方式表示,以简化起见。现在,我们将看看在包含动态多态性的代码底层发生了什么。

设计模式

设计模式是程序员最具表现力的工具之一。它们使我们能够以一种优雅和经过充分测试的方式解决设计问题。当您努力提供最佳的类设计和它们的关系时,一个众所周知的设计模式可能会挽救局面。

设计模式的最简单示例是单例。它为我们提供了一种声明和使用类的唯一实例的方法。例如,假设电子商务平台只有一个Warehouse。要访问Warehouse类,项目可能需要在许多源文件中包含并使用它。为了保持同步,我们应该将Warehouse设置为单例:

class Warehouse {
public:
  static create_instance() {
 if (instance_ == nullptr) {
 instance_ = new Warehouse();
 }
 return instance_;
 }

 static remove_instance() {
 delete instance_;
 instance_ = nullptr;
 }

private:
  Warehouse() = default;

private:
  static Warehouse* instance_ = nullptr;
};

我们声明了一个静态的Warehouse对象和两个静态函数来创建和销毁相应的实例。私有构造函数导致每次用户尝试以旧的方式声明Warehouse对象时都会产生编译错误。为了能够使用Warehouse,客户端代码必须调用create_instance()函数。

Warehouse* w = Warehouse::create_instance();
Product book;
w->add_product(book);
Warehouse::remove_instance();

Warehouse的单例实现并不完整,只是一个引入设计模式的示例。我们将在本书中介绍更多的设计模式。

总结

在本章中,我们讨论了面向对象编程的基本概念。我们涉及了类的低级细节和 C++对象模型的编译器实现。知道如何设计和实现类,而实际上没有类,有助于正确使用类。

我们还讨论了继承的必要性,并尝试在可能适用的地方使用组合而不是继承。C++支持三种类型的继承:公有、私有和保护。所有这些类型都在特定的类设计中有它们的应用。最后,我们通过一个大大增加客户端代码便利性的例子理解了多态性的用途和力量。

在下一章中,我们将学习更多关于模板和模板元编程的知识,这将成为我们深入研究名为概念的新 C++20 特性的基础。

问题

  1. 对象的三个属性是什么?

  2. 将对象移动而不是复制它们有什么优势?

  3. C++中结构体和类有什么区别?

  4. 聚合和组合关系之间有什么区别?

  5. 私有继承和保护继承有什么区别?

  6. 如果我们在类中定义了虚函数,类的大小会受到影响吗?

  7. 使用单例设计模式有什么意义?

进一步阅读

更多信息,请参考:

第四章:理解和设计模板

模板是 C++的一个独特特性,通过它,函数和类能够支持通用数据类型——换句话说,我们可以实现一个与特定数据类型无关的函数或类;例如,客户可能会请求一个max()函数来处理不同的数据类型。我们可以通过模板来实现一个max(),并将数据类型作为参数传递,而不是通过函数重载来实现和维护许多类似的函数。此外,模板可以与多重继承和运算符重载一起工作,以在 C++中创建强大的通用数据结构和算法,如标准模板库STL)。此外,模板还可以应用于编译时计算、编译时和运行时代码优化等。

在本章中,我们将学习函数和类模板的语法,它们的实例化和特化。然后,我们将介绍可变参数模板及其应用。接下来,我们将讨论模板参数及用于实例化它们的相应参数。之后,我们将学习如何实现类型特性,以及如何利用这种类型的信息来优化算法。最后,我们将介绍在程序执行时可以使用的加速技术,包括编译时计算、编译时代码优化和静态多态性。

本章将涵盖以下主题:

  • 探索函数和类模板

  • 理解可变参数模板

  • 理解模板参数和参数

  • 什么是特性?

  • 模板元编程及其应用

技术要求

本章的代码可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Expert-CPP

探索函数和类模板

我们将从介绍函数模板的语法及其实例化、推导和特化开始这一部分。然后,我们将转向类模板,并查看类似的概念和示例。

动机

到目前为止,当我们定义函数或类时,我们必须提供输入、输出和中间参数。例如,假设我们有一个函数来执行两个 int 类型整数的加法。我们如何扩展它,以便处理所有其他基本数据类型,如 float、double、char 等?一种方法是使用函数重载,手动复制、粘贴和稍微修改每个函数。另一种方法是定义一个宏来执行加法操作。这两种方法都有各自的副作用。

此外,如果我们修复一个 bug 或为一个类型添加一个新功能,这个更新需要在以后的所有其他重载函数和类中完成吗?除了使用这种愚蠢的复制-粘贴-替换方法外,我们有没有更好的方法来处理这种情况?

事实上,这是任何计算机语言都可能面临的一个通用问题。1973 年由通用函数式编程元语言ML)首创,ML 允许编写通用函数或类型,这些函数或类型在使用时只在它们操作的类型集合上有所不同,从而减少了重复。后来受到特许人寿保险师CLU)提供的参数化模块和 Ada 提供的泛型的启发,C++采用了模板概念,允许函数和类使用通用类型。换句话说,它允许函数或类在不需要重写的情况下处理不同的数据类型。

实际上,从抽象的角度来看,C++函数或类模板(如饼干模具)用作创建其他类似函数或类的模式。这背后的基本思想是创建一个函数或类模板,而无需指定某些或所有变量的确切类型。相反,我们使用占位符类型来定义函数或类模板,称为模板类型参数。一旦我们有了函数或类模板,我们可以通过在其他编译器中实现的算法自动生成函数或类。

C++中有三种模板:函数模板、模板和可变参数模板。我们接下来将看看这些。

函数模板

函数模板定义了如何生成一组函数。这里的一组函数指的是行为类似的一组函数。如下图所示,这包括两个阶段:

  • 创建函数模板;即编写它的规则。

  • 模板实例化;即用于从模板生成函数的规则:

函数模板格式

在上图的part I中,我们讨论了用于创建通用类型函数模板的格式,但是关于专门化模板,我们也称之为主模板。然后,在part II中,我们介绍了从模板生成函数的三种方式。最后,专门化和重载子节告诉我们如何为特殊类型自定义主模板(通过改变其行为)。

语法

有两种定义函数模板的方式,如下面的代码所示:

template <typename identifier_1, …, typename identifier_n > 
function_declaration;

template <class identifier_1,…, class identifier_n> 
function_declaration;

在这里,identifier_i (i=1,…,n)是类型或类参数,function_declaration声明了函数体部分。在前两个声明中唯一的区别是关键字 - 一个使用class,而另一个使用typename,但两者的含义和行为都是相同的。由于类型(如基本类型 - int、float、double、enum、struct、union 等)不是类,因此引入了typename关键字方法以避免混淆。

例如,经典的查找最大值函数模板app_max()可以声明如下:

template <class T>
T app_max (T a, T b) {
  return (a>b?a:b);   //note: we use ((a)>(b) ? (a):(b)) in macros  
}                     //it is safe to replace (a) by a, and (b) by b now

只要存在可复制构造的类型,其中 *a>b *表达式有效,这个函数模板就可以适用于许多数据类型或类。对于用户定义的类,这意味着必须定义大于号(>)。

请注意,函数模板和模板函数是不同的东西。函数模板指的是一种模板,用于由编译器生成函数,因此编译器不会为其生成任何目标代码。另一方面,模板函数意味着来自函数模板的实例。由于它是一个函数,编译器会生成相应的目标代码。然而,最新的 C++标准文档建议避免使用不精确的术语模板函数。因此,在本书中我们将使用函数模板和成员函数模板。

实例化

由于我们可能有无限多种类型和类,函数模板的概念不仅节省了源代码文件中的空间,而且使代码更易于阅读和维护。然而,与为应用程序中使用的不同数据类型编写单独的函数或类相比,它并不会产生更小的目标代码。例如,考虑使用app_max()的 float 和 int 版本的程序:

cout << app_max<int>(3,5) << endl;
cout << app_max<float>(3.0f,5.0f) << endl;

编译器将在目标文件中生成两个新函数,如下所示:

int app_max<int> ( int a, int b) {
  return (a>b?a:b);
}

float app_max<float> (float a, float b) {
  return (a>b?a:b);
}

从函数模板声明中创建函数的新定义的过程称为模板实例化。在这个实例化过程中,编译器确定模板参数,并根据应用程序的需求生成实际的功能代码。通常有三种形式:显式实例化隐式实例化模板推断。在接下来的部分,让我们讨论每种形式。

显式实例化

许多非常有用的 C++函数模板可以在不使用显式实例化的情况下编写和使用,但我们将在这里描述它们,只是让您知道如果您需要它们,它们确实存在。首先,让我们看一下 C++11 之前显式实例化的语法。有两种形式,如下所示:

template return-type 
function_name < template_argument_list > ( function_parameter-list ) ;

template return-type 
function_name ( function_parameter_list ) ;

显式实例化定义,也称为指令,强制为特定类型的函数模板实例化,无论将来将调用哪个模板函数。显式实例化的位置可以在函数模板的定义之后的任何位置,并且在源代码中对于给定的参数列表只允许出现一次。

自 C++11 以来,显式实例化指令的语法如下。在这里,我们可以看到在template关键字之前添加了extern关键字:

extern template return-type 
function_name < template_argument_list > (function_parameter_list ); 
(since C++11)

extern template return-type 
function_name ( function_parameter_list ); (since C++11)

使用extern关键字可以防止该函数模板的隐式实例化(有关更多详细信息,请参阅下一节)。

关于之前声明的app_max()函数模板,可以使用以下代码进行显式实例化:

template double app_max<double>(double, double); 
template int app_max<int>(int, int);

也可以使用以下代码进行显式实例化:

extern template double app_max<double>(double, double);//(since c++11)
extren template int app_max<int>(int, int);            //(since c++11)

这也可以以模板参数推断的方式完成:

template double f(double, double);
template int f(int, int);

最后,这也可以这样做:

extern template double f(double, double); //(since c++11)
extern template int f(int, int);          //(since c++11)

此外,显式实例化还有一些其他规则。如果您想了解更多,请参考进一步阅读部分[10]以获取更多详细信息。

隐式实例化

当调用函数时,该函数的定义需要存在。如果这个函数没有被显式实例化,将会采用隐式实例化的方法,其中模板参数的列表需要被显式提供或从上下文中推断出。以下程序的 A 部分提供了app_max()的隐式实例化的一些示例。

//ch4_2_func_template_implicit_inst.cpp
#include <iostream>
template <class T>
T app_max (T a, T b) { return (a>b?a:b); }
using namespace std;
int main(){
 //Part A: implicit instantiation in an explicit way 
 cout << app_max<int>(5, 8) << endl;       //line A 
 cout << app_max<float>(5.0, 8.0) << endl; //line B
 cout << app_max<int>(5.0, 8) << endl;     //Line C
 cout << app_max<double>(5.0, 8) << endl;  //Line D

 //Part B: implicit instantiation in an argument deduction way
 cout << app_max(5, 8) << endl;           //line E 
 cout << app_max(5.0f, 8.0f) << endl;     //line F 

 //Part C: implicit instantiation in a confuse way
 //cout<<app_max(5, 8.0)<<endl;          //line G  
 return 0;
}

ABCD的隐式实例化分别是int app_max<int>(int,int)float app_max<float>(float, float>)int app_max<int>(int,int)double app_max<double>(double, double)

推断

当调用模板函数时,编译器首先需要确定模板参数,即使没有指定每个模板参数。大多数情况下,它会从函数参数中推断出缺失的模板参数。例如,在上一个函数的 B 部分中,当在行E中调用app_max(5, 8)时,编译器会推断模板参数为 int 类型,即(int app_max<int>(int,int)),因为输入参数58都是整数。同样,行F将被推断为浮点类型,即float app_max<float>(float,float)

然而,如果在实例化过程中出现混淆会发生什么?例如,在上一个程序中对G的注释行中,根据编译器的不同,可能会调用app_max<double>(double, double)app_max<int>(int, int),或者只是给出一个编译错误消息。帮助编译器推断类型的最佳方法是通过显式给出模板参数来调用函数模板。在这种情况下,如果我们调用app_max<double>(5, 8.0),任何混淆都将得到解决。

从编译器的角度来看,有几种方法可以进行模板参数推导——从函数调用中推导,从类型中推导,自动类型推导和非推导上下文[4]。然而,从程序员的角度来看,你不应该编写花哨的代码来滥用函数模板推导的概念,以混淆其他程序员,比如前面示例中的 G 行。

专门化和重载

专门化允许我们为给定的模板参数集自定义模板代码。它允许我们为特定的模板参数定义特殊行为。专门化仍然是一个模板;你仍然需要一个实例化来获得真正的代码(由编译器自动完成)。

在下面的示例代码中,主要函数模板T app_max(T a, T b)将根据operator *a>b,*的返回值返回ab,但我们可以将其专门化为T = std::string,这样我们只比较ab的第 0 个元素;也就是说,a[0] >b[0]

//ch4_3_func_template_specialization.cpp
#include <iostream>
#include <string>

//Part A: define a  primary template
template <class T> T app_max (T a, T b) { return (a>b?a:b); }

//Part B: explicit specialization for T=std::string, 
template <> std::string app_max<std::string> (std::string a, std::string b){ 
    return (a[0]>b[0]?a:b);
}

//part C: test function
using namespace std; 
void main(){
 string a = "abc", b="efg";
 cout << app_max(5, 6) << endl; //line A 
 cout << app_max(a, b) << endl; //line B 

 //question: what's the output if un-comment lines C and D?
 //char *x = "abc", *y="efg";     //Line C
 //cout << app_max(x, y) << endl; //line D
}

前面的代码首先定义了一个主模板,然后将T显式专门化为std::string;也就是说,我们只关心aba[0]b[0]app_max()的行为被专门化)。在测试函数中,行 A调用app_max<int>(int,int)行 B调用专门化版本,因为在推导时没有歧义。如果我们取消注释CD行,将调用主函数模板char* app_max<char > (char*, char*),因为char*std::string是不同的数据类型。

从某种程度上讲,专门化与函数重载解析有些冲突:编译器需要一种算法来解决这种冲突,找到模板和重载函数中的正确匹配。选择正确函数的算法包括以下两个步骤:

  1. 在常规函数和非专门化模板之间进行重载解析。

  2. 如果选择了非专门化的模板,请检查是否存在一个更适合它的专门化。

例如,在下面的代码块中,我们声明了主要(行 0)和专门化的函数模板(行 1-4),以及f()的重载函数(行 5-6):

template<typename T1, typename T2> void f( T1, T2 );// line 0
template<typename T> void f( T );                   // line 1
template<typename T> void f( T, T );                // line 2
template<typename T> void f( int, T* );             // line 3
template<> void f<int>( int );                      // line 4
void f( int, double );                              // line 5
void f( int );                                      // line 6

f()将在下面的代码块中被多次调用。根据前面的两步规则,我们可以在注释中显示选择了哪个函数。我们将在此之后解释这样做的原因:

int i=0; 
double d=0; 
float x=0;
complex<double> c;
f(i);      //line A: choose f() defined in line 6
f(i,d);    //line B: choose f() defined in line 5
f<int>(i); //line C: choose f() defined in line 4
f(c);      //line D: choose f() defined in line 1
f(i,i);    //line E: choose f() defined in line 2
f(i,x);    //line F: choose f() defined in line 0
f(i, &d);  //line G: choose f() defined in line 3

对于行 A行 B,由于行 5行 6中定义的f()是常规函数,它们具有最高的优先级被选择,所以f(i)f(i,d)将分别选择它们。对于行 C,因为存在专门化的模板,从行 4生成的f()比从行 1生成的更匹配。对于行 D,由于ccomplex<double>类型,只有在行 1中定义的主要函数模板与之匹配。行 E将选择由行 2创建的f(),因为两个输入变量是相同类型。最后,行 F行 G将分别选择行 0行 3中的模板创建的函数。

在了解了函数模板之后,我们现在将转向类模板。

类模板

类模板定义了一组类,并且通常用于实现容器。例如,C++标准库包含许多类模板,如std::vectorstd::mapstd::deque等。在 OpenCV 中,cv::Mat是一个非常强大的类模板,它可以处理具有内置数据类型的 1D、2D 和 3D 矩阵或图像,如int8_tuint8_tint16_tuint16_tint32_tuint32_tfloatdouble等。

与函数模板类似,如下图所示,类模板的概念包含模板创建语法、其专门化以及其隐式和显式实例化:

在前面的图表的part I中,使用特定的语法格式,我们可以为通用类型创建一个类模板,也称为主模板,并且可以根据应用的需求为特殊类型定制不同的成员函数和/或变量。一旦有了类模板,在part II中,编译器将根据应用的需求显式或隐式地将其实例化为模板类。

现在,让我们看一下创建类模板的语法。

语法

创建类模板的语法如下:

[export] template <template_parameter_list> class-declaration 

在这里,我们有以下内容:

  • template_parameter-list(参见进一步阅读上下文中的链接[10])是模板参数的非空逗号分隔列表,每个参数都是非类型参数、类型参数、模板参数或任何这些的参数包。

  • class-declaration是用于声明包含类名和其主体的类的部分,用大括号括起来。通过这样做,声明的类名也成为模板名。

例如,我们可以定义一个类模板V,使其包含各种 1D 数据类型:

template <class T>
class V {
public:
  V( int n = 0) : m_nEle(n), m_buf(0) { creatBuf();}
  ~V(){  deleteBuf();  }
  V& operator = (const V &rhs) { /* ... */}
  V& operator = (const V &rhs) { /* ... */}
  T getMax(){ /* ... */ }
protected:
  void creatBuf() { /* ... */}
  void deleteBuf(){ /* ... */}

public:
  int m_nEle;
  T * m_buf;
};

一旦有了这个类模板,编译器就可以在实例化过程中生成类。出于我们在函数模板子节中提到的原因,我们将避免在本书中使用不精确的术语template类。相反,我们将使用类模板。

实例化

考虑到前一节中我们定义的类模板V,我们假设后面会出现以下声明:

V<char> cV;
V<int>  iV(10);
V<float> fV(5);

然后,编译器将创建V类的三个实例,如下所示:

class V<char>{
public:
  V(int n=0);
 // ...
public:
  int  m_nEle;
  char *m_buf;
};
class V<int>{
public:
  V(int n=0);
 // ...
public:
  int  m_nEle;
  int *m_buf;
};
class V<float>{
public:
  V(int n = 0);
  // ...
public:
  int   m_nEle;
  float *m_buf;
};

与函数模板实例化类似,类模板实例化有两种形式 - 显式实例化和隐式实例化。让我们来看看它们。

显式实例化

显式实例化的语法如下:

template class template_name < argument_list >;
extern template class template_name < argument_list >;//(since C++11)

显式实例化定义会强制实例化它们所引用的类、结构或联合体。在 C++0x 标准中,模板特化或其成员的隐式实例化被抑制。与函数模板的显式实例化类似,这种显式实例化的位置可以在其模板定义之后的任何位置,并且在整个程序中只允许定义一次。

此外,自 C++11 以来,显式实例化声明(extern template)将绕过隐式实例化步骤,这可以用于减少编译时间。

回到模板类V,我们可以显式实例化它如下:

template class V<int>;
template class V<double>;

或者,我们可以这样做(自 C++11 以来):

extern template class V<int>;
extern template class V<double>;

如果我们显式实例化函数或类模板,但程序中没有相应的定义,编译器将给出错误消息,如下所示:

//ch4_4_class_template_explicit.cpp
#include <iostream>
using namespace std;
template <typename T>       //line A
struct A {
  A(T init) : val(init) {}
  virtual T foo();
  T val;
};                         //line B
                           //line C 
template <class T> //T in this line is template parameter
T A<T>::foo() {    //the 1st T refers to function return type,
                   //the T in <> specifies that this function's template
                   //parameter is also the class template parameter
  return val;
}                        //line D

extern template struct A<int>;  //line E
#if 0                           //line F
int A<int>::foo() {  
    return val+1;    
}                    
#endif                         //line G

int main(void) {
  A<double> x(5);
  A<int> y(5);
  cout<<"fD="<<x.foo()<<",fI="<<y.foo()<< endl;
  return 0;        //output: fD=5,fI=6
}

在前面的代码块中,我们在 A 行和 B 行之间定义了一个类模板,然后我们从 C 行到 D 行实现了它的成员函数foo()。接下来,我们在 E 行明确地为int类型实例化了它。由于在 F 行和 G 行之间的代码块被注释掉了(这意味着对于这个显式的int类型实例化,没有相应的foo()定义),我们会得到一个链接错误。为了解决这个问题,我们需要在 F 行用#if 1替换#if 0

最后,显式实例化声明还有一些额外的限制,如下所示:

  • 静态:静态类成员可以命名,但静态函数不能在显式实例化声明中允许。

  • 内联:在显式实例化声明中,内联函数没有影响,内联函数会被隐式实例化。

  • 类及其成员:显式实例化类及其所有成员没有等价物。

隐式实例化

当引用模板类时,如果没有显式实例化或显式专门化,编译器将只在需要时从其模板生成代码。这称为隐式实例化,其语法如下:

class_name<argument list> object_name; //for non-pointer object 
class_name<argument list> *p_object_name; //for pointer object

对于非指针对象,模板类被实例化并创建其对象,但只生成此对象使用的成员函数。对于指针对象,除非程序中使用了成员,否则不会实例化。

考虑以下示例,在该示例中,我们在ch4_5_class_template_implicit_inst.h文件中定义了一个名为X的类模板。

//file ch4_5_class_template_implicit_inst.h
#ifndef __CH4_5_H__ 
#define __CH4_5_H__ 
#include <iostream>
template <class T>
class X {
public:
    X() = default;
    ~X() = default;
    void f() { std::cout << "X::f()" << std::endl; };
    void g() { std::cout << "X::g()" << std::endl; };
};
#endif

然后,它被以下四个cpp文件包含,每个文件中都有ain()

//file ch4_5_class_template_implicit_inst_A.cpp
#include "ch4_5_class_template_implicit_inst.h"
void main()
{
    //implicit instantiation generates class X<int>, then create object xi
    X<int>   xi ;  
    //implicit instantiation generates class X<float>, then create object xf
    X<float> xf;
    return 0;  
}

ch4_5_class_template_implicit_inst_A.cpp中,编译器将隐式实例化X<int>X<float>类,然后创建xixf对象。但由于未使用X::f()X::g(),它们不会被实例化。

现在,让我们看一下ch4_5_class_template_implicit_inst_B.cpp

//file ch4_5_class_template_implicit_inst_B.cpp
#include "ch4_5_class_template_implicit_inst.h"
void main()
{
    //implicit instantiation generates class X<int>, then create object xi
    X<int> xi;    
    xi.f();      //and generates function X<int>::f(), but not X<int>::g()

    //implicit instantiation generates class X<float>, then create object
    //xf and generates function X<float>::g(), but not X<float>::f()
    X<float> xf;  
    xf.g() ;   
}

在这里,编译器将隐式实例化X<int>类,创建xi对象,然后生成X<int>::f()函数,但不会生成X<int>::g()。类似地,它将实例化X<float>类,创建xf对象,并生成X<float>::g()函数,但不会生成X<float>::f()

然后,我们有ch4_5_class_template_implicit_inst_C.cpp

//file ch4_5_class_template_implicit_inst_C.cpp
#include "ch4_5_class_template_implicit_inst.h"
void main()
{
   //inst. of class X<int> is not required, since p_xi is pointer object
   X<int> *p_xi ;   
   //inst. of class X<float> is not required, since p_xf is pointer object
   X<float> *p_xf ; 
}

由于p_xip_xf是指针对象,因此无需通过编译器实例化它们对应的模板类。

最后,我们有ch4_5_class_template_implicit_inst_D.cpp

//file ch4_5_class_template_implicit_inst_D.cpp
#include "ch4_5_class_template_implicit_inst.h"
void main()
{
//inst. of class X<int> is not required, since p_xi is pointer object
 X<int> *p_xi; 

 //implicit inst. of X<int> and X<int>::f(), but not X<int>::g()
 p_xi = new X<int>();
 p_xi->f(); 

//inst. of class X<float> is not required, since p_xf is pointer object
 X<float> *p_xf; 
 p_xf = new X<float>();//implicit inst. of X<float> occurs here
 p_xf->f();            //implicit inst. X<float>::f() occurs here
 p_xf->g();            //implicit inst. of X<float>::g() occurs here

 delete p_xi;
 delete p_xf;
}

这将隐式实例化X<int>X<int>::f(),但不会实例化X<int>::g();同样,对于X<float>,将实例化X<float>::f()X<float>::g()

专门化

与函数专门化类似,当将特定类型作为模板参数传递时,类模板的显式专门化定义了主模板的不同实现。但是,它仍然是一个类模板,您需要通过实例化来获得真正的代码。

例如,假设我们有一个struct X模板,可以存储任何数据类型的一个元素,并且只有一个名为increase()的成员函数。但是对于 char 类型数据,我们希望increase()有不同的实现,并且需要为其添加一个名为toUpperCase()的新成员函数。因此,我们决定为该类型声明一个类模板专门化。我们可以这样做:

  1. 声明一个主类模板:
template <typename T>
struct X {
  X(T init) : m(init) {}
  T increase() { return ++m; }
  T m;
};

这一步声明了一个主类模板,其中它的构造函数初始化了m成员变量,increase()m加一并返回其值。

  1. 接下来,我们需要为 char 类型数据执行专门化:
template <>  //Note: no parameters inside <>, it tells compiler 
             //"hi i am a fully specialized template"
struct X<char> { //Note: <char> after X, tells compiler
                 // "Hi, this is specialized only for type char"
  X(char init) : m(init) {}
  char increase() { return (m<127) ? ++m : (m=-128); }
  char toUpperCase() {
    if ((m >= 'a') && (m <= 'z')) m += 'A' - 'a';
    return m;
  }
  char m;
};

这一步为 char 类型数据创建了一个专门化(相对于主类模板),并为其添加了一个额外的成员函数toUpperCase()

  1. 现在,我们进行测试:
int main() {
 X<int> x1(5);         //line A
 std::cout << x1.increase() << std::endl;

 X<char> x2('b');     //line B
 std::cout << x2.toUpperCase() << std::endl;
 return 0;
}

最后,我们有一个main()函数来测试它。在 A 行,x1是一个从主模板X<T>隐式实例化的对象。由于x1.m的初始值是5,所以x1.increase()将返回6。在 B 行,x2是从专门化模板X<char>实例化的对象,当它执行时,x2.m的值是b。在调用x2.toUpperCase()之后,B将是返回值。

此示例的完整代码可以在ch4_6_class_template_specialization.cpp中找到。

总之,在类模板的显式专门化中使用的语法如下:

template <> class[struct] class_name<template argument list> { ... }; 

在这里,空的模板参数列表template <>用于显式声明它为模板专门化,<template argument list>是要专门化的类型参数。例如,在ex4_6_class_template_specialization.cpp中,我们使用以下内容:

template <> struct X<char> { ... };

X之后的<char>标识了我们要为其声明模板类专门化的类型。

此外,当我们为模板类进行特化时,即使在主模板中相同的成员也必须被定义,因为在模板特化期间没有主模板的继承概念。

接下来,我们将看一下部分特化。这是显式特化的一般陈述。与只有模板参数列表的显式特化格式相比,部分特化需要模板参数列表和参数列表。对于模板实例化,如果用户的模板参数列表与模板参数的子集匹配,编译器将选择部分特化模板,然后编译器将从部分特化模板生成新的类定义。

在下面的示例中,对于主类模板A,我们可以为参数列表中的 const T进行部分特化。请注意,它们的参数列表相同,即<typename T>

//primary class template A
template <typename T>  class A{ /* ... */ }; 

//partial specialization for const T
template <typename T>  class A<const T>{ /* ... */ };  

在下面的示例中,主类模板B有两个参数:<typename T1typename T2 >。我们通过T1=int进行部分特化,保持T2不变:

//primary class template B
template <typename T1, typename T2> class B{ /* ... */ };          

//partial specialization for T1 = int
template <typename T2> class B<int, T2>{ /* ... */};  

最后,在下面的示例中,我们可以看到部分特化中的模板参数数量不必与原始主模板中出现的参数数量匹配。然而,模板参数的数量(出现在尖括号中的类名后面)必须与主模板中的参数数量和类型匹配:

//primary class template C: template one parameter
template <typename T> struct C { T type; };  

//specialization: two parameters in parameter list 
//but still one argument (<T[N]>) in argument list
template <typename T, int N> struct C<T[N]>          
{T type; };                                 

同样,类模板的部分特化仍然是一个类模板。您必须为其成员函数和数量变量分别提供定义。

结束本节,让我们总结一下我们到目前为止学到的内容。在下表中,您可以看到函数和类模板、它们的实例化和特化之间的比较:

函数模板 类模板 注释
声明 template <class T1, class T2> void f(T1 a, T2 b) { ... } template <class T1, class T2> class X { ... }; 声明定义了一个函数/类模板,<class T1, class T2>称为模板参数。
显式实例化 template void f <int, int >( int, int);extern templatevoid f <int, int >( int, int);`(自 C++11 起) template class X<int, float>;extern template class X<int,float>;(自 C++11 起) 实例化后现在有函数/类,但它们被称为模板函数/类。
隐式实例化 {...f(3, 4.5); f<char, float>(120, 3.14);} {...X<int,float> obj; X<char, char> *p;} 当函数调用或类对象/指针声明时,如果没有被显式实例化,则使用隐式实例化方法。
特化 template <> void f<int,float>(int a, float b) template <> class X <int, float>{ ... }; 主模板的完全定制版本(无参数列表)仍然需要被实例化。
部分特化 template <class T> void f<T, T>(T a, T b) template <class T> class X <T, T> 主模板的部分定制版本(有参数列表)仍然需要被实例化。

这里需要强调五个概念:

  • 声明:我们需要遵循用于定义函数或类模板的语法。此时,函数或类模板本身不是类型、函数或任何其他实体。换句话说,在源文件中只有模板定义,没有代码可以编译成对象文件。

  • 隐式实例化:对于任何代码的出现,都必须实例化一个模板。在这个过程中,必须确定模板参数,以便编译器可以生成实际的函数或类。换句话说,它们是按需编译的,这意味着在给定特定模板参数的实例化之前,模板函数或类的代码不会被编译。

  • 显式实例化:告诉编译器使用给定类型实例化模板,无论它们是否被使用。通常用于提供库。

  • 完全特化:这没有参数列表(完全定制);它只有一个参数列表。模板特化最有用的一点是,您可以为特定类型参数创建特殊模板。

  • 部分特化:这类似于完全特化,但是部分参数列表(部分定制)和部分参数列表。

理解可变模板

在前一节中,我们学习了如何编写具有固定数量类型参数的函数或类模板。但自 C++11 以来,标准通用函数和类模板可以接受可变数量的类型参数。这被称为可变模板,它是 C++的扩展,详情请参阅Further reading [6]。我们将通过示例学习可变模板的语法和用法。

语法

如果一个函数或类模板需要零个或多个参数,可以定义如下:

//a class template with zero or more type parameters
template <typename... Args> class X { ... };     

//a function template with zero or more type parameters
template <typename... Args> void foo( function param list) { ...}                                                                      

在这里,<typename ... Args>声明了一个参数包。请注意,这里的Args不是关键字;您可以使用任何有效的变量名。前面的类/函数模板可以接受任意数量的typename作为其需要实例化的参数,如下所示:

X<> x0;                       //with 0 template type argument
X<int, std::vector<int> > x1; //with 2 template type arguments

//with 4 template type arguments
X<int, std::vector<int>, std::map<std::string, std::vector<int>>> x2; 

//with 2 template type arguments 
foo<float, double>( function argument list ); 

//with 3 template type arguments
foo<float, double, std::vector<int>>( function argument list );

如果可变模板需要至少一个类型参数,则使用以下定义:

template <typename A, typename... Rest> class Y { ... }; 

template <typename A, typename... Rest> 
void goo( const int a, const float b) { ....};

同样,我们可以使用以下代码来实例化它们:

Y<int > y1;                                         
Y<int, std::vector<int>, std::map<std::string, std::vector<int>>> y2;
goo<int, float>(  const int a, const float b );                        
goo<int,float, double, std::vector<int>>(  const int a, const float b );      

在前面的代码中,我们创建了y1y2对象,它们是通过具有一个和三个模板参数的可变类模板Y的实例化而得到的。对于可变函数goo模板,我们将它实例化为两个模板函数,分别具有两个和三个模板参数。

示例

以下可能是最简单的示例,展示了使用可变模板来查找任何输入参数列表的最小值。这个示例使用了递归的概念,直到达到my_min(double n)为止:

//ch4_7_variadic_my_min.cpp
//Only tested on g++ (Ubuntu/Linaro 7.3.0-27 ubuntu1~18.04)
//It may have compile errors for other platforms
#include <iostream>
#include <math.h> 
double my_min(double n){
  return n;
}
template<typename... Args>
double my_min(double n, Args... args){
  return fmin(n, my_min(args...));
}
int main() {
  double x1 = my_min(2);
  double x2 = my_min(2, 3);
  double x3 = my_min(2, 3, 4, 5, 4.7,5.6, 9.9, 0.1);
  std::cout << "x1="<<x1<<", x2="<<x2<<", x3="<<x3<<std::endl;
  return 0;
}

printf()可变参数函数可能是 C 或 C++中最有用和强大的函数之一;但是,它不是类型安全的。在下面的代码块中,我们采用了经典的类型安全printf()示例来演示可变模板的用处。首先,我们需要定义一个基本函数void printf_vt(const char *s),它结束了递归:

//ch4_8_variadic_printf.cpp part A: base function - recursive end
void printf_vt(const char *s)
{
  while (*s){
    if (*s == '%' && *(++s) != '%')
      throw std::runtime_error("invalid format string: missing arguments");
     std::cout << *s++;
  }
}

然后,在其可变模板函数printf_vt()中,每当遇到%时,该值被打印,其余部分被传递给递归,直到达到基本函数:

//ch4_8_variadic_printf.cpp part B: recursive function
template<typename T, typename... Rest>
void printf_vt(const char *s, T value, Rest... rest)
{
  while (*s) {
    if (*s == '%' && *(++s) != '%') {
      std::cout << value;
      printf_vt(s, rest...); //called even when *s is 0, 
      return;                //but does nothing in that case
    }
    std::cout << *s++;
  }
}

最后,我们可以使用以下代码进行测试和比较传统的printf()

//ch4_8_variadic_printf.cpp Part C: testing
int main() {
  int x = 10;
  float y = 3.6;
  std::string s = std::string("Variadic templates");
  const char* msg1 = "%s can accept %i parameters (or %s), x=%d, y=%f\n";
  printf(msg1, s, 100, "more",x,y);  //replace 's' by 's.c_str()' 
                                     //to prevent the output bug
  const char* msg2 = "% can accept % parameters (or %); x=%,y=%\n";
  printf_vt(msg2, s, 100, "more",x,y);
  return 0;
}

前面代码的输出如下:

p.]�U can accept 100 parameters (or more), x=10, y=3.600000
Variadic templates can accept 100 parameters (or more); x=10,y=3.6

在第一行的开头,我们可以看到一些来自printf()的 ASCII 字符,因为%s的相应变量类型应该是指向字符的指针,但我们给它一个std::string类型。为了解决这个问题,我们需要传递s.c_str()。然而,使用可变模板版本的函数,我们就没有这个问题。此外,我们只需要提供%,这甚至更好 - 至少对于这个实现来说是这样。

总之,本节简要介绍了可变模板及其应用。可变模板提供了以下好处(自 C++11 以来):

  • 这是模板家族的一个轻量级扩展。

  • 它展示了在不使用丑陋的模板和预处理宏的情况下实现大量模板库的能力。因此,实现代码可以被理解和调试,并且还节省了编译时间。

  • 它使printf()可变参数函数的类型安全实现成为可能。

接下来,我们将探讨模板参数和参数。

探索模板参数和参数

在前两节中,我们学习了函数和类模板及其实例化。我们知道,在定义模板时,需要给出其参数列表。而在实例化时,必须提供相应的参数列表。在本节中,我们将进一步研究这两个列表的分类和细节。

模板参数

回想一下以下语法,用于定义类/函数模板。在template关键字后面有一个<>符号,在其中必须给出一个或多个模板参数:

//class template declaration
template <*parameter-list*> class-declaration

//function template declaration
template <parameter-list> function-declaration

参数列表中的参数可以是以下三种类型之一:

  • 非类型模板参数:指的是编译时常量值,如整数和指针,引用静态实体。这些通常被称为非类型参数。

  • 类型模板参数:指的是内置类型名称或用户定义的类。

  • 模板模板参数:表示参数是其他模板。

我们将在接下来的小节中更详细地讨论这些内容。

非类型模板参数

非类型模板参数的语法如下:

//for a non-type template parameter with an optional name
type name(optional)

//for a non-type template parameter with an optional name 
//and a default value
type name(optional)=default  

//For a non-type template parameter pack with an optional name
type ... name(optional) (since C++11) 

在这里,type是以下类型之一 - 整数类型、枚举、对象或函数的指针、对象或函数的lvalue引用、成员对象或成员函数的指针,以及std::nullptr_t(自 C++11 起)。此外,我们可以在模板声明中放置数组和/或函数类型,但它们会自动替换为数据和/或函数指针。

以下示例显示了一个使用非类型模板参数int N的类模板。在main()中,我们实例化并创建了一个对象x,因此x.a有五个初始值为1的元素。在将其第四个元素的值设置为10后,我们打印输出:

//ch4_9_none_type_template_param1.cpp
#include <iostream>
template<int N>
class V {
public:
  V(int init) { 
    for (int i = 0; i<N; ++i) { a[i] = init; } 
  }
  int a[N];
};

int main()
{
  V<5> x(1); //x.a is an array of 5 int, initialized as all 1's 
  x.a[4] = 10;
  for( auto &e : x.a) {
    std::cout << e << std::endl;
  }
}

以下是一个使用const char*作为非类型模板参数的函数模板示例:

//ch4_10_none_type_template_param2.cpp
#include <iostream>
template<const char* msg>
void foo() {
  std::cout << msg << std::endl;
}

// need to have external linkage
extern const char str1[] = "Test 1"; 
constexpr char str2[] = "Test 2";
extern const char* str3 = "Test 3";
int main()
{
  foo<str1>();                   //line 1
  foo<str2>();                   //line 2 
  //foo<str3>();                 //line 3

  const char str4[] = "Test 4";
  constexpr char str5[] = "Test 5";
  //foo<str4>();                 //line 4
  //foo<str5>();                 //line 5
  return 0;
}

main()中,我们成功地用str1str2实例化了foo(),因为它们都是编译时常量值并且具有外部链接。然后,如果我们取消注释第 3-5 行,编译器将报告错误消息。出现这些编译器错误的原因如下:

  • 第 3 行str3不是一个 const 变量,所以str3指向的值不能被改变。然而,str3的值可以被改变。

  • 第 4 行str4不是const char*类型的有效模板参数,因为它没有链接。

  • 第 5 行str5不是const char*类型的有效模板参数,因为它没有链接。

非类型参数的最常见用法之一是数组的大小。如果您想了解更多,请访问stackoverflow.com/questions/33234979

类型模板参数

类型模板参数的语法如下:

//A type Template Parameter (TP) with an optional name
typename |class name(optional)               

//A type TP with an optional name and a default
typename[class] name(optional) = default         

//A type TP pack with an optional name
typename[class] ... name(optional) (since C++11) 

注意:在这里,我们可以互换使用typenameclass关键字。在模板声明的主体内,类型参数的名称是typedef-name。当模板被实例化时,它将别名为提供的类型。

现在,让我们看一些例子:

  • 没有默认值的类型模板参数:
Template<class T>               //with name
class X { /* ... */ };     

Template<class >               //without name
class Y { /* ... */ };
  • 带有默认值的类型模板参数:
Template<class T = void>    //with name 
class X { /* ... */ };     

Template<class = void >     //without name
class Y { /* ... */ };
  • 类型模板参数包:
template<typename... Ts>   //with name
class X { /* ... */ };

template<typename... >   //without name
class Y { /* ... */ };

这个模板参数包可以接受零个或多个模板参数,并且仅适用于 C++11 及以后的版本。

模板模板参数

模板模板参数的语法如下:

//A template template parameter with an optional name
template <parameter-list> class *name*(optional) 

//A template template parameter with an optional name and a default
template <parameter-list> class *name*(optional) = default          

//A template template parameter pack with an optional name
template <parameter-list> class ... *name*(optional) (since C++11)                                                                                               

注意:在模板模板参数声明中,只能使用class关键字;不允许使用typename。在模板声明的主体中,参数的名称是template-name,我们需要参数来实例化它。

现在,假设您有一个函数,它充当对象列表的流输出运算符:

template<typename T>
static inline std::ostream &operator << ( std::ostream &out, 
    std::list<T> const& v)
{ 
    /*...*/ 
}

从前面的代码中,您可以看到对于序列容器(如向量,双端队列和多种映射类型),它们是相同的。因此,使用模板模板参数的概念,可以有一个单一的运算符<<来控制它们。这种情况的示例可以在exch4_tp_c.cpp中找到:

/ch4_11_template_template_param.cpp (courtesy: https://stackoverflow.com/questions/213761)
#include <iostream>
#include <vector>
#include <deque>
#include <list>
using namespace std;
template<class T, template<class, class...> class X, class... Args>
std::ostream& operator <<(std::ostream& os, const X<T, Args...>& objs) {
  os << __PRETTY_FUNCTION__ << ":" << endl;
  for (auto const& obj : objs)
    os << obj << ' ';
  return os;
}

int main() {
  vector<float> x{ 3.14f, 4.2f, 7.9f, 8.08f };
  cout << x << endl;

  list<char> y{ 'E', 'F', 'G', 'H', 'I' };
  cout << y << endl;

  deque<int> z{ 10, 11, 303, 404 };
  cout << z << endl;
  return 0;
}

前面程序的输出如下:

class std::basic_ostream<char,struct std::char_traits<char> > &__cdecl operator
<<<float,class std::vector,class std::allocator<float>>(class std::basic_ostream
<char,struct std::char_traits<char> > &,const class std::vector<float,class std:
:allocator<float> > &):
3.14 4.2 7.9 8.08
class std::basic_ostream<char,struct std::char_traits<char> > &__cdecl operator
<<<char,class std::list,class std::allocator<char>>(class std::basic_ostream<cha
r,struct std::char_traits<char> > &,const class std::list<char,class std::alloca
tor<char> > &):
E F G H I
class std::basic_ostream<char,struct std::char_traits<char> > &__cdecl operator
<<<int,class std::deque,class std::allocator<int>>(class std::basic_ostream<char
,struct std::char_traits<char> > &,const class std::deque<int,class std::allocat
or<int> > &):
10 11 303 404 

如预期的那样,每次调用的输出的第一部分是pretty格式的模板函数名称,而第二部分输出每个容器的元素值。

模板参数

要实例化模板,必须用相应的模板参数替换所有模板参数。参数可以是显式提供的,从初始化程序中推导出(对于类模板),从上下文中推导出(对于函数模板),或者默认值。由于有三种模板参数类别,我们也将有三个相应的模板参数。这些是模板非类型参数,模板类型参数和模板模板参数。除此之外,我们还将讨论默认模板参数。

模板非类型参数

请注意,非类型模板参数是指编译时常量值,如整数,指针和对静态实体的引用。在模板参数列表中提供的非类型模板参数必须与这些值中的一个匹配。通常,非类型模板参数用于类初始化或类容器的大小规格。

尽管讨论每种类型(整数和算术类型,指向对象/函数/成员的指针,lvalue引用参数等)的详细规则超出了本书的范围,但总体的一般规则是模板非类型参数应转换为相应模板参数的常量表达式。

现在,让我们看下面的例子:

//part 1: define template with non-type template parameters
template<const float* p> struct U {}; //float pointer non-type parameter
template<const Y& b> struct V {};     //L-value non-type parameter
template<void (*pf)(int)> struct W {};//function pointer parameter

//part 2: define other related stuff
void g(int,float);   //declare function g() 
void g(int);         //declare an overload function of g() 
struct Y {           //declare structure Y 
    float m1;
    static float m2;
};         
float a[10]; 
Y y; //line a: create a object of Y

//part 3: instantiation template with template non-type arguments
U<a> u1;      //line b: ok: array to pointer conversion
U<&y> u2;     //line c: error: address of Y
U<&y.m1> u3;  //line d: error: address of non-static member
U<&y.m2> u4;  //line e: ok: address of static member
V<y> v;       //line f: ok: no conversion needed
W<&g> w;      //line g: ok: overload resolution selects g(int)

在前面的代码中,在part 1中,我们定义了具有不同非类型模板参数的三个模板结构。然后,在part 2中,我们声明了两个重载函数和struct Y。最后,在part 3中,我们看了通过不同的非类型参数正确实例化它们的方法。

模板类型参数

与模板非类型参数相比,模板类型参数(用于类型模板参数)的规则很简单,要求必须是typeid。在这里,typeid是一个标准的 C++运算符,它在运行时返回类型识别信息。它基本上返回一个可以与其他type_info对象进行比较的type_info对象。

现在,让我们看下面的例子:

//ch4_12_template_type_argument.cpp
#include <iostream>
#include <typeinfo>
using namespace std;

//part 1: define templates
template<class T> class C  {}; 
template<class T> void f() { cout << "T" << endl; }; 
template<int i>   void f() { cout << i << endl; };     

//part 2: define structures
struct A{};            // incomplete type 
typedef struct {} B; // type alias to an unnamed type

//part 3: main() to test
int main() {
  cout << "Tid1=" << typeid(A).name() << "; "; 
  cout << "Tid2=" << typeid(A*).name() << "; ";    
  cout << "Tid3=" << typeid(B).name()  << "; ";
  cout << "Tid4=" << typeid(int()).name() << endl;

  C<A> x1;    //line A: ok,'A' names a type
  C<A*> x2;   //line B: ok, 'A*' names a type
  C<B> x3;    //line C: ok, 'B' names a type
  f<int()>(); //line D: ok, since int() is considered as a type, 
              //thus calls type template parameter f()
  f<5>();     //line E: ok, this calls non-type template parameter f() 
  return 0;
}

在这个例子中,在part 1中,我们定义了三个类和函数模板:具有其类型模板参数的类模板 C,具有类型模板参数的两个函数模板,以及一个非类型模板参数。在part 2中,我们有一个不完整的struct A和一个无名类型struct B。最后,在part 3中,我们对它们进行了测试。在 Ubuntu 18.04 中四个typeid()的输出如下:

Tid1=A; Tid2=P1A; Tid3=1B; Tid4=FivE

从 x86 MSVC v19.24,我们有以下内容:

Tid1=struct A; Tid2=struct A; Tid3=struct B; Tid4=int __cdecl(void)

另外,由于A,A*,Bint()具有 typeid,因此从 A 到 D 行的代码段与模板类型类或函数相关联。只有 E 行是从非类型模板参数函数模板实例化的,即f()

模板模板参数

对于模板模板参数,其对应的模板参数是类模板或模板别名的名称。在查找与模板模板参数匹配的模板时,只考虑主类模板。

这里,主模板是指正在进行特化的模板。即使它们的参数列表可能匹配,编译器也不会考虑与模板模板参数的部分特化。

以下是模板模板参数的示例:

//ch4_13_template_template_argument.cpp
#include <iostream>
#include <typeinfo>
using namespace std;

//primary class template X with template type parameters
template<class T, class U> 
class X {
public:
    T a;
    U b;
};

//partially specialization of class template X
template<class U> 
class X<int, U> {
public:
    int a;  //customized a
    U b;
};

//class template Y with template template parameter
template<template<class T, class U> class V> 
class Y {
public:
    V<int, char> i;
    V<char, char> j;
};

Y<X> c;
int main() {
    cout << typeid(c.i.a).name() << endl; //int
    cout << typeid(c.i.b).name() << endl; //char
    cout << typeid(c.j.a).name() << endl; //char
    cout << typeid(c.j.b).name() << endl; //char
    return 0;
}

在这个例子中,我们定义了一个主类模板X及其特化,然后是一个带有模板模板参数的类模板Y。接下来,我们隐式实例化Y,并使用模板模板参数X创建一个对象c。最后,main()输出了四个typeid()的名称,结果分别是intcharcharchar

默认模板参数

在 C++中,通过传递参数来调用函数,并且函数使用这些参数。如果在调用函数时未传递参数,则使用默认值。与函数参数默认值类似,模板参数可以有默认参数。当我们定义模板时,可以设置其默认参数,如下所示:

/ch4_14_default_template_arguments.cpp       //line 0
#include <iostream>                          //line 1  
#include <typeinfo>                          //line 2
template<class T1, class T2 = int> class X;  //line 3
template<class T1 = float, class T2> class X;//line 4
template<class T1, class T2> class X {       //line 5
public:                                      //line 6   
 T1 a;                                       //line 7
 T2 b;                                       //line 8  
};                                           //line 9
using namespace std;
int main() { 
 X<int> x1;          //<int,int>
 X<float>x2;         //<float,int>
 X<>x3;              //<float,int>
 X<double, char> x4; //<double, char>
 cout << typeid(x1.a).name() << ", " << typeid(x1.b).name() << endl;
 cout << typeid(x2.a).name() << ", " << typeid(x2.b).name() << endl;
 cout << typeid(x3.a).name() << ", " << typeid(x3.b).name() << endl;
 cout << typeid(x4.a).name() << ", " << typeid(x4.b).name() << endl;
 return 0
}

在设置模板参数的默认参数时,需要遵循一些规则:

  • 声明顺序很重要——默认模板参数的声明必须在主模板声明的顶部。例如,在前面的例子中,不能将代码移动到第 3 行和第 4 行之后的第 9 行之后。

  • 如果一个参数有默认参数,那么它后面的所有参数也必须有默认参数。例如,以下代码是不正确的:

template<class U = char, class V, class W = int> class X { };  //Error 
template<class V, class U = char,  class W = int> class X { }; //OK
  • 在同一作用域中不能给相同的参数设置默认参数两次。例如,如果使用以下代码,将收到错误消息:
template<class T = int> class Y;

//compiling error, to fix it, replace "<class T = int>" by "<class T>"
template<class T = int> class Y { 
    public: T a;  
};

在这里,我们讨论了两个列表:template_parameter_listtemplate_argument_list。这些分别用于函数或类模板的创建和实例化。

我们还了解了另外两个重要规则:

  • 当我们定义类或函数模板时,需要给出其template_parameter_list
template <template_parameter_list> 
class X { ... }

template <template_parameter_list> 
void foo( function_argument_list ) { ... } //assume return type is void
  • 当我们实例化它们时,必须提供相应的argument_list
class X<template_argument_list> x
void foo<template_argument_list>( function_argument_list )

这两个列表中的参数或参数类型可以分为三类,如下表所示。请注意,尽管顶行是用于类模板,但这些属性也适用于函数模板:

定义模板时****template <template_parameter_list> class X { ... } 实例化模板时****class X<template_argument_list> x

| 非类型 | 此参数列表中的实体可以是以下之一:

  • 整数或枚举

  • 对象指针或函数指针

  • 对对象的lvalue引用或对函数的lvalue引用

  • 成员指针

  • C++11 std ::nullptr_t C++11 结束

|

  • 此列表中的非类型参数是在编译时可以确定其值的表达式。

  • 这些参数必须是常量表达式、具有外部链接的函数或对象的地址,或者静态类成员的地址。

  • 非类型参数通常用于初始化类或指定类成员的大小。

|

| 类型 | 此参数列表中的实体可以是以下之一:

  • 必须以 typename 或 class 开头。

  • 在模板声明的主体中,类型参数的名称是typedef-name。当模板被实例化时,它将别名为提供的类型。

|

  • 参数的类型必须有typeid

  • 它不能是局部类型、没有链接的类型、无名类型或由这些类型中的任何一个构成的类型。

|

| 模板 | 此参数列表中的实体可以是以下之一:

  • template <parameter-list> class name

  • template <parameter-list> class ... name (optional) (自 C++11 起)

此列表中的模板参数是类模板的名称。

在接下来的部分中,我们将探讨如何在 C++中实现特征,并使用它们优化算法。

探索特征

泛型编程意味着编写适用于特定要求下的任何数据类型的代码。这是在软件工程行业中提供可重用高质量代码的最有效方式。然而,在泛型编程中有时候泛型并不够好。每当类型之间的差异过于复杂时,一个高效的泛型优化常见实现就会变得非常困难。例如,当实现排序函数模板时,如果我们知道参数类型是链表而不是数组,就会实现不同的策略来优化性能。

尽管模板特化是克服这个问题的一种方法,但它并不能以广泛的方式提供与类型相关的信息。类型特征是一种用于收集有关类型信息的技术。借助它,我们可以做出更明智的决策,开发高质量的优化算法。

在本节中,我们将介绍如何实现类型特征,然后向您展示如何使用类型信息来优化算法。

类型特征实现

为了理解类型特征,我们将看一下boost::is_voidboost::is_pointer的经典实现。

boost::is_void

首先,让我们来看一下最简单的特征类之一,即由 boost 创建的is_void特征类。它定义了一个通用模板,用于实现默认行为;也就是说,接受 void 类型,但其他任何类型都是 void。因此,我们有is_void::value = false

//primary class template is_void
template< typename T >
struct is_void{
    static const bool value = false;  //default value=false 
};

然后,我们对 void 类型进行了完全特化:

//"<>" means a full specialization of template class is_void
template<> 
struct is_void< void >{             //fully specialization for void
    static const bool value = true; //only true for void type
};

因此,我们有一个完整的特征类型,可以用来检测任何给定类型T是否通过检查以下表达式is_void

is_void<T>::value

接下来,让我们学习如何在boost::is_pointer特征中使用部分特化。

boost::is_pointer

boost::avoid特征类类似,首先定义了一个主类模板:

//primary class template is_pointer
template< typename T > 
struct is_pointer{
    static const bool value = false;
};

然后,它对所有指针类型进行了部分特化:

//"typename T" in "<>" means partial specialization
template< typename T >   
struct is_pointer< T* >{ //<T*> means partial specialization only for type T* 
  static const bool value = true;  //set value as true
};

现在,我们有一个完整的特征类型,可以用来检测任何给定类型T是否通过检查以下表达式is_pointer

is_pointer<T>::value

由于 boost 类型特征功能已经正式引入到 C++ 11 标准库中,我们可以在下面的示例中展示std::is_voidstd::is_pointer的用法,而无需包含前面的源代码:

//ch4_15_traits_boost.cpp
#include <iostream>
#include <type_traits>  //since C++11
using namespace std;
struct X {};
int main()
{
 cout << boolalpha; //set the boolalpha format flag for str stream.
 cout << is_void<void>::value << endl;          //true
 cout << is_void<int>::value << endl;           //false
 cout << is_pointer<X *>::value << endl;        //true
 cout << is_pointer<X>::value << endl;          //false
 cout << is_pointer<X &>::value << endl;        //false
 cout << is_pointer<int *>::value << endl;      //true
 cout << is_pointer<int **>::value << endl;     //true
 cout << is_pointer<int[10]>::value << endl;    //false
 cout << is_pointer< nullptr_t>::value << endl; //false
}

前面的代码在字符串流的开头设置了boolalpha格式标志。通过这样做,所有的布尔值都以它们的文本表示形式提取,即 true 或 false。然后,我们使用几个std::cout来打印is_void<T>::valueis_pointer<T>::value的值。每个值的输出显示在相应的注释行末尾。

使用特征优化算法

我们将使用一个经典的优化复制示例来展示类型特征的用法,而不是以一种泛型抽象的方式来讨论这个主题。考虑标准库算法copy

template<typename It1, typename It2> 
It2 copy(It1 first, It1 last, It2 out);

显然,我们可以为任何迭代器类型编写copy()的通用版本,即这里的It1It2。然而,正如 boost 库的作者所解释的那样,有些情况下复制操作可以通过memcpy()来执行。如果满足以下所有条件,我们可以使用memcpy()

  • It1It2这两种迭代器都是指针。

  • It1It2必须指向相同的类型,除了 const 和 volatile 限定符

  • It1指向的类型必须提供一个平凡的赋值运算符。

这里,平凡的赋值运算符意味着该类型要么是标量类型,要么是以下类型之一:

  • 该类型没有用户定义的赋值运算符。

  • 该类型内部没有数据成员的引用类型。

  • 所有基类和数据成员对象必须定义平凡的赋值运算符。

在这里,标量类型包括算术类型、枚举类型、指针、成员指针,或者这些类型的 const 或 volatile 修饰版本。

现在,让我们看一下原始实现。它包括两部分 - 复制器类模板和用户界面函数,即copy()

namespace detail{
//1\. Declare primary class template with a static function template
template <bool b>
struct copier {
    template<typename I1, typename I2>
    static I2 do_copy(I1 first, I1 last, I2 out);
};
//2\. Implementation of the static function template
template <bool b>
template<typename I1, typename I2>
I2 copier<b>::do_copy(I1 first, I1 last, I2 out) {
    while(first != last) {
        *out = *first; 
         ++out;
         ++first;
    }
    return out;
};
//3\. a full specialization of the primary function template
template <>
struct copier<true> {
    template<typename I1, typename I2>
    static I2* do_copy(I1* first, I1* last, I2* out){
        memcpy(out, first, (last-first)*sizeof(I2));
        return out+(last-first);
    }
};
}  //end namespace detail

如注释行中所述,前面的复制器类模板有两个静态函数模板 - 一个是主要的,另一个是完全专门化的。主要的函数模板进行逐个元素的硬拷贝,而完全专门化的函数模板通过memcpy()一次性复制所有元素:

//copy() user interface 
template<typename I1, typename I2>
inline I2 copy(I1 first, I1 last, I2 out) {
    typedef typename boost::remove_cv
    <typename std::iterator_traits<I1>::value_type>::type v1_t;

    typedef typename boost::remove_cv
    <typename std::iterator_traits<I2>::value_type>::type v2_t;

    enum{ can_opt = boost::is_same<v1_t, v2_t>::value
                    && boost::is_pointer<I1>::value
                    && boost::is_pointer<I2>::value
                    && boost::has_trivial_assign<v1_t>::value 
   };
   //if can_opt= true, using memcpy() to copy whole block by one 
   //call(optimized); otherwise, using assignment operator to 
   //do item-by-item copy
   return detail::copier<can_opt>::do_copy(first, last, out);
}

为了优化复制操作,前面的用户界面函数定义了两个remove_cv模板对象,v1_tv2_t,然后评估can_opt是否为真。之后,调用do_copy()模板函数。通过使用 boost 实用程序库中发布的测试代码(algo_opt_ examples.cpp),我们可以看到使用优化实现有显著改进;即对于复制 char 或 int 类型的数据,速度可能提高 8 倍或 3 倍。

最后,让我们用以下要点总结本节:

  • 特征除了类型之外还提供额外的信息。它通过模板特化来实现。

  • 按照惯例,特征总是作为结构体实现。用于实现特征的结构体称为特征类。

  • Bjarne Stroustrup 说我们应该将特征视为一个小对象,其主要目的是携带另一个对象或算法使用的信息,以确定策略或实现细节。进一步阅读上下文[4]

  • Scott Meyers 还总结说我们应该使用特征类来收集有关类型的信息进一步阅读上下文[5]。

  • 特征可以帮助我们以高效/优化的方式实现通用算法。

接下来,我们将探讨 C++中的模板元编程。

探索模板元编程

一种计算机程序具有将其他程序视为其数据的能力的编程技术被称为元编程。这意味着程序可以被设计为读取、生成、分析或转换其他程序,甚至在运行时修改自身。一种元编程是编译器,它以文本格式程序作为输入语言(C、Fortran、Java 等),并以另一种二进制机器代码格式程序作为输出语言。

C++ 模板元编程TMP)意味着使用模板在 C++中生成元程序。它有两个组成部分 - 必须定义一个模板,并且必须实例化已定义的模板。TMP 是图灵完备的,这意味着它至少在原则上有能力计算任何可计算的东西。此外,因为在 TMP 中变量都是不可变的(变量是常量),所以递归而不是迭代用于处理集合的元素。

为什么我们需要 TMP?因为它可以加速程序的执行时间!但在优化世界中并没有免费的午餐,我们为 TMP 付出的代价是更长的编译时间和/或更大的二进制代码大小。此外,并非每个问题都可以用 TMP 解决;它只在我们在编译时计算某些常量时才起作用;例如,找出小于常量整数的所有质数,常量整数的阶乘,展开常量次数的循环或迭代等。

从实际角度来看,模板元编程有能力解决以下三类问题:编译时计算、编译时优化,以及通过在运行时避免虚拟表查找,用静态多态性替换动态多态性。在接下来的小节中,我们将提供每个类别的示例,以演示元编程的工作原理。

编译时计算

通常,如果任务的输入和输出在编译时已知,我们可以使用模板元编程来在编译期间进行计算,从而节省任何运行时开销和内存占用。这在实时强度 CPU 利用项目中非常有用。

让我们来看一下计算*n*!的阶乘函数。这是小于或等于n的所有正整数的乘积,其中根据定义 0!=1。由于递归的概念,我们可以使用一个简单的函数来实现这一点,如下所示:

//ch4_17_factorial_recursion.cpp
#include <iostream>
uint32_t f1(const uint32_t n) {
  return (n<=1) ? 1 : n * f1(n - 1);
}

constexpr uint32_t f2(const uint32_t n) {
  return ( n<=1 )? 1 : n * f2(n - 1);
}

int main() {
  uint32_t a1 = f1(10);         //run-time computation 
  uint32_t a2 = f2(10);         //run-time computation 
  const uint32_t a3 = f2(10);   //compile-time computation 
  std::cout << "a1=" << a1 << ", a2=" << a2 << std::endl;
}

f1()在运行时进行计算,而f2()可以根据使用情况在运行时或编译时进行计算。

同样,通过使用带有非类型参数的模板,它的特化和递归概念,这个问题的模板元编程版本如下:

//ch4_18_factorial_metaprogramming.cpp
#include <iostream>
//define a primary template with non-type parameters
template <uint32_t n> 
struct fact {
  ***const static uint32_t*** value = n * fact<n - 1>::value;
  //use next line if your compiler does not support declare and initialize
  //a constant static int type member inside the class declaration 
  //enum { value = n * fact<n - 1>::value }; 
};

//fully specialized template for n as 0
template <> 
struct fact<0> { 
    const static uint32_t value = 1;
    //enum { value = 1 };
};
using namespace std;
int main() {
    cout << "fact<0>=" << fact<0>::value << endl;   //fact<0>=1
    cout << "fact<10>=" << fact<10>::value << endl; //fact<10>=3628800

    //Lab: uncomments the following two lines, build and run 
    //     this program, what are you expecting? 
    //uint32_t m=5;
    //std::cout << fact<m>::value << std::endl;
}

在这里,我们创建了一个带有非类型参数的类模板,与其他 const 表达式一样,const static uint32_t或枚举常量的值在编译时计算。这种编译时评估约束意味着只有 const 变量有意义。此外,由于我们只使用类,静态对象才有意义。

当编译器看到模板的新参数时,它会创建模板的新实例。例如,当编译器看到fact<10>::value并尝试使用参数为 10 创建fact的实例时,结果是必须创建fact<9>。对于fact<9>,它需要fact<8>等等。最后,编译器使用fact<0>::value(即 1),并且在编译时的递归终止。这个过程可以在以下代码块中看到:

fact<10>::value = 10* fact<9>::value;
fact<10>::value = 10* 9 * fact<8>::value;
fact<10>::value = 10* 9 * 8 * fact<7>::value;
.
.
.
fact<10>::value = 10* 9 * 8 *7*6*5*4*3*2*fact<1>::value;
fact<10>::value = 10* 9 * 8 *7*6*5*4*3*2*1*fact<0>::value;
...
fact<10>::value = 10* 9 * 8 *7*6*5*4*3*2*1*1;

请注意,为了能够以这种方式使用模板,我们必须在模板参数列表中提供一个常量参数。这就是为什么如果取消注释代码的最后两行,编译器会投诉:fact:template parameter n: m: a variable with non-static storage duration cannot be used as a non-type argument

最后,让我们通过简要比较constexpr 函数(CF)和 TMP 来结束本小节:

  • 计算时间:CF 根据使用情况在编译时或运行时执行,但 TMP 只在编译时执行。

  • 参数列表:CF 只能接受值,但 TMP 可以接受值和类型参数。

  • 控制结构:CF 可以使用递归、条件和循环,但 TMP 只能使用递归。

编译时代码优化

尽管前面的例子可以在编译时计算常量整数的阶乘,但我们可以使用运行时循环来展开两个-n向量的点积(其中n在编译时已知)。传统长度-n向量的好处是可以展开循环,从而产生非常优化的代码。

例如,传统的点积函数模板可以以以下方式实现:

//ch4_19_loop_unoolling_traditional.cpp
#include <iostream>
using namespace std;
template<typename T>
T dotp(int n, const T* a, const T* b)
{
  T ret = 0;
  for (int i = 0; i < n; ++i) {
      ret += a[i] * b[i];
  }
  return ret;
}

int main()
{
  float a[5] = { 1, 2, 3, 4, 5 };
  float b[5] = { 6, 7, 8, 9, 10 };
  cout<<"dot_product(5,a,b)=" << dotp<float>(5, a, b) << '\n'; //130
  cout<<"dot_product(5,a,a)=" << dotp<float>(5, a, a) << '\n'; //55
}

循环展开意味着如果我们可以优化dotp()函数内部的 for 循环为a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3] + a[4]*b[4],那么它将节省更多的运行时计算。这正是元编程在以下代码块中所做的:

//ch4_20_loop_unroolling_metaprogramming.cpp
#include <iostream>

//primary template declaration
template <int N, typename T>    
class dotp {
public:
  static T result(T* a, T* b) {
    return (*a) * (*b) + dotp<N - 1, T>::result(a + 1, b + 1);
  }
};

//partial specialization for end condition
template <typename T>   
class dotp<1, T> {
public:
  static T result(T* a, T* b) {
    return (*a) * (*b);
  }
};

int main()
{
  float a[5] = { 1, 2, 3, 4, 5 };
  float b[5] = { 6, 7, 8, 9, 10 };
  std::cout << "dot_product(5,a,b) = " 
            << dotp<5, float>::result( a, b) << '\n'; //130
  std::cout << "dot_product(5,a,a) = " 
            << dotp<5,float>::result( a, a) << '\n'; //55
}

类似于阶乘元编程示例,在dotp<5, float>::result(a, b)语句中,实例化过程递归执行以下计算:

dotp<5, float>::result( a, b)
= *a * *b + dotp<4,float>::result(a+1,b+1)
= *a * *b + *(a+1) * *(b+1) + dotp<3,float>::result(a+2,b+2)
= *a * *b + *(a+1) * *(b+1) + *(a+2) * *(b+2) 
  + dotp<2,float>::result(a+3,b+3)
= *a * *b + *(a+1) * *(b+1) + *(a+2) * *(b+2) + *(a+3) * *(b+3) 
  + dotp<1,float>::result(a+4,b+4)
= *a * *b + *(a+1) * *(b+1) + *(a+2) * *(b+2) + *(a+3) * *(b+3) 
  + *(a+4) * *(b+4)

由于N为 5,它递归调用dotp<n, float>::results()模板函数四次,直到达到dotp<1, float>::results()。由dotp<5, float>::result(a, b)计算的最终表达式显示在前面块的最后两行中。

静态多态

多态意味着多个函数具有相同的名称。动态多态允许用户在运行时确定要执行的实际函数方法,而静态多态意味着在编译时已知要调用的实际函数(或者一般来说,要运行的实际代码)。默认情况下,C++通过检查类型和/或参数的数量在编译时匹配函数调用与正确的函数定义。这个过程也被称为静态绑定或重载。然而,通过使用虚函数,编译器也可以在运行时进行动态绑定或覆盖。

例如,在以下代码中,虚函数alg()在基类 B 和派生类 D 中都有定义。当我们使用派生对象指针p作为基类的实例指针时,p->alg()函数调用将调用派生类中定义的alg()

//ch4_21_polymorphism_traditional.cpp
#include <iostream>
class B{
public:
    B() = default;
    virtual void alg() { 
        std::cout << "alg() in B"; 
    }
};

class D : public B{
public:
    D() = default; 
    virtual void alg(){
        std::cout << "alg() in D"; 
    }
};

int main()
{
    //derived object pointer p as an instance pointer of the base class
    B *p = new D();
    p->alg();       //outputs "alg() in D"
    delete p;
    return 0;
}

然而,在多态行为不变且可以在编译时确定的情况下,可以使用奇异递归模板模式(CRTP)来实现静态多态,模拟静态多态并在编译时解析绑定。因此,程序将在运行时摆脱对虚拟查找表的检查。以下代码以静态多态的方式实现了前面的示例:

//ch4_22_polymorphism_metaprogramming.cpp
#include <iostream>
template <class D> struct B {
    void ui() {
        static_cast<D*>(this)->alg();
    }
};

struct D : B<D> {
    void alg() {
        cout << "D::alg()" << endl;
     }
};

int main(){
    B<D> b;
    b.ui();
    return 0;
}

总之,模板元编程的一般思想是让编译器在编译时进行一些计算。通过这种方式,可以在一定程度上解决运行时开销的问题。我们之所以能够在编译时计算某些东西,是因为在运行时之前,某些东西是常量。

如进一步阅读中提到的,C++ TMP 是一种非常强大的方法,可以在编译时执行计算任务。第一种方法并不容易,我们必须非常小心处理编译错误,因为模板树是展开的。从实际角度来看,boost 元编程库(MPL)是一个很好的起点。它以通用方式提供了用于算法、序列和元函数的编译时 TMP 框架。此外,C++17 中的新特性 std::variant 和 std::visit 也可以用于静态多态,适用于没有相关类型共享继承接口的情况。

总结

在本章中,我们讨论了 C++中与泛型编程相关的主题。从回顾 C 宏和函数重载开始,我们介绍了 C++模板的开发动机。然后,我们介绍了具有固定数量参数的类和函数模板的语法,以及它们的特化和实例化。自 C++11 以来,标准泛型函数和类模板已经接受可变参数模板。基于此,我们进一步将模板参数和参数分为三类:非类型模板参数/参数,类型模板参数/参数和模板模板参数/参数。

我们还学习了特性和模板元编程。作为模板特化的副产品,特性类可以为我们提供有关类型的更多信息。借助类型信息,最终可以实现实现通用算法的优化。类和/或函数模板的另一个应用是通过递归在编译时计算一些常量任务,这被称为模板元编程。它具有执行编译时计算和/或优化的能力,并且可以避免在运行时进行虚拟表查找。

现在,你应该对模板有了深入的了解。你应该能够在应用程序中创建自己的函数和类模板,并练习使用特性来优化你的算法,并使用模板元编程来进行编译时计算以进行额外的优化。

在下一章中,我们将学习有关内存和管理相关主题的内容,例如内存访问、分配和释放技术的概念,以及垃圾收集基础知识。这是 C++最独特的特性,因此每个 C++开发人员都必须了解。

Questions

  1. 宏的副作用是什么?

  2. 什么是类/函数模板?什么是模板类/函数?

  3. 什么是模板参数列表?什么是模板参数列表?一旦我们有了一个类模板,我们可以显式或隐式地实例化它。在什么样的情况下,显式实例化是必要的?

  4. 在 C++中,多态是什么意思?函数重载和函数覆盖之间有什么区别?

  5. 什么是类型特征?我们如何实现类型特征?

  6. ch4_5_class_template_implicit_inst_B.cpp文件中,我们说隐式实例化生成了X<int>类,然后创建了xi对象并生成了X<int>::f()函数,但没有生成X<int>::g()。如何验证X<int>::g()没有生成?

  7. 使用模板元编程解决f(x,n) = x^n的问题,其中n是一个 const,x是一个变量。

  8. ch4_17_loop_unrolling_metaprogramming.cpp扩展到 n=10,100,10³,10⁴,10⁶,直到达到系统内存限制。比较编译时间、目标文件大小和运行 CPU 时间。

Further reading

正如本章中所引用的,查看以下来源,以了解本章涵盖的更多内容:

www.jot.fm/issues/issue_2008_02/article2.pdf

第五章:深入 STL 中的数据结构和算法

您可以在此章节中找到使用的源文件github.com/PacktPublishing/Expert-CPP

技术要求

内存分配和释放始于对函数的简单调用。调用函数通常意味着向其传递参数。函数需要空间来存储这些参数。为了简化生活,这些都是自动处理的。当我们在代码中声明对象时,同样会发生自动分配。它们的生命周期取决于它们声明的范围。无论何时它们超出范围,它们都将被自动释放。大多数编程语言为动态内存提供类似的自动释放功能。动态分配的内存 - 与自动分配相对 - 是程序员用来识别根据需求请求新内存的代码部分的术语。例如,在存储客户请求列表的程序中,当客户数量增加时会使用这种功能来请求新的内存空间。

大多数语言提供了简化的方法来访问动态内存,而不必担心其释放策略,将繁重的工作留给运行时支持环境。C++程序员必须处理内存管理的低级细节。无论是由于语言的哲学、结构还是年龄,C++都没有提供高级内存管理功能。因此,对内存结构及其管理的深入理解对于每个 C++程序员来说都是必不可少的。让我们在本章中揭示内存和适当的内存管理技术背后的奥秘。

我们将使用电路、继电器和逻辑门来设计一个能够存储位的简单设备。本节的目的是了解内存在其最低级别的结构。在本章中,我们将涵盖以下主题:

  • 什么是内存,我们如何在 C++中访问它?

  • 很难描述设备如何存储这些变量。为了在那个神奇的过程中投下一些光芒,让我们试着设计一个存储一点信息的设备。

  • 内存管理技术和习惯用法

  • 详细的内存分配

垃圾收集基础知识

使用g++编译器和选项-std=c++2a编译本章中的示例。

在最低级别的表示中,内存是一个存储位状态的设备。假设我们正在发明一个可以存储单个位信息的设备。现在,这似乎既毫无意义又神奇。毫无意义是因为发明已经在很久以前就已经发明了。神奇是因为程序员现在有幸福的稳定多功能环境,提供了大量的库、框架和工具来创建程序,甚至不需要了解它们的内部工作。声明变量或分配动态内存已经变得非常容易,就像下面的代码片段所示:

在 C++中,内存管理是有代价的。关心的程序员经常抱怨 C++因为它需要手动内存管理。而像 C#和 Java 这样的语言使用自动内存管理,使得程序运行速度比它们的 C++对应程序慢。手动内存管理经常容易出错和不安全。正如我们在前几章中已经看到的,程序代表数据和指令。几乎每个程序都在某种程度上使用计算机内存。很难想象一个有用的程序不需要内存分配。

理解计算机内存

int var;
double* pd = new double(4.2);

为了在内存管理的类型之间进行某种区分,无论是自动还是手动,程序员都使用内存分段。程序操作多个内存段,堆栈、堆、只读段等等,尽管它们都具有相同的结构并且是同一虚拟内存的一部分。

设计一个内存存储设备

内存管理和智能指针

这是一个简单的电路示例,您可能在物理课上熟悉:

它由一根连接电池和灯泡的导线组成。导线上有一个控制灯泡状态的开关。当开关关闭时,灯泡亮起,否则灯泡熄灭。我们将在这个电路中添加两个 NOR 逻辑元件。NOR 是非或的缩写。通常用以下方式表示:

它有两个输入(导线引入元件),每个代表一个电信号。如果两个输入都为 0,我们说输出(从元件出来的导线)为 1。这就是为什么我们称它为非或,因为如果任何一个输入为 1,OR 元件就会输出 1。前述 NOR 元件只是使用两个继电器构建的。继电器是使用电磁铁来闭合和打开触点的开关。看看下面的图表:

继电器的两个开关关闭(意味着继电器正在工作并拉下电路的开关)时,灯泡是关闭的。当我们将继电器的两个开关移动到开放位置时,灯泡就会亮起。上图是描述 NOR 门的一种方式。此时,我们可以使用电线、灯泡、电池和继电器创建逻辑元件。现在让我们看看两个 NOR 元件的奇怪组合,引发了一个有趣的发现:

上图是典型的R-S 触发器的表示。R代表复位S代表设置。前述方案构建的设备可以存储一个位。输出Q是我们可以读取设备内容的导线。如果我们设置触发器来存储位,输出将为 1。您应该仔细检查图表,并想象逐个或同时向其输入传递信号,并查看Q的输出。当输入S为 1 时,Q变为 1。当R为 1 时,Q变为 0。这样我们就可以设置复位位。只要我们向设备提供电流,它就会存储位。

现在想象一下,将之前设计的许多设备相互连接,以便存储多于一个位的信息。这样,我们可以构建复杂的内存设备,存储字节甚至千字节KB)的数据。

前述设备类似于在晶体管发明之前计算机中使用的设备。晶体管是一种更小的设备,能够存储位。晶体管有不同的类型。现代设备不使用继电器;相反,它们集成了数百万个晶体管来存储和操作数据。中央处理单元CPU)寄存器就是利用晶体管存储指定数量位的设备的一个例子。通常,通用寄存器最多可以存储 64 位数据。但是,您不能仅使用寄存器来存储所有程序和数据。计算机内存的组织要复杂得多。现在让我们从更高层次的角度来研究计算机内存的层次结构。

从更高层次的角度理解计算机内存

了解计算机内存和数据存储的细节对于编写专业程序至关重要。当程序员提到“内存”一词时,大多数情况下他们指的是虚拟内存。虚拟内存是由操作系统(OS)支持的抽象,用于控制和为进程提供内存空间。每个进程都有其地址空间,表示为几个段的集合。我们在《使用 C++进行低级编程》的第二章中讨论了有哪些内存段,以及给定程序如何使用每个内存段。从程序员的角度来看,访问内存空间主要限于对象声明和使用。无论我们在堆栈、堆还是静态内存中声明对象,我们都访问相同的内存抽象——虚拟内存。虽然复杂,但虚拟内存使生活变得更加轻松。直接使用物理内存更加困难,尽管这是程序员技能的重大进步。你至少应该知道有哪些内存存储单元,以及如何利用这些知识来编写更好的代码。

在本节中,我们已经讨论了物理内存层次结构。我们称之为“层次结构”,因为较低级别的每个内存单元提供更快的访问速度,但空间较小。每个连续更高级别的内存提供更多的空间,但访问速度较慢。

我们讨论物理内存层次结构是因为它将帮助我们设计更好的代码。了解每个级别的内存如何工作可以提高我们作为程序员的水平,并使我们能够更好地组织数据操作。以下图表说明了内存层次结构:

(图片)

寄存器是放置在 CPU 中的最快可访问的内存单元。寄存器的数量是有限的,因此我们无法将所有程序数据都保存在其中。另一方面,动态 RAM 能够存储程序的各种数据。由于其物理结构和与 CPU 的距离,从 DRAM 中访问数据需要更长的时间。CPU 通过数据总线访问 DRAM,数据总线是一组在 CPU 和 DRAM 之间传输数据的导线。为了向 DRAM 控制器发出读取或写入数据的信号,CPU 使用控制总线。我们将 DRAM 称为“主内存”。让我们详细看看内存层次结构。

寄存器

寄存器保存固定数量的数据。CPU 字长通常由寄存器的最大长度定义,例如八字节或四字节。我们无法直接从 C++程序中访问寄存器。

C++支持使用asm声明嵌入汇编代码,例如asm("mov edx, 4")。这是一种特定于平台的人为代码增强,因此我们不建议使用它。

在较早版本的语言中,我们可以在声明变量时使用register关键字:

register int num = 14;

修饰符指定编译器将变量存储在寄存器中。这样,它给程序员一种虚假的代码优化感觉。

编译器是将高级 C++代码转换为机器代码的复杂工具。在翻译过程中,代码经历了多次转换,包括代码优化。当程序员对代码的一部分应用“技巧”来强制编译器优化时,编译器将其视为建议而不是命令。

例如,在循环中访问变量,如果将该变量放在寄存器中而不是 DRAM 中,访问速度将更快。例如,以下循环一百万次访问对象:

auto number{42};
for (int ix = 0; ix < 10000000; ++ix) {
 int res{number + ix};
  // do something with res
}

正如我们所知,number具有自动存储期限(与auto关键字无关),并放置在堆栈上。堆栈是虚拟内存中的一个段,虚拟内存是对物理 DRAM 的抽象。从寄存器中访问对象比从 DRAM 中访问对象要快得多。假设从 DRAM 中读取number的值比从寄存器中读取的值慢五倍。显然,通过使用寄存器关键字来优化前面的循环似乎是显而易见的,如下所示:

register auto number{42};
// the loop omitted for code brevity

然而,现在编译器进行了更好的优化,因此对修改器的需求随着时间的推移而减弱,现在已经是一个不推荐使用的语言特性。更好的优化是完全摆脱number对象。

例如,以下代码表示使用实际值而不是通过驻留在 DRAM 中的变量访问该值的编译优化版本:

for (int ix = 0; ix < 1000000; ++ix) {
 int res{42 + ix};
  // do something with res
}

尽管前面的示例可以说是简单的,但我们应该考虑在编译过程中进行的编译器优化。

发现寄存器可以提高我们对程序执行细节的理解。关键是 CPU 执行的所有操作都是通过寄存器进行的,包括 CPU 应该解码和执行的指令都是使用特定的寄存器访问的,通常称为指令指针。当我们运行程序时,CPU 访问其指令并解码和执行它们。从主存中读取数据和向内存写入数据是通过从寄存器复制数据来执行的。通常,通用寄存器用于在 CPU 对其执行操作时临时保存数据。以下图表描述了CPU及其通过总线与主存的交互的抽象视图:

正如您所看到的,CPU 和 DRAM 之间的通信是通过各种总线进行的。在第二章中,我们讨论了 C++程序的低级表示 - 您应该快速查看以更好地理解以下示例。

现在,让我们看看寄存器的运行情况。以下 C++代码声明了两个变量,并将它们的和存储在第三个变量中:

int a{40}, b{2};
int c{a + b};

要执行求和指令,CPU 将变量ab的值移入其寄存器。在计算总和后,它将结果移入另一个寄存器。程序的汇编伪代码表示类似于以下内容:

mov eax, a
mov ebx, b
add eax, ebx

编译器不一定要生成将每个变量映射到一个寄存器的代码 - 寄存器的数量是有限的。您只需要记住,应该将经常访问的变量保持足够小,以适应其中一个寄存器。对于较大的对象,高速缓存内存会发挥作用。让我们看看。

高速缓存内存

缓存的概念在编程和计算机系统中很常见。在浏览器中加载的图像会被缓存,以避免在用户再次访问网站时向 Web 服务器发出进一步的请求以下载它。缓存使程序运行更快。这个概念可以以许多形式利用,包括在单个函数中。例如,以下递归函数计算一个数字的阶乘:

long factorial(long n) {
  if (n <= 1) { return 1; }
  return n * factorial(n - 1);
}

该函数不记得其先前计算的值,因此以下调用分别导致五次和六次递归调用:

factorial(5); // calls factorial(4), which calls factorial(3), and so on
factorial(6); // calls factorial(5), which calls factorial(4), and so on

我们可以通过将它们存储在全局可访问的变量中来缓存每一步已计算的值。

std::unordered_map<long, long> cache;

long factorial(long n) {
  if (n <= 1) return 1;
 if (cache.contains(n)) return cache[n];
 cache[n] = n * factorial(n - 1);
 return cache[n];
}

修改进一步调用函数的优化:

factorial(4);
// the next line calls factorial(4), stores the result in cache[5], which then calls factorial(3)
// and stores the result in cache[4] and so on
factorial(5);
factorial(6); // calls the factorial(5) which returns already calculated value in cache[5]

与缓存概念使阶乘函数运行更快的方式相同,CPU 内部实际存储设备名为缓存。该设备存储最近访问的数据,以便使对该数据的进一步访问更快。以下图表描述了 CPU 内部的寄存器缓存内存

缓存的大小通常范围从 2KB 到 64KB(很少为 128KB)。虽然对于诸如 Photoshop 之类的应用程序来说,缓存的大小可能远远不够,因为图像数据的大小可能远远大于缓存本身,但在许多情况下,它确实会有所帮助。例如,假设我们在一个向量中存储了超过 1000 个数字:

std::vector<int> vec;
vec.push_back(1);
...
vec.push_back(9999);

以下代码打印向量项:

for (auto it: vec) {
  std::cout << it;
}
// 1
// 2
// 3
// ...
// 9999

假设要打印该项,CPU将其从内存复制到 rax 寄存器,然后调用运算符<<,将 rax 的值打印到屏幕上。在循环的每次迭代中,CPU将向 rax 寄存器复制向量的下一项,并调用函数打印其值。每次复制操作都需要CPU将该项的地址放在地址总线上,并将控制总线设置为读模式。DRAM微控制器通过地址总线接收到的地址访问数据,并将其值复制到数据总线,从而将数据发送给CPUCPU将值传递给 rax 寄存器,然后执行指令打印其值。下图显示了CPUDRAM之间的交互:

为了优化循环,CPU 保持了数据局部性的概念,即将整个向量复制到缓存中,并从缓存中访问向量项,省略了对 DRAM 的不必要请求。在下图中,您可以看到通过数据总线从 DRAM 接收的数据然后存储在缓存内存中:

存储在 CPU 中的缓存被称为一级L1缓存。这是容量最小的缓存,位于 CPU 内部。许多体系结构都有二级L2缓存,它位于 CPU 外部(尽管比主存储器更接近),并且与 DRAM 的访问方式相同。L2 缓存和 DRAM 之间的区别在于物理结构和数据访问模式。L2 缓存代表静态 RAMSRAM),比 DRAM 更快,但也更昂贵。

一些运行时环境在实现垃圾回收时利用了缓存的概念。它们根据对象的生命周期将对象分成不同的类别,生命周期最短的对象,比如在代码的局部范围内分配的对象,被放入缓存中以便更快地访问和释放。

新的缓存级别用作较低级别的缓存。例如,L2 缓存用作 L1 缓存的缓存内存。当 CPU 遇到缓存未命中时,它会请求 L2 缓存,依此类推。

主存储器

DRAM 的物理结构迫使它刷新其电荷以保持数据稳定,而 SRAM 不需要像 DRAM 那样刷新。我们之所以称 DRAM 为主存储器,主要是因为程序加载到其中;操作系统维护虚拟内存并将其映射到 DRAM。所有实际的工作都是通过主存储器进行的。

正如我们之前讨论的,主存储器代表了一系列可寻址的数据字节。每个字节都有自己独特的地址,并且可以使用该地址进行访问。我们之前提到过,CPU 将数据的地址放在地址总线上,从而让 DRAM 微控制器获取请求的数据并通过数据总线发送出去。

正如我们所知,操作系统引入了虚拟内存作为对物理内存的抽象。它将虚拟内存的内容映射到物理内存,这涉及到 CPU 的转换旁路缓存TLB)。TLB 是另一种缓存内存的形式:它存储了虚拟内存物理内存的最近转换,从而为将来的请求进行缓存。如下图所示,CPUTLB协调以正确地将虚拟地址转换为物理地址:

尽管内存管理很复杂,但操作系统为我们提供了一个足够简单的抽象来管理程序所需的内存。我们有能力使用堆栈自动分配内存,也可以在堆上动态分配内存。自动内存分配实际上并不涉及太多问题和困难;我们只需声明对象,它们就会放在堆栈上,然后在执行离开作用域时自动删除。在动态内存的情况下(不要与前面提到的硬件 DRAM 混淆),分配和释放都应该手动完成,这会导致可能导致内存泄漏的错误。

永久存储

当我们关闭计算机时,主内存的内容会被擦除(因为电荷不再刷新)。为了在断电时永久存储数据,计算机配备了硬盘驱动器(HDD)或固态硬盘驱动器(SSD)。从程序员的角度来看,永久存储用于存储程序及其必要的数据。我们已经知道,为了运行程序,它应该被加载到主内存中,也就是从硬盘驱动器复制到 DRAM 中。操作系统使用加载器处理这个过程,并在内存中创建一个程序映像,通常称为进程。当程序完成或用户关闭它时,操作系统将进程的地址范围标记为可用。

假设我们使用文本编辑器在学习 C++时写笔记。在编辑器中键入的文本将驻留在主内存中,除非我们将其保存在硬盘驱动器上。这一点很重要,因为大多数程序会跟踪最近的用户活动,并允许用户修改程序设置。为了保持用户修改后的设置,即使程序重新启动,程序会将它们存储为硬盘上的单独的设置文件。下次程序运行时,它首先从硬盘驱动器中读取相应的设置文件,然后更新自身以应用最近的设置修改。

通常,永久存储的容量比主内存大得多,这使得可以将硬盘驱动器用作虚拟内存的备份。操作系统可以维护虚拟内存并伪装其大小,使其比物理 DRAM 更大。例如,启动几个重量级应用程序可能会迅速耗尽 DRAM 的最大容量。然而,操作系统仍然可以通过将其额外的空间备份到硬盘驱动器来维护更大的虚拟内存。当用户在应用程序之间切换时,操作系统将虚拟内存的超出字节复制到硬盘驱动器,并将当前运行的应用程序映射到物理内存。

这使得程序和操作系统运行得更慢,但允许我们保持它们打开,而不必担心主内存的有限大小。现在让我们深入了解 C++中的内存管理。

内存管理的基础

大多数情况下,内存管理中出现的问题是程序员忘记释放内存空间。这导致内存泄漏。内存泄漏是几乎每个程序中普遍存在的问题。当程序请求新的内存空间来存储其数据时,操作系统会将提供的空间标记为“忙碌”。也就是说,程序的任何其他指令或任何其他程序都无法请求该忙碌的内存空间。当程序部分完成内存空间时,理想情况下,它必须通知操作系统去除忙碌标签,以便为其他程序释放空间。一些语言提供对动态分配内存的自动控制,使程序员只需担心应用程序的逻辑,而不必不断担心释放内存资源。然而,C++假设程序员是负责和聪明的(这并不总是事实)。动态分配的内存管理是程序员的责任。这就是为什么语言提供了“new”和“delete”运算符来处理内存空间,其中 new 运算符分配内存空间,而 delete 运算符释放内存空间。换句话说,处理动态分配内存的理想代码如下所示:

T* p = new T(); // allocate memory space
p->do_something(); // use the space to do something useful
delete p; // deallocate memory space

忘记调用 delete 运算符会使分配的内存空间“永远忙碌”。所谓的“永远”,是指程序运行的时间。现在想象一下一个总是在用户计算机上打开的网络浏览器。这里发生的内存泄漏可能会导致内存饥饿,最终用户不得不重新启动程序,甚至更糟糕的是重新启动操作系统。

这个问题适用于我们使用的任何资源,无论是我们忘记关闭的文件还是套接字(关于套接字的更多信息请参见第十二章,网络和安全)。为了解决这个问题,C++程序员使用资源获取即初始化RAII)习惯用法,该习惯用法规定资源应该在初始化时获取,这样可以在以后正确释放它。让我们看看它的实际应用。

内存管理的一个例子

考虑以下函数,该函数动态分配了一个包含 420 个shorts的数组,从用户输入中读取它们的值,按升序打印它们,并释放数组:

void print_sorted() {
  short* arr{new short[420]};
  for (int ix = 0; ix < 420; ++ix) {
    std::cin >> arr[ix];
  }
  std::sort(arr, arr + 420);
  for (int ix = 0; ix < 420; ++ix) {
    std::cout << arr[ix];
  }
  delete arr; // very bad!
}

在前面的代码中,我们已经犯了一个错误,即使用错误的delete运算符来释放内存。要释放数组,我们必须使用delete[]运算符,否则代码会导致内存泄漏。以下是我们如何说明数组的分配:

假设我们使用delete而不是delete[]来释放空间。它将把arr视为一个 short 指针,因此将删除从arr指针中包含的地址开始的前两个字节,如下图所示:

现在我们从 420 个项目中移除了第一个项目,剩下的 419 个shorts保持在堆上不变。每当我们需要堆上的新空间时,包含 419 个“untouchables”的小部分将永远不会被再次重用。虽然 new 和 delete 运算符的家族是实现定义的,但我们不应该真的指望最好的实现来避免内存泄漏。

让我们修改前面的代码,正确释放数组的分配内存,并确保消除输入负数的可能性:

void print_sorted() {
 short* arr{new short[420]};
  for (int ix = 0; ix < 420; ++ix) {
    std::cin >> arr[ix];
 if (arr[ix] < 0) return;
  }
  std::sort(arr, arr + 420);
  // print the sorted array, code omitted for brevity
 delete[] arr;
}

前面的修改是可能的内存泄漏的另一个例子,尽管我们显然为了简单起见写了丑陋的代码。关键是,每当用户输入一个负数时,函数就会返回。这让我们有 420 个应该被释放的shorts孤立。然而,分配的内存的唯一访问是arr指针,它在堆栈上声明,因此当函数返回时它将被自动删除(指针变量,而不是指向它的内存空间)。为了消除内存泄漏的可能性,我们应该在函数退出之前简单地调用delete[]运算符:

void print_sorted() {
 short* arr{new short[420]};
  for(int ix = 0; ix < 420; ++ix) {
    std::cin >> arr[ix];
 if (arr[ix] < 0) {
 delete[] arr;
 return;
 }
  }
  // sort and print the sorted array, code omitted for brevity
 delete[] arr;
}

代码变得有些丑陋,但它修复了内存泄漏。如果我们进一步修改函数并使用第三方库函数来对数组进行排序:

import <strange_sort.h>;

void print_sorted() {
  short* arr{new short[420]};
  for (...) { /* code omitted for brevity */ }
 strange_sort::sort(arr, arr + 420);
  // print the sorted array, code omitted for brevity
  delete[] arr;
}  

事实证明,strange_sort::sort在数组项的值超过 420 时会抛出异常(毕竟这就是一个奇怪的排序)。如果异常没有被捕获,它将冒泡到调用者函数,除非它在某处被捕获,或者程序崩溃。未捕获的异常导致堆栈展开,这导致arr变量(指针)的自动销毁,因此我们面临另一个内存泄漏的可能性。为了解决这个问题,我们可以将strange_sort::sort包装在 try-catch 块中:

try {
  strange_sort::sort(arr, arr + 420);
} catch (ex) { delete[] arr; }

C++程序员不断寻求处理内存泄漏的方法,例如 RAII 习惯用法和智能指针,我们将在接下来的章节中讨论。

使用智能指针

有许多支持自动垃圾收集的语言。例如,为对象获取的内存由运行时环境跟踪。当具有对它的引用的对象超出范围时,它将释放内存空间。例如,考虑以下情况:

// a code sample of the language (not-C++) supporting automated garbage collection
void foo(int age) {
  Person p = new Person("John", 35);
  if (age <= 0) { return; }
  if (age > 18) {
   p.setAge(18);
  }
  // do something useful with the "p"
}
// no need to deallocate memory manually

在前面的代码块中,p引用(通常,垃圾收集语言中的引用类似于 C++中的指针)指的是new运算符返回的内存位置。自动垃圾收集器管理new运算符创建的对象的生命周期。它还跟踪对该对象的引用。每当对象没有引用时,垃圾收集器就会释放其空间。通过在 C++中使用 RAII 习惯用法,可以实现类似的功能。让我们看看它的实际应用。

利用 RAII 习惯用法

如前所述,RAII 习惯用法建议在初始化时获取资源。看看下面的类:

template <typename T>
class ArrayManager {
public:
  ArrayManager(T* arr) : arr_{arr} {}
  ~ArrayManager() { delete[] arr_; }

  T& operator[](int ix) { return arr_[ix]; }

  T* raw() { return arr_; }
};

print_sorted函数现在可以使用ArrayManager来正确释放分配的数组:

void print_sorted() {
 ArrayManager<short> arr{new short[420]};
  for (int ix = 0; ix < 420; ++ix) {
    std::cin >> arr[ix];
  }
  strange_sort::sort(arr.raw(), arr.raw() + 420);
  for (int ix = 0; ix < 420; ++ix) {
    std::cout << arr[ix];
  }
}

我们建议使用标准容器,如std::vector,而不是ArrayManager,尽管它是 RAII 应用的一个很好的例子:在初始化时获取资源。我们创建了一个ArrayManager的实例,并用内存资源对其进行了初始化。从那时起,我们可以忘记它的释放,因为实际的释放发生在ArrayManager的析构函数中。由于我们在堆栈上声明了ArrayManager实例,当函数返回或发生未捕获的异常时,它将被自动销毁,并且析构函数将被调用。

在这种情况下,使用标准容器是首选,因此让我们为单个指针实现 RAII 习惯用法。以下代码动态为Product实例分配内存:

Product* apple{new Product};
apple->set_name("Red apple");
apple->set_price(0.42);
apple->set_available(true);
// use the apple
// don't forget to release the resource
delete apple;

如果我们将 RAII 习惯用法应用于前面的代码,它将在代码执行的适当点释放资源:

ResourceManager<Product> res{new Product};
res->set_name("Red apple");
res->set_price(0.42);
res->set_available(true);
// use the res the way we use a Product
// no need to delete the res, it will automatically delete when gets out of the scope

ResourceManager类还应该重载运算符*->,因为它必须像指针一样行为,以便正确获取和管理指针:

template <typename T>
class ResourceManager {
public:
  ResourceManager(T* ptr) : ptr_{ptr} {}
  ~ResourceManager() { delete ptr_; }

 T& operator*() { return *ptr_; }
 T* operator->() { return ptr_; }
};

ResourceManager类关心 C++中的智能指针的概念。C++11 引入了几种类型的智能指针。我们将它们称为智能,是因为它们包装资源并管理其自动释放。这仅仅是因为当对象被设置为销毁时,对象的析构函数将被调用。也就是说,我们通过具有自动存储期的对象操作动态分配的空间。当处理程序对象超出范围时,其析构函数执行必要的操作以释放底层资源。

然而,智能指针可能带来额外的问题。在前面段落讨论的简单智能指针中,最终会出现几个问题。例如,我们没有处理ResourceManager的复制:

void print_name(ResourceManager<Product> apple) {
  std::cout << apple->name();
}

ResourceManager<Product> res{new Product};
res->set_name("Red apple");
print_name(res);
res->set_price(0.42);
// ...

前面的代码会导致未定义的行为。以下图表显示了伪装的问题:

resapple都获取了相同的资源。每当它们中的一个超出范围(apple),底层资源就会被释放,这会导致另一个ResourceManager实例拥有悬空指针。当另一个ResourceManager实例超出范围时,它将尝试两次删除指针。通常,程序员会意识到在特定情况下需要哪种智能指针。这就是为什么 C++提供了几种类型的智能指针,我们将进一步讨论。要在程序中使用它们,您应该导入<memory>头文件。

std::unique_ptr

与我们之前实现的ResourceManager实例类似,std::unique_ptr代表了一个基本的智能指针。例如,要使用这个智能指针来管理Product对象,我们这样做:

std::unique_ptr<Product> res{new Product};
res->set_name("Red apple");
// res will delete its acquired resource when goes out of scope

请注意我们如何访问Product成员函数set_name。我们将res对象视为具有类型Pointer*的东西。

unique_ptr之所以被称为 unique,是因为它提供了严格所有权的语义-它有责任销毁所获得的对象。更有趣的是,unique_ptr不能被复制。它没有复制构造函数或赋值运算符。这就是为什么它的所有权严格的。当然,这并不意味着我们不能移动unique_ptr类。在这种情况下,我们完全将所有权转移到唯一指针的另一个实例。

智能指针的主要要求之一是保持它们的轻量级。我们肯定会同意这一点。虽然unique_ptr是一个完整的类,有几个成员函数,但它不会通过附加数据成员来“污染”。它只是一个围绕分配对象的原始指针的包装器。我们可以通过调用unique_ptrrelease()成员函数来访问该原始指针,如下所示:

Product* p = res.release();
// now we should delete p manually to deallocate memory

请注意,release()函数不会调用删除运算符。它只是归还所有权。调用release()函数后,unique_ptr不再拥有资源。要重用已拥有资源的unique_ptr,您应该使用reset()成员函数。它调用底层指针的删除运算符并“重置”唯一指针以供进一步使用。另一方面,如果要获取底层对象而不释放所有权,应该调用get()成员函数:

std::unique_ptr<Product> up{new Product()};
Product* p = res.get();
// now p also points to the object managed by up

我们无法在以下情况中使用unique_ptr类,因为它无法被复制:

// Don't do this
void print_name(std::unique_ptr<Product> apple) {
  std::cout << apple->name();
}
std::unique_ptr<Product> res{new Product};
res->set_name("Red apple");
print_name(res); // bad code
res->set_price(0.42);
// ...

然而,这并不是我们在前面的代码中寻找的。您可以将前面的代码视为糟糕的设计,因为它混淆了所有权细节。让我们继续讨论 C++中的下一个智能指针,它解决了将unique_ptr传递给函数的问题。

std::shared_ptr 和 std::weak_ptr

我们需要一个提供共享所有权的智能指针。我们需要的东西在 C++11 中被引入,称为std::shared_ptr。实现具有共享所有权的智能指针更难,因为您应该注意正确释放资源。例如,当前面代码块中的print_name()函数完成其工作时,它的参数和局部对象将被销毁。销毁智能指针会导致所拥有的资源得到适当的释放。智能指针如何知道该资源是否仍然被另一个智能指针所拥有呢?其中一个流行的解决方案是保持对资源的引用计数。shared_ptr类也是如此:它保持指向底层对象的指针的数量,并在使用计数变为 0 时删除它。因此,几个共享指针可以拥有相同的对象。

现在,我们刚才讨论的示例应该重写如下:

void print_name(std::shared_ptr<Product> apple) {
  std::cout << apple->name();
}
std::shared_ptr<Product> res{new Product};
res->set_name("Red apple");
print_name(res);
res->set_price(0.42);
// ...

调用print_name()函数后,共享指针的使用计数增加了 1。当函数完成其工作时,使用计数将减少 1,但托管对象不会被释放。这是因为res对象尚未超出范围。让我们稍微修改示例以打印对共享对象的引用计数:

void print_name(std::shared_ptr<Product> apple) {
  std::cout << apple.use_count() << " eyes on the " << apple->name();
}

std::shared_ptr<Product> res{new Product};
res->set_name("Red apple");
std::cout << res.use_count() << std::endl;
print_name(res);
std::cout << res.use_count() << std::endl;
res->set_price(0.42);
// ...

前面的代码将在屏幕上打印如下内容:

1
2 eyes on the Red apple
1

当最后一个shared_ptr超出范围时,它也会销毁底层对象。然而,在共享指针之间共享对象时,您应该小心。以下代码显示了共享所有权的一个明显问题:

std::shared_ptr<Product> ptr1{new Product()};
Product* temp = ptr1.get();
if (true) {
  std::shared_ptr<Product> ptr2{temp};
  ptr2->set_name("Apple of truth");
}
ptr1->set_name("Peach"); // danger!

ptr1ptr2都指向同一个对象,但它们彼此不知道。因此,当我们通过ptr2修改Product对象时,它会影响ptr1。当ptr2超出范围(在if语句之后)时,它将销毁底层对象,而该对象仍然被ptr1拥有。这是因为我们通过将原始的temp指针传递给它,使ptr2拥有了该对象。ptr1无法跟踪到这一点。

只能使用std::shared_ptr的复制构造函数或赋值运算符来共享所有权。这样,我们避免了如果它正在被另一个shared_ptr实例使用时删除对象。共享指针使用控制块实现共享所有权。每个共享指针持有两个指针,一个指向它管理的对象,一个指向控制块。控制块表示动态分配的空间,包含资源的使用计数。它还包含对于shared_ptr至关重要的其他几个东西,例如资源的allocatordeleter。我们将在下一节介绍分配器。deleter通常是常规的delete运算符。

控制块还包含弱引用的数量。这是因为所拥有的资源也可能被弱指针指向。std::weak_ptrstd::shared_ptr的小兄弟。它指的是由shared_ptr实例管理的对象,但并不拥有它。weak_ptr是一种访问和使用由shared_ptr拥有的资源而不拥有它的方法。然而,有一种方法可以使用lock()成员函数将weak_ptr实例转换为shared_ptr

unique_ptrshared_ptr都可以用于管理动态分配的数组。必须正确指定模板参数:

std::shared_ptr<int[]> sh_arr{new int[42]};
sh_arr[11] = 44;

为了访问底层数组的元素,我们使用共享指针的[]运算符。还要注意,当在动态多态性中使用智能指针时,不会有缺点。例如,假设我们有以下类层次结构:

struct Base
{
  virtual void test() { std::cout << "Base::test()" << std::endl; }
}; 

struct Derived : Base
{
  void test() override { std::cout << "Derived::test()" << std::endl; }
};

以下代码按预期工作,并将Derived::test()输出到屏幕上。

std::unique_ptr<Base> ptr = std::make_unique_default_init<Derived>();
ptr->test();

尽管使用智能指针可能会破坏指针的美感,但建议大量使用智能指针以避免内存泄漏。然而,值得注意的是,用unique_ptrshared_ptr指针替换所有指针,也无法解决所有内存泄漏问题。它们也有缺点。在应用它们解决问题之前,考虑一种平衡的方法,或者更好地彻底了解问题和智能指针本身的细节。

在 C++程序中管理内存是有代价的。我们讨论的最重要的事情是正确释放内存空间。该语言不支持自动内存释放,但值得一提的是垃圾收集器。然而,要有一个完整的垃圾收集器,我们需要语言级别的支持。C++没有提供任何支持。让我们尝试在 C++中模拟垃圾收集器。

垃圾收集

垃圾收集器通常是可解释语言的运行时环境中的一个单独模块。例如,C#和 Java 都有垃圾收集器,这使得程序员的生活变得更加轻松。垃圾收集器跟踪代码中的所有对象分配,并在它们不再使用时释放。它被称为垃圾收集器,因为它在使用后删除内存资源:它收集程序员留下的垃圾。

据说 C++程序员不会留下垃圾,这就是为什么该语言不支持垃圾收集器的原因。尽管程序员倾向于辩护该语言,称其之所以没有垃圾收集器是因为它是一种快速的语言,但事实是它可以在没有垃圾收集器的情况下生存。

像 C#这样的语言将程序编译成中间字节码表示,然后由运行时环境解释和执行。垃圾收集器是环境的一部分,并且积极跟踪所有对象分配。它是一个复杂的机制,尽最大努力在合理的时间内管理内存。以下图表描述了典型的运行时环境,该环境分配由垃圾收集器监督的内存:

即使使用智能指针,我们仍然需要手动调用delete运算符来释放 C++中的内存空间。智能指针只是在获取对象时获取对象,并在对象超出范围时删除对象。关键点是,即使智能指针引入了一些半自动行为,它们仍然表现得好像程序员没有忘记在代码的指定点释放资源。垃圾收集器会自动执行这些操作,并通常使用单独的执行线程。它尽力不要减慢实际程序执行速度。

一些垃圾收集实现技术包括根据对象的生命周期持续时间对对象进行分类。分类使垃圾收集器访问对象并在对象不再使用时释放内存空间。为了加快这个过程,应该更频繁地访问生命周期短的对象,而不是生命周期长的对象。例如,考虑以下代码:

struct Garbage {
  char ch;
  int i;
};

void foo() {
  Garbage* g1 = new Garbage();
  if (true) {
    Garbage* g2 = new Garbage();
  }
}

int main() {
  static Garbage* g3 = new Garbage();
}

如果 C++有垃圾收集器,那么对象g1g2g3将在程序执行的不同时间段被删除。如果垃圾收集器根据它们的生命周期持续时间对它们进行分类,那么g2的生命周期将是最短的,并且应该首先被访问以释放它。

要真正在 C++中实现垃圾收集器,我们应该将其作为程序的一部分。垃圾收集器应该首先负责分配内存来跟踪并删除它:

class GarbageCollector {
public:
 template <typename T>
 static T* allocate() { 
   T* ptr{new T()};
 objects_[ptr] = true;
   return ptr;
 }

 static void deallocate(T* p) {
   if (objects_[p]) {
     objects_[p] = false;
     delete p;
   }
 } private:
 std::unordered_map<T*, bool> objects_;
};

前面的类通过静态的allocate()函数跟踪通过分配的对象。如果对象正在使用,则通过deallocate()函数删除它。以下是GarbageCollector的使用方法:

int* ptr = GarbageCollector::allocate<int>();
*ptr = 42;
GarbageCollector::deallocate(ptr);

实际上,这个类使得内存管理比智能指针稍微困难一些。基本上,在 C++中没有必要实现垃圾收集器,因为智能指针几乎可以处理关于自动内存释放的任何情况。

然而,让我们看看一种技巧,它将允许垃圾收集器正确释放某个指针指向的空间。在我们最简单的前面的实现中,我们跟踪了我们提供给用户的所有指针。每个指针指向堆上的一些空间,应该在程序执行的某个时刻被释放。在GarbageCollector中,我们将使用标准的delete运算符。问题是,它如何知道应该释放多少字节?看看下面的例子:

Student* ptr = new Student;
int* ip = new int{42};
// do something with ptr and ip
delete ptr;
delete ip;

假设一个Student实例占用 40 个字节的内存,一个整数占用 4 个字节。我们应该以某种方式将这些信息传递给删除运算符。在前面的代码中,我们删除了ptrip,它们分别指向不同大小的内存空间。那么它如何知道在ptr的情况下应该将 40 个字节标记为自由,而在ip的情况下应该将 4 个字节标记为自由?对于这个问题有不止一种解决方案,让我们看看其中一种。

每当我们分配内存时,new运算符将分配空间的大小放在实际内存空间之前,如下图所示:

这些信息然后被delete运算符使用,它通过读取内存空间之前放置的相应字节来读取内存空间的大小。C++的一个主要关注点是管理数据集合的内存。STL 容器,如std::vectorstd::list,在第六章中描述的《深入 STL 中的数据结构和算法》中,对处理内存有不同的模型。默认情况下,容器有一个指定的内存分配器,用于处理容器元素的内存分配和释放。让我们更详细地了解一下分配器。

使用分配器

分配器的理念是为容器内存管理提供控制。简单来说,分配器是 C++容器的高级垃圾收集器。虽然我们在容器内存管理范围内讨论分配器,但您肯定可以将这个想法扩展到通用的垃圾收集器。在本节的开头,我们实现了一个设计不良的垃圾收集器。当研究分配器时,您会发现GarbageCollector类和 C++中的默认分配器之间有很多相似之处。默认分配器在<memory>中定义,它有两个基本函数-allocate()deallocate()allocate()函数定义如下:

[[nodiscard]] constexpr T* allocate(std::size_t num);

allocate()函数获取类型为Tnum个对象的空间。注意[[nodiscard]]属性-这意味着调用者不应该丢弃返回值。否则,编译器将打印警告消息。

让我们使用分配器为五个整数获取空间:

import <memory>;

int main()
{
  std::allocator<int> IntAlloc;
  int* ptr = IntAlloc.allocate(5);
  // construct an integer at the second position
 std::allocator_traits<IntAlloc>::construct(IntAlloc, ptr + 1, 42);
  IntAlloc.deallocate(ptr, 5); // deallocate all
}

注意我们如何使用std::allocator_traits在分配的空间中构造对象。下图显示了

deallocate()函数定义如下:

constexpr void deallocate(T* p, std::size_t n)

在上一个代码片段中,我们使用allocate()函数返回的指针来调用deallocate()函数。

您可能不会直接在项目中使用分配器,但是每当您需要自定义内存管理行为时,使用现有的或引入新的分配器可能会有所帮助。STL 容器主要使用分配器,因为它们在结构和行为上有所不同,这导致需要为内存分配和释放具有专门的行为。我们将在下一章更详细地讨论 STL 容器。

总结

像 C#这样的语言中的垃圾收集器是由环境提供的。它们与用户程序并行工作,并在程序看起来有效时尝试清理。我们无法在 C++中做同样的事情;我们能做的只是在程序中直接实现垃圾收集器,提供一种半自动的方式来释放已使用的内存资源。自 C++11 以来,这种机制已经得到了语言中智能指针的适当覆盖。

内存管理是每个计算机程序的关键组成部分之一。程序应该能够在执行过程中动态请求内存。优秀的程序员了解内存管理的内部细节。这有助于他们设计和实现性能更好的应用程序。虽然手动内存管理被认为是一种优势,但在较大的应用程序中往往变得痛苦。在本章中,我们已经学会了如何通过智能指针避免错误并处理内存释放。有了这种基本的理解,您应该对设计避免内存泄漏的程序更有信心。

在下一章中,我们将学习 STL,重点关注数据结构和算法,并深入研究它们的 STL 实现。除了比较数据结构和算法,我们还将介绍 C++20 中一个显著的新特性:概念。

问题

  1. 解释计算机内存。

  2. 什么是虚拟内存?

  3. 用于内存分配和释放的运算符是哪些?

  4. deletedelete[]之间有什么区别?

  5. 什么是垃圾收集器,为什么 C++不支持垃圾收集器?

进一步阅读

有关更多信息,请参阅以下链接:

第二部分:设计健壮高效的应用程序

本节将集中讨论使用数据结构、算法和并发工具进行数据处理的效率。我们还将介绍基本的设计模式和最佳实践。

本节包括以下章节:

  • 第六章,深入 STL 中的数据结构和算法

  • 第七章,函数式编程

  • 第八章,并发和多线程

  • 第九章,设计并发数据结构

  • 第十章,设计面向世界的应用程序

  • 第十一章,使用设计模式设计策略游戏

  • 第十二章,网络和安全

  • 第十三章,调试和测试

  • 第十四章,使用 Qt 进行图形用户界面设计

第六章:深入 STL 中的数据结构和算法

掌握数据结构对程序员至关重要。大多数情况下,数据存储方式定义了应用程序的整体效率。例如,考虑一个电子邮件客户端。您可以设计一个显示最新 10 封电子邮件的电子邮件客户端,并且它可能具有最佳的用户界面;在几乎任何设备上都可以顺畅地显示最近的 10 封电子邮件。您的电子邮件应用程序的用户在使用您的应用程序两年后可能会收到数十万封电子邮件。当用户需要搜索电子邮件时,您的数据结构知识将发挥重要作用。您存储数十万封电子邮件的方式以及您用于排序和搜索它们的方法(算法)将是您的程序与其他所有程序的区别所在。

程序员在项目中努力寻找每日问题的最佳解决方案。使用经过验证的数据结构和算法可以极大地改善程序员的工作。一个好程序最重要的特性之一是速度,通过设计新的算法或使用现有算法来获得速度。

最后,C++20 引入了用于定义元类型概念,即描述其他类型的类型。语言的这一强大特性使数据架构完整。

C++的标准模板库STL)涵盖了大量的数据结构和算法。我们将探索使用 STL 容器来高效组织数据的方法。然后我们将深入研究 STL 提供的算法实现。理解并使用 STL 容器中的概念至关重要,因为 C++20 通过引入迭代器概念来大幅改进迭代器。

本章将涵盖以下主题:

  • 数据结构

  • STL 容器

  • 概念和迭代器

  • 掌握算法

  • 探索树和图

技术要求

本章中使用带有选项-std=c++2a的 g++编译器来编译示例。您可以在本书的 GitHub 存储库中找到本章中使用的源文件github.com/PacktPublishing/Expert-CPP

数据结构

作为程序员,您可能熟悉使用数组来存储和排序数据集。程序员在项目中除了数组之外还会大量使用其他数据结构。了解并应用适当的数据结构可能在程序性能中发挥重要作用。要选择正确的数据结构,您需要更好地了解它们。一个明显的问题可能会出现,即我们是否需要研究数据结构的动物园——向量、链表、哈希表、图、树等等。为了回答这个问题,让我们假设一个想要更好的数据结构的必要性自然而然地显现出来的想象场景。

在介绍内容中,我们提到了设计一个电子邮件客户端。让我们对其设计和实现过程中的基本任务有一个一般的了解。

电子邮件客户端是一个列出来自各个发件人的电子邮件的应用程序。我们可以将其安装在台式电脑或智能手机上,或者使用浏览器版本。电子邮件客户端应用程序的主要任务包括发送和接收电子邮件。现在假设我们正在设计一个足够简单的电子邮件客户端。就像在编程书籍中经常发生的那样,假设我们使用了一些封装了发送和接收电子邮件工作的库。我们更愿意集中精力设计专门用于存储和检索电子邮件的机制。电子邮件客户端用户应该能够查看收件箱部分中的电子邮件列表。我们还应该考虑用户可能想要对电子邮件执行的操作。他们可以逐个删除电子邮件,也可以一次删除多封。他们可以选择任意选定的电子邮件并回复给发件人或将电子邮件转发给其他人。

我们在第十章中讨论了软件设计过程和最佳实践,设计真实世界应用程序。现在,让我们草拟一个描述电子邮件对象的简单结构,如下所示:

struct Email
{
  std::string subject;
  std::string body;
  std::string from;
  std::chrono::time_point datetime;
};

我们应该关心的第一件事是将电子邮件集合存储在一个易于访问的结构中。数组听起来可能不错。假设我们将所有收到的电子邮件存储在一个数组中,如下面的代码块所示:

// let's suppose a million emails is the max for anyone
const int MAX_EMAILS = 1'000'000; 
Email inbox[MAX_EMAILS];

我们可以以任何形式存储 10 封电子邮件-这不会影响应用程序的性能。然而,显而易见的是,随着时间的推移,电子邮件的数量将增加。对于每封新收到的电子邮件,我们将Email对象与相应的字段推送到inbox数组中。数组的最后一个元素表示最近收到的电子邮件。因此,要显示最近的十封电子邮件列表,我们需要读取并返回数组的最后十个元素。

当我们尝试操作存储在inbox数组中的成千上万封电子邮件时,问题就出现了。如果我们想在所有电子邮件中搜索单词friend,我们必须扫描数组中的所有电子邮件,并将包含单词friend的电子邮件收集到一个单独的数组中。看看下面的伪代码:

std::vector<Email> search(const std::string& word) {
  std::vector<Email> search_results;  
  for (all-million-emails) {
    if (inbox[i].subject.contains(word)) {
      search_results.push_back(inbox[i]);
    }
  }
  return search_results;
}

使用数组存储所有数据对于小集合来说已经足够了。在处理更大的数据集的真实世界应用程序中,情况会发生巨大变化。使用特定的数据结构的目的是使应用程序运行更加流畅。前面的例子展示了一个简单的问题:在电子邮件列表中搜索匹配特定值。在一封电子邮件中找到该值需要合理的时间。

如果我们假设电子邮件的主题字段可能包含多达十个单词,那么在电子邮件主题中搜索特定单词需要将该单词与主题中的所有单词进行比较。在最坏的情况下,没有匹配。我们强调最坏的情况,因为只有在查找需要检查主题中的每个单词时才会出现这种情况。对成千上万甚至数十万封电子邮件做同样的操作将使用户等待时间过长。

选择适合特定问题的数据结构对于应用程序的效率至关重要。例如,假设我们使用哈希表将单词映射到电子邮件对象。每个单词将被映射到包含该单词的电子邮件对象列表。这种方法将提高搜索操作的效率,如下图所示:

search()函数将返回哈希表键引用的列表:

std::vector<Email> search(const std::string& word) {
  return table[word];
}

这种方法只需要处理每封接收到的电子邮件,将其拆分为单词并更新哈希表。

为了简单起见,我们使用Email对象作为值而不是引用。请注意,最好将指针存储在向量中指向Email

现在让我们来看看不同的数据结构及其应用。

顺序数据结构

开发人员最常用的数据结构之一是动态增长的一维数组,通常称为向量。STL 提供了一个同名的容器:std::vector。向量背后的关键思想是它包含相同类型的项目按顺序放置在内存中。例如,由 4 字节整数组成的向量将具有以下内存布局。向量的索引位于以下图表的右侧:

向量的物理结构允许实时访问其任何元素。

我们应该根据容器的操作来区分它们,以便在特定问题中正确应用它们。为此,我们通常定义容器中的操作与容器中元素数量的运行时间复杂度的关系。例如,向量的元素访问被定义为常数时间操作,这意味着获取向量项需要相同数量的指令,无论向量长度如何。

访问向量的第一个元素和访问向量的第 100 个元素需要相同的工作量,因此我们称之为常数时间操作,也称为O(1)操作

虽然向量中的元素访问速度很快,但添加新元素有些棘手。每当我们在向量的末尾插入新项时,我们还应该考虑向量的容量。当没有为向量分配更多空间时,它应该动态增长。看一下下面的Vector类及其push_back()函数:

template <typename T>
class Vector
{
public:
  Vector() : buffer_{nullptr}, capacity_{2}, size_{0}
  {
    buffer_ = new T[capacity_]; // initializing an empty array
  }
  ~Vector() { delete [] buffer_; }
  // code omitted for brevity

public:
  void push_back(const T& item)
 {
 if (size_ == capacity_) {
 // resize
 }
 buffer_[size_++] = item;
 }
  // code omitted for brevity
};

在深入实现push_back()函数之前,让我们看一下下面的图表:

我们应该分配一个全新的数组,将旧数组的所有元素复制到新数组中,然后将新插入的元素添加到新数组末尾的下一个空闲槽中。这在下面的代码片段中显示:

template <typename T>
class Vector
{
public:
  // code omitted for brevity
  void push_back(const T& item)
  {
    if (size_ == capacity_) {
 capacity_ *= 2; // increase the capacity of the vector twice
 T* temp_buffer = new T[capacity_];
      // copy elements of the old into the new
 for (int ix = 0; ix < size_; ++ix) {
 temp_buffer[ix] = buffer_[ix];
 }
 delete [] buffer_; // free the old array
 buffer_ = temp_buffer; // point the buffer_ to the new array
 }
    buffer_[size_++] = item;
  }
  // code omitted for brevity
};

调整因子可以选择不同 - 我们将其设置为2,这样每当向量满时,向量的大小就会增长两倍。因此,我们可以坚持认为,大多数情况下,在向量的末尾插入新项需要常数时间。它只是在空闲槽中添加项目并增加其private size_变量。不时地,添加新元素将需要分配一个新的、更大的向量,并将旧的向量复制到新的向量中。对于这样的情况,该操作被称为摊销常数时间完成。

当我们在向量的前面添加元素时,情况就不一样了。问题在于,所有其他元素都应该向右移动一个位置,以便为新元素腾出一个位置,如下图所示:

这是我们在Vector类中如何实现它的方式:

// code omitted for brevity
void push_front(const T& item)
{
  if (size_ == capacity_) {
    // resizing code omitted for brevity
  }
  // shifting all the elements to the right
 for (int ix = size_ - 1; ix > 0; --ix) {
 buffer_[ix] = buffer[ix - 1];
 }
  // adding item at the front buffer_[0] = item;
  size_++;
}

在需要仅在容器的前面插入新元素的情况下,选择向量并不是一个好的选择。这是其他容器应该被考虑的例子之一。

基于节点的数据结构

基于节点的数据结构不占用连续的内存块。基于节点的数据结构为其元素分配节点,没有任何顺序 - 它们可能随机分布在内存中。我们将每个项目表示为链接到其他节点的节点。

最流行和最基础的基于节点的数据结构是链表。下图显示了双向链表的可视结构:

链表与向量非常不同。它的一些操作速度更快,尽管它缺乏向量的紧凑性。

为了简洁起见,让我们在列表的前面实现元素插入。我们将每个节点都保留为一个结构:

template <typename T>
struct node 
{
  node(const T& it) : item{it}, next{nullptr}, prev{nullptr} {}
  T item;
  node<T>* next;
  node<T>* prev;
};

注意next成员 - 它指向相同的结构,这样可以允许节点链接在一起,如前面的插图所示。

要实现一个链表,我们只需要保留指向其第一个节点的指针,通常称为链表的头。在列表的前面插入元素很简单:

template <typename T>
class LinkedList 
{
  // code omitted for brevity
public:
  void push_front(const T& item) 
 {
 node<T>* new_node = new node<T>{item};
 if (head_ != nullptr) {
 new_node->next = head_->next;
 if (head_->next != nullptr) {
 head_->next->prev = new_node;
 }
 }
 new_node->next = head_;
 head_ = new_node;
 }
private:
  node<T>* head_; 
};

在向列表中插入元素时,我们应该考虑三种情况:

  • 如前所述,在列表前面插入元素需要以下步骤:

  • 在列表末尾插入元素如下图所示:

  • 最后,在列表中间插入元素的操作如下所示:

在前面的图中,向向量插入元素显然与向列表插入元素不同。您将如何在向量和列表之间进行选择?您应该专注于操作及其速度。例如,从向量中读取任何元素都需要恒定的时间。我们可以在向量中存储一百万封电子邮件,并在不需要任何额外工作的情况下检索位置为 834,000 的电子邮件。对于链表,操作是线性的。因此,如果您需要存储的数据集大部分是读取而不是写入,那么显然使用向量是一个合理的选择。

在列表中的任何位置插入元素都是一个常量时间的操作,而向量会努力在随机位置插入元素。因此,当您需要一个可以频繁添加/删除数据的对象集合时,更好的选择将是链表。

我们还应该考虑缓存内存。向量具有良好的数据局部性。读取向量的第一个元素涉及将前N个元素复制到缓存中。进一步读取向量元素将更快。我们不能说链表也是如此。要找出原因,让我们继续比较向量和链表的内存布局。

内存中的容器

正如您从前几章已经知道的那样,对象占用内存空间在进程提供的内存段之一上。大多数情况下,我们对堆栈或堆内存感兴趣。自动对象占用堆栈上的空间。以下两个声明都驻留在堆栈上:

struct Email 
{
  // code omitted for brevity
};

int main() {
  Email obj;
  Email* ptr;
}

尽管ptr表示指向Email对象的指针,但它占用堆栈上的空间。它可以指向在堆上分配的内存位置,但指针本身(存储内存位置地址的变量)驻留在堆栈上。在继续使用向量和列表之前,这一点是至关重要的。

正如我们在本章前面看到的,实现向量涉及封装指向表示指定类型的元素数组的内部缓冲区的指针。当我们声明一个Vector对象时,它需要足够的堆栈内存来存储其成员数据。Vector类有以下三个成员:

template <typename T>
class Vector
{
public:
  // code omitted for brevity

private:
  int capacity_;
  int size_;
  T* buffer_;
};

假设整数占用 4 个字节,指针占用 8 个字节,那么以下Vector对象声明将至少占用 16 个字节的堆栈内存:

int main()
{
  Vector<int> v;
}

这是我们对前面代码的内存布局的想象:

插入元素后,堆栈上的向量大小将保持不变。堆出现了。buffer_数组指向使用new[]运算符分配的内存位置。例如,看看以下代码:

// we continue the code from previous listing
v.push_back(17);
v.push_back(21);
v.push_back(74);

我们推送到向量的每个新元素都将占用堆上的空间,如下图所示:

每个新插入的元素都驻留在buffer_数组的最后一个元素之后。这就是为什么我们可以说向量是一个友好的缓存容器。

声明链表对象也会为其数据成员占用堆栈上的内存空间。如果我们讨论的是仅存储head_指针的简单实现,那么以下链表对象声明将至少占用 8 个字节的内存(仅用于head_指针):

int main()
{
  LinkedList<int> list;
}

以下插图描述了前面代码的内存布局:

插入新元素会在堆上创建一个node类型的对象。看看以下行:

list.push_back(19);

在插入新元素后,内存插图将如下所示改变:

要注意的是,节点及其所有数据成员都驻留在堆上。该项存储我们插入的值。当我们插入另一个元素时,将再次创建一个新节点。这次,第一个节点的下一个指针将指向新插入的元素。而新插入的节点的 prev 指针将指向列表的前一个节点。下图描述了在插入第二个元素后链表的内存布局:

当我们在向列表中插入元素之间在堆上分配一些随机对象时,会发生有趣的事情。例如,以下代码将一个节点插入列表,然后为一个整数(与列表无关)分配空间。最后,再次向列表中插入一个元素:

int main()
{
  LinkedList<int> list;
  list.push_back(19);
  int* random = new int(129);
  list.push_back(22);
}

这个中间的随机对象声明破坏了列表元素的顺序,如下图所示:

前面的图表提示我们,列表不是一个友好的缓存容器,因为它的结构和其元素的分配。

注意通过将每个新节点合并到代码中所创建的内存开销。我们为一个元素额外支付 16 个字节(考虑到指针占用 8 个字节的内存)。因此,列表在最佳内存使用方面输给了向量。

我们可以尝试通过在列表中引入预分配的缓冲区来解决这个问题。然后每个新节点的创建将通过placement new操作符进行。然而,更明智的选择是选择更适合感兴趣问题的数据结构。

在实际应用程序开发中,程序员很少实现自己的向量或链表。他们通常使用经过测试和稳定的库版本。C++为向量和链表提供了标准容器。此外,它为单链表和双链表提供了两个单独的容器。

STL 容器

STL 是一个强大的算法和容器集合。虽然理解和实现数据结构是程序员的一项重要技能,但你不必每次在项目中需要时都要实现它们。库提供者负责为我们实现稳定和经过测试的数据结构和算法。通过理解数据结构和算法的内部细节,我们在解决问题时能够更好地选择 STL 容器和算法。

先前讨论的向量和链表在 STL 中分别实现为std::vector<T>std::list<T>,其中T是集合中每个元素的类型。除了类型,容器还以分配器作为第二个默认template参数。例如,std::vector声明如下:

template <typename T, typename Allocator = std::allocator<T> >
class vector;

在上一章中介绍过,分配器处理容器元素的高效分配/释放。std::allocator 是 STL 中所有标准容器的默认分配器。一个更复杂的分配器,根据内存资源的不同而表现不同,是std::pmr::polymorphic_allocator。STL 提供了std::pmr::vector作为使用多态分配器的别名模板,定义如下:

namespace pmr {
  template <typename T>
  using vector = std::vector<T, std::pmr::polymorphic_allocator<T>>;
}

现在让我们更仔细地看看std::vectorstd::list

使用 std::vector 和 std::list

std::vector<vector>头文件中定义。以下是最简单的使用示例:

#include <vector>

int main()
{
  std::vector<int> vec;
  vec.push_back(4);
  vec.push_back(2);
  for (const auto& elem : vec) {
    std::cout << elem;
  }
}

std::vector是动态增长的。我们应该考虑增长因子。在声明一个向量时,它有一些默认容量,然后在插入元素时会增长。每当元素的数量超过向量的容量时,它会以给定的因子增加其容量(通常情况下,它会将其容量加倍)。如果我们知道我们将需要的向量中元素的大致数量,我们可以通过使用reserve()方法来为向量最初分配该容量来优化其使用。例如,以下代码保留了一个包含 10,000 个元素的容量:

std::vector<int> vec;
vec.reserve(10000);

它强制向量为 10,000 个元素分配空间,从而避免在插入元素时进行调整大小(除非达到 10,000 个元素的阈值)。

另一方面,如果我们遇到容量远大于向量中实际元素数量的情况,我们可以缩小向量以释放未使用的内存。我们需要调用shrink_to_fit()函数,如下例所示:

vec.shrink_to_fit();

这减少了容量以适应向量的大小。

访问向量元素的方式与访问常规数组的方式相同,使用operator[]。然而,std::vector提供了两种访问其元素的选项。其中一种被认为是安全的方法,通过at()函数进行,如下所示:

std::cout << vec.at(2);
// is the same as
std::cout << vec[2];
// which is the same as
std::cout << vec.data()[2];

at()operator[]之间的区别在于,at()通过边界检查访问指定的元素;也就是说,以下行会抛出std::out_of_range异常:

try {
  vec.at(999999);
} catch (std::out_of_range& e) { }

我们几乎以相同的方式使用std::list。这些列表大多有相似的公共接口。在本章后面,我们将讨论迭代器,允许从特定容器中抽象出来,这样我们可以用一个向量替换一个列表而几乎没有任何惩罚。在此之前,让我们看看列表和向量的公共接口之间的区别。

除了两个容器都支持的标准函数集,如size()resize()empty()clear()erase()等,列表还有push_front()函数,它在列表的前面插入一个元素。这样做是有效的,因为std::list表示一个双向链表。如下所示,std::list也支持push_back()

std::list<double> lst;
lst.push_back(4.2);
lst.push_front(3.14);
// the list contains: "3.14 -> 4.2"

列表支持许多在许多情况下非常有用的附加操作。例如,要合并两个排序列表,我们使用merge()方法。它接受另一个列表作为参数,并将其所有元素移动到当前列表。传递给merge()方法的列表在操作后变为空。

STL 还提供了一个单向链表,由std::forward_list表示。要使用它,应该包含<forward_list>头文件。由于单向链表节点只有一个指针,所以在内存方面比双向链表更便宜。

splice()方法与merge()有些相似,不同之处在于它移动作为参数提供的列表的一部分。所谓移动,是指重新指向内部指针以指向正确的列表节点。这对于merge()splice()都是成立的。

当我们使用容器存储和操作复杂对象时,复制元素的代价在程序性能中起着重要作用。考虑以下表示三维点的结构体:

struct Point
{
  float x;
  float y;
  float z;

  Point(float px, float py, float pz)
    : x(px), y(py), z(pz)
  {}

  Point(Point&& p)
    : x(p.x), y(p.y), z(p.z)
  {}
};

现在,看看以下代码,它将一个Point对象插入到一个向量中:

std::vector<Point> points;
points.push_back(Point(1.1, 2.2, 3.3));

首先构造一个临时对象,然后将其移动到向量的相应插槽中。我们可以用以下方式进行可视化表示:

显然,向量事先占用更多空间,以尽可能延迟调整大小操作。当我们插入一个新元素时,向量将其复制到下一个可用插槽(如果已满,则重新分配更多空间)。我们可以利用该未初始化空间来创建一个新元素。向量提供了emplace_back()函数来实现这一目的。以下是我们如何使用它:

points.emplace_back(1.1, 2.2, 3.3);

注意我们直接传递给函数的参数。以下插图描述了emplace_back()的使用:

emplace_back()通过std::allocator_traits::construct()构造元素。后者通常使用新操作符的放置来在已分配但未初始化的空间中构造元素。

std::list还提供了一个emplace_front()方法。这两个函数都返回插入的元素的引用。唯一的要求是元素的类型必须是EmplaceConstructible。对于向量,类型还应该是MoveInsertable

使用容器适配器

你可能已经遇到了关于堆栈和队列的描述,它们被称为数据结构(或者在 C++术语中称为容器)。从技术上讲,它们不是数据结构,而是数据结构适配器。在 STL 中,std::stackstd::queue通过提供特殊的接口来访问容器来适配容器。术语堆栈几乎无处不在。到目前为止,我们已经用它来描述具有自动存储期限的对象的内存段。该段采用堆栈的名称,因为它的分配/释放策略。

我们说每次声明对象时,对象都会被推送到堆栈上,并在销毁时弹出。对象以它们被推送的相反顺序弹出。这就是称内存段为堆栈的原因。相同的后进先出LIFO)方法适用于堆栈适配器。std::stack提供的关键函数如下:

void push(const value_type& value);
void push(value_type&& value);

push()函数有效地调用基础容器的push_back()。通常,堆栈是使用向量实现的。我们已经在第三章中讨论过这样的情况,面向对象编程的细节,当我们介绍了受保护的继承。std::stack有两个模板参数;其中一个是容器。你选择什么并不重要,但它必须有一个push_back()成员函数。std::stackstd::queue的默认容器是std::deque

std::deque允许在其开头和结尾快速插入。它是一个类似于std::vector的索引顺序容器。deque 的名称代表双端队列

让我们看看堆栈的运行情况:

#include <stack>

int main()
{
  std::stack<int> st;
  st.push(1); // stack contains: 1
  st.push(2); // stack contains: 2 1
  st.push(3); // stack contains: 3 2 1
}

push()函数的一个更好的替代方法是emplace()。它调用基础容器的emplace_back(),因此在原地构造元素。

要取出元素,我们调用pop()函数。它不接受任何参数,也不返回任何内容,只是从堆栈中移除顶部元素。要访问堆栈的顶部元素,我们调用top()函数。让我们修改前面的示例,在弹出元素之前打印所有堆栈元素:

#include <stack>

int main()
{
  std::stack<int> st;
  st.push(1);
  st.push(2);
  st.push(3);
  std::cout << st.top(); // prints 3
  st.pop();
  std::cout << st.top(); // prints 2
  st.pop();
  std::cout << st.top(); // prints 1
  st.pop();
  std::cout << st.top(); // crashes application
}

top()函数返回对顶部元素的引用。它调用基础容器的back()函数。在空堆栈上调用top()函数时要注意。我们建议在对空堆栈调用top()之前检查堆栈的大小使用size()

queue是另一个适配器,其行为与堆栈略有不同。队列背后的逻辑是它首先返回插入的第一个元素:它遵循先进先出FIFO)原则。看下面的图表:

队列中插入和检索操作的正式名称是enqeuedequeuestd::queue保持一致的方法,并提供push()pop()函数。要访问队列的第一个和最后一个元素,应该使用front()back()。两者都返回元素的引用。这里是一个简单的使用示例:

#include <queue>

int main()
{
 std::queue<char> q;
  q.push('a');
  q.push('b');
  q.push('c');
  std::cout << q.front(); // prints 'a'
  std::cout << q.back(); // prints 'c'
  q.pop();
  std::cout << q.front(); // prints 'b'
}

当你正确应用它们时,了解各种容器和适配器是有用的。在选择所有类型问题的正确容器时,并没有银弹。许多编译器使用堆栈来解析代码表达式。例如,使用堆栈很容易验证以下表达式中的括号:

int r = (a + b) + (((x * y) - (a / b)) / 4);

尝试练习一下。编写一个小程序,使用堆栈验证前面的表达式。

队列的应用更加广泛。我们将在第十一章中看到其中之一,使用设计模式设计策略游戏,在那里我们设计了一个策略游戏。

另一个容器适配器是std::priority_queue。优先队列通常适配平衡的、基于节点的数据结构,例如最大堆或最小堆。我们将在本章末尾讨论树和图,并看看优先队列在内部是如何工作的。

迭代容器

一个不可迭代的容器的概念就像一辆无法驾驶的汽车一样。毕竟,容器是物品的集合。迭代容器元素的常见方法之一是使用普通的for循环:

std::vector<int> vec{1, 2, 3, 4, 5};
for (int ix = 0; ix < vec.size(); ++ix) {
  std::cout << vec[ix];
}

容器提供了一组不同的元素访问操作。例如,向量提供了operator[],而列表则没有。std::listfront()back()方法,分别返回第一个和最后一个元素。另外,正如前面讨论的,std::vector还提供了at()operator[]

这意味着我们不能使用前面的循环来迭代列表元素。但我们可以使用基于范围的for循环来遍历列表(和向量),如下所示:

std::list<double> lst{1.1, 2.2, 3.3, 4.2};
for (auto& elem : lst) {
  std::cout << elem;
} 

这可能看起来令人困惑,但诀窍隐藏在基于范围的for实现中。它使用std::begin()函数检索指向容器第一个元素的迭代器。

迭代器是指向容器元素的对象,并且可以根据容器的物理结构前进到下一个元素。以下代码声明了一个vector迭代器,并用指向vector开头的迭代器进行初始化:

std::vector<int> vec{1, 2, 3, 4};
std::vector<int>::iterator it{vec.begin()};

容器提供两个成员函数begin()end(),分别返回指向容器开头和结尾的迭代器。以下图表显示了我们如何处理容器的开头和结尾:

使用基于范围的for迭代列表元素的先前代码可以被视为以下内容:

auto it_begin = std::begin(lst);
auto it_end = std::end(lst);
for ( ; it_begin != it_end; ++it_begin) {
  std::cout << *it_begin;
}

注意我们在先前代码中使用的*运算符,通过迭代器访问底层元素。我们认为迭代器是对容器元素的巧妙指针。

std::begin()std::end()函数通常调用容器的begin()end()方法,但它们也适用于常规数组。

容器迭代器确切地知道如何处理容器元素。例如,向前推进向量迭代器会将其移动到数组的下一个槽位,而向前推进列表迭代器会使用相应的指针将其移动到下一个节点,如下面的代码所示:

std::vector<int> vec;
vec.push_back(4);
vec.push_back(2);
std::vector<int>::iterator it = vec.begin();
std::cout << *it; // 4
it++;
std::cout << *it; // 2

std::list<int> lst;
lst.push_back(4);
lst.push_back(2);
std::list<int>::iterator lit = lst.begin();
std::cout << *lit; // 4
lit++;
std::cout << *lit; // 2

每个容器都有自己的迭代器实现;这就是为什么列表和向量迭代器有相同的接口但行为不同。迭代器的行为由其类别定义。例如,向量的迭代器是随机访问迭代器,这意味着我们可以使用迭代器随机访问任何元素。以下代码通过向量的迭代器访问第四个元素,方法是将3添加到迭代器上:

auto it = vec.begin();
std::cout << *(it + 3);

STL 中有六种迭代器类别:

  • 输入

  • 输出(与输入相同,但支持写访问)

  • 前向

  • 双向

  • 随机访问

  • 连续

输入迭代器提供读取访问(通过调用*运算符)并使用前缀和后缀递增运算符向前推进迭代器位置。输入迭代器不支持多次遍历,也就是说,我们只能使用迭代器对容器进行一次遍历。另一方面,前向迭代器支持多次遍历。多次遍历支持意味着我们可以通过迭代器多次读取元素的值。

输出迭代器不提供对元素的访问,但它允许为其分配新值。具有多次遍历特性的输入迭代器和输出迭代器的组合构成了前向迭代器。然而,前向迭代器仅支持递增操作,而双向迭代器支持将迭代器移动到任何位置。它们支持递减操作。例如,std::list支持双向迭代器。

最后,随机访问迭代器允许通过向迭代器添加/减去一个数字来跳跃元素。迭代器将跳转到由算术操作指定的位置。std::vector提供了随机访问迭代器。

每个类别都定义了可以应用于迭代器的操作集。例如,输入迭代器可用于读取元素的值并通过递增迭代器前进到下一个元素。另一方面,随机访问迭代器允许以任意值递增和递减迭代器,读取和写入元素的值等。

到目前为止在本节中描述的所有特性的组合都属于连续迭代器类别,它也期望容器是一个连续的。这意味着容器元素保证紧邻在一起。std::array就是一个连续的容器的例子。

诸如distance()的函数使用迭代器的信息来实现最快的执行结果。例如,两个双向迭代器之间的distance()函数需要线性执行时间,而随机访问迭代器的相同函数在常数时间内运行。

以下伪代码演示了一个示例实现:

template <typename Iter>
std::size_type distance(Iter first, Iter second) {
  if (Iter is a random_access_iterator) {
    return second - first; 
  }
  std::size_type count = 0;
  for ( ; first != last; ++count, first++) {}
  return count;
}

尽管前面示例中显示的伪代码运行良好,但我们应该考虑在运行时检查迭代器的类别不是一个选项。它是在编译时定义的,因此我们需要使用模板特化来生成随机访问迭代器的distance()函数。更好的解决方案是使用<type_traits>中定义的std::is_same类型特征:

#include <iterator>
#include <type_traits>

template <typename Iter>
typename std::iterator_traits<Iter>::difference_type distance(Iter first, Iter last)
{
  using category = std::iterator_traits<Iter>::iterator_category;
  if constexpr (std::is_same_v<category, std::random_access_iterator_tag>) {
    return last - first;
  }
  typename std::iterator_traits<Iter>::difference_type count;
  for (; first != last; ++count, first++) {}
  return count;
}

std::is_same_vstd::is_same的辅助模板,定义如下:

template <class T, class U>
inline constexpr bool is_same_v = is_same<T, U>::value;

迭代器最重要的特性是提供了容器和算法之间的松耦合:

STL 基于这三个概念:容器、算法和迭代器。虽然向量、列表或任何其他容器都不同,它们都有相同的目的:存储数据。

另一方面,算法是处理数据的函数;它们大部分时间都与数据集合一起工作。算法定义通常代表了指定应采取哪些步骤来处理容器元素的通用方式。例如,排序算法按升序或降序对容器元素进行排序。

向量是连续的容器,而列表是基于节点的容器。对它们进行排序将需要更深入地了解特定容器的物理结构。为了正确地对向量进行排序,应该为它实现一个单独的排序函数。相同的逻辑也适用于列表。

迭代器将这种多样性的实现提升到了一个通用级别。它们为库设计者提供了实现只需处理迭代器的排序函数的能力,抽象出容器类型。在 STL 中,sort()算法(在<algorithm>中定义)处理迭代器,我们可以使用相同的函数对向量和列表进行排序:

#include <algorithm>
#include <vector>
#include <list>
...
std::vector<int> vec;
// insert elements into the vector
std::list<int> lst;
// insert elements into the list

std::sort(vec.begin(), vec.end());
std::sort(lst.begin(), lst.end());

本节中描述的迭代器现在被认为是遗留特性。C++20 引入了基于概念的新迭代器系统。

概念和迭代器

C++20 将概念作为其主要特性之一引入。除了概念,C++20 还有基于概念的新迭代器。尽管本章讨论的迭代器现在被认为是遗留特性,但已经有大量的代码使用它们。这就是为什么我们在继续介绍新的迭代器概念之前首先介绍它们的原因。现在,让我们了解一下概念是什么,以及如何使用它们。

理解概念

抽象在计算机编程中是至关重要的。我们在第三章中引入了类,面向对象编程的细节,作为一种将数据和操作表示为抽象实体的方式。之后,在第四章中,理解和设计模板,我们深入研究了模板,并看到如何通过重用它们来使类变得更加灵活,以适用于各种聚合类型。模板不仅提供了对特定类型的抽象,还实现了实体和聚合类型之间的松耦合。例如,std::vector。它提供了一个通用接口来存储和操作对象的集合。我们可以轻松地声明三个包含三种不同类型对象的不同向量,如下所示:

std::vector<int> ivec;
std::vector<Person> persons;
std::vector<std::vector<double>> float_matrix;

如果没有模板,我们将不得不对前面的代码做如下处理:

std::int_vector ivec;
std::custom_vector persons; // supposing the custom_vector stores void* 
std::double_vector_vector float_matrix;

尽管前面的代码是不可接受的,但我们应该同意模板是泛型编程的基础。概念为泛型编程引入了更多的灵活性。现在可以对模板参数设置限制,检查约束,并在编译时发现不一致的行为。模板类声明的形式如下:

template <typename T>
class Wallet
{
  // the body of the class using the T type
};

请注意前面代码块中的typename关键字。概念甚至更进一步:它们允许用描述模板参数的类型描述来替换它。假设我们希望Wallet能够处理可以相加的类型,也就是说,它们应该是可加的。以下是如何使用概念来帮助我们在代码中实现这一点:

template <addable T>
class Wallet
{
  // the body of the class using addable T's
};

因此,现在我们可以通过提供可相加的类型来创建Wallet实例。每当类型不满足约束时,编译器将抛出错误。这看起来有点超自然。以下代码片段声明了两个Wallet对象:

class Book 
{
  // doesn't have an operator+
  // the body is omitted for brevity
};

constexpr bool operator+(const Money& a, const Money& b) { 
  return Money{a.value_ + b.value_}; 
}

class Money
{
  friend constexpr bool operator+(const Money&, const Money&);
  // code omitted for brevity
private:
  double value_;
};

Wallet<Money> w; // works fine
Wallet<Book> g; // compile error

Book类没有+运算符,因此由于template参数类型限制,g的构造将失败。

使用concept关键字来声明概念,形式如下:

template <*parameter-list*>
concept *name-of-the-concept* = *constraint-expression*;

正如你所看到的,概念也是使用模板来声明的。我们可以将它们称为描述其他类型的类型。概念在约束上有很大的依赖。约束是指定模板参数要求的一种方式,因此概念是一组约束。以下是我们如何实现前面的可加概念:

template <typename T>
concept addable = requires (T obj) { obj + obj; }

标准概念在<concepts>头文件中定义。

我们还可以通过要求新概念支持其他概念来将几个概念合并为一个。为了实现这一点,我们使用&&运算符。让我们看看迭代器如何利用概念,并举例说明一个将其他概念结合在一起的incrementable迭代器概念。

在 C++20 中使用迭代器

在介绍概念之后,显而易见的是迭代器是首先充分利用它们的。迭代器及其类别现在被认为是遗留的,因为从 C++20 开始,我们使用迭代器概念,如readable(指定类型可通过应用*运算符进行读取)和writable(指定可以向迭代器引用的对象写入值)。正如承诺的那样,让我们看看incrementable<iterator>头文件中是如何定义的:

template <typename T>
concept incrementable = std::regular<T> && std::weakly_incrementable<T>
            && requires (T t) { {t++} -> std::same_as<T>; };

因此,可递增的概念要求类型为 std::regular。这意味着它应该可以通过默认方式构造,并且具有复制构造函数和 operator==()。除此之外,可递增的概念要求类型为 weakly_incrementable,这意味着该类型支持前置和后置递增运算符,除了不需要该类型是可比较相等的。这就是为什么可递增加入 std::regular 要求类型是可比较相等的。最后,附加的 requires 约束指出类型在递增后不应更改,也就是说,它应该与之前的类型相同。尽管 std::same_as 被表示为一个概念(在中定义),在以前的版本中我们使用的是在<type_traits>中定义的 std::is_same。它们基本上做同样的事情,但是 C++17 版本的 std::is_same_v 很啰嗦,带有额外的后缀。

因此,现在我们不再提到迭代器类别,而是提到迭代器概念。除了我们之前介绍的概念,还应该考虑以下概念:

  • 输入迭代器指定该类型允许读取其引用值,并且可以进行前置和后置递增。

  • 输出迭代器指定该类型的值可以被写入,并且该类型可以进行前置和后置递增。

  • 输入或输出迭代器,除了名称过长之外,指定该类型是可递增的,并且可以被解引用。

  • 前向迭代器指定该类型是一个输入迭代器,此外还支持相等比较和多遍历。

  • 双向迭代器指定该类型支持前向迭代器,并且还支持向后移动。

  • 随机访问迭代器指定该类型为双向迭代器,支持常数时间的前进和下标访问。

  • 连续迭代器指定该类型是一个随机访问迭代器,指的是内存中连续的元素。

它们几乎重复了我们之前讨论的传统迭代器,但现在它们可以在声明模板参数时使用,这样编译器将处理其余部分。

掌握算法

正如前面提到的,算法是接受一些输入,处理它,并返回输出的函数。通常,在 STL 的上下文中,算法意味着处理数据集合的函数。数据集合以容器的形式呈现,例如 std::vector、std::list 等。

选择高效的算法是程序员日常工作中的常见任务。例如,使用二分搜索算法搜索排序后的向量将比使用顺序搜索更有效。为了比较算法的效率,进行所谓的渐近分析,考虑算法速度与输入数据大小的关系。这意味着我们实际上不应该将两个算法应用于一个包含十个或一百个元素的容器进行比较。

算法的实际差异在应用于足够大的容器时才会显现,比如有一百万甚至十亿条记录的容器。衡量算法的效率也被称为验证其复杂性。您可能遇到过 O(n)算法或 O(log N)算法。O()函数(读作 big-oh)定义了算法的复杂性。

让我们来看看搜索算法,并比较它们的复杂性。

搜索

在容器中搜索元素是一个常见的任务。让我们实现在向量中进行顺序搜索元素。

template <typename T>
int search(const std::vector<T>& vec, const T& item)
{
  for (int ix = 0; ix < vec.size(); ++ix) {
    if (vec[ix] == item) {
      return ix;
    }
  }
  return -1; // not found
}

这是一个简单的算法,它遍历向量并返回元素等于作为搜索键传递的值的索引。我们称之为顺序搜索,因为它按顺序扫描向量元素。它的复杂性是线性的:O(n)。为了衡量它,我们应该以某种方式定义算法找到结果所需的操作数。假设向量包含 n 个元素,下面的代码在搜索函数的每一行都有关于其操作的注释:

template <typename T>
int search(const std::vector<T>& vec, const T& item)
{
  for (int ix = 0;           // 1 copy
       ix < vec.size;        // n + 1 comparisons 
       ++ix)                 // n + 1 increments
  {  
    if (vec[ix] == item) {   // n comparisons
      return ix;             // 1 copy
    }
  }
  return -1;                 // 1 copy
}

我们有三种复制操作,n + 1n(也就是 2n + 1)次比较,以及 n + 1 次增量操作。如果所需元素在向量的第一个位置怎么办?那么,我们只需要扫描向量的第一个元素并从函数中返回。

然而,这并不意味着我们的算法非常高效,只需要一步就能完成任务。为了衡量算法的复杂性,我们应该考虑最坏情况:所需元素要么不存在于向量中,要么位于向量的最后位置。下图显示了我们即将找到的元素的三种情况:

我们只需要考虑最坏情况,因为它也涵盖了所有其他情况。如果我们为最坏情况定义算法的复杂性,我们可以确保它永远不会比那更慢。

为了找出算法的复杂性,我们应该找到操作次数和输入大小之间的关系。在这种情况下,输入的大小是容器的长度。让我们将复制记为 A,比较记为 C,增量操作记为 I,这样我们就有 3A + (2n + 1)C + (n + 1)I 次操作。算法的复杂性将定义如下:

O(3A + (2n + 1)C + (n + 1)I)

这可以以以下方式简化:

  • O(3A + (2n + 1)C + (n + 1)I) =

  • *O(3A + 2nC + C + nI + I) = *

  • *O(n(2C + I) + (3A + C + I)) = *

  • O(n(2C + I))

最后,O()的属性使我们可以摆脱常数系数和较小的成员,因为实际算法的复杂性只与输入的大小有关,即 n,我们得到最终复杂性等于 O(n)。换句话说,顺序搜索算法具有线性时间复杂性。

正如前面提到的,STL 的本质是通过迭代器连接容器和算法。这就是为什么顺序搜索实现不被认为是 STL 兼容的:因为它对输入参数有严格的限制。为了使其通用,我们应该考虑仅使用迭代器来实现它。为了涵盖各种容器类型,使用前向迭代器。下面的代码使用了Iter类型的操作符,假设它是一个前向迭代器:

template <typename Iter, typename T>
int search(Iter first, Iter last, const T& elem)
{
  for (std::size_t count = 0; first != last; first++, ++count) {
    if (*first == elem) return count;
  }
  return -1;
}
...
std::vector<int> vec{4, 5, 6, 7, 8};
std::list<double> lst{1.1, 2.2, 3.3, 4.4};

std::cout << search(vec.begin(), vec.end(), 5);
std::cout << search(lst.begin(), lst.end(), 5.5);

实际上,任何类型的迭代器都可以传递给search()函数。我们通过对迭代器本身应用操作来确保使用前向迭代器。我们只使用增量(向前移动)、读取(*运算符)和严格比较(==!=),这些操作都受前向迭代器支持。

二分搜索

另一方面是二分搜索算法,这个算法很容易解释。首先,它查找向量的中间元素并将搜索键与之进行比较,如果相等,算法就结束了:它返回索引。否则,如果搜索键小于中间元素,算法继续向向量的左侧进行。如果搜索键大于中间元素,算法继续向右侧子向量进行。

为了使二分搜索在向量中正确工作,它应该是排序的。二分搜索的核心是将搜索键与向量元素进行比较,并继续到左侧或右侧子向量,每个子向量都包含与向量中间元素相比较的较小或较大的元素。看一下下面的图表,它描述了二分搜索算法的执行过程:

二分搜索算法有一个优雅的递归实现(尽管最好使用迭代实现)-在下面的代码中看一下:

template <typename T>
std::size_t binsearch(const std::vector<T>& vec, const T& item, int start, int end)
{
  if (start > end) return -1;
  int mid = start + (end - start) / 2;
  if (vec[mid] == item) {
    return mid; // found
  }
  if (vec[mid] > item) {
    return binsearch(vec, item, start, mid - 1);
  }
  return binsearch(vec, item, mid + 1, end);
}

注意中间元素的计算。我们使用了start + (end - start) / 2;技术,而不是(start + end) / 2;,只是为了避免二分搜索实现中的著名错误(假设我们没有留下其他错误)。关键是对于 start 和 end 的大值,它们的和(start + end)会产生整数溢出,这将导致程序在某个时刻崩溃。

现在让我们找到二分搜索的复杂度。很明显,在执行的每一步中,源数组都会减半,这意味着我们在下一步中处理它的较小或较大的一半。这意味着最坏情况是将向量分割到只剩下一个或没有元素的情况。为了找到算法的步数,我们应该根据向量的大小找到分割的次数。如果向量有 10 个元素,那么我们将它分成一个包含五个元素的子向量;再次分割,我们得到一个包含两个元素的子向量,最后,再次分割将带我们到一个单一元素。因此,对于包含 10 个元素的向量,分割的次数是 3。对于包含n个元素的向量,分割的次数是log(n),因为在每一步中,n变为n/2,然后变为n/4,依此类推。二分搜索的复杂度是O(logn)(即对数)。

STL 算法定义在<algorithm>头文件中;二分搜索的实现也在其中。STL 实现如果元素存在于容器中则返回 true。看一下它的原型:

template <typename Iter, typename T>
bool binary_search(Iter start, Iter end, const T& elem);

STL 算法不直接与容器一起工作,而是与迭代器一起工作。这使我们能够抽象出特定的容器,并使用binary_search()来支持前向迭代器的所有容器。下面的示例调用了binary_search()函数,用于向量和列表:

#include <vector>
#include <list>
#include <algorithm>
...
std::vector<int> vec{1, 2, 3, 4, 5};
std::list<int> lst{1, 2, 3, 4};
binary_search(vec.begin(), vec.end(), 8);
binary_search(lst.begin(), lst.end(), 3);

binary_search()检查迭代器的类别,在随机访问迭代器的情况下,它使用二分搜索算法的全部功能(否则,它将退回到顺序搜索)。

排序

二分搜索算法仅适用于排序的容器。对于计算机程序员来说,排序是一个众所周知的古老任务,现在他们很少编写自己的排序算法实现。你可能多次使用了std::sort()而不关心它的实现。基本上,排序算法接受一个集合作为输入,并返回一个新的排序集合(按照算法用户定义的顺序)。

在众多的排序算法中,最流行的(甚至是最快的)是快速排序。任何排序算法的基本思想都是找到较小(或较大)的元素,并将它们与较大(或较小)的元素交换,直到整个集合排序。例如,选择排序逻辑上将集合分为两部分,已排序和未排序,其中已排序的子数组最初为空,如下所示:

算法开始在未排序的子数组中寻找最小的元素,并通过与未排序的子数组的第一个元素交换将其放入已排序的子数组中。每一步之后,已排序子数组的长度增加了一个,而未排序子数组的长度减少了,如下所示:

该过程持续进行,直到未排序的子数组变为空。

STL 提供了std::sort()函数,接受两个随机访问迭代器:

#include <vector>
#include <algorithm>
...
std::vector<int> vec{4, 7, -1, 2, 0, 5};
std::sort(vec.begin(), vec.end());
// -1, 0, 2, 4, 5, 7

sort函数不能应用于std::list,因为它不支持随机访问迭代器。相反,应该调用列表的sort()成员函数。尽管这与 STL 具有通用函数的想法相矛盾,但出于效率考虑而这样做。

sort()函数有一个第三个参数:一个比较函数,用于比较容器元素。假设我们在向量中存储Product对象:

struct Product
{
  int price;
  bool available;
  std::string title;
};

std::vector<Product> products;
products.push_back({5, false, "Product 1"});
products.push_back({12, true, "Product 2"});

为了正确排序容器,其元素必须支持小于运算符,或<。我们应该为我们的自定义类型定义相应的运算符。但是,如果我们为我们的自定义类型创建一个单独的比较函数,就可以省略运算符定义,如下面的代码块所示:

class ProductComparator
{
public:
 bool operator()(const Product& a, const Product& b) {
 return a.price > b.price;
 }
};

ProductComparator传递给std::sort()函数允许它比较向量元素,而无需深入了解其元素的类型,如下所示:

std::sort(products.begin(), products.end(), ProductComparator{});

虽然这是一个不错的技术,但更优雅的做法是使用 lambda 函数,它们是匿名函数,非常适合前面提到的场景。以下是我们如何覆盖它的方法:

std::sort(products.begin(), products.end(), 
  [](const Product& a, const Product& b) { return a.price > b.price; })

上述代码允许省略ProductComparator的声明。

探索树和图

二叉搜索算法和排序算法结合在一起,引出了默认按排序方式保持项目的容器的想法。其中一个这样的容器是基于平衡树的std::set。在讨论平衡树本身之前,让我们先看看二叉搜索树,这是一个快速查找的完美候选者。

二叉搜索树的思想是,节点的左子树的值小于节点的值。相比之下,节点的右子树的值大于节点的值。以下是一个二叉搜索树的示例:

如前面的图表所示,值为 15 的元素位于左子树中,因为它小于 30(根元素)。另一方面,值为 60 的元素位于右子树中,因为它大于根元素。相同的逻辑适用于树的其余元素。

二叉树节点表示为一个包含项目和指向每个子节点的两个指针的结构。以下是树节点的示例代码表示:

template <typename T>
struct tree_node
{
  T item;
  tree_node<T>* left;
  tree_node<T>* right;
};

在完全平衡的二叉搜索树中,搜索、插入或删除元素需要O(logn)的时间。STL 没有为树提供单独的容器,但它有基于树实现的类似容器。例如,std::set容器是基于平衡树的,可以按排序顺序唯一存储元素:

#include <set>
...
std::set<int> s{1, 5, 2, 4, 4, 4, 3};
// s has {1, 2, 3, 4, 5}

std::map也是基于平衡树,但它提供了一个将键映射到某个值的容器,例如:

#include <map>
...
std::map<int, std::string> numbers;
numbers[3] = "three";
numbers[4] = "four";
...

如前面的代码所示,map numbers函数将整数映射到字符串。因此,当我们告诉地图将3的值存储为键,three的字符串作为值时,它会向其内部树添加一个新节点,其键等于3,值等于three

setmap操作是对数的,这使得它在大多数情况下成为非常高效的数据结构。然而,更高效的数据结构接下来就要出现。

哈希表

哈希表是最快的数据结构。它基于一个简单的向量索引的想法。想象一个包含指向列表的指针的大向量:

std::vector<std::list<T> > hash_table;

访问向量元素需要常数时间。这是向量的主要优势。哈希表允许我们使用任何类型作为容器的键。哈希表的基本思想是使用精心策划的哈希函数,为输入键生成唯一的索引。例如,当我们使用字符串作为哈希表键时,哈希表使用哈希函数将哈希作为底层向量的索引值:

template <typename T>
int hash(const T& key)
{
  // generate and return and efficient
  // hash value from key based on the key's type
}

template <typename T, typename U>
void insert_into_hashtable(const T& key, const U& value)
{
  int index = hash(key);
  hash_table[index].push_back(value); // insert into the list
}

以下是我们如何说明哈希表:

访问哈希表需要常数时间,因为它是基于向量操作的。虽然可能会有不同的键导致相同的哈希值,但这些冲突通过使用值列表作为向量元素来解决(如前图所示)。

STL 支持名为std::unordered_map的哈希表:

#include <unordered_map>
...
std::unordered_map<std::string, std::string> hashtable;
hashtable["key1"] = "value 1";
hashtable["key2"] = "value 2";
...

为了为提供的键生成哈希值,函数std::unordered_map使用<functional>头文件中定义的std::hash()函数。您可以为哈希函数指定自定义实现。std::unordered_map的第三个template参数是哈希函数,默认为std::hash

二叉搜索树的平衡性是基于许多搜索索引实现的。例如,数据库系统使用称为 B 树的平衡树进行表索引。B 树不是二叉树,但它遵循相同的平衡逻辑,如下图所示:

另一方面,图表示没有适当顺序的连接节点:

假设我们正在构建一个最终将击败 Facebook 的社交网络。社交网络中的用户可以互相关注,这可以表示为图。例如,如果 A 关注 B,B 关注 C,C 既关注 B 又同时关注 A,那么我们可以将关系表示为以下图:

在图中,一个节点被称为顶点。两个节点之间的链接被称为。实际上并没有固定的图表示,所以我们应该从几种选择中进行选择。让我们想想我们的社交网络 - 我们如何表示用户 A 关注用户 B 的信息?

这里最好的选择之一是使用哈希表。我们可以将每个用户映射到他们关注的所有用户:

图的实现变成了混合容器:

#include <list>
#include <unordered_map>

template <typename T>
class Graph
{
public: 
  Graph();
  ~Graph();
  // copy, move constructors and assignment operators omitted for brevity

public:
  void insert_edge(const T& source, const T& target);
  void remove_edge(const T& source, const T& target);

  bool connected(const T& source, const T& target);

private:
  std::unordered_map<T, std::list<T> > hashtable_;
};

为了使其成为 STL 兼容的容器,让我们为图添加一个迭代器。虽然迭代图不是一个好主意,但添加迭代器并不是一个坏主意。

字符串

字符串类似于向量:它们存储字符,公开迭代器,并且它们是容器。但是,它们有些不同,因为它们专门表示一种数据:字符串。下图描述了字符串hello, C++作为以特殊\0字符结尾的字符数组:

特殊的\0字符(也称为空字符)用作字符串终止符。编译器会依次读取字符,直到遇到空字符为止。

字符串的实现方式与我们在本章开头实现向量的方式相同:

class my_string
{
public:
 my_string();
 // code omitted for brevity

public:
 void insert(char ch);
 // code omitted for brevity

private:
 char* buffer_;
 int size_;
 int capacity_;
};

C++有其强大的std::string类,提供了一堆用于处理的函数。除了std::string成员函数外,<algorithm>中定义的算法也适用于字符串。

摘要

数据结构和算法在开发高效软件方面至关重要。通过理解和利用本章讨论的数据结构,您将充分利用 C++20 的功能,使程序运行更快。程序员具有强大的问题解决能力在市场上更受欢迎,这并不是秘密。首先要通过深入理解基本算法和数据结构来获得问题解决能力。正如您在本章中已经看到的,使用二分搜索算法在搜索任务中使代码运行速度比顺序搜索快得多。高效的软件节省时间并提供更好的用户体验,最终使您的软件成为现有软件的杰出替代品。

在本章中,我们讨论了基本数据结构及其区别。我们学会了根据问题分析来使用它们。例如,在需要随机查找的问题中应用链表被认为是耗时的,因为链表元素访问操作的复杂性。在这种情况下,使用动态增长的向量更合适,因为它具有常数时间的元素访问。相反,在需要在容器的前面快速插入的问题中使用向量比如列表更昂贵。

本章还介绍了算法以及衡量它们效率的方法。我们比较了几个问题,以应用更好的算法更有效地解决它们。

在下一章中,我们将讨论 C++中的函数式编程。在学习了 STL 的基本知识后,我们现在将在容器上应用函数式编程技术。

问题

  1. 描述将元素插入动态增长的向量。

  2. 在链表的前面插入元素和在向量的前面插入元素有什么区别?

  3. 实现一个混合数据结构,它将元素存储在向量和列表中。对于每个操作,选择具有最快实现该操作的基础数据结构。

  4. 如果我们按顺序插入 100 个元素,二叉搜索树会是什么样子呢?

  5. 选择排序和插入排序算法有什么区别?

  6. 实现本章描述的排序算法,即计数排序。

进一步阅读

有关更多信息,请参考以下资源:

第七章:函数式编程

面向对象编程OOP)为我们提供了一种思考对象的方式,从而以类和它们的关系来表达现实世界。函数式编程是一种完全不同的编程范式,因为它允许我们专注于功能结构而不是代码的物理结构。学习和使用函数式编程有两种用途。首先,它是一种迫使你以非常不同的方式思考的新范式。解决问题需要灵活的思维。附着于单一范式的人往往对任何问题提供类似的解决方案,而大多数优雅的解决方案需要更广泛的方法。掌握函数式编程为开发人员提供了一种新的技能,帮助他们提供更好的解决方案。其次,使用函数式编程可以减少软件中的错误数量。其中最大的原因之一是函数式编程的独特方法:它将程序分解为函数,每个函数都不修改数据的状态。

在本章中,我们将讨论函数式编程的基本模块,以及范围。在 C++20 中引入的范围为我们提供了一种很好的方式,以便将算法组合起来,使它们能够处理数据集合。将算法组合起来,以便我们可以将它们顺序应用于这些数据集合,这是函数式编程的核心。这就是为什么我们在本章中还将讨论范围。

本章将涵盖以下主题:

  • 函数式编程介绍

  • 介绍范围库

  • 纯函数

  • 高阶函数

  • 深入递归

  • 函数式 C++中的元编程

技术要求

在本章的示例中,将使用 g++编译器以及-std=c++2a选项。

您可以在github.com/PacktPublishing/Expert-CPP找到本章的源文件。

揭示函数式编程

正如我们之前提到的,函数式编程是一种编程范式。您可以将范式视为构建程序时的一种思维方式。C++是一种多范式语言。我们可以使用它以过程范式开发程序,即通过依次执行语句来执行。在第三章《面向对象编程的细节》中,我们讨论了面向对象的方法,它涉及将复杂系统分解为相互通信的对象。另一方面,函数式编程鼓励我们将系统分解为函数而不是对象。它使用表达式而不是语句。基本上,您将某些东西作为输入,并将其传递给生成输出的函数。然后可以将其用作另一个函数的输入。这乍看起来可能很简单,但函数式编程包含了一些一开始感觉难以掌握的规则和实践。然而,当您掌握了这一点,您的大脑将解锁一种新的思维方式——函数式方式。

为了使这一点更清晰,让我们从一个示例开始,它将演示函数式编程的本质。假设我们已经获得了一个整数列表,并且需要计算其中偶数的数量。唯一的问题是有几个这样的向量。我们应该分别计算所有向量中的偶数,并将结果作为一个新向量产生,其中包含对每个输入向量的计算结果。

输入以矩阵形式提供,即向量的向量。在 C++中表达这一点的最简单方式是使用以下类型:

std::vector<std::vector<int>>

我们可以通过使用类型别名来进一步简化前面的代码,如下所示:

using IntMatrix = std::vector<std::vector<int>>;

以下是这个问题的一个例子。我们有一堆包含整数的向量,结果应该是一个包含偶数的计数的向量:

看一下以下函数。它以整数向量的向量(也称为矩阵)作为其参数。该函数计算偶数的数量:

std::vector<int> count_all_evens(const IntMatrix& numbers)
{
  std::vector<int> even_numbers_count;
  for (const auto& number_line: numbers) {
    int even{0};
 for (const auto& number: number_line) {
 if (number % 2 == 0) {
 ++even;
 }
 }
 even_numbers_count.push_back(even);
  }
  return even_numbers_count;
}

前面的函数保留了一个单独的向量,用于存储每个向量中偶数的计数。输入以向量的形式提供,这就是为什么函数循环遍历第一个向量以检索内部向量。对于每个检索到的向量,它循环遍历并在向量中每次遇到偶数时递增计数器。在完成每个向量的循环后,最终结果被推送到包含数字列表的向量中。虽然您可能希望回到前面的示例并改进代码,但我们现在将继续并将其分解为更小的函数。首先,我们将负责计算偶数数量的代码部分移入一个单独的函数中。

让我们将其命名为count_evens,如下所示:

int count_evens(const std::vector<int>& number_line) {
  return std::count_if(number_line.begin(), 
       number_line.end(), [](int num){return num % 2 == 0;});
}

注意我们如何应用count_if()算法。它接受两个迭代器,并将它们分别放在容器的开头和结尾。它还接受第三个参数,一个一元谓词,它对集合的每个元素进行调用。我们传递了一个 lambda 作为一元谓词。您也可以使用任何其他可调用实体,例如函数指针、std::函数等。

现在我们有了一个单独的计数函数,我们可以在原始的count_all_evens()函数中调用它。以下是 C++中函数式编程的实现:

std::vector<int> count_all_evens(const std::vector<std::vector<int>>& numbers) {
  return numbers | std::ranges::views::transform(count_evens);
}

在深入研究前面的代码之前,让我们先就引起我们注意的第一件事达成一致——不是|运算符的奇怪用法,而是代码的简洁性。将其与我们在本节开头介绍的代码版本进行比较。它们都完成了同样的工作,但第二个——函数式的代码——更加简洁。还要注意的是,该函数不保留或更改任何状态。它没有副作用。这在函数式编程中至关重要,因为函数必须是函数。它接受一个参数,然后在不修改它的情况下对其进行处理,并返回一个新值(通常基于输入)。函数式编程的第一个挑战是将任务分解为更小的独立函数,然后轻松地将它们组合在一起。

尽管我们是从命令式的解决方案转向函数式的解决方案,但这并不是在利用函数式编程范式时的正确方式。与其首先编写命令式代码,然后修改它以获得函数式版本,不如改变您的思维方式和解决问题的方式。您应该驯服思考函数式的过程。计算所有偶数的问题导致我们解决了一个向量的问题。如果我们能找到一种方法来解决单个向量的问题,我们就能解决所有向量的问题。count_evens()函数接受一个向量并产生一个单个值,如下截图所示:

解决了一个向量的问题后,我们应该继续将解决方案应用于所有向量的原始问题。std::transform()函数基本上做了我们需要的事情:它接受一个可以应用于单个值的函数,并将其转换为处理集合的方式。以下图片说明了我们如何使用它来实现一个函数(count_all_evens),该函数可以处理来自只处理一个项目的函数(count_evens)的函数的项目集合:

将更大的问题分解为更小的、独立的任务是函数式编程的核心。每个函数都专门用于执行一个足够简单的任务,而不会意识到原始问题。然后将函数组合在一起,以从原始输入生成一系列转换后的项目。

现在,count_all_evens()函数的最终版本利用了范围。让我们找出它们是什么以及如何使用它们,因为我们将在后续示例中需要它们。

使用范围

范围与视图相关联。我们将在本节中同时研究它们。我们在第六章中讨论了 STL 容器和算法,深入研究 STL 中的数据结构和算法。它们为我们提供了一种通用的方法来组合和处理对象集合。正如您已经知道的那样,我们经常使用迭代器来循环遍历容器并处理它们的元素。迭代器是一种工具,允许我们在算法和容器之间实现松耦合。

例如,之前,我们对向量应用了count_if(),但count_if()不知道它被应用到了什么容器。看一下count_if()的以下声明:

template <typename InputIterator, typename UnaryPredicate>
constexpr typename iterator_traits<InputIterator>::difference_type
  count_if(InputIterator first, InputIterator last, UnaryPredicate p);

正如您所看到的,除了其特定于 C++的冗长声明之外,count_if()不接受容器作为参数。相反,它使用迭代器 - 具体来说,输入迭代器。

输入迭代器支持使用++运算符向前迭代,并使用*运算符访问每个元素。我们还可以使用==!=关系比较输入迭代器。

算法在不知道容器的确切类型的情况下迭代容器。我们可以在任何具有开始和结束的实体上使用count_if(),如下所示:

#include <array>
#include <iostream>
#include <algorithm>

int main()
{
  std::array<int, 4> arr{1, 2, 3, 4};
 auto res = std::count_if(arr.cbegin(), arr.cend(), 
 [](int x){ return x == 3; });
  std::cout << "There are " << res << " number of elements equal to 3";
}

除了它们的通用性,算法不太容易组合。通常,我们将算法应用于一个集合,并将算法的结果存储为另一个集合,以便在以后的某个日期应用更多的算法。我们使用std::transform()将结果放入另一个容器中。例如,以下代码定义了一个产品的向量:

// consider the Product is already declared and has a "name", "price", and "weight"
// also consider the get_products() is defined 
// and returns a vector of Product instances

using ProductList = std::vector<std::shared_ptr<Product>>;
ProductList vec{get_products()};

假设项目是由不同的程序员团队开发的,并且他们选择将产品的名称保留为任何数字;例如,1 代表苹果,2 代表桃子,依此类推。这意味着vec将包含Product实例,每个实例的name字段中将有一个数字字符(而名称的类型是std::string - 这就是为什么我们将数字保留为字符而不是其整数值)。现在,我们的任务是将产品的名称从数字转换为完整的字符串(applepeach等)。我们可以使用std::transform来实现:

ProductList full_named_products; // type alias has been defined above
using ProductPtr = std::shared_ptr<Product>;
std::transform(vec.cbegin(), vec.cend(), 
  std::back_inserter(full_named_products), 
  [](ProductPtr p){ /* modify the name and return */ });

执行上述代码后,full_named_products向量将包含具有完整产品名称的产品。现在,要过滤出所有的苹果并将它们复制到一个苹果向量中,我们需要使用std::copy_if

ProductList apples;
std::copy_if(full_named_products.cbegin(), full_named_products.cend(),
  std::back_inserter(apples), 
  [](ProductPtr p){ return p->name() == "apple"; });

前面代码示例的最大缺点之一是缺乏良好的组合,直到引入范围。范围为我们提供了一种优雅的方式来处理容器元素和组合算法。

简而言之,范围是一个可遍历的实体;也就是说,一个范围有一个begin()和一个end(),就像我们迄今为止使用的容器一样。在这些术语中,每个 STL 容器都可以被视为一个范围。STL 算法被重新定义为直接接受范围作为参数。通过这样做,它们允许我们将一个算法的结果直接传递给另一个算法,而不是将中间结果存储在本地变量中。例如,std::transform,我们之前使用begin()end(),如果应用于一个范围,将具有以下形式(以下代码是伪代码)。通过使用范围,我们可以以以下方式重写前面的示例:

ProductList apples = filter(
  transform(vec, [](ProductPtr p){/* normalize the name */}),
  [](ProductPtr p){return p->name() == "apple";}
);

不要忘记导入<ranges>头文件。transform 函数将返回一个包含已标准化名称的Product指针的范围;也就是说,数值将被替换为字符串值。filter 函数将接受结果并返回具有apple作为名称的产品范围。

请注意,我们通过省略 std::ranges::viewsfiltertransform 函数前面的部分来简化了这些代码示例。分别使用 std::ranges::views::filterstd::ranges::views::transform

最后,我们在本章开头的示例中使用的重载运算符 **|** 允许我们将范围串联在一起。这样,我们可以组合算法以产生最终结果,如下所示:

ProductList apples = vec | transform([](ProductPtr p){/* normalize the name */})
                         | filter([](ProductPtr p){return p->name() == "apple";});

我们使用管道而不是嵌套函数调用。这可能一开始会让人困惑,因为我们习惯将 | 运算符用作按位或。每当你看到它应用于集合时,它指的是管道范围。

| 运算符受 Unix shell 管道运算符的启发。在 Unix 中,我们可以将多个进程的结果串联在一起;例如,ls -l | grep cpp | less 将在 ls 命令的结果中查找 cpp,并使用 less 程序逐屏显示最终结果。

正如我们已经提到的,范围是对集合的抽象。这并不意味着它是一个集合。这就是为什么前面的示例没有带来任何额外开销 - 它只是从一个函数传递到另一个函数的范围,其中范围只提供了集合的开始和结束。它还允许我们访问底层集合元素。以下图解释了这个想法:

函数(无论是 transform 还是 filter)返回的是一个范围结构而不是一个集合。范围的 begin() 迭代器将指向满足谓词的源集合中的元素。范围的迭代器是一个代理对象:它与常规迭代器不同,因为它指向满足给定谓词的元素。我们有时将它们称为 智能迭代器,因为每次我们推进它(例如通过增量),它都会找到满足谓词的集合中的下一个元素。更有趣的是,迭代器的“智能性”取决于我们应用于集合的函数类型。例如,filter() 函数返回一个具有智能迭代器的范围,用于它们的增量运算符。这主要是因为过滤的结果可能包含比原始集合更少的元素。另一方面,transform 不会返回具有减少元素数量的结果 - 它只是转换元素。这意味着由 transform 返回的范围对于增量/减量操作具有相同的功能,但元素访问将不同。对于每次访问,范围的智能迭代器将从原始集合中返回转换的元素。换句话说,它只是为迭代器实现了 *() 运算符,类似于下面的代码片段中所示:

auto operator*()
{
  return predicate(*current_position);
}

通过这种方式,我们创建了集合的新 视图 而不是转换元素的新集合。filter 和其他函数也是如此。更有趣的是,范围视图利用了 惰性评估。对于我们之前的示例,即使我们有两个范围转换,结果也是通过在单次遍历中评估它们来产生的。

在使用 transformfilter 的示例中,每个函数都定义了一个视图,但它们不会修改或评估任何内容。当我们将结果分配给结果集合时,向量是从视图中访问每个元素来构造的。这就是评估发生的地方。

就是这么简单 - 范围为我们提供了惰性评估的函数组合。我们之前简要介绍了函数式编程中使用的工具集。现在,让我们了解一下这种范式的好处。

为什么使用函数式编程?

首先,函数式编程引入了简洁性。与命令式对应物相比,代码要短得多。它提供了简单但高度表达的工具。当代码更少时,错误就会更少出现。

函数不会改变任何东西,这使得并行化变得更加容易。这是并发程序中的主要问题之一,因为并发任务需要在它们之间共享可变数据。大多数情况下,您必须使用诸如互斥锁之类的原语来显式同步线程。函数式编程使我们摆脱了显式同步,我们可以在多个线程上运行代码而无需进行调整。在第八章,深入数据结构中,我们将详细讨论数据竞争。

函数式范式将所有函数视为函数;也就是说,不会改变程序状态的函数。它们只是接受输入,以用户定义的方式进行转换,并提供输出。对于相同的输入,纯函数生成相同的结果,不受调用次数的影响。每当我们谈论函数式编程时,我们应该默认考虑所有纯函数。

以下函数以double作为输入,并返回其平方:

double square(double num) { return num * num; }

仅编写纯函数可能会让程序运行变慢。

一些编译器,如 GCC,提供了帮助编译器优化代码的属性。例如,[[gnu::pure]]属性告诉编译器该函数可以被视为纯函数。这将让编译器放心,函数不会访问任何全局变量,函数的结果仅取决于其输入。

有许多情况下,常规函数可能会带来更快的解决方案。然而,为了适应这种范式,您应该强迫自己以函数式思维。例如,以下程序声明了一个向量,并计算了其元素的平方根:

void calc_square_roots(std::vector<double>& vec) 
{
  for (auto& elem : vec) {
    elem = std::sqrt(elem);
  }
}

int main()
{
  std::vector<double> vec{1.1, 2.2, 4.3, 5.6, 2.4};
 calc_square_roots(vec);
}

在这里,我们通过引用传递向量。这意味着,如果我们在函数中对它进行更改,就会改变原始集合。显然,这不是一个纯函数,因为它改变了输入向量。函数式的替代方法是在一个新的向量中返回转换后的元素,保持输入不变:

std::vector<double> pure_calc_square_roots(const std::vector<double>& vec)
{
 std::vector<double> new_vector;
  for (const auto& elem : vec) {
    new_vector.push_back(std::sqrt(elem));
  }
 return new_vector;
}

函数式思维的一个更好的例子是解决一个较小的问题,并将其应用到集合中。在这种情况下,较小的问题是计算单个数字的平方根,这已经实现为std::sqrt。将其应用到集合中是通过std::ranges::views::transform完成的,如下所示:

#include <ranges>
#include <vector>

int main()
{
 std::vector<double> vec{1.1, 2.2, 4.3, 5.6, 2.4};
 auto result = vec | std::ranges::views::transform(std::sqrt);
}

正如我们已经知道的,通过使用范围,我们可以避免存储中间对象。在前面的例子中,我们直接将transform应用于向量。transform返回一个视图,而不是由源向量的转换元素组成的完整集合。当我们构造result向量时,实际的转换副本才会产生。另外,请注意std::sqrt被认为是一个纯函数。

本章开始时我们解决的例子为我们提供了函数式编程所需的视角。为了更好地掌握这种范式,我们应该熟悉它的原则。在下一节中,我们将深入探讨函数式编程的原则,以便您更好地了解何时以及如何使用这种范式。

函数式编程原则

尽管函数式范式很古老(诞生于 20 世纪 50 年代),但它并没有在编程世界中掀起风暴。如我们在本书和其他许多书中多次声明的那样,C++是一种多范式语言。这就是学习 C++的美妙之处;我们可以调整它以适应几乎每个环境。掌握这种范式并不是一件容易的事。您必须感受它并应用它,直到最终开始以这种范式思考。之后,您将能够在几秒钟内找到常规任务的解决方案。

如果您还记得第一次学习面向对象编程时,您可能会记得在能够发挥面向对象编程的真正潜力之前,您可能会有些挣扎。函数式编程也是如此。在本节中,我们将讨论函数式编程的基本概念,这将成为进一步发展的基础。您可以应用(或已经这样做)其中一些概念,而实际上并没有使用函数式范例。然而,请努力理解和应用以下每个原则。

纯函数

正如我们之前提到的,如果函数不改变状态,则函数是纯的。与非纯函数相比,纯函数可能被视为性能较差;然而,它们非常好,因为它们避免了由于状态修改而导致的代码中可能出现的大多数错误。这些错误与程序状态有关。显然,程序处理数据,因此它们组成修改状态的功能,从而为最终用户产生一些预期的结果。

在面向对象编程中,我们将程序分解为对象,每个对象都有一系列特殊功能。面向对象编程中对象的一个基本特征是其状态。通过向对象发送消息(换句话说,调用其方法)来修改对象的状态在面向对象编程中至关重要。通常,成员函数调用会导致对象状态的修改。在函数式编程中,我们将代码组织成一组纯函数,每个函数都有自己的目的,并且独立于其他函数。

让我们来看一个简单的例子,只是为了让这个概念清晰起来。假设我们在程序中处理用户对象,每个用户对象都包含与用户相关的年龄。User类型在以下代码块中被描述为struct

struct User
{
  int age;
  string name;
  string phone_number;
  string email;
};

有必要每年更新用户的年龄。假设我们有一个函数,每年为每个User对象调用一次。以下函数接受一个User对象作为输入,并将其age增加1

void update_age(User& u)
{
  u.age = u.age + 1;
}

update_age()函数通过引用接受输入并更新原始对象。这在函数式编程中并不适用。这个纯函数不是通过引用获取原始对象并改变其值,而是返回一个完全不同的user对象,具有相同的属性,除了更新的age属性:

User pure_update_age(const User& u) // cannot modify the input argument
{
 User tmp{u};
  tmp.age = tmp.age + 1;
  return tmp;
}

尽管与update_age()相比似乎效率低下,但这种方法的优点之一是它使操作变得非常清晰(在调试代码时非常有用)。现在,可以保证pure_update_age()不会修改原始对象。我们可以修改前面的代码,使其按值传递对象。这样,我们将跳过创建tmp对象,因为参数本身就代表了一个副本:

User pure_update_age(User u) // u is the copy of the passed object
{
  u.age = u.age + 1;
  return u;
}

如果一个纯函数使用相同的参数多次调用,它必须每次返回相同的结果。以下代码演示了我们的pure_update_age()函数在给定相同输入时返回相同的值:

User john{.age{21}, .name{"John"}};

auto updated{pure_update_age(john)};
std::cout << updated.age; // prints 22

updated = pure_update_age(john);
std::cout << updated.age; // prints 22

对于一个函数来说,每次针对相同的输入数据调用时都表现相同是一个很大的好处。这意味着我们可以通过将应用程序的逻辑分解为更小的函数来设计它,每个函数都有一个确切而清晰的目的。然而,纯函数在额外临时对象方面存在开销。常规设计涉及具有包含程序状态的集中存储,该状态通过纯函数间接更新。在每次纯函数调用之后,函数将修改后的对象作为可能需要存储的新对象返回。您可以将其视为调整代码以省略传递整个对象。

高阶函数

在函数式编程中,函数被视为一等对象(你可能也会遇到一等公民)。这意味着我们应该将它们视为对象,而不是一组指令。这对我们有什么区别?嗯,在这一点上,函数被视为对象的唯一重要之处是能够将其传递给其他函数。接受其他函数作为参数的函数被称为高阶函数

C++程序员将一个函数传递到另一个函数是很常见的。以下是以老式方式实现的方法:

typedef  void (*PF)(int);
void foo(int arg) 
{
  // do something with arg
}

int bar(int arg, PF f)
{
 f(arg);
  return arg;
}

bar(42, foo);

在前面的代码中,我们声明了一个指向函数的指针。PF代表函数的类型定义,接受一个整数参数,并且不返回任何值。前面的例子是将指针函数传递给其他函数作为参数的一种常见方式。我们将函数视为对象。然而,这取决于我们对对象的理解。

在前面的章节中,我们将对象定义为具有状态的东西。这意味着,如果我们将函数视为对象,我们也应该能够在需要时以某种方式改变它的状态。对于函数指针来说,情况并非如此。以下是将函数传递给另一个函数的更好方法:

class Function
{
public:
  void modify_state(int a) {
    state_ = a;
  }

  int get_state() {
    return state_;
  }

  void operator()() {
 // do something that a function would do
 }
private:
  int state_;
};

void foo(Function f)
{
 f();
  // some other useful code
}

看一下前面的代码。它声明了一个具有重载operator()的类。每当我们重载一个类的运算符时,我们使它变得可调用。尽管听起来很明显,但任何可调用的东西都被视为函数。因此,具有重载operator()的类的对象可以被视为函数(有时被称为函数对象)。这在某种程度上有点像一个技巧,因为我们不是将函数变成对象,而是使对象可调用。然而,这使我们能够实现我们想要的东西:具有状态的函数。以下客户端代码演示了Function对象具有状态:

void foo(Function f)
{
  f();
  f.modify_state(11);
 cout << f.get_state(); // get the state
  f(); // call the "function"
}

通过这样做,我们可以跟踪函数被调用的次数。以下是一个跟踪调用次数的简单示例:

class Function
{
public:
 void operator()() {    // some useful stuff ++called_; 
  }

private:
  int called_ = 0;
};

最后,std::function,它在以下代码中的<functional>头文件中定义,展示了另一种定义高阶函数的方法:

#include <functional>

void print_it(int a) {
  cout << a;
}

std::function<void(int)> function_object = print_it;

当调用function_object(使用operator())时,它将调用print_it函数。std::function封装了任何函数,并允许将其作为对象使用(以及将其传递给其他函数)。

在前面的例子中,接受其他函数作为参数的函数都是高阶函数的例子。返回函数的函数也被称为高阶函数。总之,高阶函数是接受或返回另一个函数或多个函数的函数。看一下以下例子:

#include <functional>
#include <iostream>

std::function<int (int, int)> get_multiplier()
{
 return [](int a, int b) { return a * b; };
}

int main()
{
 auto multiply = get_multiplier();
  std::cout << multiply(3, 5) << std::endl; // outputs 15
}

get_multiplier()返回一个包装在std::function中的 lambda。然后,我们调用结果,就像调用普通函数一样。get_multiplier()函数是一个高阶函数。我们可以使用高阶函数来实现柯里化,类似于我们在前面的例子中所做的。在函数式编程中,柯里化是指我们将一个函数的多个参数转换为多个函数,每个函数只接受一个参数;例如,将multiply(3, 5)转换为multiply(3)(5)。以下是我们如何实现这一点:

std::function<int(int)> multiply(int a)
{
 return a { return a * b; };
}

int main()
{
  std::cout << multiply(3)(5) << std::endl;
}

multiply()接受一个参数,并返回一个也接受单个参数的函数。注意 lambda 捕获:它捕获了a的值,以便在其主体中将其乘以b

柯里化是对逻辑学家 Haskell Curry 的致敬。Haskell、Brook 和 Curry 编程语言也以他的名字命名。

柯里化最有用的特性之一是拥有我们可以组合在一起的抽象函数。我们可以创建multiply()的专门版本,并将它们传递给其他函数,或者在适用的地方使用它们。这可以在以下代码中看到:

auto multiplyBy22 = multiply(22);
auto fiveTimes = multiply(5);

std::cout << multiplyBy22(10); // outputs 220
std::cout << fiveTimes(4); // outputs 20

在使用 STL 时,您一定会使用高阶函数。许多 STL 算法使用谓词来过滤或处理对象集合。例如,std::find_if函数找到满足传递的谓词对象的元素,如下例所示:

std::vector<int> elems{1, 2, 3, 4, 5, 6};
std::find_if(elems.begin(), elems.end(), [](int el) {return el % 3 == 0;});

std::find_if以 lambda 作为其谓词,并对向量中的所有元素调用它。满足条件的任何元素都将作为请求的元素返回。

另一个高阶函数的例子是std::transform,我们在本章开头介绍过(不要与ranges::view::transform混淆)。让我们使用它将字符串转换为大写字母:

std::string str = "lowercase";
std::transform(str.begin(), str.end(), str.begin(), 
  [](unsigned char c) { return std::toupper(c); });
std::cout << str; // "LOWERCASE"

第三个参数是容器的开始,是std::transform函数插入其当前结果的位置。

折叠

折叠(或减少)是将一组值组合在一起以生成减少数量的结果的过程。大多数情况下,我们说的是单个结果。折叠抽象了迭代具有递归性质的结构的过程。例如,链表或向量在元素访问方面具有递归性质。虽然向量的递归性质是有争议的,但我们将考虑它是递归的,因为它允许我们通过重复增加索引来访问其元素。为了处理这样的结构,我们通常在每一步中跟踪结果,并处理稍后要与先前结果组合的下一个项目。根据我们处理集合元素的方向,折叠称为折叠。

例如,std::accumulate函数(另一个高阶函数的例子)是折叠功能的完美例子,因为它结合了集合中的值。看一个简单的例子:

std::vector<double> elems{1.1, 2.2, 3.3, 4.4, 5.5};
auto sum = std::accumulate(elems.begin(), elems.end(), 0);

函数的最后一个参数是累加器。这是应该用作集合的第一个元素的先前值的初始值。前面的代码计算了向量元素的和。这是std::accumulate函数的默认行为。正如我们之前提到的,它是一个高阶函数,这意味着可以将一个函数作为其参数传递。然后将为每个元素调用该函数以产生所需的结果。例如,让我们找到先前声明的elems向量的乘积:

auto product = std::accumulate(elems.begin(), elems.end(), 1, 
  [](int prev, int cur) { return prev * cur; });

它采用二进制操作;也就是说,具有两个参数的函数。操作的第一个参数是到目前为止已经计算的先前值,而第二个参数是当前值。二进制操作的结果将是下一步的先前值。可以使用 STL 中的现有操作之一简洁地重写前面的代码:

auto product = std::accumulate(elems.begin(), elems.end(), 1, 
 std::multiplies<int>());

std::accumulate函数的更好替代品是std::reduce函数。reduce()类似于accumulate(),只是它不保留操作的顺序;也就是说,它不一定按顺序处理集合元素。您可以向std::reduce函数传递执行策略并更改其行为,例如并行处理元素。以下是如何使用并行执行策略将 reduce 函数应用于先前示例中的elems向量:

std::reduce(std::execution::par, elems.begin(), elems.end(), 
  1, std::multiplies<int>());

尽管std::reducestd::accumulate相比似乎更快,但在使用非交换二进制操作时,您应该小心。

折叠和递归是相辅相成的。递归函数也通过将问题分解为较小的任务并逐个解决它们来解决问题。

深入递归

我们已经在第二章 使用 C++进行低级编程中讨论了递归函数的主要特点。让我们来看一个简单的递归计算阶乘的例子:

int factorial(int n)
{
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

递归函数相对于它们的迭代对应物提供了优雅的解决方案。然而,你应该谨慎地考虑使用递归的决定。递归函数最常见的问题之一是堆栈溢出。

头递归

头递归是我们已经熟悉的常规递归。在前面的例子中,阶乘函数表现为头递归函数,意味着在处理当前步骤的结果之前进行递归调用。看一下阶乘函数中的以下一行:

...
return n * factorial(n - 1);
...

为了找到并返回乘积的结果,函数阶乘以减小的参数(即(n - 1))被调用。这意味着乘积(*运算符)有点像暂停,正在等待它的第二个参数由factorial(n - 1)返回。堆栈随着对函数的递归调用次数的增加而增长。让我们尝试将递归阶乘实现与以下迭代方法进行比较:

int factorial(int n) 
{
  int result = 1;
  for (int ix = n; ix > 1; --ix) {
    result *= ix;
  }
  return result;
}

这里的一个主要区别是我们在相同的变量(名为result)中存储了每一步的乘积的结果。有了这个想法,让我们试着分解阶乘函数的递归实现。

很明显,每个函数调用在堆栈上占据了指定的空间。每一步的结果都应该存储在堆栈的某个地方。尽管我们知道应该,甚至必须是相同的变量,但递归函数并不在乎;它为它的变量分配空间。常规递归函数的反直觉性促使我们寻找一个解决方案,以某种方式知道每次递归调用的结果应该存储在同一个地方。

尾递归

尾递归是解决递归函数中存在多个不必要变量的问题的方法。尾递归函数的基本思想是在递归调用之前进行实际处理。以下是我们如何将阶乘函数转换为尾递归函数:

int tail_factorial(int n, int result)
{
  if (n <= 1) return result;
  return tail_factorial(n - 1, n * result);
}

注意函数的新参数。仔细阅读前面的代码给了我们尾递归正在发生的基本概念:在递归调用之前进行处理。在tail_factorial再次在其主体中被调用之前,当前结果被计算(n * result)并传递给它。

虽然这个想法可能看起来并不吸引人,但如果编译器支持尾调用优化(TCO),它确实非常高效。TCO 基本上涉及知道阶乘函数的第二个参数(尾部)可以在每次递归调用时存储在相同的位置。这允许堆栈保持相同的大小,独立于递归调用的次数。

说到编译器优化,我们不能忽略模板元编程。我们将它与编译器优化一起提到,因为我们可以将元编程视为可以对程序进行的最大优化。在编译时进行计算总是比在运行时更好。

函数式 C++中的元编程

元编程可以被视为另一种编程范式。这是一种完全不同的编码方法,因为我们不再处理常规的编程过程。通过常规过程,我们指的是程序在其生命周期中经历的三个阶段:编码、编译和运行。显然,当程序被执行时,它会按照预期的方式执行。通过编译和链接,编译器生成可执行文件。另一方面,元编程是代码在编译代码期间被执行的地方。如果你第一次接触这个,这可能听起来有点神奇。如果程序甚至还不存在,我们怎么能执行代码呢?回想一下我们在第四章中学到的关于模板的知识,理解和设计模板,我们知道编译器会对模板进行多次处理。在第一次通过中,编译器定义了模板类或函数中使用的必要类型和参数。在下一次通过中,编译器开始以我们熟悉的方式编译它们;也就是说,它生成一些代码,这些代码将由链接器链接以生成最终的可执行文件。

由于元编程是在代码编译期间发生的事情,我们应该已经对所使用的语言的概念和结构有所了解。任何可以在编译时计算的东西都可以用作元编程构造,比如模板。

以下是 C++中经典的令人惊叹的元编程示例:

template <int N>
struct MetaFactorial
{
  enum {
    value = N * MetaFactorial<N - 1>::value
  };
};

template <>
struct MetaFactorial<0>
{
  enum {
    value = 1
  };
};

int main() {
  std::cout << MetaFactorial<5>::value; // outputs 120
  std::cout << MetaFactorial<6>::value; // outputs 720
}

为什么我们要写这么多代码来计算阶乘,而在上一节中我们只用不到五行的代码就写出了?原因在于它的效率。虽然编译代码需要花费一点时间,但与普通的阶乘函数(递归或迭代实现)相比,它的效率非常高。这种效率的原因在于阶乘的实际计算是在编译时发生的。也就是说,当可执行文件运行时,结果已经准备好了。我们只是在运行程序时使用了计算出的值;运行时不会发生计算。如果你是第一次看到这段代码,下面的解释会让你爱上元编程。

让我们详细分解和分析前面的代码。首先,MetaFactorial 模板声明为带有单个 value 属性的 enum。之所以选择这个 enum,仅仅是因为它的属性是在编译时计算的。因此,每当我们访问 MetaFactorial 的 value 属性时,它已经在编译时被计算(评估)了。看一下枚举的实际值。它从相同的 MetaFactorial 类中进行了递归依赖:

template <int N>
struct MetaFactorial
{
  enum {
 value = N * MetaFactorial<N - 1>::value
 };
};

你们中的一些人可能已经注意到了这里的技巧。MetaFactorial<N - 1> 不是与 MetaFactorial<N> 相同的结构。尽管它们有相同的名称,但每个具有不同类型或值的模板都会生成一个单独的新类型。因此,假设我们调用类似以下的内容:

std::cout << MetaFactorial<3>::value;

在这里,勤奋的编译器为每个值生成了三个不同的结构(以下是一些伪代码,表示我们应该如何想象编译器的工作):

struct MetaFactorial<3>
{
  enum {
    value = 3 * MetaFactorial<2>::value
  };
};

struct MetaFactorial<2>
{
  enum {
    value = 2 * MetaFactorial<1>::value;
  };
};

struct MetaFactorial<1>
{
  enum {
    value = 1 * MetaFactorial<0>::value;
  };
};

在下一次通过中,编译器将用其相应的数值替换生成的结构的每个值,如下伪代码所示:

struct MetaFactorial<3>
{
  enum {
   value = 3 * 2
  };
};

struct MetaFactorial<2>
{
  enum {
    value = 2 * 1
  };
};

struct MetaFactorial<1>
{
  enum {
    value = 1 * 1
  };
};

然后,编译器删除未使用的生成的结构,只留下 MetaFactorial<3>,再次只用作 MetaFactorial<3>::value。这也可以进行优化。通过这样做,我们得到以下结果:

std::cout << 6;

将此与我们之前的一行进行比较:

std::cout << MetaFactorial<3>::value;

这就是元编程的美妙之处——它是在编译时完成的,不留痕迹,就像忍者一样。编译时间会更长,但程序的执行速度是可能的情况下最快的,与常规解决方案相比。我们建议您尝试实现其他成本昂贵的计算的元版本,比如计算第 n 个斐波那契数。这并不像为运行时而不是编译时编写代码那么容易,但您已经感受到了它的力量。

总结

在这一章中,我们对使用 C++有了新的视角。作为一种多范式语言,它可以被用作函数式编程语言。

我们学习了函数式编程的主要原则,比如纯函数、高阶函数和折叠。纯函数是不会改变状态的函数。纯函数的优点之一是它们留下的错误较少,否则会因为状态的改变而引入错误。

高阶函数是接受或返回其他函数的函数。除了在函数式编程中,C++程序员在处理 STL 时也使用高阶函数。

纯函数以及高阶函数使我们能够将整个应用程序分解为一系列函数的装配线。这个装配线中的每个函数负责接收数据并返回原始数据的新修改版本(而不是改变原始状态)。当结合在一起时,这些函数提供了一个良好协调的任务线。

在下一章中,我们将深入探讨多线程编程,并讨论在 C++中引入的线程支持库组件。

问题

  1. 列出范围的优势。

  2. 哪些函数被认为是纯函数?

  3. 在函数式编程方面,纯虚函数和纯函数之间有什么区别?

  4. 什么是折叠?

  5. 尾递归相对于头递归的优势是什么?

进一步阅读

有关本章涵盖内容的更多信息,请查看以下链接:

第八章:并发和多线程

并发编程可以创建更高效的程序。很长一段时间以来,C++没有内置对并发或多线程的支持。现在它完全支持并发编程、线程、线程同步对象以及本章将讨论的其他功能。

在语言更新以支持线程之前,程序员必须使用第三方库。最流行的多线程解决方案之一是POSIX可移植操作系统接口)线程。自 C++11 以来,C++引入了线程支持。这使得语言更加健壮,并适用于更广泛的软件开发领域。对于 C++程序员来说,理解线程有些关键,因为他们倾向于尽可能地压榨程序的每一点,使其运行得更快。线程向我们介绍了一种完全不同的方式,通过并发运行函数来加速程序。在基本水平上学习多线程对于每个 C++程序员来说都是必不可少的。有很多程序在其中无法避免使用多线程,例如网络应用程序、游戏和 GUI 应用程序。本章将向您介绍 C++中的并发和多线程基础知识以及并发代码设计的最佳实践。

本章将涵盖以下主题:

  • 理解并发和多线程

  • 使用线程

  • 管理线程和共享数据

  • 设计并发代码

  • 使用线程池避免线程创建开销

  • 熟悉 C++20 中的协程

技术要求

本章中使用-std=c++2a选项的 g++编译器来编译示例。您可以在github.com/PacktPublishing/Expert-CPP找到本章中使用的源文件。

理解并发和多线程

运行程序的最简单形式涉及其指令由CPU中央处理单元)逐个执行。正如您已经从之前的章节中了解到的,程序由几个部分组成,其中一个部分包含程序的指令。每个指令都加载到 CPU 寄存器中,以便 CPU 解码和执行。实际上,无论您使用何种编程范式来生成应用程序,结果始终是一样的——可执行文件包含机器代码。

我们提到,诸如 Java 和 C#之类的编程语言使用支持环境。然而,如果在中间删减支持环境(通常是虚拟机),那么最终执行的指令应该具有特定 CPU 熟悉的形式和格式。程序员明显知道,CPU 运行的语句顺序在任何情况下都不会混合。例如,我们可以确定并且可以继续确定以下程序将分别输出4"hello"5

int a{4};
std::cout << a << std::endl;
int b{a};
++b;
std::cout << "hello" << std::endl;
b--;
std::cout << (b + 1) << std::endl;

我们可以保证在将a变量打印到屏幕之前,其值将被初始化。同样,我们可以保证在将"hello"字符串打印到屏幕之前,我们会减少b的值,并且在将(b + 1)的和打印到屏幕之前,该和将被计算。每条指令的执行可能涉及从内存中读取数据或向内存中写入数据。

在第五章中介绍了内存管理和智能指针,内存层次结构足够复杂,使我们对程序执行的理解变得更加困难。例如,前面例子中的int b{a};这一行假设a的值从内存加载到 CPU 的寄存器中,然后将用于写入b的内存位置。关键词在于位置,因为它对我们来说有一点特殊的解释。更具体地说,我们谈论的是内存位置。并发支持取决于语言的内存模型,即对内存并发访问的一组保证。尽管字节是最小的可寻址内存单元,但 CPU 处理数据时使用的是字。也就是说,字是 CPU 从内存读取或写入的最小单位。例如,我们认为以下两个声明是不同的变量:

char one;
char two;

如果这些变量分配在同一个字中(假设字的大小大于char的大小),读取和写入任何一个变量都涉及读取包含它们两个的字。对变量的并发访问可能导致意外的行为。这就是需要内存模型保证的问题。C++内存模型保证了两个线程可以访问和更新不相互干扰的内存位置。内存位置是标量类型。标量类型是算术类型、指针、枚举或nullptr_t。最大的非零长度相邻位字段序列也被认为是内存位置。一个经典的例子是以下结构:

struct S
{
  char a;             // location #1
  int b: 5;           // location #2
  unsigned c: 11;
  unsigned :0;        // :0 separates bit fields
  unsigned d: 8;      // location #3
  struct {
    int ee: 8;
  } e;                // location #4 
};

对于前面的例子,两个线程访问同一个结构的不同内存位置不会相互干扰。那么,当谈论并发或多线程时,我们应该考虑什么呢?

并发通常与多线程混淆。它们在性质上是相似的,但在细节上是不同的概念。为了简化问题,只需想象并发是两个操作的运行时间交错在一起。如果操作A与操作B同时运行,它们的开始和结束时间在任何时刻都是交错的,如下图所示:

当两个任务同时运行时,并不一定要并行运行。想象一下以下情况:你正在看电视,同时上网冲浪。虽然这不是一个好的做法,但是,让我们想象一下,你有一个不能错过的最爱电视节目,同时,你的朋友让你研究一些关于蜜蜂的资料。你实际上无法专注于这两个任务;在任何固定的时刻,你的注意力都会被你正在观看的节目或者你在网上找到的关于蜜蜂的有趣事实所吸引。你的注意力会不时地从节目转移到蜜蜂身上。

就并发而言,你同时进行两个任务。你的大脑给节目一个时间段:你观看,享受,然后切换到文章,读几句话,然后再切换回节目。这是同时运行任务的简单例子。仅仅因为它们的开始和结束时间交错,并不意味着它们同时运行。另一方面,你在做任何前面提到的任务时都在呼吸。呼吸是在后台进行的;你的大脑不会将你的注意力从节目或文章转移到你的肺部来吸气或呼气。在看节目的同时呼吸是并行运行任务的一个例子。这两个例子都向我们展示了并发的本质。

那么,当您在计算机上运行多个应用程序时会发生什么?它们是否并行运行?可以肯定的是它们是同时运行的,然而,实际的并行性取决于您计算机的硬件。大多数大众市场计算机都只有一个 CPU。正如我们从前面的章节中所知,CPU 的主要工作是逐个运行应用程序的指令。单个 CPU 如何处理同时运行两个应用程序的情况?要理解这一点,我们应该了解进程。

进程

进程是内存中运行程序的映像。当我们启动一个程序时,操作系统从硬盘读取程序的内容,将其复制到内存中,并将 CPU 指向程序的起始指令。进程有其私有的虚拟地址空间、堆栈和堆。两个进程不会以任何方式相互干扰。这是操作系统提供的保证。这也使得程序员的工作非常困难,如果他们的目标是进程间通信IPC)。我们在本书中不讨论低级硬件特性,但你应该对运行程序时发生的事情有一个基本的了解。这实际上取决于底层硬件,更具体地说,取决于 CPU 的种类和结构。CPU 的数量、CPU 核心的数量、缓存内存的级别以及 CPU 或其核心之间的共享缓存内存——所有这些都会影响操作系统运行和执行程序的方式。

计算机系统中的 CPU 数量定义了真正并行运行的进程数量。这在下图中显示:

当我们谈论多处理时,我们考虑的是允许多个进程同时运行的环境。这就是棘手的部分。如果进程实际上是同时运行的,那么我们说它们是并行运行的。因此,并发不是并行,而并行意味着并发。

如果系统只有一个 CPU,进程会同时运行但不是并行的。操作系统通过一种称为上下文切换的机制来管理这一点。上下文切换意味着暂停进程的工作一会儿,复制进程在当前时间使用的所有寄存器值,并存储进程的所有活动资源和值。当一个进程停止时,另一个进程获得运行的权利。在为第二个进程提供的指定时间段之后,操作系统开始为其进行上下文切换。同样,它复制进程使用的所有资源。然后,之前的进程开始。在启动它之前,操作系统将资源和值复制回第一个进程使用的相应槽位,然后恢复执行此进程。

有趣的是,这些过程甚至没有意识到这样的事情。所描述的过程发生得如此之快,以至于用户实际上无法注意到操作系统中运行的程序实际上并不是同时运行的。下图描述了由单个 CPU 运行的两个进程。当其中一个进程处于活动状态时,CPU 按顺序执行其指令,将任何中间数据存储在其寄存器中(你也应该考虑缓存内存,就像在游戏中一样)。另一个进程正在等待操作系统提供其运行的时间段:

运行多个进程对操作系统来说是一项复杂的工作。它管理进程的状态,确定哪个进程应该比其他进程占用更多的 CPU 时间等。每个进程在操作系统切换到另一个进程之前都有固定的运行时间。这个时间对于一个进程可能更长,对于另一个进程可能更短。使用优先级表来调度进程。操作系统为优先级更高的进程提供更多的时间,例如,系统进程的优先级高于用户进程。另一个例子可能是,监控网络健康的后台任务的优先级高于计算器应用程序。当提供的时间片用完时,操作系统会启动上下文切换,即,它会存储进程 A的状态以便稍后恢复其执行:

在存储状态之后,如下图所示,它切换到下一个进程来执行:

显然,如果进程 B之前正在运行,它的状态应该被加载回 CPU。同样,当进程 B的时间片(或时间量子)用完时,操作系统会存储它的状态,并将进程 A的状态加载回 CPU(在被操作系统暂停之前的状态):

进程之间没有任何共同之处,或者至少它们认为是这样。每个运行的进程都表现得好像它是系统中唯一的。它拥有操作系统可以提供的所有资源。实际上,操作系统设法让进程彼此不知晓,因此为每个进程模拟了自由。最后,在将进程 A的状态加载回来后,CPU 继续执行它的指令,就好像什么都没有发生过:

进程 B被冻结,直到有新的时间片可用于运行它。

一个单 CPU 运行多个进程类似于一位老师检查学生的考卷。老师一次只能检查一份考卷,尽管他们可以通过逐个检查每个考试的答案来引入一些并发性。首先,他们检查一个学生的第一个问题的答案,然后切换到第二个学生的考试的第一个答案,然后再切换回第一个学生的第二个答案,依此类推。每当老师从一份考卷切换到另一份时,他们都会记下他们停下来的问题的编号。这样,当他们回到同一份考卷时,他们就知道从哪里开始。

同样,操作系统在暂停一个进程以恢复另一个进程之前记录下进程的执行点。第二个进程可以(而且很可能会)使用被暂停进程使用的相同寄存器集。这迫使操作系统将第一个进程的寄存器值存储在某个地方,以便稍后恢复。当操作系统暂停第二个进程以恢复第一个进程时,它会将已保存的寄存器值加载回相应的寄存器中。恢复的进程不会注意到任何差异,并将继续工作,就好像它从未被暂停过一样。

前两段描述的一切都与单 CPU 系统有关。在多 CPU 系统中,系统中的每个 CPU 都有自己的寄存器集。此外,每个 CPU 可以独立地执行程序指令,而不受其他 CPU 的影响,这允许进程并行运行而无需暂停和恢复它们。在这个例子中,一位老师和几个助手类似于一个有三个 CPU 的系统。他们每个人都可以检查一份考卷;他们在任何时候都在检查三份不同的考卷。

进程的挑战

当进程需要以某种方式相互联系时,困难就会出现。比如,一个进程应该计算某些东西并将值传递给一个完全不同的进程。有几种方法可以实现 IPC,其中一种是使用在进程之间共享的内存段。下图描述了两个进程访问共享内存段的情况:

一个进程将计算结果存储到内存中的共享段中,第二个进程从该段中读取。在我们之前的例子中,老师和他们的助手在共享的纸上分享他们的检查结果。另一方面,线程共享进程的地址空间,因为它们在进程的上下文中运行。虽然进程是一个程序,线程是一个函数而不是一个程序。也就是说,一个进程必须至少有一个线程,我们称之为执行线程。线程是在系统中运行的程序的指令容器,而进程封装了线程并为其提供资源。我们大部分的兴趣都在于线程及其编排机制。现在让我们亲自见见它们。

线程

线程是进程范围内可以由操作系统调度的代码部分。虽然进程是运行程序的映像,但与利用多线程的项目相比,管理多进程项目以及 IPC 要困难得多,有时也是无用的。程序处理数据,通常是数据集合。访问、处理和更新数据是通过函数来完成的,这些函数要么是对象的方法,要么是组合在一起以实现最终结果的自由函数。在大多数项目中,我们处理成千上万个函数和对象。每个函数代表一堆指令,这些指令以一个合理的名称包装起来,用于被其他函数调用。多线程旨在并发运行函数以实现更好的性能。

例如,一个计算三个不同向量的和并打印它们的程序调用计算第一个向量的和的函数,然后是第二个向量,最后是最后一个。这一切都是顺序进行的。如果处理单个向量需要 A 的时间,那么程序将在3A的时间内运行。以下代码演示了这个例子:

void process_vector(const std::vector<int>& vec) 
{
 // calculate the sum and print it
}

int main()
{
 std::vector<int> vec1{1, 2, 3, 4, 5};
 std::vector<int> vec2{6, 7, 8, 9, 10};
 std::vector<int> vec3{11, 12, 13, 14, 15};
 process_vector(vec1); // takes A amount of time
 process_vector(vec2); // takes A amount of time
 process_vector(vec3); // takes A amount of time
}

如果有一种方法可以同时为三个不同的向量运行相同的函数,那么在前面的例子中整个程序只需要 A 的时间。执行线程,或者说线程,是并发运行任务的确切方式。通过任务,我们通常指的是一个函数,尽管你也应该记住std::packaged_task。再次强调,并发不应与并行混淆。当我们谈论线程并发运行时,你应该考虑之前讨论的进程的上下文切换。几乎同样适用于线程。

std::packaged_task类似于std::function。它包装了一个可调用对象——函数、lambda、函数对象或绑定表达式。与std::packaged_task的区别在于它可以异步调用。本章后面会详细介绍这一点。

每个进程都有一个单一的执行线程,有时被称为主线程。一个进程可以有多个线程,这时我们称之为多线程。线程几乎以与进程相同的方式运行。它们也有上下文切换。

线程彼此独立运行,但因为所有线程都属于同一个进程,它们大部分资源都是共享的。进程占用硬件和软件资源,如 CPU 寄存器和内存段,包括自己的堆栈和堆。虽然进程不与其他进程共享其堆栈或堆,但其线程必须使用进程占用的相同资源。线程的一切生活都发生在进程内部。

然而,线程不共享堆栈。每个线程都有自己的堆栈部分。这种隔离的原因在于,线程只是一个函数,函数本身应该可以访问堆栈来管理其参数和局部变量的生命周期。当我们将相同的函数作为两个(或更多)分别运行的线程运行时,运行时应该以某种方式处理它们的边界。虽然这很容易出错,但你可以通过值或引用将一个变量从一个线程传递到另一个线程。假设我们启动了三个线程,分别运行上面例子中的三个向量的process_vector()函数。你应该想象启动一个线程意味着以某种方式复制底层函数(它的变量但不是指令)并将其独立地运行。在这种情况下,相同的函数将被复制为三个不同的图像,并且每个图像都将独立于其他图像运行,因此每个图像都应该有自己的堆栈。另一方面,堆在线程之间是共享的。因此,基本上我们得到了以下结论:

与进程一样,并发运行的线程不一定是并行运行的。每个线程都会获得一小部分 CPU 时间来运行,而且从一个线程切换到另一个线程也会有开销。每个暂停的线程状态都应该被存储在某个地方,以便在恢复时能够恢复。CPU 的内部结构定义了线程是否能够真正并行运行。CPU 核心的数量定义了可以真正并行运行的线程数量。

C++线程库提供了hardware_concurrency()函数,用于查找可以真正并发运行的线程数量。在设计并发代码时,可以参考这个数字。

下图描述了两个 CPU,每个 CPU 都有四个核心。每个核心可以独立地运行一个线程:

不仅两个进程并行运行,它们的线程也使用 CPU 核心并行运行。那么,如果我们有几个线程但只有一个单核 CPU,情况会如何改变呢?几乎与我们之前为进程所说明的情况相同。看看下面的图表——它描述了 CPU 如何在某个时间片段内执行线程 1

当前活动的进程 A有两个同时运行的线程。在每个指定的时间点,只有一个线程被执行。当线程 1的时间片用完时,线程 2被执行。与我们讨论过的进程模型的不同之处在于,线程共享进程的资源,如果我们不关心并发代码设计问题,这会导致不自然的行为。让我们深入了解 C++线程支持,并找出在使用多线程时会出现什么问题。

使用线程

当 C++程序启动时,也就是main()函数开始执行时,你可以创建并启动新的线程,这些线程将与主线程并发运行。要在 C++中启动一个线程,你应该声明一个线程对象,并将要与主线程并发运行的函数传递给它。以下代码演示了使用<thread>中定义的std::thread声明和启动线程:

#include <thread> #include <iostream>

void foo() { std::cout << "Testing a thread in C++" << std::endl; }

int main() 
{
 std::thread test_thread{foo};
}

就是这样。我们可以创建一个更好的例子来展示两个线程如何同时工作。假设我们同时在循环中打印数字,看看哪个线程打印了什么:

#include <thread>
#include <iostream>

void print_numbers_in_background() 
{
 auto ix{0};  // Attention: an infinite loop!
 while (true) {
 std::cout << "Background: " << ix++ << std::endl;
 }
}

int main()
{
 std::thread background{print_numbers_in_background};
  auto jx{0};
  while (jx < 1000000) {
    std::cout << "Main: " << jx++ << std::endl;
  }
}

上面的例子将打印出带有Main:Background:前缀混合在一起的两个输出。输出的摘录可能如下所示:

...
Main: 90
Main: 91
Background: 149
Background: 150
Background: 151
Background: 152
Background: 153
Background: 
Main: 92
Main: 93
...

当主线程完成其工作(向屏幕打印一百万次)时,程序希望在不等待后台线程完成的情况下结束。这会导致程序终止。让我们看看如何修改之前的例子。

等待线程

如果要等待线程完成,thread类提供了join()函数。以下是等待background线程的修改版本的示例:

#include <thread>
#include <iostream>

void print_numbers_in_background()
{
  // code omitted for brevity
}

int main()
{
  std::thread background{print_numbers_in_background};
  // the while loop omitted for brevity
 background.join();
}

正如我们之前讨论的,thread函数作为一个独立的实体运行,独立于其他线程-甚至是启动它的线程。它不会等待它刚刚启动的线程,这就是为什么您应该明确告诉调用函数在自己之前等待它完成。在它完成之前,必须发出信号表明调用线程(主线程)正在等待线程完成。

join()函数的对称相反是detach()函数。detach()函数表示调用者对等待线程完成不感兴趣。在这种情况下,线程可以有独立的生命周期。就像这里显示的(就像它已经 18 岁了):

std::thread t{foo};
t.detach(); 

尽管分离线程可能看起来很自然,但有很多情况需要等待线程完成。例如,我们可能会将局部变量传递给正在运行的线程。在这种情况下,我们不能让调用者分离线程,因为调用者可能比线程更早完成其工作。让我们为了清晰起见举个例子。Thread 1声明了loc变量并将其传递给了从Thread 1启动的Thread 2

如果Thread 1Thread 2之前完成其执行,那么通过地址访问loc会导致未定义的行为:

不再有这样的对象,因此我们可以希望程序最好崩溃。这将导致意外行为,因为运行线程将不再访问调用者的局部变量。您应该加入或分离线程。

我们可以将任何可调用对象传递给std::thread。以下示例显示了将 lambda 表达式传递给线程:

#include <thread>

int main() {
  std::thread tl{[]{
 std::cout << "A lambda passed to the thread";
 }};
  tl.join();
}

此外,我们可以使用可调用对象作为线程参数。看一下以下代码,声明了具有重载的operator()函数的TestTask类:

#include <thread>

class TestTask
{
public:
  TestTask() = default;

 void operator()() {
 state_++;
 }

private:
  int state_ = 0;
};

int main() {
  std::thread t{TestTask()};
  t.join();
}

函数对象(具有重载的operator()函数的TestTask类)的一个优点是它能够存储状态信息。函数对象是命令设计模式的一个美丽实现,我们将在第十一章中讨论,使用设计模式设计策略游戏。回到线程,让我们继续讨论语言中的一个新添加,它允许更好地加入线程的方式。

使用 std::jthread

C++20 引入了可加入线程std::jthread。它提供了与std::thread相同的接口,因此我们可以在代码中用 jthreads 替换所有线程。它实际上是对std::thread的封装,因此基本上是将操作委托给封装的线程。

如果您的编译器版本不支持std::jthread,您可以选择使用RAII资源获取即初始化)习惯用法,这对线程非常适用。看一下以下代码:

class thread_raii
{
public:
  explicit thread_raii(std::thread& t)
    : thread_(std::move(t))
  {}

  ~thread_raii() {
    thread_.join();  
  }

private:
  std::thread thread_;
};

void foo() {
  std::cout << "Testing thread join";
}

int main() {
 std::thread t{foo};
 thread_raii r{t};
  // will automatically join the thread
}

然而,前面的代码缺少了一个额外的检查,因为传递给 RAII 类的线程可能已经被分离。为了查看线程是否可以加入,我们使用joinable()函数。这是我们应该如何重写thread_raii类的方式:

class thread_raii
{
public:
  explicit thread_raii(std::thread& t)
    : thread_(std::move(t))
  {}

 ~thread_raii()
 {
 if (thread_.joinable()) {
 thread_.join();
 }
 }
private:
  std::thread thread_;
};

在调用join()函数之前,析构函数首先测试线程是否可加入。但是,与其处理习惯用法并担心线程在加入之前是否已经加入,我们更喜欢使用std::jthread。以下是如何使用先前声明的TestTask函数来做到这一点:

std::jthread jt{TestTask()};

就是这样——不需要调用jt.join(),并且我们使用std::jthread内置的新的协作可中断功能。我们说jthread是协作可中断的,因为它提供了request_stop()函数,它做了它的名字所说的事情——请求线程停止。尽管请求的实现是定义的,但这是一个不必永远等待线程的好方法。回想一下线程在无限循环中打印数字的例子。我们修改了主线程来等待它,这导致永远等待它。下面是我们如何使用std::jthread修改线程以利用request_stop()函数:

int main()
{
 std::jthread background{print_numbers_in_background};
  auto jx{0};
  while (jx < 1000000) {
    std::cout << "Main: " << jx << std::endl;
  }
  // The main thread is about to finish, so we request the background thread to stop
 background.request_stop();
}

print_numbers_in_background()函数现在接收到一个请求,并可以相应地行为。现在,让我们看看如何将参数传递给线程函数。

将参数传递给线程函数

std::thread构造函数接受参数并将它们转发给底层的thread函数。例如,要将参数42传递给foo()函数,我们将参数传递给std::thread构造函数:

void foo(int one, int two) {
  // do something
}

std::thread t{foo, 4, 2};

42参数将作为foo()函数的第一个和第二个参数传递。

以下示例说明了通过引用传递参数:

class big_object {};

void make_changes(big_object&);

void error_prone()
{
  big_object b;
 std::jthread t{make_changes, b};
  // do something else
}

为了理解为什么我们将函数命名为error_prone,我们应该知道线程构造函数会复制传递给它的值,然后使用rvalue引用将它们传递给线程函数。这是为了处理仅可移动类型。因此,它将尝试使用rvalue调用make_changes()函数,这将无法编译通过(不能将rvalue传递给期望非常量引用的函数)。我们需要在需要引用的参数中使用std::ref进行包装。

std::thread t{make_changes, std::ref(b)};

前面的代码强调了参数应该通过引用传递。处理线程需要更加注意,因为程序中有许多方法可以获得意外结果或未定义的行为。让我们看看如何管理线程以生成更安全的多线程应用程序。

管理线程和共享数据

正如之前讨论的,线程的执行涉及暂停和恢复其中一些线程,如果线程数量超过硬件支持的并行运行线程数量。除此之外,线程的创建也有开销。在项目中处理有许多线程的建议做法之一是使用线程池。

线程池的概念在于缓存的概念。我们创建并保留线程在某个容器中以便以后使用。这个容器称为池。例如,以下向量表示一个简单的线程池:

#include <thread>
#include <vector>

std::vector<std::thread> pool;

每当我们需要一个新线程时,我们不是声明相应的std::thread对象,而是使用已在池中创建的线程。当我们完成线程时,我们可以将其推回向量以便以后使用。这在处理 10 个或更多线程时可以节省一些时间。一个合适的例子是一个 Web 服务器。

Web 服务器是一个等待传入客户端连接并为每个客户端创建一个独立连接以独立处理的程序。一个典型的 Web 服务器通常同时处理数千个客户端。每当与某个客户端启动新连接时,Web 服务器都会创建一个新线程并处理客户端请求。以下伪代码演示了 Web 服务器传入连接管理的简单实现:

void process_incoming_connections() {
  if (new connection from client) {
    t = create_thread(); // potential overhead
    t.handle_requests(client);
  }
}
while (true) {
  process_incoming_connections();
}

使用线程池时,前面的代码将避免每次处理客户端请求时都创建一个线程。创建新线程需要操作系统额外且昂贵的工作。为了节省时间,我们使用一种机制,可以在每个请求时省略创建新线程。为了使线程池更好,让我们用队列替换它的容器。每当我们请求一个线程时,线程池将返回一个空闲线程,每当我们完成一个线程时,我们将其推回线程池。线程池的简单设计如下:

#include <queue>
#include <thread>

class ThreadPool
{
public:
  ThreadPool(int number_of_threads = 1000) {
    for (int ix = 0; ix < number_of_threads; ++ix) {
      pool_.push(std::thread());
    }
  }

  std::thread get_free_thread() {
    if (pool_.empty()) {
      throw std::exception("no available thread");
    }
    auto t = pool_.front();
    pool_.pop();
    return t;
  }

  void push_thread(std::thread t) {
    pool_.push(t);
  }

private:
  std::queue<std::thread> pool_;
};

构造函数创建并将线程推送到队列。在下面的伪代码中,我们用之前介绍的ThreadPool替换了直接创建线程来处理客户端请求:

ThreadPool pool;
void process_incoming_connections() {
  if (new connection from client) {
    auto t = pool.get_free_thread();
    t.handle_request(client);
  }
}

while (true) {
  process_incoming_connections();
}

假设handle_request()函数在完成时将线程推回线程池,那么线程池就像是连接线程的集中存储。虽然前面的片段远未准备好投入生产,但它传达了在密集应用中使用线程池的基本思想。

共享数据

竞争条件是多线程程序员害怕并尽量避免的事情。想象一下两个函数同时处理相同的数据,如下所示:

int global = 0;

void inc() {
  global = global + 1;
}
...
std::thread t1{inc};
std::thread t2{inc};

可能发生竞争条件,因为线程t1t2正在用多个步骤修改相同的变量。在单个线程安全步骤中执行的任何操作称为原子操作。在这种情况下,即使使用增量运算符,增加变量的值也不是原子操作。

使用互斥锁保护共享数据

为了保护共享数据,广泛使用称为互斥锁的对象。互斥锁是控制线程运行的对象。想象线程就像人类一样,一次处理数据的交易。当一个线程锁定一个互斥锁时,另一个线程会等待,直到它完成数据并解锁互斥锁。然后另一个线程锁定互斥锁并开始处理数据。以下代码演示了如何使用互斥锁解决竞争条件的问题:

#include <mutex>
...
std::mutex locker;
void inc() {
  locker.lock();
  global = global + 1;
  locker.unlock();
}
...
std::thread t1{inc};
std::thread t2{inc};

t1开始执行inc()时,它锁定一个互斥锁,这样可以避免其他线程访问全局变量,除非原始线程不解锁下一个线程。

C++17 引入了锁保护,允许保护互斥锁,以免忘记解锁它:

std::mutex locker;
void inc() {
  std::lock_guard g(locker);
  global = global + 1;
}

如果可能的话,最好使用语言提供的保护。

避免死锁

互斥锁会带来新的问题,比如死锁。死锁是多线程代码的一种情况,当两个或多个线程锁定一个互斥锁并等待另一个解锁时发生。

避免死锁的常见建议是始终以相同的顺序锁定两个或多个互斥锁。C++提供了std::lock()函数,用于相同的目的。

以下代码说明了swap函数,它接受两个类型为X的参数。我们假设X有一个名为mt的成员,它是一个互斥锁。swap函数的实现首先锁定左对象的互斥锁,然后锁定右对象的互斥锁:

void swap(X& left, X& right)
{
  std::lock(left.mt, right.mt);
  std::lock_guard<std::mutex> lock1(left.mt, std::adopt_lock);
  std::lock_guard<std::mutex> lock2(right.mt, std::adopt_lock);
  // do the actual swapping
}

为了一般地避免死锁,避免嵌套锁。也就是说,如果已经持有一个锁,则不要获取另一个锁。如果不是这种情况,则按固定顺序获取锁。固定顺序将允许您避免死锁。

设计并发代码

当并发引入时,项目复杂性会急剧上升。与并发对应的同步代码相比,处理顺序执行的同步代码要容易得多。许多系统通过引入事件驱动开发概念(如事件循环)来避免使用多线程。使用事件循环的目的是引入一种可管理的异步编程方法。进一步想象,任何提供图形用户界面(GUI)的应用程序。每当用户点击任何 GUI 组件,如按钮;在字段中输入;甚至移动鼠标时,应用程序都会接收有关用户操作的所谓事件。无论是button_pressbutton_releasemouse_move还是其他任何事件,它都代表了应用程序对信息的正确反应。一种流行的方法是将事件循环结合起来,以排队用户交互期间发生的任何事件。

当应用程序忙于当前任务时,由用户操作产生的事件被排队等待在将来的某个时间进行处理。处理涉及调用附加到每个事件的处理程序函数。它们按照它们被放入队列的顺序进行调用。

将多线程引入项目会带来额外的复杂性。现在,您需要关注竞争条件和适当的线程处理,甚至可能使用线程池来重用线程对象。在顺序执行的代码中,您只关心代码。使用多线程,您现在需要更多地关注相同代码的执行方式。例如,一个简单的设计模式,如单例,在多线程环境中的行为会有所不同。单例的经典实现如下:

class MySingleton
{
public:
 static MySingleton* get_instance() {
 if (instance_ == nullptr) {
 instance_ = new MySingleton();
 }
 return instance_;
 }

  // code omitted for brevity
private:
  static inline MySingleton* instance_ = nullptr;
};

以下代码启动了两个线程,都使用了MySingleton类:

void create_something_unique() 
{
 MySingleton* inst = MySingleton::get_instance();
  // do something useful
}

void create_something_useful() 
{
  MySingleton* anotherInst = MySingleton::get_instance();
  // do something unique
}  

std::thread t1{create_something_unique};
std::thread t2{create_something_useful};
t1.join();
t2.join();
// some other code

线程t1t2都调用MySingleton类的get_instance()静态成员函数。可能t1t2都通过了对空实例的检查,并且都执行了新操作符。很明显,这里存在竞争条件。在这种情况下,资源(在本例中是类实例)应该受到保护。以下是使用互斥量的明显解决方案:

class MySingleton
{
public:
  static MySingleton* get_instance() {
 std::lock_guard lg{mutex_};
    if (instance_ == nullptr) {
      instance_ = new MySingleton();
    }
    return instance_;
  }

  // code omitted for brevity
private:
 static std::mutex mutex_;
  static MySingleton* instance_;
}

使用互斥量可以解决问题,但会使函数的工作速度变慢,因为每次线程请求一个实例时,都会锁定一个互斥量(这涉及操作系统内核的额外操作)。正确的解决方案是使用双重检查锁定模式。它的基本思想是这样的:

  1. instance_检查后锁定互斥量。

  2. 在锁定互斥量后再次检查instance_,因为另一个线程可能已经通过了第一次检查,并等待互斥量解锁。

有关详细信息,请参阅代码:

static MySingleton* get_instance() {
  if (instance_ == nullptr) {
 std::lock_guard lg{mutex_};
 if (instance_ == nullptr) {
 instance_ = new MySingleton();
 }
  }
  return instance_;
}

几个线程可能通过第一次检查,其中一个线程将锁定互斥量。只有一个线程可以进行新操作符调用。然而,在解锁互斥量后,通过第一次检查的线程将尝试锁定它并创建实例。第二次检查是为了防止这种情况发生。上述代码使我们能够减少同步代码的性能开销。我们提供的方法是为并发代码设计做好准备的一种方式。

并发代码设计在很大程度上基于语言本身的能力。C++的发展是非常了不起的。在最早的版本中,它没有内置支持多线程。现在,它有一个稳固的线程库,而新的 C++20 标准为我们提供了更强大的工具,如协程。

引入协程

在讨论 GUI 应用程序时,我们讨论了异步代码执行的一个例子。GUI 组件通过触发相应的事件来对用户操作做出反应,这些事件被推送到事件队列中。然后,这些队列会通过调用附加的处理程序函数逐个进行处理。所描述的过程在一个循环中发生;这就是为什么我们通常将这个概念称为事件循环。

异步系统在 I/O 操作中非常有用,因为任何输入或输出操作都会在 I/O 调用点阻塞执行。例如,以下伪代码从目录中读取文件,然后在屏幕上打印欢迎消息:

auto f = read_file("filename");
cout << "Welcome to the app!";
process_file_contents(f);

与同步执行模式相结合,我们知道只有在read_file()函数执行完成后才会打印出欢迎来到应用程序!process_file_contents()将在cout完成后调用。处理异步代码时,我们对代码执行的了解开始表现得像是一些无法识别的东西。以下修改版本的前面的例子使用read_file_async()函数异步读取文件内容:

auto p = read_file_async("filename");
cout << "Welcome to the app!";
process_file_contents(p); // we shouldn't be able to do this

考虑到read_file_async()是一个异步函数,欢迎来到应用程序!的消息将比文件内容更早打印出来。异步执行的本质允许我们调用要在后台执行的函数,这为我们提供了非阻塞的输入/输出。

然而,我们对函数的返回值处理方式有一点变化。如果我们处理一个异步函数,它的返回值被视为一种称为承诺承诺对象的东西。这是系统在异步函数完成时通知我们的方式。承诺对象有三种状态:

  • 挂起

  • 拒绝

  • 实现

承诺对象在函数完成并且结果准备好被处理时被认为是已实现的。在发生错误时,承诺对象将处于拒绝状态。如果承诺既没有被拒绝也没有被实现,它就处于挂起状态。

C++20 引入了协程作为经典异步函数的补充。协程将代码的后台执行提升到了下一个级别;它们允许函数在必要时暂停和恢复。想象一个读取文件内容并在中途停止的函数,将执行上下文传递给另一个函数,然后恢复文件的读取直到结束。因此,在深入研究之前,将协程视为以下函数:

  • 开始

  • 暂停

  • 恢复

  • 完成

要使函数成为协程,您可以使用关键字co_awaitco_yieldco_return之一。co_await是一个构造,告诉代码等待异步执行的代码。这意味着函数可以在那一点被暂停,并在结果准备好时恢复执行。例如,以下代码使用套接字从网络请求图像:

task<void> process_image()
{
  image i = co_await request_image("url");
  // do something useful with the image
}

由于网络请求操作也被视为输入/输出操作,它可能会阻塞代码的执行。为了防止阻塞,我们使用异步调用。在前面的例子中使用co_await的行是函数执行可能被暂停的地方。简单来说,当执行到达带有co_await的行时,会发生以下情况:

  1. 它暂时退出函数(直到没有准备好的数据)。

  2. 它继续执行process_image()被调用之前的位置。

  3. 然后它再次回来继续执行process_image()在它离开的地方。

为了实现这一点,协程(process_image()函数是一个协程)在 C++中不像处理常规函数那样处理。协程的一个有趣甚至令人惊讶的特性是它们是无堆栈的。我们知道函数不能没有堆栈。这是函数在执行指令之前推送其参数和局部变量的地方。另一方面,协程不是将任何东西推送到堆栈,而是将它们的状态保存在堆中,并在恢复时恢复它们。

这很棘手,因为还有堆栈式协程。堆栈式协程,也称为纤程,有一个单独的堆栈。

协程与调用者相连。在前面的例子中,调用sprocess_image()的函数将执行转移到协程,协程的暂停(也称为yielding)将执行返回给调用者。正如我们所说,堆用于存储协程的状态,但实际的函数特定数据(参数和局部变量)存储在调用者的堆栈上。就是这样——协程与存储在调用函数堆栈上的对象相关联。显然,协程的生存期与其对象一样长。

协程可能会给人一种错误的印象,认为它增加了语言的冗余复杂性,但它们的用例在改进使用异步 I/O 代码(如前面的例子中)或延迟计算的应用程序中非常好。也就是说,当我们不得不发明新的模式或引入复杂性来处理懒惰计算等项目时,现在我们可以通过在 C++中使用协程来改善我们的体验。请注意,异步 I/O 或延迟计算只是协程应用的两个例子。还有更多。

摘要

在本章中,我们讨论了并发的概念,并展示了并行之间的区别。我们学习了进程和线程之间的区别,后者引起了我们的兴趣。多线程使我们能够更有效地管理程序,尽管它也带来了额外的复杂性。为了处理数据竞争,我们使用诸如互斥锁之类的同步原语。互斥锁是一种锁定一个线程使用的数据的方式,以避免多个线程同时访问相同数据产生的无效行为。

我们还讨论了输入/输出操作被认为是阻塞的概念,而异步函数是使其非阻塞的方法之一。协程作为代码的异步执行的一部分在 C++20 中被引入。

我们学习了如何创建和启动线程。更重要的是,我们学习了如何在线程之间管理数据。在下一章中,我们将深入研究在并发环境中使用的数据结构。

问题

  1. 并发是什么?

  2. 并发和并行之间的区别是什么?

  3. 什么是进程?

  4. 进程和线程之间的区别是什么?

  5. 编写代码启动一个线程。

  6. 如何使单例模式线程安全?

  7. 重写MySingleton类,使用std::shared_ptr返回实例。

  8. 什么是协程,co_await关键字用于什么?

进一步阅读

第九章:设计并发数据结构

在上一章中,我们简要介绍了 C++中并发和多线程的基础知识。并发代码设计中最大的挑战之一是正确处理数据竞争。线程同步和协调并不是一个容易理解的话题,尽管我们可能认为它是最重要的话题。虽然我们可以在任何我们对数据竞争有丝毫怀疑的地方使用互斥量等同步原语,但这并不是我们建议的最佳实践。

设计并发代码的更好方式是尽量避免使用锁。这不仅会提高应用程序的性能,还会使其比以前更安全。说起来容易做起来难——无锁编程是一个具有挑战性的话题,我们将在本章中介绍。特别是,我们将更深入地了解设计无锁算法和数据结构的基础知识。这是一个由许多杰出的开发人员不断研究的难题。我们将简要介绍无锁编程的基础知识,这将让您了解如何以高效的方式构建代码。阅读完本章后,您将更好地能够理解数据竞争问题,并获得设计并发算法和数据结构所需的基本知识。这也可能有助于您的一般设计技能,以构建容错系统。

本章将涵盖以下主题:

  • 理解数据竞争和基于锁的解决方案

  • 在 C++代码中使用原子操作

  • 设计无锁数据结构

技术要求

本章中使用 g++编译器的-std=c++2a选项来编译示例。您可以在以下链接找到本章中使用的源文件:github.com/PacktPublishing/Expert-CPP

更深入地了解数据竞争

正如已经多次提到的,数据竞争是程序员们尽量避免的情况。在上一章中,我们讨论了死锁及其避免方法。上一章中我们使用的最后一个示例是创建一个线程安全的单例模式。假设我们使用一个类来创建数据库连接(一个经典的例子)。

以下是一个跟踪数据库连接的模式的简单实现。每次需要访问数据库时保持单独的连接并不是一个好的做法。相反,我们可以重用现有的连接来从程序的不同部分查询数据库:

namespace Db {
  class ConnectionManager 
  {
  public:
    static std::shared_ptr<ConnectionManager> get_instance()
 {
 if (instance_ == nullptr) {
 instance_.reset(new ConnectionManager());
 }
 return instance_;
 }

    // Database connection related code omitted
  private:
    static std::shared_ptr<ConnectionManager> instance_{nullptr};
  };
}

让我们更详细地讨论这个例子。在上一章中,我们加入了锁来保护get_instance()函数免受数据竞争的影响。让我们详细说明为什么这样做。为了简化这个例子,以下是我们感兴趣的四行:

get_instance()
  if (_instance == nullptr)
    instance_.reset(new)
  return instance_;

现在,想象一下我们运行一个访问get_instance()函数的线程。我们称它为线程 A,它执行的第一行是条件语句,如下所示:

get_instance()
  if (_instance == nullptr)   <--- Thread A
    instance_.reset(new)
  return instance_;

它将逐行执行指令。我们更感兴趣的是第二个线程(标记为线程 B),它开始并发执行线程 A的函数。在函数并发执行期间可能出现以下情况:

get_instance()
  if (_instance == nullptr)   <--- Thread B (checking)
    instance_.reset(new)      <--- Thread A (already checked)
  return instance_;

线程 B在将instance_nullptr进行比较时得到了一个正结果。线程 A已经通过了相同的检查,并将instance_设置为一个新对象。从线程 A的角度来看,一切都很正常,它刚刚通过了条件检查,重置了instances,并将继续执行下一行返回instance_。然而,线程 B在它的值改变之前就比较了instance_。因此,线程 B也继续设置instance_的值:

get_instance()
  if (_instance == nullptr)   
    instance_.reset(new)      <--- Thread B (already checked)
  return instance_;           <--- Thread A (returns)

前面的问题是线程 Binstance_已经被设置之后重置了它。此外,我们将get_instance()视为一个单独的操作;它由几条指令组成,每条指令都由一个线程按顺序执行。为了让两个线程不相互干扰,操作不应该包含多于一条指令。

我们关注数据竞争的原因是代码块中的间隙。代码行之间的这个间隙允许线程相互干扰。当你使用互斥锁等同步原语设计解决方案时,你应该考虑你错过的所有间隙,因为解决方案可能不正确。下面的修改使用了在前一章讨论过的互斥锁和双重检查锁定模式:

static std::shared_ptr<ConnectionManager> get_instance()
{
  if (instance_ == nullptr) {
    // mutex_ is declared in the private section
 std::lock_guard lg{mutex_};
 if (instance_ == nullptr) { // double-checking
 instance_.reset(new ConnectionManager());
 }
  }
  return instance_;
}

当两个线程尝试访问instance_对象时会发生什么:

get_instance()
  if (instance_ == nullptr)     <--- Thread B
    lock mutex                  <--- Thread A (locks the mutex)
    if (instance_ == nullptr)
      instance_.reset(new)
    unlock mutex
  return instance_

现在,即使两个线程都通过了第一次检查,其中一个线程也会锁定互斥锁。当一个线程尝试锁定互斥锁时,另一个线程会重置实例。为了确保它尚未设置,我们使用第二次检查(这就是为什么它被称为双重检查锁定):

get_instance()
  if (instance_ == nullptr)
    lock mutex                  <--- Thread B (tries to lock, waits)
    if (instance_ == nullptr)   <--- Thread A (double check)
      instance_.reset(new)      
    unlock mutex
  return instance_

线程 A完成设置instance_后,它会解锁互斥锁,这样线程 B就可以继续锁定和重置instance_

get_instance()
  if (instance_ == nullptr)
    lock mutex                  <--- Thread B (finally locks the mutex)
    if (instance_ == nullptr)   <--- Thread B (check is not passed)
      instance_.reset(new)      
    unlock mutex                <--- Thread A (unlocked the mutex)
  return instance_              <--- Thread A (returns)  

根据经验法则,你应该总是查看代码中的细节。两个语句之间总是有一个间隙,这个间隙会导致两个或更多的线程相互干扰。接下来的部分将详细讨论一个经典的递增数字的例子。

同步递增

几乎每本涉及线程同步主题的书都使用递增数字的经典例子作为数据竞争的例子。这本书也不例外。例子如下:

#include <thread>

int counter = 0;

void foo()
{
 counter++;
}

int main()
{
  std::jthread A{foo};
  std::jthread B{foo};
  std::jthread C{[]{foo();}};
  std::jthread D{
    []{
      for (int ix = 0; ix < 10; ++ix) { foo(); }
    }
  };
}

我们添加了几个线程,使示例变得更加复杂。前面的代码只是使用四个不同的线程递增counter变量。乍一看,任何时候只有一个线程递增counter。然而,正如我们在前一节中提到的,我们应该注意并寻找代码中的间隙。foo()函数似乎缺少一个。递增运算符的行为如下(伪代码):

auto res = counter;
counter = counter + 1;
return res;

现在,我们发现了本不应该有的间隙。因此,任何时候只有一个线程执行前面三条指令中的一条。也就是说,类似下面的情况是可能的:

auto res = counter;     <--- thread A
counter = counter + 1;  <--- thread B
return res;             <--- thread C

例如,线程 B可能在线程 A读取其先前值时修改counter的值。这意味着线程 A线程 B已经完成递增counter时会给counter赋予一个新的递增值。混乱引入了混乱,迟早,我们的大脑会因为尝试理解操作的顺序而爆炸。作为一个经典的例子,我们将继续使用线程锁定机制来解决这个问题。以下是一个常见的解决方案:

#include <thread>
#include <mutex>

int counter = 0;
std::mutex m;

void foo()
{
 std::lock_guard g{m};
  counter++;
}

int main()
{
  // code omitted for brevity
}

无论哪个线程首先到达lock_guard都会锁定mutex,如下所示:

lock mutex;             <--- thread A, B, D wait for the locked mutex 
auto res = counter;     <--- thread C has locked the mutex
counter = counter + 1;
unlock mutex;           *<--- A, B, D are blocked until C reaches here*
return res;             

使用锁定的问题在于性能。理论上,我们使用线程来加快程序执行,更具体地说,是数据处理。在处理大量数据的情况下,使用多个线程可能会极大地提高程序的性能。然而,在多线程环境中,我们首先要处理并发访问,因为使用多个线程访问集合可能会导致其损坏。例如,让我们看一个线程安全的堆栈实现。

实现线程安全的堆栈

回想一下来自第六章的栈数据结构适配器,《深入 STL 中的数据结构和算法》。我们将使用锁来实现栈的线程安全版本。栈有两个基本操作,pushpop。它们都修改容器的状态。正如您所知,栈本身不是一个容器;它是一个包装容器并提供适应接口以进行访问的适配器。我们将在一个新的类中包装std::stack,并加入线程安全性。除了构造和销毁函数外,std::stack提供以下函数:

  • top(): 访问栈顶元素

  • empty(): 如果栈为空则返回 true

  • size(): 返回栈的当前大小

  • push(): 将新项插入栈中(在顶部)

  • emplace(): 在栈顶就地构造一个元素

  • pop(): 移除栈顶元素

  • swap(): 与另一个栈交换内容

我们将保持简单,专注于线程安全的概念,而不是制作功能强大的完整功能栈。这里的主要关注点是修改底层数据结构的函数。我们感兴趣的是push()pop()函数。这些函数可能在多个线程相互干扰时破坏数据结构。因此,以下声明是表示线程安全栈的类:

template <typename T>
class safe_stack
{
public:
  safe_stack();
  safe_stack(const safe_stack& other);
  void push(T value); // we will std::move it instead of copy-referencing
  void pop();
  T& top();
  bool empty() const;

private:
  std::stack<T> wrappee_;
  mutable std::mutex mutex_;
};

请注意,我们将mutex_声明为可变的,因为我们在empty() const 函数中对其进行了锁定。这可能是一个比去除empty()的 const 性更好的设计选择。然而,您现在应该知道,对于任何数据成员使用可变性都意味着我们做出了糟糕的设计选择。无论如何,safe_stack的客户端代码不会太关心实现的内部细节;它甚至不知道栈使用互斥锁来同步并发访问。

现在让我们来看一下其成员函数的实现以及简短的描述。让我们从复制构造函数开始:

safe_stack::safe_stack(const safe_stack& other)
{
  std::lock_guard<std::mutex> lock(other.mutex_);
  wrappee_ = other.wrappee_;
}

请注意,我们锁定了另一个栈的互斥锁。尽管这看起来不公平,但我们需要确保在复制它时,另一个栈的底层数据不会被修改。

接下来,让我们来看一下push()函数的实现。显然很简单;我们锁定互斥锁并将数据推入底层栈:

void safe_stack::push(T value)
{
  std::lock_guard<std::mutex> lock(mutex_);
  // note how we std::move the value
  wrappee_.push(std::move(value));
}

几乎所有函数都以相同的方式包含线程同步:锁定互斥锁,执行任务,然后解锁互斥锁。这确保了一次只有一个线程访问数据。也就是说,为了保护数据免受竞态条件的影响,我们必须确保函数不变量不被破坏。

如果您不喜欢输入长的 C++类型名称,比如std::lock_guard<std::mutex>,可以使用using关键字为类型创建短别名,例如,使用locker = std::guard<std::mutex>;

现在,让我们来看一下pop()函数,我们可以修改类声明,使pop()直接返回栈顶的值。我们这样做主要是因为我们不希望有人在另一个线程中访问栈顶(通过引用),然后从中弹出数据。因此,我们将修改pop()函数以创建一个共享对象,然后返回栈元素:

std::shared_ptr<T> pop()
{
  std::lock_guard<std::mutex> lock(mutex_);
  if (wrappee_.empty()) {
    throw std::exception("The stack is empty");
  }
  std::shared_ptr<T> top_element{std::make_shared<T>(std::move(wrappee_.top()))};
  wrappee_.pop();
  return top_element;
}

请注意,safe_stack类的声明也应根据pop()函数的修改而改变。此外,我们不再需要top()

设计无锁数据结构

如果至少有一个线程保证可以取得进展,那么我们称它是无锁函数。与基于锁的函数相比,其中一个线程可以阻塞另一个线程,它们可能都在等待某些条件才能取得进展,无锁状态确保至少一个线程取得进展。我们说使用数据同步原语的算法和数据结构是阻塞的,也就是说,线程被挂起,直到另一个线程执行操作。这意味着线程在解除阻塞之前无法取得进展(通常是解锁互斥锁)。我们感兴趣的是不使用阻塞函数的数据结构和算法。我们称其中一些为无锁,尽管我们应该区分非阻塞算法和数据结构的类型。

使用原子类型

在本章的前面,我们介绍了源代码行之间的间隙是数据竞争的原因。每当您有一个由多个指令组成的操作时,您的大脑都应该警惕可能出现的问题。然而,无论您多么努力使操作独立和单一,大多数情况下,您都无法在不将操作分解为涉及多个指令的步骤的情况下取得任何成果。C++通过提供原子类型来拯救我们。

首先,让我们了解为什么使用原子这个词。一般来说,我们理解原子是指不能分解成更小部分的东西。也就是说,原子操作是一个无法半途而废的操作:要么完成了,要么没有。原子操作的一个例子可能是对整数的简单赋值:

num = 37;

如果两个线程访问这行代码,它们都不可能遇到它是半成品的情况。换句话说,赋值之间没有间隙。当然,如果num表示具有用户定义赋值运算符的复杂对象,同一语句可能会有很多间隙。

原子操作是不可分割的操作。

另一方面,非原子操作可能被视为半成品。经典的例子是我们之前讨论过的增量操作。在 C++中,对原子类型的所有操作也是原子的。这意味着我们可以通过使用原子类型来避免行之间的间隙。在使用原子操作之前,我们可以通过使用互斥锁来创建原子操作。例如,我们可能会考虑以下函数是原子的:

void foo()
{
  mutex.lock();
  int a{41};
  int b{a + 1};
  mutex.unlock();
}

真正的原子操作和我们刚刚制作的假操作之间的区别在于原子操作不需要锁。这实际上是一个很大的区别,因为诸如互斥锁之类的同步机制会带来开销和性能惩罚。更准确地说,原子类型利用低级机制来确保指令的独立和原子执行。标准原子类型在<atomic>头文件中定义。然而,标准原子类型可能也使用内部锁。为了确保它们不使用内部锁,标准库中的所有原子类型都公开了is_lock_free()函数。

唯一没有is_lock_free()成员函数的原子类型是std::atomic_flag。对这种类型的操作要求是无锁的。它是一个布尔标志,大多数情况下被用作实现其他无锁类型的基础。

也就是说,如果obj.is_lock_free()返回true,则表示对obj的操作是直接使用原子指令完成的。如果返回 false,则表示使用了内部锁。更重要的是,static constexpr函数is_always_lock_free()在所有支持的硬件上返回true,如果原子类型始终是无锁的。由于该函数是constexpr,它允许我们在编译时定义类型是否是无锁的。这是一个重大进步,以良好的方式影响代码的组织和执行。例如,std::atomic<int>::is_always_lock_free()返回true,因为std::atomic<int>很可能始终是无锁的。

在希腊语中,a 意味着不,tomo 意味着切。原子一词源自希腊语 atomos,意思是不可分割的。也就是说,原子意味着不可分割的最小单位。我们使用原子类型和操作来避免指令之间的间隙。

我们使用原子类型的特化,例如 std::atomic<long>;但是,您可以参考以下表格以获取更方便的原子类型名称。表格的左列包含原子类型,右列包含其特化:

原子类型 特化
atomic_bool std::atomic<bool>
atomic_char std::atomic<char>
atomic_schar std::atomic<signed char>
atomic_uchar std::atomic<unsigned char>
atomic_int std::atomic<int>
atomic_uint std::atomic<unsigned>
atomic_short std::atomic<short>
atomic_ushort std::atomic<unsigned short>
atomic_long std::atomic<long>
atomic_ulong std::atomic<unsigned long>
atomic_llong std::atomic<long long>
atomic_ullong std::atomic<unsigned long long>
atomic_char16_t std::atomic<char16_t>
atomic_char32_t std::atomic<char32_t>
atomic_wchar_t std::atomic<wchar_t>

上表代表了基本的原子类型。常规类型和原子类型之间的根本区别在于我们可以对它们应用的操作类型。现在让我们更详细地讨论原子操作。

原子类型的操作

回想一下我们在前一节讨论的间隙。原子类型的目标是要么消除指令之间的间隙,要么提供将多个指令组合在一起作为单个指令执行的操作。以下是原子类型的操作:

  • load()

  • store()

  • exchange()

  • compare_exchange_weak()

  • compare_exchange_strong()

  • wait()

  • notify_one()

  • notify_all()

load() 操作原子地加载并返回原子变量的值。store() 原子地用提供的非原子参数替换原子变量的值。

load()store() 与非原子变量的常规读取和赋值操作类似。每当我们访问对象的值时,我们执行一个读取指令。例如,以下代码打印了 double 变量的内容:

double d{4.2}; // "store" 4.2 into "d"
std::cout << d; // "read" the contents of "d"

对于原子类型,类似的读取操作转换为:

atomic_int m;
m.store(42);             // atomically "store" the value
std::cout << m.load();   // atomically "read" the contents 

尽管上述代码没有实际意义,但我们包含了这个例子来表示对待原子类型的不同方式。应该通过原子操作来访问原子变量。以下代码表示了 load()store()exchange() 函数的定义:

T load(std::memory_order order = std::memory_order_seq_cst) const noexcept;
void store(T value, std::memory_order order = 
            std::memory_order_seq_cst) noexcept;
T exchange(T value, std::memory_order order = 
            std::memory_order_seq_cst) noexcept;

正如您所见,还有一个名为 order 的额外参数,类型为 std::memory_order。我们很快会对它进行描述。exchange() 函数以一种方式包含了 store()load() 函数,以便原子地用提供的参数替换值,并原子地获取先前的值。

compare_exchange_weak()compare_exchange_strong() 函数的工作方式相似。它们的定义如下:

bool compare_exchange_weak(T& expected_value, T target_value, 
                           std::memory_order order = 
                            std::memory_order_seq_cst) noexcept;
bool compare_exchange_strong(T& expected_value, T target_value,
                            std::memory_order order =
                             std::memory_order_seq_cst) noexcept;

它们将第一个参数(expected_value)与原子变量进行比较,如果它们相等,则用第二个参数(target_value)替换变量。否则,它们会原子地将值加载到第一个参数中(这就是为什么它是通过引用传递的)。弱交换和强交换之间的区别在于 compare_exchange_weak() 允许出现错误(称为虚假失败),也就是说,即使 expected_value 等于底层值,该函数也会将它们视为不相等。这是因为在某些平台上,这会提高性能。

自 C++20 以来,已添加了wait()notify_one()notify_all()函数。wait()函数阻塞线程,直到原子对象的值修改。它接受一个参数与原子对象的值进行比较。如果值相等,它会阻塞线程。要手动解除线程阻塞,我们可以调用notify_one()notify_all()。它们之间的区别在于notify_one()解除至少一个被阻塞的操作,而notify_all()解除所有这样的操作。

现在,让我们讨论我们在先前声明的原子类型成员函数中遇到的内存顺序。std::memory_order定义了原子操作周围的内存访问顺序。当多个线程同时读取和写入变量时,一个线程可以按照与另一个线程存储它们的顺序不同的顺序读取更改。原子操作的默认顺序是顺序一致的顺序 - 这就是std::memory_order_seq_cst的作用。有几种类型的顺序,包括memory_order_relaxedmemory_order_consumememory_order_acquirememory_order_releasememory_order_acq_relmemory_order_seq_cst。在下一节中,我们将设计一个使用默认内存顺序的原子类型的无锁堆栈。

设计无锁堆栈

设计堆栈时要牢记的关键事项之一是确保从另一个线程返回的推送值是安全的。同样重要的是确保只有一个线程返回一个值。

在前面的章节中,我们实现了一个基于锁的堆栈,它包装了std::stack。我们知道堆栈不是一个真正的数据结构,而是一个适配器。通常,在实现堆栈时,我们选择向量或链表作为其基础数据结构。让我们看一个基于链表的无锁堆栈的例子。将新元素推入堆栈涉及创建一个新的列表节点,将其next指针设置为当前的head节点,然后将head节点设置为指向新插入的节点。

如果您对头指针或下一个指针这些术语感到困惑,请重新阅读第六章《深入 STL 中的数据结构和算法》,在那里我们详细讨论了链表。

在单线程环境中,上述步骤是可以的;但是,如果有多个线程修改堆栈,我们应该开始担心。让我们找出push()操作的陷阱。当将新元素推入堆栈时,发生了三个主要步骤:

  1. node* new_elem = new node(data);

  2. new_elem->next = head_;

  3. head_ = new_elem;

在第一步中,我们声明将插入到基础链表中的新节点。第二步描述了我们将其插入到列表的前面 - 这就是为什么新节点的next指针指向head_。最后,由于head_指针表示列表的起始点,我们应该重置其值以指向新添加的节点,就像第 3 步中所做的那样。

节点类型是我们在堆栈中用于表示列表节点的内部结构。以下是它的定义:

template <typename T>
class lock_free_stack
{
private:
 struct node {
 T data;
 node* next;
 node(const T& d) : data(d) {}
 }  node* head_;
// the rest of the body is omitted for brevity
};

我们建议您首先查找代码中的空白 - 不是在前面的代码中,而是在我们描述将新元素推入堆栈时的步骤中。仔细看看。想象两个线程同时添加节点。一个线程在第 2 步中将新元素的下一个指针设置为指向head_。另一个线程使head_指向另一个新元素。很明显,这可能导致数据损坏。对于线程来说,在步骤 2 和 3 中有相同的head_是至关重要的。为了解决步骤 2 和 3 之间的竞争条件,我们应该使用原子比较/交换操作来保证在读取其值之前head_没有被修改。由于我们需要以原子方式访问头指针,这是我们如何修改lock_free_stack类中的head_成员的方式:

template <typename T>
class lock_free_stack
{
private:
  // code omitted for brevity
 std::atomic<node*> head_;  // code omitted for brevity
};

这是我们如何在原子head_指针周围实现无锁push()的方式:

void push(const T& data)
{
  node* new_elem = new node(data);
  new_elem->next = head_.load();
  while (!head_.compare_exchange_weak(new_elem->next, new_elem));
}

我们使用compare_exchange_weak()来确保head_指针的值与我们存储在new_elem->next中的值相同。如果是,我们将其设置为new_elem。一旦compare_exchange_weak()成功,我们就可以确定节点已成功插入到列表中。

看看我们如何使用原子操作访问节点。类型为T的指针的原子形式-std::atomic<T*>-提供相同的接口。除此之外,std::atomic<T*>还提供指针的算术操作fetch_add()fetch_sub()。它们对存储的地址进行原子加法和减法。这是一个例子:

struct some_struct {};
any arr[10];
std::atomic<some_struct*> ap(arr);
some_struct* old = ap.fetch_add(2);
// now old is equal to arr
// ap.load() is equal to &arr[2]

我们故意将指针命名为old,因为fetch_add()将数字添加到指针的地址并返回old值。这就是为什么old指向与arr指向的相同地址。

在下一节中,我们将介绍更多可用于原子类型的操作。现在,让我们回到我们的无锁栈。要pop()一个元素,也就是移除一个节点,我们需要读取head_并将其设置为head_的下一个元素,如下所示:

void pop(T& popped_element)
{
  node* old_head = head_;
  popped_element = old_head->data;
  head_ = head_->next;
  delete old_head;
}

现在,好好看看前面的代码。想象几个线程同时执行它。如果两个从堆栈中移除项目的线程读取相同的head_值会怎样?这和其他一些竞争条件导致我们采用以下实现:

void pop(T& popped_element)
{
  node* old_head = head_.load();
  while (!head_.compare_exchange_weak(old_head, old_head->next));
  popped_element = old_head->data;
}

我们在前面的代码中几乎应用了与push()函数相同的逻辑。前面的代码并不完美;它应该得到改进。我们建议您努力修改它以消除内存泄漏。

我们已经看到,无锁实现严重依赖于原子类型和操作。我们在上一节讨论的操作并不是最终的。现在让我们发现一些更多的原子操作。

原子操作的更多操作

在上一节中,我们在用户定义类型的指针上使用了std::atomic<>。也就是说,我们为列表节点声明了以下结构:

// the node struct is internal to 
// the lock_free_stack class defined above
struct node
{
  T data;
  node* next;
};

节点结构是用户定义的类型。尽管在上一节中我们实例化了std::atomic<node*>,但以同样的方式,我们几乎可以为任何用户定义的类型实例化std::atomic<>,也就是std::atomic<T>。但是,您应该注意std::atomic<T>的接口仅限于以下函数:

  • load()

  • store()

  • exchange()

  • compare_exchange_weak()

  • compare_exchange_strong()

  • wait()

  • notify_one()

  • notify_all()

现在让我们根据底层类型的特定情况来查看原子类型上可用的操作的完整列表。

std::atomic<>与整数类型(如整数或指针)实例化具有以下操作,以及我们之前列出的操作:

  • fetch_add()

  • fetch_sub()

  • fetch_or()

  • fetch_and()

  • fetch_xor()

此外,除了增量(++)和减量(--)之外,还有以下运算符可用:+=-=|=&=^=

最后,有一种特殊的原子类型称为atomic_flag,具有两种可用操作:

  • clear()

  • test_and_set()

您应该将std::atomic_flag视为具有原子操作的位。clear()函数将其清除,而test_and_set()将值更改为true并返回先前的值。

总结

在本章中,我们介绍了一个相当简单的设计堆栈的例子。还有更复杂的例子可以研究和遵循。当我们讨论设计并发堆栈时,我们看了两个版本,其中一个代表无锁堆栈。与基于锁的解决方案相比,无锁数据结构和算法是程序员的最终目标,因为它们提供了避免数据竞争的机制,甚至无需同步资源。

我们还介绍了原子类型和操作,您可以在项目中使用它们来确保指令是不可分割的。正如您已经知道的那样,如果一条指令是原子的,就不需要担心它的同步。我们强烈建议您继续研究这个主题,并构建更健壮和复杂的无锁数据结构。在下一章中,我们将看到如何设计面向世界的应用程序。

问题

  1. 在多线程单例实现中为什么要检查实例两次?

  2. 在基于锁的栈的复制构造函数的实现中,为什么要锁定另一个栈的互斥量?

  3. 原子类型和原子操作是什么?

  4. 为什么对原子类型使用load()store()

  5. std::atomic<T*>支持哪些额外操作?

进一步阅读

第十章:设计面向全球的应用程序

在生产就绪项目中使用编程语言是学习语言本身的一个全新步骤。有时,这本书中的简单示例可能会在实际项目中采用不同的方法或面临许多困难。当理论遇到实践时,你才会学会这门语言。C++也不例外。学习语法、解决一些书中的问题或理解书中的一些简单示例是不同的。在创建真实世界的应用程序时,我们面临着不同范围的挑战,有时书籍缺乏支持实际问题的理论。

在本章中,我们将尝试涵盖使用 C++进行实际编程的基础知识,这将帮助你更好地处理真实世界的应用程序。复杂的项目需要大量的思考和设计。有时,程序员不得不完全重写项目,并从头开始,只是因为他们在开发初期做出了糟糕的设计选择。本章试图尽最大努力阐明软件设计的过程。你将学习更好地为你的项目设计架构的步骤。

我们将在本章中涵盖以下主题:

  • 了解项目开发生命周期

  • 设计模式及其应用

  • 领域驱动设计

  • 以亚马逊克隆为例的真实项目设计

技术要求

本章中使用-std=c++2a选项的 g++编译器来编译示例。你可以在github.com/PacktPublishing/Expert-CPP找到本章中使用的源文件。

项目开发生命周期

每当你面对一个问题时,你应该仔细考虑需求分析的过程。项目开发中最大的错误之一是在没有对问题本身进行彻底分析的情况下开始编码。

想象一种情况,你被要求创建一个计算器,一个简单的工具,允许用户对数字进行算术计算。假设你神奇地按时完成了项目并发布了程序。现在,用户开始使用你的计算器,迟早会发现他们的计算结果不会超过整数的最大值。当他们抱怨这个问题时,你准备用坚实的编码支持论据来为自己(和你的作品)辩护,比如这是因为在计算中使用了int数据类型。对你和你的同行程序员来说,这是完全可以理解的,但最终用户却无法接受你的论点。他们想要一个可以对足够大的数字进行求和的工具,否则他们根本不会使用你的程序。你开始着手下一个版本的计算器,这一次,你使用长整型甚至自定义实现的大数。当你自豪地将程序交付给等待你掌声的用户时,你突然意识到同样的用户抱怨没有功能来找到数字的对数或指数。这似乎令人生畏,因为可能会有越来越多的功能请求和越来越多的抱怨。

尽管这个例子有点简单,但它完全覆盖了真实世界中通常发生的情况。即使你为你的程序实现了所有功能,并考虑着去度一个值得的长假,用户也会开始抱怨程序中的错误。事实证明,有几种情况下,你的计算器表现出乎意料的行为,给出了错误的结果。迟早,你会意识到在将程序发布给大众之前,需要进行适当的测试。

我们将涉及在处理真实世界项目时应考虑的主题。每当你开始一个新项目时,应考虑以下步骤:

  1. 需求收集和分析

  2. 规格书创建

  3. 设计和测试规划

  4. 编码

  5. 测试和稳定性

  6. 发布和维护

前面的步骤并非对每个项目都是硬性规定,尽管它可能被认为是每个软件开发团队应该完成以实现成功产品发布的最低要求。实际上,由于 IT 领域的每个人最缺乏的是时间,大多数步骤都被省略了。但是,强烈建议遵循前面的步骤,因为最终它将在长期节省更多时间。

需求收集和分析

这是创建稳定产品的最关键步骤。程序员未能按时完成任务或在代码中留下许多错误的最常见原因之一是对项目的完全理解不足。

领域知识是如此重要,以至于在任何情况下都不应该被忽略。您可能很幸运地开发与您非常了解的内容相关的项目。但是,您应该考虑到并非每个人都像您一样幸运(嗯,您也可能是那么不幸)。

想象一下,您正在开发一个自动化分析和报告某家公司股票交易的项目。现在想象一下,您对股票和股票交易一无所知。您不了解熊市或牛市,交易交易的限制等等。您如何才能成功完成这个项目?

即使您了解股票市场和交易,您可能不了解下一个重大项目领域。如果您被要求设计和实施(有或没有团队)控制您所在城市气象站的项目,您在开始项目时会首先做什么?

您绝对应该从需求收集和分析开始。这只是一个涉及与客户沟通并就项目提出许多问题的过程。如果您没有与任何客户打交道,而是在一家产品公司工作,项目经理应被视为客户。即使项目是您的想法,您是独自工作,您也应该将自己视为客户,并且,尽管这听起来很荒谬,但要问自己很多问题(关于项目)。

假设我们要征服电子商务,并希望发布一个最终能够击败市场上的大鳄的产品。受欢迎和成功的电子商务市场包括亚马逊,eBay,阿里巴巴等。我们应该将问题陈述为“编写我们自己的亚马逊克隆”。我们应该如何收集项目的需求?

首先,我们应该列出所有我们应该实现的功能,然后我们会进行优先排序。例如,对于亚马逊克隆项目,我们可能会列出以下功能清单:

  • 创建产品。

  • 列出产品。

  • 购买产品。

  • 编辑产品细节。

  • 移除产品。

  • 按名称,价格范围和重量搜索产品。

  • 偶尔通过电子邮件提醒用户产品的可用性。

功能应尽可能详细地描述;这将为开发人员(在这种情况下是您)解决问题。例如,创建产品应该由项目管理员或任何用户完成。如果用户可以创建产品,那么可能会有限制。可能会有用户错误地在我们的系统中创建数百个产品,以增加他们唯一产品的可见性。

详细信息应在与客户的沟通中说明,讨论和最终确定。如果您独自承担项目并且是项目的客户,则沟通是在项目需求上“为自己思考”的过程。

在获取需求完成后,我们建议对每个功能进行优先排序,并将它们分类为以下类别之一:

  • 必须有

  • 应该有

  • 最好有

经过更多思考并对前述功能进行分类后,我们可以列出以下清单:

  • 创建产品[必须有]。

  • 列出产品[必须有]。

  • 购买产品[必须有]。

  • 编辑产品细节[应该有]。

  • 移除产品[必须有]。

  • 按名称搜索产品[必须有]。

  • 按价格范围搜索产品[应该有]。

  • 按重量搜索产品[很好有]。

  • 偶尔通过电子邮件提醒用户产品的可用性[很好有]。

分类将为您提供一个从哪里开始的基本想法。程序员是贪婪的人;他们想要为他们的产品实现每一个可能的功能。这是通向失败的确定途径。你应该从最基本的功能开始——这就是为什么我们有一些很好的功能。有些人开玩笑地坚持认为,应该将很好的功能重新命名为永远不会有的功能,因为在实践中,它们永远不会被实现。

规格创建

并不是每个人都喜欢创建规格。嗯,大多数程序员讨厌这一步,因为这不是编码,而是写作。

在收集项目需求之后,你应该创建一个包含描述你的项目的每个细节的文档。这种规格有许多名称和类型。它可能被称为项目需求文档PRD),功能规格开发规格等等。认真的程序员和团队会在需求分析的结果中产生一个 PRD。这些认真的人的下一步是创建功能规格以及开发规格等等。我们将所有文档组合在一个名为规格创建的单一步骤中。

是否需要之前提到的任何子文档,这取决于你和你的团队。甚至最好有一个产品的视觉表示,而不是一个文本文档。无论你的文档采取什么形式,它都应该仔细地代表你在需求收集步骤中所取得的成就。为了对此有一个基本的理解,让我们试着记录一些我们之前收集到的功能(我们将把我们的项目称为平台)

  • 创建产品。平台的用户具有管理员特权可以创建产品。

  • 平台必须允许创建具有定义特权的用户。在这一点上,应该有两种类型的用户,即普通用户和管理员用户。

  • 使用平台的任何用户都必须能够看到可用产品的列表。

  • 产品应该有图片、价格、名称、重量和描述。

  • 购买产品时,用户提供他们的卡片详细信息以结账和产品装运的详细信息。

  • 每个注册用户都应该提供一个送货地址、信用卡详细信息和一个电子邮件账户。

列表可能会很长,实际上应该很长,因为列表越长,开发人员就越了解项目。

设计和测试规划

尽管我们坚持认为需求收集步骤是软件开发中最关键的一步,但设计和测试规划也可以被认为是同样关键的一步。如果你曾经在没有先设计项目的情况下开始一个项目,你已经知道它是不可能的。尽管激励性的语录坚持认为没有什么是不可能的,程序员确信至少有一件事是不可能的,那就是在没有先设计项目的情况下成功完成一个项目。

设计的过程是最有趣的一步;它迫使我们思考、绘画、再次思考、清理一切,然后重新开始。在设计项目时,你应该从顶部开始。首先,列出所有在项目中以某种方式涉及的实体和过程。以亚马逊克隆为例,我们可以列出以下实体和过程:

  • 用户

  • 注册和授权

  • 产品

  • 交易

  • 仓库(包含产品)

  • 装运

这是一个高层设计——一个通过最终设计的起点。在这一章中,我们将主要集中在项目的设计上。

分解实体

在列出关键实体和流程之后,我们开始将它们分解为更详细的实体,稍后将转换为类。最好还是勾画一下项目的设计。只需绘制包含实体名称的矩形,并用箭头连接它们,如果它们有某种联系或是同一流程的一部分。如果有一个包含或由实体 A 开始的流程,并在实体 B 结束或导致实体 B,你可以从实体 A 开始一个箭头指向实体 B。图画得多好并不重要,这是更好地理解项目的必要步骤。例如,看看下面的图表:

将实体和流程分解为类及其相互通信是一种需要耐心和一致性的微妙艺术。例如,让我们尝试为User实体添加细节。根据规范创建步骤中所述,注册用户应提供交货地址、电子邮件地址和信用卡详细信息。让我们绘制一个代表用户的类图:

现在出现了一个有趣的问题:我们应该如何处理实体内包含的复杂类型?例如,用户的交货地址是一个复杂类型。它不能只是string,因为迟早我们可能需要按照用户的交货地址对用户进行排序,以进行最佳的发货。例如,如果用户的交货地址与包含所购产品的仓库的地址不在同一个国家,那么货运公司可能会花费我们(或用户)一大笔钱。这是一个很好的场景,因为它引入了一个新问题,并更新了我们对项目的理解。原来我们应该处理的情况是,当用户订购的产品分配给一个距离用户物理位置很远的特定仓库时。如果我们有很多仓库,我们应该选择离用户最近的一个,其中包含所需的产品。这些问题不能立即得到答案,但这是设计项目的高质量结果。否则,这些问题将在编码过程中出现,并且我们会陷入其中比我们预想的时间更长的困境中。在任何已知的宇宙中,项目的初始估计都无法满足其完成日期。

那么,如何在User类中存储用户地址呢?如下例所示,简单的std::string就可以:

class User
{
public:
  // code omitted for brevity
private:
  std::string address_;
  // code omitted for brevity
};

地址在其组成部分方面是一个复杂的对象。地址可能包括国家名称、国家代码、城市名称和街道名称,甚至可能包含纬度和经度。如果需要找到用户最近的仓库,后者就非常有用。为程序员创建更多类型以使设计更直观也是完全可以的。例如,以下结构可能非常适合表示用户的地址:

struct Address
{
  std::string country;
  std::string city;
  std::string street;
  float latitude{};
  float longitude{};
};

现在,存储用户地址变得更加简单:

class User
{
  // code omitted for brevity
  Address address_;
}; 

我们稍后会在本章回到这个例子。

设计项目的过程可能需要回到几个步骤来重新阐明项目需求。在澄清设计步骤之后,我们可以继续将项目分解为更小的组件。创建交互图也是一个不错的选择。

像下面这样的交互图将描述一些操作,比如用户进行购买产品的交易:

测试规划也可以被视为设计的一部分。它包括规划最终应用程序将如何进行测试。例如,之前的步骤包括一个地址的概念,结果发现,地址可以包含国家、城市等。一个合适的测试应该包括检查用户地址中的国家值是否可以成功设置。尽管测试规划通常不被认为是程序员的任务,但为您的项目做测试规划仍然是一种良好的实践。一个合适的测试计划会在设计项目时产生更多有用的信息。大多数输入数据处理和安全检查都是在测试规划中发现的。例如,在进行需求分析或编写功能规范时,可能不会考虑对用户名称或电子邮件地址设置严格限制。测试规划关心这样的情况,并迫使开发人员注意数据检查。然而,大多数程序员都急于达到项目开发的下一步,编码。

编码

正如之前所说,编码并不是项目开发的唯一部分。在编码之前,您应该通过利用规范中的所有需求来仔细设计您的项目。在项目开发的前几步彻底完成后,编码会变得更加容易和高效。

一些团队实践测试驱动开发(TDD),这是生产更加稳定的项目发布的好方法。TDD 的主要概念是在项目实现之前编写测试。这对程序员来说是定义项目需求和在开发过程中出现的进一步问题的一个很好的方法。

假设我们正在为User类实现 setter。用户对象包含了之前讨论过的 email 字段,这意味着我们应该有一个set_email()方法,如下面的代码片段所示:

class User
{
public:
  // code omitted for brevity
  void set_email(const std::string&);

private: 
  // code omitted for brevity
  std::string email_;
};

TDD 方法建议在实现set_email()方法之前编写一个测试函数。假设我们有以下测试函数:

void test_set_email()
{
  std::string valid_email = "valid@email.com";
  std::string invalid_email = "112%$";
  User u;
  u.set_email(valid_email);
  u.set_email(invalid_email);
}

在上面的代码中,我们声明了两个string变量,其中一个包含了一个无效的电子邮件地址值。甚至在运行测试函数之前,我们就知道,在无效数据输入的情况下,set_email()方法应该以某种方式做出反应。常见的方法之一是抛出一个指示无效输入的异常。您也可以在set_email的实现中忽略无效输入,并返回一个指示操作成功的boolean值。错误处理应该在项目中保持一致,并得到所有团队成员的认可。假设我们选择抛出异常,因此,测试函数应该在将无效值传递给方法时期望一个异常。

然后,上述代码应该被重写如下:

void test_set_email()
{
  std::string valid_email = "valid@email.com";
  std::string invalid_email = "112%$";

  User u;
  u.set_email(valid_email);
  if (u.get_email() == valid_email) {
    std::cout << "Success: valid email has been set successfully" << std::endl;
  } else {
    std::cout << "Fail: valid email has not been set" << std::endl;
  }

  try {
    u.set_email(invalid_email);
    std::cerr << "Fail: invalid email has not been rejected" << std::endl;
  } catch (std::exception& e) {
    std::cout << "Success: invalid email rejected" << std::endl;
  }
}

测试函数看起来已经完成。每当我们运行测试函数时,它会输出set_email()方法的当前状态。即使我们还没有实现set_email()函数,相应的测试函数也是实现细节的重要一步。我们现在基本上知道了这个函数应该如何对有效和无效的数据输入做出反应。我们可以添加更多种类的数据来确保set_email()方法在实现完成时得到充分测试。例如,我们可以用空字符串和长字符串来测试它。

这是set_email()方法的初始实现:

#include <regex>
#include <stdexcept>

void User::set_email(const std::string& email)
{
  if (!std::regex_match(email, std::regex("(\\w+)(\\.|_)?(\\w*)@(\\w+)(\\.(\\w+))+")) {
    throw std::invalid_argument("Invalid email");
  }

  this->email_ = email;
}

在方法的初始实现之后,我们应该再次运行我们的测试函数,以确保实现符合定义的测试用例。

为项目编写测试被认为是一种良好的编码实践。有不同类型的测试,如单元测试、回归测试、冒烟测试等。开发人员应该为他们的项目支持单元测试覆盖率。

编码过程是项目开发生命周期中最混乱的步骤之一。很难估计一个类或其方法的实现需要多长时间,因为大部分问题和困难都是在编码过程中出现的。本章开头描述的项目开发生命周期的前几个步骤往往涵盖了大部分这些问题,并简化了编码过程。

测试和稳定

项目完成后,应进行适当的测试。通常,软件开发公司会有质量保证QA)工程师,他们会细致地测试项目。

在测试阶段验证的问题会转化为相应的任务分配给程序员来修复。问题可能会影响项目的发布,也可能被归类为次要问题。

程序员的基本任务不是立即修复问题,而是找到问题的根本原因。为了简单起见,让我们看一下generate_username()函数,它使用随机数与电子邮件结合生成用户名:

std::string generate_username(const std::string& email)
{
  int num = get_random_number();
  std::string local_part = email.substr(0, email.find('@'));
  return local_part + std::to_string(num);
}

generate_username()函数调用get_random_number()将返回的值与电子邮件地址的本地部分组合在一起。本地部分是电子邮件地址中@符号之前的部分。

QA 工程师报告说,与电子邮件的本地部分相关联的数字总是相同的。例如,对于电子邮件john@gmail.com,生成的用户名是john42,对于amanda@yahoo.com,是amanda42。因此,下次使用电子邮件amanda@hotmail.com尝试在系统中注册时,生成的用户名amanda42与已存在的用户名冲突。测试人员不了解项目的实现细节是完全可以的,因此他们将其报告为用户名生成功能中的问题。虽然你可能已经猜到真正的问题隐藏在get_random_number()函数中,但总会有情况出现,问题被修复而没有找到其根本原因。错误的方法修复问题可能会改变generate_username()函数的实现。generate_random_number()函数也可能在其他函数中使用,这将使调用get_random_number()的所有函数工作不正确。虽然这个例子很简单,但深入思考并找到问题的真正原因至关重要。这种方法将节省大量时间。

发布和维护

在修复所有关键和重大问题使项目变得相对稳定之后,可以发布项目。有时公司会在软件上加上beta标签,以防用户发现有 bug 时有借口。需要注意的是,很少有软件能够完美无缺地运行。发布后,会出现更多问题。因此,维护阶段就会到来,开发人员会在修复和发布更新时工作。

程序员有时开玩笑说,发布和维护是永远无法实现的步骤。然而,如果你花足够的时间设计项目,发布第一个版本就不会花费太多时间。正如我们在前一节中已经介绍的,设计从需求收集开始。之后,我们花时间定义实体,分解它们,将其分解为更小的组件,编码,测试,最后发布。作为开发人员,我们对设计和编码阶段更感兴趣。正如已经指出的,良好的设计选择对进一步的项目开发有很大的影响。现在让我们更仔细地看一下整个设计过程。

深入设计过程

如前所述,项目设计始于列出一般实体,如用户、产品和仓库,当设计电子商务平台时:

然后我们将每个实体分解为更小的组件。为了使事情更清晰,将每个实体视为一个单独的类。将实体视为类时,在分解方面更有意义。例如,我们将user实体表示为一个类:

class User
{
public:
  // constructors and assignment operators are omitted for code brevity
  void set_name(const std::string& name);
  std::string get_name() const;
  void set_email(const std::string&);
  std::string get_email() const;
  // more setters and getters are omitted for code brevity

private:
  std::string name_;
  std::string email_;
  Address address_;
  int age;
};

User类的类图如下:

然而,正如我们已经讨论过的那样,User类的地址字段可能被表示为一个单独的类型(classstruct,目前并不重要)。无论是数据聚合还是复杂类型,类图都会发生以下变化:

这些实体之间的关系将在设计过程中变得清晰。例如,Address不是一个独立的实体,它是User的一部分,也就是说,如果没有实例化User对象,它就不能有一个实例。然而,由于我们可能希望指向可重用的代码,Address类型也可以用于仓库对象。也就是说,UserAddress之间的关系是简单的聚合而不是组合。

在讨论支付选项时,我们可能会对User类型提出更多要求。平台的用户应该能够插入支付产品的选项。在决定如何在User类中表示支付选项之前,我们应该首先找出这些选项是什么。让我们保持简单,假设支付选项是包含信用卡号、持卡人姓名、到期日和卡的安全码的选项。这听起来像另一个数据聚合,所以让我们将所有这些内容收集到一个单独的结构体中,如下所示:

struct PaymentOption
{
  std::string number;
  std::string holder_name;
  std::chrono::year_month expiration_date;
  int code;
};

请注意前面结构体中的std::chrono::year_month;它表示特定年份的特定月份,是在 C++20 中引入的。大多数支付卡只包含卡的到期月份和年份,因此这个std::chrono::year_month函数非常适合PaymentOption

因此,在设计User类的过程中,我们提出了一个新类型PaymentOption。用户可以拥有多个支付选项,因此UserPaymentOption之间的关系是一对多的。现在让我们用这个新的聚合更新User类的类图(尽管在这种情况下我们使用组合):

UserPaymentOption之间的依赖关系在以下代码中表示:

class User
{
public:
  // code omitted for brevity
  void add_payment_option(const PaymentOption& po) {
    payment_options_.push_back(op);
  }

  std::vector get_payment_options() const {
    return payment_options_;
  }
private:
  // code omitted for brevity
  std::vector<PaymentOption> payment_options_;
};

我们应该注意,即使用户可能设置了多个支付选项,我们也应该将其中一个标记为主要选项。这很棘手,因为我们可以将所有选项存储在一个向量中,但现在我们必须将其中一个设为主要选项。

我们可以使用一对或tuple(如果想要花哨一点)将向量中的选项与boolean值进行映射,指示它是否是主要选项。以下代码描述了之前引入的User类中元组的使用:

class User
{
public:
  // code omitted for brevity
  void add_payment_option(const PaymentOption& po, bool is_primary) {
    payment_options_.push_back(std::make_tuple(po, is_primary));
  }

  std::vector<std::tuple<PaymentOption, boolean> > get_payment_options() const {
    return payment_options_;
  }
private:
  // code omitted for brevity
  std::vector<std::tuple<PaymentOption, boolean> > payment_options_;
};

我们可以通过以下方式利用类型别名简化代码:

class User
{
public:
  // code omitted for brevity
  using PaymentOptionList = std::vector<std::tuple<PaymentOption, boolean> >;

  // add_payment_option is omitted for brevity
  PaymentOptionList get_payment_options() const {
    return payment_options_;
  }

private:
  // code omitted for brevity
  PaymentOptionList payment_options_;
};

以下是用户类如何检索用户的主要支付选项的方法:

User john = get_current_user(); // consider the function is implemented and works
auto payment_options = john.get_payment_options();
for (const auto& option : payment_options) {
  auto [po, is_primary] = option;
  if (is_primary) {
    // use the po payment option
  }
}

for循环中访问元组项时,我们使用了结构化绑定。然而,在学习了关于数据结构和算法的章节之后,您现在意识到搜索主要支付选项是一个线性操作。每次需要检索主要支付选项时循环遍历向量可能被认为是一种不好的做法。

您可能会更改底层数据结构以使事情运行更快。例如,std::unordered_map(即哈希表)听起来更好。但是,这并不会使事情变得更快,仅仅因为它可以在常数时间内访问其元素。在这种情况下,我们应该将boolean值映射到支付选项。对于除一个之外的所有选项,boolean值都是相同的假值。这将导致哈希表中的冲突,这将由将值链接在一起映射到相同哈希值的方式来处理。使用哈希表的唯一好处将是对主要支付选项进行常数时间访问。

最后,我们来到了将主要支付选项单独存储在类中的最简单的解决方案。以下是我们应该如何重写User类中处理支付选项的部分:

class User
{
public:
  // code omitted for brevity
  using PaymentOptionList = std::vector<PaymentOption>;
  PaymentOption get_primary_payment_option() const {
    return primary_payment_option_;
  }

  PaymentOptionList get_payment_options() const {
    return payment_options_;
  }

  void add_payment_option(const PaymentOption& po, bool is_primary) {
    if (is_primary) {
      // moving current primary option to non-primaries
      add_payment_option(primary_payment_option_, false);
      primary_payment_option_ = po;
      return;
    }
    payment_options_.push_back(po);
  }

private:
  // code omitted for brevity
  PaymentOption primary_payment_option_;
  PaymentOptionList payment_options_;
};

到目前为止,我们已经带您了解了存储支付选项的方式的过程,只是为了展示设计伴随编码的过程。尽管我们为支付选项的单一情况创建了许多版本,但这并不是最终版本。在支付选项向量中处理重复值的情况总是存在。每当您将一个支付选项添加为主要选项,然后再添加另一个选项为主要选项时,先前的选项将移至非主要列表。如果我们改变主意并再次将旧的支付选项添加为主要选项,它将不会从非主要列表中移除。

因此,总是有机会深入思考并避免潜在问题。设计和编码是相辅相成的;然而,您不应忘记 TDD。在大多数情况下,在编码之前编写测试将帮助您发现许多用例。

使用 SOLID 原则

在项目设计中,您可以使用许多原则和设计方法。保持设计简单总是更好,但是有些原则在一般情况下几乎对所有项目都有用。例如,SOLID包括五个原则,其中的一个或全部可以对设计有用。

SOLID 代表以下原则:

  • 单一职责

  • 开闭原则

  • 里氏替换

  • 接口隔离

  • 依赖反转

让我们通过示例讨论每个原则。

单一职责原则

单一职责原则简单地说明了一个对象,一个任务。尽量减少对象的功能和它们的关系复杂性。使每个对象只负责一个任务,即使将复杂对象分解为更小更简单的组件并不总是容易的。单一职责是一个上下文相关的概念。它不是指类中只有一个方法;而是使类或模块负责一个事情。例如,我们之前设计的User类只有一个职责:存储用户信息。然而,我们将支付选项添加到User类中,并强制它具有添加和删除支付选项的方法。我们还引入了主要支付选项,这涉及User方法中的额外逻辑。我们可以朝两个方向发展。

第一个建议将User类分解为两个单独的类。每个类将负责一个单一的功能。以下类图描述了这个想法:

其中一个将仅存储用户的基本信息,下一个将存储用户的支付选项。我们分别命名它们为UserInfoUserPaymentOptions。有些人可能会喜欢这种新设计,但我们会坚持旧的设计。原因在于,User类既包含用户信息又包含支付选项,后者也代表了一部分信息。我们设置和获取支付选项的方式与设置和获取用户的电子邮件的方式相同。因此,我们保持User类不变,因为它已经满足了单一职责原则。当我们在User类中添加付款功能时,这将破坏平衡。在这种情况下,User类将既存储用户信息又进行付款交易。这在单一职责原则方面是不可接受的,因此我们不会这样做。

单一职责原则也与函数相关。add_payment_option()方法有两个职责。如果函数的第二个(默认)参数为 true,则它会添加一个新的主要支付选项。否则,它会将新的支付选项添加到非主要选项列表中。最好为添加主要支付选项单独创建一个方法。这样,每个方法都将有单一职责。

开闭原则

开闭原则规定一个类应该对扩展开放,对修改关闭。这意味着每当你需要新的功能时,最好是扩展基本功能而不是修改它。例如,我们设计的电子商务应用程序中的Product类。以下是Product类的简单图表:

每个Product对象都有三个属性:名称价格重量。现在,想象一下,在设计了Product类和整个电子商务平台之后,客户提出了一个新的需求。他们现在想购买数字产品,如电子书、电影和音频录音。一切都很好,除了产品的重量。现在可能会有两种产品类型——有形和数字——我们应该重新思考Product使用的逻辑。我们可以像这里的代码中所示那样在Product中加入一个新的功能:

class Product
{
public:
  // code omitted for brevity
  bool is_digital() const {
    return weight_ == 0.0;
  }

  // code omitted for brevity
};

显然,我们修改了类——违反了开闭原则。该原则规定类应该对修改关闭。它应该对扩展开放。我们可以通过重新设计Product类并将其制作成所有产品的抽象基类来实现这一点。接下来,我们创建两个更多的类,它们继承Product基类:PhysicalProductDigitalProduct。下面的类图描述了新的设计:

正如前面的图表所示,我们从Product类中删除了weight_属性。现在我们有了两个更多的类,PhysicalProduct有一个weight_属性,而DigitalProduct没有。相反,它有一个file_path_属性。这种方法满足了开闭原则,因为现在所有的类都可以扩展。我们使用继承来扩展类,而下面的原则与此密切相关。

里斯科夫替换原则

里斯科夫替换原则是关于正确继承类型的方式。简单来说,如果有一个函数接受某种类型的参数,那么同一个函数应该接受派生类型的参数。

里斯科夫替换原则是以图灵奖获得者、计算机科学博士芭芭拉·里斯科夫的名字命名的。

一旦你理解了继承和里氏替换原则,就很难忘记它。让我们继续开发Product类,并添加一个根据货币类型返回产品价格的新方法。我们可以将价格存储在相同的货币单位中,并提供一个将价格转换为指定货币的函数。以下是该方法的简单实现:

enum class Currency { USD, EUR, GBP }; // the list goes further

class Product
{
public:
  // code omitted for brevity
  double convert_price(Currency c) {
    // convert to proper value
  }

  // code omitted for brevity
};

过了一段时间,公司决定为所有数字产品引入终身折扣。现在,每个数字产品都将享有 12%的折扣。在短时间内,我们在DigitalProduct类中添加了一个单独的函数,该函数通过应用折扣返回转换后的价格。以下是DigitalProduct中的实现:

class DigitalProduct : public Product
{
public:
  // code omitted for brevity
  double convert_price_with_discount(Currency c) {
    // convert by applying a 12% discount
  } 
};

设计中的问题是显而易见的。在DigitalProduct实例上调用convert_price()将没有效果。更糟糕的是,客户端代码不应该调用它。相反,它应该调用convert_price_with_discount(),因为所有数字产品必须以 12%的折扣出售。设计违反了里氏替换原则。

我们不应该破坏类层次结构,而应该记住多态的美妙之处。一个更好的版本将如下所示:

class Product
{
public:
  // code omitted for brevity
  virtual double convert_price(Currency c) {
    // default implementation
  }

  // code omitted for brevity
};

class DigitalProduct : public Product
{
public:
  // code omitted for brevity
  double convert_price(Currency c) override {
    // implementation applying a 12% discount
  }

  // code omitted for brevity
};

正如您所看到的,我们不再需要convert_price_with_discount()函数。而且里氏替换原则得到了遵守。然而,我们应该再次检查设计中的缺陷。让我们通过在基类中引入用于折扣计算的私有虚方法来改进它。以下是Product类的更新版本,其中包含一个名为calculate_discount()的私有虚成员函数:

class Product
{
public:
  // code omitted for brevity
  virtual double convert_price(Currency c) {
    auto final_price = apply_discount();
    // convert the final_price based on the currency
  }

private:
 virtual double apply_discount() {
 return getPrice(); // no discount by default
 }

  // code omitted for brevity
};

convert_price()函数调用私有的apply_discount()函数,该函数返回原价。这里有一个技巧。我们在派生类中重写apply_discount()函数,就像下面的DigitalProduct实现中所示:

class DigitalProduct : public Product
{
public:
  // code omitted for brevity

private:
  double apply_discount() override {
 return getPrice() * 0.12;
 }

  // code omitted for brevity
};

我们无法在类外部调用私有函数,但我们可以在派生类中重写它。前面的代码展示了重写私有虚函数的美妙之处。我们修改了实现,但接口保持不变。如果派生类不需要为折扣计算提供自定义功能,则不需要重写它。另一方面,DigitalProduct需要在转换之前对价格进行 12%的折扣。不需要修改基类的公共接口。

您应该考虑重新思考Product类的设计。直接在getPrice()中调用apply_discount()似乎更好,因此始终返回最新的有效价格。尽管在某些时候,您应该强迫自己停下来。

设计过程是有创意的,有时也是不感激的。由于意外的新需求,重写所有代码并不罕见。我们使用原则和方法来最小化在实现新功能后可能出现的破坏性变化。SOLID 的下一个原则是最佳实践之一,它将使您的设计更加灵活。

接口隔离原则

接口隔离原则建议将复杂的接口分成更简单的接口。这种隔离允许类避免实现它们不使用的接口。

在我们的电子商务应用中,我们应该实现产品发货、替换和过期功能。产品的发货是将产品项目移交给买家。在这一点上,我们不关心发货的细节。产品的替换考虑在向买家发货后替换损坏或丢失的产品。最后,产品的过期意味着处理在到期日期之前未销售的产品。

我们可以在前面介绍的Product类中实现所有功能。然而,最终我们会遇到一些产品类型,例如无法运输的产品(例如,很少有人会将房屋运送给买家)。可能有一些产品是不可替代的。例如,原始绘画即使丢失或损坏也无法替换。最后,数字产品永远不会过期。嗯,大多数情况下是这样。

我们不应该强制客户端代码实现它不需要的行为。在这里,客户端指的是实现行为的类。以下示例是违反接口隔离原则的不良实践:

class IShippableReplaceableExpirable
{
public:
  virtual void ship() = 0;
  virtual void replace() = 0;
  virtual void expire() = 0;
};

现在,Product类实现了前面展示的接口。它必须为所有方法提供实现。接口隔离原则建议以下模型:

class IShippable
{
public:
  virtual void ship() = 0;
};

class IReplaceable
{
public:
  virtual void replace() = 0;
};

class IExpirable
{
public:
  virtual void expire() = 0;
};

现在,Product类跳过了实现任何接口。它的派生类从特定类型派生(实现)。以下示例声明了几种产品类的类型,每种类型都支持前面介绍的有限数量的行为。请注意,为了代码简洁起见,我们省略了类的具体内容:

class PhysicalProduct : public Product {};

// The book does not expire
class Book : public PhysicalProduct, public IShippable, public IReplaceable
{
};

// A house is not shipped, not replaced, but it can expire 
// if the landlord decided to put it on sell till a specified date
class House : public PhysicalProduct, public IExpirable
{
};

class DigitalProduct : public Product {};

// An audio book is not shippable and it cannot expire. 
// But we implement IReplaceable in case we send a wrong file to the user.
class AudioBook : public DigitalProduct, public IReplaceable
{
};

如果要将文件下载包装为货物,可以考虑为AudioBook实现IShippable

依赖倒置原则

最后,依赖倒置原则规定对象不应该紧密耦合。它允许轻松切换到替代依赖。例如,当用户购买产品时,我们会发送购买收据。从技术上讲,有几种发送收据的方式,即打印并通过邮件发送,通过电子邮件发送,或在平台的用户账户页面上显示收据。对于后者,我们会通过电子邮件或应用程序向用户发送通知,告知收据已准备好查看。看一下以下用于打印收据的接口:

class IReceiptSender
{
public:
  virtual void send_receipt() = 0;
};

假设我们已经在Product类中实现了purchase()方法,并在完成后发送了收据。以下代码部分处理了发送收据的过程:

class Product
{
public:
  // code omitted for brevity
  void purchase(IReceiptSender* receipt_sender) {
    // purchase logic omitted
    // we send the receipt passing purchase information
 receipt_sender->send(/* purchase-information */);
  }
};

我们可以通过添加所需的收据打印选项来扩展应用程序。以下类实现了IReceiptSender接口:

class MailReceiptSender : public IReceiptSender
{
public:
  // code omitted for brevity
  void send_receipt() override { /* ... */ }
};

另外两个类——EmailReceiptSenderInAppReceiptSender——都实现了IReceiptSender。因此,要使用特定的收据,我们只需通过purchase()方法将依赖注入到Product中,如下所示:

IReceiptSender* rs = new EmailReceiptSender();
// consider the get_purchasable_product() is implemented somewhere in the code
auto product = get_purchasable_product();
product.purchase(rs);

我们可以进一步通过在User类中实现一个方法,返回具体用户所需的收据发送选项。这将使类之间的耦合更少。

在前面讨论的所有 SOLID 原则中,都是组合类的一种自然方式。遵循这些原则并不是强制性的,但如果遵循这些原则,将会改善你的设计。

使用领域驱动设计

领域是程序的主题领域。我们正在讨论和设计一个以电子商务为主题概念的电子商务平台,所有附属概念都是该领域的一部分。我们建议您在项目中考虑领域驱动设计。然而,该方法并不是程序设计的万能药。

设计项目时,考虑以下三层三层架构的三个层次是很方便的:

  • 演示

  • 业务逻辑

  • 数据

三层架构适用于客户端-服务器软件,例如我们在本章中设计的软件。表示层向用户提供与产品、购买和货物相关的信息。它通过向客户端输出结果与其他层进行通信。这是客户直接访问的一层,例如,Web 浏览器。

业务逻辑关心应用功能。例如,用户浏览由表示层提供的产品,并决定购买其中的一个。请求的处理是业务层的任务。在领域驱动设计中,我们倾向于将领域级实体与其属性结合起来,以应对应用程序的复杂性。我们将用户视为User类的实例,产品视为Product类的实例,依此类推。用户购买产品被业务逻辑解释为User对象创建一个Order对象,而Order对象又与Product对象相关联。然后,Order对象与与购买产品相关的Transaction对象相关联。购买的相应结果通过表示层表示。

最后,数据层处理存储和检索数据。从用户认证到产品购买,每个步骤都从系统数据库(或数据库)中检索或记录。

将应用程序分成层可以处理其整体的复杂性。最好协调具有单一责任的对象。领域驱动设计区分实体和没有概念身份的对象。后者被称为值对象。例如,用户不区分每个唯一的交易;他们只关心交易所代表的信息。另一方面,用户对象以User类的形式具有概念身份(实体)。

使用其他对象(或不使用)对对象执行的操作称为服务。服务更像是一个不与特定对象绑定的操作。例如,通过set_name()方法设置用户的名称是一个不应被视为服务的操作。另一方面,用户购买产品是由服务封装的操作。

最后,领域驱动设计强烈地融合了存储库工厂模式。存储库模式负责检索和存储领域对象的方法。工厂模式创建领域对象。使用这些模式允许我们在需要时交换替代实现。现在让我们在电子商务平台的背景下发现设计模式的力量。

利用设计模式

设计模式是软件设计中常见问题的架构解决方案。重要的是要注意,设计模式不是方法或算法。它们是提供组织类和它们之间关系的一种架构构造,以实现更好的代码可维护性的方式。即使以前没有使用过设计模式,你很可能已经自己发明了一个。许多问题在软件设计中往往会反复出现。例如,为现有库创建更好的接口是一种称为facade的设计模式形式。设计模式有名称,以便程序员在对话或文档中使用它们。与其他程序员使用 facade、factory 等进行闲聊应该是很自然的。

我们之前提到领域驱动设计融合了存储库和工厂模式。现在让我们来了解它们是什么,以及它们如何在我们的设计努力中发挥作用。

存储库模式

正如 Martin Fowler 最好地描述的那样,存储库模式“在领域和数据映射层之间使用类似集合的接口来访问领域对象”。

该模式提供了直接的数据操作方法,无需直接使用数据库驱动程序。添加、更新、删除或选择数据自然地适用于应用程序域。

其中一种方法是创建一个提供必要功能的通用存储库类。简单的接口如下所示:

class Entity; // new base class

template <typename T, typename = std::enable_if_t<std::is_base_of_v<Entity, T>>>
class Repository
{
public:
 T get_by_id(int);
 void insert(const T&);
 void update(const T&);
 void remove(const T&);
 std::vector<T> get_all(std::function<bool(T)> condition);
};

我们在前面引入了一个名为Entity的新类。Repository类与实体一起工作,并确保每个实体都符合Entity的相同接口,它应用std::enable_if以及std::is_base_of_v到模板参数。

std::is_base_of_vstd::is_base_of<>::value的简写。此外,std::enable_if_t替换了std::enable_if<>::type

Entity类的表示如下:

class Entity
{
public:
  int get_id() const;
  void set_id(int);
private:
  int id_;
};

每个业务对象都是一个Entity,因此,前面讨论的类应该更新为从Entity继承。例如,User类的形式如下:

class User : public Entity
{
// code omitted for brevity
};

因此,我们可以这样使用存储库:

Repository<User> user_repo;
User fetched_user = user_repo.get_by_id(111);

前面介绍的存储库模式是对该主题的简单介绍,但是你可以使它更加强大。它类似于外观模式。虽然使用外观模式的重点不是访问数据库,但是最好用数据库访问来解释。外观模式包装了一个复杂的类或类,为客户端提供了一个简单的预定义接口,以便使用底层功能。

工厂模式

当程序员谈论工厂模式时,他们可能会混淆工厂方法和抽象工厂。这两者都是提供各种对象创建机制的创建模式。让我们讨论工厂方法。它提供了一个在基类中创建对象的接口,并允许派生类修改将被创建的对象。

现在是处理物流的时候了,工厂方法将在这方面帮助我们。当你开发一个提供产品发货的电子商务平台时,你应该考虑到并非所有用户都住在你的仓库所在的同一地区。因此,从仓库向买家发货时,你应该选择适当的运输类型。自行车、无人机、卡车等等。感兴趣的问题是设计一个灵活的物流管理系统。

不同的交通工具需要不同的实现。然而,它们都符合一个接口。以下是Transport接口及其派生的具体交通工具实现的类图:

前面图表中的每个具体类都提供了特定的交付实现。

假设我们设计了以下Logistics基类,负责与物流相关的操作,包括选择适当的运输方式,如下所示:

前面应用的工厂方法允许灵活地添加新的运输类型以及新的物流方法。注意createTransport()方法返回一个Transport指针。派生类覆盖该方法,每个派生类返回Transport的子类,从而提供了特定的运输方式。这是可能的,因为子类返回了派生类型,否则在覆盖基类方法时无法返回不同的类型。

Logistics中的createTransport()如下所示:

class Logistics 
{
public:
 Transport* getLogistics() = 0;
  // other functions are omitted for brevity
};

Transport类代表了DroneTruckShip的基类。这意味着我们可以创建每个实例,并使用Transport指针引用它们,如下所示:

Transport* ship_transport = new Ship();

这是工厂模式的基础,因为例如RoadLogistics覆盖了getLogistics(),如下所示:

class RoadLogistics : public Logistics
{
public: 
  Truck* getLogistics() override {
 return new Truck();
 }
}

注意函数的返回类型,它是Truck而不是Transport。这是因为Truck继承自Transport。另外,看看对象的创建是如何与对象本身解耦的。创建新对象是通过工厂完成的,这与之前讨论的 SOLID 原则保持一致。

乍一看,利用设计模式似乎会给设计增加额外的复杂性。然而,当实践设计模式时,你应该培养对更好设计的真正感觉,因为它们允许项目整体具有灵活性和可扩展性。

总结

软件开发需要细致的规划和设计。我们在本章中学到,项目开发包括以下关键步骤:

  • 需求收集和分析:这包括理解项目的领域,讨论和最终确定应该实现的功能。

  • 规范创建:这包括记录需求和项目功能。

  • 设计和测试规划:这指的是从更大的实体开始设计项目,然后将每个实体分解为一个单独的类,考虑到项目中的其他类。这一步还涉及规划项目的测试方式。

  • 编码:这一步涉及编写代码,实现前面步骤中指定的项目。

  • 测试和稳定性:这意味着根据预先计划的用例和场景检查项目,以发现问题并加以修复。

  • 发布和维护:这是最后一步,将我们带到项目的发布和进一步的维护。

项目设计对程序员来说是一个复杂的任务。他们应该提前考虑,因为部分功能是在开发过程中引入的。

为了使设计灵活而健壮,我们已经讨论了导致更好架构的原则和模式。我们已经学习了设计软件项目及其复杂性的过程。

避免糟糕的设计决策的最佳方法之一是遵循已经设计好的模式和实践。在未来的项目中,你应该考虑使用 SOLID 原则以及经过验证的设计模式。

在下一章中,我们将设计一个策略游戏。我们将熟悉更多的设计模式,并看到它们在游戏开发中的应用。

问题

  1. TDD 的好处是什么?

  2. UML 中交互图的目的是什么?

  3. 组合和聚合之间有什么区别?

  4. 你会如何描述 Liskov 替换原则?

  5. 假设你有一个Animal类和一个Monkey类。后者描述了一种特定的会在树上跳跃的动物。从Animal类继承Monkey类是否违反了开闭原则?

  6. 在本章讨论的Product类及其子类上应用工厂方法。

进一步阅读

有关更多信息,请参阅:

第十一章:使用设计模式设计策略游戏

游戏开发是软件工程中最有趣的话题之一。C++在游戏开发中被广泛使用,因为它的效率。然而,由于该语言没有 GUI 组件,因此它被用于后端。在本章中,我们将学习如何在后端设计策略游戏。我们将整合几乎所有我们在之前章节中学到的内容,包括设计模式和多线程。

我们将设计的游戏是一个名为读者和扰乱者的策略游戏。在这里,玩家创建单位,称为读者,他们能够建造图书馆和其他建筑物,以及士兵,他们保卫这些建筑物免受敌人的攻击。

在本章中,我们将涵盖以下主题:

  • 游戏设计简介

  • 深入游戏设计的过程

  • 使用设计模式

  • 设计游戏循环

技术要求

在整个本章中,将使用带有-std=c++2a选项的 g++编译器来编译示例。您可以在github.com/PacktPublishing/Expert-CPP找到本章中将使用的源文件。

游戏设计简介

在本章中,我们将设计一个策略游戏的后端,玩家可以创建单位(工人、士兵)、建造建筑物,并与敌人战斗。无论您设计的是策略游戏还是第一人称射击游戏,都有一些基本组件是相同的,例如游戏物理,用于使游戏对玩家更真实和沉浸。

在几乎所有游戏中都有一些重复的游戏设计组件,例如碰撞检测机制、音频系统、图形渲染等。在设计游戏时,我们可以区分引擎和游戏,或者开发一个紧密联系的应用程序,代表引擎和游戏作为一个单一的结果。将游戏引擎单独设计允许它在后续版本中进行扩展,甚至用于其他游戏。毕竟,游戏具有相同的机制和相同的流程。它们主要通过情节线有所不同。

在设计游戏引擎时,您应该仔细规划将使用引擎设计的游戏类型。虽然大多数基本功能是相同的,独立于游戏类型,但在 3D 射击游戏和策略游戏中有区别。在策略游戏中,玩家会在一个大的游戏场地上进行单位的战略部署。游戏世界是从俯视角度显示的。

读者和扰乱者游戏简介

游戏的基本理念很简单:玩家拥有有限的资源。这些资源可以用来为游戏角色创建建筑物。我们称这些角色单位,分为读者和士兵。读者是聪明的角色,他们建造图书馆和其他建筑物。每个建成的图书馆可以容纳多达 10 名读者。如果玩家将 10 名读者移入图书馆,经过一定时间后,图书馆会产生一名教授。教授是一个强大的单位,可以一次摧毁三名敌方士兵。教授可以为玩家的士兵制造更好的武器。

游戏从一个已建好的房子开始,有两名士兵和三名读者。房子每 5 分钟产生一个新的读者。读者可以建造新的房子,然后产生更多的读者。他们还可以建造兵营,生产士兵。

玩家的目标是建造五座图书馆,每座图书馆至少产生一名教授。玩家在游戏过程中必须保卫自己的建筑物和读者免受敌人的攻击。敌人被称为扰乱者,因为他们的目标是打扰读者的主要目标:在图书馆里学习。

策略游戏组件

正如我们之前提到的,我们的策略游戏将包括基本组件-读者和士兵(我们将称它们为单位),建筑物和地图。游戏地图包含游戏中每个对象的坐标。我们将讨论游戏地图的简化版本。现在,让我们利用我们的项目设计技能来分解游戏本身。

游戏包括以下角色单位:

  • 一位读者

  • 一名士兵

  • 一位教授

它还包括以下建筑:

  • 一座图书馆

  • 一座房子

  • 一座兵营

现在,让我们讨论游戏的每个组件的属性。游戏角色具有以下属性:

  • 生命点数(一个整数,在每次来自敌方的攻击后减少)

  • 力量(一个整数,定义单位对敌方单位造成的伤害量)

  • 类型(读者,士兵或教授)

生命属性应该有一个基于单位类型的初始值。例如,读者的初始生命点数为 10,而士兵的生命点数为 12。在游戏中互动时,所有单位都可能受到敌方单位的攻击。每次攻击都被描述为生命点数的减少。我们减少生命点数的数量取决于攻击者的力量值。例如,士兵的力量值设定为 3,这意味着士兵发动的每次攻击都会使受害者的生命点数减少 3。当受害者的生命点数变为零时,角色单位将被摧毁。

建筑物也是如此。建筑物有一个完全建成的建造持续时间。完整的建筑物也有生命点数,敌方部队造成的任何损害都会减少这些生命点数。以下是建筑物属性的完整列表:

  • 生命点数

  • 类型

  • 建造持续时间

  • 单位生产持续时间

单位生产持续时间是生产新角色单位所需的时间。例如,一个兵营每 3 分钟生产一个士兵,一座房子每 5 分钟生产一个读者,一座图书馆在最后一个缺失的读者进入图书馆时立即产生一名教授。

现在我们已经定义了游戏组件,让我们讨论它们之间的互动。

组件之间的互动

读者和扰乱者游戏设计中的下一个重要事项是角色之间的互动。我们已经提到读者可以建造建筑物。在游戏中,这个过程应该得到照顾,因为每种类型的建筑都有其建造持续时间。因此,如果读者忙于建筑过程,我们应该测量时间,以确保建筑物在指定时间后准备好。然而,为了使游戏变得更好,我们应该考虑到不止一个读者可以参与建筑过程。这应该使建筑物的建造速度更快。例如,如果一名读者在 5 分钟内建造一座兵营,那么两名读者应该在 2 分半钟内建造一座兵营,依此类推。这是游戏中复杂互动的一个例子,并可以用以下图表来描述:

复杂互动

接下来是攻击处理。当一个单位受到敌人的攻击时,我们应该减少被告的生命点数。被告本身可以攻击攻击者(为了自卫)。每当有多个攻击者或被告时,我们应该相应地处理每个受攻击单位的生命点数减少。我们还应该定义每个单位的攻击持续时间。一个单位不应该很快地攻击另一个单位。为了使事情更加自然,我们可以在每次攻击之间引入 1 秒或 2 秒的暂停。以下图表描述了简单的攻击互动。这将在本章后面用类互动图表替换:

简单攻击互动

在游戏中发生了更大的互动。游戏中有两个组,其中一个由玩家控制,另一个由游戏自动控制。这意味着我们作为游戏设计者有责任定义敌方力量的生命周期。游戏将自动创建读者,他们将被分配创建图书馆、兵营和房屋的任务。每个士兵都应该负责保卫建筑和读者(人们)。而士兵们也应该不时地组成小组进行进攻任务。

我们将设计一个平台,让玩家创建一个帝国;然而,游戏也应该创建敌人以使游戏完整。玩家将面临来自敌人的定期攻击,而敌人将通过建造更多建筑和生产更多单位来发展。总的来说,我们可以用以下图表来描述这种互动:

玩家和自动玩家之间的互动

在设计游戏时,我们将经常参考上述类图。

设计游戏

虽然游戏不是典型的软件,但其设计与常规应用程序设计并无太大不同。我们将从主要实体开始,并进一步分解为类及其关系。

在前一节中,我们讨论了所有必要的游戏组件及其交互。我们进行了项目开发生命周期的需求分析和收集。现在,我们将开始设计游戏。

设计角色单位

以下类图表示了一个读者:

当我们浏览其他角色单位时,我们将为每个角色单位创建一个基类。每个特定单位将继承自该基类,并添加其特定的属性(如果有)。以下是角色单位的完整类图:

注意基类-它是一个接口,而不是一个常规类。它定义了要在派生类中实现的纯虚函数。以下是代码中CharacterUnit接口的样子:

class CharacterUnit
{
public:
  virtual void attack(const CharacterUnit&) = 0;
  virtual void destroy() = 0;
  virtual int get_power() const = 0;
  virtual int get_life_points() const = 0;
};

attack()方法减少角色的生命点数,而destroy()摧毁角色。摧毁意味着不仅从场景中移除角色,还停止了单位正在进行的所有交互(如建筑建造、自卫等)。

派生类为CharacterUnit接口类的纯虚函数提供了实现。让我们来看一下Reader角色单位的代码:

class Reader : public CharacterUnit
{
public:
  Reader();
  Reader(const Reader&) = delete;
  Reader& operator=(const Reader&) = delete;

public:
  void attack(const CharacterUnit& attacker) override {
    decrease_life_points_by_(attacker.get_power());
  }

  void destroy() override {
    // we will leave this empty for now
  }

  int get_life_points() const override {
    return life_points_;
  }

  int get_power() const override {
    return power_;
  }

private:
  void decrease_life_points_(int num) {
    life_points_ -= num;
    if (life_points_ <= 0) {
      destroy();
    }
  }

private:
  int life_points_;
  int power_;
};

现在,我们可以通过以下任何一种方式声明Reader单位:

Reader reader;
Reader* pr = new Reader();
CharacterUnit* cu = new Reader();

我们将主要通过它们的基接口类来引用角色单位。

注意复制构造函数和赋值运算符。我们故意将它们标记为删除,因为我们不希望通过复制其他单位来创建单位。我们将使用Prototype模式来实现这一行为。这将在本章后面讨论。

在需要对不同类型的单位执行相同操作的情况下,具有CharacterUnit接口至关重要。例如,假设我们需要计算两名士兵、一名读者和一名教授对建筑物造成的完整伤害。我们可以自由地将它们都称为CharacterUnits,而不是保留三个不同的引用来引用三种不同类型的单位。以下是具体操作:

int calculate_damage(const std::vector<CharacterUnit*>& units)
{
  return std::reduce(units.begin(), units.end(), 0, 
            [](CharacterUnit& u1, CharacterUnit& u2) {
                return u1.get_power() + u2.get_power();
            }
  );
}

calculate_damage()函数抽象出了单位类型;它不关心读者或士兵。它只调用CharacterUnit接口的get_power()方法,这个方法保证了特定对象的实现。

随着进展,我们将更新角色单位类。现在,让我们继续设计建筑物的类。

设计建筑物

建筑类与角色单位类似,具有共同的接口。例如,我们可以从以下定义房屋类开始:

class House
{
public:
  House();
  // copying will be covered by a Prototype
  House(const House&) = delete;
  House& operator=(const House&) = delete;

public:
  void attack(const CharacterUnit&);
  void destroy();
  void build(const CharacterUnit&);
  // ...

private:
  int life_points_;
  int capacity_;
  std::chrono::duration<int> construction_duration_;
};

在这里,我们使用std::chrono::duration来保持House施工持续时间的时间间隔。它在<chrono>头文件中定义为一定数量的滴答和滴答周期,其中滴答周期是从一个滴答到下一个滴答的秒数。

House类需要更多细节,但我们很快会意识到我们需要一个所有建筑的基本接口(甚至是一个抽象类)。本章将描述的建筑共享某些行为。Building的接口如下:

class IBuilding
{
public:
  virtual void attack(const CharacterUnit&) = 0;
  virtual void destroy() = 0;
  virtual void build(CharacterUnit*) = 0;
  virtual int get_life_points() const = 0;
};

注意Building前面的I前缀。许多开发人员建议为接口类使用前缀或后缀以提高可读性。例如,Building可能已被命名为IBuildingBuildingInterface。我们将对先前描述的CharacterUnit使用相同的命名技术。

HouseBarrackLibrary类实现了IBuilding接口,并且必须为纯虚方法提供实现。例如,Barrack类将如下所示:

class Barrack : public IBuilding
{
public:
  void attack(const ICharacterUnit& attacker) override {
    decrease_life_points_(attacker.get_power());
  }

  void destroy() override {
    // we will leave this empty for now
  }

  void build(ICharacterUnit* builder) override {
    // construction of the building
  }

  int get_life_points() const override {
    return life_points_;
  }

private:
  int life_points_;
  int capacity_;
  std::chrono::duration<int> construction_duration_;
};

让我们更详细地讨论施工持续时间的实现。在这一点上,std::chrono::持续时间点,作为一个提醒,告诉我们施工应该需要指定的时间。还要注意,类的最终设计可能会在本章的过程中发生变化。现在,让我们找出游戏组件如何相互交互。

设计游戏控制器

为角色单位和建筑设计类只是设计游戏本身的第一步。游戏中最重要的事情之一是设计这些组件之间的交互。我们应该仔细分析和设计诸如两个或更多角色建造一个建筑的情况。我们已经为建筑引入了施工时间,但我们没有考虑到一个建筑可能由多个读者(可以建造建筑的角色单位)来建造。

我们可以说,由两个读者建造的建筑应该比一个读者建造的建筑快两倍。如果另一个读者加入建设,我们应该重新计算持续时间。然而,我们应该限制可以在同一建筑上工作的读者数量。

如果任何读者受到敌人的攻击,那应该打扰读者建造,以便他们可以集中精力进行自卫。当一个读者停止在建筑上工作时,我们应该重新计算施工时间。攻击是另一种类似于建筑的情况。当一个角色受到攻击时,它应该通过反击来进行自卫。每次攻击都会减少角色的生命值。一个角色可能会同时受到多个敌方角色的攻击。这将更快地减少他们的生命值。

建筑有一个计时器,因为它会周期性地产生角色。设计最重要的是游戏动态-也就是循环。在每个指定的时间段,游戏中会发生一些事情。这可能是敌人士兵的接近,角色单位建造某物,或其他任何事情。一个动作的执行并不严格地与另一个无关的动作的完成相关。这意味着建筑的施工与角色的创建同时进行。与大多数应用程序不同,即使用户没有提供任何输入,游戏也应该保持运行。如果玩家未执行任何操作,游戏不会冻结。角色单位可能会等待命令,但建筑将不断地完成它们的工作-生产新的角色。此外,敌方玩家(自动化的)力求胜利,从不停顿。

并发动作

游戏中的许多动作是同时发生的。正如我们刚才讨论的,建筑的建造不应该因为一个没有参与建造的单位被敌人攻击而停止。如果敌人发动攻击,建筑也不应该停止生产新角色。这意味着我们应该为游戏中的许多对象设计并发行为。

在 C++中实现并发的最佳方法之一是使用线程。我们可以重新设计单位和建筑,使它们包括一个可以在其基类中重写的动作,该动作将在单独的线程中执行。让我们重新设计IBuilding,使其成为一个抽象类,其中包含一个额外的run()虚函数:

class Building
{
public:
  virtual void attack(const ICharacterUnit&) = 0;
  virtual void destroy() = 0;
  virtual void build(ICharacterUnit*) = 0;
  virtual int get_life_points() const = 0;

public:  
 void run() {
 std::jthread{Building::background_action_, this};
 }

private:
  virtual void background_action_() {
 // no or default implementation in the base class 
 }
};

注意background_action_()函数;它是私有的,但是虚的。我们可以在派生类中重写它。run()函数不是虚的;它在一个线程中运行私有实现。在这里,派生类可以为background_action_()提供一个实现。当一个单位被分配来建造建筑时,将调用build()虚函数。build()函数将计算建造时间的工作委托给run()函数。

游戏事件循环

解决这个问题的最简单方法是定义一个事件循环。事件循环如下所示:

while (true)
{
  processUserActions();
  updateGame();
}

即使用户(玩家)没有任何操作,游戏仍会通过调用updateGame()函数继续进行。请注意,上述代码只是对事件循环的一般介绍。正如你所看到的,它会无限循环,并在每次迭代中处理和更新游戏。

每次循环迭代都会推进游戏的状态。如果用户操作处理时间很长,可能会阻塞循环。游戏会短暂地冻结。我们通常用每秒帧数FPS)来衡量游戏的速度。数值越高,游戏越流畅。

我们需要设计游戏循环,使其在游戏过程中持续运行。设计它的重要之处在于用户操作处理不会阻塞循环。

游戏循环负责游戏中发生的一切,包括 AI。这里的 AI 指的是我们之前讨论过的敌方玩家的自动化。除此之外,游戏循环处理角色和建筑的动作,并相应地更新游戏的状态。

在深入游戏循环设计之前,让我们先了解一些设计模式,这些模式将帮助我们完成这个复杂的任务。毕竟,游戏循环本身也是一个设计模式!

使用设计模式

使用面向对象OOP编程范式来设计游戏是很自然的。毕竟,游戏代表了一组对象,它们之间进行了密集的互动。在我们的策略游戏中,有单位建造的建筑。单位会抵御来自敌方单位的攻击等等。这种相互通信导致了复杂性的增长。随着项目的发展和功能的增加,支持它将变得更加困难。很明显,设计是构建项目中最重要的(如果不是最重要的)部分之一。整合设计模式将极大地改善设计过程和项目支持。

让我们来看一些在游戏开发中有用的设计模式。我们将从经典模式开始,然后讨论更多与游戏相关的模式。

命令模式

开发人员将设计模式分为创建型、结构型和行为型三类。命令模式是一种行为设计模式。行为设计模式主要关注对象之间通信的灵活性。在这种情况下,命令模式将一个动作封装在一个包含必要信息以及动作本身的对象中。这样,命令模式就像一个智能函数。在 C++中实现它的最简单方法是重载一个类的operator(),如下所示:

class Command
{
public:
  void operator()() { std::cout << "I'm a smart function!"; }
};

具有重载operator()的类有时被称为函数对象。前述代码几乎与以下常规函数声明相同:

void myFunction() { std::cout << "I'm not so smart!"; }

调用常规函数和Command类的对象看起来很相似,如下所示:

myFunction();
Command myCommand;
myCommand();

这两者之间的区别在于,当我们需要为函数使用状态时,这一点就显而易见了。为了为常规函数存储状态,我们使用静态变量。为了在对象中存储状态,我们使用对象本身。以下是我们如何跟踪重载运算符的调用次数:

class Command
{
public:
  Command() : called_(0) {}

  void operator()() {
    ++called_;
    std::cout << "I'm a smart function." << std::endl;
    std::cout << "I've been called" << called_ << " times." << std::endl;
  }

private:
  int called_;
};

每个Command类的实例的调用次数是唯一的。以下代码声明了两个Command的实例,并分别调用了两次和三次:

Command c1;
Command c2;
c1();
c1();
c2();
c2();
c2();
// at this point, c1.called_ equals 2, c2.called_ equals 3

现在,让我们尝试将这种模式应用到我们的策略游戏中。游戏的最终版本具有图形界面,允许用户使用各种按钮和鼠标点击来控制游戏。例如,要让一个角色单位建造一座房子,而不是兵营,我们应该在游戏面板上选择相应的图标。让我们想象一个带有游戏地图和一堆按钮来控制游戏动态的游戏面板。

游戏为玩家提供以下命令:

  • 将角色单位从 A 点移动到 B 点

  • 攻击敌人

  • 建造建筑

  • 安置房屋

游戏命令的设计如下:

每个类封装了动作逻辑。客户端代码不关心处理动作。它操作命令指针,每个指针将指向具体的Command(如前图所示)。请注意,我们只描述了玩家将执行的命令。游戏本身使用命令在模块之间进行通信。自动命令的示例包括RunDefendDieCreate。以下是游戏中命令的更广泛的图表:

前述命令执行游戏过程中出现的任何事件。要监听这些事件,我们应该考虑使用观察者模式。

观察者模式

观察者模式是一种允许我们订阅对象状态变化的架构机制。我们说我们观察对象的变化。观察者模式也是一种行为设计模式。

大多数策略游戏都包含资源的概念。这可能是岩石、黄金、木材等。例如,在建造建筑时,玩家必须花费 20 单位的木材、40 单位的岩石和 10 单位的黄金。最终,玩家将耗尽资源并必须收集资源。玩家创建更多角色单位并指派它们收集资源 - 几乎就像现实生活中发生的情况一样。

现在,假设我们的游戏中有类似的资源收集或消耗活动。当玩家指派单位收集资源时,他们应该在每次收集到一定数量的资源时通知我们。玩家是“资源收集”事件的订阅者。

建筑也是如此。建筑物生产角色 - 订阅者会收到通知。角色单位完成建筑施工 - 订阅者会收到通知。在大多数情况下,订阅者是玩家。我们更新玩家仪表板,以便在玩游戏时保持游戏状态最新;也就是说,玩家在玩游戏时可以了解自己拥有多少资源、多少单位和多少建筑物。

观察者涉及实现一个存储其订阅者并在事件上调用指定函数的类。它由两个实体组成:订阅者和发布者。如下图所示,订阅者的数量不限于一个:

例如,当角色单位被指定建造建筑时,它将不断努力建造,除非它被停止。可能会有各种原因导致这种情况发生:

  • 玩家决定取消建筑施工过程。

  • 角色单位必须保护自己免受敌人的攻击,并暂停施工过程。

  • 建筑已经完成,所以角色单位停止在上面工作。

玩家也希望在建筑完成时收到通知,因为他们可能计划在建筑完成后让角色单位执行其他任务。我们可以设计建筑过程,使其在事件完成时通知其监听者(订阅者)。以下类图还涉及一个 Action 接口。将其视为命令模式的实现:

根据观察者开发类,我们会发现游戏中几乎所有实体都是订阅者、发布者或两者兼而有之。如果遇到类似情况,可以考虑使用中介者-另一种行为模式。对象通过中介者对象相互通信。触发事件的对象会让中介者知道。然后中介者将消息传递给任何与对象状态“订阅”相关的对象。以下图表是中介者集成的简化版本:

每个对象都包含一个中介者,用于通知订阅者有关更改的信息。中介者对象通常包含彼此通信的所有对象。在事件发生时,每个对象通过中介者通知感兴趣的各方。例如,当建筑施工完成时,它会触发中介者,中介者会通知所有订阅的各方。为了接收这些通知,每个对象都应该事先订阅中介者。

Flyweight 模式

Flyweight 是一种结构设计模式。结构模式负责将对象和类组装成更大、更灵活的结构。Flyweight 允许我们通过共享它们的共同部分来缓存对象。

在我们的策略游戏中,屏幕上渲染了许多对象。在游戏过程中,对象的数量会增加。玩家玩得越久,他们创建的角色单位和建筑就越多(自动敌人也是如此)。游戏中的每个单位都代表一个包含数据的单独对象。角色单位至少占用 16 字节的内存(用于其两个整数数据成员和虚拟表指针)。

当我们为了在屏幕上渲染单位而向单位添加额外字段时,情况变得更糟;例如,它们的高度、宽度和精灵(代表渲染单位的图像)。除了角色单位,游戏还应该有一些补充物品,以提高用户体验,例如树木、岩石等装饰物品。在某个时候,我们会得出结论,我们有大量对象需要在屏幕上渲染,每个对象几乎代表相同的对象,但在其状态上有一些小差异。Flyweight 模式在这里发挥了作用。对于角色单位,其高度、宽度和精灵在所有单位中存储的数据几乎相同。

Flyweight 模式建议将一个重对象分解为两个:

  • 一个不可变的对象,包含相同类型对象的相同数据

  • 一个可变对象,可以从其他对象中唯一标识自己

例如,移动的角色单位有自己的高度、长度和精灵,所有这些对于所有角色单位都是重复的。因此,我们可以将这些属性表示为具有相同值的单个不可变对象,对于所有对象的属性都是相同的。然而,角色单位在屏幕上的位置可能与其他位置不同,当玩家命令单位移动到其他位置或开始建造建筑时,单位的位置会不断变化直到达到终点。在每一步,单位都应该在屏幕上重新绘制。通过这样做,我们得到以下设计:

左侧是修改前的CharacterUnit,右侧是使用享元模式进行了最近修改。游戏现在可以处理一堆CharacterUnit对象,而每个对象都将存储对几个UnitData对象的引用。这样,我们节省了大量内存。我们将每个单位独有的值存储在CharacterUnit对象中。这些值随时间变化。尺寸和精灵是恒定的,所以我们可以保留一个具有这些值的单个对象。这些不可变数据称为内在状态,而对象的可变部分(CharacterUnit)称为外在状态

我们有意将数据成员移动到CharacterUnit,从而将其从接口重新设计为抽象类。正如我们在第三章中讨论的那样,抽象类几乎与可能包含实现的接口相同。move()方法是所有类型单位的默认实现的一个例子。这样,派生类只提供必要的行为,因为所有单位共享生命点和力量等共同属性。

在优化内存使用之后,我们应该处理复制对象的问题。游戏涉及大量创建新对象。每个建筑物都会产生一个特定的角色单位;角色单位建造建筑物,游戏世界本身渲染装饰元素(树木、岩石等)。现在,让我们尝试通过整合克隆功能来改进CharacterUnit。在本章的早些时候,我们有意删除了复制构造函数和赋值运算符。现在,是时候提供一个从现有对象创建新对象的机制了。

原型模式

这种模式让我们能够独立于它们的类型创建对象的副本。以下代码代表了CharacterUnit类的最终版本,关于我们最近的修改。我们还将添加新的clone()成员函数,以便整合原型模式:

class CharacterUnit
{
public:
  CharacterUnit() {}
  CharacterUnit& operator=(const CharacterUnit&) = delete;
  virtual ~Character() {}

 virtual CharacterUnit* clone() = 0;

public:
  void move(const Point& to) {
    // the graphics-specific implementation
  }
  virtual void attack(const CharacterUnit&) = 0;
  virtual void destroy() = 0;

  int get_power() const { return power_; }
  int get_life_points() const { return life_points_; }

private:
  CharacterUnit(const CharacterUnit& other) {
    life_points_ = other.life_points_;
    power_ = other.power_;
  }

private:
  int life_points_;
  int power_;
};

我们删除了赋值运算符,并将复制构造函数移到了私有部分。派生类重写了clone()成员函数,如下所示:

class Reader : public CharacterUnit
{
public:
 Reader* clone() override {
 return new Reader(*this);
 }

 // code omitted for brevity
};

原型模式将克隆委托给对象。通用接口允许我们将客户端代码与对象的类解耦。现在,我们可以克隆一个角色单位,而不知道它是Reader还是Soldier。看下面的例子:

// The unit can have any of the CharacterUnit derived types
CharacterUnit* new_unit = unit->clone();

动态转换在我们需要将对象转换为特定类型时非常有效。

在本节中,我们讨论了许多有用的设计模式。如果您对这些模式还不熟悉,可能会感到有些不知所措;然而,正确使用它们可以让我们设计出灵活和易维护的项目。让我们最终回到之前介绍的游戏循环。

设计游戏循环

策略游戏拥有最频繁变化的游戏玩法之一。在任何时间点,许多动作会同时发生。读者完成他们的建筑;兵营生产士兵;士兵受到敌人的攻击;玩家命令单位移动、建造、攻击或逃跑;等等。游戏循环处理所有这些。通常,游戏引擎提供了一个设计良好的游戏循环。

当我们玩游戏时,游戏循环运行。正如我们已经提到的,循环处理玩家的动作,更新游戏状态,并渲染游戏(使状态变化对玩家可见)。它在每次迭代中都这样做。循环还应该控制游戏的速率,即其 FPS。游戏循环的一次迭代的常见术语是帧,这就是为什么我们强调 FPS 作为游戏速度的原因。例如,如果你设计一个以 60FPS 运行的游戏,这意味着每帧大约需要 16 毫秒。

在本章早些时候用于简单游戏循环的以下代码:

while (true)
{
  processUserActions();
  updateGame();
}

如果没有长时间的用户操作需要处理,上述代码将运行得很快。在快速的机器上运行得更快。你的目标是坚持每帧 16 毫秒。这可能需要我们在处理操作和更新游戏状态后稍微等待一下,就像下图所示:

每次更新都会按固定的数量推进游戏时间,这需要固定的现实时间来处理。另一方面,如果处理时间超过了帧的指定毫秒数,游戏就会变慢。

游戏中发生的一切大部分都在游戏的更新部分中涵盖,就像前面的图表所示。大多数情况下,更新可能需要同时执行多个操作。此外,正如我们之前提到的,我们必须为游戏中发生的一些操作保持计时器。这主要取决于我们想要使游戏变得多么详细。例如,建造一个建筑物可能被表示为两种状态:初始和最终。

在图形设计方面,这两种状态应该代表两种不同的图像。第一张图像包含建筑的一些基本部分,可能包括周围的一些岩石,就像它刚准备开始施工一样。下一张图像代表最终建成的建筑。当一个角色单位刚开始建造建筑时,我们向玩家展示第一张图像(基础部分和周围的一些岩石)。当建筑完成时,我们用包含最终建筑的图像替换第一张图像。为了使过程更加自然(更接近现实世界),我们人为地延长了时间。这意味着我们在两个图像状态之间保持一个持续 30 秒或更长的计时器。

我们描述了最简单的情况,细节最少。如果我们需要使游戏更加详细,例如在建筑物施工过程中渲染每一个变化,我们应该在很多图像之间保持很多计时器,每个图像代表施工的每一步。再次看一下前面的图表。更新游戏后,我们等待N毫秒。等待更多毫秒会使游戏的流程更接近现实生活。如果更新花费的时间太长,导致玩家体验滞后怎么办?在这种情况下,我们需要优化游戏,使其适应最优用户体验的时间框架。现在,假设更新游戏需要执行数百个操作;玩家已经建立了一个繁荣的帝国;现在正在建造大量建筑,并用许多士兵攻击敌人。

每个角色单位的每个动作,比如从一个点移动到另一个点,攻击一个敌人单位,建造一个建筑等,都会及时显示在屏幕上。现在,如果我们一次在屏幕上渲染数百个单位的状态会怎样?这就是我们使用多线程方法的地方。每个动作都涉及独立修改对象的状态(对象可以是游戏中的任何一个单位,包括静态建筑)。

总结

设计游戏是一项复杂的任务。我们可以将游戏开发视为一个独立的编程领域。游戏有不同的类型,其中之一是策略游戏。策略游戏设计涉及设计单位和建筑等游戏组件。通常,策略游戏涉及收集资源、建立帝国和与敌人战斗。游戏过程涉及游戏组件之间的动态交流,比如角色单位建造建筑和收集资源,士兵保卫土地免受敌人侵袭等。

为了正确设计策略游戏,我们需要结合面向对象设计技能和设计模式。设计模式在设计整个游戏以及其组件之间的交互方面起着重要作用。在本章中,我们讨论了命令模式,它将动作封装在对象下;观察者模式,用于订阅对象事件;以及中介者模式,用于将观察者提升到组件之间复杂交互的水平。

游戏最重要的部分是其循环。游戏循环控制渲染、游戏状态的及时更新以及其他子系统。设计它涉及使用事件队列和定时器。现代游戏使用网络,允许多个玩家通过互联网一起玩游戏。

在下一章中,我们将介绍 C++中的网络编程,这样你就会拥有将网络编程融入游戏中所需的技能。

问题

  1. 重写私有虚函数的目的是什么?

  2. 描述命令设计模式。

  3. 飞行权重模式如何节省内存使用?

  4. 观察者模式和中介者模式有什么区别?

  5. 为什么我们将游戏循环设计为无限循环?

进一步阅读

第十二章:网络和安全

网络编程变得越来越受欢迎。大多数计算机都连接到互联网,越来越多的应用程序现在依赖于它。从可能需要互联网连接的简单程序更新到依赖稳定互联网连接的应用程序,网络编程已经成为应用程序开发的必要部分。

直到最近的标准更新,C++语言才开始支持网络。网络支持已经推迟到了后续的标准,很可能要等到 C++23。然而,我们可以通过处理网络应用程序来为发布做好准备。我们还将讨论网络的标准扩展,并看看语言中支持网络会是什么样子。本章将集中讨论网络的主要原则和驱动设备之间通信的协议。设计网络应用程序是作为程序员技能的重要补充。

开发人员经常面临的一个主要问题是应用程序的安全性。无论是与正在处理的输入数据相关还是使用经过验证的模式和实践进行编码,应用程序的安全性必须是首要任务。对于网络应用程序来说尤为重要。在本章中,我们还将深入探讨 C++中安全编程的技术和最佳实践。

本章将涵盖以下主题:

  • 计算机网络简介

  • C++中的套接字和套接字编程

  • 设计网络应用程序

  • 了解 C++程序中的安全问题

  • 利用安全编程技术进行项目开发

技术要求

在本章的示例中,将使用 g++编译器以-std=c++2a选项进行编译。

您可以在github.com/PacktPublishing/Expert-CPP找到本章的源文件。

在 C++中发现网络编程

两台计算机通过网络进行交互。计算机使用特殊的硬件组件称为网络适配器网络接口控制器连接到互联网。安装在计算机上的操作系统提供驱动程序以与网络适配器一起工作;也就是说,为了支持网络通信,计算机必须安装有支持网络堆栈的操作系统。通过堆栈,我们指的是数据在从一台计算机传输到另一台计算机时经历的一系列修改层。例如,在浏览器上打开网站会呈现通过网络收集的数据。该数据以一系列零和一接收,然后转换为对 Web 浏览器更易理解的形式。分层在网络中是至关重要的。如今的网络通信由符合我们将在此讨论的 OSI 模型的几个层组成。网络接口控制器是支持开放系统互连OSI)模型的物理和数据链路层的硬件组件。

OSI 模型旨在标准化各种设备之间的通信功能。设备在结构和组织上有所不同。这涉及硬件和软件。例如,使用英特尔 CPU 运行 Android OS 的智能手机与运行 macOS Catalina 的 MacBook 电脑是不同的。不同之处不在于上述产品背后的名称和公司,而在于硬件和软件的结构和组织。为了消除网络通信中的差异,OSI 模型提出了一套标准化的协议和互联功能。我们之前提到的层如下:

  • 应用层

  • 表示层

  • 会话层

  • 传输层

  • 网络层

  • 数据链路层

  • 物理层

更简化的模型包括以下四个层:

  • 应用程序:处理特定应用程序的详细信息。

  • 传输:这提供了两个主机之间的数据传输。

  • 网络:这处理网络中数据包的传输。

  • 链路:这包括操作系统中的设备驱动程序,以及计算机内的网络适配器。

链路(或数据链路)层包括操作系统中的设备驱动程序,以及计算机中的网络适配器。

为了理解这些层,让我们假设您正在使用桌面应用程序进行消息传递,比如SkypeTelegram。当您输入一条消息并点击发送按钮时,消息会通过网络传输到其目的地。在这种情况下,假设您正在向安装了相同应用程序的朋友发送文本消息。从高层次的角度来看,这可能看起来很简单,但这个过程是复杂的,即使是最简单的消息在到达目的地之前也经历了许多转换。首先,当您点击发送按钮时,文本消息会被转换为二进制形式。网络适配器使用二进制。它的基本功能是通过介质发送和接收二进制数据。除了实际发送到网络上的数据之外,网络适配器还应该知道数据的目的地地址。目的地地址是附加到用户数据的许多属性之一。通过用户数据,我们指的是您输入并发送给朋友的文本。目的地地址是您朋友计算机的唯一地址。输入的文本与目的地地址和其他必要信息一起打包,以便发送到目标位置。您朋友的计算机(包括网络适配器、操作系统和消息应用程序)接收并解包数据。然后消息应用程序会在屏幕上显示该数据包中的文本。

几乎在本章开头提到的每个 OSI 层都会向通过网络发送的数据添加其特定的标头。以下图表描述了应用层数据在移动到目的地之前如何叠加标头:

OSI 模型

看一下前面图表中的第一行(应用层)。数据是您在消息应用程序中输入的文本,以便将其发送给您的朋友。在每一层,一直到物理层,数据都会被打包,并附加有 OSI 模型每一层特定的标头。另一边的计算机接收并检索打包的数据。在每一层,它会移除该层特定的标头,并将其余的数据包移动到下一层。最终,数据到达您朋友的消息应用程序。

作为程序员,我们主要关注编写能够在网络上发送和接收数据的应用程序,而不深入了解各层的细节。然而,我们需要对如何在更高层次上使用标头增强数据有一定的了解。让我们学习一下网络应用程序在实践中是如何工作的。

网络应用程序的内部工作

安装在设备上的网络应用程序通过网络与其他设备上安装的应用程序进行通信。在本章中,我们将讨论通过互联网一起工作的应用程序。可以在以下图表中看到这种通信的高层概述:

在通信的最低层是物理层,它通过介质传输数据位。在这种情况下,介质是网络电缆(也考虑 Wi-Fi 通信)。用户应用程序抽象了网络通信的较低层。程序员所需的一切都由操作系统提供。操作系统实现了网络通信的低级细节,比如传输控制协议/互联网协议TCP/IP)套件。

每当应用程序需要访问网络,无论是局域网还是互联网,它都会请求操作系统提供一个访问点。操作系统通过利用网络适配器和特定软件与硬件通信来管理提供网络的网关。

这更详细的说明如下:

操作系统提供了一个用于处理其网络子系统的 API。程序员应该关心的主要抽象是套接字。我们可以将套接字视为通过网络适配器发送其内容的文件。套接字是连接两台计算机的访问点,如下图所示:

从程序员的角度来看,套接字是一个允许我们在应用程序中通过网络实现数据传输的结构。套接字是一个连接点,可以发送或接收数据;也就是说,应用程序也可以通过套接字接收数据。操作系统在请求时为应用程序提供套接字。一个应用程序可以拥有多个套接字。客户端应用程序在客户端-服务器架构中通常使用单个套接字。现在,让我们详细了解套接字编程。

使用套接字编程网络应用

正如我们之前提到的,套接字是对网络通信的抽象。我们将它们视为常规文件 - 所有写入套接字的内容都由操作系统通过网络发送到目的地。通过网络接收到的所有内容都会被操作系统写入套接字。这样,操作系统为网络应用程序提供了双向通信。

假设我们运行两个不同的与网络相关的应用程序。例如,我们打开一个网页浏览器来浏览网页,并使用一个消息应用(如 Skype)与朋友聊天。网页浏览器代表了客户端-服务器网络架构中的客户端应用程序。在这种情况下,服务器是响应所请求数据的计算机。例如,我们在网页浏览器的地址栏中输入一个地址,然后在屏幕上看到生成的网页。每当我们访问一个网站时,网页浏览器都会从操作系统请求一个套接字。在编码方面,网页浏览器使用操作系统提供的 API 创建一个套接字。我们可以用更具体的前缀来描述套接字:客户端套接字。为了让服务器处理客户端请求,运行 Web 服务器的计算机必须监听传入的连接;也就是说,服务器应用程序创建一个用于监听连接的服务器套接字。

每当客户端和服务器之间建立连接时,数据通信就可以进行。下图描述了网页浏览器对facebook.com的请求:

请注意前图中的数字组。这被称为Internet ProtocolIP地址。IP 地址是我们需要的位置,以便将数据传输到设备。有数十亿台设备连接到互联网。为了对它们进行唯一区分,每个设备都会暴露一个代表其地址的唯一数字值。使用 IP 协议建立连接,这就是为什么我们称其为 IP 地址。IP 地址由四组 1 字节长度的数字组成。它的点分十进制表示形式为 X.X.X.X,其中 X 是 1 字节数字。每个位置的值范围从 0 到 255。更具体地说,这是一个版本 4 的 IP 地址。现代系统使用版本 6 地址,这是数字和字母的组合,提供了更广泛的可用地址值范围。

创建套接字时,我们将本地计算机的 IP 地址分配给它;也就是说,我们将套接字绑定到该地址。当使用套接字向网络中的另一设备发送数据时,我们应该设置其目标地址。目标地址由该设备上的另一个套接字持有。为了在两个设备之间创建连接,我们使用两个套接字。可能会出现一个合理的问题——如果设备上运行了多个应用程序怎么办?如果我们运行了多个应用程序,每个应用程序都为自己创建了一个套接字怎么办?哪一个应该接收传入的数据?

要回答这些问题,请仔细查看前面的图表。您应该在 IP 地址末尾的冒号后看到一个数字。这被称为端口号。端口号是一个 2 字节长度的数字,由操作系统分配给套接字。由于 2 字节长度限制,操作系统无法为套接字分配超过 65,536 个唯一的端口号;也就是说,您不能有超过 65,536 个同时运行的进程或线程通过网络进行通信(但是有方法可以重用套接字)。除此之外,还有一些端口号专门为特定应用程序保留。这些端口称为众所周知的端口,范围从 0 到 1023。它们保留用于特权服务。例如,HTTP 服务器的端口号是 80。这并不意味着它不能使用其他端口。

让我们学习如何在 C++中创建套接字。我们将设计一个封装便携操作系统接口POSIX)套接字的包装类,也称为伯克利BSD套接字。它具有用于套接字编程的标准函数集。网络编程的 C++扩展将是语言的巨大补充。工作草案包含有关网络接口的信息。我们将在本章后面讨论这一点。在那之前,让我们尝试为现有和低级库创建我们自己的网络包装器。当我们使用 POSIX 套接字时,我们依赖于操作系统的 API。操作系统提供了一个 API,表示用于创建套接字、发送和接收数据等的函数和对象。

POSIX 将套接字表示为文件描述符。我们几乎可以像处理常规文件一样使用它。文件描述符遵循 UNIX 哲学,提供了一个通用的数据输入/输出接口。以下代码使用socket()函数(在<sys/socket.h>头文件中定义)创建套接字:

int s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

socket()函数的声明如下:

int socket(int domain, int type, int protocol);

因此,AF_INETSOCK_STREAMIPPROTO_TCP都是数值。域参数指定套接字的协议族。我们使用AF_INET来指定 IPv4 协议。对于 IPv6,我们使用AF_INET6。第二个参数指定套接字的类型,即它是面向流的还是数据报的套接字。对于每种特定类型,最后一个参数应相应地指定。在前面的示例中,我们使用IPPROTO_TCP指定了SOCK_STREAM传输控制协议TCP)代表可靠的面向流的协议。这就是为什么我们将类型参数设置为SOCK_STREAM的原因。在实现简单的套接字应用程序之前,让我们更多地了解网络协议。

网络协议

网络协议是一组规则和数据格式,用于定义应用程序之间的互联。例如,Web 浏览器和 Web 服务器通过超文本传输协议HTTP)进行通信。HTTP 更像是一组规则,而不是传输协议。传输协议是每个网络通信的基础。传输协议的一个例子是 TCP。当我们提到 TCP/IP 套件时,我们指的是 TCP 在 IP 上的实现。我们可以将互联网协议IP)视为互联网通信的核心。

它提供主机到主机的路由和寻址。我们通过互联网发送或接收的所有内容都被打包成IP 数据包。以下是 IPv4 数据包的外观:

IP 头部重量为 20 字节。它结合了从源地址到目的地址传递数据包所需的标志和选项。在 IP 协议领域,我们通常称数据包为数据报。每个层都有其特定的数据包术语。更加细心的专家会谈论将 TCP 段封装到 IP 数据报中。将它们称为数据包是完全可以的.

每个更高级别的协议都会向通过网络发送和接收的数据附加元信息;例如,TCP 数据封装在 IP 数据报中。除了这些元信息,协议还定义了应该执行的底层规则和操作,以完成两个或多个设备之间的数据传输。

您可以在称为请求评论RFCs)的特定文档中找到更详细的信息。例如,RFC 791 描述了互联网协议,而 RFC 793 描述了传输控制协议。

许多流行的应用程序 - 文件传输、电子邮件、网络等 - 使用 TCP 作为它们的主要传输协议。例如,HTTP 协议定义了从客户端到服务器和反之亦然传输的消息格式。实际的传输是使用传输协议进行的 - 在这种情况下是 TCP。但是,HTTP 标准并不限制 TCP 成为唯一的传输协议。

下图说明了在将数据传递到较低级别之前,TCP 头被附加到数据中:

注意源端口号和目标端口号。这些是在操作系统中区分运行进程的唯一标识符。还要看一下序列号和确认号。它们是 TCP 特有的,用于传输可靠性。

在实践中,TCP 由于以下特点而被使用:

  • 丢失数据的重传

  • 按顺序传递

  • 数据完整性

  • 拥塞控制和避免

IP(即互联网协议)是不可靠的。它不关心丢失的数据包,这就是为什么 TCP 处理丢失数据包的重传。它使用唯一标识符标记每个数据包,应该由传输的另一端确认。如果发送方没有收到数据包的确认码ACK),协议将重新发送数据包(有限次数)。正确接收数据包也非常重要。TCP 重新排序接收到的数据包以正确表示排序信息。这就是为什么在线听音乐时,我们不会在歌曲的开头听到结尾。

数据包的重传可能会导致另一个问题,即网络拥塞。当节点无法快速发送数据包时,就会发生这种情况。数据包会被卡住一段时间,不必要的重传会增加它们的数量。TCP 的各种实现采用了拥塞避免算法。

它维护一个拥塞窗口 - 一个确定可以发送的数据量的因素。使用慢启动机制,TCP 在初始化连接后缓慢增加拥塞窗口。尽管该协议在相应的请求评论RFC)中有描述,但在操作系统中实现的机制有很多不同。

在另一边是用户数据报协议UDP)。这两者之间的主要区别是 TCP 是可靠的。这意味着在丢失网络数据包的情况下,它会重新发送相同的数据包,直到它到达指定的目的地。由于其可靠性,通过 TCP 进行的数据传输被认为比使用 UDP 需要更长的时间。UDP 不能保证我们可以正确地传递数据包而且没有丢失。相反,开发人员应该负责重新发送、检查和验证数据传输。需要快速通信的应用程序倾向于依赖 UDP。例如,视频通话应用程序或在线游戏使用 UDP 因为它的速度。即使在传输过程中丢失了几个数据包,也不会影响用户体验。在玩游戏或进行视频聊天时,最好出现小故障,而不是等待下一帧游戏或视频。

TCP 比 UDP 慢的主要原因之一是 TCP 连接初始化过程中步骤较多。下图显示了 TCP 连接建立的过程,也称为三次握手:

客户端在向服务器发送SYN数据包时选择一个随机数。服务器将该随机数加一,选择另一个随机数,并回复一个SYN-ACK数据包。客户端将从服务器接收的两个数字都加一,并通过向服务器发送最后一个ACK完成握手。成功完成三次握手后,客户端和服务器可以相互传输数据包。这种连接建立过程适用于每个 TCP 连接。握手的细节对网络应用程序的开发者是隐藏的。我们创建套接字并开始监听传入的连接。

注意两种端点之间的区别。其中之一是客户端。在实现网络应用程序时,我们应该明确区分客户端和服务器,因为它们有不同的实现。这也与套接字的类型有关。创建服务器套接字时,我们使其监听传入的连接,而客户端不监听 - 它发出请求。下图描述了客户端和服务器的某些函数及其调用顺序:

在代码中创建套接字时,我们指定协议和套接字的类型。当我们需要两个端点之间的可靠连接时,我们选择 TCP。有趣的是,我们可以使用 TCP 等传输协议来构建自己的协议。假设我们定义了一种特殊的文档格式来发送和接收以使通信有效。例如,每个文档应该以单词 PACKT 开头。HTTP 也是这样工作的。它使用 TCP 进行传输,并定义了其上的通信格式。在 UDP 的情况下,我们还应该为通信设计和实现可靠性策略。前面的图表显示了 TCP 如何在两个端点之间建立连接。客户端向服务器发送SYN请求。服务器用SYN-ACK响应回答,让客户端知道可以继续握手。最后,客户端向服务器发送ACK,表示连接已正式建立。他们可以随意进行通信。

同步SYN)和确认(ACK)是协议定义的术语,在网络编程中变得常见。

UDP 不是这样工作的。它将数据发送到目的地,而不必担心是否建立了连接。如果您使用 UDP 但需要一些可靠性,您应该自己来实现;例如,通过检查一部分数据是否到达了目的地。为了检查它,您可以等待目的地用自定义定义的ACK数据包进行回复。大多数可靠性导向的实现可能会重复已经存在的协议,如 TCP。然而,有许多情况下您不需要它们;例如,您不需要拥塞避免,因为您不需要发送相同的数据包两次。

在上一章中,我们设计了一个策略游戏。假设游戏是在线的,你正在与一个真正的对手而不是一个自动化的敌对玩家进行游戏。游戏的每一帧都是基于通过网络接收的数据进行渲染的。如果我们在使数据传输可靠、增加数据完整性以及确保没有任何数据包丢失方面付出了一些努力,可能会因为玩家的不同步而影响用户体验。这种情况适合使用 UDP。我们可以实现数据传输而不需要重传策略,以便提高游戏的速度。当然,使用 UDP 并不强迫我们避免可靠性。在同样的情况下,我们可能需要确保数据包被玩家成功接收。例如,当玩家投降时,我们应该确保对手收到消息。因此,我们可以根据数据包的优先级进行有条件的可靠性。UDP 在网络应用程序中提供了灵活性和速度。

让我们来看一个 TCP 服务器应用程序的实现。

设计网络应用程序

使用一个需要网络连接的小子系统来设计应用程序的方法与完全与网络相关的应用程序不同。后者的一个例子可能是用于文件存储和同步的客户端-服务器应用程序(如 Dropbox)。它由服务器和客户端组成,其中客户端安装为桌面或移动应用程序,也可以用作文件资源管理器。由 Dropbox 控制的系统中文件的每次更新都将立即与服务器同步。这样,您将始终在云中拥有您的文件,并可以在任何地方通过互联网连接访问它们。

我们将设计一个类似的简化的服务器应用程序,用于文件存储和操作。服务器的主要任务如下:

  • 从客户端应用程序接收文件

  • 在指定的位置存储文件

  • 根据请求向客户端发送文件

参考第十章,设计面向世界的应用程序,我们可以继续进行以下应用程序的顶层设计:

在上图中的每个矩形代表一个类或一组类,涉及特定的任务。例如,存储管理器处理与存储和检索文件相关的所有事务。在这一点上,它使用文件、位置、数据库等类并不那么关心。

客户端管理器是一个类或一组类,用于处理与客户端(指客户端应用程序)相关的所有事务,包括认证或授权客户端,与客户端保持稳定的连接,从客户端接收文件,向客户端发送文件等。

在本章中,我们特别强调了网络作为一个感兴趣的实体。所有与网络连接相关的事情,以及与客户端的数据传输,都是通过网络来处理的。现在,让我们看看我们可以使用什么功能来设计网络类(我们将称之为网络管理器以方便起见)。

使用 POSIX 套接字

正如我们之前提到的,诸如socket()bind()accept()之类的函数在大多数 Unix 系统中默认支持。之前,我们包含了<sys/socket.h>文件。除此之外,我们还需要几个其他头文件。让我们实现经典的 TCP 服务器示例,并将其封装在 Networking 模块中,用于我们的文件传输应用服务器。

正如我们之前提到的,服务器端开发在套接字的类型和行为方面与客户端开发不同。虽然两边都使用套接字,但服务器端套接字不断监听传入的连接,而客户端套接字则与服务器建立连接。为了使服务器套接字等待连接,我们创建一个套接字并将其绑定到服务器 IP 地址和客户端将尝试连接的端口号。以下 C 代码表示了 TCP 服务器套接字的创建和绑定:

int s = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(port);
server.sin_addr.s_addr = INADDR_ANY;

bind(s, (struct sockaddr*)&server, sizeof(server));

第一个调用创建了一个套接字。第三个参数设置为 0,这意味着将根据套接字的类型选择默认协议。类型作为第二个参数传递,SOCK_STREAM,这将使协议值默认等于IPPROTO_TCPbind()函数将套接字绑定到指定的 IP 地址和端口号。我们在sockaddr_in结构中指定了它们,该结构将网络地址相关的细节组合在一起。

虽然我们在前面的代码中跳过了这一点,但你应该考虑检查对socket()bind()函数(以及 POSIX 套接字中的其他函数)的调用是否出现错误。几乎所有这些函数在出现错误时都会返回-1

另外,注意htons()函数。它负责将其参数转换为网络字节顺序。问题隐藏在计算机设计的方式中。一些机器(例如 Intel 微处理器)使用小端字节顺序,而其他一些使用大端顺序。小端顺序将最不重要的字节放在最前面。大端顺序将最重要的字节放在最前面。以下图表显示了两者之间的区别:

网络字节顺序是与特定机器架构无关的约定。htons()函数将提供的端口号从主机字节顺序(小端大端)转换为网络字节顺序(与机器无关)。

就是这样——套接字已经准备好了。现在,我们应该指定它准备好接收传入的连接。为了指定这一点,我们使用listen()函数:

listen(s, 5);

顾名思义,它用于监听传入的连接。传递给listen()函数的第二个参数指定了服务器在丢弃新的传入请求之前将排队的连接数。在前面的代码中,我们指定了5作为最大数。在高负载环境中,我们会增加这个数字。最大数由<sys/socket.h>头文件中定义的SOMAXCONN常量指定。

backlog 数(listen()函数的第二个参数)的选择基于以下因素:

  • 如果连接请求的速率在短时间内很高,那么 backlog 数应该有一个较大的值。

  • 服务器处理传入连接的持续时间。时间越短,backlog 值就越小。

当连接初始化发生时,我们可以选择放弃它或接受它并继续处理连接。这就是为什么我们在下面的代码段中使用accept()函数:

struct sockaddr_in client;
int addrlen;
int new_socket = accept(s, (struct sockaddr_in*)&client, &addrlen);
// use the new_socket

在前面的代码中需要考虑的两件事如下:

  • 首先,接受的套接字连接信息被写入客户端的sockaddr_in结构中。我们可以从该结构中收集关于客户端的所有必要信息。

  • 接下来,要注意accept()函数的返回值。它是一个新的套接字,用于处理来自特定客户端的请求。下一次调用accept()函数将返回另一个值,代表另一个具有独立连接的客户端。我们应该正确处理这一点,因为accept()调用是阻塞的;也就是说,它等待新的连接请求。我们将修改前面的代码,以便在单独的线程中处理多个连接。

在前面的代码中带有注释的最后一行说明new_socket可以用于接收或发送数据给客户端。让我们看看如何实现这一点,然后开始设计我们的Networking类。要读取套接字接收的数据,我们需要使用recv()函数,如下所示:

char buffer[BUFFER_MAX_SIZE]; // define BUFFER_MAX_SIZE based on the specifics of the server
recv(new_socket, buffer, sizeof(buffer), 0);
// now the buffer contains received data

recv()函数接受一个char*缓冲区来写入数据。它在sizeof(buffer)处停止写入。函数的最后一个参数是我们可以设置用于读取的附加标志。您应该考虑多次调用该函数以读取大于BUFFER_MAX_SIZE的数据。

最后,要通过套接字发送数据,我们调用send()函数,如下所示:

char msg[] = "From server with love";
send(new_socket, msg, sizeof(msg), 0);

通过这样,我们几乎涵盖了实现服务器应用程序所需的所有函数。现在,让我们将它们封装在一个 C++类中,并加入多线程,以便我们可以并发处理客户端请求。

实现一个 POSIX 套接字包装类

让我们设计和实现一个类,它将作为基于网络的应用程序的起点。该类的主要接口如下所示:

class Networking
{
public:
  void start_server();

public:
  std::shared_ptr<Networking> get_instance();
  void remove_instance();

private:
  Networking();
  ~Networking();

private:
  int socket_;
  sockaddr_in server_;
  std::vector<sockaddr_in> clients_;

private:
  static std::shared_ptr<Networking> instance_ = nullptr;
  static int MAX_QUEUED_CONNECTIONS = 1;
};

Networking类作为单例是很自然的,因为我们希望有一个单一的实例来监听传入的连接。同时,拥有多个对象,每个对象代表与客户端的单独连接,也是很重要的。让我们逐渐改进类的设计。之前,我们看到在服务器套接字监听并接受连接请求之后,将创建每个新的客户端套接字。

在那之后,我们可以通过新的客户端套接字发送或接收数据。服务器的操作方式与下图中所示的类似:

也就是说,在接受每个传入的连接之后,我们将有一个单独的套接字用于连接。我们将它们存储在Networking类的clients_向量中。因此,我们可以在一个函数中编写创建服务器套接字、监听和接受新连接的主要逻辑,如果需要的话,可以并发工作。start_server()函数作为服务器监听传入连接的起点。以下代码块说明了这一点:

void Networking::start_server()
{
  socket_ = socket(AF_INET, SOCK_STREAM, 0);
  // the following check is the only one in this code snippet
  // we skipped checking results of other functions for brevity, 
  // you shouldn't omit them in your code
  if (socket_ < 0) { 
    throw std::exception("Cannot create a socket");
  }

  struct sockaddr_in server;
  server.sin_family = AF_INET;
  server.sin_port = htons(port);
  server.sin_addr.s_addr = INADDR_ANY;

  bind(s, (struct sockaddr*)&server, sizeof(server));
  listen(s, MAX_QUEUED_CONNECTIONS);
 // the accept() should be here
}

现在,我们停在了应该接受传入连接的地方(请参阅前面的代码片段中的注释)。我们在这里有两种选择(实际上,不止两种选择,但我们只讨论其中的两种)。我们可以直接将accept()调用放入start_server()函数中,或者我们可以实现一个单独的函数,Networking类用户在适当时将调用它。

为项目中的每个错误情况拥有特定的异常类并不是一个坏的做法。在考虑自定义异常时,前面的代码可能会被重写。您可以将其作为一个作业项目来完成。

其中一个选择在start_server()函数中有accept()函数,它将每个新连接推送到clients_向量中,如下所示:

void Networking::start_server()
{
  // code omitted for brevity (see in the previous snippet)
  while (true) {
    sockaddr_in client;
    int addrlen;
    int new_socket = accept(socket_, (sockaddr_in*)&client, &addrlen);
    clients_.push_back(client);
  }
}

是的,我们使用了一个无限循环。这听起来可能很糟糕,但只要服务器在运行,它就必须接受新的连接。然而,我们都知道无限循环会阻塞代码的执行;也就是说,它永远不会离开start_server()函数。我们将我们的网络应用程序介绍为一个至少有三个组件的项目:客户端管理器、存储管理器,以及我们正在设计的Networking类。

一个组件的执行不应以不好的方式影响其他组件;也就是说,我们可以使用线程使一些组件在后台运行。在线程的上下文中运行的start_server()函数是一个不错的解决方案,尽管我们现在应该关心我们在第八章中讨论的同步问题,即并发和多线程。

还要注意前面循环的不完整性。在接受连接后,它将客户端数据推送到clients_向量中。我们应该考虑使用另一个结构,因为我们还需要存储套接字描述符,以及客户端。我们可以使用std::undordered_map将套接字描述符映射到客户端连接信息,但简单的std::pairstd::tuple也可以。

然而,让我们更进一步,创建一个表示客户端连接的自定义对象,如下所示:

class Client
{
public:
  // public accessors

private:
  int socket_;
  sockaddr_in connection_info_;
};

我们将修改Networking类,使其存储Client对象的向量:

std::vector<Client> clients_;

现在,我们可以改变设计方法,使Client对象负责发送和接收数据:

class Client
{
public:
  void send(const std::string& data) {
    // wraps the call to POSIX send() 
  }
  std::string receive() {
    // wraps the call to POSIX recv()
  }

  // code omitted for brevity 
};

更好的是,我们可以将std::thread对象附加到Client类,这样每个对象都可以在单独的线程中处理数据传输。然而,你应该小心不要使系统陷入饥饿状态。传入连接的数量可能会急剧增加,服务器应用程序将会变得卡住。在下一节中,当我们讨论安全问题时,我们将讨论这种情况。建议您利用线程池,这将帮助我们重用线程并控制程序中运行的线程数量。

类的最终设计取决于我们接收和发送给客户端的数据类型。至少有两种不同的方法。其中一种是连接到客户端,接收必要的数据,然后关闭连接。第二种方法是实现客户端和服务器之间通信的协议。虽然听起来复杂,但协议可能很简单。

这也是可扩展的,使应用程序更加健壮,因为您可以在项目发展过程中支持更多功能。在下一节中,当我们讨论如何保护网络服务器应用程序时,我们将回到设计用于验证客户端请求的协议。

保护 C++代码

与许多其他语言相比,C++在安全编码方面稍微难以掌握。有许多指南提供了关于如何避免 C++程序中的安全风险的建议。我们在第一章中讨论的最受欢迎的问题之一是使用预处理器宏。我们使用的例子有以下宏:

#define DOUBLE_IT(arg) (arg * arg)

不正确使用这个宏会导致难以发现的逻辑错误。在下面的代码中,程序员期望在屏幕上打印16

int res = DOUBLE_IT(3 + 1);
std::cout << res << std::endl;

输出是7。这里的问题在于arg参数周围缺少括号;也就是说,前面的宏应该重写如下:

#define DOUBLE_IT(arg) ((arg) * (arg))

尽管这个例子很受欢迎,我们强烈建议尽量避免使用宏。C++提供了许多可以在编译时处理的构造,比如constexprconstevalconstinit - 即使语句也有constexpr的替代方案。如果您需要在代码中进行编译时处理,请使用它们。当然,还有模块,这是语言中期待已久的补充。您应该更喜欢使用模块,而不是使用#include和无处不在的包含保护:

module my_module;
export int test;

// instead of

#ifndef MY_HEADER_H
#define MY_HEADER_H
int test
#endif 

这不仅更安全,而且更高效,因为模块只处理一次(我们可以将它们视为预编译头)。

虽然我们不希望您对安全问题变得偏执,但您几乎应该在任何地方小心。通过学习语言的怪癖和奇特之处,您将避免大部分这些问题。此外,一个好的做法是使用替换或修复以前版本的缺点的最新功能。例如,考虑以下create_array()函数:

// Don't return pointers or references to local variables
double* create_array()
{
  double arr[10] = {0.0};
  return arr;
}

create_array() 函数的调用者因为arr具有自动存储期而留下了指向不存在数组的指针。如果需要,我们可以用更好的替代方案来替换前面的代码:

#include <array>

std::array<double> create_array()
{
  std::array<double> arr;
  return arr;
}

字符串被视为字符数组,是许多缓冲区溢出问题的原因。其中最常见的问题之一是在忽略其大小的情况下向字符串缓冲区写入数据。在这方面,std::string类是 C 字符串的一个更安全的替代方案。然而,在支持旧代码时,您在使用strcpy()等函数时应该小心,就像以下示例中所示:

#include <cstdio>
#include <cstring>

int main()
{
  char small_buffer[4];
  const char* long_text = "This text is long enough to overflow small buffers!";
 strcpy(small_buffer, long_text);
}

鉴于法律上,small_buffer应该在末尾有一个空终结符,它只能处理long_text字符串的前三个字符。然而,在调用strcpy()后发生了以下情况:

在实现网络应用程序时,您应该更加小心。大部分来自客户端连接的数据应该得到适当处理,缓冲区溢出并不罕见。让我们学习如何使网络应用程序更加安全。

保护网络应用程序

在本书的前一节中,我们设计了一个使用套接字连接接收客户端数据的网络应用程序。除了大部分渗入系统的病毒来自外部世界这一事实之外,网络应用程序有这种自然倾向,即向互联网上的各种威胁打开计算机。首先,每当您运行一个网络应用程序时,系统中就存在一个开放的端口。知道您的应用程序正在监听的确切端口的人可以通过伪造协议数据侵入。我们将主要讨论网络应用程序的服务器端;然而,这里的一些主题也适用于客户端应用程序。

你应该做的第一件事之一是加入客户端授权和认证。这两个术语很容易混淆。小心不要将它们互换使用;它们是不同的:

  • 认证是验证客户端访问的过程。这意味着并非每个传入的连接请求都会立即得到服务。在与客户端传输数据之前,服务器应用程序必须确保客户端是已知的客户端。几乎与我们通过输入电子邮件和密码访问社交网络平台的方式相同,客户端的认证定义了客户端是否有权访问系统。

  • 授权,另一方面,定义了客户端在系统中可以做什么。这是一组权限,提供给特定的客户端。例如,我们在前一节讨论的客户端应用程序能够上传文件到系统中。迟早,您可能希望加入付费订阅,并为付费客户提供更广泛的功能;例如,允许他们创建文件夹来组织他们的文件。因此,当客户端请求创建文件夹时,我们可能希望授权请求以发现客户端是否有权这样做。

当客户端应用程序与服务器建立连接时,服务器获得的只是连接详细信息(IP 地址,端口号)。为了让服务器知道客户端应用程序背后的是谁(实际用户),客户端应用程序发送用户的凭据。通常,这个过程涉及向用户发送一个唯一标识符(如用户名或电子邮件地址)和密码以访问系统。然后,服务器会检查这些凭据与其数据库,并验证是否应该允许客户端访问。客户端和服务器之间的这种通信形式可能是简单的文本传输或格式化对象传输。

例如,服务器定义的协议可能要求客户端以以下形式发送JavaScript 对象表示JSON)文档:

{
  "email": "myemail@example.org",
  "password": "notSoSIMPLEp4s8"
}

服务器的响应允许客户端进一步进行,或者更新其用户界面以让用户知道操作的结果。在使用任何网络应用程序或网络应用程序时,您可能遇到了几种情况。例如,错误输入的密码可能导致服务器返回“无效的用户名或密码”错误。

除了这一必要的第一步之外,验证来自客户端应用程序的每一条数据都是明智的。如果检查电子邮件字段的大小,就可以很容易地避免缓冲区溢出。例如,当客户端应用程序故意试图破坏系统时,可能会发送一个 JSON 对象,其中的字段具有非常大的值。这个检查是服务器的责任。预防安全漏洞始于数据验证。

另一种安全攻击形式是从单个或多个客户端每秒发出过多的请求。例如,一个客户端应用程序在 1 秒内发出数百个身份验证请求,导致服务器密集处理这些请求,并浪费资源试图为它们提供服务。最好检查客户端请求的速率,例如,将其限制为每秒一个请求。

这些形式的攻击(有意或无意的)被称为拒绝服务DOS)攻击。DOS 攻击的更高级版本采取了从多个客户端向服务器发出大量请求的形式。这种形式被称为分布式 DOSDDOS)攻击。一个简单的方法可能是黑名单 IP 地址,这些 IP 地址试图通过每秒发出多个请求来使系统崩溃。作为网络应用程序的程序员,在开发应用程序时,您应该考虑本书范围之外的所有这些问题以及其他许多问题。

总结

在本章中,我们介绍了在 C++中设计网络应用程序。从其第一个版本开始,C++一直缺乏对网络的内置支持。C++23 标准计划最终在语言中引入对网络的支持。

我们首先介绍了网络的基础知识。完全理解网络需要很长时间,但在实现与网络有关的任何应用程序之前,每个程序员都必须了解一些基本概念。这些基本概念包括 OSI 模型中的分层和不同类型的传输协议,如 TCP 和 UDP。了解 TCP 和 UDP 之间的区别对于任何程序员都是必要的。正如我们所学到的,TCP 在套接字之间建立可靠的连接,而套接字是开发网络应用程序时程序员遇到的下一个东西。这些是两个应用程序实例的连接点。每当我们需要通过网络发送或接收数据时,我们应该定义一个套接字,并且几乎可以像处理常规文件一样处理它。

我们在应用程序开发中使用的所有抽象和概念都由操作系统处理,并最终由网络适配器处理。这是一种能够通过网络介质发送数据的设备。从介质接收数据并不能保证安全。网络适配器接收来自介质的任何东西。为了确保我们正确处理传入数据,我们还应该注意应用程序安全性。本章的最后一节是关于编写安全代码和验证输入,以确保程序不会受到伤害。保护程序是确保程序质量的良好步骤。开发程序的最佳方法之一是彻底测试它们。您可能还记得,在第十章中,设计面向世界的应用程序,我们讨论了软件开发步骤,并解释了一旦编码阶段完成,测试程序是最重要的步骤之一。测试后,您很可能会发现许多错误。其中一些错误很难重现和修复,这就是调试发挥作用的地方。

下一章是关于以正确的方式测试和调试您的程序。

问题

  1. 列出 OSI 模型的所有七层。

  2. 端口号的意义是什么?

  3. 为什么应该在网络应用程序中使用套接字?

  4. 描述在服务器端使用 TCP 套接字接收数据时应执行的操作顺序。

  5. TCP 和 UDP 之间有什么区别?

  6. 为什么不应该在代码中使用宏定义?

  7. 在实现服务器应用程序时,如何区分不同的客户端应用程序?

进一步阅读

第十三章:调试和测试

调试和测试在软件开发过程的流水线中扮演着极其重要的角色。测试帮助我们发现问题,而调试修复问题。然而,如果我们在实施阶段遵循一定的规则,就可以预防许多潜在的缺陷。此外,由于测试过程非常昂贵,如果我们能在需要人工测试之前使用某些工具自动分析软件,那将是非常好的。此外,关于软件何时、如何以及应该测试什么也是很重要的。

在本章中,我们将涵盖以下主题:

  • 了解问题的根本原因

  • 调试 C++程序

  • 了解静态和动态分析

  • 探索单元测试、TDD 和 BDD

在本章中,我们将学习如何分析软件缺陷,如何使用 GNU 调试器(GDB)工具来调试程序,以及如何使用工具自动分析软件。我们还将学习单元测试、测试驱动开发(TDD)和行为驱动开发(BDD)的概念,以及如何在软件工程开发过程中进行实践。

技术要求

本章的代码可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Expert-CPP

了解问题的根本原因

在医学中,一个好的医生需要理解治疗症状和治愈疾病之间的区别。例如,给一个断臂的病人止痛药只会消除症状;手术可能是帮助骨骼逐渐愈合的正确方式。

根本原因分析(RCA)是一种系统性的过程,用于确定问题的根本原因。借助适当的工具,它试图使用一组特定的步骤来确定问题的根本原因的起源。通过这样做,我们可以确定以下内容:

  • 发生了什么?

  • 它是如何发生的?

  • 为什么会发生?

  • 应该采用什么适当的方法来防止或减少它,使其永远不再发生?

RCA 假设一个地方的行动会触发另一个地方的行动,依此类推。通过追溯行动链到开始,我们可以发现问题的根源以及它如何演变成我们所拥有的症状。啊哈!这正是我们应该遵循的修复或减少软件缺陷的过程。在接下来的小节中,我们将学习基本的 RCA 步骤,如何应用 RCA 过程来检测软件缺陷,以及 C++开发人员应该遵循哪些规则来防止软件中出现这样的缺陷。

RCA 概述

通常,RCA 过程包括以下五个步骤:

  1. 定义问题:在这个阶段,我们可能会找到以下问题的答案:发生了什么?问题的症状是什么?问题发生在什么环境或条件下?

  2. 收集数据:为了制作因果因素图,我们需要收集足够的数据。这一步可能既昂贵又耗时。

  3. 制作因果因素图:因果因素图提供了一个可视化结构,我们可以用它来组织和分析收集到的数据。因果因素图只是一个带有逻辑测试的序列图,解释了导致症状发生的事件。这个图表过程应该驱动数据收集过程,直到调查人员对图表的彻底性感到满意。

  4. 确定根本原因:通过检查因果因素图,我们可以制作一个决策图,称为根本原因图,以确定根本原因或原因。

  5. 推荐和实施解决方案:一旦确定了根本原因或多个原因,以下问题的答案可以帮助我们找到解决方案:我们可以采取什么措施防止问题再次发生?解决方案将如何实施?谁将负责?实施解决方案的成本或风险是什么?

RCA 树图是软件工程行业中最流行的因素图之一。以下是一个示例结构:

假设我们有一个问题,它有ABC三种症状。症状A可能是由事件A1A2引起的,症状B可能是由事件B1B2B3B4引起的,而症状C是由事件C1C2引起的。在收集数据后,我们发现症状AC从未出现,而我们只有症状B。进一步的分析显示,在问题发生时,事件B1B2并未涉及,因此我们可以确定这个问题的根本原因是由于事件B3B4的发生。

如果软件存在缺陷,我们应该对其应用 RCA,并调查问题的原始根本原因。然后,问题的根本原因可以追溯到需求、设计、实施、验证和/或测试规划和输入数据。当找到并修复了根本原因时,软件的质量可以得到改善,因此维护费用将大大降低。

我们刚刚学会了如何找到问题的根本原因,但请记住,“最好的防御是进攻”。因此,我们可以预防问题的发生,而不是分析和修复问题。

预防胜于治疗——良好的编码行为

从成本的角度来看,IBM 的一项研究表明,假设需求和设计的总成本为 1X,那么实施和编码过程将需要 5X,单元和集成测试将需要约 10X,全面的客户测试成本将需要约 15X,而在产品发布后修复错误的成本将占约 30X!因此,最小化代码缺陷是降低生产成本的最有效方法之一。

尽管找到软件缺陷的根本原因的通用方法非常重要,但如果我们能在实施阶段预防一些缺陷,那将更好。为此,我们需要有良好的编码行为,这意味着必须遵循某些规则。这些规则可以分为低级和高级。低级规则可能包括以下内容:

  • 未初始化变量

  • 整数除法

  • 错误地使用=而不是==

  • 可能将有符号变量分配给无符号变量

  • switch语句中缺少break

  • 复合表达式或函数调用中的副作用

至于高级规则,我们有以下相关主题:

  • 接口

  • 资源管理

  • 内存管理

  • 并发

B. Stroustrup 和 H. Sutter 在他们的实时文档C++ Core Guidelines (Release 0.8)中建议遵循这些规则,其中强调了静态类型安全和资源安全。他们还强调了范围检查的可能性,以避免解引用空指针、悬空指针和异常的系统使用。如果开发人员遵循这些规则,它将使他/她的代码在静态类型上是安全的,没有任何资源泄漏。此外,它不仅可以捕获更多的编程逻辑错误,而且还可以运行得更快。

由于页面限制,本小节只会介绍一些示例。如果您想查看更多示例,请访问isocpp.github.io/CppCoreGuidelines

未初始化变量问题

未初始化的变量是程序员可能犯的最常见的错误之一。当我们声明一个变量时,将为其分配一定数量的连续内存。如果未初始化,它仍然具有一些值,但没有确定性地预测它的方法。因此,当我们执行程序时,会出现不可预测的行为:

//ch13_rca_uninit_variable.cpp
#include <iostream>
int main()
{
  int32_t x;
  // ... //do something else but not assign value to x
  if (x>0) {
    std::cout << "do A, x=" << x << std::endl;
  }
  else {
    std::cout << "do B, x=" << x << std::endl;
  }
  return 0;
}

在上面的代码中,当声明x时,操作系统将为其分配 4 个字节的未使用内存,这意味着x的值是驻留在该内存中的任何值。每次运行此程序时,x的地址和值可能都不同。此外,一些编译器(如 Visual Studio)将在调试版本中将x的值初始化为0,但在发布版本中将其保持未初始化。在这种情况下,调试版本和发布版本的输出完全不同。

复合表达式中的副作用

当运算符、表达式、语句或函数完成评估后,它可能会被延长或者可能会持续存在于其复合体内。这种持续存在会产生一些副作用,可能导致一些未定义的行为。让我们看一下以下代码来理解这一点:

//ch13_rca_compound.cpp
#include <iostream>
int f(int x, int y)
{
  return x*y;
}

int main()
{
  int x = 3;
  std::cout << f(++x, x) << std::endl; //bad,f(4,4) or f(4,3)?
}

由于操作数的评估顺序的未定义行为,上述代码的结果可能是 16 或 12。

混合有符号和无符号问题

通常,二进制运算符(+-*/%<<=>>===!=&&||!&|<<>>~^=+=-=*=/=,和%=)要求两个操作数具有相同的类型。如果两个操作数的类型不同,则一个将被提升为与另一个相同的类型。粗略地说,C 标准转换规则在子条款 6.3.1.1 [ISO/IEC 9899:2011]中给出。

  • 当我们混合相同等级的类型时,有符号的类型将被提升为无符号类型。

  • 当我们混合不同等级的类型时,如果较低等级的一方的所有值都可以由较高等级的一方表示,那么较低等级的一方将被提升为较高等级的类型。

  • 如果在上述情况下较低等级类型的所有值都不能由较高等级类型表示,则将使用较高等级类型的无符号版本。

现在,让我们来看一下传统的有符号整数减去无符号整数的问题:

//ch13_rca_mix_sign_unsigned.cpp
#include <iostream>
using namespace std;
int main()
{
 int32_t x = 10;
 uint32_t y = 20;
 uint32_t z = x - y; //z=(uint32_t)x - y
 cout << z << endl; //z=4294967286\. 
}

在上面的例子中,有符号的int将自动转换为无符号的int,结果将是uint32_t z = -10。另一方面,因为−10不能表示为无符号的int值,它的十六进制值0xFFFFFFF6将被解释为UINT_MAX - 9(即4294967286)在补码机器上。

评估顺序问题

以下示例涉及构造函数中类成员的初始化顺序。由于初始化顺序是类成员在类定义中出现的顺序,因此将每个成员的声明分开到不同的行是一个好的做法:

//ch13_rca_order_of_evaluation.cpp
#include <iostream>
using namespace std;

class A {
public:
  A(int x) : v2(v1), v1(x) {
  };
  void print() {
    cout << "v1=" << v1 << ",v2=" << v2 << endl;
  };
protected:
  //bad: the order of the class member is confusing, better
  //separate it into two lines for non-ambiguity order declare   
  int v1, v2; 
};

class B {
public:
  //good: since the initialization order is: v1 -> v2, 
  //after this we have: v1==x, v2==x.
  B(int x) : v1(x), v2(v1) {};

  //wrong: since the initialization order is: v1 -> v2, 
  //after this we have: v1==uninitialized, v2==x. 
  B(float x) : v2(x), v1(v2) {};
  void print() {
    cout << "v1=" << v1 << ", v2=" << v2 << endl;
  };

protected:
  int v1; //good, here the declaration order is clear
  int v2;
};

int main()
{
  A a(10);
  B b1(10), b2(3.0f);
  a.print();  //v1=10,v2=10,v3=10 for both debug and release
  b1.print(); //v1=10, v2=10 for both debug and release
  b2.print(); //v1=-858993460,v2=3 for debug; v1=0,v2=3 for release.
}

在类A中,尽管声明顺序是v1 -> v2,但将它们放在一行中会使其他开发人员感到困惑。在类B的第一个构造函数中,v1将被初始化为x,然后v2将被初始化为v1,因为其声明顺序是v1->v2。然而,在其第二个构造函数中,v1将首先被初始化为v2(此时,v2尚未初始化!),然后v2将被x初始化。这导致调试版本和发布版本中v1的不同输出值。

编译时检查与运行时检查

以下示例显示,运行时检查(整数类型变量云的位数)可以被编译时检查替换:

//check # of bits for int
//courtesy: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines
int nBits = 0; // don't: avoidable code
for (int i = 1; i; i <<= 1){
     ++nBits;
}
if (nBits < 32){
    cerr << "int too small\n";
}

由于int可以是 16 位或 32 位,这个例子取决于操作系统,无法实现其目标。我们应该使用int32_t或者用以下内容替换它:

 static_assert(sizeof(int) >= 4); //compile-time check

另一个例子涉及将 n 个整数的最大数量读入一维数组中:

void read_into(int* p, int n); // a function to read max n integers into *p
...
int v[10];
read_into(v, 100); //bad, off the end, but the compile cannot catch this error.

这可以使用span<int>来修复:

void read_into( span<int> buf); // read into a range of integers
...
int v[10];
read_into(v); //better, the compiler will figure out the number of elements

这里的一般规则是尽可能在编译时进行分析,而不要推迟到运行时。

避免内存泄漏

内存泄漏意味着分配的动态内存永远无法释放。在 C 中,我们使用malloc()和/或calloc()来分配内存,使用free()来释放它。在 C++中,使用new运算符和deletedelete[]运算符来动态管理内存。尽管智能指针和 RAII 的帮助可以减少内存泄漏的风险,但如果我们希望构建高质量的代码,仍然有一些规则需要遵循。

首先,最简单的内存管理方式是你的代码从未分配的内存。例如,每当你可以写T x;时,不要写T* x = new T();shared_ptr<T> x(new T());

接下来,不要使用自己的代码管理内存,如下所示:

void f_bad(){
 T* p = new T() ;
  ...                 //do something with p
 delete p ;           //leak if throw or return before reaching this line 
}

相反,尝试使用 RAII,如下所示:

void f_better()
{
 std::auto_ptr<T> p(new T()) ; //other smart pointers is ok also
 ...                           //do something with p
 //will not leak regardless whether this point is reached or not
}

然后,使用unique_ptr替换shared_ptr,除非你需要共享它的所有权,如下所示:

void f_bad()
{
 shared_ptr<Base> b = make_shared<Derived>();
 ...            
} //b will be destroyed at here

由于b在本地使用而没有复制它,它的refcount将始终为1。这意味着我们可以使用unique_ptr来替换它:

void f_better()
{
 unique_ptr<Base> b = make_unique<Derived>();
 ...            //use b locally
}               //b will be destroyed at here

最后,即使你真的需要自己动态管理内存,如果有std container库类可用,不要手动分配内存。

在本节中,我们学习了如何使用 RCA 定位问题以及如何通过编码最佳实践来预防问题。接下来,我们将学习如何使用调试器工具来控制程序的逐行执行,并在运行时检查变量和表达式的值。

调试 C++程序

调试是找到并解决程序问题或缺陷的过程。这可能包括交互式调试、数据/控制流分析以及单元和集成测试。在本节中,我们只关注交互式调试,这是逐行执行源代码并显示正在使用的变量的值及其相应内存地址的过程。

调试 C/C++程序的工具

根据你的开发环境,在 C++社区中有很多可用的工具。以下列表显示了不同平台上最受欢迎的工具。

  • Linux/Unix:

  • GDB:一个免费的开源命令行界面(CLI)调试器。

  • Eclipse:一个免费的开源集成开发环境(IDE)。它不仅支持调试,还支持编译、性能分析和智能编辑。

  • Valgrind:另一个开源的动态分析工具;它适用于调试内存泄漏和线程错误。

  • Affinic:一个商业的图形用户界面(GUI)工具,专为 GDB、LLDB 和 LLVM 调试器构建。

  • DDD:一个用于 GDB、DBX、JDB、XDB 和 Python 的开源数据显示调试器,它将数据结构显示为图形。

  • Emacs 模式下的 GDB:一个使用 GNU Emacs 查看和编辑源代码的开源 GUI 工具,用于与 GDB 一起调试。

  • KDevelop:一个用于 C/C++、Objective-等编程语言的免费开源 IDE 和调试器工具。

  • Nemiver:一个在 GNOME 桌面环境中运行良好的开源工具。

  • SlickEdit:一个用于调试多线程和多处理器代码的好工具。

  • Windows:

  • Visual Studio:一个商业工具,社区版本免费提供 GUI。

  • GDB:这也可以在 Windows 上运行,借助 Cygwin 或 MinGW 的帮助。

  • Eclipse:它的 C++开发工具(CDT)可以在 Windows 上使用 MinGW GCC 编译器的工具链进行安装。

  • macOS:

  • LLDB:这是 macOS 上 Xcode 的默认调试器,支持桌面和 iOS 设备及其模拟器上的 C/C++和 Objective-C。

  • GDB:这个 CLI 调试器也被用于 macOS 和 iOS 系统。

  • Eclipse:这个使用 GCC 的免费 IDE 适用于 macOS。

由于 GDB 可以在所有平台上运行,我们将在以下子节中向您展示如何使用 GDB。

GDB 概述

GDB 代表 GNU 调试器,允许开发人员在另一个程序执行时看到内部发生了什么,或者在另一个程序崩溃时它正在做什么。GDB 可以做以下四件事情:

  • 启动程序并指定可能影响其行为的任何内容。

  • 使程序在给定条件下停止。

  • 检查程序停止时发生了什么。

  • 在运行程序时更改变量的值。这意味着我们可以尝试纠正一个 bug 的影响和/或继续学习另一个 bug 的副作用。

请注意,涉及两个程序或可执行文件:一个是 GDB,另一个是要调试的程序。由于这两个程序可以在同一台机器上或不同的机器上运行,因此我们可能有三种调试类别,如下所示:

  • 本地调试:两个程序在同一台机器上运行。

  • 远程调试:GDB 在主机上运行,而调试的程序在远程机器上运行。

  • 模拟器调试:GDB 在主机上运行,而调试的程序在模拟器上运行。

根据撰写本书时的最新版本(GDB v8.3),GDB 支持的语言包括 C、C++、Objective-C、Ada、Assembly、D、Fortran、Go、OpenCL、Modula-2、Pascal 和 Rust。

由于 GDB 是调试行业中的一种先进工具,功能复杂且功能丰富,因此在本节中不可能学习所有其功能。相反,我们将通过示例来学习最有用的功能。

GDB 示例

在练习这些示例之前,我们需要通过运行以下代码来检查系统上是否已安装gdb

~wus1/chapter-13$ gdb --help 

如果显示以下类型的信息,我们将准备好开始:

This is the GNU debugger. Usage:
 gdb [options] [executable-file [core-file or process-id]]
 gdb [options] --args executable-file [inferior-arguments ...]

 Selection of debuggee and its files:
 --args Arguments after executable-file are passed to inferior
 --core=COREFILE Analyze the core dump COREFILE.
 --exec=EXECFILE Use EXECFILE as the executable.
 ...

否则,我们需要安装它。让我们看看如何在不同的操作系统上安装它:

  • 对于基于 Debian 的 Linux:
~wus1/chapter-13$ s*udo apt-get install build-essential* 
  • 对于基于 Redhat 的 Linux:
~wus1/chapter-13$***sudo yum install  build-essential***
  • 对于 macOS:
~wus1/chapter-13$***brew install gdb***

Windows 用户可以通过 MinGW 发行版安装 GDB。macOS 将需要 taskgated 配置。

然后,再次输入gdb --help来检查是否成功安装。

设置断点和检查变量值

在以下示例中,我们将学习如何设置断点,继续,步入或跳过函数,打印变量的值,以及如何在gdb中使用帮助。源代码如下:

//ch13_gdb_1.cpp
#include <iostream>
float multiple(float x, float y);
int main()
{
 float x = 10, y = 20;
 float z = multiple(x, y);
 printf("x=%f, y=%f, x*y = %f\n", x, y, z);
 return 0;
}

float multiple(float x, float y)
{
 float ret = x + y; //bug, should be: ret = x * y;
 return ret;
}

正如我们在第三章中提到的面向对象编程的细节,让我们以调试模式构建此程序,如下所示:

~wus1/chapter-13$ g++ -g ch13_gdb_1.cpp -o ch13_gdb_1.out

请注意,对于 g++,-g选项意味着调试信息将包含在输出的二进制文件中。如果我们运行此程序,它将显示以下输出:

x=10.000000, y=20.000000, x*y = 30.000000

现在,让我们使用gdb来查看 bug 在哪里。为此,我们需要执行以下命令行:

~wus1/chapter-13$ gdb ch13_gdb_1.out

通过这样做,我们将看到以下输出:

GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
 Copyright (C) 2018 Free Software Foundation, Inc.
 License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
 This is free software: you are free to change and redistribute it.
 There is NO WARRANTY, to the extent permitted by law. Type "show copying"
 and "show warranty" for details.
 This GDB was configured as "aarch64-linux-gnu".
 Type "show configuration" for configuration details.
 For bug reporting instructions, please see:
 <http://www.gnu.org/software/gdb/bugs/>.
 Find the GDB manual and other documentation resources online at:
 <http://www.gnu.org/software/gdb/documentation/>.
 For help, type "help".
 Type "apropos word" to search for commands related to "word"...
 Reading symbols from a.out...done.
 (gdb) 

现在,让我们详细了解各种命令:

  • breakrun:如果我们输入b mainbreak main并按Enter,则会在主函数中插入一个breakpoint。然后,我们可以输入runr来开始调试程序。在终端窗口中将显示以下信息。在这里,我们可以看到我们的第一个breakpoint在源代码的第六行,调试程序已经暂停以等待新命令:
(gdb) b main
Breakpoint 1 at 0x8ac: file ch13_gdb_1.cpp, line 6.
(gdb) r
Starting program: /home/nvidia/wus1/Chapter-13/a.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1". 

Breakpoint 1, main () at ch13_gdb_1.cpp:6
6 float x = 10, y = 20;
  • nextprintquitnnext命令将转到代码的下一行。如果该行调用子例程,则不会进入子例程;相反,它会跳过调用并将其视为单个源行。如果我们想显示变量的值,我们可以使用pprint命令,后跟变量的名称。最后,如果我们想退出gdb,可以使用qquit命令。运行这些操作后,以下是终端窗口的输出:
(gdb) n
 7 float z = multiple(x, y);
 (gdb) p z
 $1 = 0
 (gdb) n
 8 printf("x=%f, y=%f, x*y = %f\n", x, y, z);
 (gdb) p z
 $2 = 30
 (gdb) q
 A debugging session is active.
 Inferior 1 [process 29187] will be killed.
 Quit anyway? (y or n) y
 ~/wus1/Chapter-13$
  • step:现在让我们学习如何进入multiple()函数并找到错误。为此,我们需要使用brn命令首先到达第 7 行。然后,我们可以使用sstep命令进入multiple()函数。接下来,我们使用n命令到达第 14 行,使用p打印ret变量的值,即 30。到目前为止,我们已经发现,通过使用ahha the bug is at line 14!:,而不是x*y,我们有一个拼写错误,即x+y。以下代码块是这些命令的相应输出:
~/wus1/Chapter-13$gdb ch13_gdb_1.out
 ...
 (gdb) b main
 Breakpoint 1 at 0x8ac: file ch13_gdb_1.cpp, line 6.
 (gdb) r
 The program being debugged has been started already.
 Start it from the beginning? (y or n) y
 Starting program: /home/nvidia/wus1/Chapter-13/a.out
 [Thread debugging using libthread_db enabled]
 Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".                                                                                Breakpoint 1, main () at ch13_gdb_1.cpp:6
 6 float x = 10, y = 20;
 (gdb) n
 7 float z = multiple(x, y);
 (gdb) s
 multiple (x=10, y=20) at ch13_gdb_1.cpp:14
 14 float s = x + y;
 (gdb) n
 15 return s;
 (gdb) p s
 $1 = 30
  • help:最后,让我们学习如何使用help命令来结束这个小例子。当启动gdb时,我们可以使用helph命令来获取特定命令的使用信息。例如,以下终端窗口总结了我们到目前为止学到的内容:
(gdb) h b      
 Set breakpoint at specified location.
 break [PROBE_MODIFIER] [LOCATION] [thread THREADNUM] [if CONDITION]
 PROBE_MODIFIER shall be present if the command is to be placed in a
 probe point. Accepted values are `-probe' (for a generic, automatically
 guessed probe type), `-probe-stap' (for a SystemTap probe) or
 `-probe-dtrace' (for a DTrace probe).
 LOCATION may be a linespec, address, or explicit location as described
 below.
  ....

 (gdb) h r
 Start debugged program.
 You may specify arguments to give it.
 Args may include "*", or "[...]"; they are expanded using the
 shell that will start the program (specified by the "$SHELL" environment
 variable). Input and output redirection with ">", "<", or ">>"
 are also allowed.

 (gdb) h s
 Step program until it reaches a different source line.
 Usage: step [N]
 Argument N means step N times (or till program stops for another reason).

 (gdb) h n
 Step program, proceeding through subroutine calls.
 Usage: next [N]
 Unlike "step", if the current source line calls a subroutine,
 this command does not enter the subroutine, but instead steps over
 the call, in effect treating it as a single source line.

 (gdb) h p
 Print value of expression EXP.
 Variables accessible are those of the lexical environment of the selected
 stack frame, plus all those whose scope is global or an entire file.

 (gdb) h h
 Print list of commands.
 (gdb) h help
 Print list of commands.
 (gdb) help h
 Print list of commands.
 (gdb) help help
 Print list of commands.

到目前为止,我们已经学习了一些基本命令,可以用来调试程序。这些命令是breakrunnextprintquitstephelp。我们将在下一小节学习函数和条件断点、观察点,以及continuefinish命令。

函数断点、条件断点、观察点,以及继续和完成命令

在这个例子中,我们将学习如何设置函数断点、条件断点,并使用continue命令。然后,我们将学习如何在不需要逐步执行所有代码行的情况下完成函数调用。源代码如下:

//ch13_gdb_2.cpp
#include <iostream>

float dotproduct( const float *x, const float *y, const int n);
int main()
{
 float sxx,sxy;
 float x[] = {1,2,3,4,5};
 float y[] = {0,1,1,1,1};

 sxx = dotproduct( x, x, 5);
 sxy = dotproduct( x, y, 5);
 printf( "dot(x,x) = %f\n", sxx );
 printf( "dot(x,y) = %f\n", sxy );
 return 0;
}

float dotproduct( const float *x, const float *y, const int n )
{
 const float *p = x;
 const float *q = x;  //bug: replace x by y
 float s = 0;
 for(int i=0; i<n; ++i, ++p, ++q){
        s += (*p) * (*q);
 }
 return s;
}

再次,构建并运行ch13_gdb_2.cpp后,我们得到以下输出:

~/wus1/Chapter-13$ g++ -g ch13_gdb_2.cpp -o ch13_gdb_2.out
~/wus1/Chapter-13$ ./ch13_gdb_2.out
dot(x,x) = 55.000000
dot(x,y) = 55.000000

由于dot(x,x)dot(x,y)都给我们相同的结果,这里一定有问题。现在,让我们通过学习如何在dot()函数中设置断点来调试它:

  • 函数断点:要在函数的开头设置断点,我们可以使用b function_name命令。和往常一样,在输入时可以使用制表符补全。例如,假设我们输入以下内容:
(gdb) b dot<Press TAB Key>

如果我们这样做,以下命令行将自动弹出:

(gdb) b dotproduct(float const*, float const*, int)

如果它是一个类的成员函数,它的类名应该包括在内,如下所示:

(gdb) b MyClass::foo(<Press TAB key>
  • 条件断点:有几种设置条件断点的方法:
(gdb) b f.cpp:26 if s==0 //set a breakpoint in f.cpp, line 26 if s==0
(gdb) b f.cpp:20 if ((int)strcmp(y, "hello")) == 0 
  • 列出和删除断点:一旦我们设置了几个断点,我们可以列出或删除它们,如下所示:
(gdb) i b (gdb) delete breakpoints 1 (gdb) delete breakpoints 2-5
  • 删除使断点无条件:由于每个断点都有一个编号,我们可以删除断点的条件,如下所示:
(gdb) cond 1         //break point 1 is unconditional now
  • 观察点:观察点可以在表达式的值发生变化时停止执行,而不必预测它可能发生的位置(在哪一行)。有三种观察点:

  • watch:当写入发生时,gdb将中断。

  • rwatch:当读取发生时,gdb将中断。

  • awatch:当发生写入或读取时,gdb将中断。

以下代码显示了一个例子:

(gdb) watch v                 //watch the value of variable v
(gdb) watch *(int*)0x12345678 //watch an int value pointed by an address
(gdb) watch a*b + c/d         // watch an arbitrarily complex expression
  • 继续:当我们在断点处检查变量的值后,我们可以使用continuec命令来继续程序执行,直到调试器遇到断点、信号、错误或正常进程终止。

  • 完成:一旦我们进入一个函数,我们可能希望连续执行它,直到返回到其调用行。这可以使用finish命令来完成。

现在,让我们将这些命令组合在一起来调试ch13_gdb_2.cpp。以下是我们终端窗口的输出。为了方便起见,我们将其分为三部分:

//gdb output of example ch13_gdb_2.out -- part 1
~/wus1/Chapter-13$ gdb ch13_gdb_2.out                     //cmd 1
 ...
 Reading symbols from ch13_gdb_2.out ... done.

 (gdb) b dotproduct(float const*, float const*, int)      //cmd 2
 Breakpoint 1 at 0xa5c: file ch13_gdb_2.cpp, line 20.
 (gdb) b ch13_gdb_2.cpp:24 if i==1                        //cmd 3
 Breakpoint 2 at 0xa84: file ch13_gdb_2.cpp, line 24.
 (gdb) i b                                                //cmd 4
 Num Type Disp Enb Address What
 1 breakpoint keep y 0x0000000000000a5c in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:20
 2 breakpoint keep y 0x0000000000000a84 in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:24
 stop only if i==1
 (gdb) cond 2                                            //cmd 5
 Breakpoint 2 now unconditional.
 (gdb) i b                                               //cmd 6
 Num Type Disp Enb Address What
 1 breakpoint keep y 0x0000000000000a5c in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:20
 2 breakpoint keep y 0x0000000000000a84 in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:24 

第一部分,我们有以下六个命令:

  • cmd 1:我们使用构建的可执行文件ch13_gdb_2.out启动gdb。这简要显示了它的版本和文档和使用信息,然后告诉我们读取符号的过程已经完成,并且正在等待下一个命令。

  • cmd 2:我们设置了一个断点函数(在dotproduct()处)。

  • cmd 3:设置了一个条件断点

  • cmd 4: 它列出了关于断点的信息,并告诉我们有两个断点。

  • cmd 5: 我们将breakpoint 2设置为无条件的

  • cmd 6: 再次列出断点信息。此时,我们可以看到两个断点。它们分别位于ch13_gdb_2.cp文件的第 20 行和第 24 行。

接下来,让我们看看第二部分的gdb输出:

//gdb output of example ch13_gdb_2.out -- part 2 
(gdb) r                                                //cmd 7
 Starting program: /home/nvidia/wus1/Chapter-13/ch13_gdb_2.out
 [Thread debugging using libthread_db enabled]
 Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".

 Breakpoint 1, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:20
 20 const float *p = x;
 (gdb) p x                                            //cmd 8
 $1 = (const float *) 0x7fffffed68
 (gdb) c                                              //cmd 9 
 Continuing.

 Breakpoint 2, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 24 s += (*p) * (*q);
 (gdb) p i                                           //cmd 10
 $2 = 0
 (gdb) n                                             //cmd 11
 23 for(int i=0; i<n; ++i, ++p, ++q){
 (gdb) n                                             //cmd 12

 Breakpoint 2, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 24 s += (*p) * (*q);
 (gdb) p s                                           //cmd 13 
 $4 = 1
 (gdb) watch s                                       //cmd 14 
 Hardware watchpoint 3: s

第二部分有以下命令:

  • cmd 7: 通过给出run命令,程序开始运行,并在第 20 行的第一个断点处停止。

  • cmd 8: 我们打印x的值,显示其地址。

  • cmd 9: 我们继续程序。一旦继续,它会在第 24 行的第二个断点处停止。

  • cmd 10: 打印i的值,为0

  • cmd 11-12: 我们两次使用next命令。在这一点上,执行s += (*p) * (*q)语句。

  • cmd 13: 打印s的值,为1

  • cmd 14: 我们打印s的值。

最后,第三部分如下:

//gdb output of example ch13_gdb_2.out -- part 3 
(gdb) n                                             //cmd 15 
  Hardware watchpoint 3: s

 Old value = 1
 New value = 5
 dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:23
 23 for(int i=0; i<n; ++i, ++p, ++q){
 (gdb) finish                                       //cmd 16
 Run till exit from #0 dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:23

 Breakpoint 2, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 24 s += (*p) * (*q);
 (gdb) delete breakpoints 1-3                       //cmd 17
 (gdb) c                                            //cmd 18
 Continuing.

 dot(x,x) = 55.000000
 dot(x,y) = 55.000000
 [Inferior 1 (process 31901) exited normally]
 [Inferior 1 (process 31901) exited normally]
 (gdb) q                                           //cmd 19
 ~/wus1/Chapter-13$

在这一部分,我们有以下命令:

  • cmd 15: 我们使用next命令来查看如果执行下一次迭代时s的值是多少。它显示旧值为s1(s = 11),新值为5(s=11+2*2)。到目前为止,一切顺利!

  • cmd 16: 使用finish命令继续运行程序,直到退出函数。

  • cmd 17: 删除断点 1 到 3。

  • cmd 18: 使用continue命令。

  • cmd 19: 我们退出gdb,回到终端窗口。

将 gdb 记录到文本文件中

处理长堆栈跟踪或多线程堆栈跟踪时,从终端窗口查看和分析gdb输出可能会不方便。然而,我们可以先将整个会话或特定输出记录到文本文件中,然后稍后离线使用其他文本编辑工具进行浏览。为此,我们需要使用以下命令:

(gdb) set logging on

当我们执行此命令时,gdb将把所有终端窗口输出保存到名为gdb.txt的文本文件中,该文件位于当前运行的gdb文件夹中。如果我们想停止记录,只需输入以下内容:

(gdb) set logging off

关于 GDB 的一大好处是,我们可以随意多次打开和关闭日志记录命令,而不必担心转储文件名。这是因为所有输出都被连接到gdb.txt文件中。

以下是返回ch13_gdb_2.out并将gdb输出转储的示例:

~/wus1/Chapter-13$ gdb ch13_gdb_2.out           //cmd 1
 ...
Reading symbols from ch13_gdb_2.out...done.
 (gdb) set logging on                           //cmd 2
 Copying output to gdb.txt.
 (gdb) b ch13_gdb_2.cpp:24 if i==1              //cmd 3 
 Breakpoint 1 at 0xa84: file ch13_gdb_2.cpp, line 24.
 (gdb) r                                        //cmd 4 
 ...
 Breakpoint 1, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 24 s += (*p) * (*q);
 (gdb) p i                                      //cmd 5 
 $1 = 1
 (gdb) p s                                      //cmd 6 
 $2 = 1
 (gdb) finish                                   //cmd 7 
 Run till exit from #0 dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 0x00000055555559e0 in main () at ch13_gdb_2.cpp:11
 11 sxx = dotproduct( x, x, 5);
 Value returned is $3 = 55
 (gdb) delete breakpoints 1                    //cmd 8
 (gdb) set logging off                         //cmd 9
 Done logging to gdb.txt.
 (gdb) c                                       //cmd 10 
 Continuing.
 dot(x,x) = 55.000000
 dot(x,y) = 55.000000
 [Inferior 1 (process 386) exited normally]
 (gdb) q                                      //cmd 11
 ~/wus1/Chapter-13$ cat gdb.txt               //cmd 12

在前面的代码中使用的命令如下:

  • cmd 1: 启动gdb

  • cmd 2: 我们将日志标志设置为打开。此时,gdb表示输出将被复制到gdb.txt文件中。

  • cmd 3: 设置条件断点

  • cmd 4: 我们运行程序,当它到达第 24 行的条件断点时停止。

  • cmd 5cmd 6: 我们分别打印is的值。

  • cmd 7: 通过执行函数步出命令,显示sxx55(在调用sxx=dotproduct(x, x, 5))后),程序停在sxy *=* dotproduct(x, y, 5)行。

  • cmd 8: 我们删除breakpoint 1

  • cmd 9: 我们将日志标志设置为关闭。

  • cmd 10: 一旦给出继续指令,它就会从main函数中运行出来,gdb等待新命令。

  • cmd 11: 我们输入q退出gdb

  • cmd 12: 当返回到终端窗口时,通过在操作系统中运行cat命令打印已记录的gdb.txt文件的内容。

到目前为止,我们已经学会了足够的 GDB 命令来调试程序。正如你可能已经注意到的,这是耗时的,因此非常昂贵。有时,由于在错误的地方调试,情况变得更糟。为了高效地调试,我们需要遵循正确的策略。我们将在下一小节中介绍这一点。

实用调试策略

由于调试是软件开发生命周期中成本最高的阶段,发现错误并修复它们是不可行的,特别是对于大型复杂系统。然而,有一些策略可以在实际过程中使用,其中一些如下:

  • 使用 printf()或 std::cout:这是一种老式的做法。通过将一些信息打印到终端,我们可以检查变量的值,并执行进一步分析的位置和时间种类的日志配置文件。

  • 使用调试器:虽然学习使用 GDB 这类调试器工具不是一蹴而就的事情,但它可以节省大量时间。因此,逐步熟悉它,并逐渐掌握。

  • 重现错误:每当在现场报告错误时,记录运行环境和输入数据。

  • 转储日志文件:应用程序应将日志消息转储到文本文件中。发生崩溃时,我们应首先检查日志文件,以查看是否发生异常事件。

  • 猜测:粗略猜测错误的位置,然后证明它是对还是错。

  • 分而治之:即使在最糟糕的情况下,我们对存在什么错误一无所知,我们仍然可以使用二分搜索策略设置断点,然后缩小范围,最终定位它们。

  • 简化:始终从最简化的情景开始,逐渐添加外围设备、输入模块等,直到可以重现错误。

  • 源代码版本控制:如果一个错误突然出现在一个发布版上,但之前运行正常,首先检查源代码树。可能有人做了改变!

  • 不要放弃:有些错误真的很难定位和/或修复,特别是对于复杂和多团队参与的系统。暂时搁置它们,回家的路上重新思考一下,也许会有灵光一现

到目前为止,我们已经学习了如何使用 RCA 进行宏观问题定位,以及我们可以遵循的良好编码实践,以防止问题发生。此外,通过使用诸如 GDB 之类的最先进的调试器工具,我们可以逐行控制程序的执行,以便我们可以在微观级别分析和解决问题。所有这些活动都是程序员集中和手动的。是否有任何自动工具可以帮助我们诊断程序的潜在缺陷?我们将在下一节中看一下静态和动态分析。

理解静态和动态分析

在前几节中,我们学习了根本原因分析过程以及如何使用 GDB 调试缺陷。本节将讨论如何分析程序,无论是否执行。前者称为动态分析,而后者称为静态分析。

静态分析

静态分析评估计算机程序的质量,而无需执行它。虽然通常可以通过自动工具和代码审查/检查来完成,但本节我们只关注自动工具。

自动静态代码分析工具旨在分析一组代码与一个或多个编码规则或指南。通常,人们可以互换使用静态代码分析、静态分析或源代码分析。通过扫描每个可能的代码执行路径的整个代码库,我们可以在测试阶段之前找到许多潜在的错误。然而,它也有一些限制,如下所示:

  • 它可能会产生错误的阳性和阴性警报。

  • 它只应用于扫描算法内部实施的规则,其中一些可能会被主观解释。

  • 它无法找到在运行时环境中引入的漏洞。

  • 它可能会产生一种虚假的安全感,认为一切都在得到解决。

在商业和免费开源类别下,有大约 30 个自动 C/C++代码分析工具[9]。这些工具的名称包括 Clang、Clion、CppCheck、Eclipse、Visual Studio 和 GNU g++等。作为示例,我们想介绍内置于 GNU 编译器 g++[10]中的**-**Wall-Weffcc++-Wextra选项:

  • -Wall:启用所有构造警告,对于某些用户来说是有问题的。这些警告很容易避免或修改,即使与宏一起使用。它还启用了一些在 C++方言选项和 Objective-C/C++方言选项中描述的特定于语言的警告。

  • -Wextra:正如其名称所示,它检查一些-Wall未检查的额外警告标志。将打印以下任何情况的警告消息:

  • 将指针与整数零使用<<=>>=操作数进行比较。

  • 非枚举和枚举在条件表达式中出现。

  • 虚拟基类不明确。

  • register类型数组进行下标操作。

  • 使用register类型变量的地址。

  • 派生类的复制构造函数未初始化其基类。注意(b)-(f)仅适用于 C++。

  • -Weffc++:它检查了 Scott Meyers 所著的Effective and More Effective C++中建议的一些准则的违反。这些准则包括以下内容:

  • 为具有动态分配内存的类定义复制构造函数和赋值运算符。

  • 在构造函数中,优先使用初始化而不是赋值。

  • 在基类中使析构函数虚拟。

  • 使=运算符返回对*this的引用。

  • 当必须返回对象时,不要尝试返回引用。

  • 区分增量和减量运算符的前缀和后缀形式。

  • 永远不要重载&&||,

为了探索这三个选项,让我们看下面的例子:

//ch13_static_analysis.cpp
#include <iostream>
int *getPointer(void)
{
    return 0;
}

int &getVal() {
    int x = 5;
    return x;
}

int main()
{
    int *x = getPointer();
    if( x> 0 ){
        *x = 5;
   }
   else{
       std::cout << "x is null" << std::endl;
   }

   int &y = getVal();
   std::cout << y << std::endl;
   return 0;
}

首先,让我们不使用任何选项来构建它:

g++ -o ch13_static.out ch13_static_analysis.cpp 

这可以成功构建,但是如果我们运行它,预期会出现段错误核心已转储)消息。

接下来,让我们添加-Wall-Weffc++-Wextra选项并重新构建它:

g++ -Wall -o ch13_static.out ch13_static_analysis.cpp
g++ -Weffc++ -o ch13_static.out ch13_static_analysis.cpp
g++ -Wextra -o ch13_static.out ch13_static_analysis.cpp

-Wall-Weffc++都给出了以下消息:

ch13_static_analysis.cpp: In function ‘int& getVal()’:
ch13_static_analysis.cpp:9:6: warning: reference to local variable ‘x’ returned [-Wreturn-local-addr]
int x = 5;
 ^

在这里,它抱怨在int & getVal()函数(cpp文件的第 9 行)中返回了对局部变量的引用。这不起作用,因为一旦程序退出函数,x就是垃圾(x的生命周期仅限于函数的范围内)。引用一个已经失效的变量是没有意义的。

-Wextra给出了以下消息:

 ch13_static_analysis.cpp: In function ‘int& getVal()’:
 ch13_static_analysis.cpp:9:6: warning: reference to local variable ‘x’ returned [-Wreturn-local-addr]
 int x = 5;
 ^
 ch13_static_analysis.cpp: In function ‘int main()’:
 ch13_static_analysis.cpp:16:10: warning: ordered comparison of pointer with integer zero [-Wextra]
 if( x> 0 ){
 ^

前面的输出显示,*-*Wextra不仅给出了-Wall的警告,还检查了我们之前提到的六件事。在这个例子中,它警告我们代码的第 16 行存在指针和整数零的比较。

现在我们知道了如何在编译时使用静态分析选项,我们将通过执行程序来了解动态分析。

动态分析

动态分析动态程序分析的简称,它通过在真实或虚拟处理器上执行软件程序来分析软件程序的性能。与静态分析类似,动态分析也可以自动或手动完成。例如,单元测试、集成测试、系统测试和验收测试通常是人为参与的动态分析过程。另一方面,内存调试、内存泄漏检测和 IBM purify、Valgrind 和 Clang sanitizers 等性能分析工具是自动动态分析工具。在本小节中,我们将重点关注自动动态分析工具。

动态分析过程包括准备输入数据、启动测试程序、收集必要的参数和分析其输出等步骤。粗略地说,动态分析工具的机制是它们使用代码插装和/或模拟环境来对分析的代码进行检查。我们可以通过以下方式与程序交互:

  • 源代码插装:在编译之前,将特殊的代码段插入原始源代码中。

  • 目标代码插装:将特殊的二进制代码直接添加到可执行文件中。

  • 编译阶段插装:通过特殊的编译器开关添加检查代码。

  • 它不会改变源代码。相反,它使用特殊的执行阶段库来检测错误。

动态分析有以下优点:

  • 没有错误预测的模型,因此不会出现假阳性或假阴性结果。

  • 它不需要源代码,这意味着专有代码可以由第三方组织进行测试。

动态分析的缺点如下:

  • 它只能检测与输入数据相关的路径上的缺陷。其他缺陷可能无法被发现。

  • 它一次只能检查一个执行路径。为了获得完整的图片,我们需要尽可能多地运行测试。这需要大量的计算资源。

  • 它无法检查代码的正确性。可能会从错误的操作中得到正确的结果。

  • 在真实处理器上执行不正确的代码可能会产生意想不到的结果。

现在,让我们使用 Valgrind 来找出以下示例中给出的内存泄漏和越界问题:

//ch13_dynamic_analysis.cpp
#include <iostream>
int main()
{
    int n=10;
    float *p = (float *)malloc(n * sizeof(float));
    for( int i=0; i<n; ++i){
        std::cout << p[i] << std::endl;
    }
    //free(p);  //leak: free() is not called
    return 0;
}

要使用 Valgrind 进行动态分析,需要执行以下步骤:

  1. 首先,我们需要安装valgrind。我们可以使用以下命令来完成:
sudo apt install valgrind //for Ubuntu, Debian, etc.
  1. 安装成功后,我们可以通过传递可执行文件作为参数以及其他参数来运行valgrind,如下所示:
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
 --verbose --log-file=valgrind-out.txt ./myExeFile myArgumentList
  1. 接下来,让我们构建这个程序,如下所示:
g++ -o ch13_dyn -std=c++11 -Wall ch13_dynamic_analysis.cpp
  1. 然后,我们运行valgrind,如下所示:
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
 --verbose --log-file=log.txt ./ch13_dyn

最后,我们可以检查log.txt的内容。粗体和斜体行表示内存泄漏的位置和大小。通过检查地址(0x4844BFC)及其对应的函数名(main()),我们可以看到这个mallocmain()函数中:

... //ignore many lines at begining
 by 0x108A47: main (in /home/nvidia/wus1/Chapter-13/ch13_dyn)
 ==18930== Uninitialised value was created by a heap allocation
 ==18930== at 0x4844BFC: malloc (in /usr/lib/valgrind/vgpreload_memcheck-arm64-linux.so)
 ... //ignore many lines in middle
 ==18930== HEAP SUMMARY:
 ==18930== in use at exit: 40 bytes in 1 blocks
 ==18930== total heap usage: 3 allocs, 2 frees, 73,768 bytes allocated
 ==18930==
 ==18930== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
 ==18930== at 0x4844BFC: malloc (in /usr/lib/valgrind/vgpreload_memcheck-arm64-linux.so)
 ==18930==
 ==18930== LEAK SUMMARY:
 ==18930== definitely lost: 40 bytes in 1 blocks
 ==18930== indirectly lost: 0 bytes in 0 blocks
 ==18930== possibly lost: 0 bytes in 0 blocks
 ==18930== still reachable: 0 bytes in 0 blocks
 ==18930== suppressed: 0 bytes in 0 blocks

在这里,我们可以看到malloc()被调用来在地址0x4844BFC分配一些内存。堆摘要部分表明我们在0x4844BFC处有 40 字节的内存泄漏。最后,泄漏摘要部分显示肯定有一个 40 字节的内存泄漏。通过在log.txt文件中搜索0x4844BFC的地址值,我们最终发现原始代码中没有调用free(p)行。取消注释此行后,我们重新进行valgrind分析,以便泄漏问题现在已经不在报告中。

总之,借助静态和动态分析工具,程序的潜在缺陷可以自动大大减少。然而,为了确保软件的质量,人类必须参与最终的测试和评估。现在,我们将探讨软件工程中的单元测试、测试驱动开发和行为驱动开发概念。

探索单元测试、TDD 和 BDD

在上一节中,我们了解了自动静态和动态程序分析。本节将重点介绍人为参与(准备测试代码)的测试,这是动态分析的另一部分。这些是单元测试、测试驱动开发和行为驱动开发。

单元测试假设如果我们已经有了单个代码单元,那么我们需要编写一个测试驱动程序并准备输入数据来检查其输出是否正确。之后,我们进行集成测试来测试多个单元一起,然后进行验收测试,测试整个应用程序。由于集成和验收测试比单元测试更难维护且与项目更相关,因此在本书中很难覆盖它们。有兴趣的人可以通过访问www.iso.org/standard/45142.html了解更多信息。

与单元测试相比,TDD 认为我们应该先有测试代码和数据,快速开发一些代码并使其通过,最后进行重构,直到客户满意。另一方面,BDD 认为我们不应该测试程序的实现,而是测试其期望的行为。为此,BDD 强调应该建立一个软件生产相关人员之间的沟通平台和语言。

我们将在以下小节中详细讨论这些方法。

单元测试

单元是更大或更复杂应用程序中的一个单独组件。通常,一个单元有自己的用户界面,例如函数、类或整个模块。单元测试是一种软件测试方法,用于确定代码单元是否按照其设计要求的预期行为。单元测试的主要特点如下:

  • 它小巧简单,编写和运行速度快,因此可以在早期开发周期中发现问题,因此问题可以很容易地修复。

  • 由于它与依赖项隔离,因此每个测试用例都可以并行运行。

  • 单元测试驱动程序帮助我们理解单元接口。

  • 当测试单元后集成时,它极大地帮助集成和验收测试。

  • 通常由开发人员准备和执行。

虽然我们可以从头开始编写一个单元测试包,但社区中已经开发了许多单元测试框架UTFs)。Boost.Test、CppUnit、GoogleTest、Unit++和 CxxTest 是最受欢迎的。这些 UTF 通常提供以下功能:

  • 只需要最少的工作来设置一个新的测试。

  • 它们依赖于标准库并支持跨平台,这意味着它们易于移植和修改。

  • 它们支持测试固定装置,允许我们为多个不同的测试重用相同的对象配置。

  • 它们很好地处理异常和崩溃。这意味着 UTF 可以报告异常,但不能崩溃。

  • 它们具有良好的断言功能。每当断言失败时,应打印其源代码位置和变量的值。

  • 它们支持不同的输出,这些输出可以方便地由人类或其他工具进行分析。

  • 它们支持测试套件,每个套件可能包含多个测试用例。

现在,让我们来看一个 Boost UTF 的例子(自 v1.59.0 起)。它支持三种不同的使用变体:仅单头文件变体、静态库变体和共享库变体。它包括四种类型的测试用例:无参数的测试用例、数据驱动的测试用例、模板测试用例和参数化的测试用例。

它还有七种检查工具:BOOST_TEST()BOOST_CHECK()BOOST_REQUIRE()、BOOST_ERROR()BOOST_FAIL()BOOST_CHECK_MESSAGE( )BOOST_CHECK_EQUAL()。它还支持固定装置,并以多种方式控制测试输出。编写测试模块时,我们需要遵循以下步骤:

  1. 定义我们的测试程序的名称。这将在输出消息中使用。

  2. 选择一个使用变体:仅头文件、链接静态文件或作为共享库。

  3. 选择并添加一个测试用例到测试套件中。

  4. 对被测试代码执行正确性检查。

  5. 在每个测试用例之前初始化被测试的代码。

  6. 自定义测试失败报告的方式。

  7. 控制构建测试模块的运行时行为,也称为运行时配置。

例如,以下示例涵盖了步骤 1-4。如果您感兴趣,可以在www.boost.org/doc/libs/1_70_0/libs/test/doc/html/index.html获取步骤 5-7的示例:

//ch13_unit_test1.cpp
#define BOOST_TEST_MODULE my_test //item 1, "my_test" is module name
#include <boost/test/included/unit_test.hpp> //item 2, header-only

//declare we begin a test suite and name it "my_suite "
BOOST_AUTO_TEST_SUITE( my_suite ) 

//item 3, add a test case into test suit, here we choose 
//        BOOST_AUTO_TEST_CASE and name it "test_case1" 
BOOST_AUTO_TEST_CASE(test_case1) {
 char x = 'a';
 BOOST_TEST(x);        //item 4, checks if c is non-zero
 BOOST_TEST(x == 'a'); //item 4, checks if c has value 'a'
 BOOST_TEST(x == 'b'); //item 4, checks if c has value 'b'
}

//item 3, add the 2nd test case
BOOST_AUTO_TEST_CASE( test_case2 )
{
  BOOST_TEST( true );
}

//item 3, add the 3rd test case
BOOST_AUTO_TEST_CASE( test_case3 )
{
  BOOST_TEST( false );
}

BOOST_AUTO_TEST_SUITE_END() //declare we end test suite

为了构建这个,我们可能需要安装 boost,如下所示:

sudo apt-get install libboost-all-dev

然后,我们可以构建并运行它,如下所示:

~/wus1/Chapter-13$ g++ -g  ch13_unit_test1.cpp 
~/wus1/Chapter-13$ ./a.out

上述代码的结果如下:

Running 3 test cases...
 ch13_unit_test1.cpp(13): error: in "my_suite/test_case1": check x == 'b' has failed ['a' != 'b']
 ch13_unit_test1.cpp(25): error: in "my_suite/test_case3": check false has failed

 *** 2 failures are detected in the test module "my_test"

在这里,我们可以看到test_case1test_case3中存在失败。特别是在test_case1中,x的值不等于b,显然在test_case3中,一个错误的检查无法通过测试。

TDD

如下图所示,TDD 流程从编写失败的测试代码开始,然后添加/修改代码使测试通过。之后,我们对测试计划和代码进行重构,直到满足所有要求[16,17]。让我们看看下面的图表:

步骤 1是编写一个失败的测试。TDD 不是先开发代码,而是开始编写测试代码。因为我们还没有代码,所以我们知道,如果我们运行测试,它会失败。在这个阶段,定义测试数据格式和接口,并想象代码实现细节。

步骤 2的目标是尽快使测试通过,开发工作量最小。我们不想完美地实现一切;我们只希望它通过测试。一旦测试通过,我们就有东西可以展示给客户,并告诉客户,此时客户可能在看到初始产品后完善需求。然后,我们进入下一个阶段。

第三阶段是重构。在这个阶段,我们可能会进去,看看,看看我们想要改变什么以及如何改变它。

对于传统的开发人员来说,TDD 最困难的地方是从编码->测试模式转变为测试->编码模式的心态变化。为了对测试套件有一个模糊的概念,J. Hartikainen 建议开发人员考虑以下五个步骤[18]来开始:

  1. 首先确定输入和输出。

  2. 选择类/函数签名。

  3. 只决定功能的一个小方面进行测试。

  4. 实现测试。

  5. 实现代码。

一旦我们完成了这个迭代,我们可以逐渐重构它,直到实现整体的综合目标。

TDD 的例子

接下来,我们将通过实施一个案例研究来演示 TDD 过程。在这个研究中,我们将开发一个 Mat 类来执行 2D 矩阵代数,就像我们在 Matlab 中所做的那样。这是一个类模板,可以容纳所有数据类型的 m×n 矩阵。矩阵代数包括矩阵的加法、减法、乘法和除法,它还具有元素操作能力。

让我们开始吧。

步骤 1 - 编写一个失败的测试

首先,我们只需要以下内容:

  • 从给定的行数和列数创建一个Mat对象(默认应为 0×0,即空矩阵)。

  • 按行打印其元素。

  • rows()cols()获取矩阵大小。

根据这些要求,我们可以有失败的单元测试代码来提升 UTF,如下所示:

// ch13_tdd_boost_UTF1.cpp
#define BOOST_TEST_MODULE tdd_test
#include <boost/test/included/unit_test.hpp>
#include "ch13_tdd_v1.h"

BOOST_AUTO_TEST_SUITE(tdd_suite)  //begin a test suite: "tdd_suite"

BOOST_AUTO_TEST_CASE(test_case1) {
  Mat<int> x(2, 3);            //create a 2 x 3 int matrix
  x.print("int x=");
  BOOST_TEST(2 == x.rows());
  BOOST_TEST(3 == x.cols());

  Mat<float> y;              //create a 0 x 0 empty float matrix
  y.print("float y=");
  BOOST_TEST(0 == y.rows());
  BOOST_TEST(0 == y.cols());

  Mat<char> z(1,10);       //create a 1 x 10 char matrix
  z.print("char z=");
  BOOST_TEST(1 == z.rows());
  BOOST_TEST(10 == z.cols());
}
BOOST_AUTO_TEST_SUITE_END() //end test suite

现在我们的测试代码准备好了,我们准备开发代码。

步骤 2 - 开发代码使测试通过

实现一个最小的代码段来通过前面的测试,如下所示:

//file: ch13_tdd_v1.h
#ifndef __ch13_TDD_V1__
#define __ch13_TDD_V1__
#include <iostream>
#include <assert.h>
template< class T>
class Mat {
public:
  Mat(const uint32_t m=0, const uint32_t n=0);
  Mat(const Mat<T> &rhs) = delete;
  ~Mat();

  Mat<T>& operator = (const Mat<T> &x) = delete;

  uint32_t rows() { return m_rows; }
  uint32_t cols() { return m_cols; }
  void print(const char* str) const;

private:
  void creatBuf();
  void deleteBuf();
  uint32_t m_rows; //# of rows
  uint32_t m_cols; //# of cols
  T* m_buf;
};
#include "ch13_tdd_v1.cpp"
#endif

一旦我们有了前面的头文件,我们就可以开发其相应的cpp文件,如下所示:

//file: ch13_tdd_v1.cpp
#include "ch13_tdd_v1.h"
using namespace std;

template< class T>
Mat<T>::Mat(const uint32_t m, const uint32_t n)
 : m_rows(m)
 , m_cols(n)
 , m_buf(NULL)
{
 creatBuf();
}

template< class T>
Mat<T> :: ~Mat()
{ 
 deleteBuf(); 
}

template< class T>
void Mat<T>::creatBuf()
{
 uint32_t sz = m_rows * m_cols;
 if (sz > 0) {
 if (m_buf) { deleteBuf();}
 m_buf = new T[sz];
 assert(m_buf);
 }
 else {
 m_buf = NULL;
 }
}

template< class T>
void Mat<T>::deleteBuf()
{
 if (m_buf) {
 delete[] m_buf;
 m_buf = NULL;
 }
}

template< class T>
void Mat<T> ::print(const char* str) const
{
 cout << str << endl;
 cout << m_rows << " x " << m_cols << "[" << endl;
 const T *p = m_buf;
 for (uint32_t i = 0; i<m_rows; i++) {
 for (uint32_t j = 0; j < m_cols; j++) {
 cout << *p++ << ", ";
 }
 cout << "\n";
 }
 cout << "]\n";
}

假设我们使用支持-std=c++11或更高版本的 g++进行构建和执行:

~/wus1/Chapter-13$ g++ -g ch13_tdd_boost_UTF1.cpp~/wus1/Chapter-13$ a.out 

这将导致以下输出:

Running 1 test case...
 int x=2 x 3[
 1060438054, 1, 4348032,
 0, 4582960, 0,
 ]
 float y=0 x 0[
 ]
 char z=1 x 10[
 s,s,s,s,s,s,s,s,s,s,
 ]

test_case1中,我们创建了三个矩阵并测试了rows()cols()print()函数。第一个是一个 2x3 的int类型矩阵。由于它没有初始化,其元素的值是不可预测的,这就是为什么我们可以从print()中看到这些随机数。在这一点上,我们也通过了rows()cols()的测试(两个BOOST_TEST()调用没有错误)。第二个是一个空的浮点类型矩阵;它的print()函数什么也不输出,它的cols()rows()都是零。最后,第三个是一个 1x10 的char类型未初始化矩阵。同样,这三个函数的所有输出都是预期的。

步骤 3 - 重构

到目前为止,一切顺利 - 我们通过了测试!然而,在向客户展示前面的结果后,他/她可能会要求我们添加另外两个接口,如下所示:

  • 为所有元素创建一个给定初始值的 m x n 矩阵。

  • 添加numel()以返回矩阵的总元素数。

  • 添加empty(),如果矩阵既有零行又有零列,则返回 true,否则返回 false。

一旦我们向测试套件添加了第二个测试用例,整体重构后的测试代码将如下所示:

// ch13_tdd_Boost_UTF2.cpp
#define BOOST_TEST_MODULE tdd_test
#include <boost/test/included/unit_test.hpp>
#include "ch13_tdd_v2.h"

//declare we begin a test suite and name it "tdd_suite"
BOOST_AUTO_TEST_SUITE(tdd_suite)

//add the 1st test case
BOOST_AUTO_TEST_CASE(test_case1) {
  Mat<int> x(2, 3);
  x.print("int x=");
  BOOST_TEST(2 == x.rows());
  BOOST_TEST(3 == x.cols());

  Mat<float> y;
  BOOST_TEST(0 == y.rows());
  BOOST_TEST(0 == y.cols());

  Mat<char> z(1, 10);
  BOOST_TEST(1 == z.rows());
  BOOST_TEST(10 == z.cols());
}

//add the 2nd test case
BOOST_AUTO_TEST_CASE(test_case2)
{
  Mat<int> x(2, 3, 10);
  x.print("int x=");
  BOOST_TEST( 6 == x.numel() );
  BOOST_TEST( false == x.empty() );

  Mat<float> y;
  BOOST_TEST( 0 == y.numel() );
  BOOST_TEST( x.empty() ); //bug x --> y 
}

BOOST_AUTO_TEST_SUITE_END() //declare we end test suite

下一步是修改代码以通过这个新的测试计划。为了简洁起见,我们不会在这里打印ch13_tdd_v2.hch13_tdd_v2.cpp文件。您可以从本书的GitHub存储库中下载它们。构建并执行ch13_tdd_Boost_UTF2.cpp后,我们得到以下输出:

Running 2 test cases...
 int x=2x3[
 1057685542, 1, 1005696,
 0, 1240624, 0,
 ]
 int x=2x3[
 10, 10, 10,
 10, 10, 10,
 ]
 ../Chapter-13/ch13_tdd_Boost_UTF2.cpp(34): error: in "tdd_suite/test_case2": che
 ck x.empty() has failed [(bool)0 is false]

在第一个输出中,由于我们只定义了一个 2x3 的整数矩阵,并且没有在test_case1中初始化它,所以会打印出未定义的行为 - 也就是六个随机数。第二个输出来自test_case2,其中x的所有六个元素都初始化为10。在我们展示了前面的结果之后,我们的客户可能会要求我们添加其他新功能或修改当前存在的功能。但是,经过几次迭代,最终我们会达到快乐点并停止因式分解。

现在我们已经了解了 TDD,我们将讨论 BDD。

BDD

软件开发最困难的部分是与业务参与者、开发人员和质量分析团队进行沟通。由于误解或模糊的需求、技术争论和缓慢的反馈周期,项目很容易超出预算、错过截止日期或完全失败。

(BDD) [20]是一种敏捷开发过程,具有一套旨在减少沟通障碍和其他浪费活动的实践。它还鼓励团队成员在生产生命周期中不断地使用真实世界的例子进行沟通。

BDD 包含两个主要部分:故意发现和 TDD。为了让不同组织和团队的人了解开发软件的正确行为,故意发现阶段引入了示例映射技术,通过具体的例子让不同角色的人进行对话。这些例子将成为系统行为的自动化测试和实时文档。在其 TDD 阶段,BDD 规定任何软件单元的测试应该以该单元的期望行为为基础。

有几种 BDD 框架工具(JBehave、RBehave、Fitnesse、Cucumber [21]等)适用于不同的平台和编程语言。一般来说,这些框架执行以下步骤:

  1. 在故意发现阶段,阅读由业务分析师准备的规范格式文档。

  2. 将文档转换为有意义的条款。每个单独的条款都可以被设置为质量保证的测试用例。开发人员也可以根据条款实现源代码。

  3. 自动执行每个条款场景的测试。

总之,我们已经了解了关于应用开发流程中什么、何时以及如何进行测试的策略。如下图所示,传统的 V 形[2]模型强调需求->设计->编码->测试的模式。TDD 认为开发过程应该由测试驱动,而 BDD 将来自不同背景和角色的人之间的沟通加入到 TDD 框架中,并侧重于行为测试:

此外,单元测试强调在编码完成后测试单个组件。TDD 更注重如何在编写代码之前编写测试,然后通过下一级测试计划添加/修改代码。BDD 鼓励客户、业务分析师、开发人员和质量保证分析师之间的合作。虽然我们可以单独使用每一个,但在这个敏捷软件开发时代,我们真的应该将它们结合起来以获得最佳结果。

总结

在本章中,我们简要介绍了软件开发过程中与测试和调试相关的主题。测试可以发现问题,根本原因分析有助于在宏观层面上定位问题。然而,良好的编程实践可以在早期阶段防止软件缺陷。此外,命令行界面调试工具 GDB 可以帮助我们设置断点,并在程序运行时逐行执行程序并打印变量的值。

我们还讨论了自动分析工具和人工测试过程。静态分析评估程序的性能而不执行它。另一方面,动态分析工具可以通过执行程序来发现缺陷。最后,我们了解了测试过程在软件开发流程中应该如何、何时以及如何参与的策略。单元测试强调在编码完成后测试单个组件。TDD 更注重如何在开发代码之前编写测试,然后通过下一级测试计划重复这个过程。BDD 鼓励客户、业务分析师、开发人员和质量保证分析师之间的合作。

在下一章中,我们将学习如何使用 Qt 创建跨平台应用程序的图形用户界面(GUI)程序,这些程序可以在 Linux、Windows、iOS 和 Android 系统上运行。首先,我们将深入了解跨平台 GUI 编程的基本概念。然后我们将介绍 Qt 及其小部件的概述。最后,通过一个案例研究示例,我们将学习如何使用 Qt 设计和实现网络应用程序。

进一步阅读

除此之外,你还可以查看以下来源(这些在本章中没有直接提到):

练习和问题

  1. 使用 gdb 函数断点、条件断点和 watchpointcontinuefinish 命令,调试 ch13_gdb_2.cpp

  2. 使用 g++ -c -Wall -Weffc++ -Wextra  x.cpp -o x.out 来构建 cpp 文件 ch13_rca*.cpp。你从他们的警告输出中看到了什么?

  3. 为什么静态分析会产生误报,而动态分析不会呢?

  4. 下载 ch13_tdd_v2.h/.cpp 并执行下一阶段的重构。在这个阶段,我们将添加一个拷贝构造函数、赋值运算符,以及诸如 +-*/ 等的逐元素操作运算符。更具体地,我们需要做以下事情:

  5. 将第三个测试用例添加到我们的测试套件中,即 ch13_tdd_Boost_UTF2.cpp

  6. 将这些函数的实现添加到文件中;例如,ch13_tdd_v2.h/.cpp

  7. 运行测试套件来测试它们。

第十四章:使用 Qt 进行图形用户界面

C++并不直接提供图形用户界面GUI)编程。首先,我们应该了解 GUI 与特定的操作系统OS)密切相关。您可以使用 Windows API 在 Windows 中编写 GUI 应用程序,或者使用 Linux 特定的 API 在 Linux 中编写 GUI 应用程序,依此类推。每个操作系统都有自己特定的窗口和 GUI 组件形式。

我们在第一章中提到了不同平台及其差异。在讨论 GUI 编程时,平台之间的差异更加令人望而生畏。跨平台开发已经成为 GUI 开发人员生活中的一大痛苦。他们不得不专注于特定的操作系统。为其他平台实现相同的应用程序几乎需要同样多的工作。这是一个不合理的巨大时间和资源浪费。诸如Java之类的语言提供了在虚拟环境中运行应用程序的智能模型。这使得开发人员可以专注于一种语言和一个项目,因为环境负责在不同的平台上运行应用程序。这种方法的一个主要缺点是强制用户安装虚拟机,以及与特定平台应用程序相比较慢的执行时间。

为了解决这些问题,Qt 框架被创建了。在本章中,我们将了解 Qt 框架如何支持跨平台 GUI 应用程序开发。为此,您需要熟悉 Qt 及其关键特性。这将使您能够使用您喜爱的编程语言——C++来开发 GUI 应用程序。我们将首先了解 Qt 的 GUI 开发方法,然后我们将涵盖其概念和特性,如信号和槽,以及模型/视图编程。

在本章中,我们将涵盖以下主题:

  • 跨平台 GUI 编程的基础

  • Qt 核心组件

  • 使用 Qt 小部件

  • 使用 Qt Network 设计网络应用程序

技术要求

您需要安装最新的 Qt 框架才能运行本章的示例。我们建议使用 Qt Creator 作为项目的 IDE。要下载 Qt 及相应的工具,请访问qt.io网站,并选择框架的开源版本。本章的代码可以在以下网址找到:github.com/PacktPublishing/Expert-CPP

了解跨平台 GUI 编程

每个操作系统都有自己的 API。它与 GUI 特别相关。当公司计划设计、实现和发布桌面应用程序时,他们应该决定专注于哪个平台。一个团队的开发人员在一个平台上工作,几乎需要花同样多的时间为另一个平台编写相同的应用程序。这最大的原因是操作系统提供的不同方法和 API。API 的复杂性也可能在按时实现应用程序方面起到重要作用。例如,官方文档中的以下片段显示了如何使用 C++在 Windows 中创建按钮:

HWND hwndButton = CreateWindow(
  L"BUTTON", // Predefined class; Unicode assumed      
  L"OK", // Button text      
  WS_TABSTOP | WS_VISIBLE | WS_CHILD | BS_DEFPUSHBUTTON, // Styles      
  10, // x position      
  10, // y position      
  100, // Button width     
  100, // Button height     
  m_hwnd, // Parent window     
  NULL, // No menu.     
  (HINSTANCE)GetWindowLong(m_hwnd, GWL_HINSTANCE),     
  NULL); // Pointer not needed.

解决 Windows GUI 编程需要你使用HWNDHINSTACNCE和许多其他奇怪命名和令人困惑的组件。

.NET Framework 对 Windows GUI 编程进行了重大改进。如果您想支持除 Windows 之外的操作系统,使用.NET Framework 之前要三思。

然而,为了支持多个操作系统,您仍然需要深入了解 API 来实现相同的应用程序,以满足所有操作系统的用户。以下代码显示了在 Linux 中使用Gtk+ GUI 工具包创建按钮的示例:

GtkWidget* button = gtk_button_new_with_label("Linux button");

与 Windows API 相比,它似乎更容易理解。但是,您应该深入了解GtkWidgets和其他带有Gtk前缀的组件,以了解更多关于它们的信息。

正如我们已经提到的,诸如 Java 和.NET Core 之类的跨平台语言使用虚拟机在不同平台上运行代码。Qt 框架支持使用基于平台的编译方法进行跨平台 GUI 编程。让我们就 C++语言讨论这两种方法。

使用 C++作为 Java

诸如 Java 或 C#之类的语言有不同的编译模型。本书的第一章介绍了 C++的编译模型。首先,我们认为 C++是一种完全可编译的语言,而 Java 保持了混合模型。它将源代码编译成称为字节码的中间表示,然后虚拟机通过将其翻译成特定平台的机器代码来运行它。

以下图表描述了 C++和 Java 编译模型之间的差异:

Java 虚拟机(JVM)充当中间层。它对每个平台有一个独特的实现。用户需要在运行 Java 程序之前安装特定实现的虚拟机。安装过程只发生一次。另一方面,C++程序被翻译成机器代码,而不需要像 JVM 这样的中间层环境。这是 C++应用程序通常更快的原因之一。当我们在某个平台上编译 C++程序时,编译器会输出一个由特定于该平台的格式的指令组成的可执行文件。当我们将应用程序移动到另一个平台时,它就无法运行。

其他平台无法识别它的格式,也无法识别它的指令(尽管它们可能在某种程度上相似)。Java 方法通过提供一些字节码来工作,这些字节码对于所有虚拟机的实现都是相同的。但是虚拟机确切地知道他们应该为作为输入提供的字节码生成哪些指令。如果安装了虚拟机,相同的字节码可以在许多计算机上运行。以下图表演示了 Java 应用程序编译模型:

如您所见,源代码被编译成可以在每个操作系统上运行的字节码。然而,每个操作系统必须提供其自己的虚拟机实现。这意味着如果我们为该操作系统安装了专门为该操作系统实现的 JVM,我们就可以在任何操作系统上运行 Java 应用程序。

尽管 C++是一种跨平台语言,也就是说我们不需要修改代码就可以在其他平台上编译它,但是这种语言并不直接支持 GUI 编程。为了编写 GUI 应用程序,正如我们之前提到的,我们需要直接从代码中访问操作系统 API。这使得 C++ GUI 应用程序依赖于平台,因为你需要修改代码基础才能在其他平台上编译它。以下图表显示了 GUI 是如何破坏语言的跨平台性的:

尽管应用程序的逻辑、名称和任务可能相同,但现在它有三种不同的实现,有三种不同的可执行文件。要将应用程序交付给最终用户,我们需要发现他们的操作系统并交付正确的可执行文件。您可能在从网上下载应用程序时遇到了类似的情况。它们基于操作系统提供下载应用程序。这就是 Qt 发挥作用的地方。让我们看看它是如何做到的。

Qt 的跨平台模型

Qt 是一个用于创建 GUI 应用程序的流行的小部件工具包。它还允许我们创建在各种系统上运行的跨平台应用程序。Qt 包括以下模块:

  • Qt 核心:核心类

  • Qt GUI:GUI 组件的基本类

  • Qt 小部件:用于扩展 Qt GUI 的 C++小部件的类

  • Qt 多媒体:音频、视频、广播和摄像功能的类

  • Qt 多媒体小部件:实现多媒体功能的类

  • Qt 网络:网络编程的类(我们将在本章中使用它们)

  • Qt 建模语言QML):用于构建具有自定义用户界面的声明性框架

  • Qt SQL:使用 SQL 进行数据库集成的类

  • Qt Quick 模块系列:一个与 QML 相关的模块列表,本书不会讨论

  • Qt 测试:用于单元测试 Qt 应用程序的类

我们在程序中使用的每个模块都通过具有.pro扩展名的项目文件连接到编译器。该文件描述了qmake构建应用程序所需的一切。qmake是一个旨在简化构建过程的工具。我们在项目的.pro文件中描述项目组件(源文件、Qt 模块、库等)。例如,一个使用 Qt 小部件和 Qt 网络,由main.cpptest.cpp文件组成的项目将在.pro文件中具有以下内容:

QT += widgets
QT += network
SOURCES += test.cpp
SOURCES += main.cpp

我们也可以在.pro文件中指定特定于平台的源文件,如下所示:

QT += widgets
QT += network
SOURCES += test.cpp
SOURCES += main.cpp
win32 {
 SOURCES += windows_specific.cpp
}
unix {
 SOURCES += linux_world.cpp
}

当我们在 Windows 环境中构建应用程序时,windows_specific.cpp文件将参与构建过程。相反,当在 Unix 环境中构建时,将包括linux_world.cpp文件,而windows_specific.cpp文件将被忽略。通过这样,我们已经了解了 Qt 应用程序的编译模型。

Qt 强大的跨平台编程能力的整个重点在于元编译源代码;也就是说,在代码传递给 C++编译器之前,Qt 编译器通过引入或替换特定于平台的组件来清理它。例如,当我们使用按钮组件(QPushButton)时,如果在 Windows 环境中编译,它将被替换为特定于 Windows 的按钮组件。这就是为什么.pro文件也可以包含项目的特定于平台的修改。以下图表描述了这个编译过程:

元编译器通常被称为元对象编译器MOC)。这种方法的美妙之处在于产生的输出代表了我们可以直接运行的相同机器代码,而无需虚拟机。我们可以立即发布可执行文件。这种方法的缺点是,我们再次为不同的平台有不同的可执行文件。然而,我们只编写一个应用程序 - 无需使用不同的语言,深入研究特定于操作系统的 API,或学习特定于操作系统的 GUI 组件类名称。正如 Qt 所说,一次编写,到处编译。现在,让我们继续构建一个简单的 GUI 应用程序。

编写一个简单的应用程序

我们不会在本书中讨论我们之前提到的所有模块,因为这需要一本全新的书。您可以在本章末尾列出的书籍中的进一步阅读部分中查阅更多信息。main函数如下所示:

#include <QtWidgets>

int main(int argc, char** argv)
{
  QApplication app(argc, argv);

  QPushButton btn("Click me!");
  btn.show();

  return app.exec();
}

让我们来看看我们在代码中使用的各种组件。第一个是QtWidgets头文件。它包含了我们可以用来为应用程序构建细粒度 GUI 的小部件组件。接下来是QPushButton类,它代表一个可点击按钮的包装器。我们故意在这里引入它作为一个包装器,这样我们可以在本章后面讨论 Qt 程序的编译过程时解释它。这是运行上述代码的结果:

正如您所看到的,我们只声明了QPushButton类,但它出现为一个具有标准 OS 的关闭和最小化按钮的窗口(在本例中是 macOS)。这是因为QPushButton间接继承自QWidget,它是一个带有框架的小部件;也就是说,一个窗口。按钮几乎占据了窗口的所有空间。我们可以调整窗口的大小,看看按钮如何随之调整大小。我们将在本章后面更详细地讨论小部件。

当我们运行app.exec()时,GUI 构建完成。注意app对象的类型。它是一个QApplication对象。这是 Qt 应用程序的起点。当我们调用exec()函数时,我们启动了 Qt 的事件循环。我们对程序执行的感知应该有所改变,以理解 GUI 应用程序的生命周期。重新定义程序构建和执行的感知在第七章之后对你来说应该不足为奇,函数式编程。这次并不那么困难。这里需要知道的主要事情是,GUI 应用程序在主程序之外还有一个额外的实体在运行。这个实体被称为事件循环

回想一下我们在第十一章中讨论过的事件循环,使用设计模式设计策略游戏。游戏代表了用户密集交互的可视组件的程序。同样适用于具有按钮、标签和其他图形组件的常规 GUI 应用程序。

用户与应用程序交互,每个用户操作都被解释为一个事件。然后将每个事件推送到队列中。事件循环逐个处理这些事件。处理事件意味着调用与事件相关联的特殊处理程序函数。例如,每当单击按钮时,将调用keyPressedEvent()函数。它是一个虚函数,因此在设计自定义按钮时可以重写它,如下面的代码所示:

class MyAwesomeButton : public QPushButton
{
  Q_OBJECT
public:
 void keyPressedEvent(QKeyEvent* e) override
 {
 // anything that we need to do when the button is pressed
 }
};

事件的唯一参数是指向QKeyEvent的指针,它是QEvent的子类型。QEvent是 Qt 中所有事件类的基类。注意在类的开头块之后放置的奇怪的Q_OBJECT。这是一个 Qt 特定的宏,如果你想让它们被 Qt 的 MOC 发现,应该将它放在自定义类的第一行。

在下一节中,我们将介绍特定于 Qt 对象的信号和槽的机制。为了使我们的自定义对象支持该机制,我们在类定义中放置Q_OBJECT宏。

现在,让我们构建比简单按钮更大的东西。以下示例创建了一个标题为“精通 C ++”的窗口:

#include <QtWidgets>

int main(int argc, char** argv)
{
  QApplication app(argc, argv);
 QWidget window;
 window.resize(120, 100);
 window.setWindowTitle("Mastering C++");
 window.show();

  return app.exec();
}

通过执行上述程序,我们得到以下结果:

标题被截断了;我们只能看到“Mast...”部分的“Mastering C ++”。现在,如果我们手动调整大小,或者更改源代码,使第二个参数的resize()函数具有更大的值,我们会得到以下结果:

window对象是QWidget类型。QWidget是所有用户界面对象的中心类。每当您想要创建自定义小部件或扩展现有小部件时,您都会直接或间接地继承自QWidget。它有很多函数适用于每种用例。您可以使用move()函数在屏幕上移动它,可以通过调用showFullScreen()使窗口全屏,等等。在上面的代码中,我们调用了resize()函数,它接受宽度和高度来调整小部件的大小。还要注意setWindowTitle()函数,它正如其名-将传递的字符串参数设置为窗口的标题。在代码中使用字符串值时,最好使用QApplication::translate()函数。这样做可以使程序本地化变得更容易,因为当语言设置更改时,Qt 会自动用正确的翻译替换文本。QObject::tr()提供了几乎相同的功能。

QObject是所有 Qt 类型的基类。在诸如 Java 或 C#之类的语言中,每个对象都直接或间接地继承自一个通用类型,通常命名为Object。C ++没有包含一个公共基类。另一方面,Qt 引入了QObject,它具有所有对象应支持的基本功能。

现在我们已经了解了 Qt 应用程序开发的基础知识,让我们深入了解框架并发现其关键特性。

发现 Qt

Qt 随着时间的推移不断发展,在撰写本书时,其版本为 5.14。它的第一个公共预发布版本是在 1995 年宣布的。已经过去了二十多年,现在 Qt 在几乎所有平台上都有许多强大的功能,包括 Android 和 iOS 等移动系统。除了少数例外,我们可以自信地为所有平台使用 C++和 Qt 编写功能齐全的 GUI 应用程序。这是一个重大的变革,因为公司可以雇佣专门从事一种技术的小团队,而不是为每个特定平台都有几个团队。

如果你是 Qt 的新手,强烈建议你尽可能熟悉它(在本章的末尾有书籍参考)。除了 GUI 框架提供的常规组件外,Qt 还引入了一些在框架中新的或精心实现的概念。其中一个概念是使用信号和槽进行对象之间的通信。

掌握信号和槽

Qt 引入了信号和槽的概念作为对象之间灵活的通信机制。信号和槽的概念及其实现机制是将 Qt 与其他 GUI 框架区分开的特性之一。在之前的章节中,我们讨论了观察者模式。这个模式的主要思想是有一个对象通知其他对象(订阅者)一个事件。信号和槽的机制类似于观察者模式的实现。这是一种对象通知另一个对象其变化的方式。Qt 提供了一个通用接口,可以用来通过将一个对象的信号与另一个对象的槽绑定来连接对象。信号和槽都是对象的常规成员函数。信号是在对象的指定动作上调用的函数。槽是作为订阅者的另一个函数。它由信号函数调用。

正如我们之前提到的,Qt 向我们介绍了所有对象的基本类型QObject。支持信号和槽的基本功能在QObject中实现。你在代码中声明的任何对象,QWidgetQPushButton等都继承自QObject,因此它们都支持信号和槽。QObject 为我们提供了两个用于管理对象通信的函数。这些对象是connect()disconnect()

bool connect(const QObject* sender, const char* signal, 
  const QObject* receiver, const char* method, 
  Qt::ConnectionType type = Qt::AutoConnect);

bool disconnect(const QObject* sender, const char* signal, 
  const QObject* receiver, const char* method);

正如你所看到的,connect()函数将receiversender对象作为参数。它还接受信号和槽的名称。signal与发送者相关联,而slot是接收者提供的。以下图表显示了这一点:

当编写 Qt 应用程序时,操作信号和槽将变得自然,迟早你会认为每个其他框架都支持信号和槽,因为它们很方便。还要注意,在connect()disconnect()函数中,信号和槽被处理为字符串。在连接对象时指定信号和槽,我们使用另外两个宏,分别是SIGNAL()SLOT()。从现在开始不会再介绍更多的宏 - 我们保证。

这是我们如何连接两个对象的方式。假设我们想要改变标签(QLabel的一个实例)的文本,使其在按钮被点击时接收一个信号。为了实现这一点,我们将QPushButtonclicked()信号连接到QLabel的槽,如下所示:

QPushButton btn("Click me!");
QLabel lbl;
lbl.setText("No signal received");
QObject::connect(&btn, SIGNAL(clicked()), &lbl, SLOT(setText(const QString&)));

前面的代码可能看起来有点冗长,但你会习惯的。把它看作是信号和槽的便利机制的代价。然而,前面的例子不会给我们所需的结果;也就是说,它不会将标签的文本设置为接收到信号。我们应该以某种方式将该字符串传递给标签的槽。clicked()信号不会为我们做到这一点。实现这一点的一种方法是通过扩展QLabel,使其实现一个自定义槽,将文本设置为received a signal。下面是我们可以这样做的方法:

class MyLabel : public QLabel
{
Q_OBJECT
public slots:
  void setCustomText() { 
    this->setText("received a signal");
  }
};

要声明一个槽,我们像在前面的代码中所做的那样指定部分。信号的声明方式几乎相同:通过指定一个带有signals:的部分。唯一的区别是信号不能是私有或受保护的。我们只是按原样声明它们:

class Example
{
Q_OBJECT:
public:
  // member functions
public slots:
  // public slots
private slots:
  // private slots
signals: // no public, private, or protected
  // signals without any definition, only the prototype
};

现在,我们只需要更新前面的代码,以更改标签的信号(以及标签对象的类型):

QPushButton btn("Click me!");
MyLabel lbl;
lbl.setText("No signal received");
QOBject::connect(&btn, SIGNAL(clicked()), &lbl, SLOT(setCustomText()));

我们说槽将在信号被发射时被调用。您还可以在对象内部声明和发射信号。与 GUI 事件循环无关的信号和槽的一个重要细节。

当信号被发射时,连接的槽立即执行。但是,我们可以通过将Qt::ConnectionType之一作为connect()函数的第五个参数来指定连接的类型。它包括以下值:

  • AutoConnection

  • DirectConnection

  • QueuedConnection

  • BlockingQueuedConnection

  • UniqueConnection

DirectConnection中,当信号被发射时,槽立即被调用。另一方面,当使用QueuedConnection时,当执行返回到接收对象线程的事件循环时,槽被调用。BlockingQueuedConnection类似于QueuedConnection,只是信号线程被阻塞,直到槽返回一个值。AutoConnection可以是DirectConnectionQueuedConnection。当信号被发射时,类型被确定。如果接收者和发射者在同一线程中,使用DirectConnection;否则,连接使用QueuedConnection。最后,UniqueConnection与前面描述的任何连接类型一起使用。它与其中一个使用按位或组合。它的唯一目的是使connect()函数在信号和线程之间的连接已经建立时失败。

信号和槽构成了 Qt 在 GUI 编程中出色的机制。我们介绍的下一个机制在框架中很受欢迎,与我们在应用程序中操作数据的方式有关。

理解模型/视图编程

模型/视图编程根植于模型视图控制器(MVC)设计模式。该模式的主要思想是将问题分解为三个松散耦合的组件,如下所示:

  • 模型负责存储和操作数据

  • 视图负责渲染和可视化数据

  • 控制器负责额外的业务逻辑,并从模型向视图提供数据

通过其演变,我们现在有了一种简化和更便利的编程方法,称为模型/视图编程。它类似于 MVC 模式,只是通过使视图和模型更关注手头的功能来省略了控制器。我们可以说视图和控制器在模型/视图架构中合并在一起。看一下以下架构图:

模型代表数据,与其来源通信,并为架构中的其他组件提供方便的接口。模型的实现及其与其他组件的通信基于手头数据的类型。

视图通过获取所谓的模型索引来引用数据项。视图可以从模型检索和提供数据。关键是,数据项可以使用视图进行编辑,委托起到了与模型通信以保持数据同步的作用。

介绍的每个组件——模型、视图和委托——都由提供共同接口的抽象类定义。在某些情况下,类还提供了功能的默认实现。要编写专门的组件,我们从抽象类继承。当然,模型、视图和委托使用我们在上一节中介绍的信号和槽进行通信。

当模型遇到数据变化时,它会通知视图。另一方面,渲染数据项的用户交互由视图发出的信号通知。最后,委托发出的信号通知模型和视图有关数据编辑状态的信息。

模型基于QAbstractItemModel类,该类定义了视图和委托使用的接口。Qt 提供了一组现有的模型类,我们可以在不进行修改的情况下使用;但是,如果需要创建新模型,应该从QAbstractItemModel继承您的类。例如,QStringListModelQStandardItemModelQFileSystemModel类已经准备好处理数据项。QStringListModel用于存储字符串项列表(表示为QString对象)。此外,还有方便的模型类用于处理 SQL 数据库。QSqlQueryModelQSqlTableModelQSqlRelationalTableModel允许我们在模型/视图约定的上下文中访问关系数据库。

视图和委托也有相应的抽象类,即QAbstractItemViewQAbstractItemDelegate。Qt 提供了现有的视图,可以立即使用,例如QListViewQTableViewQTreeView。这些是大多数应用程序处理的基本视图类型。QListView显示项目列表,QTableView以表格形式显示数据,QTreeView以分层列表形式显示数据。如果要使用这些视图类,Qt 建议从QAbstractListModelQAbstractTableModel继承自定义模型,而不是对QAbstractItemModel进行子类化。

QListViewQTreeViewQTableView被认为是核心和低级别的类。还有更方便的类,为新手 Qt 程序员提供更好的可用性——QListWidgetQTreeWidgetQTableWidget。我们将在本章的下一节中看到使用小部件的示例。在那之前,让我们看一个QListWidget的简单示例:

#include <QListWidget>

int main(int argc, char** argv)
{
  QApplication app(argc, argv);
  QListWidget* listWgt{new QListWidget};
  return app.exec();
}

向列表窗口小部件添加项目的一种方法是通过创建它们,我们可以通过将列表窗口小部件设置为其所有者来实现。在下面的代码中,我们声明了三个QListWidgetItem对象,每个对象都包含一个名称,并与我们之前声明的列表窗口小部件相关联:

new QListWidgetItem("Amat", listWgt);
new QListWidgetItem("Samvel", listWgt);
new QListWidgetItem("Leia", listWgt);

或者,我们可以声明一个项目,然后将其插入到列表窗口小部件中:

QListWidgetItem* newName{new QListWidgetItem};
newName->setText("Sveta");
listWgt->insertItem(0, newName);

insertItem()成员函数的第一个参数是要将项目插入的row的数量。我们将Sveta项目放在列表的第一个位置。

现在我们已经涉及了行的概念,我们应该回到模型和它们的索引。模型将数据封装为数据项的集合。模型中的每个项都有一个由QModelIndex类指定的唯一索引。这意味着模型中的每个项都可以通过关联的模型索引访问。要获取模型索引,我们需要使用index()函数。以下图表描述了一个以表格结构组织其数据的模型:

视图使用这种约定来访问模型中的数据项。但是,请注意,视图在呈现数据给用户方面并没有限制。视图的实现方式取决于如何以对用户方便的方式呈现和展示数据。以下图表显示了数据在模型中的组织方式:

这是我们如何使用模型索引访问第 1 行第 2 列的特定数据项:

QModelIndex itemAtRow1Col2 = model->index(1, 2);

最后,让我们声明一个视图并为其设置一个模型,以查看模型/视图编程的实际效果:

QStringList lst;
lst << "item 1" << "item 2" << "item 3";

QStringListModel model;
model.setStringList(lst);

QListView lview;
lview.setModel(model);

一旦我们熟悉了 Qt 提供的各种小部件,我们将在下一节继续这个示例。

使用 Qt 小部件

小部件是可视化 GUI 组件。如果一个小部件没有父级,它将被视为一个窗口,也就是顶级小部件。在本章的前面,我们创建了 Qt 中最简单的窗口,如下所示:

#include <QtWidgets>

int main(int argc, char** argv)
{
  QApplication app(argc, argv);
 QWidget window;
 window.resize(120, 100);
 window.setWindowTitle("Mastering C++");
 window.show();

  return app.exec();
}

正如您所看到的,window对象没有父级。问题是,QWidget的构造函数接受另一个QWidget作为当前对象的父级。因此,当我们声明一个按钮并希望它成为window对象的子级时,我们可以这样做:

#include <QtWidgets>

int main(int argc, char** argv)
{
  QApplication app(argc, argv);
QWidget window;
  window.resize(120, 100);
  window.setWindowTitle("Mastering C++");
  window.show();

 QPushButton* btn = new QPushButton("Click me!", &window);

  return app.exec();
}

观察QPushButton构造函数的第二个参数。我们将window对象的引用作为其父级传递。当父对象被销毁时,其子对象将自动被销毁。Qt 支持许多其他小部件;让我们看看其中一些。

常见的 Qt 小部件

在上一节中,我们介绍了QPushButton类,并指出它间接继承了QWidget类。要创建一个窗口,我们使用了QWidget类。事实证明,QWidget 代表了向屏幕渲染的能力,它是所有小部件都继承的基本类。它具有许多属性和函数,例如enabled,一个布尔属性,如果小部件启用则为 true。要访问它,我们使用isEnabled()setEnabled()函数。要控制小部件的大小,我们使用它的heightwidth,分别表示小部件的高度和宽度。要获取它们的值,我们分别调用height()width()。要设置新的高度和宽度,我们应该使用resize()函数,它接受两个参数 - 宽度和高度。您还可以使用setMinimumWidth()setMinimumHeight()setMaximumWidth()setMaximumHeight()函数来控制小部件的最小和最大大小。当您在布局中设置小部件时,这可能会很有用(请参阅下一节)。除了属性和函数,我们主要对 QWidget 的公共槽感兴趣,它们如下:

  • close(): 关闭小部件。

  • hide(): 等同于setVisible(false),此函数隐藏小部件。

  • lower()raise(): 将小部件移动到父小部件的堆栈中(到底部或顶部)。每个小部件都可以有一个父小部件。没有父小部件的小部件将成为独立窗口。我们可以使用setWindowTitle()setWindowIcon()函数为此窗口设置标题和图标。

  • style: 该属性保存小部件的样式。要修改它,我们使用setStyleSheet()函数,通过传递描述小部件样式的字符串。另一种方法是调用setStyle()函数,并传递封装了与样式相关属性的QStyle类型的对象。

Qt 小部件几乎具备所有必要的属性,可以直接使用。很少遇到需要构建自定义小部件的情况。然而,一些团队为他们的软件创建了整套自定义小部件。如果您计划为程序创建自定义外观和感觉,那是可以的。例如,您可以整合扁平风格的小部件,这意味着您需要修改框架提供的默认小部件的样式。自定义小部件应该继承自QWidget(或其任何后代),如下所示:

class MyWidget : public QWidget
{}; 

如果您希望小部件公开信号和插槽,您需要在类声明的开头使用Q_OBJECT宏。更新后的MyWidget类的定义如下:

class MyWidget : public QWidget
{
Q_OBJECT
public:
  // public section

signals: 
  // list of signals

public slots:
  // list of public slots
};

正如您可能已经猜到的那样,信号没有访问修饰符,而插槽可以分为公共、私有和受保护部分。正如我们之前提到的,Qt 提供了足够的小部件。为了了解这些小部件,Qt 提供了一组将小部件组合在一起的示例。如果您已安装了 Qt Creator(用于开发 Qt 应用程序的 IDE),您应该能够通过单击一次来查看示例。在 Qt Creator 中的样子如下:

配置和运行地址簿示例将给我们提供以下界面:

单击“添加”按钮将打开一个对话框,以便我们可以向地址簿添加新条目,如下所示:

添加了几个条目后,主窗口将以表格形式显示条目,如下所示:

前面的屏幕截图显示了在一个应用程序中组合在一起的各种小部件。以下是我们在 GUI 应用程序开发中经常使用的一些常见小部件:

  • QCheckBox:表示带有文本标签的复选框。

  • QDateEdit:表示可以用来输入日期的小部件。如果还要输入时间,也可以使用QDateTimeEdit

  • QLabel:文本显示。也用于显示图像。

  • QLineEdit:单行编辑框。

  • QProgressBar:渲染垂直或水平进度条。

  • QTabWidget:标签式小部件的堆栈。这是许多组织小部件中的一个。其他组织者包括QButtonGroupQGroupBoxQStackedWidget

前面的列表并非最终版本,但它给出了 Qt 的基本功能的基本概念。我们在这里使用的地址簿示例使用了许多这些小部件。QTabWidget表示一个组织小部件。它将几个小部件组合在一起。另一种组织小部件的方法是使用布局。在下一节中,我们将介绍如何将小部件组织在一起。

使用布局组合小部件

Qt 为我们提供了一个灵活和简单的平台,我们可以在其中使用布局机制来安排小部件。这有助于确保小部件内部的空间被高效地使用,并提供友好的用户体验。

让我们来看看布局管理类的基本用法。使用布局管理类的优势在于,当容器小部件更改大小时,它们会自动调整小部件的大小和位置。Qt 的布局类的另一个优势是,它们允许我们通过编写代码来安排小部件,而不是使用 UI 组合器。虽然 Qt Creator 提供了一种通过手工组合小部件的好方法(在屏幕上拖放小部件),但大多数程序员在实际编写安排小部件外观和感觉的代码时会感到更舒适。假设您也喜欢后一种方法,我们将介绍以下布局类:

  • QHBoxLayout

  • QVBoxLayout

  • QGridLayout

  • QFormLayout

所有这些类都继承自QLayout,这是几何管理的基类。QLayout是一个抽象基类,继承自QObject。它不继承自QWidget,因为它与渲染无关;相反,它负责组织应该在屏幕上呈现的小部件。您可能不需要实现自己的布局管理器,但如果需要,您应该从QLayout继承您的类,并为以下函数提供实现:

  • addItem()

  • sizeHint()

  • setGeometry()

  • itemAt()

  • takeAt()

  • minimumSize()

这里列出的类已经足够组成几乎任何复杂的小部件。更重要的是,我们可以将一个布局放入另一个布局中,从而更灵活地组织小部件。使用QHBoxLayout,我们可以从左到右水平地组织小部件,如下面的屏幕截图所示:

要实现上述组织,我们需要使用以下代码:

QWidget *window = new QWidget;
QPushButton *btn1 = new QPushButton("Leia");
QPushButton *btn2 = new QPushButton("Patrick");
QPushButton *btn3 = new QPushButton("Samo");
QPushButton *btn4 = new QPushButton("Amat");

QHBoxLayout *layout = new QHBoxLayout;
layout->addWidget(btn1);
layout->addWidget(btn2);
layout->addWidget(btn3);
layout->addWidget(btn4);

window->setLayout(layout);
window->show();

看一下我们在小部件上调用setLayout()函数的那一行。每个小部件都可以分配一个布局。布局本身没有太多作用,除非有一个容器,所以我们需要将其设置为一个作为组织小部件(在我们的情况下是按钮)容器的小部件。QHBoxLayout继承自QBoxLayout,它有另一个我们之前列出的后代——QVBoxLayout。它类似于QHBoxLayout,但是垂直地组织小部件,如下面的屏幕截图所示:

在上述代码中,我们唯一需要做的是将QHBoxLayout替换为QVBoxLayout,如下所示:

QVBoxLayout* layout = new QVBoxLayout;

GridLayout允许我们将小部件组织成网格,如下面的屏幕截图所示:

以下是相应的代码块:

QGridLayout *layout = new QGridLayout;
layout->addWidget(btn1, 0, 0);
layout->addWidget(btn2, 0, 1);
layout->addWidget(btn3, 1, 0);
layout->addWidget(btn4, 1, 1);

最后,类似于QGridLayoutQFormLayout在设计输入表单时更有帮助,因为它以两列描述的方式布置小部件。

正如我们之前提到的,我们可以将一个布局组合到另一个布局中。为此,我们需要使用addItem()函数,如下所示:

QVBoxLayout *vertical = new QVBoxLayout;
vertical->addWidget(btn1);
vertical->addWidget(btn2);

QHBoxLayout *horizontal = new QHBoxLayout;
horizontal->addWidget(btn3);
horizontal->addWidget(btn4);

vertical->addItem(horizontal);

布局管理器足够灵活,可以构建复杂的用户界面。

总结

如果您是 Qt 的新手,本章将作为对框架的一般介绍。我们涉及了 GUI 应用程序开发的基础知识,并比较了 Java 方法和 Qt 方法。使用 Qt 的最大优点之一是它支持跨平台开发。虽然 Java 也可以做到,但 Qt 通过生成与平台原生的可执行文件而更进一步。这使得使用 Qt 编写的应用程序比集成虚拟机的替代方案快得多。

我们还讨论了 Qt 的信号和槽作为对象间通信的灵活机制。通过使用这个机制,您可以在 GUI 应用程序中设计复杂的通信机制。虽然本章中我们只看了一些简单的例子,但您可以自由地尝试各种使用信号和槽的方式。我们还熟悉了常见的 Qt 小部件和布局管理机制。现在您已经有了基本的理解,可以设计甚至最复杂的 GUI 布局。这意味着您可以通过应用本章介绍的技术和小部件来实现复杂的 Qt 应用程序。在下一章中,我们将讨论一个当今流行的话题——人工智能和机器学习。

问题

  1. 为什么 Qt 不需要虚拟机?

  2. QApplication::exec()函数的作用是什么?

  3. 如何更改顶层小部件的标题?

  4. 给定m模型,如何访问第 2 行第 3 列的项目?

  5. 给定wgt小部件,如何将其宽度更改为 400,高度更改为 450?

  6. QLayout继承以创建自己的布局管理器类时,应该实现哪些函数?

  7. 如何将信号连接到槽?

进一步阅读

第三部分:C++在人工智能世界中

本节是人工智能和机器学习最新进展的概述。我们将使用 C++来处理机器学习任务,并设计基于对话框的搜索引擎。

本节包括以下章节:

  • 第十五章, 在机器学习任务中使用 C++

  • 第十六章,实现基于对话框的搜索引擎

第十五章:在机器学习任务中使用 C++

人工智能(AI)和机器学习(ML)最近变得越来越受欢迎。从简单的食品送货网站到复杂的工业机器人,AI 已被宣称为支持软件和硬件的主要特性之一。虽然大多数时候这些术语被用来使产品看起来更严肃,但一些公司正在密集地研究并将 AI 纳入其系统中。

在我们继续之前,请考虑到这一章是从 C++程序员的角度对机器学习进行温和介绍。对于更全面的文献,请参考本章末尾的书籍列表。在本章中,我们将介绍人工智能和机器学习的概念。虽然最好有数学背景,但在本章中我们几乎不使用任何数学。如果你打算扩展你的技能并深入机器学习,你必须先考虑学习数学。

除了介绍概念,本章还提供了机器学习任务的示例。我们将实施它们,并给你一个如何研究和解决更复杂任务的基本思路。

我们将在本章中涵盖以下主题:

  • 人工智能和机器学习的介绍

  • 机器学习的类别和应用

  • 为计算设计一个 C++类

  • 神经网络结构和实现

  • 回归分析和聚类

技术要求

在本章中,使用 g++编译器和-std=c++2a选项来编译示例。你可以在github.com/PacktPublishing/Expert-CPP找到本章中使用的源文件。

人工智能的介绍

人工智能的最简单定义是机器表现得像人类一样。这是机器所展示的智能。接下来讨论智能的定义。我们如何为机器定义智能,以及在什么程度上我们应该大声宣布我们正在处理一个智能机器?

如果你不熟悉用不同的测试来验证机器智能的方法,其中一种流行的方法是图灵测试。其思想是让一个询问者向两个人提问,其中一个是机器,另一个是人类。如果询问者无法清楚区分这两者,那么这台机器就应该被认为是智能的。

图灵测试是以艾伦·图灵命名的。这项测试是在他 1950 年的论文《计算机器和智能》中提出的。他建议使用模拟游戏来确定机器是否像人类一样思考。

被询问的人在墙后,以便询问者看不见他们。然后询问者向两个参与者提出几个问题。以下图表演示了询问者如何与人类和机器进行交流,但无法亲自看到他们:

当你开始深入研究人工智能领域时,智能的定义变得越来越模糊。可以以任何形式向机器提问:文字、音频、视觉等等。有许多东西可能永远不会出现在机器中,比如他们的表情。有时人们通过对方的表情来理解彼此的情绪。你无法确定机器是否会理解,甚至能够模仿他们脸上的情绪。没有人教我们在生气时看起来生气。没有人教我们有情感。它们就在那里。很难说有一天,类似的事情是否会被机器实现。

谈到人工智能时,我们大多数时候认为它是关于一个与人类类似的说话和行为的机器人。但当你试图将其作为程序员进行分解时,你会遇到许多子领域,每个子领域都需要花费大量时间来理解。许多领域有许多正在进行的任务或处于早期研究阶段。以下是一些你可能有兴趣在职业生涯中专注的人工智能子领域:

  • 计算机视觉:设计用于视觉对象识别和通过分析它们的视觉表示来理解对象的算法。人类很容易在人群中发现熟悉的面孔,但为机器实现类似的功能可能需要很长时间才能达到与人类相同的准确性。

  • 自然语言处理NLP):机器对文本进行语言分析。它在各个领域都有应用,比如机器翻译。想象一下,计算机完全理解人类书面文本,这样我们就可以告诉它该做什么,而不是花几个月学习编程语言。

  • 知识推理:这似乎是机器表现智能的明显目标。知识推理涉及让机器根据它们所拥有的信息进行推理并提供解决方案;例如,通过检查医疗状况来提供诊断。

  • 机器学习:机器用于执行任务的算法和统计模型的研究领域。机器学习算法不依赖于直接指令,而是依赖于模式和推理。也就是说,机器学习允许机器自行完成工作,无需人类参与。

让我们分别讨论前面的子领域,然后集中讨论机器学习。

计算机视觉

计算机视觉是一个广泛的研究领域,有许多正在进行的研究项目。它涉及几乎与视觉数据处理相关的一切。它在各个领域都有广泛的应用;例如,人脸识别软件处理来自城市各处摄像头的数据,以查找和确定犯罪嫌疑人,或者光学字符识别软件从包含文本的图像中生成文本。结合一些增强现实AR)技术,软件能够将图像中的文本翻译成用户熟悉的语言。

这一领域的研究正在日益取得进展。结合人工智能系统,计算机视觉是使机器感知世界的领域。对我们来说是简单的任务,但在计算机视觉方面实现起来是具有挑战性的。例如,当我们在图像中看到一个物体时,我们很容易看出它的尺寸。例如,以下图像代表了一辆自行车的前视图:

即使我们不提到它是一辆自行车,人类也不难确定它。对我们来说,图像底部中央的黑色粗线是自行车的前轮是显而易见的。很难告诉计算机理解它是一个车轮。计算机所看到的只是一堆像素,其中一些颜色相同:

除了理解自行车的车轮,它还应该推断这辆自行车必须有另一辆在图像中看不见的车轮。而且,我们可能对自行车的大致尺寸有一个猜测,而对于计算机来说,从图像中确定它是一个全面的任务。也就是说,我们视角中的简单事物可能在计算机视觉中成为一个真正的挑战。

我们建议在计算机视觉任务中使用 OpenCV 库。这是一个用 C 和 C++编写的跨平台库。OpenCV 代表了一组旨在实时计算机视觉的功能,包括但不限于人脸识别、手势识别、动作理解、运动跟踪和其他功能。

计算机视觉中的典型任务包括对象识别、识别和检测。对象识别是理解对象是前一图像中的车辆。识别是识别对象的个别实例,例如前一图像中自行车的车轮。对象检测任务可能包括在自行车图像中找到损坏的区域。所有这些任务结合机器学习算法可能构成一个全面的软件,它能够以接近人类方式理解周围环境。

NLP

另一个有趣的研究领域是自然语言处理。自然语言处理致力于使计算机理解人类语言。更一般化的方法是自动语音识别和自然语言理解;这是虚拟助手的关键特性。今天,和手机交谈并要求它在网络上搜索某些内容已经不再是魔术。整个过程都由语音和文本分析中的复杂算法驱动。以下图表显示了发生在对话代理背后的高层视图:

许多语言处理任务与网络相关。搜索引擎处理用户输入以在网络上数百万文档中搜索是自然语言处理的顶级应用之一。在下一章中,我们将深入探讨搜索引擎的设计和实现。搜索引擎设计的主要关注点之一是处理文本数据。搜索引擎不能只存储所有网站并对用户的查询返回第一个匹配项。自然语言处理中有许多复杂的任务。假设我们正在设计一个程序,该程序接收文本文档并应输出文档中的句子。识别句子的开始和结束是其中的一个复杂任务。以下句子是一个简单的例子:

I love studying C++. It's hard, but interesting. 

程序将输出两个句子:

I love studying C++.
It's hard, but interesting.

在编码任务方面,我们只需搜索句子末尾的 .(句号)字符,并确保第一个单词以大写字母开头。如果其中一句话的形式如下,程序会如何行为?

I love studying C++!

由于句子末尾有感叹号,我们应该重新审视我们的程序,添加另一个规则来识别句子的结束。如果一句话是这样结束的呢?

It's hard, but interesting...

逐一引入更多规则和定义,以实现一个完全功能的句子提取器。在解决自然语言处理任务时,利用机器学习将我们引向更智能的方向。

另一个与语言相关的任务是机器翻译,它可以自动将一种语言的文档翻译成另一种语言。此外,需要注意的是,构建一个全面的自然语言处理系统将有益于其他研究领域,比如知识推理。

知识推理

知识推理是使计算机以类似于人类的方式思考和推理。想象一下和机器进行对话,开始如下:

[Human] Hello
[Machine] Hello

我们可以编程让机器回答特定问题或理解用户输入的复杂文本,但要让机器基于以前的经验进行推理就要困难得多。例如,以下推理是研究的目标之一:

[Human] I was walking yesterday and it was raining.
[Machine] Nice.
[Human] I should dress warmer next time.
[Machine] OK.
[Human] I think I have a temperature.
[Machine] Did you caught a cold yesterday?
[Human] I guess so.

虽然似乎很容易发现感冒和雨之间的联系,但让程序推断这一点需要付出很大的努力。它必须将雨与感冒联系起来,并将有温度与感冒联系起来。它还应该记住先前的输入,以便在智能地保持对话中使用它。

前面提到的所有研究领域对于程序员来说都是令人兴奋的深入领域。最后,机器学习通常是设计算法和模型的基础,用于每个特定应用领域。

机器学习

机器学习使我们达到了一个全新的水平,让机器执行任务的方式与人类一样,甚至可能更好。与我们之前介绍的领域相比,机器学习的目标是构建能够在没有具体指令的情况下执行任务的系统。在发明人工智能机器的过程中,我们应该更加关注人类智慧。当一个孩子出生时,并不表现出智能行为,而是开始慢慢熟悉周围的世界。没有记录表明一个月大的婴儿解决微分方程或创作音乐。就像孩子学习和发现世界一样,机器学习关注的是构建直接执行任务的基础模型,而不是直接执行任务,而是学会如何执行任务。这是设置系统执行预定义指令和让系统自行解决问题之间的根本区别。

当一个孩子开始行走、拿东西、说话和提问时,他们正在逐步获取关于世界的知识。他或她拿起一本书,尝试它的味道,不久之后就不再把书当作食物来咀嚼。几年过去了,孩子现在打开书的页面,寻找其中的图像和构成文本的小图形。再过几年,孩子开始阅读它们。多年过去了,大脑变得越来越复杂,它的神经元之间建立了越来越多的连接。孩子变成了一个聪明的人类。

想象一下一个系统,其中有一些神奇的算法和模型。在输入了大量数据之后,它将能够越来越理解,就像孩子通过处理视觉数据(通过他们的眼睛观察)、气味或味道的输入数据来了解世界一样。后来,通过提出问题的方式,孩子开始理解单词,并将这些单词与现实世界中的对象,甚至是无形的概念联系起来。机器学习系统几乎以相同的方式行事。它们处理输入数据并产生一些输出,符合我们期望的结果。下图说明了这个想法:

现在让我们深入了解机器学习。和往常一样,理解新事物的最好方法是先尝试实现它。

理解机器学习

机器学习是一个庞大的研究领域,正在快速扩展。要理解机器学习,我们首先应该了解学习的本质。思考和推理是使我们——人类——特殊的关键概念。机器学习的核心是使系统学习并利用知识来执行任务。你可能还记得学习编程的第一步。我们相信那并不容易。你必须学习新概念,构建抽象,并让你的大脑理解程序执行的底层原理。之后,你需要使用那些在入门指南中描述的关键字、指令、条件语句、函数、类等小构件来构建复杂系统。

然而,机器学习程序与我们通常创建的程序不同。看一下下面的代码:

int calculate()
{
  int a{14};
  int b{27};
  int c{a + b};
  return c;
}

简单的前述程序按照我们的指示执行。它包含了几个简单的指令,导致变量c表示ab的和。我们可以修改函数以接受用户输入,如下所示:

int calculate(int a, int b)
{
  int c{a + b};
  return c;
}

前述函数永远不会获得任何智能。无论我们调用calculate()函数多少次都无所谓。无论我们提供什么数字作为输入都无所谓。该函数代表了一系列指令。我们甚至可以说是一系列硬编码的指令。也就是说,该函数永远不会修改自己的指令以根据输入的不同行为。然而,我们可以引入一些逻辑;比如说,我们让它在收到负数时每次返回 0:

int calculate(int a, int b)
{
  if (a < 0 && b < 0) {
    return 0;
  }
  int c{a + b};
  return c;
}

条件语句引入了函数基于其输入所做决定的最简单形式。我们可以添加更多的条件语句,使函数增长并具有复杂的实现。然而,无论添加多少条件语句,它都不会变得更聪明,因为它不是代码自己想出来的。这就是我们在处理程序时所面临的限制。它们不会思考;它们会按照我们编程的方式行事。我们决定它们必须如何行事。它们总是服从。嗯,只要我们没有引入错误。

现在,想象一下 ML 算法在行动。假设calculate()函数中有一些魔法,以便它根据输入返回一个值。假设它具有以下形式:

int calculate(int a, int b)
{
  // some magic
  // return value 
}

现在,假设我们正在调用calculate()并将24作为参数传递,希望它将计算它们的总和并返回6。此外,想象一下,我们可以以某种方式告诉它结果是否符合我们的预期。过了一会儿,函数以一种方式行事,以便它了解如何使用这些输入值并返回它们的总和。我们正在构建的以下类代表了我们对理解 ML 的第一步。

设计一个学习的算法

以下类代表一个计算机。它包括四种算术运算,并期望我们提供如何计算输入值的示例:

struct Example
{
  int input1;
  int input 2;
  int output;
};

class CalculationMachine
{
public:
  using Examples = std::vector<Example>;
  // pass calculation examples through the setExamples()
 void setExamples(const Examples& examples);

  // the main function of interest
  // returns the result of the calculation
 int calculate(int a, int b);

private:
  // this function pointer will point to 
  // one of the arithmetic functions below
 int (*fptr_)(int, int) = nullptr;

private:
  // set of arithmetic functions
  static int sum(int, int);
  static int subtract(int, int);
  static int multiply(int, int);
  static int divide(int, int);
};

在使用calculate()函数之前,我们应该为setExamples()函数提供一个示例列表。以下是我们提供给CalculationMachine的示例的示例:

3 4 7
2 2 4
5 5 10
4 5 9

每行中的前两个数字代表输入参数;第三个数字是操作的结果。setExamples()函数是CalculationMachine学习如何使用正确的算术函数。我们可以从前面的例子中猜出正在发生的事情,同样CalculationMachine试图找到最适合其操作的方法。它通过示例并定义在调用calculate()时应该使用哪个函数。实现方式类似于以下内容:

void CalculationMachine::setExamples(const Examples& examples)
{
  int sum_count{0};
  int sub_count{0};
  int mul_count{0};
  int div_count{0};
  for (const auto& example : Examples) {
 if (CalculationMachine.sum(example.input1, example.input2) == example.output) {
 ++sum_count;
 }
 if (CalculationMachine.subtract(example.input1, example.input2) == example.output) {
 ++sub_count;
 }
    // the same for multiply() and divide()
  }

  // the function that has the maximum number of correct output results
  // becomes the main function for called by calculate()
  // fptr_ is assigned the winner arithmetic function
}

从前面的例子中可以看出,该函数调用所有算术函数并将它们的返回值与示例输出进行比较。每次结果正确时,它会增加特定函数的正确答案计数。最后,具有最多正确答案的函数被分配给fptr_,该函数由calculate()函数使用如下:

int CalculationMachine::calculate(int a, int b)
{
  // fptr_ points to the sum() function
 return fptr_(a, b);
}

我们设计了一个简单的学习算法。setExamples()函数可以被重命名为setDataSet()trainWithExamples()或类似的名称。CalculationMachine的例子的重点在于我们定义了一个模型和算法来处理它,并且我们可以称之为 ML。它从数据中学习。或者,更好的是,它从经验中学习。我们提供给CalculationMachine的示例向量中的每个记录都可以被视为一种经验。我们说计算的性能随着经验的增加而提高。也就是说,我们提供的示例越多,它在选择正确的函数执行任务时就越有信心。而任务就是根据两个输入参数计算值。学习过程本身不是任务。学习是导致执行任务的原因。任务通常被描述为系统应该如何处理一个示例,其中一个示例是一组特征。尽管在 ML 术语中,一个示例被表示为一个向量(数学),其中每个条目都是另一个特征,但向量数据结构的选择只是一个巧合。作为系统训练的基本原则之一,ML 算法可以被分类为监督或无监督。让我们检查它们的区别,然后建立 ML 系统的各种应用。

ML 的分类

以下图表说明了 ML 的分类:

ML 算法的分类取决于它们在学习过程中的经验类型。我们通常称示例的集合为数据集。有些书籍也使用术语数据点。数据集基本上是代表对目标系统有用的任何数据的集合。它可能包括一段时间内的天气测量,某家公司或多家公司的股票价格列表,或任何其他数据集。虽然数据集可能是未经处理的或所谓的原始数据,但也有数据集包含每个经验的附加信息。在CalculationMachine的示例中,我们使用了一个原始数据集,尽管我们已经编程系统识别前两个值是操作的操作数,第三个值是其结果。如前所述,我们将 ML 算法分类为监督和无监督。

监督学习算法从带标签的数据集中学习;也就是说,每条记录都包含描述数据的附加信息。CalulcationMachine是监督学习算法的一个例子。监督学习也被称为带教练训练。教练使用数据集来教授系统。

监督学习算法将能够在从提供的经验中学习后标记新的未知数据。下图最好描述了它:

监督学习算法的一个应用示例是电子邮件应用中的垃圾邮件过滤器。用户将电子邮件标记为垃圾邮件或非垃圾邮件,然后系统试图在新收到的电子邮件中找到模式以检测潜在的垃圾邮件。

CalculationMachine的示例是监督学习的另一个案例。我们用以下数据集来喂它:

3 4 7
2 2 4
5 5 10
4 5 9

我们编程CalculationMachine以读取前两个数字作为输入参数,第三个数字作为应用于输入的函数产生的输出。这样,我们提供了关于系统应该得到什么结果的必要信息。

无监督学习算法更加复杂——它们处理包含大量特征的数据集,然后试图找到特征的有用属性。无监督学习算法大多是独立定义数据集中的内容。就智能而言,无监督学习方法更符合智能生物的描述,而不是监督学习算法。相比之下,监督学习算法试图预测哪些输入值映射到输出值,而无监督算法执行多个操作来发现数据集中的模式。根据前面图表中的关联,下图描述了一个无监督学习算法:

无监督学习算法的应用示例包括推荐系统。我们将在下一章中讨论一个例子,设计一个网络搜索引擎。推荐系统分析用户活动以推荐类似的数据,例如电影推荐。

从前面的插图中可以看出,还有强化学习。这是一类从错误中学习的算法。学习系统和其经验之间存在反馈循环,因此强化学习算法与环境进行交互。它可能在开始时犯很多错误,经过处理反馈后,纠正自身以改进算法。学习过程成为任务执行的一部分。想象一下,CalculationMachine只接收输入数字而不是计算结果。对于每个经验,它将通过应用算术运算之一产生结果,然后接收反馈。假设它减去数字,然后根据反馈修改自身以计算总和。

ML 的应用

了解机器学习的分类有助于更好地将其应用于各种任务。有许多任务可以通过机器学习来解决。我们已经提到分类是机器学习算法解决的任务之一。基本上,分类是过滤和排序输入以指定输入所属的类别的过程。用机器学习解决分类通常意味着它产生一个将输入映射到特定输出的函数。输出类别的概率分布也是一种分类任务。分类任务的最佳示例之一是对象识别。输入是一组像素值(换句话说,是一幅图像),输出是标识图像中物体的值。想象一下一个能够识别不同种类的工具并在命令下将它们交给工人的机器人;也就是说,一个在车库里工作的机械师有一个能够识别螺丝刀并在命令下将其带来的助手机器人。

更具挑战性的是具有缺失输入的分类。在前面的例子中,这类似于要求机器人带来螺丝钉的东西。当一些输入缺失时,学习算法必须使用多个函数来实现成功的结果。例如,助手机器人可能首先带来钳子,然后找到螺丝刀作为正确的解决方案。

与分类类似的是回归,在这种情况下,系统被要求根据提供的一些输入来预测一个数值。不同之处在于输出的格式。回归任务的一个例子是预测股票未来价格。这些以及其他机器学习的应用使其迅速成为一个研究领域。学习算法不仅仅是一系列条件语句,尽管一开始可能感觉是这样。它们是基于更全面的构造,模仿人脑神经元及其连接而建模的。这将我们带到下一节,即人工神经网络ANNs)的研究。

神经网络

神经网络被设计用于识别模式。它们是模仿人脑的;更具体地说,我们谈论的是大脑神经元及其人工对应物——人工神经元。人类大脑中的神经元在下图中有所说明:

神经元通过突触与其他神经元进行通信。神经元的基本功能是处理部分数据并根据该数据产生信号。在编程术语中,神经元接受一组输入并产生输出。

这就是为什么下面的图表清楚地说明了为什么人工神经元类似于人脑神经元结构:

ANN 是自然神经网络的一个简化模型。它代表了一组相互连接的节点,每个节点代表一个神经元模型。每个节点连接可以传输类似于生物大脑神经元中突触的信号。神经网络是一组帮助进行聚类和分类的算法。正如您从前面的图表中看到的,神经网络由三层组成:

  • 输入层

  • 隐藏层

  • 输出层

输入层和输出层不言自明;初始输入是外部数据,例如图像、音频或文本文件。输出是任务的完成,例如对文本内容的分类或图像中识别的对象。隐藏层是使网络产生合理结果的关键。输入到输出的转换经过隐藏层,隐藏层进行了必要的分析、处理和修改以产生输出。

考虑前面的图表;它显示一个神经元可以有多个输入和输出连接。通常,每个连接都有一个权重,指定连接的重要性。前面图表中的分层告诉我们,每一层的神经元都连接到紧邻的前一层和后一层的神经元。您应该注意,输入和输出层之间可能有几个隐藏层。虽然输入和输出层的主要目的是读取外部数据并返回计算(或推断)的输出,但隐藏层的目的是通过学习来适应。学习还涉及调整连接和权重,以提高输出的准确性。这就是机器学习发挥作用的地方。因此,如果我们创建一个复杂的神经网络,其中包含几个隐藏层,准备学习和改进,我们就得到了一个人工智能系统。例如,让我们先来研究聚类问题,然后再进行回归分析。

聚类

聚类涉及将一组对象分组以将它们分布在相似对象的组中。也称为聚类分析,它是一组旨在将相似对象分组在一起的技术和算法。最简单的说明是将一组有颜色的对象分成不同的组,每组由相同颜色的对象组成,如下所示:

虽然我们在本章讨论 AI 任务,但我们建议您首先尝试用到目前为止所掌握的知识库来解决问题。也就是说,让我们想一想如何通过相似性对对象进行分类。首先,我们应该对对象的外观有一个基本的概念。在前面的例子中,一个对象的表示可能是这样的:形状颜色,尺寸(2D 对象的宽度高度),等等。不深入探讨,基本对象表示可能是这样的:

struct Object
{
  int color;
  int shape;
  int width;
  int height;
};

让我们考虑颜色和形状的值在一定范围内的事实。我们可以使用枚举来提高可读性。聚类分析涉及分析对象以某种方式对其进行分类。首先想到的是有一个接受对象列表的函数。让我们试着定义一个:

using objects_list = std::vector<Object>;
using categorized_table = std::unordered_map<int, objects_list>;
categorized_table clusterize(const objects_list& objects)
{
  // categorization logic 
}

想一想实现细节。我们需要定义聚类点。它可能是颜色,也可能是形状的类型。具有挑战性的是,它可能是未知的。也就是说,为了以防万一,我们对每个属性的对象进行分类如下:

categorized_table clusterize(const objects_list& objects)
{
  categorized_table result;
  for (const auto& obj : objects) {
    result[obj.color].push_back(obj);
    result[obj.shape].push_back(obj);
  }
  return result;
}

具有相似颜色或形状的对象被分组在一个哈希表中。虽然前面的代码相当简单,但它包含了按某种相似性标准对对象进行分组的基本思想。在前面的例子中,我们更可能将其描述为硬聚类。一个对象要么属于一个簇,要么不属于。相反,软聚类(也称为模糊聚类)描述了对象对某个簇的归属程度。

例如,形状属性的对象相似性可以由应用于对象的函数的结果来定义。也就是说,如果对象 A 的形状是正方形,对象 B 的形状是菱形,那么函数定义了对象 A 和对象 B 是否具有相似的形状。这意味着我们应该更新前面例子中的逻辑,以便根据几个值来比较对象并定义它们的形状为一组。通过进一步发展这个想法,我们迟早会到达不同的聚类策略和算法,比如 K 均值聚类。

回归分析

回归分析涉及找出一个值对另一个值的偏差。理解回归分析的最简单方法是通过数学函数的图表。您可能还记得函数 f(x) = y 的图表:

对于每个x的值,函数都会得出一个固定的y值。回归分析与前面的图表有些相似,因为它涉及查找变量之间的关系。更具体地说,它估计因变量和几个自变量之间的关系。因变量也被称为结果,而自变量也被称为特征。特征的数量可能是一个。

最常见的回归分析形式是线性回归。它看起来与前面的图表相似。以下是一个例子,表示测试程序所花费的时间与发布版本中发现的错误数量之间的关系:

有两种类型的回归:负回归是前面图表中显示的一种,因为自变量的值减少而因变量增加。相反,正回归具有自变量增加的值。

机器学习中的回归分析被用作一种预测的方式。你可能会开发一个程序,根据自变量的数值来预测结果。正如你到目前为止已经猜到的那样,机器学习是一个涵盖广泛主题的大领域。尽管程序员倾向于尽可能少地使用数学,但在机器学习中却是不可能的。你仍然需要掌握一些数学知识,以充分利用机器学习。回归分析在很大程度上依赖于数学统计。

C++和机器学习

现在已经不再是秘密,机器学习更多地涉及数学而不是编程。计算机科学的根源在数学中,在早期,计算机科学家首先是数学家。你可能熟悉一些杰出的科学家,包括艾伦·图灵、约翰·冯·诺伊曼、克劳德·香农、诺伯特·维纳、尼古劳斯·维尔特、唐纳德·克努斯等。他们都是数学家,对技术有着特殊的热爱。在其发展过程中,计算机编程成为了一个更加友好的领域,对新手更加友好。在过去的二三十年里,计算机程序员不再被迫在开发有用的程序之前学习数学。编程语言演变成了越来越高级的工具,几乎每个人都可以编写代码。

有很多框架可以让程序员的工作更轻松。现在只需要几周的时间就可以掌握一些框架或高级编程语言,并创建一个新的程序。然而,程序往往会重复自己。现在构建一些东西并不那么困难,因为有很多模式和最佳实践可以帮助我们。数学的作用已经被推到了后台,越来越多的人成为程序员,甚至根本不需要使用数学。这实际上并不是一个问题;这更像是技术发展的自然流动。最终,技术的目标是让人类生活更加舒适。工程师也是如此。然而,在 20 世纪 60 年代,NASA 的工程师使用计算机进行计算,但那时的计算机并非我们今天所知的计算机。那些都是真正的人类,拥有一种特殊的专业称为“计算机”,尽管成为计算机意味着在数学上非常出色,比其他人更快地解决方程。

现在我们是计算机科学的新时代的一部分,数学再次回归。机器学习工程师现在使用数学的方式,就像数学家在 20 世纪 70 年代或 80 年代使用编程语言一样。现在仅仅知道一种编程语言或一个框架已经不够了,要设计一个新的算法或将机器学习应用到你的应用程序中,你还应该至少在一些数学子领域表现出色,比如线性代数、统计学和概率论。

几乎相同的逻辑也适用于 C++。现代语言提供了广泛的功能,而 C++开发人员仍在努力设计具有手动内存管理的无缺陷程序。如果您对 ML 领域进行一些快速研究,您会发现大多数库或示例都在使用 Python。起初,这可能被视为在 ML 任务中使用的默认语言。然而,ML 工程师开始触及一个新的进化阈值——性能。这个阈值并不新鲜;许多工具仍在需要性能的部分使用 C++。游戏开发、操作系统、关键任务系统以及许多其他基本领域都在使用 C++(和 C)作为事实标准。现在是 C++征服新领域的时候了。我们对读者的最好建议是学习 ML 和 C++,因为将 C++纳入其中对于 ML 工程师来说慢慢变得至关重要,以获得最佳性能。

总结

我们介绍了 ML 及其类别和应用。这是一个快速增长的研究领域,在构建智能系统方面有着众多应用。我们将 ML 分类为监督、无监督和强化学习算法。每个类别都在解决分类、聚类、回归和机器翻译等任务中有应用。

我们实现了一个简单的学习算法,它根据提供的经验定义了一个计算函数。我们称之为我们用来训练系统的数据集。使用数据集(称为经验)进行训练是 ML 系统中的关键属性之一。

最后,我们介绍并讨论了应用于识别模式的人工神经网络。ML 和神经网络在解决任务时息息相关。本章为您提供了领域的必要介绍以及几个任务的示例,以便您花一些时间深入了解该主题。这将帮助您对 AI 和 ML 有一个大致的了解,因为在实际应用开发中,对工程师来说这变得越来越必要。在下一章中,我们将学习如何实现基于对话的搜索引擎。

问题

  1. 什么是 ML?

  2. 监督学习和无监督学习算法之间有什么区别?

  3. 给出一些 ML 应用的例子。

  4. 你会如何修改CalculationMachine类以在用不同的经验集训练后改变其行为?

  5. 神经网络的目的是什么?

进一步阅读

第十六章:实现基于对话框的搜索引擎

在这本书中,我们已经走了这么远!我们已经学习了 C++应用程序开发的基础知识,并讨论了构建和设计面向全球的应用程序。我们还深入研究了数据结构和算法,这是高效编程的核心。现在是时候利用所有这些技能来设计复杂的软件,比如搜索引擎了。

随着互联网的普及,搜索引擎已成为最受欢迎的产品。大多数用户从搜索引擎开始他们的网络之旅。各种网络搜索服务,如 Google、Baidu、Yandex 等,每天接收大量的流量,处理数万亿的请求。搜索引擎在不到一秒的时间内处理每个请求。尽管它们维护了成千上万的服务器来处理负载,但它们高效处理的核心是数据结构和算法、数据架构策略和缓存。

设计高效搜索系统的问题不仅出现在网络搜索引擎中。本地数据库、客户关系管理CRM)系统、会计软件等都需要强大的搜索功能。在本章中,我们将了解搜索引擎的基础知识,并讨论用于构建快速搜索引擎的算法和数据结构。您将了解网络搜索引擎的一般工作原理,并了解需要高处理能力的项目中使用的新数据结构。您还将建立信心,去构建自己的搜索引擎,与现有的搜索引擎竞争。

在本章中,我们将涵盖以下主题:

  • 理解搜索引擎的结构

  • 理解和设计用于在搜索引擎中将关键词映射到文档的倒排索引

  • 为搜索平台的用户设计和构建推荐引擎

  • 使用知识图谱设计基于对话框的搜索引擎

技术要求

本章中使用g++编译器和-std=c++2a选项来编译示例。您可以在github.com/PacktPublishing/Expert-CPP找到本章中使用的源文件。

理解搜索引擎的结构

想象一下世界上数十亿的网页。在搜索引擎界面中输入一个单词或短语,不到一秒钟就会返回一个长长的结果列表。搜索引擎如此快速地处理如此多的网页,这是奇迹般的。它是如何如此快速地找到正确的文档的呢?为了回答这个问题,我们将做程序员可以做的最明智的事情,设计我们自己的引擎。

以下图表显示了搜索引擎背后的基本思想:

用户使用搜索引擎的用户界面输入单词。搜索引擎扫描所有文档,对其进行过滤,按相关性对其进行排序,并尽快向用户做出响应。我们主要关注的是网络搜索引擎的实现。寻找某物需要在数十亿的文档中进行搜索。

让我们试着想出一种方法来从数十亿的文档中找到短语“Hello, world!”(为了简洁起见,我们将网页称为文档)。扫描每个文档以查找该短语将需要大量的时间。如果我们认为每个文档至少有 500 个单词,搜索特定单词或单词组合将需要很长时间。更实际的方法是事先扫描所有文档。这个扫描过程包括在文档中建立每个单词出现的索引,并将信息存储在数据库中,这也被称为文档索引。当用户输入一个短语时,搜索引擎将在其数据库中查找这些单词,并返回满足查询的文档链接。

在搜索文档之前,引擎验证用户输入并不会有害。用户在短语中出现拼写错误并不罕见。除了拼写错误,如果引擎自动完成单词和短语,用户体验会更好。例如,当用户输入“hello”时,引擎可能建议搜索短语“Hello, world!”。一些搜索引擎跟踪用户,存储有关其最近搜索、请求设备的详细信息等信息。例如,如果用户搜索“如何重新启动计算机”,如果搜索引擎知道用户的操作系统,结果会更好。如果是 Linux 发行版,搜索引擎将对搜索结果进行排序,使描述如何重新启动基于 Linux 的计算机的文档首先出现。

我们还应该注意定期出现在网络上的新文档。后台作业可能会持续分析网络以查找新内容。我们称这个作业为爬虫,因为它爬行网络并索引文档。爬虫下载文档以解析其内容并构建索引。已经索引的文档可能会得到更新,或者更糟的是被删除。因此,另一个后台作业应定期更新现有文档。您可能会遇到爬行网络以解析文档的任务术语蜘蛛

下面更新的图表更详细地说明了搜索引擎的结构:

搜索具有广泛的应用。想象一下最简单的搜索形式——在数组中查找一个单词:

using words = std::vector<std::string>;
words list = get_list_of_words(); // suppose the function is implemented

auto find_in_words(const std::string& term)
{
  return std::find(list.begin(), list.end(), term);
}

尽管前面的例子适用于最简单的搜索引擎,但真正的问题是设计一个可扩展的搜索引擎。您不希望通过搜索字符串数组来处理用户请求。相反,您应该努力实现一个能够搜索数百万个文档的可扩展搜索引擎。这需要大量的思考和设计,因为一切都很重要,从正确选择的数据结构到高效的数据处理算法。现在让我们更详细地讨论搜索引擎的组件。我们将整合从之前章节学到的所有技能来设计一个好的搜索引擎。

提供方便的用户界面

在构建提供令人惊叹的用户体验的细粒度用户界面上投入时间和资源至关重要。关键在于简单。界面越简单,使用起来就越好。我们将以市场主导地位的 Google 为例。它在页面中央有一个简单的输入字段。用户在字段中输入请求,引擎会建议一些短语:

我们不认为用户是懒惰的人,但提供建议列表是有帮助的,因为有时用户不知道他们正在寻找的确切术语。让我们集中精力在建议列表的结构和实施上。毕竟,我们对解决问题感兴趣,而不是设计漂亮的用户界面。我们不会在本章讨论用户界面设计;更好的是集中在搜索引擎的后端。然而,在继续之前,有一件事情我们应该考虑。我们正在实现的搜索引擎是基于对话的。用户查询引擎并可以从几个答案中选择以缩小结果列表。例如,假设用户查询“一台电脑”,搜索引擎会问“台式机还是笔记本?”。这会大大减少搜索结果并为用户提供更好的结果。我们将使用决策树来实现这一点。但在此之前,让我们了解搜索引擎的复杂性。

首先,存在输入标记化的问题。这涉及文档解析和搜索短语分析。您可能构建了一个很好的查询解析器,但由于用户在查询中犯了一个错误,它就会出现问题。让我们来看看处理模糊查询的一些方法。

处理查询中的拼写错误

用户在输入时犯错并非罕见。虽然这似乎是一件简单的事情,但对于搜索引擎设计者来说可能会是一个真正的问题。如果用户输入了 helo worl 而不是 hello world,那么在数百万份文档中进行搜索可能会产生意外的错误结果。你可能熟悉搜索引擎提供的自动建议。例如,当我们输入错误时,谷歌搜索界面是这样的:

注意屏幕截图底部的两行。其中一行显示了 hello world 的搜索结果,这表明搜索引擎假定用户输入的查询存在拼写错误,并主动显示了正确查询的结果。然而,仍然有可能用户确实想要搜索他们输入的确切单词。因此,用户体验提供了下一行,即搜索 helo worl 的结果。

因此,在构建搜索引擎时,我们需要解决几个问题,首先是用户请求。首先,我们需要为用户提供一个方便的界面来输入他们的文本。界面还应该与用户进行交互,以提供更好的结果。这包括根据部分输入的单词提供建议,就像之前讨论的那样。使搜索引擎与用户进行交互是用户界面的另一个改进,我们将在本章中讨论。

接下来是检查拼写错误或不完整单词,这并不是一件容易的事。保留字典中所有单词的列表并比较用户输入的单词可能需要一段时间。为了解决这个问题,必须使用特定的数据结构和算法。例如,在检查用户查询中的拼写错误时,找到单词之间的Levenshtein 距离可能会有所帮助。Levenshtein 距离是一个单词需要添加、删除或替换的字符数,使其等于另一个单词。例如,worldworl之间的 Levenshtein 距离是 1,因为从world中删除字母d或在worl中添加d可以使这些单词相等。codingsitting之间的距离是 4,因为以下四次编辑将一个单词变成另一个单词:

  1. coding -> codting(在中间插入t

  2. codting -> cotting(将t替换为d

  3. cotting -> citting(将o替换为i

  4. citting -> sitting(将c替换为s

现在,想象一下,如果我们要将每个用户输入与成千上万个单词进行比较以找到最接近的单词,处理将需要多长时间。另一种方法是使用一个大的trie(数据结构)来预先发现可能的拼写错误。Trie 是一个有序搜索树,其中键是字符串。看一下下面表示 trie 的图表:

每条路径代表一个有效的单词。例如,a 节点指向 n 和 r 节点。注意 n 后面的#。它告诉我们,直到这个节点的路径代表一个单词,an。然而,它继续指向 d,然后是另一个#,意味着直到这个节点的路径代表另一个单词,and。对于 trie 的其余部分也适用相同的逻辑。例如,想象一下world的 trie 部分:

当引擎遇到worl时,它会通过前面的 trie。w 没问题,o 也没问题,直到单词的倒数第二个字符 l 之前的所有字符都没问题。在前面的图表中,l 后面没有终端节点,只有 d。这意味着我们可以确定没有worl这样的单词;所以它可能是world。为了提供良好的建议和检查拼写错误,我们应该有用户语言的完整词典。当你计划支持多种语言时,情况会变得更加困难。然而,尽管收集和存储词典可以说是一项简单的任务,更困难的任务是收集所有网页文档并相应地存储以进行快速搜索。搜索引擎收集和解析网站以构建搜索引擎数据库的工具、程序或模块(如前所述)称为爬虫。在更深入地研究我们将如何存储这些网页之前,让我们快速看一下爬虫的功能。

爬取网站

每次用户输入查询时搜索数百万个文档是不现实的。想象一下,当用户在系统的 UI 上点击搜索按钮后,搜索引擎解析网站以搜索用户查询。这将永远无法完成。搜索引擎从网站发送的每个请求都需要一些时间。即使时间少于一毫秒(0.001 秒),在用户等待查询完成的同时分析和解析所有网站将需要很长时间。假设访问和搜索一个网站大约需要 0.5 毫秒(即使如此,这也是不合理的快)。这意味着搜索 100 万个网站将需要大约 8 分钟。现在想象一下你打开谷歌搜索并进行查询,你会等待 8 分钟吗?

正确的方法是将所有信息高效地存储在数据库中,以便搜索引擎快速访问。爬虫下载网页并将它们存储为临时文档,直到解析和索引完成。复杂的爬虫可能还会解析文档,以便更方便地存储。重要的一点是,下载网页不是一次性的行为。网页的内容可能会更新。此外,在此期间可能会出现新页面。因此,搜索引擎必须保持其数据库的最新状态。为了实现这一点,它安排爬虫定期下载页面。智能的爬虫可能会在将内容传递给索引器之前比较内容的差异。

通常,爬虫作为多线程应用程序运行。开发人员应该尽可能快地进行爬取,因为保持数十亿个文档的最新状态并不是一件容易的事。正如我们已经提到的,搜索引擎不直接搜索文档。它在所谓的索引文件中进行搜索。虽然爬取是一个有趣的编码任务,但在本章中我们将主要集中在索引上。下一节介绍搜索引擎中的索引功能。

索引文档

搜索引擎的关键功能是索引。以下图表显示了爬虫下载的文档如何被处理以构建索引文件:

在前面的图表中,索引显示为倒排索引。正如你所看到的,用户查询被引导到倒排索引。虽然在本章中我们在索引倒排索引这两个术语之间交替使用,但倒排索引是更准确的名称。首先,让我们看看搜索引擎的索引是什么。索引文档的整个目的是提供快速的搜索功能。其思想很简单:每次爬虫下载文档时,搜索引擎会处理其内容,将其分成指向该文档的单词。这个过程称为标记化。假设我们从维基百科下载了一个包含以下文本的文档(为了简洁起见,我们只列出了段落的一部分作为示例):

In 1979, Bjarne Stroustrup, a Danish computer scientist, began work on "C with Classes", the predecessor to C++. The motivation for creating a new language originated from Stroustrup's experience in programming for his PhD thesis. Stroustrup found that Simula had features that were very helpful for large software development...

搜索引擎将前面的文档分成单独的单词,如下所示(出于简洁起见,这里只显示了前几个单词):

In
1979
Bjarne
Stroustrup
a
Danish
computer
scientist
began
work
...

将文档分成单词后,引擎为文档中的每个单词分配一个标识符ID)。假设前面文档的 ID 是 1,下表显示了单词指向(出现在)ID 为 1 的文档:

In 1
1979 1
Bjarne 1
Stroustrup 1
a 1
Danish 1
computer 1
scientist 1
...

可能有几个文档包含相同的单词,因此前表实际上可能看起来更像以下表:

In 1, 4, 14, 22
1979 1, 99, 455
Bjarne 1, 202, 1314
Stroustrup 1, 1314
a 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ...
Danish 1, 99, 102, 103
计算机 1, 4, 5, 6, 24, 38, ...
scientist 1, 38, 101, 3958, ...

下表表示了倒排索引。它将单词与爬虫下载的文档的 ID 进行了映射。现在,当用户通过键入computer查询引擎时,结果是基于从索引中检索到的 ID 生成的,即在前面的示例中是 1, 4, 5, 6, 24, 38, ...。索引还有助于找到更复杂查询的结果。例如,计算机科学家匹配以下文档:

computer 1, 4, 5, 6, 24, 38, ...
scientist 1, 38, 101, 3958, ...

为了回应用户并提供包含两个术语的文档,我们应该找到引用文档的交集(参见前表中的粗体数字),例如,1 和 38。

请注意,用户查询在与索引匹配之前也会被标记化。标记化通常涉及单词规范化。如果没有规范化,计算机科学家查询将不会返回任何结果(请注意查询中的大写字母)。让我们更多地了解一下这个。

标记化文档

你可能还记得第一章中的标记化概念,构建 C++应用程序,我们讨论了编译器如何通过将源文件标记化为更小的、不可分割的单元(称为标记)来解析源文件。搜索引擎以类似的方式解析和标记化文档。

我们不会详细讨论这个,但你应该考虑文档是以一种方式处理的,这意味着标记(在搜索引擎上下文中具有意义的不可分割的术语)是规范化的。例如,我们正在查看的所有单词都是小写的。因此,索引表应该如下所示:

in 1, 4, 14, 22
1979 1, 99, 455
bjarne 1, 202, 1314
stroustrup 1, 1314
a 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ...
danish 1, 99, 102, 103
computer 1, 4, 5, 6, 24, 38, ...
scientist 1, 38, 101, 3958, ...

作为 C++程序员,看到 bjarne 或 stroustrup 变成小写可能会让您感到不舒服。然而,由于我们正在将用户输入与倒排索引键进行匹配,我们应该考虑用户输入可能不具有我们期望的形式。因此,我们需要对用户输入应用相同的规则,以使其与倒排索引的形式匹配。

接下来,注意 a。毫不夸张地说,这是每个文档中都出现的一个词。其他类似的例子是theanin等词。我们称它们为停用词;它们在实际处理之前被过滤掉。通常,搜索引擎会忽略它们,因此倒排索引更新为以下形式:

1979 1, 99, 455
bjarne 1, 202, 1314
stroustrup 1, 1314
danish 1, 99, 102, 103
computer 1, 4, 5, 6, 24, 38, ...
scientist 1, 38, 101, 3958, ...

您应该注意,规范化不仅仅是将单词变成小写。它还涉及将单词转换为它们的正常形式。

将单词规范化为其根形式(或其词干)也称为词干提取

看一下我们在本节开头使用的文档中的以下句子:

The motivation for creating a new language originated from Stroustrup's experience in programming for his PhD thesis.

creating,originated 和 Stroustrup's 已经被规范化,因此倒排索引将具有以下形式:

motivation 1
create 1
new 1
language 1
originate 1
stroustrup 1
experience 1
programming 1
phd 1
thesis 1

还要注意,我们已经忽略了停用词,并且在前面的表中没有包括the

标记化是索引创建的第一步。除此之外,我们可以以任何使搜索更好的方式处理输入,如下一节所示。

对结果进行排序

相关性是搜索引擎最重要的特性之一。仅仅返回与用户输入匹配的文档是不够的。我们应该以一种方式对它们进行排名,以便最相关的文档首先出现。

一种策略是记录文档中每个单词的出现次数。例如,描述计算机的文档可能包含单词computer的多次出现,如果用户搜索a computer,结果将显示包含最多computer出现次数的文档。以下是一个示例索引表:

computer 1{18}, 4{13}, 899
map 4{9}, 1342{4}, 1343
world 12

花括号中的值定义了文档中每个单词的出现次数。

当向用户呈现搜索结果时,我们可以考虑许多因素。一些搜索引擎会存储与用户相关的信息,以便返回个性化的结果。甚至用户用于访问搜索引擎的程序(通常是网络浏览器)也可能改变搜索平台的结果。例如,Linux 操作系统上搜索重新安装操作系统的用户会得到包含重新安装 Ubuntu的结果,因为浏览器提供了操作系统类型和版本信息。然而,考虑到隐私问题,有些搜索引擎完全消除了个性化用户数据的使用。

文档的另一个属性是更新日期。新鲜内容始终具有更高的优先级。因此,当向用户返回文档列表时,我们可能还会按其内容更新的顺序重新排列它们。对文档的相关排名的担忧将我们带到下一节,我们将在那里讨论推荐引擎。

构建推荐引擎

我们在上一章介绍了人工智能AI)和机器学习ML)。推荐引擎可以被视为一个 AI 驱动的解决方案,或者一个简单的条件语句集合。构建一个接收用户数据并返回最满足该输入的选项的系统是一个复杂的任务。将 ML 纳入这样的任务中可能听起来相当合理。

然而,你应该考虑到推荐引擎可能包括一系列规则,这些规则在输出给最终用户之前对数据进行处理。推荐引擎可以在预期和意想不到的地方运行。例如,在亚马逊浏览产品时,推荐引擎会根据我们当前查看的产品向我们推荐产品。电影数据库会根据我们之前观看或评分的电影向我们推荐新电影。对许多人来说,这可能看起来出乎意料,但推荐引擎也在搜索引擎背后运行。

你可能熟悉一些电子商务平台推荐产品的方式。大多数情况下,建议窗格的标题类似于“购买此产品的顾客还购买了...”。回想一下我们在上一章介绍的聚类分析。现在,如果我们试图了解这些建议是如何工作的,我们可能会发现一些聚类算法。

让我们简单地看一下并设想一些推荐机制。比如,一个书店网站。约翰买了一本名为“掌握 Qt5”的书,那么我们可以把这个信息放在表格中:

掌握 Qt5
约翰

接下来,约翰决定购买一本 C++书籍,掌握 C++编程。莱娅购买了一本名为设计模式的书。卡尔购买了三本书,名为学习 Python掌握机器学习Python 机器学习。表格被更新,现在看起来是这样的:

掌握 Qt5 掌握 C++编程 设计模式 学习 Python 掌握机器学习 Python 机器学习
约翰
莱娅
卡尔

现在,让我们想象哈鲁特访问网站并购买了之前列出的两本书,学习 PythonPython 机器学习。向他推荐书籍掌握 Qt5是否合理?我们认为不合理。但我们知道他购买了哪些书,我们也知道另一个用户卡尔购买了三本书,其中两本与哈鲁特购买的书相同。因此,向哈鲁特推荐掌握机器学习可能是合理的,告诉他购买这两本书的其他顾客也购买了这本书。这是推荐引擎从高层次的工作原理的一个简单例子。

使用知识图谱

现在,让我们回到我们的搜索引擎。用户正在搜索一位著名的计算机科学家——比如,唐纳德·克努斯。他们在搜索框中输入这个名字,然后从整个网络中得到排序后的最佳结果。再次看看谷歌搜索。为了充分利用用户界面,谷歌向我们展示了一些关于搜索主题的简要信息。在这种情况下,它在网页右侧显示了这位伟大科学家的几张图片和一些关于他的信息。这个部分看起来是这样的:

这种方式,搜索引擎试图满足用户的基本需求,让他们能够更快地找到信息,甚至无需访问任何网站。在这种情况下,我们最感兴趣的是放置在前面信息框下面的建议框。它的标题是“人们还搜索”,看起来是这样的:

这些是基于搜索 Donald Knuth 后搜索 Alan Turing 的用户活动的推荐。这促使推荐引擎提出建议,即如果有人新搜索 Donald Knuth,他们可能也对 Alan Turing 感兴趣。

我们可以通过谷歌称之为知识图谱的东西来组织类似的建议机制。这是一个由节点组成的图,每个节点代表一些可搜索的主题、人物、电影或其他任何东西。图数据结构是一组节点和连接这些节点的边,就像以下图表中的那样:

在知识图谱中,每个节点代表一个单一实体。所谓实体,我们指的是城市、人、宠物、书籍,或者几乎你能想象到的任何其他东西。现在,图中的边代表实体之间的连接。每个节点可以通过多个节点连接到另一个节点。例如,看看这两个节点:

这两个节点只包含文本。我们可能猜测 Donald Knuth 是一个名字,而《计算机程序设计艺术》是某种艺术。建立知识图谱的本质是我们可以将每个节点与代表其类型的另一个节点相关联。以下图表扩展了之前的图表:

看看我们添加的两个新节点。其中一个代表一个,而另一个代表一本。更令人兴奋的是,我们将 Donald Knuth 节点与节点连接,并标记为 is a 关系。同样,我们将《计算机程序设计艺术》节点连接到书籍节点,因此我们可以说《计算机程序设计艺术》是一本书。现在让我们将 Donald Knuth 与他写的书连接起来:

所以,现在我们有了一个完整的关系,因为我们知道 Donald Knuth 是一位作者《计算机程序设计艺术》的人,而这本书又代表一本书。

让我们再添加几个代表人的节点。以下图表显示了我们如何添加了 Alan Turing 和 Peter Weyland 节点:

所以,Alan Turing 和 Peter Weyland 都是人。现在,如果这是搜索引擎知识库的一部分,那么它给了我们对用户搜索意图的很好洞察。当我们点击 Donald Knuth 的结果时,我们知道这是关于一个人的。如果需要,我们可以建议用户查看我们在知识图谱中积累的其他人。是否合理建议搜索 Donald Knuth 的用户也查看 Alan Turing 和 Peter Weyland 的页面?这里就有棘手的部分:尽管两者都是人,它们之间并没有强烈的联系。因此,我们需要一些额外的东西来定义两个不同人之间连接的相关性。看看图表的以下添加:

现在清楚了,Donald Knuth 和 Alan Turing 共享相同的活动,被表示为“计算机科学”节点,代表了一门研究领域,而 Peter Weyland 原来是一个虚构的角色。所以,Peter Weyland 和 Donald Knuth 相关的唯一一件事就是他们都是人。看一下我们放在从人节点到计算机科学节点的边上的数字。假设我们将关系评分从 0 到 100,后者表示关系最强。所以,我们为 Alan Turing 和 Donald Knuth 都放了 99。我们本应该省略从 Peter Weyland 到计算机科学的边,而不是放 0,但我们故意这样做来显示对比。这些数字是权重。我们给边添加权重以强调连接因素;也就是说,Alan Turing 和 Donald Knuth 共享相同的事物,并且彼此之间关系密切。如果我们将 Steve Jobs 作为知识图中的一个新人物,图将会是这样的:

看一下边的权重。Steve Jobs 与计算机科学有一定关系,但他更多地与“商人”和“影响者”节点相关。同样,我们现在可以看到 Peter Weyland 与 Steve Jobs 的关系比与 Donald Knuth 的关系更密切。现在,对于推荐引擎来说,建议搜索 Donald Knuth 的用户也应该看看 Alan Turing 更具信息量,因为他们都是人,并且与计算机科学的关系权重相等或接近相等。这是一个很好的例子,展示了如何在搜索引擎中整合这样的图。我们接下来要做的是向您介绍使用类似知识图来构建一个更智能的框架,以提供相关的搜索结果。我们称之为基于对话的搜索。

实现基于对话的搜索引擎

最后,让我们来设计搜索引擎的一部分,这部分将为我们提供精细的用户界面。正如我们在本章开头提到的,基于对话的搜索引擎涉及构建一个用户界面,询问用户与其查询相关的问题。这种方法在我们有模糊的结果的情况下最为适用。例如,搜索 Donald 的用户可能心里想的是以下之一:

  • 唐纳德·克努斯,伟大的计算机科学家

  • 唐纳德·达克,卡通人物

  • 唐纳德·邓恩,杰瑞德·邓恩的真名,虚构的角色

  • 唐纳德·特朗普,商人和第 45 任美国总统

前面的列表只是对 Donald 搜索词的潜在结果的一个小例子。那么,缺乏基于对话的方法的搜索引擎会怎么做呢?它们会为用户输入的最佳匹配提供相关结果列表。例如,在撰写本书时,搜索 Donald 会得到一个与 Donald Trump 相关的网站列表,尽管我当时心里想的是 Donald Knuth。在这里,我们可以看到最佳匹配和用户最佳匹配之间的微妙差别。

搜索引擎收集大量数据用于个性化搜索结果。如果用户从事网站开发领域的工作,他们的大部分搜索请求都会与该特定领域有关。这对于提供用户更好的搜索结果非常有帮助。例如,一个搜索历史记录中大部分请求都与网站开发相关的用户,在搜索 zepelin 时将会得到更好、更专注的结果。理想的搜索引擎将提供链接到 Zeplin 应用程序用于构建 Web UI 的网站,而对于其他用户,引擎将提供有关摇滚乐队 Led Zeppelin 的信息的结果。

设计基于对话框的搜索引擎是提供用户更好界面的下一步。如果我们已经有了强大的知识库,构建起来就足够简单了。我们将使用前一节中描述的知识图概念。假设当用户输入搜索词时,我们从知识图中获取所有匹配的主题,并为用户提供潜在命中列表,如下图所示:

因此,用户现在更容易选择一个主题,并节省回忆完整名称的时间。来自知识图的信息可以(对于一些搜索引擎而言)在用户输入查询时合并到自动建议中。此外,我们将着手处理搜索引擎的主要组件。显然,本章无法涵盖实现的每个方面,但我们将讨论的基本组件足以让您开始设计和实现自己的搜索引擎。

我们不会去烦恼搜索引擎的用户界面部分。我们最关心的是后端。当谈论应用程序的后端时,通常指的是用户看不到的部分。更具体地说,让我们看一下下面的图表:

正如您所看到的,大部分引擎位于后端。虽然用户界面可能感觉简单,但它是整个搜索系统的重要部分。这是用户开始他们旅程的地方,界面设计得越好,用户在搜索时的不适感就越少。我们将集中在后端;以下是我们将讨论的几个主要模块:

  • 查询解析器:分析用户查询,规范化单词,并收集查询中每个术语的信息,以便稍后传递给查询处理器。

  • 查询处理器:使用索引和辅助数据库检索与查询相关的数据,并构建响应。

  • 对话生成器:为用户在搜索时提供更多选择。对话生成器是一个辅助模块。发出请求的用户可以省略对话,也可以使用它来进一步缩小搜索结果。

我们跳过了一些在搜索引擎中常见的组件(如爬虫),而是集中在与基于对话框的搜索引擎密切相关的组件上。现在让我们从查询解析器开始。

实现查询解析器

查询解析器做的就是其名字所暗示的:解析查询。作为查询解析器的基本任务,我们应该通过空格来分隔单词。例如,用户查询zeplin best album被分成以下术语:zeplinbestalbum。以下类表示基本的查询解析器:

// The Query and Token will be defined in the next snippet
class QueryParser
{
public:
  static Query parse(const std::string& query_string) {
 auto tokens = QueryParser::tokenize(query_string);
    // construct the Query object and return
    // see next snippet for details
 }

private:
  static std::vector<Token> tokenize(const std::string& raw_query) {
    // return tokenized query string
  }
};

看一下前面的parse()函数。这是类中唯一的公共函数。我们将添加更多的私有函数,这些函数从parse()函数中调用,以完全解析查询并将结果作为Query对象返回。Query表示一个简单的结构,包含有关查询的信息,如下所示:

struct Query
{
  std::string raw_query;
  std::string normalized_query;
  std::vector<Token> tokens;
  std::string dialog_id; // we will use this in Dialog Generator
};

raw_query是用户输入的查询的文本表示,而normalized_query是规范化后的相同查询。例如,如果用户输入good books, a programmer should readraw_query就是这个确切的文本,而normalized_querygood books programmer should read。在下面的片段中,我们不使用normalized_query,但在完成实现时您将需要它。我们还将标记存储在Token向量中,其中Token是一个结构,如下所示:

struct Token
{
  using Word = std::string;
  using Weight = int;
  Word value;
  std::unordered_map<Word, Weight> related;
};

related属性表示与标记语义相关的单词列表。如果两个单词在概念上表达相似的含义,我们称它们为语义相关。例如,单词bestgood,或者albumcollection可以被认为是语义相关的。您可能已经猜到了哈希表值中权重的目的。我们使用它来存储相似性的Weight

权重的范围是在利用搜索引擎的过程中应该进行配置的内容。假设我们选择的范围是从 0 到 99。单词bestgood的相似性权重可以表示为接近 90 的数字,而单词albumcollection的相似性权重可能在 40 到 70 之间偏离。选择这些数字是棘手的,它们应该在引擎的开发和利用过程中进行调整。

最后,Query结构的dialog_id表示如果用户选择了生成器建议的路径,则生成的对话的 ID。我们很快就会谈到这一点。现在让我们继续完成parse()函数。

看一下QueryParser类的以下补充内容:

class QueryParser
{
public:
  static Query parse(const std::string& query_string, 
                     const std::string& dialog_id = "")
  {
    Query qr;
    qr.raw_query = query_string;
    qr.dialog_id = dialog_id;
    qr.tokens = QueryParser::tokenize(query_string);
    QueryParser::retrieve_word_relations(qr.tokens);
    return qr;
  }

private:
  static std::vector<Token> tokenize(const std::string& raw_string) {
    // 1\. split raw_string by space
    // 2\. construct for each word a Token
    // 3\. return the list of tokens 
  }

  static void retrieve_word_relations(std::vector<Token>& tokens) {
    // for each token, request the Knowledge Base
    // to retrieve relations and update tokens list
  }
};

尽管前面的代码片段中的两个私有函数(tokenizeretrieve_word_relations)没有实现,但基本思想是对搜索查询进行规范化和收集信息。在继续实现查询处理器之前,请查看前面的代码。

实现查询处理器

查询处理器执行搜索引擎的主要工作,即从搜索索引中检索结果,并根据搜索查询响应相关的文档列表。在本节中,我们还将涵盖对话生成。

正如您在前一节中看到的,查询解析器构造了一个包含标记和dialog_idQuery对象。我们将在查询处理器中使用这两者。

由于可扩展性问题,建议为对话生成器单独设计一个组件。出于教育目的,我们将保持实现简洁,但您可以重新设计基于对话的搜索引擎,并完成与爬虫和其他辅助模块的实现。

Query对象中的标记用于向搜索索引发出请求,以检索与每个单词相关联的文档集。以下是相应的QueryProcessor类的外观:

struct Document {
  // consider this
};

class QueryProcessor
{
public:
  using Documents = std::vector<Document>;
  static Documents process_query(const Query& query) {
 if (!query.dialog_id.empty()) {
 // request the knowledge graph for new terms
 }
 // retrieve documents from the index
 // sort and return documents
 }
};

将前面的代码片段视为实现的介绍。我们希望表达QueryProcessor类的基本思想。它具有process_query()函数,根据查询参数中的标记从索引中检索文档。这里的关键作用由搜索索引发挥。我们定义其构造方式和存储文档的方式对于进行快速查询至关重要。同时,作为附加参数提供的对话 ID 允许process_query()函数请求知识库(或知识图)以检索与查询相关的更多相关标记。

还要考虑到QueryProcessor还负责生成对话(即定义一组路径,为用户提供查询的可能场景)。生成的对话将发送给用户,当用户进行另一个查询时,使用的对话将通过我们已经看到的对话 ID 与该查询相关联。

尽管前面的实现大多是介绍性的(因为实际代码的规模太大,无法放入本章),但它是您进一步设计和实现引擎的良好基础。

总结

从头开始构建搜索引擎是一项需要经验丰富的程序员来完成的任务。本书涉及了许多主题,并在本章中通过设计搜索引擎将大部分主题结合起来。

我们已经了解到,网络搜索引擎是由爬虫、索引器和用户界面等多个组件组成的复杂系统。爬虫负责定期检查网络,下载网页供搜索引擎索引。索引会产生一个名为倒排索引的大型数据结构。倒排索引,或者简称索引,是一种将单词与它们出现的文档进行映射的数据结构。

接下来,我们定义了推荐引擎是什么,并尝试为我们的搜索引擎设计一个简单的推荐引擎。推荐引擎与本章讨论的基于对话的搜索引擎功能相连。基于对话的搜索引擎旨在向用户提供有针对性的问题,以更好地了解用户实际想要搜索的内容。

通过从 C++的角度讨论计算机科学的各种主题,我们完成了本书的阅读。我们从 C++程序的细节开始,然后简要介绍了使用数据结构和算法进行高效问题解决。了解一种编程语言并不足以在编程中取得成功。您需要解决需要数据结构、算法、多线程等技能的编码问题。此外,解决不同的编程范式可能会极大地增强您对计算机科学的认识,并使您以全新的方式看待问题解决。在本书中,我们涉及了几种编程范式,比如函数式编程。

最后,正如您现在所知,软件开发不仅仅局限于编码。架构和设计项目是成功应用开发的关键步骤之一。第十章,设计面向全球的应用程序,到第十六章,实现基于对话的搜索,大部分与设计现实世界应用程序的方法和策略有关。让本书成为您从 C++开发者的角度进入编程世界的入门指南。通过开发更复杂的应用程序来发展您的技能,并与同事和刚刚开始职业生涯的人分享您的知识。学习新知识的最佳方式之一就是教授它。

问题

  1. 爬虫在搜索引擎中的作用是什么?

  2. 为什么我们称搜索索引为倒排索引?

  3. 令牌化单词在索引之前的主要规则是什么?

  4. 推荐引擎的作用是什么?

  5. 知识图是什么?

进一步阅读

有关更多信息,请参考以下书籍:

信息检索导论Christopher Manning 等www.amazon.com/Introduction-Information-Retrieval-Christopher-Manning/dp/0521865719/

第十七章:评估

第一章

  1. 从源代码生成可执行文件的过程称为编译。编译 C++程序是一系列复杂的任务,最终产生机器代码。通常,C++编译器解析和分析源代码,生成中间代码,对其进行优化,最后生成一个名为对象文件的机器代码文件。另一方面,解释器不会产生机器代码。相反,它逐行执行源代码中的指令。

  2. 首先是预处理,然后编译器通过解析代码、执行语法和语义分析来编译代码,然后生成中间代码。在优化生成的中间代码之后,编译器生成最终的对象文件(包含机器代码),然后可以与其他对象文件链接。

  3. 预处理器旨在处理源文件,使其准备好进行编译。预处理器使用预处理指令,如#define#include。指令不代表程序语句,而是预处理器的命令,告诉它如何处理源文件的文本。编译器无法识别这些指令,因此每当您在代码中使用预处理指令时,预处理器会在实际编译代码之前相应地解析它们。

  4. 编译器为每个编译单元输出一个对象文件。链接器的任务是将这些对象文件合并成一个单一的对象文件。

  5. 库可以链接到可执行文件中,可以是静态库也可以是动态库。当将它们作为静态库链接时,它们将成为最终可执行文件的一部分。动态链接库也应该被操作系统加载到内存中,以便为您的程序提供调用其函数的能力。

第二章

  1. 通常,main()函数有两个参数,argcargv,其中argc是程序的输入参数数量,argv包含这些输入参数。很少见的是,您可能会看到一个广泛支持但未标准化的第三个参数,最常见的名称是envpenvp的类型是 char 指针数组,它保存系统的环境变量。

  2. constexpr说明符声明函数的值可以在编译时求值。相同的定义也适用于变量。名称由const和表达式组成。

  3. 递归导致为函数调用分配额外的空间。与迭代解决方案相比,为函数分配空间和调用的成本很高。

  4. 栈保存具有自动存储期的对象;也就是说,程序员不需要关心内存中这些对象的构造和销毁。通常,栈用于函数参数和局部变量。另一方面,堆允许在程序执行期间分配新的内存。然而,正确的内存空间释放现在是程序员的责任。

  5. 指针的大小不取决于指针的类型,因为指针是表示内存中地址的值。地址的大小取决于系统。通常是 32 位或 64 位。因此,我们说指针的大小是 4 或 8 字节。

  6. 数组在项目位置方面具有独特的结构。它们在内存中是连续放置的;第二个项目紧跟在第一个项目后面,第三个项目紧跟在第二个项目后面,依此类推。考虑到这一特性,以及数组由相同类型的元素组成的事实,访问任何位置的项目都需要恒定的时间。

  7. 如果我们在case语句中忘记了break关键字,执行将会转移到下一个case语句,而不检查其条件。

  8. 例如,operations['+'] = [](int a, int b) { return a + b; }

第三章

  1. 身份、状态和行为。

  2. 在移动对象而不是复制时,我们省略了创建临时变量。

  3. 在 C++中,结构体和类之间没有任何区别,除了默认访问修饰符。结构体的默认访问修饰符是 public,而类的默认访问修饰符是 private。

  4. 在聚合的情况下,包含其他类的实例或实例的类可以在没有聚合的情况下实例化。而组合则表示强的包含关系。

  5. 私有继承将继承的成员隐藏在派生类的客户端代码中。保护继承也是如此,但允许链中的派生类访问这些成员。

  6. 通常,引入虚函数会导致向类添加指向虚函数表的附加数据成员。通常,这会增加类对象的 4 或 8 个字节的空间(根据指针的大小)。

  7. 单例设计模式允许构造类的单个实例。这在许多项目中非常有用,其中我们需要确保类的实例数量限制为一个。例如,如果实现为单例的数据库连接类效果最佳。

第四章

  1. 宏是强大的工具,如果以正确的方式使用。然而,以下方面限制了宏的使用。(1) 你无法调试宏;(2) 宏扩展可能导致奇怪的副作用;(3) 宏没有命名空间,因此如果你的宏与其他地方使用的名称冲突,你会在不想要的地方得到宏替换,这通常会导致奇怪的错误消息;和(4) 宏可能影响你不知道的事情。有关更多详细信息,请访问stackoverflow.com/questions/14041453

  2. 类/函数模板是一种用于生成模板类/函数的模板。它只是一个模板,而不是一个类/函数,因此编译器不会为其生成任何对象代码。模板类/函数是类/函数模板的一个实例。由于它是一个类/函数,编译器会生成相应的对象代码。

  3. 当我们定义一个类/函数模板时,在template关键字后面有一个<>符号,其中必须给出一个或多个类型参数。<>中的类型参数被称为模板参数列表。当我们实例化一个类/函数模板时,所有模板参数必须用相应的模板参数替换,这被称为模板参数列表。

隐式实例化是按需发生的。然而,当提供库文件(.lib)时,你不知道用户将来会使用什么类型的参数列表,因此,你需要显式实例化所有潜在的类型。

  1. 多态意味着某物以不同的形式存在。具体来说,在编程语言中,多态意味着一些函数、操作或对象在不同的上下文中有几种不同的行为。在 C++中,有两种多态性:动态多态和静态多态。动态多态允许用户在运行时确定要执行的实际函数方法,而静态多态意味着在编译时知道要调用的实际函数(或者一般来说,要运行的实际代码)。

函数重载意味着使用相同的名称但不同的参数集(不同的签名)定义函数。

函数重写是子类重写父类中定义的虚方法的能力。

  1. 类型特征是一种用于收集有关类型信息的技术。借助它,我们可以做出更明智的决策

在通用编程中开发高质量优化的算法。类型特征可以通过部分或完全模板特化来实现。

  1. 我们可以在g()中编写一个错误语句,并构建代码。如果实例化了未使用的函数,则编译器将报告错误,否则将成功构建。您可以在以下文件中找到示例代码,ch4_5_class_template_implicit_inst_v2.hch4_5_class_template_implicit_inst_B_v2.cpp,位于github.com/PacktPublishing/Mastering-Cpp-Programming./tree/master/Chapter-4.

  2. 请参考github.com/PacktPublishing/Mastering-Cpp-Programming./tree/master/Chapter-4中的ch4_q7.cpp

  3. 这是一个实验练习;不需要答案。

第五章

  1. 计算机内存可以描述为一个概念 - 动态 RAMDRAM),或者是计算机包含的所有内存单元的组合,从寄存器和缓存内存开始,到硬盘结束。从程序员的角度来看,DRAM 是最感兴趣的,因为它保存了计算机中运行的程序的指令和数据。

  2. 虚拟内存是一种有效管理计算机物理内存的方法。通常,操作系统会整合虚拟内存来处理程序的内存访问,并有效地为特定程序分配内存块。

  3. 在 C++中,我们使用newdelete运算符来分配和释放内存空间。

  4. delete用于释放为单个对象分配的空间,而delete[]用于动态数组,并在堆上释放数组的所有元素。

  5. 垃圾收集器是一种工具或一组工具和机制,用于在堆上提供自动资源释放。对于垃圾收集器,需要一个支持环境,比如虚拟机。C++直接编译成可以在没有支持环境的情况下运行的机器代码。

第六章

  1. 在向向量中插入新元素时,它被放置在向量的已分配的空闲槽中。如果向量的大小和容量相等,则意味着向量没有空闲槽可供新元素使用。在这些(罕见)情况下,向量会自动调整大小,这涉及分配新的内存空间,并将现有元素复制到新的更大空间。

  2. 在链表的前面插入元素时,我们只创建新元素并更新列表指针,以有效地将新元素放入列表中。在向向量的前面插入新元素时,需要将所有向量元素向右移动,以释放一个槽位给该元素。

  3. 请参考 GitHub 中的章节源代码。

  4. 它看起来像一个链表。

  5. 选择排序搜索最大(或最小)元素,并用该最大(或最小)元素替换当前元素。插入排序将集合分为两部分,并遍历未排序部分,并将其每个元素放入已排序部分的适当槽中。

  6. 请参考 GitHub 中的章节源代码。

第七章

  1. C++中的 ranges 库允许处理元素的范围,并使用视图适配器对其进行操作,这样更有效,因为它们不会将整个集合作为适配器结果存储。

  2. 如果函数不修改状态,并且对于相同的输入产生相同的结果,则该函数是纯的。

  3. 纯虚函数是没有实现的函数的特征。纯虚函数用于描述派生类的接口函数。函数式编程中的纯函数是那些不修改状态的函数。

  4. 折叠(或缩减)是将一组值组合在一起以生成减少数量的结果的过程。

  5. 尾递归允许编译器通过省略为每个递归调用分配新内存空间来优化递归调用。

第八章

  1. 如果两个操作的开始和结束时间在任何时刻交错,则它们会同时运行。

  2. 并行意味着任务同时运行,而并发不强制任务同时运行。

  3. 进程是程序的映像。它是程序指令和数据加载到计算机内存中的组合。

  4. 线程是进程范围内可以由操作系统调度程序调度的代码部分,而进程是正在运行的程序的映像。

  5. 请参考章节中的任何示例。

  6. 通过使用双重检查锁定。

  7. 请参考 GitHub 上该章节的源代码。

  8. C++20 引入了协程作为经典异步函数的补充。协程将代码的后台执行提升到了下一个级别。它们允许在必要时暂停和恢复函数。co_await是一个构造,告诉代码等待异步执行的代码。这意味着函数可以在那一点被暂停,并在结果准备好时恢复执行。

第九章

  1. 双重检查锁定是使单例模式在多线程环境中无缺陷地工作的一种方法。

  2. 这是一种确保在复制其他堆栈的基础数据时,其底层数据不会被修改的方法。

  3. 原子操作是不可分割的操作,原子类型利用底层机制来确保指令的独立和原子执行。

  4. load()store()利用低级机制来确保写入和读取操作是原子的。

  5. 除了load()store()之外,还有诸如exchange()wait()notify_one()等操作。

第十章

  1. TDD 代表测试驱动开发,其目的是在项目的实际实现之前编写测试。这有助于更清晰地定义项目需求,并在代码中避免大部分错误。

  2. 交互图表现了对象之间通信的确切过程。这使开发人员能够高层次地查看任何给定时刻的实际程序执行。

  3. 在聚合的情况下,包含其他类的实例或实例的类可以在没有聚合的情况下实例化。另一方面,组合表达了强包含关系。

  4. 简而言之,里氏替换原则确保接受某种类型 T 对象作为参数的任何函数也将接受类型 K 对象,如果 K 扩展了 T。

  5. 开闭原则规定类应该对扩展开放,对修改关闭。在所述示例中,Animal对扩展开放,因此从Animal继承monkey类并不违反该原则。

  6. 请参考 GitHub 上该章节的源代码。

第十一章

  1. 覆盖私有虚函数允许通过保持其公共接口不变来修改类的行为。

  2. 这是一种行为设计模式,其中对象封装了一个动作和执行该动作所需的所有信息。

  3. 尽可能与其他对象共享数据。当我们有许多具有相似结构的对象时,跨对象共享重复数据可以最小化内存使用。

  4. 观察者通知订阅对象有关事件,而中介者则扮演着相互通信对象之间连接的角色。

  5. 将游戏循环设计为无限循环是合理的,因为从理论上讲,游戏可能永远不会结束,只有在玩家命令结束时才会结束。

第十二章

  1. 物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。

  2. 端口号提供了一种区分在同一环境中运行的多个网络应用程序的方法。

  3. 套接字是提供程序员发送和接收网络数据的抽象。

  4. 首先,我们需要创建并绑定带有 IP 地址的套接字。接下来,我们应该监听传入的连接,如果有一个连接,我们应该接受连接以进一步处理数据通信。

  5. TCP 是一种可靠的协议。它处理端点之间的稳固连接,并通过重新发送接收方未收到的数据包来处理数据包丢失。另一方面,UDP 不可靠。几乎所有处理方面都由程序员来处理。UDP 的优势在于它的速度,因为它省略了握手、检查和数据包丢失处理。

  6. 宏定义会导致代码中的逻辑错误,很难发现。最好始终使用const表达式而不是宏。

  7. 客户端应用程序必须具有唯一的标识符,以及用于授权和/或验证它们的令牌(或密码)。

第十三章

  1. 这是一个实验室练习;不需要答案。

  2. 以下输出来自 NVIDIA Jetson Nano 上的 Ubuntu 18.04:

swu@swu-desktop:~/ch13$ g++ -c -Wall -Weffc++ -Wextra ch13_rca_compound.cpp
 ch13_rca_compound.cpp: In function ‘int main()’:
 ch13_rca_compound.cpp:11:17: warning: operation on ‘x’ may be undefined [-Wsequence-point]
 std::cout << f(++x, x) << std::endl; //bad,f(4,4) or f(4,3)?
 ^~~

swu@swu-desktop:~/ch13$ g++ -c -Wall -Weffc++ -Wextra ch13_rca_mix_sign_unsigned.cpp
nothing is detected 
swu@swu-desktop:~/ch13$ g++ -c -Wall -Weffc++ -Wextra ch13_rca_order_of_evaluation.cpp
 ch13_rca_order_of_evaluation.cpp: In constructor ‘A::A(int)’:
 ch13_rca_order_of_evaluation.cpp:14:14: warning: ‘A::v3’ will be initialized after [-Wreorder]
 int v1, v2, v3;
 ^~
 ch13_rca_order_of_evaluation.cpp:14:6: warning: ‘int A::v1’ [-Wreorder]
 int v1, v2, v3;
 ^~
 ch13_rca_order_of_evaluation.cpp:7:2: warning: when initialized here [-Wreorder]
 A(int x) : v2(v1), v3(v2), v1(x) {
 ^
 ch13_rca_order_of_evaluation.cpp: In constructor ‘B::B(float)’:
 ch13_rca_order_of_evaluation.cpp:32:6: warning: ‘B::v2’ will be initialized after [-Wreorder]
 int v2;
 ^~
 ch13_rca_order_of_evaluation.cpp:31:6: warning: ‘int B::v1’ [-Wreorder]
 int v1; //good, here the declaration order is clear
 ^~
 ch13_rca_order_of_evaluation.cpp:25:2: warning: when initialized here [-Wreorder]
 B(float x) : v2(x), v1(v2) {};
 ^
 swu@swu-desktop:~/ch13$ g++ -c -Wall -Weffc++ -Wextra ch13_rca_uninit_variable.cpp
 ch13_rca_uninit_variable.cpp: In function ‘int main()’:
 ch13_rca_uninit_variable.cpp:7:2: warning: ‘x’ is used uninitialized in this function [-Wuninitialized]
 if (x) {
 ^~
  1. 因为静态分析工具从其模型中预测错误,动态分析工具通过程序的执行来检测错误。

  2. 请参考样本代码,ch13_tdd_v3.hch13_tdd_v3.cppch13_tdd_Boost_UTF3.cpp

github.com/PacktPublishing/Mastering-Cpp-Programming./tree/master/Chapter-13

第十四章

  1. Qt 的编译模型允许省略虚拟机。它使用元对象编译器MOC)将其转换为 C++,然后编译为特定平台的机器代码。

  2. QApplication::exec()是应用程序的起点。它启动 Qt 的事件循环。

  3. 通过调用setWindowTitle()

  4. m->index (2, 3)

  5. wgt->resize (400, 450)

  6. 当从QLayout继承时,应为addItem()sizeHint()setGeometry()itemAt()takeAt()minimumSize()函数提供实现。

  7. 通过使用connect()函数,该函数以源对象和目标对象以及信号和插槽的名称作为参数。

第十五章

  1. ML代表机器学习,是计算机系统用于执行特定任务的算法和统计模型的研究领域,而不使用显式指令,而是依赖模式和推理。

  2. 监督学习算法(也称为带教练的训练)从带标签的数据集中学习;也就是说,每个记录都包含描述数据的附加信息。无监督学习算法更加复杂——它们处理包含大量特征的数据集,然后试图找到特征的有用属性。

  3. 机器学习应用包括机器翻译、自然语言处理、计算机视觉和电子邮件垃圾邮件检测。

  4. 其中一种方法是为每个结果添加权重,如果减法操作的权重超过其他操作,它将成为主导操作。

  5. 神经网络的目的是识别模式。

第十六章

  1. 网络爬虫下载网页并存储其内容,以便搜索引擎对其进行索引。

  2. 我们称之为倒排索引,因为它将单词映射回它们在文档中的位置。

  3. 在索引之前,标记化会规范化单词。

  4. 推荐引擎验证并推荐适合特定请求的最佳结果。

  5. 知识图是一个图,其中节点是主题(知识),边是主题之间的连接。

posted @ 2024-05-05 00:03  绝不原创的飞龙  阅读(35)  评论(0编辑  收藏  举报