面向 C++ 的现代 CMake 第二版(三)

原文:zh.annas-archive.org/md5/4abd6886e8722cebdc63cd42f86a9282

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:链接可执行文件和库

你可能会认为,一旦我们成功地将源代码编译成二进制文件,我们作为构建工程师的角色就完成了。然而,事实并非完全如此。尽管二进制文件确实包含了 CPU 执行所需的所有代码,但这些代码可能会以复杂的方式分布在多个文件中。我们不希望 CPU 在不同的文件中寻找单独的代码片段。相反,我们的目标是将这些分散的单元合并为一个文件。为了实现这一目标,我们使用了一个称为链接的过程。

快速观察可以发现,CMake 有很少的链接命令,其中target_link_libraries()是主要命令。那么,为什么要专门用一整章来讲解这个命令呢?不幸的是,计算机科学几乎没有什么事情是简单的,链接也不例外:为了获得正确的结果,我们需要了解整个过程——我们需要知道链接器是如何工作的,并掌握基本知识。我们将讨论目标文件的内部结构,重定位和引用解析机制的工作原理,以及它们的用途。我们还会讨论最终的可执行文件与其组成部分之间的区别,以及在将程序加载到内存时,系统如何构建进程镜像。

然后,我们将介绍各种类型的库:静态库、共享库和共享模块。尽管它们都叫做“库”,但实际上差异很大。创建一个正确链接的可执行文件依赖于正确的配置,并处理一些具体的细节,例如位置无关代码PIC)。

我们将学习链接中的另一个麻烦——唯一定义规则ODR)。确保定义的数量是准确的至关重要。管理重复符号特别具有挑战性,尤其是对于共享库。此外,我们还将探讨为什么链接器有时无法找到外部符号,即使可执行文件已正确链接到相关库。

最后,我们将了解如何高效地使用链接器,为在特定框架中进行测试准备我们的解决方案。

本章将涵盖以下主要内容:

  • 正确理解链接的基础

  • 构建不同类型的库

  • 解决 ODR 问题

  • 链接顺序和未解析符号

  • main()分离用于测试

技术要求

你可以在 GitHub 上找到本章中提到的代码文件,链接地址为:github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch08

要构建本书中提供的示例,请始终使用推荐的命令:

cmake -B <build tree> -S <source tree>
cmake --build <build tree> 

确保将<build tree><source tree>占位符替换为合适的路径。提醒一下:build tree是目标/输出目录的路径,source tree是源代码所在的路径。

正确理解链接的基础

我们在第七章《使用 CMake 编译 C++ 源代码》中讨论了 C++ 程序的生命周期。它由五个主要阶段组成——编写、编译、链接、加载和执行。在正确编译所有源代码之后,我们需要将它们组合成一个可执行文件。我们曾提到过,编译过程中生成的目标文件不能被处理器直接执行。那么,为什么呢?

为了回答这个问题,我们需要理解目标文件是广泛使用的可执行和可链接格式ELF)的一种变体,该格式在类似 Unix 的系统以及许多其他系统中都很常见。像 Windows 或 macOS 这样的系统有自己的格式,但我们将重点讲解 ELF 格式,以便解释其原理。图 8.1 展示了编译器如何构建这些文件:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_08_01.png

图 8.1:目标文件的结构

编译器会为每个翻译单元(每个 .cpp 文件)准备一个目标文件。这些文件将用于构建我们程序的内存映像。目标文件由以下内容组成:

  • 一个ELF 头,用于标识目标操作系统OS)、文件类型、目标指令集架构,以及有关 ELF 文件中两个头表的位置和大小的详细信息:程序头表(在目标文件中不存在)和区段头表。

  • 按类型分组信息的二进制区段。

  • 一个区段头表,包含有关名称、类型、标志、内存中的目标地址、文件中的偏移量以及其他杂项信息。它用于了解文件中有哪些区段以及它们的位置,类似于目录。

当编译器处理你的源代码时,它将收集的信息按不同区段分类。这些区段构成了 ELF 文件的核心,位于ELF 头区段头之间。以下是一些这样的区段示例:

  • .text 区段包含机器代码,包含所有指定给处理器执行的指令。

  • .data 区段保存初始化的全局变量和静态变量的值。

  • .bss 区段为未初始化的全局变量和静态变量保留空间,这些变量在程序启动时会被初始化为零。

  • .rodata 区段保存常量的值,使其成为只读数据段。

  • .strtab 区段是一个字符串表,包含常量字符串,例如从基础的 hello.cpp 示例中提取的“Hello World”。

  • .shstrtab 区段是一个字符串表,保存所有其他区段的名称。

这些部分与最终的可执行文件版本非常相似,该文件将被加载到内存中运行我们的应用程序。然而,我们不能仅仅将目标文件连接起来并将结果文件加载到内存中。没有谨慎处理的合并会导致一系列复杂的问题。首先,我们会浪费空间和时间,消耗过多的 RAM 页。将指令和数据传输到 CPU 缓存也会变得繁琐。整个系统不得不处理更高的复杂性,浪费宝贵的周期,并且在执行过程中在无数的 .text.data 和其他部分之间跳跃。

我们将采用更有组织的方法:每个目标文件的各个部分将与其他目标文件中相同类型的部分分组。这个过程叫做重定位,这也是目标文件的 ELF 文件类型被标记为“可重定位”的原因。但是重定位不仅仅是将匹配的部分组合在一起。它还涉及更新文件中的内部引用,例如变量地址、函数地址、符号表索引和字符串表索引。这些值在各自的目标文件中是局部的,并且从零开始编号。因此,在合并文件时,必须调整这些值,以确保它们引用合并后的文件中的正确地址。

图 8.2 展示了重定位的过程 —— .text 部分已经被重定位,.data 部分正在从所有链接的文件中组装,而 .rodata.strtab 部分将遵循相同的过程(为了简便,图中没有包含头部):

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_08_02.png

图 8.2:.data 部分的重定位

接下来,链接器需要解析引用。当一个翻译单元中的代码引用了另一个翻译单元中定义的符号时,无论是通过包含其头文件还是使用 extern 关键字,编译器都会确认声明,假设定义将在稍后提供。链接器的主要作用是收集这些未解决的外部符号引用,然后识别并填充它们在合并后的可执行文件中的正确地址。图 8.3 显示了该引用解析过程的一个简单示例:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_08_03.png

图 8.3:引用解析

如果程序员不了解其工作原理,链接过程中的这一部分可能会成为问题的根源。我们可能会遇到无法找到对应外部符号的未解决引用,或者相反,提供了过多的定义,链接器不知道该选择哪个。

最终的可执行文件与目标文件非常相似,因为它包含了已重定位的部分和已解析的引用、段头表以及当然描述整个文件的ELF 头。主要的区别是存在一个程序头,如下图所示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_08_04.png

图 8.4:ELF 中可执行文件的结构

程序头位于ELF 头之后。操作系统的加载器将读取这个程序头,以便设置程序、配置内存布局并创建进程映像。程序头中的条目指定哪些部分会被复制、复制的顺序,以及在虚拟内存中的地址。它们还包含有关访问控制标志(读、写或执行)以及其他一些有用信息。每个命名的部分将在创建的进程中由一块内存表示;这种内存块称为

目标文件也可以被捆绑到库中,这是一种中间产品,可以在最终的可执行文件或其他库中使用。

现在我们已经理解了链接的原理,接下来让我们进入下一部分,讨论三种不同类型的库。

构建不同类型的库

在编译源代码之后,通常希望避免在相同平台上重新编译,或者甚至将编译结果与外部项目共享。虽然可以分发最初生成的单个目标文件,但这会带来一些挑战。分发多个文件并逐个将它们集成到构建系统中可能会很麻烦,尤其是在处理大量文件时。一种更高效的方法是将所有目标文件合并为一个单独的单元进行共享。CMake 可以大大简化这一任务。我们可以通过简单的 add_library() 命令(配合 target_link_libraries() 命令)来生成这些库。

按惯例,所有库都有一个共同的前缀 lib,并使用系统特定的扩展名来表示它们是哪种类型的库:

  • 静态库在类 Unix 系统上具有 .a 扩展名,在 Windows 上则是 .lib

  • 共享库(和模块)在某些类 Unix 系统(如 Linux)上具有 .so 扩展名,在其他系统(如 macOS)上则是 .dylib。在 Windows 上,它们的扩展名是 .dll

  • 共享模块通常与共享库使用相同的扩展名,但并非总是如此。在 macOS 上,它们可以使用 .so,特别是当模块是从另一个 Unix 平台移植过来时。

构建库(静态库、共享库或共享模块)的过程通常被称为“链接”,如在 ch08/01-libraries 项目的构建输出中所见:

[ 33%] Linking CXX static library libmy_static.a
[ 66%] Linking CXX shared library libmy_shared.so
[100%] Linking CXX shared module libmy_module.so
[100%] Built target module_gui 

然而,并非所有前述的库在创建时都一定会使用链接器。某些库的创建过程可能会跳过像重定位和引用解析等步骤。

让我们深入了解每种库类型,以理解它们各自的工作原理。

静态库

静态库本质上是存储在归档文件中的原始目标文件集合。有时,它们会通过索引来加速链接过程。在类 Unix 系统上,可以使用 ar 工具创建这种归档文件,并通过 ranlib 进行索引。

在构建过程中,仅将静态库中必要的符号导入到最终的可执行文件中,从而优化其大小和内存使用。这种选择性整合确保了可执行文件是自包含的,运行时不需要外部文件。

要创建静态库,我们可以简单地使用我们在前几章中已经看到的命令:

add_library(<name> [<source>...]) 

这个简写代码默认会生成一个静态库。通过将BUILD_SHARED_LIBS变量设置为ON,可以覆盖这一行为。如果我们无论如何都想构建一个静态库,可以提供一个明确的关键字:

add_library(<name> STATIC [<source>...]) 

使用静态库可能并不总是理想的选择,特别是当我们希望在同一台机器上共享多个应用程序编译后的代码时。

共享库

共享库与静态库有显著不同。它们是使用链接器构建的,链接器完成了链接的两个阶段。这会生成一个完整的文件,包含节头、节以及节头表,如图 8.1所示。

共享库,通常被称为共享对象,可以在多个不同的应用程序之间同时使用。当第一个程序使用共享库时,操作系统将该库的一个实例加载到内存中。随后的程序将由操作系统提供相同的地址,得益于复杂的虚拟内存机制。然而,对于每个使用该库的进程,库的.data.bss段会被单独实例化。这确保了每个进程可以调整其变量,而不影响其他进程。

得益于这种方法,系统的整体内存使用得到了优化。如果我们使用的是一个广泛认可的库,可能无需将其与程序一起包含,因为它很可能已经在目标机器上可用。然而,如果该库没有预先安装,用户需要在运行应用程序之前手动安装它。如果安装的库版本与预期不符,可能会导致潜在问题。这种问题被称为“依赖地狱”。更多详情请参见本章的进一步阅读部分。

我们可以通过明确使用SHARED关键字来构建共享库:

add_library(<name> SHARED [<source>...]) 

由于共享库在程序初始化期间被加载,因此执行程序与磁盘上的实际库文件之间没有直接关联。相反,链接是间接完成的。在类 Unix 系统中,这是通过共享对象名称SONAME)实现的,它可以理解为库的“逻辑名称”。

这为库版本控制提供了灵活性,并确保对库的向后兼容性更改不会立即破坏依赖的应用程序。

我们可以使用生成器表达式查询生成的 SONAME 文件的一些路径属性(确保将target替换为目标的名称):

  • $<TARGET_SONAME_FILE:target>返回完整路径(.so.3)。

  • $<TARGET_SONAME_FILE_NAME:target> 仅返回文件名。

  • $<TARGET_SONAME_FILE_DIR:target> 返回目录。

这些在更高级的场景中非常有用,我们将在本书稍后部分讨论,包括:

  • 在打包和安装过程中正确使用生成的库。

  • 为依赖管理编写自定义 CMake 规则。

  • 在测试过程中使用 SONAME。

  • 在构建后命令中复制或重命名生成的库。

你可能会对其他特定于操作系统的构件有类似需求;为此,CMake 提供了两种生成器表达式,它们提供与 SONAME 相同的后缀。对于 Windows,我们有:

  • $<TARGET_LINKER_FILE:target> 返回与生成的 动态链接库 (DLL) 相关联的 .lib 导入库的完整路径。请注意,.lib 扩展名与静态 Windows 库相同,但它们的应用不同。

  • $<TARGET_RUNTIME_DLLS:target> 返回目标在运行时所依赖的 DLL 列表。

  • $<TARGET_PDB_FILE:target> 返回 .pdb 程序数据库文件的完整路径(用于调试目的)。

由于共享库在程序初始化时加载到操作系统内存中,因此当知道程序将使用哪些库时它们是适用的。那么在运行时需要确定的场景怎么办呢?

共享模块

共享模块或模块库是共享库的变种,旨在作为插件在运行时加载。与标准共享库不同,标准共享库在程序启动时自动加载,而共享模块仅在程序明确请求时才会加载。这可以通过系统调用完成:

  • Windows 上的 LoadLibrary

  • 在 Linux 和 macOS 上使用 dlopen() 后跟 dlsym()

这种方法的主要原因是内存节省。许多软件应用程序具有生命周期内并不总是使用的高级功能。每次将此类功能加载到内存中将会非常低效。

另外,我们可能希望为主程序提供一个扩展的途径,能够销售、交付并单独加载具有专门功能的部分。

要构建共享模块,我们需要使用 MODULE 关键字:

add_library(<name> MODULE [<source>...]) 

你不应该尝试将可执行文件与模块链接,因为该模块旨在与可执行文件分开部署,后者将使用该模块。

与位置无关的代码(PIC)

由于虚拟内存的使用,今天的程序本质上是某种程度的与位置无关的。这项技术抽象了物理地址。当调用一个函数时,CPU 使用 内存管理单元 (MMU) 将虚拟地址(每个进程从 0 开始)转换为相应的物理地址(在分配时确定)。有趣的是,这些映射不总是遵循特定的顺序。

编译一个库会带来不确定性:我们无法确定哪些进程可能会使用这个库,或者它将位于虚拟内存的哪个位置。我们也无法预测符号的地址或它们相对于库的机器代码的位置。为了解决这个问题,我们需要另一个间接层。

PIC的引入是为了将符号(如函数和全局变量的引用)映射到它们的运行时地址。PIC 为二进制文件引入了一个新的部分:全局偏移表GOT)。在链接过程中,GOT 部分相对于.text部分(程序代码)的相对位置会被计算出来。所有的符号引用将通过一个偏移量指向 GOT 中的占位符。

当程序加载时,GOT(全局偏移表)部分会转变为一个内存段。随着时间的推移,这个段会积累符号的运行时地址。这种方法被称为“懒加载”,它确保加载器仅在需要时填充特定的 GOT 条目。

所有共享库和模块的源代码必须在编译时启用 PIC 标志。通过将POSITION_INDEPENDENT_CODE目标属性设置为ON,我们会告诉 CMake 适当添加编译器特定的标志,例如 GCC 或 Clang 的-fPIC

对于共享库,这个属性是自动启用的。然而,如果一个共享库依赖于另一个目标,例如静态库或对象库,你还必须将这个属性应用于依赖的目标:

set_target_properties(dependency
                      PROPERTIES POSITION_INDEPENDENT_CODE ON) 

忽视这一步骤会导致 CMake 中的冲突,因为它会检查这个属性是否存在不一致。你可以在第五章,处理目标部分的处理冲突的传播属性*小节中找到更深入的讨论。

我们接下来的讨论重点是符号。具体来说,接下来的部分将探讨命名冲突的挑战,这可能导致歧义和定义不一致。

解决 ODR 问题

Netscape 的首席固执者兼技术远见者 Phil Karlton 曾说过一句话,他说的对:

“计算机科学中有两件困难的事情:缓存失效和命名事物。”

名称之所以困难,原因有很多。它们必须既精确又简单,简短又富有表现力。这不仅赋予了它们意义,而且使程序员能够理解原始实现背后的概念。C++和许多其他语言增加了另一个要求:大多数名称必须是唯一的。

这个要求表现为 ODR(单一定义规则):在一个单独的翻译单元(一个.cpp文件)的范围内,你必须准确地定义一个符号一次,即使相同的名称(无论是变量、函数、类类型、枚举、概念还是模板)被多次声明。为了澄清,“声明”引入了符号,而“定义”提供了符号的所有细节,比如变量的值或函数的主体。

在链接过程中,这条规则会扩展到整个程序,涵盖你在代码中有效使用的所有非内联函数和变量。考虑以下包含三个源文件的示例:

ch08/02-odr-fail/shared.h

int i; 

ch08/02-odr-fail/one.cpp

#include <iostream>
#include "shared.h"
int main() {
  std::cout << i << std::endl;
} 

ch08/02-odr-fail/two.cpp

#include "shared.h" 

它还包含一个列表文件:

ch08/02-odr-fail/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(ODR CXX)
set(CMAKE_CXX_STANDARD 20)
add_executable(odr one.cpp two.cpp) 

如你所见,示例非常简单——我们创建了一个 shared.h 头文件,定义了 i 变量,该变量在两个不同的翻译单元中使用:

  • one.cpp 仅将 i 打印到屏幕

  • two.cpp 仅包含头文件

但是当我们尝试构建示例时,链接器会产生以下错误:

/usr/bin/ld:
CMakeFiles/odr.dir/two.cpp.o:(.bss+0x0): multiple definition of 'i';
CMakeFiles/odr.dir/one.cpp.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status 

符号不能定义多次。然而,有一个重要的例外。类型、模板和 extern 内联函数可以在多个翻译单元中重复定义,但前提是这些定义完全相同(即它们具有完全相同的令牌序列)。

为了演示这一点,让我们将变量的定义替换为类型的定义:

ch08/03-odr-success/shared.h

struct shared {
  static inline int i = 1;
}; 

然后,我们按以下方式使用它:

ch08/03-odr-success/one.cpp

#include <iostream>
#include "shared.h"
int main() {
  std::cout << shared::i << std::endl;
} 

另外两个文件,two.cppCMakeLists.txt,与 02-odr-fail 示例中的保持一致。这样的变化将允许链接成功:

[ 33%] Building CXX object CMakeFiles/odr.dir/one.cpp.o
[ 66%] Building CXX object CMakeFiles/odr.dir/two.cpp.o
[100%] Linking CXX executable odr
[100%] Built target odr 

另外,我们可以将变量标记为仅对某个翻译单元局部(它不会被导出到目标文件之外)。为此,我们将使用 static 关键字(该关键字是特定上下文的,因此不要将其与类中的 static 关键字混淆),如下所示:

ch08/04-odr-success/shared.h

static int i; 

如果你尝试链接这个示例,你会发现它有效,这意味着静态变量为每个翻译单元单独存储。因此,对一个的修改不会影响另一个。

ODR 规则对于静态库和目标文件的作用完全相同,但当我们使用共享库构建代码时,情况就不那么清晰了——我们来看一下。

排序动态链接的重复符号

链接器将允许此处的重复符号。在以下示例中,我们将创建两个共享库 AB,其中包含一个 duplicated() 函数和两个唯一的 a()b() 函数:

ch08/05-dynamic/a.cpp

#include <iostream>
void a() {
  std::cout << "A" << std::endl;
}
void duplicated() {
  std::cout << "duplicated A" << std::endl;
} 

第二个实现文件几乎与第一个完全相同:

ch08/05-dynamic/b.cpp

#include <iostream>
void b() {
  std::cout << "B" << std::endl;
}
void duplicated() {
  std::cout << "duplicated B" << std::endl;
} 

现在,让我们使用每个函数来看看会发生什么(为了简化,我们将它们声明为 extern):

ch08/05-dynamic/main.cpp

extern void a();
extern void b();
extern void duplicated();
int main() {
  a();
  b();
  duplicated();
} 

上面的代码将运行每个库中的唯一函数,然后调用在两个动态库中定义的具有相同签名的函数。你认为会发生什么?在这种情况下,链接顺序会有影响吗?让我们分别测试两种情况:

  • main_1 目标将首先与 a 库链接

  • main_2 目标将首先与 b 库链接

列表文件如下所示:

ch08/05-dynamic/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Dynamic CXX)
add_library(a SHARED a.cpp)
add_library(b SHARED b.cpp)
add_executable(main_1 main.cpp)
target_link_libraries(main_1 a b)
add_executable(main_2 main.cpp)
target_link_libraries(main_2 b a) 

构建并运行这两个可执行文件后,我们将看到以下输出:

root@ce492a7cd64b:/root/examples/ch08/05-dynamic# b/main_1
A
B
duplicated A
root@ce492a7cd64b:/root/examples/ch08/05-dynamic# b/main_2
A
B
duplicated B 

啊哈!显然,库的链接顺序对链接器非常重要。如果我们不小心,这可能会导致混淆。与人们的想法相反,命名冲突在实践中并不罕见。

如果我们定义了本地可见的符号,它们将优先于 DLL 中的符号。如果在main.cpp中定义了duplicated()函数,它将覆盖两个目标的行为。

在从库中导出名称时一定要小心,因为你迟早会遇到命名冲突。

使用命名空间——不要依赖链接器

C++命名空间的发明是为了避免这种奇怪的问题,并更有效地处理 ODR。最佳做法是将你的库代码封装在一个以库名命名的命名空间中。这种策略有助于防止因重复符号而引发的复杂问题。

在我们的项目中,可能会遇到一个共享库链接到另一个库,形成一个长链。这样的情况并不像看起来那样罕见,尤其是在复杂的配置中。然而,重要的是要理解,仅仅将一个库链接到另一个库并不会引入任何命名空间的继承。在这个链中的每个链接的符号仍然保持其编译时的原始命名空间。

虽然链接器的复杂性非常有趣,有时也至关重要,但另一个紧迫的问题常常浮现出来:已正确定义的符号神秘地消失了。我们将在下一节中深入探讨这个问题。

链接顺序和未解决的符号

链接器的行为有时看起来很任性,似乎无缘无故就抛出抱怨。这对于那些不熟悉这个工具细节的新手程序员来说,常常是一个特别令人烦恼的挑战。可以理解的是,他们通常尽量避免接触构建配置,直到不得不进行更改——也许是集成他们开发的库——这时一切都乱套了。

试想这样一种情况:一个相对简单的依赖链,主可执行文件依赖于一个“外部”库。而这个外部库又依赖于一个包含必需的int b变量的“嵌套”库。突然,一个令人费解的错误信息出现在程序员面前:

outer.cpp:(.text+0x1f): undefined reference to 'b' 

这样的错误并不罕见。通常,它们表示链接器中忘记添加某个库。然而,在这种情况下,库似乎已经正确地添加到了target_link_libraries()命令中:

ch08/06-unresolved/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Order CXX)
add_library(outer outer.cpp)
add_library(nested nested.cpp)
add_executable(main main.cpp)
target_link_libraries(main **nested** outer) 

那该怎么办!?很少有错误能像这个一样令人抓狂,难以调试和理解。我们看到的是链接顺序不正确。让我们深入源代码找出原因:

ch08/06-unresolved/main.cpp

#include <iostream>
extern int a;
int main() {
  std::cout << a << std::endl;
} 

代码看起来足够简单——我们将打印一个外部变量a,它可以在outer库中找到。我们事先用extern关键字声明它。以下是该库的源代码:

ch08/06-unresolved/outer.cpp

extern int b;
int a = b; 

这也很简单 —— outer 依赖于 nested 库来提供外部变量 b,然后将其赋值给 a 变量。让我们查看 nested 的源代码,确认我们没有遗漏定义:

ch08/06-unresolved/nested.cpp

int b = 123; 

确实,我们已经为 b 提供了定义,并且由于它没有用 static 关键字标记为局部,因此它正确地从 nested 目标中导出。正如我们之前看到的,这个目标与 main 可执行文件在 CMakeLists.txt 中进行了链接:

target_link_libraries(main **nested** outer) 

那么,undefined reference to 'b' 错误是从哪里来的呢?

解析未定义符号是这样的 —— 链接器按从左到右的顺序处理二进制文件。在链接器遍历这些二进制文件时,它将执行以下操作:

  1. 收集所有从该二进制文件导出的未定义符号,并将它们存储以供以后使用。

  2. 尝试用此二进制文件中定义的符号来解析之前所有已处理二进制文件中收集到的未定义符号。

  3. 对下一个二进制文件重复这个过程。

如果在整个操作完成后仍然有未定义的符号,链接会失败。这就是我们例子中的情况(CMake 将可执行目标的目标文件放在库文件之前):

  1. 链接器处理了 main.o,发现了对 a 变量的未定义引用,并将其收集起来以便将来解析。

  2. 链接器处理了libnested.a,没有发现未定义的引用,也没有需要解决的问题。

  3. 链接器处理了 libouter.a,发现了对 b 变量的未定义引用,并解析了对 a 变量的引用。

我们确实正确解析了对 a 变量的引用,但没有解析 b 变量的引用。为了解决这个问题,我们需要反转链接顺序,使 nested 排在 outer 后面:

target_link_libraries(main outer **nested**) 

有时,我们会遇到循环引用的情况,其中翻译单元相互定义符号,且没有单一的有效顺序能满足所有引用。解决这个问题的唯一方法是处理某些目标两次:

target_link_libraries(main nested outer nested) 

这是一种常见做法,但使用时稍显不优雅。如果你有幸使用 CMake 3.24 或更新版本,你可以利用 $<LINK_GROUP> 生成器表达式和 RESCAN 特性,添加链接器特定的标志,例如 --start-group--end-group,以确保所有符号都被评估:

target_link_libraries(main "$<LINK_GROUP:RESCAN,nested,outer>") 

请记住,这种机制引入了额外的处理步骤,应该仅在必要时使用。需要(并且有正当理由)使用循环引用的情况非常罕见。遇到这个问题通常表示设计不当。它在 Linux、BSD、SunOS 和 Windows 上的 GNU 工具链中得到支持。

我们现在准备处理 ODR 问题了。我们可能会遇到什么其他问题?在链接时符号异常丢失。让我们找出问题所在。

处理未引用的符号

当库,特别是静态库被创建时,它们本质上是由多个目标文件组成的档案。我们提到过,一些归档工具还可能创建符号索引以加速链接过程。这些索引提供了每个符号与其所在目标文件的映射。当符号被解析时,包含该符号的目标文件将被并入最终的二进制文件(一些链接器进一步优化,通过仅包含文件的特定部分)。如果静态库中的某个目标文件没有任何符号被引用,那么该目标文件可能会完全被省略。因此,静态库中只有实际使用的部分才会出现在最终的二进制文件中。

然而,在某些场景下,你可能需要一些未引用的符号:

  • 静态初始化:如果你的库有全局对象需要初始化(即它们的构造函数在 main() 之前执行),并且这些对象没有在其他地方直接引用;链接器可能会将它们从最终的二进制文件中排除。

  • 插件架构:如果你正在开发一个插件系统(使用模块库),其中的代码需要在运行时被识别并加载,而不需要直接引用。

  • 静态库中的未使用代码:如果你正在开发一个静态库,其中包含一些实用功能或代码,这些代码不一定总是被直接引用,但你仍希望它出现在最终的二进制文件中。

  • 模板实例化:对于依赖于模板的库;如果没有明确提到,某些模板实例化可能在链接过程中被忽略。

  • 链接问题:特别是在复杂的构建系统或庞大的代码库中,链接可能会产生不可预测的结果,其中某些符号或代码段似乎缺失。

在这些情况下,强制在链接过程中包含所有目标文件可能是有益的。这通常通过一种称为 whole-archive 链接模式来实现。

具体的编译器链接标志有:

  • --whole-archive 用于 GCC

  • --force-load 用于 Clang

  • /WHOLEARCHIVE 用于 MSVC

为此,我们可以使用 target_link_options() 命令:

target_link_options(tgt INTERFACE
  -Wl,--whole-archive $<TARGET_FILE:lib1> -Wl,--no-whole-archive
) 

然而,这个命令是特定于链接器的,因此需要使用生成器表达式来检测不同的编译器并提供相应的标志。幸运的是,CMake 3.24 引入了一个新的生成器表达式来实现这一目的:

target_link_libraries(tgt INTERFACE
  "$<LINK_LIBRARY:WHOLE_ARCHIVE,lib1>"
) 

使用这种方法可以确保 tgt 目标包含 lib1 库中的所有目标文件。

然而,仍需考虑一些潜在的缺点:

  • 增加的二进制文件大小:这个标志可能会显著增大你的最终二进制文件,因为指定库中的所有对象都会被包含在内,无论它们是否被使用。

  • 符号冲突的潜在风险:引入所有符号可能会导致与其他符号冲突,进而产生链接错误。

  • 维护开销:过度依赖此类标志可能会掩盖代码设计或结构中的潜在问题。

在了解如何解决常见的链接问题后,我们现在可以继续准备项目进行测试。

为了测试,分离 main() 函数

正如我们所建立的那样,链接器强制执行 ODR,并确保在链接过程中所有外部符号提供它们的定义。我们可能面临的另一个与链接器相关的挑战是项目的优雅和高效的测试。

在理想的情况下,我们应该测试与生产环境中运行的完全相同的源代码。一个全面的测试流水线会构建源代码,对生成的二进制文件进行测试,然后打包并分发可执行文件(可选择不包括测试本身)。

但是我们如何实现这一点呢?可执行文件通常有一个精确的执行流程,通常涉及读取命令行参数。C++ 的编译性质不容易支持可以临时注入到二进制文件中的可插拔单元,仅用于测试。这表明我们可能需要采取更为细致的方法来应对这一挑战。

幸运的是,我们可以使用链接器以一种优雅的方式帮助我们解决这个问题。考虑将程序的所有逻辑从 main() 提取到一个外部函数 start_program() 中,如下所示:

ch08/07-testing/main.cpp

extern int start_program(int, const char**);
int main(int argc, const char** argv) {
  return **start_program****(argc, argv);**
} 

当新写的 main() 函数只是将参数转发到另一个地方定义的函数(在另一个文件中)时,跳过测试是合理的。我们可以创建一个包含原始源代码的库,main() 中的源代码被包装在一个新的函数 start_program() 中。在这个示例中,代码检查命令行参数的数量是否大于 1

ch08/07-testing/program.cpp

#include <iostream>
int **start_program**(int argc, const char** argv) {
  if (argc <= 1) {
    std::cout << "Not enough arguments" << std::endl;
    return 1;
  }
  return 0;
} 

现在我们可以准备一个构建该应用程序并将这两个翻译单元链接在一起的项目:

ch08/07-testing/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Testing CXX)
add_library(program program.cpp)
add_executable(main main.cpp)
**target_link_libraries****(main program)** 

main 目标仅提供所需的 main() 函数。命令行参数验证逻辑包含在 program 目标中。我们现在可以通过创建另一个具有自己 main() 函数的可执行文件来进行测试,该文件将托管测试用例。

在现实世界的场景中,像 GoogleTestCatch2 这样的框架将提供自己的 main() 方法,可以替换程序的入口点并运行所有定义的测试。我们将在 第十一章测试框架 中深入探讨实际测试的主题。现在,让我们专注于一般原则,并直接在 main() 函数中编写自己的测试用例:

ch08/07-testing/test.cpp

#include <iostream>
extern int start_program(int, const char**);
using namespace std;
int main()
{
  cout << "Test 1: Passing zero arguments to start_program:\n";
  auto exit_code = start_program(0, nullptr);
  if (exit_code == 0)
    cout << "Test FAILED: Unexpected zero exit code.\n";
  else
    cout << "Test PASSED: Non-zero exit code returned.\n"; 
  cout << endl;
  cout << "Test 2: Passing 2 arguments to start_program:\n";
  const char *arguments[2] = {"hello", "world"};
  exit_code = start_program(2, arguments);
  if (exit_code != 0)
    cout << "Test FAILED: Unexpected non-zero exit code\n";
  else
    cout << "Test PASSED\n";
} 

上述代码将调用 start_program 两次,分别带有和不带有参数,并检查返回的退出代码是否正确。如果测试正确执行,您将看到以下输出:

./test
Test 1: Passing zero arguments to start_program:
Not enough arguments
Test PASSED: Non-zero exit code returned
Test 2: Passing 2 arguments to start_program:
Test PASSED 

Not enough arguments 行来自 start_program(),这是一个预期的错误消息(我们在检查程序是否正确失败)。

这个单元测试在清晰的代码和优雅的测试实践方面还有很多改进空间,但它是一个开始。

我们现在已经定义了两次 main()

  • main.cpp中用于生产环境

  • test.cpp中用于测试目的

现在,让我们在CMakeLists.txt的底部定义测试可执行文件:

add_executable(test test.cpp)
target_link_libraries(test program) 

这个新增内容创建了一个新的目标,链接到与我们的生产代码相同的二进制代码。但它赋予了我们根据需要调用所有导出函数的灵活性。得益于此,我们可以自动运行所有代码路径并检查它们是否按预期工作。太棒了!

总结

在 CMake 中的链接最初看起来可能很简单,但随着我们深入探讨,我们发现背后隐藏了更多内容。毕竟,链接可执行文件并不像拼图一样简单。当我们深入研究目标文件和库的结构时,我们清楚地看到,存储各种类型的数据、指令、符号名称等的段需要重新排序。在程序可以运行之前,这些段将进行所谓的重定位。

解决符号的问题也至关重要。链接器必须遍历所有翻译单元中的引用,确保没有遗漏。一旦解决了这些问题,链接器接着会创建程序头并将其放入最终的可执行文件中。这个头文件为系统加载器提供了指令,详细说明了如何将整合后的段落转换为构成进程运行时内存映像的段。我们还讨论了三种类型的库:静态库、共享库和共享模块。我们研究了它们之间的差异,以及在某些场景下某些库可能比其他库更适合使用。此外,我们还提到了一些有关 PIC 的内容——这是一个强大的概念,它促进了符号的懒绑定。

ODR 是一个 C++概念,但正如我们所看到的,它被链接器强力执行。我们探讨了如何解决静态库和动态库中最基本的符号重复问题。我们还强调了在可能的情况下使用命名空间的价值,并建议不要过度依赖链接器来避免符号冲突。

对于一个看起来可能很简单的步骤(鉴于 CMake 专门用于链接的命令较少),它确实有一些复杂性。一个较为棘手的方面是链接顺序,尤其是在处理具有嵌套和循环依赖的库时。我们现在理解了链接器是如何选择最终二进制文件中的符号的,以及在需要时如何覆盖这种行为。

最后,我们研究了如何利用链接器准备我们的程序进行测试——通过将main()函数分离到另一个翻译单元中。这使我们能够引入另一个可执行文件,该文件运行与生产中将要执行的机器代码完全相同的测试。

通过我们对链接的全新理解,我们已经准备好将外部库引入到 CMake 项目中。在下一章中,我们将学习如何管理 CMake 中的依赖关系。

深入阅读

关于本章讨论主题的更多信息,请参考以下内容:

加入我们社区的 Discord

加入我们社区的 Discord 空间,与作者和其他读者一起讨论:

discord.com/invite/vXN53A7ZcA

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/QR_Code94081075213645359.png

第九章:在 CMake 中管理依赖项

解决方案的大小无关紧要;随着项目的增长,你很可能会选择依赖其他项目。避免创建和维护模板代码的工作至关重要,这样可以腾出时间专注于真正重要的事情:业务逻辑。外部依赖有多种用途。它们提供框架和功能,解决复杂问题,并在构建和确保代码质量方面发挥关键作用。这些依赖项可以有所不同,从像Protocol BuffersProtobuf)这样的专用编译器到像 Google Test 这样的测试框架。

在处理开源项目或内部代码时,高效管理外部依赖项至关重要。手动进行这些管理将需要大量的设置时间和持续的支持。幸运的是,CMake 在处理各种依赖管理方法方面表现出色,同时能够保持与行业标准的同步。

我们将首先学习如何识别和利用主机系统上已有的依赖项,从而避免不必要的下载和延长的编译时间。这项任务相对简单,因为许多包要么与 CMake 兼容,要么 CMake 自带对其的支持。我们还将探索如何指示 CMake 查找并包含那些没有本地支持的依赖项。对于旧版包,某些情况下采用替代方法可能会更有效:我们可以使用曾经流行的 pkg-config 工具来处理更繁琐的任务。

此外,我们将深入探讨如何管理尚未安装在系统上的在线可用依赖项。我们将研究如何从 HTTP 服务器、Git 和其他类型的仓库中获取这些依赖项。我们还将讨论如何选择最佳方法:首先在系统内搜索,如果未找到包,则转而获取。最后,我们将回顾一种较旧的技术,用于下载外部项目,这在某些特殊情况下可能仍然适用。

在本章中,我们将涵盖以下主要内容:

  • 使用已安装的依赖项

  • 使用系统中未安装的依赖项

技术要求

你可以在 GitHub 上找到本章中的代码文件,链接为 github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch09

要构建本书中提供的示例,始终使用推荐的命令:

cmake -B <build tree> -S <source tree>
cmake --build <build tree> 

确保将 <build tree><source tree> 占位符替换为适当的路径。提醒一下:build tree 是目标/输出目录的路径,source tree 是你的源代码所在的路径。

使用已安装的依赖项

当我们的项目依赖于一个流行的库时,操作系统很可能已经安装了正确的包。我们只需要将它连接到项目的构建过程中。我们该怎么做呢?我们需要找到包在系统中的位置,以便 CMake 能够使用它的文件。手动完成这一过程是可行的,但每个环境都有些不同。在一个系统上有效的路径可能在另一个系统上无效。因此,我们应该在构建时自动找到这些路径。有多种方法可以实现这一点,但通常最好的方法是 CMake 内置的find_package()命令,它知道如何找到许多常用的包。

如果我们的包不受支持,我们有两个选择:

  • 我们可以编写一个小插件,称为find-module,来帮助find_package()

  • 我们可以使用一种较旧的方法,叫做pkg-config

让我们先从推荐的选项开始。

使用 CMake 的find_package()查找包。

让我们首先来看以下场景:你想改进网络通信或数据存储的方式。简单的纯文本文件或像 JSON 和 XML 这样的开放文本格式在大小上过于冗长。使用二进制格式会有所帮助,而像谷歌的 Protobuf 这样的知名库看起来是答案。

你已经阅读了说明并在系统上安装了所需的内容。现在该怎么办呢?如何让 CMake 的find_package()找到并使用这个新库?

要执行这个示例,我们必须安装我们想使用的依赖项,因为find_package()命令只会查找已经安装在系统中的包。它假设你已经安装了所有必要的包,或者用户知道如何安装所需的包。如果你想处理其他情况,你需要一个备用计划。你可以在使用系统中不存在的依赖项部分找到更多信息。

对于 Protobuf,情况相对简单:你可以从官方仓库(github.com/protocolbuffers/protobuf)下载、编译并安装库,或者使用操作系统中的包管理器。如果你按照第一章:CMake 的第一步中提到的 Docker 镜像进行操作,你的依赖项已经安装好了,你无需做任何事情。然而,如果你想自己尝试安装,Debian Linux 上安装 Protobuf 库和编译器的命令如下:

$ apt update
$ apt install protobuf-compiler libprotobuf-dev 

目前很多项目选择支持 CMake。它们通过创建一个配置文件并在安装过程中将其放入合适的系统目录来实现这一点。配置文件是选择支持 CMake 的项目中不可或缺的一部分。

如果你想使用一个没有配置文件的库,别担心。CMake 支持一种外部机制来查找此类库,称为查找模块。与配置文件不同,查找模块不是它们帮助定位的项目的一部分。实际上,CMake 本身通常会为许多流行的库提供这些查找模块。

如果你卡住了,既没有配置文件也没有查找模块,你还有其他选择:

  • 为特定包编写自己的查找模块并将其包含到你的项目中

  • 使用 FindPkgConfig 模块来利用传统的 Unix 包定义文件

  • 编写配置文件并请求包维护者将其包含进来

你可能会认为自己还没准备好创建这样的合并请求。没关系,因为你很可能不需要这么做。CMake 自带了超过 150 个查找模块,可以找到如 Boost、bzip2、curl、curses、GIF、GTK、iconv、ImageMagick、JPEG、Lua、OpenGL、OpenSSL、PNG、PostgreSQL、Qt、SDL、Threads、XML-RPC、X11 和 zlib 等库,也包括我们在本例中将使用的 Protobuf 文件。完整列表可以在 CMake 文档中找到(请参见进一步阅读部分)。

CMake 的find_package()命令可以使用查找模块和配置文件。CMake 首先检查其内建的查找模块。如果没有找到需要的模块,它会继续检查不同包提供的配置文件。CMake 会扫描通常安装包的路径(取决于操作系统)。它会寻找与这些模式匹配的文件:

  • <CamelCasePackageName>Config.cmake

  • <kebab-case-package-name>-config.cmake

如果你想将外部查找模块添加到你的项目中,设置CMAKE_MODULE_PATH变量。CMake 会首先扫描这个目录。

回到我们的示例,目标很简单:我想展示我可以构建一个有效使用 Protobuf 的项目。别担心,你不需要了解 Protobuf 就能理解发生了什么。简单来说,Protobuf 是一个将数据以特定二进制格式保存的库。这使得将 C++对象读写到文件或通过网络传输变得容易。为了设置这个,我们使用一个.proto文件来给 Protobuf 定义数据结构:

ch09/01-find-package-variables/message.proto

syntax = "proto3";
message Message {
    int32 id = 1;
} 

这段代码是一个简单的模式定义,包含了一个 32 位整数。Protobuf 包自带一个二进制文件,该文件会将这些.proto文件编译成 C++源文件和头文件,我们的应用程序可以使用这些文件。我们需要将这个编译步骤加入到构建过程中,但稍后我们会回到这个话题。现在,让我们看看main.cpp文件如何使用 Protobuf 生成的输出:

ch09/01-find-package-variables/main.cpp

**#****include****"message.pb.h"**
#include <fstream>
using namespace std;
int main()
{
  **Message m;**
  **m.****set_id****(****123****);**
  **m.****PrintDebugString****();**
  fstream fo("./hello.data", ios::binary | ios::out);
  **m.****SerializeToOstream****(&fo);**
  fo.close();
  return 0;
} 

我已经包含了一个 message.pb.h 头文件,我期望 Protobuf 会生成这个文件。这个头文件将包含在 message.proto 中配置的 Message 对象的定义。在 main() 函数中,我创建了一个简单的 Message 对象。我将其 id 字段设置为 123,作为一个随机示例,然后将其调试信息打印到标准输出。接下来,该对象的二进制版本被写入文件流中。这是类似 Protobuf 这样的序列化库的最基本用例。

message.pb.h 头文件必须在编译 main.cpp 之前生成。这是通过 Protobuf 编译器 protoc 完成的,它将 message.proto 作为输入。管理这个过程听起来很复杂,但其实并不复杂!

这是 CMake 魔法发生的地方:

ch09/01-find-package-variables/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(FindPackageProtobufVariables CXX)
**find_package****(Protobuf REQUIRED)**
protobuf_generate_cpp(GENERATED_SRC GENERATED_HEADER
                      message.proto)
add_executable(main main.cpp ${GENERATED_SRC} ${GENERATED_HEADER})
target_link_libraries(main PRIVATE **${Protobuf_LIBRARIES}**)
target_include_directories(main PRIVATE
  **${Protobuf_INCLUDE_DIRS}**${CMAKE_CURRENT_BINARY_DIR}
) 

让我们来逐步解析:

  • 前两行是直接的:它们设置了项目并指定将使用 C++ 语言。

  • find_package(Protobuf REQUIRED) 告诉 CMake 查找 Protobuf 库(通过执行捆绑的 FindProtobuf.cmake 查找模块),并为我们的项目做好准备。如果找不到库,构建将停止,因为我们使用了 REQUIRED 关键字。

  • protobuf_generate_cpp 是在 Protobuf 查找模块中定义的自定义函数。它自动化了调用 protoc 编译器的过程。成功编译后,它会将生成的源文件路径存储在作为前两个参数提供的变量中:GENERATED_SRCGENERATED_HEADER。所有后续的参数将被视为需要编译的文件列表(message.proto)。

  • add_executable 使用 main.cpp 和 Protobuf 生成的文件创建我们的可执行文件。

  • target_link_libraries 告诉 CMake 将 Protobuf 库链接到我们的可执行文件。

  • target_include_directories() 将包提供的必要 INCLUDE_DIRSCMAKE_CURRENT_BINARY_DIR 添加到 include 路径。后者告诉编译器在哪里找到 message.pb.h 头文件。

Protobuf 查找模块提供以下功能:

  • 它查找 Protobuf 库及其编译器。

  • 它提供了帮助函数来编译 .proto 文件。

  • 它设置了包含和链接的路径变量。

虽然并非每个模块都提供像 Protobuf 这样的方便助手函数,但大多数模块都会为你设置一些关键变量。这些变量对于管理项目中的依赖关系非常有用。无论你是使用内置的查找模块还是配置文件,在包成功找到之后,你可以期望以下一些或所有变量被设置:

  • <PKG_NAME>_FOUND:指示包是否成功找到。

  • <PKG_NAME>_INCLUDE_DIRS<PKG_NAME>_INCLUDES:指向包含包头文件的目录。

  • <PKG_NAME>_LIBRARIES<PKG_NAME>_LIBS:这些是你需要链接的库列表。

  • <PKG_NAME>_DEFINITIONS:包含包所需的任何编译器定义。

在运行 find_package() 后,你可以立即检查 <PKG_NAME>_FOUND 变量,看看 CMake 是否成功找到了该包。

如果某个包模块是为 CMake 3.10 或更高版本编写的,它也很可能提供目标定义。这些目标将被标记为 IMPORTED 目标,以区分它们来自外部依赖项。

Protobuf 是学习 CMake 中依赖项的一个很好的示例,它定义了模块特定的变量和 IMPORTED 目标。这样的目标让我们能够编写更加简洁的代码:

ch09/02-find-package-targets/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(FindPackageProtobufTargets CXX)
find_package(Protobuf REQUIRED)
protobuf_generate_cpp(GENERATED_SRC GENERATED_HEADER
  message.proto)
add_executable(main main.cpp ${GENERATED_SRC} ${GENERATED_HEADER})
target_link_libraries(main PRIVATE **protobuf::libprotobuf**)
target_include_directories(main PRIVATE
                                ${CMAKE_CURRENT_BINARY_DIR}) 

看看高亮代码与此示例的前一个版本有何不同:与使用列出文件和目录的变量相比,使用 IMPORTED 目标是个好主意。这种方法简化了列表文件。它还自动处理了瞬态使用要求或传递的属性,如这里的 protobuf::libprotobuf 目标所示。

如果你想确切知道某个特定的 find 模块提供了什么,最好的资源就是它的在线文档。例如,你可以通过以下链接在 CMake 官方网站上找到 Protobuf 的详细信息:cmake.org/cmake/help/latest/module/FindProtobuf.html

为了简化示例,本节中的例子将直接在找不到 Protobuf 库时失败。但一个真正稳健的解决方案应该验证 Protobuf_FOUND 变量,并为用户提供明确的诊断信息(以便他们可以安装它),或者自动执行安装。我们将在本章稍后学习如何做到这一点。

find_package() 命令有几个可以使用的参数。虽然它们的列表较长,但我们这里将重点介绍关键参数。该命令的基本格式是:

find_package(<Name> [version] [EXACT] [QUIET] [REQUIRED]) 

让我们来逐一解释这些可选参数的含义:

  • [version] 这指定了你所需的最小版本号,格式为 major.minor.patch.tweak(例如 1.22)。你还可以指定一个范围,例如 1.22...1.40.1,使用三个点作为分隔符。

  • EXACT:与非范围型的 [version] 一起使用,告诉 CMake 你需要一个精确版本,而不是更高版本。

  • QUIET:这会抑制所有关于包是否被找到的消息。

  • REQUIRED:如果未找到包,构建将停止并显示诊断信息,即使使用了 QUIET 参数。

如果你确定一个包应该在你的系统上,但 find_package() 无法找到它,你可以深入挖掘。 从 CMake 3.24 开始,你可以在 debug 模式下运行配置阶段以获取更多信息。使用以下命令:

cmake -B <build tree> -S <source tree> --debug-find-pkg=<pkg> 

使用此命令时要小心。确保你准确输入包名,因为它是区分大小写的。

关于find_package()命令的更多信息可以在文档页面找到:cmake.org/cmake/help/latest/command/find_package.html

查找模块是为 CMake 提供已安装依赖项信息的非常便捷的方式。大多数流行的库都在所有主要平台上得到 CMake 的广泛支持。但是,当我们想要使用一个还没有专门查找模块的第三方库时,该怎么办呢?

编写自己的查找模块

在极少数情况下,你想在项目中使用的库没有提供配置文件,并且 CMake 中也没有现成的查找模块。你可以为该库编写一个自定义的查找模块,并将其随项目一起分发。虽然这种情况并不理想,但为了照顾项目的用户,还是必须这么做。

我们可以尝试为libpqxx库编写一个自定义的查找模块,libpqxx是 PostgreSQL 数据库的客户端。libpqxx已经预安装在本书的 Docker 镜像中,因此如果你使用的是该镜像,就不必担心。Debian 用户可以通过libpqxx-dev包安装它(其他操作系统可能需要不同的命令):

apt-get install libpqxx-dev 

我们将首先编写一个名为FindPQXX.cmake的新文件,并将其存储在项目源树中的cmake/module目录下。为了确保 CMake 在调用find_package()时能够找到这个查找模块,我们将在CMakeLists.txt中使用list(APPEND)将该路径添加到CMAKE_MODULE_PATH变量中。简单提醒一下:CMake 会首先检查CMAKE_MODULE_PATH中列出的目录,以查找查找模块,然后才会在其他位置进行搜索。你完整的 listfile 应如下所示:

ch09/03-find-package-custom/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(FindPackageCustom CXX)
**list****(APPEND CMAKE_MODULE_PATH**
            **"${CMAKE_SOURCE_DIR}/cmake/module/"****)**
**find_package****(PQXX REQUIRED)**
add_executable(main main.cpp)
target_link_libraries(main PRIVATE **PQXX::PQXX**) 

完成这些步骤后,我们将继续编写实际的查找模块。如果FindPQXX.cmake文件为空,即使使用find_package()并加上REQUIRED选项,CMake 也不会报错。查找模块的作者需要负责设置正确的变量并遵循最佳实践(例如引发错误)。根据 CMake 的指南,以下是一些关键点:

  • 当调用find_package(<PKG_NAME> REQUIRED)时,CMake 会将<PKG_NAME>_FIND_REQUIRED变量设置为1。如果找不到库,查找模块应使用message(FATAL_ERROR)

  • 当使用find_package(<PKG_NAME> QUIET)时,CMake 会将<PKG_NAME>_FIND_QUIETLY设置为1。此时,查找模块应避免显示任何额外的消息。

  • CMake 会将<PKG_NAME>_FIND_VERSION变量设置为 listfiles 中指定的版本。如果查找模块无法找到正确的版本,应该触发FATAL_ERROR

当然,最好遵循上述规则,以确保与其他查找模块的一致性。

要为PQXX创建一个优雅的查找模块,按照以下步骤操作:

  1. 如果库和头文件的路径已经知道(由用户提供或从上次运行的缓存中检索),则使用这些路径创建IMPORTED目标。如果完成此操作,您可以停止这里。

  2. 如果路径未知,首先找到底层依赖(在本例中是 PostgreSQL)的库和头文件。

  3. 接下来,搜索常见路径以查找 PostgreSQL 客户端库的二进制版本。

  4. 同样,扫描已知路径以找到 PostgreSQL 客户端的include头文件。

  5. 最后,确认是否找到了库和头文件。如果找到了,就创建一个IMPORTED目标。

要为PQXX创建一个强大的查找模块,让我们专注于几个重要任务。首先,IMPORTED目标的创建有两种情况——要么用户指定了库的路径,要么路径是自动检测的。为了保持代码的简洁并避免重复,我们将编写一个函数来管理搜索过程的结果。

定义 IMPORTED 目标

要设置一个IMPORTED目标,我们实际上只需要定义一个带有IMPORTED关键字的库。这样,我们就可以在调用的CMakeLists.txt列表文件中使用target_link_libraries()命令。我们需要指定库的类型,为了简化,我们将其标记为UNKNOWN。这意味着我们不关心库是静态的还是动态的,我们只需要将一个参数传递给链接器。

接下来,我们为目标设置必要的属性——即IMPORTED_LOCATIONINTERFACE_INCLUDE_DIRECTORIES。我们使用传递给函数的参数来进行这些设置。虽然可以指定其他属性,如COMPILE_DEFINITIONS,但PQXX并不需要这些属性。

之后,为了提高查找模块的效率,我们将在缓存变量中存储找到的路径。这样,我们在未来的运行中就不需要重复搜索了。值得注意的是,我们在缓存中显式设置了PQXX_FOUND,使其全局可访问,并允许用户的CMakeLists.txt进行引用。

最后,我们将这些缓存变量标记为advanced,在 CMake GUI 中隐藏它们,除非激活了advanced选项。这是一个常见的最佳实践,我们也会采用这种做法。

以下是这些操作的代码示例:

ch09/03-find-package-custom/cmake/module/FindPQXX.cmake

# Defining IMPORTED targets
function(define_imported_target library headers)
  add_library(PQXX::PQXX UNKNOWN IMPORTED)
  set_target_properties(PQXX::PQXX PROPERTIES
    IMPORTED_LOCATION ${library}
    INTERFACE_INCLUDE_DIRECTORIES ${headers}
  )
  set(PQXX_FOUND 1 CACHE INTERNAL "PQXX found" FORCE)
  set(PQXX_LIBRARIES ${library}
      CACHE STRING "Path to pqxx library" FORCE)
  set(PQXX_INCLUDES ${headers}
      CACHE STRING "Path to pqxx headers" FORCE)
  mark_as_advanced(FORCE PQXX_LIBRARIES)
  mark_as_advanced(FORCE PQXX_INCLUDES)
endfunction() 

现在,我们来讨论如何使用自定义或以前存储的路径来加速设置过程。

接受用户提供的路径并重用缓存值

让我们处理一下用户将PQXX安装在非标准位置,并通过命令行参数-D提供所需路径的情况。如果是这样,我们立即调用之前定义的函数,并使用return()停止搜索。我们假设用户已提供了库及其依赖项(如 PostgreSQL)的准确路径:

ch09/03-find-package-custom/cmake/module/FindPQXX.cmake(续)

...
# Accepting user-provided paths and reusing cached values
if (PQXX_LIBRARIES AND PQXX_INCLUDES)
  define_imported_target(${PQXX_LIBRARIES} ${PQXX_INCLUDES})
  return()
endif() 

如果先前已进行过配置,这个条件将成立,因为变量 PQXX_LIBRARIESPQXX_INCLUDES 已存储在缓存中。

现在我们来看看如何处理查找 PQXX 依赖的附加库。

搜索嵌套依赖

为了使用 PQXX,主机系统必须安装 PostgreSQL。虽然在当前的查找模块中使用其他查找模块是完全可以的,但我们应该传递 REQUIREDQUIET 标志,以确保嵌套搜索和主搜索之间的一致行为。为此,我们将设置两个辅助变量来存储需要传递的关键字,并根据 CMake 接收到的参数填充它们:PQXX_FIND_QUIETLYPQXX_FIND_REQUIRED

# Searching for nested dependencies
set(QUIET_ARG)
if(PQXX_FIND_QUIETLY)
  **set****(QUIET_ARG QUIET)**
endif()
set(REQUIRED_ARG)
if(PQXX_FIND_REQUIRED)
  **set****(REQUIRED_ARG REQUIRED)**
endif()
**find_package****(PostgreSQL** **${QUIET_ARG}****${REQUIRED_ARG}****)** 

完成此操作后,我们将深入探讨如何精准定位 PQXX 库在操作系统中的位置。

搜索库文件

CMake 提供了 find_library() 命令来帮助查找库文件。该命令将接受要查找的文件名和可能的路径列表,路径格式为 CMake 的路径样式:

find_library(**<VAR_NAME>****<NAMES>****<PATHS>** NAMES  PATHS  <...>) 

<VAR_NAME> 将作为存储命令输出的变量名。如果找到匹配的文件,其路径将存储在 <VAR_NAME> 变量中。如果未找到,则 <VAR_NAME>-NOTFOUND 变量将被设置为 1。我们将使用 PQXX_LIBRARY_PATH 作为我们的 VAR_NAME,因此我们最终会得到 PQXX_LIBRARY_PATH 中的路径或 PQXX_LIBRARY_PATH-NOTFOUND 中的 1

PQXX 库通常会将其位置导出到 $ENV{PQXX_DIR} 环境变量中,这意味着系统可能已经知道它的位置。我们可以通过先使用 file(TO_CMAKE_PATH) 格式化它,然后将此路径包含在我们的搜索中:

ch09/03-find-package-custom/cmake/module/FindPQXX.cmake(续)

...
# Searching for library files
file(TO_CMAKE_PATH "$ENV{PQXX_DIR}" _PQXX_DIR)
find_library(PQXX_LIBRARY_PATH NAMES **libpqxx pqxx**
  PATHS
    ${_PQXX_DIR}/lib/${CMAKE_LIBRARY_ARCHITECTURE}
    # (...) many other paths - removed for brevity
    /usr/lib
  NO_DEFAULT_PATH
) 

NO_DEFAULT_PATH 关键字指示 CMake 绕过其标准的搜索路径列表。虽然通常不建议这样做(因为默认路径通常是正确的),但使用 NO_DEFAULT_PATH 可以让你在需要时明确指定自己的搜索位置。

接下来让我们来查找可以被库用户包含的必需头文件。

搜索头文件

为了查找所有已知的头文件,我们将使用 find_path() 命令,它的工作方式与 find_library() 非常相似。主要区别在于 find_library() 会自动为库添加系统特定的扩展,而使用 find_path() 时,我们需要指定确切的名称。

此外,别把 pqxx/pqxx 弄混了。它是一个实际的头文件,但其扩展名被库创建者故意省略,以便与 C++ 的 #include 指令对齐。这样,它就可以像这样使用尖括号:#include <pqxx/pqxx>

这是代码片段:

ch09/03-find-package-custom/cmake/module/FindPQXX.cmake(续)

...
# Searching for header files
find_path(PQXX_HEADER_PATH NAMES **pqxx/pqxx**
  PATHS
    ${_PQXX_DIR}/include
    # (...) many other paths - removed for brevity
    /usr/include
  NO_DEFAULT_PATH
) 

接下来,我们将看看如何完成搜索过程,处理任何缺失的路径,并调用定义 imported 目标的函数。

返回最终结果

现在,到了检查我们是否设置了任何PQXX_LIBRARY_PATH-NOTFOUNDPQXX_HEADER_PATH-NOTFOUND变量的时间。我们可以手动打印诊断消息并停止构建,也可以使用 CMake 的find_package_handle_standard_args()帮助函数。这个函数会将<PKG_NAME>_FOUND变量设置为1,如果路径变量正确填充。它还会提供适当的诊断消息(它会尊重QUIET关键字),如果在find_package()调用中提供了REQUIRED关键字,它将以FATAL_ERROR终止执行。

如果找到了库,我们将调用之前写的函数来定义IMPORTED目标并将路径存储在缓存中:

ch09/03-find-package-custom/cmake/module/FindPQXX.cmake(续)

...
# Returning the final results
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(
  PQXX DEFAULT_MSG PQXX_LIBRARY_PATH PQXX_HEADER_PATH
)
if (PQXX_FOUND)
  **define_imported_target(**
    **"${PQXX_LIBRARY_PATH};${POSTGRES_LIBRARIES}"**
    **"${PQXX_HEADER_PATH};${POSTGRES_INCLUDE_DIRECTORIES}"**
  **)**
elseif(PQXX_FIND_REQUIRED)
  message(FATAL_ERROR "Required PQXX library not found")
endif() 

就是这样!这个查找模块会找到PQXX并创建适当的PQXX::PQXX目标。完整文件可以在书籍的examples代码库中找到。

对于那些支持良好且很可能已经安装的库,这种方法非常有效。但如果你正在处理旧的、支持较差的包呢?类 Unix 系统有一个叫做pkg-config的工具,CMake 也有一个有用的包装模块来支持它。

使用 FindPkgConfig 发现遗留包

管理依赖关系并弄清楚必要的编译标志是一个与 C++ 库本身一样古老的挑战。为了解决这个问题,开发了各种工具,从简单的机制到集成到构建系统和 IDE 中的全面解决方案。PkgConfig(freedesktop.org/wiki/Software/pkg-config)就是其中一种工具,它曾经非常流行,通常可以在类 Unix 系统中找到,虽然它也可以在 macOS 和 Windows 上使用。

然而,PkgConfig 正在逐渐被更现代的解决方案所取代。那么,你还应该考虑支持它吗?很可能,你不需要。以下是原因:

  • 如果你的库没有提供.pc PkgConfig 文件,那么为一个过时的工具编写定义文件的价值不大;最好选择更新的替代方案

  • 如果你能选择一个支持 CMake 的较新版本的库(我们将在本章后面讨论如何从互联网下载依赖项)

  • 如果这个包被广泛使用,CMake 的最新版本可能已经包含了它的查找模块

  • 如果网上有社区创建的查找模块,并且它的许可证允许你使用它,那也是一个不错的选择

  • 如果你能自己编写并维护一个查找模块

只有在你正在使用的库版本已经提供了 PkgConfig .pc 文件,并且没有可用的配置模块或查找模块时,才使用 PkgConfig。此外,应该有充分的理由说明为什么自己创建一个查找模块不可行。如果你确信不需要 PkgConfig,可以跳过这一节。

不幸的是,并非所有环境都能迅速更新到最新版本的库。许多公司仍在生产中使用老旧系统,这些系统不再接收最新的包。如果您的系统中有某个库的 .pc 文件,它可能看起来像这里显示的 foobar 文件:

prefix=/usr/local
exec_prefix=${prefix}
includedir=${prefix}/include
libdir=${exec_prefix}/lib
Name: foobar
Description: A foobar library
Version: 1.0.0
Cflags: -I${includedir}/foobar
Libs: -L${libdir} -lfoobar 

PkgConfig 的格式简单,许多熟悉这个工具的开发者出于习惯,倾向于使用它,而不是学习更复杂的系统,如 CMake。尽管它很简单,PkgConfig 仍然能够检查特定的库及其版本是否可用,还能获取库的链接标志和目录信息。

要在 CMake 中使用它,您需要在系统中找到 pkg-config 工具,运行特定的命令,然后存储结果以便编译器后续使用。每次使用 PkgConfig 时都做这些步骤可能会觉得很繁琐。幸运的是,CMake 提供了一个 FindPkgConfig 查找模块。如果找到了 PkgConfig,PKG_CONFIG_FOUND 将被设置。然后,我们可以使用 pkg_check_modules() 查找所需的包。

我们在上一节中已经熟悉了 libpqxx,并且它提供了一个 .pc 文件,接下来我们将尝试使用 PkgConfig 查找它。为了实现这一点,让我们编写一个简单的 main.cpp 文件,使用一个占位符连接类:

ch09/04-find-pkg-config/main.cpp

#include <pqxx/pqxx>
int main()
{
  // We're not actually connecting, but
  // just proving that pqxx is available.
  pqxx::nullconnection connection;
} 

在典型的列表文件中,我们通常会先使用 find_package() 函数,如果未检测到库,再切换到 PkgConfig。这种方法在环境更新时很有用,因为我们可以继续使用 main 方法,而无需修改代码。为了简洁起见,本示例将跳过这一部分。

ch09/04-find-pkg-config/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(FindPkgConfig CXX)
**find_package****(PkgConfig REQUIRED)**
**pkg_check_modules(PQXX REQUIRED IMPORTED_TARGET libpqxx)**
message("PQXX_FOUND: ${**PQXX_FOUND**}")
add_executable(main main.cpp)
target_link_libraries(main PRIVATE **PkgConfig::PQXX**) 

让我们分解一下发生了什么:

  1. find_package() 命令用于查找 PkgConfig。如果 pkg-config 丢失,过程将因 REQUIRED 关键字而停止。

  2. FindPkgConfig 查找模块中的 pkg_check_modules() 自定义宏设置了一个名为 PQXX 的新 IMPORTED 目标。查找模块会查找 libpqxx 依赖项,如果找不到它,查找过程会失败,这又是由于 REQUIRED 关键字的作用。IMPORTED_TARGET 关键字至关重要;否则,我们将需要手动定义目标。

  3. 我们通过 message() 函数验证设置,显示 PQXX_FOUND。如果我们之前没有使用 REQUIRED,这里就是检查变量是否已设置的地方,可能用于激活其他回退方案。

  4. main 可执行文件通过 add_executable() 声明。

  5. 最后,我们使用 target_link_libraries() 将由 pkg_check_modules() 导入的 PkgConfig::PQXX 目标进行链接。请注意,PkgConfig:: 是固定的前缀,PQXX 是我们传递给宏的第一个参数派生出来的。

使用这个选项比为没有 CMake 支持的依赖项创建查找模块更快。然而,它也有一些缺点。一个问题是,它依赖于较旧的pkg-config工具,这在构建项目的操作系统中可能不可用。此外,这种方法会创建一个特殊情况,需要与其他方法不同的维护方式。

我们已经讨论了如何处理已安装在计算机上的依赖项。然而,这只是故事的一部分。很多时候,你的项目会交给那些可能没有系统上所有必需依赖项的用户。让我们看看如何处理这种情况。

使用系统中不存在的依赖项

CMake 在管理依赖项方面表现出色,特别是当依赖项尚未安装在系统中时。你可以采取几种方法。如果你使用的是 CMake 版本 3.14 或更新版本,那么FetchContent模块是管理依赖项的最佳选择。基本上,FetchContent是对另一个模块ExternalProject的用户友好封装。它不仅简化了过程,还增加了一些额外的功能。我们将在本章后面深入探讨ExternalProject。现在,只需知道这两者之间的主要区别是执行顺序:

  • FetchContent会在配置阶段引入依赖项。

  • ExternalProject会在构建阶段引入依赖项。

这个顺序很重要,因为在配置阶段,由FetchContent定义的目标将处于相同的命名空间中,因此可以轻松地在项目中使用它们。我们可以将它们与其他目标链接,就像我们自己定义的一样。有些情况下这样做并不合适,那时ExternalProject是必须的选择。

让我们先看看如何处理大多数情况。

FetchContent

FetchContent模块非常有用,它提供了以下功能:

  • 外部项目的目录结构管理

  • 从 URL 下载源代码(并在需要时从归档中提取)

  • 支持 Git、Subversion、Mercurial 和 CVS(并行版本系统)仓库

  • 如果需要,获取更新

  • 使用 CMake、Make 或用户指定的工具配置并构建项目

  • 提供其他目标的嵌套依赖项

使用FetchContent模块涉及三个主要步骤:

  1. 使用include(FetchContent)将模块添加到项目中。

  2. 使用FetchContent_Declare()命令配置依赖项。这将指示FetchContent依赖项的位置及使用的版本。

  3. 使用FetchContent_MakeAvailable()命令完成依赖项设置。这将下载、构建、安装并将列表文件添加到主项目中以供解析。

你可能会想知道为什么步骤 2步骤 3是分开的。原因是为了在多层项目中允许配置覆盖。例如,考虑一个依赖于外部库 A 和 B 的项目。库 A 也依赖于 B,但它的作者使用的是一个较旧版本,这个版本与父项目的版本不同(图 9.1):

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_09_01.png

图 9.1:层次化项目

如果配置和下载在同一个命令中进行,父项目将无法使用更新版本,即使它向后兼容,因为依赖已经为旧版本配置了导入目标,这会引起库的目标名称和文件的冲突。

为了指定需要的版本,最顶层的项目必须调用FetchContent_Declare()命令并提供 B 的覆盖配置,然后库 A 才会完全设置。随后在 A 中调用FetchContent_Declare()将被忽略,因为 B 的依赖已经配置好了。

让我们看看FetchContent_Declare()命令的签名:

FetchContent_Declare(<depName> <contentOptions>...) 

depName是依赖项的唯一标识符,稍后将由FetchContent_MakeAvailable()命令使用。

contentOptions提供了依赖项的详细配置,可能会变得相当复杂。重要的是要意识到,FetchContent_Declare()在后台使用的是较老的ExternalProject_Add()命令。实际上,许多传递给FetchContent_Declare的参数都会直接转发到该内部调用。在详细解释所有参数之前,让我们看看一个实际示例,它从 GitHub 下载依赖项。

使用 YAML 读取器的基本示例

我写了一个小程序,它从 YAML 文件中读取用户名并在欢迎信息中打印出来。YAML 是一个很好的简单格式,可以存储人类可读的配置,但机器解析起来相当复杂。我发现了一个很棒的小项目,解决了这个问题,它叫做yaml-cpp,由 Jesse Beder 开发(github.com/jbeder/yaml-cpp)。

这个示例相当直接。它是一个问候程序,打印出Welcome <name>信息。name的默认值为Guest,但我们可以在 YAML 配置文件中指定一个不同的名字。以下是 C++代码:

ch09/05-fetch-content/main.cpp

#include <string>
#include <iostream>
#include "yaml-cpp/yaml.h"
using namespace std;
int main() {
  string name = "Guest";
  YAML::Node config = YAML::LoadFile("config.yaml");
  if (config["name"])
    name = config["name"].as<string>();
  cout << "Welcome " << name << endl;
  return 0;
} 

这个示例的配置文件只有一行:

ch09/05-fetch-content/config.yaml

name: Rafal 

我们将在其他部分重用这个示例,所以请花点时间理解它的工作原理。现在代码已经准备好了,我们来看一下如何构建它并获取依赖:

ch09/05-fetch-content/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(ExternalProjectGit CXX)
add_executable(welcome main.cpp)
configure_file(config.yaml config.yaml COPYONLY)
**include****(FetchContent)**
**FetchContent_Declare(external-yaml-cpp**
 **GIT_REPOSITORY    https://github.com/jbeder/yaml-cpp.git**
 **GIT_TAG** **0.8****.****0**
**)**
**FetchContent_MakeAvailable(external-yaml-cpp)**
target_link_libraries(welcome PRIVATE yaml-cpp::yaml-cpp) 

我们可以显式访问由yaml-cpp库创建的目标。为了证明这一点,我们将使用CMakePrintHelpers帮助模块:

include(CMakePrintHelpers)
cmake_print_properties(TARGETS yaml-cpp::yaml-cpp
                       PROPERTIES TYPE SOURCE_DIR) 

当我们构建这样的项目时,配置阶段将打印以下输出:

Properties for TARGET yaml-cpp::yaml-cpp:
   yaml-cpp.TYPE = "STATIC_LIBRARY"
   yaml-cpp.SOURCE_DIR = "/tmp/b/_deps/external-yaml-cpp-src" 

这告诉我们,由 external-yaml-cpp 依赖项定义的目标存在,它是一个静态库,并且其源目录位于构建树内。这个输出对于实际项目来说不是必需的,但如果你不确定如何正确包含一个导入的目标,它有助于调试。

由于我们已经通过 configure_file() 命令将 .yaml 文件复制到输出目录,我们可以运行该程序:

~/examples/ch09/05-fetch-content$ /tmp/b/welcome
Welcome Rafal 

一切顺利!几乎没有任何工作,我们就引入了一个外部依赖,并在项目中使用了它。

如果我们需要多个依赖项,我们应编写多个 FetchContent_Declare() 命令,每次选择一个唯一的标识符。但不需要多次调用 FetchContent_MakeAvailable(),因为它支持多个标识符(这些标识符不区分大小写):

FetchContent_MakeAvailable(lib-A lib-B lib-C) 

现在,我们将学习如何编写依赖声明。

下载依赖

FetchContent_Declare() 命令提供了多种选项,这些选项来自于 ExternalProject 模块。基本上,你可以执行三种主要操作:

  • 下载依赖

  • 更新依赖

  • 补丁依赖

让我们从最常见的场景开始:从互联网获取文件。CMake 支持许多下载源:

  • HTTP 服务器(URL)

  • Git

  • Subversion

  • Mercurial

  • CVS

从列表顶部开始,我们首先探索如何从 URL 下载依赖,并根据需要定制此过程。

从 URL 下载

我们可以提供一个 URL 列表,按顺序扫描,直到下载成功为止。CMake 会识别下载的文件是否为压缩包,并默认解压它。

基本声明:

FetchContent_Declare(dependency-id
                     **URL <url1> [<url2>...]**
) 

下面是一些额外的选项,可以进一步定制此方法:

  • URL_HASH <algo>=<hashValue>:此项检查通过<algo>生成的下载文件的校验和是否与提供的<hashValue>匹配。建议使用此方法来确保下载文件的完整性。支持的算法包括:MD5SHA1SHA224SHA256SHA384SHA512SHA3_224SHA3_256SHA3_384SHA3_512

  • DOWNLOAD_NO_EXTRACT <bool>:此项明确禁用下载后解压缩。我们可以在后续步骤中通过访问 <DOWNLOADED_FILE> 变量来使用下载文件的文件名。

  • DOWNLOAD_NO_PROGRESS <bool>:此项明确禁用下载进度的日志记录。

  • TIMEOUT <seconds>INACTIVITY_TIMEOUT <seconds>:这些设置超时,以便在固定的总时间或不活动时间后终止下载。

  • HTTP_USERNAME <username>HTTP_PASSWORD <password>:这些配置 HTTP 身份验证。请小心不要硬编码凭证。

  • HTTP_HEADER <header1> [<header2>...]:这会向你的 HTTP 请求添加额外的头部,对于 AWS 或自定义令牌非常有用。

  • TLS_VERIFY <bool>:验证 SSL 证书。如果未设置此项,CMake 将从 CMAKE_TLS_VERIFY 变量中读取此设置,该变量默认设置为 false。跳过 TLS 验证是不安全且不推荐的做法,尤其是在生产环境中应避免。

  • TLS_CAINFO <file>:提供一个权限文件的路径;如果没有指定,CMake 会从 CMAKE_TLS_CAINFO 变量中读取此设置。如果你的公司发行的是自签名的 SSL 证书,则此选项很有用。

大多数程序员会参考像 GitHub 这样的在线仓库来获取库的最新版本。以下是操作方法。

从 Git 下载

要从 Git 下载依赖项,确保主机系统上安装了 Git 版本 1.6.5 或更高版本。以下选项对于从 Git 克隆项目至关重要:

FetchContent_Declare(dependency-id
                     **GIT_REPOSITORY <url>**
                     **GIT_TAG <tag>**
) 

<url><tag> 都应与 git 命令兼容。在生产环境中,建议使用特定的 git 哈希(而非标签),以确保生产的二进制文件可追溯,并避免不必要的 git fetch 操作。如果你更喜欢使用分支,可以使用类似 origin/main 的远程名称。这可以确保本地克隆的正确同步。

其他选项包括:

  • GIT_REMOTE_NAME <name>:设置远程名称(origin 是默认值)。

  • GIT_SUBMODULES <module>...:指定要更新的子模块;从 3.16 版本开始,此值默认为 none(之前会更新所有子模块)。

  • GIT_SUBMODULES_RECURSE 1:启用递归更新子模块。

  • GIT_SHALLOW 1:这将执行浅克隆,因为它跳过了下载历史提交,因此速度更快。

  • TLS_VERIFY <bool>:验证 SSL 证书。如果未设置此项,CMake 将从 CMAKE_TLS_VERIFY 变量中读取此设置,该变量默认设置为 false;跳过 TLS 验证是不安全且不推荐的做法,尤其是在生产环境中应避免。

如果你的依赖项存储在 Subversion 中,你也可以通过 CMake 获取它。

从 Subversion 下载

要从 Subversion 下载,我们需要指定以下选项:

FetchContent_Declare(dependency-id
                     **SVN_REPOSITORY <url>**
                     **SVN_REVISION -r<rev>**
) 

此外,我们可以提供以下内容:

  • SVN_USERNAME <user>SVN_PASSWORD <password>:这些提供了用于检出和更新的凭据。避免在项目中硬编码这些信息。

  • SVN_TRUST_CERT <bool>:跳过 Subversion 服务器站点证书的验证。仅在网络路径和服务器的完整性是可信的情况下使用此选项。

Subversion 与 CMake 配合使用非常简单,Mercurial 也是如此。

从 Mercurial 下载

这种模式非常简单,我们只需提供两个参数,就可以完成:

FetchContent_Declare(dependency-id
                     **HG_REPOSITORY <url>**
                     **HG_TAG <tag>**
) 

最后,我们可以使用 CVS 提供依赖项。

从 CVS 下载

要从 CVS 检出模块,我们需要提供以下三个参数:

FetchContent_Declare(dependency-id
                     **CVS_REPOSITORY <cvsroot>**
                     **CVS_MODULE <module>**
                     **CVS_TAG <tag>**
) 

这样,我们已经涵盖了FetchContent_Declare()的所有下载选项。CMake 支持在成功下载后执行的附加步骤。

更新和修补

默认情况下,如果下载方法支持更新,例如,如果我们配置了指向mainmaster分支的 Git 依赖项,则update步骤会重新下载外部项目的文件。我们可以通过两种方式覆盖这种行为:

  • 提供在更新过程中执行的自定义命令,使用UPDATE_COMMAND <cmd>

  • 完全禁用update步骤(以便在没有网络的情况下构建)– UPDATE_DISCONNECTED <bool>。请注意,依赖项仍然会在第一次构建时被下载。

Patch是一个可选步骤,会在更新后执行。要启用它,我们需要使用PATCH_COMMAND <cmd>指定要执行的精确命令。

CMake 文档警告说,有些补丁可能比其他补丁“更粘”。例如,在 Git 中,更新时修改的文件不会恢复到原始状态,我们需要小心避免错误地将文件补丁两次。理想情况下,patch命令应当是健壮且幂等的。

您可以将updatepatch命令串联起来:

FetchContent_Declare(dependency-id
                     **GIT_REPOSITORY <url>**
                     GIT_TAG <tag>
                     **UPDATE_COMMAND <cmd>**
                     **PATCH_COMMAND <cmd>**
) 

下载依赖项在系统上没有时是有帮助的。但如果它们已经存在呢?我们如何使用本地版本呢?

尽可能使用已安装的依赖项

从版本 3.24 开始,CMake 引入了一个功能,允许FetchContent跳过下载,如果依赖项已经在本地可用。要启用此功能,只需在声明中添加FIND_PACKAGE_ARGS关键字:

FetchContent_Declare(dependency-id
                     **GIT_REPOSITORY <url>**
                     GIT_TAG <tag>
                     **FIND_PACKAGE_ARGS <args>**
) 

如您所猜测的,这个关键字指示FetchContent模块在启动任何下载之前使用find_package()函数。如果在本地找到该包,则将使用它,不会发生下载或构建。请注意,这个关键字应当是命令中的最后一个,因为它会消耗后续的所有参数。

以下是如何更新之前的示例:

ch09/06-fetch-content-find-package/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(ExternalProjectGit CXX)
add_executable(welcome main.cpp)
configure_file(config.yaml config.yaml COPYONLY)
include(FetchContent)
FetchContent_Declare(external-yaml-cpp
  GIT_REPOSITORY    https://github.com/jbeder/yaml-cpp.git
  GIT_TAG           0.8.0
  FIND_PACKAGE_ARGS NAMES yaml-cpp
)
FetchContent_MakeAvailable(external-yaml-cpp)
target_link_libraries(welcome PRIVATE yaml-cpp::yaml-cpp)
include(CMakePrintHelpers)
cmake_print_properties(TARGETS yaml-cpp::yaml-cpp
                       PROPERTIES TYPE SOURCE_DIR
                       INTERFACE_INCLUDE_DIRECTORIES
                      ) 

我们做了两个关键的更改:

  1. 我们添加了FIND_PACKAGE_ARGSNAMES关键字,用来指定我们要查找yaml-cpp包。如果没有NAMES,CMake 将默认使用dependency-id,在这个例子中是external-yaml-cpp

  2. 我们在打印的属性中添加了INTERFACE_INCLUDE_DIRECTORIES。这是一次性检查,以便我们手动验证是否使用了已安装的包,还是下载了一个新的包。

在测试之前,请确保包已经实际安装在您的系统上。如果没有,您可以使用以下命令安装它:

git clone https://github.com/jbeder/yaml-cpp.git
cmake -S yaml-cpp -B build-dir
cmake --build build-dir
cmake --install build-dir 

使用这个设置,我们现在可以构建我们的项目。如果一切顺利,你应该能看到来自cmake_print_properties()命令的调试输出。这将表明我们正在使用本地版本,如INTERFACE_INCLUDE_DIRECTORIES属性中所示。请记住,这些输出是特定于你的环境的,结果可能因环境不同而有所不同。

--
 Properties for TARGET yaml-cpp::yaml-cpp:
   yaml-cpp::yaml-cpp.TYPE = "STATIC_LIBRARY"
   yaml-cpp::yaml-cpp.INTERFACE_INCLUDE_DIRECTORIES =
                                                "/usr/local/include" 

如果你没有使用 CMake 3.24,或者希望支持使用旧版本的用户,你可能会考虑手动运行find_package()命令。这样,你只会下载那些未安装的包:

find_package(yaml-cpp QUIET)
if (NOT TARGET yaml-cpp::yaml-cpp)
  # download missing dependency
endif() 

无论你选择哪种方法,首先尝试使用本地版本,只有在找不到依赖项时才下载,是一种非常周到的做法,可以提供最佳的用户体验。

在引入FetchContent之前,CMake 有一个更简单的模块,名为ExternalProject。虽然FetchContent是大多数情况下的推荐选择,但ExternalProject仍然有其自身的一些优点,在某些情况下可能会非常有用。

ExternalProject

如前所述,在FetchContent引入到 CMake 之前,另一个模块曾经承担类似的功能:ExternalProject(在 3.0.0 版本中添加)。顾名思义,它用于从在线仓库获取外部项目。多年来,该模块逐渐扩展以满足不同的需求,最终形成了一个相当复杂的命令:ExternalProject_Add()

ExternalProject模块在构建阶段填充依赖项。这与FetchContent在配置阶段执行的方式有很大不同。因此,ExternalProject不能像FetchContent那样将目标导入项目。另一方面,ExternalProject可以直接将依赖项安装到系统中,执行它们的测试,并做其他有趣的事情,比如覆盖配置和构建过程中使用的命令。

有一些少数的使用场景可能需要使用它。由于要有效地使用这个遗留模块需要较高的开销,因此可以将其视为一种好奇心。我们主要在此介绍它,是为了展示当前方法如何从它演变而来。

ExternalProject提供了一个ExternalProject_Add命令,用于配置依赖项。以下是一个示例:

include(ExternalProject)
ExternalProject_Add(external-yaml-cpp
  GIT_REPOSITORY    https://github.com/jbeder/yaml-cpp.git
  GIT_TAG           0.8.0
  INSTALL_COMMAND   ""
  TEST_COMMAND      ""
) 

如前所述,它与FetchContent中的FetchContent_Declare非常相似。你会注意到示例中有两个额外的关键词:INSTALL_COMMANDTEST_COMMAND。在这个例子中,它们用于抑制依赖项的安装和测试,因为这些操作通常会在构建过程中执行。ExternalProject执行许多可深度配置的步骤,并且这些步骤按以下顺序执行:

  1. mkdir: 为外部项目创建子目录。

  2. download: 从仓库或 URL 下载项目文件。

  3. update: 如果fetch方法支持,下载更新。

  4. patch: 执行一个patch命令,修改下载的文件。

  5. configure: 执行配置阶段。

  6. build: 执行 CMake 项目的构建阶段。

  7. install:安装 CMake 项目。

  8. test:执行测试。

对于每个步骤(排除 mkdir 外),你可以通过添加 <STEP>_COMMAND 关键字来覆盖默认行为。还有很多其他选项 – 请参考在线文档以获取完整参考。如果出于某种原因,你想使用这种方法而非推荐的 FetchContent,可以通过在 CMake 中执行 CMake 来应用一个不太优雅的黑客方式导入目标。更多细节,请查看本书仓库中的 ch09/05-external-project 代码示例。

通常,我们会依赖系统中已存在的库。如果库不存在,我们会使用 FetchContent,这是一种特别适合小型且编译速度较快的依赖项的方法。

然而,对于像 Qt 这样的大型库,这种方法可能比较耗时。在这种情况下,提供预编译库的包管理器,能根据用户环境量身定制库,成为一个明智的选择。尽管像 Apt 或 Conan 这样的工具提供了解决方案,但它们要么过于系统特定,要么复杂,无法在本书中详细介绍。好消息是,大多数用户只要提供明确的安装说明,就能安装你项目可能需要的依赖项。

总结

本章已经为你提供了如何使用 CMake 的查找模块识别系统安装的包,并且如何利用随库一起提供的配置文件的知识。对于不支持 CMake 但包含 .pc 文件的旧版库,可以使用 PkgConfig 工具和 CMake 内置的 FindPkgConfig 查找模块。

我们还探讨了 FetchContent 模块的功能。该模块允许我们从各种来源下载依赖项,同时配置 CMake 首先扫描系统,从而避免不必要的下载。我们还简要讨论了这些模块的历史背景,并讨论了在特殊情况下使用 ExternalProject 模块的选项。

CMake 设计的目的是在通过我们讨论的多数方法找到库时,自动生成构建目标。这为过程增加了方便性和优雅性。有了这个基础,你就可以将标准库集成到你的项目中了。

在下一章中,我们将学习如何使用 C++20 模块在更小的范围内提供可重用的代码。

进一步阅读

若要获取本章所涉及主题的更多信息,可以参考以下内容:

加入我们在 Discord 上的社区

加入我们社区的 Discord 空间,与作者及其他读者进行讨论:

discord.com/invite/vXN53A7ZcA

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/QR_Code94081075213645359.png

第十章:使用 C++20 模块

C++20 引入了一个新的语言特性:模块。它们将头文件中的纯文本符号声明替换为模块文件,模块文件将预编译成中间二进制格式,极大地减少了构建时间。

我们将讨论 CMake 中 C++20 模块的最重要话题,从 C++20 模块作为概念的概述开始:它们相较于标准头文件的优势,以及如何简化源代码中单元的管理。尽管精简构建过程的前景令人兴奋,本章也将重点讲解模块采用过程中的困难与漫长道路。

在理论部分讲解完毕后,我们将进入实践部分,讨论如何在项目中实现模块:我们将讨论如何在早期版本的 CMake 中启用它们的实验性支持,以及在 CMake 3.28 中的完整发布。

我们对 C++20 模块的探讨不仅仅是为了理解这一新特性——更是为了重新思考大型 C++项目中组件的交互方式。到本章结束时,你不仅能掌握模块的理论方面内容,还将通过实例获得实践经验,提升你利用这一特性优化项目结果的能力。

本章将讨论以下主要内容:

  • C++20 模块是什么?

  • 编写支持 C++20 模块的项目

  • 配置工具链

本章的技术要求与其他章节不同,请确保仔细阅读下一节内容。

技术要求

你可以在 GitHub 上找到本章中涉及的代码文件,网址为github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch10

尝试本章示例所需的工具链实用程序如下:

  • CMake 3.26 或更新版本(推荐使用 3.28)

  • 任何受支持的生成器:

    • Ninja 1.11 及更新版本(包括 Ninja 和 Ninja 多配置版本)

    • Visual Studio 17 2022 及更新版本

  • 任何受支持的编译器:

    • MSVC 工具集 14.34 及更新版本

    • Clang 16 及更新版本

    • GCC 14(适用于开发中的分支,2023 年 9 月 20 日后)及更新版本

如果你熟悉 Docker,可以使用在《第一章:CMake 入门》中“在不同平台上安装 CMake”部分介绍的完全配置的镜像。

要构建本章提供的示例,请使用以下命令:

cmake -B <build tree> -S <source tree> -G "Ninja" -D CMAKE_CXX_COMPILER=clang++-18 && cmake --build <build tree> 

请确保将占位符<build tree><source tree>替换为适当的路径。

C++20 模块是什么?

三年前,我就想写关于如何使用 C++模块的内容。尽管模块已被纳入 C++20 规范,但 C++生态系统的支持当时还远未准备好使用这一功能。幸运的是,自本书第一版以来,情况发生了很大变化,随着 CMake 3.28 的发布,C++20 模块得到了正式支持(尽管从 3.26 版本起就已提供实验性支持)。

三年时间看似很长,用来实现一个特性,但我们需要记住,这不仅仅取决于 CMake。许多拼图的部分必须协调工作。首先,我们需要编译器理解如何处理模块,其次,像 GNU Make 或 Ninja 这样的构建系统必须能够与模块兼容,只有这样 CMake 才能利用这些新机制来支持模块。

这告诉我们一件事:并不是每个人都会拥有最新的兼容工具,即便如此,目前的支持仍处于初期阶段。这些限制使得模块不适合广泛使用。所以,可能现在还不要依赖它们来构建生产级项目。

然而,如果你是前沿解决方案的爱好者,那你有福了!如果你能够严格控制项目的构建环境,比如使用专用机器或构建容器化(如 Docker 等),你可以在内部有效使用模块。只需小心行事,并理解你的使用情况可能会有所不同。可能会有一个时刻,你需要完全放弃模块,因为任何工具中的功能缺失或实现错误。

“模块”在 C++构建的语境中是一个非常多义的词。我们在本书中之前已经讨论过 CMake 中的模块:查找模块、实用模块等等。为了澄清,C++模块与 CMake 模块没有任何关系。实际上,它们是 C++20 版本中添加的语言原生特性。

从本质上讲,一个 C++模块是一个单一的源文件,它将头文件和实现文件的功能封装成一个连贯的代码单元。它包括两个主要组件:

  • 二进制模块接口BMI)的作用与头文件类似,但它采用二进制格式,显著减少了其他翻译单元在使用时的重新编译需求。

  • 模块实现单元提供模块的实现、定义和内部细节。其内容不能直接从模块外部访问,有效地封装了实现细节。

引入模块是为了减少编译时间,并解决预处理器和传统头文件的一些问题。让我们来看一下在一个典型的传统项目中,多个翻译单元是如何结合在一起的。

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_10_01.png

图 10.1:使用传统头文件的项目结构

上图展示了预处理器如何遍历项目树以构建程序。正如我们在第七章《使用 CMake 编译 C++ 源代码》中学到的,为了构建每个翻译单元,预处理器会机械地将文件拼接在一起。这意味着生成一个包含所有通过预处理器指令包含的头文件的长文件。这样,main.cpp 会先包含自己的源文件,然后是 lib.ha.h1.h2.h 的内容。只有这样,编译器才会启动并开始解析每个字符以生成二进制目标文件。直到我们意识到,为了编译 lib.cpp,在 main.cpp 中包含的头文件必须再次被编译。这种冗余随着每个翻译单元的增加而不断增长。

传统头文件还存在其他问题:

  • 包含保护 是必需的,如果忘记了,会导致问题。

  • 具有循环引用的符号需要前向声明

  • 对头文件的小改动会导致所有翻译单元的重新编译。

  • 预处理器宏很难调试和维护。

模块立即解决了许多这些问题,但仍然存在一些相关问题:像头文件一样,模块之间也可以相互依赖。当一个模块导入另一个模块时,我们仍然需要按照正确的顺序编译它们,从最嵌套的模块开始。这通常不是一个重大问题,因为模块往往比头文件大得多。在许多情况下,整个库可以存储在一个模块中。

让我们看看模块在实践中是如何编写和使用的。在这个简单的示例中,我们只会返回两个参数的和:

ch10/01-cxx-modules/math.cppm

export module math;
export int add(int a, int b) {
    return a + b;
} 

这样的模块不言自明:我们从一个声明开始,告诉程序的其余部分这是一个名为 math 的模块。接着,我们使用 export 关键字标注一个普通的函数定义,使其可以从模块外部访问。

你会注意到模块文件的扩展名与普通的 C++ 源代码不同。这只是一个约定,不应影响代码的处理方式。我的建议是根据你将使用的工具链来选择:

  • .ixx 是 MSVC 的扩展名。

  • .cppm 是 Clang 的扩展名。

  • .cxx 是 GCC 的扩展名。

要使用这个模块,我们需要在程序中导入它:

ch10/01-cxx-modules/main.cpp

**import** **math;**
#include <iostream>
int main() {
  std::cout << "Addition 2 + 2 = " << **add****(****2****,** **2****)** << std::endl;
  return 0;
} 

import math 语句足以将模块中导出的符号直接引入到 main 程序中。现在,我们可以在 main() 函数的主体中使用 add() 函数。表面上看,模块与头文件非常相似。但是,如果我们像往常一样编写 CMake 列表文件,我们可能无法成功构建项目。是时候引入使用 C++ 模块所需的步骤了。

使用 C++20 模块支持编写项目

本书主要讨论 CMake 3.26,但值得注意的是 CMake 经常更新,版本 3.28 就在本章印刷前发布。如果你正在使用此版本或更新版本,你可以通过将 cmake_minimum_required() 命令设置为 VERSION 3.28.0 来访问最新功能。

另一方面,如果你需要坚持使用旧版本或想要服务于那些可能没有升级的更广泛受众,你需要启用实验性支持以在 CMake 中使用 C++20 模块。

让我们探讨如何实现这个。

启用对 CMake 3.26 和 3.27 的实验性支持

实验性支持代表了一种协议:作为开发者,你承认这个特性还没有准备好用于生产环境,应仅用于测试目的。要签署这样的协议,你需要在项目的列表文件中将 CMAKE_EXPERIMENTAL_CXX_MODULE_CMAKE_API 变量设置为与你使用的 CMake 版本对应的特定值。

CMake 官方的 Kitware 仓库托管了一个问题追踪器,你可以搜索标签 area:cxxmodules。在 3.28 发布之前,只有一个问题被报告(在 3.25.0 中),这是一个潜在稳定特性的良好指标。如果你决定启用实验功能,构建你的项目以确认它能为你的用户工作。

以下是可以在 CMake 的仓库和文档中找到的标志:

  • 3c375311-a3c9-4396-a187-3227ef642046 用于 3.25(未记录)

  • 2182bf5c-ef0d-489a-91da-49dbc3090d2a 用于 3.26

  • aa1f7df0-828a-4fcd-9afc-2dc80491aca7 用于 3.27

不幸的是,如果你没有至少访问 CMake 3.25,你将无法使用。模块在此版本之前不可用。此外,如果 CMake 版本低于 3.27,你需要设置另一个变量来启用模块的动态依赖:

set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1) 

以下是你如何自动选择当前版本的正确 API 密钥,并明确禁用不支持的版本的构建(在此示例中,我们只支持 CMake 3.26 及以上)。

ch10/01-cxx-modules/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(CXXModules CXX)
# turn on the experimental API
if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.28.0)
  # Assume that C++ sources do import modules
  cmake_policy(SET CMP0155 NEW)
elseif(CMAKE_VERSION VERSION_GREATER_EQUAL 3.27.0)
  set(CMAKE_EXPERIMENTAL_CXX_MODULE_CMAKE_API
      "aa1f7df0-828a-4fcd-9afc-2dc80491aca7")
elseif(CMAKE_VERSION VERSION_GREATER_EQUAL 3.26.0)
  set(CMAKE_EXPERIMENTAL_CXX_MODULE_CMAKE_API
      "2182bf5c-ef0d-489a-91da-49dbc3090d2a")
  set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1)
else()
  message(FATAL_ERROR "Version lower than 3.26 not supported")
endif() 

让我们逐条分析:

  1. 首先,我们检查版本是否为 3.28 或更高。这使我们能够启用 CMP0155 策略,使用 cmake_policy()。如果我们希望支持低于 3.28 的版本,这是必需的。

  2. 如果不是这种情况,我们将检查版本是否高于 3.27。如果是,我们将设置相应的 API 密钥。

  3. 如果版本不高于 3.27,我们将检查它是否高于 3.26。如果是这样,设置适当的 API 密钥并启用实验性的 C++20 模块动态依赖标志。

  4. 如果版本低于 3.26,则不受我们的项目支持,将打印一个致命错误消息通知用户。

这使我们能够支持从 3.26 开始的一系列 CMake 版本。如果我们在项目要构建的每个环境中都能使用 CMake 3.28,那么上面的 if() 代码块就不再需要。那么,什么是必需的呢?

启用对 CMake 3.28 及更高版本的支持

要使用 C++20 模块(从 3.28 开始),你必须明确声明此版本为最小版本。可以使用如下的项目头文件:

cmake_minimum_required(VERSION 3.28.0)
project(CXXModules CXX) 

如果最小所需版本设置为 3.28 或更高,它将默认启用CMP0155策略。继续阅读,了解在定义模块之前,我们还需要配置哪些其他方面。如果需要 3.27 或更低版本,构建可能会失败,即使项目是使用 CMake 3.28 或更新版本构建的。

接下来需要考虑的是编译器要求。

设置编译器要求

无论我们使用 CMake 3.26、3.27、3.28 还是更新版本构建,要创建使用 C++模块的解决方案,都需要设置两个全局变量。第一个禁用不支持的 C++扩展,第二个确保编译器支持所需的标准。

ch10/01-cxx-modules/CMakeLists.txt(续)

# Libc++ has no support compiler extensions for modules.
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD 20) 

设置标准可能看起来是多余的,因为支持模块的编译器数量非常有限。然而,为了确保项目的未来可用性,这是一个很好的实践。

一般配置相当直接,到此为止。我们现在可以继续在 CMake 中定义一个模块。

声明 C++模块

CMake 模块定义利用了target_sources()命令和FILE_SET关键字:

target_sources(math
  **PUBLIC FILE_SET CXX_MODULES TYPE CXX_MODULES FILES** **math****.cppm**
) 

在上面突出显示的行中,我们引入了一种新的文件集类型:CXX_MODULES。从 CMake 3.28 开始,默认支持此类型。对于 3.26 版本,需要启用实验性 API。如果没有正确的支持,将会出现如下错误信息:

CMake Error at CMakeLists.txt:25 (target_sources):
  target_sources File set TYPE may only be "HEADERS" 

如果你在构建输出中看到这个消息,请检查你的代码是否正确。如果 API 密钥值对于所用版本不正确,也会出现此消息。

在同一个二进制文件中定义模块,正如之前讨论的那样,具有许多好处。然而,当创建一个库时,这些优势更加明显。这样的库可以在其他项目中使用,也可以被同一个项目中的其他库使用,从而进一步增强模块化。

要声明模块并将其与主程序链接,可以使用以下 CMake 配置:

ch10/01-cxx-modules/CMakeLists.txt(续)

add_library(math)
target_sources(math
  **PUBLIC FILE_SET CXX_MODULES FILES** **math****.cppm**
)
target_compile_features(math PUBLIC cxx_std_20)
set_target_properties(math PROPERTIES CXX_EXTENSIONS OFF)
add_executable(main main.cpp)
target_link_libraries(main PRIVATE math) 

为了确保这个库可以在其他项目中使用,我们必须使用target_compile_features()命令,并明确要求cxx_std_20。此外,我们还需要在目标级别重复设置CXX_EXTENSIONS OFF。如果没有这个设置,CMake 会生成错误并停止构建。这看起来有些冗余,可能会在未来的 CMake 版本中解决。

项目设置完成后,是时候进行最终的构建了。

配置工具链

根据 Kitware 页面上的一篇博客文章(见进一步阅读部分),CMake 早在版本 3.25 就已支持模块。尽管 3.28 正式支持此功能,但这并不是我们享受模块便利所需解决的唯一问题。

下一个要求集中在构建系统上:它需要支持动态依赖。目前,你只有两个选择:

  • Ninja 1.11 及更新版本(Ninja 和 Ninja Multi-Config)

  • Visual Studio 17 2022 及更新版本

同样,你的编译器需要生成特定格式的文件,以便 CMake 映射源依赖。该格式在 Kitware 开发人员撰写的论文 p1589r5 中有描述。这篇论文已提交给所有主要编译器进行实现。目前,只有三种编译器已经成功实现了所需的格式:

  • Clang 16

  • Visual Studio 2022 17.4(19.34)中的 MSVC

  • GCC 14(用于开发中的分支,在 2023-09-20 后)及更新版本

假设你已经在环境中配置了所有必要的工具(你可以使用我们为本书提供的 Docker 镜像),并且你的 CMake 项目已经准备好构建,那么剩下的就是配置 CMake 以使用所需的工具链。正如你在第一章中回顾的那样,你可以使用 -G 命令行参数选择构建系统生成器:

cmake -B <build tree> -S <source tree> -G "Ninja" 

该命令将配置项目使用 Ninja 构建系统。下一步是设置编译器。如果你的默认编译器不支持模块,而你已安装了另一个编译器来尝试,那么你可以通过像这样定义全局变量 CMAKE_CXX_COMPILER 来实现:

cmake -B <build tree> -S <source tree> -G "Ninja" -D CMAKE_CXX_COMPILER=clang++-18 

我们在示例中选择了 Clang 18,因为它是撰写本文时最新的版本(捆绑在 Docker 镜像中)。在成功配置后(你可能会看到一些关于实验性功能的警告),你需要构建项目:

cmake --build <build tree> 

一如既往,请确保将占位符 <build tree><source tree> 替换为适当的路径。如果一切顺利,你可以运行你的程序并观察模块功能是否按预期工作:

$ ./main
Addition 2 + 2 = 4 

这就是 C++20 模块在实践中工作的方式。

进一步阅读部分包括来自 Kitware 的一篇博客文章,以及关于 C++ 编译器源依赖格式的提案,提供了有关 C++20 模块实现和使用的更多见解。

总结

在本章中,我们深入探讨了 C++20 模块,明确它们不同于 CMake 模块,并代表了 C++ 在简化编译方面的一项重大进展,解决了与冗余头文件编译和有问题的预处理器宏相关的挑战。我们展示了如何使用一个简单的示例编写并导入 C++20 模块。接着,我们探索了如何为 C++20 模块设置 CMake。由于此功能仍处于实验阶段,因此需要设置特定的变量,我们提供了一系列条件语句,以确保你的项目正确配置了所使用的 CMake 版本。

关于所需的工具,我们强调了构建系统必须支持动态依赖,目前的选项是 Ninja 1.11 或更新版本。对于编译器支持,Clang 16 和 Visual Studio 2022 17.4(19.34)中的 MSVC 支持完整的 C++20 模块,而 GCC 的支持仍在等待中。我们还指导您如何配置 CMake 以使用选定的工具链,包括选择构建系统生成器并设置编译器版本。在配置并构建项目之后,您可以运行程序查看 C++20 模块的实际效果。

在下一章中,我们将学习自动化测试的重要性及其应用,以及 CMake 对测试框架的支持。

进一步阅读

欲了解更多信息,您可以参考以下资源:

留下评价!

喜欢这本书吗?通过在亚马逊上留下评价来帮助像您一样的读者。扫描下面的二维码,获取您选择的免费电子书。

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/Review_Copy.png

第十一章:测试框架

有经验的专业人士知道,测试必须自动化。几年前有人向他们解释过这一点,或者他们是通过吃了不少苦头才学到这一点。这种做法对没有经验的程序员来说并不那么显而易见;他们觉得这只是额外的、无意义的工作,似乎没有带来太多价值。可以理解:当一个人刚开始编写代码时,他们还没有创建真正复杂的解决方案,也没有在大型代码库上工作。很可能,他们是自己独立开发他们的个人项目。这些早期的项目很少超过几个月就能完成,因此几乎没有机会看到代码在较长时间内如何退化。

所有这些因素都促使编程新手认为编写测试是一种浪费时间和精力的行为。初学者可能会告诉自己,他们实际上每次通过构建和运行过程时都会测试自己的代码。毕竟,他们已经手动确认了自己的代码是正常工作的,并且能够按预期执行。那么,是时候进行下一个任务了,对吧?

自动化测试确保了新更改不会无意中破坏我们的程序。在本章中,我们将学习为什么测试很重要,以及如何使用 CMake 附带的工具 CTest 来协调测试执行。CTest 可以查询可用的测试、过滤执行、洗牌、重复执行并设置时间限制。我们将探讨如何使用这些功能、控制 CTest 的输出以及处理测试失败。

接下来,我们将修改项目结构以适应测试,并创建我们自己的测试运行器。在介绍了基本原理之后,我们将继续添加流行的测试框架:Catch2 和 GoogleTest(也称为 GTest),以及它的模拟库。最后,我们将介绍使用 LCOV 进行详细的测试覆盖率报告。

本章将涵盖以下主要内容:

  • 为什么自动化测试值得投入精力?

  • 使用 CTest 标准化 CMake 中的测试

  • 创建最基本的 CTest 单元测试

  • 单元测试框架

  • 生成测试覆盖率报告

技术要求

您可以在 GitHub 上找到本章中出现的代码文件,地址是 github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch11

要构建本书中提供的示例,请始终使用推荐的命令:

cmake -B <build tree> -S <source tree>
cmake --build <build tree> 

请务必将占位符 <build tree><source tree> 替换为适当的路径。提醒一下,build tree 是目标/输出目录的路径,source tree 是源代码所在的路径。

为什么自动化测试值得投入精力?

想象一个工厂生产线,其中一台机器在钢板上打孔。这些孔需要具有特定的大小和形状,以便容纳用于最终产品的螺栓。生产线的设计师会设置机器,测试孔的大小,然后继续前进。最终,某些情况会发生变化:钢板可能会变厚,工人可能会调整孔的大小,或者因为设计变化,需要打更多的孔。一个聪明的设计师会在关键点安装质量控制检查,以确保产品符合规格。无论孔是如何制作的:钻孔、冲孔或激光切割,它们必须满足特定要求。

同样的原则也适用于软件开发。我们很难预测哪些代码能在多年后保持稳定,哪些会经历多次修订。随着软件功能的扩展,我们必须确保不会无意间破坏已有的功能。我们会犯错。即使是最优秀的程序员也无法预见每一个变更的影响。开发人员经常在自己没有最初编写的代码上工作,可能并不了解代码背后的所有假设。他们会阅读代码,形成心理模型,做出修改,然后祈祷一切顺利。当这种方法失败时,修复 bug 可能需要几个小时或几天,并且会对产品及其用户产生负面影响。

有时,你会发现某些代码很难理解。你可能甚至会开始责怪别人搞砸了,但最后发现原来是自己写错了。这种情况通常发生在代码编写得很快,且没有充分理解问题的情况下。

作为开发人员,我们不仅面临项目截止日期和有限预算的压力,有时我们还会在夜里被叫醒修复一个关键问题。令人惊讶的是,一些不那么明显的错误竟然能从代码审查中漏过。

自动化测试可以防止大多数这些问题。它们是用来验证另一段代码是否正确执行的代码片段。顾名思义,这些测试会在有人修改代码时自动运行,通常作为构建过程的一部分。它们通常作为一个步骤添加,以确保在将代码合并到代码库之前,代码的质量是可靠的。

你可能会想跳过创建自动化测试以节省时间,但那是一个代价高昂的错误。正如斯蒂芬·赖特所说:“经验是你在需要它之后才会获得的东西。”除非你在编写一次性脚本或进行实验,否则不要跳过测试。你可能会一开始感到沮丧,因为你精心编写的代码总是无法通过测试。但请记住,测试失败意味着你刚刚避免了将一个重大问题引入生产环境。现在花在测试上的时间,未来会为你节省修复 bug 的时间——并让你晚上睡得更安稳。测试并不像你想象的那样难以添加和维护。

使用 CTest 标准化 CMake 中的测试

从根本上说,自动化测试就是运行一个可执行文件,将你的被测试系统SUT)置于特定状态,执行你想要测试的操作,并检查结果是否符合预期。你可以将它们视为完成句子GIVEN_<CONDITION>_WHEN_<SCENARIO>_THEN_<EXPECTED-OUTCOME>的结构化方式,并验证它是否对 SUT 成立。一些资源建议按照这种方式命名你的测试函数:例如,GIVEN_4_and_2_WHEN_Sum_THEN_returns_6

实现和执行这些测试有很多方法,这取决于你选择的框架、如何将其与 SUT 连接以及其具体设置。对于首次与项目互动的用户来说,即使是像测试二进制文件的文件名这样的细节,也会影响他们的体验。因为没有标准的命名约定,一个开发者可能会将他们的测试可执行文件命名为test_my_app,另一个可能选择unit_tests,而第三个可能选择一个不太直接的名称或根本不进行测试。弄清楚要运行哪个文件、使用哪个框架、传递什么参数以及如何收集结果,都是用户宁愿避免的麻烦。

CMake 通过一个独立的ctest命令行工具解决了这个问题。通过项目作者通过列表文件配置,它提供了一种标准化的测试运行方式。这种统一的接口适用于每个使用 CMake 构建的项目。遵循这个标准,你将享受到其他好处:将项目集成到持续集成/持续部署CI/CD)管道中变得更容易,并且测试结果能更方便地显示在像 Visual Studio 或 CLion 这样的 IDE 中。最重要的是,你只需付出最小的努力,就能获得一个强大的测试运行工具。

那么,如何在一个已经配置好的项目中使用 CTest 运行测试呢?你需要选择以下三种操作模式之一:

  • 仪表板

  • 测试

  • 构建并测试

仪表板模式允许你将测试结果发送到一个名为 CDash 的独立工具,也是 Kitware 开发的。CDash 收集并展示软件质量测试结果,提供一个易于导航的仪表板。对于非常大的项目,这个话题非常有用,但超出了本书的讨论范围。

测试模式的命令行如下:

ctest [<options>] 

在这个模式下,CTest 应该在你使用 CMake 构建项目后,在构建树中运行。这里有很多可用的选项,但在深入讨论之前,我们需要解决一个小小的不便:ctest二进制文件必须在构建树中运行,且只能在项目构建完成后运行。在开发周期中,这可能会有点尴尬,因为你需要运行多个命令并在目录之间切换。

为了简化这一过程,CTest 提供了构建并测试模式。我们将首先探索这个模式,以便稍后能全神贯注地讨论测试模式

构建并测试模式

要使用这个模式,我们需要执行ctest,后跟--build-and-test

ctest --build-and-test <source-tree> <build-tree>
      --build-generator <generator> [<options>...]
      [--build-options <opts>...]
      [--test-command <command> [<args>...]] 

本质上,这是测试模式的一个简单封装。它接受构建配置选项和在--test-command参数之后的测试命令。需要注意的是,除非在--test-command之后包含ctest关键字,否则不会运行任何测试,如下所示:

ctest --build-and-test project/source-tree /tmp/build-tree --build-generator "Unix Makefiles" --test-command ctest 

在此命令中,我们指定了源路径和构建路径,并选择了一个构建生成器。所有三个都必须指定,并遵循cmake命令的规则,详细描述见第一章CMake 初步使用指南

您可以添加更多参数,通常它们分为三类:配置控制、构建过程或测试设置。

配置阶段的参数如下:

  • --build-options—为cmake配置包含额外选项。将其放在--test-command之前,且--test-command必须位于最后。

  • --build-two-config—为 CMake 运行两次配置阶段。

  • --build-nocmake—跳过配置阶段。

  • --build-generator-platform—提供一个特定于生成器的平台。

  • --build-generator-toolset—提供一个特定于生成器的工具集。

  • --build-makeprogram—为基于 Make 或 Ninja 的生成器指定一个make可执行文件。

构建阶段的参数如下:

  • --build-target—指定要构建的目标。

  • --build-noclean—在不先构建clean目标的情况下进行构建。

  • --build-project—指定正在构建的项目名称。

测试阶段的参数如下:

  • --test-timeout—为测试设置时间限制(秒)。

现在我们可以配置测试模式,可以通过在--test-command cmake后添加参数,或者直接运行测试模式来实现。

测试模式

在构建项目后,您可以在构建目录中使用ctest命令来运行测试。如果您正在使用构建和测试模式,这将由系统为您完成。通常情况下,只运行不带任何额外标志的ctest就足够了。如果所有测试都成功,ctest将返回一个0的退出码(在类 Unix 系统上),您可以在 CI/CD 管道中验证此退出码,以防止将有缺陷的更改合并到生产分支。

编写良好的测试与编写生产代码本身一样具有挑战性。我们将系统被测试单元(SUT)设置为特定状态,运行单个测试,然后销毁 SUT 实例。这个过程相当复杂,可能会产生各种问题:跨测试污染、时序和并发干扰、资源竞争、死锁导致的执行冻结等。

幸运的是,CTest 提供了多种选项来缓解这些问题。您可以控制运行哪些测试、它们的执行顺序、它们生成的输出、时间限制、重复率等。以下部分将提供必要的背景信息,并简要概述最有用的选项。

查询测试

我们可能需要做的第一件事是了解哪些测试实际上已经为项目编写。CTest 提供了-N选项,该选项禁用执行并只打印一个列表,如下所示:

# ctest -N
Test project /tmp/b
  Test #1: SumAddsTwoInts
  Test #2: MultiplyMultipliesTwoInts
Total Tests: 2 

你可能希望使用-N与下一节描述的过滤器来检查在应用过滤器时会执行哪些测试。

如果你需要一种可以被自动化工具处理的 JSON 格式,可以使用ctest并加上--show-only=json-v1选项。

CTest 还提供了一种机制,通过LABELS关键字对测试进行分组。要列出所有可用的标签(但不实际执行任何测试),可以使用--print-labels。当测试通过add_test(<name> <test-command>)命令手动定义在列表文件中时,此选项非常有用,因为你可以通过测试属性指定单独的标签,像这样:

set_tests_properties(<name> PROPERTIES LABELS "<label>") 

但请记住,来自不同框架的自动化测试发现方法可能不支持这种详细的标签级别。

过滤测试

有时,你可能只想运行特定的测试,而不是整个测试套件。例如,如果你在调试一个单独失败的测试,就没有必要运行其他所有测试。你还可以使用这种机制将测试分配到多台机器上,适用于大型项目。

这些标志将根据提供的<r> 正则表达式 (regex) 过滤测试,如下所示:

  • -R <r>--tests-regex <r> - 仅运行名称匹配<r>的测试

  • -E <r>--exclude-regex <r> - 跳过名称匹配<r>的测试

  • -L <r>--label-regex <r> - 仅运行标签匹配<r>的测试

  • -LE <r>--label-exclude <regex> - 跳过标签匹配<r>的测试

高级场景可以通过--tests-information选项(或其简写形式-I)来实现。此选项接受用逗号分隔的格式<start>,<end>,<step>,<test-IDs>。你可以省略任何字段,但需要保留逗号。<Test IDs>选项是一个逗号分隔的测试序号列表。例如:

  • -I 3,, 将跳过测试 1 和测试 2(从第三个测试开始执行)

  • -I ,2, 将只运行第一个和第二个测试

  • -I 2,,3 将从第二个测试开始,按每三个测试执行一次

  • -I ,0,,3,9,7 只会运行第三个、第九个和第七个测试

你还可以在文件中指定这些范围,以便在多台机器上分布式地执行测试,适用于非常大的测试套件。使用-I-R时,只有同时满足两个条件的测试才会运行。如果你希望运行满足任一条件的测试,可以使用-U选项。如前所述,你可以使用-N选项来检查过滤结果。

打乱测试

编写单元测试可能会很棘手。其中一个令人惊讶的问题是测试耦合,即一个测试通过不完全设置或清除被测系统的状态而影响另一个测试。换句话说,第一个执行的测试可能会“泄漏”其状态并污染第二个测试。这种耦合是个大问题,因为它引入了测试之间未知的、隐性的关系。

更糟糕的是,这种错误通常能够在复杂的测试场景中藏得非常隐蔽。我们可能会在某个测试随机失败时发现它,但也有相反的可能性:一个错误的状态导致测试通过,而实际上不应该通过。这样的错误通过的测试会给开发者带来安全感的错觉,这甚至比没有测试还要糟糕。认为代码已经通过正确测试的假设可能会促使开发者做出更大胆的行动,从而导致意外的结果。

发现此类问题的一种方法是将每个测试单独运行。通常,直接从测试框架执行测试运行器时并不会如此。要运行单个测试,你需要向测试可执行文件传递一个框架特定的参数。这使得你能够发现那些在测试套件中通过但单独执行时失败的测试。

另一方面,CTest 通过隐式地在子 CTest 实例中执行每个测试用例,有效地消除了测试之间基于内存的交叉污染。你甚至可以更进一步,添加 --force-new-ctest-process 选项来强制使用独立的进程。

不幸的是,如果你的测试使用了外部的、争用的资源,比如 GPU、数据库或文件,这种方法单独是行不通的。我们可以采取的一个额外预防措施是随机化测试执行的顺序。引入这种变化通常足以最终检测出虚假的通过测试。CTest 支持通过 --schedule-random 选项实现这种策略。

处理失败

这是约翰·C·麦克斯威尔(John C. Maxwell)的一句名言:“早失败,频繁失败,但始终向前失败。” 向前失败意味着从我们的错误中学习。这正是我们在运行单元测试时(或许在生活的其他领域)想要做到的。除非你在附加调试器的情况下运行测试,否则很难检测到你犯错的地方,因为 CTest 会简洁地列出失败的测试,而不会实际打印出它们的输出。

测试用例或被测试系统(SUT)打印到 stdout 的信息可能对于准确判断出错原因至关重要。为了查看这些信息,我们可以使用 --output-on-failure 运行 ctest。或者,设置环境变量 CTEST_OUTPUT_ON_FAILURE 也会产生相同的效果。

根据解决方案的大小,遇到任何测试失败时可能有意义停止执行。可以通过向 ctest 提供 --stop-on-failure 参数来实现这一点。

CTest 会存储失败测试的名称。为了节省时间在漫长的测试套件中,我们可以专注于这些失败的测试,跳过执行那些通过的测试,直到问题解决为止。这个功能可以通过 --rerun-failed 选项启用(其他任何过滤器将被忽略)。记住,在解决所有问题后,运行所有测试以确保在此期间没有引入回归。

当 CTest 没有检测到任何测试时,这可能意味着两件事:要么测试不存在,要么项目存在问题。默认情况下,ctest 会打印一条警告信息并返回 0 的退出码,以避免混淆视听。大多数用户会有足够的上下文来理解他们遇到的情况以及接下来的处理方式。然而,在一些环境中,ctest 会作为自动化管道的一部分始终被执行。在这种情况下,我们可能需要明确指出,缺少测试应该被视为错误(并返回一个非零退出码)。我们可以通过提供 --no-tests=error 参数来配置这种行为。相反的行为(不显示警告)可以使用 --no-tests=ignore 选项。

重复测试

迟早在你的职业生涯中,你会遇到大多数时候都能正确工作的测试。我想强调的是“多数”这个词。偶尔,这些测试会因为环境原因而失败:比如时间模拟错误、事件循环问题、异步执行处理不当、并行性问题、哈希冲突等,这些复杂的场景并非每次都会发生。这些不可靠的测试被称为不稳定测试

这种不一致看起来似乎并不是一个非常重要的问题。我们可能会说,测试并不代表真实的生产环境,这也是它们有时会失败的根本原因。这里有一点真理:测试并不意味着复制每一个细节,因为那样做不切实际。测试是一个模拟,是对可能发生的情况的近似,通常这就足够了。如果重新运行测试会在下次执行时通过,那会有什么坏处吗?

事实上,它是有的。这里有三个主要的关注点,如下所述:

  • 如果你的代码库中积累了足够多的不稳定测试,它们将成为代码更改顺利交付的严重障碍。尤其是在你急于完成工作的时候:要么是准备在周五下午下班,要么是在交付一个影响客户的严重问题的关键修复时。

  • 你无法完全确定你的不稳定测试失败是因为测试环境的不完善。可能正好相反:它们失败是因为它们复制了生产环境中已经发生的罕见场景。只是这个问题还不明显,尚未触发警报……

  • 不是测试不稳定,而是你的代码不稳定!环境偶尔会出现问题——作为程序员,我们以确定性的方式应对这一点。如果被测系统(SUT)表现得如此,那就是一个严重错误的信号——例如,代码可能在读取未初始化的内存。

没有完美的方法来解决所有前述问题——可能的原因实在是太多了。然而,我们可以通过使用 –repeat <mode>:<#> 选项反复运行测试,来提高识别不稳定测试的机会。有三种模式可以选择,如下所述:

  • until-fail—运行测试 <#> 次;所有运行必须通过。

  • until-pass—最多运行测试 <#> 次;必须至少成功一次。当处理已知不稳定但又太难调试或禁用的测试时,这个选项非常有用。

  • after-timeout—最多运行测试 <#> 次,但仅在测试超时的情况下重试。适用于繁忙的测试环境。

一般建议尽快调试不稳定的测试,或者如果它们不能可靠地产生一致的结果,就将其移除。

控制输出

每次将每一条信息都打印到屏幕上会变得非常繁忙。CTest 会减少噪音,将它执行的测试输出收集到日志文件中,常规运行时只提供最有用的信息。当出现问题并且测试失败时,你可以期待一个总结,可能还会有一些日志,如果你启用了 --output-on-failure 选项,正如前面所提到的。

我根据经验知道,“足够的信息”通常是够用的,直到它不再足够。有时,我们可能希望查看通过测试的输出,也许是为了检查它们是否真正有效(而不是在没有错误的情况下悄无声息地停止)。要获取更详细的输出,添加 -V 选项(或者如果你想在自动化管道中明确显示,可以使用 --verbose)。如果这还不够,你可能需要使用 -VV--extra-verbose。对于极为深入的调试,使用 --debug(但要准备好面对包含所有细节的海量文本)。

如果你想要的是相反的效果,CTest 还提供了“Zen 模式”,可以通过 -Q--quiet 启用。启用后将不会打印任何输出(你可以停止担心并学会喜爱这个错误)。看起来这个选项除了让人困惑之外没有其他用途,但请注意,输出仍然会存储在测试文件中(默认存储在 ./Testing/Temporary)。自动化管道可以检查退出代码是否为非零值,并收集日志文件进行进一步处理,而不会在主输出中堆积可能让不熟悉该产品的开发人员困惑的细节。

要将日志存储到特定路径,请使用 -O <file>--output-log <file> 选项。如果你遇到输出过长的问题,可以使用两个限制选项,将其限制为每个测试的给定字节数:--test-output-size-passed <size>--test-output-size-failed <size>

杂项

还有一些其他选项,对于日常测试需求也非常有用,如下所述:

  • -C <cfg>, --build-config <cfg>—指定要测试的配置。Debug 配置通常包含调试符号,使得理解更为容易,但 Release 配置也应该进行测试,因为重度优化选项可能会影响被测系统(SUT)的行为。此选项仅适用于多配置生成器。

  • -j <jobs>, --parallel <jobs>—设置并行执行的测试数量。这对于加速开发过程中执行长时间运行的测试非常有用。需要注意的是,在一个繁忙的环境中(共享测试运行器上),它可能会因为调度的原因产生不利影响。通过下一个选项可以稍微减轻这个问题。

  • --test-load <level>—以一种不超过<level>值的方式调度并行测试,以避免 CPU 负载过高(按最佳努力)。

  • --timeout <seconds>—指定单个测试的默认时间限制。

现在我们理解了如何在不同的场景中执行ctest,接下来我们学习如何添加一个简单的测试。

为 CTest 创建最基本的单元测试

从技术上讲,即使没有任何框架,也可以编写单元测试。我们所需要做的就是创建我们要测试的类的实例,执行其中一个方法,然后检查新的状态或返回值是否符合我们的期望。然后,我们报告结果并删除测试对象。让我们试试吧。

我们将使用以下结构:

- CMakeLists.txt
- src
  |- CMakeLists.txt
  |- calc.cpp
  |- calc.h
  |- main.cpp
- test
  |- CMakeLists.txt
  |- calc_test.cpp 

main.cpp开始,我们看到它使用了一个Calc类:

ch11/01-no-framework/src/main.cpp

#include <iostream>
#include "calc.h"
using namespace std;
int main() {
  Calc c;
  cout << "2 + 2 = " << c.Sum(2, 2) << endl;
  cout << "3 * 3 = " << c.Multiply(3, 3) << endl;
} 

没有什么太花哨的——main.cpp仅仅包含了calc.h头文件,并调用了Calc对象的两个方法。让我们快速浏览一下Calc的接口,这是我们的 SUT:

ch11/01-no-framework/src/calc.h

#pragma once
class Calc {
public:
   int Sum(int a, int b);
   int Multiply(int a, int b);
}; 

接口尽可能简单。我们在这里使用#pragma once—它的作用与常见的预处理器包含保护相同,并且几乎所有现代编译器都能理解它,尽管它不是官方标准的一部分。

包含保护是头文件中的短行代码,用于防止在同一个父文件中被多次包含。

我们来看看类的实现:

ch11/01-no-framework/src/calc.cpp

#include "calc.h"
int Calc::Sum(int a, int b) {
  return a + b;
}
int Calc::Multiply(int a, int b) {
  return a * a; // a mistake!
} 

哎呀!我们引入了一个错误!Multiply忽略了b参数,反而返回了a的平方。这个问题应该通过正确编写的单元测试来检测出来。那么,我们来编写一些测试吧!开始:

ch11/01-no-framework/test/calc_test.cpp

#include "calc.h"
#include <cstdlib>
void SumAddsTwoIntegers() {
  Calc sut;
  if (4 != sut.Sum(2, 2))
    std::exit(1);
}
void MultiplyMultipliesTwoIntegers() {
  Calc sut;
  if(3 != sut.Multiply(1, 3))
    std::exit(1);
} 

我们通过编写两个测试方法来开始calc_test.cpp文件,每个方法对应一个被测试的 SUT 方法。如果调用的方法返回的值与预期不符,每个函数都会调用std::exit(1)。我们本可以使用assert()abort()terminate(),但那样会导致在ctest输出中显示一个不太明确的Subprocess aborted消息,而不是更易读的Failed消息。

该是时候创建一个测试运行器了。我们的测试运行器将尽可能简单,以避免引入过多的工作量。看看我们为运行仅仅两个测试而必须编写的main()函数:

ch11/01-no-framework/test/unit_tests.cpp

#include <string>
void SumAddsTwoIntegers();
void MultiplyMultipliesTwoIntegers();
int main(int argc, char *argv[]) {
  if (argc < 2 || argv[1] == std::string("1"))
    SumAddsTwoIntegers();
  if (argc < 2 || argv[1] == std::string("2"))
    MultiplyMultipliesTwoIntegers();
} 

下面是发生的事情的详细说明:

  1. 我们声明了两个外部函数,这些函数将从另一个翻译单元链接过来。

  2. 如果未提供任何参数,则执行两个测试(argv[]中的第一个元素总是程序名)。

  3. 如果第一个参数是测试标识符,则执行该测试。

  4. 如果任何测试失败,它会内部调用exit()并返回一个1退出代码。

  5. 如果没有执行任何测试或所有测试都通过,它会隐式返回一个0退出代码。

要运行第一个测试,请执行:

./unit_tests 1 

要运行第二个测试,请执行:

./unit_tests 2 

我们尽可能简化了代码,但它仍然很难阅读。任何需要维护这一部分的人,在添加更多测试后都将面临不小的挑战。功能上还是很粗糙——调试这样的测试套件会很困难。不过,让我们看看如何与 CTest 一起使用它:

ch11/01-no-framework/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(NoFrameworkTests CXX)
**include****(CTest)**
add_subdirectory(src **bin**)
add_subdirectory(test) 

我们从常见的头文件和include(CTest)开始。这启用了 CTest,并且应该始终在顶层的CMakeLists.txt中完成。接下来,我们在每个子目录中包含两个嵌套的列表文件:srctest。指定的bin值表示我们希望将src子目录中的二进制输出放置在<build_tree>/bin中。否则,二进制文件会被放到<build_tree>/src中,这可能会让用户感到困惑,因为构建产物并不是源文件。

对于src目录,列表文件是直接的,包含一个简单的main目标定义:

ch11/01-no-framework/src/CMakeLists.txt

add_executable(main main.cpp calc.cpp) 

我们还需要一个test目录的列表文件:

ch11/01-no-framework/test/CMakeLists.txt

add_executable(unit_tests
               unit_tests.cpp
               calc_test.cpp
               ../src/calc.cpp)
target_include_directories(unit_tests PRIVATE ../src)
**add_test****(NAME SumAddsTwoInts** **COMMAND** **unit_tests** **1****)**
**add_test****(NAME MultiplyMultipliesTwoInts** **COMMAND** **unit_tests** **2****)** 

我们现在定义了第二个unit_tests目标,它也使用src/calc.cpp实现文件及其相关的头文件。最后,我们明确地添加了两个测试:

  • SumAddsTwoInts

  • MultiplyMultipliesTwoInts

每个测试都将其 ID 作为参数传递给add_test()命令。CTest 将简单地执行COMMAND关键字后提供的内容,并在子 shell 中执行,收集输出和退出代码。不要过于依赖add_test()方法;在稍后的单元测试框架部分,我们将发现一种更好的方法来处理测试用例。

要运行测试,请在构建树中执行ctest

# ctest
Test project /tmp/b
    Start 1: SumAddsTwoInts
1/2 Test #1: SumAddsTwoInts ...................   Passed    0.00 sec
    Start 2: MultiplyMultipliesTwoInts
2/2 Test #2: MultiplyMultipliesTwoInts ........***Failed    0.00 sec
50% tests passed, 1 tests failed out of 2
Total Test time (real) =   0.00 sec
The following tests FAILED:
          2 - MultiplyMultipliesTwoInts (Failed)
Errors while running CTest
Output from these tests are in: /tmp/b/Testing/Temporary/LastTest.log
Use "--rerun-failed --output-on-failure" to re-run the failed cases verbosely. 

CTest 执行了两个测试并报告说其中一个失败——Calc::Multiply返回的值没有达到预期。很好。我们现在知道代码有一个 bug,应该有人修复它。

你可能注意到,在迄今为止的大多数示例中,我们并没有使用第四章中描述的项目结构,设置第一个 CMake 项目。这样做是为了简洁起见。本章讨论的是更高级的概念,因此,使用完整的结构是必要的。在你的项目中(无论项目多小),最好从一开始就遵循这个结构。正如一位智者曾经说过:“你踏上了这条路,如果你不小心,谁也不知道你会被卷到哪里去。

我希望现在已经明确,完全从头开始为你的项目构建一个测试框架并不可取。即便是最基本的示例,也不易阅读,开销较大,且没有什么价值。然而,在我们可以采用单元测试框架之前,我们需要重新思考项目的结构。

为测试构建项目结构

C++具有一些有限的反射能力,但无法提供像 Java 那样强大的回溯功能。这可能是为什么为 C++代码编写测试和单元测试框架比在其他功能更丰富的环境中更具挑战性的原因之一。由于这种有限的方式,程序员需要更加参与编写可测试的代码。我们需要仔细设计接口并考虑实际的方面。例如,如何避免编译代码两次,并在测试和生产之间重用构件?

对于较小的项目,编译时间可能不是大问题,但随着项目的增长,短编译循环的需求仍然存在。在前面的示例中,我们将所有 SUT 源代码包含在了单元测试可执行文件中,除了main.cpp文件。如果你仔细观察,可能会注意到该文件中的某些代码没有被测试(即main()本身的内容)。编译代码两次会引入一定的可能性,导致生成的构件不完全相同。这些差异可能随着时间的推移逐渐增加,尤其是在添加编译标志和预处理指令时,在贡献者匆忙、经验不足或不熟悉项目时可能会存在风险。

这个问题有多种解决方案,但最直接的方法是将整个解决方案构建为一个库,并与单元测试链接。你可能会想知道那时该如何运行它。答案是创建一个引导可执行文件,它与库链接并执行其代码。

首先,将你当前的main()函数重命名为run()start_program()之类的名称。然后,创建另一个实现文件(bootstrap.cpp),其中只包含一个新的main()函数。这个函数充当适配器:它的唯一作用是提供一个入口点并调用run(),同时传递任何命令行参数。将所有内容链接在一起后,你就得到了一个可测试的项目。

通过重命名main(),你现在可以将 SUT 与测试连接,并测试其主要功能。否则,你将违反在第八章链接可执行文件和库中讨论的单一定义规则ODR),因为测试运行器也需要它自己的main()函数。正如我们在第八章的*为测试分离 main()*部分承诺的那样,我们将在这里深入讨论这个话题。

还需要注意,测试框架可能默认会提供自己的main()函数,因此不一定需要自己编写。通常,它会自动检测所有已链接的测试,并根据你的配置运行它们。

通过这种方法产生的构件可以归类为以下几个目标:

  • 一个包含生产代码的sut

  • bootstrap带有main()包装器,调用来自sutrun()

  • 带有main()包装器的unit tests,该包装器运行所有sut的测试

以下图表显示了目标之间的符号关系:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_11_01.png

图 11.1:在测试和生产可执行文件之间共享工件

我们最终得到了六个实现文件,这些文件将生成各自的(.o目标文件,如下所示:

  • calc.cpp:要进行单元测试的Calc类。这个被称为单元测试对象UUT),因为 UUT 是 SUT 的一个特例。

  • run.cpp:原始的入口点重命名为run(),现在可以进行测试。

  • bootstrap.cpp:新的main()入口点,调用run()

  • calc_test.cpp:测试Calc类。

  • run_test.cpp:新的run()测试可以放在这里。

  • unit_tests.o:单元测试的入口点,扩展为调用run()的测试。

我们即将构建的库不一定非得是静态库或共享库。通过选择对象库,我们可以避免不必要的归档或链接。从技术上讲,使用动态链接来处理 SUT 是可能节省一些时间的,但我们经常发现自己在两个目标(测试和 SUT)中都做了更改,这样就没有节省任何时间。

让我们从文件名为main.cpp的文件开始,检查一下我们的文件是如何变化的:

ch11/02-structured/src/run.cpp

#include <iostream>
#include "calc.h"
using namespace std;
int **run****()** {
  Calc c;
  cout << "2 + 2 = " << c.Sum(2, 2) << endl;
  cout << "3 * 3 = " << c.Multiply(3, 3) << endl;
  **return****0****;**
} 

更改很小:文件和函数被重命名,并且我们添加了一个return语句,因为编译器不会为除main()之外的其他函数隐式添加return语句。

新的main()函数如下所示:

ch11/02-structured/src/bootstrap.cpp

int run(); // declaration
int main() {
  run();
} 

简单起见,我们声明链接器将从另一个翻译单元提供run()函数,然后调用它。

接下来是src列表文件:

ch11/02-structured/src/CMakeLists.txt

add_library(sut **STATIC** calc.cpp run.cpp)
target_include_directories(sut **PUBLIC** .)
add_executable(bootstrap bootstrap.cpp)
target_link_libraries(bootstrap PRIVATE sut) 

首先,我们创建一个 SUT 库并将.标记为PUBLIC 包含目录,以便它将传递到所有与 SUT 链接的目标(即bootstrapunit_tests)。请注意,包含目录是相对于列表文件的,这使我们可以使用点(.)来引用当前的<source_tree>/src目录。

现在是时候更新我们的unit_tests目标了。我们将把对../src/calc.cpp文件的直接引用替换为对sut的链接引用,同时为run_test.cpp文件中的主函数添加一个新测试。为了简便起见,我们不在这里讨论,但如果你感兴趣,可以查看本书的仓库中的示例。

同时,下面是整个test列表文件:

ch11/02-structured/test/CMakeLists.txt

add_executable(unit_tests
               unit_tests.cpp
               calc_test.cpp
               run_test.cpp)
target_link_libraries(unit_tests PRIVATE sut) 

ch11/02-structured/test/CMakeLists.txt(续)

add_test(NAME SumAddsTwoInts COMMAND unit_tests 1)
add_test(NAME MultiplyMultipliesTwoInts COMMAND unit_tests 2)
**add_test****(NAME RunOutputsCorrectEquations** **COMMAND** **unit_tests** **3****)** 

完成!我们已按需要注册了新测试。通过遵循这种做法,你可以确保在生产中使用的机器代码上执行你的测试。

我们在这里使用的目标名称 sutbootstrap 是为了从测试的角度让它们的用途非常明确。在实际项目中,你应该选择与生产代码(而非测试)上下文相匹配的名称。例如,对于 FooApp,应该命名你的目标为 foo 而不是 bootstraplib_foo 而不是 sut

现在我们已经知道如何在合适的目标中构建一个可测试的项目,让我们将焦点转向测试框架本身。我们可不想手动将每个测试用例添加到列表文件中,对吧?

单元测试框架

上一节表明,编写一个小的单元测试驱动程序并不复杂。它可能不太漂亮,但信不信由你,一些专业开发者确实喜欢重新发明轮子,认为他们的版本在各方面都会更好。避免这个陷阱:你最终会创建大量的样板代码,可能会让它变成一个独立的项目。使用流行的单元测试框架可以使你的解决方案与多个项目和公司中被认可的标准保持一致,且通常伴随免费更新和扩展。你不会吃亏。

如何将单元测试框架整合到你的项目中呢?当然,通过根据所选框架的规则实现测试,然后将这些测试与框架提供的测试运行器链接。测试运行器启动选定测试的执行并收集结果。与我们之前看到的基本 unit_tests.cpp 文件不同,许多框架会自动检测所有测试并使其对 CTest 可见。这个过程要顺畅得多。

在这一章中,我选择介绍两个单元测试框架,原因如下:

  • Catch2 相对容易学习,并且有很好的支持和文档。虽然它提供了基本的测试用例,但它也包含了优雅的宏,支持行为驱动开发BDD)。虽然它可能缺少一些功能,但在需要时可以通过外部工具进行补充。访问它的主页:github.com/catchorg/Catch2

  • GoogleTest (GTest) 很方便,但也更高级。它提供了一套丰富的功能,如各种断言、死锁测试,以及值参数化和类型参数化测试。它甚至支持通过 GMock 模块生成 XML 测试报告和模拟。你可以在这里找到它:github.com/google/googletest

框架的选择取决于你的学习偏好和项目规模。如果你喜欢循序渐进,并且不需要完整的功能集,Catch2 是一个不错的选择。那些喜欢一头扎进去,并且需要全面工具集的人会觉得 GoogleTest 更适合。

Catch2

这个由 Martin Hořeňovský 维护的框架非常适合初学者和小型项目。当然,它也能够适应更大的应用程序,但需要注意的是,在某些领域你可能需要额外的工具(深入探讨这个问题会让我们偏离主题)。首先,让我们看一下 Calc 类的一个简单单元测试实现:

ch11/03-catch2/test/calc_test.cpp

#include <catch2/catch_test_macros.hpp>
#include "calc.h"
TEST_CASE("SumAddsTwoInts", "[calc]") {
  Calc sut;
  CHECK(4 == sut.Sum(2, 2));
}
TEST_CASE("MultiplyMultipliesTwoInts", "[calc]") {
  Calc sut;
  CHECK(12 == sut.Multiply(3, 4));
} 

就是这样。这几行代码比我们之前的示例更强大。CHECK() 宏不仅仅是验证预期,它们会收集所有失败的断言并将它们一起展示,帮助你避免频繁的重新编译。

最棒的部分是什么?你不需要手动将这些测试添加到 listfiles 中以通知 CMake。忘掉 add_test() 吧;你以后不再需要它了。如果你允许,Catch2 会自动将你的测试注册到 CTest 中。只要像前面章节讨论的那样配置你的项目,添加框架就非常简单。使用 FetchContent() 将其引入你的项目。

你可以选择两个主要版本:Catch2 v2 和 Catch2 v3。版本 2 是一个适用于 C++11 的单头文件库的遗留版本。版本 3 编译为静态库,并需要 C++14。建议选择最新版本。

在使用 Catch2 时,确保选择一个 Git 标签并将其固定在 listfile 中。通过 main 分支进行升级并不能保证是无缝的。

在商业环境中,你可能需要在 CI 管道中运行测试。在这种情况下,请记得设置你的环境,以便系统中已经安装了所需的依赖项,并且每次构建时无需重新下载它们。正如在第九章《CMake 中的依赖管理》中的尽可能使用已安装的依赖部分所提到的,你需要使用FIND_PACKAGE_ARGS关键字扩展FetchContent_Declare()命令,以便使用系统中的包。

我们将像这样在我们的 listfile 中包含版本 3.4.0:

ch11/03-catch2/test/CMakeLists.txt

include(FetchContent)
FetchContent_Declare(
  **Catch2**
  **GIT_REPOSITORY https://github.com/catchorg/Catch2.git**
  **GIT_TAG        v3.4.0**
)
FetchContent_MakeAvailable(**Catch2**) 

然后,我们需要定义我们的 unit_tests 目标,并将其与 sut 以及框架提供的入口点和 Catch2::Catch2WithMain 库链接。由于 Catch2 提供了自己的 main() 函数,我们不再使用 unit_tests.cpp 文件(此文件可以删除)。以下代码展示了这个过程:

ch11/03-catch2/test/CMakeLists.txt(续)

add_executable(unit_tests calc_test.cpp run_test.cpp)
target_link_libraries(unit_tests PRIVATE
                      **sut Catch2::Catch2WithMain**) 

最后,我们使用 Catch2 提供的模块中定义的 catch_discover_tests() 命令,自动检测 unit_tests 中的所有测试用例并将其注册到 CTest 中,如下所示:

ch11/03-catch2/test/CMakeLists.txt(续)

list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(Catch)
**catch_discover_tests(unit_tests)** 

完成了。我们刚刚为我们的解决方案添加了一个单元测试框架。现在让我们看看它的实际效果。测试运行器的输出如下所示:

# ./test/unit_tests
unit_tests is a Catch2 v3.4.0 host application.
Run with -? for options
---------------------------------------------------------------------
MultiplyMultipliesTwoInts
---------------------------------------------------------------------
/root/examples/ch11/03-catch2/test/calc_test.cpp:9
.....................................................................
/root/examples/ch11/03-catch2/test/calc_test.cpp:11: FAILED:
  CHECK( 12 == sut.Multiply(3, 4) )
with expansion:
  12 == 9
=====================================================================
test cases: 3 | 2 passed | 1 failed
assertions: 3 | 2 passed | 1 failed 

Catch2 能够将 sut.Multiply(3, 4) 表达式扩展为 9,为我们提供了更多的上下文信息,这在调试时非常有帮助。

请注意,直接执行运行器二进制文件(编译后的 unit_test 可执行文件)可能比使用 ctest 稍微快一些,但 CTest 提供的额外优势值得这种折衷。

这就是 Catch2 的设置过程。如果你将来需要添加更多的测试,只需创建新的实现文件并将其路径添加到 unit_tests 目标的源列表中。

Catch2 提供了诸如事件监听器、数据生成器和微基准测试等多种功能,但它缺少内置的模拟功能。如果你不熟悉模拟,我们将在下一节介绍。你可以通过以下模拟框架之一将模拟功能添加到 Catch2:

也就是说,如果你想要更精简和先进的体验,还有一个值得关注的框架——GoogleTest。

GoogleTest

使用 GoogleTest 有几个重要的优点:它已经存在很长时间,并在 C++ 社区中得到了广泛认可,因此多个 IDE 本地支持它。世界上最大的搜索引擎背后的公司维护并广泛使用它,这使得它不太可能过时或被废弃。它可以测试 C++11 及以上版本,如果你在一个较旧的环境中工作,这对你来说是个好消息。

GoogleTest 仓库包含两个项目:GTest(主要的测试框架)和 GMock(一个添加模拟功能的库)。这意味着你可以通过一次 FetchContent() 调用下载这两个项目。

使用 GTest

要使用 GTest,我们的项目需要遵循 为测试构建我们的项目 部分中的指示。这就是我们在该框架中编写单元测试的方式:

ch11/04-gtest/test/calc_test.cpp

#include <gtest/gtest.h>
#include "calc.h"
class CalcTestSuite : public ::testing::Test {
protected:
  Calc sut_;
};
TEST_F(CalcTestSuite, SumAddsTwoInts) {
  EXPECT_EQ(4, sut_.Sum(2, 2));
}
TEST_F(CalcTestSuite, MultiplyMultipliesTwoInts) {
  EXPECT_EQ(12, sut_.Multiply(3, 4));
} 

由于这个示例也将在 GMock 中使用,我选择将测试放在一个 CalcTestSuite 类中。测试套件将相关测试组织在一起,以便它们可以重用相同的字段、方法、设置和拆卸步骤。要创建一个测试套件,声明一个继承自 ::testing::Test 的新类,并将可重用元素放在其受保护的部分。

测试套件中的每个测试用例都使用 TEST_F() 宏声明。对于独立的测试,有一个更简单的 TEST() 宏。由于我们在类中定义了 Calc sut_,每个测试用例可以像访问 CalcTestSuite 的方法一样访问它。实际上,每个测试用例都在自己的实例中运行,这些实例继承自 CalcTestSuite,这就是为什么需要使用 protected 关键字的原因。请注意,可重用的字段并不意味着在连续的测试之间共享数据;它们的目的是保持代码的简洁性(DRY)。

GTest 并不像 Catch2 那样提供自然的断言语法。相反,你需要使用显式的比较,例如 EXPECT_EQ()。根据约定,预期值放在前面,实际值放在后面。还有许多其他类型的断言、帮助器和宏值得探索。有关 GTest 的详细信息,请参阅官方参考资料(google.github.io/googletest/)。

要将此依赖项添加到我们的项目中,我们需要决定使用哪个版本。与 Catch2 不同,GoogleTest 倾向于采用“始终保持最新”的理念(源自 GTest 依赖的 Abseil 项目)。它声明:“如果你从源代码构建我们的依赖并遵循我们的 API,你不应该遇到任何问题。”(有关更多详细信息,请参阅 进一步阅读 部分)。如果你能接受遵循这一规则(且从源代码构建不成问题),将 Git 标签设置为 master 分支。否则,从 GoogleTest 仓库中选择一个发布版本。

在企业环境中,你很可能会在 CI 管道中运行测试。在这种情况下,记得设置你的环境,使其系统中已经安装了依赖项,并且每次构建时不需要再次获取它们。正如 第九章 CMake 中的依赖管理 中的 尽可能使用已安装的依赖项 部分所述,你需要扩展 FetchContent_Declare() 命令,使用 FIND_PACKAGE_ARGS 关键字来使用系统中的包。

无论如何,添加对 GTest 的依赖看起来是这样的:

ch11/04-gtest/test/CMakeLists.txt

include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG v1.14.0
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest) 

我们遵循与 Catch2 相同的方法——执行 FetchContent() 并从源代码构建框架。唯一的不同是添加了 set(gtest...) 命令,这是 GoogleTest 作者推荐的,目的是防止在 Windows 上覆盖父项目的编译器和链接器设置。

最后,我们可以声明我们的测试运行程序可执行文件,将其与 gtest_main 链接,并且通过内置的 CMake GoogleTest 模块自动发现我们的测试用例,如下所示:

ch11/04-gtest/test/CMakeLists.txt(续)

add_executable(unit_tests
               calc_test.cpp
               run_test.cpp)
target_link_libraries(unit_tests PRIVATE sut gtest_main)
include(GoogleTest)
gtest_discover_tests(unit_tests) 

这完成了 GTest 的设置。直接执行的测试运行程序的输出比 Catch2 更加冗长,但我们可以传递 --gtest_brief=1 参数,限制只输出失败信息,如下所示:

# ./test/unit_tests --gtest_brief=1
~/examples/ch11/04-gtest/test/calc_test.cpp:15: Failure
Expected equality of these values:
  12
  sut_.Multiply(3, 4)
    Which is: 9
[  FAILED  ] CalcTestSuite.MultiplyMultipliesTwoInts (0 ms)
[==========] 3 tests from 2 test suites ran. (0 ms total)
[  PASSED  ] 2 tests. 

幸运的是,即使是冗长的输出,在通过 CTest 运行时也会被抑制(除非我们通过命令行显式启用 ctest --output-on-failure)。

现在我们已经建立了框架,让我们来讨论模拟。毕竟,当测试与其他元素紧密耦合时,无法称其为真正的“单元测试”。

GMock

编写纯粹的单元测试就是在隔离的环境中执行一段代码,避免与其他代码片段的干扰。这样的测试单元必须是一个自包含的元素,可以是一个类或一个组件。当然,几乎没有任何用 C++ 编写的程序会将所有单元完全隔离开来。

很可能,你的代码将严重依赖类之间某种形式的关联关系。问题在于:此类的对象将需要另一个类的对象,而后者又需要另一个类。到时候,你的整个解决方案可能都会参与到“单元测试”中。更糟的是,你的代码可能会与外部系统耦合并依赖其状态。例如,它可能会紧密依赖数据库中的特定记录、传入的网络数据包,或存储在磁盘上的特定文件。

为了实现单元测试的解耦,开发人员使用测试替身或测试中的类的特殊版本。常见的替身类型包括假的、存根和模拟。以下是这些术语的一些粗略定义:

  • 假替身是对更复杂机制的有限实现。例如,可以用内存中的映射来代替实际的数据库客户端。

  • 存根为方法调用提供特定的预设答案,仅限于测试中使用的响应。它还可以记录哪些方法被调用以及调用的次数。

  • 模拟是存根的一个略微扩展的版本。它还会在测试过程中验证方法是否按预期被调用。

这样的测试替身在测试开始时创建,并作为参数传递给被测试类的构造函数,用来代替真实的对象。这种机制被称为依赖注入

简单的测试替身存在的问题是它们过于简单。为了模拟不同测试场景中的行为,我们将不得不提供许多不同的替身,每种替身对应耦合对象可能的每个状态。这并不实际,且会把测试代码分散到太多文件中。这就是 GMock 的作用:它允许开发人员为特定类创建通用的测试替身,并为每个测试在线定义其行为。GMock 将这些替身称为“模拟”,但实际上,它们是所有前述测试替身的混合体,具体取决于场合。

考虑以下示例:让我们在Calc类中添加一个功能,它会将一个随机数加到提供的参数上。它将通过一个AddRandomNumber()方法表示,并返回该和作为一个int类型的值。我们如何确认返回值确实是随机数与提供给类的值的准确和呢?正如我们所知,随机生成的数字是许多重要过程的关键,如果我们使用不当,可能会带来各种后果。检查所有随机数直到所有可能性耗尽并不实际。

为了测试,我们需要将随机数生成器包装在一个可以模拟的类中(换句话说,就是用模拟对象替代)。模拟对象将允许我们强制一个特定的响应,用于“伪造”随机数的生成。Calc将使用该值在AddRandomNumber()中,并允许我们检查该方法返回的值是否符合预期。将随机数生成与另一个单元分离是一个附加值(因为我们将能够用另一种生成器替换当前的生成器)。

让我们从抽象生成器的公共接口开始。这个头文件将允许我们在实际的生成器和模拟对象中实现它,进而使我们能够互换使用它们:

ch11/05-gmock/src/rng.h

#pragma once
class RandomNumberGenerator {
public:
  **virtual** int Get() = 0;
  **virtual** ~RandomNumberGenerator() = default;
}; 

实现该接口的类将通过Get()方法为我们提供一个随机数。请注意virtual关键字——它必须出现在所有需要模拟的方法上,除非我们想涉及更复杂的基于模板的模拟。我们还需要记得添加一个虚拟析构函数。

接下来,我们需要扩展Calc类,以接受并存储生成器,这样我们就可以为发布版本提供真实的生成器,或者为测试提供模拟对象:

ch11/05-gmock/src/calc.h

#pragma once
**#****include****"rng.h"**
class Calc {
  **RandomNumberGenerator* rng_;**
public:
   **Calc****(RandomNumberGenerator* rng);**
   int Sum(int a, int b);
   int Multiply(int a, int b);
   **int****AddRandomNumber****(****int** **a)****;**
}; 

我们包含了头文件,并添加了一个方法来提供随机加法。此外,还创建了一个字段来存储指向生成器的指针,并添加了一个带参数的构造函数。这就是依赖注入在实际中的工作方式。现在,我们实现这些方法,代码如下:

ch11/05-gmock/src/calc.cpp

#include "calc.h"
**Calc::****Calc****(RandomNumberGenerator* rng) {**
  **rng_ = rng;**
**}**
int Calc::Sum(int a, int b) {
  return a + b;
}
int Calc::Multiply(int a, int b) {
  return a * b; // now corrected
}
**int****Calc::AddRandomNumber****(****int** **a)****{**
  **return** **a + rng_->****Get****();**
**}** 

在构造函数中,我们将提供的指针赋值给一个类字段。然后,我们在AddRandomNumber()中使用该字段来获取生成的值。生产代码将使用真实的生成器,测试将使用模拟对象。记住,我们需要取消引用指针以启用多态性。作为额外的功能,我们可以为不同的实现创建不同的生成器类。我只需要一个:一个均匀分布的梅森旋转伪随机生成器,如以下代码片段所示:

ch11/05-gmock/src/rng_mt19937.cpp

#include <random>
#include "rng_mt19937.h"
int RandomNumberGeneratorMt19937::Get() {
  std::random_device rd;
  std::mt19937 gen(rd());
  std::uniform_int_distribution<> distrib(1, 6);
  return distrib(gen);
} 

每次调用时创建一个新实例效率不高,但对于这个简单的示例来说是足够的。其目的是生成从16的数字,并将其返回给调用者。

该类的头文件仅提供了一个方法的签名:

ch11/05-gmock/src/rng_mt19937.h

#include "rng.h"
class RandomNumberGeneratorMt19937
      : public RandomNumberGenerator {
public:
  int Get() override;
}; 

这是我们在生产代码中使用它的方式:

ch11/05-gmock/src/run.cpp

#include <iostream>
#include "calc.h"
#include "rng_mt19937.h"
using namespace std;
int run() {
  auto rng = new RandomNumberGeneratorMt19937();
  Calc c(rng);
  cout << "Random dice throw + 1 = "
       << c.AddRandomNumber(1) << endl;
  delete rng;
  return 0;
} 

我们已经创建了一个生成器,并将其指针传递给Calc的构造函数。一切准备就绪,现在可以开始编写我们的模拟对象。为了保持代码的整洁,开发人员通常将模拟对象放在一个单独的test/mocks目录中。为了避免歧义,头文件名称会加上_mock后缀。

这里是代码:

ch11/05-gmock/test/mocks/rng_mock.h

#pragma once
**#****include****"gmock/gmock.h"**
class RandomNumberGeneratorMock : public
RandomNumberGenerator {
public:
  **MOCK_METHOD****(****int****, Get, (), (****override****));**
}; 

在添加gmock.h头文件后,我们可以声明我们的模拟对象。按计划,它是一个实现了RandomNumberGenerator接口的类。我们不需要自己编写方法,而是需要使用 GMock 提供的MOCK_METHOD宏。这些宏告知框架需要模拟接口中的哪些方法。请使用以下格式(大量括号是必需的):

MOCK_METHOD(<return type>, <method name>,
           (<argument list>), (<keywords>)) 

我们已经准备好在测试套件中使用模拟对象(为了简洁,省略了之前的测试用例),如下所示:

ch11/05-gmock/test/calc_test.cpp

#include <gtest/gtest.h>
#include "calc.h"
**#****include****"mocks/rng_mock.h"**
using namespace ::testing;
class CalcTestSuite : public Test {
protected:
  **RandomNumberGeneratorMock rng_mock_;**
  Calc sut_**{&rng_mock_}**;
};
TEST_F(CalcTestSuite, AddRandomNumberAddsThree) {
  **EXPECT_CALL****(rng_mock_,** **Get****()).****Times****(****1****).****WillOnce****(****Return****(****3****));**
  **EXPECT_EQ****(****4****, sut_.****AddRandomNumber****(****1****));**
} 

让我们分解一下这些改动:我们添加了新的头文件,并在测试套件中为rng_mock_创建了一个新的字段。接下来,模拟对象的地址传递给sut_的构造函数。我们之所以能这么做,是因为字段会按照声明顺序进行初始化(rng_mock_sut_之前)。

在我们的测试用例中,我们对rng_mock_Get()方法调用 GMock 的EXPECT_CALL宏。这告诉框架,如果在执行过程中没有调用Get()方法,则测试将失败。链式调用的Times明确说明了测试通过所需的调用次数。WillOnce确定了方法被调用后模拟框架的行为(它返回3)。

通过使用 GMock,我们能够将模拟的行为与预期结果一起表达。这大大提高了可读性,并简化了测试的维护。最重要的是,它为每个测试用例提供了灵活性,因为我们可以通过一个简洁的表达式来区分不同的行为。

最后,为了构建项目,我们需要确保gmock库与测试运行器进行链接。为此,我们将其添加到target_link_libraries()列表中:

ch11/05-gmock/test/CMakeLists.txt

include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.14.0
)
# For Windows: Prevent overriding the parent project's
  compiler/linker settings
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
add_executable(unit_tests
               calc_test.cpp
               run_test.cpp)
target_link_libraries(unit_tests PRIVATE sut gtest_main **gmock**)
include(GoogleTest)
gtest_discover_tests(unit_tests) 

现在,我们可以享受 GoogleTest 框架的所有好处了。GTest 和 GMock 都是高级工具,提供了许多概念、工具和助手,适用于不同的情况。这个例子(尽管有点冗长)只是触及了它们的表面。我鼓励你将它们融入到你的项目中,因为它们会大大提升你工作的质量。开始使用 GMock 的一个好地方是官方文档中的“Mocking for Dummies”页面(你可以在进一步阅读部分找到该链接)。

在有了测试之后,我们应该以某种方式衡量哪些部分已被测试,哪些没有,并努力改善这种情况。最好使用自动化工具来收集并报告这些信息。

生成测试覆盖率报告

向如此小的解决方案中添加测试并不算特别具有挑战性。真正的难点出现在稍微复杂一些和更长的程序中。多年来,我发现,当代码行数接近 1,000 行时,逐渐变得很难追踪哪些行和分支在测试中被执行,哪些没有。超过 3,000 行之后,几乎不可能再追踪了。大多数专业应用程序的代码量远远超过这个数。更重要的是,许多经理用来谈判解决技术债务的关键指标之一就是代码覆盖率百分比,因此了解如何生成有用的报告有助于获取那些讨论所需的实际数据。为了解决这个问题,我们可以使用一个工具来了解哪些代码行被测试用例“覆盖”。这种代码覆盖工具会与被测试系统(SUT)连接,并在测试期间收集每一行的执行情况,并将结果以方便的报告形式展示出来,就像这里展示的报告一样:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_11_02.png

图 11.2:LCOV 生成的代码覆盖率报告

这些报告会显示哪些文件被测试覆盖,哪些没有。更重要的是,你还可以查看每个文件的详细信息,精确知道哪些代码行被执行了,以及执行了多少次。在下图中,Line data列显示Calc构造函数执行了4次,每个测试执行一次:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_11_03.png

图 11.3:代码覆盖率报告的详细视图

生成类似报告的方式有很多,具体方法在不同平台和编译器之间有所不同,但一般都遵循相同的步骤:准备好待测系统,获取基线,进行测量并生成报告。

最简单的工具叫做LCOV。它不是一个缩写,而是gcov的图形前端,gcovGNU 编译器集合GCC)中的一个覆盖率工具。让我们看看如何在实践中使用它。

使用 LCOV 生成覆盖率报告

LCOV 将生成 HTML 覆盖率报告,并内部使用gcov来测量覆盖率。如果你使用的是 Clang,放心—Clang 支持生成这种格式的度量。你可以从Linux 测试项目的官方仓库获取 LCOV(github.com/linux-test-project/lcov),或者直接使用包管理器。顾名思义,它是一个面向 Linux 的工具。

虽然可以在 macOS 上运行它,但 Windows 平台不受支持。最终用户通常不关心测试覆盖率,因此通常可以在自己的构建环境中手动安装 LCOV,而不是将其集成到项目中。

为了测量覆盖率,我们需要执行以下步骤:

  1. Debug配置下编译,并启用编译器标志以支持代码覆盖。这将生成覆盖率注释(.gcno)文件。

  2. 将测试可执行文件与gcov库链接。

  3. 在没有运行任何测试的情况下,收集基线的覆盖率度量。

  4. 运行测试。这将创建覆盖率数据(.gcda)文件。

  5. 将指标收集到一个聚合信息文件中。

  6. 生成一个(.html)报告。

我们应该从解释为什么代码必须在Debug配置下编译开始。最重要的原因是,通常Debug配置会禁用所有优化,使用-O0标志。CMake 默认在CMAKE_CXX_FLAGS_DEBUG变量中执行此操作(尽管文档中并未明确说明)。除非你决定覆盖此变量,否则你的Debug构建应该是未优化的。这是为了防止任何内联和其他类型的隐式代码简化。否则,追踪每条机器指令来源于哪一行源代码将变得困难。

在第一步中,我们需要指示编译器为我们的 SUT 添加必要的仪器。具体的标志因编译器而异;然而,两大主流编译器(GCC 和 Clang)提供相同的--coverage标志来启用覆盖率仪器,并生成 GCC 兼容的gcov格式数据。

这就是如何将覆盖率仪器添加到我们前一部分示例中的 SUT:

ch11/06-coverage/src/CMakeLists.txt

add_library(sut STATIC calc.cpp run.cpp rng_mt19937.cpp)
target_include_directories(sut PUBLIC .)
**if** **(CMAKE_BUILD_TYPE** **STREQUAL** **Debug)**
  **target_compile_options****(sut PRIVATE --coverage)**
  **target_link_options****(sut PUBLIC --coverage)**
  **add_custom_command****(****TARGET** **sut PRE_BUILD** **COMMAND**
                     **find** **${CMAKE_BINARY_DIR}** **-type f**
                     **-name '*.gcda' -exec rm {} +)**
**endif****()**
add_executable(bootstrap bootstrap.cpp)
target_link_libraries(bootstrap PRIVATE sut) 

让我们逐步解析,具体如下:

  1. 确保我们使用if(STREQUAL)命令在Debug配置中运行。记住,除非你使用-DCMAKE_BUILD_TYPE=Debug选项运行cmake,否则无法获得任何覆盖率数据。

  2. --coverage添加到sut库中所有目标文件PRIVATE编译选项

  3. --coverage添加到PUBLIC链接器选项:GCC 和 Clang 将其解释为请求将gcov(或兼容的)库链接到所有依赖sut的目标(由于属性传播)。

  4. 引入add_custom_command()命令以清除任何过时的.gcda文件。添加此命令的原因在避免 SEGFAULT 陷阱部分中进行了详细讨论。

这已经足够生成代码覆盖率。如果你使用的是 CLion 等 IDE,你将能够运行单元测试并查看覆盖率结果,并在内置报告视图中查看结果。然而,这在任何可能在 CI/CD 中运行的自动化管道中是行不通的。为了生成报告,我们需要使用 LCOV 自己生成。

为此,最好定义一个新的目标coverage。为了保持整洁,我们将在另一个文件中定义一个单独的函数AddCoverage,并在test列表文件中使用,具体如下:

ch11/06-coverage/cmake/Coverage.cmake

function(AddCoverage target)
  find_program(LCOV_PATH lcov REQUIRED)
  find_program(GENHTML_PATH genhtml REQUIRED)
  **add_custom_target****(coverage**
    COMMENT "Running coverage for ${target}..."
    COMMAND ${LCOV_PATH} -d . --zerocounters
    COMMAND $<TARGET_FILE:${target}>
    COMMAND ${LCOV_PATH} -d . --capture -o coverage.info
    COMMAND ${LCOV_PATH} -r coverage.info '/usr/include/*'
                         -o filtered.info
    COMMAND ${GENHTML_PATH} -o coverage filtered.info
      --legend
    COMMAND rm -rf coverage.info filtered.info
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction() 
lcov and genhtml (two command-line tools from the LCOV package). The REQUIRED keyword instructs CMake to throw an error when they’re not found. Next, we add a custom coverage target with the following steps:
  1. 清除任何先前运行的计数器。

  2. 运行target可执行文件(使用生成器表达式获取其路径)。$<TARGET_FILE:target>是一个特殊的生成器表达式,在这种情况下,它会隐式地添加对target的依赖,导致它在执行所有命令之前被构建。我们将target作为参数传递给此函数。

  3. 从当前目录收集解决方案的指标(-d .),并输出到文件(-o coverage.info)。

  4. 删除(-r)系统头文件('/usr/include/*')中的不需要的覆盖数据,并输出到另一个文件(-o filtered.info)。

  5. coverage目录中生成 HTML 报告,并添加--legend颜色。

  6. 删除临时的.info文件。

  7. 指定WORKING_DIRECTORY关键字会将二进制树设置为所有命令的工作目录。

这些是 GCC 和 Clang 的通用步骤。需要注意的是,gcov工具的版本必须与编译器版本匹配:你不能使用 GCC 的gcov工具处理 Clang 编译的代码。为了将lcov指向 Clang 的gcov工具,我们可以使用--gcov-tool参数。唯一的问题是它必须是一个可执行文件。为了解决这个问题,我们可以提供一个简单的包装脚本(记得用chmod +x标记它为可执行),如下所示:

# cmake/gcov-llvm-wrapper.sh
#!/bin/bash
exec llvm-cov gcov "$@" 

这样做意味着我们之前函数中所有对${LCOV_PATH}的调用将接收以下标志:

--gcov-tool ${CMAKE_SOURCE_DIR}/cmake/gcov-llvm-wrapper.sh 

确保此函数可以包含在test列表文件中。我们可以通过在主列表文件中扩展包含搜索路径来实现,如下所示:

ch11/06-coverage/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(Coverage CXX)
include(CTest)
**list****(APPEND CMAKE_MODULE_PATH** **"${CMAKE_SOURCE_DIR}/cmake"****)**
add_subdirectory(src bin)
add_subdirectory(test) 

高亮的那一行允许我们将所有cmake目录下的.cmake文件包含到我们的项目中。现在,我们可以在test列表文件中使用Coverage.cmake,如下所示:

ch11/06-coverage/test/CMakeLists.txt(片段)

# ... skipped unit_tests target declaration for brevity
**include****(Coverage)**
**AddCoverage(unit_tests)**
include(GoogleTest)
gtest_discover_tests(unit_tests) 

要构建coverage目标,使用以下命令(注意第一个命令以-DCMAKE_BUILD_TYPE=Debug构建类型选择结尾):

# cmake -B <binary_tree> -S <source_tree> -DCMAKE_BUILD_TYPE=Debug
# cmake --build <binary_tree> -t coverage 

执行上述所有步骤后,你将看到类似这样的简短总结:

Writing directory view page.
Overall coverage rate:
  lines......: 95.7% (22 of 23 lines)
  functions..: 75.0% (6 of 8 functions)
[100%] Built target coverage 

接下来,在浏览器中打开coverage/index.html文件,享受报告吧!不过,有一个小问题…

避免 SEGFAULT 问题

当我们开始编辑这样的已构建解决方案中的源代码时,可能会遇到问题。这是因为覆盖信息被分为两部分:

  • gcno文件,或称GNU 覆盖注释,在 SUT 的编译过程中生成。

  • gcda文件,或称GNU 覆盖数据,在测试运行期间生成并更新

“更新”功能是潜在的分段错误源。在我们初次运行测试后,会留下许多gcda文件,这些文件不会在任何时候被删除。如果我们对源代码进行一些修改并重新编译目标文件,新的gcno文件将会被创建。然而,并没有清除步骤——来自先前测试运行的gcda文件会与过时的源代码一起存在。当我们执行unit_tests二进制文件时(它发生在gtest_discover_tests宏中),覆盖信息文件将不匹配,并且我们会收到SEGFAULT(分段错误)错误。

为了避免这个问题,我们应该删除任何过时的gcda文件。由于我们的sut实例是一个STATIC库,我们可以将add_custom_command(TARGET)命令钩入构建事件。清理将在重新构建开始之前执行。

进一步阅读部分查找更多信息的链接。

摘要

表面上看,似乎与适当测试相关的复杂性大到不值得付出努力。令人吃惊的是,很多代码在没有任何测试的情况下运行,主要的论点是测试软件是一项令人畏惧的任务。我还要补充一句:如果是手动测试,那就更糟了。不幸的是,没有严格的自动化测试,代码中任何问题的可见性都是不完整的,甚至是不存在的。未经测试的代码也许写起来更快(但并不总是如此);然而,它在阅读、重构和修复时绝对要慢得多。

在本章中,我们概述了一些从一开始就进行测试的关键原因。最有说服力的一个原因是心理健康和良好的睡眠。没有一个开发者会躺在床上想:“我真期待几小时后被叫醒,去处理生产中的问题和修复 bug。”但说真的,在将错误部署到生产环境之前捕捉到它们,对你(以及公司)来说可能是救命稻草。

在测试工具方面,CMake 真正展现了它的强大之处。CTests 能够在检测故障测试方面发挥奇效:隔离、洗牌、重复和超时。所有这些技术都非常方便,并可以通过一个方便的命令行标志来使用。我们学习了如何使用 CTests 列出测试、过滤测试,并控制测试用例的输出,但最重要的是,我们现在知道了在各个方面采用标准解决方案的真正力量。任何用 CMake 构建的项目都可以完全一样地进行测试,而无需调查其内部细节。

接下来,我们结构化了我们的项目,以简化测试过程,并在生产代码和测试运行器之间重用相同的目标文件。写我们自己的测试运行器很有趣,但也许让我们专注于程序应该解决的实际问题,并投入时间采用流行的第三方测试框架。

说到这里,我们学习了 Catch2 和 GoogleTest 的基础知识。我们进一步深入了解了 GMock 库,并理解了测试替身如何使真正的单元测试成为可能。最后,我们设置了 LCOV 报告。毕竟,没有什么比硬数据更能证明我们的解决方案已经完全测试过了。

在下一章中,我们将讨论更多有用的工具,以提高源代码的质量,并发现我们甚至不知道存在的问题。

延伸阅读

更多信息,请参考以下链接:

加入我们社区的 Discord

加入我们社区的 Discord 讨论区,与作者和其他读者交流:

discord.com/invite/vXN53A7ZcA

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/QR_Code94081075213645359.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值