面向-C---的现代-CMake-第二版-全-

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

创建一流的软件并非易事。开发人员在网上研究这个话题时,经常难以判断哪些建议是最新的,哪些方法已经被更新、更好的实践所取代。此外,大多数资源以混乱的方式解释过程,缺乏适当的背景、上下文和结构。

现代 CMake for C++ 提供了一个端到端的指南,通过全面处理 C++ 解决方案的构建,提供了更简便的体验。它不仅教你如何在项目中使用 CMake,还突出展示了使项目保持可维护、优雅和清晰的要素。该指南将帮助你自动化许多项目中常见的复杂任务,包括构建、测试和打包。

本书指导你如何组织源目录、构建目标以及创建包。在进阶过程中,你将学会如何编译和链接可执行文件和库,详细了解这些过程,并优化每个步骤以获得最佳结果。此外,你将学会如何将外部依赖项(如第三方库、测试框架、程序分析工具和文档生成工具)集成到项目中。最后,你将学习如何导出、安装和打包你的解决方案,以供内部和外部使用。

完成本书后,你将能够在专业层面上自信地使用 CMake。

本书适合的读者

当你学会了 C++ 后,你会很快发现,仅仅精通语言本身并不足以使你准备好交付最高标准的项目。本书弥补了这一空白:它面向任何希望成为更好的软件开发人员,甚至是专业构建工程师的人!

阅读本书,如果你想从零开始学习现代 CMake,或提升和刷新你当前的 CMake 技能。它将帮助你理解如何制作一流的 C++ 项目,并从其他构建环境过渡过来。

本书内容

第一章CMake 入门,涵盖了 CMake 的安装、命令行界面的使用,并介绍了 CMake 项目所需的基本构建块。

第二章CMake 语言,涵盖了 CMake 语言的基本概念,包括命令调用、参数、变量、控制结构和注释。

第三章在流行 IDE 中使用 CMake,强调了集成开发环境IDE)的重要性,指导你选择合适的 IDE,并提供 Clion、Visual Studio Code 和 Visual Studio IDE 的设置说明。

第四章设置你的第一个 CMake 项目,将教你如何在 CMake 项目的顶级文件中进行配置,结构化文件树,并为开发准备必要的工具链。

第五章与目标一起工作,探讨了逻辑构建目标的概念,理解它们的属性和不同类型,并学习如何为 CMake 项目定义自定义命令。

第六章使用生成器表达式,解释了生成器表达式的目的和语法,包括如何用于条件扩展、查询和转换。

第七章使用 CMake 编译 C++ 源代码,深入探讨编译过程,配置预处理器和优化器,并发现减少构建时间和改善调试的技巧。

第八章链接可执行文件和库,了解链接机制、不同类型的库、唯一定义规则、链接顺序,以及如何为测试准备项目。

第九章在 CMake 中管理依赖关系,将教你如何管理第三方库,如何为那些缺乏支持的库添加 CMake 支持,并从互联网获取外部依赖项。

第十章使用 C++20 模块,介绍了 C++20 模块,展示如何在 CMake 中启用其支持,并相应地配置工具链。

第十一章测试框架,将帮助你理解自动化测试的重要性,利用 CMake 中内置的测试支持,并使用流行框架开始单元测试。

第十二章程序分析工具,将展示如何在构建时和运行时自动格式化源代码并检测软件错误。

第十三章生成文档,介绍了如何使用 Doxygen 从源代码自动生成文档,并添加样式以增强文档的外观。

第十四章安装和打包,为你的项目准备发布,无论是否进行安装,创建可重用的包,并指定单个组件进行打包。

第十五章创建你的专业项目,将本书中获得的所有知识应用于开发一个全面的、专业级别的项目。

第十六章编写 CMake 预设,将高级项目配置封装到使用 CMake 预设文件的工作流中,使项目设置和管理更高效。

附录 - 杂项命令,作为参考,提供与字符串、列表、文件和数学运算相关的各种 CMake 命令。

为了充分利用本书的内容

本书假设读者对 C++ 和类 Unix 系统有基本的了解。尽管 Unix 知识不是严格要求,但它会对充分理解本书中的示例有所帮助。

本书针对 CMake 3.26,但大多数技术应该在 CMake 3.15 版本及以上都能使用(新增加的功能通常会突出显示)。一些章节已更新至 CMake 3.28,以涵盖最新的功能。

示例运行环境的准备在第 1-3 章中涵盖,但我们特别建议,如果你熟悉 Docker 工具,可以使用本书提供的 Docker 镜像。

下载示例代码文件

本书的代码包托管在 GitHub 上,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp-2E。我们还提供了其他来自我们丰富书籍和视频目录的代码包,您可以在github.com/PacktPublishing/查看。快来看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。您可以在此下载:packt.link/gbp/9781805121800

使用的约定

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

CodeInText:表示文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账户。例如:“将下载的WebStorm-10*.dmg磁盘镜像文件挂载为系统中的另一个磁盘。”

代码块设置如下:

cmake_minimum_required(VERSION 3.26)
project(Hello)
add_executable(Hello hello.cpp) 

当我们希望特别提醒您关注代码块中的某部分时,相关的行或项目将设置为粗体:

cmake_minimum_required(VERSION 3.26)
project(Hello)
add_executable(Hello hello.cpp)
**add_subdirectory****(api)** 

任何命令行输入或输出以以下方式编写:

cmake --build <dir> --parallel [<number-of-jobs>]
cmake --build <dir> -j [<number-of-jobs>] 

粗体:表示新术语、重要单词或屏幕上显示的单词。例如:“从管理面板中选择系统信息。”

警告或重要注释以如下方式显示。

提示和技巧以如下方式显示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中注明书名。如果您有关于本书的任何问题,请通过questions@packtpub.com与我们联系。

勘误表:虽然我们已尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现任何错误,我们将非常感激您能向我们报告。请访问,www.packtpub.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,并输入详细信息。

盗版:如果你在互联网上发现任何我们作品的非法复制品,任何形式的,我们将非常感激您能提供该位置地址或网站名称。请通过电子邮件copyright@packtpub.com与我们联系,并附上相关材料的链接。

如果您有兴趣成为作者:如果您在某个领域具有专业知识,并且有兴趣写作或为书籍贡献内容,请访问authors.packtpub.com

分享您的想法

一旦您阅读了现代 CMake for C++第二版,我们很想听听您的想法!请点击这里直接访问亚马逊的书评页面并分享您的反馈。

您的评论对我们和技术社区非常重要,帮助我们确保提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

你喜欢随时随地阅读,但又不能把印刷版书籍带在身边吗?

你的电子书购买是否无法在你选择的设备上使用?

不用担心,现在购买每本 Packt 图书,你都可以免费获得该图书的 DRM-free PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制并粘贴代码到你的应用程序中。

优惠不止这些,你还可以获得独家折扣、新闻通讯以及每日发送到邮箱的精彩免费内容。

按照以下简单步骤获取这些福利:

  1. 扫描二维码或访问下面的链接:

https://packt.link/free-ebook/9781805121800

  1. 提交你的购买凭证。

  2. 就这样!我们会将你的免费 PDF 和其他福利直接发送到你的电子邮件。

第一章:CMake 入门

软件创建有一种神奇的魅力。我们不仅仅是在创建一个能够被激活的工作机制,而且我们还常常在构思解决方案功能背后的想法。

为了将我们的想法付诸实践,我们在以下循环中工作:设计、编码和测试。我们发明变更,用编译器理解的语言表达这些变更,并检查它们是否按预期工作。为了从源代码中创建合适的高质量软件,我们需要仔细执行重复且容易出错的任务:调用正确的命令,检查语法,链接二进制文件,运行测试,报告问题等等。

每次都记住每个步骤是非常费劲的。相反,我们希望专注于实际编码,并将其他所有工作委托给自动化工具。理想情况下,这个过程应该在我们修改代码后,点击一个按钮就开始。它应该是智能的、快速的、可扩展的,并且在不同操作系统和环境中以相同方式工作。它应该得到多个集成开发环境IDE)的支持。进一步地,我们可以将这个过程流畅地整合进持续集成CI)流水线,每当提交更改到共享仓库时,自动构建和测试我们的软件。

CMake 是许多此类需求的答案;然而,它需要一些工作来正确配置和使用。CMake 不是复杂性的根源;复杂性来自于我们在这里所处理的主题。别担心,我们会非常有条理地逐步学习这一过程。很快,你就会成为一个软件构建高手。

我知道你迫不及待地想开始编写自己的 CMake 项目,而这正是我们在本书的大部分内容中要做的事情。但因为你将主要为用户(包括你自己)创建项目,所以首先理解用户的视角是非常重要的。

那么,让我们从这里开始:成为一个CMake 高级用户。我们将了解一些基础知识:这个工具是什么,原理上它如何工作,以及如何安装它。然后,我们将深入探讨命令行和操作模式。最后,我们将总结项目中不同文件的用途,并解释如何在不创建项目的情况下使用 CMake。

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

  • 理解基础知识

  • 在不同平台上安装 CMake

  • 精通命令行

  • 导航项目文件

  • 发现脚本和模块

技术要求

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

要构建本书提供的示例,请始终执行所有推荐的命令:

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

一定要将占位符<build tree><source tree>替换为适当的路径。正如本章所述,build tree是输出目录的路径,而source tree是源代码所在的路径。

为了构建 C++程序,你还需要一个适合你平台的编译器。如果你熟悉 Docker,你可以使用在不同平台上安装 CMake一节中介绍的完全集成的镜像。如果你更倾向于手动设置 CMake,我们将在同一节中解释安装过程。

理解基础知识

C++源代码的编译似乎是一个相当简单的过程。让我们从经典的 Hello World 示例开始。

以下代码位于ch01/01-hello/hello.cpp中,C++语言中的 Hello World

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

为了生成可执行文件,我们当然需要一个 C++编译器。CMake 本身不附带编译器,因此你需要自行选择并安装一个。常见的选择包括:

  • Microsoft Visual C++ 编译器

  • GNU 编译器集合

  • Clang/LLVM

大多数读者都对编译器非常熟悉,因为它是学习 C++时不可或缺的部分,所以我们不会详细介绍如何选择和安装编译器。本书中的示例将使用 GNU GCC,因为它是一个成熟的、开源的、可免费在多个平台上使用的软件编译器。

假设我们已经安装了编译器,对于大多数供应商和系统,运行它的方式类似。我们应该将文件名作为参数传递给它:

$ g++ hello.cpp -o hello 

代码是正确的,因此编译器将默默地生成一个可执行的二进制文件,供我们的机器理解。我们可以通过调用文件名来运行它:

$ ./hello
Hello World! 

运行一个命令来构建程序很简单;然而,随着项目的增长,你会很快明白,所有东西都保存在一个文件中是不可能的。清洁代码实践建议源代码文件应保持简短,并且结构要井井有条。手动编译每个文件可能是一个乏味且容易出错的过程。一定有更好的方法。

什么是 CMake?

假设我们通过编写一个脚本来自动化构建,该脚本遍历我们的项目树并编译所有内容。为了避免不必要的编译,脚本将检测自上次运行以来源代码是否已被修改。现在,我们希望有一种方便的方式来管理每个文件传递给编译器的参数——最好是基于可配置的标准来处理。此外,我们的脚本应当知道如何将所有已编译的文件链接成一个单一的二进制文件,或者更好的是,构建可以重用的完整解决方案,并将其作为模块集成到更大的项目中。

构建软件是一个非常多样化的过程,涵盖了多个不同的方面:

  • 编译可执行文件和库

  • 管理依赖关系

  • 测试

  • 安装

  • 打包

  • 生成文档

  • 再做一些测试

创建一个真正模块化且强大的 C++ 构建工具以适应所有需求将需要非常长的时间。事实证明,它做到了。Bill Hoffman 在 Kitware 实现了 CMake 的第一个版本,已经有 20 多年的历史了。正如你可能已经猜到的,它非常成功。如今,它拥有众多功能并得到了社区的广泛支持。CMake 正在积极开发,并已成为 C 和 C++ 程序员的行业标准。

自动化构建代码的问题早于 CMake 的诞生,所以自然,市面上有很多选择:GNU Make、Autotools、SCons、Ninja、Premake 等。但为什么 CMake 却占据优势呢?

关于 CMake,有几个我认为(当然是主观的)重要的地方:

  • 它始终专注于支持现代编译器和工具链。

  • CMake 真正跨平台——它支持为 Windows、Linux、macOS 和 Cygwin 构建项目。

  • 它为流行的 IDE 生成项目文件:Microsoft Visual Studio、Xcode 和 Eclipse CDT。此外,它也是其他项目模型的基础,如 CLion。

  • CMake 操作在恰当的抽象层次上——它允许你将文件分组到可重用的目标和项目中。

  • 有大量的项目是用 CMake 构建的,并提供了一种简便的方式将它们集成到你的项目中。

  • CMake 将测试、打包和安装视为构建过程的固有部分。

  • 为了保持 CMake 的精简,过时的、未使用的功能会被弃用。

CMake 提供了一种统一、简化的体验。无论你是在 IDE 中构建软件,还是直接从命令行构建,真正重要的是它也会处理构建后的阶段。

即使所有前面的环境不同,你的 CI/CD 流水线也可以轻松使用相同的 CMake 配置,并通过单一标准构建项目。

它是如何工作的?

你可能会认为 CMake 是一个在一端读取源代码并在另一端生成二进制文件的工具——虽然从原则上讲这是真的,但这并不是完整的画面。

CMake 本身不能独立构建任何东西——它依赖系统中的其他工具来执行实际的编译、链接和其他任务。你可以把它看作是构建过程的指挥者:它知道需要执行哪些步骤,最终目标是什么,以及如何找到合适的工作者和材料来完成任务。

这个过程有三个阶段:

  • 配置

  • 生成

  • 构建

让我们详细探讨一下这些内容。

配置阶段

这个阶段是关于读取存储在目录中的项目详情,称为 源树,并为生成阶段准备一个输出目录或 构建树

CMake 首先检查项目是否已配置过,并从 CMakeCache.txt 文件中读取缓存的配置变量。在第一次运行时,情况并非如此,因此它会创建一个空的构建树,并收集关于它所处环境的所有细节:例如,架构是什么,哪些编译器可用,已安装了哪些链接器和归档工具。此外,它还会检查是否能正确编译一个简单的测试程序。

接下来,解析并执行 CMakeLists.txt 项目配置文件(是的,CMake 项目是用 CMake 的编程语言配置的)。这个文件是一个 CMake 项目的最基本形式(源文件可以稍后添加)。它告诉 CMake 项目的结构、目标及其依赖项(库和其他 CMake 包)。

在此过程中,CMake 将收集的信息存储在构建树中,例如系统细节、项目配置、日志和临时文件,这些信息将在下一步骤中使用。具体来说,CMake 会创建一个 CMakeCache.txt 文件,用于存储更稳定的信息(例如编译器和其他工具的路径),这样当整个构建过程重新执行时,可以节省时间。

生成阶段

在读取项目配置后,CMake 将为其所处的具体环境生成一个 构建系统。构建系统实际上就是为其他构建工具(例如,GNU Make 或 Ninja 的 Makefile,以及 Visual Studio 的 IDE 项目文件)量身定制的配置文件。在这个阶段,CMake 还可以通过评估 生成器表达式 对构建配置进行一些最后的调整。

生成阶段会在配置阶段之后自动执行。因此,本书及其他资源有时在提到“构建系统的配置”或“生成”时会将这两个阶段互换使用。要明确仅运行配置阶段,可以使用 cmake-gui 工具。

构建阶段

为了生成项目中指定的最终产物(如可执行文件和库),CMake 需要运行适当的 构建工具。这可以通过直接调用、通过 IDE 或使用适当的 CMake 命令来实现。这些构建工具将执行步骤,通过编译器、链接器、静态和动态分析工具、测试框架、报告工具以及你能想到的其他任何工具来生成 目标产物

这个解决方案的优势在于,它能够通过单一配置(即相同的项目文件)按需为每个平台生成构建系统:

图 1.1:CMake 的各个阶段

你还记得我们在 理解基础 部分提到的 hello.cpp 应用程序吗?用 CMake 构建它非常简单。我们只需要在与源文件相同的目录中放置以下 CMakeLists.txt 文件。

ch01/01-hello/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Hello)
add_executable(Hello hello.cpp) 

创建该文件后,在相同目录下执行以下命令:

cmake -B <build tree>
cmake --build <build tree> 

请注意,<build tree>是一个占位符,应该替换为存放生成文件的临时目录的路径。

这是在 Docker 中运行的 Ubuntu 系统的输出(Docker 是一种可以在其他系统内运行的虚拟机;我们将在在不同平台上安装 CMake一节中讨论它)。第一个命令生成一个构建系统

~/examples/ch01/01-hello# cmake -B ~/build_tree
-- The C compiler identification is GNU 11.3.0
-- The CXX compiler identification is GNU 11.3.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (1.0s)
-- Generating done (0.1s)
-- Build files have been written to: /root/build_tree 

第二个命令实际上是构建项目:

~/examples/ch01/01-hello# cmake --build ~/build_tree
Scanning dependencies of target Hello
[ 50%] Building CXX object CMakeFiles/Hello.dir/hello.cpp.o
[100%] Linking CXX executable Hello
[100%] Built target Hello 

剩下的就是运行已编译的程序:

~/examples/ch01/01-hello# ~/build_tree/Hello
Hello World! 

在这里,我们已经生成了一个存储在构建树目录中的构建系统。接下来,我们执行了构建阶段并生成了一个可以运行的最终二进制文件。

现在你知道结果是什么样子了,我相信你一定有很多问题:这个过程的前提条件是什么?这些命令是什么意思?为什么需要两个命令?我该如何编写自己的项目文件?别担心——这些问题将在接下来的章节中得到解答。

本书将为您提供与当前版本的 CMake 相关的最重要信息(截至写作时,版本为 3.26)。为了给您提供最佳建议,我特别避免了任何已弃用或不再推荐的功能,并且强烈建议至少使用 CMake 版本 3.15,这被认为是现代 CMake。如果您需要更多信息,可以在cmake.org/cmake/help/在线查看最新的完整文档。

在不同平台上安装 CMake

CMake 是一个跨平台的开源软件,用 C++ 编写。这意味着你当然可以自己编译它;然而,最可能的情况是你不需要这么做。因为可以从官方网站下载预编译的二进制文件,cmake.org/download/

基于 Unix 的系统提供了可以直接从命令行安装的现成包。

记住,CMake 并不自带编译器。如果您的系统还没有安装编译器,您需要在使用 CMake 之前先安装它们。确保将编译器的可执行文件路径添加到PATH环境变量中,以便 CMake 能找到它们。

为了避免在学习本书时遇到工具和依赖问题,我建议通过使用第一种安装方法:Docker 进行实践。在真实的工作场景中,除非你本身就处于虚拟化环境中,否则你当然会选择使用本地版本。

让我们来看一下 CMake 可以使用的不同环境。

Docker

Docker(www.docker.com/)是一个跨平台工具,提供操作系统级虚拟化,允许应用程序以称为容器的定义良好的包的形式进行交付。这些自给自足的包包含了运行软件所需的所有库、依赖项和工具。Docker 在轻量级环境中执行其容器,并且这些环境相互隔离。

这个概念使得共享完整的工具链变得极为方便,这些工具链是某一特定过程所必需的,已经配置好并随时可以使用。当你不需要担心微小的环境差异时,一切变得非常简单,我无法强调这点有多么重要。

Docker 平台有一个公开的容器镜像仓库,registry.hub.docker.com/,提供数百万个现成的镜像。

为了方便起见,我已经发布了两个 Docker 仓库:

  • swidzinski/cmake2:base:一个基于 Ubuntu 的镜像,包含构建 CMake 所需的工具和依赖项

  • swidzinski/cmake2:examples:基于前述工具链的镜像,包含本书中的所有项目和示例

第一个选项适用于那些仅仅想要一个干净的镜像,准备构建自己项目的读者,第二个选项适用于在我们通过章节时进行动手实践并使用示例的读者。

你可以按照其官方文档中的说明安装 Docker(请参阅 docs.docker.com/get-docker)。然后,在终端中执行以下命令来下载镜像并启动容器:

$ docker pull swidzinski/cmake2:examples
$ docker run -it swidzinski/cmake2:examples
root@b55e271a85b2:root@b55e271a85b2:# 

请注意,示例位于匹配此格式的目录中:

devuser/examples/examples/ch<N>/<M>-<title> 

在这里,<N><M> 分别是零填充的章节和示例编号(如 010812)。

Windows

在 Windows 上安装非常简单——只需从官方网站下载 32 位或 64 位的版本。你也可以选择便携式 ZIP 或 MSI 包,使用 Windows 安装程序,它将把 CMake 的 bin 目录添加到 PATH 环境变量中(图 1.2),这样你就可以在任何目录中使用它,而不会出现类似的错误:

cmake 未被识别为内部或外部命令、可操作程序或批处理文件。

如果你选择 ZIP 包,你需要手动完成安装。MSI 安装程序带有一个方便的图形用户界面:

图 1.2:安装向导可以为你设置 PATH 环境变量

正如我之前提到的,这是开源软件,因此你可以自行构建 CMake。然而,在 Windows 上,你首先需要在系统上获取 CMake 的二进制版本。这种情况是 CMake 贡献者用来生成新版本的方式。

Windows 平台与其他平台没有区别,也需要一个可以完成 CMake 启动的构建工具。一个流行的选择是 Visual Studio IDE,它捆绑了一个 C++编译器。社区版可以从 Microsoft 官网免费下载:visualstudio.microsoft.com/downloads/

Linux

在 Linux 上安装 CMake 与其他流行软件包的安装过程相同:从命令行调用包管理器。包仓库通常会保持更新,提供相对较新的 CMake 版本,但通常不会是最新版本。如果您对此没有异议,并且使用的是 Debian 或 Ubuntu 等发行版,那么最简单的做法就是直接安装适当的包:

$ sudo apt-get install cmake 

对于 Red Hat 发行版,请使用以下命令:

$ yum install cmake 

请注意,在安装包时,包管理器将从为您的操作系统配置的仓库中获取最新版本。在许多情况下,包仓库并不提供最新版本,而是提供一个经过时间验证的稳定版本,这些版本通常能够可靠地运行。根据您的需求选择,但请注意,旧版本不会具备本书中描述的所有功能。

要获取最新版本,请参考 CMake 官方网站的下载部分。如果您知道当前版本号,可以使用以下命令之一。

Linux x86_64 的命令是:

$ VER=3.26.0 && wget https://github.com/Kitware/CMake/releases/download/v$VER/cmake-$VER-linux-x86_64.sh && chmod +x cmake-$VER-linux-x86_64.sh && ./cmake-$VER-linux-x86_64.sh 

Linux AArch64 的命令是:

$ VER=3.26.0 && wget https://github.com/Kitware/CMake/releases/download/v$VER/cmake-$VER-Linux-aarch64.sh && chmod +x cmake-$VER-Linux-aarch64.sh && ./cmake-$VER-Linux-aarch64.sh 

或者,查看从源代码构建部分,学习如何在您的平台上自行编译 CMake。

macOS

这个平台也得到了 CMake 开发者的强力支持。最常见的安装选择是通过 MacPorts,使用以下命令:

$ sudo port install cmake 

请注意,在写作时,MacPorts 中提供的最新版本是 3.24.4。要获取最新版本,请安装cmake-devel包:

$ sudo port install cmake-devel 

或者,您可以使用 Homebrew 包管理器:

$ brew install cmake 

macOS 的包管理器将涵盖所有必要步骤,但请注意,除非您从源代码构建,否则您可能无法获得最新版本。

从源代码构建

如果您使用的是其他平台,或者只是想体验尚未发布的最新版本(或未被您喜欢的包仓库采用),请从官方网站下载源代码并自行编译:

$ wget https://github.com/Kitware/CMake/releases/download/v3.26.0/cmake-3.26.0.tar.gz
$ tar xzf cmake-3.26.0.tar.gz
$ cd cmake-3.26.0
$ ./bootstrap
$ make
$ make install 

从源代码构建相对较慢,并且需要更多步骤。然而,只有通过这种方式,您才能自由选择任何版本的 CMake。这对于操作系统仓库中提供的包过时的情况尤其有用:系统版本越老,更新的频率越低。

现在我们已经安装了 CMake,接下来让我们学习如何使用它!

精通命令行

本书的大部分内容将教你如何为用户准备 CMake 项目。为了满足用户的需求,我们需要深入了解用户在不同场景下如何与 CMake 进行交互。这样,你可以测试你的项目文件,并确保它们正常工作。

CMake 是一套工具,包含五个可执行文件:

  • cmake:配置、生成和构建项目的主要可执行文件

  • ctest:用于运行并报告测试结果的测试驱动程序

  • cpack:用于生成安装程序和源代码包的打包程序

  • cmake-guicmake 的图形界面包装器

  • ccmakecmake 的基于控制台的 GUI 包装器

此外,CMake 的背后公司 Kitware 还提供了一个名为 CDash 的独立工具,用于对我们项目的构建健康状态进行高级监控。

CMake 命令行

cmake 是 CMake 套件的主要二进制文件,并提供几种操作模式(有时也称为动作):

  • 生成项目构建系统

  • 构建项目

  • 安装项目

  • 运行脚本

  • 运行命令行工具

  • 运行工作流预设

  • 获取帮助

让我们看看它们是如何工作的。

生成项目构建系统

构建我们项目所需的第一步是生成构建系统。以下是三种执行 CMake 生成项目构建系统 操作的命令形式:

cmake [<options>] -S <source tree> -B <build tree>
cmake [<options>] <source tree>
cmake [<options>] <build tree> 

我们将在接下来的章节中讨论可用的 <options>。现在,让我们专注于选择正确的命令形式。CMake 的一个重要特点是支持 源代码外构建 或支持将 构建产物 存储在与源代码树不同的目录中。这是一种推荐的做法,可以保持源代码目录的干净,避免将意外的文件或忽略指令污染 版本控制系统VCSs)。

这就是为什么第一种命令形式是最实用的原因。它允许我们分别使用 -S-B 来指定源代码树的路径和生成的构建系统路径:

cmake -S ./project -B ./build 

CMake 将从 ./project 目录读取项目文件,并在 ./build 目录中生成构建系统(如有需要,事先创建该目录)。

我们可以省略一个参数,cmake 会“猜测”我们打算使用当前目录。注意,省略两个参数将产生 源代码内构建,并将 构建产物 与源代码文件一起存储,这是我们不希望发生的。

运行 CMAKE 时要明确

不要使用 cmake <directory> 命令的第二种或第三种形式,因为它们可能会产生一个杂乱的 源代码内构建。在 第四章设置你的第一个 CMake 项目 中,我们将学习如何防止用户这样做。

<directory>: it will use the cached path to the sources and rebuild from there. Since we often invoke the same commands from the Terminal command history, we might get into trouble here; before using this form, always check whether your shell is currently working in the right directory.

示例

使用上一级目录中的源代码在当前目录中生成构建树:

cmake -S .. 

使用当前目录中的源代码在 ./build 目录中生成构建树:

cmake -B build 

选择生成器

如前所述,在生成阶段,你可以指定一些选项。选择和配置生成器决定了在后续的构建项目部分中,系统将使用哪个构建工具,构建文件的样子,以及构建树的结构。

那么,你应该在意吗?幸运的是,答案通常是“否”。CMake 确实支持多个本地构建系统在许多平台上的使用;然而,除非你同时安装了几个生成器,否则 CMake 会为你正确地选择一个。这个选择可以通过CMAKE_GENERATOR环境变量或直接在命令行中指定生成器来覆盖,例如:

cmake -G <generator name> -S <source tree> -B <build tree> 

一些生成器(例如 Visual Studio)支持对工具集(编译器)和平台(编译器或 SDK)进行更深入的指定。此外,CMake 会扫描那些覆盖默认值的环境变量:CMAKE_GENERATOR_TOOLSETCMAKE_GENERATOR_PLATFORM。另外,这些值也可以在命令行中直接指定:

cmake -G <generator name>
      -T <toolset spec>
      -A <platform name>
      -S <source tree> -B <build tree> 

Windows 用户通常希望为他们喜欢的 IDE 生成构建系统。在 Linux 和 macOS 上,使用Unix MakefilesNinja生成器非常常见。

要检查你的系统上可用的生成器,请使用以下命令:

cmake --help 

help输出的末尾,你将得到一个完整的生成器列表,例如在 Windows 10 上生成的列表(部分输出已被截断以提高可读性):

此平台上可用的生成器如下:

Visual Studio 17 2022       
Visual Studio 16 2019       
Visual Studio 15 2017 [arch]
Visual Studio 14 2015 [arch]
Visual Studio 12 2013 [arch]
Visual Studio 11 2012 [arch]
Visual Studio 9 2008 [arch] 
Borland Makefiles           
NMake Makefiles             
NMake Makefiles JOM         
MSYS Makefiles              
MinGW Makefiles             
Green Hills MULTI           
Unix Makefiles              
Ninja                       
Ninja Multi-Config          
Watcom WMake                
CodeBlocks - MinGW Makefiles
CodeBlocks - NMake Makefiles
CodeBlocks - NMake Makefiles JOM
CodeBlocks - Ninja          
CodeBlocks - Unix Makefiles 
CodeLite - MinGW Makefiles  
CodeLite - NMake Makefiles  
CodeLite - Ninja            
CodeLite - Unix Makefiles   
Eclipse CDT4 - NMake Makefiles
Eclipse CDT4 - MinGW Makefiles
Eclipse CDT4 - Ninja        
Eclipse CDT4 - Unix Makefiles
Kate - MinGW Makefiles      
Kate - NMake Makefiles      
Kate - Ninja                
Kate - Unix Makefiles       
Sublime Text 2 - MinGW Makefiles
Sublime Text 2 - NMake Makefiles
Sublime Text 2 - Ninja      
Sublime Text 2 - Unix Makefiles 

如你所见,CMake 支持许多不同的生成器和 IDE。

管理项目缓存

CMake 在配置阶段会查询系统的各种信息。由于这些操作可能需要一些时间,因此收集到的信息会缓存到构建树目录中的CMakeCache.txt文件中。有一些命令行选项可以更方便地管理缓存的行为。

我们可以使用的第一个选项是能够预填充缓存信息

cmake -C <initial cache script> -S <source tree> -B <build tree> 

我们可以提供一个 CMake 列表文件的路径,该文件(仅)包含一个set()命令列表,用于指定将用于初始化一个空构建树的变量。我们将在下一章讨论编写列表文件。

初始化和修改现有的缓存变量可以通过另一种方式进行(例如,当创建一个文件仅仅是为了设置几个变量时,可能有些过于繁琐)。你可以在命令行中直接设置它们,如下所示:

cmake -D <var>[:<type>]=<value> -S <source tree> -B <build tree> 

:<type>部分是可选的(它由 GUI 使用),并接受以下类型:BOOLFILEPATHPATHSTRINGINTERNAL。如果你省略类型,CMake 会检查变量是否存在于CMakeCache.txt文件中并使用其类型;否则,它将被设置为UNINITIALIZED

一个特别重要的变量是我们通常通过命令行设置的,它指定了构建类型CMAKE_BUILD_TYPE)。大多数 CMake 项目将在多个场合使用它来决定诊断信息的详细程度、调试信息的存在与否,以及创建的工件的优化级别。

对于单配置生成器(如 GNU Make 和 Ninja),你应该在配置阶段指定构建类型,并为每种配置类型生成一个单独的构建树。这里使用的值有DebugReleaseMinSizeRelRelWithDebInfo。如果缺少此信息,可能会对依赖它进行配置的项目产生未定义的影响。

这是一个例子:

cmake -S . -B ../build -D CMAKE_BUILD_TYPE=Release 

请注意,多配置生成器是在构建阶段配置的。

出于诊断目的,我们还可以使用-L选项列出缓存变量:

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

有时,项目作者可能会提供有用的帮助信息与变量一起显示——要打印它们,请添加H修饰符:

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

令人惊讶的是,手动使用-D选项添加的自定义变量,除非你指定支持的类型之一,否则在此打印输出中不可见。

可以使用以下选项来移除一个或多个变量:

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

在这里,通配符表达式支持*(通配符)和?(任意字符)符号。使用这些符号时要小心,因为很容易删除比预期更多的变量。

-U-D选项可以重复使用多次。

调试和追踪

cmake命令可以使用多种选项来让你查看内部信息。要获取关于变量、命令、宏和其他设置的一般信息,可以运行以下命令:

cmake --system-information [file] 

可选的文件参数允许你将输出存储到文件中。在构建树目录中运行它将打印关于缓存变量和日志文件中构建信息的额外内容。

在我们的项目中,我们将使用message()命令来报告构建过程的详细信息。CMake 根据当前的日志级别(默认情况下为STATUS)筛选这些日志输出。以下行指定了我们感兴趣的日志级别:

cmake --log-level=<level> 

在这里,level可以是以下任意之一:ERRORWARNINGNOTICESTATUSVERBOSEDEBUGTRACE。你可以在CMAKE_MESSAGE_LOG_LEVEL缓存变量中永久指定此设置。

另一个有趣的选项允许你在每次调用message()显示日志上下文。为了调试非常复杂的项目,可以像使用堆栈一样使用CMAKE_MESSAGE_CONTEXT变量。每当你的代码进入一个有趣的上下文时,你可以为它起个描述性的名字。通过这种方式,我们的消息将附带当前的CMAKE_MESSAGE_CONTEXT变量,如下所示:

[some.context.example] Debug message. 

启用此类日志输出的选项如下:

cmake --log-context <source tree> 

我们将在第二章,《CMake 语言》中更详细地讨论命名上下文和日志命令。

如果其他方法都失败了,我们需要使用“重磅武器”,那么总有追踪模式,它会打印出每个执行的命令,包含文件名、调用所在的行号以及传递的参数列表。你可以通过以下方式启用它:

cmake --trace 

正如你所想象的那样,它不推荐用于日常使用,因为输出内容非常长。

配置预设

用户可以指定许多选项来生成项目的构建树。在处理构建树路径、生成器、缓存和环境变量时,很容易感到困惑或遗漏某些内容。开发者可以简化用户与项目的交互,并提供一个 CMakePresets.json 文件,指定一些默认设置。

要列出所有可用的预设,执行以下命令:

cmake --list-presets 

你可以使用以下提供的预设之一:

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

要了解更多,请参阅本章的导航项目文件部分和第十六章编写 CMake 预设

清理构建树

有时,我们可能需要删除生成的文件。这可能是由于构建之间环境发生了变化,或者仅仅是为了确保我们在干净的状态下工作。我们可以手动删除构建树目录,或者仅将 --fresh 参数添加到命令行中:

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

CMake 然后会以系统无关的方式删除 CMakeCache.txtCMakeFiles/,并从头开始生成构建系统。

构建一个项目

在生成构建树后,我们可以开始构建项目。CMake 不仅知道如何为许多不同的构建器生成输入文件,还可以根据项目的需求为它们运行,并提供适当的参数。

避免直接调用 MAKE

许多在线资源推荐在生成阶段之后直接通过调用 make 命令来运行 GNU Make。因为 GNU Make 是 Linux 和 macOS 的默认生成器,所以这个建议是有效的。然而,建议使用本节中描述的方法,因为它与生成器无关,并且在所有平台上都得到官方支持。因此,你不需要担心每个应用程序用户的确切环境。

构建模式的语法是:

cmake --build <build tree> [<options>] [-- <build-tool-options>] 

在大多数情况下,只需要提供最低限度的设置即可成功构建:

cmake --build <build tree> 

唯一必需的参数是生成的构建树的路径。这与在生成阶段通过 -B 参数传递的路径相同。

CMake 允许你指定适用于所有构建器的关键构建参数。如果你需要向所选的本地构建器传递特殊参数,可以将它们添加到命令末尾,在 -- 标记之后:

cmake --build <build tree> -- <build tool options> 

让我们看看还有哪些其他选项可用。

运行并行构建

默认情况下,许多构建工具会使用多个并发进程来利用现代处理器并并行编译源代码。构建工具了解项目依赖关系的结构,因此它们可以同时处理满足依赖关系的步骤,从而节省用户时间。

如果你希望在多核机器上更快地构建(或为了调试强制进行单线程构建),你可能想要覆盖该设置。

只需通过以下任一选项指定作业的数量:

cmake --build <build tree> --parallel [<number of jobs>]
cmake --build <build tree> -j [<number of jobs>] 

另一种方法是通过CMAKE_BUILD_PARALLEL_LEVEL环境变量进行设置。命令行选项将覆盖这个变量。

选择构建和清理的目标

每个项目由一个或多个部分组成,这些部分被称为目标(我们将在本书的第二部分讨论这些)。通常,我们希望构建所有可用的目标;然而,有时我们可能希望跳过某些目标,或者明确构建一个故意排除在正常构建之外的目标。我们可以通过以下方式做到这一点:

cmake --build <build tree> --target <target1> --target <target2> … 

我们可以通过重复使用–target参数来指定多个构建目标。此外,还有一个简写版本-t <target>,可以用来代替。

清理构建树

一个特殊的目标是clean,通常不会被构建。构建它的特殊效果是从构建目录中删除所有产物,这样以后可以重新从头开始创建。你可以通过以下方式开始这个过程:

cmake --build <build tree> -t clean 

此外,如果你希望先清理然后再执行正常构建,CMake 还提供了一个便捷的别名:

cmake --build <build tree> --clean-first 

这个操作与清理构建树部分中提到的清理不同,它只影响目标产物,而不会影响其他内容(如缓存)。

为多配置生成器配置构建类型

所以,我们已经了解了一些关于生成器的信息:它们有不同的形状和大小。其中一些生成器支持在一个构建树中构建DebugRelease这两种构建类型。支持这一功能的生成器包括 Ninja Multi-Config、Xcode 和 Visual Studio。其他生成器都是单配置生成器,它们需要为每个想要构建的配置类型提供单独的构建树。

选择DebugReleaseMinSizeRelRelWithDebInfo并按以下方式指定:

cmake --build <build tree> --config <cfg> 

否则,CMake 将使用Debug作为默认设置。

调试构建过程

当出现问题时,我们首先应该检查输出信息。然而,经验丰富的开发者知道,始终打印所有细节会让人困惑,所以它们通常默认隐藏这些信息。当我们需要深入查看时,可以通过告诉 CMake 启用详细模式来获得更详细的日志:

cmake --build <build tree> --verbose
cmake --build <build tree> -v 

同样的效果可以通过设置CMAKE_VERBOSE_MAKEFILE缓存变量来实现。

安装项目

当构建产物生成后,用户可以将它们安装到系统中。通常,这意味着将文件复制到正确的目录,安装库,或执行某些来自 CMake 脚本的自定义安装逻辑。

安装模式的语法是:

cmake --install <build tree> [<options>] 

与其他操作模式一样,CMake 需要一个生成的构建树的路径:

cmake --install <build tree> 

安装操作还有很多其他附加选项。让我们看看它们能做些什么。

选择安装目录

我们可以通过添加我们选择的前缀来预先添加安装路径(例如,当我们对某些目录的写入权限有限时)。以/home/user为前缀的/usr/local路径变成/home/user/usr/local

该选项的签名如下:

cmake --install <build tree> --install-prefix <prefix> 

如果你使用的是 CMake 3.21 或更早版本,你将需要使用一个不太明确的选项:

cmake --install <build tree> --prefix <prefix> 

请注意,这在 Windows 上无法使用,因为该平台上的路径通常以驱动器字母开头。

针对多配置生成器的安装

就像在构建阶段一样,我们可以指定要用于安装的构建类型(更多细节请参见构建项目部分)。可用的类型包括DebugReleaseMinSizeRelRelWithDebInfo。其签名如下:

cmake --install <build tree> --config <cfg> 

选择要安装的组件

作为开发人员,你可能选择将项目拆分为可以独立安装的组件。我们将在第十四章 安装和打包中进一步讨论组件的概念。现在,我们假设它们表示一些不需要在每种情况下都使用的工件集。这可能是像applicationdocsextra-tools之类的东西。

要安装单个组件,使用以下选项:

cmake --install <build tree> --component <component> 

设置文件权限

如果安装是在类似 Unix 的平台上进行的,你可以使用以下选项指定已安装目录的默认权限,格式为u=rwx,g=rx,o=rx

cmake --install <build tree>
      --default-directory-permissions <permissions> 

调试安装过程

类似于构建阶段,我们还可以选择查看安装阶段的详细输出。为此,请使用以下任一方法:

cmake --install <build tree> --verbose
cmake --install <build tree> -v 

如果设置了VERBOSE环境变量,也可以实现相同的效果。

运行脚本

CMake 项目使用 CMake 自定义语言进行配置。它是跨平台的,且非常强大。既然它已经存在,为什么不让它用于其他任务呢?果然,CMake 可以运行独立的脚本(更多内容请参见发现脚本和模块部分),如下所示:

cmake [{-D <var>=<value>}...] -P <cmake script file>
      [-- <unparsed options>...] 

运行这样的脚本不会执行任何配置生成阶段,也不会影响缓存。

你可以通过两种方式将值传递给此脚本:

  • 通过使用-D选项定义的变量

  • 通过可以在--标记后传递的参数

CMake 将为所有传递给脚本的参数(包括--标记)创建CMAKE_ARGV<n>变量。

运行命令行工具

在少数情况下,我们可能需要以平台无关的方式运行单个命令——比如复制文件或计算校验和。并非所有平台都是一样的,因此并非每个系统都提供所有命令(或者它们的名称不同)。

CMake 提供了一种模式,在这种模式下,大多数常见命令可以跨平台以相同的方式执行。其语法如下:

cmake -E <command> [<options>] 

由于这种模式的使用比较有限,我们不会深入讨论它。不过,如果你对细节感兴趣,我建议运行 cmake -E 来列出所有可用命令。为了简单了解可用命令,CMake 3.26 支持以下命令:capabilitiescatchdircompare_filescopycopy_directorycopy_directory_if_differentcopy_if_differentechoecho_appendenvenvironmentmake_directorymd5sumsha1sumsha224sumsha256sumsha384sumsha512sumremoveremove_directoryrenamermsleeptartimetouchtouch_nocreatecreate_symlinkcreate_hardlinktruefalse

如果你想使用的命令缺失,或者你需要更复杂的行为,可以考虑将其包装在脚本中并以 -P 模式运行。

运行预设工作流

我们在 它是如何工作的? 部分中提到,使用 CMake 构建有三个阶段:配置、生成和构建。此外,我们还可以通过 CMake 运行自动化测试,甚至创建可重新分发的包。通常,用户需要通过命令行手动执行每个步骤,通过调用适当的 cmake 操作。然而,高级项目可以指定 工作流预设,将多个步骤捆绑为一个操作,只需一个命令就能执行。现在,我们只提到,用户可以通过运行以下命令来获取可用预设的列表:

cmake ––workflow --list-presets 

它们可以通过以下方式执行预设的工作流:

cmake --workflow --preset <name> 

这一部分将在 第十六章编写 CMake 预设 中深入讲解。

获取帮助

不足为奇的是,CMake 提供了大量的帮助,可以通过其命令行访问。帮助模式的语法如下:

cmake --help 

这将打印出可供深入探索的主题列表,并解释需要添加哪些参数才能获得更多帮助。

CTest 命令行

自动化测试对于生成和维护高质量代码非常重要。CMake 套件带有一个专门的命令行工具,名为 CTest,专门用于此目的。它旨在标准化测试的运行和报告方式。作为 CMake 用户,你不需要了解测试这个特定项目的细节:使用了什么框架或如何运行它。CTest 提供了一个便捷的界面,可以列出、筛选、打乱、重试和时间限制测试运行。

要运行已构建项目的测试,我们只需在生成的构建树中调用 ctest

$ ctest
Test project /tmp/build
Guessing configuration Debug
    Start 1: SystemInformationNew
1/1 Test #1: SystemInformationNew .........   Passed 3.19 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) =   3.24 sec 

我们为此专门编写了整整一章内容:第十一章测试框架

CPack 命令行

在我们构建并测试完我们惊人的软件后,我们准备与世界分享它。少数强力用户完全可以使用源代码。然而,绝大多数人为了方便和节省时间,选择使用预编译的二进制文件。

CMake 并不会让你陷入困境,它是自带电池的。CPack 是一个工具,用于为各种平台创建可重新分发的包:压缩归档、可执行安装程序、向导、NuGet 包、macOS 包、DMG 包、RPM 等。

CPack 的工作方式与 CMake 非常相似:它使用 CMake 语言进行配置,并且有许多 包生成器 可供选择(不要与 CMake 构建系统生成器混淆)。我们将在 第十四章《安装与打包》中详细介绍它,因为这个工具是为成熟的 CMake 项目使用的。

CMake GUI

CMake for Windows 带有一个 GUI 版本,用于配置之前准备好的项目的构建过程。对于类 Unix 平台,它有一个使用 Qt 库构建的版本。Ubuntu 在 cmake-qt-gui 包中提供了它。

要访问 CMake GUI,请运行 cmake-gui 可执行文件:

图 1.3:CMake GUI——使用 Visual Studio 2019 生成器的构建系统的配置阶段

GUI 应用程序是为你的应用程序用户提供的便利:对于那些不熟悉命令行并且更喜欢图形界面的用户来说,它非常有用。

使用命令行工具代替

我绝对推荐终端用户使用 GUI,但对于像你这样的程序员,我建议避免任何手动阻塞步骤,因为每次构建程序时都需要点击表单。这在成熟的项目中尤其有利,其中整个构建过程可以在没有任何用户交互的情况下完全执行。

CCMake 命令行

ccmake 可执行文件是 CMake 在类 Unix 平台上的交互式文本用户界面(如果没有明确构建,它在 Windows 上不可用)。我在这里提到它是为了让你知道当你看到它时会是什么(图 1.4),但和 GUI 一样,开发人员直接编辑 CMakeCache.txt 文件会更有利。

图 1.4:ccmake 中的配置阶段

说完这些,我们已经完成了 CMake 套件命令行的基本介绍。现在是时候了解一个典型的 CMake 项目的结构了。

导航项目目录和文件

CMake 项目由相当多的文件和目录组成。让我们大致了解每个文件的作用,以便我们可以开始修改它们。它们大致可以分为几类:

  • 当然,我们会有一些项目文件,这些文件是我们作为开发人员在项目发展过程中准备和修改的。

  • 还有一些文件是 CMake 为自己生成的,尽管它们包含 CMake 语言命令,但并不是供开发人员编辑的。任何手动更改都会被 CMake 覆盖。

  • 有些文件是为高级用户(即:非项目开发人员)设计的,用来根据个人需求定制 CMake 构建项目的方式。

  • 最后,还有一些临时文件,在特定上下文中提供有价值的信息。

本节还将建议你可以将哪些文件放入版本控制系统VCS)的忽略文件中。

源代码树

这是你的项目所在的目录(也称为项目根目录)。它包含所有 C++源代码和 CMake 项目文件。

以下是该目录中的最重要要点:

  • 它需要一个CMakeLists.txt配置文件。

  • 这个目录的路径由用户在使用cmake命令生成构建系统时通过-S参数指定。

  • 避免在 CMake 代码中硬编码任何指向源代码树的绝对路径——你的软件用户会将项目存储在不同的路径中。

在这个目录中初始化一个版本库是个好主意,可以使用像Git这样的 VCS。

构建树

CMake 在用户指定的路径中创建此目录。它将存储构建系统和构建过程中创建的所有内容:项目的构建产物、临时配置、缓存、构建日志以及本地构建工具(如 GNU Make)的输出。此目录的其他名称包括构建根目录二进制树

记住的关键点:

  • 你的构建配置(构建系统)和构建产物将被创建在这里(例如二进制文件、可执行文件、库文件,以及用于最终链接的目标文件和归档文件)。

  • CMake 建议将该目录放置在源代码树目录之外(这种做法称为源外构建)。这样,我们可以防止项目的污染(源内构建)。

  • 它通过-B参数在生成构建系统时指定给cmake命令。

  • 这个目录并不是生成文件的最终目的地。相反,建议你的项目包含一个安装阶段,将最终的构件复制到系统中应有的位置,并删除所有用于构建的临时文件。

不要将这个目录添加到版本控制系统(VCS)中——每个用户会为自己选择一个目录。如果你有充分的理由进行源代码内构建,请确保将这个目录添加到 VCS 忽略文件中(例如.gitignore)。

列表文件

包含 CMake 语言的文件称为列表文件,可以通过调用include()find_package()来互相包含,或者通过add_subdirectory()间接包含。CMake 并不强制规定这些文件的命名规则,但根据惯例,它们的扩展名是.cmake

项目文件

CMake 项目使用CMakeLists.txt列表文件进行配置(注意,由于历史原因,这个文件的扩展名不常见)。该文件是每个项目源代码树顶端必须存在的文件,并且是配置阶段首先执行的文件。

顶级的CMakeLists.txt应该包含至少两个命令:

  • cmake_minimum_required(VERSION <x.xx>):设置 CMake 的期望版本,并告诉 CMake 如何处理与策略相关的遗留行为。

  • project(<name> <OPTIONS>):命名项目(提供的名称将存储在PROJECT_NAME变量中),并指定配置项目的选项(更多内容请参见第二章CMake 语言)。

随着软件的成长,你可能希望将其划分为可以单独配置和推理的小单元。CMake 通过引入具有独立CMakeLists.txt文件的子目录来支持这一点。你的项目结构可能类似于以下示例:

myProject/CMakeLists.txt
myProject/api/CMakeLists.txt
myProject/api/api.h
myProject/api/api.cpp 

一个非常简单的顶层CMakeLists.txt文件可以将所有内容整合在一起:

cmake_minimum_required(VERSION 3.26)
project(app)
message("Top level CMakeLists.txt")
add_subdirectory(api) 

项目的主要方面在顶层文件中得到涵盖:管理依赖项、声明需求以及检测环境。我们还有一个add_subdirectory(api)命令,用来包含api子目录中的另一个CMakeLists.txt文件,以执行特定于应用程序 API 部分的步骤。

缓存文件

缓存变量将在配置阶段第一次运行时从列表文件生成,并存储在CMakeCache.txt中。该文件位于构建树的根目录,格式非常简单(为了简洁,省略了一些行):

# This is the CMakeCache file.
# For build in directory: /root/build tree
# It was generated by CMake: /usr/local/bin/cmake
# The syntax for the file is as follows:
# KEY:TYPE=VALUE
# KEY is the name of a variable in the cache.
# TYPE is a hint to GUIs for the type of VALUE, DO NOT EDIT
  #TYPE!.
# VALUE is the current value for the KEY.
########################
# EXTERNAL cache entries
########################
# Flags used by the CXX compiler during DEBUG builds.
CMAKE_CXX_FLAGS_DEBUG:STRING=/MDd /Zi /Ob0 /Od /RTC1
# ... more variables here ...
########################
# INTERNAL cache entries
########################
# Minor version of cmake used to create the current loaded
  cache
CMAKE_CACHE_MINOR_VERSION:INTERNAL=19
# ... more variables here ... 

从头部注释中可以看到,这种格式非常易于理解。EXTERNAL部分的缓存项是供用户修改的,而INTERNAL部分则由 CMake 管理。

这里有几个关键点需要记住:

  • 你可以通过手动管理此文件,调用cmake(请参阅本章命令行精通部分中的缓存选项),或者通过ccmakecmake-gui进行管理。

  • 你可以通过删除此文件来重置项目为默认配置;它将从列表文件重新生成。

缓存变量可以从列表文件中读取和写入。有时,变量引用的求值过程会有些复杂;我们将在第二章CMake 语言中详细讨论这一点。

包定义文件

CMake 生态系统的一个重要组成部分是项目可以依赖的外部包。它们以无缝、跨平台的方式提供库和工具。希望提供 CMake 支持的包作者会附带一个 CMake 包配置文件。

我们将在第十四章安装与打包中学习如何编写这些文件。与此同时,以下是一些值得注意的细节:

  • 配置文件(原始拼写)包含有关如何使用库的二进制文件、头文件和辅助工具的信息。有时,它们会公开可以在你的项目中使用的 CMake 宏和函数。

  • 配置文件命名为<PackageName>-config.cmake<PackageName>Config.cmake

  • 使用find_package()命令来包含包。

如果需要特定版本的包,CMake 将会检查关联的<PackageName>-config-version.cmake<PackageName>ConfigVersion.cmake

如果供应商没有为包提供配置文件,有时配置会与 CMake 本身捆绑,或者可以通过项目中的Find-module(原始拼写)提供。

生成的文件

许多文件是由cmake可执行文件在生成阶段生成的。因此,它们不应手动编辑。CMake 将它们用作cmake安装操作、CTest 和 CPack 的配置。

可能会遇到的文件包括:

  • cmake_install.cmake

  • CTestTestfile.cmake

  • CPackConfig.cmake

如果你正在实现源代码构建,最好将它们添加到 VCS 忽略文件中。

JSON 和 YAML 文件

CMake 使用的其他格式包括JavaScript 对象表示法JSON)和另一种标记语言YAML)。这些文件作为与外部工具(如 IDE)进行通信的接口,或者提供可以轻松生成和解析的配置。

预设文件

项目的高级配置在需要指定诸如缓存变量、选择的生成器、构建树的路径等事项时,可能变得相对繁琐,特别是当我们有多种方式构建项目时。这时预设(presets)就派上用场了——我们可以通过提供一个存储所有详细信息的文件,而不是通过命令行手动配置这些值,并将其与项目一起发布。自 CMake 3.25 起,预设还允许我们配置工作流,将各个阶段(配置、构建、测试和打包)绑定为一个命名的执行步骤列表。

如本章的掌握命令行部分所提到的,用户可以通过 GUI 选择预设,或者使用命令--list-presets并使用--preset=<preset>选项为构建系统选择一个预设。

预设存储在两个文件中:

  • CMakePresets.json:这是供项目作者提供官方预设的文件。

  • CMakeUserPresets.json:这是为那些希望根据自己的喜好自定义项目配置的用户设计的(可以将其添加到 VCS 忽略文件中)。

预设在项目中并非必需,只有在高级场景下才会变得有用。详情请参见第十六章编写 CMake 预设

基于文件的 API

CMake 3.14 引入了一个 API,允许外部工具查询构建系统信息:生成文件的路径、缓存条目、工具链等。我们提到这个非常高级的话题,是为了避免在文档中看到基于文件的 API时产生困惑。该名称暗示了它的工作原理:必须将带有查询的 JSON 文件放置在构建树中的特定路径下。CMake 将在构建系统生成期间读取该文件,并将响应写入另一个文件,以便外部应用程序解析。

基于文件的 API 是为了替代一个已被弃用的机制——服务器模式(或 cmake-server),该机制最终在 CMake 3.26 中被移除。

配置日志

从 3.26 版本开始,CMake 将提供一个结构化的日志文件,用于高级调试 配置阶段,文件位于:

<build tree>/CMakeFiles/CMakeConfigureLog.yaml 

这是一个你通常不需要关注的特性——直到你需要的时候。

在 Git 中忽略文件

有许多版本控制系统(VCS);其中最流行的之一是 Git。每当我们开始一个新项目时,确保只将必要的文件添加到仓库中是很重要的。如果我们在 .gitignore 文件中指定不需要的文件,项目的清洁度会更容易维护。例如,我们可以排除一些生成的文件、用户特定的文件或临时文件。

Git 在创建新的提交时会自动跳过这些文件。以下是我在项目中使用的文件:

ch01/01-hello/.gitignore

CMakeUserPresets.json
# If in-source builds are used, exclude their output like so:
build_debug/
build_release/
# Generated and user files
**/CMakeCache.txt
**/CMakeUserPresets.json
**/CTestTestfile.cmake
**/CPackConfig.cmake
**/cmake_install.cmake
**/install_manifest.txt
**/compile_commands.json 

现在,你持有了一张项目文件的地图。有些文件非常重要,你会经常使用它们,而有些文件则不太重要。虽然了解这些文件可能看起来没什么用,但知道 不该在哪里寻找 答案可能是非常宝贵的。无论如何,本章还有一个最后的问题:你可以使用 CMake 创建哪些其他自包含的单元?

发现脚本和模块

CMake 主要关注构建产生可以被其他系统使用的工件的项目(例如 CI/CD 管道、测试平台,或者部署到机器或存储在工件仓库中)。然而,CMake 还有两个概念使用其语言:脚本和模块。让我们解释它们是什么,以及它们有什么不同。

脚本

CMake 提供了一种与平台无关的编程语言,配备了许多有用的命令。用这种语言编写的脚本可以与更大的项目捆绑在一起,或者完全独立。

将其视为一种一致的跨平台工作方式。通常,为了执行一个任务,你需要为 Linux 创建一个单独的 Bash 脚本,为 Windows 创建单独的批处理文件或 PowerShell 脚本,依此类推。而 CMake 将这一切抽象化,你可以拥有一个在所有平台上都能正常工作的文件。当然,你也可以使用外部工具,如 Python、Perl 或 Ruby 脚本,但这增加了依赖项,会增加 C/C++ 项目的复杂性。那么,为什么要引入另一种语言呢?大多数情况下,你可以使用更简单的方式来完成任务。使用 CMake!

我们已经从 掌握命令行 部分学到,我们可以使用 -P 选项来执行脚本:cmake -P script.cmake

但是我们希望使用的脚本文件的实际要求是什么呢?其实并不复杂:脚本可以根据需要复杂,也可以只是一个空文件。不过,仍然建议在每个脚本的开头调用 cmake_minimum_required() 命令。该命令告诉 CMake 应该对项目中的后续命令应用哪些策略(详细内容请参见 第四章设置你的第一个 CMake 项目)。

这是一个简单脚本的示例:

ch01/02-script/script.cmake

# An example of a script
cmake_minimum_required(VERSION 3.26.0)
message("Hello world")
file(WRITE Hello.txt "I am writing to a file") 

在运行脚本时,CMake 不会执行任何常规阶段(例如配置或生成),也不会使用缓存,因为在脚本中没有 源树构建树 的概念。这意味着项目特定的 CMake 命令在脚本模式下不可用/不可使用。就这些了,祝你脚本编写愉快!

实用模块

CMake 项目可以使用外部模块来增强其功能。模块是用 CMake 语言编写的,包含宏定义、变量和执行各种功能的命令。它们的复杂程度从非常复杂的脚本(如 CPackCTest 提供的脚本)到相对简单的脚本,例如 AddFileDependenciesTestBigEndian

CMake 分发版包含了超过 80 个不同的实用模块。如果这些还不够,你可以通过浏览一些策划的列表,如 github.com/onqtam/awesome-cmake,从互联网上下载更多,或者从头编写你自己的模块。

要使用实用模块,我们需要调用 include(<MODULE>) 命令。以下是一个简单的项目,展示了这一过程:

ch01/03-module/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(ModuleExample)
include (TestBigEndian)
test_big_endian(IS_BIG_ENDIAN)
if(IS_BIG_ENDIAN)
message("BIG_ENDIAN")
else()
message("LITTLE_ENDIAN")
endif() 

我们将在相关主题时学习哪些模块可用。如果你感兴趣,可以查看包含模块的完整列表,网址为 cmake.org/cmake/help/latest/manual/cmake-modules.7.html

查找模块

包定义文件 部分,我提到 CMake 有一个机制,用来查找不支持 CMake 并且不提供 CMake 包配置文件的外部依赖文件。这就是查找模块的用途。CMake 提供了超过 150 个查找模块,能够定位这些已安装在系统中的包。就像实用模块一样,网上有更多的查找模块可用。作为最后的手段,你也可以自己编写。

你可以通过调用 find_package() 命令并提供相关包的名称来使用它们。这样,查找模块会进行一场捉迷藏的游戏,检查所有已知的软件位置。如果找到文件,将定义包含其路径的变量(如该模块手册中所指定)。现在,CMake 可以基于该依赖关系进行构建。

例如,FindCURL 模块搜索一个流行的 Client URL 库,并定义以下变量:CURL_FOUNDCURL_INCLUDE_DIRSCURL_LIBRARIESCURL_VERSION_STRING

我们将在 第九章在 CMake 中管理依赖》中更深入地讨论查找模块。

总结

现在您了解了 CMake 是什么以及它是如何工作的;您学习了 CMake 工具家族的关键组成部分,并了解了它如何在各种系统上安装。作为一名真正的高级用户,您知道如何通过命令行运行 CMake 的各种方式:构建系统生成、构建项目、安装、运行脚本、命令行工具和打印帮助信息。您还了解了 CTest、CPack 和 GUI 应用程序。这将帮助您以正确的视角为用户和其他开发者创建项目。此外,您还了解了项目的组成:目录、列表文件、配置、预设和辅助文件,以及如何在版本控制系统中忽略哪些内容。最后,您快速浏览了其他非项目文件:独立脚本和两种类型的模块——实用模块和查找模块。

在下一章中,我们将学习如何使用 CMake 编程语言。这将使您能够编写自己的列表文件,并为您的第一个脚本、项目和模块打开大门。

深入阅读

获取更多信息,您可以参考以下资源:

留下评论!

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

第二章:CMake 语言

CMake 语言 编写代码比想象的要复杂。当你第一次阅读 CMake 列表文件时,可能会觉得它的语言如此简单,以至于可以直接实践,而不需要任何理论基础。你可能会尝试做出修改,并在没有充分理解其工作原理的情况下实验代码。我不会责怪你。我们程序员通常都很忙,构建相关的问题通常也不是一个值得投入大量时间的有趣话题。为了快速推进,我们往往凭直觉做出修改,希望它们能奏效。这种解决技术问题的方式被称为巫术编程

CMake 语言看起来很简单:在我们引入了小扩展、修复、黑客技巧或一行代码后,我们突然发现有些东西不起作用。通常,调试所花费的时间超过了理解该主题本身所需的时间。幸运的是,这不会成为我们的命运,因为本章涵盖了使用 CMake 语言实践所需的大部分关键知识。

在本章中,我们不仅将学习 CMake 语言的构成模块——注释命令变量控制结构——还将理解相关背景知识,并根据最新的实践进行示例操作。

CMake 让你处于一个独特的地位。一方面,你扮演着构建工程师的角色,必须全面了解编译器、平台及相关方面;另一方面,你是一个开发者,编写生成构建系统的代码。编写高质量代码是一项具有挑战性的任务,需要多方面的能力。代码不仅要能正常工作、易于阅读,还应该易于分析、扩展和维护。

总结时,我们将展示一些在 CMake 中最实用和最常用的命令。那些也常用但使用频率较低的命令将被放入附录杂项命令中(包括 stringlistfilemath 命令的参考指南)。

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

  • CMake 语言语法基础

  • 处理变量

  • 使用列表

  • 理解 CMake 中的控制结构

  • 探索常用的命令

技术要求

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

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

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

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

CMake 语言语法基础

编写 CMake 代码与编写其他命令式语言非常相似:代码行从上到下、从左到右执行,偶尔会进入一个包含文件或调用的函数。执行的起点由模式决定(参见第一章掌握命令行部分,与 CMake 初步接触),即从源代码树的根文件(CMakeLists.txt)或作为参数传递给 cmake.cmake 脚本文件开始。

由于 CMake 脚本广泛支持 CMake 语言,除了项目相关功能外,我们将在本章中利用它们来练习 CMake 语法。一旦我们熟练掌握了编写简单列表文件的技巧,就可以进阶到创建实际的项目文件,这部分内容将在第四章中讲解,设置你的第一个 CMake 项目

提醒一下,可以使用以下命令运行脚本:cmake -P script.cmake

CMake 支持 7 位 ASCII 文本文件,以实现跨平台的可移植性。你可以使用\n\r\n行结束符。CMake 版本高于 3.2 支持 UTF-8UTF-16,并且可以选择使用 字节顺序标记 (BOM)。

在 CMake 列表文件中,所有内容要么是注释,要么是命令调用

注释

就像在 C++ 中一样,注释有两种类型:单行注释和括号(多行)注释。但与 C++ 不同的是,括号注释可以嵌套。单行注释以井号#开始:

# they can be placed on an empty line
message("Hi"); # or after a command like here. 

多行括号注释得名于其符号——它们以#开头,后跟一个左方括号[,接着是任意数量的等号=(可以包括 0 个),然后是另一个方括号[。要关闭一个括号注释,请使用相同数量的等号并反向括号]

#[=[
bracket comment
  #[[
    nested bracket comment
  #]]
#]=] 

你可以通过在括号注释的首行再添加一个#来快速停用多行注释,如下所示:

##[=[ this is a single-line comment now
no longer commented
  #[[
    still, a nested comment
  #]]
#]=] this is a single-line comment now 

知道如何使用注释绝对是有用的,但这又引出了另一个问题:我们应该在何时使用它们?因为编写列表文件本质上是编程,所以将最佳编码实践应用于它们也是一个好主意。

遵循这种做法的代码通常被称为干净代码——这是软件开发大师如罗伯特·C·马丁、马丁·福勒等许多作者多年来使用的术语。

关于哪些做法是有益的,哪些是有害的,常常会有很多争议,正如你所料,注释也未能免于这些争论。所有事情都应该根据具体情况进行判断,但普遍认可的指南认为好的注释至少应该具备以下之一:

  • 信息:它们可以解开复杂性,比如正则表达式模式或格式化字符串。

  • 意图:它们可以解释代码的意图,当代码的实现或接口并不明显时。

  • 澄清:它们可以解释一些无法轻易重构或更改的概念。

  • 后果警告:它们可以提供警告,特别是关于可能破坏其他部分的代码。

  • 加强:它们可以强调某个在代码中难以表达的概念的重要性。

  • 合法条款:它们可以添加这个必要的麻烦,通常这并不是程序员的领域。

最好通过更好的命名、重构或修正代码来避免注释。省略以下类型的注释:

  • 强制性:这些是为了完整性而添加的,但它们并不真正重要。

  • 冗余:这些内容重复了代码中已清晰表达的内容。

  • 误导性:如果这些没有跟随代码变化,它们可能会变得过时或不准确。

  • 日志:这些记录了已更改的内容及其时间(可以使用版本控制系统VCS)来替代)。

  • 分隔符:这些标记出不同的部分。

如果可以,避免添加注释,采用更好的命名实践,并重构或修正代码。编写优雅的代码是一个具有挑战性的任务,但它提升了读者的体验。由于我们花在阅读代码上的时间通常比编写代码的时间更多,我们应该始终努力编写易于阅读的代码,而不仅仅是追求快速完成。我建议你查看本章末尾的进一步阅读部分,那里有一些关于清洁代码的好参考。如果你对注释感兴趣,你可以找到我 YouTube 视频《你代码中的哪些注释是好的?》的链接,深入探讨这个话题。

命令调用

该采取行动啦!调用命令是 CMake 列表文件的核心。在运行命令时,必须指定命令名称,后面跟上括号,括号内可以包含以空格分隔的命令参数

图 2.1:命令示例

命令名称不区分大小写,但 CMake 社区有一个约定,使用 snake_case(即用下划线连接的小写单词)。你还可以定义自己的命令,我们将在本章的理解 CMake 中的控制结构部分讲解这一点。

CMake 与 C++ 之间一个显著的区别是,CMake 中的命令调用不是表达式。这意味着你不能将另一个命令作为参数传递给已调用的命令,因为括号内的所有内容都被视为该特定命令的参数。

CMake 命令后面也不需要加分号。这是因为每行源代码只能包含一个命令。

命令后面可以选择性地加上注释:

command(argument1 "argument2" argument3) # comment
command2() #[[ multiline comment 

但不是反过来:

#[[ bracket
]] command() 

如我们之前所说,CMake 列表文件中的所有内容要么是注释,要么是命令 调用。CMake 语法确实如此简单,通常来说,这是件好事。虽然有些限制(例如,你不能通过表达式来递增计数器变量),但大部分情况下,这些限制是可以接受的,因为 CMake 并非旨在成为一种通用编程语言。

CMake 提供了命令来操作变量、控制执行流、修改文件等等。为了简化操作,我们将在不同示例中逐步介绍相关命令。这些命令可以分为两组:

  • 脚本命令:这些命令始终可用,它们改变命令处理器的状态,访问变量,并影响其他命令和环境。

  • 项目命令:这些命令在项目中可用,它们用于操作项目状态和构建目标。

几乎所有命令都依赖于语言中的其他元素才能运行:变量、条件语句,最重要的是命令行参数。现在,让我们探讨一下如何利用它们。

命令参数

CMake 中的许多命令需要空格分隔的参数来配置其行为。如图 2.1所示,围绕参数使用的引号可能相当特殊。某些参数需要引号,而其他参数则不需要。为什么会有这样的区别呢?

在底层,CMake 识别的唯一数据类型是 string。因此,每个命令都期望它的参数是零个或多个字符串。CMake 会评估每个参数为静态字符串,然后将它们传递给命令。评估意味着字符串插值,或者用另一个值替换字符串的一部分。这可能意味着替换转义序列,扩展变量引用(也叫做变量插值),以及解包列表

根据上下文的不同,我们可能需要根据需要启用这样的评估。因此,CMake 提供了三种类型的参数:

  • 括号参数

  • 加引号的参数

  • 未加引号的参数

CMake 中的每种参数类型都有其特殊性,并提供不同级别的评估。

括号参数

括号参数不会被评估,因为它们用于将多行字符串逐字传递给命令作为单一参数。这意味着这样的参数会包括制表符和换行符形式的空白字符。

括号参数的格式与注释完全相同。它们以 [=[ 开始,并以 ]=] 结束,开头和结尾的等号数量必须匹配(只要匹配,省略等号也是允许的)。与注释的唯一不同是,括号参数不能嵌套。

下面是使用这种参数的示例,结合 message() 命令,它会将所有传递的参数打印到屏幕上:

ch02/01-arguments/bracket.cmake

message([[multiline
  bracket
  argument
]])
message([==[
  because we used two equal-signs "=="
  this command receives only a single argument
  even if it includes two square brackets in a row
  { "petsArray" = [["mouse","cat"],["dog"]] }
]==]) 

在前面的示例中,我们可以看到不同形式的括号参数。注意在第一次调用中将闭合标签放在单独一行,会导致输出中出现空行:

$ cmake -P ch02/01-arguments/bracket.cmake
multiline
bracket
argument
  because we used two equal-signs "=="
  following is still a single argument:
  { "petsArray" = [["mouse","cat"],["dog"]] } 

第二种形式在传递包含双括号(]])(代码片段中突出显示)的文本时非常有用,因为它们不会被解释为标记参数的结束。

这类括号参数的使用较为有限——通常它们包含较长的文本块,其中的信息会显示给用户。大多数情况下,我们需要的是更动态的内容,比如带引号的参数。

带引号的参数

带引号的参数类似于常规的 C++ 字符串——这些参数将多个字符(包括空格)组合在一起,并且它们会展开转义序列。像 C++ 字符串一样,它们以双引号字符 " 开头和结尾,因此要在输出字符串中包含引号字符,必须用反斜杠进行转义 \"。其他常见的转义序列也被支持:\\ 表示字面上的反斜杠,\t 是制表符,\n 是换行符,\r 是回车符。

这就是与 C++ 字符串相似之处的终结。带引号的参数可以跨越多行,并且它们会插入变量引用。可以将它们视为内置的printf函数来自C,或来自C++20std::format函数。要在参数中插入变量引用,只需将变量名用 ${name} 这样的标记括起来。我们将在本章的处理变量部分进一步讨论变量引用。

你能猜到下面的脚本输出会有多少行吗?

ch02/01-arguments/quoted.cmake

message("1\. escape sequence: \" \n in a quoted argument")
message("2\. multi...
  line")
message("3\. and a variable reference: ${CMAKE_VERSION}") 

让我们来看一个实际例子:

$ cmake -P ch02/01-arguments/quoted.cmake
1\. escape sequence: "
in a quoted argument
2\. multi...
line
3\. and a variable reference: 3.26.0 

没错——我们有一个转义的引号字符,一个换行转义序列和一个字面上的换行符。我们还访问了一个内置的 CMAKE_VERSION 变量,可以看到它在最后一行被插入。让我们来看 CMake 是如何处理没有引号的参数的。

未加引号的参数

在编程领域,我们已经习惯了字符串必须以某种方式进行定界,例如使用单引号、双引号或反斜杠。CMake 偏离了这一惯例,提出了未加引号的参数。我们或许可以争论去掉定界符会让代码更易读。这个说法是否成立?我让你自己去判断。

未加引号的参数会同时处理转义序列变量引用。但是,要小心分号 (;),因为在 CMake 中,分号会被视为列表的定界符。如果参数中包含分号,CMake 会将其拆分成多个参数。如果需要使用分号,必须使用反斜杠进行转义 \;。我们将在本章的使用列表部分进一步讨论分号。

你可能会发现这些参数最难处理,因此这里有一个例子可以帮助说明这些参数是如何被划分的:

图 2.2:转义序列导致多个标记被解释为一个单一的参数

处理未加引号的参数时总是需要小心。一些 CMake 命令需要特定数量的参数,并会忽略任何附加内容。如果你的参数被不小心分开了,你可能会遇到难以调试的错误。

未加引号的参数不能包含未转义的引号(“)、井号(#)和反斜杠(\)。如果这些还不够记住,括号()仅在它们形成正确的配对时才允许使用。也就是说,你必须从一个左括号开始,并在关闭命令参数列表之前关闭它。

下面是一些展示我们讨论过的规则的例子:

ch02/01-arguments/unquoted.cmake

message(a\ single\ argument)
message(two arguments)
message(three;separated;arguments)
message(${CMAKE_VERSION})  # a variable reference
message(()()())            # matching parentheses 

上面的代码将会输出什么呢?我们来看看:

$ cmake -P ch02/01-arguments/unquoted.cmake
a single argument
twoarguments
threeseparatedarguments
3.16.3
()()() 

即使是像message()这样简单的命令,对于未加引号的参数分隔也有严格要求。当a single argument中的空格被正确转义时,它被正确打印出来。然而,twoargumentsthreeseparatearguments却被在了一起,因为message()不会自动添加空格。

鉴于所有这些复杂性,何时使用未加引号的参数会更有利呢?一些 CMake 命令允许使用由关键字引导的可选参数,表示将提供一个可选参数。在这种情况下,使用未加引号的关键字参数可以使代码更加易读。例如:

project(myProject VERSION 1.2.3) 

在这个命令中,VERSION关键字和后面的参数1.2.3是可选的。正如你所看到的,两个部分都没有加引号,以提高可读性。注意,关键字是区分大小写的。

现在我们已经了解了如何处理 CMake 参数的复杂性和怪癖,接下来就可以处理 CMake 中各种变量的操作了。

操作变量

CMake 中的变量是一个令人惊讶的复杂话题。变量不仅有三类——普通变量缓存变量环境变量——它们还存在于不同的变量作用域中,并有特定的规则规定一个作用域如何影响另一个作用域。通常,对于这些规则理解不够清晰会成为 bug 和头痛的源头。我建议你认真学习这一部分,确保在继续之前完全理解所有的概念。

让我们从一些关于 CMake 变量的关键事实开始:

  • 变量名是区分大小写的,并且几乎可以包含任何字符。

  • 所有变量都以字符串形式存储,即使一些命令可以将它们解释为其他数据类型的值(甚至是列表!)。

基本的变量操作命令是set()unset(),但还有其他命令可以改变变量的值,例如string()list()

要声明一个普通变量,我们只需要调用set(),提供其名称和值:

ch02/02-variables/set.cmake

set(MyString1 "Text1")
set([[My String2]] "Text2")
set("My String 3" "Text3")
message(${MyString1})
message(${My\ String2})
message(${My\ String\ 3}) 

如你所见,使用括号和加引号的参数可以使变量名包含空格。然而,在稍后的引用中,我们必须使用反斜杠\来转义空格。因此,建议仅在变量名中使用字母数字字符、连字符(-)和下划线(_)

同时避免使用以下保留名称(无论是大写、小写还是混合大小写),这些名称以以下任何一个开始:CMAKE__CMAKE_,或者是一个下划线_,后跟任何 CMake 命令的名称。

要取消设置一个变量,我们可以使用unset(),方法如下:unset(MyString1)

set()命令接受一个普通文本变量名作为第一个参数,但message()命令使用用${}语法包裹的变量引用。

如果我们将一个变量用${}语法传递给set()命令,会发生什么情况?

要回答这个问题,我们需要更好地理解变量引用

变量引用

我已经在命令参数部分简要提到了引用,因为它们会在带引号和不带引号的参数中进行求值。我们了解到,要创建对已定义变量的引用,我们需要使用${}语法,像这样:message(${MyString1})

在求值时,CMake 将从最内层的作用域遍历到最外层作用域,并将${MyString1}替换为一个值,或者如果没有找到变量,则替换为空字符串(CMake 不会产生任何错误信息)。这个过程也被称为变量求值扩展插值

插值是从内向外进行的,从最内层的花括号对开始,逐步向外推进。例如,如果遇到${MyOuter${MyInner}}引用:

  1. CMake 将首先尝试评估MyInner,而不是查找名为MyOuter${MyInner}的变量。

  2. 如果MyInner变量成功展开,CMake 将使用新形成的引用重复扩展过程,直到无法继续扩展为止。

为了避免出现意外的结果,建议不要将变量扩展标记存储在变量值中。

CMake 会执行变量扩展,直到完全展开,之后才会将得到的结果作为参数传递给命令。这就是为什么当我们调用set(${MyInner} "Hi")时;我们实际上不会改变MyInner变量,而是会修改一个以MyInner存储的值命名的变量。通常,这并不是我们想要的结果。

变量引用在处理变量类别时有些特别,但一般来说,以下内容适用:

  • ${}语法用于引用普通缓存变量。

  • $ENV{}语法用于引用环境变量。

  • $CACHE{}语法用于引用缓存变量。

没错,通过${},你可能从某个类别获取到一个值:如果在当前作用域内设置了普通变量,则会使用该变量;但如果没有设置,或者被取消设置,CMake 将使用具有相同名称的缓存变量。如果没有这样的变量,引用将评估为空字符串。

CMake 预定义了许多内置的普通变量,它们有不同的用途。例如,你可以在--标记之后将命令行参数传递给脚本,这些参数将被存储在CMAKE_ARGV<n>变量中(CMAKE_ARGC变量将包含计数)。

让我们介绍其他类别的变量,以便更清楚地理解它们是什么。

使用环境变量

这是最简单的一种变量类型。CMake 会复制用于启动cmake进程的环境中的变量,并将它们提供给单一的全局作用域。要引用这些变量,可以使用$ENV{<name>}语法。

CMake 会更改这些变量,但更改只会影响正在运行的cmake进程中的本地副本,而不会影响实际的系统环境;此外,这些更改不会对后续的构建或测试运行产生影响,因此不推荐这样做。

请注意,有一些环境变量会影响 CMake 行为的不同方面。例如,CXX变量指定了用于编译 C++文件的可执行文件。我们将在本书中讲解环境变量,它们将变得非常相关。完整的列表可以在文档中找到:cmake.org/cmake/help/latest/manual/cmake-env-variables.7.html

需要注意的是,如果你将ENV变量作为命令的参数,值将在生成构建系统时进行插值。这意味着它们将永久地嵌入到构建树中,改变构建阶段的环境将没有任何效果。

例如,考虑以下项目文件:

ch02/03-environment/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(Environment)
message("generated with " $ENV{myenv})
add_custom_target(EchoEnv ALL COMMAND echo "myenv in build
  is" $ENV{myenv}) 

上面的例子有两个步骤:它将在配置过程中打印myenv环境变量,并通过add_custom_target()添加一个构建阶段,该阶段在构建过程中回显相同的变量。我们可以用一个 bash 脚本来测试在配置阶段使用一个值、在构建阶段使用另一个值的效果:

ch02/03-environment/build.sh

#!/bin/bash
export myenv=first
echo myenv is now $myenv
cmake -B build .
cd build
export myenv=second
echo myenv is now $myenv
cmake --build . 

运行前面的代码清楚地显示,在配置过程中设置的值被保留到生成的构建系统中:

$ ./build.sh | grep -v "\-\-"
myenv is now first
generated with first
myenv is now second
Scanning dependencies of target EchoEnv
myenv in build is first
Built target EchoEnv 

这就结束了我们暂时对环境变量的讨论。现在让我们转向变量的最后一类:缓存变量。

使用缓存变量

我们在第一章《CMake 初步应用》中讨论 cmake 命令行选项时首次提到了缓存变量。本质上,它们是存储在构建树中 CMakeCache.txt 文件里的持久变量。它们包含在项目的配置阶段收集的信息。它们来源于系统(编译器、链接器、工具等的路径)和用户,通过 GUI 或通过命令行的 -D 选项提供。再强调一次,缓存变量在脚本中不可用;它们只存在于项目中。

如果 ${<name>} 引用在当前作用域内找不到普通变量,而存在同名的缓存变量,则将使用缓存变量。然而,它们也可以通过 $CACHE{<name>} 语法显式引用,并通过 set() 命令的特殊形式进行定义:

set(<variable> <value> CACHE <type> <docstring> [FORCE]) 

与用于普通变量set() 命令不同,缓存变量需要额外的参数:<type><docstring>。这是因为这些变量可以由用户配置,GUI 需要这些信息来适当地显示它们。

以下类型是被接受的:

  • BOOL:布尔值开/关。GUI 会显示一个复选框。

  • FILEPATH:磁盘上文件的路径。GUI 将打开一个文件对话框。

  • PATH:磁盘上目录的路径。GUI 将打开一个目录对话框。

  • STRING:一行文本。GUI 提供一个文本框供填写。通过调用 set_property(CACHE <variable> STRINGS <values>),它可以被下拉框控件替代。

  • INTERNAL:一行文本。GUI 会跳过内部条目。内部条目可用于在多次运行之间持久存储变量。使用此类型隐式添加了 FORCE 关键字。

<docstring> 值仅仅是一个标签,GUI 会在字段旁边显示它,以便为用户提供有关该设置的更多细节。即使是 INTERNAL 类型,也需要提供此信息。

在代码中设置缓存变量的规则在某种程度上与环境变量相同——值仅在当前的 CMake 执行中被覆盖。然而,如果变量在缓存文件中不存在,或者指定了可选的 FORCE 参数,则该值将被持久保存:

set(FOO "BAR" CACHE STRING "interesting value" FORCE) 

类似于 C++,CMake 支持变量作用域,尽管它的实现方式比较特定。

如何在 CMake 中正确使用变量作用域

变量作用域 可能是 CMake 语言中最奇怪的概念。这也许是因为我们习惯了在通用编程语言中实现变量作用域的方式。我们早期解释这个概念,是因为对作用域的错误理解通常是导致难以发现和修复的错误的根源。

为了澄清,作为一个通用概念,变量作用域旨在通过代码表达不同层次的抽象。作用域以树形结构相互嵌套。最外层的作用域(根)被称为全局作用域。任何作用域都可以称为局部作用域,表示当前执行或讨论的作用域。作用域在变量之间创建了边界,使得嵌套作用域可以访问外部作用域中定义的变量,但反过来则不行。

CMake 有两种变量作用域:

  • 文件:在文件内执行块和自定义函数时使用

  • 目录:当调用add_subdirectory()命令在嵌套目录中执行另一个CMakeLists.txt列表文件时使用

    条件块、循环块和宏不会创建独立的作用域。

那么,CMake 中变量作用域的实现有什么不同呢?当创建一个嵌套作用域时,CMake 会简单地用外部作用域中的所有变量的副本填充它。后续命令将影响这些副本。但一旦嵌套作用域的执行完成,所有副本将被删除,外部作用域中的原始变量将被恢复。

CMake 中作用域的概念有一些有趣的影响,这在其他语言中并不常见。在嵌套作用域中执行时,如果你取消设置(unset())一个在外部作用域中创建的变量,它会消失,但仅在当前的嵌套作用域内,因为该变量是局部副本。如果你现在引用这个变量,CMake 会认为没有定义这样的变量,它将忽略外部作用域,并继续在缓存变量中查找(这些被视为独立的)。这是一个可能的陷阱。

文件变量作用域是通过block()function()命令打开的(但不是macro()),并分别通过endblock()endfunction()命令关闭。我们将在本章的命令定义部分讨论函数。现在,让我们看看如何通过更简单的block()命令(在 CMake 3.25 中引入)来实际使用变量作用域。

考虑以下示例:

ch02/04-scope/scope.cmake

cmake_minimum_required(VERSION 3.26)
set(V 1)
message("> Global: ${V}")
block() # outer block
  message(" > Outer: ${V}")
  set(V 2)
  block() # inner block
    message("  > Inner: ${V}")
    set(V 3)
    message("  < Inner: ${V}")
  endblock()
  message(" < Outer: ${V}")
endblock()
message("< Global: ${V}") 

我们最初将变量V设置为1,在全局作用域中。进入外部和内部块后,我们分别将它们改为23。我们还会在进入和退出每个作用域时打印变量:

> Global: 1
 > Outer: 1
  > Inner: 2
  < Inner: 3
 < Outer: 2
< Global: 1 

如前所述,当我们进入每个嵌套作用域时,变量值会从外部作用域临时复制,但在退出时会恢复其原始值。这反映在输出的最后两行中。

block()命令还可以将值传播到外部作用域(就像 C++ 默认的行为一样),但必须通过PROPAGATE关键字显式启用。如果我们使用block(PROPAGATE V)来启用内部块的传播,输出将如下所示:

> Global: 1
 > Outer: 1
  > Inner: 2
  < Inner: 3
 < Outer: 3
< Global: 1 

再次,我们只影响了外部块的作用域,而没有影响全局作用域。

修改外部作用域变量的另一种方法是为 set()unset() 命令设置 PARENT_SCOPE 标志:

set(MyVariable "New Value" PARENT_SCOPE)
unset(MyVariable PARENT_SCOPE) 

这种解决方法有些局限,因为它不允许访问比当前层级更高的变量。另一个值得注意的事实是,使用 PARENT_SCOPE 不会改变当前作用域中的变量。

现在我们已经知道如何处理基本变量,让我们来看看一个特殊的情况:由于所有变量都以字符串形式存储,CMake 必须采取更有创意的方法来处理更复杂的数据结构,如列表

使用列表

要存储一个列表,CMake 会将所有元素连接成一个字符串,使用分号 ; 作为分隔符:a;list;of;5;elements。你可以通过反斜杠来转义分号,例如:a\;single\;element

要创建一个列表,我们可以使用 set() 命令:

set(myList a list of five elements) 

由于列表存储方式,以下命令将产生完全相同的效果:

set(myList "a;list;of;five;elements")
set(myList a list "of;five;elements") 

CMake 会在未加引号的参数中自动解包列表。通过传递一个未加引号的 myList 引用,我们实际上是向命令发送了更多的参数:

message("the list is:" ${myList}) 

message() 命令将接收六个参数:“the list is:”、"a"、"list"、"of"、"five" 和 "elements"。这可能会产生意外的后果,因为输出将没有额外的空格分隔每个参数:

the list is:alistoffiveelements 

正如你所见,这是一个非常简单的机制,应该谨慎使用。

CMake 提供了一个 list() 命令,提供了多种子命令来读取、搜索、修改和排序列表。以下是简短的总结:

list(LENGTH <list> <out-var>)
list(GET <list> <element index> [<index> ...] <out-var>)
list(JOIN <list> <glue> <out-var>)
list(SUBLIST <list> <begin> <length> <out-var>)
list(FIND <list> <value> <out-var>)
list(APPEND <list> [<element>...])
list(FILTER <list> {INCLUDE | EXCLUDE} REGEX <regex>)
list(INSERT <list> <index> [<element>...])
list(POP_BACK <list> [<out-var>...])
list(POP_FRONT <list> [<out-var>...])
list(PREPEND <list> [<element>...])
list(REMOVE_ITEM <list> <value>...)
list(REMOVE_AT <list> <index>...)
list(REMOVE_DUPLICATES <list>)
list(TRANSFORM <list> <ACTION> [...])
list(REVERSE <list>)
list(SORT <list> [...]) 

大多数情况下,我们在项目中并不需要使用列表。然而,如果你遇到那种少见的情况,列表概念会很方便,你可以在附录杂项命令中找到 list() 命令的更深入参考。

现在我们已经知道如何处理各种列表和变量,让我们把重点转移到控制执行流程,学习 CMake 中可用的控制结构。

理解 CMake 中的控制结构

CMake 语言如果没有控制结构就不完整了!像其他所有功能一样,控制结构以命令的形式提供,分为三类:条件块循环命令定义。控制结构在脚本和项目的构建系统生成过程中执行。

条件块

CMake 中唯一支持的条件块是简单的 if() 命令。所有条件块必须以 endif() 命令结束,并且可以包含任意数量的 elseif() 命令和一个可选的 else() 命令,顺序如下:

if(<condition>)
  <commands>
elseif(<condition>) # optional block, can be repeated
  <commands>
else()              # optional block
  <commands>
endif() 

如同许多其他命令式语言,if()-endif() 块控制哪些命令集合将被执行:

  • 如果 if() 命令中指定的 <condition> 表达式满足条件,第一部分将被执行。

  • 否则,CMake 会在本块中满足条件的第一个 elseif() 命令所属的部分执行命令。

  • 如果没有这样的命令,CMake 会检查是否提供了else()命令,并执行该代码段中的任何命令。

  • 如果前面的条件都不满足,执行将在endif()命令之后继续。

请注意,在任何条件块中都不会创建本地变量作用域

提供的<condition>表达式根据非常简单的语法进行评估——让我们进一步了解它。

条件命令的语法

相同的语法适用于if()elseif()while()命令。

逻辑运算符

if()条件支持NOTANDOR逻辑运算符:

  • NOT <condition>

  • <condition> AND <condition>

  • <condition> OR <condition>

此外,可以使用匹配的括号对(())来嵌套条件。像所有优秀的编程语言一样,CMake 语言遵循评估顺序,从最内层的括号开始:

(<condition>) AND (<condition> OR (<condition>)) 

字符串和变量的评估

出于兼容性原因(因为变量引用(${})语法并非一直存在),CMake 会尝试将未加引号的参数评估为变量引用。换句话说,在条件中使用一个简单的变量名(例如QUX)等同于写${QUX}。这里有一个示例供你考虑,还有一个陷阱:

set(BAZ FALSE)
set(QUX "BAZ")
if(${QUX}) 

if()条件在这里有点复杂——首先,它会将${QUX}评估为BAZ,这是一个已识别的变量,然后它将被评估为一个包含五个字符的字符串FALSE。字符串只有在等于以下常量之一时才被视为布尔真(这些比较是不区分大小写的):ONYYESTRUE,或者是一个非零数字

这使我们得出结论,前面示例中的条件将评估为布尔假

然而,这里有一个陷阱——如果条件中有一个未加引号的参数,且该参数是一个包含值的变量名,例如BAR,会如何评估呢?考虑以下代码示例:

set(FOO BAR)
if(FOO) 

根据我们到目前为止所说的内容,它应该是false,因为BAR字符串不满足评估为布尔真值的条件。遗憾的是,并非如此,因为 CMake 在未加引号的变量引用时会做出例外处理。与加引号的参数不同,FOO不会被评估为BAR,从而生成if("BAR")语句(这将是false)。相反,只有当FOO等于以下常量之一时,CMake 才会将if(FOO)评估为false(这些比较是不区分大小写的):

  • OFFNOFALSENIGNORE,或NOTFOUND

  • -NOTFOUND结尾的字符串

  • 一个空字符串

因此,简单地请求一个未定义的变量将被评估为false

if (CORGE) 

当一个变量在之前定义时,情况就会发生变化,条件评估为true

set(CORGE "A VALUE")
if (CORGE) 

如果你认为递归求值未加引号的if()参数很令人困惑,可以将变量引用放在引号参数中:if("${CORGE}")。这会使得在将参数传递到if()命令之前,首先对参数进行求值,行为将与字符串求值一致。

换句话说,CMake 假定传递变量名给if()命令的用户是想检查该变量是否已定义且其值不等于布尔假。要明确检查变量是否已定义(忽略其值),我们可以使用以下方法:

if(DEFINED <name>)
if(DEFINED CACHE{<name>})
if(DEFINED ENV{<name>}) 

比较值

比较操作支持以下运算符:

EQUALLESSLESS_EQUALGREATERGREATER_EQUAL

其他语言中常见的比较运算符在 CMake 中并不适用:==><!= 等等。

它们可以用来比较数值,例如:

if (1 LESS 2) 

你可以通过在任何运算符前添加VERSION_前缀来比较软件版本,格式为major[.minor[.patch[.tweak]]]

if (1.3.4 VERSION_LESS_EQUAL 1.4) 

被省略的组件会被视为零,且非整数版本组件会在该点截断比较的字符串。

对于字典序字符串比较,我们需要在操作符前加上STR前缀(注意没有下划线):

if ("A" STREQUAL "${B}") 

我们通常需要比简单的相等比较更高级的机制。幸运的是,CMake 还支持POSIX 正则表达式匹配(CMake 文档提示支持扩展正则表达式ERE)类型,但未提到支持特定的正则表达式字符类)。我们可以使用MATCHES运算符,如下所示:

<VARIABLE|STRING> MATCHES <regex> 

任何匹配的组都会被捕获到CMAKE_MATCH_<n>变量中。

简单检查

我们已经提到过一个简单的检查方法DEFINED,但还有其他方法,如果条件满足,直接返回true

我们可以检查以下内容:

  • 判断一个值是否在列表中:<VARIABLE|STRING> IN_LIST <VARIABLE>

  • 判断在此版本的 CMake 中是否可以调用某个命令:COMMAND <command-name>

  • 判断是否存在 CMake 策略:POLICY <policy-id>(在第四章《设置你的第一个 CMake 项目》中讲解过)

  • 判断 CTest 测试是否通过add_test()添加:TEST <test-name>

  • 判断构建目标是否已定义:TARGET <target-name>

我们将在第五章《与目标一起工作》中深入探讨构建目标,但现在我们只需要知道,目标是通过add_executable()add_library()add_custom_target()命令创建的项目中的构建过程的逻辑单元。

检查文件系统

CMake 提供了多种操作文件的方法。我们很少需要直接操作文件,通常我们更倾向于使用高层方法。作为参考,本书将在附录中提供一个与文件相关的命令简短列表。但通常情况下,只需要以下运算符(仅对绝对路径定义了明确的行为):

  • EXISTS <path-to-file-or-directory>:检查文件或目录是否存在。

  • 这会解析符号链接(如果符号链接的目标存在,它会返回 true)。

  • <file1> IS_NEWER_THAN <file2>:检查哪个文件较新。

如果 file1file2 更新(或两者相同),或者其中一个文件不存在,则返回 true

  • IS_DIRECTORY path-to-directory:检查路径是否为目录。

  • IS_SYMLINK file-name:检查路径是否为符号链接。

  • IS_ABSOLUTE path:检查路径是否为绝对路径。

此外,从 3.24 版本开始,CMake 支持简单的路径比较检查,它会压缩多个路径分隔符,但不会进行其他规范化操作:

if ("/a////b/c" PATH_EQUAL "/a/b/c") # returns true 

若要进行更高级的路径操作,请参考 cmake_path() 命令的文档。

这完成了条件命令的语法;接下来我们将讨论的控制结构是循环。

循环

CMake 中的循环非常简单——我们可以使用 while() 循环或 foreach() 循环来反复执行相同的一组命令。这两个命令都支持循环控制机制:

  • break() 循环会停止剩余代码块的执行,并跳出外部循环。

  • continue() 循环会停止当前迭代的执行,并从下一次迭代的顶部重新开始。

请注意,任何循环块中都不会创建局部的 变量作用域

while()

循环块通过 while() 命令打开,通过 endwhile() 命令关闭。只要在 while() 中提供的 <condition> 表达式为 true,被包裹的命令就会被执行。条件语法与 if() 命令相同:

while(<condition>)
  <commands>
endwhile() 

你可能猜到了——通过一些额外的变量——while 循环可以替代 for 循环。实际上,使用 foreach() 循环更为简单——让我们来看一下。

foreach() 循环

foreach() 块有多种变体,能够为给定列表中的每个值执行包裹的命令。像其他块一样,它有开启和关闭命令:foreach()endforeach()

foreach() 的最简单形式旨在提供类似 C++ 风格的 for 循环:

foreach(<loop_var> RANGE <max>)
  <commands>
endforeach() 

CMake 会从 0 迭代到 <max>(包括 <max>)。如果我们需要更多控制,可以使用第二种变体,提供 <min><max>,并且可以选择性地提供 <step>。所有参数必须是非负整数,且 <min> 必须小于 <max>

foreach(<loop_var> RANGE <min> <max> [<step>]) 

然而,foreach() 在处理列表时真正展现其强大功能:

foreach(<loop_variable> IN [LISTS <lists>] [ITEMS <items>]) 

CMake 会从一个或多个指定的 <lists> 列表变量中,或者从内联定义的 <items> 值列表中获取元素,并将其放入 <loop variable> 中。然后,它会为列表中的每个项执行所有命令。你可以选择仅提供列表、仅提供值,或者两者同时提供:

ch02/06-loops/foreach.cmake

set(MyList 1 2 3)
foreach(VAR IN LISTS MyList ITEMS e f)
  message(${VAR})
endforeach() 

上述代码将输出以下内容:

1
2
3
e
f 

或者,我们可以使用简短的版本(跳过 IN 关键字)来实现相同的结果:

foreach(VAR 1 2 3 e f) 

从版本 3.17 起,foreach() 增强了对列表压缩的支持(ZIP_LISTS):

foreach(<loop_var>... IN ZIP_LISTS <lists>) 

压缩列表的过程涉及遍历多个列表,并对具有相同索引的对应项进行操作。让我们来看一个例子:

ch02/06-loops/foreach.cmake

set(L1 "one;two;three;four")
set(L2 "1;2;3;4;5")
foreach(num IN ZIP_LISTS L1 L2)
    message("word=${num_0}, num=${num_1}")
endforeach() 

CMake 会为每个提供的列表创建一个 num_<N> 变量,并用每个列表中的项填充它。

你可以传递多个变量名(每个列表一个),每个列表将使用一个单独的变量来存储其项:

foreach(word num IN ZIP_LISTS L1 L2)
    message("word=${word}, num=${num}") 

ZIP_LISTS 中的两个示例将产生相同的输出:

word=one, num=1
word=two, num=2
word=three, num=3
word=four, num=4 

如果列表之间的项数不同,较短列表的变量将为空。

值得注意的是,从版本 3.21 起,foreach() 中的循环变量被限制在循环的局部作用域内。这结束了我们对循环的讨论。

命令定义

有两种方法可以定义自己的命令:可以使用 macro() 命令或 function() 命令。解释这两个命令之间的区别最简单的方式是将它们与 C 风格的预处理器宏 和实际的 C++ 函数进行比较:

macro() 命令更像是一个查找替换指令,而不是像 function() 这样的实际子程序调用。与函数不同,宏不会在调用栈上创建单独的条目。这意味着在宏中调用 return() 会返回到比函数更高一级的调用语句(如果我们已经在最顶层作用域,可能会终止执行)。

function() 命令为其变量创建 局部作用域,与 macro() 命令不同,后者在调用者的 变量作用域 中工作。这可能会导致混淆的结果。我们将在下一节讨论这些细节。

定义命令的两种方法都允许定义可以在命令的局部作用域中引用的命名参数。此外,CMake 提供了以下变量用于访问与调用相关的值:

  • ${ARGC}:参数的数量

  • ${ARGV}:所有参数作为列表

  • ${ARGV<index>}:特定索引(从 0 开始)处的参数值,无论该参数是否为预期参数

  • ${ARGN}:由调用者在最后一个预期参数后传递的匿名参数列表

访问超出 ARGC 范围的数字参数是未定义行为。为了处理高级场景(通常是参数个数未知的情况),你可能会对官方文档中的 cmake_parse_arguments() 感兴趣。如果你决定定义一个带命名参数的命令,那么每次调用必须传递所有参数,否则会无效。

定义宏类似于定义任何其他块:

macro(<name> [<argument>…])
  <commands>
endmacro() 

在此声明之后,我们可以通过调用宏的名称来执行宏(函数调用不区分大小写)。

如我们所知,宏不会在调用栈上创建单独的条目或 变量作用域。以下示例突出了与宏行为相关的一些问题:

ch02/08-definitions/macro.cmake

macro(MyMacro myVar)
  set(myVar "new value")
  message("argument: ${myVar}")
endmacro()
set(myVar "first value")
message("myVar is now: ${myVar}")
MyMacro("called value")
message("myVar is now: ${myVar}") 

这是该脚本的输出:

$ cmake -P ch02/08-definitions/macro.cmake
myVar is now: first value
argument: called value
myVar is now: new value 

发生了什么?尽管我们明确地将 myVar 设置为 new value,但它没有影响 message("argument: ${myVar}") 的输出!这是因为传递给宏的参数不会被当作真实的变量,而是当作常量查找并替换的指令。

另一方面,myVar 变量在全局范围内从 first value 被更改为 new value。这种行为是一个 副作用,被认为是不好的实践,因为在不阅读宏的情况下,无法知道哪些全局变量会被更改。建议尽可能使用函数,因为它们能够避免许多问题。

函数

要将命令声明为函数,请遵循以下语法:

function(<name> [<argument>...])
  <commands>
endfunction() 

函数需要一个名称,并可以选择性地接受一组期望的参数名称。如前所述,函数创建它们自己的 变量作用域。你可以调用 set(),提供函数的某个命名参数,任何更改都将仅在函数内有效(除非指定了 PARENT_SCOPE,正如我们在 如何正确使用 CMake 中的变量作用域 部分讨论过的那样)。

函数遵循调用栈规则,可以使用 return() 命令返回到调用范围。从 CMake 3.25 开始,return() 命令允许使用可选的 PROPAGATE 关键字,后面跟着一个变量名列表。其目的是类似于 block() 命令 —— 将指定变量的值从 局部范围 传递到调用范围。

CMake 为每个函数设置了以下变量(这些变量自版本 3.17 起可用):

  • CMAKE_CURRENT_FUNCTION

  • CMAKE_CURRENT_FUNCTION_LIST_DIR

  • CMAKE_CURRENT_FUNCTION_LIST_FILE

  • CMAKE_CURRENT_FUNCTION_LIST_LINE

让我们实际看看这些函数变量:

ch02/08-definitions/function.cmake

function(MyFunction FirstArg)
  message("Function: ${CMAKE_CURRENT_FUNCTION}")
  message("File: ${CMAKE_CURRENT_FUNCTION_LIST_FILE}")
  message("FirstArg: ${FirstArg}")
  set(FirstArg "new value")
  message("FirstArg again: ${FirstArg}")
  message("ARGV0: ${ARGV0} ARGV1: ${ARGV1} ARGC: ${ARGC}")
endfunction()
set(FirstArg "first value")
MyFunction("Value1" "Value2")
message("FirstArg in global scope: ${FirstArg}") 

使用 cmake -P function.cmake 运行此脚本将打印以下输出:

Function: MyFunction
File: /root/examples/ch02/08-definitions/function.cmake
FirstArg: Value1
FirstArg again: new value
ARGV0: Value1 ARGV1: Value2 ARGC: 2
FirstArg in global scope: first value 

如你所见,函数的一般语法和概念与宏非常相似,但不容易出现隐式错误。

CMake 中的过程式范式

假设我们希望编写类似于 C++ 程序风格的 CMake 代码。我们将创建一个 CMakeLists.txt 文件,调用三个已定义的命令,这些命令可能会调用它们自己定义的命令。图 2.3 展示了这一点:

图 2.3:过程调用图

在 CMake 中,采用过程式风格编程可能会遇到问题,因为你必须在调用命令之前提供命令定义。CMake 的解析器不会接受其他方式。你的代码可能看起来像这样:

cmake_minimum_required(...)
project(Procedural)
# Definitions
function(pull_shared_protobuf)
function(setup_first_target)
function(calculate_version)
function(setup_second_target)
function(setup_tests)
# Calls
setup_first_target()
setup_second_target()
setup_tests() 

多么糟糕!一切都被颠倒了!因为最低抽象级别的代码出现在文件的开头,所以很难理解。正确结构化的代码应该在第一个子程序中列出最一般的步骤,然后提供稍微更详细的子程序,并将最详细的步骤放在文件的末尾。

这个问题有解决方案,比如将命令定义移到其他文件中,并在不同目录之间划分作用域(作用域目录将在第四章设置你的第一个 CMake 项目中详细解释)。但也有一种简单而优雅的方法——在文件顶部声明一个入口点宏,并在文件末尾调用它:

macro(main)
  first_step()
  second_step()
  third_step()
endmacro()
function(first_step)
function(second_step)
function(third_step)
main() 

使用这种方法,我们的代码是按照逐渐缩小的范围编写的,并且由于我们实际上是在最后才调用main()宏,CMake 不会因为执行未定义的命令而报错。

为什么在这种情况下使用宏而不是函数?因为宏能够不受限制地访问全局变量,而且由于我们没有向main()传递任何参数,因此不需要担心通常的注意事项。

你可以在本书 GitHub 仓库的ch02/09-procedural/CMakeLists.txt列表文件中找到一个简单的示例。

关于命名约定的几点说明

命名在软件开发中一向被认为是难题,但尽管如此,保持易读易懂的解决方案仍然非常重要。对于 CMake 脚本和项目,我们应该像处理任何软件开发解决方案一样,遵循清晰代码的方法:

  • 遵循一致的命名风格(snake_case是 CMake 社区公认的标准)。

  • 使用简短但有意义的名称(例如,避免使用func()f()等类似名称)。

  • 避免在命名中使用双关语或聪明的做法。

  • 使用可以发音、易于搜索且无需进行思维映射的名称。

现在我们已经知道如何正确地使用正确的语法调用命令,让我们首先探索哪些命令对我们最有益。

探索常用命令

CMake 提供了许多脚本命令,允许你处理变量和环境。部分命令在附录中有详细介绍,例如,list()string()file()。其他一些命令,如find_file()find_package()find_path(),更适合放在讨论它们各自主题的章节中。在本节中,我们将简要概述在大多数情况下都很有用的常见命令:

  • message()

  • include()

  • include_guard()

  • file()

  • execute_process()

让我们开始吧。

message()命令

我们已经知道并喜爱我们可靠的message()命令,它将文本打印到标准输出。但是,它的功能远不止表面那么简单。通过提供一个MODE参数,你可以像这样定制命令的行为:message(<MODE> "要打印的文本")

识别的模式如下:

  • FATAL_ERROR:这会停止处理和生成。

  • SEND_ERROR:这会继续处理,但跳过生成。

  • WARNING:这会继续处理。

  • AUTHOR_WARNING:一个 CMake 警告。此警告会继续处理。

  • DEPRECATION:如果启用了CMAKE_ERROR_DEPRECATEDCMAKE_WARN_DEPRECATED变量,则此命令按相应方式工作。

  • NOTICE或省略模式(默认):这将向stderr打印一条信息,以引起用户的注意。

  • STATUS:这继续处理,推荐用于向用户传递主要信息。

  • VERBOSE:这继续处理,通常用于包含更详细的信息,这些信息通常不太必要。

  • DEBUG:这继续处理,并应包含在项目出现问题时可能有帮助的任何细节。

  • TRACE:这继续处理,推荐在项目开发过程中打印消息。通常,这些类型的消息在发布项目之前会被删除。

选择正确的模式需要额外的工作,但通过根据严重性为输出文本着色(自 3.21 起)或在声明不可恢复错误后停止执行,它可以节省调试时间:

ch02/10-useful/message_error.cmake

message(FATAL_ERROR "Stop processing")
message("This won't be printed.") 

消息将根据当前的日志级别进行打印(默认情况下是STATUS)。我们在上一章的调试和追踪选项部分讨论了如何更改这一点。

第一章CMake 的第一步中,我提到了使用CMAKE_MESSAGE_CONTEXT进行调试,现在是时候深入研究它了。在此期间,我们已经深入了解了该主题的三个关键内容:列表、作用域和函数。

在复杂的调试场景中,指示消息发生在哪个上下文中可能非常有用。考虑以下输出,其中在foo函数中打印的消息具有适当的前缀:

$ cmake -P message_context.cmake --log-context
[top] Before `foo`
[top.foo] foo message
[top] After `foo` 

其工作原理如下:

ch02/10-useful/message_context.cmake

function(foo)
  list(APPEND CMAKE_MESSAGE_CONTEXT "foo")
  message("foo message")
endfunction()
list(APPEND CMAKE_MESSAGE_CONTEXT "top")
message("Before `foo`")
foo()
message("After `foo`") 

让我们分解一下:

  1. 首先,我们将top添加到上下文跟踪变量CMAKE_MESSAGE_CONTEXT中,然后我们打印初始的Before `foo`消息,并且匹配的前缀[top]将被添加到输出中。

  2. 接下来,进入foo()函数时,我们会在该函数所属的函数名后,将一个名为foo的新上下文添加到列表中,并输出另一条信息,该信息在输出中将以扩展的[top.foo]前缀出现。

  3. 最后,在函数执行完成后,我们打印After `foo`消息。该消息将以原始的[foo]作用域打印。为什么?因为变量作用域规则:更改的CMAKE_MESSAGE_CONTEXT变量仅存在于函数作用域结束之前,然后会恢复为原始未更改的版本。

使用message()的另一个酷技巧是将缩进添加到CMAKE_MESSAGE_INDENT列表中(与CMAKE_MESSAGE_CONTEXT完全相同的方式):

list(APPEND CMAKE_MESSAGE_INDENT "  ")
message("Before `foo`")
foo()
message("After `foo`") 

我们脚本的输出看起来可能更简单:

Before `foo`
  foo message
After `foo` 

由于 CMake 没有提供任何真正的调试器或断点等工具,因此在事情没有按计划进行时,生成干净的日志消息的能力变得非常方便。

include()命令

将代码分割到不同的文件中以保持秩序并且,嗯,分开,是非常有用的。然后,我们可以通过调用include()从父列表文件中引用它们,如下所示:

include(<file|module> [OPTIONAL] [RESULT_VARIABLE <var>]) 

如果我们提供了一个文件名(即带有.cmake扩展名的路径),CMake 会尝试打开并执行该文件。

请注意,不会创建嵌套的独立变量作用域,因此在该文件中对变量的任何更改将影响调用作用域。

如果文件不存在,CMake 会报错,除非我们指定该文件是可选的,使用OPTIONAL关键字。当我们需要知道include()是否成功时,可以提供一个RESULT_VARIABLE关键字,并指定变量名。该变量将在成功时填充包含文件的完整路径,若失败则为未找到(NOTFOUND)。

在脚本模式下运行时,任何相对路径都会相对于当前工作目录进行解析。如果希望强制根据脚本本身进行查找,可以提供绝对路径:

include("${CMAKE_CURRENT_LIST_DIR}/<filename>.cmake") 

如果我们没有提供路径,但提供了模块名称(没有.cmake扩展名或其他后缀),CMake 会尝试查找该模块并包含它。CMake 会在CMAKE_MODULE_PATH中以及 CMake 模块目录中查找名为<module>.cmake的文件。

当 CMake 遍历源树并包含不同的列表文件时,以下变量会被设置:CMAKE_CURRENT_LIST_DIRCMAKE_CURRENT_LIST_FILECMAKE_PARENT_LIST_FILECMAKE_CURRENT_LIST_LINE

include_guard()命令

当我们包含具有副作用的文件时,我们可能希望限制它们只能被包含一次。这时,include_guard([DIRECTORY|GLOBAL])就派上用场了。

include_guard()放在包含文件的顶部。当 CMake 第一次遇到它时,它会在当前作用域中记录这一事实。如果该文件再次被包含(可能因为我们无法控制项目中的所有文件),则不会再进行处理。

如果我们想要防止在不相关的函数作用域中包含那些不会共享变量的文件,我们应该提供DIRECTORYGLOBAL参数。正如名字所示,DIRECTORY关键字会在当前目录及其子目录中应用保护,而GLOBAL关键字则会将保护应用到整个构建中。

file()命令

为了让你了解如何使用 CMake 脚本,我们快速看看file()命令的一些常用变体:

file(READ <filename> <out-var> [...])
file({WRITE | APPEND} <filename> <content>...)
file(DOWNLOAD <url> [<file>] [...]) 

简而言之,file()命令允许你读取、写入、传输文件,并且可以操作文件系统、文件锁、路径和归档,所有这些操作都是系统独立的。更多详情请参见附录

execute_process()命令

有时你需要使用系统中可用的工具(毕竟,CMake 主要是一个构建系统生成器)。CMake 为此提供了一个命令:你可以使用execute_process()来运行其他进程并收集它们的输出。这个命令非常适合用于脚本,也可以在项目中使用,但它仅在配置阶段有效。以下是该命令的一般格式:

execute_process(COMMAND <cmd1> [<arguments>]... [OPTIONS]) 

CMake 将使用操作系统的 API 创建一个子进程(因此,&&||>等 shell 操作符将无法使用)。但是,你仍然可以通过多次提供COMMAND <cmd> <arguments>参数来链式执行命令并将一个命令的输出传递给另一个命令。

可选地,你可以使用TIMEOUT <seconds>参数来终止任务,如果任务在规定时间内未完成,还可以根据需要设置WORKING_DIRECTORY <directory>

所有任务的退出代码可以通过提供RESULTS_VARIABLE <variable>参数收集到一个列表中。如果你只对最后执行命令的结果感兴趣,可以使用单数形式:RESULT_VARIABLE <variable>

为了收集输出,CMake 提供了两个参数:OUTPUT_VARIABLEERROR_VARIABLE(它们的使用方式相似)。如果你想合并stdoutstderr,可以对两个参数使用相同的变量。

请记住,当为其他用户编写项目时,确保你计划使用的命令在你声称支持的平台上是可用的。

总结

本章为 CMake 的实际编程打开了大门——你现在可以编写出色的、有信息量的注释,并利用内置命令,还理解了如何正确地为它们提供各种参数。仅凭这些知识,你就能理解你可能在其他人创建的项目中看到的 CMake 列表文件中的不寻常语法。我们已经涵盖了 CMake 中的变量——具体来说,如何引用、设置和取消设置普通缓存环境变量。我们深入探讨了文件和目录变量作用域的工作原理,如何创建它们,以及可能遇到的问题和解决方法。我们还涵盖了列表和控制结构。我们研究了条件语法、逻辑运算、无引号参数的评估,以及字符串和变量的操作。我们学习了如何比较值、进行简单检查并检查系统中文件的状态。这使我们能够编写条件块和while循环;在讨论循环时,我们也掌握了foreach循环的语法。

了解如何使用宏和函数语句定义自定义命令无疑会帮助你写出更清晰、更具程序化的代码。我们还讨论了改善代码结构和创建更具可读性名称的策略。

最后,我们正式介绍了message()命令及其多个日志级别。我们还学习了如何分区和包含列表文件,并发现了一些其他有用的命令。凭借这些信息,我们已经为迎接下一章,第三章在流行的 IDE 中使用 CMake,做好了充分准备。

进一步阅读

有关本章涵盖的主题的更多信息,你可以参考以下链接:

加入我们的社区,加入 Discord 讨论群

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

discord.com/invite/vXN53A7ZcA

第三章:在流行的 IDE 中使用 CMake

编程既是一门艺术,也是一项深具技术性的过程,正如我们都深知的,它是非常困难的。因此,我们应该尽可能优化这一过程。虽然我们很少能通过简单的开关来获得更好的结果,但使用集成开发环境(IDE)绝对是其中少数的例外之一。

如果你以前没有使用过合适的 IDE(或者你认为像 Emacs 或 Vim 这样的文本处理器已经是你能得到的最好工具),那么本章就是为你准备的。如果你是经验丰富的专业人士,已经熟悉这个话题,你可以将本章作为当前热门选择的快速概览,或者考虑换一个工具,甚至更好的是,确认你当前使用的工具就是最好的选择。

本章以强调为新手提供可访问性的方式,轻松介绍了选择 IDE 这一关键问题。我们将讨论为什么你需要一个 IDE 以及如何选择最适合你需求的 IDE。虽然市场上有许多选择,但像往常一样,有些选择显然比其他的更好。不幸的是,这并不是一个通用的、一刀切的解决方案。许多因素会影响你选择合适 IDE 后的生产力水平。我们将讨论一些在某些规模的组织中可能很重要的考虑因素,确保你能够掌握细微差别而不至于陷入复杂性。接下来,我们会快速介绍工具链,在那里我们将讨论可用的选择。

然后,我们将重点介绍几种流行 IDE 的独特特点,如复杂的 CLion、灵活的 Visual Studio Code,以及强大的 Visual Studio IDE。每一节都将展示这些环境的优点和高级功能,帮助你了解如何迈出使用 IDE 的第一步。此外,我们还将介绍一个从众多功能中主观挑选出的高级功能,让你知道如果决定使用这套工具时,可能会遇到哪些亮点。

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

  • 了解集成开发环境(IDE)

  • 从 CLion IDE 开始

  • 从 Visual Studio Code 开始

  • 从 Visual Studio IDE 开始

了解集成开发环境(IDE)

在本节中,我们将讨论 IDE 及其如何显著提高开发速度和代码质量。让我们先为那些对这个话题不熟悉的人解释一下什么是 IDE。

为什么以及如何选择一个 IDE?IDE(集成开发环境)是一种综合工具,它将各种专业工具结合起来,以简化软件开发过程。创建一个专业项目的过程包含多个步骤:设计、编码、构建、测试、打包、发布和维护。每个步骤都包含许多小任务,复杂性可能会让人感到压倒性。IDE 提供了解决方案,通过提供一个由 IDE 创建者策划和配置的工具平台,使你能够无缝使用这些工具,而无需为每个项目单独设置它们。

IDE 主要围绕代码编辑器、编译器和调试器设计。它们旨在提供足够的集成,使你能够编辑代码、立即编译并运行带有调试器的代码。IDE 可以包括构建工具链,或允许开发人员选择自己喜欢的编译器和调试器。编辑器通常是软件的核心部分,但通常可以通过插件大大扩展功能,例如代码高亮、格式化等。

更高级的 IDE 提供了非常复杂的功能,如热重载调试(在 Visual Studio 2022 中可用;继续阅读以了解更多)。这个功能允许你在调试器中运行代码,编辑它,并继续执行而无需重新启动程序。你还会发现重构工具,可以重命名符号或将代码提取到单独的函数中,以及静态分析工具,可以在编译之前识别错误。此外,IDE 还提供了与 Git 和其他版本控制系统的工具,这些工具对于解决冲突等问题非常有价值。

我相信你现在可以看到,早期学习如何使用 IDE 并在组织中标准化这种使用方式是多么有益。接下来,让我们了解一下为什么选择一个适合你的IDE 如此重要。

选择 IDE

有很多代码编辑器正处于被社区认定为功能完整的 IDE 的边缘。在选择一个具体的 IDE 之前,建议你先做一些研究,特别是考虑到当前软件发布周期的节奏以及该领域的快速变化。

在我几年的企业经验中,IDE 提供的功能足够吸引人,让人从一个 IDE 切换到另一个 IDE 的情况并不常见。开发人员的习惯几乎是第二天性,不能忽视。记住,一旦你在某个 IDE 中感到舒适,它很可能会成为你未来相当长时间的首选工具。这就是为什么你仍然会看到开发者使用 Vim(一个 1991 年发布的基于控制台的文本编辑器),并通过一堆插件扩展它,使它与现代的、基于 GUI 的 IDE 一样强大。所以不必感到压力。

程序员选择一个 IDE 而不是另一个 IDE 的原因各不相同;其中一些原因非常重要(速度、可靠性、全面性、完整性),而另一些则…没那么重要。我想分享一下我对这个选择的主观看法,希望你也能觉得有用。

选择一个全面的 IDE

如果你刚刚开始,你可能会考虑使用一个简单的文本编辑器并通过运行几个命令来构建代码。这种方法完全可行,尤其是在你尝试理解基础知识时(我鼓励你在本书中使用实际命令来跟踪你的进度)。它还帮助你理解没有 IDE 时初学者可能遇到的情况。

另一方面,IDE 是为了特定的目的而创建的。它们简化了开发人员在项目生命周期中处理的众多流程,这非常有价值。虽然最初可能会让人感到不知所措,但选择一个包括所有必要功能的综合 IDE。确保它尽可能完整,但要留意成本,因为 IDE 对于小型企业或个人开发者来说可能非常昂贵。这是一个在手动管理花费的时间和 IDE 提供的功能成本之间的平衡。

无论价格如何,总是选择一个有强大社区支持的 IDE,以便在遇到问题时获得帮助。浏览社区论坛和像StackOverflow.com这样的热门问答网站,看看用户是否能得到他们问题的答案。此外,选择一个由有声望的公司积极开发的 IDE。你不想浪费时间在一个已经很久没有更新、可能会在不久的将来被弃用或停产的工具上。例如,不久前,GitHub 创建的编辑器 Atom,在发布 7 年后被停用。

选择一个在你的组织中得到广泛支持的 IDE

出乎意料的是,这可能并不符合每个开发者的偏好。你可能已经习惯了来自大学、之前工作或个人项目中的其他工具。正如前面提到的,这样的习惯可能会诱使你忽视公司的建议,固守自己熟悉的工具。抵制这种诱惑。随着时间的推移,这样的选择会变得越来越具有挑战性。根据我在爱立信、亚马逊和思科的经历,只有一次,我努力配置和维护一个非标准 IDE 是值得的。那是因为我成功获得了足够的组织支持,能够共同解决问题。

你的主要目标应该是编写代码,而不是在一个不受支持的 IDE 中挣扎。学习推荐的软件可能需要一些努力,但它所需的精力少于违背常规的做法(是的,Vim 输了这一战;是时候继续前进了)。

不要根据目标操作系统和平台选择 IDE

你可能认为如果你在为 Linux 开发软件,你需要使用一台 Linux 机器和基于 Linux 的 IDE。然而,C++ 是一种可移植的语言,这意味着只要你编写正确,它应该能够在任何平台上以相同的方式编译和运行。当然,你可能会遇到库的问题,因为并不是所有的库都是默认安装的,有些可能是特定于你的平台的。

严格遵循目标平台并非总是必要的,有时甚至可能适得其反。例如,如果你要针对一个较旧或长期支持LTS)版本的操作系统进行开发,你可能无法使用最新的工具链版本。如果你希望在不同于目标平台的环境下进行开发,是完全可以实现的。

在这种情况下,可以考虑交叉编译远程开发。交叉编译是指使用专门的工具链,使得在一个平台(如 Windows)上运行的编译器能够为另一个平台(如 Linux)生成目标文件。这种方法在行业中广泛使用,并且得到 CMake 的支持。或者,我推荐远程开发,在这种情况下,你将代码发送到目标机器,并在那里使用本地工具链进行构建。这种方法得到了许多 IDE 的支持,并且提供了几个好处,我们将在下一节中进行探讨。

选择一款支持远程开发的 IDE

虽然这不应是你主要的选择标准,但在满足其他要求后,考虑 IDE 是否支持远程开发是很有帮助的。随着时间的推移,即便是经验丰富的开发者也会遇到需要不同目标平台的项目,这可能是由于团队、项目甚至公司发生变化。

如果你首选的 IDE 支持远程开发,你可以继续使用它,利用在不同操作系统上编译和调试代码的能力,并在 IDE 的 GUI 中查看结果。远程开发相比交叉编译的主要优势是其集成的调试支持,提供了一个更加简洁的过程,而无需进行 CMake 项目级别的配置。此外,公司通常会提供强大的远程机器,让开发者可以使用更便宜、轻量级的本地设备。

当然,有人认为交叉编译提供了更大的开发环境控制,使得可以为测试做临时性更改。它不需要带宽进行代码传输,支持低端的互联网连接或离线工作。然而,考虑到大多数软件开发都需要上网获取信息,这可能就不是一个特别重要的优势。使用像 Docker 这样的虚拟化环境可以运行本地生产副本并设置远程开发连接,提供安全性、可定制性,以及构建和部署容器的能力。

这里提到的考虑因素稍微倾向于在大型公司工作的情况,在这些公司中,事务进展较慢,且很难做出高影响力的改变。如果你决定根据你的使用场景优先考虑其他 IDE 的方面,这些建议并不否定使用 CMake 时能获得一个完全完整的体验的可能性。

安装工具链

正如我们之前讨论的,IDE 整合了所有必要的工具来简化软件开发。这个过程的一个关键部分是构建二进制文件,有时在后台或即时构建,以为开发人员提供附加信息。工具链是由编译器、链接器、归档工具、优化器、调试器和 C++标准库实现等工具组成的集合。它们还可能包括其他实用的工具,如bashmakegawkgrep等,这些工具用于构建程序。

一些 IDE 自带工具链或工具链下载器,而其他 IDE 则没有。最好直接运行已安装的 IDE,并检查是否能够编译一个基础的测试程序。CMake 通常会在配置阶段默认执行此操作,大多数 IDE 会在初始化新项目时作为一部分执行此过程。如果此过程失败,IDE 或操作系统的包管理器可能会提示你安装所需的工具。只需按照提示操作,因为这个流程通常已经做好了充分准备。

如果没有提示,或者如果你想使用特定的工具链,这里有一些根据平台不同的选项:

  • GNU GCC (gcc.gnu.org/) 用于 Linux、Windows(通过 MinGW 或 Cygwin)、macOS 及其他多个平台。GCC 是最受欢迎且广泛使用的 C++编译器之一,支持多种平台和架构。

  • Clang/LLVM (clang.llvm.org/) 用于 Linux、Windows、macOS 等多个平台。Clang 是 C、C++和 Objective-C 编程语言的编译器前端,利用 LLVM 作为其后端。

  • Microsoft Visual Studio/MSVC (visualstudio.microsoft.com/) 主要用于 Windows,同时通过 Visual Studio Code 和 CMake 提供跨平台支持。MSVC 是由微软提供的 C++编译器,通常在 Visual Studio IDE 中使用。

  • MinGW-w64 (mingw-w64.org/) 用于 Windows。MinGW-w64 是原 MinGW 项目的一个改进版,旨在提供对 64 位 Windows 和新 API 的更好支持。

  • Apple Clang (developer.apple.com/xcode/cpp/) 用于 macOS、iOS、iPadOS、watchOS 和 tvOS。Apple 版 Clang,经过针对 Apple 硬件和软件生态系统的优化,已集成在 Xcode 中。

  • Cygwin (www.cygwin.com/) 用于 Windows。Cygwin 为 Windows 提供了一个与 POSIX 兼容的环境,允许使用 GCC 和其他 GNU 工具。

如果你想快速开始,而不深入研究每个工具链的细节,你可以按照我的个人偏好:如果 IDE 没有提供工具链,可以选择在 Windows 上使用 MinGW,在 Linux 上使用 Clang/LLVM,在 macOS 上使用 Apple Clang。每种工具链都非常适合其主要平台,并通常提供最佳体验。

使用本书的示例与 IDE 配合

本书附带了一套丰富的 CMake 项目示例,已上传至官方 GitHub 仓库,链接如下:github.com/PacktPublishing/Modern-CMake-for-Cpp-2E

自然地,当我们探索 IDE 的主题时,出现了一个问题:我们如何在这里介绍的所有 IDE 中使用这个仓库呢?嗯,我们需要认识到,这本教你如何创建专业项目的书本身并不是一个专业项目。它是一个由多个不同完成度的项目组成的集合,在可能的情况下做了适当的简化。不幸的是(或者说,或许幸运的是?),IDE 并不是为加载成百上千的项目并方便管理它们而设计的。它们通常将功能集中于加载一个正在编辑的项目。

这让我们处于一个有些尴尬的境地:使用 IDE 来浏览示例集实际上很困难。当你使用 IDE 加载示例集时,通过选择示例目录来打开,绝大多数 IDE 会检测到多个 CMakeLists.txt 文件,并要求你选择一个。选定后,通常会执行初始化过程,写入临时文件,基本上会运行 CMake 配置和生成阶段,以便让项目进入可以构建的状态。正如你可能猜到的那样,这只对选中的 CMakeLists.txt 文件所在的示例有效。大多数 IDE 确实提供在工作区中切换不同目录(或项目)的方法,但这可能没有我们希望的那样简单直接。

如果你在这方面遇到困难,有两个选择:要么不使用 IDE 来构建示例(而是使用控制台命令),要么每次都将示例加载到一个新的项目中。如果你想练习命令,我推荐第一个选项,因为这些命令将来可能会派上用场,并且能帮助你更好地理解幕后发生了什么。这通常是构建工程师的一个不错选择,因为这些知识将被频繁使用。另一方面,如果你是在做单一项目,主要作为开发者关注代码的业务层面,或许尽早使用 IDE 是最佳选择。无论如何,选择一个并不妨碍你偶尔使用另一个。

说完这些,让我们集中精力回顾今天的顶级 IDE,看看哪个最适合你。无论你是否在公司工作,它们都会为你提供很好的服务。

从 CLion IDE 开始

CLion 是一款付费的跨平台 IDE,适用于 Windows、macOS 和 Linux,由 JetBrains 开发。没错——这款软件是基于订阅的;从 2024 年初开始,你可以以 $99.00 获得一年的个人使用许可。大型组织支付更多费用,初创公司支付较少。如果你是学生或发布开源项目,可以获得免费许可证。此外,还有 30 天的试用期来测试软件。这是本列表中唯一不提供“社区版”或简化版免费版本的 IDE。尽管如此,这仍然是一款由知名公司开发的强大软件,可能非常值得这个价格。

图 3.1 显示了 IDE 在浅色模式下的界面(深色模式是默认选项):

图 3.1:CLion IDE 的主窗口

正如你所看到的,这是一款功能全面的 IDE,能够应对你可能遇到的任何需求。接下来我们来聊聊它的独特之处。

你可能会喜欢它的原因

与其他选择不同,C 和 C++ 是 CLion 支持的第一个也是唯一的编程语言。这个 IDE 的许多功能专门设计用于支持这一环境,并符合 C/C++ 的思维模式。我们将其他 IDE 的功能与 CLion 进行对比时,这一点尤为明显:代码分析、代码导航、集成调试器和重构工具在像 Visual Studio IDE 这样的竞争软件中都有。然而,它们并没有像 CLion 一样深入且强大地面向 C/C++。当然,这一点很难客观衡量。

不管怎样,CMake 已在 CLion 中完全集成,并且是该 IDE 中项目格式的首选。不过,像 Autotools 和 Makefile 项目这样的替代方案目前正处于早期支持阶段,可以用来最终迁移到 CMake。值得注意的是,CLion 原生支持 CMake 的 CTest,并支持多种单元测试框架,并提供专门的流程来生成代码、运行测试、收集和展示结果。你可以使用 Google Test、Catch、Boost.Test 和 doctest。

我特别喜欢的一个功能是能够与 Docker 配合使用,在容器中开发 C++ 程序——稍后会详细介绍。与此同时,让我们看看如何开始使用 CLion。

踏出你的第一步

从官方网站下载 CLion(www.jetbrains.com/clion)后,你可以按照你所使用平台的常规安装流程进行安装。CLion 在 Windows(图 3.2)和 macOS(图 3.3)上都提供了一个足够直观的可视化安装程序。

图 3.2:Windows 上的 CLion 安装设置

图 3.3:macOS 上的 CLion 安装设置

在 Linux 上,你需要解压下载的档案并运行安装脚本:

tar -xzf CLion-<version>.tar.gz
./CLion-<version>/bin/CLion.sh 

这些说明可能已经过时,请确保通过 CLion 网站确认最新信息。

在第一次运行时,你将被要求提供许可证代码或启动 30 天免费试用。选择第二个选项将允许你尝试该 IDE,并决定它是否适合你。接下来,你将能够创建一个新项目并选择目标 C++ 版本。之后,CLion 将自动检测可用的编译器和 CMake 版本,并尝试构建一个测试项目,以确认所有设置正确。在某些平台(如 macOS)上,你可能会收到自动提示,要求根据需要安装开发者工具。在其他平台上,你可能需要自行设置并确保这些工具在 PATH 环境变量中可用。

接下来,确保根据你的需求配置工具链。工具链是按项目配置的,所以请创建一个默认的 CMake 项目。然后,导航到 设置/首选项 (Ctrl/Command + Alt + S),选择 构建、执行、部署 > CMake。在此标签页中,你可以配置构建配置文件(图 3.3)。如果需要,你可以添加一个 Release 配置文件,以便在不带调试符号的情况下构建优化后的文件。要添加该配置文件,只需点击配置文件列表上方的加号图标。CLion 会为你创建一个默认的 Release 配置文件。你可以通过主窗口顶部的下拉菜单在不同配置文件之间切换。

现在,你可以简单地按 F9 编译并运行程序,同时附加调试器。之后,阅读 CLion 的官方文档,因为还有很多有用的功能值得探索。我想向你介绍我最喜欢的功能之一:调试器。

高级功能:增强版调试器

CLion 的调试能力确实是前沿的,特别是为 C++ 设计的。我非常高兴发现了其中一个最新的功能——CMake 调试,它包括许多标准调试功能:代码逐步调试、断点、监视、内联值探索等。当某些事情无法按预期工作时,能够在不同作用域(缓存、ENV 和当前作用域)中查看变量是极其方便的。

对于 C++ 调试,你将获得由GNU 项目调试器GDB)提供的许多标准功能,如汇编视图、断点、逐步调试、监视点等,但也有一些重要的增强功能。在 CLion 中,你会发现一个并行堆栈视图,它可以让你以图形化的方式查看所有线程,并显示每个线程的当前堆栈帧。此外,还有一个高级内存视图功能,允许你查看正在运行的程序在 RAM 中的布局,并即时修改内存。CLion 提供了多个其他工具,帮助你了解程序的运行情况:寄存器视图、代码反汇编、调试控制台、核心转储调试、任意可执行文件的调试等。

作为补充,CLion 拥有一项非常出色的评估表达式功能,它可以大显身手,甚至允许在程序执行过程中修改对象。只需右键点击一行代码,并从菜单中选择此功能。

关于 CLion 的介绍就到这里;现在是时候看看另一个 IDE 了。

开始使用 Visual Studio Code

Visual Studio CodeVS Code)是由 Microsoft 开发的一款免费的跨平台集成开发环境,适用于 Windows、macOS 和 Linux。不要将它与另一款 Microsoft 产品——Visual Studio IDE 混淆(通常以发布年份命名,例如 Visual Studio 2022)。

VS Code 因其庞大的扩展生态系统和对数百种编程语言的支持而受到青睐(据估计,支持的语言超过 220 种!)。当 GitHub 被 Microsoft 收购时,VS Code 被推出作为 Atom 的替代品。

该 IDE 的整体设计非常出色,正如图 3.4所示。

图 3.4:VS Code 的主窗口

现在,让我们来看看是什么让 VS Code 如此特别。

为什么你可能会喜欢它

C++虽然不是 VSC 支持的语言优先项,但由于有许多复杂的语言扩展,它离榜单前列很近。这个权衡带来了在同一环境下按需切换多种语言的能力。

使用这个工具需要一些学习曲线,因为大多数扩展遵循基础 UI 功能,而不是实现独立的高级接口。许多功能可以通过命令面板访问(按F1键即可)。命令面板需要你输入命令名称,而不是点击图标或按钮。为了保持 VSC 简洁、快速且免费,这是一个合理的取舍。事实上,这款 IDE 加载速度非常快,即使在我不进行项目开发时,我也更愿意将它用作通用文本编辑器。

尽管如此,VS Code 凭借庞大的优秀扩展库而真正强大,其中绝大多数都是免费的。对于 C++和 CMake,特别提供了专用扩展,接下来我们将看看如何配置它们。

开始你的第一步

VSC 可以从官方网站获取:code.visualstudio.com/。该网站提供了适用于 Windows 和 macOS 的下载链接,还涵盖了多个 Linux 发行版:Debian、Ubuntu、Red Hat、Fedora 和 SUSE。根据你平台的常规安装流程安装软件。之后,你可以通过访问扩展市场Ctrl/Command + Shift + X)来安装一系列扩展。以下是推荐的初始扩展:

  • Microsoft 的 C/C++

  • Microsoft 的 C/C++扩展包

  • twxs 的 CMake

  • Microsoft 的 CMake 工具

它们将提供常规的代码高亮、编译、运行和调试代码的能力,但你可能需要自己安装工具链。通常,当你开始打开相关文件时,VS Code 会在弹出窗口中建议安装扩展,因此你不一定需要自己去找。

如果你参与远程项目,我还建议安装 Remote – SSH by Microsoft 扩展,因为这将使体验更加连贯和舒适;该扩展不仅负责文件同步,还能通过附加到远程机器上的调试器来启用远程调试。

然而,还有一个更有趣的扩展,它改变了处理项目的方式;让我们看看如何改变。

高级功能:开发容器

如果你将应用程序部署到生产环境中,无论是传输已编译的工件还是运行构建过程,确保所有依赖项都已存在至关重要。否则,你将遇到各种问题。即便所有依赖项都已考虑到,不同的版本或配置可能会导致你的解决方案在开发环境或预发布环境中表现不同。我在很多情况下都有过类似的经历。在虚拟化普及之前,处理环境问题就是生活的一部分。

随着像 Docker 这样的轻量级容器的引入,一切变得更加简单。突然间,你能够运行一个精简的操作系统,并将服务隔离到自己的空间。这种隔离使得所有依赖项都可以与容器一起打包,从而解除了开发者的一大困扰。

直到最近,在容器中开发涉及手动构建、运行并通过 IDE 与远程会话连接到容器。这个过程并不难,但它需要手动操作,而这些操作可能会因不同开发者而有所不同。

近年来,微软发布了一个开源标准——开发容器(containers.dev/),以帮助解决这个小小的不便。该规范主要由一个devcontainer.json文件组成,你可以将其放入你的项目仓库,指示 IDE 如何在容器中设置开发环境。

要使用此功能,只需安装 Dev Containers by Microsoft 扩展,并将其指向一个准备好的项目的仓库。如果你不介意修改主CMakeLists.txt,可以尝试使用本书的仓库:

git@github.com:PacktPublishing/Modern-CMake-for-Cpp-2E.git

我可以确认,其他 IDE,如 CLion,正在采用这一标准,所以如果你面临上述情况,采用这个标准似乎是一个不错的做法。接下来是微软家族的下一个产品。

从 Visual Studio IDE 开始

Visual Studio (VS) 是一款由微软开发的适用于 Windows 的 IDE。曾经也有适用于 macOS 的版本,但将在 2024 年 8 月停用。需要特别区分的是,它与微软的 另一款 IDE VS Code 是不同的。

VS 提供几种版本:社区版、专业版和企业版。社区版是免费的,适用于最多五个用户的公司。成熟的公司需要支付许可费用,费用从每个用户每月 $45 起。图 3.5 显示了 VS 社区版的界面:

图 3.5:VS 2022 的主窗口

像本章讨论的其他 IDE 一样,如果你更喜欢,可以启用暗黑模式。接下来,让我们看看这款 IDE 的一些值得注意的功能。

你可能会喜欢它的原因

这款 IDE 与 VS Code 共享许多特性,提供了类似的体验,但在更精致的形式下。整个套件功能丰富,许多功能都利用了 GUI、向导和可视化元素。这些功能大多可以开箱即用,而不是通过扩展来实现(尽管仍然有一个庞大且丰富的包市场,供你获取额外功能)。换句话说,它就像 VSC,但功能更为先进。

根据版本的不同,你的测试工具将涵盖广泛的测试类型:单元测试、性能测试、负载测试、手动测试、测试资源管理器、测试覆盖率、IntelliTest 和代码分析工具。尤其是分析工具,它是一个非常有价值的工具,并且在社区版中也有提供。

如果你正在设计 Windows 桌面应用程序,VS 提供了可视化编辑器和大量组件。对于 通用 Windows 平台 (UWP),它有着广泛的支持,UWP 是 Windows 10 中引入的 Windows 应用程序 UI 标准。该支持使得 UI 设计时尚现代,且对适应性控件进行了深度优化,可以很好地适配不同屏幕。

另一个值得一提的地方是,尽管 VS 是一款仅支持 Windows 的 IDE,但你仍然可以开发针对 Linux 和移动平台(Android 和 iOS)的项目。它还支持使用 Windows 原生库和 Unreal Engine 的游戏开发者。

想亲自体验它是如何工作的吗?下面是开始使用的方法。

开始你的第一步

这款 IDE 仅适用于 Windows,并遵循标准的安装流程。首先从 visualstudio.microsoft.com/free-developer-offers/ 下载安装程序。运行安装程序后,你将被要求选择版本(社区版、专业版或企业版)并选择你需要的工作负载:

图 3.6:VS IDE 安装器窗口

工作负载实际上是一些功能集,它们允许 VS 支持特定语言、环境或程序格式。一些工作负载包括 Python、Node.js 或 .NET。当然,我们更关注与 C++ 相关的工作负载(图 3.6);针对不同使用场景,提供了广泛的支持:

  • 使用 C++ 进行桌面开发

  • 通用 Windows 平台开发

  • 使用 C++ 进行游戏开发

  • 使用 C++ 进行移动开发

  • 使用 C++ 进行 Linux 开发

选择适合你所需应用的选项并点击安装。不必担心是否安装了所有选项——你可以随时通过重新运行安装程序来修改选择。如果你决定更精确地配置工作负载组件,请确保保留Windows 的 C++ CMake 工具Linux 的 C++ CMake 工具选项,以便获得 CMake 支持。

安装完成后,你可以启动 IDE 并在启动窗口中选择创建新项目。根据你之前安装的工作负载,系统会展示多个模板。如果要使用 CMake,选择CMake 项目模板。其他选项不一定会使用它。在创建项目后,你可以通过点击窗口顶部的绿色播放按钮来启动它;代码会编译,并且你会看到基本程序执行后的输出:

Hello CMake. 

现在,你已经准备好在 Visual Studio 中使用 CMake 了。

高级功能:Hot Reload 调试

尽管运行 Visual Studio 可能会消耗更多资源并且启动时间较长,但它提供了许多无可匹敌的功能。其中一个重大变化就是 Hot Reload。它的工作方式如下:打开一个 C++ 项目,附加调试器启动,修改代码文件,点击Hot Reload按钮(或Alt + F10),你的更改会立即反映到正在运行的应用程序中,同时保持应用程序的状态。

为确保启用 Hot Reload 支持,请在项目 > 属性 > C/C++ > 常规菜单中设置以下两个选项:

  • 调试信息格式必须设置为程序数据库用于编辑和继续 /ZI

  • 启用增量链接必须设置为是 /INCREMENTAL

Hot Reload 的幕后机制可能看起来像是魔法,但它是一个非常实用的功能。虽然存在一些限制,比如全局/静态数据的更改、对象布局的调整,或是“时间旅行”式的更改(比如修改已经构造好的对象的构造函数)。

你可以在官方文档中找到更多关于 Hot Reload 的信息,链接地址:learn.microsoft.com/en-us/visualstudio/debugger/hot-reload

本章节总结了三大主要的 IDE。虽然初学时可能会感到学习曲线陡峭,但我保证,当你进入更高级的任务时,投入的学习努力会很快得到回报。

总结

本章深入探讨了如何使用 IDE 来优化编程过程,特别关注那些与 CMake 深度集成的 IDE。它为初学者和经验丰富的专业人士提供了全面的指南,详细说明了使用 IDE 的好处以及如何选择最适合个人或组织需求的 IDE。

我们首先讨论了 IDE 在提升开发速度和代码质量中的重要性,解释了什么是 IDE 以及它如何通过整合代码编辑器、编译器和调试器等工具,简化软件开发中的各个步骤。接下来,我们简要提醒了工具链的重要性,解释了如果系统中没有安装工具链则必须进行安装,并列出了最常见的选择。

我们讨论了如何开始使用 CLion 及其独特功能,并深入了解了它的调试能力。VS Code 是微软推出的免费跨平台集成开发环境(IDE),因其庞大的扩展生态系统和对众多编程语言的支持而广受认可。我们引导您完成了初始设置及其关键扩展的安装,并介绍了一项名为 Dev Containers 的高级功能。专为 Windows 设计的 VS IDE 提供了一个精致且功能丰富的环境,满足各种用户需求。我们还涵盖了设置过程、关键功能以及先进的 Hot Reload 调试功能。

每个 IDE 部分都提供了关于为什么选择特定 IDE 的见解,启动步骤,以及一个突显该 IDE 特点的高级功能。我们还强调了远程开发支持的概念,突出了它在行业中的日益重要性。

总结来说,本章作为程序员了解和选择 IDE 的基础指南,提供了主要选项的清晰概述,介绍了它们的独特优势,以及如何有效地与 CMake 配合使用,以提高编码效率和项目管理水平。在下一章中,我们将学习使用 CMake 设置项目的基础知识。

进一步阅读

有关本章内容的更多信息,您可以参考以下资源:

加入我们的 Discord 社区

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

discord.com/invite/vXN53A7ZcA

第四章:设置你的第一个 CMake 项目

现在,我们已经收集了足够的信息,可以开始讨论 CMake 的核心功能:构建项目。在 CMake 中,项目包含了所有源文件以及管理将我们的解决方案付诸实践所需的配置。配置开始时需要执行所有的检查:验证目标平台是否受支持,确保所有必要的依赖项和工具都已存在,并确认所提供的编译器是否与所需的功能兼容。

一旦初步检查完成,CMake 将生成一个针对所选构建工具量身定制的构建系统。然后,构建系统将被执行,这意味着编译源代码并将其与相关的依赖项链接在一起,生成最终的构件。

生成的构件可以通过不同的方式分发给用户。它们可以作为二进制包直接与用户共享,允许用户通过包管理器将其安装到他们的系统中。或者,它们可以作为单个可执行安装程序进行分发。此外,最终用户也可以通过访问开源仓库中的共享项目来自己创建这些构件。在这种情况下,用户可以利用 CMake 在自己的机器上编译项目,然后再进行安装。

充分利用 CMake 项目可以显著提升开发体验以及生成代码的整体质量。通过利用 CMake 的强大功能,许多繁琐的任务可以被自动化,例如构建后执行测试、运行代码覆盖率检查器、格式化工具、验证器、静态分析工具以及其他工具。这种自动化不仅节省了时间,还确保了开发过程中的一致性,并促进了代码质量的提升。

为了充分发挥 CMake 项目的优势,我们首先需要做出一些关键决策:如何正确配置整个项目,如何划分项目以及如何设置源代码树,以确保所有文件都能整齐地组织在正确的目录中。通过从一开始就建立一个连贯的结构和组织,CMake 项目可以在开发过程中得到有效管理和扩展。

接下来,我们将了解项目的构建环境。我们将探讨诸如我们使用的架构、可用的工具、它们支持的功能以及我们正在使用的语言标准等内容。为了确保一切都同步,我们将编译一个测试用的 C++ 文件,并查看我们选择的编译器是否符合我们为项目设定的标准要求。这一切都旨在确保我们的项目、所使用的工具以及选择的标准能够顺利配合。

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

  • 理解基本的指令和命令

  • 划分你的项目

  • 思考项目结构

  • 确定环境作用域

  • 配置工具链

  • 禁用源代码内构建

技术要求

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

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

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

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

理解基本的指令和命令

第一章CMake 入门中,我们已经看过了一个简单的项目定义。让我们再来回顾一下。它是一个包含几个配置语言处理器命令的 CMakeLists.txt 文件的目录:

chapter01/01-hello/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Hello)
add_executable(Hello hello.cpp) 

在同一章节中,在名为项目文件的部分中,我们学习了一些基本命令。这里我们将深入解释它们。

指定最低的 CMake 版本

在项目文件和脚本的最顶部使用 cmake_minimum_required() 命令非常重要。该命令不仅验证系统是否具有正确版本的 CMake,还隐式触发另一个命令 cmake_policy(VERSION),该命令指定用于项目的策略。这些策略定义了 CMake 中命令的行为,它们是在 CMake 发展的过程中引入的,以适应支持的语言和 CMake 本身的变化和改进。

为了保持语言的简洁,CMake 团队每当出现不兼容的更改时,就引入了新的策略。每个策略启用了与该更改相关的新行为。这些策略确保项目能够适应 CMake 不断发展的特性和功能,同时保持与旧代码库的兼容性。

通过调用 cmake_minimum_required(),我们告诉 CMake 需要根据参数中提供的版本应用默认策略。当 CMake 升级时,我们无需担心它会破坏我们的项目,因为新版本附带的新策略不会被启用。

策略可以影响 CMake 的各个方面,包括其他重要命令,如 project()。因此,重要的是在 CMakeLists.txt 文件中首先设置你正在使用的版本。否则,你将收到警告和错误。

每个 CMake 版本都会引入大量策略。然而,除非你在将旧项目升级到最新 CMake 版本时遇到问题,否则不必深入了解这些策略的细节。在这种情况下,建议参考官方文档,获取有关策略的全面信息和指导:cmake.org/cmake/help/latest/manual/cmake-policies.7.html

定义语言和元数据

即使从技术上讲,project() 命令并不一定要放在 cmake_minimum_required() 之后,但建议将其放在该位置。这样做可以确保我们在配置项目时使用正确的策略。我们可以使用它的两种形式之一:

project(<PROJECT-NAME> [<language-name>...]) 

或者:

project(<PROJECT-NAME>
        [VERSION <major>[.<minor>[.<patch>[.<tweak>]]]]
        [DESCRIPTION <project-description-string>]
        [HOMEPAGE_URL <url-string>]
        [LANGUAGES <language-name>...]) 

我们需要指定 <PROJECT-NAME>,但其他参数是可选的。调用此命令将隐式设置以下变量:

PROJECT_NAME
CMAKE_PROJECT_NAME (only in the top-level CMakeLists.txt)
PROJECT_IS_TOP_LEVEL, <PROJECT-NAME>_IS_TOP_LEVEL
PROJECT_SOURCE_DIR, <PROJECT-NAME>_SOURCE_DIR
PROJECT_BINARY_DIR, <PROJECT-NAME>_BINARY_DIR 

支持哪些语言?相当多。而且你可以同时使用多个语言!以下是你可以用来配置项目的语言关键字列表:

  • ASM, ASM_NASM, ASM_MASM, ASMMARMASM, ASM-ATT: 汇编语言的方言

  • C: C

  • CXX: C++

  • CUDA: Nvidia 的统一计算设备架构

  • OBJC: Objective-C

  • OBJCXX: Objective-C++

  • Fortran: Fortran

  • HIP: 跨平台异构计算接口(适用于 Nvidia 和 AMD 平台)

  • ISPC: 隐式 SPMD 程序编译器语言

  • CSharp: C#

  • Java: Java(需要额外的步骤,请参阅官方文档)

CMake 默认启用 C 和 C++,所以你可能需要明确指定只使用 CXX 来配置你的 C++ 项目。为什么?project() 命令会检测并测试你选择的语言所支持的编译器,因此声明所需的语言可以帮助你在配置阶段节省时间,跳过不必要的语言检查。

指定 VERSION 关键字将自动设置可以用于配置包或在头文件中暴露以供编译期间使用的变量(我们将在 第七章使用 CMake 编译 C++ 源代码配置头文件 部分中讲解):

PROJECT_VERSION, <PROJECT-NAME>_VERSION
CMAKE_PROJECT_VERSION (only in the top-level CMakeLists.txt)
PROJECT_VERSION_MAJOR, <PROJECT-NAME>_VERSION_MAJOR
PROJECT_VERSION_MINOR, <PROJECT-NAME>_VERSION_MINOR
PROJECT_VERSION_PATCH, <PROJECT-NAME>_VERSION_PATCH
PROJECT_VERSION_TWEAK, <PROJECT-NAME>_VERSION_TWEAK 

我们还可以设置 DESCRIPTIONHOMEPAGE_URL,这将为类似的目的设置以下变量:

PROJECT_DESCRIPTION, <PROJECT-NAME>_DESCRIPTION
PROJECT_HOMEPAGE_URL, <PROJECT-NAME>_HOMEPAGE_URL 

cmake_minimum_required()project() 命令将允许我们创建一个基本的列表文件并初始化一个空项目。虽然对于小型单文件项目,结构可能不那么重要,但随着代码库的扩展,这变得至关重要。你如何为此做准备?

划分你的项目

随着我们的解决方案在代码行数和文件数量上的增长,我们很快意识到必须解决一个迫在眉睫的挑战:要么开始划分项目,要么面临被复杂性淹没的风险。我们可以通过两种方式解决这个问题:拆分 CMake 代码和将源文件移动到子目录中。在这两种情况下,我们的目标都是遵循名为 关注点分离 的设计原则。简而言之,我们将代码拆分为更小的部分,将密切相关的功能组合在一起,同时保持其他代码部分分离,以建立清晰的边界。

我们在 第一章CMake 入门 中谈到了划分 CMake 代码时讨论的列表文件。我们讲解了 include() 命令,它允许 CMake 执行来自外部文件的代码。

这种方法有助于关注点分离,但仅仅有一点点——专门的代码被提取到独立的文件中,甚至可以跨不相关的项目共享,但如果作者不小心,它仍然可能污染全局变量作用域,带入其中的内部逻辑。

你看,调用include()并不会引入任何额外的作用域或隔离,超出文件内已经定义的内容。让我们通过一个例子来看这个潜在问题,假设有一个支持小型汽车租赁公司的软件,它将包含许多源文件,定义软件的不同方面:管理客户、汽车、停车位、长期合同、维修记录、员工记录等等。如果我们将所有这些文件放在一个目录中,查找任何文件都会是一场噩梦。因此,我们在项目的主目录中创建了多个目录,并将相关文件移到其中。我们的CMakeLists.txt文件可能看起来像这样:

ch04/01-partition/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(Rental CXX)
add_executable(Rental
               main.cpp
               **cars/car.cpp** 
               # more files in other directories
) 

这看起来很好,但正如你所看到的,我们仍然在顶层文件中包含了来自嵌套目录的源文件列表!为了增加关注点分离,我们可以将源列表提取到另一个 listfile 中,并将其存储在sources变量中:

ch04/02-include/cars/cars.cmake

set(sources
    cars/car.cpp
#   more files in other directories
) 

现在我们可以使用include()命令引用这个文件,以访问sources变量:

ch04/02-include/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(Rental CXX)
**include****(cars/cars.cmake)**
add_executable(Rental
               main.cpp
               ${sources} # for cars/
) 

CMake 实际上会将sources设置为与add_executable相同的作用域,并将变量填充为所有文件。这个解决方案是可行的,但也有一些缺陷:

  • 嵌套目录中的变量会污染顶层作用域(反之亦然)

虽然在一个简单的例子中并不是问题,但在更复杂的多级树结构中,尤其是过程中使用了多个变量时,它很容易变成一个难以调试的问题。如果我们有多个包含的 listfile,它们定义了各自的sources变量,该怎么办?

  • 所有目录将共享相同的配置

随着项目的成熟,这个问题会显现出来。如果没有任何粒度控制,我们只能将每个源文件视为相同,无法为某些代码部分指定不同的编译标志,选择更新的语言版本,或在代码的特定区域消除警告。所有内容都是全局的,这意味着我们需要同时对所有翻译单元进行更改。

  • 这里有共享的编译触发器

对配置的任何更改都会导致所有文件必须重新编译,即使这些更改对其中一些文件来说毫无意义。

  • 所有路径都是相对于顶层的

请注意,在cars.cmake中,我们必须提供cars/car.cpp文件的完整路径。这导致了大量重复的文本,破坏了可读性,也违反了不要重复自己DRY)的干净代码原则(不必要的重复容易导致错误)。重命名一个目录将变得非常困难。

另一种选择是使用add_subdirectory()命令,它引入了变量作用域等。让我们来看看。

使用子目录管理作用域

在项目结构中,按照文件系统的自然结构来组织项目是一种常见做法,其中嵌套的目录表示应用程序的离散元素,如业务逻辑、图形用户界面(GUI)、API 和报告,最后是单独的测试、外部依赖、脚本和文档目录。为了支持这一概念,CMake 提供了以下命令:

add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL]) 

正如之前所述,这将把源目录添加到我们的构建中。可选地,我们可以提供一个路径,将构建的文件写入该路径(binary_dir或构建树)。EXCLUDE_FROM_ALL关键字将禁用自动构建子目录中定义的目标(我们将在下一章中讨论目标)。这对于将项目中不需要用于核心功能的部分(如示例扩展)分离开来非常有用。

add_subdirectory()将评估source_dir路径(相对于当前目录),并解析其中的CMakeLists.txt文件。这个文件将在目录作用域内进行解析,从而消除了前述方法中的问题:

  • 变量被隔离在嵌套的作用域中。

  • 嵌套的产物可以独立配置。

  • 修改嵌套的CMakeLists.txt文件不需要重新构建不相关的目标。

  • 路径是本地化到目录的,如果需要,也可以将其添加到父级包含路径中。

这是我们的add_subdirectory()示例的目录结构:

├── CMakeLists.txt
├── cars
│   ├── CMakeLists.txt
│   ├── car.cpp
│   └── car.h
└── main.cpp 

在这里,我们有两个CMakeLists.txt文件。顶层文件将使用嵌套目录cars

ch04/03-add_subdirectory/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(Rental CXX)
add_executable(Rental main.cpp)
add_subdirectory(cars)
target_link_libraries(Rental PRIVATE cars) 

最后一行用于将cars目录中的产物链接到Rental可执行文件。这是一个特定于目标的命令,我们将在下一章中详细讨论:第五章与目标一起工作

让我们看看嵌套的列表文件长什么样:

ch04/03-add_subdirectory/cars/CMakeLists.txt

add_library(cars OBJECT
    car.cpp
#   more files in other directories
)
target_include_directories(cars PUBLIC .) 

在这个示例中,我使用了add_library()来生成一个全局可见的目标cars,并通过target_include_directories()cars目录添加到其公共包含目录中。这告诉 CMake cars.h文件的位置,因此在使用target_link_libraries()时,main.cpp文件可以直接使用头文件而无需提供相对路径:

#include "car.h" 

我们可以在嵌套列表文件中看到add_library()命令,那么在这个示例中我们是否开始使用库了呢?其实没有。因为我们使用了OBJECT关键字,我们表示我们只关心生成目标文件(就像在之前的示例中那样)。我们只是将它们归类到一个单独的逻辑目标(cars)下。你可能已经对目标有了初步的了解。保持这个想法——我们将在下一章详细解释。

何时使用嵌套项目

在前一节中,我们简要提到了 add_subdirectory() 命令中使用的 EXCLUDE_FROM_ALL 参数,用来标识我们代码库中的附加元素。CMake 文档建议,如果这些部分存在于源代码树中,它们应该在各自的 CMakeLists.txt 文件中有自己的 project() 命令,这样它们就可以生成自己的构建系统,并且可以独立构建。

还有其他场景会有用吗?当然。例如,一个场景是在你与多个 C++ 项目合作,它们在一个 CI/CD 管道中构建(可能是在构建一个框架或一组库时)。或者,可能是你正在将构建系统从一个遗留的解决方案(例如 GNU Make)迁移过来,它使用普通的 makefile。在这种情况下,你可能希望有一个选项,逐步将它们拆解成更独立的部分——可能是将它们放入一个独立的构建管道,或者仅仅是在一个更小的范围内工作,IDE 如 CLion 可以加载它们。你可以通过在嵌套目录中的列表文件中添加 project() 命令来实现这一点。只需别忘了在前面加上 cmake_minimum_required()

由于支持项目嵌套,我们是否可以以某种方式将并行构建的相关项目连接起来?

保持外部项目为外部项目

虽然从技术上讲,在 CMake 中引用一个项目的内部内容到另一个项目是可能的,但这并不是常规做法,也不推荐这样做。CMake 确实提供了一些支持,包括 load_cache() 命令用于从另一个项目的缓存中加载值。然而,使用这种方法可能会导致循环依赖和项目耦合的问题。最好避免使用这个命令,并做出决定:我们的相关项目应该嵌套在一起,通过库连接,还是合并为一个单独的项目?

这是我们可以使用的分区工具:包含列表文件添加子目录嵌套项目。那么我们该如何使用它们,才能让我们的项目保持可维护、易于导航和扩展呢?为了做到这一点,我们需要一个明确定义的项目结构。

思考项目结构

不是什么秘密,随着项目的增长,找到其中的内容会变得越来越困难——无论是在列表文件中还是在源代码中。因此,从一开始就保持项目的清晰性非常重要。

假设有这样一个场景,你需要交付一些重要且时间紧迫的变更,而这些变更在项目中的两个目录里都不太适合。现在,你需要额外推送一个 清理提交,以重构文件层次结构,使其更适合你的更改。或者,更糟的是,你决定将它们随便放在任何地方,并添加一个 TODO,计划以后再处理这个问题。

在一年中的过程中,这些问题不断积累,技术债务也在增长,维护代码的成本也随之增加。当在生产环境中出现严重漏洞需要快速修复时,或者当不熟悉代码库的人需要进行偶尔的更改时,这种情况会变得非常麻烦。

因此,我们需要一个好的项目结构。那么这意味着什么呢?我们可以借鉴软件开发中其他领域(如系统设计)的一些规则。项目应具有以下特征:

  • 易于导航和扩展

  • 边界清晰(项目特定的文件应仅包含在项目目录中)

  • 单独的目标遵循层次树结构

并没有唯一的解决方案,但在各种在线项目结构模板中,我建议使用这个模板,因为它简单且具有可扩展性:

图 4.1:项目结构示例

该项目概述了以下组件的目录:

  • cmake:共享宏和函数、find_modules,以及一次性脚本

  • src:二进制文件和库的源文件和头文件

  • test:自动化测试的源代码

在这个结构中,CMakeLists.txt 文件应该存在于以下目录中:顶层项目目录、testsrc 以及所有其子目录。主列表文件不应该自己声明任何构建步骤,而是应配置项目的一般设置,并通过 add_subdirectory() 命令将构建责任委托给嵌套的列表文件。这些列表文件如果需要,还可以将工作委托给更深层次的列表文件。

一些开发者建议将可执行文件和库分开,创建两个顶级目录而不是一个:srclib。CMake 对这两种工件的处理是相同的,在这个层级上的分离其实并不重要。如果你喜欢这种模型,可以随意使用它。

src 目录中拥有多个子目录对于大型项目非常有用。但如果你只构建一个单一的可执行文件或库,你可以跳过这些子目录,直接将源文件存储在 src 中。无论如何,记得在那里添加一个 CMakeLists.txt 文件,并执行任何嵌套的列表文件。这是你一个简单目标的文件树结构示例:

图 4.2:可执行文件的目录结构

图 4.1 中,我们看到 src 目录根目录下有一个 CMakeLists.txt 文件 – 它将配置关键的项目设置,并包含所有来自嵌套目录的列表文件。app1 目录(在 图 4.2 中可见)包含另一个 CMakeLists.txt 文件,以及 .cpp 实现文件:class_a.cppclass_b.cpp。还有一个包含可执行程序入口点的 main.cpp 文件。CMakeLists.txt 文件应该定义一个目标,使用这些源文件来构建一个可执行文件 – 接下来的一章中我们将学习如何做到这一点。

我们的头文件被放置在include目录中,可以用于为其他 C++翻译单元声明符号。

接下来,我们有一个lib3目录,它包含专门用于该可执行文件的库(在项目其他地方或外部使用的库应该位于src目录中)。这种结构提供了极大的灵活性,并且允许轻松扩展项目。当我们继续添加更多类时,我们可以方便地将它们分组到库中,以提高编译速度。让我们看看一个库是怎样的:

图 4.3:库的目录结构

库应遵循与可执行文件相同的结构,但有一个小区别:一个可选的lib1目录被添加到包含目录中。当库计划在项目外部使用时,该目录会被包含。它包含其他项目在编译过程中将使用的公共头文件。当我们开始构建自己的库时,我们将在第七章使用 CMake 编译 C++源文件中进一步讨论这个话题。

所以,我们已经讨论了文件在目录结构中的布局方式。现在,是时候看看各个CMakeLists.txt文件是如何汇聚在一起形成一个完整的项目,并且它们在更大场景中的作用是什么。

图 4.4:CMake 如何在一个项目中合并列表文件

在前面的图中,每个框表示一个CMakeLists.txt列表文件,位于每个目录中,而斜体标签表示每个文件执行的操作(从上到下)。让我们从 CMake 的角度再次分析这个项目(有关所有详细信息,请查看ch04/05-structure目录中的示例):

  1. 执行从项目根目录开始——也就是从位于源代码树顶部的CMakeLists.txt列表文件开始。该文件将设置 CMake 的最低要求版本及适当的策略,设置项目名称、支持的语言和全局变量,并包含cmake目录中的文件,以便它们的内容在全局范围内可用。

  2. 下一步是通过调用add_subdirectory(src bin)命令进入src目录的范围(我们希望将编译的产物放在<binary_tree>/bin而不是<binary_tree>/src)。

  3. CMake 读取src/CMakeLists.txt文件,发现它的唯一目的是添加四个嵌套的子目录:app1app2lib1lib2

  4. CMake 进入app1的变量范围,了解另一个嵌套的库lib3,它有自己的CMakeLists.txt文件;然后进入lib3的范围。正如你可能注意到的,这是一个深度优先遍历目录结构。

  5. lib3库添加了一个与其同名的静态库目标。CMake 返回到app1的父范围。

  6. app1子目录添加了一个依赖于lib3的可执行文件。CMake 返回到src的父范围。

  7. CMake 会继续进入剩余的嵌套作用域并执行它们的 listfiles,直到所有 add_subdirectory() 调用完成。

  8. CMake 返回到顶层作用域并执行剩余的命令 add_subdirectory(test)。每次,CMake 都会进入新的作用域并执行适当 listfile 中的命令。

  9. 所有的目标都被收集并检查其正确性。CMake 现在拥有生成构建系统所需的所有必要信息。

需要注意的是,前面的步骤是按我们在 listfiles 中编写命令的顺序执行的。在某些情况下,这个顺序非常重要,而在其他情况下,它可能不那么关键。我们将在下一章,第五章与目标工作 中深入探讨这个问题,理解它的含义。

那么,什么时候是创建包含项目所有元素的目录的合适时机呢?我们应该从一开始就创建所有未来需要的内容并保持目录为空,还是等到我们真正有了需要放入各自类别的文件时再创建?这是一个选择——我们可以遵循极限编程XP)规则YAGNI你不会需要它),或者我们可以尝试让我们的项目具有未来适应性,为未来的开发者打下良好的基础。

尝试在这些方法之间寻找良好的平衡——如果你怀疑你的项目将来可能需要一个 extern 目录,那么就添加它(你的版本控制系统可能需要一个空的 .keep 文件来将目录检查到仓库中)。

另一种有效的方式来引导他人放置外部依赖项是创建一个 README 文件,概述推荐的结构。这对于将来将参与项目的经验较少的程序员特别有帮助。你可能自己也观察到过:开发人员往往不愿意创建目录,特别是在项目的根目录下。如果我们提供一个良好的项目结构,其他人会更倾向于遵循它。

一些项目几乎可以在所有环境中构建,而另一些则对其要求非常特定。顶层的 listfile 是确定适当行动方案的理想位置。让我们看看如何做到这一点。

环境作用域

CMake 提供了多种方式来查询环境,通过 CMAKE_ 变量、ENV 变量和特殊命令。例如,收集到的信息可以用来支持跨平台脚本。这些机制使我们能够避免使用平台特定的 shell 命令,这些命令可能不容易移植,或者在不同环境中有不同的命名。

对于性能关键型应用,了解构建平台的所有特性(例如,指令集、CPU 核心数等)会非常有用。然后,这些信息可以传递给编译后的二进制文件,以便它们能够优化到最佳状态(我们将在下一章学习如何进行传递)。让我们来探索 CMake 提供的本地信息。

检测操作系统

有很多情况需要了解目标操作系统是什么。即便是像文件系统这样常见的事情,在 Windows 和 Unix 之间也有很大的不同,例如区分大小写、文件路径结构、扩展名的存在、权限等等。一台系统上可用的大多数命令在另一台系统上都不可用;它们可能会有不同的名称(例如,Unix 中是ifconfig,而 Windows 中是ipconfig),或者输出的内容完全不同。

如果你需要在一个 CMake 脚本中支持多个目标操作系统,只需检查CMAKE_SYSTEM_NAME变量,以便可以根据需要采取相应的措施。下面是一个简单的示例:

if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
  message(STATUS "Doing things the usual way")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
  message(STATUS "Thinking differently")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
  message(STATUS "I'm supported here too.")
elseif(CMAKE_SYSTEM_NAME STREQUAL "AIX")
  message(STATUS "I buy mainframes.")
else()
  message(STATUS "This is ${CMAKE_SYSTEM_NAME} speaking.")
endif() 

如果需要,可以使用一个包含操作系统版本的变量:CMAKE_SYSTEM_VERSION。不过,我的建议是尽量使你的解决方案尽可能不依赖于具体系统,并使用内置的 CMake 跨平台功能。特别是在文件系统操作方面,你应该使用在附录中描述的file()命令。

交叉编译 —— 主机和目标系统是什么?

交叉编译是指在一台机器上编译代码,以便在不同的目标平台上执行。例如,使用适当的工具集,您可以通过在 Windows 机器上运行 CMake 来为 Android 编译应用程序。尽管交叉编译超出了本书的范围,但了解它如何影响 CMake 的某些部分是很重要的。

允许交叉编译的必要步骤之一是将CMAKE_SYSTEM_NAMECMAKE_SYSTEM_VERSION变量设置为适合你正在编译的操作系统的值(CMake 文档称其为目标系统)。用于执行构建的操作系统被称为主机系统

无论配置如何,主机系统的信息始终可以通过名称中包含HOST关键字的变量访问:CMAKE_HOST_SYSTEMCMAKE_HOST_SYSTEM_NAMECMAKE_HOST_SYSTEM_PROCESSORCMAKE_HOST_SYSTEM_VERSION

还有一些包含HOST关键字的变量,记住它们明确引用的是主机系统。否则,所有变量引用的都是目标系统(通常情况下,这也是主机系统,除非我们在进行交叉编译)。

如果你有兴趣深入了解交叉编译,我建议参考 CMake 文档:cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html

缩写变量

CMake 会预定义一些变量,这些变量会提供主机和目标系统的信息。如果使用了特定的系统,则会将相应的变量设置为非 false 值(即 1true):

  • ANDROID, APPLE, CYGWIN, UNIX, IOS, WIN32, WINCE, WINDOWS_PHONE

  • CMAKE_HOST_APPLE, CMAKE_HOST_SOLARIS, CMAKE_HOST_UNIX, CMAKE_HOST_WIN32

WIN32CMAKE_HOST_WIN32 变量对于 32 位和 64 位版本的 Windows 和 MSYS 为 true(此值是为了兼容性保留的)。另外,UNIX 对于 Linux、macOS 和 Cygwin 为 true

主机系统信息

CMake 可以提供更多变量,但为了节省时间,它不会查询环境中不常需要的信息,比如 处理器是否支持 MMX总物理内存是多少。这并不意味着这些信息不可用——你只需要显式地请求它们,方法是使用以下命令:

cmake_host_system_information(RESULT <VARIABLE> QUERY <KEY>...) 

我们需要提供目标变量和我们感兴趣的键列表。如果只提供一个键,则变量将包含一个值;否则,它将是一个值的列表。我们可以请求关于环境和操作系统的许多详细信息:

Key 描述
HOSTNAME 主机名
FQDN 完全限定域名
TOTAL_VIRTUAL_MEMORY 总虚拟内存(单位:MiB)
AVAILABLE_VIRTUAL_MEMORY 可用虚拟内存(单位:MiB)
TOTAL_PHYSICAL_MEMORY 总物理内存(单位:MiB)
AVAILABLE_PHYSICAL_MEMORY 可用物理内存(单位:MiB)
OS_NAME 如果此命令存在,uname -s 的输出;可能为 WindowsLinuxDarwin
OS_RELEASE 操作系统子类型,例如 on Windows Professional
OS_VERSION 操作系统版本 ID
OS_PLATFORM On Windows,$ENV{PROCESSOR_ARCHITECTURE}。在 Unix/macOS 上,使用 uname -m

如果需要,我们甚至可以查询特定处理器的信息:

Key 描述
NUMBER_OF_LOGICAL_CORES 逻辑核心数量
NUMBER_OF_PHYSICAL_CORES 物理核心数量
HAS_SERIAL_NUMBER 如果处理器有序列号,则为 1
PROCESSOR_SERIAL_NUMBER 处理器序列号
PROCESSOR_NAME 人类可读的处理器名称
PROCESSOR_DESCRIPTION 人类可读的完整处理器描述
IS_64BIT 如果处理器是 64 位的,则为 1
HAS_FPU 如果处理器有浮点运算单元,则为 1
HAS_MMX 如果处理器支持 MMX 指令,则为 1
HAS_MMX_PLUS 如果处理器支持扩展 MMX 指令,则为 1
HAS_SSE 如果处理器支持 SSE 指令,则为 1
HAS_SSE2 如果处理器支持 SSE2 指令,则为 1
HAS_SSE_FP 如果处理器支持 SSE 浮点指令,则为 1
HAS_SSE_MMX 如果处理器支持 SSE MMX 指令,则为 1
HAS_AMD_3DNOW 如果处理器支持 3DNow 指令,则为 1
HAS_AMD_3DNOW_PLUS 如果处理器支持 3DNow+ 指令,则为 1
HAS_IA64 如果 IA64 处理器正在模拟 x86,则为 1

该平台是 32 位还是 64 位架构?

在 64 位架构中,内存地址、处理器寄存器、处理器指令、地址总线和数据总线都是 64 位宽。虽然这是一个简化的定义,但它大致说明了 64 位平台与 32 位平台的区别。

在 C++ 中,不同的架构意味着一些基本数据类型(intlong)以及指针的位宽不同。CMake 利用指针的大小来收集目标机器的信息。这些信息通过 CMAKE_SIZEOF_VOID_P 变量提供,对于 64 位,它的值为 8(因为指针宽度为 8 字节),对于 32 位,值为 4(4 字节):

if(CMAKE_SIZEOF_VOID_P EQUAL 8)
  message(STATUS "Target is 64 bits")
endif() 

系统的字节序是什么?

架构可以根据处理器的字节顺序(即数据的自然单位)分为 大端序小端序。在 大端序 系统中,最高有效字节存储在最低的内存地址,而最低有效字节存储在最高的内存地址。另一方面,在 小端序 系统中,字节顺序是反转的,最低有效字节存储在最低内存地址,最高有效字节存储在最高内存地址。

在大多数情况下,字节序并不重要,但当你编写需要便于移植的按位代码时,CMake 会为你提供一个存储在 CMAKE_<LANG>_BYTE_ORDER 变量中的 BIG_ENDIANLITTLE_ENDIAN 值,其中 <LANG> 可以是 CCXXOBJCCUDA

现在我们知道如何查询环境变量,让我们将注意力转向项目的关键设置。

配置工具链

对于 CMake 项目,工具链包含了构建和运行应用程序时使用的所有工具——例如工作环境、生成器、CMake 可执行文件本身以及编译器。

想象一下,当你的构建因为一些神秘的编译和语法错误停止时,一个经验较少的用户会有什么感受。他们必须深入源代码并尝试理解发生了什么。经过一个小时的调试,他们发现正确的解决方法是更新他们的编译器。我们能否为用户提供更好的体验,在开始构建之前检查编译器是否具备所有必需的功能?

当然!有方法可以指定这些要求。如果工具链不支持所有必需的功能,CMake 会提前停止并显示清晰的错误信息,提示用户介入。

设置 C++ 标准

我们可以考虑的初步步骤之一是指定编译器应该支持的所需 C++ 标准,以构建我们的项目。对于新项目,建议至少设置 C++14,但最好是 C++17 或 C++20。从 CMake 3.20 开始,如果编译器支持,可以将所需标准设置为 C++23。此外,从 CMake 3.25 开始,可以将标准设置为 C++26,尽管这目前只是一个占位符。

C++11 官方发布以来已经过去了十多年,它不再被视为 现代 C++ 标准。除非你的目标环境非常旧,否则不建议使用这个版本来启动项目。

坚持使用旧标准的另一个原因是如果你在构建难以升级的遗留目标。然而,C++ 委员会一直在努力保持 C++ 向后兼容,在大多数情况下,你不会遇到将标准提高到更高版本的任何问题。

CMake 支持在每个目标的基础上设置标准(如果你的代码库的某些部分非常旧,这很有用),但最好是让整个项目趋向于一个统一的标准。可以通过将 CMAKE_CXX_STANDARD 变量设置为以下值之一来完成:98111417202326,方法如下:

set(CMAKE_CXX_STANDARD 23) 

这将成为所有后续定义的目标的默认值(因此最好将其设置在根列表文件的顶部附近)。如果需要,你可以在每个目标的基础上覆盖它,方法如下:

set_property(TARGET <target> PROPERTY CXX_STANDARD <version>) 

或者:

set_target_properties(<targets> PROPERTIES CXX_STANDARD <version>) 

第二个版本允许我们在需要时指定多个目标。

坚持标准支持

前面提到的 CXX_STANDARD 属性不会阻止 CMake 继续构建,即使编译器不支持所需的版本——它被视为一种偏好设置。CMake 并不知道我们的代码是否实际使用了旧编译器中无法使用的新特性,它会尽力在现有的环境中工作。

如果我们确定这不会成功,可以设置另一个变量(该变量可以像之前的变量一样在每个目标上覆盖)以明确要求我们所需的标准:

set(CMAKE_CXX_STANDARD_REQUIRED ON) 

在这种情况下,如果系统中的编译器不支持所需的标准,用户将看到以下信息,构建将停止:

Target "Standard" requires the language dialect "CXX23" (with compiler extensions), but CMake does not know the compile flags to use to enable it. 

即使在现代环境中,要求支持 C++23 可能有些过头。但是,C++20 应该在最新的系统上没有问题,因为自 2021/2022 年以来,GCC/Clang/MSVC 都已经普遍支持它。

厂商特定的扩展

根据你所在组织实施的政策,你可能会对允许或禁用特定供应商扩展感兴趣。这些扩展是什么呢?我们可以这样说,C++标准的进展对于一些编译器生产商的需求来说有点慢,所以他们决定为语言添加自己的增强功能——如果你喜欢的话,叫做扩展。例如,C++ 技术报告 1TR1)是一个库扩展,它在这些功能普及之前就引入了正则表达式、智能指针、哈希表和随机数生成器。为了支持 GNU 项目发布的这类插件,CMake 会用-std=gnu++14替换标准编译器标志(-std=c++14)。

一方面,这可能是期望的,因为它提供了一些方便的功能。另一方面,如果你切换到不同的编译器(或者你的用户这样做了),你的代码将失去可移植性,无法编译通过。这也是一个按目标设置的属性,存在一个默认变量CMAKE_CXX_EXTENSIONS。CMake 在这里更为宽容,允许使用扩展,除非我们特别告诉它不要使用:

set(CMAKE_CXX_EXTENSIONS OFF) 

如果可能的话,我推荐这样做,因为这个选项将坚持使用与供应商无关的代码。这种代码不会给用户带来任何不必要的要求。与之前的选项类似,你可以使用set_property()按目标修改这个值。

跨过程优化

通常,编译器会在单一翻译单元的层面上优化代码,这意味着你的.cpp文件会被预处理、编译,然后优化。在这些操作过程中生成的中间文件会被传递给链接器,最终生成一个单一的二进制文件。然而,现代编译器具备在链接时执行跨过程优化(interprocedural optimization)的能力,也称为链接时优化(link-time optimization)。这使得所有编译单元可以作为一个统一的模块进行优化,原则上这将实现更好的结果(有时会以更慢的构建速度和更多的内存消耗为代价)。

如果你的编译器支持跨过程优化,使用它可能是一个不错的主意。我们将采用相同的方法。负责此设置的变量叫做CMAKE_INTERPROCEDURAL_OPTIMIZATION。但在设置它之前,我们需要确保它被支持,以避免错误:

include(CheckIPOSupported)
check_ipo_supported(RESULT ipo_supported)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ${ipo_supported}) 

如你所见,我们需要包含一个内建模块以访问check_ipo_supported()命令。如果优化不被支持,这段代码会优雅地失败,并回退到默认行为。

检查支持的编译器功能

正如我们之前讨论的,如果我们的构建失败了,最好尽早失败,这样我们可以给用户提供清晰的反馈信息并缩短等待时间。有时我们特别关心哪些 C++ 特性受支持(哪些不受支持)。CMake 会在配置阶段询问编译器,并将可用特性的列表存储在 CMAKE_CXX_COMPILE_FEATURES 变量中。我们可以编写一个非常具体的检查,询问是否有某个特性可用:

ch04/07-features/CMakeLists.txt

list(FIND CMAKE_CXX_COMPILE_FEATURES cxx_variable_templates result)
if(result EQUAL -1)
  message(FATAL_ERROR "Variable templates are required for compilation.")
endif() 

正如你可能猜到的,针对我们使用的每个特性编写测试文件是一项艰巨的任务。即使是 CMake 的作者也建议只检查某些高级的 元特性 是否存在:cxx_std_98cxx_std_11cxx_std_14cxx_std_17cxx_std_20cxx_std_23cxx_std_26。每个 元特性 表示编译器支持特定的 C++ 标准。如果你愿意,可以像我们在前面的例子中那样使用它们。

CMake 已知的特性完整列表可以在文档中找到:cmake.org/cmake/help/latest/prop_gbl/CMAKE_CXX_KNOWN_FEATURES.html

编译测试文件

当我使用 GCC 4.7.x 编译一个应用程序时,遇到了一个特别有趣的场景。我已经在编译器的参考文档中手动确认了我们使用的所有 C++11 特性都受支持。然而,解决方案仍然无法正常工作。代码默默地忽略了对标准 <regex> 头文件的调用。事实证明,这个特定的编译器存在一个 bug,导致 regex 库未实现。

没有任何单一的检查可以防止这种罕见的 bug(你也不需要去检查这些 bug!),但你可能想使用最新标准中的一些前沿实验性特性,而且你不确定哪些编译器支持它。你可以通过创建一个测试文件,使用那些特定需求的特性,在一个可以快速编译并执行的小示例中测试你的项目是否能正常工作。

CMake 提供了两个配置时命令,try_compile()try_run(),用于验证所需的所有内容是否在目标平台上受支持。

try_run() 命令提供了更多的灵活性,因为它不仅可以确保代码能够编译,还能验证代码是否正确执行(例如,你可以测试 regex 是否正常工作)。当然,这对于交叉编译场景来说不起作用(因为主机无法运行为不同目标平台构建的可执行文件)。请记住,这个检查的目的是为用户提供编译是否正常工作的快速反馈,因此它不用于运行单元测试或任何复杂的操作——保持文件尽可能简单。例如,像这样:

ch04/08-test_run/main.cpp

#include <iostream>
int main()
{
  std::cout << "Quick check if things work." << std::endl;
} 

调用try_run()其实并不复杂。我们首先设置所需的标准,然后调用try_run()并将收集到的信息打印给用户:

ch04/08-test_run/CMakeLists.txt

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
**try_run****(run_result compile_result**
        **${CMAKE_BINARY_DIR}****/test_output**
        **${CMAKE_SOURCE_DIR}****/main.cpp**
        **RUN_OUTPUT_VARIABLE output)**
message("run_result: ${run_result}")
message("compile_result: ${compile_result}")
message("output:\n" ${output}) 

这个命令一开始可能让人觉得有些复杂,但实际上只有几个参数是必需的,用于编译和运行一个非常基础的测试文件。我还额外使用了可选的RUN_OUTPUT_VARIABLE关键字来收集来自stdout的输出。

下一步是通过使用一些我们将在实际项目中使用的现代 C++特性,来扩展我们的基本测试文件——或许可以通过添加一个变参模板,看看目标机器上的编译器是否能够处理它。

最后,我们可以在条件块中检查收集到的输出是否符合我们的预期,并且当出现问题时,message(SEND_ERROR <error>)会被打印出来。记住,SEND_ERROR关键字允许 CMake 继续配置阶段,但会阻止生成构建系统。这对于在中止构建之前显示所有遇到的错误非常有用。现在我们已经知道如何确保编译可以完全完成。接下来,我们将讨论禁用源代码目录构建的问题。

禁用源代码目录构建

第一章CMake 入门中,我们讨论了源代码目录构建,并且建议始终指定构建路径为源代码之外。这不仅可以让构建树更干净、.gitignore文件更简单,还能减少你意外覆盖或删除源文件的风险。

如果你想提前停止构建,可以使用以下检查:

ch04/09-in-source/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(NoInSource CXX)
if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
  message(FATAL_ERROR "In-source builds are not allowed")
endif()
message("Build successful!") 

如果你想了解更多关于 STR 前缀和变量引用的信息,请回顾第二章CMake 语言

但是请注意,无论你在前面的代码中做什么,似乎 CMake 仍然会创建一个CMakeFiles/目录和一个CMakeCache.txt文件。

你可能会在网上看到一些建议,使用未文档化的变量来确保用户在任何情况下都不能在源目录中写入。依赖于未文档化的变量来限制在源目录中的写入是不推荐的。这些变量可能在所有版本中都不起作用,并且可能在没有警告的情况下被移除或修改。

如果你担心用户将这些文件留在源代码目录中,可以将它们添加到.gitignore(或等效文件)中,并更改提示信息,要求手动清理。

总结

本章介绍了有价值的概念,为构建健壮和具有未来保障的项目奠定了坚实的基础。我们讨论了设置最小 CMake 版本,以及配置项目的基本方面,如名称、语言和元数据字段。建立这些基础使得我们的项目能够有效地扩展。

我们探索了项目划分,比较了基本的include()add_subdirectory的使用,后者带来了诸如作用域变量管理、简化路径和增加模块化等好处。能够创建嵌套项目并分别构建它们,在逐步将代码分解为更独立的单元时证明是有用的。在理解了可用的划分机制之后,我们深入探讨了如何创建透明、弹性和可扩展的项目结构。我们考察了 CMake 如何遍历 listfile 以及配置步骤的正确顺序。接下来,我们研究了如何为目标和主机机器设置环境作用域,它们之间的差异是什么,以及通过不同查询可以获得哪些关于平台和系统的信息。我们还介绍了工具链配置,包括指定所需的 C++版本、处理特定供应商的编译器扩展以及启用重要的优化。我们学习了如何测试编译器所需的功能,并执行示例文件以测试编译支持。

尽管到目前为止涉及的技术方面对于项目至关重要,但它们不足以使项目真正有用。为了增加项目的实用性,我们需要理解目标(targets)的概念。我们之前简要地提到过这一话题,但现在我们已经准备好全面探讨它,因为我们已经充分理解了相关的基础知识。在下一章中将介绍的目标(targets),将在进一步提升项目的功能性和有效性方面发挥关键作用。

进一步阅读

有关本章涉及主题的更多信息,请参阅以下链接:

留下评论!

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

第五章:使用目标

在 CMake 中,整个应用程序可以从一个源代码文件(例如经典的 helloworld.cpp)构建。但同样,也可以创建一个项目,其中可执行文件由多个源文件构建:几十个甚至成千上万个。许多初学者遵循这种路径:他们只用几个文件来构建二进制文件,并让他们的项目自然发展,缺乏严格的规划。他们会根据需要不断添加文件,直到所有内容都直接链接到一个二进制文件,没有任何结构。

作为软件开发者,我们有意地划定边界并指定组件,以将一个或多个翻译单元(.cpp 文件)分组。我们这样做是为了提高代码可读性、管理耦合性和内聚性、加速构建过程,并最终发现和提取可重用的组件,使其成为自治单元。

每个大型项目都会促使你引入某种形式的分区。这就是 CMake 目标派上用场的地方。CMake 目标代表一个专注于特定目标的逻辑单元。目标之间可以有依赖关系,它们的构建遵循声明式方法。CMake 会负责确定构建目标的正确顺序,尽可能进行并行构建,并按需执行必要的步骤。作为一般原则,当一个目标被构建时,它会生成一个可以被其他目标使用的 artifact,或者作为构建过程的最终输出。

请注意 artifact 这个词的使用。我故意避免使用特定术语,因为 CMake 提供了灵活性,不仅限于生成可执行文件或库。实际上,我们可以利用生成的构建系统来生成各种类型的输出:额外的源文件、头文件、目标文件、档案、配置文件等等。唯一的要求是一个命令行工具(如编译器)、可选的输入文件和一个指定的输出路径。

目标是一个非常强大的概念,大大简化了构建项目的过程。理解它们的功能,并掌握如何以优雅和有组织的方式配置它们至关重要。这些知识确保了一个顺畅和高效的开发体验。

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

  • 理解目标的概念

  • 设置目标的属性

  • 编写自定义命令

技术要求

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

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

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

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

理解目标的概念

如果你曾使用过 GNU Make,你已经见过目标的概念。实际上,它是一个构建系统遵循的配方,用来将一组文件编译成另一个文件。它可以是一个 .cpp 实现文件编译成的 .o 目标文件,也可以是多个 .o 文件打包成的 .a 静态库。在构建系统中,目标及其转换有很多种组合和可能性。

然而,CMake 允许你节省时间,并跳过定义这些步骤的中间过程;它在更高的抽象层次上工作。它理解大多数语言如何直接从源文件生成可执行文件。因此,你不需要像使用 GNU Make 那样编写明确的命令来编译你的 C++ 目标文件。只需使用 add_executable() 命令,后跟可执行目标的名称和源文件列表即可:

add_executable(app1 a.cpp b.cpp c.cpp) 

我们在前面的章节中已经使用过这个命令,我们也知道可执行目标在实际应用中的使用方式——在生成步骤中,CMake 会创建一个构建系统,并填充适当的配方,将每个源文件编译并链接成一个单一的二进制可执行文件。

在 CMake 中,我们可以使用这三个命令来创建一个目标:

  • add_executable()

  • add_library()

  • add_custom_target()

在构建可执行文件或库之前,CMake 会检查生成的输出是否比源文件更新。这个机制帮助 CMake 避免重新创建已经是最新的产物。通过比较时间戳,CMake 可以有效地识别哪些目标需要重新构建,从而减少不必要的重新编译。

所有定义目标的命令都要求将目标的名称作为第一个参数提供,以便在后续的命令中引用这些目标,诸如target_link_libraries()target_sources()target_include_directories()等命令都可以用到目标。我们稍后会学习这些命令,但现在,让我们仔细看看我们可以定义什么样的目标。

定义可执行目标

定义可执行目标的命令 add_executable() 不言自明(我们在前面的章节中已经依赖并使用了这个命令)。它的正式结构如下:

add_executable(<name> [WIN32] [MACOSX_BUNDLE]
               [EXCLUDE_FROM_ALL]
               [source1] [source2 ...]) 

如果我们为 Windows 编译,通过添加可选参数 WIN32 关键字,我们将生成一个不会显示默认控制台窗口的可执行文件(通常我们可以在控制台窗口中看到输出流 std::cout)。相反,应用程序将期望生成自己的图形用户界面(GUI)。

下一个可选参数MACOSX_BUNDLE在某种程度上类似;它使得为 macOS/iOS 生成的应用程序可以从 Finder 中启动,作为 GUI 应用程序。

EXCLUDE_FROM_ALL关键字在使用时,会阻止可执行目标在常规默认构建中被构建。这样的目标必须在构建命令中明确提到:

cmake --build -t <target> 

最后,我们需要提供将被编译成目标的源代码列表。支持以下扩展:

  • 对于 C 语言:cm

  • 对于 C++语言:CMc++cccppcxxmmmmppCPPixxcppmccmcxxmc++m

请注意,我们没有将任何头文件添加到源代码列表中。这可以通过提供包含这些文件的目录路径给target_include_directories()命令来隐式完成,或者通过使用target_sources()命令的FILE_SET功能(在 CMake 3.23 中新增)。这是可执行文件的重要话题,但由于其复杂且与目标相互独立,我们将在第七章使用 CMake 编译 C++源代码中深入探讨其细节。

定义库目标

定义库与定义可执行文件非常相似,但当然,它不需要定义如何处理 GUI 方面的关键字。以下是该命令的签名:

add_library(<name> [STATIC | SHARED | MODULE]
            [EXCLUDE_FROM_ALL]
            [<source>...]) 

关于名称、排除所有 和源代码匹配可执行目标的规则完全一致。唯一的区别在于STATICSHAREDMODULE关键字。如果你有使用库的经验,你会知道这些关键字定义了 CMake 将生成哪种类型的构件:静态链接库、共享(动态)库或模块。再一次,这确实是一个庞大的话题,将在第八章链接可执行文件和库中深入讲解。

自定义目标

自定义目标与可执行文件或库有所不同。它们通过执行明确给定的命令行,扩展了 CMake 默认提供的构建功能;例如,它们可以用于:

  • 计算其他二进制文件的校验和。

  • 运行代码清理工具并收集结果。

  • 将编译报告发送到指标管道。

正如你从这个列表中可以猜到的,自定义目标仅在相当复杂的项目中有用,因此我们将仅介绍基本内容,继续深入更重要的主题。

定义自定义目标时,使用以下语法(为了简洁,某些选项已被省略):

add_custom_target(Name [ALL] [COMMAND command2 [args2...] ...]) 

自定义目标有一些需要考虑的缺点。由于它们涉及 Shell 命令,可能是系统特定的,从而限制了可移植性。此外,自定义目标可能不会为 CMake 提供一种直接的方法来确定生成的具体构件或副产品(如果有的话)。

自定义目标与可执行文件和库不同,不会进行陈旧性检查(它们不会验证源文件是否比二进制文件更新),因为默认情况下,它们没有被添加到依赖关系图中(因此ALL关键字与EXCLUDE_FROM_ALL正好相反)。让我们来了解一下这个依赖关系图的内容。

依赖关系图

成熟的应用程序通常由多个组件构建而成,特别是内部库。从结构角度来看,将项目进行划分是有用的。当相关的内容被打包成一个单一的逻辑实体时,它们可以与其他目标进行链接:另一个库或一个可执行文件。当多个目标使用相同的库时,这尤其方便。请看一下图 5.1,它描述了一个示例性的依赖关系图:

图 5.1:BankApp 项目中依赖关系构建的顺序

在这个项目中,我们有两个库、两个可执行文件和一个自定义目标。我们的用例是为用户提供一个带有良好图形界面的银行应用程序(GuiApp),以及一个命令行版本,作为自动化脚本的一部分(TerminalApp)。这两个可执行文件都依赖于相同的Calculations库,但只有其中一个需要Drawing库。为了确保我们的应用程序二进制文件是从可靠来源下载的,我们还会计算一个校验和,并通过单独的安全渠道分发它。CMake 在为这样的解决方案编写 list 文件时非常灵活:

ch05/01-targets/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(BankApp CXX)
add_executable(terminal_app terminal_app.cpp)
add_executable(gui_app gui_app.cpp)
target_link_libraries(terminal_app calculations)
target_link_libraries(gui_app calculations drawing)
add_library(calculations calculations.cpp)
add_library(drawing drawing.cpp)
add_custom_target(checksum ALL
    COMMAND sh -c "cksum terminal_app>terminal.ck"
    COMMAND sh -c "cksum gui_app>gui.ck"
    BYPRODUCTS terminal.ck gui.ck
    COMMENT "Checking the sums..."
) 

我们通过使用target_link_libraries()命令将我们的库与可执行文件链接。如果没有这个命令,生成可执行文件时将因为未定义的符号而失败。你有没有注意到我们在声明任何库之前就调用了这个命令?当 CMake 配置项目时,它会收集有关目标及其属性的信息——它们的名称、依赖关系、源文件以及其他细节。

在解析所有文件之后,CMake 将尝试构建一个依赖关系图。像所有有效的依赖关系图一样,它们是有向无环图DAGs)。这意味着有明确的方向,指示哪个目标依赖于哪个,且这些依赖关系不能形成循环。

当我们在构建模式下执行cmake时,生成的构建系统将检查我们定义了哪些顶级目标,并递归地构建它们的依赖关系。让我们考虑一下图 5.1中的示例:

  1. 从顶部开始,构建第 1 组中的两个库。

  2. CalculationsDrawing库构建完成后,构建第 2 组——GuiAppTerminalApp

  3. 构建一个校验和目标;运行指定的命令行以生成校验和(cksum是一个 Unix 的校验和工具,这意味着该示例在其他平台上无法构建)。

但是有一个小问题——上述解决方案并不能保证校验和目标在可执行文件之后构建。CMake 不知道校验和依赖于可执行二进制文件的存在,因此它可以自由地先开始构建校验和。为了解决这个问题,我们可以将add_dependencies()命令放在文件的最后:

add_dependencies(checksum terminal_app gui_app) 

这将确保 CMake 理解校验和目标与可执行文件之间的关系。

这很好,但target_link_libraries()add_dependencies()之间有什么区别?target_link_libraries()是用来与实际库配合使用的,并允许你控制属性传播。第二个则仅用于顶级目标,用来设置它们的构建顺序。

随着项目复杂度的增加,依赖树变得越来越难以理解。我们如何简化这个过程?

可视化依赖关系

即使是小型项目也可能很难理解并与其他开发人员共享。一个简洁的图表在这里会大有帮助。毕竟,一图胜千言。我们可以像我在图 5.1中做的那样,自己动手绘制图表。但这既繁琐又需要在项目变动时更新。幸运的是,CMake 有一个很棒的模块,可以生成dot/graphviz格式的依赖图,并且它支持内部和外部依赖!

要使用它,我们可以简单地执行以下命令:

cmake --graphviz=test.dot . 

该模块将生成一个文本文件,我们可以将其导入到 Graphviz 可视化软件中,Graphviz 可以渲染图像或生成 PDF 或 SVG 文件,这些文件可以作为软件文档的一部分存储。每个人都喜欢出色的文档,但几乎没有人喜欢创建它——现在,你不需要做这件事了!

自定义目标默认不可见,我们需要创建一个特殊的配置文件CMakeGraphVizOptions.cmake,它将允许我们自定义图形。使用set(GRAPHVIZ_CUSTOM_TARGETS TRUE)命令可以在图形中启用自定义目标:

ch05/01-targets/CMakeGraphVizOptions.cmake

set(GRAPHVIZ_CUSTOM_TARGETS TRUE) 

其他选项允许添加图表名称、标题和节点前缀,并配置哪些目标应包含或排除在输出中(按名称或类型)。有关CMakeGraphVizOptions模块的完整描述,请访问官方的 CMake 文档。

如果你很着急,你甚至可以直接通过浏览器在这个地址运行 Graphviz:dreampuf.github.io/GraphvizOnline/

你所需要做的就是将test.dot文件的内容复制并粘贴到左侧窗口,你的项目就会被可视化(图 5.2)。很方便,不是吗?

图 5.2:BankApp 示例在 Graphviz 中的可视化

使用这种方法,我们可以快速查看所有显式定义的目标。

现在我们理解了目标的概念,知道如何定义不同类型的目标,包括可执行文件、库和自定义目标,以及如何创建依赖图并将其打印出来。让我们利用这些信息深入探讨,看看如何配置这些目标。

设置目标属性

目标具有类似于 C++ 对象字段的属性。这些属性中有些是可以修改的,有些则是只读的。CMake 定义了一个庞大的“已知属性”列表(见 进一步阅读 部分),根据目标的类型(可执行文件、库或自定义目标),可以使用不同的属性。你也可以根据需要添加自己的属性。使用以下命令来操作目标的属性:

get_target_property(<var> <target> <property-name>)
set_target_properties(<target1> <target2> ...
                      PROPERTIES <prop1-name> <value1>
                      <prop2-name> <value2> ...) 

要在屏幕上打印目标属性,我们首先需要将其存储在 <var> 变量中,然后通过消息将其传递给用户。属性的读取必须逐一进行;而在目标上设置属性时,可以同时为多个目标指定多个属性。

属性的概念并非目标独有;CMake 还支持设置其他作用域的属性:GLOBALDIRECTORYSOURCEINSTALLTESTCACHE。要操作各种属性,可以使用通用的 get_property()set_property() 命令。在某些项目中,你会看到这些低级命令用于执行 set_target_properties() 命令所做的事情,只是工作量稍大一些:

set_property`(`TARGET `<target>` PROPERTY `<name> <value>)` 

一般来说,尽量使用尽可能多的高级命令。在某些情况下,CMake 提供了简写命令,并附带了额外的机制。例如,add_dependencies(<target> <dep>) 是一个简写,用于将依赖项添加到 MANUALLY_ADDED_DEPENDENCIES 目标属性中。在这种情况下,我们可以像查询任何其他属性一样使用 get_target_property() 查询它。然而,我们不能使用 set_target_properties() 来修改它(它是只读的),因为 CMake 坚持使用 add_dependencies() 命令来限制操作只能是附加依赖项。

当我们在接下来的章节中讨论编译和链接时,我们会介绍更多的属性设置命令。与此同时,让我们专注于如何将一个目标的属性传递给另一个目标。

什么是传递性使用要求?

让我们达成共识,命名确实很难,有时最终会得到一个难以理解的标签。“传递性使用要求”不幸地就是你在 CMake 在线文档中会遇到的那些难以理解的标题之一。让我们拆解一下这个奇怪的名字,并尝试提出一个更容易理解的术语。

从中间术语开始:使用。正如我们之前讨论的,一个目标可能依赖于另一个目标。CMake 文档有时将这种依赖关系称为 使用,就像一个目标使用另一个目标一样。

会有这样的情况:当某个被使用的目标为自己设置了特定的属性依赖,这些属性或依赖反过来成为使用该目标的其他目标需求:链接一些库,包含一个目录,或者需要特定的编译器特性。

我们难题的最后一部分,传递性,正确描述了行为(可能可以简化一点)。CMake 将被使用目标的一些属性/需求附加到使用目标的属性中。可以说,一些属性可以隐式地在目标之间传递(或者简单地传播),因此更容易表达依赖关系。

简化这个概念,我将其视为传播的属性,它们在源目标(被使用的目标)和目标使用者(使用其他目标的目标)之间传播。

让我们通过一个具体的例子来理解它为什么存在以及它是如何工作的:

target_compile_definitions(<source> <INTERFACE|PUBLIC|PRIVATE> [items1...]) 

这个目标命令将填充COMPILE_DEFINITIONS属性到一个<source>目标中。编译定义就是传递给编译器的-Dname=definition标志,用于配置 C++预处理器定义(我们将在第七章使用 CMake 编译 C++源代码中详细讲解)。这里有趣的部分是第二个参数。我们需要指定三个值中的一个,INTERFACEPUBLICPRIVATE,来控制该属性应该传递给哪些目标。现在,别把这些和 C++的访问控制符混淆——这是一个独立的概念。

传播关键字是这样工作的:

  • PRIVATE设置源目标的属性。

  • INTERFACE设置目标使用者的属性。

  • PUBLIC设置源目标和目标使用者的属性。

当一个属性不需要传递给任何目标时,设置为PRIVATE。当需要进行这样的传递时,使用PUBLIC。如果你处在一种情况,源目标在其实现(.cpp文件)中并不使用该属性,而只在头文件中使用,并且这些头文件被传递给消费者目标,那么应该使用INTERFACE关键字。

这在背后是如何工作的呢?为了管理这些属性,CMake 提供了一些命令,比如前面提到的target_compile_definitions()。当你指定PRIVATEPUBLIC关键字时,CMake 会将提供的值存储到目标的属性中,在这个例子中是COMPILE_DEFINITIONS。此外,如果关键字是INTERFACEPUBLIC,CMake 会将值存储到带有INTERFACE_前缀的属性中——INTERFACE_COMPILE_DEFINITIONS。在配置阶段,CMake 会读取源目标的接口属性,并将它们的内容附加到目标使用者上。就是这样——传播的属性,或者 CMake 所称的传递性使用需求。

使用set_target_properties()命令管理的属性可以在cmake.org/cmake/help/latest/manual/cmake-properties.7.html中找到,位于目标上的属性部分(并非所有目标属性都是传递性的)。以下是最重要的属性:

  • COMPILE_DEFINITIONS

  • COMPILE_FEATURES

  • COMPILE_OPTIONS

  • INCLUDE_DIRECTORIES

  • LINK_DEPENDS

  • LINK_DIRECTORIES

  • LINK_LIBRARIES

  • LINK_OPTIONS

  • POSITION_INDEPENDENT_CODE

  • PRECOMPILE_HEADERS

  • SOURCES

我们将在接下来的几页中讨论这些选项,但请记住,所有这些选项当然在 CMake 手册中有详细描述。你可以通过以下链接找到它们的详细描述(将<PROPERTY>替换为你感兴趣的属性):https://cmake.org/cmake/help/latest/prop_tgt/<PROPERTY>.html

下一个问题是,这种传播会传播多远?属性只会设置到第一个目标目标,还是会传播到依赖图的最顶端?你可以自行决定。

为了在目标之间创建依赖关系,我们使用target_link_libraries()命令。这个命令的完整签名需要一个传播关键字:

target_link_libraries(<target>
                     <PRIVATE|PUBLIC|INTERFACE> <item>...
                    [<PRIVATE|PUBLIC|INTERFACE> <item>...]...) 

如你所见,这个签名还指定了一个传播关键字,它控制属性如何从源目标存储到目标目标图 5.3 展示了在生成阶段(配置阶段完成后)传播的属性会发生什么:

图 5.3:属性如何传播到目标目标

传播关键字的工作方式如下:

  • PRIVATE 将源值追加到源目标私有属性中。

  • INTERFACE 将源值追加到源目标接口属性中。

  • PUBLIC 会将值追加到源目标的两个属性中。

正如我们之前讨论的,接口属性仅用于进一步传播属性到链条下游(到下一个目标目标),而源目标在其构建过程中不会使用这些属性。

我们之前使用的基本命令target_link_libraries(<target> <item>...)隐式指定了PUBLIC关键字。

如果你正确设置了源目标的传播关键字,属性将自动被放置到目标目标上,除非出现冲突……

处理冲突的传播属性

当一个目标依赖于多个其他目标时,可能会出现传播的属性相互冲突的情况。比如一个使用的目标指定了POSITION_INDEPENDENT_CODE属性为true,而另一个则为false。CMake 会理解为冲突,并打印出类似以下的错误信息:

CMake Error: The INTERFACE_POSITION_INDEPENDENT_CODE property of "source_target" does not agree with the value of POSITION_INDEPENDENT_CODE already determined for "destination_target". 

接收到这样的消息是有用的,因为我们明确知道是我们引入了这个冲突,需要解决它。CMake 有自己的属性,这些属性必须在源目标和目标目标之间“一致”。

在少数情况下,这可能变得很重要——例如,如果你在多个目标中使用同一个库,然后将它们链接到一个单一的可执行文件。如果这些源目标使用的是不同版本的同一个库,你可能会遇到问题。

为确保我们只使用相同的特定版本,我们可以创建一个自定义接口属性INTERFACE_LIB_VERSION,并将版本存储在其中。但这还不足以解决问题,因为 CMake 默认不会传播自定义属性(该机制仅适用于内建目标属性)。我们必须显式地将自定义属性添加到“兼容”属性列表中。

每个目标都有四个这样的列表:

  • COMPATIBLE_INTERFACE_BOOL

  • COMPATIBLE_INTERFACE_STRING

  • COMPATIBLE_INTERFACE_NUMBER_MAX

  • COMPATIBLE_INTERFACE_NUMBER_MIN

将你的属性添加到其中之一会触发传播和兼容性检查。BOOL列表将检查所有传播到目标目标的属性是否评估为相同的布尔值。类似地,STRING将评估为字符串。NUMBER_MAXNUMBER_MIN略有不同——传播的值不必完全匹配,但目标目标将接收最高或最低的值。

这个示例将帮助我们理解如何在实践中应用这一点:

ch05/02-propagated/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(PropagatedProperties CXX)
add_library(source1 empty.cpp)
set_property(TARGET source1 PROPERTY **INTERFACE_LIB_VERSION** **4**)
set_property(TARGET source1 APPEND PROPERTY
             COMPATIBLE_INTERFACE_STRING LIB_VERSION)
add_library(source2 empty.cpp)
set_property(TARGET source2 PROPERTY **INTERFACE_LIB_VERSION** **4**)
add_library(destination empty.cpp)
target_link_libraries(destination source1 source2) 

我们在这里创建了三个目标;为了简化起见,所有目标都使用相同的空源文件。在两个源目标上,我们指定了带有INTERFACE_前缀的自定义属性,并将它们设置为相同的匹配库版本。这两个源目标都链接到目标目标。最后,我们为source1指定了一个STRING兼容性要求作为属性(这里没有加INTERFACE_前缀)。

CMake 会将这个自定义属性传播到目标目标,并检查所有源目标的版本是否完全匹配(兼容性属性可以只设置在一个目标上)。

现在我们了解了常规目标是什么,让我们来看一看那些看起来像目标、闻起来像目标、有时也像目标但实际上并不是目标的其他事物。

认识伪目标

目标的概念非常有用,如果它的一些行为可以借用到其他地方,那就更好了;这些地方并不代表构建系统的输出,而是输入——外部依赖、别名等。这些就是伪目标,或者说是那些没有进入生成的构建系统的目标:

  • 导入目标

  • 别名目标

  • 接口库

我们来看一下。

导入目标

如果你浏览过本书的目录,你就会知道我们将讨论 CMake 如何管理外部依赖——其他项目、库等等。IMPORTED目标本质上是这个过程的产物。CMake 可以通过find_package()命令定义它们。

你可以调整这种目标的目标属性:编译定义编译 选项包含目录等——它们甚至支持传递性使用要求。然而,你应该将它们视为不可变的;不要更改它们的源代码或依赖关系。

IMPORTED目标的定义范围可以是全局的,也可以是局部的,即只在定义该目标的目录中可见(在子目录中可见,但在父目录中不可见)。

别名目标

别名目标正如你所期待的那样——它们创建了一个不同名称的目标引用。你可以使用以下命令为可执行文件和库创建别名目标:

add_executable(<name> ALIAS <target>)
add_library(<name> ALIAS <target>) 

别名目标的属性是只读的,不能安装或导出别名(它们在生成的构建系统中不可见)。

那么,为什么要使用别名呢?它们在项目的某些部分(例如子目录)需要一个特定名称的目标,而实际的实现可能会根据情况以不同的名称出现时非常有用。例如,你可能希望构建一个与你的解决方案一起提供的库,或者根据用户的选择导入它。

接口库

这是一个有趣的构造——一个不编译任何东西,而是作为一个工具目标的库。它的整个概念是围绕传播的属性(传递性使用要求)构建的。

接口库有两个主要用途——表示仅包含头文件的库,以及将一堆传播的属性捆绑成一个逻辑单元。

使用add_library(INTERFACE)创建仅包含头文件的库非常简单:

add_library(Eigen **INTERFACE**
  src/eigen.h src/vector.h src/matrix.h
)
target_include_directories(Eigen INTERFACE
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/src>
  $<INSTALL_INTERFACE:include/Eigen>
) 
generator expressions (these are indicated with dollar sign and angle brackets, $<...> and will be explained in the next chapter), we set its include directories to be ${CMAKE_CURRENT_SOURCE_DIR}/src when a target is exported and include/Eigen when it’s installed (which will also be explained at the end of this chapter).

要使用这样的库,我们只需要链接它:

target_link_libraries(executable Eigen) 

这里不会发生实际的链接,但 CMake 会将此命令理解为要求将所有INTERFACE属性传播到executable目标。

第二种用法完全利用相同的机制,但目的是不同的——它创建一个逻辑目标,可以作为传播属性的占位符。然后,我们可以将这个目标作为其他目标的依赖,并以干净、方便的方式设置属性。这里是一个例子:

add_library(warning_properties **INTERFACE**)
target_compile_options(warning_properties INTERFACE
  -Wall -Wextra -Wpedantic
)
target_link_libraries(executable warning_properties) 

add_library(INTERFACE)命令创建一个逻辑的warning_properties目标,用于设置第二个命令中指定的编译选项,应用于executable目标。我推荐使用这些INTERFACE目标,因为它们提高了代码的可读性和可重用性。可以将其视为将一堆魔法值重构为一个命名良好的变量。我还建议明确地添加一个后缀,如_properties,以便轻松区分接口库和常规库。

目标库

目标库用于将多个源文件归为一个逻辑目标,并在构建过程中将它们编译成(.o目标文件。创建目标库的方法与其他库相同,只不过使用OBJECT关键字:

add_library(<target> OBJECT <sources>) 

在构建过程中生成的目标文件可以通过$<TARGET_OBJECTS:objlib>生成表达式作为编译元素并入到其他目标中:

add_library(... $<TARGET_OBJECTS:objlib> ...)
add_executable(... $<TARGET_OBJECTS:objlib> ...) 

另外,你也可以使用target_link_libraries()命令将它们作为依赖项添加。

在我们Calc库的上下文中,目标库将有助于避免为静态和共享版本的库重复编译库源文件。显式编译目标文件并启用POSITION_INDEPENDENT_CODE是共享库的先决条件。

回到项目的目标:calc_obj将提供编译好的目标文件,然后它们将用于calc_staticcalc_shared库。让我们探讨这两种库类型之间的实际区别,并理解为什么有时需要同时创建这两种类型。

伪目标是否已经穷尽了目标的概念?当然没有!那样就太简单了。我们仍然需要理解这些目标是如何用来生成构建系统的。

构建目标

“目标”这个术语可以根据项目中的上下文和生成的构建系统的不同而有不同的含义。在生成构建系统的上下文中,CMake 将用 CMake 语言编写的列表文件“编译”成所选构建工具的语言,例如为 GNU Make 创建一个 Makefile。这些生成的 Makefile 有自己的目标集合。部分目标是从列表文件中定义的目标直接转换而来,而其他目标则是作为构建系统生成过程的一部分隐式创建的。

一个这样的构建系统目标是ALL,这是 CMake 默认生成的,包含所有顶级列表文件目标,例如可执行文件和库(不一定是自定义目标)。当我们运行cmake --build <build tree>而不选择任何特定目标时,ALL会被构建。正如你在第一章中可能记得的,你可以通过在cmake构建命令中添加--target <name>参数来选择一个目标。

有些可执行文件或库在每次构建中可能都不需要,但我们希望它们作为项目的一部分保留,以备在少数需要时使用。为了优化我们的默认构建,我们可以像这样将它们从ALL目标中排除:

add_executable(<name> **EXCLUDE_FROM_ALL** [<source>...])
add_library(<name> **EXCLUDE_FROM_ALL** [<source>...]) 

自定义目标的工作方式正好相反——默认情况下,它们会被排除在ALL目标之外,除非你显式地使用ALL关键字将它们添加进去,就像我们在 BankApp 示例中所做的那样。

另一个隐式定义的构建目标是clean,它简单地从构建树中移除生成的产物。我们使用它来删除所有旧文件并从头开始构建。然而,重要的是要理解,它并不只是简单地删除构建目录中的所有内容。为了让clean正确工作,你需要手动指定你的自定义目标可能会创建的任何文件作为BYPRODUCTS(见 BankApp 示例)。

这就是我们探索目标及其不同方面的总结:我们知道如何创建目标,配置其属性,使用伪目标,并决定它们是否应该默认构建。此外,还有一种有趣的非目标机制,用于创建可以在所有实际目标中使用的自定义工件——自定义命令(不要与自定义目标混淆)。

编写自定义命令

使用自定义目标有一个缺点——一旦将它们添加到ALL目标中,或者让其他目标依赖它们,它们就会每次都被构建。有时,这是你想要的效果,但也有些情况下,出于某些原因,有必要生成不应被重新创建的文件,此时需要自定义行为:

  • 生成另一个目标依赖的源代码文件

  • 将另一种语言翻译成 C++

  • 在另一个目标构建之前或之后立即执行自定义操作

自定义命令有两个签名,第一个是add_custom_target()的扩展版本:

add_custom_command(OUTPUT output1 [output2 ...]
                   COMMAND command1 [ARGS] [args1...]
                   [COMMAND command2 [ARGS] [args2...] ...]
                   [MAIN_DEPENDENCY depend]
                   [DEPENDS [depends...]]
                   [BYPRODUCTS [files...]]
                   [IMPLICIT_DEPENDS <lang1> depend1
                                    [<lang2> depend2] ...]
                   [WORKING_DIRECTORY dir]
                   [COMMENT comment]
                   [DEPFILE depfile]
                   [JOB_POOL job_pool]
                   [VERBATIM] [APPEND] [USES_TERMINAL]
                   [COMMAND_EXPAND_LISTS]) 

正如你可能已经猜到的那样,自定义命令并不会创建一个逻辑目标,但和自定义目标一样,它必须被添加到依赖图中。这样做有两种方式——将其输出的工件作为可执行文件(或库)的源,或者显式地将其添加到自定义目标的DEPENDS列表中。

使用自定义命令作为生成器

诚然,并不是每个项目都需要从其他文件生成 C++代码。一个这样的情况可能是Google 的协议缓冲区Protobuf)的.proto文件编译。如果你不熟悉这个库,Protobuf 是一个平台中立的二进制序列化工具,用于结构化数据。

换句话说:它可以用于在二进制流中编码对象:文件或网络连接。为了保持 Protobuf 的跨平台性和快速性,Google 的工程师发明了他们自己的 Protobuf 语言,该语言在.proto文件中定义模型,如下所示:

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
} 

这样的文件可以用于在多种语言中编码数据——C++、Ruby、Go、Python、Java 等。Google 提供了一个编译器protoc,它读取.proto文件,并输出针对所选语言的结构和序列化源代码(稍后需要编译或解释)。聪明的工程师不会将这些生成的源文件提交到版本库,而是会使用原始的 Protobuf 格式,并在构建链中添加一步生成源文件的操作。

我们还不知道如何检测目标主机上是否存在(以及在哪里存在)Protobuf 编译器(我们将在第九章CMake 中的依赖管理中学习这一点)。所以,目前我们暂时假设编译器的protoc命令位于系统已知的位置。我们已经准备了一个person.proto文件,并且知道 Protobuf 编译器将输出person.pb.hperson.pb.cc文件。下面是我们如何定义一个自定义命令来编译它们:

add_custom_command(OUTPUT person.pb.h person.pb.cc
        COMMAND protoc ARGS person.proto
        DEPENDS person.proto
) 

然后,为了在我们的可执行文件中支持序列化,我们可以直接将输出文件添加到源代码中:

add_executable(serializer serializer.cpp person.pb.cc) 

假设我们正确处理了头文件的包含和 Protobuf 库的链接,当我们对 .proto 文件进行修改时,所有内容会自动编译并更新。

一个简化的(但不太实用的)示例是通过从另一个位置复制必要的头文件来创建它:

ch05/03-command/CMakeLists.txt

add_executable(main main.cpp constants.h)
target_include_directories(main PRIVATE ${CMAKE_BINARY_DIR})
add_custom_command(OUTPUT constants.h **COMMAND** **cp**
                   ARGS "${CMAKE_SOURCE_DIR}/template.xyz" constants.h) 

在这种情况下,我们的“编译器”是 cp 命令。它通过简单地从源代码树复制 constants.h 文件到构建树根目录,满足 main 目标的依赖。

使用自定义命令作为目标钩子

第二版本的 add_custom_command() 命令引入了一个机制,用于在构建目标之前或之后执行命令:

add_custom_command(**TARGET** **<target>**
                   PRE_BUILD | PRE_LINK | POST_BUILD
                   COMMAND command1 [ARGS] [args1...]
                   [COMMAND command2 [ARGS] [args2...] ...]
                   [BYPRODUCTS [files...]]
                   [WORKING_DIRECTORY dir]
                   [COMMENT comment]
                   [VERBATIM] [USES_TERMINAL]
                   [COMMAND_EXPAND_LISTS]) 

我们在第一个参数中指定希望“增强”的目标,并在以下条件下执行:

  • PRE_BUILD 会在为此目标执行任何其他规则之前运行(仅适用于 Visual Studio 生成器;对于其他生成器,它的行为像 PRE_LINK)。

  • PRE_LINK 将命令绑定到所有源文件编译完成后、目标链接(或归档)之前运行。它不适用于自定义目标。

  • POST_BUILD 会在所有其他规则为此目标执行完成后运行。

使用这个版本的 add_custom_command(),我们可以复制前面 BankApp 示例中的校验和生成:

ch05/04-command/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Command CXX)
add_executable(main main.cpp)
add_custom_command(TARGET main POST_BUILD
                   COMMAND cksum
                   ARGS "$<TARGET_FILE:main>" > "main.ck") 

main 可执行文件的构建完成后,CMake 会执行 cksum 命令并传入提供的参数。但是,首个参数发生了什么?它不是一个变量,否则它应该用大括号(${})括起来,而不是用尖括号($<>)。它是一个 生成器表达式,计算得到目标二进制文件的完整路径。这个机制在许多目标属性的上下文中非常有用,我们将在下一章中解释。

总结

理解目标是编写清晰、现代 CMake 项目的关键。在本章中,我们不仅讨论了目标的构成以及如何定义三种不同类型的目标:可执行文件、库和自定义目标,还解释了目标之间如何通过依赖图相互依赖,并学习了如何使用 Graphviz 模块来可视化它。通过这些基本理解,我们得以学习目标的一个关键特性——属性。我们不仅介绍了几个命令来设置目标的常规属性,还解决了传递使用要求的谜团,也就是所谓的传播属性。

这是一个很难解决的问题,因为我们不仅需要理解如何控制哪些属性会被传播,还要理解这种传播如何影响后续目标。此外,我们还发现了如何确保来自多个源的属性兼容性。

接着,我们简要讨论了伪目标:导入目标、别名目标和接口库。它们在后续的项目中将派上用场,特别是当我们知道如何将它们与传播属性结合起来,造福我们自己时。然后,我们讨论了生成的构建目标,以及配置阶段如何影响它们。之后,我们花了一些时间研究了一种类似于目标但并不完全相同的机制:自定义命令。我们简要提到它们如何生成供其他目标使用的文件(如编译、翻译等),以及它们的钩子功能:在目标构建时执行附加步骤。

有了如此坚实的基础,我们准备进入下一个主题——将 C++ 源代码编译成可执行文件和库。

进一步阅读

若想了解本章涉及的更多内容,您可以参考以下资源:

加入我们的 Discord 社区

加入我们社区的 Discord 频道,与作者和其他读者讨论:

discord.com/invite/vXN53A7ZcA

第六章:使用生成器表达式

许多 CMake 用户在私下学习时并未遇到生成器表达式,因为它们是相对高级的概念。然而,它们对于那些准备进入广泛发布阶段或首次发布给更广泛观众的项目至关重要,因为它们在导出、安装和打包中扮演着重要角色。如果你只是想快速学习 CMake 的基础知识,并专注于 C++ 方面,可以暂时跳过本章,稍后再回来。另一方面,我们现在讨论生成器表达式,是因为接下来的章节将在解释 CMake 的更深层次内容时引用这些知识。

我们将首先介绍生成器表达式的主题:它们是什么,它们的用途是什么,以及它们是如何构建和展开的。接下来会简要介绍嵌套机制,并更详细地描述条件扩展,这允许使用布尔逻辑、比较操作和查询。当然,我们还会深入探讨可用表达式的广度。

但是首先,我们将研究字符串、列表和路径的转换,因为在集中研究主要内容之前,了解这些基础内容是很有益的。最终,生成器表达式在实际应用中用于获取在构建的后期阶段中可用的信息,并将其呈现于适当的上下文中。确定这个上下文是它们价值的一个重要部分。我们将探讨如何根据用户选择的构建配置、当前平台和工具链来参数化我们的构建过程。也就是说,正在使用什么编译器,它的版本是什么,以及它具备哪些功能,这还不止:我们将弄清楚如何查询构建目标的属性及其相关信息。

为了确保我们能够充分理解生成器表达式的价值,我在本章的最后部分包括了一些有趣的使用示例。此外,还提供了如何查看生成器表达式输出的简短说明,因为这有些棘手。不过别担心,生成器表达式并不像它们看起来那么复杂,你很快就能开始使用它们了。

本章将介绍以下主要内容:

  • 什么是生成器表达式?

  • 学习一般表达式语法的基本规则

  • 条件扩展

  • 查询与转换

  • 试验示例

技术要求

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

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

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

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

生成器表达式是什么?

CMake 在三个阶段构建解决方案:配置、生成和运行构建工具。通常,在配置阶段所有所需的数据都是可用的。然而,有时我们会遇到类似“先有鸡还是先有蛋”这种悖论的情况。举个例子,来自第五章使用自定义命令作为目标钩子部分——某个目标需要知道另一个目标的二进制工件路径。不幸的是,这些信息只有在所有列表文件解析完毕并且配置阶段完成后才会变得可用。

那么,我们如何解决这样的问题呢?一种解决方案是为该信息创建一个占位符,并将其评估延迟到下一个阶段——生成阶段

这正是生成器表达式(也称为“genexes”)的作用。它们围绕目标属性构建,如LINK_LIBRARIESINCLUDE_DIRECTORIESCOMPILE_DEFINITIONS和传播的属性,尽管并非所有属性都如此。它们遵循类似于条件语句和变量评估的规则。

生成器表达式将在生成阶段进行评估(即配置完成并创建构建系统时),这意味着将它们的输出捕获到变量中并打印到控制台并不直接。

生成器表达式有很多种,从某种意义上说,它们构成了自己的一种领域特定语言——一种支持条件表达式、逻辑运算、比较、转换、查询和排序的语言。使用生成器表达式可以操作和查询字符串、列表、版本号、shell 路径、配置和构建目标。在本章中,我们将简要概述这些概念,重点介绍基础知识,因为在大多数情况下它们不是必需的。我们主要关注生成器表达式的主要应用,即从生成的目标配置和构建环境的状态中获取信息。欲了解完整参考资料,最好在线阅读官方 CMake 手册(请参阅进一步阅读部分以获取网址)。

一切通过示例解释更为清晰,因此我们直接进入,描述生成器表达式的语法。

学习常规表达式语法的基本规则

要使用生成器表达式,我们需要通过支持生成器表达式评估的命令将其添加到 CMake 列表文件中。大多数特定目标的命令都支持生成器表达式评估,还有许多其他命令(可以查看特定命令的官方文档了解更多)。一个常与生成器表达式一起使用的命令是target_compile_definitions()。要使用生成器表达式,我们需要将其作为命令参数提供,如下所示:

target_compile_definitions(foo PUBLIC BAR=$<TARGET_FILE:baz>) 

该命令将一个-D定义标志添加到编译器的参数中(暂时忽略PUBLIC),该标志设置BAR预处理器定义为foo目标生成的二进制文件路径。之所以有效,是因为生成器表达式以当前的形式存储在一个变量中。展开实际上推迟到生成阶段,此时许多内容已经完全配置并确定。

生成器表达式是如何形成的?

图 6.1:生成器表达式的语法

图 6.1所示,结构看起来相当简单且易于阅读:

  • 以美元符号和括号($<)打开。

  • 添加EXPRESSION名称。

  • 如果表达式需要参数,请添加冒号(:)并提供arg1arg2argN值,以逗号(,)分隔。

  • 使用>结束表达式。

有一些不需要任何参数的表达式,例如$<PLATFORM_ID>

需要注意的是,除非明确指出,否则表达式通常在使用该表达式的目标上下文中进行评估。这一关联可以从使用表达式的命令中推断出来。在前面的例子中,我们看到target_compile_definitions()foo作为它操作的目标。因此,在该命令中使用的特定目标生成器表达式将隐式地使用foo。然而,值得注意的是,例子中使用的生成器表达式$<TARGET_FILE>需要目标属性作为其操作的上下文。还有一些生成器表达式不接受目标作为参数(如$<COMPILE_LANGUAGE>),而是隐式使用封闭命令的目标。稍后会对这些进行更详细的讨论。

当使用生成器表达式的高级特性时,生成器表达式可能会变得非常混乱和复杂,因此在使用之前了解其具体内容非常重要。

嵌套

我们先从将生成器表达式作为参数传递给另一个生成器表达式的能力开始,换句话说,就是生成器表达式的嵌套:

$<UPPER_CASE:$<PLATFORM_ID>> 

这个例子并不复杂,但很容易想象当我们增加嵌套层数并与使用多个参数的命令配合工作时会发生什么。

为了让问题更复杂一些,可能还需要将常规变量展开与生成器表达式一起使用:

$<UPPER_CASE:${my_variable}> 

my_variable 变量会首先在配置阶段展开。随后,生成表达式将在生成阶段展开。这种特性有一些罕见的用法,但我强烈建议避免使用:生成器表达式几乎提供了所有必要的功能。将常规变量混入这些表达式中会增加难以调试的间接性层次。此外,在配置阶段收集的信息通常会过时,因为用户会在构建或安装阶段通过命令行参数覆盖生成器表达式中使用的值。

在讲解语法之后,让我们继续讨论生成器表达式中可用的基本机制。

条件展开

在生成器表达式中,是否应该展开一个表达式是通过布尔逻辑来确定的。尽管这是一个很棒的特性,但由于历史原因,它的语法可能不一致且难以阅读。它有两种形式。第一种形式同时支持顺利路径和错误路径:

$<IF:condition,true_string,false_string> 

IF 表达式依赖于嵌套才能发挥作用:你可以将任何参数替换为另一个表达式,生成相当复杂的求值(甚至可以将一个 IF 条件嵌套在另一个里面)。这种形式需要恰好三个参数,因此我们不能省略任何一个。为了跳过条件不满足时的值,最好的选择是:

$<IF:condition,true_string,> 

有一种简写形式,可以跳过 IF 关键字和逗号:

$<condition:true_string> 

如你所见,它打破了将 EXPRESSION 名称作为第一个标记的惯例。我猜这里的意图是缩短表达式,避免输入那些宝贵的字符,但结果可能很难合理化。这是来自 CMake 文档的一个示例:

$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CXX_COMPILER_ID:AppleClan
  g,Clang>>:COMPILING_CXX_WITH_CLANG> 

这个表达式只有在用 Clang 编译器编译的 C++ 代码中才会返回 COMPILING_CXX_WITH_CLANG(在其他所有情况下返回空字符串)。我希望语法能与常规 IF 命令的条件对齐,但遗憾的是并非如此。现在,如果你在某个地方看到第二种形式,你应该能够识别它,但为了可读性,最好避免在自己的项目中使用。

布尔值求值

生成器表达式会求值为两种类型之一——布尔值或字符串。布尔值由 1(真)和 0(假)表示。没有专门的数值类型;任何不是布尔值的东西都只是字符串。

需要记住的是,作为条件传递的嵌套表达式必须显式地求值为布尔值。

布尔类型可以隐式转换为字符串,但你需要使用显式的 BOOL 运算符(稍后解释)来实现反向转换。

布尔值的表达式有三种类型:逻辑运算符、比较表达式和查询。让我们快速看一下这些类型。

逻辑运算符

有四个逻辑运算符:

  • $<NOT:arg>:这个表达式用于否定布尔参数。

  • $<AND:arg1,arg2,arg3...>:如果所有参数都为真,则返回 true。

  • $<OR:arg1,arg2,arg3...>:如果任意一个参数为真,则返回 true。

  • $<BOOL:string_arg>:此操作将字符串类型的参数转换为布尔类型。

使用$<BOOL>进行字符串转换时,如果未满足以下条件,则会计算为布尔值 true(1):

  • 字符串为空。

  • 字符串是0FALSEOFFNNOIGNORENOTFOUND的大小写不敏感等价物。

  • 字符串以-NOTFOUND后缀结尾(区分大小写)。

比较

比较将根据其条件计算为1(满足条件时)或0(不满足条件时)。以下是一些你可能会觉得有用的常见操作:

  • $<STREQUAL:arg1,arg2>:此操作按大小写敏感方式比较字符串。

  • $<EQUAL:arg1,arg2>:此操作将字符串转换为数字并进行相等比较。

  • $<IN_LIST:arg,list>:此操作检查list列表中是否包含arg元素(区分大小写)。

  • $<VERSION_EQUAL:v1,v2>$<VERSION_LESS:v1,v2>$<VERSION_GREATER:v1,v2>$<VERSION_LESS_EQUAL:v1,v2>$<VERSION_GREATER_EQUAL:v1,v2>按组件逐一比较版本。

  • $<PATH_EQUAL:path1,path2>:此操作比较两个路径的词法表示,而不进行任何规范化(自 CMake 3.24 起)。

查询

查询会直接从一个变量返回布尔值,或作为某个操作的结果返回。最简单的查询之一是:

$<TARGET_EXISTS:arg> 

如你所料,如果目标在配置阶段已定义,则返回 true。

现在,你已经知道如何应用条件展开、使用逻辑运算符、比较以及基本查询来计算布尔值。单单这些就很有用,但生成器表达式能提供更多,特别是在查询的上下文中:它们可以在IF条件展开中使用,或者单独作为命令的参数使用。是时候在适当的上下文中介绍它们了。

查询和转换

有许多生成器表达式可用,但为了避免迷失在细节中,让我们专注于最常用的一些。我们将从对可用数据的一些基本转换开始。

处理字符串、列表和路径

生成器表达式仅提供了最低限度的操作,用于转换和查询数据结构。在生成器阶段处理字符串是可能的,以下是一些常用的表达式:

  • $<LOWER_CASE:string>$<UPPER_CASE:string>:此操作将string转换为所需的大小写。

列表操作直到最近才得到了很大的扩展。从 CMake 3.15 开始,以下操作可用:

  • $<IN_LIST:string,list>:如果list中包含string值,则返回 true。

  • $<JOIN:list,d>:此表达式使用d分隔符连接一个以分号分隔的list

  • $<REMOVE_DUPLICATES:list>:此操作去除list中的重复项(不排序)。

  • $<FILTER:list,INCLUDE|EXCLUDE,regex>:此操作使用regex包含或排除list中的项。

从 3.27 版本开始,添加了$<LIST:OPERATION>生成器表达式,其中OPERATION可以是以下之一:

  • LENGTH

  • GET

  • SUBLIST

  • FIND

  • JOIN

  • APPEND

  • PREPEND

  • INSERT

  • POP_BACK

  • POP_FRONT

  • REMOVE_ITEM

  • REMOVE_AT

  • REMOVE_DUPLICATES

  • FILTER

  • TRANSFORM

  • REVERSE

  • SORT

在生成器表达式中处理列表的情况比较少见,因此我们仅指示可能的情况。如果你遇到这些情况,请查看在线手册,了解如何使用这些操作。

最后,我们可以查询和变换系统路径。这是一个有用的补充,因为它在不同操作系统之间具有可移植性。自 CMake 3.24 起,以下简单查询已经可以使用:

  • $<PATH:HAS_ROOT_NAME,path>

  • $<PATH:HAS_ROOT_DIRECTORY,path>

  • $<PATH:HAS_ROOT_PATH,path>

  • $<PATH:HAS_FILENAME,path>

  • $<PATH:HAS_EXTENSION,path>

  • $<PATH:HAS_STEM,path>

  • $<PATH:HAS_RELATIVE_PART,path>

  • $<PATH:HAS_PARENT_PATH,path>

  • $<PATH:IS_ABSOLUTE,path>

  • $<PATH:IS_RELATIVE,path>

  • $<PATH:IS_PREFIX[,NORMALIZE],prefix,path>:如果前缀是路径的前缀,则返回 true。

类似地,我们可以检索我们能够检查的所有路径组件(自 CMake 3.27 起,可以提供路径列表,而不仅仅是一个路径):

  • $<PATH:GET_ROOT_NAME,path...>

  • $<PATH:GET_ROOT_DIRECTORY,path...>

  • $<PATH:GET_ROOT_PATH,path...>

  • $<PATH:GET_FILENAME,path...>

  • $<PATH:GET_EXTENSION[,LAST_ONLY],path...>

  • $<PATH:GET_STEM[,LAST_ONLY],path...>

  • $<PATH:GET_RELATIVE_PART,path...>

  • $<PATH:GET_PARENT_PATH,path...>

此外,3.24 版本引入了一些变换操作;我们将列出它们以供完整性参考:

  • $<PATH:CMAKE_PATH[,NORMALIZE],path...>

  • $<PATH:APPEND,path...,input,...>

  • $<PATH:REMOVE_FILENAME,path...>

  • $<PATH:REPLACE_FILENAME,path...,input>

  • $<PATH:REMOVE_EXTENSION[,LAST_ONLY],path...>

  • $<PATH:REPLACE_EXTENSION[,LAST_ONLY],path...,input>

  • $<PATH:NORMAL_PATH,path...>

  • $<PATH:RELATIVE_PATH,path...,base_directory>

  • $<PATH:ABSOLUTE_PATH[,NORMALIZE],path...,base_directory>

还有一个路径操作,它将提供的路径格式化为主机的 shell 支持的样式:$<SHELL_PATH:path...>

再次说明,之前介绍的表达式是为了以后参考,并不是现在就需要记住的信息。推荐的实际应用知识详细信息在随后的章节中。

参数化构建配置和平台

CMake 用户在构建项目时提供的关键信息之一是所需的构建配置。在大多数情况下,它将是DebugRelease。我们可以使用生成器表达式通过以下语句访问这些值:

  • $<CONFIG>:此表达式返回当前构建配置的字符串:DebugRelease或其他。

  • $<CONFIG:configs>:如果configs包含当前构建配置(不区分大小写比较),则返回 true。

我们在第四章设置你的第一个 CMake 项目中的理解构建环境部分讨论了平台。我们可以像读取配置一样阅读相关信息:

  • $<PLATFORM_ID>:这将返回当前平台 ID 的字符串形式:LinuxWindowsDarwin(针对 macOS)。

  • $<PLATFORM_ID:platform> 如果platform包含当前平台 ID,则为真。

这种特定于配置或平台的参数化是我们工具箱中的强大补充。我们可以将其与之前讨论的条件展开一起使用:

$<IF:condition,true_string,false_string> 

例如,我们在构建测试二进制文件时可能应用一个编译标志,而在生产环境中应用另一个:

target_compile_definitions(my_target PRIVATE
                           $<IF:$<CONFIG:Debug>,Test,Production>
) 

但这只是开始。还有许多其他情况可以通过生成器表达式来处理。当然,下一个重要的方面是系统中存在的工具。

工具链调优

工具链、工具包,或者说编译器和链接器,幸运的是(或不幸的是?)在不同供应商之间并不一致。这带来了各种后果。它们中有些是好的(在特殊情况下性能更好),而有些则不那么理想(配置风格多样,标志命名不一致等)。

生成器表达式在这里通过提供一系列查询来帮助缓解问题,并在可能的情况下改善用户体验。

与构建配置和平台一样,有多个表达式返回关于工具链的信息,无论是字符串还是布尔值。然而,我们需要指定我们感兴趣的语言(将#LNG替换为CCXXCUDAOBJCOBJCXXFortranHIPISPC之一)。对HIP的支持在 3.21 版本中添加。

  • $<#LNG_COMPILER_ID>:这将返回所使用的#LNG编译器的 CMake 编译器 ID。

  • $<#LNG_COMPILER_VERSION>:这将返回所使用的#LNG编译器的 CMake 编译器版本。

要检查 C++将使用哪个编译器,我们应该使用$<CXX_COMPILER_ID>生成器表达式。返回的值,即 CMake 的编译器 ID,是为每个支持的编译器定义的常量。你可能会遇到诸如AppleClangARMCCClangGNUIntelMSVC等值。完整列表请参考官方文档(进一步阅读部分中的 URL)。

类似于上一节,我们还可以在条件表达式中利用工具链信息。有多个查询可以返回true,如果任何提供的参数与特定值匹配:

  • $<#LNG_COMPILER_ID:ids>:如果ids包含 CMake 的#LNG编译器 ID,则返回 true。

  • $<#LNG_COMPILER_VERSION:vers>:如果vers包含 CMake 的#LNG编译器版本,则返回 true。

  • $<COMPILE_FEATURES:features>:如果features中列出的所有特性都被此目标的编译器支持,则返回 true。

在需要目标参数的命令中,如target_compile_definitions(),我们可以使用其中一种特定于目标的表达式来获取字符串值:

  • $<COMPILE_LANGUAGE>:返回编译步骤中源文件的语言。

  • $<LINK_LANGUAGE>:返回链接步骤中源文件的语言。

评估一个简单的布尔查询:

  • $<COMPILE_LANGUAGE:langs>:如果langs包含用于编译该目标的语言,则返回 true。可以使用此表达式为编译器提供特定语言的标志。例如,为了使用-fno-exceptions标志编译目标的 C++源文件:

    target_compile_options(myapp
      PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions>
    ) 
    
  • $<LINK_LANGUAGE:langs> – 它遵循与COMPILE_LANGUAGE相同的规则,如果langs包含用于该目标链接的语言,则返回 true。

或者,查询更复杂的场景:

  • $<COMPILE_LANG_AND_ID:lang,compiler_ids...>:如果lang语言用于此目标,并且compiler_ids列表中的某个编译器将用于此编译,则返回 true。这个表达式对于为特定编译器指定编译定义非常有用:

    target_compile_definitions(myapp PRIVATE
      $<$<COMPILE_LANG_AND_ID:CXX,AppleClang,Clang>:CXX_CLANG>
      $<$<COMPILE_LANG_AND_ID:CXX,Intel>:CXX_INTEL>
      $<$<COMPILE_LANG_AND_ID:C,Clang>:C_CLANG>
    ) 
    
  • 在这个示例中,对于使用AppleClangClang编译的 C++源文件(CXX),将设置-DCXX_CLANG定义。对于使用Intel编译器编译的 C++源文件,将设置-DCXX_INTEL定义标志。最后,对于使用Clang编译器编译的 C 源文件(C),将设置-DC_CLANG定义。

  • $<LINK_LANG_AND_ID:lang,compiler_ids...>:它的作用类似于COMPILE_LANG_AND_ID,但检查的是链接步骤中使用的语言。使用此表达式可以指定特定语言和链接器组合的链接库、链接选项、链接目录和链接依赖项。

这里需要注意的是,一个单独的目标可以由多种语言的源文件组成。例如,可以将 C 语言的产物与 C++链接(但我们应该在project()命令中声明这两种语言)。因此,引用特定语言的生成器表达式将用于某些源文件,但不会用于其他源文件。

让我们继续讨论下一个重要类别:与目标相关的生成器表达式。

查询与目标相关的信息

有许多生成器表达式可以查询目标属性并检查与目标相关的信息。请注意,直到 CMake 3.19,许多引用另一个目标的目标表达式会自动在它们之间创建依赖关系。但在 CMake 的最新版本中,这种情况不再发生。

一些生成器表达式会从被调用的命令中推断目标;最常用的是返回目标属性值的基本查询:

$<TARGET_PROPERTY:prop> 
  • 一个较少为人知,但在target_link_libraries()命令中非常有用的生成器表达式是$<LINK_ONLY:deps>。它允许我们存储PRIVATE链接依赖项,这些依赖项不会通过传递的使用要求传播;这些依赖项用于接口库,我们在第五章与目标的工作中的理解传递使用要求部分已经讨论过。

还有一组与安装和导出相关的表达式,它们通过上下文推断出目标。我们将在第十四章安装与打包中深入讨论这些表达式,因此现在我们只做一个简短的介绍:

  • $<INSTALL_PREFIX>:当目标通过install(EXPORT)导出,或在INSTALL_NAME_DIR中评估时,这返回安装前缀;否则,它为空。

  • $<INSTALL_INTERFACE:string>:当表达式与install(EXPORT)一起导出时,这返回string

  • $<BUILD_INTERFACE:string>:当表达式通过export()命令或由同一构建系统中的另一个目标导出时,这返回string

  • $<BUILD_LOCAL_INTERFACE:string>:当表达式被同一构建系统中另一个目标导出时,这返回string

然而,大多数查询要求明确提供目标名称作为第一个参数:

  • $<TARGET_EXISTS:target>:如果目标存在,这返回true

  • $<TARGET_NAME_IF_EXISTS:target>:如果目标存在,它返回target名称,否则返回空字符串。

  • $<TARGET_PROPERTY:target,prop>:这返回目标的prop属性值。

  • $<TARGET_OBJECTS:target>:这返回目标库目标文件列表。

你可以查询目标构件的路径:

  • $<TARGET_FILE:target>:这返回完整路径。

  • $<TARGET_FILE_NAME:target>:这只返回文件名。

  • $<TARGET_FILE_BASE_NAME:target>:这返回基本名称。

  • $<TARGET_FILE_NAME:target>:这返回没有前缀或后缀的基本名称(例如,对于libmylib.so,基本名称为mylib)。

  • $<TARGET_FILE_PREFIX:target>:这只返回前缀(例如,lib)。

  • $<TARGET_FILE_SUFFIX:target>:这只返回后缀(例如,.so.exe)。

  • $<TARGET_FILE_DIR:target>:这返回目录。

有一些表达式族提供与常规TARGET_FILE表达式类似的功能(每个表达式还接受_NAME_BASE_NAME_DIR后缀):

  • TARGET_LINKER_FILE:这查询用于链接到目标的文件路径。通常,它是目标生成的库(.a.lib.so)。但是,在使用动态链接库DLLs)的平台上,它将是与目标的 DLL 关联的 .lib 导入库。

  • TARGET_PDB_FILE:这查询链接器生成的程序数据库文件(.pdb)的路径。

管理库是一个复杂的话题,CMake 提供了许多生成器表达式来帮助解决。我们将在第八章链接可执行文件和库中引入它们,直到它们变得相关。

最后,还有一些特定于 Apple 包的表达式:

  • $<TARGET_BUNDLE_DIR:target>:这是目标的捆绑目录(my.appmy.frameworkmy.bundle)的完整路径。

  • $<TARGET_BUNDLE_CONTENT_DIR:target>:这是目标的完整路径,指向目标的捆绑内容目录。在 macOS 上,它是my.app/Contentsmy.frameworkmy.bundle/Contents。其他软件开发工具包SDKs)(例如 iOS)具有平坦的捆绑结构——my.appmy.frameworkmy.bundle

这些是处理目标的主要生成器表达式。值得知道的是,还有很多其他的表达式。我建议参考官方文档以获取完整列表。

转义

在少数情况下,你可能需要将一个具有特殊含义的字符传递给生成器表达式。为了转义这种行为,可以使用以下表达式:

  • $<ANGLE-R>:这是一个字面量的>符号

  • $<COMMA>:这是一个字面量的,符号

  • $<SEMICOLON>:这是一个字面量的;符号

最后的表达式可以在使用包含;的参数时防止列表扩展。

现在我们已经介绍了所有查询和转换,我们可以看看它们在实践中的应用。让我们通过一些示例来了解如何使用它们。

试验例子

当有一个好的实践例子来支持理论时,一切都会更容易理解。显然,我们希望编写一些 CMake 代码并试一试。然而,由于生成器表达式直到配置完成后才会被求值,因此我们不能使用像message()这样的配置时命令来进行实验。我们需要使用一些特殊的技巧来进行调试。要调试生成器表达式,你可以使用以下方法之一:

  • 将其写入文件(这个版本的file()命令支持生成器表达式):file(GENERATE OUTPUT filename CONTENT "$<...>")

  • 从命令行显式添加一个自定义目标并构建它:add_custom_target(gendbg COMMAND ${CMAKE_COMMAND} -E echo "$<...>")

我推荐第一种选项,便于简单练习。不过记住,我们无法在这些命令中使用所有表达式,因为有些表达式是针对特定目标的。介绍完这些之后,我们来看一些生成器表达式的应用实例。

构建配置

第一章使用 CMake 的第一步 中,我们讨论了构建类型,指定我们正在构建的配置——DebugRelease,等等。可能会有这种情况,你希望根据正在进行的构建类型采取不同的操作。一个简单易行的方法是使用$<CONFIG>生成器表达式:

target_compile_options(tgt $<$<CONFIG:DEBUG>:-ginline-points>) 

上面的例子检查配置是否等于DEBUG;如果是这种情况,嵌套的表达式将被求值为1。外部简写的if表达式将变为true,我们的-ginline-points调试标志被添加到选项中。了解这种形式很重要,这样你就能理解其他项目中的类似表达式,但我建议使用更为详细的$<IF:...>,以提高可读性。

系统特定的一行命令

生成器表达式还可以用来将冗长的if命令压缩成简洁的一行代码。假设我们有以下代码:

if (${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
     target_compile_definitions(myProject PRIVATE LINUX=1)
endif() 

它告诉编译器,如果这是目标系统,就将-DLINUX=1添加到参数中。虽然这并不算太长,但可以用一个相当简单的表达式替代:

target_compile_definitions(myProject PRIVATE
                           $<$<CMAKE_SYSTEM_NAME:LINUX>:LINUX=1>) 

这样的代码运行良好,但你能放入生成器表达式中的内容是有限的,一旦超过了这个限度,就会变得难以阅读。此外,许多 CMake 用户推迟学习生成器表达式,导致他们难以跟上发生的事情。幸运的是,完成本章后,我们将不再遇到这些问题。

带有特定编译器标志的接口库

如我们在第五章《与目标一起工作》中讨论的那样,接口库可以用来提供与编译器匹配的标志:

add_library(enable_rtti INTERFACE)
target_compile_options(enable_rtti INTERFACE
  $<$<OR:$<COMPILER_ID:GNU>,$<COMPILER_ID:Clang>>:-rtti>
) 

即使在这样一个简单的例子中,我们也能看到当我们嵌套太多生成器表达式时,表达式变得多么难以理解。不幸的是,有时这是实现所需效果的唯一方法。以下是该例子的解释:

  • 我们检查COMPILER_ID是否为GNU;如果是这样,我们将OR的值评估为1

  • 如果不是,我们检查COMPILER_ID是否为Clang,并将OR评估为1。否则,将OR评估为0

  • 如果OR的值被评估为1,则将-rtti添加到enable_rtti 编译选项中。否则,什么也不做。

接下来,我们可以将我们的库和可执行文件与enable_rtti接口库进行链接。如果编译器支持,它会添加-rtti标志。顺便提一下,RTTI代表运行时类型信息,在 C++中使用typeid等关键字来确定对象的类;除非你的代码使用了这个功能,否则不需要启用该标志。

嵌套的生成器表达式

有时,当我们尝试在生成器表达式中嵌套元素时,结果并不明显。我们可以通过生成测试输出到调试文件来调试表达式。

让我们尝试一些东西,看看会发生什么:

ch06/01-nesting/CMakeLists.txt

set(myvar "small text")
set(myvar2 "small text >")
file(GENERATE OUTPUT nesting CONTENT "
  1 $<PLATFORM_ID>
  2 $<UPPER_CASE:$<PLATFORM_ID>>
  3 $<UPPER_CASE:hello world>
  4 $<UPPER_CASE:${myvar}>
  5 $<UPPER_CASE:${myvar2}>
") 

按照本章技术要求部分的描述构建此项目后,我们可以使用 Unix cat命令读取生成的nesting文件:

# cat nesting
  1 Linux
  2 LINUX
  3 HELLO WORLD
  4 SMALL TEXT
  5 SMALL  text> 

下面是每行代码的工作原理:

  1. PLATFORM_ID的输出值是LINUX

  2. 嵌套值的输出将正确转换为大写LINUX

  3. 我们可以转换普通字符串。

  4. 我们可以转换配置阶段变量的内容。

  5. 变量会首先进行插值,并且闭合的尖括号(>)会被解释为生成器表达式的一部分,因此只有部分字符串会被转换为大写。

换句话说,要意识到变量的内容可能会影响生成器表达式扩展的行为。如果需要在变量中使用尖括号,请使用$<ANGLE-R>

条件表达式与 BOOL 操作符评估之间的区别

在评估布尔类型为字符串时,生成器表达式可能有些令人困惑。理解它们与常规条件表达式的不同之处是很重要的,从显式的IF关键字开始:

ch06/02-boolean/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Boolean CXX)
file(GENERATE OUTPUT boolean CONTENT "
  1 $<0:TRUE>
  2 $<0:TRUE,FALSE> (won't work)
  3 $<1:TRUE,FALSE>
  4 $<IF:0,TRUE,FALSE>
  5 $<IF:0,TRUE,>
") 

让我们使用 Linux 的 cat 命令查看生成的文件:

# cat boolean
  1
  2  (won't work)
  3 TRUE,FALSE
  4 FALSE
  5 

让我们检查每一行的输出:

  1. 这是一个布尔扩展,其中 BOOL0;因此,TRUE 字符串没有被写出。

  2. 这是一个典型的错误 – 作者打算根据 BOOL 值打印 TRUEFALSE,但由于它也是布尔 false 扩展,两个参数被视为一个并未打印出来。

  3. 这是一个反向值的相同错误 – 它是一个布尔 true 扩展,两个参数写在同一行。

  4. 这是一个正确的条件表达式,以 IF 开头 – 它打印 FALSE,因为第一个参数为 0

  5. 这是条件表达式的正确用法,但当我们不需要为布尔 false 提供值时,我们应使用第一行中使用的形式。

生成器表达式因其复杂的语法而闻名。本示例中提到的差异甚至会让经验丰富的构建者感到困惑。如果有疑问,可以将此类表达式复制到另一个文件,并通过添加缩进和空白来进行分析,以便更好地理解。

通过看到生成器表达式的工作示例,我们已经为实际使用它们做好了准备。接下来的章节将讨论许多与生成器表达式相关的主题。随着时间的推移,我们将涵盖更多它们的应用。

摘要

本章专门讲解了生成器表达式,或称“genexes”的细节。我们从生成和扩展生成器表达式的基础开始,探索了它们的嵌套机制。我们深入探讨了条件扩展的强大功能,它涉及布尔逻辑、比较操作和查询。生成器表达式在根据用户选择的构建配置、平台和当前工具链等因素调整构建过程时,展现出其强大的优势。

我们还涵盖了字符串、列表和路径的基本但至关重要的转换。一个亮点是使用生成器表达式查询在后期构建阶段收集的信息,并在上下文匹配要求时显示这些信息。我们现在也知道如何检查编译器的 ID、版本和功能。我们还探讨了如何查询构建目标属性并使用生成器表达式提取相关信息。本章以实际示例和在可能的情况下查看输出的指南结束。至此,你已经准备好在项目中使用生成器表达式了。

在下一章中,我们将学习如何使用 CMake 编译程序。具体来说,我们将讨论如何配置和优化这一过程。

进一步阅读

有关本章所涵盖主题的更多信息,您可以参考以下内容:

留下评论!

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

第七章:使用 CMake 编译 C++ 源码

简单的编译场景通常由工具链的默认配置或者集成开发环境(IDE)提供。然而,在专业环境中,业务需求经常需要更高级的功能。可能需要更高的性能、更小的二进制文件、更强的可移植性、自动化测试或者更多的调试能力 – 不胜枚举。在一个一致、未来可靠的方式中管理所有这些很快就变成了一个复杂、纠结的问题(尤其是在需要支持多个平台时)。

编译的过程通常在 C++ 的书籍中解释得不够详细(像虚拟基类这样的深入主题似乎更有趣)。在本章中,我们将通过讨论编译的不同方面来解决这个问题:我们将了解编译的工作原理、它的内部阶段以及它们如何影响二进制输出。

之后,我们将专注于先决条件 – 我们将讨论可以用于微调编译过程的命令,如何从编译器要求特定功能,以及如何正确地告知编译器处理哪些输入文件。

然后,我们将专注于编译的第一阶段 – 预处理器。我们将提供包含头文件的路径,并学习如何通过预处理器定义从 CMake 和构建环境中插入变量。我们将涵盖最有趣的用例,并学习如何公开 CMake 变量以便从 C++ 代码中访问。

在此之后,我们将讨论优化器及其如何通过不同的标志影响性能。我们还将讨论优化的成本,特别是它如何影响生成的二进制文件的调试能力,以及如果不需要这些影响时应该怎么做。

最后,我们将解释如何通过使用预编译头文件和统一构建来管理编译过程,以减少编译时间。我们将学习如何调试构建过程并找出可能存在的任何错误。

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

  • 编译的基础知识

  • 预处理器的配置

  • 配置优化器

  • 管理编译过程

技术要求

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

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

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

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

编译的基础知识

编译可以大致描述为将用高级编程语言编写的指令转换为低级机器码的过程。这使我们能够使用诸如类和对象等抽象概念来创建应用程序,而不必费力处理处理器特定的汇编语言。我们不需要直接操作 CPU 寄存器,考虑短跳或长跳,或管理堆栈帧。编译型语言更具表现力、可读性和安全性,并鼓励编写可维护的代码,同时尽可能提供最佳性能。

在 C++中,我们使用静态编译——这意味着整个程序必须在执行之前先被翻译成本地代码。这与像 Java 或 Python 这样的语言不同,后者每次用户运行程序时都会即时解释和编译程序。每种方法都有其独特的优点。C++旨在提供多种高级工具,同时提供本地性能。C++编译器可以为几乎所有架构生成一个自包含的应用程序。

创建并运行 C++程序涉及多个步骤:

  1. 设计你的应用程序:这包括规划应用程序的功能、结构和行为。一旦设计完成,按照代码可读性和可维护性的最佳实践,仔细编写源代码。

  2. 编译单个.cpp 实现文件,也称为翻译单元,成目标文件:这一步涉及将您编写的高级语言代码转换为低级机器码。

  3. 将链接 目标文件合并成单个可执行文件:在此步骤中,所有其他依赖项,包括动态库和静态库,也会被链接。这一过程创建了一个可以在预定平台上运行的可执行文件。

要运行程序,操作系统OS)将使用一种名为加载器的工具,将程序的机器码和所有所需的动态库映射到虚拟内存中。加载器随后读取程序头部,以确定执行应从哪里开始,并开始运行指令。

在这个阶段,程序的启动代码开始发挥作用。系统 C 库提供的一个特殊函数_start被调用。_start函数收集命令行参数和环境变量,启动线程,初始化静态符号,并注册清理回调函数。只有在此之后,它才会调用main(),这是程序员填入自己代码的函数。

如你所见,在幕后发生了大量工作。本章重点讲解早期列表中的第二步。通过考虑整体情况,我们可以更好地理解潜在问题可能来自哪里。尽管软件开发中的复杂性看起来似乎无法逾越,但开发中并不存在“魔法”。一切都有解释和原因。我们需要理解,由于我们如何编译程序,程序在运行时可能会出现问题,即使编译步骤本身看似成功。编译器不可能在其操作过程中检查所有边界情况。因此,让我们深入了解当编译器执行其工作时,实际发生了什么。

编译如何工作

如前所述,编译是将高级语言翻译成低级语言的过程。具体来说,这涉及生成机器代码,这些机器代码是特定处理器可以直接执行的指令,格式为平台独有的二进制目标文件。在 Linux 上,最常用的格式是可执行与可链接格式ELF)。Windows 使用 PE/COFF 格式规范,而在 macOS 上,我们会遇到 Mach 对象(Mach-O 格式)。

目标文件是单个源文件的直接翻译。每个文件必须单独编译,然后由链接器将其合并成一个可执行文件或库。这个模块化过程在修改代码时可以显著节省时间,因为只有程序员更新的文件需要重新编译。

编译器必须执行以下阶段才能创建目标文件

  • 预处理

  • 语言分析

  • 汇编

  • 优化

  • 代码生成

让我们更详细地解释一下它们。

预处理,虽然大多数编译器自动调用,但被视为实际编译之前的准备步骤。它的作用是对源代码进行基本的操作;执行#include指令、通过#define指令和-D标志替换标识符为已定义的值、调用简单的宏,并根据#if#elif#endif指令有条件地包含或排除部分代码。预处理器对实际的 C++代码毫不知情。从本质上讲,它充当一个高级的查找和替换工具。

然而,预处理器在构建高级程序中的作用至关重要。将代码分割成多个部分并在多个翻译单元之间共享声明的能力是代码可重用性的基础。

接下来是语言分析,在这一阶段,编译器进行更复杂的操作。它逐字符扫描预处理后的文件(现在已包含由预处理器插入的所有头文件)。通过一种称为词法分析的过程,它将字符分组为有意义的记号——这些记号可能是关键字、运算符、变量名等。

然后,令牌会被组装成链并进行检查,以验证它们的顺序和存在是否符合 C++的语法规则——这一过程称为语法分析或解析。通常,这是生成大多数错误信息的阶段,因为它识别了语法问题。

最后,编译器进行语义分析。在这个阶段,编译器检查文件中的语句是否在逻辑上是合理的。例如,它确保所有类型正确性检查都已满足(你不能将整数赋值给字符串变量)。这一分析确保程序在编程语言的规则范围内是合乎逻辑的。

汇编阶段本质上是将这些令牌翻译成基于平台可用指令集的 CPU 特定指令。有些编译器实际上生成汇出文件,然后传递给专门的汇编程序。该程序生成 CPU 可以执行的机器代码。其他编译器直接在内存中生成机器代码。通常,这些编译器还提供生成可供人类阅读的汇编代码的选项。然而,尽管这些代码是可以阅读的,但并不意味着它们容易理解或值得这么做。

优化并不仅仅局限于编译过程中的某一个步骤,而是在每个阶段逐步进行的。然而,在初步汇编生成后,有一个独立的阶段,专注于最小化寄存器使用并消除冗余代码。

一个有趣且值得注意的优化技术是内联展开或内联。在这个过程中,编译器有效地将函数体“剪切”并将其“粘贴”到函数调用的位置。C++标准并没有明确定义何时进行这种操作——它是依赖于实现的。内联展开可以提高执行速度并减少内存使用,但它也会对调试产生重大影响,因为执行的代码不再与源代码中的原始行对应。

代码生成阶段涉及将优化后的机器代码写入一个与目标平台规范对齐的目标文件中。然而,这个目标文件尚未准备好执行——它需要传递给链中的下一个工具:链接器。链接器的工作是适当地重新定位我们的目标文件的各个部分,并解决对外部符号的引用,有效地为文件的执行做准备。此步骤标志着美国信息交换标准代码ASCII)源代码转化为二进制可执行文件,这些文件可以直接由 CPU 处理。

这些阶段每个都非常重要,并且可以配置以满足我们的特定需求。让我们看看如何使用 CMake 来管理这个过程。

初始配置

CMake 提供了多个命令,可以影响编译过程中的每个阶段。

  • target_compile_features(): 这需要一个具有特定功能的编译器来编译此目标。

  • target_sources(): 该命令将源文件添加到已定义的目标中。

  • target_include_directories(): 该命令设置预处理器 包含路径

  • target_compile_definitions(): 该命令设置预处理器定义。

  • target_compile_options(): 该命令设置编译器特定的命令行选项。

  • target_precompile_headers(): 该命令设置外部头文件以便进行预编译优化。

每个命令接受类似格式的参数:

target_...(<target name> <INTERFACE|PUBLIC|PRIVATE> <arguments>) 

这意味着使用该命令设置的属性通过传递的使用要求传播,如 第五章与目标一起工作 中的 什么是传递使用要求? 部分所讨论的,可以用于可执行文件和库。另外,值得注意的是,所有这些命令都支持生成器表达式。

需要从编译器中获取特定的功能

第四章设置你的第一个 CMake 项目 中的 检查支持的编译器功能 部分所述,预见问题并确保在出现错误时给用户清晰的信息至关重要——例如,当一个可用的编译器 X 不提供所需的功能 Y 时。这种方法比让用户解读不兼容工具链所产生的错误更为友好。我们不希望用户将不兼容问题归咎于我们的代码,而是他们过时的环境。

你可以使用以下命令来指定目标构建所需的所有功能:

target_compile_features(<target> <PRIVATE|PUBLIC|INTERFACE>
                        <feature> [...]) 

CMake 支持以下 compiler_ids 的 C++ 标准和编译器功能:

  • AppleClang: 用于 Xcode 版本 4.4+ 的 Apple Clang

  • Clang: Clang 编译器版本 2.9+

  • GNU: GNU 编译器版本 4.4+

  • MSVC: Microsoft Visual Studio 版本 2010+

  • SunPro: Oracle Solaris Studio 版本 12.4+

  • Intel: Intel 编译器版本 12.1+

CMake 支持超过 60 个功能,你可以在官方文档中找到完整列表,详见解释 CMAKE_CXX_KNOWN_FEATURES 变量的页面。不过,除非你在寻找某个非常具体的功能,否则我建议选择一个表示一般 C++ 标准的高级元功能:

  • cxx_std_14

  • cxx_std_17

  • cxx_std_20

  • cxx_std_23

  • cxx_std_26

查看以下示例:

target_compile_features(my_target PUBLIC cxx_std_26) 

这基本上等同于在 第四章设置你的第一个 CMake 项目 中引入的 set(CMAKE_CXX_STANDARD 26)set(CMAKE_CXX_STANDARD_REQUIRED ON)。然而,区别在于 target_compile_features() 是按目标处理的,而不是为整个项目全局处理,这在你需要为项目中的所有目标添加时可能会显得麻烦。

在官方手册中查看 CMake 的 支持的编译器 的更多详细信息(请参见 进一步阅读 部分获取网址)。

管理目标的源文件

我们已经知道如何告诉 CMake 哪些源文件构成一个目标,无论它是可执行文件还是库。我们通过在使用add_executable()add_library()命令时提供一个文件列表来做到这一点。

随着您的解决方案扩展,每个目标的文件列表也在增长。这可能会导致一些相当冗长的add_...()命令。我们该如何处理呢?一种诱人的方法可能是使用file()命令的GLOB模式,这样可以从子目录中收集所有文件并将它们存储在一个变量中。我们可以将其作为参数传递给目标声明,再也不需要关心文件列表了:

file(GLOB helloworld_SRC "*.h" "*.cpp")
add_executable(helloworld ${helloworld_SRC}) 

然而,这种方法并不推荐。让我们理解一下为什么。CMake 根据列表文件中的更改生成构建系统。所以,如果没有检测到任何更改,您的构建可能会在没有任何警告的情况下失败(这是开发者的噩梦)。此外,省略目标声明中的所有源代码可能会破坏像 CLion 这样的 IDE 中的代码检查,因为它知道如何解析某些 CMake 命令来理解您的项目。

在目标声明中使用变量是不建议的,原因是:它会创建一个间接层,导致开发者在阅读项目时必须解包目标定义。为了遵循这个建议,我们又面临另一个问题:如何有条件地添加源文件?这在处理特定平台的实现文件时是一个常见场景,例如gui_linux.cppgui_windows.cpp

target_sources()命令允许我们将源文件附加到之前创建的目标:

ch07/01-sources/CMakeLists.txt

add_executable(main main.cpp)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
  target_sources(main PRIVATE gui_linux.cpp)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
  target_sources(main PRIVATE gui_windows.cpp)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
  target_sources(main PRIVATE gui_macos.cpp)
else()
  message(FATAL_ERROR "CMAKE_SYSTEM_NAME=${CMAKE_SYSTEM_NAME} not supported.")
endif() 

这样,每个平台都会得到一组兼容的文件。这很好,但如果源文件列表很长怎么办?嗯,我们只能接受某些事情尚不完美,并继续手动添加它们。如果您正在与一个非常长的列表作斗争,那么您很可能在项目结构上做错了什么:也许可以考虑将源文件划分为库。

现在我们已经涵盖了编译的基本知识,让我们深入了解第一步——预处理。像所有计算机科学的事物一样,细节决定成败。

配置预处理器

预处理器在构建过程中扮演着巨大的角色。也许这有点令人惊讶,因为它的功能看起来相当直接和有限。在接下来的章节中,我们将介绍如何提供包含文件的路径和使用预处理器定义。我们还将解释如何使用 CMake 配置包含的头文件。

提供包含文件的路径

预处理器的最基本功能是能够使用#include指令包含.h.hpp头文件,这有两种形式:

  • 尖括号形式:#include <path-spec>

  • 引号形式:#include "path-spec"

如我们所知,预处理器将把这些指令替换为 path-spec 中指定文件的内容。查找这些文件可能会很有挑战性。应该搜索哪些目录,以及按什么顺序搜索?不幸的是,C++ 标准并未明确规定这一点。我们必须查看所使用编译器的手册。

通常,尖括号形式将检查标准的 包含目录,这些目录包括系统中存储标准 C++ 库和标准 C 库头文件的目录。

引号形式首先会在当前文件的目录中搜索被包含的文件,然后再检查尖括号形式的目录。

CMake 提供了一条命令来操作搜索包含文件的路径:

target_include_directories(<target> [SYSTEM] [AFTER|BEFORE]
                           <INTERFACE|PUBLIC|PRIVATE> [item1...]
                          [<INTERFACE|PUBLIC|PRIVATE> [item2...]
...]) 

这使我们能够添加希望编译器扫描的自定义路径。CMake 将在生成的构建系统中将它们添加到编译器调用中,并为特定编译器提供适当的标志(通常是 -I)。

target_include_directories() 命令通过在目标的 INCLUDE_DIRECTORIES 属性中附加或预附加目录来修改它,具体取决于是否使用 AFTERBEFORE 关键字。然而,是否在默认目录之前或之后检查这些目录,仍然由编译器决定(通常是在之前)。

SYSTEM 关键字表示编译器应将给定的目录视为标准系统目录(用于尖括号形式)。对于许多编译器,这些目录是通过 -isystem 标志传递的。

预处理器定义

回想一下之前讨论的编译阶段中的预处理器 #define#if#elif 以及 #endif 指令。让我们看一下以下示例:

ch07/02-definitions/definitions.cpp

#include <iostream>
int main() {
#if defined(ABC)
    std::cout << "ABC is defined!" << std::endl;
#endif
#if (DEF > 2*4-3)
    std::cout << "DEF is greater than 5!" << std::endl;
#endif
} 

如此一来,这个例子没有任何效果,因为 ABCDEF 都没有被定义(在这个例子中,DEF 会默认为 0)。我们可以通过在代码的顶部添加两行来轻松改变这一点:

#define ABC
#define DEF 8 

编译并执行此代码后,我们可以在控制台中看到两条消息:

ABC is defined!
DEF is greater than 5! 

这看起来似乎足够简单,但如果我们想根据外部因素(如操作系统、架构或其他因素)来条件化这些部分怎么办?好消息是,你可以将值从 CMake 传递给 C++ 编译器,而且这并不复杂。

target_compile_definitions() 命令就足够了:

ch07/02-definitions/CMakeLists.txt

set(VAR 8)
add_executable(defined definitions.cpp)
target_compile_definitions(defined PRIVATE ABC "DEF=${VAR}") 

前面的代码将与两个 #define 语句的行为完全相同,但我们有灵活性使用 CMake 的变量和生成器表达式,并且可以将命令放入条件块中。

传统上,这些定义通过 -D 标志传递给编译器(例如,-DFOO=1),有些程序员仍然在这个命令中继续使用这个标志:

target_compile_definitions(hello PRIVATE -DFOO) 

CMake 能识别这一点,并会自动移除任何前导的 -D 标志。它还会忽略空字符串,因此以下命令是完全有效的:

target_compile_definitions(hello PRIVATE -D FOO) 

在这种情况下,-D 是一个独立的参数,移除后会变成空字符串,并随后被忽略,从而确保行为正确。

避免在单元测试中访问私有类字段

一些在线资源建议结合使用特定的 -D 定义与 #ifdef/ifndef 指令,用于单元测试。此方法最直接的应用是将 public 访问控制符包含在条件包含中,当 UNIT_TEST 被定义时,使所有字段都变为公共(默认情况下,类字段是私有的):

class X {
#ifdef UNIT_TEST
  public:
#endif
  int x_;
} 

尽管这种技术提供了便利(允许测试直接访问私有成员),但它并不会产生干净的代码。理想情况下,单元测试应该专注于验证公共接口内方法的功能,将底层实现视为黑盒。因此,我建议仅在不得已时使用这种方法。

使用 Git 提交跟踪已编译版本

让我们思考一些可以从了解环境或文件系统细节中受益的用例。一个典型的例子可能是在专业环境中,传递用于构建二进制文件的修订或提交 SHA。可以通过以下方式实现:

ch07/03-git/CMakeLists.txt

add_executable(print_commit print_commit.cpp)
execute_process(COMMAND git log -1 --pretty=format:%h
                OUTPUT_VARIABLE SHA)
target_compile_definitions(print_commit
                           PRIVATE "SHA=${SHA}") 

然后,SHA 可以在我们的应用中按如下方式使用:

ch07/03-git/print_commit.cpp

#include <iostream>
// special macros to convert definitions into c-strings:
#define str(s) #s
#define xstr(s) str(s)
int main()
{
#if defined(SHA)
    std::cout << "GIT commit: " << xstr(SHA) << std::endl;
#endif
} 

当然,前面的代码要求用户安装并在其 PATH 中能够访问 Git。这个功能在生产服务器上运行的程序是通过持续集成/部署流水线构建的情况下特别有用。如果我们的软件出现问题,可以迅速检查到底是哪个 Git 提交被用来构建有问题的产品。

跟踪确切的提交对于调试非常有帮助。将单个变量传递给 C++ 代码非常简单,但当需要将几十个变量传递给头文件时,我们该如何处理呢?

配置头文件

通过 target_compile_definitions() 传递定义可能会变得繁琐,尤其是当变量众多时。难道提供一个带有占位符的头文件,引用这些变量,并让 CMake 来填充它们,不更简单吗?绝对可以!

CMake 的 configure_file(<input> <output>) 命令允许你从模板生成新文件,示例如下:

ch07/04-configure/configure.h.in

#cmakedefine FOO_ENABLE
#cmakedefine FOO_STRING1 "@FOO_STRING1@"
#cmakedefine FOO_STRING2 "${FOO_STRING2}"
#cmakedefine FOO_UNDEFINED "@FOO_UNDEFINED@" 

你可以按如下方式使用此命令:

ch07/04-configure/CMakeLists.txt

add_executable(configure configure.cpp)
set(FOO_ENABLE ON)
set(FOO_STRING1 "abc")
set(FOO_STRING2 "def")
configure_file(configure.h.in configured/configure.h)
target_include_directories(configure PRIVATE
                           ${CMAKE_CURRENT_BINARY_DIR}) 

CMake 然后会生成一个类似以下的输出文件:

ch07/04-configure/<build_tree>/configured/configure.h

#define FOO_ENABLE
#define FOO_STRING1 "abc"
#define FOO_STRING2 "def"
/* #undef FOO_UNDEFINED */ 

如你所见,@VAR@${VAR} 变量占位符已被 CMake 列表文件中的值替换。此外,#cmakedefine 被已定义变量的 #define 和未定义变量的 /* #undef VAR */ 所取代。如果你需要显式的 #define 1#define 0 用于 #if 块,请改用 #cmakedefine01

你可以通过简单地在实现文件中包含这个配置好的头文件,将其集成到你的应用程序中:

ch07/04-configure/configure.cpp

#include <iostream>
#include "configured/configure.h"
// special macros to convert definitions into c-strings:
#define str(s) #s
#define xstr(s) str(s)
using namespace std;
int main()
{
#ifdef FOO_ENABLE
  cout << "FOO_ENABLE: ON" << endl;
#endif
  cout << "FOO_STRING1: " << xstr(FOO_STRING1) << endl;
  cout << "FOO_STRING2: " << xstr(FOO_STRING2) << endl;
  cout << "FOO_UNDEFINED: " << xstr(FOO_UNDEFINED) << endl;
} 

通过将二叉树添加到我们的包含路径中,并使用 target_include_directories() 命令,我们可以编译示例,并接收来自 CMake 的输出:

FOO_ENABLE: ON
FOO_STRING1: "abc"
FOO_STRING2: "def"
FOO_UNDEFINED: FOO_UNDEFINED 

configure_file() 命令还包括一系列格式化和文件权限选项,由于篇幅限制,我们不会在此深入探讨。如果你感兴趣,可以参考在线文档获取更多细节(请参阅本章的 进一步阅读 部分)。

在准备好完整的头文件和源文件编译后,让我们讨论在后续步骤中输出代码是如何形成的。尽管我们无法直接影响语言分析或汇编(因为这些步骤遵循严格的标准),但我们可以调整优化器的配置。让我们来探索一下这种配置如何影响最终结果。

配置优化器

优化器将分析前一阶段的输出,并使用多种策略,程序员通常不会直接使用这些策略,因为它们不符合干净代码原则。但这没关系——优化器的核心作用是提高代码性能,追求低 CPU 使用率、最小化寄存器使用和减少内存占用。当优化器遍历源代码时,它会将代码重构为几乎无法辨认的形式,专门为目标 CPU 量身定制。

优化器不仅会决定哪些函数可以删除或压缩,它还会重新排列代码,甚至大规模复制代码!如果它能够确定某些代码行是多余的,它会将这些行从重要函数中间删除(你甚至不会注意到)。它会回收内存,让多个变量在不同时间占用相同的位置。它甚至可以将你的控制结构重塑成完全不同的形式,如果这样做能节省几次 CPU 周期的话。

如果程序员手动将上述技术应用到源代码中,它将把代码变成一团糟,既难写又难理解。然而,当编译器应用这些技术时,它们是有益的,因为编译器严格遵循给定的指令。优化器是一只无情的野兽,服务的唯一目的就是加速执行速度,无论输出变得多么扭曲。这样的输出可能包含一些调试信息,如果我们在测试环境中运行它,或者可能不包含调试信息,以防止未授权的人篡改。

每个编译器都有自己独特的技巧,这与它支持的平台和所遵循的哲学一致。我们将查看 GNU GCC 和 LLVM Clang 中最常见的一些,以便了解哪些是实际可行的。

事情是这样的——许多编译器默认不会启用任何优化(包括 GCC)。在某些情况下这样没问题,但在其他情况下就不行了。为什么要慢呢,当你可以更快?为了解决这个问题,我们可以使用 target_compile_options() 命令,明确表达我们对编译器的期望。

该命令的语法与本章中的其他命令类似:

target_compile_options(<target> [BEFORE]
                       <INTERFACE|PUBLIC|PRIVATE> [items1...]
                      [<INTERFACE|PUBLIC|PRIVATE> [items2...]
...]) 

我们提供命令行选项,在构建目标时使用,并且还指定了传播关键字。当执行时,CMake 会将给定的选项附加到目标的适当 COMPILE_OPTIONS 变量中。如果我们希望将它们放在前面,可以使用可选的 BEFORE 关键字。在某些场景中,顺序可能很重要,因此能够选择顺序是有益的。

请注意,target_compile_options() 是一个通用命令。它也可以用于为编译器提供其他参数,例如 -D 定义,CMake 还提供了 target_compile_definition() 命令。建议尽可能使用最专业的 CMake 命令,因为它们在所有支持的编译器中保证以相同的方式工作。

现在是讨论细节的时候了。接下来的部分将介绍可以在大多数编译器中启用的各种优化。

一般级别

优化器的所有不同行为可以通过特定的标志来深入配置,这些标志我们可以作为 编译选项 传递。如果我们只是想要一个在大多数情况下都能很好工作的最佳解决方案,该怎么办?我们可以选择一个通用的解决方案——一个优化级别说明符。

大多数编译器提供四个基本的优化级别,从 03。我们通过 -O<level> 选项来指定它们。-O0 意味着 没有优化,通常这是编译器的默认级别。另一方面,-O2 被认为是 完全优化,它生成高度优化的代码,但代价是最慢的编译时间。

还有一个中间的 -O1 级别,这个级别(根据你的需求)可能是一个不错的折衷——它启用了合理的优化机制,同时不会过多地减慢编译速度。

最后,我们可以选择 -O3,这是完全优化,类似于 -O2,但采用更激进的子程序内联和循环向量化方法。

还有一些优化的变体,它们优化的是生成文件的大小(不一定是速度)——-Os。有一种超激进的优化 -Ofast,它是 -O3 优化,但不严格遵守 C++ 标准。最明显的区别是使用了 -ffast-math-ffinite-math 标志,这意味着如果你的程序涉及精确计算(大多数程序都是),你可能希望避免使用它。

CMake 知道并非所有编译器都是一样的,因此它通过为编译器提供一些默认标志来标准化开发者的体验。这些标志存储在系统范围内(而非特定目标)的变量中,用于所使用的语言(CXX 用于 C++)和构建配置(DEBUGRELEASE):

  • CMAKE_CXX_FLAGS_DEBUG 等于 -g

  • CMAKE_CXX_FLAGS_RELEASE 等于 -O3 -DNDEBUG

正如你所看到的,调试配置不会启用任何优化,而发布配置则直接使用 O3。如果你愿意,你可以通过 set() 命令直接更改它们,或者只需添加目标编译选项,这将覆盖默认行为。另两个标志(-g, -DNDEBUG)与调试相关——我们将在本章的 为调试器提供信息 部分讨论它们。

CMAKE_<LANG>_FLAGS_<CONFIG> 这样的变量是全局的——它们适用于所有目标。建议通过属性和命令(如 target_compile_options())来配置目标,而不是依赖于全局变量。这样,你可以更细粒度地控制你的目标。

通过选择优化级别 -O<level>,我们间接设置了一长串标志,每个标志控制着特定的优化行为。然后,我们可以通过追加更多标志来微调优化,如下所示:

  • 使用 -f 选项启用它们:-finline-functions

  • 使用 -fno 选项禁用它们:-fno-inline-functions

这些标志中的一些值得更好地理解,因为它们会影响你的程序的运行方式以及你如何调试它。让我们来看看。

函数内联

正如你可能记得的那样,编译器可以通过在类的声明块中定义一个函数,或通过显式使用 inline 关键字来鼓励内联一些函数:

struct X {
  void im_inlined(){ cout << "hi\n"; };
  void me_too();
};
**inline** void X::me_too() { cout << "bye\n"; }; 

内联一个函数的决定最终由编译器做出。如果启用了内联,并且该函数仅在一个位置使用(或是一个在少数地方使用的相对较小的函数),那么内联很可能会发生。

函数内联是一种有趣的优化技术。它通过将目标函数的代码提取出来并嵌入到所有调用该函数的位置来工作。这个过程替换了原始的调用,并节省了宝贵的 CPU 周期。

让我们考虑以下使用我们刚刚定义的类的示例:

int main() {
  X x;
  x.im_inlined();
  x.me_too();
  return 0;
} 

如果没有内联,代码将在main()框架中执行,直到方法调用为止。然后,它会为im_inlined()创建一个新框架,在一个单独的作用域中执行,并返回到main()框架。me_too()方法也会发生同样的情况。

然而,当发生内联时,编译器会替换调用,类似这样:

int main() {
  X x;
  cout << "hi\n";
  cout << "bye\n";
  return 0;
} 

这并不是精确的表示,因为内联发生在汇编或机器代码的层面(而非源代码层面),但它提供了一个大致的概念。

编译器使用内联来节省时间。它跳过了创建和销毁新调用框架的过程,避免了查找下一个要执行的指令地址(并返回)的需求,并且增强了指令缓存,因为它们彼此非常接近。

然而,内联确实带来了一些显著的副作用。如果一个函数被多次使用,它必须复制到所有调用位置,从而导致文件大小增大和内存使用增加。尽管今天这可能不像以前那么关键,但它仍然相关,尤其是在为低端设备(内存有限)开发软件时。

此外,内联对调试产生了重大影响。内联代码不再出现在原始的行号位置,这使得追踪变得更加困难,有时甚至变得不可能。这就是为什么在内联的函数上设置调试断点时,永远不会被触发(即使代码仍然以某种方式被执行)。为了解决这个问题,你需要在调试版本中禁用内联(这意味着无法测试完全相同的发布版本)。

我们可以通过为目标指定-O0(o-zero)级别,或直接修改负责内联的标志来实现:

  • -finline-functions-called-once:仅适用于 GCC。

  • -finline-functions:适用于 Clang 和 GCC。

  • -finline-hint-functions:仅适用于 Clang。

内联可以通过-fno-inline-...显式禁用,但是,若要了解详细信息,建议查阅特定编译器版本的文档。

循环展开

循环展开,也称为循环解开,是一种优化技术。该策略旨在将循环转换为一系列实现相同结果的语句。因此,这种方法将程序的小体积换成了执行速度,因为它消除了循环控制指令、指针运算和循环结束检查。

请看以下示例:

void func() {
  for(int i = 0; i < 3; i++)
    cout << "hello\n";
} 

上述代码将被转换为类似如下内容:

void func() {
    cout << "hello\n";
    cout << "hello\n";
    cout << "hello\n";
} 

结果将是一样的,但我们不再需要分配 i 变量、递增它或将其与值 3 比较三次。如果在程序的生命周期内多次调用 func(),即使是展开如此短小的函数,也会产生显著的差异。

然而,理解两个限制因素是很重要的。首先,循环展开只有在编译器知道或能够准确估计迭代次数时才有效。其次,循环展开可能会对现代 CPU 产生不良影响,因为增加的代码大小可能会妨碍有效的缓存。

每个编译器提供的此标志的版本略有不同:

  • -floop-unroll:这是用于 GCC 的选项。

  • -funroll-loops:这是用于 Clang 的选项。

如果你不确定,广泛测试此标志是否影响你特定的程序,并显式地启用或禁用它。请注意,在 GCC 中,它在 -O3 下隐式启用,作为隐式启用的 -floop-unroll-and-jam 标志的一部分。

循环向量化

被称为单指令多数据SIMD)的机制是在 1960 年代初期开发的,目的是实现并行性。顾名思义,它旨在同时对多个数据执行相同的操作。让我们通过以下示例来实际了解这一点:

int a[128];
int b[128];
// initialize b
for (i = 0; i<128; i++)
  a[i] = b[i] + 5; 

通常,这样的代码会循环 128 次,但在具备能力的 CPU 上,通过同时计算两个或更多的数组元素,代码的执行可以显著加速。这是因为连续元素之间没有依赖关系,且数组之间的数据没有重叠。聪明的编译器可以将前面的循环转换为如下形式(这发生在汇编级别):

for (i = 0; i<32; i+=4) {
  a[ i ] = b[ i ] + 5;
  a[i+1] = b[i+1] + 5;
  a[i+2] = b[i+2] + 5;
  a[i+3] = b[i+3] + 5;
} 

GCC 在 -O3 下会启用这种自动循环向量化。Clang 默认启用它。两种编译器都提供不同的标志来启用/禁用特定的向量化:

  • -ftree-vectorize -ftree-slp-vectorize:这是用于启用 GCC 中向量化的选项。

  • -fno-vectorize -fno-slp-vectorize:这是用于在 Clang 中禁用向量化的选项。

向量化的效率源于利用 CPU 制造商提供的特殊指令,而不仅仅是将原始的循环形式替换为展开的版本。因此,手动实现相同的性能水平是不可行的(此外,这也不会导致简洁的代码)。

优化器在提高程序运行时性能方面发挥着至关重要的作用。通过有效地利用其策略,我们可以获得更多的效益。效率不仅在编码完成后很重要,在软件开发过程中同样如此。如果编译时间过长,我们可以通过更好地管理过程来改进它。

管理编译过程

作为程序员和构建工程师,我们还必须考虑编译过程中的其他方面,例如完成时间以及在解决方案构建过程中识别和修正错误的便捷性。

降低编译时间

在需要频繁重新编译的繁忙项目中(可能每小时多次),确保编译过程尽可能快速是至关重要的。这不仅影响你的代码编译测试循环的效率,还会影响你的专注力和工作流程。

幸运的是,C++已经相当擅长管理编译时间,这要归功于分离的翻译单元。CMake 会确保只重新编译受到最近更改影响的源文件。然而,如果我们需要进一步改善,有几种技术可以使用:头文件预编译和统一构建。

头文件的预编译

头文件(.h)由预处理器在实际编译开始之前包含到翻译单元中。这意味着每当.cpp实现文件发生变化时,它们必须重新编译。此外,如果多个翻译单元使用相同的共享头文件,每次包含时都必须编译一次。这是低效的,但它已经是长期以来的标准做法。

幸运的是,从 CMake 3.16 版本开始,CMake 提供了一个命令来启用头文件预编译。这使得编译器可以将头文件与实现文件分开处理,从而加速编译过程。以下是该命令的语法:

target_precompile_headers(<target>
                          <INTERFACE|PUBLIC|PRIVATE> [header1...]
                         [<INTERFACE|PUBLIC|PRIVATE> [header2...]
...]) 

添加的头文件列表存储在PRECOMPILE_HEADERS目标属性中。正如我们在第五章与目标的协作中讨论的,在什么是传递的使用要求?部分,我们可以使用传播的属性,通过选择PUBLICINTERFACE关键字,将头文件与任何依赖目标共享;然而,对于使用install()命令导出的目标,不应这样做。其他项目不应被强迫使用我们的预编译头文件,因为这并不是一种常规做法。

使用在第六章使用生成器表达式中描述的$<BUILD_INTERFACE:...>生成器表达式,防止预编译头文件出现在目标的使用要求中,尤其是在它们被安装时。然而,它们仍会被添加到通过export()命令从构建树中导出的目标中。如果现在这看起来有点困惑,不用担心——在第十四章安装与打包中会做详细说明。

CMake 会将所有头文件的名称放入一个cmake_pch.hcmake_pch.hxx文件中,然后将该文件预编译为一个特定于编译器的二进制文件,扩展名为.pch.gch.pchi

我们可以在我们的列表文件中像这样使用它:

ch07/06-precompile/CMakeLists.txt

add_executable(precompiled hello.cpp)
target_precompile_headers(precompiled PRIVATE <iostream>) 

我们也可以在对应的源文件中使用它:

ch07/06-precompile/hello.cpp

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

请注意,在我们的main.cpp文件中,我们不需要包含cmake_pch.h或任何其他头文件——它将由 CMake 使用特定于编译器的命令行选项包含进来。

在前面的例子中,我使用了一个内置头文件;然而,你可以轻松地添加自己的包含类或函数定义的头文件。可以使用两种形式之一来引用头文件:

  • header.h(直接路径)被解释为相对于当前源目录的路径,并将以绝对路径包含。

  • [["header.h"]](双括号和引号)的路径将根据目标的 INCLUDE_DIRECTORIES 属性进行扫描,该属性可以通过 target_include_directiories() 配置。

一些在线参考资料可能会建议避免预编译那些不是标准库的一部分的头文件,比如 <iostream>,或者完全不使用预编译头文件。这是因为修改列表或编辑自定义头文件将导致目标中的所有翻译单元重新编译。使用 CMake 时,这个问题就没有那么严重,尤其是当你正确地组织项目(将项目结构划分为相对较小、聚焦于特定领域的目标)时。每个目标都有一个独立的预编译头文件,这样可以限制头文件更改的影响。

如果你的头文件被认为相对稳定,你可以决定在目标中重用预编译头文件。为此,CMake 提供了一个方便的命令:

target_precompile_headers(<target> REUSE_FROM <other_target>) 

这会设置目标的 PRECOMPILE_HEADERS_REUSE_FROM 属性,重用头文件,并在这些目标之间创建依赖关系。使用这种方法,消费目标将无法再指定自己的预编译头文件。此外,所有的编译选项编译标志编译定义必须在目标之间匹配。

注意要求,尤其是如果你有任何使用双括号格式([["header.h"]])的头文件。两个目标都需要适当设置它们的包含路径,以确保编译器能够找到这些头文件。

Unity 构建

CMake 3.16 引入了另一种编译时间优化功能——Unity 构建,也被称为统一构建超大构建。Unity 构建通过利用 #include 指令将多个实现源文件合并。这有一些有趣的影响,其中一些是有利的,而另一些可能是有害的。

最明显的优势是,当 CMake 创建统一构建文件时,避免了不同翻译单元中头文件的重新编译:

#include "source_a.cpp"
#include "source_b.cpp" 

当两个源文件中都有 #include "header.h" 行时,参考的文件只会被解析一次,得益于包含保护(假设它们已正确添加)。虽然不如预编译头文件精细,但这也是一种替代方案。

这种构建方式的第二个好处是,优化器现在可以在更大的范围内工作,优化所有捆绑源代码之间的过程间调用。这类似于我们在第四章设置你的第一个 CMake 项目中的过程间优化部分讨论的链接时间优化。

然而,这些好处是有权衡的。由于我们减少了目标文件和处理步骤的数量,我们也增加了处理较大文件所需的内存量。此外,我们减少了可并行工作的数量。编译器在多线程编译方面并不特别擅长,因为它们通常不需要这样做——构建系统通常会启动许多编译任务,以便在不同的线程上同时执行所有文件。将所有文件分组在一起会使这一过程变得复杂,因为 CMake 现在需要并行编译的文件变少了。

使用 Unity 构建时,你还需要考虑一些可能不容易察觉的 C++ 语义影响——匿名命名空间隐藏跨文件的符号,现在这些符号的作用域局限于 Unity 文件,而不是单独的翻译单元。静态全局变量、函数和宏定义也会发生同样的情况。这可能会导致名称冲突,或执行错误的函数重载。

Jumbo 构建在重新编译时表现不佳,因为它们会编译比实际需要的更多文件。它们最适合用于代码需要尽可能快地编译所有文件的情况。在 Qt Creator(一个流行的 GUI 库)上进行的测试表明,你可以期望性能提高 20%到 50%之间(具体取决于使用的编译器)。

要启用 Unity 构建,我们有两个选择:

  • CMAKE_UNITY_BUILD变量设置为true——它将初始化随后定义的每个目标上的UNITY_BUILD属性。

  • 手动将UNITY_BUILD目标属性设置为true,用于所有应使用 Unity 构建的目标。

第二种选择通过调用以下内容来实现:

set_target_properties(<target1> <target2> ...
                      PROPERTIES UNITY_BUILD true) 

在许多目标上手动设置这些属性当然需要更多的工作,并增加了维护成本,但你可能需要这样做,以便更精细地控制这一设置。

默认情况下,CMake 会创建包含八个源文件的构建,这些源文件由目标的UNITY_BUILD_BATCH_SIZE属性指定(该属性在目标创建时从CMAKE_UNITY_BUILD_BATCH_SIZE变量复制)。你可以更改目标属性或默认变量。

从版本 3.18 开始,你可以明确地定义文件应如何与命名组捆绑。为此,请将目标的UNITY_BUILD_MODE属性更改为GROUP(默认值是BATCH)。然后,通过将源文件的UNITY_GROUP属性设置为你选择的名称来将它们分配到组中:

set_property(SOURCE <src1> <src2> PROPERTY UNITY_GROUP "GroupA") 

然后,CMake 将忽略UNITY_BUILD_BATCH_SIZE并将该组中的所有文件添加到一个 Unity 构建中。

CMake 的文档建议默认情况下不要为公共项目启用统一构建。推荐的做法是,应用程序的最终用户应该能够决定是否希望使用 jumbo 构建,可以通过提供-DCMAKE_UNITY_BUILD命令行参数来实现。如果统一构建由于代码编写方式引发问题,你应该明确地将目标的属性设置为 false。然而,你可以自由地为内部使用的代码启用此功能,例如公司内部的代码或你自己的私人项目。

这些是使用 CMake 减少编译时间的最重要方面。编程中还有其他常常让我们浪费大量时间的因素——其中最臭名昭著的就是调试。让我们看看如何在这方面改进。

查找错误

作为程序员,我们花费大量时间在寻找 bug 上。不幸的是,这是我们职业的一个事实。识别错误并修复它们的过程常常让人焦躁不安,尤其是当修复需要长时间工作时。当我们缺乏必要的工具来帮助我们在这些困难的情况下航行时,这个难度会大大增加。正因如此,我们必须特别注意如何配置环境,使得这一过程变得更加简化,尽可能轻松和耐受。一种实现这一目标的方法是通过target_compile_options()配置编译器。那么,哪些编译选项可以帮助我们实现这一目标呢?

配置错误和警告

软件开发中有很多令人头疼的事情——在深夜修复关键性 bug,处理大型系统中的高可见度和高成本故障,或者面对恼人的编译错误。一些错误难以理解,而另一些则是繁琐且具有挑战性的修复任务。在你努力简化工作并减少失败的机会时,你会发现很多关于如何配置编译器警告的建议。

其中一个值得注意的建议是默认启用-Werror标志进行所有构建。从表面上看,这个标志的功能看起来很简单——它将所有警告视为错误,直到你解决每个警告,代码才会继续编译。虽然看起来似乎是一种有益的方法,但它通常并非如此。

你看,警告之所以不被归类为错误,是有原因的:它们的设计目的是提醒你。如何处理这些警告由你自己决定。特别是在你进行实验或原型开发时,能够忽视某些警告往往是非常宝贵的。

另一方面,如果你有一段完美的、没有警告的、无懈可击的代码,似乎不应该让将来的修改破坏这种完美的状态。启用它并保持在那里,似乎也没有什么坏处,至少在你的编译器没有升级之前是这样。新版本的编译器通常对已弃用的特性更加严格,或者在提供改进建议方面更加高效。虽然这在警告仍然是警告时有益,但它可能导致在代码没有更改的情况下出现意外的构建失败,或者更让人沮丧的是,当你需要快速修复与新警告无关的问题时。

那么,什么时候启用所有可能的警告是可以接受的呢?简短的答案是,当你在创建一个公共库时。在这种情况下,你会希望预防那些因环境比你严格而导致的代码问题的工单。如果你选择启用这个设置,请确保及时更新新的编译器版本及其引入的警告。还需要特别管理这个更新过程,与代码变更的管理分开进行。

否则,让警告保持原样,集中精力处理错误。如果你觉得有必要强求严格,可以使用-Wpedantic标志。这个特定的标志会启用严格的 ISO C 和 ISO C++标准要求的所有警告。然而,请记住,这个标志并不能确认标准的符合性;它只是标识出那些需要诊断消息的非 ISO 做法。

更宽容且脚踏实地的程序员将会满足于-Wall,可以选择与-Wextra搭配使用,增加一些精致的警告,这样就足够了。这些警告被认为是真正有用的,当有时间时,你应该在代码中处理这些警告。

根据你的项目类型,还有许多其他警告标志可能会有用。我建议你阅读所选编译器的手册,看看有哪些可用的选项。

调试构建

偶尔,编译会失败。这通常发生在我们尝试重构大量代码或清理我们的构建系统时。有时问题可以很容易解决;然而,也有一些复杂的问题需要深入调查配置步骤。我们已经知道如何打印更详细的 CMake 输出(如在第一章中讨论的《CMake 的第一步》),但我们如何分析每个阶段实际上发生了什么?

调试各个阶段

-save-temps,可以传递给 GCC 和 Clang 编译器,允许我们调试编译的各个阶段。这个标志会指示编译器将某些编译阶段的输出存储在文件中,而不是存储在内存中。

ch07/07-debug/CMakeLists.txt

add_executable(debug hello.cpp)
target_compile_options(debug PRIVATE **-save-temps=obj**) 

启用此选项将在每个翻译单元中生成两个额外的文件(.ii.s)。

第一个文件,<build-tree>/CMakeFiles/<target>.dir/<source>.ii,存储预处理阶段的输出,并附有注释,解释每部分源代码的来源:

# 1 "/root/examples/ch07/06-debug/hello.cpp"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# / / / ... removed for brevity ... / / /
# 252 "/usr/include/x86_64-linux-
  gnu/c++/9/bits/c++config.h" 3
namespace std
{
  typedef long unsigned int size_t;
  typedef long int ptrdiff_t;
  typedef decltype(nullptr) nullptr_t;
}
... 

第二个文件,<build-tree>/CMakeFiles/<target>.dir/<source>.s,包含语言分析阶段的输出,已准备好进入汇编阶段:

 .file   "hello.cpp"
        .text
        .section        .rodata
        .type   _ZStL19piecewise_construct, @object
        .size   _ZStL19piecewise_construct, 1
_ZStL19piecewise_construct:
        .zero   1
        .local  _ZStL8__ioinit
        .comm   _ZStL8__ioinit,1,1
.LC0:
        .string "hello world"
        .text
        .globl  main
        .type   main, @function
main:
( ... ) 

根据问题的类型,我们通常可以揭示实际问题。例如,预处理器的输出可以帮助我们识别错误,如错误的包含路径(可能提供错误版本的库),或定义中的错误导致的#ifdef评估错误。

与此同时,语言分析的输出对于针对特定处理器和解决关键优化问题尤其有益。

调试头文件包含问题

调试错误的包含文件可能是一个具有挑战性的任务。我应该知道——在我第一份公司工作时,我曾经需要将整个代码库从一个构建系统迁移到另一个。如果你发现自己处于一个需要精确理解用于包含所请求头文件的路径的情况,可以考虑使用-H编译选项:

ch07/07-debug/CMakeLists.txt

add_executable(debug hello.cpp)
target_compile_options(debug PRIVATE **-H**) 

产生的输出将类似于以下内容:

[ 25%] Building CXX object
  CMakeFiles/inclusion.dir/hello.cpp.o
. /usr/include/c++/9/iostream
.. /usr/include/x86_64-linux-gnu/c++/9/bits/c++config.h
... /usr/include/x86_64-linux-gnu/c++/9/bits/os_defines.h
.... /usr/include/features.h
-- removed for brevity --
.. /usr/include/c++/9/ostream 

目标文件的名称后,每一行输出都包含一个头文件路径。在这个例子中,行首的单个点表示顶级包含(#include指令位于hello.cpp中)。两个点表示此文件由后续文件(<iostream>)包含。每增加一个点,表示嵌套的层级增加。

在此输出的末尾,你还可能会看到一些关于如何改进代码的建议:

Multiple include guards may be useful for:
/usr/include/c++/9/clocale
/usr/include/c++/9/cstdio
/usr/include/c++/9/cstdlib 

虽然你不需要解决标准库中的问题,但你可能会看到一些你自己编写的头文件被列出。在这种情况下,你可能需要考虑进行修正。

为调试器提供信息

机器代码是一组神秘的指令和数据,以二进制格式编码。它并没有传达更深层次的意义或目标。这是因为 CPU 并不关心程序的目标是什么,或者所有指令的含义。唯一的要求是代码的正确性。编译器会将上述所有内容翻译成 CPU 指令的数字标识符,存储数据以初始化所需的内存,并提供成千上万的内存地址。换句话说,最终的二进制文件不需要包含实际的源代码、变量名、函数签名或程序员关心的任何其他细节。这就是编译器的默认输出——原始且裸露。

这样做主要是为了节省空间并减少过多的开销。巧合的是,我们也在一定程度上保护了我们的应用程序免受逆向工程的攻击。是的,即使没有源代码,你也可以理解每个 CPU 指令的作用(例如,将这个值复制到那个寄存器)。但是,即使是最基础的程序也包含太多这样的指令,难以理清它们的逻辑。

如果你是一个特别有动力的人,你可以使用一个叫做反汇编器的工具,通过大量的知识(和一点运气),你将能够解读可能发生的事情。然而,这种方法并不太实际,因为反汇编的代码没有原始符号,这使得解读程序的逻辑变得非常困难且缓慢。

相反,我们可以要求编译器将源代码与编译后代码与原始代码之间的引用映射一起存储到生成的二进制文件中。然后,我们可以将调试器附加到正在运行的程序上,并查看在任何时刻正在执行哪个源代码行。当我们在编写新功能或修复错误等代码时,这一点是不可或缺的。

这两个用例是两个构建配置的原因:DebugRelease。正如我们之前所看到的,CMake 默认会向编译器提供一些标志来管理此过程,并首先将它们存储在全局变量中:

  • CMAKE_CXX_FLAGS_DEBUG 包含 -g

  • CMAKE_CXX_FLAGS_RELEASE包含 -DNDEBUG

-g标志的意思是“添加调试信息”。它以操作系统的本地格式提供:stabs、COFF、XCOFF 或 DWARF。这些格式可以被像 gdb(GNU 调试器)这样的调试器访问。通常,这对于像 CLion 这样的集成开发环境(IDE)来说是足够的,因为它们在后台使用 gdb。在其他情况下,请参考所提供调试器的手册,检查适用于您所选择编译器的正确标志。

对于 Release 配置,CMake 会添加 -DNDEBUG 标志。这是一个预处理器定义,简单来说就是“不是调试构建”。一些面向调试的宏将被故意禁用,其中之一就是在 <assert.h> 头文件中可用的 assert。如果你决定在生产代码中使用断言,它们将不起作用:

int main(void)
{
    **assert****(****false****)**;
    std::cout << "This shouldn't run. \n";
    return 0;
} 

Release 配置中,assert(false) 调用不会产生任何效果,但在 Debug 配置中,它会正常停止执行。如果你正在实践断言编程,并且仍然需要在发布版本中使用 assert(),你可以选择更改 CMake 提供的默认设置(从 CMAKE_CXX_FLAGS_RELEASE 中移除 NDEBUG),或者在包含头文件之前实现硬编码的覆盖,方法是取消定义该宏:

#undef NDEBUG
#include <assert.h> 

更多信息请参见断言参考:en.cppreference.com/w/c/error/assert

如果您的断言可以在编译时完成,您可以考虑用 C++11 中引入的 static_assert() 替代 assert(),因为该函数不像 assert() 那样被 #ifndef(NDEBUG) 预处理器指令保护。

到这里,我们已经学会了如何管理编译过程。

总结

我们又完成了一个章节!毫无疑问,编译是一个复杂的过程。由于它的各种边界情况和特定要求,在没有强大工具的支持下很难管理。幸运的是,CMake 在这方面做得非常出色。

那么,到目前为止我们学到了什么呢?我们从讨论编译是什么以及它在构建和运行操作系统中的应用程序这一更广泛叙述中所处的位置开始。然后,我们检查了编译的各个阶段以及管理这些阶段的内部工具。这种理解对于解决我们未来可能遇到的复杂问题是非常宝贵的。

接下来,我们探索了如何使用 CMake 来验证主机上可用的编译器是否满足构建我们代码所需的所有必要要求。正如我们已经确立的那样,对于我们的解决方案的用户来说,看到一条友好的消息,提示他们升级编译器,远比看到由无法处理新语言特性的过时编译器打印出来的晦涩错误信息要好得多。

我们简要讨论了如何将源文件添加到已经定义的目标中,然后继续讲解了预处理器的配置。这是一个相当重要的主题,因为这一阶段将所有代码片段汇集在一起,并决定哪些部分会被忽略。我们谈到了如何提供文件路径并单独或批量添加自定义定义(以及一些用例)。接着,我们讨论了优化器;我们探讨了所有常见的优化级别以及它们隐式添加的标志。我们还详细讲解了一些标志——finlinefloop-unrollftree-vectorize

最后,是时候回顾更大的图景,并研究如何管理编译的可行性了。我们在这里解决了两个主要方面——减少编译时间(从而帮助保持程序员的专注力)和发现错误。后者对于识别哪些地方出了问题以及为什么会出问题至关重要。正确配置工具并理解事情发生的原因,有助于确保代码的质量(也有助于维护我们的心理健康)。

在下一章,我们将学习链接以及在构建库并在项目中使用它们时需要考虑的所有事项。

进一步阅读

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

加入我们的 Discord 社区

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

discord.com/invite/vXN53A7ZcA

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

你可能会认为,一旦我们成功地将源代码编译成二进制文件,我们作为构建工程师的角色就完成了。然而,事实并非完全如此。尽管二进制文件确实包含了 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 展示了编译器如何构建这些文件:

图 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 部分将遵循相同的过程(为了简便,图中没有包含头部):

图 8.2:.data 部分的重定位

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

图 8.3:引用解析

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

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

图 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

第九章:在 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):

图 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

第十章:使用 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)的作用与头文件类似,但它采用二进制格式,显著减少了其他翻译单元在使用时的重新编译需求。

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

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

图 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 对测试框架的支持。

进一步阅读

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

留下评价!

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

第十一章:测试框架

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

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

自动化测试确保了新更改不会无意中破坏我们的程序。在本章中,我们将学习为什么测试很重要,以及如何使用 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的测试

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

图 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)连接,并在测试期间收集每一行的执行情况,并将结果以方便的报告形式展示出来,就像这里展示的报告一样:

图 11.2:LCOV 生成的代码覆盖率报告

这些报告会显示哪些文件被测试覆盖,哪些没有。更重要的是,你还可以查看每个文件的详细信息,精确知道哪些代码行被执行了,以及执行了多少次。在下图中,Line data列显示Calc构造函数执行了4次,每个测试执行一次:

图 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

第十二章:程序分析工具

编写高质量代码并非易事,即便是经验丰富的开发者也是如此。通过在我们的解决方案中加入测试,我们可以减少在主代码中犯基本错误的可能性。但这还不足以避免更复杂的问题。每一段软件都包含了大量的细节,要追踪所有这些细节几乎成为了一项全职工作。维护产品的团队会建立各种约定和特定的设计实践。

有一些问题与一致的编码风格有关:我们应该在代码中使用 80 列还是 120 列?我们应该允许使用 std::bind 还是坚持使用 Lambda 函数?使用 C 风格的数组是否可以接受?小函数是否应该写成一行?我们是否应该总是使用 auto,还是仅在提高可读性时使用?理想情况下,我们应该避免已知通常不正确的语句:无限循环、使用标准库保留的标识符、无意的数据丢失、不必要的 if 语句以及其他任何不符合“最佳实践”的东西(更多信息请参见 进一步阅读 部分)。

另一个需要考虑的方面是代码的现代化。随着 C++ 的发展,它引入了新的特性。跟踪所有可以更新到最新标准的地方可能是一个挑战。此外,手动进行这一操作既费时又增加了引入错误的风险,尤其是在大型代码库中。最后,我们还应检查在程序运行时其操作是否正常:运行程序并检查其内存。内存是否在使用后正确释放?我们是否访问了已正确初始化的数据?或者代码是否尝试访问不存在的指针?

手动管理所有这些挑战和问题既费时又容易出错。幸运的是,我们可以使用自动化工具来检查和强制执行规则,纠正错误,并使我们的代码保持最新。现在是时候探索程序分析工具了。在每次构建时,我们的代码将会被仔细审查,确保它符合行业标准。

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

  • 强制格式化

  • 使用静态检查工具

  • 使用 Valgrind 进行动态分析

技术要求

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

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

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

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

强制格式化

专业开发人员通常会遵循规则。有人说,资深开发人员知道什么时候可以打破规则,因为他们能为其必要性提供正当理由。另一方面,非常资深的开发人员通常避免打破规则,以节省解释自己选择的时间。关键是要关注真正影响产品的问题,而不是陷入琐碎的细节。

在编码风格和格式化方面,开发人员面临许多选择:我们应该使用制表符还是空格进行缩进?如果是空格,使用多少个?列或文件中的字符限制应该是多少?这些选择通常不会改变程序的行为,但可能引发冗长的讨论,增加的价值不大。

确实存在一些常见的做法,但讨论通常围绕个人偏好和轶事证据展开。例如,选择每列 80 个字符而非 120 个字符是任意的。重要的是保持一致的风格,因为不一致可能会妨碍代码的可读性。为了确保一致性,建议使用像clang-format这样的格式化工具。这个工具可以通知我们代码是否没有正确格式化,甚至可以自动修正。下面是格式化代码的示例命令:

clang-format -i --style=LLVM filename1.cpp filename2.cpp 

-i选项指示 clang-format 直接编辑文件,而--style指定要使用的格式化风格,例如LLVMGoogleChromiumMozillaWebKit或在文件中提供的自定义风格(更多细节请参见进一步阅读部分)。

当然,我们不想每次更改时都手动执行此命令;CMake应该作为构建过程的一部分来处理此事。我们已经知道如何在系统中找到clang-format(我们需要事先手动安装它)。我们尚未讨论的是如何将这个外部工具应用于所有源文件。为此,我们将创建一个便捷的函数,可以从cmake目录中包含该函数:

ch12/01-formatting/cmake/Format.cmake

function(Format target directory)
  find_program(CLANG-FORMAT_PATH clang-format REQUIRED)
  set(EXPRESSION h hpp hh c cc cxx cpp)
  list(TRANSFORM EXPRESSION PREPEND "${directory}/*.")
  file(GLOB_RECURSE SOURCE_FILES FOLLOW_SYMLINKS
       LIST_DIRECTORIES false ${EXPRESSION}
  )
  add_custom_command(TARGET ${target} PRE_BUILD COMMAND
    ${CLANG-FORMAT_PATH} -i --style=file ${SOURCE_FILES}
  )
endfunction() 

Format函数接受两个参数:targetdirectory。它将在目标构建之前格式化来自该目录的所有源文件。

从技术角度来看,目录中的所有文件不必都属于目标,目标的源文件可能分布在多个目录中。然而,追踪与目标相关的所有源文件和头文件是复杂的,特别是在需要排除外部库的头文件时。在这种情况下,聚焦于目录比聚焦于逻辑目标要容易。我们可以为每个需要格式化的目录调用该函数。

这个函数的步骤如下:

  1. 查找已安装的clang-format二进制文件。如果未找到该二进制文件,REQUIRED关键字会在配置过程中抛出错误。

  2. 创建一个文件扩展名列表以进行格式化(用作通配符表达式)。

  3. 在每个表达式前加上directory的路径。

  4. 递归搜索源文件和头文件(使用之前创建的列表),将找到的文件路径放入SOURCE_FILES变量中(但跳过任何找到的目录路径)。

  5. 将格式化命令附加到targetPRE_BUILD步骤。

这种方法适用于小到中型代码库。对于更大的代码库,我们可能需要将绝对文件路径转换为相对路径,并使用目录作为工作目录运行格式化命令。这可能是由于 shell 命令中的字符限制,通常限制大约为 13,000 个字符。

让我们来探讨一下如何在实践中使用这个功能。这是我们的项目结构:

- CMakeLists.txt
- .clang-format
- cmake
  |- Format.cmake
- src
  |- CMakeLists.txt
  |- header.h
  |- main.cpp 

首先,我们设置项目并将cmake目录添加到模块路径中,以便稍后包含:

ch12/01-formatting/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Formatting CXX)
enable_testing()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
add_subdirectory(src bin) 

接下来,我们为src目录填充listfile

ch12/01-formatting/src/CMakeLists.txt

add_executable(main main.cpp)
include(Format)
Format(main .) 

这很直接。我们创建一个名为main的可执行目标,包含Format.cmake模块,并在当前目录(src)为main目标调用Format()函数。

现在,我们需要一些未格式化的源文件。头文件包含一个简单的unused函数:

ch12/01-formatting/src/header.h

int unused() { return 2 + 2; } 

我们还将包括一个源文件,其中包含多余的、不正确的空白符:

ch12/01-formatting/src/main.cpp

#include <iostream>
                               using namespace std;
                       int main() {
      cout << "Hello, world!" << endl;
                                          } 

快完成了。我们只需要格式化工具的配置文件,通过--style=file命令行参数启用:

ch12/01-formatting/.clang-format

BasedOnStyle: Google
ColumnLimit: 140
UseTab: Never
AllowShortLoopsOnASingleLine: false
AllowShortFunctionsOnASingleLine: false
AllowShortIfStatementsOnASingleLine: false 

ClangFormat将扫描父目录中的.clang-format文件,该文件指定了确切的格式化规则。这使我们能够自定义每个细节。在我的案例中,我从 Google 的编码风格开始,并做了一些调整:140 字符列限制,不使用制表符,不允许短的循环、函数或if语句写在一行内。

在构建项目后(格式化会在编译前自动进行),我们的文件看起来像这样:

ch12/01-formatting/src/header.h(已格式化)

int unused() {
  return 2 + 2;
} 

即使头文件没有被目标使用,仍然进行了格式化。短函数不能写在一行内,正如预期的那样,添加了新行。main.cpp文件现在看起来也相当简洁。不需要的空白符消失了,缩进已标准化:

ch12/01-formatting/src/main.cpp(已格式化)

#include <iostream>
using namespace std;
int main() {
  cout << "Hello, world!" << endl;
} 

自动化格式化可以节省代码审查时的时间。如果你曾经因为空白符问题而不得不修改提交,你一定能体会到这带来的轻松。统一的格式化使你的代码保持整洁,无需费力。

对整个代码库应用格式化最有可能会在仓库中的大多数文件中引入一次性的大变动。如果你(或你的团队成员)正在进行一些工作,这可能会导致大量的合并冲突。最好在所有待处理的更改完成后再进行此类操作。如果这不可行,可以考虑逐步采用,可能按目录进行。你的团队成员会感激这一点。

尽管格式化工具在使代码视觉上保持一致方面表现出色,但它不是一个全面的程序分析工具。对于更高级的需求,其他专门用于静态分析的工具是必要的。

使用静态检查工具

静态程序分析涉及在不运行已编译版本的情况下检查源代码。始终使用静态检查器可以显著提高代码质量,使其更加一致,不易受到错误和已知安全漏洞的影响。C++社区提供了多种静态检查器,如Astréeclang-tidyCLazyCMetricsCppcheckCpplintCQMetricsESBMCFlawFinderFlintIKOSJoernPC-LintScan-BuildVera++等。

其中许多工具将CMake视为行业标准,并提供现成的支持或集成教程。一些构建工程师更喜欢不编写CMake代码,而是通过在线可用的外部模块来包含静态检查器。例如,Lars Bilke 在他的 GitHub 仓库中的集合:github.com/bilke/cmake-modules

一个普遍的看法是设置静态检查器很复杂。这种看法存在是因为静态检查器通常模拟实际编译器的行为来理解代码。但实际上并不一定要很难。

Cppcheck在其手册中概述了以下简单步骤:

  1. 定位静态检查器的可执行文件。

  2. 使用以下命令生成编译数据库

    • cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
  3. 使用生成的 JSON 文件运行检查器:

    • <path-to-cppcheck> --project=compile_commands.json

这些步骤应该集成到构建过程中,以确保它们不会被忽略。

由于CMake知道如何构建我们的目标,它是否也支持任何静态检查器?完全支持,而且比你想的要简单得多。CMake允许你为以下工具按目标启用检查器:

要启用这些检查器,将目标属性设置为包含检查器可执行文件路径和任何需要转发的命令行选项的分号分隔列表:

  • <LANG>_CLANG_TIDY

  • <LANG>_CPPCHECK

  • <LANG>_CPPLINT

  • <LANG>_INCLUDE_WHAT_YOU_USE

  • LINK_WHAT_YOU_USE

C替换<LANG>以处理 C 源代码,用CXX处理 C++源代码。如果你希望为所有项目目标启用检查器,可以设置一个以CMAKE_为前缀的全局变量——例如:

set(CMAKE_CXX_CLANG_TIDY /usr/bin/clang-tidy-3.9;-checks=*) 

在此语句之后定义的任何目标都会将其CXX_CLANG_TIDY属性设置为此值。请记住,启用此分析可能会稍微延长构建时间。另一方面,更详细地控制检查器如何测试目标是非常有用的。我们可以创建一个简单的函数来处理此操作:

ch12/02-clang-tidy/cmake/ClangTidy.cmake

function(AddClangTidy target)
  find_program(CLANG-TIDY_PATH clang-tidy REQUIRED)
  set_target_properties(${target}
    PROPERTIES CXX_CLANG_TIDY
    "${CLANG-TIDY_PATH};-checks=*;--warnings-as-errors=*"
  )
endfunction() 

AddClangTidy 函数包括两个基本步骤:

  1. 定位 clang-tidy 二进制文件并将其路径存储在 CLANG-TIDY_PATH 中。REQUIRED 关键字确保如果找不到二进制文件,配置将停止并抛出错误。

  2. 通过提供二进制路径和特定选项来启用目标的 clang-tidy,以激活所有检查并将警告视为错误。

要使用这个功能,我们只需包含模块并为所选目标调用它:

ch12/02-clang-tidy/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)
include(ClangTidy)
AddClangTidy(sut) 

这种方法简洁且非常有效。在构建解决方案时,clang-tidy 的输出将如下所示:

[  6%] Building CXX object bin/CMakeFiles/sut.dir/calc.cpp.o
/root/examples/ch12/04-clang-tidy/src/calc.cpp:3:11: warning: method 'Sum' can be made static [readability-convert-member-functions-to-static]
int Calc::Sum(int a, int b) {
          ^
[ 12%] Building CXX object bin/CMakeFiles/sut.dir/run.cpp.o
/root/examples/ch12/04-clang-tidy/src/run.cpp:1:1: warning: #includes are not sorted properly [llvm-include-order]
#include <iostream>
^        ~~~~~~~~~~
/root/examples/ch12/04-clang-tidy/src/run.cpp:3:1: warning: do not use namespace using-directives; use using-declarations instead [google-build-using-namespace]
using namespace std;
^
/root/examples/ch12/04-clang-tidy/src/run.cpp:6:3: warning: initializing non-owner 'Calc *' with a newly created 'gsl::owner<>' [cppcoreguidelines-owning-memory]
  auto c = new Calc();
  ^ 

请注意,除非您将 --warnings-as-errors=* 选项添加到命令行参数中,否则构建会成功完成。组织应决定一组必须严格遵循的规则,以防止不合规的代码进入代码库。

clang-tidy 还提供了一个有用的 --fix 选项,可以在可能的情况下自动修正您的代码。这个功能是一个宝贵的时间节省工具,尤其在扩展检查项列表时非常有用。与格式化类似,在将静态分析工具所做的更改添加到现有代码库时,要小心合并冲突。

根据您的情况、代码库的大小和团队的偏好,您应选择少量最适合您需求的检查工具。包含过多的检查工具可能会导致干扰。以下是 CMake 默认支持的检查工具的简要概述。

clang-tidy

这是官方文档中关于 clang-tidy 的介绍:

clang-tidy 是一个基于 clang 的 C++ 静态分析工具。它的目的是提供一个可扩展的框架,用于诊断和修复典型的编程错误,如风格违规、接口误用或通过静态分析可以推断出的错误。clang-tidy 是模块化的,并提供了一个方便的接口用于编写新的检查项。

该工具非常灵活,提供了超过 400 项检查。它与 ClangFormat 配合良好,能够自动应用修复(超过 150 项修复可用),以符合相同的格式文件。它提供的检查覆盖了性能、可读性、现代化、C++ 核心指南以及易出错的领域。

Cpplint

以下是 Cpplint 官方网站的描述:

Cpplint 是一个命令行工具,用于检查 C/C++ 文件的风格问题,遵循 Google 的 C++ 风格指南。Cpplint 由 Google 公司在 google/styleguide 上开发和维护。

这个静态代码分析工具旨在使您的代码符合 Google 的风格指南。它是用 Python 编写的,可能会为某些项目引入不必要的依赖。修复建议以 EmacsEclipseVS7Junit 格式提供,也可以作为 sed 命令使用。

Cppcheck

这是官方文档中关于 Cppcheck 的介绍:

Cppcheck 是一个用于 C/C++代码的静态分析工具。它提供独特的代码分析,检测错误,重点检查未定义的行为和危险的编码结构。目标是尽量减少误报。Cppcheck 设计为即使代码有非标准语法(在嵌入式项目中常见),也能够进行分析。

这个工具特别擅长最小化误报,使其成为可靠的代码分析选项。它已经存在超过 14 年,并且仍在积极维护。如果你的代码与 Clang 不兼容,它尤其有用。

include-what-you-use

这是来自官方官网的 include-what-you-use 描述:

include-what-you-use 的主要目标是去除多余的#includes。它通过找出此文件(包括.cc 和.h 文件)中实际上不需要的#includes,并在可能的情况下用前置声明替换#includes 来实现这一点。

虽然在小型项目中,包含过多头文件似乎不是什么大问题,但避免不必要的头文件编译所节省的时间,在大型项目中会迅速积累。

这里是CMake博客中关于“Link what you use”的描述:

这是一个内置的 CMake 功能,使用 ld 和 ldd 的选项打印出可执行文件是否链接了超出实际需求的库。

静态分析在医疗、核能、航空、汽车和机械等行业中起着至关重要的作用,因为软件错误可能会威胁生命。明智的开发者也会在非关键环境中采用这些实践,尤其是当成本较低时。在构建过程中使用静态分析不仅比手动发现和修复错误更具成本效益,而且通过CMake启用也非常简单。我甚至可以说,对于任何质量敏感的软件(包括涉及开发者以外的其他人的软件),几乎没有理由跳过这些检查。

这个功能还通过专注于消除不必要的二进制文件,帮助加速构建时间。不幸的是,并非所有的错误都能在运行程序之前被检测到。幸运的是,我们可以采取额外的措施,像使用Valgrind,来深入了解我们的项目。

使用 Valgrind 进行动态分析

Valgrind (www.valgrind.org) 是一个用于构建动态分析工具的*nix 工具框架,这意味着它在程序运行时进行分析。它配备了各种工具,适用于多种类型的调查和检查。一些工具包括:

  • Memcheck:检测内存管理问题

  • Cachegrind:分析 CPU 缓存,并识别缓存未命中和其他问题

  • CallgrindCachegrind的扩展,提供关于调用图的额外信息

  • Massif:一个堆分析器,显示程序不同部分如何随时间使用堆

  • Helgrind:一个用于数据竞争问题的线程调试器

  • DRDHelgrind的一个较轻量、功能较为有限的版本

列表中的每个工具在需要时都非常有用。大多数系统包管理器都知道Valgrind,并可以轻松地在你的操作系统上安装它。如果你使用的是 Linux,它可能已经安装了。此外,官方网站还提供了源代码,供那些喜欢自己编译的用户使用。

我们的讨论将主要集中在Memcheck上,这是Valgrind套件中最常用的工具(当开发者提到Valgrind时,通常指的是ValgrindMemcheck)。我们将探讨如何与CMake一起使用它,这将使得如果以后需要使用其他工具时,更容易采用套件中的其他工具。

Memcheck

Memcheck对于调试内存问题非常宝贵,尤其是在 C++中,这个话题可能特别复杂。程序员对内存管理有广泛的控制,因此可能会犯各种错误。这些错误可能包括读取未分配或已经释放的内存,重复释放内存,甚至写入错误的地址。这些漏洞往往容易被忽视,甚至渗透到简单的程序中。有时,仅仅是忘记初始化一个变量,就足以导致问题。

调用Memcheck看起来像这样:

valgrind [valgrind-options] tested-binary [binary-options] 

MemcheckValgrind的默认工具,但你也可以明确指定它,如下所示:

valgrind --tool=memcheck tested-binary 

运行Memcheck会显著降低程序的运行速度;手册(见进一步阅读中的链接)表示,使用它的程序可能会变得比正常速度慢 10 到 15 倍。为了避免每次运行测试时都需要等待Valgrind,我们将创建一个单独的目标,在需要测试代码时从命令行调用。理想情况下,这个步骤应该在任何新代码合并到主代码库之前完成。你可以将这个步骤包含在一个早期的 Git 钩子中,或作为持续集成CI)流水线的一部分。

要为Valgrind创建自定义目标,可以在CMake生成阶段之后使用以下命令:

cmake --build <build-tree> -t valgrind 

下面是如何在CMake中添加这样的目标:

ch12/03-valgrind/cmake/Valgrind.cmake

function(AddValgrind target)
  find_program(VALGRIND_PATH valgrind REQUIRED)
  add_custom_target(valgrind
    COMMAND ${VALGRIND_PATH} --leak-check=yes
            $<TARGET_FILE:${target}>
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction() 

在这个例子中,我们定义了一个名为AddValgrindCMake函数,它接受要测试的目标(我们可以在多个项目中重复使用它)。这里发生了两件主要的事情:

  1. CMake会检查默认的系统路径以查找valgrind可执行文件,并将其路径存储在VALGRIND_PATH变量中。如果没有找到该二进制文件,REQUIRED关键字将导致配置中断并报错。

  2. 创建了一个名为valgrind的自定义目标。它会在指定的二进制文件上运行Memcheck,并且总是检查内存泄漏。

Valgrind选项可以通过多种方式设置:

  • ~/.valgrindrc文件中(在你的主目录下)

  • 通过$VALGRIND_OPTS环境变量

  • ./.valgrindrc文件中(在工作目录下)

这些文件按顺序进行检查。另外,请注意,只有在文件属于当前用户、是常规文件且没有标记为全局可写时,最后一个文件才会被考虑。这是一个安全机制,因为提供给 Valgrind 的选项可能会有潜在的危害。

为了使用 AddValgrind 函数,我们将其与 unit_tests 目标一起使用,因为我们希望在像单元测试这样的精细控制环境中运行它:

ch12/03-valgrind/test/CMakeLists.txt(片段)

# ...
add_executable(unit_tests calc_test.cpp run_test.cpp)
# ...
**include****(Valgrind)**
**AddValgrind****(unit_tests)** 

记住,使用 Debug 配置生成构建树可以让 Valgrind 访问调试信息,从而使输出更加清晰。

让我们看看这个在实践中是如何工作的:

# cmake -B <build tree> -S <source tree> -DCMAKE_BUILD_TYPE=Debug
# cmake --build <build-tree> -t valgrind 

这将配置项目,构建 sutunit_tests 目标,并开始执行 Memcheck,它将提供一般信息:

[100%] Built target unit_tests
==954== Memcheck, a memory error detector
==954== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==954== Using Valgrind-3.18.1 and LibVEX; rerun with -h for copyright info
==954== Command: ./unit_tests 

==954== 前缀包含进程的 ID,有助于区分 Valgrind 的注释和被测试进程的输出。

接下来,像往常一样运行测试,使用 gtest

[==========] Running 3 tests from 2 test suites.
[----------] Global test environment set-up.
...
[==========] 3 tests from 2 test suites ran. (42 ms total)
[  PASSED  ] 3 tests. 

最后,呈现一个总结:

==954==
==954== HEAP SUMMARY:
==954==     in use at exit: 1 bytes in 1 blocks
==954==   total heap usage: 209 allocs, 208 frees, 115,555 bytes allocated 

哎呀!我们仍然使用了至少 1 字节。通过 malloc()new 分配的内存没有与适当的 free()delete 操作匹配。看起来我们的程序中有内存泄漏。Valgrind 提供了更多细节来帮助找到它:

==954== 1 bytes in 1 blocks are definitely lost in loss record 1 of 1
==954==    at 0x483BE63: operator new(unsigned long) (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==954==    by 0x114FC5: run() (run.cpp:6)
==954==    by 0x1142B9: RunTest_RunOutputsCorrectEquations_Test::TestBody() (run_test.cpp:14) 

by 0x<address> 开头的行表示调用栈中的单独函数。我已将输出截断(它有来自 GTest 的噪声),以便集中显示有趣的部分——最顶层的函数和源代码引用 run()(run.cpp:6)

最后,总结信息出现在底部:

==954== LEAK SUMMARY:
==954==    definitely lost: 1 bytes in 1 blocks
==954==    indirectly lost: 0 bytes in 0 blocks
==954==      possibly lost: 0 bytes in 0 blocks
==954==    still reachable: 0 bytes in 0 blocks
==954==         suppressed: 0 bytes in 0 blocks
==954==
==954== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) 

Valgrind 非常擅长发现复杂问题。有时,它甚至可以更深入地挖掘出一些不容易归类的问题,这些问题会出现在 “possibly lost” 行中。

让我们看看 Memcheck 这次发现了什么问题:

ch12/03-valgrind/src/run.cpp

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

没错:突出显示的代码是有问题的。实际上,我们创建了一个对象,而在测试结束之前没有删除它。这正是为什么拥有广泛的测试覆盖非常重要的原因。

Valgrind 是一个有用的工具,但在复杂程序中,其输出可能会变得难以应对。实际上,有一种更有效地管理这些信息的方法——那就是 Memcheck-Cover 项目。

Memcheck-Cover

像 CLion 这样的商业 IDE 可以直接解析 Valgrind 的输出,使得通过图形界面浏览变得更加容易,无需在控制台中滚动。如果你的编辑器没有这个功能,第三方报告生成器可以提供更清晰的视图。由 David Garcin 开发的 Memcheck-Cover 通过生成 HTML 文件提供了更好的体验,如下图所示:

图 12.1:由 Memcheck-Cover 生成的报告

这个简洁的小项目可以在 GitHub 上找到 (github.com/Farigh/memcheck-cover);它需要 Valgrindgawk(GNU AWK 工具)。为了使用它,我们将在一个单独的 CMake 模块中准备一个设置函数。它将包含两部分:

  1. 获取和配置工具

  2. 添加一个自定义目标来运行Valgrind并生成报告

这是配置的样子:

ch12/04-memcheck/cmake/Memcheck.cmake

function(AddMemcheck target)
  include(FetchContent)
  FetchContent_Declare(
   memcheck-cover
   GIT_REPOSITORY https://github.com/Farigh/memcheck-cover.git
   GIT_TAG        release-1.2
  )
  FetchContent_MakeAvailable(memcheck-cover)
  set(MEMCHECK_PATH ${memcheck-cover_SOURCE_DIR}/bin) 

在第一部分中,我们遵循与常规依赖项相同的做法:包括FetchContent模块,并通过FetchContent_Declare指定项目的仓库和所需的 Git 标签。接下来,我们启动获取过程并配置二进制路径,使用FetchContent_Populate(由FetchContent_MakeAvailable隐式调用)设置的memcheck-cover_SOURCE_DIR变量。

函数的第二部分是创建目标以生成报告。我们将其命名为memcheck(这样如果出于某些原因希望保留两个选项,它就不会与之前的valgrind目标重叠):

ch12/04-memcheck/cmake/Memcheck.cmake(续)

 add_custom_target(memcheck
    COMMAND ${MEMCHECK_PATH}/memcheck_runner.sh -o
      "${CMAKE_BINARY_DIR}/valgrind/report"
      -- $<TARGET_FILE:${target}>
    COMMAND ${MEMCHECK_PATH}/generate_html_report.sh
      -i "${CMAKE_BINARY_DIR}/valgrind"
      -o "${CMAKE_BINARY_DIR}/valgrind"
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction() 

这一过程包括两个命令:

  1. 首先,我们将运行memcheck_runner.sh包装脚本,它将执行ValgrindMemcheck并将输出收集到通过-o参数提供的文件中。

  2. 然后,我们将解析输出并使用generate_html_report.sh生成报告。这个脚本需要通过-i-o参数提供输入和输出目录。

这两个步骤应该在CMAKE_BINARY_DIR工作目录中执行,这样单元测试二进制文件就可以通过相对路径访问文件(如果需要的话)。

我们需要在我们的 listfiles 中添加的最后一件事,当然是调用这个函数:

ch12/04-memcheck/test/CMakeLists.txt(片段)

include(Memcheck)
AddMemcheck(unit_tests) 

在使用Debug配置生成构建系统后,我们可以使用以下命令构建目标:

# cmake -B <build tree> -S <source tree> -DCMAKE_BUILD_TYPE=Debug
# cmake --build <build-tree> -t memcheck 

然后,我们可以享受生成的格式化报告,它作为 HTML 页面生成。

总结

“你将花更多的时间阅读代码,而不是编写代码,所以要优化可读性而非可写性。” 这一原则在各种关于清洁代码的书籍中都有提及。许多软件开发人员的经验也支持这一点,这就是为什么连空格、换行符的数量,以及#import语句的顺序等小细节都要标准化。这种标准化不仅仅是为了精益求精;它是为了节省时间。遵循本章的做法,你可以忘记手动格式化代码。当你构建代码时,格式会自动调整,这本来就是你测试代码时要做的一步。借助ClangFormat,你可以确保格式符合你选择的标准。

除了简单的空格调整外,代码还应该遵循许多其他规范。这就是 clang-tidy 的用武之地。它帮助执行你团队或组织所达成的编码规范。我们深入讨论了这个静态检查工具,还涉及了其他选项,如CpplintCppcheck、include-what-you-use 和 Link What You Use。由于静态链接器的速度相对较快,我们可以将它们添加到构建过程中,投入非常小,而且通常非常值得。

我们还检查了Valgrind工具,重点介绍了Memcheck,它能帮助识别内存管理中的问题,如不正确的读取和写入。这个工具对于避免长时间的手动调试和防止生产环境中的 bug 非常宝贵。我们介绍了一种方法,通过Memcheck-Cover(一个 HTML 报告生成器)让Valgrind的输出更具用户友好性。在无法运行 IDE 的环境中,像 CI 流水线,这尤其有用。

本章只是一个起点。许多其他工具,无论是免费的还是商业的,都可以帮助您提高代码质量。探索它们,找到最适合您的工具。在下一章中,我们将深入探讨生成文档的过程。

进一步阅读

欲了解更多信息,您可以参考以下链接:

发表评论!

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

第十三章:生成文档

高质量的代码不仅仅是编写得好、运行正常并经过测试——它还需要有完善的文档。文档能够帮助我们分享可能会丢失的信息,描绘更大的图景,提供上下文,揭示意图,最终——教育外部用户和维护者。

你还记得上次加入一个新项目时,迷失在一堆目录和文件中几个小时吗?这一问题是可以避免的。真正优秀的文档可以让一个完全陌生的人在几秒钟内找到他们需要的代码行。可惜,缺乏文档的问题经常被忽视。难怪如此——编写文档需要相当的技能,而我们中的许多人并不擅长这一点。此外,文档和代码很容易变得过时。除非实施严格的更新和审查流程,否则很容易忘记文档也需要关注。

一些团队(为了节省时间或因为管理层的鼓励)采用了编写自文档化代码的做法。通过为文件名、函数、变量等选择有意义且易读的标识符,他们希望避免编写文档的麻烦。即使是最好的函数签名,也不能确保传达所有必要的信息——例如,int removeDuplicates();很有描述性,但它并没有说明返回的是什么。它可能是发现的重复项数量,剩余项的数量,或其他什么——这并不明确。虽然良好的命名习惯是绝对正确的,但它不能替代用心的文档编写。记住:没有免费的午餐。

为了简化工作,专业人员使用自动文档生成器,这些生成器会分析代码和源文件中的注释,生成各种格式的全面文档。将此类生成器添加到 CMake 项目中非常简单——让我们来看看如何做!

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

  • 将 Doxygen 添加到你的项目中

  • 使用现代化外观生成文档

  • 使用自定义 HTML 增强输出

技术要求

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

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

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

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

将 Doxygen 添加到你的项目中

用于从 C++ 源代码生成文档的最成熟和最流行的工具之一就是 Doxygen。当我说“成熟”时,我是认真的:第一个版本是由 Dimitri van Heesch 于 1997 年 10 月发布的。此后,它得到了巨大的发展,并得到了几乎 250 位贡献者的积极支持(github.com/doxygen/doxygen)。

你可能会担心将 Doxygen 纳入没有从一开始就使用文档生成的大型项目中的挑战。的确,注释每个函数的任务可能看起来非常繁重。然而,我鼓励你从小处开始。专注于记录你最近在最新提交中工作的元素。记住,即使是部分完成的文档也比完全没有要好,而且它逐渐有助于建立你项目的更全面的理解。

Doxygen 可以生成以下格式的文档:

  • 超文本标记语言 (HTML)

  • 富文本格式 (RTF)

  • 可移植文档格式 (PDF)

  • Lamport TeX (LaTeX)

  • PostScript (PS)

  • Unix 手册(man 页面)

  • 微软编译的 HTML 帮助 (.CHM)

如果你在代码中使用注释按照 Doxygen 指定的格式提供额外信息,它会解析这些注释以丰富输出文件。此外,代码结构还将被分析以生成有用的图表和图示。后者是可选的,因为它需要外部的 Graphviz 工具(graphviz.org/)。

开发者应该首先考虑以下问题:项目的用户只会接收文档,还是他们会自己生成文档(例如在从源代码构建时)?第一个选项意味着文档会与二进制文件一起分发,或者在线提供,或者(不那么优雅地)与源代码一起提交到代码库中。

这个考虑非常重要,因为如果你希望用户在构建过程中生成文档,他们的系统中必须存在这些依赖项。这并不是一个大问题,因为 Doxygen 和 Graphviz 可以通过大多数包管理器获得,所需要的只是一个简单的命令,比如针对 Debian 系统的命令:

apt-get install doxygen graphviz 

Windows 版本的二进制文件也可以使用(请查看项目网站的进一步阅读部分)。

然而,一些用户可能不愿意安装这些工具。我们必须决定是为用户生成文档,还是让他们在需要时添加依赖项。项目也可以像第九章中描述的那样,自动为用户添加这些依赖项,管理 CMake 中的依赖项。请注意,Doxygen 是使用 CMake 构建的,因此如果需要,你已经知道如何从源代码编译它。

当系统中安装了 Doxygen 和 Graphviz 时,我们可以将文档生成功能添加到项目中。与一些在线资料所建议的相反,这并不像看起来那么困难或复杂。我们无需创建外部配置文件,提供 Doxygen 可执行文件的路径,或添加自定义目标。自 CMake 3.9 起,我们可以使用来自 FindDoxygen 查找模块的 doxygen_add_docs() 函数,它会设置文档目标。

函数签名如下:

doxygen_add_docs(targetName [sourceFilesOrDirs...]
  [ALL] [WORKING_DIRECTORY dir] [COMMENT comment]) 

第一个参数指定目标名称,我们需要在 cmake-t 参数中显式构建该目标(生成构建树之后),如下所示:

# cmake --build <build-tree> -t targetName 

或者,我们可以通过添加 ALL 参数来确保始终构建文档,尽管通常不需要这样做。WORKING_DIRECTORY 选项非常简单;它指定了命令应在其中执行的目录。由 COMMENT 选项设置的值会在文档生成开始前显示,提供有用的信息或说明。

我们将遵循前几章的做法,创建一个包含辅助函数的工具模块(以便在其他项目中重用),如下所示:

ch13/01-doxygen/cmake/Doxygen.cmake

function(Doxygen input output)
  find_package(Doxygen)
  if (NOT DOXYGEN_FOUND)
    add_custom_target(doxygen COMMAND false
      COMMENT "Doxygen not found")
    return()
  endif()
  set(DOXYGEN_GENERATE_HTML YES)
  set(DOXYGEN_HTML_OUTPUT
    ${PROJECT_BINARY_DIR}/${output})
  doxygen_add_docs(doxygen
      ${PROJECT_SOURCE_DIR}/${input}
      COMMENT "Generate HTML documentation"
  )
endfunction() 

该函数接受两个参数——inputoutput 目录——并创建一个自定义的 doxygen 目标。以下是发生的过程:

  1. 首先,我们使用 CMake 内建的 Doxygen 查找模块来确定系统中是否安装了 Doxygen。

  2. 如果 Doxygen 不可用,我们会创建一个虚拟的 doxygen 目标,向用户提示并运行 false 命令,(在类 Unix 系统中)返回 1,导致构建失败。此时,我们会用 return() 终止该函数。

  3. 如果 Doxygen 可用,我们将其配置为在提供的 output 目录中生成 HTML 输出。Doxygen 是极其可配置的(更多信息请参阅官方文档)。要设置任何选项,只需按照示例调用 set() 并在选项名前加上 DOXYGEN_ 前缀。

  4. 设置实际的 doxygen 目标。所有 DOXYGEN_ 变量将被转发到 Doxygen 的配置文件中,并从提供的源树中的 input 目录生成文档。

如果您的文档需要由用户生成,步骤 2 可能应该涉及安装 Doxygen。

要使用这个函数,我们可以将其集成到项目的主列表文件中,如下所示:

ch13/01-doxygen/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Doxygen CXX)
enable_testing()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
add_subdirectory(src bin)
**include****(Doxygen)**
**Doxygen(src docs)** 

一点也不难!构建 doxygen 目标会生成类似这样的 HTML 文档:

图 13.1:使用 Doxygen 生成的类参考

为了在成员函数文档中添加重要细节,我们可以在头文件中用适当的注释将 C++ 方法声明之前,像这样:

ch13/01-doxygen/src/calc.h(片段)

 /**
    Multiply... Who would have thought?
    @param a the first factor
    @param b the second factor
    @result The product
   */
   int Multiply(int a, int b); 

这种格式被称为 Javadoc。重要的是,注释块应该以双星号开始:/**。更多信息可以在 Doxygen 的 docblocks 描述中找到(参见 Further reading 部分的链接)。带有这种注释的 Multiply 函数将如下面的图所示呈现:

图 13.2:参数和结果的注释

如前所述,如果安装了 Graphviz,Doxygen 会自动检测并生成依赖图,正如下图所示:

图 13.3:由 Doxygen 生成的继承和协作图

通过直接从源代码生成文档,我们建立了一种在开发周期中随代码更改迅速更新的流程。此外,在代码审查过程中,任何被忽视的注释更新也很容易被发现。

许多开发者表达了对 Doxygen 提供的设计显得过时的担忧,这让他们不愿意将生成的文档展示给客户。然而,这个问题有一个简单的解决方案。

生成现代化外观的文档

使用干净、清新的设计对项目进行文档化非常重要。毕竟,如果我们为我们的前沿项目投入了这么多精力编写高质量的文档,用户必须意识到这一点。尽管 Doxygen 功能丰富,但它并不以遵循最新视觉趋势而著称。然而,重新设计其外观并不需要大量的努力。

幸运的是,一位名为 jothepro 的开发者创建了一个名为 doxygen-awesome-css 的主题,它提供了一个现代化且可定制的设计。这个主题在下面的截图中展示:

图 13.4:doxygen-awesome-css 主题下的 HTML 文档

该主题不需要任何额外的依赖项,可以通过其 GitHub 页面轻松获取:github.com/jothepro/doxygen-awesome-css

尽管一些在线资源推荐使用多种应用程序的组合,比如通过 Breathe 和 Exhale 扩展将 Doxygen 的输出与 Sphinx 配合使用,但这种方法可能会很复杂且依赖较多(例如需要 Python)。通常来说,更简洁的方法更为实际,尤其是对于那些并非每个成员都深度了解 CMake 的团队来说。

我们可以通过自动化流程高效实现这一主题。让我们看看如何通过添加一个新宏来扩展我们的 Doxygen.cmake 文件以使用它:

ch13/02-doxygen-nice/cmake/Doxygen.cmake(片段)

macro(UseDoxygenAwesomeCss)
  include(FetchContent)
  FetchContent_Declare(doxygen-awesome-css
    GIT_REPOSITORY
      https://github.com/jothepro/doxygen-awesome-css.git
    GIT_TAG
      V2.3.1
  )
  FetchContent_MakeAvailable(doxygen-awesome-css)
  set(DOXYGEN_GENERATE_TREEVIEW     YES)
  set(DOXYGEN_HAVE_DOT              YES)
  set(DOXYGEN_DOT_IMAGE_FORMAT      svg)
  set(DOXYGEN_DOT_TRANSPARENT       YES)
  set(DOXYGEN_HTML_EXTRA_STYLESHEET
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome.css)
endmacro() 

我们已经从书中的前几章了解了所有这些命令,但为了确保完全清晰,让我们再重复一遍发生了什么:

  1. 使用 FetchContent 模块从 Git 获取 doxygen-awesome-css

  2. 配置 Doxygen 的额外选项(这些是主题的 README 文件中专门推荐的)

  3. 将主题的 css 文件复制到 Doxygen 的输出目录

如你所想,最好在Doxygen函数中调用这个宏,并在doxygen_add_docs()之前,如下所示:

ch13/02-doxygen-nice/cmake/Doxygen.cmake(片段)

function(Doxygen input output)
# ...
  **UseDoxygenAwesomeCss()**
  doxygen_add_docs (...)
endfunction()
macro(UseDoxygenAwesomeCss)
# ...
endmacro() 

记住,宏中的所有变量都在调用函数的作用域内设置。

我们现在可以在生成的 HTML 文档中享受现代风格,并骄傲地与世界分享它。然而,我们的主题提供了一些 JavaScript 模块来增强体验。我们该如何包含它们呢?

使用自定义 HTML 增强输出

Doxygen Awesome 提供了一些附加功能,可以通过在文档头部的 HTML <head> 标签内包含一些 JavaScript 片段来启用。它们非常有用,因为它们允许在亮色模式和暗色模式之间切换,添加代码片段的复制按钮,段落标题的永久链接,以及互动目录。

然而,实现这些功能需要将额外的代码复制到输出目录,并将其包含在生成的 HTML 文件中。

这是需要在</head>标签之前包含的 JavaScript 代码:

ch13/cmake/extra_headers

<script type="text/javascript" src="img/$relpath^doxygen-awesome-darkmode-toggle.js"></script>
<script type="text/javascript" src="img/$relpath^doxygen-awesome-fragment-copy-button.js"></script>
<script type="text/javascript" src="img/$relpath^doxygen-awesome-paragraph-link.js"></script>
<script type="text/javascript" src="img/$relpath^doxygen-awesome-interactive-toc.js"></script>
<script type="text/javascript">
    DoxygenAwesomeDarkModeToggle.init()
    DoxygenAwesomeFragmentCopyButton.init()
    DoxygenAwesomeParagraphLink.init()
    DoxygenAwesomeInteractiveToc.init()
</script> 

如你所见,这段代码首先会包含一些 JavaScript 文件,然后初始化不同的扩展。不幸的是,这段代码不能简单地添加到某个变量中。相反,我们需要用自定义文件覆盖默认的头文件。这个覆盖可以通过在 Doxygen 的HTML_HEADER配置变量中提供文件路径来完成。

若要创建一个自定义头文件而不硬编码整个内容,可以使用 Doxygen 的命令行工具生成默认的头文件,并在生成文档之前编辑它:

doxygen -w html header.html footer.html style.css 

虽然我们不会使用或修改footer.htmlstyle.css,但它们是必需的参数,因此我们仍然需要创建它们。

最后,我们需要自动将</head>标签与ch13/cmake/extra_headers文件的内容进行连接,以包含所需的 JavaScript。这可以通过 Unix 命令行工具sed来完成,它将原地编辑header.html文件:

sed -i '/<\/head>/r ch13/cmake/extra_headers' header.html 

现在我们需要用 CMake 语言将这些步骤编码。以下是实现这一目标的宏:

ch13/02-doxygen-nice/cmake/Doxygen.cmake(片段)

macro(UseDoxygenAwesomeExtensions)
  set(DOXYGEN_HTML_EXTRA_FILES
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome-darkmode-toggle.js
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome-fragment-copy-button.js
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome-paragraph-link.js
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome-interactive-toc.js
  )
  execute_process(
   COMMAND doxygen -w html header.html footer.html style.css
   WORKING_DIRECTORY ${PROJECT_BINARY_DIR}
  )
  execute_process(
   COMMAND sed -i
   "/<\\/head>/r ${PROJECT_SOURCE_DIR}/cmake/extra_headers"   
   header.html
   WORKING_DIRECTORY ${PROJECT_BINARY_DIR}
  )
  set(DOXYGEN_HTML_HEADER ${PROJECT_BINARY_DIR}/header.html)
endmacro() 

这段代码看起来很复杂,但仔细检查后,你会发现它其实非常直接。它的功能如下:

  1. 将四个 JavaScript 文件复制到输出目录

  2. 执行doxygen命令以生成默认的 HTML 文件

  3. 执行sed命令以将所需的 JavaScript 注入头文件

  4. 使用自定义版本覆盖默认头文件

为了完成集成,在启用基本样式表之后,调用这个宏:

ch13/02-doxygen-nice/cmake/Doxygen.cmake(片段)

function(Doxygen input output)
 # …
  UseDoxygenAwesomeCss()
  **UseDoxygenAwesomeExtensions()**
# …
endfunction() 

该示例的完整代码以及实际示例可以在本书的在线仓库中找到。像往常一样,我建议在实际环境中查看和探索这些示例。

其他文档生成工具

本书没有涉及其他许多工具,因为我们专注于 CMake 支持的项目。不过,其中一些工具可能更适合你的使用场景。如果你感兴趣,可以访问我发现的两个有趣项目的官方网站:

  • Adobe 的 Hyde (github.com/adobe/hyde):Hyde 针对 Clang 编译器,生成的 Markdown 文件可以被 Jekyll (jekyllrb.com/) 等工具使用,Jekyll 是一个由 GitHub 支持的静态页面生成器。

  • Standardese (github.com/standardese/standardese):这个工具使用 libclang 来编译代码,并提供 HTML、Markdown、LaTex 和 man 页的输出。它的目标(相当大胆)是成为下一个 Doxygen。

总结

在这一章中,我们深入探讨了如何将强大的文档生成工具 Doxygen 添加到你的 CMake 项目中,并提升其吸引力。尽管这项任务看起来可能令人生畏,但实际上相当可控,并且显著提升了你解决方案中的信息流动和清晰度。如你所见,花时间添加和维护文档是值得的,尤其是当你或你的团队成员在理解应用中的复杂关系时。

在探索如何使用 CMake 内置的 Doxygen 支持来生成实际文档之后,我们稍微转了个方向,确保文档不仅具有可读性,还具有可理解性。

由于过时的设计可能让眼睛感到不适,我们探讨了生成的 HTML 的替代外观。这是通过使用 Doxygen Awesome 扩展来完成的。为了启用其附带的增强功能,我们通过添加必要的 JavaScript 自定义了标准头部。

通过生成文档,你可以确保它与实际代码的接近度,使得在逻辑同步的情况下,维护书面解释变得更加容易,尤其是当它们都在同一个文件中时。此外,作为程序员,你可能需要同时处理大量任务和细节。文档作为记忆辅助工具,帮助你保持和回忆项目的复杂性。记住,“即使是最短的铅笔也比最强的记忆要长。”做个对自己有益的事——把长的事情写下来,成功就会跟随而来。

总结来说,本章强调了 Doxygen 在你的项目管理工具包中的价值,帮助团队内部的理解和沟通。

在下一章中,我将带你了解如何通过 CMake 自动化打包和安装项目,进一步提升你的项目管理技能。

进一步阅读

加入我们社区的 Discord

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

discord.com/invite/vXN53A7ZcA

第十四章:安装和打包

我们的项目已经构建、测试并且文档化完毕。现在,终于到了将其发布给用户的时候。本章主要集中在我们需要采取的最后两步:安装和打包。这些是建立在我们迄今为止所学的所有内容之上的高级技术:管理目标及其依赖关系、临时使用要求、生成器表达式等等。

安装使我们的项目可以被发现并在系统范围内访问。我们将讨论如何导出目标以供其他项目使用而无需安装,以及如何安装我们的项目以便轻松地在系统范围内访问。我们还将学习如何配置项目,使其能够自动将各种工件类型放置到适当的目录中。为了处理更高级的场景,我们将介绍一些低级命令,用于安装文件和目录,以及执行自定义脚本和 CMake 命令。

接下来,我们将探讨如何设置可重用的 CMake 包,其他项目可以通过 find_package() 命令进行发现。我们将解释如何确保目标及其定义不局限于特定的文件系统位置。我们还将讨论如何编写基本的和高级的 配置文件,以及与包关联的 版本文件。然后,为了使事物更具模块化,我们将简要介绍组件的概念,无论是在 CMake 包还是在 install() 命令方面。所有这些准备工作将为本章的最终部分铺平道路:使用 CPack 生成归档、安装程序、捆绑包和包,这些包能够被不同操作系统中的各种包管理器识别。这些包可以分发预构建的工件、可执行文件和库。这是最终用户开始使用我们软件的最简单方法。

在本章中,我们将覆盖以下主要主题:

  • 无需安装即可导出

  • 在系统上安装项目

  • 创建可重用的包

  • 定义组件

  • 使用 CPack 打包

技术要求

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

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

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

要安装示例,请使用以下命令:

cmake --install <build tree> 

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

无需安装即可导出

我们如何让项目A的目标对使用它的项目B可用?通常,我们会使用find_package()命令,但这要求创建一个包并将其安装到系统中。虽然有用,但这种方法需要一些工作。有时,我们只需要一种快速构建项目并使其目标可供其他项目使用的方法。

一种节省时间的方法是包含项目BA的主列表文件,该文件已经包含了所有目标定义。然而,这个文件也可能包含全局配置、带有副作用的 CMake 命令、额外的依赖项,甚至可能包含不适合B的目标(比如单元测试)。因此,这不是最好的方法。相反,我们可以为使用项目B提供一个目标导出文件,让它通过include()命令来包含:

cmake_minimum_required(VERSION 3.26.0)
project(B)
include(/path/to/A/TargetsOfA.cmake) 

这将使用像add_library()add_executable()这样的命令,定义所有目标并设置正确的属性。

你必须在TARGETS关键字后指定所有要导出的目标,并在FILE后提供目标文件名。其他参数是可选的:

export(**TARGETS [target1 [target2 [...]]]**
       [NAMESPACE <namespace>] [APPEND] **FILE** **<path>**
       [EXPORT_LINK_INTERFACE_LIBRARIES]
) 

这是对各个参数的解释:

  • NAMESPACE建议用来指示目标是从其他项目导入的。

  • APPEND防止 CMake 在写入前清除文件内容。

  • EXPORT_LINK_INTERFACE_LIBRARIES导出目标链接依赖关系(包括导入的和配置特定的变体)。

让我们将此导出方法应用到Calc库示例,它提供了两个简单的方法:

ch14/01-export/src/include/calc/basic.h

#pragma once
int Sum(int a, int b);
int Multiply(int a, int b); 

我们需要声明Calc目标,以便有东西可以导出:

ch14/01-export/src/CMakeLists.txt

add_library(calc STATIC basic.cpp)
target_include_directories(calc INTERFACE include) 

然后,为了生成导出文件,我们使用export(TARGETS)命令:

ch14/01-export/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.26)
project(ExportCalcCXX)
add_subdirectory(src bin)
set(EXPORT_DIR "${CMAKE_CURRENT_BINARY_DIR}/cmake")
export(TARGETS calc
  FILE "${EXPORT_DIR}/CalcTargets.cmake"
  NAMESPACE Calc::
) 

我们的导出目标声明文件将位于构建树的cmake子目录中(遵循.cmake文件的约定)。为了避免稍后重复此路径,我们将其设置为EXPORT_DIR变量。然后,我们调用export()生成目标声明文件CalcTargets.cmake,其中包含calc目标。对于包含此文件的项目,它将作为Calc::calc可见。

请注意,此导出文件还不是一个包。更重要的是,这个文件中的所有路径都是绝对路径并且硬编码为构建树中的路径,导致它们无法移动(在理解可移动目标的问题一节中讨论)。

export()命令也有一个简化版本,使用EXPORT关键字:

export(EXPORT <export> [NAMESPACE <namespace>] [FILE <path>]) 

然而,它需要预定义导出的名称,而不是一个导出目标的列表。此类<export>实例是由install(TARGETS)创建的目标名称列表(我们将在安装逻辑目标一节中讨论此命令)。

这是一个小示例,演示了这种简写是如何在实践中使用的:

ch14/01-export/CMakeLists.txt(续)

install(TARGETS calc EXPORT CalcTargets)
export(EXPORT CalcTargets
  FILE "${EXPORT_DIR}/CalcTargets2.cmake"
  NAMESPACE Calc::
) 

这段代码的工作方式与之前的示例类似,但现在它在export()install()命令之间共享一个单一的目标列表。

两种生成导出文件的方法产生类似的结果。它们包括一些样板代码和几行定义目标的代码。在将<build-tree>设置为构建树路径后,它们将创建类似于以下的目标导出文件

/cmake/CalcTargets.cmake (片段)

# Create imported target Calc::calc
add_library(Calc::calc STATIC IMPORTED)
set_target_properties(Calc::calc PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES
  **"/<source-tree>/include"**
)
# Import target "Calc::calc" for configuration ""
set_property(TARGET Calc::calc APPEND PROPERTY
  IMPORTED_CONFIGURATIONS NOCONFIG
)
set_target_properties(Calc::calc PROPERTIES
  IMPORTED_LINK_INTERFACE_LANGUAGES_NOCONFIG "CXX"
  IMPORTED_LOCATION_NOCONFIG "**/<build-tree>/libcalc.a**"
) 

通常,我们不会编辑或打开此文件,但需要注意的是,路径将被硬编码在其中(请参见高亮行)。在当前形式下,构建的项目无法重新定位。要改变这一点,需要一些额外的步骤。在下一节中,我们将解释什么是重新定位以及为什么它很重要。

将项目安装到系统中

第一章CMake 入门中,我们提到 CMake 为将构建的项目安装到系统中提供了命令行模式:

cmake --install <dir> [<options>] 

这里,<dir>是生成的构建树的路径(必需)。<options>包括:

  • --config <cfg>:选择多配置生成器的构建配置。

  • --component <comp>:将安装限制为指定的组件。

  • --default-directory-permissions <permissions>:设置已安装目录的默认权限(以<u=rwx,g=rx,o=rx>格式)。

  • --install-prefix <prefix>:指定非默认的安装路径(存储在CMAKE_INSTALL_PREFIX变量中)。在类 Unix 系统上默认为/usr/local,在 Windows 上默认为c:/Program Files/${PROJECT_NAME}。在 CMake 3.21 之前,您必须使用一个不太明确的选项:--prefix <prefix>

  • -v, --verbose:增加输出的详细程度(也可以通过设置VERBOSE环境变量实现)。

安装通常涉及将生成的产物和必要的依赖项复制到系统目录中。使用 CMake 为所有 CMake 项目引入了一个方便的安装标准,并提供了几个额外的好处:

  • 它为不同类型的产物提供平台特定的安装路径(遵循GNU 编码标准)。

  • 它通过生成目标导出文件来增强安装过程,允许其他项目直接重用项目的目标。

  • 它通过配置文件创建可发现的包,包装目标导出文件以及作者定义的特定于包的 CMake 宏和函数。

这些功能非常强大,因为它们节省了大量时间,并简化了以这种方式准备的项目的使用。执行基本安装的第一步是将构建的产物复制到目标目录。这将引导我们进入install()命令及其各种模式:

  • install(``TARGETS): 该命令用于安装输出的产物,如库文件和可执行文件。

  • install(FILES|PROGRAMS):安装单个文件并设置它们的权限。这些文件不需要是任何逻辑目标的一部分。

  • install(DIRECTORY):此命令安装整个目录。

  • install(SCRIPT|CODE):在安装过程中运行 CMake 脚本或代码片段。

  • install(EXPORT):此命令生成并安装目标导出文件。

  • install(RUNTIME_DEPENDENCY_SET <set-name> [...]):此命令安装项目中定义的运行时依赖集。

  • install(IMPORTED_RUNTIME_ARTIFACTS <target>... [...]):此命令查询导入的目标的运行时工件并安装它们。

将这些命令添加到你的列表文件中会在构建树中生成一个 cmake_install.cmake 文件。虽然可以通过 cmake -P 手动调用此脚本,但不推荐这么做。该文件是 CMake 在执行 cmake --install 时内部使用的。

每种 install() 模式都有一组全面的选项,其中一些选项在不同模式间是共享的:

  • DESTINATION:此选项指定安装路径。相对路径会与 CMAKE_INSTALL_PREFIX 一起使用,而绝对路径会按原样使用(并且不被 cpack 支持)。

  • PERMISSIONS:此选项设置支持平台上的文件权限。可用的值包括 OWNER_READOWNER_WRITEOWNER_EXECUTEGROUP_READGROUP_WRITEGROUP_EXECUTEWORLD_READWORLD_WRITEWORLD_EXECUTESETUIDSETGID。可以通过 CMAKE_INSTALL_DEFAULT_DIRECTORY_PERMISSIONS 变量设置安装时创建的默认目录权限。

  • CONFIGURATIONS:此选项指定配置(DebugRelease)。跟随此关键字的选项仅在当前构建配置位于列表中时才适用。

  • OPTIONAL:如果安装的文件不存在,则不会报错。

两个共享选项,COMPONENTEXCLUDE_FROM_ALL,用于特定组件的安装。这些选项将在本章稍后的定义组件部分讨论。现在,让我们先看看第一个安装模式:install(TARGETS)

安装逻辑目标

通过 add_library()add_executable() 定义的目标可以通过 install(TARGETS) 命令轻松安装。这意味着将构建系统生成的工件复制到适当的目标目录,并为它们设置合适的文件权限。此模式的通用签名如下:

install(TARGETS <target>... [EXPORT <export-name>]
        [<output-artifact-configuration> ...]
        [INCLUDES DESTINATION [<dir> ...]]
) 

在初始模式指定符(即 TARGETS)之后,我们必须提供我们希望安装的目标列表。在这里,我们可以选择性地通过 EXPORT 选项将它们分配给一个命名的导出,该导出可以在 export(EXPORT)install(EXPORT) 中使用,以生成目标导出文件。接着,我们需要配置输出工件的安装(按类型分组)。可选地,我们还可以提供一个目录列表,这些目录将添加到每个目标的 INTERFACE_INCLUDE_DIRECTORIES 属性的目标导出文件中。

[<output-artifact-configuration>...] 提供了一组配置块的列表。单个块的完整语法如下:

**<TYPE>** [DESTINATION <dir>]
       [PERMISSIONS permissions...]
       [CONFIGURATIONS [Debug|Release|...]]
       [COMPONENT <component>]
       [NAMELINK_COMPONENT <component>]
       [OPTIONAL] [EXCLUDE_FROM_ALL]
       [NAMELINK_ONLY|NAMELINK_SKIP] 

该命令要求每个输出产物块以<TYPE>开始(这是唯一必需的元素)。CMake 识别几种类型:

  • ARCHIVE:静态库(.a)和 Windows 系统的 DLL 导入库(.lib)。

  • LIBRARY:共享库(.so),但不包括 DLL。

  • RUNTIME:可执行文件和 DLL。

  • OBJECTS:来自OBJECT库的目标文件

  • FRAMEWORK:具有FRAMEWORK属性设置的静态库和共享库(这将使它们排除在ARCHIVELIBRARY之外)。这是特定于 macOS 的。

  • BUNDLE:标记为MACOSX_BUNDLE的可执行文件(也不属于RUNTIME)。

  • FILE_SET <set>:目标指定的<set>文件集中的文件。可以是 C++头文件或 C++模块头文件(自 CMake 3.23 起)。

  • PUBLIC_HEADERPRIVATE_HEADERRESOURCE:在目标属性中指定的文件,名称相同(在 Apple 平台上,它们应该设置在FRAMEWORKBUNDLE目标中)。

CMake 文档声称,如果你只配置了一种产物类型(例如LIBRARY),则仅安装这种类型。对于 CMake 3.26.0 版本而言,这不准确:所有产物都会像使用默认选项一样安装。可以通过为所有不需要的产物类型指定<TYPE> EXCLUDE_FROM_ALL来解决此问题。

一个install(TARGETS)命令可以包含多个产物配置块。然而,需要注意的是,每次调用时只能指定一种类型。也就是说,如果你想为DebugRelease配置设置不同的ARCHIVE产物目标路径,那么必须分别执行两次install(TARGETS ... ARCHIVE)调用。

你也可以省略类型名称并为所有产物指定选项。安装时将会对这些目标产生的每个文件执行安装,无论其类型如何:

install(TARGETS executable, static_lib1
  DESTINATION /tmp
) 

在许多情况下,你不需要显式地提供DESTINATION,因为有内建的默认值,但在处理不同平台时,有一些注意事项需要记住。

利用不同平台的默认安装路径

当 CMake 安装你的项目文件时,它会将文件复制到系统中的特定目录。不同的文件类型应该放在不同的目录中。该目录由以下公式确定:

${CMAKE_INSTALL_PREFIX} + ${DESTINATION} 

如前一节所述,你可以显式地提供安装的DESTINATION组件,或者让 CMake 根据产物类型使用内建的默认值:

产物类型 内建默认值 安装目录变量
RUNTIME bin CMAKE_INSTALL_BINDIR
LIBRARY``ARCHIVE lib CMAKE_INSTALL_LIBDIR
PUBLIC_HEADER``PRIVATE_HEADER``FILE_SETtype HEADERS include CMAKE_INSTALL_INCLUDEDIR

表 14.1:每种产物类型的默认目标路径

虽然默认路径非常有用,但并非总是适用。例如,CMake 默认将库的DESTINATION设置为lib。库的完整路径会被计算为 Unix 类系统上的/usr/local/lib,在 Windows 上则是类似C:\Program Files (x86)\<project-name>\lib的路径。然而,对于支持多架构的 Debian 来说,这并不理想,因为它需要一个架构特定的路径(例如,i386-linux-gnu),当INSTALL_PREFIX/usr时。为每个平台确定正确的路径是 Unix 类系统的常见挑战。为了解决这个问题,可以遵循GNU 编码标准,相关链接会在本章最后的进一步阅读部分提供。

我们可以通过设置CMAKE_INSTALL_<DIRTYPE>DIR变量来覆盖每个值的默认目标路径。与其开发算法来检测平台并为安装目录变量分配适当的路径,不如使用 CMake 的GNUInstallDirs工具模块。该模块通过相应地设置安装目录变量来处理大多数平台。只需在任何install()命令之前通过include()包含该模块,就可以完成配置。

需要自定义配置的用户可以通过命令行参数覆盖安装目录变量,如下所示:

-DCMAKE_INSTALL_BINDIR=/path/in/the/system 

然而,安装库的公共头文件仍然存在挑战。让我们来探讨一下原因。

处理公共头文件

在 CMake 中管理公共头文件时,最佳实践是将其存储在一个能表示其来源并引入命名空间的目录中,例如/usr/local/include/calc。这使得它们可以在 C++项目中通过包含指令使用:

#include <calc/basic.h> 

大多数预处理器将尖括号指令解释为请求扫描标准系统目录。我们可以使用GNUInstallDirs模块自动填充安装路径中的DESTINATION部分,确保头文件最终被放置在include目录中。

从 CMake 3.23.0 开始,我们可以通过target_sources()命令和FILE_SET关键字显式地添加要安装到适当目标的头文件。这个方法更为推荐,因为它处理了头文件的重定位问题。以下是语法:

target_sources(<target>
  [<PUBLIC|PRIVATE|INTERFACE>
   **[FILE_SET <name> TYPE <type> [BASE_DIR <dir>] FILES]**
   <files>...
  ]...
) 

假设我们的头文件位于src/include/calc目录中,以下是一个实际的示例:

ch14/02-install-targets/src/CMakeLists.txt (片段)

add_library(calc STATIC basic.cpp)
target_include_directories(calc INTERFACE include)
target_sources(calc PUBLIC FILE_SET HEADERS
                           BASE_DIRS include
                           FILES include/calc/basic.h
) 
 target file set called HEADERS. We’re using a special case here: if the name of the file set matches one of the available types, CMake will assume we want the file set to be of such type, eliminating the need to define the type explicitly. If you use a different name, remember to define the FILE_SET's type with the appropriate TYPE <TYPE> keyword.

在定义文件集后,我们可以像这样在安装命令中使用它:

ch14/02-install-targets/src/CMakeLists.txt (续)

...
include(GNUInstallDirs)
install(TARGETS calc ARCHIVE FILE_SET HEADERS) 

我们包含GNUInstallDirs模块并配置calc静态库及其头文件的安装。在安装模式下运行cmake,如预期那样工作:

# cmake -S <source-tree> -B <build-tree>
# cmake --build <build-tree>
# cmake --install <build-tree>
-- Install configuration: ""
-- Installing: /usr/local/lib/libcalc.a
-- Installing: /usr/local/include/calc/basic.h 

FILE_SET HEADERS关键字的支持是一个相对较新的更新,遗憾的是,并不是所有环境都能提供更新版的 CMake

如果你使用的版本低于 3.23,你需要在库目标的 PUBLIC_HEADER 属性中指定公共头文件(以分号分隔的列表),并手动处理重定位问题(更多信息请参见 理解可重定位目标的问题 部分):

ch14/03-install-targets-legacy/src/CMakeLists.txt (片段)

add_library(calc STATIC basic.cpp)
target_include_directories(calc INTERFACE include)
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER src/include/calc/basic.h
) 

你还需要更改目标目录,将库名包括在 include 路径中:

ch14/02-install-targets-legacy/src/CMakeLists.txt (续)

...
include(GNUInstallDirs)
install(TARGETS calc
  ARCHIVE
  **PUBLIC_HEADER**
  **DESTINATION** **${CMAKE_INSTALL_INCLUDEDIR}****/calc**
) 

显式地将 /calc 插入路径是必要的,因为在 PUBLIC_HEADER 属性中指定的文件不会保留其目录结构。即使这些文件嵌套在不同的基础目录中,它们也会被安装到同一个目标目录。这一重大缺点促使了 FILE_SET 的开发。

现在,你知道如何处理大多数安装情况,但对于更高级的场景应该如何处理呢?

低级安装

现代 CMake 正在逐渐避免直接操作文件。理想情况下,我们应将文件添加到逻辑目标中,使用目标作为一种高级抽象来表示所有底层资源:源文件、头文件、资源、配置等等。主要优势是代码的简洁性;通常,将文件添加到目标只需要更改一行代码。

不幸的是,并不是所有的安装文件都可以或方便地添加到一个目标中。在这种情况下,有三种选择:install(FILES)install(PROGRAMS)install(DIRECTORY)

使用 install(FILES) 和 install(PROGRAMS) 安装

FILESPROGRAMS 模式非常相似。它们可以用来安装各种资源,包括公共头文件、文档、脚本、配置文件以及运行时资源,如图片、音频文件和数据集。

这是命令签名:

install(<FILES|PROGRAMS> files...
        TYPE <type> | DESTINATION <dir>
        [PERMISSIONS permissions...]
        [CONFIGURATIONS [Debug|Release|...]]
        [COMPONENT <component>]
        [RENAME <name>] [OPTIONAL] [EXCLUDE_FROM_ALL]
) 

FILESPROGRAMS 之间的主要区别是对复制文件设置的默认文件权限。install(PROGRAMS) 会为所有用户设置 EXECUTE 权限,而 install(FILES) 不会(但两者都会设置 OWNER_WRITEOWNER_READGROUP_READWORLD_READ 权限)。

你可以通过使用可选的 PERMISSIONS 关键字来修改此行为,然后选择前导关键字(FILESPROGRAMS)作为指示所安装内容的标志。我们已经介绍了 PERMISSIONSCONFIGURATIONSOPTIONAL 的工作原理。COMPONENTEXCLUDE_FROM_ALL 将在后面的 定义组件 部分讨论。

在初始关键字后,我们需要列出所有想要安装的文件。CMake 支持相对路径和绝对路径,也支持生成器表达式。记住,如果文件路径以生成器表达式开头,它必须是绝对路径。

下一个必需的关键字是 TYPEDESTINATION。你可以显式地提供 DESTINATION 路径,也可以要求 CMake 查找特定 TYPE 文件的路径。与 install(TARGETS) 中不同,在此上下文中,TYPE 并不选择任何要安装的文件子集。不过,安装路径的计算遵循相同的模式(其中 + 符号表示平台特定的路径分隔符):

${CMAKE_INSTALL_PREFIX} + ${DESTINATION}

类似地,每个 TYPE 都有内置的默认值:

文件类型 内置默认值 安装目录变量
BIN bin CMAKE_INSTALL_BINDIR
SBIN sbin CMAKE_INSTALL_SBINDIR
LIB lib CMAKE_INSTALL_LIBDIR
INCLUDE include CMAKE_INSTALL_INCLUDEDIR
SYSCONF etc CMAKE_INSTALL_SYSCONFDIR
SHAREDSTATE com CMAKE_INSTALL_SHARESTATEDIR
LOCALSTATE var CMAKE_INSTALL_LOCALSTATEDIR
RUNSTATE $LOCALSTATE/run CMAKE_INSTALL_RUNSTATEDIR
DATA $DATAROOT CMAKE_INSTALL_DATADIR
INFO $DATAROOT/info CMAKE_INSTALL_INFODIR
LOCALE $DATAROOT/locale CMAKE_INSTALL_LOCALEDIR
MAN $DATAROOT/man CMAKE_INSTALL_MANDIR
DOC $DATAROOT/doc CMAKE_INSTALL_DOCDIR

表 14.2:每种文件类型的内置默认值

这里的行为遵循在 利用不同平台的默认目标 小节中描述的相同原则:如果没有为该 TYPE 文件设置安装目录变量,CMake 将提供一个内置的默认路径。同样,为了便于移植,我们可以使用 GNUInstallDirs 模块。

表中的一些内置猜测以安装目录变量为前缀:

  • $LOCALSTATECMAKE_INSTALL_LOCALSTATEDIR,或者默认为 var

  • $DATAROOTCMAKE_INSTALL_DATAROOTDIR,或者默认为 share

install(TARGETS) 一样,GNUInstallDirs 模块将提供平台特定的安装目录变量。我们来看一个示例:

ch14/04-install-files/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(InstallFiles CXX)
include(GNUInstallDirs)
install(FILES
  src/include/calc/basic.h
  src/include/calc/nested/calc_extended.h
  DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
) 

在这种情况下,CMake 将这两个仅包含头文件的库 basic.hnested/calc_extended.h 安装到系统范围的 include 目录下的项目特定子目录中。

GNUInstallDirs 源代码中,我们知道 CMAKE_INSTALL_INCLUDEDIR 对所有支持的平台都是相同的。然而,仍然推荐使用它以提高可读性,并与更动态的变量保持一致。例如,CMAKE_INSTALL_LIBDIR 会根据架构和发行版有所不同 —— liblib64lib/<multiarch-tuple>

从 CMake 3.20 开始,你可以在 install(FILES)install(PROGRAMS) 命令中使用 RENAME 关键字。该关键字后面必须跟一个新文件名,并且只有在命令安装单个文件时才有效。

本节中的示例演示了将文件安装到合适目录的简便性。然而,有一个问题 —— 请观察安装输出:

# cmake -S <source-tree> -B <build-tree>
# cmake --build <build-tree>
# cmake --install <build-tree>
-- Install configuration: ""
-- Installing: /usr/local/include/calc/basic.h
-- Installing: /usr/local/include/calc/calc_extended.h 

无论原始嵌套如何,两个文件都会被安装到同一目录中。有时,这并不理想。我们将在下一部分探讨如何处理这种情况。

处理整个目录

如果将单个文件添加到安装命令中不适用,你可以选择更广泛的方法,处理整个目录。install(DIRECTORY)模式就是为此设计的,它将指定的目录逐字复制到选定的目标位置。它的表现如下:

install(DIRECTORY dirs...
        TYPE <type> | DESTINATION <dir>
        [FILE_PERMISSIONS permissions...]
        [DIRECTORY_PERMISSIONS permissions...]
        [USE_SOURCE_PERMISSIONS] [OPTIONAL] [MESSAGE_NEVER]
        [CONFIGURATIONS [Debug|Release|...]]
        [COMPONENT <component>] [EXCLUDE_FROM_ALL]
        [FILES_MATCHING]
        [[PATTERN <pattern> | REGEX <regex>] [EXCLUDE]
        [PERMISSIONS permissions...]] [...]
) 

这里的许多选项类似于install(FILES)install(PROGRAMS)中的选项,并以相同的方式运行。一个关键细节是,如果在DIRECTORY关键字后提供的路径不以/结尾,路径的最后一个目录将被附加到目标位置。例如:

install(DIRECTORY aaa DESTINATION /xxx) 

该命令创建一个目录/xxx/aaa,并将aaa的内容复制到其中。相比之下,下面的命令将aaa的内容直接复制到/xxx

install(DIRECTORY aaa/ DESTINATION /xxx) 

install(DIRECTORY)还引入了其他文件不可用的机制:

  • 输出静默

  • 扩展权限控制

  • 文件/目录过滤

让我们从输出静默选项MESSAGE_NEVER开始。它在安装过程中禁用输出诊断。当我们安装的目录中包含大量文件,并且打印所有文件信息会产生太多噪声时,它非常有用。

关于权限,install(DIRECTORY)支持三种选项:

  • USE_SOURCE_PERMISSIONS设置已安装文件的权限,跟随原始文件的权限。仅在未设置FILE_PERMISSIONS时有效。

  • FILE_PERMISSIONS允许我们指定要设置的已安装文件和目录的权限。默认权限为OWNER_WRITEOWNER_READGROUP_READWORLD_READ

  • DIRECTORY_PERMISSIONS的工作方式与FILE_PERMISSIONS相似,但它会为所有用户设置额外的EXECUTE权限(因为在类 Unix 系统中,目录的EXECUTE权限表示允许列出其内容)。

请注意,CMake 会忽略不支持权限选项的平台上的权限设置。通过在每个过滤表达式后添加PERMISSIONS关键字,可以实现更精细的权限控制。通过此方式匹配到的文件或目录将会接收指定的权限。

让我们来谈谈过滤器或“通配”表达式。它们控制从源目录中安装哪些文件/目录,并遵循以下语法:

PATTERN <pat> | REGEX <reg> [EXCLUDE] [PERMISSIONS <perm>] 

有两种匹配方法可以选择:

  • 使用PATTERN(这是一个更简单的选项),你可以提供一个包含?占位符(匹配任何字符)和*通配符(匹配任何字符串)的模式。只有以<pat>结尾的路径才会被匹配。

  • REGEX选项更为高级,支持正则表达式。它允许匹配路径的任何部分,尽管^$锚点仍然可以表示路径的开始和结束。

可选地,FILES_MATCHING关键字可以在第一个过滤器之前设置,指定过滤器将应用于文件,而非目录。

记住两个警告:

  • FILES_MATCHING需要一个包含性过滤器。你可以排除一些文件,但除非你也包括一些文件,否则没有文件会被复制。然而,所有目录都会被创建,不管是否进行了过滤。

  • 所有子目录默认都会包含;你只能排除它们。

对于每个过滤方法,你可以选择使用EXCLUDE命令排除匹配的路径(这仅在未使用FILES_MATCHING时有效)。

可以通过在任何过滤器后添加PERMISSIONS关键字及所需权限的列表来为所有匹配的路径设置特定权限。我们通过一个例子来探讨这个,假设我们安装了三个目录,以不同的方式操作。我们有一些供运行时使用的静态数据文件:

data
- data.csv 

我们还需要一些位于src目录中的公共头文件,以及其他无关的文件:

src
- include
  - calc
    - basic.h
    - ignored
      - empty.file
    - nested
      - calc_extended.h 

最后,我们需要两个配置文件,具有两个级别的嵌套。为了让事情更有趣,我们将使/etc/calc/的内容仅对文件所有者可访问:

etc
- calc
  - nested.conf
- sample.conf 

为了安装包含静态数据文件的目录,我们首先使用install(DIRECTORY)命令的最基本形式启动项目:

ch14/05-install-directories/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.26)
project(InstallDirectories CXX)
install(DIRECTORY data/ DESTINATION share/calc) 

这个命令将简单地将我们data目录中的所有内容放入${CMAKE_INSTALL_PREFIX}share/calc中。请注意,我们的源路径以/符号结尾,表示我们不想复制data目录本身,只复制其内容。

第二种情况是相反的:我们不添加尾部的/,因为该目录应该被包含。原因是我们依赖于一个特定于系统的路径来处理INCLUDE文件类型,这是由GNUInstallDirs提供的(注意INCLUDEEXCLUDE关键字表示不同的概念):

ch14/05-install-directories/CMakeLists.txt(片段)

...
include(GNUInstallDirs)
install(DIRECTORY src/include/calc TYPE INCLUDE
  PATTERN "ignored" EXCLUDE
  PATTERN "calc_extended.h" EXCLUDE
) 

此外,我们已经从此操作中排除了两个路径:整个ignored目录和所有以calc_extended.h结尾的文件(记住PATTERN是如何工作的)。

第三种情况安装一些默认的配置文件并设置它们的权限:

ch14/05-install-directories/CMakeLists.txt(片段)

install(DIRECTORY etc/ TYPE SYSCONF
  DIRECTORY_PERMISSIONS
    OWNER_READ OWNER_WRITE OWNER_EXECUTE
  PATTERN "nested.conf"
    PERMISSIONS OWNER_READ OWNER_WRITE
) 

我们避免将etc从源路径附加到SYSCONF路径(因为GNUInstallDirs已经提供了这一点),以防止重复。我们设置了两个权限规则:子目录仅对所有者可编辑和列出,而以nested.conf结尾的文件仅对所有者可编辑。

安装目录涵盖了各种使用案例,但对于其他高级场景(如安装后配置),可能需要外部工具。我们如何将它们集成?

在安装过程中调用脚本

如果你曾经在类 Unix 系统上安装过共享库,你可能记得需要指示动态链接器扫描受信目录并使用ldconfig构建缓存(参考进一步阅读部分获取相关资料)。为了便于完全自动化的安装,CMake 提供了install(SCRIPT)install(CODE)模式。以下是完整的语法:

install([[SCRIPT <file>] [CODE <code>]]
        [ALL_COMPONENTS | COMPONENT <component>]
        [EXCLUDE_FROM_ALL] [...]
) 
to execute during installation. To illustrate, let’s modify the 02-install-targets example to build a shared library:

ch14/06-install-code/src/CMakeLists.txt

add_library(calc **SHARED** basic.cpp)
target_include_directories(calc INTERFACE include)
target_sources(calc PUBLIC FILE_SET HEADERS
                           BASE_DIRS include
                           FILES include/calc/basic.h
) 

在安装脚本中将工件类型从ARCHIVE更改为LIBRARY,然后添加逻辑以便在之后运行ldconfig

ch14/06-install-code/CMakeLists.txt(片段)

install(TARGETS calc **LIBRARY** FILE_SET HEADERS))
**if** **(UNIX)**
**install****(CODE** **"execute_process(COMMAND ldconfig)"****)**
**endif****()** 

if()条件确保命令适用于操作系统(ldconfig不应在 Windows 或 macOS 上执行)。提供的代码在 CMake 中必须是语法有效的(错误只会在安装时显示)。

运行安装命令后,通过打印缓存的库来确认安装成功:

# cmake -S <source-tree> -B <build-tree>
# cmake --build <build-tree>
# cmake --install <build-tree>
-- Install configuration: ""
-- Installing: /usr/local/lib/libcalc.so
-- Installing: /usr/local/include/calc/basic.h
# ldconfig -p | grep libcalc
        libcalc.so (libc6,x86-64) => /usr/local/lib/libcalc.so 

SCRIPTCODE模式都支持生成器表达式,增加了此命令的多功能性。它可以用于多种目的:打印用户消息、验证安装成功、进行广泛的配置、文件签名等。

接下来,我们深入探讨在 CMake 安装中管理运行时依赖项的方面,这是 CMake 的最新功能之一。

安装运行时依赖项

我们已经涵盖了几乎所有可安装的工件及其相应的命令。接下来要讨论的主题是运行时依赖项。可执行文件和共享库通常依赖于其他库,这些库必须存在于系统中并在程序初始化时动态加载。从版本 3.21 开始,CMake 可以为每个目标构建所需库的列表,并通过引用二进制文件的适当部分在构建时捕获其位置。然后可以使用该列表将这些运行时工件安装到系统中以备将来使用。

对于在项目中定义的目标,可以通过两步实现:

install(TARGETS ... RUNTIME_DEPENDENCY_SET <set-name>)
install(RUNTIME_DEPENDENCY_SET <set-name> <arg>...) 

或者,可以通过一个命令以相同的效果完成:

install(TARGETS ... RUNTIME_DEPENDENCIES <arg>...) 

如果目标是导入的,而不是在项目中定义的,那么它的运行时依赖项可以通过以下方式安装:

install(IMPORTED_RUNTIME_ARTIFACTS <target>...) 
RUNTIME_DEPENDENCY_SET <set-name> argument to create a named reference that can be later used in the install(RUNTIME_DEPENDENCY_SET) command.

如果这个功能对你的项目有益,建议查阅install()命令的官方文档了解更多信息。

现在我们了解了在系统上安装文件的各种方式,接下来让我们探索如何将它们转换为其他 CMake 项目可用的本地包。

创建可重用的包

在前面的章节中,我们广泛使用了find_package()并观察到了它的便捷性和简单性。为了通过此命令访问我们的项目,我们需要完成几个步骤,以便 CMake 可以将我们的项目视为一个一致的包:

  1. 使我们的目标可迁移。

  2. 目标导出文件安装到标准位置。

  3. 为包创建配置文件

  4. 为包生成版本文件

从头开始:为什么目标需要是可重定位的?我们该如何做到这一点?

了解可重定位目标的问题

安装解决了许多问题,但也引入了一些复杂性。CMAKE_INSTALL_PREFIX是平台特定的,用户可以在安装阶段通过--install-prefix命令行参数设置它。挑战在于,目标导出文件是在安装之前生成的,即在构建阶段,当安装后文件的最终位置尚未确定时。考虑以下代码:

ch14/03-install-targets-legacy/src/CMakeLists.txt

add_library(calc STATIC basic.cpp)
**target_include_directories****(calc INTERFACE include)**
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER src/include/calc/basic.h
) 

在这个例子中,我们特地将include directory 添加到calcinclude directories中。由于这是一个相对路径,CMake 导出的目标生成过程会自动将该路径与CMAKE_CURRENT_SOURCE_DIR变量的内容结合,指向包含此列表文件的目录。

include directory path still points to its source tree.

CMake 通过生成器表达式解决了这个本末倒置的问题,这些表达式会根据上下文被替换为它们的参数或空字符串:

  • $<BUILD_INTERFACE:...>:这会在常规构建中评估为‘...’参数,但在安装时排除。

  • $<INSTALL_INTERFACE:...>:这会在安装时评估为‘...’参数,但在常规构建时排除。

  • $<BUILD_LOCAL_INTERFACE:...>:当另一个目标在相同的构建系统中使用时,它会评估为‘...’参数(此功能在 CMake 3.26 中新增)。

这些表达式允许将选择使用哪条路径的决定推迟到后续阶段:构建和安装。以下是如何在实践中使用它们:

ch14/07-install-export-legacy/src/CMakeLists.txt(片段)

add_library(calc STATIC basic.cpp)
target_include_directories(calc INTERFACE
**"$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"**
**"$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>"**
)
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER "include/calc/basic.h"
) 

target_include_directories()中,我们关注最后两个参数。使用的生成器表达式是互斥的,这意味着最终步骤中只有一个参数会被使用,另一个会被删除。

对于常规构建,calc目标的INTERFACE_INCLUDE_DIRECTORIES属性将会使用第一个参数进行扩展:

"/root/examples/ch14/07-install-export/src/include" "" 

另一方面,在安装时,值将使用第二个参数进行扩展:

"" "/usr/lib/calc/include" 

引号在最终值中不存在;它们在此处用于表示空文本值,以便于理解。

关于CMAKE_INSTALL_PREFIX:它不应作为路径中指定目标的组件使用。它将在构建阶段进行评估,使得路径变为绝对路径,且可能与安装时提供的路径不同(如果使用了--install-prefix选项)。相反,应该使用$<INSTALL_PREFIX>生成器表达式:

target_include_directories(my_target PUBLIC
  $<INSTALL_INTERFACE:**$<INSTALL_PREFIX>**/include/MyTarget>
) 

或者,更好的是,你可以使用相对路径,它们会被自动加上正确的安装前缀:

target_include_directories(my_target PUBLIC
  $<INSTALL_INTERFACE:include/MyTarget>
) 

若需更多示例和信息,请参考官方文档(链接可以在进一步阅读部分找到)。

现在我们的目标是兼容安装的,我们可以安全地生成并安装它们的目标导出文件。

安装目标导出文件

我们之前在 不进行安装的导出 部分提到过目标导出文件。安装目标导出文件的过程非常相似,创建它们的命令语法也是如此:

install(EXPORT <export-name> DESTINATION <dir>
        [NAMESPACE <namespace>] [[FILE <name>.cmake]|
        [PERMISSIONS permissions...]
        [CONFIGURATIONS [Debug|Release|...]]
        [EXPORT_LINK_INTERFACE_LIBRARIES]
        [COMPONENT <component>]
        [EXCLUDE_FROM_ALL]) 

它是 export(EXPORT) 和其他 install() 命令的结合(其选项的功能类似)。请记住,它将创建并安装一个目标导出文件,用于通过 install(TARGETS) 命令定义的命名导出。这里的关键区别在于,生成的导出文件将包含在 INSTALL_INTERFACE 生成器表达式中评估的目标路径,而不像 export(EXPORT) 使用 BUILD_INTERFACE。这意味着我们需要小心处理包含文件和其他相对引用的文件。

再次强调,对于 CMake 3.23 或更高版本,如果正确使用 FILE_SET HEADERS,这将不再是问题。让我们看看如何为 ch14/02-install-export 示例中的目标生成并安装导出文件。为此,我们必须在 install(TARGETS) 命令之后调用 install(EXPORT)

ch14/07-install-export/src/CMakeLists.txt

add_library(calc STATIC basic.cpp)
target_sources(calc
  PUBLIC FILE_SET HEADERS BASE_DIRS include
  FILES "include/calc/basic.h"
)
include(GNUInstallDirs)
install(TARGETS calc EXPORT CalcTargets ARCHIVE FILE_SET HEADERS) **install****(****EXPORT** **CalcTargets**
 **DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
 **NAMESPACE Calc::**
**)** 

注意在 install(EXPORT) 中引用 CalcTargets 导出名。在构建树中运行 cmake --install 将导致导出文件在指定的目标位置生成:

...
-- Installing: /usr/local/lib/calc/cmake/CalcTargets.cmake
-- Installing: /usr/local/lib/calc/cmake/CalcTargets-noconfig.cmake 

如果你需要覆盖默认的目标导出文件名(<export name>.cmake),可以通过添加 FILE new-name.cmake 参数来更改它(文件名必须以 .cmake 结尾)。

不要混淆这一点——目标导出文件 不是 配置文件,因此你不能直接使用 find_package() 来消费已安装的目标。不过,如果需要,也可以直接 include() 导出文件。那么,我们如何定义一个可以被其他项目消费的包呢?让我们来看看!

编写基本配置文件

一个完整的包定义包括目标导出文件、包的配置文件和包的版本文件。然而,从技术上讲,find_package() 要正常工作只需要一个配置文件。它充当包定义,负责提供任何包功能和宏,检查需求,查找依赖项,并包含目标导出文件。

正如我们之前提到的,用户可以通过以下方式将包安装到系统的任何位置:

# cmake --install <build tree> --install-prefix=<path> 

这个前缀决定了已安装文件的复制位置。为此,你必须确保以下几点:

  • 目标属性中的路径是可重新定位的(如 理解可重新定位目标的问题 部分所述)。

  • 配置文件中使用的路径是相对于它的。

要使用已安装在非默认位置的包,消费项目在配置阶段需要通过 CMAKE_PREFIX_PATH 变量提供 <installation path>

# cmake -B <build tree> -DCMAKE_PREFIX_PATH=<installation path> 

find_package() 命令将以平台特定的方式扫描文档中列出的路径(请参见进一步阅读部分)。在 Windows 和类 Unix 系统上检查的一个模式是:

<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/(cmake|CMake) 

这表示将配置文件安装到像 lib/calc/cmake 这样的路径应该是可行的。此外,CMake 要求配置文件必须命名为 <PackageName>-config.cmake<PackageName>Config.cmake 才能被找到。

让我们将配置文件的安装添加到 06-install-export 示例中:

ch14/09-config-file/CMakeLists.txt(片段)

...
**install****(****EXPORT** **CalcTargets**
 **DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
 **NAMESPACE Calc::**
**)**
**install****(FILES** **"CalcConfig.cmake"**
 **DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
**)** 

此命令从相同的源目录安装 CalcConfig.cmakeCMAKE_INSTALL_LIBDIR 会评估为平台正确的 lib 路径)。

最简单的配置文件由包含目标导出文件的单行组成:

ch14/09-config-file/CalcConfig.cmake

include("${CMAKE_CURRENT_LIST_DIR}/CalcTargets.cmake") 

CMAKE_CURRENT_LIST_DIR 指的是配置文件所在的目录。在我们的示例中,CalcConfig.cmakeCalcTargets.cmake 安装在同一个目录下(由 install(EXPORT) 设置),因此目标导出文件将被正确包含。

为了验证我们包的可用性,我们将创建一个包含一个列表文件的简单项目:

ch14/10-find-package/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(FindCalcPackage CXX)
find_package(Calc REQUIRED)
include(CMakePrintHelpers)
message("CMAKE_PREFIX_PATH: ${CMAKE_PREFIX_PATH}")
message("CALC_FOUND: ${Calc_FOUND}")
cmake_print_properties(TARGETS "Calc::calc" PROPERTIES
  IMPORTED_CONFIGURATIONS
  INTERFACE_INCLUDE_DIRECTORIES
) 

为了测试这个,将 09-config-file 示例构建并安装到一个目录中,然后构建 10-find-package 并通过 DCMAKE_PREFIX_PATH 参数引用它:

# cmake -S <source-tree-of-08> -B <build-tree-of-08>
# cmake --build <build-tree-of-08>
# cmake --install <build-tree-of-08>
# cmake -S <source-tree-of-09> -B <build-tree-of-09>  
        -DCMAKE_PREFIX_PATH=<build-tree-of-08> 

这将产生以下输出(所有的 <*_tree-of_> 占位符将被实际路径替换):

CMAKE_PREFIX_PATH: <build-tree-of-08>
CALC_FOUND: 1
--
Properties for TARGET Calc::calc:
   Calc::calc.IMPORTED_CONFIGURATIONS = "NOCONFIG"
   Calc::calc.INTERFACE_INCLUDE_DIRECTORIES = "<build-tree-of-08>/include"
-- Configuring done
-- Generating done
-- Build files have been written to: <build-tree-of-09> 

该输出表示 CalcTargets.cmake 文件已被找到并正确包含,include directory 的路径遵循所选择的前缀。这种解决方案适用于基本的打包场景。现在,让我们学习如何处理更高级的场景。

创建高级配置文件

如果你需要管理多个目标导出文件,在你的配置文件中包含一些宏可能会很有用。CMakePackageConfigHelpers 工具模块提供了访问 configure_package_config_file() 命令的功能。使用它时,提供一个模板文件,CMake 变量将插入其中,以生成包含两个嵌入式宏定义的配置文件

  • set_and_check(<variable> <path>):这与 set() 类似,但它会检查 <path> 是否实际存在,否则会因 FATAL_ERROR 失败。建议在配置文件中使用此方法,以便及早发现路径错误。

  • check_required_components(<PackageName>):这是添加到 config 文件末尾的内容。它检查在 find_package(<package> REQUIRED <component>) 中,用户所需的所有组件是否已找到。

配置文件生成过程中,可以为复杂的目录树准备好安装路径。命令签名如下:

configure_package_config_file(<template> <output>
  INSTALL_DESTINATION <path>
  [PATH_VARS <var1> <var2> ... <varN>]
  [NO_SET_AND_CHECK_MACRO]
  [NO_CHECK_REQUIRED_COMPONENTS_MACRO]
  [INSTALL_PREFIX <path>]
) 

<template>文件将与变量进行插值并存储在<output>路径中。INSTALL_DESTINATION路径用于将存储在PATH_VARS中的路径转换为相对于安装目标的路径。INSTALL_PREFIX可以作为基本路径提供,表示INSTALL_DESTINATION相对于此路径。

NO_SET_AND_CHECK_MACRONO_CHECK_REQUIRED_COMPONENTS_MACRO选项告诉 CMake 不要将这些宏定义添加到生成的配置文件中。让我们在实际中看到这种生成方式,扩展07-install-export示例:

ch14/11-advanced-config/CMakeLists.txt(片段)

...
install(EXPORT CalcTargets
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
  NAMESPACE Calc::
)
**include****(CMakePackageConfigHelpers)**
**set****(LIB_INSTALL_DIR** **${CMAKE_INSTALL_LIBDIR}****/calc)**
**configure_package_config_file(**
  **${CMAKE_CURRENT_SOURCE_DIR}****/CalcConfig.cmake.in**
  **"${CMAKE_CURRENT_BINARY_DIR}/CalcConfig.cmake"**
  **INSTALL_DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
  **PATH_VARS LIB_INSTALL_DIR**
**)**
install(FILES **"${CMAKE_CURRENT_BINARY_DIR}/CalcConfig.cmake"**
  **DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
) 

在前面的代码中,我们:

  1. 使用include()来包含带有帮助器的实用模块。

  2. 使用set()来设置一个变量,该变量将用于生成可重定位的路径。

  3. 使用CalcConfig.cmake.in模板为构建树生成CalcConfig.cmake配置文件,并提供LIB_INSTALL_DIR作为变量名称,计算为相对于INSTALL_DESTINATION${CMAKE_INSTALL_LIBDIR}/calc/cmake

  4. 将为构建树生成的配置文件传递给install(FILE)

请注意,install(FILES)中的DESTINATION路径与configure_package_config_file()中的INSTALL_DESTINATION路径相等,这确保了配置文件内正确的相对路径计算。

最后,我们需要一个config文件模板(它们的名称通常以.in结尾):

ch14/11-advanced-config/CalcConfig.cmake.in

@PACKAGE_INIT@
set_and_check(CALC_LIB_DIR "@PACKAGE_LIB_INSTALL_DIR@")
include("${CALC_LIB_DIR}/cmake/CalcTargets.cmake")
check_required_components(Calc) 

此模板以@PACKAGE_INIT@占位符开始。生成器将用set_and_checkcheck_required_components宏的定义填充它。

下一行将CALC_LIB_DIR设置为传递给@PACKAGE_LIB_INSTALL_DIR@占位符的路径。CMake 将使用列表文件中提供的$LIB_INSTALL_DIR填充它,但路径是相对于安装路径计算的。随后,该路径将用于include()命令来包含目标导出文件。最后,check_required_components()验证是否已找到使用此包的项目所需的所有组件。即使包没有任何组件,推荐使用此命令,以确保用户仅使用受支持的依赖项。否则,他们可能会错误地认为他们已成功添加了组件(这些组件可能仅存在于包的更新版本中)。

通过这种方式生成的CalcConfig.cmake配置文件如下所示:

#### Expanded from @PACKAGE_INIT@ by
  configure_package_config_file() #######
#### Any changes to this file will be overwritten by the
  next CMake run ####
#### The input file was CalcConfig.cmake.in  #####
get_filename_component(PACKAGE_PREFIX_DIR
  "${CMAKE_CURRENT_LIST_DIR}/../../../" ABSOLUTE)
macro(set_and_check _var _file) 
  # ... removed for brevity
endmacro()
macro(check_required_components _NAME) 
  # ... removed for brevity
endmacro()
##################################################################
set_and_check(CALC_LIB_DIR "${PACKAGE_PREFIX_DIR}/lib/calc")
include("${CALC_LIB_DIR}/cmake/CalcTargets.cmake")
check_required_components(Calc) 

以下图示展示了各种包文件之间的关系,帮助更好地理解:

图 11.1 – 高级包的文件结构

图 14.1:高级包的文件结构

包的所有必需的子依赖项也必须在包的配置文件中找到。这可以通过调用CMakeFindDependencyMacro帮助器中的find_dependency()宏来完成。我们在第九章《CMake 中的依赖管理》中学习了如何使用它。

任何暴露给消费项目的宏或函数定义应当放在一个单独的文件中,该文件通过包的配置文件包含。有趣的是,CMakePackageConfigHelpers也有助于生成包的版本文件。接下来我们将探讨这一点。

生成包的版本文件

随着你的包的发展,获得新功能并淘汰旧功能,跟踪这些变化并提供可供开发者使用的变更日志是至关重要的。当开发者需要某个特定功能时,使用你的包的开发者可以在find_package()中指定支持该功能的最低版本,如下所示:

find_package(Calc 1.2.3 REQUIRED) 

CMake 将搜索Calc的配置文件,并检查是否在同一目录下存在名为<config-file>-version.cmake<config-file>Version.cmake版本文件(例如,CalcConfigVersion.cmake)。该文件包含版本信息,并指定与其他版本的兼容性。例如,即使你没有安装确切的版本 1.2.3,可能会有 1.3.5 版本,而它被标记为与旧版本兼容。CMake 将接受该包,因为它向后兼容。

你可以使用CMakePackageConfigHelpers工具模块,通过调用write_basic_package_version_file()来生成包的版本文件

write_basic_package_version_file(
  <filename> [VERSION <ver>]
  COMPATIBILITY <AnyNewerVersion | SameMajorVersion |
                 SameMinorVersion | ExactVersion>
  [ARCH_INDEPENDENT]
) 

首先,提供生成物件的<filename>;确保它遵循之前讨论的命名规则。你可以选择性地传入显式的VERSION(采用 major.minor.patch 格式)。如果未提供,project()命令中指定的版本将被使用(如果项目没有指定版本,将会出现错误)。

COMPATIBILITY关键字表示:

  • ExactVersion必须匹配版本的所有三个组件,并且不支持版本范围:(例如,find_package(<package> 1.2.8...1.3.4))。

  • SameMinorVersion在前两个组件相同的情况下匹配(忽略patch)。

  • SameMajorVersion在第一个组件相同的情况下匹配(忽略minorpatch)。

  • AnyNewerVersion与其名称相反,它匹配任何旧版本(例如,版本 1.4.2 与find_package(<package> 1.2.8)兼容)。

对于依赖架构的包,需要精确的架构匹配。然而,对于与架构无关的包(如仅包含头文件的库或宏包),你可以指定ARCH_INDEPENDENT关键字来跳过此检查。

以下代码展示了如何为我们在07-install-export中开始的项目提供版本文件的实际示例:

ch14/12-version-file/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.26)
**project****(VersionFile VERSION** **1.2****.****3** **LANGUAGES CXX)**
...
**include****(CMakePackageConfigHelpers)**
**write_basic_package_version_file(**
  **"${CMAKE_CURRENT_BINARY_DIR}/CalcConfigVersion.cmake"**
  **COMPATIBILITY AnyNewerVersion**
**)**
install(FILES "CalcConfig.cmake"
  **"${CMAKE_CURRENT_BINARY_DIR}/CalcConfigVersion.cmake"**
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
) 

为了方便起见,我们在文件的顶部配置了包的版本,在project()命令中,将简短的project(<name> <languages>)语法切换为显式的完整语法,通过添加LANGUAGE关键字。

在包含助手模块之后,我们生成版本文件并将其与 CalcConfig.cmake 一起安装。通过跳过 VERSION 关键字,我们使用 PROJECT_VERSION 变量。该包标记为完全向后兼容,使用 COMPATIBILITY AnyNewerVersion。这会将 版本文件 安装到与 CalcConfig.cmake 相同的目标位置。就这样——我们的包已经完全配置好了。

通过此方法,我们完成了包创建的主题。现在我们知道如何处理重定位及其重要性,如何安装 目标导出文件,以及如何编写 配置文件版本文件

在下一节中,我们将探索组件及其在包中的使用。

定义组件

我们将首先讨论关于术语 组件 的潜在混淆。请看 find_package() 的完整签名:

find_package(<PackageName> 
             [version] [EXACT] [QUIET] [MODULE] [REQUIRED]
             **[[COMPONENTS] [components...]]**
             **[OPTIONAL_COMPONENTS components...]**
             [NO_POLICY_SCOPE]
) 

重要的是不要将这里提到的组件与 install() 命令中使用的 COMPONENT 关键字混淆。尽管它们使用相同的名称,但它们是不同的概念,必须分别理解。我们将在接下来的子章节中进一步探讨这一点。

如何在 find_package() 中使用组件

当使用 COMPONENTSOPTIONAL_COMPONENTS 列表调用 find_package() 时,我们向 CMake 指示我们只关心提供这些组件的包。然而,必须理解的是,验证这一需求是包本身的责任。如果包的供应商没有在配置文件中实现必要的检查,如 创建高级配置文件 一节所述,过程将不会按预期进行。

请求的组件通过 <package>_FIND_COMPONENTS 变量(包括可选和非可选组件)传递给配置文件。对于每个非可选组件,都会设置一个 <package>_FIND_REQUIRED_<component> 变量。包的作者可以编写宏来扫描此列表并验证是否提供了所有必需的组件,但这不是必需的。check_required_components() 函数可以完成这个任务。配置文件 应该在找到必需的组件时设置 <package>_<component>_FOUND 变量。文件末尾的一个宏将验证是否设置了所有必需的变量。

如何在 install() 命令中使用组件

不是所有生成的产物在每种情况下都需要安装。例如,一个项目可能为开发安装静态库和公共头文件,但默认情况下,它可能只需要安装共享库以供运行时使用。为了启用这种双重行为,产物可以通过 COMPONENT 关键字在所有 install() 命令中进行分组。希望限制安装到特定组件的用户可以通过执行以下区分大小写的命令来实现:

cmake --install <build tree> 
      --component=<component1 name> --component=<component2 name> 

COMPONENT 关键字分配给一个产物并不会自动将其从默认安装中排除。要实现这一排除,必须添加 EXCLUDE_FROM_ALL 关键字。

让我们通过一个代码示例来探讨这个概念:

ch14/13-components/CMakeLists.txt(片段)

install(TARGETS calc EXPORT CalcTargets
  ARCHIVE
    **COMPONENT lib**
  FILE_SET HEADERS
    **COMPONENT headers**
)
install(EXPORT CalcTargets
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
  NAMESPACE Calc::
  **COMPONENT lib**
)
install(CODE "MESSAGE(\"Installing 'extra' component\")"
  **COMPONENT extra**
  **EXCLUDE_FROM_ALL**
) 

上述install命令定义了以下组件:

  • lib:包含静态库和目标导出文件,默认情况下会安装。

  • headers:包含 C++头文件,默认情况下也会安装。

  • extra:执行一段代码以打印消息,默认情况下不安装。

让我们再强调一遍:

  • 没有--component参数的cmake --install将安装libheaders组件。

  • cmake --install --component headers将只安装公共头文件。

  • cmake --install --component extra将打印一个通常无法访问的消息(EXCLUDE_FROM_ALL关键字会阻止此行为)。

如果安装的工件没有指定COMPONENT关键字,则默认为Unspecified,这一点由CMAKE_INSTALL_DEFAULT_COMPONENT_NAME变量定义。

由于无法通过 cmake 命令行列出所有可用组件,彻底记录包的组件对用户非常有帮助。一个INSTALLREADME”文件是存放这些信息的好地方。

如果cmake在没有指定组件的情况下使用--component参数调用一个不存在的组件,命令将会成功完成,但不会安装任何内容,也不会出现警告或错误。

将我们的安装划分为多个组件使用户能够选择性地安装包的部分内容。现在让我们转向管理版本化共享库的符号链接,这对于优化您的安装过程非常有用。

管理版本化共享库的符号链接

您的安装目标平台可能使用符号链接来帮助链接器发现当前安装的共享库版本。在创建一个指向lib<name>.so.1文件的lib<name>.so符号链接后,可以通过将-l<name>参数传递给链接器来链接此库。

CMake 的install(TARGETS <target> LIBRARY)块在需要时处理创建此类符号链接的操作。不过,我们可以决定将该步骤移到另一个install()命令中,方法是向该块添加NAMELINK_SKIP

install(TARGETS <target> LIBRARY 
        COMPONENT cmp NAMELINK_SKIP) 

要将符号链接分配给另一个组件(而不是完全禁用它),我们可以对相同的目标重复执行install()命令,并指定不同的组件,后跟NAMELINK_ONLY关键字:

install(TARGETS <target> LIBRARY 
        COMPONENT lnk NAMELINK_ONLY) 

同样的效果可以通过NAMELINK_COMPONENT关键字实现:

install(TARGETS <target> LIBRARY 
        COMPONENT cmp NAMELINK_COMPONENT lnk) 

现在我们已经配置了自动安装,可以使用 CMake 自带的 CPack 工具为用户提供预构建的工件。

使用 CPack 进行打包

尽管从源代码构建项目有其优点,但它可能耗时且复杂,这对于最终用户尤其是非开发者来说并不理想。一个更方便的分发方式是使用二进制包,其中包含已编译的工件和其他必要的静态文件。CMake 支持通过一个名为cpack的命令行工具生成这样的包。

要生成一个软件包,请选择适合目标平台和软件包类型的生成器。不要将软件包生成器与构建系统生成器(如 Unix Makefile 或 Visual Studio)混淆。

以下表格列出了可用的软件包生成器:

生成器名称 生成的文件类型 平台
Archive 7Z, 7zip - (.7z)TBZ2 (.tar.bz2)TGZ (.tar.gz)TXZ (.tar.xz)TZ (.tar.Z)TZST (.tar.zst)ZIP (.zip) 跨平台
Bundle macOS Bundle (.bundle) macOS
Cygwin Cygwin 软件包 Cygwin
DEB Debian 软件包(.deb Linux
External 第三方打包工具的 JSON(.json)文件 跨平台
FreeBSD PKG (.pkg) *BSD, Linux, macOS
IFW QT 安装程序二进制文件 Linux, Windows, macOS
NSIS 二进制文件(.exe Windows
NuGet NuGet 包(.nupkg Windows
productbuild PKG (.pkg) macOS
RPM RPM (.rpm) Linux
WIX 微软安装程序(.msi Windows

表 14.3:可用的软件包生成器

这些生成器中的大多数具有广泛的配置。虽然本书的范围不涉及其所有细节,但你可以在进一步阅读部分找到更多信息。我们将专注于一个一般的使用案例。

要使用 CPack,请使用必要的install()命令配置项目的安装,并构建项目。生成的cmake_install.cmake文件将在构建目录中,供 CPack 根据CPackConfig.cmake文件准备二进制软件包。虽然你可以手动创建此文件,但在项目的清单文件中使用include(CPack)更为简便。它会在构建目录中生成配置,并在需要时提供默认值。

让我们扩展13-components示例,使用 CPack:

ch14/14-cpack/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.26)
**project****(CPackPackage VERSION** **1.2****.****3** **LANGUAGES CXX)**
include(GNUInstallDirs)
add_subdirectory(src bin)
install(...)
install(...)
install(...)
**set****(CPACK_PACKAGE_VENDOR** **"Rafal Swidzinski"****)**
**set****(CPACK_PACKAGE_CONTACT** **"email@example.com"****)**
**set****(CPACK_PACKAGE_DESCRIPTION** **"Simple Calculator"****)**
**include****(CPack)** 

CPack 模块从project()命令中提取以下变量:

  • CPACK_PACKAGE_NAME

  • CPACK_PACKAGE_VERSION

  • CPACK_PACKAGE_FILE_NAME

CPACK_PACKAGE_FILE_NAME存储软件包名称的结构:

$CPACK_PACKAGE_NAME-$CPACK_PACKAGE_VERSION-$CPACK_SYSTEM_NAME 

在这里,CPACK_SYSTEM_NAME是目标操作系统的名称,如Linuxwin32。例如,在 Debian 上执行 ZIP 生成器时,CPack 将生成一个名为CPackPackage-1.2.3-Linux.zip的文件。

要在构建项目后生成软件包,请进入项目的构建目录并运行:

cpack [<options>] 

CPack 从 CPackConfig.cmake 文件中读取选项,但你可以覆盖这些设置:

  • -G <generators>:以分号分隔的软件包生成器列表。默认值可以在CPackConfig.cmake中的CPack_GENERATOR变量中指定。

  • -C <configs>:以分号分隔的构建配置(调试、发布)列表,用于生成软件包(对于多配置构建系统生成器是必需的)。

  • -D <var>=<value>:覆盖CPackConfig.cmake文件中设置的变量。

  • --config <config-file>:使用指定的配置文件,而不是默认的CPackConfig.cmake

  • --verbose, -V:提供详细输出。

  • -P <packageName>:此选项覆盖包名。

  • -R <packageVersion>:此选项覆盖包版本。

  • --vendor <vendorName>:此选项覆盖包供应商。

  • -B <packageDirectory>:此选项指定 cpack 的输出目录(默认为当前工作目录)。

让我们尝试为我们的 14-cpack 示例项目生成包。我们将使用 ZIP、7Z 和 Debian 包生成器:

cpack -G "ZIP;7Z;DEB" -B packages 

你应该获得这些包:

  • CPackPackage-1.2.3-Linux.7z

  • CPackPackage-1.2.3-Linux.deb

  • CPackPackage-1.2.3-Linux.zip

这些二进制包已经准备好发布到你项目的官方网站、GitHub 发布版或包仓库,供最终用户使用。

总结

编写跨平台安装脚本的复杂性可能令人畏惧,但 CMake 显著简化了这一任务。虽然它需要一些初始设置,但 CMake 精简了这一过程,并与我们在本书中探讨的概念和技术无缝集成。

我们首先了解了如何从项目中导出 CMake 目标,使其可以在其他项目中使用而无需安装。接着我们深入了解了如何安装已配置好导出的项目。探讨安装基础时,我们重点关注了一个关键方面:安装 CMake 目标。现在,我们已经掌握了 CMake 如何为各种工件类型分配不同的目标目录,以及对公共头文件的特殊处理。我们还研究了 install() 命令的其他模式,包括安装文件、程序和目录,并在安装过程中执行脚本。

然后我们的旅程带我们来到了 CMake 的可重用包。我们探讨了如何使项目目标可移动,从而便于用户自定义安装位置。这包括创建可通过find_package()消费的完全定义的包,涉及到准备目标导出文件配置文件版本文件。鉴于用户需求的多样性,我们学习了如何将工件和操作分组到安装组件中,并将其与 CMake 包的组件区分开来。我们的探索最终介绍了 CPack。我们发现如何准备基本的二进制包,提供了一种高效的方法来分发预编译的软件。虽然掌握 CMake 中安装和打包的细节是一个持续的过程,但这一章为我们打下了坚实的基础,使我们能够自信地处理常见场景并深入探索。

在下一章,我们将通过制作一个紧密结合、专业的项目,运用我们积累的知识,展示这些 CMake 技术的实际应用。

进一步阅读

加入我们的 Discord 社区

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

discord.com/invite/vXN53A7ZcA

第十五章:创建你的专业项目

我们已经掌握了构建专业项目所需的所有知识,包括结构化、构建、依赖管理、测试、分析、安装和打包。现在,是时候通过创建一个连贯的专业项目来应用这些技能了。重要的是要理解,即使是微不足道的程序也能从自动化质量检查和将原始代码转化为完整解决方案的无缝流程中受益。的确,实现这些检查和流程是一个重要的投资,因为它需要许多步骤才能正确设置一切。尤其是将这些机制添加到现有的代码库时,这些代码库通常庞大而复杂。这就是为什么从一开始就使用 CMake 并尽早建立所有必要的流程是有益的。这样配置起来更简单,也更高效,因为这些质量控制和构建自动化最终需要集成到长期项目中。

在本章中,我们将开发一个尽可能小的解决方案,同时充分利用我们到目前为止在本书中讨论的 CMake 实践。为了保持简单,我们将只实现一个实际的功能——两个数字相加。这样的基础业务代码将使我们能够专注于前几章中学习的与构建相关的项目方面。为了处理一个与构建相关的更具挑战性的问题,项目将同时包含一个库和一个可执行文件。库将处理内部业务逻辑,并作为 CMake 包可供其他项目使用。可执行文件面向最终用户,将提供一个演示库功能的用户界面。

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

  • 规划我们的工作

  • 项目布局

  • 构建和管理依赖

  • 测试与程序分析

  • 安装与打包

  • 提供文档

技术要求

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

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

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

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

本章使用 GCC 编译,以便与用于收集结果的 lcov 工具的代码覆盖率仪器兼容。如果你想使用 llvm 或其他工具链进行编译,请确保根据需要调整覆盖率处理。

要运行测试,执行以下命令:

ctest --test-dir <build tree> 

或者直接从构建树目录执行:

ctest 

注意,在本章中,测试结果将输出到 test 子目录。

规划我们的工作

本章我们将构建的软件并不打算过于复杂——我们将创建一个简单的计算器,能够将两个数字相加(图 15.1)。它将是一个控制台应用程序,采用文本用户界面,利用第三方库和一个独立的计算库,后者可以在其他项目中使用。尽管这个项目可能没有重大实际应用,但它的简洁性非常适合展示本书中讨论的各种技术的应用。

图 15.1:在终端中执行的我们的项目的文本用户界面,支持鼠标操作

通常,项目要么生成面向用户的可执行文件,要么生成供开发者使用的库。项目同时生成这两者的情况比较少见,尽管也会发生。例如,一些应用程序会附带独立的 SDK 或库,帮助开发插件。另一个例子是一个包含使用示例的库。我们的项目属于后一类,展示了该库的功能。

我们将通过回顾章节列表、回想每个章节的内容,并选择我们将用于构建应用程序的技术和工具来开始规划:

  • 第一章CMake 的第一步

本章提供了关于 CMake 的基本信息,包括安装和命令行使用方法,以构建项目。它还涵盖了项目文件的基本信息,如它们的作用、典型命名约定和特殊性。

  • 第二章CMake 语言

我们介绍了编写正确 CMake 列表文件和脚本所需的工具,涵盖了基本的代码内容,如注释、命令调用和参数。我们解释了变量、列表和控制结构,并介绍了一些有用的命令。这些基础将贯穿整个项目。

  • 第三章在流行的 IDE 中使用 CMake

我们讨论了三种 IDE——CLion、VS Code 和 Visual Studio IDE——并强调了它们的优点。在我们的最终项目中,选择使用哪个 IDE(或是否使用 IDE)由你决定。一旦决定,你可以在 Dev 容器中开始这个项目,只需通过几步构建一个 Docker 镜像(或者直接从 Docker Hub 获取)。在容器中运行镜像可以确保开发环境与生产环境一致。

  • 第四章设置你的第一个 CMake 项目

配置项目至关重要,因为它决定了生效的 CMake 策略、命名、版本控制和编程语言。我们将利用这一章来影响构建过程的基本行为。

我们还将遵循既定的项目划分和结构来确定目录和文件的布局,并利用系统发现变量适应不同的构建环境。工具链配置是另一个关键方面,它使我们能够强制指定一个特定的 C++ 版本和编译器支持的标准。按照本章的建议,我们将禁用源代码内构建,以保持工作空间的整洁。

  • 第五章与目标一起工作

在这里,我们了解了每个现代 CMake 项目如何广泛使用目标。我们当然也会使用目标来定义一些库和可执行文件(既用于测试,也用于生产),以保持项目的组织性,并确保遵守DRYDon’t Repeat Yourself)原则。通过学习目标属性和传递使用要求(传播属性),我们将能够将配置保持在目标定义附近。

  • 第六章使用生成器表达式

生成器表达式在整个项目中被大量使用。我们将尽量使这些表达式保持简单明了。项目将包含自定义命令,以生成 Valgrind 和覆盖率报告的文件。此外,我们还将使用目标钩子,特别是 PRE_BUILD,来清理覆盖率插桩过程产生的 .gcda 文件。

  • 第七章使用 CMake 编译 C++ 源代码

没有 C++ 项目是不需要编译的。基础知识非常简单,但 CMake 允许我们以多种方式调整这一过程:扩展目标的源文件、配置优化器、提供调试信息。对于这个项目,默认的编译标志已经足够,但我们还是会稍微玩一下预处理器:

  • 我们将在编译后的可执行文件中存储构建元数据(项目版本、构建时间和 Git 提交 SHA),并将其展示给用户。

  • 我们将启用头文件的预编译。这在如此小的项目中并不是必需的,但它有助于我们练习这一概念。

不需要 Unity 构建——这个项目不会大到需要添加它们才有意义。

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

我们将获得有关链接的一般信息,这在任何项目中默认都是有用的。此外,由于这个项目包含一个库,我们将明确引用一些特定的构建指令,具体如下:

  • 用于测试和开发的静态库

  • 用于发布的共享库

本章还概述了如何隔离 main() 函数以进行测试,这是我们将采用的做法。

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

为了增强项目的吸引力,我们将引入一个外部依赖项:一个基于文本的 UI 库。第九章 探讨了管理依赖项的各种方法。选择将很简单:FetchContent 工具模块通常是推荐的且最方便的。

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

尽管我们已经探讨了 C++20 模块的使用以及支持此功能的环境要求(CMake 3.28,最新编译器),但其广泛支持仍然不足。为了确保项目的可访问性,我们暂时不会引入模块。

  • 第十一章测试框架

实施适当的自动化测试对于确保我们解决方案的质量在时间的推移中保持一致至关重要。我们将集成 CTest,并组织我们的项目以便于测试,应用之前提到的 main() 函数分离。

本章讨论了两个测试框架:Catch2 和 GTest 配合 GMock;我们将使用后者。为了获取详细的覆盖率信息,我们将使用 LCOV 生成 HTML 报告。

  • 第十二章程序分析工具

对于静态分析,我们可以选择一系列工具:Clang-Tidy,Cpplint,Cppcheck,include-what-you-use 和 link-what-you-use。我们将选择 Cppcheck,因为 Clang-Tidy 与使用 GCC 构建的预编译头文件兼容性较差。

动态分析将使用 Valgrind 的 Memcheck 工具进行,并辅以 Memcheck-cover 包装器生成 HTML 报告。此外,我们的源代码将在构建过程中通过 ClangFormat 自动格式化。

  • 第十三章生成文档:

提供文档在将库作为我们项目的一部分时是至关重要的。CMake 通过使用 Doxygen 实现文档生成的自动化。我们将在更新的设计中采用这种方法,并结合 doxygen-awesome-css 主题。

  • 第十四章安装与打包

最后,我们将配置解决方案的安装和打包,并准备文件形成如上所述的包,同时包括目标定义。我们将通过包含 GNUInstallDirs 模块,安装解决方案及构建目标的产物到相应目录。我们还将配置一些组件来模块化解决方案,并为与 CPack 的使用做准备。

专业项目通常还包括一些文本文件:READMELICENSEINSTALL 等。我们将在本章末尾简要介绍这些文件。

为了简化过程,我们不会实现自定义逻辑来检查是否所有必需的工具和依赖项都可用。我们将依赖 CMake 来显示其诊断信息,并告诉用户缺少什么。如果你的项目得到广泛关注,可能需要考虑添加这些机制来改善用户体验。

确定了清晰的计划后,接下来我们将讨论如何实际构建项目,无论是从逻辑目标还是目录结构的角度。

项目布局

构建任何项目时,我们应该先清楚地理解将在其中创建哪些逻辑目标。在这种情况下,我们将遵循以下图所示的结构:

图 15.2:逻辑目标的结构

让我们按照构建顺序来探索结构。首先,我们编译calc_obj,一个目标库。如果需要回顾目标库,请查看第五章与目标合作。然后,我们应关注静态 共享库

共享库与静态库

第八章链接可执行文件和库中,我们介绍了共享库和静态库。我们指出,当多个程序使用相同的库时,共享库可以减少整体内存使用。此外,用户通常已经安装了流行的库,或者知道如何快速安装它们。

更重要的是,共享库是独立的文件,必须放置在特定路径中,以便动态链接器能够找到它们。相比之下,静态库直接嵌入到可执行文件中,这使得使用时更快,因为无需额外步骤来定位内存中的代码。

作为库的作者,我们可以决定提供静态版本还是共享版本,或者我们可以同时提供这两种版本,并将此决定留给使用我们库的程序员。由于我们在应用我们的知识,我们将提供两个版本。

calc_test目标,包括用于验证库核心功能的单元测试,将使用静态库。虽然我们从相同的目标文件构建这两种类型的库,但测试任一库类型都是可以接受的,因为它们的功能应该是相同的。与calc_console_static目标关联的控制台应用程序将使用共享库。该目标还链接了一个外部依赖项,即 Arthur Sonzogni 的功能终端(X)用户界面(FTXUI)库(进一步阅读部分有指向 GitHub 项目的链接)。

最后的两个目标,calc_consolecalc_console_test,旨在解决测试可执行文件中的常见问题:测试框架和可执行文件提供的多个入口点的冲突。为避免此问题,我们故意将main()函数隔离到一个引导目标calc_console中,它仅调用calc_console_static中的主要功能。

在理解了必要的目标及其相互关系之后,我们的下一步是使用适当的文件和目录组织项目的结构。

项目文件结构

该项目由两个关键元素组成:calc库和calc_console可执行文件。为了有效地组织我们的项目,我们将采用以下目录结构:

  • src包含所有已发布目标和库头文件的源代码。

  • test包含上述库和可执行文件的测试。

  • cmake包含 CMake 用于构建和安装项目的工具模块和辅助文件。

  • 根目录包含顶级配置和文档文件。

这个结构(如图 15.3所示)确保了关注点的清晰分离,便于项目的导航和维护:

图 15.3:项目的目录结构

以下是每个四个主要目录中的文件完整列表:

根目录 ./test
CHANGELOG CMakeLists.txt
CMakeLists.txt calc/CMakeLists.txt
INSTALL``LICENSE``README.md calc/calc_test.cpp``calc_console/CMakeLists.txt``calc_console/tui_test.cpp
./src ./cmake
CMakeLists.txt``calc/CMakeLists.txt``calc/CalcConfig.cmake``calc/basic.cpp``calc/include/calc/basic.h``calc_console/CMakeLists.txt``calc_console/bootstrap.cpp``calc_console/include/tui.h``calc_console/tui.cpp BuildInfo.cmake``Coverage.cmake``CppCheck.cmake``Doxygen.cmake``Format.cmake``GetFTXUI.cmake``Packaging.cmake``Memcheck.cmake``NoInSourceBuilds.cmake``Testing.cmake``buildinfo.h.in``doxygen_extra_headers

表 15.1:项目文件结构

虽然看起来 CMake 引入了相当大的开销,且cmake目录最初包含的内容比实际的业务代码还多,但随着项目功能的扩展,这种动态会发生变化。最初建立一个清晰、有序的项目结构需要付出较大的努力,但可以放心,这项投资在未来将带来显著的回报。

本章将详细讲解表 15.1中提到的所有文件,并逐步分析它们的功能以及在项目中的作用。这个过程将分为四个步骤:构建、测试、安装和提供文档。

构建和管理依赖项

所有构建过程都遵循相同的程序。我们从顶层的列表文件开始,向下推进到项目的源代码树中。图 15.4展示了构建过程中的项目文件,括号中的数字表示 CMake 脚本执行的顺序。

图 15.4:构建阶段使用的文件

顶层的CMakeLists.txt(1)列表文件配置了项目:

ch15/01-full-project/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Calc VERSION 1.1.0 LANGUAGES CXX)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(NoInSourceBuilds)
include(CTest)
add_subdirectory(src bin)
add_subdirectory(test)
include(Packaging) 

我们首先指定项目的基本信息,并设置 CMake 工具模块的路径(即项目中的cmake目录)。然后,我们通过自定义模块来防止源代码构建。接着,我们启用CTest模块(CMake 内置的测试模块)。此步骤应该在项目的根目录进行,因为该命令会在相对于源代码树位置的二进制树中创建CTestTestfile.cmake文件。如果放在其他地方,将导致ctest无法找到它。

接下来,我们包括两个关键目录:

  • src,包含项目源代码(在构建树中命名为bin

  • test,包含所有测试工具

最后,我们包括Packaging模块,相关内容将在安装与打包部分中讨论。

让我们检查一下 NoInSourceBuilds 实用模块,以理解它的功能:

ch15/01-full-project/cmake/NoInSourceBuilds.cmake

if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
  message(FATAL_ERROR
    "\n"
    "In-source builds are not allowed.\n"
    "Instead, provide a path to build tree like so:\n"
    "cmake -B <destination>\n"
    "\n"
    "To remove files you accidentally created execute:\n"
    "rm -rf CMakeFiles CMakeCache.txt\n"
  )
endif() 

这里没有什么意外,我们检查用户是否提供了一个单独的生成文件目标目录,使用 cmake 命令。它必须与项目的源代码树路径不同。如果没有,我们将指导用户如何指定该目录,并在他们出错时如何清理仓库。

我们的顶级 listfile 随后包括了 src 子目录,指示 CMake 处理其中的 listfile 文件:

ch15/01-full-project/src/CMakeLists.txt

include(Coverage)
include(Format)
include(CppCheck)
include(Doxygen)
add_subdirectory(calc)
add_subdirectory(calc_console) 

这个文件很简单——它包括了我们将要使用的 ./cmake 目录中的所有模块,并引导 CMake 到嵌套的目录中去执行那里的 listfile 文件。

接下来,让我们检查 calc 库的 listfile。它有些复杂,因此我们将其分解并按部分进行讨论。

构建 Calc 库

calc 目录中的 listfile 配置了该库的各个方面,但现在,我们只关注构建部分:

ch15/01-full-project/src/calc/CMakeLists.txt(片段)

add_library(calc_obj OBJECT basic.cpp)
target_sources(calc_obj
               PUBLIC FILE_SET HEADERS
               BASE_DIRS include
               FILES include/calc/basic.h
)
set_target_properties(calc_obj PROPERTIES
    POSITION_INDEPENDENT_CODE 1
)
# ... instrumentation of calc_obj for coverage
add_library(calc_shared SHARED)
target_link_libraries(calc_shared calc_obj)
add_library(calc_static STATIC)
target_link_libraries(calc_static calc_obj)
# ... testing and program analysis modules
# ... documentation generation
# ... installation 

我们定义了三个目标:

  • calc_obj,一个目标库,编译 basic.cpp 实现文件。它的 basic.h 头文件通过 target_sources() 命令中的 FILE_SET 关键字包含进来。这样隐式地配置了合适的包含目录,以便在构建和安装模式下正确导出。通过创建目标库,我们避免了对两个库版本的冗余编译,但启用 POSITION_INDEPENDENT_CODE 是必要的,这样共享库才能依赖于该目标。

  • calc_shared,一个依赖于 calc_obj 的共享库。

  • calc_static,一个同样依赖于 calc_obj 的静态库。

为了提供背景信息,以下是基础库的 C++ 头文件。这个头文件仅声明了 Calc 命名空间中的两个函数,帮助避免命名冲突:

ch15/01-full-project/src/calc/include/calc/basic.h

#pragma once
namespace Calc {
    int Add(int a, int b);
    int Subtract(int a, int b);
}  // namespace Calc 

实现文件也很直接:

ch15/01-full-project/src/calc/basic.cpp

namespace Calc {
  int Add(int a, int b) {
    return a + b;
  }
  int Subtract(int a, int b) {
    return a - b;
  }
} // namespace Calc 

这部分解释完了 src/calc 目录中的文件。接下来是 src/calc_console 以及如何使用该库构建控制台计算器的可执行文件。

构建 Calc 控制台可执行文件

calc_console 目录包含多个文件:一个 listfile,两个实现文件(业务逻辑和引导文件),以及一个头文件。这个 listfile 如下所示:

ch15/01-full-project/src/calc_console/CMakeLists.txt(片段)

add_library(calc_console_static STATIC tui.cpp)
target_include_directories(calc_console_static PUBLIC include)
target_precompile_headers(calc_console_static PUBLIC <string>)
include(GetFTXUI)
target_link_libraries(calc_console_static PUBLIC calc_shared
                      ftxui::screen ftxui::dom ftxui::component)
include(BuildInfo)
BuildInfo(calc_console_static)
# ... instrumentation of calc_console_static for coverage
# ... testing and program analysis modules
# ... documentation generation
add_executable(calc_console bootstrap.cpp)
target_link_libraries(calc_console calc_console_static)
# ... installation 

尽管这个 listfile 看起来复杂,但作为经验丰富的 CMake 用户,我们现在可以轻松地解读它的内容:

  1. 定义 calc_console_static 目标,包含没有 main() 函数的业务代码,以便与具有自己入口点的 GTest 进行链接。

  2. 配置包含目录。我们本可以通过 FILE_SET 单独添加头文件,但由于它们是内部文件,我们简化了这一步。

  3. 实现头文件预编译,这里仅用<string>头文件作为示例,尽管更大的项目可能会包含更多头文件。

  4. 包含一个自定义 CMake 模块,用于获取 FTXUI 依赖项。

  5. 将业务代码与共享的calc_shared库和 FTXUI 组件链接。

  6. 添加一个自定义模块,用于生成构建信息并将其嵌入到产物中。

  7. 为此目标概述额外的步骤:代码覆盖度仪器、测试、程序分析和文档生成。

  8. 创建并链接calc_console引导程序可执行文件,建立入口点。

  9. 概述安装过程。

我们将在本章后续的相关部分探索测试、文档和安装过程。

我们正在包含GetFTXUI实用模块,而不是在系统中查找config-module,因为大多数用户不太可能已经安装它。我们只需要获取并构建它:

ch15/01-full-project/cmake/GetFTXUI.cmake

include(FetchContent)
FetchContent_Declare(
FTXTUI
GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI.git
GIT_TAG        v0.11
)
**option****(FTXUI_ENABLE_INSTALL** **""****OFF****)**
**option****(FTXUI_BUILD_EXAMPLES** **""****OFF****)**
**option****(FTXUI_BUILD_DOCS** **""****OFF****)**
FetchContent_MakeAvailable(FTXTUI) 

我们正在使用推荐的FetchContent方法,该方法在第九章CMake 中的依赖管理》中有详细介绍。唯一不同的是调用了option()命令,这使我们可以绕过 FTXUI 的漫长构建步骤,并防止其安装步骤影响此项目的安装过程。有关更多详细信息,请参阅进一步阅读部分。

calc_console目录的列表文件包含另一个与构建相关的自定义实用模块:BuildInfo。该模块将捕获三条信息,并将其显示在可执行文件中:

  • 当前 Git 提交的 SHA。

  • 构建时间戳。

  • 顶级列表文件中指定的项目版本。

正如我们在第七章使用 CMake 编译 C++源代码》中学到的,CMake 可以捕获构建时的值并通过模板文件将其传递给 C++代码,例如使用一个结构体:

ch15/01-full-project/cmake/buildinfo.h.in

struct BuildInfo {
  static inline const std::string CommitSHA = "@COMMIT_SHA@";
  static inline const std::string Timestamp = "@TIMESTAMP@";
  static inline const std::string Version = "@PROJECT_VERSION@";
}; 

为了在配置阶段填充该结构体,我们将使用以下代码:

ch15/01-full-project/cmake/BuildInfo.cmake

set(BUILDINFO_TEMPLATE_DIR ${CMAKE_CURRENT_LIST_DIR})
set(DESTINATION "${CMAKE_CURRENT_BINARY_DIR}/buildinfo")
string(TIMESTAMP TIMESTAMP)
find_program(GIT_PATH git REQUIRED)
execute_process(COMMAND ${GIT_PATH} log --pretty=format:'%h' -n 1
                OUTPUT_VARIABLE COMMIT_SHA)
configure_file(
  "${BUILDINFO_TEMPLATE_DIR}/buildinfo.h.in"
  "${DESTINATION}/buildinfo.h" @ONLY
)
function(BuildInfo target)
  target_include_directories(${target} PRIVATE ${DESTINATION})
endfunction() 

在包含该模块后,我们设置变量以捕获所需的信息,并使用configure_file()生成buildinfo.h。最后一步是调用BuildInfo函数,将生成的文件目录包含到目标的include目录中。

生成的头文件如果需要,可以与多个不同的消费者共享。在这种情况下,你可能希望在列表文件的顶部添加include_guard(GLOBAL),以避免为每个目标都运行git命令。

在深入实现控制台计算器之前,我想强调的是,你不需要深入理解tui.cpp文件或 FTXUI 库的复杂细节,因为这对我们当前的目的并不重要。相反,让我们关注代码中的高亮部分:

ch15/01-full-project/src/calc_console/tui.cpp

#include "tui.h"
#include <ftxui/dom/elements.hpp>
**#****include****"buildinfo.h"**
**#****include****"calc/basic.h"**
using namespace ftxui;
using namespace std;
string a{"12"}, b{"90"};
auto input_a = Input(&a, "");
auto input_b = Input(&b, "");
auto component = Container::Vertical({input_a, input_b});
Component getTui() {
  return Renderer(component, [&] {
    **auto** **sum = Calc::****Add****(****stoi****(a),** **stoi****(b));**
    return vbox({
      **text****(****"CalcConsole "** **+ BuildInfo::Version),**
**text****(****"Built: "** **+ BuildInfo::Timestamp),**
**text****(****"SHA: "** **+ BuildInfo::CommitSHA),**
       separator(),
       input_a->Render(),
       input_b->Render(),
       separator(),
       **text****(****"Sum: "** **+** **to_string****(sum)),**
     }) |
     border;
   });
} 

这段代码提供了getTui()函数,该函数返回一个ftxui::Component对象,这个对象封装了交互式 UI 元素,如标签、文本字段、分隔符和边框。对于那些好奇这些元素的详细工作原理的人,更多的资料可以在进一步阅读部分找到。

更重要的是,包含指令链接到calc_obj目标和BuildInfo模块中的头文件。交互从 lambda 函数开始,调用Calc::Sum,并使用text()函数显示结果。

在构建时收集的buildinfo.h中的值会以类似的方式使用,并在运行时显示给用户。

tui.cpp旁边,有一个头文件:

ch15/01-full-project/src/calc_console/include/tui.h

#include <ftxui/component/component.hpp>
ftxui::Component getTui(); 

这个头文件被calc_console目标中的引导文件使用:

ch15/01-full-project/src/calc_console/bootstrap.cpp

#include <ftxui/component/screen_interactive.hpp>
#include "tui.h"
int main(int argc, char** argv) {
  ftxui::ScreenInteractive::FitComponent().Loop(getTui());
} 

这段简短的代码初始化了一个带有 FTXUI 的交互式控制台屏幕,显示getTui()返回的Component对象,并在循环中处理键盘输入。所有src目录下的文件都已处理完毕,我们现在可以继续进行程序的测试和分析。

测试和程序分析

程序分析和测试是确保我们解决方案质量的重要组成部分。例如,在运行测试代码时,使用 Valgrind 更加有效(因为它具有一致性和覆盖率)。因此,我们将把测试和程序分析配置在同一位置。图 15.5 展示了执行流程和设置它们所需的文件:

图 15.5:用于启用测试和程序分析的文件

括号中的数字表示处理列表文件的顺序。从顶级列表文件开始,并添加srctest目录:

  • src中,包含CoverageFormatCppCheck模块,并添加src/calcsrc/calc_console目录。

  • src/calc中,定义目标并使用包含的模块进行配置。

  • src/calc_console中,定义目标并使用包含的模块进行配置。

  • test中,包含Testing(包括Memcheck),并添加test/calctest/calc_console目录。

  • test/calc中,定义测试目标并使用包含的模块进行配置。

  • test/calc_console中,定义测试目标并使用包含的模块进行配置。

让我们来查看一下test目录的列表文件:

ch15/01-full-project/test/CMakeLists.txt

**include****(Testing)**
add_subdirectory(calc)
add_subdirectory(calc_console) 

在这个层次中,包含了Testing实用模块,为两个目标组(来自calccalc_console目录)提供功能:

ch15/01-full-project/cmake/Testing.cmake(片段)

include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG v1.14.0
)
# For Windows: Prevent overriding the parent project's
# compiler/linker settings
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
**option****(INSTALL_GMOCK** **"Install GMock"****OFF****)**
**option****(INSTALL_GTEST** **"Install GTest"****OFF****)**
FetchContent_MakeAvailable(googletest)
# ... 

我们启用了测试并包含了 FetchContent 模块来获取 GTest 和 GMock。虽然 GMock 在本项目中没有使用,但它与 GTest 一起在同一仓库中提供,因此我们也进行了配置。关键的配置步骤是通过使用 option() 命令,防止这些框架的安装影响到我们项目的安装。

在同一个文件中,我们定义了一个 AddTests() 函数,以方便全面测试业务目标:

ch15/01-full-project/cmake/Testing.cmake (续)

# ...
include(GoogleTest)
include(Coverage)
include(Memcheck)
macro(AddTests target)
  message("Adding tests to ${target}")
  target_link_libraries(${target} PRIVATE gtest_main gmock)
  gtest_discover_tests(${target})
  **AddCoverage(****${target}****)**
 **AddMemcheck(****${target}****)**
endmacro() 

首先,我们包含了必要的模块:GoogleTest 与 CMake 一起捆绑提供,CoverageMemcheck 是项目中自定义的工具模块。接着,提供了 AddTests 宏,用于准备一个测试目标,应用覆盖率插桩和内存检查。AddCoverage()AddMemcheck() 函数分别在它们各自的工具模块中定义。现在,我们可以继续实现它们。

准备 Coverage 模块

在多个目标上添加覆盖率涉及几个步骤。Coverage 模块提供了一个函数,用于为指定目标定义覆盖率目标:

ch15/01-full-project/cmake/Coverage.cmake (片段)

function(AddCoverage target)
  find_program(LCOV_PATH lcov REQUIRED)
  find_program(GENHTML_PATH genhtml REQUIRED)
  add_custom_target(coverage-${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-${target}
      filtered.info --legend
    COMMAND rm -rf coverage.info filtered.info
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction()
# ... 

这个实现与 第十一章 中介绍的实现略有不同,因为它现在在输出路径中包括了目标名称,以防止名称冲突。接下来,我们需要一个函数来清除之前的覆盖率结果:

ch15/01-full-project/cmake/Coverage.cmake (续)

# ...
function(CleanCoverage target)
  add_custom_command(TARGET ${target} PRE_BUILD COMMAND
    find ${CMAKE_BINARY_DIR} -type f
    -name '*.gcda' -exec rm {} +)
endfunction()
# ... 

此外,我们还提供了一个函数来准备目标进行覆盖率分析:

ch15/01-full-project/cmake/Coverage.cmake (片段)

# ...
function(InstrumentForCoverage target)
  if (CMAKE_BUILD_TYPE STREQUAL Debug)
target_compile_options(${target}
                       PRIVATE --coverage -fno-inline)
    target_link_options(${target} PUBLIC --coverage)
  endif()
endfunction() 

InstrumentForCoverage() 函数应用于 src/calcsrc/calc_console,在执行目标 calc_objcalc_console_static 时生成覆盖率数据文件。

要为两个测试目标生成报告,请在配置项目并选择 Debug 构建类型后,执行以下 cmake 命令:

cmake --build <build-tree> -t coverage-calc_test
cmake --build <build-tree> -t coverage-calc_console_test 

接下来,我们希望对我们定义的多个目标进行动态程序分析,因此,要应用在 第十二章 中介绍的 Memcheck 模块,程序分析工具,我们需要稍作调整,以扫描多个目标。

准备 Memcheck 模块

生成 Valgrind 内存管理报告由 AddTests() 启动。我们通过其初始设置开始 Memcheck 模块:

ch15/01-full-project/cmake/Memcheck.cmake (片段)

include(FetchContent)
FetchContent_Declare(
  memcheck-cover
  GIT_REPOSITORY https://github.com/Farigh/memcheck-cover.git
  GIT_TAG        release-1.2
)
FetchContent_MakeAvailable(memcheck-cover) 

这段代码我们已经很熟悉了。现在,让我们来看一下创建必要目标以生成报告的函数:

ch15/01-full-project/cmake/Memcheck.cmake (续)

function(AddMemcheck target)
  set(MEMCHECK_PATH ${memcheck-cover_SOURCE_DIR}/bin)
  **set****(REPORT_PATH** **"${CMAKE_BINARY_DIR}/valgrind-${target}"****)**
  add_custom_target(memcheck-${target}
    COMMAND ${MEMCHECK_PATH}/memcheck_runner.sh -o
      "${REPORT_PATH}/report"
      -- $<TARGET_FILE:${target}>
    COMMAND ${MEMCHECK_PATH}/generate_html_report.sh
      -i ${REPORT_PATH}
      -o ${REPORT_PATH}
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction() 

我们稍微改进了 第十二章 中的 AddMemcheck() 函数,以便处理多个目标。我们使 REPORT_PATH 变量针对每个目标特定。

要生成 Memcheck 报告,请使用以下命令(请注意,当使用 Debug 构建类型进行配置时,生成报告更为有效):

cmake --build <build-tree> -t memcheck-calc_test
cmake --build <build-tree> -t memcheck-calc_console_test 

好的,我们定义了 CoverageMemcheck 模块(它们在 Testing 模块中使用),那么让我们看看实际的测试目标是如何配置的。

应用测试场景

为了实现测试,我们将遵循以下场景:

  1. 编写单元测试。

  2. 使用 AddTests() 定义并配置测试的可执行目标。

  3. 被测软件SUT)进行插桩,以启用覆盖率收集。

  4. 确保在构建之间清除覆盖率数据,以防止出现段错误。

让我们从必须编写的单元测试开始。为了简洁起见,我们将提供最简单(也许有些不完整)的单元测试。首先,测试库:

ch15/01-full-project/test/calc/basic_test.cpp

#include "calc/basic.h"
#include <gtest/gtest.h>
TEST(CalcTest, SumAddsTwoInts) {
  EXPECT_EQ(4, Calc::Add(2, 2));
}
TEST(CalcTest, SubtractsTwoInts) {
  EXPECT_EQ(6, Calc::Subtract(8, 2));
} 

接着测试控制台——为此我们将使用 FXTUI 库。同样,完全理解源代码并不是必要的;这些测试仅用于说明目的:

ch15/01-full-project/test/calc_console/tui_test.cpp

#include "tui.h"
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <ftxui/screen/screen.hpp>
using namespace ::ftxui;
TEST(ConsoleCalcTest, RunWorksWithDefaultValues) {
  auto component = getTui();
  auto document = component->Render();
  auto screen = Screen::Create(Dimension::Fit(document));
  Render(screen, document);
  auto output = screen.ToString();
  ASSERT_THAT(output, testing::HasSubstr("Sum: 102"));
} 

这个测试将 UI 渲染到一个静态的 Screen 对象,并检查字符串输出是否包含预期的和。虽然这不是一个很好的测试,但至少它是一个简短的测试。

现在,让我们通过两个嵌套的列表文件配置我们的测试。首先,针对库:

ch15/01-full-project/test/calc/CMakeLists.txt

add_executable(calc_test basic_test.cpp)
target_link_libraries(calc_test PRIVATE calc_static)
**AddTests(calc_test)** 

然后是可执行文件:

ch15/01-full-project/test/calc_console/CMakeLists.txt

add_executable(calc_console_test tui_test.cpp)
target_link_libraries(calc_console_test
                      PRIVATE calc_console_static)
**AddTests(calc_console_test)** 

这些配置使 CTest 可以执行测试。我们还需要为业务逻辑目标准备覆盖率分析,并确保覆盖率数据在构建之间得到更新。

让我们为 calc 库目标添加必要的指令:

ch15/01-full-project/src/calc/CMakeLists.txt(续)

# ... calc_obj target definition
**InstrumentForCoverage(calc_obj)**
# ... calc_shared target definition
# ... calc_static target definition
**CleanCoverage(calc_static)** 

插桩通过额外的 --coverage 标志添加到 calc_obj,但是 CleanCoverage() 被调用到 calc_static 目标。通常情况下,你会对 calc_obj 应用它以保持一致性,但我们在 CleanCoverage() 中使用了 PRE_BUILD 关键字,而 CMake 不允许在对象库上使用 PRE_BUILDPRE_LINKPOST_BUILD 钩子。

最后,我们还将插桩并清理控制台目标:

ch15/01-full-project/src/calc_console/CMakeLists.txt(续)

# ... calc_console_test target definition
# ... BuildInfo
**InstrumentForCoverage(calc_console_static)**
**CleanCoverage(calc_console_static)** 

通过这些步骤,CTestr 已设置好运行我们的测试并收集覆盖率。接下来,我们将添加启用静态分析的指令,因为我们希望在第一次构建以及后续所有构建中都保持项目的高质量。

添加静态分析工具

我们即将完成为我们的目标配置质量保证的工作。最后一步是启用自动格式化并集成CppCheck

ch15/01-full-project/src/calc/CMakeLists.txt(续)

# ... calc_static target definition
# ... Coverage instrumentation and cleaning
**Format(calc_static .)**
**AddCppCheck(calc_obj)** 

我们在这里遇到一个小问题:calc_obj 不能有 PRE_BUILD 钩子,因此我们改为对 calc_static 应用格式化。我们还确保 calc_console_static 目标被格式化并检查:

ch15/01-full-project/src/calc_console/CMakeLists.cmake(续)

# ... calc_console_test target definition
# ... BuildInfo
# ... Coverage instrumentation and cleaning
**Format(calc_console_static .)**
**AddCppCheck(calc_console_static)** 

我们仍然需要定义FormatCppCheck函数。从Format()开始,我们借用了第十二章程序分析工具中描述的代码:

ch15/01-full-project/cmake/Format.cmake

function(Format target directory)
  find_program(CLANG-FORMAT_PATH clang-format REQUIRED)
  set(EXPRESSION h hpp hh c cc cxx cpp)
  list(TRANSFORM EXPRESSION PREPEND "${directory}/*.")
  file(GLOB_RECURSE SOURCE_FILES FOLLOW_SYMLINKS
    LIST_DIRECTORIES false ${EXPRESSION}
  )
  add_custom_command(TARGET ${target} PRE_BUILD COMMAND
    ${CLANG-FORMAT_PATH} -i --style=file ${SOURCE_FILES}
  )
endfunction() 

要将CppCheck与我们的源代码集成,我们使用:

ch15/01-full-project/cmake/CppCheck.cmake

function(AddCppCheck target)
  find_program(CPPCHECK_PATH cppcheck REQUIRED)
  set_target_properties(${target}
    PROPERTIES CXX_CPPCHECK
    "${CPPCHECK_PATH};**--enable=warning;--error-exitcode=10**"
  )
endfunction() 

这很简单方便。你可能会发现它与 Clang-Tidy 模块(见第十二章程序分析工具)有些相似,展示了 CMake 在功能上的一致性。

cppcheck的参数如下:

  • --enable=warning:启用警告信息。要启用更多检查,请参阅 Cppcheck 手册(见进一步阅读部分)。

  • --error-exitcode=1:设置当cppcheck检测到问题时返回的错误代码。可以是1255之间的任何数字(因为0表示成功),尽管某些数字可能被系统保留。

所有srctest目录中的文件都已创建,我们的解决方案现在可以构建并完全测试。我们可以继续进行安装和打包步骤。

安装和打包

图 15.6显示了我们将如何配置项目进行安装和打包:

图 15.6:配置安装和打包的文件

顶层的 listfile 包括Packaging工具模块:

ch15/01-full-project/CMakeLists.txt(片段)

# ... configure project
# ... enable testing
# ... include src and test subdirectories
**include****(Packaging)** 

Packaging模块详细描述了项目的包配置,我们将在使用 CPack 打包部分中探讨。我们现在的重点是安装三个主要组件:

  • Calc 库的工件:静态和共享库、头文件以及目标导出文件

  • Calc 库的包定义配置文件

  • Calc 控制台可执行文件

一切都已规划好,现在是配置库的安装的时候了。

库的安装

为了安装该库,我们首先定义逻辑目标及其工件目的地,利用GNUInstallDirs模块的默认值以避免手动指定路径。工件将按组件分组。默认安装将安装所有文件,但你可以选择只安装runtime组件,跳过development工件:

ch15/01-full-project/src/calc/CMakeLists.txt(续)

# ... calc library targets definition
# ... configuration, testing, program analysis
# Installation
include(GNUInstallDirs)
install(TARGETS calc_obj calc_shared calc_static
  EXPORT CalcLibrary
  ARCHIVE COMPONENT development
  LIBRARY COMPONENT runtime
  FILE_SET HEADERS COMPONENT runtime
) 

对于 UNIX 系统,我们还配置了共享库的安装后注册,使用ldconfig

ch15/01-full-project/src/calc/CMakeLists.txt(续)

if (UNIX)
  install(CODE "execute_process(COMMAND ldconfig)"
    COMPONENT runtime
  )
endif() 

为了在其他 CMake 项目中启用可重用性,我们将通过生成并安装一个目标导出文件和一个引用它的配置文件来打包该库:

ch15/01-full-project/src/calc/CMakeLists.txt(续)

install(EXPORT CalcLibrary
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
  NAMESPACE Calc::
  COMPONENT runtime
)
install(FILES "CalcConfig.cmake"
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
) 

为了简单起见,CalcConfig.cmake文件保持简洁:

ch15/01-full-project/src/calc/CalcConfig.cmake

include("${CMAKE_CURRENT_LIST_DIR}/CalcLibrary.cmake") 

这个文件位于 src/calc 中,因为它只包含库目标。如果有来自其他目录的目标定义,比如 calc_console,通常会将 CalcConfig.cmake 放在顶层或 src 目录中。

现在,库已经准备好在构建项目后通过 cmake --install 命令进行安装。然而,我们仍然需要配置可执行文件的安装。

可执行文件的安装

当然,我们希望用户能够在他们的系统上使用可执行文件,因此我们将使用 CMake 安装它。准备二进制可执行文件的安装非常简单;为此,我们只需要包含 GNUInstallDirs 并使用一个 install() 命令:

ch15/01-full-project/src/calc_console/CMakeLists.txt(续)

# ... calc_console_static definition
# ... configuration, testing, program analysis
# ... calc_console bootstrap executable definition
# Installation
include(GNUInstallDirs)
install(TARGETS calc_console
  RUNTIME COMPONENT runtime
) 

这样,可执行文件已经设置好可以安装了。现在,让我们继续进行打包。

使用 CPack 进行打包

我们可以配置多种支持的包类型,但对于这个项目,基本配置就足够了:

ch15/01-full-project/cmake/Packaging.cmake

# CPack configuration
set(CPACK_PACKAGE_VENDOR "Rafal Swidzinski")
set(CPACK_PACKAGE_CONTACT "email@example.com")
set(CPACK_PACKAGE_DESCRIPTION "Simple Calculator")
include(CPack) 

这样的最小配置对于标准存档(如 ZIP 文件)非常有效。为了在构建项目后测试安装和打包过程,可以在构建树内使用以下命令:

# cpack -G TGZ -B packages
CPack: Create package using TGZ
CPack: Install projects
CPack: - Run preinstall target for: Calc
CPack: - Install project: Calc []
CPack: Create package
CPack: - package: .../packages/Calc-1.0.0-Linux.tar.gz generated. 

这就结束了安装和打包的部分;接下来的任务是文档。

提供文档

一个专业项目的最后润色是文档。没有文档的项目在团队合作和与外部分享时都非常难以导航和理解。我甚至会说,程序员常常在离开某个特定文件后,重新阅读自己的文档,以便理解文件中的内容。

文档对于法律和合规性以及告知用户如何使用软件也非常重要。如果时间允许,我们应该投入精力为我们的项目设置文档。

文档通常分为两类:

  • 技术文档(涵盖接口、设计、类和文件)

  • 一般文档(涵盖所有其他非技术文档)

正如我们在第十三章中看到的,生成文档,大部分技术文档可以通过 CMake 使用 Doxygen 自动生成。

生成技术文档

虽然一些项目在构建阶段生成文档并将其包含在包中,但我们选择不这样做。尽管如此,也有可能出于某些有效原因选择这样做,比如如果文档需要在线托管。

图 15.7 提供了文档生成过程的概述:

图 15.7:用于生成文档的文件

为了生成文档,我们将创建另一个 CMake 工具模块 Doxygen。首先使用 Doxygen find-module 并下载 doxygen-awesome-css 项目来获取主题:

ch15/01-full-project/cmake/Doxygen.cmake(片段)

find_package(Doxygen REQUIRED)
include(FetchContent)
FetchContent_Declare(doxygen-awesome-css
  GIT_REPOSITORY
    https://github.com/jothepro/doxygen-awesome-css.git
  GIT_TAG
    v2.3.1
)
FetchContent_MakeAvailable(doxygen-awesome-css) 

然后,我们需要一个函数来创建生成文档的目标。我们将调整在第十三章《生成文档》中介绍的代码,以支持多个目标:

ch15/01-full-project/cmake/Doxygen.cmake(续)

function(Doxygen target **input**)
  set(NAME "doxygen**-${target}**")
  set(DOXYGEN_GENERATE_HTML YES)
  set(DOXYGEN_HTML_OUTPUT   ${PROJECT_BINARY_DIR}/${output})
  UseDoxygenAwesomeCss()
  UseDoxygenAwesomeExtensions()
  doxygen_add_docs("doxygen**-${target}**"
      ${PROJECT_SOURCE_DIR}/${input}
      COMMENT "Generate HTML documentation"
  )
endfunction()
# ... copied from Ch13:
#     UseDoxygenAwesomeCss
#     UseDoxygenAwesomeExtensions 

通过调用库目标来使用此功能:

ch15/01-full-project/src/calc/CMakeLists.txt(片段)

# ... calc_static target definition
# ... testing and program analysis modules
**Doxygen(calc src/calc)**
# ... file continues 

对于控制台可执行文件:

ch15/01-full-project/src/calc_console/CMakeLists.txt(片段)

# ... calc_static target definition
# ... testing and program analysis modules
**Doxygen(calc_console src/calc_console)**
# ... file continues 

此设置为项目添加了两个目标:doxygen-calcdoxygen-calc_console,允许按需生成技术文档。现在,让我们考虑应该包含哪些其他文档。

为专业项目编写非技术性文档

专业项目应包括一组非技术性文档,存储在顶层目录中,对于全面理解和法律清晰度至关重要:

  • README: 提供项目的一般描述

  • LICENSE: 详细说明有关项目使用和分发的法律参数

你可能考虑的其他文档包括:

  • INSTALL: 提供逐步的安装说明

  • CHANGELOG: 提示版本之间的重要变更

  • AUTHORS: 列出贡献者及其联系方式,如果项目有多个贡献者的话

  • BUGS: 提供已知问题和报告新问题的详细信息

CMake 不会直接与这些文件交互,因为它们不涉及自动处理或脚本。然而,它们的存在对于一个良好文档化的 C++项目至关重要。以下是每个文档的最小示例:

ch15/01-full-project/README.md

# Calc Console
Calc Console is a calculator that adds two numbers in a
terminal. It does all the math by using a **Calc** library.
This library is also available in this package.
This application is written in C++ and built with CMake.
## More information
- Installation instructions are in the INSTALL file
- License is in the LICENSE file 

这很简短,可能有点傻。注意.md扩展名——它代表Markdown,这是一种基于文本的格式化语言,易于阅读。像 GitHub 这样的站点和许多文本编辑器会以丰富的格式呈现这些文件。

我们的INSTALL文件将如下所示:

ch15/01-full-project/INSTALL

To install this software you'll need to provide the following:
- C++ compiler supporting C++17
- CMake >= 3.26
- GIT
- Doxygen + Graphviz
- CPPCheck
- Valgrind
This project also depends on GTest, GMock and FXTUI. This
software is automatically pulled from external repositories
during the installation.
To configure the project type:
cmake -B <temporary-directory>
Then you can build the project:
cmake --build <temporary-directory>
And finally install it:
cmake --install <temporary-directory>
To generate the documentation run:
cmake --build <temporary-directory> -t doxygen-calc
cmake --build <temporary-directory> -t doxygen-calc_console 

LICENSE文件有点棘手,因为它需要一些版权法方面的专业知识(以及其他方面)。我们可以像许多其他项目一样,使用现成的开源软件许可证,而不是自己编写所有条款。对于这个项目,我们将使用 MIT 许可证,它非常宽松。请查看进一步阅读部分,获取一些有用的参考资料:

ch15/01-full-project/LICENSE

Copyright 2022 Rafal Swidzinski
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 

最后,我们有CHANGELOG。如前所述,保持文件中的变更记录很有帮助,这样使用你项目的开发者可以轻松找到支持他们所需功能的版本。例如,可能有用的是说明在版本 0.8.2 中为库添加了乘法功能。像以下这样简单的内容已经非常有帮助:

ch15/01-full-project/CHANGELOG

1.1.0 Updated for CMake 3.26 in 2nd edition of the book
1.0.0 Public version with installer
0.8.2 Multiplication added to the Calc Library
0.5.1 Introducing the Calc Console application
0.2.0 Basic Calc library with Sum function 

有了这些文档,项目不仅获得了操作结构,还有效地传达了其使用方法、变更和法律事项,确保用户和贡献者掌握所有必要的信息。

总结

在本章中,我们基于迄今为止学到的一切,整合了一个专业的项目。让我们快速回顾一下。

我们首先规划了项目结构,并讨论了哪些文件将位于哪个目录中。基于之前的经验和对更高级场景的实践需求,我们划分了面向用户的主要应用程序和另一个开发人员可能使用的库。这决定了目录结构和我们希望构建的 CMake 目标之间的关系。接着,我们为构建配置了各个目标:我们提供了库的源代码,定义了它的目标,并为其配置了独立位置代码参数以供使用。面向用户的应用程序也定义了它的可执行目标,提供了源代码,并配置了它的依赖:FTXUI 库。

拥有构建工件后,我们继续通过测试和质量保证来增强我们的项目。我们添加了覆盖率模块以生成覆盖报告,使用 Memcheck 在运行时通过 Valgrind 验证解决方案,并且还使用 CppCheck 执行静态分析。

现在这个项目已经准备好安装,因此我们使用迄今为止学到的技术为库和可执行文件创建了适当的安装条目,并为 CPack 准备了一个包配置。最后的任务是确保项目的文档是正确的,因此我们设置了自动文档生成(使用 Doxygen),并编写了一些基础文档来处理软件分发中的非技术性方面。

这使我们完成了项目配置,现在我们可以轻松地使用几个精确的 CMake 命令来构建并安装它。但如果我们能只用一个简单的命令来完成整个过程呢?让我们在最后一章:第十六章编写 CMake 预设中探索如何做到这一点。

进一步阅读

如需更多信息,您可以参考以下链接:

留下评论!

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

第十六章:编写 CMake 预设

预设是在 CMake 3.19 版本中加入的,旨在简化项目设置的管理。在有了预设之前,用户必须记住冗长的命令行配置,或者直接在项目文件中设置覆盖项,这样的做法容易出错并且变得复杂。使用预设后,用户可以更加简便地处理项目配置时所使用的生成器、并行构建任务的数量,以及需要构建或测试的项目组件等设置。使用预设后,CMake 变得更加易用。用户只需配置一次预设,之后可以随时使用它们,这使得每次执行 CMake 时更加一致,且更容易理解。预设还帮助标准化不同用户和计算机之间的设置,简化了协作项目的工作。

预设与 CMake 的四种主要模式兼容:配置构建系统、构建、运行测试和打包。它们允许用户将这些部分连接在一起,形成工作流,使整个过程更加自动化和有序。此外,预设还提供了条件和宏表达式(或简单地称为宏)等功能,赋予用户更大的控制力。

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

  • 在项目中使用定义的预设

  • 编写预设文件

  • 定义特定阶段的预设

  • 定义工作流预设

  • 添加条件和宏

技术要求

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

本章中执行示例所需的命令将在每个部分中提供。

在项目中使用定义的预设

当我们需要具体指定诸如缓存变量、选择的生成器等元素时,项目配置可能变得非常复杂,尤其是在有多种方式可以构建项目时。这时预设就显得非常有用。我们可以通过创建一个预设文件,并将所需的配置存储在项目中,避免记住命令行参数或编写 shell 脚本以不同的参数执行cmake

CMake 使用两个可选文件来存储项目预设:

  • CMakePresets.json:由项目作者提供的官方预设。

  • CMakeUserPresets.json:专为希望向项目添加自定义预设的用户设计。项目应将此文件添加到版本控制忽略列表,以确保自定义设置不会无意间共享到仓库中。

预设文件必须放在项目的顶级目录中,CMake 才能识别它们。每个预设文件可以为每个阶段(配置、构建、测试、打包以及涵盖多个阶段的工作流预设)定义多个预设。用户随后可以通过 IDE、GUI 或命令行选择一个预设进行执行。

通过在命令行中添加--list-presets参数,可以列出预设,具体取决于我们要列出的阶段。例如,可以使用以下命令列出构建预设:

cmake --build --list-presets 

测试预设可以通过以下命令列出:

ctest --list-presets 

要使用预设,我们需要遵循相同的模式,并在--preset参数后提供预设名称。

此外,无法使用cmake命令列出包预设;需要使用cpack。以下是包预设的命令行:

cpack --preset <preset-name> 

选择预设后,当然可以添加阶段特定的命令行参数,例如,指定构建树或安装路径。添加的参数将覆盖预设中设置的任何内容。

工作流预设有一个特殊情况,如果在运行cmake命令时附加了--workflow参数,则可以列出并应用它们:

$ cmake --workflow --list-presets
Available workflow presets:
  "myWorkflow"
$ cmake --workflow --preset myWorkflow
Executing workflow step 1 of 4: configure preset "myConfigure"
... 

这就是如何在项目中应用和查看可用预设。现在,让我们来探索预设文件的结构。

编写预设文件

CMake 会在顶层目录中查找CMakePresets.jsonCMakeUserPresets.json文件。这两个文件使用相同的 JSON 结构来定义预设,因此它们之间没有太大的区别可以讨论。该格式是一个包含以下键的 JSON 对象:

  • version:这是一个必需的整数,指定预设 JSON 架构的版本。

  • cmakeMinimumRequired:这是一个对象,指定了所需的 CMake 版本。

  • include:这是一个字符串数组,包含从数组中提供的文件路径加载的外部预设(自 schema 版本 4 起)。

  • configurePresets:这是一个对象数组,用于定义配置阶段的预设。

  • buildPresets:这是一个对象数组,用于定义构建阶段的预设。

  • testPresets:这是一个对象数组,特定于测试阶段的预设。

  • packagePresets:这是一个对象数组,特定于包阶段的预设。

  • workflowPresets:这是一个对象数组,特定于工作流模式的预设。

  • vendor:这是一个包含由 IDE 和其他供应商定义的自定义设置的对象;CMake 不会处理此字段。

在编写预设时,CMake 要求version条目必须存在;其他值是可选的。这里有一个预设文件示例(实际的预设将在后续章节中添加):

ch16/01-presets/CMakePresets.json

{
  "version": 6,
  "cmakeMinimumRequired": {
    "major": 3,
    "minor": 26,
    "patch": 0
  },
  "include": [],
  "configurePresets": [],
  "buildPresets": [],
  "testPresets": [],
  "packagePresets": [],
  "workflowPresets": [],
  "vendor": {
    "data": "IDE-specific information"
  }
} 

不需要像前面的示例那样添加空数组;除了version之外的条目是可选的。说到这,CMake 3.26 的适用架构版本是6

现在我们了解了预设文件的结构,让我们来实际学习如何定义这些预设。

定义阶段特定的预设

特定于阶段的预设只是配置单独 CMake 阶段的预设:配置、构建、测试、打包和安装。它们允许以精细且结构化的方式定义构建配置。以下是所有预设阶段共享的常见特性概述,之后将介绍如何为单独的阶段定义预设。

预设的共同特性

有三个特性用于配置预设,无论是哪个 CMake 阶段。即,这些是唯一名称字段、可选字段以及与配置预设的关联。以下各节将分别介绍这些内容。

唯一名称字段

每个预设在其阶段内必须具有唯一的名称字段。鉴于 CMakeUserPresets.json(如果存在)隐式包含了 CMakePresets.json(如果存在),这两个文件共享命名空间,防止它们之间出现重复名称。例如,你不能在这两个文件中都有名为 myPreset 的打包阶段预设。

一个最小的预设文件可能如下所示:

{
  "version": 6,
  "configurePresets": [
    {
      "name": "myPreset"
    },
    {
      "name": "myPreset2"
    }
  ]
} 

可选字段

每个特定于阶段的预设都可以使用相同的可选字段:

  • displayName:这是一个字符串,提供预设的用户友好名称。

  • description:这是一个字符串,提供预设功能的说明。

  • inherits:这是一个字符串,或一个字符串数组,它有效地复制此字段中命名的预设的配置作为基础,进一步扩展或修改。

  • hidden:这是一个布尔值,用于将预设从列表中隐藏;这些隐藏的预设只能通过继承使用。

  • environment:这是一个对象,用于覆盖此阶段的 ENV 变量;每个键标识一个单独的变量,值可以是字符串或 null;它支持宏。

  • condition:这是一个对象,用于启用或禁用此预设(稍后我们会详细讲解)。

  • vendor:这是一个自定义对象,包含供应商特定的值,并遵循与根级别 vendor 字段相同的约定。

预设可以形成类似图形的继承结构,只要没有循环依赖。CMakeUserPresets.json 可以从项目级别的预设继承,但反过来则不行。

与配置阶段预设的关联

所有特定于阶段的预设都必须与配置预设相关联,因为它们必须知道构建树的位置。虽然 configure 预设本身默认与自身相关联,但构建、测试和打包预设需要通过 configurePreset 字段显式定义这种关联。

与你可能想到的不同,这种关联并不意味着 CMake 会在你决定运行任何后续预设时自动执行配置预设。你仍然需要手动执行每个预设,或者使用工作流预设(稍后我们会讨论这个)。

在掌握了这些基础概念后,我们可以继续进入单个阶段的预设细节,首先是配置阶段。随着进展,我们将探索这些预设如何相互作用,以及它们如何帮助简化 CMake 中的项目配置和构建过程。

定义配置阶段预设

如前所述,配置预设位于configurePresets数组中。通过在命令行中添加--list-presets参数,可以列出它们,特定于配置阶段:

cmake --list-presets 

要使用选择的预设配置项目,请在--preset参数后指定其名称,如下所示:

cmake --preset myConfigurationPreset 

配置预设有一些通用字段,如namedescription,但它还有自己的一套可选字段。以下是最重要字段的简化描述:

  • generator:一个字符串,指定用于预设的生成器;对于 schema 版本 < 3 是必需的

  • architecturetoolset:一个字符串,配置支持这些选项的生成器

  • binaryDir:一个字符串,提供构建树的相对或绝对路径;对于 schema 版本 < 3 是必需的;支持宏

  • installDir:一个字符串,提供安装目录的相对或绝对路径;对于 schema 版本 < 3 是必需的,并且支持宏

  • cacheVariables:定义缓存变量的映射;值支持宏

在定义cacheVariables映射时,请记住项目中变量解析的顺序。如图 16.1所示,任何通过命令行定义的缓存变量将覆盖预设变量。任何缓存或环境预设变量将覆盖来自缓存文件或主机环境的变量。

图 16.1:预设如何覆盖 CMakeCache.txt 和系统环境变量

让我们声明一个简单的myConfigure配置预设,指定生成器、构建树和安装路径:

ch16/01-presets/CMakePresets.json(续)

...
  "configurePresets": [
    {
      "name": "myConfigure",
      "displayName": "Configure Preset",
      "description": "Ninja generator",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build",
      "installDir": "${sourceDir}/build/install"
    }
  ],
... 

我们已经完成了对configure预设的介绍,这也带我们进入构建阶段预设。

定义构建阶段预设

你不会惊讶地发现,构建预设位于buildPresets数组中。通过在命令行中添加--list-presets参数,可以列出它们,特定于构建阶段:

cmake --build --list-presets 

要使用选择的预设构建项目,请在--preset参数后指定其名称,如下所示:

cmake --build --preset myBuildingPreset 

构建预设也有一些通用字段,如namedescription,并且它有自己的一套可选字段。最重要字段的简化描述如下:

  • jobs:一个整数,设置用于构建项目的并行作业数

  • targets:一个字符串或字符串数组,设置要构建的目标,并支持宏

  • configuration:一个字符串,确定多配置生成器的构建类型(DebugRelease等)

  • cleanFirst: 一个布尔值,确保在构建之前始终清理项目

就这样。现在,我们可以像这样编写构建预设:

ch16/01-presets/CMakePresets.json(续)

...
  "buildPresets": [
    {
      "name": "myBuild",
      "displayName": "Build Preset",
      "description": "Four jobs",
      "configurePreset": "myConfigure",
      "jobs": 4
    }
  ],
... 

你会注意到,所需的configurePreset字段已设置为指向我们在前一部分定义的myConfigure预设。现在,我们可以继续进行测试预设。

定义测试阶段预设

测试预设位于testPresets数组中。可以通过在命令行中添加--list-presets参数显示它们,特定于测试阶段:

ctest --list-presets 

要使用预设测试项目,请在--preset参数后指定其名称,如下所示:

ctest --preset myTestPreset 

测试预设也有一组独特的可选字段。以下是最重要字段的简化描述:

  • configuration: 一个字符串,决定多配置生成器的构建类型(如DebugRelease等)

  • output: 一个对象,配置输出

  • filter: 一个对象,指定要运行的测试

  • execution: 一个对象,配置测试的执行

每个对象将适当的命令行选项映射到配置值。我们将突出显示一些重要选项,但这并不是详尽无遗的列表。完整参考请查阅进一步阅读部分。

output对象的可选条目包括:

  • shortProgress: 布尔值;进度将在一行内报告

  • verbosity: 一个字符串,将输出详细程度设置为以下级别之一:默认、详细或额外

  • outputOnFailure: 一个布尔值,在测试失败时打印程序输出

  • quiet: 布尔值;抑制所有输出

对于 exclude,一些接受的条目包括:

  • name: 一个字符串,用于排除名称匹配正则表达式模式的测试,并支持宏

  • label: 一个字符串,用于排除标签匹配正则表达式模式的测试,并支持宏

  • fixtures: 一个对象,决定从测试中排除哪些固定装置(更多详情请参阅官方文档)

最后,执行对象接受以下可选条目:

  • outputLogFile: 一个字符串,指定输出日志文件路径,并支持宏

filter对象接受includeexclude键来配置测试用例的过滤;以下是一个部分填充的结构来说明这一点:

 "testPresets": [
    {
      "name": "myTest",
      "configurePreset": "myConfigure",
      "filter": {
        "include": {
                     ... name, label, index, useUnion ...
                   },
        "exclude": {
                     ... name, label, fixtures ...
                   }
      }
    }
  ],
... 

每个键定义了它自己的选项对象:

对于include,条目包括:

  • name: 一个字符串,用于包含名称匹配正则表达式模式的测试,并支持宏

  • label: 一个字符串,用于包含标签匹配正则表达式模式的测试,并支持宏

  • index: 一个对象,选择要运行的测试,并接受startendstride整数,以及一个specificTests整数数组;它支持宏

  • useUnion: 一个布尔值,启用使用由indexname确定的测试的并集,而不是交集

对于exclude,条目包括:

  • name: 一个字符串,用于排除名称匹配正则表达式模式的测试,并支持宏

  • label: 一个字符串,排除与正则表达式匹配的标签的测试,并支持宏

  • fixtures: 一个对象,确定要从测试中排除的固定项(更多详细信息请参见官方文档)

最后,execution对象可以添加到此处:

 "testPresets": [
    {
      "name": "myTest",
      "configurePreset": "myConfigure",
      "execution": {
                   ... stopOnFailure, enableFailover, ...
                   ... jobs, repeat, scheduleRandom,  ...
                   ... timeout, noTestsAction ...
                   }     
    }
  ],
... 

它接受以下可选条目:

  • stopOnFailure: 一个布尔值,启用在任何测试失败时停止测试

  • enableFailover: 一个布尔值,表示是否恢复先前中断的测试

  • jobs: 一个整数,表示并行运行多个测试的数量

  • repeat: 一个对象,用于确定如何重复测试;该对象必须包含以下字段:

    • mode – 一个字符串,具有以下之一的值:until-failuntil-passafter-timeout

    • count – 一个整数,确定重复的次数

  • scheduleRandom: 一个布尔值,启用随机顺序执行测试

  • timeout: 一个整数,设置所有测试总执行时间的限制(以秒为单位)

  • noTestsAction: 一个定义如果未找到测试时采取的行动的字符串,选项包括defaulterrorignore

虽然有许多配置选项,但简单的预设也是可行的:

ch16/01-presets/CMakePresets.json(续)

...
  "testPresets": [
    {
      "name": "myTest",
      "displayName": "Test Preset",
      "description": "Output short progress",
      "configurePreset": "myConfigure",
      "output": {
        "shortProgress": true
      }
    }
  ],
... 

与构建预设一样,我们还需要为新的测试预设设置必需的configurePreset字段,以便将所有内容整齐地连接在一起。让我们来看看最后一种特定于阶段的预设类型——包预设。

定义包阶段预设

包预设在模式版本6中被引入,这意味着你需要至少 CMake 3.25 版本才能使用它们。这些预设应包含在packagePresets数组中。你也可以通过在命令行中添加--list-presets参数来显示它们,这对于特定的测试阶段有效:

cpack --list-presets 

要使用预设创建项目包,指定其名称,方法是在--preset参数后面添加预设名称,如下所示:

cpack --preset myTestPreset 

包预设利用与其他预设相同的共享字段,同时引入一些特定于它自己的可选字段:

  • generators: 一个字符串数组,设置用于创建包的生成器(ZIP7ZDEB等)

  • configuration: 一个字符串数组,确定 CMake 打包时所使用的构建类型列表(DebugRelease等)

  • filter: 一个对象,指定要运行的测试

  • packageNamepackageVersionpackageDirectoryvendorName: 字符串,指定已创建包的元数据

让我们也用一个简洁的包预设来扩展我们的预设文件:

ch16/01-presets/CMakePresets.json(续)

...
  "packagePresets": [
    {
      "name": "myPackage",
      "displayName": "Package Preset",
      "description": "ZIP generator",
      "configurePreset": "myConfigure",
      "generators": [
        "ZIP"
      ]
    }
  ],
... 

这种配置将使我们能够简化项目包的创建,但我们仍然缺少一个关键部分:项目安装。让我们看看如何使它正常工作。

添加安装预设

你可能注意到,CMakePresets.json 对象不支持定义 "installPresets"。通过预设没有明确的方式来安装你的项目,这似乎很奇怪,因为配置预设提供了 installDir 字段!那么,我们是否必须依赖手动安装命令呢?

幸运的是,不需要。我们有一个变通方法,可以通过使用构建预设来实现我们的目标。请看下面:

ch16/01-presets/CMakePresets.json(续)

...
  "buildPresets": [
    {
      "name": "myBuild",
      ...
    },
    {
      "name": "myInstall",
      "displayName": "Installation",
      **"targets"****:****"install"****,**
      "configurePreset": "myConfigure"
    }
  ],
... 

我们可以创建一个构建预设,将 targets 字段设置为 install。当我们正确配置安装时,install 目标由项目隐式定义。使用此预设进行构建将执行必要的步骤,将项目安装到关联配置预设中指定的 installDir(如果 installDir 字段为空,将使用默认位置):

$ cmake --build --preset myInstall
[0/1] Install the project...
-- Install configuration: ""
-- Installing: .../install/include/calc/basic.h
-- Installing: .../install/lib/libcalc_shared.so
-- Installing: .../install/lib/libcalc_static.a
-- Installing: .../install/lib/calc/cmake/CalcLibrary.cmake
-- Installing: .../install/lib/calc/cmake/CalcLibrary-noconfig.cmake
-- Installing: .../install/lib/calc/cmake/CalcConfig.cmake
-- Installing: .../install/bin/calc_console
-- Set non-toolchain portion of runtime path of ".../install/bin/calc_console" to "" 

这个巧妙的技巧可以帮助我们节省一些资源。如果我们能为最终用户提供一个单一命令,处理从配置到安装的所有操作,那将更好。好吧,我们可以,使用工作流预设。让我们看一下。

定义工作流预设

工作流预设是我们项目的终极自动化解决方案。它们允许我们按预定顺序自动执行多个阶段特定的预设。这样,我们几乎可以在一步操作中完成端到端的构建。

要发现项目的可用工作流,我们可以执行以下命令:

cmake --workflow --list-presets 

要选择并应用预设,请使用以下命令:

cmake –workflow --preset <preset-name> 

此外,通过 --fresh 标志,我们可以清除构建树并清除缓存。

定义工作流预设非常简单;我们需要定义一个名称,且可以选择性地提供 displayNamedescription,就像为阶段特定预设定义一样。之后,我们必须列出工作流应该执行的所有阶段特定预设。这是通过提供一个包含 typename 属性的对象数组 steps 来完成的,如下所示:

ch16/01-presets/CMakePresets.json(续)

...
  "workflowPresets": [
    {
      "name": "myWorkflow",
      "steps": [
        {
          "type": "configure",
          "name": "myConfigure"
        },
        {
          "type": "build",
          "name": "myBuild"
        },
        {
          "type": "test",
          "name": "myTest"
        },
        {
          "type": "package",
          "name": "myPackage"
        },
        {
          "type": "build",
          "name": "myInstall"
        }
      ]
... 

steps 数组中的每个对象都引用了我们在本章前面定义的预设,指明其类型(configurebuildtestpackage)以及名称。这些预设共同执行所有必要的步骤,通过一个命令从零开始完全构建和安装项目:

cmake --workflow --preset myWorkflow 

工作流预设是自动化 C++ 构建、测试、打包和安装的终极解决方案。接下来,让我们探索如何通过条件和宏来管理一些边缘情况。

添加条件和宏

当我们讨论每个阶段特定预设的通用字段时,我们提到了 condition 字段。现在是时候回到这个话题了。condition 字段启用或禁用预设,并在与工作流集成时展现其真正的潜力。本质上,它允许我们绕过在某些条件下不适用的预设,并创建适用的替代预设。

条件要求预设架构版本为 3 或更高版本(在 CMake 3.22 中引入),并且是 JSON 对象,用于编码一些简单的逻辑操作,以判断诸如操作系统、环境变量,甚至所选生成器等情况是否符合预设的场景。CMake 通过宏提供这些数据,宏本质上是一组有限的只读变量,可在预设文件中使用。

条件对象的结构根据检查类型有所不同。每个条件必须包含一个 type 字段,并根据类型定义其他字段。已识别的基本类型包括:

  • const:这检查 value 字段中提供的值是否为布尔值 true

  • equalsnotEquals:这些用于将 lhs 字段的值与 rhs 字段中的值进行比较

  • inListnotInList:这些用于检查 string 字段中的值是否出现在 list 字段中的数组中

  • matchesnotMatches:这些用于评估 string 字段的值是否与 regex 字段中定义的模式一致

一个条件示例如下所示:

"condition": {
               "type": "equals",
               "lhs": "${hostSystemName}",
               "rhs": "Windows"
             } 

const 条件的实际用途主要是用于禁用预设,而不将其从 JSON 文件中移除。除了 const 外,所有基本条件都允许在它们引入的字段中使用宏:lhsrhsstringlistregex

高级条件类型,类似于“not”、“and”和“or”操作,使用其他条件作为参数:

  • not:对 condition 字段中提供的条件进行布尔反转

  • anyOfallOf:这些用于检查 conditions 数组中的任意或所有条件是否为 true

例如:

"condition": {
              "type": "anyOf",
              "conditions": [
                              {
                                "type": "equals",
                                "lhs": "${hostSystemName}",
                                "rhs": "Windows"
                              },{
                                "type": "equals",
                                "lhs": "${hostSystemName}",
                                "rhs": "Linux"
                              }
                            ]
             } 

如果系统是 Linux 或 Windows,则此条件评估为 true

通过这些示例,我们介绍了我们的第一个宏:${hostSystemName}。宏遵循简单的语法,并且限于特定实例,如:

  • ${sourceDir}:这是源树的路径

  • ${sourceParentDir}:这是源树父目录的路径

  • ${sourceDirName}:这是项目的目录名称

  • ${presetName}:这是预设的名称

  • ${generator}:这是用于创建构建系统的生成器

  • ${hostSystemName}:这是系统名称:在 Linux 上为 Linux,在 Windows 上为 Windows,在 macOS 上为 Darwin

  • ${fileDir}:这是包含当前预设的文件名(适用于使用 include 数组导入外部预设的情况)

  • ${dollar}:这是转义的美元符号($

  • ${pathListSep}:这是环境特定的路径分隔符

  • $env{<variable-name>}:如果预设中指定了环境变量(区分大小写),则返回该环境变量的值,或返回父环境的值

  • $penv{<variable-name>}:这是从父环境中返回的环境变量

  • $vendor{<macro-name>}:这允许 IDE 供应商引入他们自己的宏

这些宏提供了足够的灵活性,以便在预设及其条件中使用,支持在需要时有效地切换工作流步骤。

摘要

我们刚刚完成了对 CMake 预设的全面概述,这些预设是在 CMake 3.19 中引入的,旨在简化项目管理。预设允许产品作者通过配置项目构建和交付的所有阶段,为用户提供一个精心准备的体验。预设不仅简化了 CMake 的使用,还增强了一致性,并支持环境感知的设置。

我们解释了 CMakePresets.jsonCMakeUserPresets.json 文件的结构和使用,提供了定义各种预设类型的见解,比如配置预设、构建预设、测试预设、打包预设和工作流预设。每种类型都有详细描述:我们了解了常见字段,如何在内部组织预设,如何在它们之间建立继承关系,以及最终用户可用的具体配置选项。

对于配置预设,我们讨论了选择生成器、构建目录和安装目录的重要话题,以及如何通过 configurePreset 字段将预设连接在一起。现在我们知道如何处理构建预设,设置构建作业数量、目标和清理选项。接下来,我们学习了测试预设如何通过广泛的过滤和排序选项、输出格式和执行参数(如超时和容错)来协助测试选择。我们理解了如何通过指定包生成器、过滤器和包元数据来管理打包预设。我们甚至介绍了一种通过专门的构建预设应用执行安装阶段的解决方法。

接下来,我们发现工作流预设如何允许将多个阶段特定的预设进行分组。最后,我们讨论了条件和宏表达式,为项目作者提供了更大的控制力,可以更好地控制各个预设的行为及其在工作流中的集成。

我们的 CMake 之旅已圆满完成!恭喜你——现在你拥有了开发、测试和打包高质量 C++ 软件所需的所有工具。接下来的最佳方式是应用你所学的知识,创建出色的软件供用户使用。祝你好运!

深入阅读

如需更多信息,您可以参考以下资源:

加入我们的社区 Discord

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

discord.com/invite/vXN53A7ZcA

附录

杂项命令

每种语言都包括一些用于各种任务的实用命令,CMake 也不例外。它提供了用于算术运算、按位操作、字符串操作以及列表和文件操作的工具。尽管由于功能增强和多个模块的发展,这些命令的需求有所减少,但在高度自动化的项目中,它们仍然是必不可少的。如今,您可能会发现它们在使用cmake -P <filename>调用的 CMake 脚本中更为有用。

因此,本附录总结了 CMake 命令和其多种模式,作为方便的离线参考或官方文档的简化版。要获取更详细的信息,请查阅提供的链接。

此参考适用于 CMake 3.26.6。

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

  • string()命令

  • list()命令

  • file()命令

  • math()命令

string()命令

string()命令用于操作字符串。它提供了多种模式,执行不同的操作:搜索和替换、操作、比较、哈希、生成和 JSON 操作(自 CMake 3.19 版本起提供最后一个)。

完整的详细信息可以在在线文档中找到:cmake.org/cmake/help/latest/command/string.html

请注意,接受string()模式的<input>参数将接受多个<input>值,并在执行命令之前将它们连接起来,因此:

string(PREPEND myVariable "a" "b" "c") 

等同于以下内容:

string(PREPEND myVariable "abc") 

可用的string()模式包括搜索和替换、操作、比较、哈希、生成和 JSON。

搜索和替换

以下模式可用:

  • string(FIND <haystack> <pattern> <out> [REVERSE])<haystack>字符串中搜索<pattern>并将找到的位置以整数形式写入<out>变量。如果使用了REVERSE标志,它将从字符串的末尾向前搜索。此操作仅适用于 ASCII 字符串(不支持多字节字符)。

  • string(REPLACE <pattern> <replace> <out> <input>)<input>中的所有<pattern>替换为<replace>,并将结果存储在<out>变量中。

  • string(REGEX MATCH <pattern> <out> <input>)使用正则表达式匹配<input>中第一次出现的<pattern>,并将其存储在<out>变量中。

  • string(REGEX MATCHALL <pattern> <out> <input>)使用正则表达式匹配<input>中所有出现的<pattern>并将其存储在<out>变量中,格式为逗号分隔的列表。

  • string(REGEX REPLACE <pattern> <replace> <out> <input>)正则替换<input>中的所有<pattern>出现,并使用<replace>表达式将它们替换,并将结果存储在<out>变量中。

正则表达式操作遵循 C++ 语法,如 <regex> 头文件中定义的标准库所示。你可以使用捕获组将匹配项添加到 <replace> 表达式中,并使用数字占位符:\\1\\2...(需要使用双反斜杠,以确保参数被正确解析)。

操作

以下模式是可用的:

  • string(APPEND <out> <input>) 通过附加 <input> 字符串来修改存储在 <out> 中的字符串。

  • string(PREPEND <out> <input>) 通过在字符串前添加 <input> 字符串来修改存储在 <out> 中的字符串。

  • string(CONCAT <out> <input>) 将所有提供的 <input> 字符串连接在一起,并将其存储在 <out> 变量中。

  • string(JOIN <glue> <out> <input>) 使用 <glue> 值将所有提供的 <input> 字符串交织在一起,并将其作为连接的字符串存储在 <out> 变量中(不要在列表变量中使用此模式)。

  • string(TOLOWER <string> <out>)<string> 转换为小写并将其存储在 <out> 变量中。

  • string(TOUPPER <string> <out>)<string> 转换为大写并将其存储在 <out> 变量中。

  • string(LENGTH <string> <out>) 计算 <string> 的字节数,并将结果存储在 <out> 变量中。

  • string(SUBSTRING <string> <begin> <length> <out>)<string> 中提取一个子字符串,长度为 <length> 字节,起始位置为 <begin> 字节,并将其存储在 <out> 变量中。提供 -1 作为长度表示“直到字符串的末尾”。

  • string(STRIP <string> <out>) 移除 <string> 的前导和尾随空白字符,并将结果存储在 <out> 变量中。

  • string(GENEX_STRIP <string> <out>) 移除 <string> 中所有使用的生成器表达式,并将结果存储在 <out> 变量中。

  • string(REPEAT <string> <count> <out>) 生成一个包含 <count> 次重复的 <string> 的字符串,并将其存储在 <out> 变量中。

比较

字符串比较采用以下形式:

string(COMPARE <operation> <stringA> <stringB> <out>) 

<operation> 参数是以下之一:

  • LESS

  • GREATER

  • EQUAL

  • NOTEQUAL

  • LESS_EQUAL

  • GREATER_EQUAL

它将用于比较 <stringA><stringB>,并将结果(truefalse)存储在 <out> 变量中。

哈希

哈希模式具有以下签名:

string(<hashing-algorithm> <out> <string>) 

它使用 <hashing-algorithm><string> 进行哈希,并将结果存储在 <out> 变量中。支持以下算法:

  • MD5: 消息摘要算法 5,RFC 1321

  • SHA1: 美国安全哈希算法 1,RFC 3174

  • SHA224: 美国安全哈希算法,RFC 4634

  • SHA256: 美国安全哈希算法,RFC 4634

  • SHA384: 美国安全哈希算法,RFC 4634

  • SHA512: 美国安全哈希算法,RFC 4634

  • SHA3_224: Keccak SHA-3

  • SHA3_256: Keccak SHA-3

  • SHA3_384: Keccak SHA-3

  • SHA3_512: Keccak SHA-3

生成

以下模式是可用的:

  • string(ASCII <number>... <out>) 将给定的 <number> 的 ASCII 字符存储在 <out> 变量中。

  • string(HEX <string> <out>)<string> 转换为其十六进制表示并将其存储在 <out> 变量中(从 CMake 3.18 起)。

  • string(CONFIGURE <string> <out> [@ONLY] [ESCAPE_QUOTES]) 作用与 configure_file() 完全相同,但用于字符串。结果存储在 <out> 变量中。提醒一下,使用 @ONLY 关键字将替换限制为 @VARIABLE@ 形式的变量。

  • string(MAKE_C_IDENTIFIER <string> <out>)<string> 中的非字母数字字符转换为下划线,并将结果存储在 <out> 变量中。

  • string(RANDOM [LENGTH <len>] [ALPHABET <alphabet>] [RANDOM_SEED <seed>] <out>) 生成一个由 <len> 个字符(默认为 5)组成的随机字符串,使用来自随机种子 <seed> 的可选 <alphabet>,并将结果存储在 <out> 变量中。

  • string(TIMESTAMP <out> [<format>] [UTC]) 生成一个表示当前日期和时间的字符串,并将其存储在 <out> 变量中。

  • string(UUID <out> NAMESPACE <ns> NAME <name> TYPE <type>) 生成一个全局唯一标识符。使用此模式稍微复杂一些;你需要提供一个命名空间(必须是 UUID)、一个名称(例如,域名)和一个类型(可以是 MD5SHA1)。

JSON

对 JSON 格式字符串的操作使用以下签名:

string(JSON <out> [ERROR_VARIABLE <error>] <operation + args>) 

有几种操作可以使用。它们都将结果存储在 <out> 变量中,错误存储在 <error> 变量中。操作及其参数如下:

  • GET <json> <member|index>... 返回通过 <member> 路径或 <index><json> 字符串中的一个或多个元素提取值的结果。

  • TYPE <json> <member|index>... 返回通过 <member> 路径或 <index><json> 字符串中的一个或多个元素的类型。

  • MEMBER <json> <member|index>... <array-index> 返回通过 <member> 路径或 <index><json> 字符串中的一个或多个数组类型元素在 <array-index> 位置提取的成员名称。

  • LENGTH <json> <member|index>... 返回通过 <member> 路径或 <index><json> 字符串中的一个或多个数组类型元素的元素数量。

  • REMOVE <json> <member|index>... 返回通过 <member> 路径或 <index><json> 字符串中的一个或多个元素进行移除操作的结果。

  • SET <json> <member|index>... <value> 返回通过 <member> 路径或 <index><json> 字符串中的一个或多个元素进行上插入操作的结果,将 <value> 插入其中。

  • EQUAL <jsonA> <jsonB> 判断 <jsonA><jsonB> 是否相等。

list() 命令

该命令提供基本的列表操作:读取、查找、修改和排序。一些模式会改变列表(修改原始值)。如果之后还需要使用原始值,请确保复制它。

完整的详细信息可以在在线文档中找到:

cmake.org/cmake/help/latest/command/list.html

可用的 list() 模式类别包括读取、搜索、修改和排序。

读取

以下模式可用:

  • list(LENGTH <list> <out>) 计算 <list> 变量中的元素数量,并将结果存储在 <out> 变量中。

  • list(GET <list> <index>... <out>)<list> 中通过 <index> 索引指定的元素复制到 <out> 变量中。

  • list(JOIN <list> <glue> <out>)<list> 元素与 <glue> 分隔符交错连接,并将结果字符串存储在 <out> 变量中。

  • list(SUBLIST <list> <begin> <length> <out>) 的作用类似于 GET 模式,但操作的是范围而非显式索引。如果 <length>-1,则返回从 <begin> 索引到 <list> 变量中提供的列表末尾的所有元素。

搜索

此模式简单地查找 <needle> 元素在 <list> 变量中的索引,并将结果存储在 <out> 变量中(如果元素未找到,则返回 -1):

list(FIND <list> <needle> <out>) 

修改

以下模式可用:

  • list(APPEND <list> <element>...) 将一个或多个 <element> 值添加到 <list> 变量的末尾。

  • list(PREPEND <list> [<element>...]) 的作用类似于 APPEND,但将元素添加到 <list> 变量的开头。

  • list(FILTER <list> {INCLUDE | EXCLUDE} REGEX <pattern>) 根据 <pattern> 值筛选 <list> 变量中的元素,选择 INCLUDEEXCLUDE 匹配的元素。

  • list(INSERT <list> <index> [<element>...]) 将一个或多个 <element> 值添加到 <list> 变量的指定 <index> 位置。

  • list(POP_BACK <list> [<out>...])<list> 变量的末尾移除一个元素,并将其存储在可选的 <out> 变量中。如果提供了多个 <out> 变量,将移除更多的元素以填充它们。

  • list(POP_FRONT <list> [<out>...])POP_BACK 类似,但从 <list> 变量的开头移除一个元素。

  • list(REMOVE_ITEM <list> <value>...)FILTER EXCLUDE 的简写,但不支持正则表达式。

  • list(REMOVE_AT <list> <index>...)<list> 中指定的 <index> 位置移除元素。

  • list(REMOVE_DUPLICATES <list>) 移除 <list> 中的重复元素。

  • list(TRANSFORM <list> <action> [<selector>] [OUTPUT_VARIABLE <out>])<list> 中的元素应用特定的变换。默认情况下,操作应用于所有元素,但我们可以通过添加 <selector> 来限制影响范围。如果没有提供 OUTPUT_VARIABLE 关键字,则提供的列表将被修改(就地改变);如果提供了该关键字,结果将存储在 <out> 变量中。

以下选择器可用:AT <index>FOR <start> <stop> [<step>]REGEX <pattern>

操作包括 APPEND <string>PREPEND <string>TOLOWERTOUPPERSTRIPGENEX_STRIPREPLACE <pattern> <expression>。它们的功能与同名的 string() 模式完全相同。

排序

以下模式可用:

  • list(REVERSE <list>) 简单地反转 <list> 的顺序。

  • list(SORT <list>) 按字母顺序对列表进行排序。

请参考在线手册以获取更多高级选项。

file()命令

该命令提供与文件相关的各种操作:读取、传输、锁定和归档。它还提供检查文件系统和操作表示路径的字符串的模式。

完整的详细信息可以在在线文档中找到:

cmake.org/cmake/help/latest/command/file.html

可用的file()模式类别包括读取、写入、文件系统、路径转换、传输、锁定和归档。

阅读

可用的模式如下:

  • file(READ <filename> <out> [OFFSET <o>] [LIMIT <max>] [HEX])<filename>读取文件到<out>变量中。读取操作可选择从偏移量<o>开始,并遵循可选的<max>字节限制。HEX flag指定输出应转换为十六进制表示。

  • file(STRINGS <filename> <out>)<filename>文件读取字符串并将其存储到<out>变量中。

  • file(<hashing-algorithm> <filename> <out>) 计算来自<filename>文件的<hashing-algorithm>哈希值,并将结果存储到<out>变量中。可用的算法与string()哈希函数相同。

  • file(TIMESTAMP <filename> <out> [<format>]) 生成<filename>文件的时间戳字符串表示,并将其存储到<out>变量中。可选接受一个<format>字符串。

  • file(GET_RUNTIME_DEPENDENCIES [...]) 获取指定文件的运行时依赖项。这是一个高级命令,仅在install(CODE)install(SCRIPT)场景中使用。从 CMake 3.21 版本开始可用。

写入

可用的模式如下:

  • file({WRITE | APPEND} <filename> <content>...) 将所有<content>参数写入或追加到<filename>文件中。如果提供的系统路径不存在,它将被递归创建。

  • file({TOUCH | TOUCH_NOCREATE} [<filename>...]) 更新<filename>的时间戳。如果文件不存在,则仅在TOUCH模式下创建该文件。

  • file(GENERATE OUTPUT <output-file> [...]) 是一个高级模式,它为当前 CMake 生成器的每个构建配置生成一个输出文件。

  • file(CONFIGURE OUTPUT <output-file> CONTENT <content> [...])GENERATE_OUTPUT类似,但还会通过将变量占位符替换为值来配置生成的文件。

文件系统

可用的模式如下:

  • file({GLOB | GLOB_RECURSE} <out> [...] [<globbing-expression>...]) 生成与<globbing-expression>匹配的文件列表,并将其存储在<out>变量中。GLOB_RECURSE模式还会扫描嵌套目录。

  • file(RENAME <oldname> <newname>) 将文件从<oldname>移动到<newname>

  • file({REMOVE | REMOVE_RECURSE } [<files>...]) 删除<files>REMOVE_RECURSE模式还会删除目录。

  • file(MAKE_DIRECTORY [<dir>...]) 创建一个目录。

  • file(COPY <file>... DESTINATION <dir> [...])将文件复制到<dir>目标路径。它提供了过滤、设置权限、符号链接链跟踪等选项。

  • file(COPY_FILE <file> <destination> [...])将单个文件复制到<destination>路径。从 CMake 3.21 版本开始提供。

  • file(SIZE <filename> <out>)读取<filename>的字节大小,并将其存储在<out>变量中。

  • file(READ_SYMLINK <linkname> <out>)读取<linkname>符号链接的目标路径,并将其存储在<out>变量中。

  • file(CREATE_LINK <original> <linkname> [...])<linkname>处创建指向<original>的符号链接。

  • file({CHMOD|CHMOD_RECURSE} <files>... <directories>... PERMISSIONS <permissions>... [...])设置文件和目录的权限。

  • file(GET_RUNTIME_DEPENDENCIES [...])收集各种文件类型的运行时依赖项:可执行文件、库文件和模块。与install(RUNTIME_DEPENDENCY_SET)一起使用。

路径转换

以下模式可用:

  • file(REAL_PATH <path> <out> [BASE_DIRECTORY <dir>])计算从相对路径到绝对路径,并将其存储在<out>变量中。它可以选择性地接受<dir>作为基础目录。从 CMake 3.19 版本开始提供。

  • file(RELATIVE_PATH <out> <directory> <file>)计算<file>相对于<directory>的路径,并将其存储在<out>变量中。

  • file({TO_CMAKE_PATH | TO_NATIVE_PATH} <path> <out>)<path>转换为 CMake 路径(目录以正斜杠分隔),转换为平台的本地路径,并反向转换。结果存储在<out>变量中。

传输

以下模式可用:

  • file(DOWNLOAD <url> [<path>] [...])<url>下载文件并将其存储在<path>中。

  • file(UPLOAD <file> <url> [...])<file>上传到 URL。

锁定

锁定模式对<path>资源加上建议性锁:

file(LOCK <path> [DIRECTORY] [RELEASE]
     [GUARD <FUNCTION|FILE|PROCESS>]
     [RESULT_VARIABLE <out>] [TIMEOUT <seconds>]
) 

此锁可以选择性地限定为FUNCTIONFILEPROCESS,并限制超时时间为<seconds>。要释放锁,请提供RELEASE关键字。结果将存储在<out>变量中。

归档

创建归档提供了以下签名:

file(ARCHIVE_CREATE OUTPUT <destination> PATHS <source>...
  [FORMAT <format>]
  [COMPRESSION <type> [COMPRESSION_LEVEL <level>]]
  [MTIME <mtime>] [VERBOSE]
) 

它将在<destination>路径创建一个包含<source>文件的归档,格式为支持的格式之一:7zipgnutarpaxpaxrrawzip(默认格式为paxr)。如果所选格式支持压缩级别,则可以提供一个单数字符号0-9,其中0为默认值。

提取模式具有以下签名:

file(ARCHIVE_EXTRACT INPUT <archive> [DESTINATION <dir>]
  [PATTERNS <patterns>...] [LIST_ONLY] [VERBOSE]
) 

它从<archive>中提取与可选的<patterns>值匹配的文件到目标<dir>。如果提供了LIST_ONLY关键字,则不会提取文件,而是仅列出文件。

math()命令

CMake 还支持一些简单的算术运算。详细信息请参阅在线文档:

cmake.org/cmake/help/latest/command/math.html

要评估一个数学表达式并将其作为字符串存储在 <out> 变量中,可以选择 <format>HEXADECIMALDECIMAL),使用以下签名:

math(EXPR <out> "<expression>" [OUTPUT_FORMAT <format>]) 

<expression> 值是一个字符串,支持 C 代码中存在的运算符(它们在这里具有相同的含义):

  • 算术运算:+-*/,和 % 取模除法

  • 位运算:| 或,& 与,^ 异或,~ 非,<< 左移,>> 右移

  • 括号 (...)

常量值可以以十进制或十六进制格式提供。

posted @ 2025-10-27 08:51  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报