第 3 步:配置与缓存变量

CMake 项目通常包含一些用户和打包人员关心的项目特定配置变量。CMake 有多种方式可以让调用用户或进程传递这些配置选项,其中最基础的方式是 -D 标志。

在本步骤中,我们将探讨如何在 CML(CMakeLists.txt)内部提供项目配置选项的细节,以及如何调用 CMake 来利用由 CMake 和各个项目提供的配置选项。

背景

如果我们有一个支持多种压缩算法的压缩软件 CMake 项目,我们可能希望让项目的打包人员在构建软件时决定启用哪些算法。我们可以通过使用通过 -D 标志设置的变量来实现这一点。

if(COMPRESSION_SOFTWARE_USE_ZLIB)
  message("I will use Zlib!")
  # ...
endif()

if(COMPRESSION_SOFTWARE_USE_ZSTD)
  message("I will use Zstd!")
  # ...
endif()
$ cmake -B build \
    -DCOMPRESSION_SOFTWARE_USE_ZLIB=ON \
    -DCOMPRESSION_SOFTWARE_USE_ZSTD=OFF
...
I will use Zlib!

当然,我们需要为这些配置选项提供合理的默认值,并提供一种沟通给定选项用途的方法。此功能由 option() 命令提供。

option(COMPRESSION_SOFTWARE_USE_ZLIB "Support Zlib compression" ON)
option(COMPRESSION_SOFTWARE_USE_ZSTD "Support Zstd compression" ON)

if(COMPRESSION_SOFTWARE_USE_ZLIB)
  # Same as before
# ...
$ cmake -B build \
    -DCOMPRESSION_SOFTWARE_USE_ZLIB=OFF
...
I will use Zstd!

-D 标志和 option() 创建的名称不是普通变量,而是缓存(cache)变量。缓存变量是全局可见的变量,具有粘性(sticky),即一旦设置后很难更改其值。事实上,它们的粘性非常强,以至于在项目模式下,CMake 会在多次配置之间保存并恢复缓存变量。如果一个缓存变量被设置过一次,它将一直保留,除非被另一个 -D 标志覆盖。

注意

CMake 本身有许多用于配置的普通变量和缓存变量。这些记录在 cmake-variables(7) 中,其操作方式与项目提供的配置变量相同。

set() 也可以用于操作缓存变量,但它不会更改已经创建过的变量。

set(StickyCacheVariable "I will not change" CACHE STRING "")
set(StickyCacheVariable "Overwrite StickyCache" CACHE STRING "")

message("StickyCacheVariable: ${StickyCacheVariable}")
$ cmake -P StickyCacheVariable.cmake
StickyCacheVariable: I will not change

因为 -D 标志在任何其他命令之前处理,所以在设置缓存变量值时,它们具有优先权。

$ cmake \
  -DStickyCacheVariable="Commandline always wins" \
  -P StickyCacheVariable.cmake
StickyCacheVariable: Commandline always wins

虽然缓存变量通常无法更改,但它们可以被普通变量遮蔽(shadowed)。我们可以通过 set() 一个与缓存变量同名的变量,然后使用 unset() 移除该普通变量来观察这一现象。

set(ShadowVariable "In the shadows" CACHE STRING "")
set(ShadowVariable "Hiding the cache variable")
message("ShadowVariable: ${ShadowVariable}")

unset(ShadowVariable)
message("ShadowVariable: ${ShadowVariable}")
$ cmake -P ShadowVariable.cmake
ShadowVariable: Hiding the cache variable
ShadowVariable: In the shadows

练习 1 - 使用选项

我们可以想象一个场景:用户确实需要我们的 MathFunctions 库,而 Tutorial 工具只是一个“可有可无”的附加组件。在这种情况下,我们可能希望添加一个选项,允许用户禁用 Tutorial 二进制文件的构建,仅构建 MathFunctions 库。

掌握了选项、条件判断和缓存变量的知识后,我们就具备了实现此配置所需的所有要素。

目标

添加一个名为 TUTORIAL_BUILD_UTILITIES 的选项,以控制是否配置和构建 Tutorial 二进制文件。

注意

CMake 允许我们在配置后确定构建哪些目标。我们的用户可以要求仅构建 MathFunctions 库而不包含 Tutorial。CMake 还有一些机制可以将目标从 ALL 中排除,即构建所有其他可用目标的默认目标。

然而,完全将目标从配置中排除的选项非常方便且常用,特别是当配置这些目标涉及可能耗时的重量级步骤时。

如果完全排除打包人员不感兴趣的目标,还能简化我们将在后续步骤中讨论的 install() 逻辑。

有用资源

要编辑的文件

  • CMakeLists.txt

开始

Help/guide/tutorial/Step3 文件夹包含针对 Step1 的完整推荐解决方案,以及本步骤相关的 TODO。花点时间回顾并重新熟悉一下 Tutorial 项目。

当你觉得自己理解了当前代码后,从 TODO 1 开始,一直完成到 TODO 2

构建并运行

我们现在可以重新配置我们的项目。然而,这次我们希望通过 -D 标志来控制配置。我们再次导航到 Help/guide/tutorial/Step3 并调用 CMake,但这次带上我们的配置选项。

cmake -B build -DTUTORIAL_BUILD_UTILITIES=OFF

现在我们可以像往常一样进行构建。

cmake --build build

构建后,我们应该会观察到没有生成 Tutorial 可执行文件。因为缓存变量具有粘性,即使重新配置也不会改变这一点,尽管选项默认是 ON

cmake -B build
cmake --build build

这不会产生 Tutorial 可执行文件,缓存变量已被“锁定”。要改变这一点,我们有两个选择。首先,我们可以编辑在 CMake 配置运行之间存储缓存变量的文件,即“CMake Cache”。该文件位于 build/CMakeCache.txt,在其中我们可以找到该选项的缓存变量。

//Build the Tutorial executable
TUTORIAL_BUILD_UTILITIES:BOOL=OFF

我们可以将其从 OFF 改为 ON,重新运行构建,这样我们就会得到 Tutorial 可执行文件。

注意

CMakeCache.txt 条目的格式为 <Name>:<Type>=<Value>,但“类型”仅作为一个提示。无论缓存怎么说,CMake 中的所有对象都是字符串。

或者,我们可以在命令行上更改缓存变量的值,因为命令行是在 CMakeCache.txt 加载之前运行的,所以它的值优先于缓存文件中的值。

cmake -B build -DTUTORIAL_BUILD_UTILITIES=ON
cmake --build build

这样做,我们观察到 CMakeCache.txt 中的值已从 OFF 翻转为 ON,并且 Tutorial 可执行文件已被构建。

解决方案

首先,我们创建我们的 option() 来为我们的缓存变量提供一个合理的默认值。

TODO 1:点击显示/隐藏答案
TODO 1: CMakeLists.txt
option(TUTORIAL_BUILD_UTILITIES "Build the Tutorial executable" ON)

然后我们可以检查缓存变量,以有条件地启用 Tutorial 可执行文件(通过添加其子目录)。

TODO 2:点击显示/隐藏答案
TODO 2: CMakeLists.txt
if(TUTORIAL_BUILD_UTILITIES)
  add_subdirectory(Tutorial)
endif()

练习 2 - CMAKE 变量

CMake 有几个重要的普通变量和缓存变量,旨在让打包人员控制构建。编译器、默认标志、包搜索位置等决策都由 CMake 自己的配置变量控制。

其中最重要的是语言标准。因为语言标准会对给定包呈现的 ABI 产生重大影响。例如,库在较新的标准中使用标准 C++ 模板并在早期标准上提供补丁(polyfills)是非常常见的。如果一个库在不同的标准下被使用,那么标准模板和补丁之间的 ABI 不兼容可能导致难以理解的错误和运行时崩溃。

确保我们所有的目标都在相同的语言标准下构建,可以通过 CMAKE_<LANG>_STANDARD 缓存变量来实现。对于 C++,这是 CMAKE_CXX_STANDARD

注意

因为这些变量非常重要,所以开发者不要在他们的 CML 中覆盖或遮蔽它们同样重要。如果在库需要 C++20 时在 CML 中遮蔽了 CMAKE_<LANG>_STANDARD,而打包人员决定用 C++23 构建其余的库和应用程序,这可能导致上述可怕、难以理解的错误。

如果没有非常强有力的理由,不要 set() 全局的 CMAKE_ 变量。我们将在后续步骤中讨论目标传达定义和最低标准等要求的更好方法。

在本练习中,我们将向库和可执行文件中引入一些 C++20 代码,并通过设置相应的缓存变量使用 C++20 进行构建。

目标

使用 std::format 来格式化打印的字符串,而不是使用流操作符。为了确保 std::format 的可用性,配置 CMake 为 C++ 目标使用 C++20 标准。

有用资源

要编辑的文件

  • Tutorial/Tutorial.cxx

  • MathFunctions/MathFunctions.cxx

开始

继续编辑 Step3 中的文件。完成 TODO 3TODO 7。我们将修改我们的打印代码,使用 std::format 代替流操作符。

使用前一个练习中讨论的任何方法,确保你的缓存变量设置正确,以便能够构建 Tutorial 可执行文件。

构建并运行

我们需要使用新的标准重新配置我们的项目,我们可以使用与 TUTORIAL_BUILD_UTILITIES 缓存变量相同的方法来实现。

cmake -B build -DCMAKE_CXX_STANDARD=20

注意

按照惯例,配置变量以变量提供者为前缀。CMake 配置变量以 CMAKE_ 为前缀,而项目应以 <PROJECT>_ 为其变量添加前缀。

教程配置变量遵循此惯例,并以 TUTORIAL_ 为前缀。

现在我们已经使用 C++20 进行了配置,可以像往常一样构建了。

cmake --build build

解决方案

我们需要包含 <format> 并使用它。

TODO 3-5: 点击显示/隐藏答案
TODO 3: Tutorial/Tutorial.cxx
#include <format>
#include <iostream>
#include <string>
TODO 4: Tutorial/Tutorial.cxx
if (argc < 2) {
  std::cout << std::format("Usage: {} number\n", argv[0]);
  return 1;
}
TODO 5: Tutorial/Tutorial.cxx
// calculate square root
double const outputValue = mathfunctions::sqrt(inputValue);
std::cout << std::format("The square root of {} is {}\n", inputValue,
                         outputValue);

再次针对 MathFunctions 库进行同样的操作。

TODO 6-7: 点击显示/隐藏答案
TODO 6: MathFunctions.cxx
#include <format>
#include <iostream>
TODO 7: MathFunctions.cxx
double delta = x - (result * result);
result = result + 0.5 * delta / result;

std::cout << std::format("Computing sqrt of {} to be {}\n", x, result);

练习 3 - CMakePresets.json

管理这些配置值很快就会变得难以承受。在 CI 系统中,将其记录为给定 CI 步骤的一部分是合适的。例如,在 Github Actions CI 步骤中,我们可能会看到类似以下的内容

- name: Configure and Build
  run: |
    cmake \
      -B build \
      -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_CXX_STANDARD=20 \
      -DCMAKE_CXX_EXTENSIONS=ON \
      -DTUTORIAL_BUILD_UTILITIES=OFF \
      # Possibly many more options
      # ...

    cmake --build build

在本地开发代码时,即使键入所有这些选项一次也容易出错。如果因任何原因需要进行全新的配置,多次执行可能会让人筋疲力尽。

这个问题有许多不同的解决方案,最终的选择取决于你作为开发者的偏好。面向 CLI 的开发者通常使用任务运行器(task runners)来调用 CMake 并设置项目所需的选项。大多数 IDE 也有控制 CMake 配置的自定义机制。

在此完全列举每种可能的配置工作流是不可能的。相反,我们将探讨 CMake 的内置解决方案,即 CMake Presets。预设(Presets)为我们提供了一种命名和表达 CMake 配置选项集合的格式。

注意

预设能够表达完整的工作流,从配置、构建,一直到安装软件包。

它们比我们此处所能介绍的要灵活得多。我们将仅限于使用它们进行配置。

CMake 预设有两种标准文件,CMakePresets.json,旨在作为项目的一部分并由源代码控制系统跟踪;以及 CMakeUserPresets.json,旨在用于本地用户配置,不应被跟踪。

对开发者有用的最简单的预设仅做配置变量这一件事。

{
  "version": 4,
  "configurePresets": [
    {
      "name": "example-preset",
      "cacheVariables": {
        "EXAMPLE_FOO": "Bar",
        "EXAMPLE_QUX": "Baz"
      }
    }
  ]
}

在调用 CMake 时,之前我们可能会执行

cmake -B build -DEXAMPLE_FOO=Bar -DEXAMPLE_QUX=Baz

现在我们可以使用预设

cmake -B build --preset example-preset

CMake 将搜索名为 CMakePresets.jsonCMakeUserPresets.json 的文件,并加载其中的命名配置(如果可用)。

注意

命令行标志可以与预设混合使用。命令行标志的优先级高于预设中找到的值。

预设也支持有限的宏,即可以在预设中进行大括号展开的变量。我们唯一感兴趣的是 ${sourceDir} 宏,它会展开为项目的根目录。我们可以使用它来设置我们的构建目录,从而在配置项目时跳过 -B 标志。

{
  "name": "example-preset",
  "binaryDir": "${sourceDir}/build"
}

目标

使用 CMake 预设而不是命令行标志来配置和构建教程。

有用资源

要编辑的文件

  • CMakePresets.json

入门

继续编辑 Step3 中的文件。完成 TODO 8TODO 9

注意

CMakePresets.json 内部的 TODO 需要被替换。当你完成练习时,文件中不应留下任何 TODO 键。

你可以通过在配置之前删除现有的构建文件夹来验证预设是否工作正常,这将确保你没有重复使用现有的 CMake 缓存进行配置。

注意

在 CMake 3.24 及更高版本上,可以通过使用 cmake --fresh 进行配置来达到同样的效果。

所有未来的配置更改都将通过 CMakePresets.json 文件进行。

构建和运行

我们现在可以使用预设文件来管理我们的配置。

cmake --preset tutorial

预设能够为我们运行构建步骤,但对于本教程,我们将继续手动运行构建。

cmake --build build

解决方案

我们需要做两个更改:首先,我们要将构建目录(也称为“二进制目录”)设置为项目文件夹的 build 子目录;其次,我们需要将 CMAKE_CXX_STANDARD 设置为 20

TODO 8-9: 点击显示/隐藏答案
TODO 8-9: CMakePresets.json
{
  "version": 4,
  "configurePresets": [
    {
      "name": "tutorial",
      "displayName": "Tutorial Preset",
      "description": "Preset to use with the tutorial",
      "binaryDir": "${sourceDir}/build",
      "cacheVariables": {
        "CMAKE_CXX_STANDARD": "20"
      }
    }
  ]
}