面向-C---的现代-CMake-教程-全-

面向 C++ 的现代 CMake 教程(全)

原文:zh.annas-archive.org/md5/125f0c03ca93490db2ba97b08bc69e99

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

打造顶级软件并非易事。开发者在网上研究这一主题时,常常遇到的问题是如何判断哪些建议是更新的,哪些方法已经被更 fresh、更好的实践所超越。与此同时,大多数资源对这个过程的解释是混乱的,没有适当的背景、上下文和结构。

现代 CMake for C++ 是一本端到端的指南,提供了更简单的体验,因为它全面地处理了 C++解决方案的构建。它教会您如何在 CMake 项目中使用 CMake,并展示使其可维护、优雅和干净的原因。它引导您通过许多项目中出现的复杂任务的自动化,包括构建、测试和打包。

本书将指导您如何形成源目录,以及如何构建目标和包。随着您的进展,您将学习如何编译和链接可执行文件和库,这些过程是如何详细工作的,以及如何优化所有步骤以获得最佳结果。您还将了解如何将外部依赖项添加到项目中:第三方库、测试框架、程序分析工具和文档生成器。最后,您将探索如何为内部和外部目的导出、安装和打包您的解决方案。

完成本书后,您将能够在专业水平上自信地使用 CMake。

本书适合谁阅读

学习 C++语言往往不足以让你为交付项目到最高标准做好准备。如果你有兴趣成为一名专业的构建工程师,一个更好的软件开发者,或者简单地说,想精通 CMake,如果你想了解项目是如何组合在一起的以及为什么这样,如果你正在从不同的构建环境中过渡,或者如果你对从零开始学习现代 CMake 感兴趣,那么这本书适合你。

本书涵盖内容

第一章,CMake 的初步步骤,涵盖了如何安装和使用 CMake 的命令行,以及构成项目的文件。

第二章,CMake 语言,提供了关键代码信息:注释、命令调用和参数、变量、列表和控制结构。

第三章,设置您的第一个 CMake 项目,介绍了项目的基本配置、所需的 CMake 版本、项目元数据和文件结构,以及工具链设置。

第四章,与目标工作,介绍了逻辑构建目标,这些目标为可执行文件和库生成工件。

第五章,使用 CMake 编译 C++源代码,解释了编译过程的细节以及如何在 CMake 项目中控制编译过程。

第六章,使用 CMake 链接,提供了关于链接、静态和共享库的一般信息。本章还解释了如何结构化一个项目,以便它可以被测试。

第七章,使用 CMake 管理依赖项,解释了现代 CMake 中可用的依赖管理方法。

第八章,测试框架,描述了如何将最流行的测试框架添加到您的项目中,以及如何使用 CMake 工具集中的 CTest 工具。

第九章,程序分析工具,介绍了如何在您的项目中执行自动格式化,以及进行静态和动态分析。

第十章,生成文档,解释了如何使用 Doxygen 直接从 C++源代码生成用户手册。

第十一章,安装和打包,展示了如何将您的项目准备成在其他项目中使用或在系统上安装。我们还将看到 CPack 工具的解释。

第十二章,创建你的专业项目,介绍了如何将迄今为止你所获得的所有知识整合成一个完全形成的项目。

附录:杂项命令,提供最流行命令的快速参考:string()list()file()math()

为了充分利用本书

本书假设您对 C++和类 Unix 系统有基本的熟悉度。虽然这不是一个严格的要求,但熟悉这些内容将有助于您完全理解本书中的示例。

本书针对 CMake 3.20,但描述的大多数技术应该从 CMake 3.15 起就可以工作(在之后添加的功能通常会 highlighted)。

所有示例已在安装以下包的 Debian 上进行测试:

clang-format clang-tidy cppcheck doxygen g++ gawk git graphviz lcov libpqxx-dev libprotobuf-dev make pkg-config protobuf-compiler tree valgrind vim wget

为了体验相同的环境,建议使用如第一章所解释的 Docker 镜像。

如果你使用的是本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库(下一节中有链接)访问代码。这样做可以帮助你避免因复制和粘贴代码而可能出现的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图和图表的彩色图像。您可以通过此链接下载: static.packt-cdn.com/downloads/9781801070058_ColorImages.pdf

本书中使用了以下约定:

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、假 URL、用户输入和 Twitter 处理。以下是一个示例:"选择DebugReleaseMinSizeRelRelWithDebInfo并按如下方式指定。"

代码块如下所示:

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

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

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

任何命令行输入或输出如下所示:

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

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:"如果其他方法都失败了,我们需要使用大炮,总有跟踪模式。"

提示或重要说明

文本中代码如下所示。

第一部分:介绍 CMake

打好基础对于理解更高级的内容和避免愚蠢的错误至关重要。大多数 CMake 用户在这里会遇到麻烦:没有坚实的基础,很难达到正确的结果。难怪会这样。跳过入门材料,直接进入实际行动并快速完成任务是很有诱惑力的。我们在本节中解释了 CMake 的核心主题,并拼凑了几行代码来展示最简单的项目看起来是什么样子,以解决这两个问题。

为了构建恰当的思维背景,我们将解释 CMake 究竟是什么以及它是如何工作的,还会介绍命令行的样子。我们会讨论不同的构建阶段,并学习用于生成构建系统的语言。我们还将讨论 CMake 项目:它们包含哪些文件,如何处理它们的目录结构,以及我们将探索它们的主要配置。

本部分包括以下章节:

  • 第一章,《CMake 的初步步骤》

  • 第二章,《CMake 语言》

  • 第三章,《设置你的第一个 CMake 项目》

第一章:CMake 的初步步骤

将源代码转换成运行中的应用程序有一种神奇的感觉。这种神奇不仅仅在于效果本身,即我们设计并使其实际运行的机制,而且在于将想法付诸实践的过程或行为。

作为程序员,我们按照以下循环工作:设计、编码和测试。我们发明变更,我们用编译器能理解的言语来阐述它们,然后我们检查它们是否如预期那样工作。为了从我们的源代码创建一个适当、高质量的程序,我们需要精心执行重复性、易出错的任务:调用正确的命令,检查语法,链接二进制文件,运行测试,报告问题,等等。

每次都记住每个步骤需要付出很大的努力。相反,我们希望专注于实际的编程,并将其他所有任务委派给自动化工具。理想情况下,这个过程将从我们更改代码后立即开始,只需按一个按钮。它会智能、快速、可扩展,并且在不同的操作系统和环境中以相同的方式工作。它会被多种集成开发环境IDEs)支持,同时也会被持续集成CI)管道支持,在我们向共享仓库提交更改后测试我们的软件。

CMake 是许多需求的答案;然而,要正确配置和使用它需要一些工作。这不是因为 CMake 不必要的复杂,而是因为我们这里处理的主题就是这样。不要担心。我们将非常有条理地进行整个学习过程;在您意识到之前,您将已经成为一个建筑大师。

我知道您渴望开始编写自己的 CMake 项目,我赞赏您的热情。由于您的项目主要面向用户(包括您自己),您理解这一观点也很重要。

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

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

  • 理解基础知识

  • 在不同平台上安装 CMake

  • 掌握命令行

  • 导航项目文件

  • 发现脚本和模块

技术要求

您可以在 GitHub 上找到本章中存在的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter01

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

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

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

理解基础知识

编译 C++源代码似乎是一个相当直接的过程。让我们以一个小型程序为例,比如经典的hello.cpp应用:

chapter-01/01-hello/hello.cpp

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

现在,要获得可执行文件,我们只需要运行一个命令。我们通过将文件名作为参数传递给编译器来调用它:

$ g++ hello.cpp -o a.out

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

$ ./a.out
Hello World!
$

然而,随着我们的项目增长,你会很快理解将所有内容放在一个文件中 simply not possible。良好的代码实践建议文件应保持小且结构良好。手动编译每个文件可能是一个繁琐且脆弱的过程。一定有更好的方法。

什么是 CMake?

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

我们添加的功能越多,我们得到一个完整的解决方案的机会就越大。软件构建是一个非常多样化的过程,可以涵盖多个不同的方面:

  • 编译可执行文件和库

  • 管理依赖项

  • 测试

  • 安装

  • 打包

  • 生成文档

  • 再测试一下

要创建一个真正模块化且强大的 C++构建应用程序需要很长时间,而且确实做到了。Bill Hoffman 在 Kitware 实现了 CMake 的第一个版本,至今已有 20 多年。正如你可能已经猜到的,它非常成功。它现在有很多功能和社区支持。今天,CMake 正在积极开发中,并已成为 C 和 C++程序员的行业标准。

自动化构建代码的问题比 CMake 要早得多,所以自然而然,有很多选择: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 首先创建一个空的构建树,并收集有关它正在工作的环境的详细信息,例如架构、可用的编译器、链接器和归档器。此外,它检查一个简单的测试程序是否可以正确编译。

接下来,解析并执行CMakeLists.txt项目配置文件(是的,CMake 项目是用 CMake 的编程语言配置的)。这个文件是 CMake 项目的最小配置(源文件可以在以后添加)。它告诉 CMake 关于项目结构、目标和依赖项(库和其他 CMake 包)。在这个过程中,CMake 将在构建树中存储收集的信息,如系统详细信息、项目配置、日志和临时文件,供下一步使用。特别是,创建了一个CMakeCache.txt文件来存储更稳定的变量(如编译器和其他工具的路径),以节省下一次配置的时间。

生成阶段

在阅读项目配置后,CMake 将为它正在工作的确切环境生成一个构建系统。构建系统只是为其他构建工具(例如,为 GNU Make 的 Makefile 或为 Ninja 和 Visual Studio 的 IDE 项目文件)定制的配置文件。在这个阶段,CMake 仍然可以通过评估生成器表达式来对构建配置进行一些最后的润色。

注意

生成阶段在配置阶段自动执行后执行。因此,本书和其他资源在提到“配置”或“生成”构建系统时,通常会提到这两个阶段。要显式运行 just the 配置阶段,你可以使用cmake-gui工具。

构建阶段

为了生成我们项目中所指定的最终工件,我们必须运行适当的构建工具。这可以直接通过 IDE 调用,或者使用 CMake 命令。反过来,这些构建工具将执行步骤,使用编译器、链接器、静态和动态分析工具、测试框架、报告工具以及你能想到的任何其他工具来生成目标

这个解决方案的优点在于能够根据需要为每个平台生成构建系统,只需一个配置(即,相同的项目文件):

图 1.1 – CMake 的阶段

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_1.1_B17205.jpg)

图 1.1 – CMake 的阶段

你们还记得我们在理解基本内容部分提到的hello.cpp应用程序吗?CMake 让构建它变得非常简单。我们需要的只是一个位于源代码旁边的CMakeLists.txt文件和两个简单的命令cmake -B buildtreecmake --build buildtree,如下所示:

chapter01/01-hello/CMakeLists.txt:用 CMake 语言编写的世界

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

这是来自 Dockerized Linux 系统的输出(请注意,我们将在在不同的平台上安装 CMake部分讨论 Docker):

root@5f81fe44c9bd:/root/examples/chapter01/01-hello# cmake -B buildtree.
-- The C compiler identification is GNU 9.3.0
-- The CXX compiler identification is GNU 9.3.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /root/examples/chapter01/01-hello/buildtree
root@5f81fe44c9bd:/root/examples/chapter01/01-hello# cmake --build buildtree/
Scanning dependencies of target Hello
[ 50%] Building CXX object CMakeFiles/Hello.dir/hello.cpp.o
[100%] Linking CXX executable Hello
[100%] Built target Hello

现在只剩下运行它了:

root@68c249f65ce2:~# ./buildtree/Hello
Hello World!

在这里,我们生成了一个存储在buildtree目录中的构建系统。接着,我们执行了构建阶段,生成了一个我们能够运行的最终二进制文件。

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

获取帮助

本书将为您提供与当前版本的 CMake(撰写本书时为 3.20 版)最相关的最重要的信息。为了给您提供最好的建议,我明确避免使用任何已弃用和不推荐使用的功能。我强烈建议至少使用版本 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/cmake:toolchain:这个包含了构建 CMake 所需的精心挑选的工具和依赖项。

  • swidzinski/cmake:examples:这个包含了前面的工具链以及本书中的所有项目和示例。

第一个选项是为那些只想使用一个空白镜像来构建自己项目的读者准备的,第二个选项是为在章节中进行实例实践准备的。

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

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

请注意,所有示例都可以在匹配此格式的目录中找到:/root/examples/``examples/chapter-<N>/<M>-<title>

Windows

在 Windows 上的安装很简单——只需下载 32 位或 64 位的版本。你可以选择一个便携式 ZIP 或 MSI 包用于 Windows 安装程序。

使用 ZIP 包,你将不得不将 CMake 二进制目录添加到PATH环境变量中,这样你才能在任何目录中使用它,而不会出现错误:

'cmake' is not recognized as an internal or external command, operable program or batch file.

如果你喜欢方便,可以直接使用 MSI 安装程序:

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

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

如我前面提到的,这是开源软件,所以自己构建 CMake 是可能的。然而,首先,你需要在你的系统上获取 CMake 的二进制副本。那么,如果你有自己的构建工具,为什么还要使用其他的呢?这种场景是 CMake 贡献者用来生成新版本的。

在 Windows 上,我们还需要一个构建工具来完成由 CMake 启动的构建过程。这里的一个流行选择是 Visual Studio,其社区版可从微软网站免费获得:visualstudio.microsoft.com/downloads/

Linux

在 Linux 上获取 CMake 与获取任何其他流行软件包相同。只需使用命令行的包管理器即可。软件包通常会保持更新,包含相对较新的版本。然而,如果你想要最新版本,你可以从网站上下载安装脚本:

适用于 Linux x86_64 的脚本

$ wget -O - https://github.com/Kitware/CMake/releases/download/v3.20.0/cmake-3.20.0-linux-x86_64.sh | bash

适用于 Linux aarch64 的脚本

$ wget -O - https://github.com/Kitware/CMake/releases/download/v3.20.0/cmake-3.20.0-Linux-aarch64.sh | bash

适用于 Debian/Ubuntu 的软件包

$ sudo apt-get install cmake

适用于 Red Hat 的软件包

$ yum install cmake

macOS

这个平台也得到了 CMake 开发者的强烈支持。最流行的安装方式是通过 MacPorts:

$ sudo port install cmake

另外,你可以使用 Homebrew:

$ brew install cmake

从源代码构建

如果其他方法都失败了——或者如果你在一个特殊的平台上——从官方网站下载源代码并自己编译它:

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

从源代码构建相对较慢,需要更多步骤。然而,通过这种方式,你可以保证使用最新版本的 CMake。这在与 Linux 上可用的软件包相比较时尤为明显:系统版本越旧,更新越少。

既然我们已经轻松安装了 CMake,那就让我们学习如何使用它吧!

精通命令行

本书的大部分内容将教你如何为你的用户准备 CMake 项目。为了满足他们的需求,我们需要彻底了解用户在不同场景中如何与 CMake 互动。这将允许你测试项目文件,并确保它们正确运行。

CMake 是一组工具的家族,包括五个可执行文件:

  • cmake:这是主要的可执行文件,用于配置、生成和构建项目。

  • ctest:这个程序用于运行和报告测试结果。

  • cpack:这个程序用于生成安装程序和源代码包。

  • cmake-gui:这是围绕cmake的图形界面。

  • ccmake:这是基于控制台的图形界面围绕cmake

CMake

这个二进制文件提供了一些操作模式(也称为动作):

  • 生成项目构建系统

  • 构建项目

  • 安装项目

  • 运行脚本

  • 运行命令行工具

  • 获取帮助

生成项目构建系统

安装项目是我们构建项目所需的第一步。以下是执行 CMake 构建操作的几种方式:

生成模式的语法

cmake [<options>] -S <path-to-source> -B <path-to-build>
cmake [<options>] <path-to-source>
cmake [<options>] <path-to-existing-build>

我们将在接下来的部分讨论这些选项。现在,让我们专注于选择正确的命令形式。CMake 的一个重要特性是支持离线构建或在单独的目录中生成工件。与 GNU Make 等工具相比,这确保了源代码目录中不会包含任何与构建相关的文件,并避免了使用-S选项后跟生成构建系统的目录路径的污染:

cmake -S ./project -B ./build

前面命令将在./build目录中(如果缺失则创建)生成一个构建系统,该构建系统来源于./project目录中的源代码。

我们可以省略一个参数,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.

示例

在当前目录中构建,但源代码从上一级目录获取(注意-S是可选的):

cmake -S ..

./build目录中构建,并使用当前目录中的源代码:

cmake -B build

生成器的选项

如前所述,在生成阶段,你可以指定一些选项。选择和配置生成器决定了我们将使用我们系统中的哪个构建工具来构建,构建文件将呈现什么样子,以及构建树的结构将如何。

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

cmake -G <generator-name> <path-to-source>

一些生成器(如 Visual Studio)支持对工具集(编译器)和平面(编译器或 SDK)进行更详细的指定。另外,这些生成器还有相应的环境变量,这些环境变量会覆盖默认值:CMAKE_GENERATOR_TOOLSETCMAKE_GENERATOR_PLATFORM。我们可以像下面这样直接指定它们:

cmake -G <generator-name> 
      -T <toolset-spec> -A <platform-name>
      <path-to-source>

Windows 用户通常希望为他们的首选 IDE 生成一个构建系统。在 Linux 和 macOS 上,使用 Unix Makefiles 或 Ninja 生成器非常普遍。

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

cmake --help

help打印输出结束时,你应该看到一个完整的列表,如下所示:

Windows 10 上有许多生成器可供选择:

The following generators are available on this platform:
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 10 2010 [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 在配置阶段查询系统获取各种信息。这些信息存储在构建树目录中的CMakeCache.txt文件中。有一些选项可以让你更方便地管理该文件。

我们首先可以使用的功能是预填充缓存信息的能力:

cmake -C <initial-cache-script> <path-to-source>

我们可以提供 CMake 脚本的路径,该脚本(仅)包含一个set()命令列表,用于指定将用于初始化空构建树的变量。

现有缓存变量的初始化和修改可以通过另一种方式完成(例如,仅设置几个变量而创建一个文件似乎有些过于繁琐)。你只需在命令行中简单地设置它们,如下所示:

cmake -D <var>[:<type>]=<value> <path-to-source>

:<type>部分是可选的(GUIs 使用它);你可以使用BOOLFILEPATHPATHSTRINGINTERNAL。如果你省略了类型,它将设置为已有变量的类型;否则,它将设置为UNINITIALIZED

一个特别重要的变量包含构建类型:例如,调试和发布。许多 CMake 项目会在多个场合读取它,以决定诸如消息的冗余度、调试信息的的存在以及创建的艺术品的优化级别等事情。

对于单配置生成器(如 Make 和 Ninja),你需要在配置阶段指定CMAKE_BUILD_TYPE变量,并为每种类型的配置生成一个单独的构建树:DebugReleaseMinSizeRelRelWithDebInfo

以下是一个示例:

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

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

我们可以使用-L选项:

cmake -L[A][H] <path-to-source>

这样的列表将包含未标记为ADVANCED的缓存变量。我们可以通过添加A修饰符来改变这一点。要打印带有变量的帮助信息 - 添加H修饰符。

令人惊讶的是,使用-D选项手动添加的自定义变量如果不指定支持的一种类型,将不可见。

删除一个或多个变量的操作可以通过以下选项完成:

cmake -U <globbing_expr> <path-to-source>

在此,通配符表达式支持*通配符和任何?字符符号。使用时要小心,以免破坏东西。

-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 <path-to-source>

我们将在第二章 CMake 语言 中更详细地讨论日志记录。

如果其他方法都失败了——我们必须使用大杀器——总是有跟踪模式。这将打印出每个命令以及它来自的文件名和确切的行号及其参数。你可以按照如下方式启用它:

cmake --trace

预设选项

正如你可能已经猜到的,用户可以指定很多选项来从你的项目中生成一个构建树。当处理构建树路径、生成器、缓存和环境变量时,很容易感到困惑或遗漏某些内容。开发者可以简化用户与他们项目交互的方式,并提供一个指定一些默认值的CMakePresets.json文件。要了解更多,请参考导航项目文件部分。

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

cmake --list-presets

你可以按照如下方式使用其中一个预设:

cmake --preset=<preset>

这些值覆盖了系统默认值和环境。然而,同时,它们也可以被命令行上明确传递的任何参数覆盖:

图 1.3 – 预设如何覆盖 CMakeCache.txt 和系统环境变量

图 1.3 – 预设如何覆盖 CMakeCache.txt 和系统环境变量

构建项目

在生成我们的构建树之后,我们准备进入下一阶段:运行构建工具。CMake 不仅知道如何为许多不同的构建器生成输入文件,而且还知道如何为你提供特定于项目的参数来运行它们。

不推荐

许多在线资源建议在生成阶段之后直接运行 GNU Make:make。这是 Linux 和 macOS 的默认生成器,通常可以工作。然而,我们更喜欢本节描述的方法,因为它与生成器无关,并且支持所有平台。因此,我们不需要担心我们应用程序每个用户的准确环境。

构建模式的语法

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

在这些大多数情况下,提供最少量的东西以获得成功的构建就足够了:

cmake --build <dir>

CMake 需要知道我们生成的构建树的位置。这是我们在生成阶段传递给 -B 参数的相同路径。

通过提供一些选项,CMake 允许您指定对每个构建器都有效的关键构建参数。如果您需要向您选择的本地构建器提供特殊参数,请在--标记之后,在命令的末尾传递它们:

cmake --build <dir> -- <build-tool-options>

并行构建选项

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

如果您在强大的机器上构建(或者为了调试而强制进行单线程构建),您可能想要覆盖那个设置。只需使用以下任一选项指定作业数量:

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

另一种方法是使用CMAKE_BUILD_PARALLEL_LEVEL环境变量来设置。像往常一样,我们总是可以使用前面的选项来覆盖变量。

目标选项

我们将在书的第二部分讨论目标。现在,我们只需说每个项目都由一个或多个称为目标的部分组成。通常,我们想要构建它们所有;然而,在某些情况下,我们可能想要跳过一些或者显式构建被正常构建中故意排除的目标。我们可以这样做:

cmake --build <dir> --target <target1> -t <target2> ...

正如您将观察到的,我们可以通过重复-t参数来指定多个目标。

通常不构建的一个目标是clean。这将从构建目录中删除所有工件。您可以这样调用它:

cmake --build <dir> -t clean

此外,CMake 还提供了一个方便的别名,如果你想要先清理然后再进行正常构建的话:

cmake --build <dir> --clean-first

多配置生成器的选项

所以,我们已经对生成器有了一些了解:它们有不同的形状和大小。其中一些提供的功能比其他的多,而这些功能之一就是能够在单个构建树中构建DebugRelease构建类型。

支持此功能的生成器包括 Ninja 多配置、Xcode 和 Visual Studio。其余的生成器都是单配置生成器,为此目的需要一个独立的构建树。

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

cmake --build <dir> --config <cfg>

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

调试选项

当事情出错时,我们首先应该做的是检查输出信息。然而,经验丰富的开发者知道,一直打印所有细节是令人困惑的,所以它们通常默认隐藏它们。当我们需要揭开盖子时,我们可以通过告诉 CMake 要详细输出日志来请求更详细的日志:

cmake --build <dir> --verbose
cmake --build <dir> -v

通过设置CMAKE_VERBOSE_MAKEFILE缓存变量也可以达到同样的效果。

安装项目

当构建工件时,用户可以将它们安装到系统中。通常,这意味着将文件复制到正确的目录中,安装库,或者从 CMake 脚本中运行一些自定义安装逻辑。

安装模式的语法

cmake --install <dir> [<options>]

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

cmake --install <dir>

多配置生成器选项

与构建阶段类似,我们可以指定我们想要用于安装的构建类型(有关详细信息,请参阅构建项目部分)。可用的类型包括DebugReleaseMinSizeRelRelWithDebInfo。签名如下:

cmake --install <dir> --config <cfg>

组件选项

作为开发者,您可能会选择将项目拆分为可以独立安装的组件。我们将在第十一章进一步讨论组件的概念,安装与打包。现在,我们假设它们代表解决方案的不同部分。这可能类似于applicationdocsextra-tools

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

cmake --install <dir> --component <comp>

权限选项

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

cmake --install <dir> 
      --default-directory-permissions <permissions>

安装目录选项

我们可以为项目配置中指定的安装路径添加一个自定义前缀(例如,当我们对某些目录的写入权限有限时)。原本的/usr/local路径通过添加/home/user前缀后变为/home/user/usr/local。此选项的签名如下:

cmake --install <dir> --prefix <prefix>

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

调试选项

同样地,我们也可以选择在构建阶段查看安装阶段的详细输出。为此,可以使用以下任意一个选项:

cmake --build <dir> --verbose
cmake --build <dir> -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.20 支持以下命令:

capabilities, cat, chdir, compare_files, copy, copy_directory, copy_if_different, echo, echo_append, env, environment, make_directory, md5sum, sha1sum, sha224sum, sha256sum, sha384sum, sha512sum, remove, remove_directory, rename, rm, server, sleep, tar, time, touch, touch_nocreate, create_symlink, create_hardlink, true, 和 false

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

获取帮助

毫不奇怪,CMake 通过其命令行提供了广泛的帮助。

帮助模式的语法

cmake ––help[-<topic>]

CTest

自动化测试对于生成和维护高质量代码非常重要。这就是为什么我们专门用了一整章来讨论这个主题(请参考第八章,测试框架),其中我们深入探讨了 CTest 的使用。它是可用的命令行工具之一,所以我们现在简要介绍一下。

CTest 是在更高层次的抽象中封装 CMake,其中构建阶段成为开发我们软件过程中的一个垫脚石。CMake 还可以为我们执行其他任务,包括更新、运行各种测试、将项目状态报告给外部仪表板以及运行编写在 CMake 语言中的脚本。

更重要的是,CTest 标准化了使用 CMake 构建的解决方案的测试运行和报告。这意味着作为用户,你不需要知道项目使用的是哪个测试框架或如何运行它。CTest 提供了一个方便的界面来列出、筛选、洗牌、重试和计时测试运行。此外,如果需要构建,它还可以调用 CMake。

运行构建项目测试的最简单方法是在生成的构建树中调用ctest

$ ctest
Test project C:/Users/rapha/Desktop/CMake/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 包、RPMs 等。

CPack 的工作方式与 CMake 非常相似:它使用 CMake 语言进行配置,并有许多可供选择的包生成器(只是不要将它们与 CMake 构建系统生成器混淆)。我们将在第十一章中详细介绍,该章节讨论安装和打包,因为这是一个用于 CMake 项目最后阶段的相当庞大的工具。

CMake GUI

对于 Windows,CMake 附带一个 GUI 版本,用于配置先前准备好的项目的构建过程。对于 Unix-like 平台,有一个用 QT 库构建的版本。Ubuntu 在cmake-qt-gui包中提供。

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

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

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

图形用户界面(GUI)应用程序是方便的工具,因为那里的选项相当有限。对于不熟悉命令行并且更喜欢基于窗口的界面的用户来说,这可能很有用。

不推荐

我肯定会推荐 GUI 给那些追求方便的最终用户;然而,作为一名程序员,我避免引入任何需要每次构建程序时点击表单的手动、阻塞步骤。这对于 CI 管道中的构建自动化尤为重要。这些工具需要无头应用程序,以便在没有用户交互的情况下完全执行构建。

CCMake

ccmake可执行文件是 CMake 的 Unix-like 平台的curses界面(Windows 上不可用)。它不是 CMake 包的一部分,因此用户必须单独安装。

Debian/Ubuntu 系统的命令如下:

$ sudo apt-get install cmake-curses-gui

请注意,可以通过这个 GUI 交互式地指定项目配置设置。程序运行时,终端底部会提供简短的说明:

CCMake 命令的语法

ccmake [<options>]
ccmake {<path-to-source> | <path-to-existing-build>}

CCMake 使用与cmake相同的选项集:

图 1.5 – ccmake 中的配置阶段

图 1.5 – ccmake 中的配置阶段

与图形用户界面(GUI)一样,这种模式相当有限,旨在供经验较少的用户使用。如果您在 Unix 机器上工作,我建议您快速查看并更快地继续。

这结束了关于 CMake 命令行的基本介绍。是时候探索一下典型 CMake 项目的结构了。

浏览项目文件

CMake 使用很多文件来管理其项目。在修改内容之前,让我们试图了解每个文件的作用。重要的是要意识到,尽管一个文件包含 CMake 语言命令,但这并不意味着它一定是为了让开发者编辑的。有些文件是为了被后续工具使用而生成的,对这些文件所做的任何更改都将在某个阶段被覆盖。其他文件是为了让高级用户根据个人需求调整项目。最后,还有一些在特定上下文中提供宝贵信息的临时文件。本节还将指定哪些应该放在您版本控制系统的忽略文件中。

源代码树

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

此目录的关键收获如下:

  • 您必须在它的顶部目录中提供一个CMakeLists.txt配置文件。

  • 它应该使用如git这样的 VCS 进行管理。

  • 此目录的路径由用户通过cmake命令的-S参数给出。

  • 避免在您的 CMake 代码中硬编码任何绝对路径到源代码树——您的软件的用户可以将项目存储在不同的路径下。

构建树

CMake 使用此目录来存储构建过程中生成的所有内容:项目的工件、短暂配置、缓存、构建日志以及您的本地构建工具将创建的任何内容。这个目录的别名还包括构建根二进制树

此目录的关键收获如下:

  • 您的二进制文件将在此处创建,例如可执行文件和库文件,以及用于最终链接的对象文件和归档文件。

  • 不要将此目录添加到您的 VCS 中——它是特定于您的系统的。如果您决定将其放在源代码树内,请确保将其添加到 VCS 忽略文件中。

  • CMake 建议进行源外构建,或生成工件的目录与所有源文件分离的构建。这样,我们可以避免用临时、系统特定的文件污染项目的源代码树(或者进行源内构建)。

  • 如果提供了源代码的路径,例如cmake -S ../project ./,则使用-B或作为cmake命令的最后一个参数来指定此目录。

  • 建议您的项目包括一个安装阶段,允许您将最终工件放在系统中的正确位置,以便可以删除用于构建的所有临时文件。

列表文件

包含 CMake 语言的文件称为列表文件,可以通过调用include()find_package(),或者间接地通过add_subdirectory()来相互包含:

  • CMake 不强制这些文件的一致命名,但通常它们具有.cmake扩展名。

  • 一个非常重要的命名异常是一个名为CMakeLists.txt的文件,这是在配置阶段第一个被执行的文件。它需要位于源树的顶部。

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

CMakeLists.txt

CMakeLists.txt列表文件用于配置 CMake 项目。你必须在源树根目录中提供至少一个。这样的顶级文件在配置阶段是第一个被执行的,它至少应该包含两个命令:

  • cmake_minimum_required(VERSION <x.xx>):设置 CMake 的预期版本(隐含地告诉 CMake 如何应用与遗留行为相关的策略)。

  • project(<name> <OPTIONS>):用于命名项目(提供的名称将存储在PROJECT_NAME变量中)并指定配置选项(我们将在第二章 CMake 语言中进一步讨论)。

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

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

然后可以使用一个非常简单的CMakeLists.txt文件将其全部整合在一起:

CMakeLists.txt

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

项目的主要方面在顶级文件中涵盖:管理依赖项,声明要求,以及检测环境。在此文件中,我们还有一个add_subdirectory(api)命令,以从api目录中包含另一个CMakeListst.txt文件,执行与应用程序的 API 部分相关的特定步骤。

CMakeCache.txt

缓存变量将在第一次运行配置阶段时从listfiles生成,并存储在CMakeCache.txt中。此文件位于构建树的根目录中,格式相当简单:

# This is the CMakeCache file.
# For build in directory:
  c:/Users/rapha/Desktop/CMake/empty_project/build
# It was generated by CMake: C:/Program
  Files/CMake/bin/cmake.exe
# You can edit this file to change values found and used by
  cmake.
# If you do want to change a value, simply edit, save, and
  exit the editor.
# 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手动管理此文件(请参阅缓存选项精通命令行部分),或者通过ccmake/cmake-gui

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

  • 缓存变量可以从列表文件中读写。有时,变量引用评估有点复杂;然而,我们将在第二章 CMake 语言中更详细地介绍。

包的配置文件

CMake 生态系统的大部分包括项目可以依赖的外部包。它们允许开发人员以无缝、跨平台的方式使用库和工具。支持 CMake 的包应提供配置文件,以便 CMake 了解如何使用它们。

我们将在第十一章中学习如何编写这些文件,安装和打包。同时,这里有一些有趣的细节要注意:

  • 配置文件(原名)包含有关如何使用库二进制文件、头文件和辅助工具的信息。有时,它们暴露出 CMake 宏,可以在您的项目中使用。

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

  • 描述包的 CMake 文件名为 <PackageName>-config.cmake<PackageName>Config.cmake

  • 使用包时,您可以指定需要的包的哪个版本。CMake 会在关联的 <Config>Version.cmake 文件中检查这个版本。

  • 配置文件由支持 CMake 生态系统的包供应商提供。如果一个供应商没有提供这样的配置文件,可以用 Find-module(原名)来替换。

  • CMake 提供了一个包注册表,用于在系统范围内和每个用户处存储包。

cmake_install.cmake、CTestTestfile.cmake 和 CPackConfig.cmake 文件

这些文件由 cmake 可执行文件在生成阶段在构建树中生成。因此,不建议手动编辑它们。CMake 使用它们作为 cmake 安装操作、CTest 和 CPack 的配置。如果您实现源代码构建(不建议),添加到 VCS 忽略文件中可能是个不错的主意。

CMakePresets.json 和 CMakeUserPresets.json

当我们需要具体设置诸如缓存变量、选择生成器、构建树路径等事物时,项目的配置可能会变得相对繁琐——尤其是当我们有多种构建项目的方式时。这时预设就派上用场了。

用户可以通过 GUI 选择预设,或者使用命令行 --list-presets 并使用 --preset=<preset> 选项为构建系统选择一个预设。您可以在本章的 精通命令行 部分找到更多详细信息。

预设以相同的 JSON 格式存储在两个文件中:

  • CMakePresets.json:这是为了让项目作者提供官方预设。

  • CMakeUserPresets.json:这是专为希望按自己的喜好自定义项目配置的用户准备的(您可以在 VCS 忽略文件中添加它)。

预设是项目文件,所以它们的解释属于这里。然而,在项目中它们并非必需,只有在完成初始设置后它们才变得有用。所以,如果您愿意,可以跳到下一节,需要时再回来:

chapter-01/02-presets/CMakePresets.json

{
  "version": 1,
  "cmakeMinimumRequired": {
    "major": 3, "minor": 19, "patch": 3
  },
  "configurePresets": [ ],
  "vendor": {
    "vendor-one.com/ExampleIDE/1.0": {
      "buildQuickly": false
    }
  }
}

CMakePresets.json 指定以下根字段:

  • Version:这是必须的,总是1

  • cmakeMinimumRequired:这是可选的。它以散列形式指定 CMake 版本,包含三个字段:majorminorpatch

  • vendor:IDE 可以使用这个可选字段来存储其元数据。它是一个以供应商域和斜杠分隔的路径为键的映射。CMake 实际上忽略这个字段。

  • configurePresets:这是一个可选的可用预设数组。

让我们向我们的configurePresets数组中添加两个预设:

chapter-01/02-presets/CMakePresets.json:my-preset

{
  "name": "my-preset",
  "displayName": "Custom Preset",
  "description": "Custom build - Ninja",
  "generator": "Ninja",
  "binaryDir": "${sourceDir}/build/ninja",
  "cacheVariables": {
    "FIRST_CACHE_VARIABLE": {
      "type": "BOOL", "value": "OFF"
    },
    "SECOND_CACHE_VARIABLE": "Ninjas rock"
  },
  "environment": {
    "MY_ENVIRONMENT_VARIABLE": "Test",
    "PATH": "$env{HOME}/ninja/bin:$penv{PATH}"
  },
  "vendor": {
    "vendor-one.com/ExampleIDE/1.0": {
      "buildQuickly": true
    }
  }
},

此文件支持树状结构,其中子预设从多个父预设继承属性。这意味着我们可以创建先前预设的副本,并只覆盖我们需要的字段。以下是一个子预设可能的样子:

chapter-01/02-presets/CMakePresets.json:my-preset-multi

{
  "name": "my-preset-multi",
  "inherits": "my-preset",
  "displayName": "Custom Ninja Multi-Config",
  "description": "Custom build - Ninja Multi",
  "generator": "Ninja Multi-Config"
}

注意

CMake 文档只将一些字段明确标记为必需的。然而,还有一些其他字段被标记为可选的,这些字段必须在预设中提供,或者从其父预设继承。

预设被定义为具有以下字段的映射:

  • name:这是一个必需的字符串,用于标识预设。它必须对机器友好,并且在两个文件中唯一。

  • Hidden:这是一个可选的布尔值,用于隐藏预设,使其不在 GUI 和命令行列表中显示。这样的预设可以是另一个预设的父预设,并且不需要提供除其名称以外的任何内容。

  • displayName:这是一个可选的字符串,有一个人类可读的名字。

  • description:这是一个可选的字符串,用于描述预设。

  • Inherits:这是一个可选的字符串或预设名称数组,用于从其中继承。在冲突的情况下,早期预设的值将被优先考虑,每个预设都可以覆盖任何继承的字段。此外,CMakeUserPresets.json可以继承项目预设,但反之则不行。

  • Vendor:这是一个可选的供应商特定值的映射。它遵循与根级vendor字段相同的约定。

  • Generator:这是一个必需或继承的字符串,用于指定预设要使用的生成器。

  • architecturetoolset:这些是用于配置支持这些选项的生成器的可选字段(在生成项目构建系统部分提到)。每个字段可以是一个简单的字符串,或者一个带有valuestrategy字段的哈希表,其中strategysetexternal。当strategy字段配置为set时,将设置字段值,如果生成器不支持此字段,则会产生错误。配置为external意味着字段值是为外部 IDE 设置的,CMake 应该忽略它。

  • binaryDir:这是一个必需或继承的字符串,提供了构建树目录的路径(相对于源树是绝对路径或相对路径)。它支持宏扩展。

  • cacheVariables:这是一个可选的缓存变量映射,其中键表示变量名。接受的值包括null"TRUE""FALSE"、字符串值,或具有可选type字段和必需value字段的哈希。value可以是"TRUE""FALSE"的字符串值。除非明确指定为null,否则缓存变量会通过并集操作继承——在这种情况下,它将保持未设置。字符串值支持宏扩展。

  • Environment: 这是一个可选的环境变量映射,其中键表示变量名。接受的值包括null或字符串值。除非明确指定为null,否则环境变量会通过并集操作继承——在这种情况下,它将保持未设置。字符串值支持宏扩展,变量可以以任何顺序相互引用,只要没有循环引用即可。

以下宏将被识别和评估:

  • ${sourceDir}:这是源树的位置。

  • ${sourceParentDir}:这是源树父目录的位置。

  • ${sourceDirName}: 这是${sourceDir}的最后一个文件名组件。例如,对于/home/rafal/project,它就是project

  • ${presetName}: 这是预设的名称字段的值。

  • ${generator}:这是预设的生成器字段的值。

  • ${dollar}: 这是一个字面意义上的美元符号($)。

  • $env{<variable-name>}:这是一个环境变量宏。如果预设中定义了该变量,它将返回预设中的变量值;否则,它将从父环境返回值。请注意,预设中的变量名是区分大小写的(与 Windows 环境不同)。

  • $penv{<variable-name>}:这个选项与$env类似,但总是从父环境返回值。这允许您解决预设环境变量中不允许的循环引用问题。

  • $vendor{<macro-name>}:这使得供应商能够插入自己的宏。

在 Git 中忽略文件

有很多版本控制系统;其中最流行的一种是 Git。每当我们开始一个新项目时,确保我们只将需要存在于仓库中的文件提交到仓库中是很重要的。如果我们只是将生成的、用户或临时文件添加到.gitignore文件中,项目卫生更容易维护。这样,Git 就知道在构建新提交时自动跳过它们。这是我在我项目中使用的文件:

chapter-01/01-hello/.gitignore

# If you put build tree in the source tree add it 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 脚本,你可以有一个版本。当然,你可以引入外部工具,如 Python、Perl 或 Ruby 脚本,但这又是另一个依赖,将增加 C/C++项目的复杂性。是的,有时这将是唯一能完成工作的事情,但更多的时候,我们可以用一些更简单的东西应付过去。

我们已经从掌握命令行部分了解到,我们可以使用-P选项执行脚本:cmake -P script.cmake。但是提供的脚本文件的实际要求是什么?并不多:脚本可以像你喜欢的那么复杂,也可以是一个空文件。然而,建议你在脚本的开始处调用cmake_minimum_required()命令。这个命令告诉 CMake 应该对项目中的后续命令应用哪些策略(更多详情请参阅第三章,设置你的第一个 CMake 项目)。

chapter-01/03-script/script.cmake

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

当运行脚本时,CMake 不会执行任何常规阶段(如配置或生成),也不会使用缓存。由于脚本中没有源/构建树的概念,通常持有这些路径引用的变量将包含当前工作目录:CMAKE_BINARY_DIRCMAKE_SOURCE_DIRCMAKE_CURRENT_BINARY_DIRCMAKE_CURRENT_SOURCE_DIR

快乐脚本编程!

实用模块

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

CMake 分发版包含了几乎 90 个不同的实用模块。如果这还不够,你可以在浏览精选列表,如在github.com/onqtam/awesome-cmake找到的列表上互联网下载更多,或者从头开始编写一个模块。

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

chapter-01/04-module/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.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()命令并提供相关包的名称来使用它们。这样的查找模块将然后玩一场捉迷藏游戏,并检查它所寻找的软件的所有已知位置。在此之后,它定义了变量(如该模块手册中所指定的)允许你针对该依赖项进行构建。

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

我们将在第七章更深入地讨论查找模块,使用 CMake 管理依赖项

总结

现在你已经了解了 CMake 是什么以及它是如何工作的;你学习了 CMake 工具家族的关键组成部分以及如何在各种系统中安装它们。像真正的功率用户一样,你知道通过命令行运行 CMake 的所有方式:生成构建系统、构建项目、安装、运行脚本、命令行工具和打印帮助。你知道 CTest、CPack 和 GUI 应用程序。这将帮助你为用户和其他开发者创建项目,并从正确的角度出发。此外,你还学会了组成一个项目的内容:目录、列表文件、配置文件、预设和帮助文件,以及在 VCS 中应该忽略哪些内容。最后,你简要地查看了其他非项目文件:独立的脚本和模块。

在下一章中,我们将深入探讨 CMake 的编程语言。这将使你能够编写自己的列表文件并打开编写第一个脚本、项目和模块的大门。

进一步阅读

更多信息,你可以参考以下资源:

第二章:CMake 语言

CMake 语言中写作有点棘手。当你第一次阅读 CMake 列表文件时,你可能会觉得其中的语言如此简单,以至于不需要任何特殊培训或准备。接下来的内容经常是尝试引入变化和实验代码的实际尝试,而没有彻底理解它是如何工作的。我们程序员通常非常忙碌,并且过于热衷于用最小的投入解决任何与构建相关的问题。我们倾向于基于直觉进行更改,希望它们可能管用。解决技术问题的这种方法称为巫术编程

CMake 语言看起来很简单:在我们完成小的添加、修复或黑客攻击,或者添加了一行代码之后,我们意识到有些事情不对劲。调试时间通常比实际研究主题的时间还要长。幸运的是,这不会是我们的命运——因为本章涵盖了实践中使用 CMake 语言所需的大部分关键知识。

在本章中,我们不仅将学习 CMake 语言的构建块——注释命令变量控制结构,还将提供必要的背景知识,并在一个干净现代的 CMake 示例中尝试它们。CMake 让你处于一个独特的位置。一方面,你扮演着构建工程师的角色;你需要理解编译器、平台以及中间所有事物的复杂性。另一方面,你是一名开发者;你在编写生成构建系统的代码。编写好的代码是困难的,并要求同时思考多个层面——它应该能工作且容易阅读,但它也应该容易分析、扩展和维护。这正是我们将在这里讨论的内容。

最后,我们将介绍 CMake 中一些最有用和常见的命令。不经常使用的命令将放在附录部分(这将包括字符串、列表和文件操作命令的完整参考指南)。

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

  • CMake 语言基础语法

  • 使用变量

  • 使用列表

  • 理解 CMake 中的控制结构

  • 有用命令

技术要求

你可以在 GitHub 上找到本章中存在的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter02

为了构建本书中提供的示例,总是使用推荐的命令:

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

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

CMake 语言基础语法

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

正如我们在上一章中讨论的,脚本支持 CMake 语言的大部分(排除任何与项目相关的功能)。因此,它们是开始练习 CMake 语法的好方法,这就是为什么我们会在这里使用它们。在熟练编写基本列表文件之后,我们将在下一章开始准备实际的项目文件。如果你还记得,脚本可以用以下命令运行:

cmake -P script.cmake

注意

CMake 支持\n\r\n行结束。UTF-8支持可选的字节顺序标记BOMs)的 CMake 版本 above 3.0,并且UTF-16在 CMake 版本 above 3.2 中得到支持。

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

注释

就像在C++中一样,有两种注释——单行注释和方括号 (多行)注释。但与 C++不同,方括号注释可以嵌套。让我给你展示一下语法:

# single-line comments start with a hash sign "#"
# they can be placed on an empty line
message("Hi"); # or after a command like here.
#[=[ 
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

那是个巧妙的技巧,但我们在 CMake 文件中什么时候以及如何使用注释呢?由于编写列表文件本质上是一种编程,因此最好也将我们最好的编程实践应用到它们上。遵循此类实践的代码通常被称为干净——这个术语多年来被软件开发大师如 Robert C. Martin, Martin Fowler,以及其他许多作者使用。认为有帮助和有害的东西常常激烈争论,正如你所猜的,注释也没有被排除在这些争论之外。

一切都应该根据具体情况来判断,但通常公认的指导原则说,好的注释至少提供以下之一:

  • 信息:它们可以解释像正则表达式模式或格式化字符串这样的复杂性。

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

  • 阐明:它们可以解释难以重构或更改的概念。

  • 警告后果:它们可以提供警告,尤其是关于可能破坏其他内容的代码。

  • 放大:它们可以强调难以用代码表达的想法。

  • 法律条款:它们可以添加这个必要的恶棍,这通常不是程序员的领域。

如果你可以,避免添加注释并采用更好的命名约定,或者重构或修正你的代码。如果你可以,避免添加以下类型的注释:

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

  • 冗余:这些重复了代码中已经清晰写明的内容。

  • 误导:如果它们没有跟随代码更改,它们可能是过时的或不正确的。

  • 日志:这些记录了更改的内容和时间(使用版本控制系统代替)。

  • 分隔符:这些用于标记章节。

不带注释编写优雅的代码很难,但它可以提高读者的体验。由于我们花在阅读代码上的时间比编写代码的时间多,我们总是应该努力编写可读的代码,而不仅仅是尝试快速编写它。我建议在本章末尾查看进一步阅读部分,那里有一些关于整洁代码的好参考资料。如果你对注释特别感兴趣,你会在其中一个 YouTube 视频中找到一个深入讨论这个主题的链接,这个视频是我关于这个主题的众多视频之一。

命令调用

是时候动手了!调用命令是 CMake 列表文件的核心。要执行一个命令,你必须提供其名称,后面跟着括号,其中你可以包含一个空格分隔的命令参数列表。

图 2.1 – 命令示例

图 2.1 – 命令示例

命令名称不区分大小写,但 CMake 社区有一个约定,在命令名称中使用蛇形大小写(即,使用下划线连接的小写字母单词)。你也可以定义自己的命令,这部分将在本章的理解 CMake 中的控制结构部分进行介绍。

与 C++ 相比,特别引人注目的是 CMake 中的命令调用不是表达式。你不能将另一个命令作为参数传递给被调用的命令,因为所有括号内的内容都被解释为该命令的参数。

更加令人沮丧的是,CMake 命令在调用结束时不需要分号。这可能是因为源代码的每一行可以包含多达一个命令调用,后面可以跟一个可选的单行注释。或者,整个行必须是括号注释的一部分。所以,这些是唯一允许的格式:

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

在括号注释后放置命令是不允许的:

[[ bracket 
]] command()

在删除任何注释、空格和空行之后,我们得到一个命令调用的列表。这创造了一个有趣的视角——CMake 语法真的很简单,但这是一件好事吗?我们是如何处理变量的?或者,我们是如何指导执行流程的?

CMake 提供了这些操作以及更多命令。为了简化事情,我们将随着不同示例的推进介绍相关命令,并将它们分为三个类别:

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

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

  • CTest 命令:这些命令在 CTest 脚本中可用,用于管理测试。

在本章中,我们将介绍最有用的脚本命令(因为它们在项目中也非常有用)。项目和 CTest 命令将在我们引入与构建目标相关的概念(第三章,设置你的第一个 CMake 项目)和测试框架(第八章,测试框架)时讨论。

几乎每个命令都依赖于语言的其他元素才能正常工作:变量、条件语句,以及最重要的,命令行参数。让我们看看应该如何使用这些元素。

命令参数

许多命令需要用空格分隔的参数来指定它们的行为方式。正如你在图 2.1中看到的,引号周围的参数有些奇怪。一些参数有引号,而其他参数没有——这是怎么回事?

在底层,CMake 唯一能识别的数据类型是一个字符串。这就是为什么每个命令都期望其参数为零个或多个字符串。但是,普通的、静态的字符串并不非常有用,尤其是当我们不能嵌套命令调用时。参数就派上用场了——CMake 将评估每个参数为静态字符串,然后将它们传递给命令。评估意味着字符串插值,或将字符串的一部分替换为另一个值。这可以意味着替换转义序列,展开变量引用(也称为变量插值),以及解包列表

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

  • 方括号参数

  • 引号参数

  • 未引用的参数

每种参数类型提供不同级别的评估,并且有一些小怪癖。

方括号参数

方括号参数不进行评估,因为它们用于将多行字符串作为单个参数传递给命令,而不做任何更改。这意味着它会包括制表符和换行符形式的空白。

这些参数的结构与注释完全一样——也就是说,它们以[=[开头,以]=]结尾,其中开头和结尾标记中的等号数量必须匹配(省略等号也是可以的,但它们仍然必须匹配)。与注释的区别在于,你不能嵌套方括号参数。

下面是使用此类参数与message()命令的一个例子,该命令将所有传递的参数打印到屏幕上:

chapter02/01-arguments/bracket.cmake

message([[multiline
  bracket
  argument
]])
message([==[
  because we used two equal-signs "=="
  following is still a single argument:
  { "petsArray" = [["mouse","cat"],["dog"]] }
]==])

在上面的例子中,我们可以看到不同形式的括号参数。第一个省略了等号。注意把闭合标签放在单独一行上,在输出中会显示为一个空行:

$ cmake -P chapter02/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++字符串相似之处的结束。引号参数可以跨越多行,并且它们将插值变量引用。可以认为它们内置了sprintf函数从std::format${name}。我们将在使用变量部分更多地讨论变量引用。

让我们尝试这些参数的实际应用:

chapter02/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 chapter02/01-arguments/quoted.cmake
1\. escape sequence: "
 in a quoted argument
2\. multi...
line
3\. and a variable reference: 3.16.3

没错——我们有一个转义的引号字符,一个转义的换行符,和一个字面的换行符。它们都将被打印在输出中。我们还访问了内置的CMAKE_VERSION变量,我们可以在最后一行正确地看到它被插值。

未引用的参数

在编程世界中,最后一种参数确实比较少见。我们习惯了字符串要以某种方式进行分隔,例如,使用单引号、双引号或反斜杠。CMake 与这个约定不符,并引入了未引用的参数。我们可能会认为省略分隔符可以使代码更容易阅读,就像省略分号一样。这是真的吗?我会让你自己形成自己的看法。

未引用的参数评估转义序列变量引用。然而,要小心分号(;),因为在 CMake 中,这被视为分隔符。CMake 会将包含它的参数拆分为多个参数。如果你需要使用它,用反斜杠(\;)转义它。这就是 CMake 如何管理列表的方式。我将在使用列表部分详细解释。

你可能会发现这些参数是最让人困惑的,所以这里有一个说明来帮助澄清这些参数是如何划分的:

图 2.2 – 转义序列导致分离的标记被解释为一个参数

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_2.2_B17205.jpg)

图 2.2 – 转义序列导致分离的标记被解释为一个参数

问题

为什么一个值作为单个参数传递或多个参数传递会有区别?一些 CMake 命令需要特定数量的参数,并忽略任何开销。如果你的参数不小心被分开了,你会得到难以调试的错误。

未引用的参数不能包含未转义的引号(")、散列(#)和反斜杠(\)。如果这些规则还不够难以记忆,圆括号(())只能在它们形成正确、匹配的对时使用。也就是说,你将以一个开放圆括号开始,并在关闭命令参数列表之前关闭它。

让我们看看上面所有规则的一些例子:

chapter02/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 chapter02/01-arguments/unquoted.cmake
a single argument
twoarguments
threeseparatedarguments
3.16.3
()()() 

即使是像 message() 这样的简单命令也非常在意分离的未引用参数:

  • a single argument 中的空格在显式转义时被正确打印。

  • 然而,twoargumentsthreeseparatearguments在一起,因为 message() 本身不会添加任何空格。

现在我们已经理解了如何处理 CMake 参数的复杂性和怪癖,我们准备迎接下一个有趣的话题——在 CMake 中处理各种变量

在 CMake 中处理变量

CMake 中的变量是一个相当复杂的话题。不仅变量分为三种类别——普通、缓存和环境变量,而且它们还存在于不同的作用域中,有着特定的一套规则,一个作用域如何影响另一个作用域。在大多数情况下,对这些规则的误解成为错误和头痛的来源。我建议你仔细学习这一部分,并确保在继续之前理解了所有概念。

让我们先了解一些关于 CMake 变量的关键事实:

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

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

  • CMake 的基本变量操作命令是 set()unset(),但还有其他可以影响变量的命令,如 string()list()

要设置一个变量,我们只需调用 set(),提供其名称和值:

chapter02/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 命令的名称。

注意

set()命令接受一个普通文本变量名作为其第一个参数,但message()命令使用的是用${}语法包裹的变量引用。如果我们向set()命令提供一个用${}语法包裹的变量,会发生什么?为了回答这个问题,我们需要更好地理解变量引用。

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

变量引用

我已经在命令参数部分简要提到了引用,因为它们对带引号和不带引号的参数进行评估。我们还了解到,要创建一个对已定义变量的引用,我们需要使用${}语法,如下所示:message(${MyString1})

在评估时,CMake 将遍历作用域堆栈(我稍后会解释)并将${MyString1}替换为一个值,如果没有找到变量,则替换为一个空字符串(CMake 不会生成任何错误消息)。这个过程被称为变量评估展开插值

这样的插值是逆向进行的。这意味着两件事:

  • 如果遇到以下引用——${MyOuter${MyInner}}——CMake 将首先尝试评估MyInner,而不是搜索名为MyOuter${MyInner}的变量。

  • 如果MyInner变量成功展开,CMake 将重复展开过程,直到无法进一步展开为止。

让我们考虑以下变量的示例:

  • MyInner带有Hello

  • MyOuter带有${My

如果我们调用message("${MyOuter}Inner} World")命令,我们将收到Hello World的输出,这是因为${MyOuter}被替换为字面值${My,与顶级值Inner}结合,创建了另一个变量引用——${MyInner}

CMake 将进行这种展开到最大限度,然后才将结果值作为参数传递给命令。这就是为什么我们调用set(${MyInner} "Hi")时,我们实际上并没有改变MyInner变量,而是改变了Hello变量。CMake 展开${MyInner}Hello,并将该字符串作为set()命令的第一个参数,并传递一个新的值,Hi。通常,这并不是我们想要的结果。

变量引用在变量类别方面的工作方式有些奇特,但总的来说,以下内容适用:

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

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

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

没错,使用${},你可能会从一个类别或另一个类别中获取一个值。我将在如何在 CMake 中正确使用变量作用域部分解释这一点,但首先,让我们介绍一下其他类别的变量,以便我们清楚地了解它们是什么。

注意

请记住,您可以在--标记之后通过命令行向脚本传递参数。值将存储在CMAKE_ARGV<n>变量中,传递的参数数量将在CMAKE_ARGC变量中。

使用环境变量

这是最简单的变量类型。CMake 会复制启动cmake过程时使用的环境中的变量,并使它们在一个单独的全局作用域中可用。要引用这些变量,请使用$ENV{<name>}语法。

CMake 还允许您设置(set())和取消设置(unset())这些变量,但更改只会对运行中的cmake过程中的本地副本进行修改,而不会对实际系统环境进行修改;此外,这些更改不会对后续的构建或测试运行可见。

要修改或创建一个变量,请使用set(ENV{<variable>} <value>)命令,如下所示:

set(ENV{CXX} "clang++")

要清除一个环境变量,请使用unset(ENV{<variable>}),如下所示:

unset(ENV{VERBOSE})

请注意,有几个环境变量会影响 CMake 行为的不同方面。CXX变量是其中的一个——它指定了用于编译 C++文件的执行文件。我们将在本书中覆盖其他环境变量,因为它们将变得相关。完整的列表可以在文档中找到:

cmake.org/cmake/help/latest/manual/cmake-env-variables.7.html

如果您将ENV变量作为命令的参数,它们的值将在构建系统的生成过程中进行插值。这意味着它们将被编织进构建树中,更改构建阶段的环境将没有任何效果。

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

chapter02/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 脚本测试会发生什么,该脚本在配置阶段使用一个值,在构建阶段使用另一个值:

chapter02/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文件中的持久变量。它们包含在项目配置阶段收集的信息,既有来自系统的(编译器、链接器、工具等的路径),也有通过CMakeCache.txt文件来自用户的——它们只存在于项目中

缓存变量可以用$CACHE{<name>}语法引用。

要设置一个缓存变量,请使用以下语法使用set()

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

正如你所见,有一些新的必需参数(与正常变量的 set() 命令相比),它还引入了第一个关键字:CACHEFORCE

CACHE 指定为 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 的当前执行中覆盖。看这个例子:

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

如果变量在缓存中存在,上述调用将没有永久效果。然而,如果值不在缓存中或者指定了可选的 FORCE 参数,该值将被持久化:

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

设置缓存变量有一些不明显的含义。也就是说,任何同名的正常变量都会被移除。我们在下一节中找出原因。

作为提醒,缓存变量也可以从命令行管理(查看 第一章CMake 的第一步 中的适当部分)。

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

变量作用域 可能是整个 CMake 语言概念中最难的部分。这可能是因为我们习惯于在支持命名空间和作用域操作符的更高级语言中是如何处理事情的。CMake 没有这些机制,所以它以自己 somewhat unusual 的方式处理这个问题。

为了澄清,作用域作为一个一般概念是为了将不同层次的抽象分离,以便当调用一个用户定义的函数时,函数中设置的变量是局部的。这些局部变量即使与全局变量的名称完全相同,也不会影响全局作用域。如果明确需要,函数应该对全局变量也有读/写权限。这种变量的分离(或作用域)必须在多个层面上工作——当一个函数调用另一个函数时,相同的分离规则适用。

CMake 有两个作用域:

  • 执行function()

  • 在嵌套目录中执行的CMakeLists.txt清单文件从add_subdirectory()命令开始

我们将在本书稍后介绍前面的命令,但首先,我们需要了解作用域概念是如何实现的。当创建一个嵌套作用域时,CMake 简单地用当前作用域的所有变量的副本填充它。随后的命令将影响这些副本。但一旦嵌套作用域的执行完成,所有的副本都被删除,并恢复原始的父作用域。

让我们考虑以下场景:

  1. 父作用域将VAR变量设置为ONE

  2. 嵌套作用域开始,并将VAR打印到控制台。

  3. VAR变量被设置为TWO,并将VAR打印到控制台。

  4. 嵌套作用域结束,并将VAR打印到控制台。

控制台的输出将如下所示:ONETWOONE。这是因为嵌套作用域结束后,复制的VAR变量被丢弃。

在 CMake 中作用域的概念如何工作有着有趣的暗示,这在其他语言中并不常见。如果你在一个嵌套作用域中执行时取消设置(unset())了在父作用域中创建的变量,它将消失,但仅在嵌套作用域中。当嵌套作用域完成后,变量会恢复到其原来的值。

这让我们想到了变量引用和${}语法的的行为。无论何时我们尝试访问普通变量,CMake 都会从当前作用域搜索变量,如果定义了这样一个名字的变量,它会返回它的值。到目前为止,一切都好。然而,当 CMake 找不到这个名字的变量(例如,如果它不存在或被取消设置(unset())),它将搜索缓存变量,并在找到匹配项时返回那里的值。

如果在嵌套作用域中调用unset(),这可能是一个潜在的陷阱。取决于我们引用那个变量的位置——在内部还是外部作用域——我们将访问缓存或原始值。

但如果我们真的需要在调用(父)作用域中更改变量,该怎么办呢?CMake 有一个PARENT_SCOPE标志,你可以在set()unset()命令的末尾添加:

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

这个变通方法有点局限,因为它不允许访问超过一级的变量。值得注意的是,使用PARENT_SCOPE并不会改变当前作用域中的变量。

让我们看看实际中变量作用域是如何工作的,并考虑以下示例:

chapter02/04-scope/CMakeLists.txt

function(Inner)
  message("  > Inner: ${V}")
  set(V 3)
  message("  < Inner: ${V}")
endfunction()
function(Outer)
  message(" > Outer: ${V}")
  set(V 2)
  Inner()
  message(" < Outer: ${V}")
endfunction()
set(V 1)
message("> Global: ${V}")
Outer()
message("< Global: ${V}")

我们将全局变量V设置为1,然后调用Outer函数;然后将V设置为2并调用Inner函数,然后将V设置为3。在每一步之后,我们都将变量打印到控制台:

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

如我们之前解释的,当我们深入函数时,变量值会被复制到嵌套作用域中,但当我们退出作用域时,它们的原始值会被恢复。

如果我们更改Inner函数的set()命令以在父级作用域中操作:set(V 3 PARENT_SCOPE),输出会是什么?

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

我们影响了Outer函数的作用域,但没有影响Inner函数或全局作用域!

正如 CMake 文档中提到的,CMake 脚本在单个目录作用域中绑定变量(这有点冗余,因为唯一有效地创建目录作用域的命令,add_subdirectory(),在脚本中不允许)。

由于所有变量都存储为字符串,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 语言中,控制结构是不可或缺的!与 everything else 一样,它们以命令的形式提供,并分为三个类别:条件块循环命令定义。控制结构在脚本中执行,在项目构建系统生成期间也执行。

条件块

CMake 支持的唯一条件块是谦逊的if()命令。所有条件块都必须用endif()命令关闭,它们可能有任意数量的elseif()命令和一个可选的else()命令,顺序如下:

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

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

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

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

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

  • 如果上述条件都不满足,执行将继续endif()命令之后进行。

提供的<条件>表达式根据一个非常简单的语法进行评估。

条件命令的语法

对于if()elseif()while()命令,相同的语法是有效的。

逻辑运算符

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

  • NOT <条件>

  • <条件> AND <条件>

  • <条件> OR <条件>

此外,条件的嵌套是可能的,匹配的括号对(())。像所有体面的语言一样,CMake 语言尊重评估的顺序,并从最内层的括号开始:

  • (<条件>) AND (<条件> OR (<条件>))

字符串和变量的评估

由于历史原因(因为变量引用(${})语法并非一直存在),CMake 会尝试将未引用的参数评估为变量引用。换句话说,在条件中使用普通的变量名(例如,VAR)等同于写${VAR}。下面是一个供你考虑的示例和一个陷阱

set(VAR1 FALSE)
set(VAR2 "VAR1")
if(${VAR2})

if()条件在这里有点复杂——首先,它会评估${VAR2}VAR1,这是一个已知的变量,进而将其评估为FALSE字符串。字符串只有等于以下常量之一时才被认为是布尔真(这些比较不区分大小写):

  • ONYYESTRUE

  • 非零数字

这使我们得出结论,前一个示例中的条件将评估为假。

然而,这里还有一个陷阱——一个未引用的参数的条件评估会怎样,这个参数的名称包含一个值为BAR的变量呢?考虑以下代码示例:

set(FOO BAR)
if(FOO)

根据我们迄今为止的说法,这将是false,因为BAR字符串不符合评估为布尔true值的准则。不幸的是,这不是事实,因为 CMake 在未引用的变量引用方面做出了例外。与引号引用的参数不同,FOO不会被评估为BAR以产生if("BAR")语句(这将是false)。相反,CMake 只会评估if(FOO)false,如果它是以下常量之一(这些比较不区分大小写):

  • OFFNOFALSENIGNORENOTFOUND

  • -NOTFOUND结尾的字符串

  • 空字符串

所以,简单地询问一个未定义的变量将被评估为false

if (FOO)

然而,事先定义一个变量会改变情况,条件被评估为true

set(FOO "FOO")
if (FOO)

注意

如果你认为未引用的参数的行为令人困惑,请将变量引用用引号引起来:if ("${FOO}")。这将导致在提供的参数传递到if()命令之前先评估参数,行为将与字符串的评估一致。

换句话说,CMake 假设用户询问变量是否定义(并且不是显式false)。幸运的是,我们可以明确地检查这一点(而不必担心内部值):

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

比较值

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

EQUALLESSLESS_EQUALGREATER,和GREATER_EQUAL

它们可以用来比较数字值,如下所示:

if (1 LESS 2) 

注意

根据 CMake 文档,如果操作数之一不是数字,值将是false。但实际实验表明,以数字开头的字符串比较工作正确:if (20 EQUALS "20 GB")

你可以通过给任何操作符添加VERSION_前缀,按照major[.minor[.patch[.tweak]]]格式比较软件版本:

if (1.3.4 VERSION_LESS_EQUAL 1.4)

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

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

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

我们经常需要比简单相等比较更高级的机制。幸运的是,CMake 也支持MATCHES操作符,如下所示:

<变量名|字符串> MATCHES <正则表达式>

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

简单检查

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

我们可以检查以下内容:

  • 如果值在列表中:<变量名|字符串> IN_LIST <变量名>

  • 如果一个命令可以被调用:COMMAND <命令名>

  • 如果存在 CMake 策略:POLICY <策略 ID>(这在第三章中有介绍,设置你的第一个 CMake 项目)

  • 如果一个 CTest 测试是用add_test()添加的:TEST <测试名称>

  • 如果定义了一个构建目标:TARGET <目标名称>

我们将在第四章,《使用目标》中探索构建目标,但现在,让我们说目标是为项目创建的逻辑构建过程中的单位,该项目已经调用了add_executable()add_library()add_custom_target()命令。

检查文件系统

CMake 提供了许多处理文件的方法。我们很少需要直接操作它们,通常我们更愿意使用高层次的方法。为了参考,本书将在附录部分提供一个简短的与文件相关的命令列表。但大多数时候,只需要以下操作符(对于绝对路径的行为定义得很清楚):

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

这解决了符号链接(如果符号链接的目标存在,则返回true)。

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

如果file1比(或等于)file2新,或者这两个文件中的一个不存在,则返回true

  • IS_DIRECTORY path-to-directory:检查一个路径是否是目录

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

  • IS_ABSOLUTE path:检查一个路径是否是绝对的

循环

CMake 中的循环相当直接——我们可以使用while()foreach()来反复执行相同的命令集。这两个命令都支持循环控制机制:

  • break()循环停止剩余块的执行,并从外层循环中断。

  • continue()循环停止当前迭代的执行,并从下一个开始。

循环块用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>(包括)。如果我们需要更多的控制,可以使用第二种变体,提供<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>中,一次执行<commands>中的每一个项目。你可以选择只提供列表、只提供值,或者两者都提供:

chapter02/06-loops/foreach.cmake

set(MY_LIST 1 2 3)
foreach(VAR IN LISTS MY_LIST 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>)

压缩列表意味着简单地遍历多个列表,并对具有相同索引的相应项目进行操作。让我们看一个例子:

chapter02/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("num_0=${num_0}, num_1=${num_1}")
endforeach()

CMake 将为每个提供的列表创建一个 num_<N> 变量,并填充每个列表的项目。您可以传递多个 <loop_var> 变量名(每个列表一个),每个列表将使用单独的变量存储其项目:

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

如果列表之间的项数不同,CMake 不会为较短的列表定义变量。

所以,关于循环的内容就讲到这里。

命令定义

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

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

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

这两种方法都接受可以在命令块内部命名和引用的参数。此外,CMake 允许您使用以下引用访问在命令调用中传递的参数:

  • ${ARGC}:参数的数量

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

  • ${ARG0}${ARG1}${ARG2}:特定索引处的参数值

  • ${ARGN}: 传递给调用者的一些匿名参数,在最后一个预期参数之后

使用索引访问超出 ARGC 范围的数字参数是未定义行为。

如果您决定定义一个带有命名参数的命令,每个调用都必须传递它们全部,否则它将是无效的。

定义宏与其他块类似:

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

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

以下示例突出了宏中变量作用域的所有问题:

chapter02/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 chapter02/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()

一个函数需要一个名称,可选地接受一个预期参数的名称列表。如果函数调用传递的参数比声明的参数多,多余的参数将被解释为匿名参数并存储在 ARGN 变量中。

如前所述,函数打开它们自己的作用域。你可以调用 set(),提供函数的一个命名参数,任何更改都仅限于函数内部(除非指定了 PARENT_SCOPE,正如我们在 如何在 CMake 中正确使用变量作用域 部分讨论的那样)。

函数遵循调用栈的规则,通过 return() 命令返回调用作用域。

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

  • CMAKE_CURRENT_FUNCTION

  • CMAKE_CURRENT_FUNCTION_LIST_DIR

  • CMAKE_CURRENT_FUNCTION_LIST_FILE

  • CMAKE_CURRENT_FUNCTION_LIST_LINE

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

chapter02/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}")

这会打印出以下输出:

Function: MyFunction
File: /root/examples/chapter02/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 – 过程式调用图

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_2.3_B17205.jpg)

图 2.3 – 过程式调用图

在 CMake 中以这种过程式风格编写代码有点问题——你被迫提供你计划使用的命令定义。CMake 解析器别无选择。你的代码可能看起来像这样:

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

多么噩梦般的场景!一切都被颠覆了!这段代码非常难以阅读,因为最微小的细节都放在了文件的最顶部。一段正确结构的代码首先在第一个子程序中列出最一般的步骤,然后提供稍微详细一些的子程序,并将最详细的步骤推到最后一个文件。

这个问题有解决方案:将命令定义移动到其他文件并将作用域分区到目录之间(将在 第三章 中详细解释,设置你的第一个 CMake 项目)。但还有一个简单而优雅的解决方案:在文件顶部声明一个入口点宏,并在文件的最后调用它:

macro(main)
function(...) # key steps
function(...) # details
function(...) # fine details
main()

采用这种方法,我们的代码是以逐渐缩小的范围编写的,并且因为我们实际上直到最后才调用main()宏,所以 CMake 不会抱怨未定义命令的执行!

最后一个问题依然存在——为什么要在宏上而不是推荐函数上使用?在这种情况下,无限制访问全局变量是好的,由于我们没有向main()传递任何参数,所以我们不需要担心常见的警告。

你可以在本书 GitHub 仓库中的chapter-02/09-procedural/CMakeLists.txt清单文件中找到这个概念的一个简单示例。

关于命名约定的一点说明

在软件开发中,命名是以著称困难的,尽管如此,维持一个易于阅读和理解解决方案仍然非常重要。在 CMake 脚本和项目方面,我们应该遵循干净代码方法的规则,就像在任何一个软件开发解决方案方面一样:

  • 遵循一致的命名风格(在 CMake 社区中,snake_case是被接受的标准化风格)。

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

  • 避免在你的命名中使用双关语和机智。

  • 使用可以发音、可搜索的名称,不需要进行心智映射。

既然我们已经知道如何正确地使用正确的语法调用命令,那么让我们来探讨哪些命令最初对我们最有益。

有用命令

CMake 提供了许多脚本命令,允许你与变量和环境交互。其中一些在附录部分有广泛的覆盖,例如list()string()file()(我们在这里解释这些命令,并在主章节集中精力于项目)。其他的,如find_...(),更适合在讨论管理依赖的章节中。在本节中,我们将简要介绍对脚本最有用的命令。

message()命令

我们已经知道并喜欢我们可靠的message()命令,它将文本打印到标准输出。然而,它的内涵远不止所见。通过提供一个MODE参数,你可以自定义输出的样式,在出错的情况下,你可以停止代码的执行:message(<MODE> "text")

已识别的模式如下:

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

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

  • WARNING:这会继续处理。

  • AUTHOR_WARNING:CMake 警告。这会继续处理。

  • DEPRECATION:如果启用了CMAKE_ERROR_DEPRECATEDCMAKE_WARN_DEPRECATED变量,这将相应地工作。

  • NOTICE或省略模式(默认):这会在stderr上打印一条消息,以吸引用户的注意。

  • STATUS:这会继续处理,并且建议用于主要用户信息。

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

  • DEBUG:这会继续处理,并且应该包含在项目出现问题时可能有助于解决问题的任何详细信息。

  • TRACE:这会继续处理,并且在项目开发期间推荐打印消息。通常,这类消息在发布项目之前会被删除。

以下示例在第一条消息后停止执行:

chapter02/10-useful/message_error.cmake

message(FATAL_ERROR "Stop processing")
message("Won't print this.")

这意味着将根据当前日志级别(默认是STATUS)打印消息。我们在上一章的调试和跟踪选项部分讨论了如何更改此设置。在那部分,我承诺要谈论使用CMAKE_MESSAGE_CONTEXT进行调试,所以让我们开始吧。从那时起,我们已经了解了这个谜题的三个重要部分:列表、作用域和函数。

当我们启用一个命令行标志,cmake --log-context,我们的消息将被点分隔的上下文装饰,并存储在CMAKE_MESSAGE_CONTEXT列表中。考虑以下示例:

chapter02/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`")

前面脚本的输出将如下所示:

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

函数的初始作用域是从父作用域中复制的(父作用域中已经有一个列表项:top)。foo中的第一条命令向CMAKE_MESSAGE_CONTEXT中添加了一个新项,该项带有foo函数名称。打印消息,函数作用域结束,丢弃本地复制的变量,并恢复之前的范围(不包含foo)。

这种方法在非常复杂的项目中有很多嵌套函数时非常有用。希望您永远不需要它,但我觉得这是一个非常好的例子,展示了函数作用域在实际中是如何工作的。

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

list(APPEND CMAKE_MESSAGE_INDENT "  ")

我们脚本的输出可以变得更加整洁:

Before `foo`
  foo message
After `foo`

由于 CMake 没有提供任何真正的带有断点或其他工具的调试器,因此在事情并不完全按计划进行时,生成干净的日志消息功能非常方便。

include()命令

我们可以将 CMake 代码分割到单独的文件中,以保持事物有序,嗯,分离。然后,我们可以通过调用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的文件,然后在其模块目录中搜索。

include_guard()命令

当我们包含具有副作用的文件时,我们可能希望限制它们,使它们只被包含一次。这就是include_guard([DIRECTORY|GLOBAL])发挥作用的地方。

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

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

file()命令

为了让您了解您可以用 CMake 脚本做什么,让我们快速浏览一下文件操作命令的最有用变体:

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 <命令> <参数>参数来链接命令,并将一个的输出传递给另一个。

可选地,您可以使用TIMEOUT <秒>参数来终止进程,如果它没有在规定的时间内完成任务,并且您可以根据需要设置WORKING_DIRECTORY <目录>

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

为了收集输出,CMake 提供了两个参数:OUTPUT_VARIABLEERROR_VARIABLE(这两个参数用法相似)。如果您想合并stdoutstderr,请为这两个参数使用同一个变量。

记住,当为其他用户编写项目时,您应该确保您打算使用的命令在您声称支持的平台上是可用的。

总结

本章打开了使用 CMake 进行实际编程的大门——你现在能够编写伟大的、富有信息性的注释和调用内置命令,并理解如何正确地为它们提供各种参数。这个知识本身将帮助您理解 CMake 列表文件中您可能在其他项目中看到的异常语法。

接下来,我们讲解了 CMake 中的变量——具体来说,是如何引用、设置和取消设置普通、缓存和环境变量。我们深入探讨了目录和函数作用域是如何工作的,并讨论了与嵌套作用域相关的问题(及其解决方法)。

我们还讲解了列表和控制结构。我们讨论了条件的语法、它们的逻辑操作、未引用参数的评估以及字符串和变量。我们学习了如何比较值、进行简单检查以及查看系统文件的状态。这使我们能够编写条件块和 while 循环。在谈论循环的时候,我们也掌握了 foreach 循环的语法。

我相信了解如何使用宏和函数语句定义自己的命令将帮助您以更程序化的风格编写更干净的代码。我们还分享了一些关于如何更好地组织代码和提出更易读名称的想法。

最后,我们正式介绍了message()命令及其多个日志级别。我们还学习了如何分割和包含列表文件,发现了几种其他有用的命令。我相信有了这些材料,我们准备好迎接下一章,并在 CMake 中编写我们的第一个项目。

进一步阅读

关于本章涵盖的主题,您可以参考以下内容:

第三章:设置你的第一个 CMake 项目

现在我们已经收集了足够的信息,可以开始讨论 CMake 的核心功能:构建项目。在 CMake 中,一个项目包含管理将我们的解决方案带入生活的所有源文件和配置。配置从执行所有检查开始:目标平台是否受支持,是否拥有所有必要的依赖项和工具,以及提供的编译器是否工作并支持所需功能。

完成后,CMake 将为我们的选择构建工具生成一个构建系统并运行它。源文件将与彼此以及它们的依赖项编译和链接,以产生输出工件。

项目可以由一组开发人员内部使用,产生用户可以通过包管理器在其系统上安装的包,或者它们可以用来提供单执行文件安装器。项目还可以在开源存储库中分享,以便用户可以使用 CMake 在他们的机器上编译项目并直接安装它们。

充分利用 CMake 项目将改善开发体验和生成代码的质量,因为我们可以自动化许多单调的任务,例如在构建后运行测试,检查代码覆盖率,格式化代码,以及使用 linters 和其他工具检查源代码。

为了充分发挥 CMake 项目的力量,我们首先要了解一些关键决策——这些决策是如何正确配置整个项目以及如何划分项目和设置源代码树,以便所有文件都整齐地组织在正确的目录中。

然后,我们将学习如何查询项目构建的环境——例如,它的架构是什么?有哪些工具可用?它们支持哪些功能?并使用的是哪种语言标准?最后,我们将学习如何编译一个测试C++文件,以验证所选编译器是否满足我们项目中设定的标准要求。

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

  • 基本指令和命令

  • 如何划分你的项目

  • 思考项目结构

  • 作用域环境

  • 配置工具链

  • 禁用源代码内构建

技术要求

你可以在 GitHub 上找到本章中出现的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter03

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

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 语言中的 Hello world

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

在同一章节中,在项目文件部分,我们了解了一些基本命令。让我们深入解释一下。

指定最小的 CMake 版本——cmake_minimum_required()

这并不是一个严格的项目特定命令,因为它也应该用于脚本,但我们在这里重复它是因为它非常重要。正如你所知,cmake_minimum_required()将检查系统是否有正确的 CMake 版本,但隐式地,它还会调用另一个命令,cmake_policy(VERSION),这将告诉 CMake 对于这个项目应该使用哪些正确的策略。这些策略是什么?

在 CMake 发展的过去 20 年中,随着 CMake 及其支持的语言的发展,命令的行为发生了许多变化。为了保持语法简洁明了,CMake 团队决定引入策略来反映这些变化。每当引入一个向后不兼容的更改时,它都会附带一个策略,启用新的行为。

通过调用cmake_minimum_required(),我们告诉 CMake 需要应用到提供参数中的版本的策略。当 CMake 通过新的策略升级时,我们不需要担心它们会破坏我们的项目,因为新策略不会被启用。如果我们用最新版本测试项目并且结果令我们满意,我们可以把更新后的项目发送给我们的用户。

策略可以影响 CMake 的每一个方面,包括其他重要命令如project()。因此,很重要的一点是,你要在CMakeLists.txt文件开始时设定你正在使用的版本。否则,你会收到警告和错误。

每个版本都引入了许多策略——除非你正在将旧项目升级到最新的 CMake 版本遇到问题,否则描述它们并没有真正的价值。在这种情况下,请参考官方文档中的策略:cmake.org/cmake/help/latest/manual/cmake-policies.7.html

定义语言和元数据——project()

从技术上讲,CMake 不需要project()命令。任何包含CMakeLists.txt文件的目录都会以项目模式被解析。CMake 隐式地在文件顶部添加了这个命令。但我们已经知道我们需要首先指定最小版本,所以最好不要忘记调用project()。我们可以使用它的两种形式之一:

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(仅在最顶层的CMakeLists.txt中)

  • PROJECT_SOURCE_DIR<PROJECT-NAME>_SOURCE_DIR

  • PROJECT_BINARY_DIR<PROJECT-NAME>_BINARY_DIR

支持哪些语言?很多。以下是您可以用来配置项目的语言关键词列表:CCXX(C++)、CUDAOBJC(Objective-C)、OBJCXX(Objective C++)、FortranISPCASM,以及CSharp(C#)和Java

CMake 默认支持 C 和 C++,所以您可能只想明确指定CXX用于您的 C++项目。为什么?project()命令将检测和测试您选择的可用编译器,所以选择正确的编译器将使您在配置阶段节省时间,通过跳过任何对未使用语言的检查。

指定VERSION将使以下变量可用:

  • PROJECT_VERSION<PROJECT-NAME>_VERSION

  • CMAKE_PROJECT_VERSION(仅在顶级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,这将以相同的方式设置变量。

CMake 还允许通过enable_language(<lang>)指定使用的语言,这将不会创建任何元数据变量。

这些命令将允许我们创建一个基本的列表文件并初始化一个空项目。现在,我们可以开始添加东西来构建。对于迄今为止我们所用的例子中的微小单文件项目,结构确实不太重要。但是当代码量增加时会发生什么?

划分您的项目

随着我们的解决方案在行数和文件数量上的增长,我们逐渐意识到不可避免的事情即将发生:要么我们开始分区项目,要么我们淹没在代码行和众多文件中。我们可以用两种方法来解决这个问题:通过分区 CMake 代码,或将源文件移动到子目录中。在这两种情况下,我们都旨在遵循一个称为关注点分离的设计原则。简单来说,就是将代码分成块,将具有紧密相关功能的代码分组,同时将其他代码片段解耦,以创建强大的边界。

在第一章《CMake 的初步步骤》中讨论列表文件时,我们稍微提到了分区 CMake 代码。我们讨论了include()命令,该命令允许 CMake 执行来自外部文件的代码。调用include()不会引入任何未在文件中定义的作用域或隔离(如果包含的文件包含函数,那么在调用时它们的作用域将会被正确处理)。

这种方法有助于关注点的分离,但效果有限——专用代码被提取到单独的文件中,甚至可以跨不相关的项目共享,但如果作者不小心,它仍然可能会用其内部逻辑污染全局变量作用域。编程中的一个古老真理是,即使是最糟糕的机制也比最好的意图好。我们将在后面学习如何解决这个问题,但现在,让我们将重点转移到源代码上。

让我们考虑一个支持小型汽车租赁公司的软件示例——它将有很多源文件,定义软件的不同方面:管理客户、车辆、停车位、长期合同、维护记录、员工记录等等。如果我们把这些文件都放在一个单一的目录中,找到任何东西都将是一场噩梦。因此,我们在项目的主目录中创建多个目录,并将相关文件移入其中。我们的CMakeLists.txt文件可能看起来像这样:

第三章/01-partition/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(Rental CXX)
add_executable(Rental
               main.cpp
               cars/car.cpp  
               # more files in other directories 
)

这很好,但正如您所看到的,我们仍然在顶层文件中包含了嵌套目录的源文件列表!为了增加关注点的分离,我们可以将源文件列表放在另一个列表文件中,并使用前述的include()命令和cars_sources变量,像这样:

第三章/02-include/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(Rental CXX)
include(cars/cars.cmake)
add_executable(Rental
               main.cpp
               ${cars_sources}
               # ${more variables}
)

新的嵌套列表文件将包含以下源文件:

第三章/02-include/cars/cars.cmake

set(cars_sources
    cars/car.cpp
#   cars/car_maintenance.cpp
)

CMake 将有效地在add_executable相同的范围内设置cars_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关键字将禁用子目录中定义的目标的默认构建(我们将在下一章讨论目标)。这对于分离不需要核心功能的项目的部分(例如示例扩展)可能很有用。

此命令将在source_dir路径(相对于当前目录评估)中寻找一个CMakeLists.txt文件。然后在该目录作用域中解析该文件,意味着前面方法中提到的所有缺陷都不存在:

  • 变量更改被限制在嵌套作用域内。

  • 您可以自由地以任何喜欢的方式配置嵌套的艺术品。

  • 更改嵌套的CMakeLists.txt文件不需要构建无关的目标。

  • 路径仅限于目录本地,如果需要,它们甚至可以添加到父级包含路径中。

让我们来看一个使用add_subdirectory()的项目:

chapter03/03-add_subdirectory# tree -A
.
├── CMakeLists.txt
├── cars
│   ├── CMakeLists.txt
│   ├── car.cpp
│   └── car.h
└── main.cpp

这里,我们有两个CMakeLists.txt文件。顶层文件将使用嵌套目录cars

chapter03/02-add_subdirectory/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(Rental CXX)
add_executable(Rental main.cpp)
add_subdirectory(cars)
target_link_libraries(Rental PRIVATE cars)

最后一行用于将来自cars目录的艺术品链接到Rental可执行文件。这是一个目标特定的命令,我们将在下一章深入讨论。让我们看看嵌套列表文件看起来如何:

chapter03/02-add_subdirectory/cars/CMakeLists.txt

add_library(cars OBJECT
    car.cpp
#   car_maintenance.cpp
)
target_include_directories(cars PUBLIC .)

正如你所看到的,我使用add_library()来生成一个全局可见的目标cars,并使用target_include_directories()将其添加到其公共包含目录中。这允许main.cpp不提供相对路径即可包含cars.h文件:

#include "car.h"

我们可以在嵌套列表文件中看到add_library()命令,所以我们是在这个例子中开始使用库了吗?实际上,不是。由于我们使用了OBJECT关键字,我们表明我们只对生成对象文件感兴趣(与上一个例子完全一样)。我们只是将它们分组在一个逻辑目标(cars)下。您可能已经对目标有一个大致的了解。暂时保留那个想法——我们马上就会讨论到。

嵌套项目

在上一节中,我们简要提到了 add_subdirectory() 命令中使用的 EXCLUDE_FROM_ALL 参数。CMake 文档建议,如果我们有这样的部分存在于源树中,它们在自己的 CMakeLists.txt 文件中应该有自己的 project() 命令,这样它们就可以生成自己的构建系统,并且可以独立构建。

还有其他哪些场景会用到这个功能呢?当然。例如,一个场景是当你在一个 CI/CD 管道中构建多个 C++ 项目(也许是在构建框架或一系列库时)。另一种情况可能是,你可能正在从遗留解决方案(如 GNU Make)中移植构建系统,该解决方案使用普通的 makefiles。在这种情况下,你可能需要一个选项,逐步将事物分解成更独立的单元——可能要把它们放在单独的构建管道中,或者只是为了在一个更小的范围内工作,这可以被如 CLion 这样的 IDE 加载。

你可以通过在嵌套目录中的 listfile 添加 project() 命令来实现。只是不要忘记用 cmake_minimum_required() 它前缀。

由于支持项目嵌套,我们能否 somehow 连接并排构建的相关项目?

外部项目

技术上可以从一个项目到达另一个项目,CMake 也在一定程度上支持这一点。甚至还有一个 load_cache() 命令,允许你从另一个项目的缓存中加载值。话说回来,这并不是一个常规或推荐的使用场景,它会导致循环依赖和项目耦合的问题。最好避免使用这个命令,并做出决定:我们的相关项目应该嵌套、通过库连接,还是合并成一个项目?

这些是我们可用的分区工具:包括 listfiles添加子目录嵌套项目。但我们应该如何使用它们,使我们的项目保持可维护性、易于导航和扩展?为了实现这一点,我们需要一个定义良好的项目结构。

思考项目结构

众所周知,随着项目增长,在 listfiles 和源代码中找到东西变得越来越难。因此,从一开始就保持项目卫生非常重要。

想象一个场景,你需要交付一些重要、时间敏感的更改,它们不适合你的项目中的两个目录之一。现在,你需要快速推送一个 cleanup commit ,引入更多的目录和另一层文件层次结构,以便你的更改有一个好的地方放置。或者(更糟糕的是),你决定只是把它们推到任何地方,并创建一个票证稍后处理问题。

在整个一年中,这些工单积累,技术债务增长,维护代码的成本也在增加。当需要快速修复现场系统的严重错误,且不熟悉代码库的人需要引入他们的更改时,这变得极其麻烦。

所以,我们需要一个好的项目结构。但这意味着什么?我们可以从软件开发的其他领域(例如,系统设计)借鉴几条规则。项目应该具有以下特征:

  • 它应该易于导航和扩展。

  • 它应该是自包含的——例如,项目特定的文件应该在项目目录中,而不应该在其他地方。

  • 抽象层次应该通过可执行文件和二进制文件来表达。

没有一种单一公认的解决方案,但在网上可用的众多项目结构模板中,我建议遵循这个模板,因为它简单且非常可扩展:

图 3.1 – 项目结构示例

图 3.1 – 项目结构示例

这个项目概述了以下组件的目录结构:

  • cmake:包括宏和函数、find_modules 以及一次性脚本

  • src:将存储我们的二进制文件和库的源代码

  • doc:用于构建文档

  • extern:我们从中源代码构建的外部项目的配置

  • test:包含自动化测试的代码

在这种结构中,CMakeLists.txt 文件应该存在于以下目录中:顶级项目目录、srcdocexterntest。主列表文件不应该声明任何自身的构建步骤,而是应该使用 add_subdirectory() 命令来执行嵌套目录中的所有列表文件。如果有需要,这些还可以将这项工作委托给更深层次的目录。

注意

一些开发者建议将可执行文件与库分开,创建两个顶级目录(srclib),而不是一个。CMake 将这两种工件同等对待,在这种层次上进行分离并不真正重要。

src 目录中有多个目录对于大型项目来说非常有用。但如果你只是构建一个可执行文件或库,你可以跳过它们,并将源文件直接存储在 src 中。无论如何,记得在那里添加一个 CMakeLists.txt 文件,并执行任何嵌套的列表文件。

你的目标文件树可能看起来是这样的:

图 3.2 – 可执行文件的目录结构

图 3.2 – 可执行文件的目录结构

我们在app1目录的根目录中看到一个CMakeLists.txt文件——它将配置关键的项目设置并包括嵌套目录中的所有列表文件。src目录包含另一个CMakeLists.txt文件以及.cpp实现文件:两个类和带有可执行程序入口点的主文件。CMakeLists.txt文件应该定义一个目标,使用这些源文件构建一个可执行文件——我们将在下一章学习如何做到这一点。

我们的头文件放在include目录中——这些文件被.cpp实现文件用来声明来自其他 C++翻译单元的符号。

我们有一个test目录来存储自动化测试的源代码,我们还有lib3,它包含了一个只针对这个可执行文件的库(项目其他地方使用的库或导出到项目外的库应该放在src目录中)。

这个结构非常具有表现力,并允许项目的许多扩展。随着我们不断添加更多的类,我们可以很容易地将它们分组到库中,以加快编译过程。让我们看看库的样子:

图 3.3 – 库的目录结构

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_3.4.jpg)

图 3.3 – 库的目录结构

结果证明,库遵循与可执行文件相同的结构,只有一个小的区别:在include目录中有一个可选的lib3目录。只有当我们从项目中外部使用库时,这个目录才应该存在。它提供了其他项目在编译时将消耗的公共头文件。我们将在第五章使用 CMake 编译 C++源代码*中回到这个主题,构建我们自己的库。

所以,我们已经讨论了文件是如何布局在目录结构中的。现在,是时候看看单独的CMakeFiles.txt文件是如何组合成一个项目的,以及它们在大场景中的作用。

图 3.4 – CMake 如何将列表文件合并到一个项目中

图 3.4 – CMake 如何将列表文件合并到一个项目中

图 3.4中,每个框代表了一个位于特定目录中的CMakeLists.txt文件列表,而草体字中的标签代表了每个文件执行的动作(从上到下)。让我们从 CMake 的角度再次分析这个项目:

  1. 执行从项目的根开始——也就是说,从源树中的一个列表文件开始。这个文件将设置所需的最小 CMake 版本和相应的策略,设置项目名称,支持的语言,全局变量,并包括来自cmake目录的文件,以便它们的内容在全局范围内可用。

  2. 下一步是进入src目录的作用域,通过调用add_subdirectory(src bin)命令(我们想将编译后的工件放在<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 将继续进入剩余的嵌套作用域并执行它们的列表文件,直到所有 add_subdirectory() 调用完成。

  8. CMake 返回顶层作用域并执行剩余的三个命令:add_subdirectory(doc)add_subdirectory(extern)add_subdirectory(test)。每次,CMake 进入新的作用域并从相应的列表文件中执行命令。

  9. 所有目标都被收集并检查其正确性。CMake 现在拥有生成构建系统的所有必要信息。

我们需要记住,前面的步骤是按照我们编写命令的准确顺序发生的。有时这很重要,而其他时候,则不那么重要。我们在下一章解决这个问题。

所以,创建包含项目所有元素的目录的正确时机是什么时候呢?我们应该从一开始就创建未来所需的一切并保持目录空空如也,还是等到我们实际上需要放入其自己类别的文件时再做呢?这是一个选择——我们可以遵循极端编程规则 YAGNI你不需要它),或者我们可以尝试使我们的项目具有未来性,并为即将到来的新开发者打下良好的基础。

尝试在這些方法之间寻求良好的平衡——如果你怀疑你的项目可能有一天需要一个 extern 目录,那么添加它(你可能需要创建一个空白的 .keep 文件以将目录检入仓库)。为了帮助其他人知道将他们的外部依赖项放在哪里,创建一个 readme 文件,为未来踏上这条道路的 less 经验丰富的程序员铺平道路。你自己可能已经注意到了这一点:开发者不愿意创建目录,尤其是在项目的根目录中。如果我们提供一个好的项目结构,人们倾向于遵循它。

一些项目可以在几乎所有的环境中构建,而其他项目则非常关注它们的特定环境。顶层列表文件是评估如何进行项目的最佳位置,取决于有什么可用。让我们来看看如何做到这一点。

环境作用域

CMake 提供了多种查询环境的方法,使用CMAKE_变量、ENV变量和特殊命令。例如,收集的信息可以用来支持跨平台脚本。这些机制允许我们避免使用可能不易移植或在不同环境中命名不同的平台特定 shell 命令。

对于性能关键的应用程序,了解目标平台的所有特性(例如,指令集、CPU 核心数等)将很有用。然后可以将这些信息传递给编译后的二进制文件,以便它们可以被完美地调整(我们将在下一章学习如何做到这一点)。看看 CMake 中 native 提供了哪些信息。

发现操作系统

有许多场合知道目标操作系统是有用的。即使是像文件系统这样平凡的东西,在 Windows 和 Unix 之间也有很大的不同,比如大小写敏感性、文件路径结构、扩展名的存在、权限等。在一个系统上大多数命令在另一个系统上可能不可用,或者它们可能命名不同(即使只是一个字母——例如,ifconfigipconfig命令)。

如果你需要用一个 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 将预定义一些变量,提供关于宿主和目标系统的信息。如果使用特定的系统,相应的变量将被设置为非假值(即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 版本以及为了遗留原因而保持为真。另外,UNIX对于 Linux、macOS 和 Cygwin 也为真。

宿主系统信息

CMake 可以提供更多的变量,但为了节省时间,它不查询环境中的罕见信息,例如处理器是否支持 MMX总物理内存是多少。这并不意味着这些信息不可用——你只需要通过以下命令显式地请求它:

cmake_host_system_information(RESULT <VARIABLE> QUERY <KEY>…)

我们需要提供一个目标变量和我们要关注的键列表。如果我们只提供一个键,变量将包含一个值;否则,它将是一组值。我们可以询问许多关于环境和操作系统的详细信息:

如果需要,我们甚至可以查询处理器特定信息:

平台是否有 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 将提供 BIG_ENDIANLITTLE_ENDIAN 值,存储在 CMAKE_<LANG>_BYTE_ORDER 变量中,其中 <LANG>CCXXOBJCCUDA

现在我们已经知道如何查询环境,让我们将重点转移到项目的主要设置上。

配置工具链

对于 CMake 项目,工具链包括构建和运行应用程序的所有工具——例如,工作环境、生成器、CMake 执行文件本身以及编译器。

想象一下一个经验较少的使用者在构建过程中遇到一些神秘的编译和语法错误时会感到怎样。他们不得不深入源代码试图了解发生了什么。经过一个小时的调试后,他们发现正确的解决方案是更新他们的编译器。我们能否为用户提供更好的体验,并在开始构建前检查编译器中是否包含了所有必需的功能?

当然!有方法可以指定这些要求。如果工具链不支持所有必需的功能,CMake 将提前停止并显示发生了什么清晰的消息,要求用户介入。

设置 C++ 标准

我们可能首先想要做的是设置编译器需要支持的 C++ 标准,如果用户想要构建我们的项目的话。对于新项目,这至少应该是 C++14,但最好是 C++17C++20。CMake 还支持将标准设置为实验性的 C++23,但那只是一个草案版本。

注意

C++11 正式发布以来已经过去了 10 年,它不再被认为是现代 C++ 标准。除非你的目标环境非常老旧,否则不建议用这个版本开始项目。

坚持旧标准的原因之一是因为你在构建太难升级的遗留目标。然而,C++ 委员会非常努力地保持 C++ 的向后兼容性,在大多数情况下,你将不会有任何问题将标准提升到更高版本。

CMake 支持基于每个目标单独设置标准,这意味着你可以拥有任何粒度。我相信最好让整个项目统一到一个标准上。这可以通过将 CMAKE_CXX_STANDARD 变量设置为以下值之一来实现:981114172023(自 CMake 3.20 起)。这将作为所有随后定义的目标的默认值(所以最好在根列表文件的顶部附近设置它)。如果需要,你可以按每个目标单独覆盖它,像这样:

set_property(TARGET <target> PROPERTY CXX_STANDARD <standard>)

坚持标准支持

上文提到的CXX_STANDARD属性即使编译器不支持期望的版本——它也被视为一个偏好。CMake 不知道我们的代码实际上是否使用了在之前的编译器中不可用的全新特性,并且它会尝试利用可用的所有内容。

如果我们确信这将不会成功,我们可以设置另一个默认标志(它可以通过与前一个相同的方式覆盖)并明确要求我们目标的标准:

set(CMAKE_CXX_STANDARD_REQUIRED ON)

在这种情况下,如果最新的编译器不在系统当中(在这个例子中,GNU GCC 11),用户将只看到以下消息,并且构建将停止:

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++14 应该完全没问题,因为它自 2015 年以来已经在GCC/Clang中得到全面支持。

供应商特定的扩展

根据你在组织中实施的政策,你可能对允许或禁用供应商特定的扩展感兴趣。这些是什么?嗯,我们可以说 C++标准对于一些编译器生产商来说进展得太慢,所以他们决定向语言添加他们自己的增强——如果你喜欢的话,就是插件。为了实现这一点,CMake 将把-std=gnu++14添加到编译命令行中,而不是-std=c++14

一方面,这可能是想要的,因为它允许一些方便的功能。但另一方面,如果你的代码切换到不同的编译器(或者你的用户这样做!)构建将失败!

这也是一个针对每个目标的属性,其有一个默认变量,CMAKE_CXX_EXTENSIONS。CMake 在这里更加宽松,除非我们明确告诉它不要这样做,否则允许扩展:

set(CMAKE_CXX_EXTENSIONS OFF)

如果可能的话,我建议这样做,因为此选项将坚持拥有与供应商无关的代码。此类代码不会对用户施加任何不必要的要求。类似地,你可以使用set_property()按每个目标的基础更改此值。

跨过程优化

通常,编译器在单个翻译单元的层面上优化代码,这意味着你的.cpp文件将被预处理、编译,然后优化。后来,这些文件将被链接器用来构建单一的二进制文件。现代编译器可以在链接后进行优化(这称为链接时优化),以便所有编译单元可以作为一个单一模块进行优化。

如果你的编译器支持跨过程优化,使用它可能是个好主意。我们将采用与之前相同的方法。此设置的默认变量称为CMAKE_INTERPROCEDURAL_OPTIMIZATION。但在设置之前,我们需要确保它被支持以避免错误:

include(CheckIPOSupported) 
check_ipo_supported(RESULT ipo_supported)
if(ipo_supported)
  set(CMAKE_INTERPROCEDURAL_OPTIMIZATION True)
endif()

正如你所见,我们不得不包含一个内置模块来获取check_ipo_supported()命令的访问权限。

检查支持的编译器功能

如我们之前讨论的,如果我们的构建失败,最好是早点失败,这样我们就可以向用户提供一个清晰的反馈信息。我们特别感兴趣的是衡量哪些 C++特性被支持(以及哪些不被支持)。CMake 将在配置阶段询问编译器,并将可用特性的列表存储在CMAKE_CXX_COMPILE_FEATURES变量中。我们可以编写一个非常具体的检查,询问某个特性是否可用:

chapter03/07-features/CMakeLists.txt

list(FIND CMAKE_CXX_COMPILE_FEATURES 
  cxx_variable_templates result)
if(result EQUAL -1)
  message(FATAL_ERROR "I really need variable templates.")
endif()

正如您可能猜到的,为每个使用特性编写一个测试文件是一项艰巨的任务。即使是 CMake 的作者也建议只检查某些高级元特性是否存在:cxx_std_98cxx_std_11cxx_std_14cxx_std_17cxx_std_20cxx_std_23。每个元特性都表明编译器支持特定的 C++标准。如果您愿意,您可以像前一个示例中那样使用它们。

已知于 CMake 的所有特性的完整列表可以在文档中找到:

cmake.org/cmake/help/latest/prop_gbl/CMAKE_CXX_KNOWN_FEATURES.html

编译测试文件

当我用 GCC 4.7.x 编译一个应用程序时,有一个特别有趣的场景出现在我面前。我已手动在编译器的参考资料中确认了我们使用的所有 C++11 特性都被支持。然而,解决方案仍然无法正确工作。代码默默地忽略了标准<regex>头文件的调用。结果证明,GCC 4.7.x 有一个 bug,正则表达式库没有被实现。

没有一个单一的检查能保护你免受此类 bug 的影响,但通过创建一个测试文件,你可以填入所有你想检查的特性,从而有机会减少这种行为。CMake 提供了两个配置时间命令,try_compile()try_run(),以验证您需要的所有内容在目标平台上是否支持。

第二个命令给您更多的自由,因为您可以确保代码不仅编译成功,而且执行也正确(您可以潜在地测试regex是否工作)。当然,这对于交叉编译场景不起作用(因为主机无法运行为不同目标构建的可执行文件)。只需记住,这个检查的目的是在编译成功时向用户提供一个快速的反馈,所以它并不是用来运行任何单元测试或其他复杂内容的——尽量保持文件尽可能简单。例如,像这样:

chapter03/08-test_run/main.cpp

#include <iostream>
int main()
{
  std::cout << "Quick check if things work." << std::endl;
}

调用test_run()其实并不复杂。我们首先设置所需的标准,然后调用test_run(),并将收集的信息打印给用户:

chapter03/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})

这个命令有很多可选字段需要设置,一开始可能会觉得有点复杂,但当我们阅读并与示例中的调用进行比较时,一切都会变得明朗起来:

try_run(<runResultVar> <compileResultVar>
        <bindir> <srcfile> [CMAKE_FLAGS <flags>...]
        [COMPILE_DEFINITIONS <defs>...]
        [LINK_OPTIONS <options>...]
        [LINK_LIBRARIES <libs>...]
        [COMPILE_OUTPUT_VARIABLE <var>]
        [RUN_OUTPUT_VARIABLE <var>]
        [OUTPUT_VARIABLE <var>]
        [WORKING_DIRECTORY <var>]
        [ARGS <args>...])

只有几个字段是编译和运行一个非常基础的测试文件所必需的。我还使用了可选的RUN_OUTPUT_VARIABLE关键字来收集stdout的输出。

下一步是使用我们在实际项目中将要使用的更现代的 C++特性来扩展这个简单的文件——也许通过添加一个可变模板来看目标机器上的编译器是否能消化它。每次我们在实际项目中引入一个新特性,我们可以在测试文件中放入这个特性的微小样本。但请记住——保持简洁。我们希望在最短的时间内检查编译是否成功。

最后,我们可以在条件块中检查收集的输出是否符合我们的期望,当有些不对劲时会打印message(SEND_ERROR)。记住SEND_ERROR会在配置阶段继续,但不会启动生成。这有助于在放弃构建之前显示所有遇到的错误。

禁用源内构建

在第1章,《CMake 的初步步骤》中,我们讨论了源内构建,以及建议始终指定为源外构建路径。这不仅允许更干净的构建树和更简单的.gitignore文件,而且还减少了你意外覆盖或删除任何源文件的可能性。

在网上搜索解决方案时,你可能会偶然发现一个 StackOverflow 帖子,提出了同样的问题:stackoverflow.com/q/1208681/6659218。在这里,作者注意到不管你做什么,似乎 CMake 仍然会创建一个CMakeFiles/目录和一个CMakeCache.txt文件。一些答案建议使用未记录的变量,以确保用户在任何情况下都不能在源目录中写入:

# add this options before PROJECT keyword
set(CMAKE_DISABLE_SOURCE_CHANGES ON)
set(CMAKE_DISABLE_IN_SOURCE_BUILD ON)

我认为在使用任何软件的未记录功能时要小心,因为它们可能会在没有警告的情况下消失。在 CMake 3.20 中设置前面的变量会导致构建失败,并出现相当丑陋的错误:

CMake Error at /opt/cmake/share/cmake-3.20/Modules/CMakeDetermineSystem.cmake:203 (file):
  file attempted to write a file:
  /root/examples/chapter03/09-in-source/CMakeFiles/CMakeOutput.log into a source
  directory.

然而,它仍然创建了提到的文件!因此,我的建议是使用更旧的——但完全支持——机制:

chapter03/09-in-source/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.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!")

如果 Kitware(CMake 背后的公司)正式支持CMAKE_DISABLE_SOURCE_CHANGESCMAKE_DISABLE_IN_SOURCE_BUILD,那么当然,切换到那个解决方案。

总结

我们在本章引入了许多有价值的概念,它们将为我们打下坚实的基础,以便我们向前发展并构建坚固、未来 proof 的项目。我们讨论了如何设置最低的 CMake 版本以及如何配置项目的关键方面,即名称、语言和元数据字段。

打下良好的基础将有助于确保我们的项目能够快速成长。这就是我们讨论项目分区的理由。我们分析了使用include()的简单代码分区,并将其与add_subdirectory()进行了比较。在此过程中,我们了解了管理变量目录作用域的好处,并探讨了使用更简单的路径和增加的模块性的好处。当需要逐步将代码分解为更独立的单元时,创建嵌套项目和分别构建它是非常有用的。

在概览了我们可用的分区机制之后,我们探讨了如何使用它们——例如,如何创建透明、有弹性且可扩展的项目结构。具体来说,我们分析了 CMake 如何遍历列表文件以及不同配置步骤的正确顺序。

接下来,我们研究了如何作用域化我们目标和宿主机器的环境,它们之间的区别是什么,以及通过不同的查询可以获取关于平台和系统的哪些信息。

最后,我们发现了如何配置工具链——例如,如何指定所需的 C++版本,如何解决特定编译器扩展的问题,以及如何启用重要的优化。我们最后了解了如何测试我们的编译器所需的特性以及编译测试文件。

虽然从技术上讲,项目所有这些就足够了,但它仍然不是一个非常有用的项目。为了改变这一点,我们需要引入目标。到目前为止,我们在这里那里提到了它们,但我试图在我们先了解一些基本概念之前避免这个话题。现在我们已经做到了,我们将详细查看它们。

进一步阅读

关于本章涵盖的议题的更多信息,你可以参考以下内容:

第二部分:使用 CMake 构建

现在我们已经掌握了最基本的技能,是时候更深入一点学习了。下一部分将使你能够解决在使用 CMake 构建项目时遇到的大多数情况。

我们故意关注现代、优雅的实践,避免引入太多的遗留问题。具体来说,我们将处理逻辑构建目标,而不是操纵单个文件。

接下来,我们将详细解释工具链执行的二进制工件构建步骤。这是许多关于 C++的书籍所缺少的部分:如何配置和使用预处理器、编译器和链接器,以及如何优化它们的行为。

最后,本部分将涵盖 CMake 提供管理依赖关系的所有不同方式,并解释如何为您的特定用例选择最佳方法。

本部分包括以下章节:

  • 第四章,与目标一起工作

  • 第五章,使用 CMake 编译 C++源代码

  • 第六章,用 CMake 进行链接

  • 第七章,用 CMake 管理依赖关系

第四章:使用目标

在 CMake 中,我们可以构建的最基本目标是一个单一的二进制可执行文件,它包含了一个完整的应用程序。它可以由单一片源代码组成,如经典的helloworld.cpp。或者它可以更复杂——由数百个甚至数千个文件构建而成。这就是许多初学者项目的外观——用一个源文件创建一个二进制文件,再添加另一个,在不知不觉中,一切都被链接到一个没有结构可言的二进制文件中。

作为软件开发者,我们故意划设定界线,并将组件指定为将一个或多个翻译单元(.cpp文件)分组在一起。我们这样做有多个原因:增加代码的可读性,管理耦合和 connascence,加快构建过程,最后,提取可重用的组件。

每一个足够大的项目都会推动你引入某种形式的分区。CMake 中的目标正是为了解决这个问题——一个高级逻辑单元,为 CMake 形成一个单一目标。一个目标可能依赖于其他目标,它们以声明性方式生成。CMake 将负责确定目标需要以什么顺序构建,然后逐个执行必要的步骤。作为一个一般规则,构建一个目标将生成一个 artifact,这个 artifact 将被输送到其他目标中,或作为构建的最终产品交付。

我故意使用不确切的词汇artifact,因为 CMake 并没有限制你只能生成可执行文件或库。实际上,我们可以使用生成的构建系统来创建许多类型的输出:更多的源文件、头文件、对象文件、归档文件和配置文件——任何真正需要的。我们需要的只是一个命令行工具(如编译器)、可选的输入文件和一个输出路径。

目标是一个非常强大的概念,极大地简化了项目的构建。理解它们如何工作以及如何以最优雅、最清洁的方式配置它们是关键。

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

  • 目标概念

  • 编写自定义命令

  • 理解生成器表达式

技术要求

您可以在 GitHub 上找到本章中存在的代码文件,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter04

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

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 允许你节省时间并跳过那些食谱的中间步骤;它在更高的抽象级别上工作。它理解如何直接从源文件构建可执行文件。所以,你不需要编写显式的食谱来编译任何对象文件。所需的就是一个add_executable()命令,带有可执行目标的名字和要作为其元素的文件列表:

add_executable(app1 a.cpp b.cpp c.cpp)

我们在之前的章节中已经使用了这个命令,并且我们已经知道如何在实践中使用可执行目标——在生成步骤中,CMake 将创建一个构建系统并为其填充编译每个源文件并将它们链接在一起成一个单一的二进制可执行文件的食谱。

在 CMake 中,我们可以使用三个命令之一创建一个目标:

  • add_executable()

  • add_library()

  • add_custom_target()

前两个相对容易解释;我们已经在之前的章节中简要使用过它们来构建可执行文件和库(我们将在第五章深入讨论它们,使用 CMake 编译 C++源代码)。但那些自定义目标是什么?

它们允许你指定自己的命令行,该命令行将被执行而不检查产生的输出是否是最新的,例如:

  • 计算其他二进制文件的校验和。

  • 运行代码净化器并收集结果。

  • 将编译报告发送到数据处理管道。

以下是add_custom_target()命令的完整签名:

add_custom_target(Name [ALL] [command1 [args1...]]
                  [COMMAND command2 [args2...] ...]
                  [DEPENDS depend depend depend ... ]
                  [BYPRODUCTS [files...]]
                  [WORKING_DIRECTORY dir]
                  [COMMENT comment]
                  [JOB_POOL job_pool]
                  [VERBATIM] [USES_TERMINAL]
                  [COMMAND_EXPAND_LISTS]
                  [SOURCES src1 [src2...]])

我们不会在这里讨论每一个选项,因为我们想快速继续其他目标,但可以说自定义目标不必一定以文件形式产生有形工件。

自定义目标的一个好用例可能是需要在每次构建时删除特定文件的需求——例如,确保代码覆盖报告不包含过时数据。我们只需要像这样定义一个自定义目标:

add_custom_target(clean_stale_coverage_files 
          COMMAND find . -name "*.gcda" -type f -delete)

之前的命令将搜索所有具有.gcda扩展名的文件并将它们删除。但是有一个问题;与可执行文件和库目标不同,自定义目标只有在它们被添加到依赖图时才会构建。我们来了解一下那是怎么回事。

依赖图

成熟的应用程序通常由许多组件组成,我这里不是指外部依赖。具体来说,我指的是内部库。从结构上讲,将它们添加到项目中是有用的,因为相关的事物被包装在单一的逻辑实体中。并且它们可以与其他目标链接——另一个库或一个可执行文件。当多个目标使用同一个库时,这尤其方便。看看图 4.1,它描述了一个示例依赖关系图:

图 4.1 – BankApp 项目中依赖关系的构建顺序

图 4.1 – BankApp 项目中依赖关系的构建顺序

在这个项目中,我们有两个库,两个可执行文件和一个自定义目标。我们的用例是提供一个带有漂亮 GUI 的用户银行应用程序(GuiApp),以及一个作为自动化脚本一部分的命令行版本(TerminalApp)。两个可执行文件都依赖于同一个Calculations库,但只有其中一个需要Drawing库。为了确保我们的应用程序在用户从互联网下载时没有被修改,我们将计算一个校验和,将其存储在文件中,并通过单独的安全渠道分发它。CMake 在编写此类解决方案的列表文件方面相当灵活:

chapter04/01-targets/CMakeLists.txt

cmake_minimum_required(VERSION 3.19.2)
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 将尝试构建一个依赖关系图。和所有有效的依赖关系图一样,它们都是有向无环图。这意味着有一个明确的方向,即哪个目标依赖于哪个目标,并且这样的依赖关系不能形成循环。

当我们以构建模式执行cmake时,生成的构建系统将检查我们定义了哪些顶层目标,并递归地构建它们的依赖关系。让我们考虑一下来自图 4.1的例子:

  1. 从顶部开始,为组 1 构建两个库。

  2. CalculationsDrawing库完成后,构建组 2——GuiAppTerminalApp

  3. 构建一个校验和目标;运行指定的命令行生成校验和(cksum是一个 Unix 校验和工具)。

不过有一个小问题——前面的解决方案并不能保证校验和目标在可执行文件之后构建。CMake 不知道校验和依赖于可执行二进制文件的存在,所以它可以先开始构建它。为了解决这个问题,我们可以把add_dependencies()命令放在文件的末尾:

add_dependencies(checksum terminal_app gui_app)

这将确保 CMake 理解 Checksum 目标与可执行文件之间的关系。

很好,但target_link_libraries()add_dependencies()之间有什么区别?第一个是用来与实际库一起使用,并允许你控制属性传播。第二个仅适用于顶级目标以设置它们的构建顺序。

随着项目复杂性的增加,依赖树变得越来越难以理解。我们如何简化这个过程?

可视化依赖关系

即使小型项目也难以推理和与其他开发人员共享。最简单的方法之一是通过一个好的图表。毕竟,一张图片胜过千言万语。我们可以自己动手绘制图表,就像我在图 4.1中做的那样。但这很繁琐,并且需要不断更新。幸运的是,CMake 有一个很好的模块,可以在dot/graphviz格式中生成依赖图。而且它支持内部和外部依赖!

要使用它,我们可以简单地执行这个命令:

cmake --graphviz=test.dot .

该模块将生成一个文本文件,我们可以将其导入到 Graphviz 可视化软件中,该软件可以渲染图像或生成 PDF 或 SVG 文件,作为软件文档的一部分。每个人都喜欢伟大的文档,但几乎没有人喜欢创建它——现在,你不需要!

如果你急于求成,甚至可以直接从你的浏览器中运行 Graphviz,地址如下:

dreampuf.github.io/GraphvizOnline/

重要说明

自定义目标默认是不可见的,我们需要创建一个特殊的配置文件CMakeGraphVizOptions.cmake,它将允许我们自定义图表。一个方便的自定义命令是set(GRAPHVIZ_CUSTOM_TARGETS TRUE);将其添加到特殊配置文件中以在您的图表中启用报告自定义目标。您可以在模块的文档中找到更多选项。

你只需要将test.dot文件的内容复制并粘贴到左侧窗口中,你的项目就会被可视化。非常方便,不是吗?

图 4.2 —— BankApp 示例的 Graphviz 可视化

图 4.2 —— 使用 Graphviz 可视化的 BankApp 示例

为了清晰起见,我已经从前面的图中移除了自动生成的图例部分。

使用这种方法,我们可以快速查看所有明确定义的目标。现在我们有了这个全局视角,让我们深入了解一下如何配置它们。

目标属性

目标具有类似于 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_property()来更改它(它是只读的),因为 CMake 坚持使用add_dependencies()命令来限制操作只是添加。

在接下来的章节中讨论编译和链接时,我们将介绍更多的属性设置命令。同时,让我们关注一个目标的属性如何传递到另一个目标。

传递使用要求是什么?

我们姑且同意命名是困难的,有时最终得到的结果很难理解。“传递使用要求”不幸之一,那些你在在线 CMake 文档中遇到的神秘标题。让我们解开这个奇怪的名字,也许提出一个更容易理解的术语。

我将先澄清这个谜题的中间部分。正如我们之前讨论的,一个目标可能依赖于另一个目标。CMake 文档有时将这种依赖性称为使用,即一个目标使用另一个目标。这很简单,所以继续下一个。

有时,这样的使用目标必须满足一些特定的要求:链接一些库,包含一个目录,或者需要特定的编译特性。所有这些都是要求,所以在某种意义上文档是正确的。问题是,在文档的其他任何上下文中,它们都不被称为要求。当你为一个单一目标指定同样的要求时,你设置属性依赖项。因此,名称的最后部分也许应该简单地称为“属性”。

最后一个是–传递的。我相信这是正确的(也许有点太聪明了)。CMake 将在使用它们的目标的某些属性/要求附加到使用它们的目标的属性上。你可以说是有些属性可以隐式地传递(或简单地传播)跨目标,所以更容易表达依赖关系。

简化这个概念,我认为这就像是传播属性,在源目标(被使用的目标)和目标目标(使用其他目标的目标)之间。

让我们通过一个具体的例子来了解它为什么存在以及它是如何工作的:

target_compile_definitions(<source> <INTERFACE|PUBLIC|PRIVATE> [items1...])

这个目标命令将填充<source>目标的COMPILE_DEFINITIONS属性。编译定义仅仅是传递给编译器的-Dname=definition标志,用于配置 C++预处理器定义(我们将在第五章,使用 CMake 编译 C++源代码中讨论这个)。这里有趣的部分是第二个参数。我们需要指定三个值之一,INTERFACEPUBLICPRIVATE,以控制属性应该传递给哪些目标。现在,不要将这些与 C++访问修饰符混淆——这完全是另一回事。

传播关键字按如下方式工作:

  • PRIVATE 用于设置源目标属性。

  • INTERFACE 用于设置目标目标属性。

  • PUBLIC 用于设置源目标和目标目标属性。

当一个属性不应该传递给任何目标目标时,将其设置为PRIVATE。当需要这样的传递时,选择PUBLIC。如果你处于这样的情况,源目标在其实现中(.cpp文件)不使用属性,只在头文件中使用,并且这些被传递给消费者目标,那么INTERFACE就是答案。

这个机制是如何工作的呢?为了管理这些属性,CMake 提供了一些命令,比如前面提到的target_compile_definitions()。当你指定一个PRIVATEPUBLIC关键字时,CMake 将在与命令匹配的目标属性中存储提供的值——在这个例子中,是COMPILE_DEFINITIONS。另外,如果关键字是INTERFACEPUBLIC,它将在带有INTERFACE_前缀的属性中存储值——INTERFACE_COMPILE_DEFINITIONS。在配置阶段,CMake 将读取源目标的对接口属性并将它们的内容附加到目标目标。就这样——传播属性,或者按 CMake 的说法,传递使用要求。

在 CMake 3.20 中,有 12 个这样的属性通过适当的命令(如target_link_options())或直接通过set_target_properties()命令进行管理:

  • AUTOUIC_OPTIONS

  • COMPILE_DEFINITIONS

  • COMPILE_FEATURES

  • COMPILE_OPTIONS

  • INCLUDE_DIRECTORIES

  • LINK_DEPENDS

  • LINK_DIRECTORIES

  • LINK_LIBRARIES

  • LINK_OPTIONS

  • POSITION_INDEPENDENT_CODE

  • PRECOMPILE_HEADERS

  • SOURCES

我们将在接下来的页面中讨论这些选项的大部分,但请记住,所有这些选项当然都在 CMake 手册中有描述。在以下 URL 格式(将<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>...]...)

正如你所看到的,这个签名也指定了传播关键词,但这个关键词控制着源目标中的属性在目的目标中的存储位置。图 4.3 展示了在生成阶段(在配置阶段完成后)传播属性会发生什么:

图 4.3 – 属性如何传播到目标目标

图 4.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_target2" 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有点不同——传播的值不必匹配,但目标目标将只是接收最高或最低的值。

这个示例将帮助我们理解如何在实践中应用这一点:

chapter04/02-propagated/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
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>
)

在前面的片段中,我们创建了一个包含三个头文件的 Eigen 接口库。接下来,使用生成表达式(在本章最后部分解释),我们将其包含目录设置为当目标导出时为${CMAKE_CURRENT_SOURCE_DIR}/src,安装时为include/Eigen(这将在本章最后解释)。

要使用这样的库,我们只需要链接它:

target_link_libraries(executable Eigen)

这里实际上并不发生链接,但 CMake 会理解这个命令为一个请求,将所有的INTERFACE属性传播到executable目标。

第二个用例正好利用了相同的机制,但目的不同——它创建了一个逻辑目标,可以作为传播属性的占位符。我们随后可以用这个目标作为其他目标依赖,并以一种清晰、方便的方式设置属性。这是一个例子:

add_library(warning_props INTERFACE)
target_compile_options(warning_props INTERFACE 
  -Wall -Wextra -Wpedantic
) 
target_link_libraries(executable warning_props)

add_library(INTERFACE)命令创建了一个逻辑warning_props目标,用于在第二个命令中设置编译选项executable目标上。我建议使用这些INTERFACE目标,因为它们可以提高代码的可读性和可重用性。把它看作是将一串魔法值重构为命名良好的变量。我还建议使用_props后缀以便于区分接口库和常规库。

伪目标是否穷尽了目标的概念?当然不是!那将会太简单了。我们还需要理解这些目标如何翻译成生成的构建系统。

构建目标

目标这个词有点含义丰富。在项目上下文和生成的构建系统上下文中,它的意思不同。当 CMake 生成一个构建系统时,它将 CMake 语言的列表文件“编译”成所选构建工具的语言;也许它为 GNU Make 创建了一个 Makefile。这样的 Makefile 有自己的目标——其中一些是列表文件目标的直接转换,其他的是隐式创建的。

其中一个构建系统目标是ALL,CMake 默认生成的包含所有顶层列表文件目标的构建系统,如可执行文件和库(不一定是自定义目标)。当我们运行cmake --build <build tree>命令而没有选择一个具体的目标时,会构建ALL。正如您可能还记得第一章的内容,您可以通过向 preceding command 添加--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目标中,或者开始让它们为其他目标提供依赖,它们将会在每次构建时都被构建(你仍然可以在if块中启用它们以限制这种情况)。有时,这正是你所需要的,但在某些情况下,需要自定义行为来生成不应该在没有理由的情况下重新创建的文件:

  • 生成另一个目标依赖的源代码文件

  • 将另一种语言翻译成 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++代码。一个这样的场合可能是.proto文件的编译。如果你不熟悉这个库,protobuf 是一个适用于结构化数据的平台中立二进制序列化器。为了同时保持跨平台和快速,谷歌的工程师们发明了他们自己的 protobuf 格式,该格式在.proto文件中定义模型,例如这个:

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
}

这样的文件可以跨多种语言共享——C++、Ruby、Go、Python、Java 等等。Google 提供了编译器,用于读取.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文件进行更改时,一切都会自动编译和更新。

一个简化(且实用性远小于)的例子可能是通过从另一个位置复制来创建所需的头文件:

chapter04/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 示例中的校验和生成:

chapter04/04-command/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
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 解决方案的构建分为三个阶段——配置、生成和运行构建工具。通常,我们在配置阶段拥有所有必需的数据。但是偶尔,我们会遇到鸡和蛋的问题。拿前面小节的一个例子来说——一个目标需要知道另一个目标的可执行文件路径。但是这些信息在所有列表文件被解析和配置阶段完成后才可用。

我们如何处理这类问题呢?我们可以为这些信息创建一个占位符,并将其评估推迟到下一阶段——生成阶段。

这就是生成器表达式(有时被称为 genexes)所做的。它们围绕目标属性(如LINK_LIBRARIESINCLUDE_DIRECTORIESCOMPILE_DEFINITIONS)构建,传播属性和许多其他内容,但并非全部。它们遵循与条件语句和变量评估类似的规则。

需要注意的是,表达式通常是在使用表达式的目标上下文中评估的(除非有明确的说明否则)。

重要提示

生成器表达式将在生成阶段评估(当配置完成且构建系统被创建时),这意味着你无法很容易地将它们的输出捕获到一个变量中并打印到控制台。要调试它们,你可以使用以下任一方法:

• 将其写入文件(这个特定的file()命令支持生成器表达式):file(GENERATE OUTPUT filename CONTENT "$<...>")

• 从命令行显式添加一个自定义目标并构建它:add_custom_target(gendbg COMMAND ${CMAKE_COMMAND} -E echo "$<...>")

一般语法

让我们拿最简单的例子来说:

target_compile_definitions(foo PUBLIC
  BAR=$<TARGET_FILE:foo>)

前面的命令向编译器参数添加了一个-D定义标志(现在忽略PUBLIC)来设置BAR预处理器定义为foo 目标的可执行文件路径

生成器表达式是如何形成的?

图 4.4 – 生成器表达式的语法

图 4.4 – 生成器表达式的语法

正如你在图 4.4中看到的,结构似乎相当简单和易读:

  • 用美元符号和方括号($<)打开。

  • 添加EXPRESSION名称。

  • 如果一个表达式需要参数,请添加冒号(:)并提供arg1arg2arg3的值,用逗号(,)分隔。

  • >关闭表达式。

还有一些不需要任何参数的表达式,例如$<PLATFORM_ID>。然而,当使用它们的更高级功能时,生成器表达式可能会迅速变得非常令人困惑和复杂。

嵌套

让我们从能够将一般表达式作为另一个表达式的参数开始,或者换句话说,一般表达式的嵌套:

$<UPPER_CASE:$<PLATFORM_ID>>

这个例子并不复杂,但很容易想象当我们增加嵌套级别并使用多个参数的命令时会发生什么。

更甚者,你技术上可以在这个混合中添加一个变量展开:

$<UPPER_CASE:${my_variable}>

变量将在配置阶段展开,生成表达式在生成阶段展开。这个特性有些少见的使用场景,但我强烈建议避免使用。

条件表达式

生成表达式中支持布尔逻辑。这是一个很好的特性,但由于历史原因,其语法不一致且可能很难阅读。它有两种形式。第一种形式支持快乐路径和悲伤路径:

$<IF:condition,true_string,false_string>

这里的语法与其他所有表达式对齐,像所有表达式一样,嵌套是允许的。所以,你可以用另一个表达式替换任何一个参数,产生一些非常复杂的评估——你甚至可以将一个条件嵌套在另一个条件中。这种形式需要恰好三个参数,所以我们不能省略任何东西。在条件未满足的情况下跳过值的最好方法是:

$<IF:condition,true_string,>

第二种形式是对前一种的简写;只有当条件满足时,它才会展开成一个字符串:

$<condition:true_string >

正如你所见,它打破了将 EXPRESSION 名称作为第一个标记提供的一致性约定。我假设这里的意图是为了缩短表达式,省去那宝贵的三个字符,但结果可能真的很难找到合理的理由。以下是从 CMake 文档中举的一个例子:

$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CXX_COMPILER_ID:AppleClan
  g,Clang>>:COMPILING_CXX_WITH_CLANG>

我希望语法能与常规 IF 命令的条件对齐,但遗憾的是并非如此。

评估类型

生成表达式评估为两种类型之一——布尔值或字符串。布尔值用 1(真)和 0(假)表示。其他所有都是字符串。

重要的是要记住,嵌套表达式作为条件在条件表达式中是明确要求评估为布尔值的。

有一个显式的逻辑运算符将字符串转换为布尔值,但布尔类型可以隐式地转换为字符串。

既然我们已经了解了基本语法,那么让我们来看看我们能用它做些什么。

评估为布尔值

我们在上一节开始讨论条件表达式。我想一开始就彻底覆盖这个概念,这样就不用以后再回来了。有三种表达式评估为布尔值。

逻辑运算符

有四个逻辑运算符:

  • $<NOT:arg> 否定布尔参数。

  • $<AND:arg1,arg2,arg3...> 如果所有参数都是 1,则返回 1

  • $<OR:arg1,arg2,arg3...> 如果任意一个参数是 1,则返回 1

  • $<BOOL:string_arg> 将字符串参数转换为布尔类型。

字符串转换将评估为 1,如果这些条件没有满足:

  • 字符串为空。

  • 字符串是 0FALSEOFFNNOIGNORENOTFOUND 的不区分大小写等价物。

  • 字符串以 -NOTFOUND 后缀结尾(区分大小写)。

字符串比较

比较如果满足其条件则评估为 1,否则为 0

  • $<STREQUAL:arg1,arg2> 是一个区分大小写的字符串比较。

  • $<EQUAL:arg1,arg2>将字符串转换为数字并比较相等性。

  • $<IN_LIST:arg,list>检查arg元素是否在list列表中(区分大小写)。

  • $<VERSION_EQUAL:v1,v2>$<VERSION_LESS:v1,v2>$<VERSION_GREATER:v1,v2>$<VERSION_LESS_EQUAL:v1,v2>$<VERSION_GREATER_EQUAL:v1,v2>是逐组件的版本比较。

变量查询

有很多包含布尔值变量。如果它们满足条件,它们也将评估为1,否则为0

有一个简单的查询:

  • $<TARGET_EXISTS:arg> - arg目标存在吗?

有多个查询扫描传递的参数以查找特定值:

  • $<CONFIG:args>args中的当前配置(DebugRelease等)(不区分大小写)。

  • $<PLATFORM_ID:args>args中的当前平台 ID。

  • $<LANG_COMPILER_ID:args>args中的 CMakeLANG编译器 ID,其中LANGCCXXCUDAOBJCOBJCXXFortranISPC之一。

  • $<LANG_COMPILER_VERSION:args>args中的 CMakeLANG编译器版本,其中LANGCCXXCUDAOBJCOBJCXXFortranISPC之一。

  • $<COMPILE_FEATURES:features>如果features被编译器支持,将返回true

  • $<COMPILE_LANG_AND_ID:lang,compiler_id1,compiler_id2...>是这个lang目标的lang和在这个目标中使用的编译器是否存在于compiler_ids列表中。这个表达式用于指定特定编译器的配置细节:

    target_compile_definitions(myapp PRIVATE 
     $<$<COMPILE_LANG_AND_ID:CXX,AppleClang,Clang>:CXX_CLAN
      G>
     $<$<COMPILE_LANG_AND_ID:CXX,Intel>:CXX_INTEL>
     $<$<COMPILE_LANG_AND_ID:C,Clang>:C_CLANG>
    )
    

在前一个示例中,如果我们用AppleClangClang编译CXX编译器,将设置-DCXX_CLANG定义。对于来自 Intel 的CXX编译器,将设置-DCXX_INTEL定义标志。最后,对于CClang编译器,我们将得到一个-DC_CLANG定义。

  • $<COMPILE_LANGUAGE:args>如果在这个目标中使用args编译指定语言。这可以用来为编译器提供语言特定的标志:

    target_compile_options(myapp
      PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions>
    )
    

如果我们编译CXX,编译器将使用-fno-exceptions标志。

  • $<LINK_LANG_AND_ID:lang,compiler_id1,compiler_id2...>COMPILE_LANG_AND_ID类似,但检查链接步骤使用的语言。使用此表达式指定特定语言和链接器组合的目标的链接库、链接选项、链接目录和链接依赖项。

  • $<LINK_LANGUAGE:args>args中链接步骤使用的语言。

评估为字符串

有很多表达式被评估为字符串。我们可以直接将它们输出到目标的占位符中,或者作为另一个表达式的参数。我们已经学习了其中一个 - 条件表达式评估为字符串。还有哪些可用?

变量查询

这些表达式在生成阶段将评估为一个特定的值:

  • $<CONFIG> - 配置(DebugRelease)名称。

  • $<PLATFORM_ID> – 当前系统的 CMake 平台 ID(LinuxWindowsDarwin)。我们在上一章的环境范围部分讨论了平台。

  • $<LANG_COMPILER_ID> – 这是用于LANG编译器的 CMake 编译器 ID,其中LANGCCXXCUDAOBJCOBJCXXFortranISPC中的一个。

  • $<LANG_COMPILER_VERSION> – 这是用于LANG编译器的 CMake 编译器版本,其中LANGCCXXCUDAOBJCOBJCXXFortranISPC中的一个。

  • `

  • $<LINK_LANGUAGE> – 在评估链接选项时,目标的语言。

目标依赖查询

使用以下查询,您可以评估可执行文件或库目标属性。请注意,自 CMake 3.19 以来,对于在另一个目标上下文中查询大多数目标表达式,不再创建这些目标之间的自动依赖关系(如 3.19 之前所发生的那样):

  • $<TARGET_NAME_IF_EXISTS:target> – 如果存在,则是target的目标名称;否则为空字符串。

  • $<TARGET_FILE:target>target二进制文件的完整路径。

  • $<TARGET_FILE_NAME:target>target文件名。

  • $<TARGET_FILE_BASE_NAME:target>target的基础文件名,或者没有前缀和后缀的$<TARGET_FILE_NAME:target>。对于libmylib.so,基础名将是mylib

  • $<TARGET_FILE_PREFIX:target>target文件名的前缀(lib)。

  • $<TARGET_FILE_SUFFIX:target>target文件名的后缀(或扩展名)(.so.exe)。

  • $<TARGET_FILE_DIR:target>target二进制文件的目录。

  • $<TARGET_LINKER_FILE:target> – 链接到target目标时使用的文件。通常,它是target表示的库(在具有.lib导入库的平台上的.a.lib.so)。

TARGET_LINKER_FILE提供了与常规TARGET_FILE表达式相同的系列表达式:

$<TARGET_LINKER_FILE_NAME:target>$<TARGET_LINKER_FILE_BASE_NAME:target>$<TARGET_LINKER_FILE_PREFIX:target>$<TARGET_LINKER_FILE_SUFFIX:target>$<TARGET_LINKER_FILE_DIR:target>

  • $<TARGET_SONAME_FILE:target> – 具有 soname 的文件的完整路径(.so.3)。

  • $<TARGET_SONAME_FILE_NAME:target> – 具有 soname 的文件名称。

  • $<TARGET_SONAME_FILE_DIR:target> – 具有 soname 的文件的目录。

  • $<TARGET_PDB_FILE:target> – 链接器生成的程序数据库文件(.pdb)的完整路径,用于target

PDB 文件提供了与常规TARGET_FILE相同的表达式:$<TARGET_PDB_FILE_BASE_NAME:target>$<TARGET_PDB_FILE_NAME:target>$<TARGET_PDB_FILE_DIR:target>

  • $<TARGET_BUNDLE_DIR:target> – 目标(target)的全路径到捆绑包(Apple 特定的包)目录(my.appmy.frameworkmy.bundle)。

  • $<TARGET_BUNDLE_CONTENT_DIR:target>target的全路径的捆绑内容目录。在 macOS 上,它是my.app/Contentsmy.frameworkmy.bundle/Contents。其他的my.appmy.frameworkmy.bundle

  • $<TARGET_PROPERTY:target,prop>targetprop值。

  • $<TARGET_PROPERTY:prop> – 正在评估的表达式的targetprop值。

  • $<INSTALL_PREFIX> – 当目标用install(EXPORT)导出时或在INSTALL_NAME_DIR中评估时,安装前缀为;否则,为空。

转义

在很少的情况下,您可能需要向具有特殊意义的生成器表达式传递一个字符。为了逃避这种行为,请使用以下表达式:

  • $<ANGLE-R> – 字面上的>符号(比较包含>的字符串)

  • $<COMMA> – 字面上的,符号(比较包含,的字符串)

  • $<SEMICOLON> – 字面上的符号(防止在带有的参数上进行列表展开)

字符串转换

在生成器阶段处理字符串是可能的,使用以下表达式:

  • $<JOIN:list,d> – 使用d分隔符将分号分隔的list连接起来。

  • $<REMOVE_DUPLICATES:list> – 不排序地删除list中的重复项。

  • $<FILTER:list,INCLUDE|EXCLUDE,regex> – 使用regex正则表达式从列表中包含/排除项。

  • $<LOWER_CASE:string>$<UPPER_CASE:string> – 将字符串转换为另一种大小写。

  • $<GENEX_EVAL:expr> – 以当前目标的嵌套表达式的上下文评估expr字符串。当嵌套表达式的评估返回另一个表达式时(它们不是递归评估的),这很有用。

  • $<TARGET_GENEX_EVAL:target,expr> – 以与GENEX_EVAL转换类似的方式评估expr,但在target的上下文中。

输出相关表达式

CMake 文档未能提供对“输出相关表达式”的好解释。这让我们有些迷茫;它们与输出有什么关系?

根据 v3.13 文档(在较新的版本中被删除),“这些表达式生成输出,在某些情况下取决于输入。”

结果它们真的是点点滴滴。一些是缩写条件表达式的遗留版本。其他的只是尚未进入其他部分的字符串转换表达式。

以下表达式如果满足特定条件将返回其第一个参数,否则返回空字符串:

  • $<LINK_ONLY:deps> – 在target_link_libraries()中隐式设置以存储PRIVATE deps链接依赖项,这些依赖项不会作为使用要求传播。

  • $<INSTALL_INTERFACE:content> – 如果用于install(EXPORT),则返回content

  • $<BUILD_INTERFACE:content> – 如果与export()命令一起使用或在与同一构建系统中的另一个目标一起使用时,返回content

以下输出表达式将对其参数执行字符串转换:

  • $<MAKE_C_IDENTIFIER:input> – 转换为遵循与string(MAKE_C_IDENTIFIER)相同行为的 C 标识符。

  • $<SHELL_PATH:input> – 将绝对路径(或路径列表)转换为与目标操作系统匹配的壳路径样式。在 Windows 壳中,反斜杠被转换为斜杠,在 MSYS 壳中,驱动器字母被转换为 POSIX 路径。

最后,我们有一个游离变量查询表达式:

  • $<TARGET_OBJECTS:target> – 从target对象库返回一个对象文件列表

尝试一下的例子

当有一个好的实际例子来支持理论时,一切都会更容易理解。以下是生成器表达式的一些用途:

构建配置

在第一章中,我们讨论了指定我们要构建的配置的构建类型 – DebugRelease等。可能会有这样的情况,基于你正在进行的构建类型,你希望采取不同的行动。实现这一目标的一种简单方法是使用$<CONFIG>生成器表达式:

target_compile_options(tgt $<$<CONFIG:DEBUG>:-ginline-
  points>)

前一个示例检查config是否等于DEBUG;如果是这样,嵌套表达式被评估为1。外部的简写if表达式 then 变为true,我们的-ginline-points调试标志被添加到选项中。

特定于系统的单行命令

生成器表达式还可以用来将冗长的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>)

这样的代码工作得很好,但是你能在生成器表达式中放入多少内容,直到它变得难以阅读,有一个限度。在这种情况下,最好还是使用长的条件块。

与编译器特定标志相关的接口库

正如我们在这章开头讨论的,接口库可以用来提供与编译器匹配的标志:

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接口库链接我们的库和可执行文件。如果编译器支持,CMake 将添加-rtti标志。

嵌套生成器表达式

有时,在尝试在生成器表达式中嵌套元素时,不清楚会发生什么。我们可以通过生成测试输出到调试文件来调试这些表达式。

让我们尝试一些东西,看看会发生什么:

chapter04/04-genex/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}>
")

输出如下:

# 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关键字开始:

chapter04/04-genex/CMakeLists.txt(片段)

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,>
")

这将产生一个文件,像这样:

# 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不写值时,我们应该使用第一种形式。

生成器表达式以其复杂的语法而闻名。本例中提到的区别即使是经验丰富的构建者也会感到困惑。如果有疑问,将这样的表达式复制到另一个文件中,通过增加缩进和空格来拆分它,以便更好地理解。

总结

理解目标对于编写干净、现代的 CMake 项目至关重要。在本章中,我们不仅讨论了构成目标以及目标如何相互依赖,还学习了如何使用 Graphviz 模块在图表中呈现这些信息。有了这个基本的了解,我们能够学习目标的关键特性——属性(各种各样的属性)。我们不仅介绍了几个设置目标常规属性的命令,还解决了传递属性或传播属性的谜团。解决这个问题很困难,因为我们不仅需要控制哪些属性被传播,还需要可靠地将它们传播到选定的、更远的靶子。此外,我们还发现了如何确保当属性来自多个来源时,它们传播后仍然是兼容的。

我们随后简要讨论了伪目标——导入的目标、别名目标和接口库。它们都将会在我们的项目中派上用场,特别是当我们知道如何将它们与传播属性结合起来以造福我们的项目时。然后,我们谈到了生成的构建目标和它们在配置阶段我们行动的直接结果。之后,我们重点关注自定义命令(它们如何生成可以被其他目标消费、编译、翻译等的文件)以及它们的钩子函数——在目标构建时执行额外步骤。

本章的最后部分致力于生成表达式(genex)的概念。我们解释了其语法、嵌套以及条件表达式的工作原理。然后,我们介绍了两种类型的评估——布尔值和字符串。每种都有它自己的一套表达式,我们详细探索并评论了这些表达式。此外,我们还提供了一些使用示例,并澄清了它们在实际中是如何工作的。

有了这样一个坚实的基础,我们准备好进入下一个主题——将 C++源代码编译成可执行文件和库。

进一步阅读

更多信息,请访问以下网站:

  • Graphviz 模块文档:

CMake 社区文档中的 Graphviz 部分

CMakeGraphVizOptions 模块文档

  • Graphviz 软件:

Graphviz.org

  • CMake 目标属性:

CMake 目标属性

  • 传递性使用要求:

CMake 构建系统的传递性使用要求

第五章:使用 CMake 编译 C++源代码

简单的编译场景通常由工具链的默认配置或直接由 IDE 提供。然而,在专业环境中,业务需求往往需要更高级的东西。可能是对更高性能、更小二进制文件、更可移植性、测试支持或广泛的调试功能的需求——您说得都对。以一种连贯、未来无忧的方式管理所有这些,很快就会变得复杂、纠缠不清(尤其是在需要支持多个平台的情况下)。

编译过程在 C++书籍中往往没有解释得足够清楚(像虚拟基类这样的深入主题似乎更有趣)。在本章中,我们将回顾基础知识,以确保事情不如预期时能取得成功。我们将发现编译是如何工作的,它的内部阶段是什么,以及它们如何影响二进制输出。

之后,我们将重点关注先决条件——我们将讨论我们可以使用哪些命令来调整编译,如何从编译器那里要求特定的功能,以及如何向编译器提供必须处理的输入文件。

然后,我们将重点关注编译的第一阶段——预处理器。我们将提供包含头文件的路径,并研究如何插入 CMake 和环境预处理器定义。我们将涵盖一些有趣的用例,并学习如何大量暴露 CMake 变量给 C++代码。

紧接着,我们将讨论优化器以及不同标志如何影响性能。我们还将痛苦地意识到优化的代价——调试被破坏的代码有多困难。

最后,我们将解释如何通过使用预编译头和单元编译来减少编译时间,为发现错误做准备,调试构建,以及在最终二进制文件中存储调试信息。

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

  • 编译的基础

  • 预处理器配置

  • 配置优化器

  • 管理编译过程

技术要求

您可以在 GitHub 上找到本章中存在的代码文件,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter05

构建本书提供的示例时,始终使用建议的命令:

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++程序需要几个步骤:

  1. 设计你的应用程序并仔细编写源代码。

  2. 将单个.cpp实现文件(称为翻译单元)编译成目标文件

  3. 目标文件链接成单个可执行文件,并添加所有其他依赖项——动态和静态库。

  4. 要运行程序,操作系统将使用一个名为加载器的工具将它的机器代码和所有必需的动态库映射到虚拟内存。加载器然后读取头文件以检查程序从哪里开始,并将控制权交给代码。

  5. 启动 C++运行时;执行特殊的_start函数来收集命令行参数和环境变量。它开始线程,初始化静态符号,并注册清理回调。然后它调用由程序员编写的main()函数。

正如你所见,幕后发生了相当多的工作。本章讨论的是前述列表中的第二步。从整体的角度考虑,我们可以更好地理解一些可能问题的来源。毕竟,软件中没有黑魔法(即使难以理解的复杂性让它看起来像是那样)。一切都有解释和原因。程序运行时可能会失败,是因为我们如何编译它(即使编译步骤本身已经成功完成)。编译器在其工作中检查所有边缘情况是不可能的。

编译是如何工作的

如前所述,编译是将高级语言翻译成低级语言的过程——具体来说,是通过产生特定处理器可以直接执行的机器代码,以二进制对象文件格式生成,该格式特定于给定平台。在 Linux 上,最流行的格式是可执行和可链接格式ELF)。Windows 使用 PE/COFF 格式规范。在 macOS 上,我们会找到 Mach 对象(Mach-O 格式)。

对象文件**是单个源文件的直接翻译。每一个对象文件都需要单独编译,之后链接器将它们合并成一个可执行文件或库。正因为如此,当你修改了代码,只需重新编译受影响的文件,就能节省时间。

编译器必须执行以下阶段来创建一个对象文件

  • 预处理

  • 语言分析

  • 汇编

  • 优化

  • 代码生成

#include指令,用定义的值替换标识符(#define指令和-D标志),调用简单的宏,并根据#if#elif#endif指令有条件地包含或排除代码的一部分。预处理器对实际的 C++代码一无所知,通常只是一个更高级的查找和替换工具。然而,它在构建高级程序中的工作至关重要;将代码分成部分并在多个翻译单元之间共享声明是代码可重用的基础。

接下来是语言分析。在这里,更有趣的事情会发生。编译器将逐字符扫描文件(包含预处理器包含的所有头文件),并进行词法分析,将它们分组成有意义的标记——关键字、操作符、变量名等。然后,标记被分组成标记链,并检查它们的顺序和存在是否遵循 C++的规则——这个过程称为语法分析或解析(通常,在打印错误方面,它是声音最大的部分)。最后,进行语义分析——编译器尝试检测文件中的语句是否真的有意义。例如,它们必须满足类型正确性检查(你不能将整数赋值给字符串变量)。

汇编不过是将这些标记翻译成基于平台可用指令集的 CPU 特定指令。一些编译器实际上会创建一个汇编输出文件,之后再传递给专门的汇编器程序,以产生 CPU 可执行的机器代码。其他的编译器直接从内存中产生相同的机器代码。通常,这类编译器包括一个选项,以产生人类可读的汇编代码文本输出(尽管,仅仅因为你能读它,并不意味着它值得这么做)。

优化在整个编译过程中逐步进行,一点一点地,在每个阶段。在生成第一个汇编版本之后有一个明确的阶段,负责最小化寄存器的使用和删除未使用的代码。一个有趣且重要的优化是在线扩展或内联。编译器将“剪切”函数的主体并“粘贴”代替其调用(标准未定义这种情况发生在哪些情况下——这取决于编译器的实现)。这个过程加快了执行速度并减少了内存使用,但对调试有重大缺点(执行的代码不再在原始行上)。

代码发射包括根据目标平台指定的格式将优化后的机器代码写入对象文件。这个对象文件不能直接执行——它必须传递给下一个工具,链接器,它将适当移动我们对象文件的各个部分并解决对外部符号的引用。这是从 ASCII 源代码到可被处理器处理的二进制对象文件的转换。

每个阶段都具有重要意义,可以根据我们的特定需求进行配置。让我们看看如何使用 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>
  <value>)

这意味着它们支持属性传播,如前章所讨论的,既可以用于可执行文件也可以用于库。顺便提一下——所有这些命令都支持生成器表达式。

要求编译器具有特定的特性

如第三章“设置你的第一个 CMake 项目”中讨论的,检查支持的编译器特性,为使用你的软件的用户准备可能出错的事情,并努力提供清晰的消息——可用的编译器 X 没有提供所需的特性 Y。这比用户可能拥有的不兼容的工具链产生的任何错误都要好。我们不希望用户假设是你的代码出了问题,而不是他们过时的环境。

以下命令允许你指定构建目标所需的所有特性:

target_compile_features(<target> <PRIVATE|PUBLIC|INTERFACE>
                        <feature> [...])

CMake 理解 C++标准和这些compiler_ids所支持的编译器特性:

  • 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_CXX_KNOWN_FEATURES变量,但我建议坚持使用通用 C++标准——cxx_std_98cxx_std_11cxx_std_14cxx_std_17cxx_std_20cxx_std_23。查看进阶阅读部分以获取更多详细信息。

管理目标源代码

我们已经知道如何告诉 CMake 哪些源文件组成一个目标——一个可执行文件或一个库。我们在使用add_executable()add_library()时提供文件列表。

随着解决方案的增长,每个目标的文件列表也在增长。我们可能会得到一些非常长的add_...()命令。我们如何处理呢?一种诱惑可能是使用GLOB模式的file()命令——它可以收集子目录中的所有文件并将它们存储在一个变量中。我们将其作为目标声明的参数传递,并不再担心列表文件:

file(GLOB helloworld_SRC "*.h" "*.cpp")
add_executable(helloworld ${helloworld_SRC})

然而,前面提到的方法并不推荐。让我们找出原因。CMake 根据列表文件的变化生成构建系统,因此如果没有进行任何更改,构建可能会在没有警告的情况下失败(我们知道,在花费了长时间进行调试后,这种类型的失败是最糟糕的)。除此之外,不在目标声明中列出所有源代码将导致代码审查在 IDE(如 CLion)中失败(CLion 只解析一些命令以理解您的项目)。

如果不建议在目标声明中使用变量,我们如何才能在例如处理特定平台的实现文件(如gui_linux.cppgui_windows.cpp)时条件性地添加源文件呢?

我们可以使用target_sources()命令将文件追加到先前创建的目标:

chapter05/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)
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)。

使用BEFOREAFTER确定路径应该附加到目标INCLUDE_DIRECTORIES属性之前还是之后。是否检查这里提供的目录还是默认目录之前还是之后(通常,是之前)仍然由编译器决定。

SYSTEM关键字通知编译器,提供的目录是作为标准系统目录(与尖括号形式一起使用)。对于许多编译器,这个值将作为-isystem标志提供。

预处理器定义

记得我提到预处理器的#define#if#elif#endif指令时描述编译阶段吗?让我们考虑以下示例:

chapter05/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()命令将解决这个问题:

chapter05/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指令的组合。最简单的可能方法是将访问修饰符包裹在条件包含中,并在定义UNIT_TEST时忽略它们:

class X {
#ifndef UNIT_TEST
 private: 
#endif
  int x_;
}

虽然这种用例非常方便(它允许测试直接访问私有成员),但这不是非常整洁的代码。单元测试应该只测试公共接口中方法是否如预期工作,并将底层实现视为黑盒机制。我建议你只在万不得已时使用这个方法。

使用 git 提交跟踪编译版本

让我们考虑一下在了解环境或文件系统详情方面有益的用例。一个在专业环境中可能很好的例子是传递用于构建二进制的修订版或提交SHA

chapter05/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}")

我们可以在应用程序中如此使用它:

chapter05/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 提交。

跟踪确切的提交对调试非常有用。对于一个变量来说,这不是很多工作,但是当我们想要将数十个变量传递给我们的头文件时会发生什么?

配置头文件

如果我们有多个变量,通过target_compile_definitions()传递定义可能会有些繁琐。我们不能提供一个带有引用各种变量的占位符的头文件,并让 CMake 填充它们吗?

当然我们可以!使用configure_file(<input> <output>)命令,我们可以从模板生成新的文件,就像这个一样:

chapter05/04-configure/configure.h.in

#cmakedefine FOO_ENABLE
#cmakedefine FOO_STRING1 "@FOO_STRING@"
#cmakedefine FOO_STRING2 "${FOO_STRING}"
#cmakedefine FOO_UNDEFINED "@FOO_UNDEFINED@"

我们可以使用命令,像这样:

chapter05/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 生成一个输出文件,像这样:

chapter05/04-configure/<build_tree>/configure.h

#define FOO_ENABLE
#define FOO_STRING1 "abc"
#define FOO_STRING2 "def"
/* #undef FOO_UNDEFINED "@FOO_UNDEFINED@" */

正如你所见,@VAR@${VAR}变量占位符被替换成了 CMake 列表文件中的值。此外,#cmakedefine被替换成了#define给已定义的变量,对于未定义的变量则替换成/* #undef VAR */

如果你需要为#if块提供显式的#define 1#define 0,请使用#cmakedefine01

我们如何在应用程序中使用这样的配置头文件?我们可以在实现文件中简单地包含它:

chapter05/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_ENABLE1: " << xstr(FOO_ENABLE1) << endl;
  cout << "FOO_ENABLE2: " << xstr(FOO_ENABLE2) << endl;
  cout << "FOO_UNDEFINED: " << xstr(FOO_UNDEFINED) << endl;
}

由于我们已使用target_include_directories()命令将二叉树添加到了我们的包含路径中,因此我们可以编译示例并从 CMake 接收填充好的输出:

FOO_ENABLE: ON
FOO_ENABLE1: FOO_ENABLE1
FOO_ENABLE2: FOO_ENABLE2
FOO_UNDEFINED: FOO_UNDEFINED

configure_file()命令还具有许多格式化和文件权限选项。在这里描述它们可能会稍显冗长。如果你有兴趣,可以查看在线文档以获取详细信息(链接在进一步阅读部分)。

在准备好我们头文件和源文件的完整组合后,我们可以讨论在下一步中输出代码是如何形成的。由于我们无法直接影响语言分析或汇编(这些步骤遵循严格的标准),我们肯定可以访问优化器的配置。让我们了解它如何影响最终结果。

配置优化器

优化器将分析前阶段的结果,并使用多种程序员认为不整洁的技巧,因为它们不符合整洁代码原则。没关系——优化器的关键作用是使代码具有高性能(即,使用较少的 CPU 周期、较少的寄存器和较少的内存)。当优化器遍历源代码时,它会对其进行大量转换,以至于它几乎变得无法辨认。它变成了针对目标 CPU 的特殊准备版本。

优化器不仅会决定哪些函数可以被删除或压缩;它还会移动代码或甚至显著地重复它!如果它可以完全确定某些代码行是没有意义的,它就会从重要函数的中间抹去它们(你甚至都注意不到)。它会重复利用内存,所以众多变量在不同时间段可以占据同一个槽位。如果这意味着它可以节省一些周期,它还会将你的控制结构转换成完全不同的结构。

这里描述的技术,如果由程序员手动应用到源代码中,将会使其变得可怕、难以阅读。编写和推理将会困难。另一方面,如果由编译器应用,那就非常棒了,因为编译器将严格遵循所写的内容。优化器是一种无情的野兽,只服务于一个目的:使执行快速,无论输出会变得多么糟糕。如果我们在测试环境中运行它,输出可能包含一些调试信息,或者它可能不包含,以便让未授权的人难以篡改。

每个编译器都有自己的技巧,与它所遵循的平台和哲学相一致。我们将查看 GNU GCC 和 LLVM Clang 中可用的最常见的一些,以便我们可以了解什么是有用和可能的。

问题是——许多编译器默认不会启用任何优化(包括 GCC)。这在某些情况下是可以的,但在其他情况下则不然。为什么要慢慢来,当你可以快速前进时呢?要改变事物,我们可以使用target_compile_options()命令,并精确指定我们想从编译器那里得到什么。

这个命令的语法与本章中的其他命令相似:

target_compile_options(<target> [BEFORE]
  <INTERFACE|PUBLIC|PRIVATE> [items1...]
  [<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])

我们提供target命令行选项以添加,并指定传播关键字。当执行此命令时,CMake 将在目标相应的COMPILE_OPTIONS变量中附加给定选项。可选的BEFORE关键字可用于指定我们想要在它们之前添加它们。在某些情况下,顺序很重要,因此能够选择是件好事。

重要提示

target_compile_options()是一个通用命令。它也可以用来为类似编译器的-D定义提供其他参数,对于这些参数,CMake 还提供了target_compile_definition()命令。始终建议尽可能使用 CMake 命令,因为它们在所有支持的编译器上都是一致的。

是讨论细节的时候了。接下来的章节将介绍您可以在大多数编译器中启用的各种优化方法。

通用级别

优化器的所有不同行为都可以通过我们作为编译选项传递的具体标志进行深度配置。了解它们需要花费大量时间,并需要深入了解编译器、处理器和内存的内部工作原理。如果我们只想在大多数情况下都能良好工作的最佳可能场景怎么办?我们可以寻求一个通用解决方案——一个优化级别指定符。

大多数编译器提供四个基本级别的优化,从03。我们使用-O<level>选项指定它们。-O0意味着没有优化,通常,这是编译器的默认级别。另一方面,-O2被认为是完全优化,它生成高度优化的代码,但编译时间最慢。

有一个中间的-O1级别,根据您的需求,它可以是一个很好的折中方案——它启用了适量的优化机制,而不会使编译速度变得太慢。

最后,我们可以使用-O3,这是完全优化,类似于-O2,但它在子程序内联和循环向量化方面采取了更为激进的方法。

还有一些优化变体,它们将优化生成文件的大小(不一定是速度)——-Os。还有一个超级激进的优化,-Ofast,它是不严格符合 C++标准的-O3优化。最明显的区别是使用-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级别或直接针对负责的标志:

  • -finline-functions-called-once:仅 GCC 支持

  • -finline-functions:Clang 和 GCC

  • -finline-hint-functions:仅 Clang 支持

  • -finline-functions-called-once:仅 GCC 支持

你可以使用-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)是 20 世纪 60 年代初为实现并行化而开发的一种机制。它的工作方式正如其名称所暗示的那样;它可以同时对多块信息执行相同的操作。实际意味着什么?让我们考虑以下示例:

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实现文件发生变化时,它们都必须重新编译。此外,如果多个翻译文件使用相同的共享头文件,每次包含时都必须重新编译。这是浪费,但长期以来一直是这样。*

幸运的是,自从版本 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.h|xx文件中,然后预编译为具有.pch.gch.pchi扩展名的特定于编译器的二进制文件。*

我们可以像这样使用它:*

chapter05/06-precompile/CMakeLists.txt*

add_executable(precompiled hello.cpp)
target_precompile_headers(precompiled PRIVATE <iostream>)

chapter05/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 还引入了另一个编译时间优化功能——统一构建,也称为统一构建巨构建。统一构建将多个实现源文件与#include指令结合在一起(毕竟,编译器不知道它是在包含头文件还是实现)。这带来了一些有趣的含义——有些是非常有用的,而其他的是潜在有害的。

让我们从最明显的一个开始——避免在 CMake 创建统一构建文件时在不同翻译单元中重新编译头文件:

#include "source_a.cpp"
#include "source_b.cpp"

当这两个源中都包含#include "header.h"行时,多亏了包含守卫(假设我们没有忘记添加那些),它只会被解析一次。这不如预编译头文件优雅,但这是一个选项。

这种构建方式的第二个好处是,优化器现在可以更大规模地作用,并优化所有捆绑源之间的跨过程调用。这类似于我们在第二章《CMake 语言》中讨论的链接时间优化。

然而,这些好处是有代价的。因为我们减少了对象文件的数量和处理步骤,我们也增加了处理更大文件所需的内存量。此外,我们减少了并行化工作量。编译器并不是真正那么擅长多线程编译,因为它们不需要——构建系统通常会启动许多编译任务,以便在不同的线程上同时执行所有文件。当我们把所有文件放在一起时,我们会使它变得困难得多,因为 CMake 现在会在我们创建的多个巨构建之间安排并行构建。

在使用统一构建时,你还需要考虑一些可能不是那么明显捕捉到的 C++语义含义——匿名命名空间跨文件隐藏符号现在被分组到一组中。静态全局变量、函数和宏定义也是如此。这可能会导致名称冲突,或者执行不正确的函数重载。

在重新编译时,巨构构建不受欢迎,因为它们会编译比所需更多的文件。当代码旨在尽可能快地整体编译所有文件时,它们效果最佳。在 Qt Creator 上进行的测试表明,您可以期待性能提升在 20%到 50%之间(取决于所使用的编译器)。

启用统一构建,我们有两个选项:

  • CMAKE_UNITY_BUILD变量设置为true——它将在定义后的每个目标上初始化UNITY_BUILD属性。

  • 手动将UNITY_BUILD设置为每个应使用统一构建的目标的true

第二个选项是通过以下方式实现的:

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,并将组中的所有文件添加到单个巨构构建中。

CMake 的文档建议不要默认启用公共项目的统一构建。建议您的应用程序的最终用户能够通过提供DCMAKE_UNITY_BUILD命令行参数来决定他们是否需要巨构构建。更重要的是,如果由于您的代码编写方式而引起问题,您应该明确将目标属性设置为false。然而,这并不妨碍您为内部使用的代码启用此功能,例如在公司内部或为您私人项目使用。

不支持的 C++20 模块

如果你密切关注 C++标准的发布,你会知道 C++20 引入了一个新特性——模块。这是一个重大的变革。它允许你避免使用头文件时的许多烦恼,减少构建时间,并使得代码更简洁、更易于导航和推理。

本质上,我们可以创建一个带有模块声明的单文件,而不是创建一个单独的头部和实现文件:

export module hello_world;
import <iostream>; 
export void hello() {
    std::cout << "Hello world!\n";
}

然后,你可以在代码中简单地导入它:

import hello_world;
int main() {
    hello();
}

注意我们不再依赖预处理器;模块有自己的关键字——importexportmodule。最受欢迎的编译器最新版本已经可以执行所有必要的任务,以支持模块作为编写和构建 C++解决方案的新方法。我原本希望在本章开始时,CMake 已经提供了对模块的早期支持。不幸的是,这一点尚未实现。

然而,到你购买这本书的时候(或不久之后)可能就有了。有一些非常好的指标;Kitware 开发者已经创建(并在 3.20 中发布)了一个新的实验性特性,以支持 C++20 模块依赖项扫描对 Ninja 生成器的支持。现在,它只打算供编译器编写者使用,这样他们就可以在开发过程中测试他们的依赖项扫描工具。

当这个备受期待的特性完成并在一个稳定的版本中可用时,我建议彻底研究它。我预计它将简化并大大加快编译速度,超过今天可用的任何东西。

查找错误。

作为程序员,我们花了很多时间寻找 bug。这是一个悲哀的事实。查找并解决错误常常会让我们感到不舒服,尤其是如果它需要长时间的话。如果我们没有仪器帮助我们导航暴风雨,盲目飞行会更困难。这就是为什么我们应该非常小心地设置我们的环境,使这个过程尽可能容易和可忍受。我们通过使用target_compile_options()配置编译器来实现这一点。那么编译选项能帮助我们什么呢?

配置错误和警告。

软件开发中有许多令人压力很大的事情——比如在半夜修复关键错误、在高知名度的大型系统中处理昂贵的失败、以及处理那些令人烦恼的编译错误,尤其是那些难以理解或修复起来极其繁琐的错误。当研究一个主题以简化你的工作并减少失败的可能性时,你会发现有很多关于如何配置编译器警告的建议。

一条这样的好建议就是为所有构建启用-Werror标志作为默认设置。这个标志做的简单而无辜的事情是——所有警告都被视为错误,除非你解决所有问题,否则代码不会编译。虽然这可能看起来是个好主意,但几乎从来不是。

你看,警告之所以不是错误,是有原因的。它们是用来警告你的。决定如何处理这是你的事。拥有忽视警告的自由,尤其是在你实验和原型化解决方案时,通常是一种祝福。

另一方面,如果你有一个完美无瑕、没有警告、闪闪发光的代码,允许未来的更改破坏这种情况真是太可惜了。启用它并只是保持在那里会有什么害处呢?表面上看起来没有。至少在你升级编译器之前是这样。编译器的新版本往往对弃用的特性更加严格,或者更好地提出改进建议。当你不将所有警告视为错误时,这很好,但当你这样做时,有一天你会发现你的构建开始在没有代码更改的情况下失败,或者更令人沮丧的是,当你需要快速修复一个与新警告完全无关的问题时。

那么,“几乎不”是什么意思,当你实际上应该启用所有可能的警告时?快速答案是当你编写一个公共库时。这时,你真的想避免因为你的代码在一个比你的环境更严格的编译器中编译而产生问题报告。如果你决定启用它,请确保你对编译器的新版本和它引入的警告了如指掌。

否则,让警告就是警告,专注于错误。如果你觉得自己有必要吹毛求疵,可以使用-Wpedantic标志。这是一个有趣的选择——它启用了所有严格遵循 ISO C 和 ISO C++所要求的警告。请注意,使用此标志并不能检查代码是否符合标准——它只能找到需要诊断信息的非 ISO 实践。

更加宽容和脚踏实地的程序员会对-Wall感到满意,可选地加上-Wextra,以获得那种额外的华丽感觉。这些被认为是有实际用处和意义的警告,当你有空时应该修复你的代码中的这些问题。

还有许多其他的警告标志,这取决于项目的类型可能会有所帮助。我建议你阅读你选择的编译器的手册,看看有什么可用。

调试构建过程

偶尔,编译会失败。这通常发生在我们试图重构一堆代码或清理我们的构建系统时。有时,事情很容易解决,但随后会有更复杂的问题,需要深入分析配置的每个步骤。我们已经知道如何打印更详细的 CMake 输出(如在第一章中讨论的,CMake 的初步步骤),但我们如何分析在每个阶段实际发生的情况呢?

调试单个阶段

我们可以向编译器传递-save-temps标志(GCC 和 Clang 都有这个标志),它将强制将每个阶段的输出存储在文件中,而不是内存中:

chapter05/07-debug/CMakeLists.txt

add_executable(debug hello.cpp)
target_compile_options(debug PRIVATE -save-temps=obj)

前面的片段通常会产生两个额外的文件:

  • <build-tree>/CMakeFiles/<target>.dir/<source>.ii:存储预处理阶段的输出,带有注释解释源代码的每一部分来自哪里:

    # 1 "/root/examples/chapter05/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:
    ( ... )
    

根据问题的性质,我们通常可以发现实际的问题所在。预处理器的输出对于发现诸如不正确的include 路径(提供错误版本的库)以及导致错误#ifdef评估的定义错误等 bug 很有帮助。

语言分析阶段的输出对于针对特定处理器和解决关键优化问题很有用。

解决头文件包含的调试问题

错误地包含的文件可能是一个真正难以调试的问题。我应该知道——我的第一份企业工作就是将整个代码库从一种构建系统移植到另一种。如果你发现自己需要精确了解正在使用哪些路径来包含请求的头文件,可以使用-H

chapter05/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

object file的名称之后,输出中的每一行都包含一个头文件的路径。行首的一个点表示顶级包含(#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 指令的数值标识符、一些用于初始化内存的数据以及成千上万的内存地址。换句话说,最终的二进制文件不需要包含实际的源代码、变量名、函数签名或程序员关心的任何其他细节。这就是编译器的默认输出——原始且干燥。

这样做主要是为了节省空间并在执行时尽量减少开销。巧合的是,我们也在一定程度上(somewhat)保护了我们的应用程序免受逆向工程。是的,即使没有源代码,你也可以理解每个 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,它在<assert.h>头文件中可用。如果你决定在你的生产代码中使用断言,它们将根本不会工作:

int main(void)
{
    bool my_boolean = false;
    assert(my_boolean); 
    std::cout << "This shouldn't run. \n"; 
    return 0;
}

Release配置中,assert(my_boolean)调用将不会产生任何效果,但在Debug模式下它会正常工作。如果你在实践断言性编程的同时还需要在发布构建中使用assert(),你会怎么做?要么更改 CMake 提供的默认设置(从CMAKE_CXX_FLAGS_RELEASE中移除NDEBUG),要么通过在包含头文件前取消定义宏来实现硬编码覆盖:

#undef NDEBUG
#include <assert.h>

有关assert的更多信息,请参考:en.cppreference.com/w/c/error/assert

总结

我们已经完成了又一章!毫无疑问,编译是一个复杂的过程。有了所有的边缘情况和特定要求,如果没有一个好工具,管理起来可能会很困难。幸运的是,CMake 在这方面做得很好。

到目前为止,我们学到了什么?我们首先讨论了编译是什么以及它在操作系统中构建和运行应用程序的更广泛故事中的位置。然后,我们研究了编译的阶段以及管理它们的内部工具。这对于解决我们将来可能会遇到的更高级别案例中的所有问题非常有用。

然后,我们探讨了如何让 CMake 验证宿主上可用的编译器是否满足我们代码构建的所有必要要求。正如我们之前所确定的,对于我们的解决方案的用户来说,看到一个友好的消息要求他们升级,而不是由一个混淆于语言新特性的旧编译器打印出的某些神秘错误,会是一个更好的体验。

我们简要讨论了如何向已定义的目标添加源代码,然后转向预处理器配置。这是一个相当大的主题,因为这一阶段将所有的代码片段汇集在一起,决定哪些将被忽略。我们谈论了提供文件路径以及作为单个参数和批量(还有一些用例)添加自定义定义。

然后,我们讨论了优化器;我们探索了所有通用优化级别的优化以及它们隐含的标志,但我们也详细讨论了其中的一些——finlinefloop-unrollftree-vectorize

最后,是再次研究整体编译流程和如何管理编译可行性的时候了。在这里我们解决了两个主要问题——减少编译时间(从而加强程序员的注意力集中)和查找错误。后者对于发现什么坏了和如何坏是非常重要的。正确设置工具并了解事情为何如此发生,在确保代码质量(以及我们的心理健康)方面起着很长的作用。

在下一章中,我们将学习链接知识,以及我们需要考虑的所有构建库和使用它们的项目中的事情。

进一步阅读

第六章:使用 CMake 进行链接

你可能会认为,在我们成功将源代码编译成二进制文件之后,作为构建工程师我们的工作就完成了。事实几乎如此——二进制文件包含了 CPU 执行的所有代码,但代码分散在多个文件中,方式非常复杂。链接是一个简化事物并使机器代码整洁、易于消费的过程。

快速查看命令列表会让你知道 CMake 并没有提供很多与链接相关的命令。承认,target_link_libraries()是唯一一个实际配置这一步骤的命令。那么为什么要用一整章来讲述一个命令呢?不幸的是,在计算机科学中,几乎没有什么事情是容易的,链接也不例外。

为了获得正确的结果,我们需要跟随整个故事——了解链接器究竟如何工作,并正确掌握基础知识。我们将讨论对象文件的内部结构,如何进行重定位和引用解析,以及它们的用途。我们将讨论最终可执行文件与其组件的区别以及系统如何构建进程映像。

然后,我们将向您介绍各种库——静态库、共享库和共享模块。它们都被称为库,但实际上它们几乎没有任何共同之处。构建正确链接的可执行文件严重依赖于有效的配置(以及关注如位置无关代码PIC)这样的微小细节。

我们将学习链接过程中的另一个麻烦——一定义规则ODR)。我们需要正好得到定义的数量。处理重复的符号有时可能非常棘手,特别是当共享库涉及其中时。然后,我们将了解为什么有时链接器找不到外部符号,即使可执行文件与适当的库链接在一起。

最后,我们将了解到如何节省时间并使用链接器为我们的解决方案准备测试,专用框架。

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

  • 正确掌握链接的基础知识

  • 构建不同类型的库

  • 解决一定义规则的问题

  • 链接的顺序和未解析的符号

  • 为测试分离main()

技术要求

你可以在 GitHub 上找到本章中存在的代码文件,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter06

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

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

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

正确掌握链接的基础知识

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

为了回答这个问题,让我们来看看编译器是如何构建流行 ELF 格式的(Unix-like 系统和许多其他系统使用)对象文件的:

图 6.1 – *对象文件*的结构

图 6.1 – 对象文件的结构

编译器将为每个翻译单元(每个 .cpp 文件)准备一个 对象文件。这些文件将用于构建我们程序的内存映像。对象文件包含以下元素:

  • 一个 ELF 头,用于标识目标操作系统、ELF 文件类型、目标指令集架构以及 ELF 文件中找到的两个头表的位置和大小信息——程序头表(不在对象文件中)和段头表。

  • 按类型分组的信息段。

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

编译器在处理你的源代码时,会将收集的信息分组到几个不同的容器中,这些容器将被放在它们自己的独立部分。其中一些如下:

  • .text 段:机器代码,包含处理器要执行的所有指令

  • .data 段:所有初始化全局和静态对象(变量)的值

  • .bss 段:所有未初始化全局和静态对象(变量)的值,将在程序启动时初始化为零

  • .rodata 段:所有常量(只读数据)的值

  • .strtab 段:包含所有常量字符串的字符串表,如我们在基本 hello.cpp 示例中放入的 Hello World

  • .shstrtab 段:包含所有段名称的字符串表

这些组非常类似于最终的可执行版本,它们将被放入 RAM 中以运行我们的应用程序。然而,我们并不能像这样直接将这个文件加载到内存中。这是因为每个 对象文件 都有自己的段集合。如果我们只是将它们连接在一起,我们就会遇到各种问题。我们将浪费大量的空间和时间,因为我们需要更多的内存页面。指令和数据将更难复制到 CPU 缓存中。整个系统将不得不更加复杂,并且会在运行时浪费宝贵的周期在许多(可能达到数万).text.data 和其他段之间跳转。

所以,我们将要做的 instead is take each section of the object file and put it together with the same type of section from all other object files. 这个过程称为Relocatable对于对象文件). 除了只是将相应的段放在一起,它还必须更新文件内的内部关联——即,变量的地址、函数的地址、符号表索引或字符串表索引。所有这些值都是针对对象文件的局部值,它们的编号从零开始。当我们捆绑文件在一起时,我们需要偏移这些值,以便它们指向捆绑文件中的正确地址。

图 6.2 显示了移动 in action – .text 段被移动,.data 正在从所有链接的文件中构建,.rodata.strtab 将紧随其后(为了简化,图不包括头):

图 6.2 – .data 段的移动

图 6.2 – .data 段的移动

第二,链接器需要extern关键字),编译器读取声明并信任定义在外面某个地方,稍后再提供。链接器负责收集此类未解决的外部符号引用,在合并到可执行文件后找到并填充它们所在的地址。图 6.3 显示了一个简单的引用解析示例:

图 6.3 – 引用解析

图 6.3 – 引用解析

如果程序员不知道它是如何工作的,链接的这部分可能会成为问题之源。我们可能会最终得到未解决的引用,它们找不到它们的外部符号,或者相反——我们提供了太多的定义,链接器不知道选择哪一个。

最终的可执行文件对象文件非常相似;它包含已移动的段和已解决的引用、段头表,当然还有描述整个文件的 ELF 头。主要区别在于存在程序头(如图6.4所示)。

图 6.4 – ELF 中可执行文件的结构

图 6.4 – ELF 中可执行文件的结构

程序头位于 ELF 头之后。系统加载器将读取此头以创建进程映像。该头包含一些通用信息和内存布局的描述。布局中的每个条目代表一个称为的内存片段。条目指定要读取哪些段,以什么顺序,以及虚拟内存中的哪些地址,它们的标志是什么(读、写或执行),还有一些其他有用的细节。

对象文件*也可能被打包进库中,这是一种中间产品,可以被用于最终的执行文件或其他库中。在下一节中,我们将讨论三种库的类型。

构建不同类型的库

在源代码编译后,我们可能希望避免在同一平台上一再编译,甚至尽可能与外部项目共享。当然,你也可以简单地提供所有的目标文件,就像它们最初被创建的那样,但这有几个缺点。分发多个文件并分别添加到构建系统中更加困难。如果它们很多,这可能会很麻烦。相反,我们可以简单地将所有的目标文件合并到一个单一的目标中并共享它。CMake 在这个过程中极大地帮助我们。我们可以使用简单的add_library()命令(与target_link_libraries()命令一起使用)创建这些库。按惯例,所有库都有一个公共前缀lib,并使用特定于系统的扩展名表示它们是哪种类型的库:

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

  • 共享库在类 Unix 系统上有.so扩展名,在 Windows 上有.dll

当构建库(静态、共享或共享模块)时,你经常会遇到这个名字链接来表示这个过程。即使 CMake 在chapter06/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

与可能看起来相反,链接器并不用于创建所有上述库。执行重定位和引用解析有例外。让我们来看看每种库类型,了解它们是如何工作的。

静态库

要构建一个静态库,我们可以简单地使用我们在前面章节中已经看到的命令:

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

如果BUILD_SHARED_LIBS变量没有设置为ON,上述代码将生成一个静态库。如果我们想无论如何都构建一个静态库,我们可以提供一个显式的关键字:

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

静态库是什么?它们本质上是一组存储在归档中的原始目标文件。在类 Unix 系统上,这样的归档可以通过ar工具创建。静态库是最古老、最基本的提供编译代码的方法。如果你想避免将你的依赖项与可执行文件分离,那么你可以使用它们,但代价是可执行文件的大小和占用内存会增加。

归档可能包含一些额外的索引,以加快最终的链接过程。每个平台都使用自己的方法来生成这些索引。类 Unix 系统使用一个名为ranlib的工具来完成这项工作。

共享库

了解到我们可以使用SHARED关键字来构建共享库,这并不令人惊讶:

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

我们也可以通过将BUILD_SHARED_LIBS变量设置为ON并使用简短版本来实现:

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

与静态库相比,这种差异是显著的。共享库使用链接器构建,并将执行链接的两个阶段。这意味着我们将收到带有正确段头、段和段头表的文件(图 6.1)。

共享库(也称为共享对象)可以在多个不同的应用程序之间共享。操作系统将在第一个使用它的程序中将这样的库加载到内存中的一个实例,并且所有随后启动的程序都将提供相同的地址(感谢复杂的虚拟内存机制)。只有.data.bss段将为每个消耗库的进程创建单独的实例(这样每个进程就可以修改自己的变量,而不会影响其他消费者)。

得益于这种方法,系统中的整体内存使用情况得到了改善。如果我们使用一个非常受欢迎的库,我们可能不需要将其与我们的程序一起分发。很可能目标机器上已经提供了这个库。然而,如果情况不是这样,用户在运行应用程序之前需要明确地安装它。这可能会导致一些问题,当安装的库版本与预期不符时(这类问题被称为依赖地狱;更多信息可以在进阶阅读部分找到)。

共享模块

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

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

这是一个旨在作为插件在运行时加载的共享库版本,而不是在编译时与可执行文件链接的东西。共享模块不会随着程序的启动自动加载(像常规共享库那样)。只有在程序通过进行系统调用(如 Windows 上的LoadLibrary或 Linux/macOS 上的dlopen()/dlsym())明确请求时,才会发生这种情况。

你不应该尝试将你的可执行文件与模块链接,因为这在所有平台上都不能保证有效。如果你需要这样做,请使用常规共享库。

位置无关代码

所有共享库和模块的源代码都应该使用位置无关代码标志编译。CMake 检查目标的POSITION_INDEPENDENT_CODE属性,并适当地添加编译器特定的编译标志,如gccclang-fPIC

PIC 这个词有点让人困惑。现在,程序已经在某种意义上位置无关,因为它们使用虚拟内存来抽象实际的物理地址。在调用函数时,CPU 使用对每个进程来说都是0的物理地址,该物理地址在分配时可用。这些映射不必指向连续的物理地址或遵循任何特定的顺序。

PIC 是关于将符号(对函数和全局变量的引用)映射到它们的运行时地址。在库的编译过程中,不知道哪些进程可能会使用它。无法预先确定库将在虚拟内存中的哪个位置加载,或者将以什么顺序加载。这反过来意味着符号的地址是未知的,以及它们相对于库机器代码的位置也是未知的。

为了解决这个问题,我们需要增加一个间接层。PIC 将为我们添加一个新节到输出中——.text节在链接时是已知的;因此,所有符号引用可以在那时指向占位符 GOT。指向内存中符号的实际值将在首次执行访问引用符号的指令时填充。那时,加载器将设置 GOT 中特定条目的值(这就是懒加载这个术语的由来)。

共享库和模块将自动将POSITION_INDEPENDENT_CODE属性设置为ON。然而,重要的是要记住,如果你的共享库被链接到另一个目标,比如静态库或对象库,你也需要在这个目标上设置这个属性。这是如何做到的:

set_target_properties(dependency_target 
                      PROPERTIES POSITION_INDEPENDENT_CODE
                      ON)

不这样做会在 CMake 上遇到麻烦,因为默认情况下,此属性会以描述处理传播属性冲突一节中的方式进行检查第四章与目标一起工作

说到符号,还有一个问题需要讨论。下一节将讨论名称冲突导致定义不明确和不一致的问题。

使用单一定义规则解决问题的方法

菲尔·卡尔顿说得一点也没错,他说如下的话:

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

名称之所以难以处理,有几个原因——它们必须精确、简单、短小且富有表现力。这使得它们具有意义,并允许程序员理解背后的概念。C++和许多其他语言提出了一个额外的要求——许多名称必须是唯一的。

这以几种不同的方式表现出来。程序员需要遵循 ODR。这表明,在单个翻译单元(单个.cpp文件)的作用域内,您需要精确一次地定义它,即使您多次声明相同的名称(变量、函数、类类型、枚举、概念或模板)。

此规则将扩展到整个程序的作用域,适用于您在代码中实际使用的所有变量和非内联函数。考虑以下示例:

第六章/02-odr-fail/shared.h

int i;

第六章/02-odr-fail/one.cpp

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

第六章/02-odr-fail/two.cpp

#include "shared.h"

第六章/02-odr-fail/two.cpp

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

正如你所看到的,这是非常直接的——我们创建了一个shared.h头文件,它在两个单独的翻译单元中使用:

  • one.cpp文件,它简单地将i打印到屏幕上

  • two.cpp文件,它除了包含头文件外什么也不做

然后我们将这两个文件链接成一个可执行文件,并收到以下错误:

[100%] Linking CXX executable odr
/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

你不能定义这些事情两次。然而,有一个值得注意的例外——类型、模板和外部内联函数可以在多个翻译单元中重复定义,如果它们完全相同(即,它们的标记序列相同)。我们可以通过将简单的定义int i;替换为类定义来证明这一点:

chapter06/03-odr-success/shared.h

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

然后,我们像这样使用它:

chapter06/03-odr-success/one.cpp

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

剩下的两个文件two.cppCMakeLists.txt保持不变,与02odrfail示例中的一样。这样的更改将允许链接成功:

-- Build files have been written to: /root/examples/chapter06/03-odr-success/b
[ 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关键字,如下所示:

chapter06/04-odr-success/shared.h

static int i;

所有其他文件都将保持不变,与原始示例一样,链接仍然成功。这当然意味着前面的代码中的变量为每个翻译单元存储在单独的内存中,一个翻译单元的更改不会影响另一个。

动态链接的重复符号

名称解析规则(ODR)对静态库和对象文件的作用完全一样,但当我们使用SHARED库构建代码时,情况就不那么明确了。链接器将允许在此处重复符号。在以下示例中,我们将创建两个共享库AB,其中一个duplicated()函数和两个独特的a()b()函数:

chapter06/05-dynamic/a.cpp

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

第二个实现文件几乎是第一个的完全副本:

chapter06/05-dynamic/b.cpp

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

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

chapter06/05-dynamic/main.cpp

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

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

  • main_1首先与a库链接。

  • main_2首先与b库链接。

以下是一个此类项目的代码:

chapter06/05-dynamic/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
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/chapter06/05-dynamic# b/main_1
A
B
duplicated A
root@ce492a7cd64b:/root/examples/chapter06/05-dynamic# b/main_2
A
B
duplicated B

啊哈!所以,链接器确实关心链接库的顺序。如果我们不小心,这可能会造成一些混淆。实际上,名称冲突并不像看起来那么罕见。

这种行为有一些例外;如果我们定义本地可见符号,它们将优先于从动态链接库中可用的那些。在main.cpp中添加以下函数将使两个二进制的输出最后一行都变为重复的 MAIN,如下所示:

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

当导出库中的名称时,总是要非常小心,因为迟早会遇到名称冲突。

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

命名空间的概念是为了避免这种奇怪的问题,并以一种可管理的方式处理 ODR(唯一公共引用规则)。难怪建议用与库同名的命名空间包裹你的库代码。这样,我们可以摆脱所有重复符号的问题。

在我们的项目中,我们可能会遇到一种情况,其中一个共享库会链接另一个,然后又链接另一个,形成一个漫长的链。这并不罕见,尤其是在更复杂的设置中。重要的是要记住,简单地将一个库链接到另一个库并不意味着有任何命名空间继承。这个链中的每个符号都保持未保护状态,保存在它们最初编译的命名空间中。

链接器的怪癖在某些场合很有趣且有用,但让我们谈谈一个并不那么罕见的问题——当正确定义的符号无缘无故失踪时该怎么办。

链接顺序和未解决符号

链接器往往看起来有些古怪,经常会无缘无故地抱怨一些事情。这对刚开始接触的程序员来说是一个尤其艰难的考验,因为他们还不太熟悉这个工具。难怪他们会尽可能长时间地避免接触构建配置。最终,他们不得不修改一些东西(也许是在可执行文件中添加一个他们工作的库),然后一切就乱套了。

让我们考虑一个相当简单的依赖链——主可执行文件依赖于outer库,而outer库又依赖于nested库(包含必要的int b变量)。突然间,程序员的屏幕上出现了一个不起眼的提示信息:

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

这并不是一个罕见的诊断——通常,这意味着我们忘记向链接器添加一个必要的库。但在这种情况下,库实际上已经被正确地添加到了target_link_libraries()命令中:

第六章/06-order/CMakeLists.txt

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

那接下来怎么办!?很少有错误会让人如此抓狂地去调试和理解。我们在这里看到的是链接顺序的不正确。让我们深入源代码找出原因:

第六章/06-order/main.cpp

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

前面的代码看起来简单 enough —— 我们将打印一个名为a的外部变量,可以在outer库中找到。我们提前用extern关键词声明它。以下是该库的源代码:

第六章/06-order/outer.cpp

extern int b;
int a = b;

这也相当简单——outer依赖于nested库来提供b外部变量,该变量被分配给a导出变量。让我们查看nested的源代码,以确认我们没有错过定义:

第六章/06-order/nested.cpp

int b = 123;

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

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。我们只需要将链接顺序颠倒,以便nestedouter之后:

target_link_libraries(main outer nested)

另一个不太优雅的选项是重复库(这对于循环引用很有用):

target_link_libraries(main nested outer nested)

最后,我们可以尝试使用链接器特定的标志,如--start-group--end-group。查看您链接器的文档,因为这些具体内容超出了本书的范围。

既然我们已经知道如何解决常见问题,那么让我们谈谈如何利用链接器的好处。

为测试分离 main()

正如我们迄今为止所建立的,链接器强制执行 ODR,并确保在链接过程中所有外部符号提供它们的定义。我们可能会遇到的一个有趣的问题是正确地进行构建测试。

理想情况下,我们应该测试与生产中运行的完全相同的源代码。一个彻底的测试管道应该构建源代码,在生成的二进制文件上运行其测试,然后才打包和分发可执行文件(不包括测试本身)。

但我们实际上是如何实现这一点的呢?可执行文件有非常具体的执行流程,这通常需要阅读命令行参数。C++的编译性质实际上并不支持可以仅用于测试目的而临时注入到二进制文件中的可插拔单元。这似乎需要一个相当复杂的解决方案。

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

chapter06/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

chapter06/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;
}

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

chapter06/07-testing/CMakeLists.cpp

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

main目标只是提供了所需的main()函数。program目标包含了所有的逻辑。现在我们可以通过创建另一个包含其自己的main()和测试逻辑的可执行文件来测试它。

在现实场景中,像main()方法这样的框架可以用来替换程序的入口点并运行所有定义的测试。我们将在第八章深入研究实际的测试主题,测试框架。现在,让我们关注通用原则,并在另一个main()函数中编写我们自己的测试:

chapter06/07-testing/test.cpp

#include <iostream>
extern int start_program(int, const char**);
using namespace std;
int main() {
  auto exit_code = start_program(0, nullptr);
  if (exit_code == 0)
    cout << "Non-zero exit code expected" << endl;
  const char* arguments[2] = {"hello", "world"};
  exit_code = start_program(2, arguments);
  if (exit_code != 0)
    cout << "Zero exit code expected" << endl;
}

前面的代码将两次调用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 中管理依赖关系。

进一步阅读

关于本章涵盖的主题,你可以参考以下内容:

  • ELF 文件的结构:

可执行文件与链接格式

  • 关于add_library()的 CMake 手册:

CMake 的 add_library 命令

  • 依赖地狱:

依赖地狱

  • 模块与共享库的区别:

模块与共享库的区别

第七章:使用 CMake 管理依赖

你的解决方案是大型还是小型,并不重要;随着它的成熟,你最终会决定引入外部依赖。避免根据普遍的商业逻辑创建和维护代码的成本是很重要的。这样,你就可以将时间投入到对你和你的客户有意义的事情上。

外部依赖不仅用于提供框架和功能以及解决古怪的问题。它们在构建和控制代码质量的过程中也起着重要的作用——无论是特殊编译器如Protobuf,还是测试框架如GTest

无论你是在处理开源项目,还是在使用你公司其他开发者编写的项目,你仍然需要一个良好、干净的流程来管理外部依赖。自己解决这个问题将花费无数的设置时间和大量的额外支持工作。幸运的是,CMake 在适应不同风格和依赖管理的历史方法的同时,还能跟上行业批准标准的不断演变。

为了提供一个外部依赖,我们首先应该检查宿主系统是否已经有了这个依赖,因为最好避免不必要的下载和漫长的编译。我们将探讨如何找到并把这样的依赖转换成 CMake 目标,在我们的项目中使用。这可以通过很多方式完成,特别是当包支持 CMake 开箱即用,或者至少提供给一个稍微老一点的 PkgConfig 工具的文件时。如果情况不是这样,我们仍然可以编写自己的文件来检测并包含这样的依赖。

我们将讨论当一个依赖在系统上不存在时应该做什么。正如你可以想象,我们可以采取替代步骤来自动提供必要的文件。我们将考虑使用不同的 Git 方法来解决这个问题,并将整个 CMake 项目作为我们构建的一部分引入。

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

  • 如何找到已安装的包

  • 使用FindPkgConfig0发现遗留包

  • 编写自己的 find-modules

  • 与 Git 仓库协作

  • 使用ExternalProjectFetchContent模块

技术要求

你可以在这个章节中找到的代码文件在 GitHub 上,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter07

为了构建本书中提供的示例,总是使用推荐的命令:

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

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

如何找到已安装的包

好的,假设你已经决定通过网络通信或静态存储数据来提高你的技能。纯文本文件、JSON,甚至是老旧的 XML 都不行。你希望将你的数据直接序列化为二进制格式,最好使用业界知名的库——比如谷歌的 protocol buffers(Protobuf)。你找到了文档,在系统中安装了依赖项,现在怎么办?我们实际上如何告诉 CMake 找到并使用你引入的这项外部依赖?幸运的是,有一个find_package()命令。在大多数情况下,它都像魔法一样起作用。

让我们倒带并从头开始设置场景——我们必须安装我们想要使用的依赖项,因为find_package(),正如其名,只是关于在系统中发现包。我们假设依赖项已经安装,或者我们解决方案的用户知道如何在提示时安装特定的、必要的依赖项。为了覆盖其他场景,你需要提供一个备份计划(关于这方面的更多信息可以在与 Git 仓库一起工作部分中找到)。

在 Protobuf 的情况下,情况相当直接:你可以从官方存储库(github.com/protocolbuffers/protobuf)下载、编译并自行安装库,也可以使用你操作系统的包管理器。如果你正在使用第章 1CMake 初步》中提到的 Docker 镜像,你将使用 Debian Linux。安装 Protobuf 库和编译器的命令如下:

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

每个系统都有它自己的安装和管理包的方式。找到一个包所在的路径可能会很棘手且耗时,特别是当你想要支持今天大多数操作系统时。幸运的是,如果涉及的包提供了一个合适的配置文件,允许 CMake 确定支持该包所需的变量,find_package()通常可以为你完成这个任务。

如今,许多项目都符合这一要求,在安装过程中提供了这个文件给 CMake。如果你计划使用某个流行的库而它没有提供此文件,暂时不必担心。很可能 CMake 的作者已经将文件与 CMake 本身捆绑在一起(这些被称为find-modules,以便与配置文件区分开来)。如果情况不是这样,我们仍然还有一些选择:

  • 为特定包提供我们自己的 find-modules,并将其与我们的项目捆绑在一起。

  • 编写一个配置文件,并请包维护者将该包与文件一起分发。

你可能会说你还没有完全准备好自己创建这样的合并请求,这没关系,因为很可能你不需要这么做。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.org/cmake/help/latest/manual/cmake-modules.7.html#find modules

查找模块和配置文件都可以在 CMake 项目中用一个find_package()命令。CMake 寻找匹配的查找模块,如果找不到任何模块,它会转向配置文件。搜索将从存储在CMAKE_MODULE_PATH变量中的路径开始(默认情况下这个变量是空的)。当项目想要添加和使用外部查找模块时,这个变量可以被项目配置。接下来,CMake 将扫描安装的 CMake 版本的内置查找模块列表。

如果没有找到适用的模块,该寻找相应的包配置文件了。CMake 有一长串适合宿主操作系统的路径,可以扫描与以下模式匹配的文件名:

  • <CamelCasePackageName>Config.cmake

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

让我们稍微谈谈项目文件;在这个例子中,我其实并不打算设计一个带有远程过程调用和所有附件的网络解决方案。相反,我只是想证明我能构建并运行一个依赖于 Protobuf 的项目。为了实现这一点,我将创建一个尽可能小的合同的.proto文件。如果你对 Protobuf 不是特别熟悉,只需知道这个库提供了一种机制,可以将结构化数据序列化为二进制形式。为此,我们需要提供一个此类结构的模式,它将用于将二进制形式写入和读取 C++对象。

我想出的是这样的:

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

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

如果你不熟悉 Protobuf 语法(这其实不是这个例子真正关注的),不必担心。这是一个只包含一个 32 位整数的简单message。Protobuf 有一个特殊的编译器,它会读取这些文件,并生成可以被我们的应用程序使用的 C++源文件和头文件。这意味着我们需要将这个编译步骤以某种方式添加到我们的过程中。我们稍后再回到这个问题。现在,让我们看看我们的main.cpp文件长什么样:

chapter07/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包含一个唯一的id字段。在main.cpp文件中,我创建了一个代表这个消息的对象,将字段设置为123,并将其调试信息打印到标准输出。接下来,我创建了一个文件流,将这个对象的二进制版本写入其中,并关闭流——这是序列化库最简单的可能用途。

请注意,我已经包含了一个message.pb.h头文件。这个文件还不存在;它需要在message.proto编译期间由 Protobuf 编译器protoc创建。这种情况听起来相当复杂,暗示这样一个项目的列表文件必须非常长。根本不是!这就是 CMake 魔法发生的地方:

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

cmake_minimum_required(VERSION 3.20.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})

让我们来分解一下:

  • 前两行我们已经知道了;它们创建了一个项目和声明了它的语言。

  • find_package(Protobuf REQUIRED) 要求 CMake 运行捆绑的FindProtobuf.cmake查找模块,并为我们设置 Protobuf 库。那个查找模块将扫描常用路径(因为我们提供了REQUIRED关键字)并在找不到库时终止。它还将指定有用的变量和函数(如下面的行所示)。

  • protobuf_generate_cpp 是 Protobuf 查找模块中定义的自定义函数。在其内部,它调用add_custom_command(),该命令使用适当的参数调用protoc编译器。我们通过提供两个变量来使用这个函数,这些变量将被填充生成的源文件(GENERATED_SRC)和头文件(GENERATED_HEADER)的路径,以及要编译的文件列表(message.proto)。

  • 如我们所知,add_executable 将使用main.cpp和前面命令中配置的 Protobuf 文件创建我们的可执行文件。

  • target_link_libraries 将由find_package()找到的(静态或共享)库添加到我们的main目标链接命令中。

  • target_include_directories() 将必要的INCLUDE_DIRS(由包提供)添加到包含路径中,以及CMAKE_CURRENT_BINARY_DIR。后者是必需的,以便编译器可以找到生成的message.pb.h头文件。

换句话说,它实现了以下功能:

  • 查找库和编译器的所在位置

  • 提供辅助函数,教会 CMake 如何调用.proto文件的定制编译器

  • 添加包含包含和链接所需路径的变量

在大多数情况下,当你调用find_package()时,你可以期待一些变量会被设置,不管你是使用内置的查找模块还是随包附带的配置文件(假设已经找到了包):

  • <PKG_NAME>_FOUND

  • <PKG_NAME>_INCLUDE_DIRS<PKG_NAME>_INCLUDES

  • <PKG_NAME>_LIBRARIES<PKG_NAME>_LIBRARIES<PKG_NAME>_LIBS

  • <PKG_NAME>_DEFINITIONS

  • 由查找模块或配置文件指定的IMPORTED目标

最后一个观点非常有趣——如果一个包支持所谓的“现代 CMake”(以目标为中心),它将提供这些IMPORTED目标而不是这些变量,这使得代码更简洁、更简单。建议优先使用目标而不是变量。

Protobuf 是一个很好的例子,因为它提供了变量和IMPORTED目标(自从 CMake 3.10 以来):protobuf::libprotobufprotobuf::libprotobuf-liteprotobuf::libprotocprotobuf::protoc。这允许我们编写更加简洁的代码:

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

cmake_minimum_required(VERSION 3.20.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})

protobuf::libprotobuf导入的目标隐式地指定了包含目录,并且多亏了传递依赖(或者我叫它们传播属性),它们与我们的main目标共享。链接器和编译器标志也是同样的过程。

如果你需要确切知道特定 find-module 提供了什么,最好是访问其在线文档。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关键字意味着我们想要一个确切的版本(这里不支持版本范围)。

  • QUIET关键字可以静默所有关于找到/未找到包的消息。

  • REQUIRED关键字如果找不到包将停止执行,并打印一个诊断消息(即使启用了QUIET也是如此)。

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

为包提供配置文件的概念并不新鲜。而且它肯定不是 CMake 发明的。还有其他工具和格式为此目的而设计。PkgConfig 就是其中之一。CMake 还提供了一个有用的包装模块来支持它。

使用 FindPkgConfig 发现遗留的包

管理依赖项和发现它们所需的所有编译标志的问题与 C++库本身一样古老。有许多工具可以处理这个问题,从非常小和简单的机制到作为构建系统和 IDE 的一部分提供的非常灵活的解决方案。其中一个(曾经非常流行)的工具被称为 PkgConfig(freedesktop.org/wiki/Software/pkg-config/)。它通常在类 Unix 系统中可用(尽管它也适用于 macOS 和 Windows)。

pkg-config正逐渐被其他更现代的解决方案所取代。这里出现了一个问题——你应该投入时间支持它吗?答案一如既往——视情况而定:

  • 如果一个库真的很受欢迎,它可能已经有了自己的 CMake find-module;在这种情况下,你可能不需要它。

  • 如果没有 find-module(或者它不适用于您的库)并且库只提供 PkgConfig .pc文件,只需使用现成的即可。

许多(如果不是大多数)库已经采用了 CMake,并在当前版本中提供了包配置文件。如果您不发布您的解决方案并且您控制环境,请使用find_package(),不要担心遗留版本。

遗憾的是,并非所有环境都可以快速更新到库的最新版本。许多公司仍在使用生产中的遗留系统,这些系统不再获得最新包。在这种情况下,用户可能只能使用较旧的(但希望兼容)版本。而且经常情况下,它会提供一个.pc文件。

此外,如果这意味着您的项目可以为大多数用户无障碍地工作,那么支持旧的 PkgConfig 格式的努力可能是值得的。

在任何情况下,首先使用find_package(),如前一部分所述,如果<PKG_NAME>_FOUND为假,则退回到 PkgConfig。这样,我们覆盖了一种场景,即环境升级后我们只需使用主方法而无需更改代码。

这个助手工具的概念相当简单——库的作者提供一个小型的.pc文件,其中包含编译和链接所需的信息,例如这个:

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

这个格式相当直接,轻量级,甚至支持基本变量扩展。这就是为什么许多开发者更喜欢它而不是像 CMake 这样的复杂、健壮的解决方案。尽管 PkgConfig 极其易于使用,但其功能却相当有限:

  • 检查系统中是否存在库,并且是否提供了与之一起的.pc文件

  • 检查是否有一个库的足够新版本可用

  • 通过运行pkg-config --libs libfoo获取库的链接器标志

  • 获取库的包含目录(此字段技术上可以包含其他编译器标志)——pkg-config --cflags libfoo

为了在构建场景中正确使用 PkgConfig,您的构建系统需要在操作系统中找到pkg-config可执行文件,运行它几次,并提供适当的参数,然后将响应存储在变量中,以便稍后传递给编译器。在 CMake 中我们已经知道如何做到这一点——扫描已知存储辅助工具的路径以检查是否安装了 PkgConfig,然后使用几个exec_program()命令来发现如何链接依赖项。尽管步骤有限,但似乎每次使用 PkgConfig 时都这样做是过于繁琐的。

幸运的是,CMake 提供了一个方便的内置查找模块,正是为了这个目的——FindPkgConfig。它遵循大多数常规查找模块的规则,但不是提供PKG_CONFIG_INCLUDE_DIRSPKG_CONFIG_LIBS变量,而是设置一个变量,直接指向二进制文件的路径——PKG_CONFIG_EXECUTABLE。不出所料,PKG_CONFIG_FOUND变量也被设置了——我们将使用它来确认系统中是否有这个工具,然后使用模块中定义的pkg_check_modules()帮助命令扫描一个pkg_check_modules()包。

我们来实际看看这个过程。一个提供.pc文件的相对受欢迎的库的一个例子是一个 PostgreSQL 数据库的客户端——libpqxx

为了在 Debian 上安装它,您可以使用libpqxx-dev包(您的操作系统可能需要不同的包):

apt-get install libpqxx-dev

我们将创建一个尽可能短的main.cpp文件,其中包含一个虚拟连接类:

chapter07/02-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;
}

现在我们可以通过使用 PkgConfig 查找模块为之前的代码提供必要的依赖项:

chapter07/03-find-pkg-config/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.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)

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

  • 我们要求 CMake 使用find_package()命令查找 PkgConfig 可执行文件。如果因为REQUIRED关键字而没有pkg-config,它将会失败。

  • FindPkgConfig查找模块中定义的pkg_check_modules()自定义宏被调用,以创建一个名为PQXX的新IMPORTED目标。查找模块将搜索一个名为libpxx的依赖项,同样,因为REQUIRED关键字,如果库不可用,它将会失败。注意IMPORTED_TARGET关键字——没有它,就不会自动创建目标,我们必须手动定义由宏创建的变量。

  • 我们通过打印PQXX_FOUND来确认一切是否正确,并显示诊断信息。如果我们之前没有指定REQUIRED,我们在这里可以检查这个变量是否被设置(也许是为了允许其他备选机制介入)。

  • 我们创建了main可执行文件。

  • 我们链接了由pkg_check_modules()创建的PkgConfig::PQXX IMPORTED目标。注意PkgConfig::是一个常量前缀,PQXX来自传递给该命令的第一个参数。

这是一种相当方便的方法,可以引入尚不支持 CMake 的依赖项。这个查找模块还有其他一些方法和选项;如果你对了解更多感兴趣,我建议你参考官方文档:cmake.org/cmake/help/latest/module/FindPkgConfig.html

查找模块旨在为 CMake 提供一个非常方便的方式来提供有关已安装依赖项的信息。大多数流行的库在所有主要平台上都广泛支持 CMake。那么,当我们想要使用一个还没有专用的查找模块的第三方库时,我们能做些什么呢?

编写你自己的查找模块

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

既然我们已经在上一节中熟悉了libpqxx,那么现在就让我们为它编写一个好的查找模块吧。我们首先在项目中源代码树的cmake/module目录下创建一个新文件FindPQXX.cmake,并开始编写。我们需要确保当调用find_package()时,CMake 能够发现这个查找模块,因此我们将这个路径添加到CMakeLists.txt中的CMAKE_MODULE_PATH变量里,用list(APPEND)。整个列表文件应该看起来像这样:

chapter07/04-find-package-custom/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.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 也不会抱怨一些特定的变量没有被设置(包括PQXX_FOUND),这是查找模块的作者需要尊重 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变量,设置为所需版本。查找模块应该找到适当的版本,或者发出FATAL_ERROR

当然,为了与其他查找模块保持一致性,最好遵循前面的规则。让我们讨论创建一个优雅的PQXX查找模块所需的步骤:

  1. 如果已知库和头文件的路径(要么由用户提供,要么来自之前运行的缓存),使用这些路径并创建一个IMPORTED目标。在此结束。

  2. 否则,请找到嵌套依赖项——PostgreSQL 的库和头文件。

  3. 在已知的路径中搜索 PostgreSQL 客户端库的二进制版本。

  4. 在已知的路径中搜索 PostgreSQL 客户端包含头文件

  5. 检查是否找到了库和包含头文件;如果是,创建一个IMPORTED目标。

创建IMPORTED目标发生了两次——如果用户从命令行提供了库的路径,或者如果它们是自动找到的。我们将从编写一个函数来处理我们搜索过程的结果开始,并保持我们的代码 DRY。

要创建一个IMPORTED目标,我们只需要一个带有IMPORTED关键字的库(以便在CMakeLists.txt中的target_link_libraries()命令中使用它)。该库必须提供一个类型——我们将其标记为UNKNOWN,以表示我们不希望检测找到的库是静态的还是动态的;我们只想为链接器提供一个参数。

接下来,我们将IMPORTED_LOCATIONINTERFACE_INCLUDE_DIRECTORIES``IMPORTED目标的必需属性设置为函数被调用时传递的参数。我们还可以指定其他属性(如COMPILE_DEFINITIONS);它们对于PQXX来说只是不必要的。

在那之后,我们将路径存储在缓存变量中,这样我们就无需再次执行搜索。值得一提的是,PQXX_FOUND在缓存中被显式设置,因此它在全局变量作用域中可见(所以它可以被用户的CMakeLists.txt访问)。

最后,我们将缓存变量标记为高级,这意味着除非启用“高级”选项,否则它们不会在 CMake GUI 中显示。对于这些变量,这是一种常见的做法,我们也应该遵循约定:

chapter07/04-find-package-custom/cmake/module/FindPQXX.cmake

function(add_imported_library 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)的正确路径给我们。

如果配置阶段在过去已经执行过,这个条件也将为真,因为PQXX_LIBRARIESPQXX_INCLUDES变量是被缓存的。

if (PQXX_LIBRARIES AND PQXX_INCLUDES)
  add_imported_library(${PQXX_LIBRARIES} ${PQXX_INCLUDES})
  return()
endif()

是时候找到一些嵌套依赖项了。为了使用PQXX,宿主机器还需要 PostgreSQL。在我们的查找模块中使用另一个查找模块是完全合法的,但我们应该将REQUIREDQUIET标志传递给它(以便嵌套搜索与外层搜索行为一致)。这不是复杂的逻辑,但我们应该尽量避免不必要的代码。

CMake 有一个内置的帮助宏,正是为此而设计——find_dependency()。有趣的是,文档中指出它不适合用于 find-modules,因为它如果在找不到依赖项时调用return()命令。因为这是一个宏(而不是一个函数),return()将退出调用者的作用域,即FindPQXX.cmake文件,停止外层 find-module 的执行。可能有些情况下这是不希望的,但在这个情况下,这正是我们想要做的——阻止 CMake 深入寻找PQXX的组件,因为我们已经知道 PostgreSQL 不可用:

# deliberately used in mind-module against the
  documentation
include(CMakeFindDependencyMacro)
find_dependency(PostgreSQL)

为了找到PQXX库,我们将设置一个_PQXX_DIR帮助变量(转换为 CMake 风格的路径)并使用find_library()命令扫描我们在PATHS关键字之后提供的路径列表。该命令将检查是否有与NAMES关键字之后提供的名称匹配的库二进制文件。如果找到了匹配的文件,其路径将被存储在PQXX_LIBRARY_PATH变量中。否则,该变量将被设置为<VAR>-NOTFOUND,在这种情况下是PQXX_HEADER_PATH-NOTFOUND

NO_DEFAULT_PATH关键字禁用了默认行为,这将扫描 CMake 为该主机环境提供的默认路径列表:

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
)

接下来,我们将使用find_path()命令搜索所有已知的头文件,这个命令的工作方式与find_library()非常相似。主要区别在于find_library()知道库的系统特定扩展,并将这些扩展作为需要自动添加,而对于find_path(),我们需要提供确切的名称。

在这里也不要混淆pqxx/pqxx。这是一个实际的头文件,但库作者故意省略了扩展名,以符合 C++风格#include指令(而不是遵循 C 风格.h扩展名):#include <pqxx/pqxx>

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

现在是检查PQXX_LIBRARY_PATHPQXX_HEADER_PATH变量是否包含任何-NOTFOUND值的时候。同样,我们可以手动进行这项工作,然后根据约定打印诊断信息或终止构建执行,或者我们可以使用 CMake 提供的FindPackageHandleStandardArgs列表文件中的find_package_handle_standard_args()帮助函数。这是一个帮助命令,如果路径变量被填充,则将<PKG_NAME>_FOUND变量设置为1,并提供关于成功和失败的正确诊断信息(它将尊重QUIET关键字)。如果传递了REQUIRED关键字给 find-module,而其中一个提供的路径变量为空,它还将以FATAL_ERROR终止执行。

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

include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(
  PQXX DEFAULT_MSG PQXX_LIBRARY_PATH PQXX_HEADER_PATH
)
if (PQXX_FOUND)
  add_imported_library(
    "${PQXX_LIBRARY_PATH};${POSTGRES_LIBRARIES}"
    "${PQXX_HEADER_PATH};${POSTGRES_INCLUDE_DIRECTORIES}"
  )
endif()

就这些。这个 find-module 将找到PQXX并创建相应的PQXX::PQXX目标。你可以在整个文件中找到这个模块,文件位于书籍示例仓库中:chapter07/04-find-package-custom/cmake/module/FindPQXX.cmake

如果一个库很受欢迎,并且很可能会在系统中已经安装,这种方法非常有效。然而,并非所有的库随时都能获得。我们能否让这个步骤变得简单,让我们的用户使用 CMake 获取和构建这些依赖项?

使用 Git 仓库工作

许多项目依赖于 Git 作为版本控制系统。假设我们的项目和外部库都在使用它,有没有某种 Git 魔法能让我们把这些仓库链接在一起?我们能否构建库的特定(或最新)版本,作为构建我们项目的一个步骤?如果是,怎么做?

通过 Git 子模块提供外部库

一个可能的解决方案是使用 Git 内置的机制,称为Git 子模块。子模块允许项目仓库使用其他 Git 仓库,而实际上不将引用的文件添加到项目仓库中。它们的工作方式与软链接类似——它们指向外部仓库中的特定分支或提交(但你需要显式地更新它们)。要向你的仓库中添加一个子模块(并克隆其仓库),执行以下命令:

git submodule add <repository-url>

如果你拉取了一个已经包含子模块的仓库,你需要初始化它们:

git submodule update --init -- <local-path-to-submodule>

正如你所看到的,这是一个多功能的机制,可以利用第三方代码在我们的解决方案中。一个小缺点是,当用户克隆带有根项目的仓库时,子模块不会自动拉取。需要一个显式的init/pull命令。暂时保留这个想法——我们也会用 CMake 解决它。首先,让我们看看我们如何在代码中使用一个新创建的子模块。

为了这个例子,我决定写一个小程序,从 YAML 文件中读取一个名字,并在欢迎消息中打印出来。YAML 是一种很好的简单格式,用于存储可读的配置,但机器解析起来相当复杂。我找到了一个由 Jesse Beder(及当时 92 名其他贡献者)解决这个问题的整洁小型项目,称为 yaml-cpp(github.com/jbeder/yaml-cpp)。

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

第七章/05-git-submodule-manual/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;
}

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

第七章/05-git-submodule-manual/config.yaml

name: Rafal

让我们回到main.cpp一会儿——它包含了"yaml-cpp/yaml.h"头文件。为了使其可用,我们需要克隆yaml-cpp项目并构建它。让我们创建一个extern目录来存储所有第三方依赖项(如第三章、设置你的第一个 CMake 项目部分中所述)并添加一个 Git 子模块,引用库的仓库:

$ mkdir extern
$ cd extern
$ git submodule add https://github.com/jbeder/yaml-cpp.git
Cloning into 'chapter07/01-git-submodule-manual/extern/yaml-cpp'...
remote: Enumerating objects: 8134, done.
remote: Total 8134 (delta 0), reused 0 (delta 0), pack-reused 8134
Receiving objects: 100% (8134/8134), 3.86 MiB | 3.24 MiB/s, done.
Resolving deltas: 100% (5307/5307), done.

Git 已经克隆了仓库;现在我们可以将其作为项目的依赖项,并让 CMake 负责构建:

chapter07/05-git-submodule-manual/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(GitSubmoduleManual CXX)
add_executable(welcome main.cpp)
configure_file(config.yaml config.yaml COPYONLY)
add_subdirectory(extern/yaml-cpp)
target_link_libraries(welcome PRIVATE yaml-cpp)

让我们分解一下我们在这里给予 CMake 的指令:

  1. 设置项目并添加我们的welcome可执行文件。

  2. 接下来,调用configure_file,但实际上不配置任何内容。通过提供COPYONLY关键字,我们只是将我们的config.yaml复制到构建树中,这样可执行文件在运行时能够找到它。

  3. 添加 yaml-cpp 仓库的子目录。CMake 会将其视为项目的一部分,并递归执行任何嵌套的CMakeLists.txt文件。

  4. 将库提供的yaml-cpp目标与welcome目标链接。

yaml-cpp 的作者遵循在第三章设置你的第一个 CMake 项目》中概述的实践,并将公共头文件存储在单独的目录中——<项目名称>/include/<项目名称>。这允许库的客户(如main.cpp)通过包含"yaml-cpp/yaml.h"库名称的路径来访问这些文件。这种命名实践非常适合发现——我们立即知道是哪个库提供了这个头文件。

正如你所看到的,这并不是一个非常复杂的过程,但它并不理想——用户在克隆仓库后必须手动初始化我们添加的子模块。更糟糕的是,它没有考虑到用户可能已经在他们的系统上安装了这个库。这意味着浪费了下载并构建这个依赖项的过程。一定有更好的方法。

自动初始化 Git 子模块

为用户提供整洁的体验并不总是对开发者来说是痛苦的。如果一个库提供了一个包配置文件,我们只需让find_package()在安装的库中搜索它。正如承诺的那样,CMake 首先检查是否有合适的 find 模块,如果没有,它将寻找配置文件。

我们已经知道,如果<LIB_NAME>_FOUND变量被设置为1,则库被找到,我们可以直接使用它。我们也可以在库未找到时采取行动,并提供方便的解决方法来默默改善用户的体验:退回到获取子模块并从源代码构建库。突然之间,一个新克隆的仓库不自动下载和初始化嵌套子模块的事实看起来并没有那么糟糕,不是吗?

让我们将上一个示例中的代码进行扩展:

chapter07/06-git-submodule-auto/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(GitSubmoduleAuto CXX)
add_executable(welcome main.cpp)
configure_file(config.yaml config.yaml COPYONLY)
find_package(yaml-cpp QUIET)
if (NOT yaml-cpp_FOUND)
  message("yaml-cpp not found, initializing git submodule")
  execute_process(
    COMMAND git submodule update --init -- extern/yaml-cpp
    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
  )
  add_subdirectory(extern/yaml-cpp)
endif()
target_link_libraries(welcome PRIVATE yaml-cpp)

我们添加了高亮显示的行:

  • 我们将尝试悄悄地查找 yaml-cpp 并使用它。

  • 如果它不存在,我们将打印一个简短的诊断信息,并使用execute_process()命令来初始化子模块。这实际上是从引用仓库中克隆文件。

  • 最后,我们将add_subdirectory()用于从源代码构建依赖项。

简短而精炼。这也适用于未使用 CMake 构建的库——我们可以遵循 git submodule 的示例,再次调用 execute_process() 以同样的方式启动任何外部构建工具。

可悲的是,如果您的公司使用 Concurrent Versions System (CVS)、Subversion (SVN)、Mercurial 或任何其他方法向用户提供代码,这种方法就会崩溃。如果您不能依赖 Git submodules,替代方案是什么?

为不使用 Git 的项目克隆依赖项

如果您使用另一个 VCS 或者提供源代码的存档,您可能会在依赖 Git submodules 将外部依赖项引入您的仓库时遇到困难。很有可能是构建您代码的环境安装了 Git 并能执行 git clone 命令。

让我们看看我们应该如何进行:

chapter07/07-git-clone/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(GitClone CXX)
add_executable(welcome main.cpp)
configure_file(config.yaml config.yaml COPYONLY)
find_package(yaml-cpp QUIET)
if (NOT yaml-cpp_FOUND)
  message("yaml-cpp not found, cloning git repository")
  find_package(Git)
  if (NOT Git_FOUND)
    message(FATAL_ERROR "Git not found, can't initialize!")
  endif ()
  execute_process(
    COMMAND ${GIT_EXECUTABLE} clone
    https://github.com/jbeder/yaml-cpp.git
    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/extern
  )  
  add_subdirectory(extern/yaml-cpp)
endif()
target_link_libraries(welcome PRIVATE yaml-cpp)

再次,加粗的行是我们 YAML 项目中的新部分。发生了以下情况:

  1. 首先,我们通过 FindGit 查找模块检查 Git 是否可用。

  2. 如果不可以使用,我们就束手无策了。我们将发出 FATAL_ERROR,并希望用户知道接下来该做什么。

  3. 否则,我们将使用 FindGit 查找模块设置的 GIT_EXECUTABLE 变量调用 execute_process() 并克隆我们感兴趣的仓库。

Git 对于有一定经验的开发者来说尤其有吸引力。它可能适合一个不包含对相同仓库的嵌套引用的小项目。然而,如果确实如此,您可能会发现您可能需要多次克隆和构建同一个项目。如果依赖项目根本不使用 Git,您将需要另一个解决方案。

使用 ExternalProject 和 FetchContent 模块

在线 CMake 参考书籍将建议使用 ExternalProjectFetchContent 模块来处理更复杂项目中依赖项的管理。这实际上是个好建议,但它通常在没有适当上下文的情况下给出。突然之间,我们面临了许多问题。这些模块是做什么的?何时选择一个而不是另一个?它们究竟是如何工作的,以及它们是如何相互作用的?一些答案比其他的更难找到,令人惊讶的是,CMake 的文档没有为该主题提供一个平滑的介绍。不用担心——我们在这里会处理。

外部项目

CMake 3.0.0 引入了一个名为 ExternalProject 的模块。正如您所猜测的,它的目的是为了添加对在线仓库中可用的外部项目的支持。多年来,该模块逐渐扩展以满足不同的需求,最终变得相当复杂的命令——ExternalProject_Add()。我是说复杂——它接受超过 85 个不同的选项。不足为奇,因为它提供了一组令人印象深刻的特性:

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

  • 从 URL 下载源代码(如有需要,从归档中提取)

  • 支持 Git、Subversion、Mercurial 和 CVS 仓库

  • 如有需要,获取更新

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

  • 执行安装和运行测试

  • 记录到文件

  • 从终端请求用户输入

  • 依赖于其他目标

  • 向构建过程中添加自定义命令/步骤

ExternalProject 模块在构建阶段填充依赖项。对于通过 ExternalProject_Add() 添加的每个外部项目,CMake 将执行以下步骤:

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

  2. download – 从仓库或 URL 获取项目文件

  3. update – 在支持差量更新的下载方法中重新运行时更新文件

  4. patch – 可选执行一个补丁命令,用于修改下载文件以满足项目需求

  5. configure – 为 CMake 项目执行配置阶段,或为非 CMake 依赖手动指定命令

  6. build – 为 CMake 项目执行构建阶段,对于其他依赖项,执行 make 命令

  7. install – 安装 CMake 项目,对于其他依赖项,执行 make install 命令

  8. test – 如果定义了任何 TEST_... 选项,则执行依赖项的测试

步骤按照前面的确切顺序进行,除了 test 步骤,该步骤可以通过 TEST_BEFORE_INSTALL <bool>TEST_AFTER_INSTALL <bool> 选项在 install 步骤之前或之后可选地启用。

下载步骤选项

我们主要关注控制 download 步骤或 CMake 如何获取依赖项的选项。首先,我们可能选择不使用 CMake 内置的此方法,而是提供一个自定义命令(在此处支持生成器表达式):

DOWNLOAD_COMMAND <cmd>...

这样做后,我们告诉 CMake 忽略此步骤的所有其他选项,只需执行一个特定于系统的命令。空字符串也被接受,用于禁用此步骤。

从 URL 下载依赖项

我们可以提供一系列 URL,按顺序扫描直到下载成功。CMake 将识别下载文件是否为归档文件,并默认进行解压:

URL <url1> [<url2>...]

其他选项允许我们进一步自定义此方法的行为:

  • URL_HASH <algo>=<hashValue> – 检查通过 <algo> 生成的下载文件的校验和是否与提供的 <hashValue> 匹配。建议确保下载的完整性。支持的算法包括 MD5SHA1SHA224SHA256SHA384SHA512SHA3_224SHA3_256SHA3_384SHA3_512,这些算法由 string(<HASH>) 命令定义。对于 MD5,我们可以使用简写选项 URL_MD5 <md5>

  • 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> – 如果你的公司发行自签名 SSL 证书,这个选项很有用。这个选项提供了一个权威文件的路径;如果没有指定,CMake 将从CMAKE_TLS_CAINFO变量中读取这个设置。

从 Git 下载依赖项

要从 Git 下载依赖项,你需要确保主机安装了 Git 1.6.5 或更高版本。以下选项是克隆 Git 的必要条件:

GIT_REPOSITORY <url>
GIT_TAG <tag>

<url><tag>都应该符合git命令能理解的格式。此外,建议使用特定的 git 哈希,以确保生成的二进制文件可以追溯到特定的提交,并且不会执行不必要的git fetch。如果你坚持使用分支,使用如origin/main的远程名称。这保证了本地克隆的正确同步。

其他选项如下:

  • GIT_REMOTE_NAME <name> – 远程名称,默认为origin

  • GIT_SUBMODULES <module>... – 指定应该更新的子模块。从 3.16 起,这个值默认为无(之前,所有子模块都被更新)。

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

  • GIT_SHALLOW 1 – 执行浅克隆(不下载历史提交)。这个选项推荐用于性能。

  • TLS_VERIFY <bool> – 这个选项在从 URL 下载依赖项部分解释过。它也适用于 Git,并且为了安全起见应该启用。

从 Subversion 下载依赖项

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

SVN_REPOSITORY <url>
SVN_REVISION -r<rev>

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

  • SVN_USERNAME <user>SVN_PASSWORD <password> – 用于检出和更新的凭据。像往常一样,避免在项目中硬编码它们。

  • SVN_TRUST_CERT <bool> – 跳过对 Subversion 服务器证书的验证。只有在你信任网络路径到服务器及其完整性时才使用这个选项。默认是禁用的。

从 Mercurial 下载依赖项

这种模式非常直接。我们需要提供两个选项,就完成了:

HG_REPOSITORY <url>
HG_TAG <tag>

从 CVS 下载依赖项

要从 CVS 检出模块,我们需要提供这三个选项:

CVS_REPOSITORY <cvsroot>
CVS_MODULE <module>
CVS_TAG <tag>

更新步骤选项

默认情况下,update步骤如果支持更新,将会重新下载外部项目的文件。我们可以用两种方式覆盖这个行为:

  • 提供一个自定义命令,在更新期间执行UPDATE_COMMAND <cmd>

  • 完全禁用update步骤(允许在断开网络的情况下构建)– UPDATE_DISCONNECTED <bool>。请注意,第一次构建期间的download步骤仍然会发生。

修补步骤选项

Patch是一个可选步骤,在源代码获取后执行。要启用它,我们需要指定我们要执行的确切命令:

PATCH_COMMAND <cmd>...

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

重要提示

前面提到的选项列表只包含最常用的条目。确保参考官方文档以获取更多详细信息和描述其他步骤的选项:cmake.org/cmake/help/latest/module/ExternalProject.html

在实际中使用 ExternalProject

依赖项在构建阶段被填充非常重要,它有两个效果——项目的命名空间完全分离,任何外部项目定义的目标在主项目中不可见。后者尤其痛苦,因为我们在使用find_package()命令后不能以同样的方式使用target_link_libraries()。这是因为两个配置阶段的分离。主项目必须完成配置阶段并开始构建阶段,然后依赖项才能下载并配置。这是一个问题,但我们将学习如何处理第二个。现在,让我们看看ExternalProject_Add()如何与我们在 previous examples 中使用的 yaml-cpp 库工作:

chapter07/08-external-project-git/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(ExternalProjectGit CXX)
add_executable(welcome main.cpp)
configure_file(config.yaml config.yaml COPYONLY)
include(ExternalProject)
ExternalProject_Add(external-yaml-cpp
  GIT_REPOSITORY    https://github.com/jbeder/yaml-cpp.git
  GIT_TAG           yaml-cpp-0.6.3
)
target_link_libraries(welcome PRIVATE yaml-cpp)

构建该项目采取以下步骤:

  • 我们包含了ExternalProject模块以访问其功能。

  • 我们调用了FindExternalProject_Add()命令,该命令将构建阶段任务为下载必要文件,并在我们的系统中配置、构建和安装依赖项。

我们需要小心这里,并理解这个例子之所以能工作,是因为 yaml-cpp 库在其CMakeLists.txt中定义了一个安装阶段。这个阶段将库文件复制到系统中的标准位置。target_link_libraries()命令中的yaml-cpp参数被 CMake 解释为直接传递给链接器的参数——-lyaml-cpp。这个行为与之前的例子不同,在那里我们明确定义了yaml-cpp目标。如果库不提供安装阶段(或者二进制版本的名字不匹配),链接器将抛出错误。

在此之际,我们应该更深入地探讨每个阶段的配置,并解释如何使用不同的下载方法。我们将在FetchContent部分讨论这些问题,但首先,让我们回到讨论ExternalProject导致的依赖项晚获取问题。我们不能在外部项目被获取的时候使用它们的目标,因为编译阶段已经结束了。CMake 将通过将其标记为特殊的UTILITY类型来显式保护使用FindExternalProject_Add()创建的目标。当你错误地尝试在主项目中使用这样一个目标(也许是为了链接它)时,CMake 将抛出一个错误:

Target "external-yaml-cpp-build" of type UTILITY may not be linked into another target.

为了绕过这个限制,技术上我们可以创建另一个目标,一个IMPORTED库,然后使用它(就像我们在这个章节前面用FindPQXX.cmake做的那样)。但这实在太麻烦了。更糟糕的是,CMake 实际上理解外部 CMake 项目创建的目标(因为它在构建它们)。在主项目中重复这些声明不会是一个非常 DRY 的做法。

另一个可能的解决方案是将整个依赖项的获取和构建提取到一个独立的子项目中,并在配置阶段构建该子项目。要实现这一点,我们需要用execute_process()启动 CMake 的另一个实例。通过一些技巧和add_subdirectory()命令,我们随后可以将这个子项目的列表文件和二进制文件合并到主项目中。这种方法(有时被称为超级构建)过时且不必要的复杂。在这里我不详细说明,因为对初学者来说没有太大用处。如果你好奇,可以阅读 Craig Scott 这篇很好的文章:crascit.com/2015/07/25/cmake-gtest/

总之,当项目间存在命名空间冲突时,ExternalProject可以帮我们摆脱困境,但在其他所有情况下,FetchContent都远远优于它。让我们来找出为什么。

FetchContent

现在,建议使用FetchContent模块来导入外部项目。这个模块自 CMake 3.11 版本以来一直可用,但我们建议至少使用 3.14 版本才能有效地与之工作。

本质上,它是一个高级别的ExternalProject包装器,提供类似的功能和更多功能。关键区别在于执行阶段——与ExternalProject不同,FetchContent在配置阶段填充依赖项,将外部项目声明的所有目标带到主项目的范围内。这样,我们可以像定义自己的目标一样精确地使用它们。

使用FetchContent模块需要三个步骤:

  1. 将模块包含在你的项目中,使用include(FetchModule)

  2. 使用FetchContent_Declare()命令配置依赖项。

  3. 使用FetchContent_MakeAvailable()命令填充依赖项——下载、构建、安装,并将其列表文件添加到主项目中并解析。

你可能会问自己为什么DeclareMakeAvailable命令被分开。这是为了在层次化项目中启用配置覆盖。这是一个场景——一个父项目依赖于AB外部库。A库也依赖于B,但A库的作者仍在使用与父项目不同的旧版本(图 7.1):

图 7.1 —— 层次化项目

图 7.1 —— 层次化项目

而且,对MakeAvailable的依赖既不能配置也不能填充依赖,因为要覆盖A库中的版本,父项目将被迫无论在A库中最终是否需要,都要填充依赖。

由于有了单独的配置步骤,我们能够为父项目指定一个版本,并在所有子项目和依赖项中使用它:

FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  # release-1.11.0
  GIT_TAG        e2239ee6043f73722e7aa812a459f54a28552929 
)

任何后续调用FetchContent_Declare(),以googletest作为第一个参数,都将被忽略,以允许层次结构最高的项目决定如何处理这个依赖。

FetchContent_Declare()命令的签名与ExternalProject_Add()完全相同:

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

这并非巧合——这些参数会被 CMake 存储,直到调用FetchContent_MakeAvailable()并且需要填充时才会传递。然后,内部会将这些参数传递给ExternalProject_Add()命令。然而,并非所有的选项都是允许的。我们可以指定downloadupdatepatch步骤的任何选项,但不能是configurebuildinstalltest步骤。

当配置就绪后,我们会像这样填充依赖项:

FetchContent_MakeAvailable(<depName>)

这将下载文件并读取目标到项目中,但在这次调用中实际发生了什么?FetchContent_MakeAvailable()是在 CMake 3.14 中添加的,以将最常用的场景封装在一个命令中。在图 7.2中,你可以看到这个过程的详细信息:

  1. 调用FetchContent_GetProperties(),从全局变量将FetchContent_Declare()设置的配置从全局变量传递到局部变量。

  2. 检查(不区分大小写)是否已经为具有此名称的依赖项进行了填充,以避免重复下载。如果是,就在这里停止。

  3. 调用FetchContent_Populate()。它会配置包装的ExternalProject模块,通过传递我们设置的(但跳过禁用的)选项并下载依赖项。它还会设置一些变量,以防止后续调用重新下载,并将必要的路径传递给下一个命令。

  4. 最后,add_subdirectory()带着源和构建树作为参数调用,告诉父项目列表文件在哪里以及构建工件应放在哪里。

通过调用add_subdirectory(),CMake 实际上执行了获取项目的配置阶段,并在当前作用域中检索那里定义的任何目标。多么方便!

图 7.2 – FetchContent_MakeAvailable()如何包装对 ExternalProject 的调用

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_7.2_B17205.jpg)

图 7.2 – FetchContent_MakeAvailable()如何包装对 ExternalProject 的调用

显然,我们可能遇到两个无关项目声明具有相同名称的目标的情况。这是一个只能通过回退到ExternalProject或其他方法来解决的问题。幸运的是,这种情况并不经常发生。

为了使这个解释完整,它必须与一个实际例子相补充。让我们看看当我们将FetchContent更改为FetchContent时,前一部分的列表文件是如何变化的:

chapter07/09-fetch-content/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.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           yaml-cpp-0.6.3
)
FetchContent_MakeAvailable(external-yaml-cpp)
target_link_libraries(welcome PRIVATE yaml-cpp)

ExternalProject_Add直接被FetchContent_Declare替换,我们还添加了另一个命令——FetchContent_MakeAvailable。代码的变化微乎其微,但实际的区别却很大!我们可以明确地访问由 yaml-cpp 库创建的目标。为了证明这一点,我们将使用CMakePrintHelpers帮助模块,并向之前的文件添加这些行:

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

现在,配置阶段将打印以下输出:

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

目标存在;它是一个静态库,其源代码目录位于构建树内部。使用相同的助手在ExternalProject示例中调试目标简单地返回:

No such TARGET "yaml-cpp" !

在配置阶段目标没有被识别。这就是为什么FetchContent要好得多,并且应该尽可能地在任何地方使用。

总结

当我们使用现代的、得到良好支持的项目时,管理依赖关系并不复杂。在大多数情况下,我们只需依赖系统中有可用的库,如果没有就回退到FetchContent。如果依赖项相对较小且易于构建,这种方法是合适的。

对于一些非常大的库(如 Qt),从源代码构建会花费大量时间。为了在这些情况下提供自动依赖解析,我们不得不求助于提供与用户环境匹配的库编译版本的包管理器。像 Apt 或 Conan 这样的外部工具超出了本书的范围,因为它们要么太依赖于系统,要么太复杂。

好消息是,大多数用户知道如何安装您的项目可能需要的依赖项,只要您为他们提供清晰的指示即可。从这一章,您已经知道如何使用 CMake 的 find-modules 检测系统中的包,以及库捆绑的配置文件。

我们还了解到,如果一个库有点旧,不支持 CMake,但 distribution 中包含.pc文件,我们可以依靠 PkgConfig 工具和随 CMake 捆绑的FindPkgConfig查找模块。我们可以期待,当使用上述任一方法找到库时,CMake 会自动创建构建目标,这是方便且优雅的。我们还讨论了依赖 Git 及其子模块和克隆整个仓库的方法。当其他方法不适用或实施起来不切实际时,这种方法非常有用。

最后,我们探讨了ExternalProject模块及其功能和限制。我们研究了FetchContent如何扩展ExternalProject模块,它与模块有哪些共同之处,与模块有何不同,以及为什么FetchContent更优越。

现在你已准备好在你的项目中使用常规库;然而,我们还应该覆盖另一种类型的依赖——测试框架。每个认真的项目都需要 Correctness testing,而 CMake 是一个很好的工具来自动化这一过程。我们将在下一章学习如何操作。

深入阅读

关于本章涵盖的主题的更多信息,你可以参考以下内容:

第三部分:使用 CMake 自动化

完成前面的章节后,你已经变成了一个能够使用 CMake 构建各种项目的自给自足的构建工程师。成为 CMake 专家的最后一个步骤是学习如何引入和自动化各种质量检查,并为协作工作和发布做好准备。在大型公司内部开发的高质量项目往往共享同样的理念:自动化耗竭心灵能量的重复性任务,以便重要决策得以实施。

为了实现这一点,我们利用 CMake 生态系统的力量,添加构建过程中进行的所有测试:代码风格检查、单元测试以及我们解决方案的静态和动态分析。我们还将通过使用工具来简化文档过程,生成漂亮的网页,并且我们将打包和安装我们的项目,使其消费变得轻而易举,无论是对于其他开发者还是最终用户。

作为总结,我们将把我们所学的所有内容整合成一个连贯的单元:一个能够经受住时间考验的专业项目。

本节包括以下章节:

  • 第八章,测试框架

  • 第九章,程序分析工具

  • 第十章,生成文档

  • 第十一章,安装和打包

  • 第十二章,创建你的专业项目

第八章:测试框架

有经验的专家知道测试必须自动化。有人向他们解释了这一点,或者他们通过艰苦的方式学到了。这种做法对于没有经验的程序员来说并不那么明显:它似乎是不必要的,额外的工作,并不会带来太多价值。难怪:当某人刚开始编写代码时,他们会避免编写复杂的解决方案和为庞大的代码库做出贡献。他们很可能是他们宠物项目的唯一开发者。这些早期的项目通常需要不到几个月就能完成,所以几乎没有任何机会看到代码在更长时间内是如何变质的。

所有这些因素共同构成了编写测试是浪费时间和精力的观念。编程实习生可能会对自己说,每次执行“构建-运行”流程时,他们实际上确实测试了他们的代码。毕竟,他们已经手动确认了他们的代码可以工作,并且做到了预期。现在是时候转向下一个任务了,对吧?

自动化测试确保新的更改不会意外地破坏我们的程序。在本章中,我们将学习测试的重要性以及如何使用与 CMake 捆绑的 CTest 工具来协调测试执行。CTest 能够查询可用的测试、过滤执行、洗牌、重复和限制时间。我们将探讨如何使用这些特性、控制 CTest 的输出以及处理测试失败。

接下来,我们将调整我们项目的结构以支持测试,并创建我们自己的测试运行器。在讨论基本原理之后,我们将继续添加流行的测试框架:Catch2 和 GoogleTest 及其模拟库。最后,我们将介绍使用 LCOV 进行详细测试覆盖率报告。

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

  • 自动化测试为什么值得麻烦?

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

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

  • 单元测试框架

  • 生成测试覆盖率报告

技术要求

您可以在 GitHub 上的以下链接找到本章中存在的代码文件:

github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter08

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

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

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

自动化测试为什么值得麻烦?

想象一个工厂生产线,有一个机器在钢板上打孔。这些孔必须具有特定的尺寸和形状,以便它们可以容纳将最终产品固定的螺栓。这样一个工厂线的设计者会设置机器,测试孔是否正确,然后继续。迟早,一些东西会改变:工厂会使用不同、更厚的钢材;工人可能会意外地改变孔的大小;或者,简单地说,需要打更多的孔,机器必须升级。一个聪明的设计师会在生产线的某些点上设置质量控制检查,以确保产品遵循规格并保持其关键特性。孔必须符合特定的要求,但它们是如何产生的并不重要:钻孔、冲孔还是激光切割。

同样的方法在软件开发中也得到了应用:很难预测哪些代码将保持多年不变,哪些代码将经历多次修订。随着软件功能的扩展,我们需要确保我们不会意外地破坏东西。但是,我们还是会犯错。即使是最优秀的程序员也会犯错,因为他们无法预见他们所做的每一处改动的全部影响。更不用说,开发者经常在别人编写的代码上工作,他们不知道之前做出了哪些微妙的假设。他们会阅读代码,建立一个粗略的心理模型,添加必要的改动,并希望他们做对了。大多数时候,这是真的——直到它不再是。在这种情况下,引入的错误可能需要花费数小时甚至数天来修复,更不用说它可能对产品和客户造成的损害。

偶尔,你可能会遇到一些非常难以理解和跟进去的代码。你不仅会质疑这段代码是如何产生的以及它做了什么,你还会开始追查谁应该为创造这样的混乱负责。如果你发现自己是作者,也别太惊讶。这曾经发生在我身上,也会发生在你身上。有时候,代码是在匆忙中编写的,没有完全理解问题。作为开发者,我们不仅受到截止日期或预算的压力。半夜被叫醒修复一个关键故障,你会对某些错误如何逃过代码审查感到震惊。

大多数这些问题都可以通过自动化测试来避免。这些测试代码用于检查另一段代码(即生产中使用的代码)是否正确运行。正如其名,自动化测试应该在每次有人做出改动时无需提示地执行。这通常作为构建过程的一部分发生,并且经常作为控制代码质量的一个步骤,在将其合并到仓库之前执行。

你可能会有避免自动化测试以节省时间的冲动。这将是一个非常昂贵的教训。史蒂文·赖特(Steven Wright)说得对:“经验是你需要的经验之后才得到的。”相信我:除非你正在为个人目的编写一次性脚本,或者为非生产性原型编写脚本,否则不要跳过编写测试。最初,你可能会因为自己精心编写的代码不断在测试中失败而感到烦恼。但如果你真的思考一下,那个失败的测试刚刚阻止了你将一个破坏性更改推送到生产环境中。现在投入的努力将在节省修复 bug(和完整的夜晚睡眠)方面得到回报。测试并不像看起来那么难以添加和维护。

使用 CTest 在 CMake 中标准化测试

最终,自动化测试涉及到的不过是运行一个可执行文件,设置你的 test_my_app,另一个将使用 unit_tests,第三个将使用一些不明显或者根本不提供测试的文件。找出需要运行哪个文件,使用哪个框架,向运行器传递哪些参数,以及如何收集结果是用户希望避免的问题。

CMake 通过引入一个独立的 ctest 命令行工具来解决这个问题。它由项目作者通过列表文件进行配置,并为执行测试提供了一个统一的方式:对于使用 CMake 构建的每个项目,都有一个相同的、标准化的接口。如果你遵循这个约定,你将享受其他好处:将项目添加到(CI/CD)流水线将更容易,在诸如 Visual Studio 或 CLion 等(IDE)中突出显示它们——所有这些事情都将得到简化,更加方便。更重要的是,你将用非常少的投入获得一个更强大的测试运行工具。

如何在一个已经配置的项目上使用 CTest 执行测试?我们需要选择以下三种操作模式之一:

  • 测试

  • 构建与测试

  • 仪表板客户端

最后一种模式允许您将测试结果发送到一个名为 CDash 的单独工具(也来自 Kitware)。CDash 通过一个易于导航的仪表板收集和汇总软件质量测试结果,如下面的屏幕截图所示:

图 8.1 ‒ CDash 仪表板时间轴视图的屏幕截图

图 8.1 ‒ CDash 仪表板时间轴视图的屏幕截图

CDash 不在本书的范围内,因为它是作为共享服务器的高级解决方案,可供公司中的所有开发者访问。

注意

如果你有兴趣在线学习,请参考 CMake 的官方文档并访问 CDash 网站:

cmake.org/cmake/help/latest/manual/ctest.1.html#dashboard-client

www.cdash.org/

让我们回到前两种模式。测试模式的命令行如下所示:

ctest [<options>]

在这种模式下,应在构建树中执行 CTest,在用cmake构建项目之后。在开发周期中,这有点繁琐,因为您需要执行多个命令并来回更改工作目录。为了简化这个过程,CTest 增加了一个第二种模式:build-and-test模式。

构建和测试模式

要使用此模式,我们需要以--build-and-test开始执行ctest,如下所示:

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

本质上,这是一个简单的包装器,它围绕常规测试模式接受一些构建配置选项,并允许我们添加第一个模式下的命令——换句话说,所有可以传递给ctest <options>的选项,在传递给ctest --build-and-test时也会生效。这里唯一的要求是在--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之前,这是最后一个参数。

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

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

  • --build-generator-platform, --build-generator-toolset—提供生成器特定的平台和工具集。

  • --build-makeprogram—在使用 Make 或 Ninja 生成器时指定make可执行文件。

以下是控制构建阶段的参数:

  • --build-target—构建指定的目标(而不是all目标)。

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

  • --build-project—提供构建项目的名称。

这是用于控制测试阶段的参数:

  • --test-timeout—限制测试的执行时间(以秒为单位)。

剩下的就是在--test-command cmake参数之后配置常规测试模式。

测试模式

假设我们已经构建了我们的项目,并且我们在构建树中执行ctest(或者我们使用build-and-test包装器),我们最终可以执行我们的测试。

在没有任何参数的情况下,一个简单的ctest命令通常足以在大多数场景中获得满意的结果。如果所有测试都通过,ctest将返回一个0的退出码。您可以在 CI/CD 管道中使用此命令,以防止有错误的提交合并到您仓库的生产分支。

编写好的测试可能和编写生产代码本身一样具有挑战性。我们将 SUT 设置为特定的状态,运行一个测试,然后拆除 SUT 实例。这个过程相当复杂,可能会产生各种问题:跨测试污染、时间和并发干扰、资源争用、由于死锁而导致的执行冻结,以及其他许多问题。

我们可以采用一些策略来帮助检测和解决这些问题。CTest 允许你影响测试选择、它们的顺序、产生的输出、时间限制、重复等等。以下部分将提供必要的上下文和对最有用选项的简要概述。像往常一样,请参阅 CMake 文档以获取详尽的列表。

查询测试

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

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

你可能想用下一节中描述的筛选器与-N一起使用,以检查当应用筛选器时会执行哪些测试。

如果你需要一个可以被自动化工具消费的 JSON 格式,请用--show-only=json-v1执行ctest

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 <正则表达式>—跳过标签匹配<r>的测试

使用--tests-information选项(或更短的形式,-I)可以实现高级场景。用这个筛选器提供一个逗号分隔的范围内的值:<开始>, <结束>, <步长>。任意字段都可以为空,再有一个逗号之后,你可以附加个别<测试 ID>值来运行它们。以下是几个例子:

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

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

  • -I 2,,3将从第二行开始运行每个第三测试

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

选择性地,CTest 将接受包含规格的文件名,格式与上面相同。正如您所想象的,用户更喜欢按名称过滤测试。此选项可用于将测试分布到多台机器上,适用于非常大的测试套件。

默认情况下,与-R一起使用的-I选项将缩小执行范围(仅运行同时满足两个要求的测试)。如果您需要两个要求的并集来执行(任一要求即可),请添加-U选项。

如前所述,您可以使用-N选项来检查过滤结果。

洗牌测试

编写单元测试可能很棘手。遇到的一个更令人惊讶的问题就是测试耦合,这是一种情况,其中一个测试通过不完全设置或清除 SUT 的状态来影响另一个测试。换句话说,首先执行的测试可能会“泄漏”其状态,污染第二个测试。这种耦合之所以糟糕,是因为它引入了测试之间的未知、隐性关系。

更糟糕的是,这种错误在测试场景的复杂性中隐藏得非常好。我们可能会在它导致测试失败时检测到它,但反之亦然:错误的状态导致测试通过,而它本不该通过。这种虚假通过的测试给开发者带来了安全感,这比没有测试还要糟糕。代码正确测试的假设可能会鼓励更大胆的行动,导致意外的结果。

发现此类问题的一种方法是单独运行每个测试。通常,当我们直接从测试框架中执行测试运行器而不使用 CTest 时,并非如此。要运行单个测试,您需要向测试可执行文件传递框架特定的参数。这允许您检测在测试套件中通过但在单独执行时失败的测试。

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

不幸的是,仅凭这一点还不足以应对测试使用的外部、争用资源,如 GPU、数据库或文件。我们可以采取的额外预防措施之一是简单地随机化测试执行顺序。这种干扰通常足以最终检测到这种虚假通过的测试。CTest 支持这种策略,通过--schedule-random选项。

处理失败

这里有一句约翰·C· Maxwell 著名的名言:“Fail early, fail often, but always fail forward.” 这正是我们在执行单元测试时(也许在生活的其他领域)想要做的事情。除非你在运行测试时附带了调试器,否则很难了解到你在哪里出了错,因为 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选项。

重复执行测试

迟早在你的职业生涯中,你将会遇到那些大部分时间都能正确工作的测试。我想强调一下most这个词。偶尔,这些测试会因为环境原因而失败:由于错误地模拟了时间、事件循环问题、异步执行处理不当、并发性、散列冲突,以及其他在每次运行时都不会发生的非常复杂的情况。这些不可靠的测试被称为“flaky tests”。

这种不一致性看起来并不是一个很重要的问题。我们可能会说测试并不等同于真正的生产环境,这也是它们有时候会失败的根本原因。这种说法有一定的道理:测试不可能模拟每一个细节,因为这并不可行。测试是一种模拟,是对可能发生的事情的一种近似,这通常已经足够好了。如果测试在下次执行时会通过,重新运行测试有什么害处呢?

实际上,这是有关系的。主要有三个担忧,如下所述:

  • 如果你在你的代码库中收集了足够的不稳定测试,它们将成为代码变更顺利交付的一个严重障碍。尤其是当你急于回家(比如周五下午)或交付一个严重影响客户问题的紧急修复时,这种情况尤其令人沮丧。

  • 你无法真正确信你的不稳定测试之所以失败是因为测试环境的不足。可能正好相反:它们失败是因为它们复现了一个在生产环境中已经发生的罕见场景。只是还没有足够明显地发出警报… 而已。

  • 不是测试本身具有不稳定性——是你的代码有问题!环境有时确实会出问题——作为程序员,我们以确定性的方式处理这些问题。如果 SUT 以这种方式运行,这是一个严重错误的迹象——例如,代码可能正在读取未初始化的内存。

没有一种完美的方式来解决所有上述情况——可能的原因太多。然而,我们可以通过使用–repeat <mode>:<#>选项来重复运行测试,从而增加我们识别不稳定测试的机会。以下是三种可供选择的模式:

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

  • until-pass—运行测试至<#>次;至少要通过一次。当处理已知具有不稳定性的测试时,这个方法很有用,但这些测试太难且重要,无法进行调试或禁用。

  • after-timeout—运行测试至<#>次,但只有在测试超时的情况下才重试。在繁忙的测试环境中使用它。

一般建议尽快调试不稳定测试或如果它们不能被信任以产生一致的结果,就摆脱它们。

控制输出

每次都将所有信息打印到屏幕上会立即变得非常繁忙。Ctest 减少了噪音,并将它执行的测试的输出收集到日志文件中,在常规运行中只提供最有用的信息。当事情变坏,测试失败时,如果你启用了--output-on-failure(如前面所述),你可以期待一个摘要,可能还有一些日志。

我从经验中知道,“足够的信息”是足够的,直到它不再足够。有时,我们可能希望查看通过测试的输出,也许是为了检查它们是否真的在正常工作(而不是默默地停止,没有错误)。为了获取更详细的输出,可以添加-V选项(或者如果你想在自动化管道中明确表示,可以使用--verbose)。如果这还不够,你可能想要-VV--extra-verbose。对于非常深入的调试,可以使用--debug(但要做好准备,因为会有很多文本细节)。

如果你在寻找相反的,CTest 还提供了通过-Q启用的“禅模式”,或--quiet。那时将不会打印任何输出(你可以停止担心,学会平静)。似乎这个选项除了让人困惑之外没有其他用途,但请注意,输出仍然会存储在测试文件中(默认在./Testing/Temporary中)。自动化管道可以通过检查退出代码是否非零值,并在不向开发者输出可能混淆的详细信息的情况下,收集日志文件进行进一步处理。

要将在特定路径存储日志,请使用-O <文件>--output-log <文件>选项。如果您苦于输出过长,有两个限制选项可以将它们限制为每个测试给定的字节数:--test-output-size-passed <大小>--test-output-size-failed <大小>

杂项

还有一些其他的有用选项,可以满足你日常测试需求,如下所述:

  • -C <配置>, --build-config <配置>(仅限多配置生成器)—使用此选项指定要测试的配置。Debug配置通常包含调试符号,使事情更容易理解,但Release也应该测试,因为强烈的优化选项可能会潜在地影响 SUT 的行为。

  • -j <作业数>, --parallel <作业数>—这设置了并行执行的测试数量。在开发过程中,它非常有用,可以加快长测试的执行。请注意,在一个繁忙的环境中(在共享的测试运行器上),它可能会因调度而产生不利影响。这可以通过下一个选项稍微缓解。

  • --test-load <级别>—使用此选项以一种方式安排并行测试,使 CPU 负载不超过<级别>值(尽最大努力)。

  • --timeout <秒>—使用此选项指定单个测试的默认时间限制。

既然我们已经了解了如何在许多不同场景下执行ctest,那么让我们学习如何添加一个简单的测试。

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

技术上讲,编写单元测试可以在没有任何框架的情况下进行。我们只需要做的是创建一个我们想要测试的类的实例,执行其一种方法,并检查返回的新状态或值是否符合我们的期望。然后,我们报告结果并删除被测试对象。让我们试一试。

我们将使用以下结构:

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

main.cpp开始,我们可以看到它将使用一个Calc类,如下面的代码片段所示:

chapter08/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 如下:

chapter08/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——它的工作方式与常见的预处理器包含保护符完全一样,尽管它不是官方标准的一部分,但几乎所有现代编译器都能理解。让我们看看类的实现,如下所示:

chapter08/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的平方。这应该被正确编写的单元测试检测到。所以,让我们写一些!开始吧:

chapter08/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消息。

是时候创建一个测试运行器了。我们的将会尽可能简单,因为正确地做这将需要大量的工作。 just look at the main() function we had to write in order to run just two tests:

chapter08/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();
}

下面是这里发生的事情的分解:

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

  • 如果没有提供任何参数,执行两个测试(argv[]中的零元素总是程序名)。

  • 如果第一个参数是测试的标识符,执行它。

  • 如果有任何测试失败,它内部调用exit()并返回1退出码。

  • 如果没有执行任何测试或所有测试都通过,它隐式地返回0退出码。

要运行第一个测试,我们将执行./unit_tests 1;要运行第二个,我们将执行./unit_tests 2。我们尽可能简化代码,但它仍然变得相当难以阅读。任何可能需要维护这一部分的人在添加更多测试后都不会有很好的时光,更不用说这个功能相当原始——调试这样一个测试套件将是一项艰巨的工作。尽管如此,让我们看看我们如何使用它与 CTest,如下所示:

chapter08/01-no-framework/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(NoFrameworkTests CXX)
enable_testing()
add_subdirectory(src bin)
add_subdirectory(test)

我们从常用的标题和enable_testing()开始。这是为了告诉 CTest 我们想在当前目录及其子目录中启用测试。接下来,我们在每个子目录中包含两个嵌套的列表文件:srctest。高亮的bin值表示我们希望src子目录的二进制输出放在<build_tree>/bin中。否则,二进制文件将出现在<build_tree>/src中,这可能会引起混淆。毕竟,构建工件不再是源文件。

src目录的列表文件非常直接,包含一个简单的main目标定义,如下所示:

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

add_executable(main main.cpp calc.cpp)

我们还需要为test目录编写一个列表文件,如下所示:

chapter08/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实现文件和相应的头文件。最后,我们明确添加了两个测试:SumAddsTwoIntsMultiplyMultipliesTwoInts。每个都将其 ID 作为add_test()命令的参数。CTest将简单地取COMMAND关键字之后提供的一切,并在子壳中执行它,收集输出和退出代码。不要对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返回的值没有达到预期。非常好。我们现在知道我们的代码有一个错误,有人应该修复它。

注意

你可能注意到,在迄今为止的大多数例子中,我们并没有一定使用在第第三章设置你的第一个 CMake 项目中描述的项目结构。这是为了保持事情的简洁。本章讨论更多高级概念,因此使用完整的结构是合适的。在你的项目中(无论多么小),最好从一开始就遵循这个结构。正如一个智者曾经说过:“你踏上道路,如果你不保持你的脚步,你不知道会被冲到哪里.

众所周知,你应避免在项目中构建测试框架。即使是最基础的例子也会让人眼睛疲劳,开销很大,并且没有增加任何价值。然而,在我们采用单元测试框架之前,我们需要重新思考项目的结构。

为测试搭建项目结构

C++具有一些有限的内省能力,但无法提供像 Java 那样的强大回顾功能。这可能正是编写 C++代码的测试和单元测试框架比在其他更丰富的环境中困难的原因。这种经济方法的含义之一是程序员必须更参与构造可测试代码。我们不仅要更仔细地设计我们的接口,还要回答关于实践问题,例如:我们如何避免编译双重,并在测试和生产之间重用工件?

编译时间对于小型项目可能不是一个问题,但随着时间推移,项目会增长。对于短编译循环的需求并不会消失。在之前的例子中,我们将所有sut源文件附加到单元测试可执行文件中,除了main.cpp文件。如果你仔细阅读,你会发现我们在这个文件中有些代码是没有被测试的(main()本身的内容)。通过编译代码两次,产生的工件可能不会完全相同。这些事物可能会随着时间的推移而逐渐偏离(由于添加了编译标志和预处理器指令)。当工程师匆忙、缺乏经验或不熟悉项目时,这可能尤其危险。

处理这个问题有多种方法,但最优雅的方法是将整个解决方案构建为一个库,并与单元测试链接。你可能会问:“我们怎么运行它呢?”我们需要一个引导可执行文件,它将链接库并运行其代码。

首先,将您当前的main()函数重命名为run()start_program()或类似名称。然后,创建另一个实现文件(bootstrap.cpp),其中包含一个新的main()函数,仅此而已。这将成为我们的适配器(或者说是包装器):它的唯一作用是提供一个入口点并调用run()转发命令行参数(如果有)。剩下的就是将所有内容链接在一起,这样我们就有了一个可测试的项目。

通过重命名main(),我们现在可以链接被测试的系统(SUT)和测试,并且还能测试它的主要功能。否则,我们就违反了main()函数。正如第六章“为测试分离 main()”部分所承诺的,我们将详细解释这个主题。

测试框架可能提供自己的main()函数实现,所以我们不需要编写。通常,它会检测我们链接的所有测试,并根据所需配置执行它们。

这种方法产生的工件可以分为以下目标:

  • 带有生产代码的sut

  • bootstrap带有main()包装器,调用sut中的run()

  • 带有main()包装器,运行所有sut测试的单元测试

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

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

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

我们最终会得到六个实现文件,它们将生成各自的(.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 依赖动态链接来节省几秒钟是可能的,但往往我们同时在两个目标上进行更改:testssut,抵消了任何潜在的收益。

让我们看看我们的文件有哪些变化,首先是从先前命名为main.cpp的文件开始,如下所示:

chapter08/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()函数这样做。

新的main()函数看起来像这样:

chapter08/02-structured/src/bootstrap.cpp

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

尽可能简单——我们声明链接器将从另一个翻译单元提供run()函数,并且我们调用它。接下来需要更改的是src列表文件,您可以看到这里:

chapter08/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的链接引用作为unit_tests目标。我们还将为run_test.cpp文件中的主函数添加一个新测试。为了简洁起见,我们将跳过讨论那个部分,但如果您感兴趣,可以查看在线示例。同时,这是整个test列表文件:

chapter08/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)

我们还应该注册新的测试,如下所示:

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,而不是bootstrap,将lib_foo命名为sut

既然我们已经知道如何在一个适当的目标中结构一个可测试的项目,那么让我们将重点转移到测试框架本身。我们不想手动将每个测试用例添加到我们的列表文件中,对吧?

单元测试框架

上一节证明了编写一个微小的单元测试驱动并不非常复杂。它可能不够美观,但信不信由你,专业开发者实际上确实喜欢重新发明轮子(他们的轮子会更漂亮、更圆、更快)。不要陷入这个陷阱:你会创建出如此多的模板代码,它可能成为一个独立的项目。将一个流行的单元测试框架引入你的解决方案中,可以使它符合超越项目和公司的标准,并为你带来免费的更新和扩展。你没有损失。

我们如何将单元测试框架添加到我们的项目中呢?嗯,根据所选框架的规则在实现文件中编写测试,并将这些测试与框架提供的测试运行器链接起来。测试运行器是您的入口点,将启动所选测试的执行。与我们在本章早些时候看到的基本的unit_tests.cpp文件不同,许多它们将自动检测所有测试。太美了。

本章我决定介绍两个单元测试框架。我选择它们的原因如下:

  • Catch2 是一个相对容易学习、得到良好支持和文档的项目。它提供了简单的测试用例,但同时也提供了用于行为驱动开发BDD)的优雅宏。它缺少一些功能,但在需要时可以与外部工具配合使用。您可以在这里访问其主页:github.com/catchorg/Catch2

  • GTest 也非常方便,但功能更加强大。它的关键特性是一组丰富的断言、用户定义的断言、死亡测试、致命和非致命失败、值和类型参数化测试、XML 测试报告生成以及模拟。最后一个是通过从同一存储库中可用的 GMock 模块提供的: github.com/google/googletest

您应该选择哪个框架取决于您的学习方法和项目大小。如果您喜欢缓慢、逐步的过程,并且不需要所有的花哨功能,那么选择 Catch2。那些喜欢从深层次开始并需要大量火力支持的开发人员将受益于选择 GTest。

Catch2

这个由 Martin Hořeňovský维护的框架,对于初学者和小型项目来说非常棒。这并不是说它不能处理更大的应用程序,只要你记住,只要记得在需要额外工具的区域会有所需要。如果我详细介绍这个框架,我就会偏离本书的主题太远,但我仍然想给你一个概述。首先,让我们简要地看看我们可以为我们的Calc类编写单元测试的实现,如下所示:

chapter08/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()宏不仅验证期望是否满足——它们还会收集所有失败的断言,并在单个输出中呈现它们,这样你就可以进行一次修复,避免重复编译。

现在,最好的一部分:我们不需要在任何地方添加这些测试,甚至不需要通知 CMake 它们存在;你可以忘记add_test(),因为你再也用不到了。如果允许的话,Catch2 会自动将你的测试注册到 CTest。在上一节中描述的配置项目后,添加框架非常简单。我们需要使用FetchContent()将其引入项目。

有两个主要版本可供选择:v2v3。版本 2 作为一个单头库(只需#include <catch2/catch.hpp>)提供给 C++11,最终将被版本 3 所取代。这个版本由多个头文件组成,被编译为静态库,并要求 C++14。当然,如果你能使用现代 C++(是的,C++11 不再被认为是“现代”的),那么推荐使用更新的版本。在与 Catch2 合作时,你应该选择一个 Git 标签并在你的列表文件中固定它。换句话说,不能保证升级不会破坏你的代码(升级很可能不会破坏代码,但如果你不需要,不要使用devel分支)。要获取 Catch2,我们需要提供一个仓库的 URL,如下所示:

chapter08/03-catch2/test/CMakeLists.txt

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

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

chapter08/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,如下所示:

chapter08/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 Catch v3.0.0 host application.
Run with -? for options
--------------------------------------------------------------
MultiplyMultipliesTwoInts
--------------------------------------------------------------
examples/chapter08/03-catch2/test/calc_test.cpp:9
..............................................................
examples/chapter08/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

直接执行运行器(编译的unit_test可执行文件)稍微快一点,但通常,你希望使用ctest --output-on-failure命令,而不是直接执行测试运行器,以获得前面提到的所有 CTest 好处。注意 Catch2 能够方便地将sut.Multiply(3, 4)表达式扩展为9,为我们提供更多上下文。

这就结束了 Catch2 的设置。如果你还需要添加更多测试,只需创建实现文件并将它们的路径添加到unit_tests目标的源列表中。

这个框架包含了一些有趣的小技巧:事件监听器、数据生成器和微基准测试,但它并不提供模拟功能。如果你不知道什么是模拟,继续阅读——我们马上就会涉及到这一点。然而,如果你发现自己需要模拟,你总是可以在这里列出的一些模拟框架旁边添加 Catch2:

话说回来,对于一个更简洁、更先进的体验,还有另一个框架值得一看。

GTest

使用 GTest 有几个重要的优点:它已经存在很长时间,并且在 C++社区中高度认可(因此,多个 IDE 支持它)。背后最大的搜索引擎公司的维护和广泛使用,所以它很可能在不久的将来变得过时或被遗弃。它可以测试 C++11 及以上版本,所以如果你被困在一个稍微老一点的环境中,你很幸运。

GTest 仓库包括两个项目:GTest(主测试框架)和 GMock(一个添加模拟功能的库)。这意味着你可以用一个FetchContent()调用来下载它们。

使用 GTest

要使用 GTest,我们的项目需要遵循为测试结构化项目部分的方向。这就是我们在这个框架中编写单元测试的方法:

chapter08/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继承,并将重用元素(字段、方法)放在其protected部分。

测试套件中的每个测试用例都是用TEST_F()预处理器宏声明的,该宏将测试套件和测试用例提供的名称字符串化(还有一个简单的TEST()宏,定义不相关的测试)。因为我们已经在类中定义了Calc sut_,每个测试用例可以像CalcTestSuite的一个方法一样访问它。实际上,每个测试用例在其自己的类中隐式继承自CalcTestSuite运行(这就是我们需要protected关键字的原因)。请注意,重用字段不是为了在连续测试之间共享任何数据——它们的目的是保持代码DRY

GTest 没有提供像 Catch2 那样的自然断言语法。相反,我们需要使用一个显式的比较,比如EXPECT_EQ()。按照惯例,我们将期望值作为第一个参数,实际值作为第二个参数。还有许多其他断言、助手和宏值得学习。

注意

关于 GTest 的详细信息,请参阅官方参考资料(google.github.io/googletest/).

要将此依赖项添加到我们的项目中,我们需要决定使用哪个版本。与 Catch2 不同,GTest 倾向于采用“现场开发”的理念(起源于 GTest 所依赖的 Abseil 项目)。它指出:“如果你从源代码构建我们的依赖项并遵循我们的 API,你不会遇到任何问题。”(更多详情请参阅进阶阅读部分。)

如果你习惯于遵循这个规则(并且从源代码构建没有问题),将你的 Git 标签设置为master分支。否则,从 GTest 仓库中选择一个版本。我们还可以选择首先在宿主机器上搜索已安装的副本,因为 CMake 提供了一个捆绑的FindGTest模块来查找本地安装。自 v3.20 起,CMake 将使用上游的GTestConfig.cmake配置文件(如果存在),而不是依赖于可能过时的查找模块。

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

chapter08/04-gtest/test/CMakeLists.txt

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

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

最后,我们可以声明我们的测试运行器可执行文件,链接gtest_main,并借助内置的 CMake GoogleTest模块自动发现我们的测试用例,如下所示:

chapter08/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/chapter08/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()中使用这个值,并允许我们检查该方法返回的值是否符合预期。将随机数生成分离到另一个单元中是一个额外的价值(因为我们将能够交换一种生成器类型为另一种)。

让我们从抽象生成器的公共接口开始。这将允许我们在实际生成器和模拟中实现它,使其可以相互替换。我们将执行以下代码:

chapter08/05-gmock/src/rng.h

#pragma once
class RandomNumberGenerator {
 public:
  virtual int Get() = 0;
  virtual ~RandomNumberGenerator() = default;
};

实现此接口的类将从Get()方法提供随机数。注意virtual关键字——除非我们希望涉及更复杂的基于模板的模拟,否则所有要模拟的方法都必须有它,除非我们希望涉及更复杂的基于模板的模拟。我们还需要记得添加一个虚拟析构函数。接下来,我们需要扩展我们的Calc类以接受和存储生成器,如下所示:

第八章/05-gmock/源码/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);
};

我们包含了头文件并添加了一个提供随机增加的方法。此外,创建了一个存储生成器指针的字段以及一个参数化构造函数。这就是依赖注入在实际工作中的运作方式。现在,我们实现这些方法,如下所示:

第八章/05-gmock/源码/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()中使用这个字段来获取生成的值。生产代码将使用一个真正的数字生成器;测试将使用模拟。记住我们需要对指针进行解引用以启用多态。作为奖励,我们可能为不同的实现创建不同的生成器类。我只需要一个:一个梅森旋转伪随机生成器,具有均匀分布,如下面的代码片段所示:

第八章/05-gmock/源码/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之间的数字并将它们返回给调用者。这个类的头文件尽可能简单,正如我们所见:

第八章/05-gmock/源码/rng_mt19937.h

#include "rng.h"
class RandomNumberGeneratorMt19937
      : public RandomNumberGenerator {
 public:
  int Get() override;
};

这是我们如何在生产代码中使用它:

第八章/05-gmock/源码/运行.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后缀。我们将执行以下代码:

第八章/05-gmock/测试/模拟/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>))

我们准备好在我们的测试套件中使用模拟(为了简洁,省略了之前的测试案例),如下所示:

第八章/05-gmock/测试/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()列表中,如下所示:

第八章/05-gmock/test/CMakeLists.txt

include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.11.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)

现在,我们可以享受 GTest 框架的所有好处。GTest 和 GMock 都是非常先进的工具,拥有大量的概念、实用程序和帮助器,适用于不同的场合。这个例子(尽管有点长)只是触及了可能实现的功能的表面。我鼓励你将它们纳入你的项目中,因为它们将极大地提高你的代码质量。开始使用 GMock 的一个好地方是官方文档中的Mocking for Dummies页面(你可以在进阶阅读部分找到这个链接)。

有了测试之后,我们应该以某种方式衡量哪些部分被测试了,哪些没有,并努力改善这种情况。最好使用自动化工具来收集和报告这些信息。

生成测试覆盖报告

向如此小的解决方案中添加测试并不是非常具有挑战性。真正的困难来自于稍微高级一些和更长的程序。多年来,我发现当我接近 1,000 行代码时,逐渐变得难以跟踪测试中执行了哪些行和分支,哪些没有。超过 3,000 行后,几乎是不可能的。大多数专业应用程序将拥有比这更多的代码。为了解决这个问题,我们可以使用一个工具来了解哪些代码行被“测试用例覆盖”。这样的代码覆盖工具连接到 SUT,并在测试中收集每行的执行信息,以方便的报告形式呈现,就像这里显示的这样:

图 8.3 ‒ 由 LCOV 生成的代码覆盖报告

图 8.3 ‒ 由 LCOV 生成的代码覆盖报告

这些报告将显示哪些文件被测试覆盖了,哪些没有。更重要的是,你还可以查看每个文件的具体细节,确切地看到哪些代码行被执行了,以及这种情况发生了多少次。在下面的屏幕截图中,Calc 构造函数被执行了 4 次,每次都是针对不同的测试:

图 8.4 ‒ 代码覆盖报告的详细视图

图 8.4 ‒ 代码覆盖报告的详细视图

生成类似报告有多种方法,它们在平台和编译器之间有所不同,但它们通常遵循相同的程序:准备要测量的 SUT,获取基线,测量和报告。

执行这项工作的最简单工具名叫gcov,它是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变量中实现这一点(尽管在文档中没有提到这一点)。除非你决定覆盖这个变量,否则你的调试构建应该是未优化的。这是为了防止任何内联和其他类型的隐式代码简化。否则,将很难追踪哪一行机器指令来自哪一行源代码。

在第一步中,我们需要指示编译器为我们的 SUT 添加必要的 instrumentation。需要添加的确切标志是编译器特定的;然而,两个主要的编译器—GCC 和 Clang—提供相同的--coverage标志,以启用覆盖率,生成 GCC 兼容的gcov格式的数据。

这就是我们如何将覆盖率 instrumentation 添加到前面章节中的示例 SUT:

chapter08/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. sut库的所有object filesPRIVATE编译选项添加--coverage

  3. PUBLIC链接器选项添加--coverage: both GCC 和 Clang 将此解释为请求与所有依赖于sut的目标链接gcov(或兼容)库(由于传播属性)。

  4. add_custom_command()命令被引入以清除任何陈旧的.gcda文件。讨论添加此命令的原因在避免 SEGFAULT 陷阱部分中有详细说明。

这足以生成代码覆盖率。如果你使用的是 Clion 之类的 IDE,你将能够运行带有覆盖率的单元测试,并在内置的报告视图中获取结果。然而,这不会在 CI/CD 中运行的任何自动化管道中工作。要获取报告,我们需要自己使用 LCOV 生成它们。

为此目的,最好定义一个名为coverage的新目标。为了保持整洁,我们将在另一个文件中定义一个单独的函数AddCoverage,用于在test列表文件中使用,如下所示:

chapter08/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()

在前面的片段中,我们首先检测lcovgenhtml(来自 LCOV 包的两个命令行工具)的路径。REQUIRED关键字指示 CMake 在找不到它们时抛出错误。接下来,我们按照以下步骤添加一个自定义的coverage目标:

  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列表文件中。我们可以通过在主列表文件中扩展包含搜索路径来实现:

chapter08/06-coverage/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(Coverage CXX)
enable_testing()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
add_subdirectory(src bin)
add_subdirectory(test)

这行小代码允许我们将cmake目录中的所有.cmake文件包括在我们的项目中。现在我们可以在test列表文件中使用Coverage.cmake,如下所示:

chapter08/06-coverage/test/CMakeLists.txt(片段)

# ... skipped unit_tests target declaration for brevity
include(Coverage)
AddCoverage(unit_tests)
include(GoogleTest)
gtest_discover_tests(unit_tests)

为了构建这个目标,请使用以下命令(注意第一个命令以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.2% (20 of 21 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实例是一个静态库,我们可以将add_custom_command(TARGET)命令挂钩到构建事件上。在重建开始之前,将执行清理。

进一步阅读部分找到更多信息链接。

摘要

在表面上看,似乎与适当测试相关的复杂性如此之大,以至于不值得付出努力。令人惊讶的是,运行没有任何测试的代码量有多少,主要论点是测试软件是一个令人畏惧的任务。我要补充的是:如果手动完成,更是如此。不幸的是,如果没有严格的自动化测试,代码中任何问题的可见性是不完整或根本不存在的。未测试的代码通常写起来更快(并非总是如此),但肯定更慢阅读、重构和修复。

在本章中,我们概述了从一开始就进行测试的一些关键原因。其中最引人入胜的是心理健康和一个良好的夜晚睡眠。没有开发人员会躺在床上想:“我迫不及待地想在几小时后醒来灭火和修复 bug。”但认真地说:在部署到生产之前捕获错误,可能对你(和公司)来说是个救命稻草。

谈到测试工具,CMake 确实显示了其真正的实力。CTest 可以在检测错误测试方面做到 wonders:隔离、洗牌、重复、超时。所有这些技术都非常方便,并且可以通过简单的命令行标志直接使用。我们还学会了如何使用 CTest 列出测试、过滤测试以及控制测试用例的输出,但最重要的是,我们现在知道了采用标准解决方案的真正力量。任何使用 CMake 构建的项目都可以以完全相同的方式进行测试,而无需调查其内部细节。

接下来,我们优化了项目结构,以简化测试过程并在生产代码和测试运行器之间复用相同的对象文件。编写自己的测试运行器很有趣,但也许让我们专注于程序应该解决的实际问题,并投入时间去拥抱一个流行的第三方测试框架。

说到这个,我们学习了 Catch2 和 GTest 的基础知识。我们进一步深入研究了 GMock 库的细节,并理解了测试替身是如何工作以使真正的单元测试成为可能的。最后,我们使用 LCOV 设置了报告。毕竟,没有什么比硬数据更能证明我们的解决方案实际上是完全测试过的了。

在下一章中,我们将讨论更多有用的工具来提高源代码的质量并发现我们甚至不知道存在的 issue。

进一步阅读

您可以通过以下链接获取更多信息:

github.com/catchorg/Catch2/blob/devel/docs/cmake-integration.md

github.com/catchorg/Catch2/blob/devel/docs/tutorial.md

gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html

gcc.gnu.org/onlinedocs/gcc/Invoking-Gcov.html

gcc.gnu.org/onlinedocs/gcc/Gcov-Data-Files.html

ltp.sourceforge.net/coverage/lcov/lcov.1.php

ltp.sourceforge.net/coverage/lcov/genhtml.1.php

第九章:程序分析工具

编写高质量代码并非易事,即使对于经验非常丰富的开发者也是如此。通过向我们的解决方案中添加测试,我们可以减少在业务代码中犯明显错误的风险。但这还不足以避免更复杂的问题。每一段软件都由如此多的细节组成,跟踪它们全部成为了一份全职工作。团队维护产品达成了数十种约定和多种特殊设计实践。

一些问题涉及一致的编码风格:我们的代码应该使用 80 列还是 120 列?我们应该允许使用std::bind还是坚持使用 Lambda 函数?使用 C 风格数组可以吗?小函数是否应该定义在单行中?我们是否应该始终坚持使用auto,或者只在提高可读性时使用?

理想情况下,我们还应避免任何已知在一般情况下不正确的语句:无限循环、使用标准库保留的标识符、无意中失去精度、冗余的if语句,以及其他不被认为是“最佳实践”的内容(参见进一步阅读部分获取参考资料)。

的另一件事是要关注代码现代化:随着 C++的发展,它提供了新特性。跟踪我们可以重构以适应最新标准的所有地方可能会很困难。此外,人工努力需要时间并引入了引入 bug 的风险,对于大型代码库来说这是相当可观的。

最后,我们应该检查事物在运动时的表现:执行程序并检查其内存。内存在使用后是否被正确释放?我们是否正确地访问了初始化的数据?或者代码试图解引用一些悬空指针?

手工管理所有这些问题和问题是不效率且容易出错的。幸运的是,我们可以使用自动工具来检查和强制执行规则、修复错误并现代化代码为我们。是时候发现程序分析工具了。我们的代码将在每次构建时进行检查,以确保其符合行业标准。

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

  • 强制格式化

  • 使用静态检查器

  • 使用 Valgrind 进行动态分析

技术要求

您可以在 GitHub 上找到本章中存在的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter09

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

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

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

强制格式化

专业开发者通常遵循规则。他们认为高级开发者知道何时打破一条规则(因为他们可以证明需要这么做)。另一方面,有人说非常高级的开发者不打破规则,因为向他人解释理由是浪费时间。我说,选择你的战斗,专注于对产品有实际影响和实质性作用的事情。

当涉及到代码风格和格式化时,程序员面临着许多选择:我们应该使用制表符还是空格进行缩进?如果使用空格,是多少个?列字符数的限制是多少?文件呢?在大多数情况下,这些选择不会影响程序的行为,但它们确实会产生很多噪音,并引发长时间讨论,这些讨论对产品并没有太大价值。

有些实践是普遍认同的,但大多数时候,我们是在争论个人偏好和 anecdotal 证据。毕竟,将列中的字符数从 120 强制到 80 是一个任意选择。只要我们保持一致,我们选择什么并不重要。风格上的不一致是坏事,因为它影响软件的一个重要方面——代码的可读性。

避免这种情况的最佳方式是使用格式化工具,如clang-format。这可以警告我们的代码格式不正确,甚至在我们允许的情况下修复突出显示的问题。以下是一个格式化代码的命令示例:

clang-format -i --style=LLVM filename1.cpp filename2.cpp

-i选项告诉 ClangFormat 就地编辑文件。--style选择应使用哪种支持的格式化样式:LLVMGoogleChromiumMozillaWebKit或自定义,从file提供(在进一步阅读部分有详细信息的链接)。

当然,我们不想每次修改后都手动执行这个命令;CMake 应该在构建过程中处理这个问题。我们已经知道如何在系统中找到clang-format(我们之前需要手动安装它)。我们还没有讨论的是将外部工具应用于所有源文件的过程。为此,我们将创建一个方便的函数,可以从cmake目录中包含:

chapter09/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。它将格式化来自directory的所有源文件,在构建target之前。

从技术上讲,directory中的所有文件不一定都属于target(并且目标源代码可能位于多个目录中)。然而,找到所有属于目标(以及可能的依赖目标)的源文件和头文件是一个非常复杂的过程,尤其是当我们需要过滤掉属于外部库且不应该格式化的头文件时。在这种情况下,按目录工作更加可行。我们只需为每个格式化目标调用函数。

这个函数有以下几个步骤:

  1. 查找系统中安装的clang-format二进制文件。REQUIRED关键字将在找不到二进制文件时停止配置并显示错误。

  2. 创建一个要格式化的文件扩展名列表(用作通配符表达式)。

  3. 在每个表达式前加上directory的路径。

  4. 递归搜索源文件和头文件(使用之前创建的列表),跳过目录,并将它们的路径放入SOURCE_FILES变量中。

  5. 将格式化命令作为targetPRE_BUILD步骤。

这个命令对于小到中等大小的代码库来说效果很好。对于大量文件,我们需要将绝对文件路径转换为相对路径,并使用directory作为工作目录执行格式化(list(TRANSFORM)命令在这里很有用)。这可能是因为传递给 shell 的命令长度有限制(通常约为 13,000 个字符),而太多的长路径根本放不下。

让我们看看如何在实际中使用这个函数。我们将使用以下项目结构:

- CMakeLists.txt
- .clang-format
- cmake
  |- Format.cmake
- src
  |- CMakeLists.txt
  |- header.h
  |- main.cpp

首先,我们需要设置项目并将cmake目录添加到模块路径中,这样我们稍后才能包含它:

第九章/01-格式化/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(Formatting CXX)
enable_testing()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
add_subdirectory(src bin)

设置好之后,让我们为src目录填写列表文件:

第九章/01-格式化/src/CMakeLists.txt

add_executable(main main.cpp)
include(Format)
Format(main .)

这很简单,直截了当。我们创建了一个名为main的可执行目标,包含了Format.cmake模块,并在当前目录(src)中调用了Format()函数。

现在,我们需要一些未格式化的源文件。头文件只是一个简单的unused函数:

第九章/01-格式化/src/header.h

int unused() { return 2 + 2; }

我们还会添加一个源文件,其中空格过多:

第九章/01-格式化/src/main.cpp

#include <iostream>
  using namespace std;
    int main() {
      cout << "Hello, world!" << endl;
    }

万事俱备,只差格式化器的配置文件(可在命令行中使用--style=file参数启用):

第九章/01-格式化/.clang-format

BasedOnStyle: Google
ColumnLimit: 140
UseTab: Never
AllowShortLoopsOnASingleLine: false
AllowShortFunctionsOnASingleLine: false
AllowShortIfStatementsOnASingleLine: false

Clang Format 将扫描父目录中的.clang-format文件,该文件指定确切的格式化规则。这允许我们指定每一个小细节,或者定制前面提到的标准之一。在我的案例中,我选择从 Google 的编码风格开始,并加入一些调整:限制列数为 140 个字符,移除制表符,并允许短循环、函数和if语句。

我们来看看在构建该项目后文件的变化情况(格式化会在编译前自动进行):

第九章/01-格式化/src/header.h(已格式化)

int unused() {
  return 2 + 2;
}

尽管目标没有使用头文件,但格式化器还是对其进行了格式化;不允许单行上有短函数。格式化器添加了新行,正如所期望的那样。main.cpp文件现在看起来也很酷:

第九章/01-格式化/src/main.cpp(已格式化)

#include <iostream>
using namespace std;
int main() {
  cout << "Hello, world!" << endl;
}

删除了不必要的空格,并将缩进标准化。

添加自动化格式化工具并不需要太多努力,而且在代码审查时节省你大量时间。如果你曾经不得不修改提交来修正一些空白字符,你就会明白这种感觉。一致的格式化让你的代码整洁而无需任何努力。

注意

将格式化应用到现有代码库中很可能会对大多数仓库中的文件引入一次性的巨大变化。如果你(或你的团队成员)有一些正在进行的工作,这可能会导致大量的合并冲突。最好协调这样的努力,在所有待处理的变化完成后进行。如果这不可能,考虑逐步采用,也许按目录 basis 进行。你的同事们会感谢你的。

格式化器是一个伟大而简单的工具,可以统一代码的视觉方面,但它不是一个完全成熟的程序分析工具(它主要关注空白字符)。为了处理更高级的场景,我们需要使用能够理解程序源代码的工具来执行静态分析。

使用静态检查器

静态程序分析是检查源代码而不实际运行编译版本的过程。严格应用静态检查器显著提高了代码的质量:它变得更加一致,更少出现错误。引入已知的安全漏洞的机会也减少了。C++社区已经创建了数十个静态检查器:Astrée、Clang-Tidy、CLazy、CMetrics、Cppcheck、Cpplint、CQMetrics、ESBMC、FlawFinder、Flint、IKOS、Joern、PC-Lint、Scan-Build、Vera++等等。

许多它们认识 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

像往常一样,<LANG>应该用所使用的语言替换,所以用C表示 C 源文件,用CXX表示 C++。如果你不需要针对每个目标控制检查器,可以通过设置一个前缀为CMAKE_的适当的全局变量,为项目中的所有目标指定一个默认值,例如以下:

set(CMAKE_CXX_CLANG_TIDY /usr/bin/clang-tidy-3.9;-checks=*)

在此声明之后定义的任何目标,其CXX_CLANG_TIDY属性将以此方式设置。只需记住,这将分析常规构建,使它们稍微变慢。

另一方面,更细粒度地控制检查器如何测试目标有一定的价值。我们可以编写一个简单的函数来解决这个问题:

chapter09/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. target上启用 Clang-Tidy,提供二进制文件的路径和自定义选项以启用所有检查,并将警告视为错误。

要使用这个函数,我们只需要包含模块并针对所选目标调用它:

chapter09/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/chapter09/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/chapter09/04-clang-tidy/src/run.cpp:1:1: warning: #includes are not sorted properly [llvm-include-order]
#include <iostream>
^        ~~~~~~~~~~
/root/examples/chapter09/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/chapter09/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 个)可以遵循相同的格式文件。提供的检查项包括性能改进、可读性、现代化、cpp-core-guidelines 和易出错命名空间等方面的改进。

Cpplint

以下是从官方网站对 Cpplint 的描述:

Cpplint 是一个命令行工具,用于检查遵循 Google C++风格指南的 C/C++文件的风格问题。Cpplint 是由 Google 公司在 google/styleguide 开发和维护的。

这个代码检查工具旨在让您的代码符合上述的 Google 风格。它是用 Python 编写的,这可能会成为某些项目不愿依赖的库。提供的修复格式可以被 Emacs、Eclipse、VS7、Junit 以及作为sed命令的格式所消费。

Cppcheck

以下是从官方网站对 Cppcheck 的描述:

Cppcheck 是一个用于 C/C++代码的静态分析工具。它提供独特的代码分析来检测错误,并专注于检测未定义行为和危险编码结构。目标是尽量减少误报。Cppcheck 旨在能够分析具有非标准语法(在嵌入式项目中很常见)的您的 C/C++代码。

这个工具非常值得推荐,它能让您在使用时无忧无虑,避免由于误报而产生的不必要噪音。它已经相当成熟(已有 14 多年的历史),并且仍然维护得非常活跃。另外,如果你的代码不能与 Clang 编译,你可能会觉得它很有用。

包含你使用的(include-what-you-use)

以下是从官方网站对 include-what-you-use 的描述:

包含你使用的的主要目标是去除不必要的#include。它通过找出实际不需要包含的#include(对于.cc 和.h 文件),并在可能的情况下用前向声明替换#include 来实现这一点。

如果你的代码库比较瘦,太多的包含头文件可能看起来并不是一个大问题。在更大的项目中,避免不必要的头文件编译节省的时间会迅速累积。

以下是 CMake 博客上对 link-what-you-use 的描述:

这是一个内置的 CMake 功能,使用 ld 和 ldd 的选项来输出如果可执行文件链接了比实际需要更多的库。

这也加快了构建时间;在这种情况下,我们关注的是不需要的二进制文件。

静态分析在软件错误可能影响人们安全的领域至关重要,尤其是在医疗、核能、航空、汽车和机械工业中。明智的开发者知道,在要求不高的环境中遵循类似实践并不会有什么坏处,尤其是在采用成本如此之低的情况下。在构建过程中使用静态分析器不仅比手动查找和修复错误便宜得多;而且通过 CMake 很容易启用。我甚至可以说,在质量敏感的软件(即涉及除程序员以外的其他人的所有软件)中几乎没有任何理由跳过这些检查。

不幸的是,并非所有错误都能在程序执行之前捕获。我们能做些什么来更深入地了解我们的项目呢?

使用 Valgrind 进行动态分析

Valgrind (www.valgrind.org) 是一个允许构建动态分析工具的框架——即在程序运行时执行的分析。它提供了一个广泛的工具套件,允许进行各种调查和检查。其中一些工具如下:

  • Memcheck – 检测内存管理问题

  • Cachegrind – 分析 CPU 缓存,并定位缓存缺失和其他缓存问题

  • Callgrind – Cachegrind 的扩展,带有关于调用图的额外信息

  • Massif – 一种堆分析器,可以显示程序随时间使用堆的情况

  • Helgrind – 线程调试器,有助于解决数据竞争问题

  • DRD – Helgrind 的更轻量级、有限版本

这个列表中的每一个工具在适当的时候都非常方便。大多数包管理器都知道 Valgrind 并且可以轻松地在您的操作系统上安装它(如果您使用的是 Linux,可能已经安装了)。无论如何,官方网站提供了源代码,所以您可以自己构建它。

我们将重点关注套件中最有用的应用程序。当人们提到 Valgrind 时,他们经常会指的是 Valgrind 的 Memcheck。让我们找出如何使用它与 CMake 一起工作——这将为您需要它们时采用其他工具铺平道路。

Memcheck

Memcheck 在调试内存问题时可能不可或缺。在 C++ 中,这尤其棘手,因为程序员对自己如何管理内存有极大的控制权。可能出现各种错误:读取未分配的内存、读取已经释放的内存、尝试多次释放内存以及写入错误的地址。开发者显然试图避免这些错误,但由于这些错误如此微妙,它们甚至可以潜入最简单的程序中。有时,只需忘记一个变量的初始化,我们就陷入了困境。

调用 Memcheck 看起来像这样:

valgrind [valgrind-options] tested-binary [binary-options]

Memcheck 是 Valgrind 的默认工具,但您也可以明确选择它:

valgrind --tool=memcheck tested-binary

运行 Memcheck 代价昂贵;手册(参见进一步阅读中的链接)说,用它 instrumented 的程序可以慢 10-15 倍。为了避免每次运行测试时都要等待 Valgrind,我们将创建一个可以在需要测试代码时从命令行调用的独立目标。理想情况下,开发者会在将他们的更改合并到仓库的默认分支之前运行它。这可以通过早期 Git 钩子或添加为 CI 管道中的一个步骤来实现。在生成阶段完成后,我们将使用以下命令来构建自定义目标:

cmake --build <build-tree> -t valgrind

添加此类目标并不困难:

chapter09/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()

在这个例子中,我们创建了一个 CMake 模块(这样我们就可以在不同的项目中重复使用同一个文件)来包装接受要测试的目标的函数。这里发生两件事:

  • CMake 会在默认的系统路径中搜索valgrind可执行文件,并将其存储在VALGRIND_PATH变量中。如果找不到二进制文件,REQUIRED关键字会导致配置出现错误而停止。

  • 创建了一个自定义目标valgrind;它将在target二进制文件上执行 Memcheck 工具。我们还添加了一个选项,始终检查内存泄漏。

谈到 Valgrind 选项时,我们可以提供命令行参数,也可以如下进行:

  1. ~/.valgrindrc文件(在你的家目录中)

  2. $VALGRIND_OPTS环境变量

  3. ./.valgrindrc文件(在工作目录中)

这些按顺序进行检查。另外,请注意,最后一个文件只有在属于当前用户、是普通文件,并且没有被标记为世界可写时才会被考虑。这是一个安全机制,因为给 Valgrind 的选项可能是有害的。

要使用AddValgrind函数,我们应该向其提供一个 unit_tests 目标:

chapter09/03-valgrind/test/CMakeLists.txt(片段)

# ...
add_executable(unit_tests calc_test.cpp run_test.cpp)
# ...
include(Valgrind)
AddValgrind(unit_tests)

请记住,使用Debug配置生成构建树可以让 Valgrind 访问调试信息,这使得它的输出更加清晰。让我们看看实际中这是如何工作的:

# cmake --build <build-tree> -t valgrind

这会构建sutunit_tests目标:

[100%] Built target unit_tests

启动 Memcheck 的执行,它将为我们提供一般信息:

==954== Memcheck, a memory error detector
==954== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==954== Using Valgrind-3.15.0 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 非常擅长找到非常复杂的错误。偶尔,它甚至能够更深入地挖掘,找到不能自动分类的值得怀疑的情况。此类发现将在可能丢失行中予以说明。

让我们看看 Memcheck 在此案例中发现的问题是什么:

chapter09/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

商业 IDE,如 CLion,原生支持解析 Valgrind 的输出,以便可以通过 GUI 轻松导航,而不必滚动控制台窗口以找到正确的消息。如果你的编辑器没有这个选项,你仍然可以通过使用第三方报告生成器获得更清晰的错误视图。由 David Garcin 编写的 Memcheck-cover 提供了一个更愉快的体验,以生成的 HTML 文件的形式,如图9.1所示:

图 9.1 – 由 memcheck-cover 生成的报告

图 9.1 – 由 memcheck-cover 生成的报告

这个小巧的项目在 GitHub 上可用(github.com/Farigh/memcheck-cover);它需要 Valgrind 和gawk(GNU AWK 工具)。要使用它,我们将在一个单独的 CMake 模块中准备一个设置函数。它将由两部分组成:

  • 获取和配置工具

  • 添加一个自定义目标,执行 Valgrind 并生成报告

配置如下所示:

chapter09/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目标重叠):

chapter09/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包装脚本,该脚本将执行 Valgrind 的 Memcheck 并收集通过-o参数提供的文件输出的输出。

  2. 然后,我们将解析输出并使用generate_html_report.sh创建报告。这个脚本需要通过-i-o参数提供的输入和输出目录。

这两个步骤应该在CMAKE_BINARY_DIR工作目录中执行,以便如果需要,单元测试二进制可以通过相对路径访问文件。

当然,我们还需要在我们的列表文件中添加的最后一样东西,当然是调用这个函数的调用。它的模式和AddValgrind一样:

chapter09/04-memcheck/test/CMakeLists.txt(片段)

include(Memcheck)
AddMemcheck(unit_tests)

在用Debug配置生成构建系统后,我们可以用以下命令来构建目标:

cmake --build <build-tree> -t memcheck

然后我们可以享受我们的格式化报告。嗯,要真正享受它,我们还需要在run.cpp中添加那个缺失的delete c;,这样它就不会抱怨了(或者,更好的是,使用智能指针)。

总结

“你会在研究代码上花费的时间比在编写代码上多——因此,你应该优化阅读而不是编写。”

这句话在讨论整洁代码实践的书中被像咒语一样重复。难怪,因为这是非常正确的,很多软件开发者已经在实践中证明了这一点——以至于连像空格数、换行符以及#import声明的顺序这样的微小事情都有了规定。这并不是出于小气,而是为了节省时间。遵循本章概述的实践,我们无需担心手动格式化代码。它将自动在构建过程中作为副作用进行格式化——这是我们无论如何都必须执行的步骤,以检查代码是否正确运行。通过引入 ClangFormat,我们还可以确保它看起来正确。

当然,我们想要的不仅仅是简单的空格修正;代码必须符合几十个其他的小规定。这是通过添加 Clang-Tidy 并配置它强制执行我们选择的编码风格来完成的。我们详细讨论了这道静态检查器,但我们也提到了其他选项:Cpplint,Cppcheck,Include-what-you-use 和 Link-what-you-use。由于静态链接器相对较快,我们可以少量投资将它们添加到构建中,这通常是非常值得的。

最后,我们查看了 Valgrind 工具,特别是 Memcheck,它允许调试与内存管理相关的问题:不正确的读取、写入、释放等等。这是一个非常方便的工具,可以节省数小时的手动调查,并防止错误溜进生产环境。正如提到的,它的执行可能会慢一些,这就是我们创建一个单独的目标来显式地在提交代码之前运行它的原因。我们还学会了如何使用 Memcheck-Cover(一个 HTML 报告生成器)以更易接受的形式呈现 Valgrind 的输出。这在支持运行 IDE 的环境中(如 CI 管道)可能非常有用。

当然,我们不仅限于这些工具;还有很多:既有自由和开源项目,也有带有广泛支持的商业产品。这只是对这个主题的介绍。确保探索对你来说正确的东西。在下一章,我们将更详细地查看文档生成。

进一步阅读

要获取更多信息,你可以参考以下链接:

第十章:生成文档

高质量代码不仅编写得很好、运行正常且经过测试,而且还彻底进行了文档化。文档使我们能够分享否则可能丢失的信息,绘制更广阔的图景,提供上下文,揭示意图,最终——教育外部用户和维护者。

你还记得上次加入新项目时,在目录和文件迷宫中迷失了几个小时吗?这种情况是可以避免的。优秀的文档确实能引导一个完全的新手在几秒钟内找到他们想要查看的代码行。遗憾的是,缺失文档的问题常常被一笔勾销。难怪——这需要很多技巧,而且我们中的许多人并不擅长。此外,文档和代码真的可以很快分道扬镳。除非实施严格的更新和审查流程,否则很容易忘记文档也需要维护。

一些团队(出于时间考虑或受到经理的鼓励)遵循编写“自文档化代码”的做法。通过为文件名、函数、变量等选择有意义的、可读的标识符,他们希望避免文档化的繁琐工作。虽然良好的命名习惯绝对是正确的,但它不能取代文档。即使是最出色的函数签名也不能保证传达所有必要的信息——例如,int removeDuplicates();非常具有描述性,但它没有揭示返回值是什么!它可能是找到的重复项数量、剩余项的数量,或其他内容——是不确定的。记住:没有免费的午餐这种事。

为了简化事情,专业人士使用自动文档生成器,这些生成器可以分析源文件中的代码和注释,以生成多种不同格式的全面文档。将此类生成器添加到 CMake 项目中非常简单——让我们来看看如何操作!

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

  • 向您的项目添加 Doxygen

  • 使用现代外观生成文档

技术要求

您可以在 GitHub 上找到本章中出现的代码文件,链接如下:

github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter10

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

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

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

向您的项目添加 Doxygen

能够从 C++源代码生成文档的最著名且最受欢迎的工具之一是 Doxygen。当我提到“著名”时,我的意思是:第一个版本是由 Dimitri van Heesch 在 1997 年 10 月发布的。从那时起,它得到了极大的发展,并且由其仓库的 180 多个贡献者积极参与支持(github.com/doxygen/doxygen)。

Doxygen 可以生成以下格式的文档:

  • 超文本标记语言HTML

  • 富文本格式RTF

  • 便携式文档格式PDF

  • Lamport 的 TeXLaTeX

  • PostScriptPS

  • Unix 手册手册页

  • 微软编译的 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] [USE_STAMP_FILE] [WORKING_DIRECTORY dir]
  [COMMENT comment])

第一个参数指定了目标名称,我们需要使用cmake-t参数(在生成构建树之后)显式构建它,如下所示:

cmake --build <build-tree> -t targetName

或者,我们总是可以通过添加 ALL 参数(通常不必要)来构建它。其他选项相当直观,除了可能 USE_STAMP_FILE。这允许 CMake 在源文件没有更改的情况下跳过文档的重新生成(但要求 sourceFilesOrDirs 只包含文件)。

我们将遵循前几章的做法,创建一个带有辅助函数的工具模块(以便在其他项目中重复使用),如下所示:

chapter-10/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 目标,该目标将通知用户并运行一个 false 命令,该命令(在 Unix-like 系统上)返回 1,导致构建失败。我们在此时终止函数并用 return()

  3. 如果系统中可用 Doxygen,我们将配置它以在提供的 output 目录中生成 HTML 输出。Doxygen 非常可配置(更多信息请参阅官方文档)。要设置任何选项,只需按照示例通过调用 set() 并将其名称前缀为 DOXYGEN_

  4. 设置实际的 doxygen 目标:所有 DOXYGEN_ 变量都将转发到 Doxygen 的配置文件中,并且将从源树中的提供的 input 目录生成文档。

如果你 documentation 要由用户生成,步骤 2 可能应该涉及安装必要的依赖项。

要使用这个函数,我们可以在我们项目的 main listfile 中添加它,如下所示:

chapter-10/01-doxygen/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
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 文档:

图 10.1 – 使用 Doxygen 生成的类参考

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_10.1_B17205.jpg)

图 10.1 – 使用 Doxygen 生成的类参考

你可以在成员函数文档中看到的额外描述是通过在头文件中添加适当注释来实现的:

chapter-10/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 描述中找到更多信息(请参阅 进一步阅读 部分中的链接)。

如前所述,如果安装了 Graphviz,Doxygen 将检测到它并生成依赖关系图,如下所示:

图 10.2 – 使用 Doxygen 生成的继承和协作图

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_10.2_B17205.jpg)

图 10.2 – 使用 Doxygen 生成的继承和协作图

直接从源代码生成文档,我们创建了一个机制,可以快速更新它,以反映在整个开发周期中发生的任何代码更改。此外,注释中任何遗漏的更新都有可能在代码审查期间被发现。

许多开发者会抱怨 Doxygen 提供的设计过时,这让他们犹豫是否向客户展示生成的文档。别担心——有一个简单的解决方案可以解决这个问题。

使用现代风格生成文档

拥有项目文档并以干净、清新的设计也是非常重要的。毕竟,如果我们为我们的前沿项目编写高质量文档付出这么多努力,用户必然会这样认为。Doxygen 拥有所有的功能,但它并不以遵循最新的视觉趋势而闻名。然而,这并不意味着我们需要付出很多努力来改变这一点。

幸运的是,一个名为jothepro的开发者创建了一个名为doxygen-awesome-css的主题,它提供了一个现代、可自定义的设计。它甚至还有夜间模式!您可以在以下屏幕快照中看到此内容:

![图 10.3 – 使用 doxygen-awesome-css 主题的 HTML 文档

(img/Figure_10.3_B17205.jpg)

图 10.3 – 使用 doxygen-awesome-css 主题的 HTML 文档

该主题不需要任何额外的依赖项,可以很容易地从其 GitHub 页面github.com/jothepro/doxygen-awesome-css获取。

注意

在线资源建议使用多个依次执行的应用程序来升级体验。一种流行的方法是使用 Breathe 和 Exhale 扩展与 Sphinx 一起转换 Doxygen 的输出。这个过程看起来有点复杂,并且会引入很多其他依赖项(如 Python)。我建议在可能的情况下保持工具简单。很可能会发现项目中的每个开发者并不都非常了解 CMake,这样的复杂过程会给他们带来困难。

我们将直接进入这个主题的自动化采用。让我们看看如何通过添加一个新的宏来扩展我们的Doxygen.cmake文件以使用它,如下所示:

chapter-10/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
      v1.6.0
  )
  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()

我们已经在书的 previous chapters 中了解到了所有这些命令,但为了完全清晰,让我们重申一下发生了什么,如下所示:

  1. doxygen-awesome-css通过FetchContent模块从 Git 中提取,并作为项目的一部分提供。

  2. 为 Doxygen 配置了额外的选项,如主题的README文件中所建议。

  3. DOXYGEN_HTML_EXTRA_STYLESHEET配置了主题的.css文件的路径。它将被复制到输出目录。

正如您所想象的,最好在Doxygen函数中调用这个宏,在doxygen_add_docs()之前,像这样:

chapter-10/02-doxygen-nice/cmake/Doxygen.cmake

function(Doxygen input output)
  ...
  UseDoxygenAwesomeCss()
  doxygen_add_docs (...)
endfunction()
macro(UseDoxygenAwesomeCss)
  ...
endmacro()

作为提醒,宏中的所有变量都在调用函数的作用域中设置。

现在我们可以享受我们生成的 HTML 文档中的现代风格,并自豪地与世界分享。

摘要

在本章中,我们介绍了如何将 Doxygen,这个文档生成工具,添加到 CMake 项目中,并使其变得优雅。这个过程并不复杂,将大大改善您解决方案中的信息流。花在添加文档上的时间是值得的,特别是如果您发现您或您的团队成员在理解应用程序中的复杂关系时遇到困难。

您可能担心将 Doxygen 添加到没有从开始就使用文档生成的较大项目中会很困难。要求开发者在每个函数中添加注释的工作量可能让开发者感到不堪重负。不要追求立即完整:从小处着手,只需填写您在最新提交中触摸的元素的描述。即使文档不完整,也比完全没有文档好。

请记住,通过生成文档,您将确保其与实际代码的接近性:如果它们都在同一个文件中,同步维护编写解释和逻辑要容易得多。另外,要意识到像大多数程序员一样,您可能是一个非常忙碌的人,并且最终会忘记您项目中的某些小细节。记住:最短的铅笔也比最长的记忆长。对自己好一点——把事情写下来,繁荣昌盛。

在下一章中,我们将学习如何使用 CMake 自动打包和安装我们的项目。

进一步阅读

其他文档生成工具

还有数十种其他工具未在此书中涉及,因为我们专注于由 CMake 支持的项目。然而,其中一些可能更适合您的用例。如果您想冒险,可以访问我在这里列出的两个我觉得有趣的项目的网站:

针对 Clang 编译器,Hyde 生成 Markdown 文件,这些文件可以被如 Jekyll(jekyllrb.com/)等工具消费,Jekyll 是一个由 GitHub 支持的静态页面生成器。

该工具使用libclang编译您的代码,并提供 HTML、Markdown、LaTex 和 man 页面的输出。它大胆地目标是成为下一个 Doxygen。

第十一章:安装和打包

我们的项目已经构建、测试并文档化。现在,终于到了将其发布给用户的时候。本章主要介绍我们将要采取的最后两个步骤:安装和打包。这些都是建立在迄今为止我们所学习的一切之上的高级技术:管理目标和它们的依赖关系、瞬态使用需求、生成器表达式等等。

安装使我们的项目能够在系统范围内被发现和访问。在本章中,我们将介绍如何导出目标,以便另一个项目可以在不安装的情况下使用它们,以及如何安装我们的项目,以便它们可以很容易地被系统上的任何程序使用。特别是,我们将学习如何配置我们的项目,使其可以自动将不同类型的工件放入正确的目录中。为了处理更高级的场景,我们将介绍用于安装文件和目录的低级命令,以及用于执行自定义脚本和 CMake 命令的命令。

接下来,我们将学习如何设置可重用的 CMake 包,以便它们可以被其他项目通过调用find_package()发现。具体来说,我们将解释如何确保目标和它们的定义不会固定在文件系统的特定位置。我们还将讨论如何编写基本和高级的配置文件,以及与包关联的版本文件

然后,为了使事情模块化,我们将简要介绍组件的概念,包括 CMake 包和install()命令。所有这些准备将为本章我们将要涵盖的最后方面铺平道路:使用 CPack 生成各种包管理器在不同操作系统中认识的归档文件、安装程序、捆绑包和包。这些可以用来携带预构建的工件、可执行文件和库。这是最终用户开始使用我们的软件的最简单方法。

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

  • 无需安装导出

  • 在系统上安装项目

  • 创建可重用的包

  • 定义组件

  • 使用 CPack 打包

技术要求

您可以在 GitHub 上找到本章的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter11

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

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

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

无需安装导出

我们如何使项目A的目标对消费项目B可用?通常,我们会使用find_package()命令,但这意味着我们需要创建一个包并在系统上安装它。这种方法很有用,但需要一些工作。有时,我们只是需要一种快速的方法来构建一个项目,并使其目标对其他项目可用。

我们可以通过包含A的主列表文件来节省一些时间:它已经包含了所有的目标定义。不幸的是,它也可能包含很多其他内容:全局配置、需求、具有副作用的 CMake 命令、附加依赖项,以及我们可能不想在B中出现的目标(如单元测试)。所以,我们不要这样做。更好的方法是提供B,并通过include()命令包含:

cmake_minimum_required(VERSION 3.20.0)
project(B)
include(/path/to/project-A/ProjectATargets.cmake)

执行此操作将为A的所有目标提供正确的属性集定义(如add_library()add_executable()等命令)。

当然,我们不会手动写这样的文件——这不会是一个非常 DRY 的方法。CMake 可以用export()命令为我们生成这些文件,该命令具有以下签名:

export(TARGETS [target1 [target2 [...]]] 
  [NAMESPACE <namespace>] [APPEND] FILE <path>
  [EXPORT_LINK_INTERFACE_LIBRARIES])

我们必须提供所有我们想要导出的目标,在TARGET关键字之后,并提供目标文件名在FILE之后。其他参数是可选的:

  • NAMESPACE建议作为一个提示,说明目标已经从其他项目中导入。

  • APPEND告诉 CMake 在写入文件之前不要擦除文件的内容。

  • EXPORT_LINK_INTERFACE_LIBRARIES将导出目标链接依赖(包括导入和配置特定的变体)。

让我们用我们示例中的 Calc 库来看看这个功能,它提供了两个简单的方法:

chapter-11/01-export/src/include/calc/calc.h

#pragma once
int Sum(int a, int b);
int Multiply(int a, int b);

我们这样声明它的目标:

chapter-11/01-export/src/CMakeLists.txt

add_library(calc STATIC calc.cpp)
target_include_directories(calc INTERFACE include)

然后,我们要求 CMake 使用export(TARGETS)命令生成导出文件:

chapter-11/01-export/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.20.0)
project(ExportCalcCXX)
add_subdirectory(src bin)
set(EXPORT_DIR "${CMAKE_CURRENT_BINARY_DIR}/cmake")
export(TARGETS calc
  FILE "${EXPORT_DIR}/CalcTargets.cmake"
  NAMESPACE Calc::
)
...

在前面的代码中,我们可以看到EXPORT_DIR变量已被设置为构建树中的cmake子目录(按照.cmake文件的约定)。然后,我们导出目标声明文件CalcTargets.cmake,其中有一个名为calc的单一目标,对于将包含此文件的工程项目,它将作为Calc::calc可见。

请注意,这个导出文件还不是包。更重要的是,这个文件中的所有路径都是绝对的,且硬编码到构建树中。换句话说,它们是不可移动的(我们将在理解可移动目标的问题部分讨论这个问题)。

export()命令还有一个更短的版本:

export(EXPORT <export> [NAMESPACE <namespace>] [FILE
  <path>])

然而,它需要一个<export>名称,而不是一个导出的目标列表。这样的<export>实例是由install(TARGETS)定义的目标的命名列表(我们将在安装逻辑目标部分介绍这个命令)。以下是一个演示如何在实际中使用这种简写法的微型示例:

chapter-11/01-export/CMakeLists.txt(续)

...
install(TARGETS calc EXPORT CalcTargets)
export(EXPORT CalcTargets
  FILE "${EXPORT_DIR}/CalcTargets2.cmake"
  NAMESPACE Calc::
)

前面的代码与之前的代码完全一样,但现在,export()install() 命令之间的单个目标列表被共享。

生成导出文件的两个方法会产生相同的结果。它们将包含一些模板代码和几行定义目标的内容。将 /tmp/b 设置为构建树路径时,它们看起来像这样:

/tmp/b/cmake/CalcTargets.cmake(片段)

# Create imported target Calc::calc
add_library(Calc::calc STATIC IMPORTED)
set_target_properties(Calc::calc PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES
  "/root/examples/chapter11/01-export/src/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 "/tmp/b/libcalc.a"
)

通常,我们不会编辑这个文件,甚至不会打开它,但我想要强调这个生成文件中的硬编码路径。以其当前形式,这个包是不可移动的。如果我们想要改变这一点,我们首先需要做一些跳跃。我们将在下一节探讨为什么这很重要。

在系统上安装项目

在第章 1 CMake 初学者中,我们提到 CMake 提供了一个命令行模式,可以在系统上安装构建好的项目:

cmake --install <dir> [<options>]

<dir> 是生成构建树的目标路径(必需)。我们的 <options> 如下:

  • --config <cfg>:这对于多配置生成器,选择构建配置。

  • --component <comp>:这限制了安装到给定组件。

  • --default-directory-permissions <permissions>:这设置了安装目录的默认权限(在 <u=rwx,g=rx,o=rx> 格式中)。

  • --prefix <prefix>:这指定了非默认的安装路径(存储在 CMAKE_INSTALL_PREFIX 变量中)。对于类 Unix 系统,默认为 /usr/local,对于 Windows,默认为 c:/Program Files/${PROJECT_NAME}

  • -v, --verbose:这会使输出详细(这也可以通过设置 VERBOSE 环境变量来实现)。

安装可以由许多步骤组成,但它们的本质是将生成的工件和必要的依赖项复制到系统上的某个目录中。使用 CMake 进行安装不仅为所有 CMake 项目引入了一个方便的标准,而且还做了以下事情:

  • 为根据它们的类型提供特定于平台的安装路径(遵循GNU 编码标准

  • 通过生成目标导出文件,增强安装过程,允许项目目标直接被其他项目重用

  • 通过配置文件创建可发现的包,这些文件封装了目标导出文件以及作者定义的特定于包的 CMake 宏和函数

这些功能非常强大,因为它们节省了很多时间,并简化了以这种方式准备的项目使用。执行基本安装的第一步是将构建好的工件复制到目标目录。

这让我们来到了 install() 命令及其各种模式:

  • install(TARGETS):这会安装输出工件,如库和可执行文件。

  • install(FILES|PROGRAMS):这会安装单个文件并设置它们的权限。

  • install(DIRECTORY): 这会安装整个目录。

  • install(SCRIPT|CODE):在安装期间运行 CMake 脚本或代码段。

  • install(EXPORT):这生成并安装一个目标导出文件。

将这些命令添加到您的列表文件中将生成一个cmake_install.cmake文件在您的构建树中。虽然可以手动调用此脚本使用cmake -P,但不建议这样做。这个文件是用来在执行cmake --install时由 CMake 内部使用的。

注意

即将推出的 CMake 版本还将支持安装运行时工件和依赖集合,因此请务必查阅最新文档以了解更多信息。

每个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的一部分)。

  • PUBLIC_HEADERPRIVATE_HEADERRESOURCE:在目标属性中指定相同名称的文件(在苹果平台上,它们应该设置在FRAMEWORKBUNDLE目标上)。

CMake 文档声称,如果你只配置了一种工件类型(例如,LIBRARY),只有这种类型将被安装。对于 CMake 3.20.0 版本,这并不正确:所有工件都将以默认选项配置的方式安装。这可以通过为所有不需要的工件类型指定<TYPE> EXCLUDE_FROM_ALL来解决。

注意

单个install(TARGETS)命令可以有多个工件配置块。但是,请注意,每次调用您可能只能指定每种类型的一个。也就是说,如果您想要为DebugRelease配置指定不同位置的ARCHIVE工件,那么您必须分别进行两次install(TARGETS ... ARCHIVE)调用。

你也可以省略类型名称,为所有工件指定选项:

install(TARGETS executable, static_lib1
  DESTINATION /tmp
)

安装过程将会对所有这些目标生成的文件进行,不论它们的类型是什么。

另外,你并不总是需要为DESTINATION提供安装目录。让我们看看原因。

为不同平台确定正确的目的地

目标路径的公式如下所示:

${CMAKE_INSTALL_PREFIX} + ${DESTINATION}

如果未提供DESTINATION,CMake 将使用每个类型的内置默认值:

虽然默认路径有时很有用,但它们并不适用于每种情况。例如,默认情况下,CMake 会“猜测”库的DESTINATION应该是lib。所有类 Unix 系统上的库的完整路径将被计算为/usr/local/lib,而在 Windows 上则是类似于C:\Program Files (x86)\<项目名称>\lib。这对于支持多架构的 Debian 来说不会是一个很好的选择,当INSTALL_PREFIX/usr时,它需要一个特定架构(例如i386-linux-gnu)的路径。为每个平台确定正确的路径是类 Unix 系统的一个常见问题。为了做到正确,我们需要遵循GNU 编码标准(在进一步阅读部分可以找到这个链接)。

在采用“猜测”之前,CMake 将检查是否为这种工件类型设置了CMAKE_INSTALL_<DIR>DIR变量,并使用从此处开始的路径。我们需要的是一个算法,能够检测平台并填充安装目录变量以提供适当的路径。CMake 通过提供GNUInstallDirs实用模块简化了此操作,该模块处理大多数平台并相应地设置安装目录变量。在调用任何install()命令之前只需include()它,然后你就可以正常使用了。

需要自定义配置的用户可以通过命令行使用-DCMAKE_INSTALL_BINDIR=/path/in/the/system提供安装目录变量。

然而,安装库的公共头文件可能会有些棘手。让我们来看看原因。

处理公共头文件

install(TARGETS)文档建议我们在库目标的PUBLIC_HEADER属性中(用分号分隔)指定公共头文件:

chapter-11/02-install-targets/src/CMakeLists.txt

add_library(calc STATIC calc.cpp)
target_include_directories(calc INTERFACE include)
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER src/include/calc/calc.h
)

如果我们使用 Unix 的默认“猜测”方式,文件最终会出现在/usr/local/include。这并不一定是最佳实践。理想情况下,我们希望能够将这些公共头文件放在一个能表明它们来源并引入命名空间的目录中;例如,/usr/local/include/calc。这将允许我们在这个系统上的所有项目中使用它们,如下所示:

#include <calc/calc.h>

大多数预处理器将尖括号中的指令识别为扫描标准系统目录的请求。这就是我们之前提到的GNUInstallDirs模块的作用。它为install()命令定义了安装变量,尽管我们也可以显式使用它们。在这种情况下,我们想要在公共头文件的目的地calc前加上CMAKE_INSTALL_INCLUDEDIR

chapter-11/02-install-targets/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(InstallTargets CXX) 
add_subdirectory(src bin)
include(GNUInstallDirs)
install(TARGETS calc
  ARCHIVE
  PUBLIC_HEADER
  DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
)

在从src包含列表文件,定义了我们的calc目标之后,我们必须配置静态库及其公共头文件的安装。我们已经包含了GNUInstallDirs模块,并明确指定了DESTINATIONPUBLIC_HEADERS。以安装模式运行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/calc.h

这种方式对于这个基本案例来说很好,但有一个轻微的缺点:以这种方式指定的文件不保留它们的目录结构。它们都将被安装在同一个目的地,即使它们嵌套在不同的基本目录中。

计划在新版本中(CMake 3.23.0)使用FILE_SET关键字更好地管理头文件:

target_sources(<target>
  [<PUBLIC|PRIVATE|INTERFACE>
   [FILE_SET <name> TYPE <type> [BASE_DIR <dir>] FILES]
   <files>...
  ]...
)

有关官方论坛上的讨论,请参阅进一步阅读部分中的链接。在发布该选项之前,我们可以使用此机制与PRIVATE_HEADERRESOURCE工件类型。但我们如何指定更复杂的安装目录结构呢?

低级安装

现代 CMake 正在逐步放弃直接操作文件的概念。理想情况下,我们总是将它们添加到一个逻辑目标中,并使用这个更高层次的抽象来表示所有底层资产:源文件、头文件、资源、配置等等。主要优点是代码的简洁性:通常,我们添加一个文件到目标时不需要更改多于一行代码。

不幸的是,将每个已安装的文件添加到目标上并不总是可能的或方便的。对于这种情况,有三种选择可用:install(FILES)install(PROGRAMS)install(DIRECTORY)

使用 install(FILES|PROGRAMS) 安装文件集

FILESPROGRAMS 模式非常相似。它们可以用来安装公共头文件、文档、shell 脚本、配置文件,以及所有种类的资产,包括图像、音频文件和将在运行时使用的数据集。

以下是命令签名:

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 都会有内置猜测:

这里的行为遵循在 为不同平台计算正确的目的地 部分描述的相同原则:如果此 TYPE 文件没有设置安装目录变量,CMake 将退回到默认的“猜测”路径。再次,我们可以使用 GNUInstallDirs 模块以提高可移植性。

表中一些内置猜测的前缀是安装目录变量:

  • $LOCALSTATECMAKE_INSTALL_LOCALSTATEDIR 或默认为 var

  • $DATAROOTCMAKE_INSTALL_DATAROOTDIR 或默认为 share

install(TARGETS)类似,如果包含了GNUInstallDirs模块,它将提供特定于平台的安装目录变量。让我们来看一个例子:

chapter-11/03-install-files/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(InstallFiles CXX)
include(GNUInstallDirs)
install(FILES
  src/include/calc/calc.h
  src/include/calc/nested/calc_extended.h
  DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
)

在这种情况下,CMake 将在系统级include 目录的项目特定子目录中安装两个头文件库——即calc.hnested/calc_extended.h

注意

GNUInstallDirs源文件中我们知道CMAKE_INSTALL_INCLUDEDIR对所有支持的平台都包含相同的路径。然而,为了可读性和与更动态的变量保持一致,仍然建议使用它。例如,CMAKE_INSTALL_LIBDIR将根据架构和发行版而变化——liblib64lib/<multiarch-tuple>

CMake 3.20 还向install(FILES|PROGRAMS)命令添加了相当有用的RENAME关键字,后跟新文件名(仅当files...列表包含单个文件时才有效)。

本节中的示例展示了安装文件到适当目录是多么简单。不过有一个问题——看看安装输出:

# cmake -S <source-tree> -B <build-tree>
# cmake --build <build-tree>
# cmake --install <build-tree>
-- Install configuration: ""
-- Installing: /usr/local/include/calc/calc.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|PROGRAMS)重复的。它们的工作方式是一样的。有一个值得注意的细节:如果在与DIRECTORY关键字提供的路径不以/结尾,路径的最后目录将被添加到目的地,如下所示:

install(DIRECTORY a DESTINATION /x)

这将创建一个名为/x/a的目录并将a的内容复制到其中。现在,看看以下代码:

install(DIRECTORY a/ DESTINATION /x)

这将直接将a的内容复制到/x

install(DIRECTORY)还引入了其他对文件不可用的机制:

  • 静默输出

  • 扩展权限控制

  • 文件/目录过滤

让我们先从静默输出选项MESSAGE_NEVER开始。它禁用了安装过程中的输出诊断。当我们有很多要安装的目录中的文件,打印它们所有人会太吵时,这个功能非常有用。

接下来是权限。这个install()模式支持设置权限的三个选项:

  • USE_SOURCE_PERMISSIONS按预期工作——它设置了遵循原始文件权限的安装文件权限。只有当FILE_PERMISSIONS未设置时,这才会起作用。

  • FILE_PERMISSIONS也非常容易解释。它允许我们指定想要设置在安装的文件和目录上的权限。默认的权限有OWNER_WRITEOWNER_READGROUP_READWORLD_READ

  • DIRECTORY_PERMISSIONS与前面选项的工作方式类似,但它将为所有用户设置额外的EXECUTE权限(这是因为 Unix-like 系统将目录上的EXECUTE理解为列出其内容的权限)。

请注意,CMake 将在不支持它们的平台上忽略权限选项。通过在每一个过滤表达式之后添加PERMISSIONS关键字,可以实现更多的权限控制:任何被它匹配的文件或目录都将接收到在此关键字之后指定的权限。

让我们来谈谈过滤器或“通配符”表达式。你可以设置多个过滤器,控制从源目录安装哪些文件/目录。它们有以下语法:

PATTERN <p> | REGEX <r> [EXCLUDE] [PERMISSIONS
  <permissions>]

有两种匹配方法可以选择:

  • 使用PATTERN,这是更简单的选项,我们可以提供一个带有?占位符(匹配任何字符)和通配符,*(匹配任何字符串)的模式。只有以<pattern>结尾的路径才会被匹配。

  • 另一方面,REGEX选项更高级——它支持正则表达式。它还允许我们匹配路径的任何部分(我们仍然可以使用^$锚点来表示路径的开始和结束)。

可选地,我们可以在第一个过滤器之前设置FILES_MATCHING关键字,这将指定任何过滤器都将应用于文件,而不是目录。

记住两个注意事项:

  • FILES_MATCHING需要一个包含性过滤器,也就是说,你可以排除一些文件,但除非你也添加一个表达式来包含其中的一些,否则没有文件会被复制。然而,无论过滤与否,所有目录都会被创建。

  • 所有子目录默认都是被过滤进去的;你只能进行排除。

对于每种过滤方法,我们可以选择EXCLUDE匹配的路径(这只有在没有使用FILES_MATCHING时才有效)。

我们可以通过在任何一个过滤器之后添加PERMISSIONS关键字和一个所需权限的列表,为所有匹配的路径设置特定的权限。让我们试试看。在这个例子中,我们将以三种不同的方式安装三个目录。我们将有一些在运行时使用的静态数据文件:

data
- data.csv

我们还需要一些位于src目录中的公共头文件,以及其他不相关的文件:

src
- include
  - calc
    - calc.h
    - ignored
      - empty.file
    - nested
      - calc_extended.h

最后,我们需要两个嵌套级别的配置文件。为了使事情更有趣,我们将使得/etc/calc/的内容只能被文件所有者访问:

etc
- calc
  - nested.conf
- sample.conf

要安装具有静态数据文件的目录,我们将使用install(DIRECTORY)命令的最基本形式开始我们的项目:

chapter-11/04-install-directories/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.20.0)
project(InstallDirectories CXX)
install(DIRECTORY data/ DESTINATION share/calc)
...

这个命令将简单地取我们data目录下的所有内容并将其放入${CMAKE_INSTALL_PREFIX}share/calc。请注意,我们的源路径以一个/符号结束,以表示我们不想复制data目录本身,只想它的内容。

第二个案例正好相反:我们不添加尾随的/,因为目录应该被包含。这是因为我们依赖于GNUInstallDirs提供的特定于系统的INCLUDE文件类型路径(注意INCLUDEEXCLUDE关键词代表无关的概念):

第十一章/04-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是如何工作的)。

第三个案例安装了一些默认的配置文件并设置了它们的权限:

第十一章/04-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
)

再次说明,我们不关心从源路径中添加etcSYSCONF类型的路径(这已经由包含GNUInstallDirs提供),因为我们会把文件放在/etc/etc中。此外,我们必须指定两个权限规则:

  • 子目录只能由所有者编辑和列出。

  • nested.conf结尾的文件只能由所有者编辑。

安装目录处理了很多不同的用例,但对于真正高级的安装场景(如安装后配置),我们可能需要使用外部工具。我们应该如何做到这一点?

在安装过程中调用脚本

如果你曾经在类 Unix 系统上安装过一个共享库,你可能记得在可以使用它之前,你可能需要告诉动态链接器扫描可信目录并调用ldconfig(在进一步阅读部分可以看到参考文献)来构建其缓存。如果你想要使你的安装完全自动化,CMake 提供了install(SCRIPT|CODE)命令来支持这类情况。以下是完整命令的签名:

install([[SCRIPT <file>] [CODE <code>]]
        [ALL_COMPONENTS | COMPONENT <component>]
        [EXCLUDE_FROM_ALL] [...])

你应该选择SCRIPTCODE模式并提供适当的参数——要么是一个运行 CMake 脚本的路径,要么是在安装过程中执行的 CMake 代码片段。为了了解这是如何工作的,我们将修改02-install-targets示例以构建一个共享库:

第十一章/05-install-code/src/CMakeLists.txt

add_library(calc SHARED calc.cpp)
target_include_directories(calc INTERFACE include)
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER src/include/calc/calc.h
)

我们需要在安装脚本中将 artifact 类型从 ARCHIVE 更改为 LIBRARY 以复制文件。然后,我们可以在之后添加运行 ldconfig 的逻辑:

第十一章/05-install-code/CMakeLists.txt(片段)

...
install(TARGETS calc LIBRARY
  PUBLIC_HEADER
  DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
 )
if (UNIX)
  install(CODE "execute_process(COMMAND ldconfig)")
endif()

if()条件检查命令是否与操作系统匹配(在 Windows 或 macOS 上执行ldconfig是不正确的)。当然,提供的代码必须具有有效的 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/calc.h
# ldconfig -p | grep libcalc
        libcalc.so (libc6,x86-64) => /usr/local/lib/libcalc.so

这两种模式都支持生成表达式,如果你需要的话。因此,这个命令和 CMake 本身一样多功能,可以用于所有 sorts of things:为用户打印消息,验证安装是否成功,进行详尽的配置,文件签名——你能想到的都有。

既然我们已经知道了将一组文件安装到系统上的所有不同方法,那么接下来让我们学习如何将它们转换为其他 CMake 项目可以原生使用的包。

创建可重用包

在之前的章节中,我们大量使用了find_package()。我们看到了它有多方便,以及它是如何简化整个过程的。为了使我们的项目通过这个命令可用,我们需要完成几步,以便 CMake 可以将我们的项目视为一个连贯的包:

  • 使我们的目标可移动。

  • 将目标导出文件安装到标准位置。

  • 为包创建配置文件和版本文件

让我们从开头说起:为什么目标需要可移动,我们又该如何实现?

理解可移动目标的问题

安装解决了许多问题,但不幸的是,它也引入了一些复杂性:不仅CMAKE_INSTALL_PREFIX是平台特定的,而且它还可以在安装阶段由用户使用--prefix选项进行设置。然而,目标导出文件是在安装之前生成的,在构建阶段,此时我们不知道安装的工件将去哪里。请看下面的代码:

chapter-11/01-export/src/CMakeLists.txt

add_library(calc STATIC calc.cpp)
target_include_directories(calc INTERFACE include)

在这个例子中,我们特别将包含目录添加到calc包含目录中。由于这是一个相对路径,CMake 生成的目标将隐式地将这个路径与CMAKE_CURRENT_SOURCE_DIR变量的内容相结合,该变量指向这个列表文件所在的目录。

然而,这还不够。已安装的项目不应再需要源代码或构建树中的文件。一切(包括库头文件)都被复制到一个共享位置,如 Linux 上的/usr/lib/calc/。由于这个片段中定义的目标的包含目录路径仍然指向其源树,所以我们不能在另一个项目中使用这个目标。

CMake 用两个生成表达式解决了这个问题,这些表达式将根据上下文过滤出表达式:

  • $<BUILD_INTERFACE>:这包括了常规构建的内容,但在安装时将其排除。

  • $<INSTALL_INTERFACE>:这包括了安装的内容,但排除了常规构建。

下面的代码展示了你如何实际上使用它们:

chapter-11/06-install-export/src/CMakeLists.txt

add_library(calc STATIC calc.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 src/include/calc/calc.h
)

对于常规构建,calc目标属性的INTERFACE_INCLUDE_DIRECTORIES值将像这样扩展:

"/root/examples/chapter-11/05-package/src/include" ""

空的双引号意味着在INSTALL_INTERFACE中提供的值被排除,并被评估为空字符串。另一方面,当我们安装时,该值将像这样扩展:

"" "/usr/lib/calc/include"

这次,在BUILD_INTERFACE生成表达式中提供的值被评估为空字符串,我们留下了另一个生成表达式的值。

关于CMAKE_INSTALL_PREFIX再说一句:这个变量不应该用作目标中指定路径的组件。它将在构建阶段进行评估,使路径成为绝对路径,并且不一定与在安装阶段提供的路径相同(因为用户可能使用--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生成表达式中评估的目标路径,而不是BUILD_INTERFACE,就像export(EXPORT)一样。

在此示例中,我们将为chapter-11/06-install-export/src/CMakeLists.txt中的目标生成并安装目标导出文件。为此,我们必须在顶层列表文件中调用install(EXPORT)

chapter-11/06-install-export/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(InstallExport CXX)
include(GNUInstallDirs) # so it's available in ./src/
add_subdirectory(src bin)
install(TARGETS calc EXPORT CalcTargets ARCHIVE
  PUBLIC_HEADER DESTINATION
    ${CMAKE_INSTALL_INCLUDEDIR}/calc
)
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()来消耗已安装的目标。然而,如果需要,您可以直接包含导出文件。那么,我们如何定义可以被其他项目消耗的包呢?让我们找出答案!

编写基本配置文件

一个完整的包定义包括目标导出文件、包的config 文件以及包的版本文件,但技术上来说,为了使find_package()工作只需要一个 config-file。它被视为一个包定义,负责提供任何包函数和宏,检查要求,查找依赖项,并包含目标导出文件。

如我们之前提到的,用户可以使用以下命令将您的包安装到他们系统上的任何位置:

cmake --install <build tree> --prefix=<installation path> 

这个前缀决定了安装文件将被复制到的位置。为了支持这一点,您至少必须确保以下几点:

  • 目标属性中的路径可以移动(如理解可移动目标的问题部分所述)。

  • 您 config-file 中使用的路径相对于它本身是相对的。

为了使用已安装在非默认位置的这类包,消费项目在配置阶段需要通过CMAKE_PREFIX_PATH变量提供<安装路径>。我们可以用以下命令来实现:

cmake -B <build tree> -DCMAKE_PREFIX_PATH=<installation path>

find_package()命令将按照文档中概述的路径(进一步阅读部分的链接)以平台特定的方式扫描。在 Windows 和类 Unix 系统中检查的一个模式如下:

<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/(cmake|CMake)

这告诉我们,将 config-file 安装在如lib/calc/cmake的路径上应该完全没有问题。另外,重要的是要强调 config-files 必须命名为<包名>-config.cmake<包名>Config.cmake才能被找到。

让我们将 config-file 的安装添加到06-install-export示例中:

chapter-11/07-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
)

此命令将从同一源目录(CMAKE_INSTALL_LIBDIR将被评估为平台正确的lib路径)安装CalcConfig.cmake

我们能够提供的最基本的 config-file 由一条包含目标导出文件的直线组成:

chapter-11/07-config-file/CalcConfig.cmake

include("${CMAKE_CURRENT_LIST_DIR}/CalcTargets.cmake")

CMAKE_CURRENT_LIST_DIR变量指的是 config-file 所在的目录。因为在我们示例中CalcConfig.cmakeCalcTargets.cmake安装在同一个目录中(如install(EXPORT)所设置),目标导出文件将被正确包含。

为了确保我们的包可以被使用,我们将创建一个简单的项目,仅包含一个 listfile:

chapter-11/08-find-package/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
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
)

为了在实际中测试这个,我们可以将07-config-file示例构建并安装到一个目录中,然后在使用DCMAKE_PREFIX_PATH参数引用它的情况下构建08-find-package,如下所示:

# cmake -S <source-tree-of-07> -B <build-tree-of-07>
# cmake --build <build-tree-of-07>
# cmake --install <build-tree-of-07>
# cmake -S <source-tree-of-08> -B <build-tree-of-08>  
  -DCMAKE_PREFIX_PATH=<build-tree-of-07>

这将产生以下输出(所有<_tree-of_>占位符都将被真实路径替换):

CMAKE_PREFIX_PATH: <build-tree-of-07>
CALC_FOUND: 1
--
 Properties for TARGET Calc::calc:
   Calc::calc.IMPORTED_CONFIGURATIONS = "NOCONFIG"
   Calc::calc.INTERFACE_INCLUDE_DIRECTORIES = "<build-tree-of-07>/include"
-- Configuring done
-- Generating done
-- Build files have been written to: <build-tree-of-08>

找到了CalcTargets.cmake文件,并正确地包含了它,*include 目录*的路径设置为遵循所选的前缀。这对于一个非常基础的打包情况解决了打包问题。现在,让我们学习如何处理更高级的场景。

创建高级配置文件

如果你管理的不仅仅是单个目标导出文件,那么在配置文件中包含几个宏可能是有用的。CMakePackageConfigHelpers工具模块让我们可以使用configure_package_config_file()命令。使用它时,我们需要提供一个模板文件,这个文件会被 CMake 变量插值,以生成一个带有两个内嵌宏定义的配置文件:

  • set_and_check(<variable> <path>): 这个命令类似于set(),但它会检查<path>是否存在,如果不存在则会导致FATAL_ERROR。建议在配置文件中使用它,以便尽早发现错误的路径。

  • check_required_components(<PackageName>): 这句话添加到配置文件的最后,将验证我们包中由用户在find_package(<package> REQUIRED <component>)中 required 的所有组件是否已经被找到。这是通过检查<package>_<component>_FOUND变量是否为真来完成的。

可以在生成配置文件的同时为更复杂的目录树准备安装阶段的路径。看看以下的签名:

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_DESTINATION的基路径来指示INSTALL_DESTINATION是相对于INSTALL_PREFIX的。

NO_SET_AND_CHECK_MACRONO_CHECK_REQUIRED_COMPONENTS_MACRO告诉 CMake 不要在生成的配置文件中添加这些宏定义。让我们在实践中看看这个生成过程。再次,我们将扩展06-install-export示例:

chapter-11/09-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. 设置一个变量,用于生成可移动路径。

  3. 使用位于源树中的CalcConfig.cmake.in模板生成构建树中的CalcConfig.cmake配置文件。最后,提供一个名为LIB_INSTALL_DIR的变量,它将被计算为相对于INSTALL_DESTINATION${CMAKE_INSTALL_LIBDIR}/calc/cmake的相对路径。

  4. 将构建树生成的配置文件传递给install(FILE)

请注意,install(FILE)中的DESTINATIONinstall(FILES)中的INSTALL_DESTINATION是相同的,这样就可以正确计算相对路径。

最后,我们需要一个配置文件模板(它们的名称通常以.in结尾):

chapter-11/09-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命令的定义,以便它们可以消耗项目。您可能会认出这些@PLACEHOLDERS@来自我们的普通configure_file()——它们的工作方式与 C++文件中的相同。

接下来,我们将(CALC_LIB_DIR)设置为通过@PACKAGE_LIB_INSTALL_DIR@占位符传递的路径。它将包含列表文件中提供的$LIB_INSTALL_DIR的路径,但它将相对于安装路径进行计算。然后,我们使用它来包含目标导出文件。

最后,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
macro(check_required_components _NAME) # ... removed for
  brevity
###########################################################################
set_and_check(CALC_LIB_DIR
  "${PACKAGE_PREFIX_DIR}/lib/calc")
include("${CALC_LIB_DIR}/cmake/CalcTargets.cmake")
check_required_components(Calc)

以下图表展示了各种包文件之间的关系,从而提供了这种关系的视角:

图 11.1 – 高级包的文件结构

图 11.1 – 高级包的文件结构

包的所有必需的子依赖项也必须在包配置文件中找到。这可以通过调用CMakeFindDependencyMacro助手中的find_dependency()宏来实现。我们在第七章中学习了如何使用它,使用 CMake 管理依赖项

如果您决定向消耗项目暴露任何宏或函数,建议您将它们的定义放在一个单独的文件中,然后您可以从包的配置文件中include()它。

有趣的是,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<package>将与find_package(<package> 1.2.8)相匹配。

通常,所有包必须为与消费项目相同的架构构建(执行精确检查)。然而,对于不编译任何内容的包(仅头文件库、宏包等),您可以使用ARCH_INDEPENDENT关键词跳过此检查。

现在,是时候来一个实际例子了。以下代码展示了如何为我们在06-install-export示例中开始的项目提供版本文件

chapter-11/10-version-file/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.20.0)
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关键词来使用显式、完整语法的语法。

在包含助手工具模块后,我们调用生成命令并将文件写入符合find_package()所需模式的构建树中。在这里,我们故意省略了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()中使用组件

当我们调用find_package()并带有COMPONENTSOPTIONAL_COMPONENTS列表时,我们告诉 CMake 我们只对提供这些组件的包感兴趣。然而,重要的是要意识到,是否有必要检查这一要求取决于包本身,如果包的供应商没有在创建高级 config 文件小节中提到的 config 文件中添加必要的检查,那么什么也不会发生。

请求的组件将通过<package>_FIND_COMPONENTS变量传递给 config 文件(可选和非可选都有)。此外,对于每个非可选组件,将设置一个<package>_FIND_REQUIRED_<component>。作为包的作者,我们可以编写一个宏来扫描这个列表并检查我们是否提供了所有必需的组件。但我们不需要这样做——这正是check_required_components()所做的。要使用它,config 文件应在找到必要的组件时设置<Package>_<Component>_FOUND变量。文件末尾的宏将检查是否设置了所有必需的变量。

如何在install()命令中使用组件

一些生成的工件可能不需要在所有场景中都进行安装。例如,一个项目可能为了开发目的安装静态库和公共头文件,但默认情况下,它只需安装共享库以供运行时使用。为了实现这种行为的双重性,我们可以使用在所有install()命令中可用的COMPONENT关键字来将工件分组,用户如果对限制安装到特定组件感兴趣,可以通过运行以下命令(组件名称区分大小写)来显式请求:

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

COMPONENT关键字标记一个工件并不意味着它不会被默认安装。为了防止这种情况发生,我们必须添加EXCLUDE_FROM_ALL关键字。

让我们通过一个代码示例来探索这些组件:

chapter-11/11-components/CMakeLists.txt(片段)

...
install(TARGETS calc EXPORT CalcTargets
  ARCHIVE
    COMPONENT lib
  PUBLIC_HEADER
    DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
    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
)
...

这些安装命令定义了以下组件:

  • lib:这包含静态库和目标导出文件。它默认安装。

  • headers:包含公共头文件。它默认安装。

  • extra:通过打印一条消息执行一段代码。它不会被默认安装。

让我们重申这一点:

  • 不带--component参数的cmake --install将安装libheaders组件。

  • cmake --install --component headers将只安装公共头文件。

  • cmake --install --component extra将打印一条在其他情况下无法访问的消息(因为EXCLUDE_FROM_ALL关键字)。

如果安装的工件没有指定COMPONENT关键字,它将从CMAKE_INSTALL_DEFAULT_COMPONENT_NAME变量中获得默认值Unspecified

注意

由于没有简单的方法从cmake命令行列出所有可用的组件,您的包的用户将受益于详尽的文档,列出您的包的组件。也许在INSTALL文件中提到这一点是个好主意。

如果调用cmake时为不存在的一个组件提供了--component参数,那么该命令将成功执行,不带任何警告或错误。它只是不会安装任何东西。

将我们的安装划分为组件使得用户能够挑选他们想要安装的内容。我们 mostly 讨论了将安装文件划分为组件,但还有些程序步骤,比如install(SCRIPT|CODE)或为共享库创建符号链接。

管理版本化共享库的符号链接

您的安装目标平台可能使用符号链接来帮助链接器发现当前安装的共享库版本。在创建一个指向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 Makefiles,Visual Studio 等)混淆。

要使用 CPack,我们需要正确配置项目的安装,并使用必要的install()命令构建项目。在我们构建树中生成的cmake_install.cmake将用于cpack根据配置文件(CPackConfig.cmake)准备二进制包。虽然可以手动创建此文件,但使用include(CPack)更容易地在项目的列表文件中包含实用模块。它将在项目的构建树中生成配置,并在需要的地方提供所有默认值。

让我们看看如何扩展示例11-components,使其可以与 CPack 一起工作:

chapter-11/12-cpack/CMakeLists.txt (片段)

cmake_minimum_required(VERSION 3.20.0)
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_NAME-$CPACK_PACKAGE_VERSION-$CPACK_SYSTEM_NAME

在这里,CPACK_SYSTEM_NAME是目标操作系统的名称;例如,Linuxwin32。例如,通过在 Debian 上执行 ZIP 生成器,CPack 将生成一个名为CPackPackage-1.2.3-Linux.zip的文件。

在我们构建项目之后,我们可以在构建树中运行cpack二进制文件来生成实际的包:

cpack [<options>]

从技术上讲,CPack 能够读取放置在当前工作目录中的所有配置文件选项,但你也可以选择从命令行覆盖这些设置:

  • -G <generators>:这是一个由分号分隔的包生成器列表。默认值可以在CPackConfig.cmake中的CPACK_GENERATOR变量中指定。

  • -C <configs>:这是一个由分号分隔的构建配置(调试、发布)列表,用于生成包(对于多配置构建系统生成器,这是必需的)。

  • -D <var>=<value>: 这个选项会覆盖CPackConfig.cmake文件中设置的<var>变量,以<value>为准。

  • --config <config-file>: 这是你应该使用的配置文件,而不是默认的CPackConfig.cmake

  • --verbose, -V: 提供详细输出。

  • -P <packageName>: 覆盖包名称。

  • -R <packageVersion>: 覆盖包版本。

  • --vendor <vendorName>: 覆盖包供应商。

  • -B <packageDirectory>: 为cpack指定输出目录(默认情况下,这将是目前的工作目录)。

让我们尝试为我们的12-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 如何处理各种工件类型的不同目的地以及如何处理 somewhat special 的公共头文件。为了在较低级别管理这些安装步骤,我们讨论了install()命令的其他模式,包括安装文件、程序和目录以及在安装过程中调用脚本。

在解释了如何编码安装步骤之后,我们学习了 CMake 的可重用包。具体来说,我们学习了如何使项目中的目标可移动,以便包可以在用户希望安装的任何地方进行安装。然后,我们专注于形成一个完全定义的包,它可以通过find_package()被其他项目消费,这需要准备目标导出文件、配置文件以及版本文件

认识到不同用户可能需要我们包的不同部分,我们发现了如何将工件和动作分组在安装组件中,以及它们与 CMake 包组件的区别。

最后,我们提到了 CPack,并学习了如何准备基本的二进制包,以使用预编译的形式分发我们的软件。

要完全掌握安装和打包的所有细节和复杂性还有很长的路要走,但本章为我们提供了坚实的基础,以处理最常见的情况并自信地进一步探索它们。

在下一章中,我们将把我们到目前为止所学的所有内容付诸实践,通过创建一个连贯、专业的项目。

进一步阅读

要了解更多关于本章涵盖的主题,请查看以下资源:

第十二章:创建你的专业项目

我们已经掌握了构建专业项目的所有必要知识;我们学习了结构化、构建、依赖管理、测试、分析、安装和打包。是时候将这些学到的技能付诸实践,通过创建一个连贯、专业的项目。

需要理解的重要一点是,即使是简单的程序,也会从自动化质量检查和简化的端到端过程中受益,这些过程将原始代码转化为完全成熟的解决方案。确实,这通常是一个相当大的投资,因为需要采取许多步骤才能一切准备就绪——尤其是如果我们试图将这些机制添加到已经存在的代码库中(通常,它们已经很大且复杂)。

那就是从一开始就使用 CMake 并提前设置所有管道的理由;这样做不仅配置起来会更简单,更重要的是,早点做会更有效率,因为所有的质量控制和构建自动化最终都需要添加到长期项目中。

这正是本章我们将要做的——我们将编写一个新的解决方案,尽可能小和简单。它将执行一个单一的(几乎)实用功能——将两个数字相加。限制业务代码的功能将允许我们关注项目中学到的每个其他方面。

为了有一个更复杂的问题来解决,这个项目将同时构建一个库和一个可执行文件。该库将提供内部业务逻辑,并作为 CMake 包供其他项目使用。可执行文件只为最终用户而设计,并实现一个用户界面,显示底层库的功能。

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

  • 规划我们的工作

  • 项目布局

  • 构建与管理依赖

  • 测试与程序分析

  • 安装与打包

  • 提供文档

技术要求

你可以在这个章节中找到的代码文件在 GitHub 上:

github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter12

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

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

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

规划我们的工作

本章我们将要构建的软件并不打算非常复杂——我们将创建一个简单的计算器,用于相加两个数字(图 12.1)。它将被发布为一个具有文本用户界面的控制台应用程序和一个执行数学运算的库,这可以潜在地用于另一个项目。虽然在现实生活中这种项目用处不大,因为 C++在其标准库中提供了大量的计算支持,但它的平凡性将完美地探索本书中讨论的所有技术如何在实践中共同工作:

图 12.1 – 控制台计算器用户界面的两种状态

图 12.1 – 控制台计算器用户界面的两种状态

通常,项目要么产生面向用户的可执行文件,要么为开发者提供库。同时做两者的项目比较少见,但并非完全罕见——一些应用程序提供独立的 SDK 或支持插件创建的库。另一种情况可能是提供其使用示例的库。本章我们将要构建的项目在一定程度上适合最后一类。

我们将通过回顾章节列表、回顾其内容,并选择其中描述的技巧和工具来开始规划,以构建我们的计算应用程序:

1章,CMake 的初步步骤

第一章为我们提供了关于 CMake 的基本信息——如何安装它以及如何使用其命令行构建准备好的项目。这里提供的关于项目文件的信息将是关键:不同文件的责任、通常使用的名称和一些古怪之处。在本章中,我们还讨论了生成器的预设文件,但在本项目中将跳过这些内容。

2章,CMake 语言

在这里,我们介绍了编写正确的列表文件和脚本所需的工具。我们分享了关于代码:注释、命令调用和参数的基本信息。我们还详细解释了变量、列表和控制结构,并介绍了一些非常有用的命令。这些知识将在整个项目中得到应用。

3章,设置你的第一个 CMake 项目

第三章讨论的主题将对项目产生重大影响:

  • 指定最小的 CMake 版本决定了哪些 CMake 政策将适用;命名、版本化和配置项目的语言影响了构建的基本行为。

  • 关于项目划分和结构化的洞察力塑造了目录和文件的结构布局。

  • 系统发现变量有助于我们决定如何处理不同的环境,特别是针对本项目——例如,我们需要运行ldconfig吗?

  • 工具链配置允许指定特定版本的 C++和编译器支持的标准。

本章还告诉我们,禁用源代码构建通常是个好主意,所以我们将会这样做。

第四章,使用目标工作

在这里,我们强调了现代 CMake 项目如何广泛使用目标。我们的项目也将如此,以下原因是:

  • 定义几个库和可执行文件(用于测试和生产)将使项目保持组织性并保持干燥.

  • 目标属性和传递使用要求(传播属性)使配置接近目标定义。

  • 生成器表达式将在解决方案中多次出现,但我们将其尽可能保持简单。

在这个项目中,我们将使用自定义命令生成 Valgrind 和覆盖报告文件,并使用目标挂钩(PRE_BUILD)来清理由覆盖度 instrumentation 产生的.gcda文件。

第五章,使用 CMake 编译 C++源代码

没有编译的 C++项目是不存在的。基础知识相当简单,但 CMake 允许我们在许多方面调整此过程:扩展目标源、配置优化器、提供调试信息。对于这个项目,默认编译标志就足够了,但我们将继续尝试预处理器:

  • 我们将在编译的可执行文件中存储构建元数据(项目版本、构建时间和 Git 提交 SHA),并展示给用户。

  • 我们将启用头文件的预编译。在一个如此小的项目中,这并不是真正的必需品,但它将帮助我们练习这个概念。

统一构建将不再必要——项目不会足够大,以至于添加它们值得。

第六章,使用 CMake 链接

第六章为我们提供了关于链接(在任何项目中都有用)的一般信息,其中大部分默认情况下就很有用。但由于该项目还提供了一个库,我们将明确参考一些以下构建说明:

  • 用于测试和开发的静态库

  • 用于发布的共享库

本章概述了如何为测试分离main(),我们也将这样做。

第七章,使用 CMake 管理依赖关系

为了使项目更有趣,我们将引入一个外部依赖项:一个文本 UI 库。我们在这一章描述了几种依赖管理方法。选择正确的一个并不太困难:通常推荐使用FetchContent实用程序模块,最为方便(除非我们正在解决本章中描述的具体边缘情况)。

第八章,测试框架

适当的自动化测试对于确保我们解决方案的质量随时间不会降低至关重要。我们将添加对 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 在这里显示其诊断信息,并告诉用户缺少什么。如果你在阅读这本书后发布的项目获得了显著的牵引力,你可能会想要考虑添加这些机制以改善用户体验。

已经形成了明确的计划,接下来让我们讨论一下如何实际地组织项目,包括逻辑目标和目录结构。

项目布局

为了构建任何项目,我们应该首先清楚理解其中将要创建的逻辑目标。在本例中,我们将遵循图 12.2所示的结构:

图 12.2 – 逻辑目标结构

图 12.2 – 逻辑目标结构

让我们按照构建顺序来探索结构。首先,我们将编译calc_obj,这是一个对象库。我们在书中提到了几次对象库,但并没有真正介绍这个概念。现在让我们来做这件事。

对象库

对象库用于将多个源文件组合到一个单一的逻辑目标中,并在构建过程中编译成(.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 链接。我们提到,总体内存使用对于使用相同共享库的多个程序可能更有利,并且用户可能已经拥有最流行的库,或者知道如何快速安装它们。更重要的是,共享库以单独的文件形式提供,必须安装在特定的路径上,动态链接器才能找到它们,而静态库作为可执行文件的一部分合并。在这种形式下,它们的使用速度略快,因为不需要进行额外的查找来找到内存中代码的位置。

作为库作者,我们可以决定是否提供库的静态或共享版本,或者我们可以简单地提供这两个版本,并将此决定留给使用我们库的程序员。我们在这里选择后一种方法(只是为了看看它是如何完成的)。

静态库将由calc_test目标消耗,其中将包含确保库提供的业务功能按预期工作的单元测试。如前所述,我们从相同的一组编译目标文件构建两个版本。在这种场景下,测试任何一个版本都是完全可以的,因为它们的实际行为应该没有实际的区别。

提供的calc_console_static目标将使用共享库。此目标还将链接到外部依赖项:函数式终端(X)用户界面FTXUI)库,由 Arthur Sonzogni 编写(在进一步阅读部分有一个到 GitHub 项目的链接)。它为文本用户界面提供了一个无依赖、跨平台的框架。

最后两个目标是calc_consolecalc_console_testcalc_console目标只是一个围绕calc_console_staticmain()引导包装器。它的唯一目的是从业务代码中提取入口点。这允许我们编写单元测试(需要提供自己的入口点)并从calc_console_test运行它们。

我们现在知道需要构建哪些目标以及它们之间的关系。让我们弄清楚如何用文件和目录结构化项目。

项目文件结构

项目包含两个主要目标,calc库和calc_console可执行文件,每个目标都将在srctest下的目录树中,以将生产代码与测试分开(如图 12.3所示)。此外,我们将在另外两个目录中拥有我们的文件:

  • 项目根目录,包含顶级配置和关键项目文档文件

  • 用于所有实用模块和帮助文件的cmake目录,CMake 使用这些文件来构建和安装项目:

图 12.3 – 项目目录结构

图 12.3 – 项目目录结构

以下是每个四个主要目录中的完整文件列表:

最初,cmake目录比业务代码更繁忙,但随着项目的功能增长,这种情况会很快改变。启动一个干净项目的努力是巨大的,但不用担心——很快就会得到回报。

我们将遍历所有文件,并详细查看它们做什么以及它们在项目中的作用。这将在四个步骤中完成:构建、测试、安装和提供文档。

构建和管理依赖项

所有的构建过程都是相同的。我们从顶层列表文件开始,向下导航到项目源树。图 12.4 显示了哪些项目文件参与构建。圆括号中的数字表示 CMake 脚本执行的顺序:

图 12.4 – 构建阶段使用的文件

图 12.4 – 构建阶段使用的文件

我们的顶层列表文件将配置项目并加载嵌套元素:

chapter-12/01-full-project/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(Calc VERSION 1.0.0 LANGUAGES CXX)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(NoInSourceBuilds)
add_subdirectory(src bin)
add_subdirectory(test)
include(Install)

我们首先提供关键项目详情,并添加到 CMake 实用模块的路径(我们项目中的cmake目录)。然后我们禁用源内构建(通过自定义模块),并包括两个关键目录:

  • src,包含项目源代码(在构建树中将被命名为bin

  • test,包含所有的测试工具

最后,我们包含另一个模块,将设置项目的安装。这将在另一节中讨论。与此同时,让我们看看NoInSourceBuilds模块是如何工作的:

chapter-12/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命令提供了目标目录作为参数来存储生成的文件。这必须与项目源树不同的路径。如果不是这样,我们告知用户如何提供它,以及如何在犯错误后清理仓库。

我们的顶级列表文件然后包含了src子目录,指示 CMake 读取其中的列表文件:

chapter-12/01-full-project/src/CMakeLists.txt

add_subdirectory(calc)
add_subdirectory(calc_console)

这个文件非常微妙——它只是进入嵌套目录,执行其中的列表文件。让我们跟随calc库的列表文件——它有点复杂,所以我们将分部分讨论。

构建 Calc 库

calc的列表文件包含一些测试配置,但我们现在将关注构建;其余内容将在测试和程序分析部分讨论:

chapter-12/01-full-project/src/calc/CMakeLists.txt(片段)

add_library(calc_obj OBJECT calc.cpp)
target_include_directories(calc_obj INTERFACE
  "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"
  "$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>"
)
set_target_properties(calc_obj PROPERTIES
    PUBLIC_HEADER src/calc/include/calc/calc.h
    POSITION_INDEPENDENT_CODE 1
)
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

我们声明了三个目标:

  • calc_obj,一个对象库,编译一个calc.cpp实现文件。它还通过PUBLIC_HEADER属性引用calc.h头文件,该属性可以在配置的包含目录中找到(感谢生成器表达式提供特定模式的适当路径——构建或安装)。使用这个库,我们避免了其他目标的重复编译,但我们也需要启用POSITION_INDEPENDENT_CODE,以便生成的对象文件可以被共享库使用。

  • calc_shared,一个依赖于calc_obj的共享库。

  • calc_static,一个依赖于calc_obj的静态库。

为了完整性,我们将添加calc库的 C++ 代码清单:

chapter-12/01-full-project/src/calc/include/calc/calc.h

#pragma once
namespace Calc {
int Sum(int a, int b);
int Multiply(int a, int b);
}  // namespace Calc

这段代码相当基础:它声明了两个全局函数,包含在Calc命名空间中(C++命名空间在库中非常有用,帮助避免名称冲突)。

实现文件也非常直接:

chapter-12/01-full-project/src/calc/calc.cpp

namespace Calc {
int Sum(int a, int b) {
  return a + b;
}
int Multiply(int a, int b) {
  return a * b;
}
}  // namespace Calc

这结束了src/calc目录中文件的解释。接下来是src/calc_console,以及使用这个库构建控制台计算器的可执行文件。

构建 Calc 控制台可执行文件

calc_console的源目录包含几个文件:一个列表文件,两个实现文件(业务代码和引导程序),和一个头文件。列表文件如下所示:

chapter-12/01-full-project/src/calc_console/CMakeLists.txt(片段)

include(GetFTXUI)
add_library(calc_console_static STATIC tui.cpp)
target_include_directories(calc_console_static PUBLIC
include)
target_precompile_headers(calc_console_static PUBLIC
<string>)
target_link_libraries(calc_console_static PUBLIC
calc_shared
  ftxui::screen ftxui::dom ftxui::component)
include(BuildInfo)
BuildInfo(calc_console_static)
# … testing and program analysis modules
# ... documentation generation
add_executable(calc_console bootstrap.cpp)
target_link_libraries(calc_console calc_console_static)

列表文件看起来很忙,但现在,作为有经验的 CMake 用户,我们可以轻松地解开里面正在发生的事情:

  1. 包含 CMake 模块以获取 FTXUI 依赖。

  2. 声明calc_console_static目标,其中包含业务代码,但不包括main()函数,以允许 GTest 定义自己的入口点。

  3. 添加一个头文件预编译——我们只是添加了一个标准的string头文件来证明一个观点,但对于更大的项目,我们可以添加更多(包括项目所属的头文件)。

  4. 将业务代码与共享的calc_shared库和 FTXUI 库链接。

  5. 添加所有要在该目标上执行的动作:生成构建信息、测试、程序分析和文档。

  6. 添加并链接calc_console引导可执行文件,该文件提供了入口点。

再次,我们将推迟讨论测试和文档,而是查看依赖管理和构建信息生成。

请注意,我们倾向于使用实用模块而不是 find-module 来引入 FTXUI。这是因为这个依赖项不太可能已经存在于系统中。与其希望找到它,不如我们获取并安装它:

chapter-12/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 构建的繁琐步骤,并使它的安装配置与这个项目的安装分离。对于 GTest 依赖项也需要这样做。option()命令在进一步阅读部分中有参考。

calc_command的列表文件包含了一个更多的自定义实用模块,与构建相关:BuildInfo。我们将使用它来记录三个可以在可执行文件中显示的值:

  • 当前 Git 提交的 SHA

  • 构建的时间戳

  • 顶级列表文件中指定的项目版本

你可能还记得从第五章,《使用 CMake 编译 C++源码》,我们可以使用 CMake 捕获一些构建时的值,并通过模板文件将它们提供给 C++代码——例如,使用一个方便的 C++结构体:

chapter-12/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@";
};

为了在配置阶段填充该结构,我们将使用以下代码:

chapter-12/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 directories中。如果需要,该文件可以与多个不同的消费者共享。在这种情况下,你可能需要在列表文件的顶部添加include_guard(GLOBAL),以避免为每个目标运行git命令。

在深入讨论控制台计算器的实现之前,我想强调你不必太担心tui.cpp文件的复杂性。要完全理解它,你将需要对 FXTUI 库有一定的了解——我们在这里不想深入讲解。相反,让我们关注高亮的行:

chapter-12/01-full-project/src/calc_console/tui.cpp

#include "tui.h"
#include <ftxui/dom/elements.hpp>
#include "buildinfo.h"
#include "calc/calc.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::Sum(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方法,并使用结果值打印带有sum的标签(通过调用下面的text()函数)。

同样,标签用于呈现在构建时通过连续三次调用text()收集的BuildInfo::值。

这个方法在其相关的头文件中声明:

chapter-12/01-full-project/src/calc_console/include/tui.h

#include <ftxui/component/component.hpp>
ftxui::Component getTui();

然后由calc_console目标中的引导程序使用:

chapter-12/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对象,使其对用户可见,并在循环中收集键盘事件,创建一个界面,如图12.1所示。再次强调,完全理解这一点并不是非常关键,因为ftxui的主要目的是为我们提供一个外部依赖,我们可以用它来练习 CMake 技术。

我们已经覆盖了src目录中的所有文件。让我们继续讨论前面提到的测试和分析程序的主题。

测试与程序分析

程序分析与测试相辅相成,确保我们解决方案的质量。例如,当使用测试代码时,运行 Valgrind 变得更加一致。为此,我们将配置这两件事在一起。图12.5展示了执行流程和设置所需的文件(将在src目录中添加几个片段):

图 12.5 – 用于启用测试和程序分析的文件

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_12.5_B17205.jpg)

图 12.5 – 用于启用测试和程序分析的文件

正如我们所知,测试文件位于test目录中,它们的列表文件通过add_subdirectory()命令从顶层列表文件执行。让我们看看里面有什么:

chapter-12/01-full-project/test/CMakeLists.txt

include(Testing)
add_subdirectory(calc)
add_subdirectory(calc_console)

Testing模块中定义的测试工具包括在这个级别,以允许两个目标组(来自calccalc_console目录)使用它们:

chapter-12/01-full-project/cmake/Testing.cmake (片段)

enable_testing()
include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.11.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,但这两个框架都包含在一个单一的存储库中,因此我们也需要配置 GMock。此配置的突出部分使这两个框架的安装与我们的项目的安装分离(通过将适当的option()设置为OFF)。

接下来,我们需要创建一个函数,以启用对业务目标的彻底测试。我们将其保存在同一个文件中:

chapter-12/01-full-project/cmake/Testing.cmake(继续)

...
include(GoogleTest)
include(Coverage)
include(Memcheck)
macro(AddTests target)
  target_link_libraries(${target} PRIVATE gtest_main gmock)
  gtest_discover_tests(${target})
  AddCoverage(${target})
  AddMemcheck(${target})
endmacro()

在这里,我们首先包含必要的模块:GoogleTest与 CMake 捆绑在一起,但CoverageMemcheck将由我们编写。然后我们提供了一个AddTests宏,该宏将准备一个测试目标、启用覆盖和内存检查。让我们详细看看它是如何工作的。

准备覆盖模块

为多个目标添加覆盖是一个有点棘手的过程,因为它包括几个步骤。我们首先介绍两个函数,以启用覆盖跟踪并在构建之间清理陈旧的跟踪文件:

chapter-12/01-full-project/cmake/Coverage.cmake(片段)

function(EnableCoverage target)
  if (CMAKE_BUILD_TYPE STREQUAL Debug)
    target_compile_options(${target} PRIVATE --coverage 
      -fno-inline)
    target_link_options(${target} PUBLIC --coverage)
  endif()
endfunction()
function(CleanCoverage target)
  add_custom_command(TARGET ${target} PRE_BUILD COMMAND
    find ${CMAKE_BINARY_DIR} -type f
    -name '*.gcda' -exec rm {} +)
endfunction()

前面的函数将在我们到达单独的目标配置(calc_...calc_console_...)时被使用。Coverage模块还将提供一个生成自定义覆盖目标的函数:

chapter-12/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()

AddCoverage()函数在Testing模块中的AddTests()函数中被调用。它与第八章中介绍的测试框架略有不同,因为它考虑了目标名称,并将其添加到输出路径以避免任何冲突。

为了生成两个测试目标的报告,我们只需运行两个cmake命令(在用Debug构建类型配置项目后):

cmake --build <build-tree> -t coverage-calc_test
cmake --build <build-tree> -t coverage-calc_console_test

现在是我们修改之前创建的 Memcheck 模块的时候了(在第九章程序分析工具中)以处理多个目标。

准备 Memcheck 模块

AddTests()调用了 Valgrind 内存管理报告的生成。我们将从一般设置开始这个模块:

chapter-12/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)

我们已经熟悉这段代码了;让我们看看将创建适当目标的函数:

chapter-12/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()

为了处理多个目标,REPORT_PATH变量被设置以存储目标特定的报告路径。然后在此后的命令中使用此变量。

可以通过以下命令生成 Memcheck 报告(这在Debug构建类型中效果更好):

cmake --build <build-tree> -t memcheck-calc_test
cmake --build <build-tree> -t memcheck-calc_console_test

这些都是Testing模块所使用的模块。我们来看看它是如何使用的。

应用测试场景

为了让测试工作,必须发生几件事情:

  1. 我们需要为两个目录创建嵌套列表文件并定义测试目标。

  2. 单元测试需要编写并作为可执行目标准备。

  3. 这些目标需要调用 AddTests()

  4. 被测试的软件SUT)需要被修改以启用覆盖率收集。

  5. 收集的覆盖率应在构建之间清理,以避免段错误。

正如 test/CMakeLists.txt 暗示的那样,我们将创建两个嵌套列表文件来配置我们的测试。再一次,我们将为库提供一个:

chapter-12/01-full-project/test/calc/CMakeLists.txt(片段)

add_executable(calc_test calc_test.cpp)
target_link_libraries(calc_test PRIVATE calc_static)
AddTests(calc_test)
EnableCoverage(calc_obj)

我们也会为可执行文件提供一个:

chapter-12/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)
EnableCoverage(calc_console_static)

为了保持简洁,我们将提供尽可能简单的单元测试。一个文件将覆盖库:

chapter-12/01-full-project/test/calc/calc_test.cpp

#include "calc/calc.h"
#include <gtest/gtest.h>
TEST(CalcTest, SumAddsTwoInts) {
  EXPECT_EQ(4, Calc::Sum(2, 2));
}
TEST(CalcTest, MultiplyMultipliesTwoInts) {
  EXPECT_EQ(12, Calc::Multiply(3, 4));
}

我们将有一个第二个文件来测试业务代码。为此,我们将使用 FXTUI 库。同样,我们不期望你详细了解这个源代码。本章提供的测试列表只是为了完整:

chapter-12/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"));
}

这个测试代码简单地将文本界面渲染为默认状态到一个静态屏幕对象,然后将其存储在一个字符串中。为了使测试通过,输出需要包含默认和的子字符串。

现在,我们需要完成剩下的步骤:创建测试目标并准备好它们的源代码后,是时候使用 Testing 模块的 AddTests() 函数将它们注册到 CPack 了。

我们为图书馆这样做:

chapter-12/01-full-project/test/calc/CMakeLists.txt(继续)

# ... calc_test target definition
AddTests(calc_test)
EnableCoverage(calc_obj)

然后为可执行文件这样做:

chapter-12/01-full-project/test/calc_console/CMakeLists.txt(继续)

# ... calc_console_test target definition
AddTests(calc_console_test)
EnableCoverage(calc_console_static)

随后,我们指示 SUT 使用 EnableCoverage() 启用覆盖率 instrumentation。注意,在库的情况下,我们必须添加 instrumentation 到 对象库 而不是静态库。这是因为 --coverage 标志必须添加到编译步骤,这发生在 calc_obj 正在构建的时候。

遗憾的是,我们在这里不能添加覆盖率文件的清理,因为 CMake 要求 add_custom_command 钩子必须在目标定义相同的目录中调用。这使我们回到了之前未完成的 src/calcsrc/calc_console 列表文件。我们需要分别添加 CleanCoverage(calc_static)CleanCoverage(calc_console_static)(我们首先必须包含 Coverage 模块)。还需要向这些文件添加什么吗?启用静态分析的说明!

添加静态分析工具

我们将业务代码列表文件的继续推迟到现在,这样我们就可以在适当的情况下讨论添加的模块。我们可以在库列表文件中添加一个 CleanCoverage 函数调用和其他一些东西:

chapter-12/01-full-project/src/calc/CMakeLists.txt(继续)

# ... calc_static target definition
include(Coverage)
CleanCoverage(calc_static)
include(Format)
Format(calc_static .)
include(CppCheck)
AddCppCheck(calc_obj)
# ... documentation generation

我们也可以将它们添加到可执行文件中:

章节-12/01-full-project/src/calc_console/CMakeLists.cmake (继续)

# ... calc_console_static target definition
include(BuildInfo)
BuildInfo(calc_console_static)
include(Coverage)
CleanCoverage(calc_console_static)
include(Format)
Format(calc_console_static .)
include(CppCheck)
AddCppCheck(calc_console_static)
# ... documentation generation
# ... calc_console bootstrap target definition

这些文件现在几乎完成了(正如第二个注释所暗示的,我们还需要添加文档代码,这将在自动文档生成部分完成)。

列表中出现了两个新模块:FormatCppCheck。让我们先来看第一个:

章节-12/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()

Format()函数是第九章《程序分析工具》中描述的格式化函数的完整复制;我们在这里只是重新使用它。

接下来是一个全新的CppCheck模块:

章节-12/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=10 – 这指定了当cppcheck检测到问题时,我们希望得到一个错误代码。这可以是从1255的任何数字(0表示成功),尽管有些数字可能被系统保留。

使用非常方便——调用AddCppCheck将通知 CMake 需要在指定的目标上自动运行检查。

我们已经在srctest子目录中几乎创建了所有文件。现在,我们的解决方案可以构建并完全测试。终于到了安装和打包的时候了。

安装和打包

我们回到前一章讨论的主题,并从快速查看设置安装和打包所需的文件开始:

Figure 12.6 – 配置安装和打包文件的示例

![Figure 12.6 – 配置安装和打包文件的示例

这里只需要文件——大部分工作已经在之前的章节中完成。正如您可能记得的,顶层列表文件包含一个 CMake 模块,它将处理这个过程:

章节-12/01-full-project/CMakeLists.txt (片段)

...
include(Install)

我们关注的是安装两个项目:

  • 计算库工件:静态库、共享库以及与之相关的头文件和目标导出文件

  • 计算控制台可执行文件

包定义配置文件将只引入库目标,因为潜在的消费项目不会依赖于可执行文件。

在配置安装步骤之后,我们将转向 CPack 配置。Install模块的高级概述如下:

章节-12/01-full-project/cmake/Install.cmake (概览)

# Includes
# Installation of Calc library
# Installation of Calc Console executable
# Configuration of CPack

一切都有计划,所以是时候为库编写一个安装模块了。

库的安装

要安装库,最好先配置逻辑目标并指定其工件的安装位置。为了避免手动提供路径,我们将使用GNUInstallDirs模块提供的默认值。为了模块化,我们将把工件分组到组件中。默认安装将安装所有文件,但你可以选择只安装runtime组件并跳过development工件:

chapter-12/01-full-project/cmake/Install.cmake(片段)

include(GNUInstallDirs)
# Calc library
install(TARGETS calc_obj calc_shared calc_static
  EXPORT CalcLibrary
  ARCHIVE COMPONENT development
  LIBRARY COMPONENT runtime 
  PUBLIC_HEADER DESTINATION
    ${CMAKE_INSTALL_INCLUDEDIR}/calc
      COMPONENT runtime
)

在安装过程中,我们想用ldconfig注册我们复制的共享库:

chapter-12/01-full-project/cmake/Install.cmake(继续)

if (UNIX)
  install(CODE "execute_process(COMMAND ldconfig)"
    COMPONENT runtime
  )
endif()

有了这些步骤的准备,我们可以通过将其封装在可重用的 CMake 包中来使库对其他 CMake 项目可见。我们需要生成并安装目标导出文件和包括它的配置文件:

chapter-12/01-full-project/cmake/Install.cmake(继续)

install(EXPORT CalcLibrary
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
  NAMESPACE Calc::
  COMPONENT runtime
)
install(FILES "CalcConfig.cmake"
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
)

正如我们所知,对于非常简单的包,配置文件可以非常简洁:

chapter-12/01-full-project/CalcConfig.cmake

include("${CMAKE_CURRENT_LIST_DIR}/CalcLibrary.cmake")

就这样。现在,在构建解决方案后运行cmake--install模式,库将被安装。剩下的要安装的只有可执行文件。

可执行文件的安装

所有二进制可执行文件的安装是最简单的一步。我们只需要使用一个命令:

chapter-12/01-full-project/cmake/Install.cmake(继续)

# CalcConsole runtime
install(TARGETS calc_console
  RUNTIME COMPONENT runtime
)

完成啦!让我们继续进行配置的最后部分——打包。

使用 CPack 打包

我们可以放手去配置一大堆受支持的包类型;对于这个项目,然而,基本的配置就足够了:

chapter-12/01-full-project/cmake/Install.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: /tmp/b/packages/Calc-1.0.0-Linux.tar.gz generated. 

安装和打包就此结束;接下来要处理的事务是文档。

提供文档

当然,一个专业项目的最后一个元素是文档。它分为两个类别:

  • 技术文档(接口、设计、类和文件)

  • 一般文档(其他不如此技术性的文档)

正如我们在第十章中看到的,生成文档,很多技术文档可以通过使用 Doxygen 在 CMake 中自动生成。

自动文档生成

需要提及的是:一些项目在构建阶段生成文档,并将其与其他项目内容一起打包。这是个人喜好问题。对于这个项目,我们决定不这样做。你可能有选择其他方式的好理由(例如,在网上托管文档)。

图 12.7 展示了在此过程中使用的执行流程概述:

图 12.7 – 用于生成文档的文件

图 12.7 – 用于生成文档的文件

为了生成我们目标的用户文档,我们将创建另一个 CMake 工具模块Doxygen。我们将从使用 Doxygen 查找模块和下载doxygen-awesome-css项目主题开始:

chapter-12/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
    v1.6.0
)
FetchContent_MakeAvailable(doxygen-awesome-css)

然后,我们需要一个函数来创建生成文档的目标。我们将从第十章《生成文档》中介绍的代码中汲取灵感,并对其进行修改以支持许多目标:

chapter-12/01-full-project/cmake/Doxygen.cmake(继续)

function(Doxygen target input)
  set(NAME "doxygen-${target}")
  set(DOXYGEN_HTML_OUTPUT
    ${PROJECT_BINARY_DIR}/${NAME})
  set(DOXYGEN_GENERATE_HTML         YES)
  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)
  doxygen_add_docs(${NAME}
    ${PROJECT_SOURCE_DIR}/${input}
      COMMENT "Generate HTML documentation"
  )
endfunction()

现在,我们需要通过为库目标调用它来使用这个函数:

chapter-12/01-full-project/src/calc/CMakeLists.txt(继续)

# ... calc_static target definition
# ... testing and program analysis modules
include(Doxygen)
Doxygen(calc src/calc)

然后我们为控制台可执行文件调用它:

chapter-12/01-full-project/src/calc_console/CMakeLists.txt(继续)

# ... calc_static target definition
# ... testing and program analysis modules
include(Doxygen)
Doxygen(calc_console src/calc_console)
add_executable(calc_console bootstrap.cpp)
target_link_libraries(calc_console calc_console_static)

在项目中添加了两个新目标:doxygen-calcdoxygen-calc_console,可以按需生成技术文档。

我们还应该提供其他文档吗?

非技术性专业文档

专业项目应该总是至少包括两个存储在顶级目录中的文件:

  • README – 通常描述项目

  • LICENSE – 指定项目的法律特性

您可能还想添加这些:

  • INSTALL – 描述安装所需的步骤

  • CHANGELOG – 列出不同版本中发生的重要变化

  • AUTHORS – 如果项目有多个贡献者,则包含信用和联系信息

  • BUGS – 告知已知错误,并指导如何报告新错误

至于 CMake,当涉及到这些文件时并不会发挥任何作用——没有自动化行为或脚本可以使用。然而,这些文件是 C++项目的重要组成部分,应该为完整性而覆盖。作为参考,我们将提供一套最小化的示例文件,从一个简短的README文件开始:

chapter-12/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文件将如下所示:

chapter-12/01-full-project/INSTALL

To install this software you'll need to provide the following:
- C++ compiler supporting C++17
- CMake >= 3.20
- 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 许可证,这是一个非常宽松的许可证。根据特定项目的需求,您可能想要选择其他的东西——在进一步阅读部分查看一些有用的参考资料:

chapter-12/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 版本中增加了乘法功能可能是有用的。如下所示的简单内容已经很有帮助了:

chapter-12/01-full-project/CHANGELOG

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

我们的专业项目现在已经完成——我们可以构建它,测试它,生成包,将所有源代码上传到仓库,并发布工件。当然,如果这一切可以自动发生,也许通过 CI/CD 管道,那就更容易了。但那是另一本书的故事。

概要

这一章结束了我们通过 CMake 的漫长旅程。现在你完全理解了 CMake 旨在解决的问题以及实现这些解决方案所需的步骤。

在前三章中,我们探索了所有基础知识:什么是 CMake 以及用户如何利用它将源代码从原始状态变为可运行状态,CMake 的关键组件是什么,不同项目文件有什么目的。我们解释了 CMake 的语法:注释,命令调用,参数,变量和控制结构。我们发现了模块和子项目是如何工作的,正确的项目结构是什么,以及如何与各种平台和工具链一起工作。

本书的第二部分教导我们如何使用 CMake 进行构建:如何使用目标,自定义命令,构建类型和生成器表达式。我们深入探讨了编译的技术细节,以及预处理器和优化器的配置。我们讨论了链接,并介绍了不同的库类型。然后,我们研究了 CMake 如何使用FetchContentExternalProject模块来管理项目的依赖关系。我们还研究了 Git 子模块作为可能的替代方案。最重要的是,我们学习了如何使用find_package()FindPkgConfig查找已安装的包。如果这些还不够,我们探讨了编写自己的查找模块。

最后一部分告诉我们如何进行测试,分析,文档,安装和打包的自动化。我们研究了 CTest 和测试框架:Catch2,GoogleTest 和 GoogleMock。覆盖率报告也得到了涵盖。第九章,程序分析工具,让我们了解了不同的分析工具:格式化器和静态检查器(Clang-Tidy,Cppcheck 等),并解释了如何添加 Valgrind 套件中的 Memcheck 内存分析器。接下来,我们简要介绍了如何使用 Doxygen 生成文档以及如何使其更具吸引力。最后,我们展示了如何将项目安装到系统上,创建可重用的 CMake 包,以及如何配置和使用 CPack 生成二进制包。

最后一章利用了所有的知识来展示一个完全专业的项目。

恭喜你完成这本书。我们已经涵盖了开发、测试和打包高质量 C ++软件所需的一切内容。从这里开始取得进步的最佳方式是将你所学的内容付诸实践,为你的用户创建伟大的软件。祝你好运!

R.

进一步阅读

更多信息,您可以参考以下链接:

附录:杂项命令

每种语言都有许多实用的命令,CMake 在这方面也不例外:它提供了进行简单算术、位运算、字符串处理、列表和文件操作的工具。有趣的是,它们必要性相对较少(感谢过去几年中所有的改进和编写模块),但在更自动化的项目中仍然可能需要。

因此,本附录是对各种命令及其多种模式的一个简要总结。将其视为一个方便的离线参考或官方文档的简化版。如果您需要更多信息,请访问提供的链接。

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

  • string()命令

  • list()命令

  • file()命令

  • math()命令

字符串()命令

string()命令用于操作字符串。它有多种模式,可以执行字符串的不同操作:搜索和替换、 manipulation、比较、散列、生成和 JSON 操作(从 CMake 3.19 开始提供后一种)。

完整细节请在线查阅文档:cmake.org/cmake/help/latest/command/string.html

接受<input>参数的string()模式将接受多个<input>值,并在命令执行前将它们连接起来:

string(PREPEND myVariable "a" "b" "c")

这相当于以下内容:

string(PREPEND myVariable "abc")

让我们探索所有可用的string()模式。

搜索和替换

以下模式可供使用:

  • 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>,并将结果存储在<out>变量中。

正则表达式操作遵循 C++标准库中<regex>头文件定义的 C++语法。您可以使用捕获组将匹配项添加到带有数字占位符\\1\\2...的<replace>表达式中:(需要使用双反斜杠,以便正确解析参数)。

操作

可用以下模式:

  • string(APPEND <out> <input>) 通过附加<input>字符串改变存储在<out>中的字符串。

  • string(PREPEND <out> <input>) 通过在<out>中的字符串前添加<input>字符串改变这些字符串。

  • 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>参数是以下之一:LESSGREATEREQUALNOTEQUALLESS_EQUALGREATER_EQUAL。它将用于比较<stringA><stringB>,并将结果(truefalse)存储在<out>变量中。

散列

散列模式具有以下签名:

string(<algorithm> <out> <string>)

使用<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: 凯凯 SHA-3

  • SHA3_256: 凯凯 SHA-3

  • SHA3_384: 凯凯 SHA-3

  • SHA3_512: 凯凯 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>变量中。

  • string(MAKE_C_IDENTIFIER <string> <out>)<string> 中的非字母数字字符转换为下划线,并将结果存储在 <out> 变量中。

  • string(RANDOM [LENGTH <len>] [ALPHABET <alphabet>] [RANDOM_SEED <seed>] <out>) 生成一个 <len> 个字符(默认 5)的随机字符串,使用可选的 <alphabet> 从随机种子 <seed>,并将结果存储在 <out> 变量中。

  • string(TIMESTAMP <out> [<format>] [UTC]) 生成一个表示当前日期和时间的字符串,并将其存储在 <out> 变量中。

  • string(UUID <out> ...) 生成一个全球唯一的标识符。这个模式使用起来有点复杂。

JSON

JSON 格式的字符串操作使用以下签名:

string(JSON <out> [ERROR_VARIABLE <error>] <operation +
args>)

several operations are available. They all store their results in the <out> variable, and errors in the <error> variable. Operations and their arguments are as follows:

  • GET <json> <member|index>... 返回使用 <member> 路径或 <index><json> 字符串中获取一个或多个元素的结果。

  • TYPE <json> <member|index>... 返回 <json> 字符串中使用 <member> 路径或 <index> 的一个或多个元素的类型。

  • MEMBER <json> <member|index>... <array-index> 返回 <json> 字符串中 <array-index> 位置的一个或多个数组类型元素的成员名称。

  • LENGTH <json> <member|index>... 返回 <json> 字符串中使用 <member> 路径或 <index> 的一个或多个数组类型元素的数量。

  • REMOVE <json> <member|index>... 返回使用 <member> 路径或 <index><json> 字符串中删除一个或多个元素的结果。

  • SET <json> <member|index>... <value> 返回将 <value> 插入到 <json> 字符串中一个或多个元素的结果。

  • EQUAL <jsonA> <jsonB> 评估 <jsonA><jsonB> 是否相等。

list() 命令

该命令提供了列表的基本操作:阅读、搜索、修改和排序。有些模式会改变列表(改变原始值)。如果你之后需要它,请确保复制原始值。

完整详细信息可以在在线文档中找到:

cmake.org/cmake/help/latest/command/list.html

阅读

以下模式可用:

  • list(LENGTH <list> <out>) 计算 <list> 变量的元素数量,并将结果存储在 <out> 变量中。

  • list(GET <list> <index>... <out>)<list> 中指定的索引列表元素复制到 <out> 变量中。

  • list(JOIN <list> <glue> <out>)<list> 元素与 <glue> 分隔符交织在一起,并将结果字符串存储在 <out> 变量中。

  • list(SUBLIST <list> <begin> <length> <out>) 类似于 GET 模式,但它操作范围而不是明确的索引。如果 <length>-1,将从 <begin> 索引到 <list> 变量提供的列表末尾返回元素。

搜索

此模式简单地查找 <list> 变量中的 <needle> 元素的索引,并将结果存储在 <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>) 用于过滤 <list> 变量,以 INCLUDEEXCLUDE 包含或排除与 <pattern> 值匹配的元素。

  • list(INSERT <list> <index> [<element>...]) 在给定的 <index> 处向 <list> 变量添加一个或多个 <element> 值。

  • 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() 命令

此命令提供了与文件相关的各种操作:读取、传输、锁定和归档。它还提供了检查文件系统和对表示路径的字符串执行操作的模式。

完整详细信息可以在在线文档中找到:

链接

读取

以下是一些可用模式:

  • file(READ <filename> <out> [OFFSET <o>] [LIMIT <max>] [HEX])<filename> 文件读取到 <out> 变量。读取可以从偏移量 <o> 开始,并具有可选的字节限制 <max>HEX 标志指定输出应转换为十六进制表示。

  • file(STRINGS <filename> <out>)<filename> 文件中读取字符串到 <out> 变量。

  • file(<algorithm> <filename> <out>)<filename> 文件中计算 <algorithm> 哈希值,并将结果存储在 <out> 变量中。可用的算法与 string() 哈希函数相同。

  • file(TIMESTAMP <filename> <out> [<format>]) 生成 <filename> 文件的时间戳字符串表示,并将其存储在 <out> 变量中。可选地接受一个 <format> 字符串。

  • file(GET_RUNTIME_DEPENDENCIES [...]) 为指定文件获取运行时依赖项。这是一个仅在 install(CODE)install(SCRIPT) 场景中使用的高级命令。

编写

以下模式可用:

  • 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> [...])files 复制到 <dir> 目的地。提供过滤、设置权限、符号链接链跟随等功能选项。

  • 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(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> 下载文件并将其存储在路径中。

  • 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> 文件中的一种支持格式:7zipgnutarpaxpaxrrawzippaxr 为默认值)。如果所选格式支持压缩级别,它可以是一个单个数字 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

要评估一个数学表达式并将其作为可选的 <format>HEXADECIMALDECIMAL)字符串存储在 <out> 变量中,请使用以下签名:

math(EXPR <out> "<expression>" [OUTPUT_FORMAT <format>])

<expression> 的值是一个支持 C 代码中存在的运算符的字符串(这里的意义相同):

  • 算术:+-*/%(取模除法)

  • 位运算:| 或,& 与,^ 异或,~ 非,<< 左移,>> 右移

  • 圆括号 (...)

常数值可以以十进制或十六进制格式提供。

posted @ 2024-05-24 17:58  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报