第二步:CMake 语言基础

在上一步中,我们匆忙地处理了 CMake 语言的几个方面,这些语言用于 CMakeLists.txt 文件中,目的是尽快生成可构建的程序。然而,在实际应用中,我们会遇到比仅描述源文件和头文件列表复杂得多的情况。

为了应对这种复杂性,CMake 提供了一种图灵完备的领域特定语言来描述软件的构建过程。在我们编写更复杂的 CML 和其他 CMake 文件时,理解这门语言的基础知识是必要的。这门语言正式称为“CMake 语言”,或者更通俗地说,称为 CMakeLang。

注意

CMake 语言并不太适合描述与软件构建无关的事情。虽然它提供了一些通用用途的特性,但在 CMake 语言中解决与构建不直接相关的问题时,开发者应谨慎。

通常,正确的做法是使用通用编程语言编写一个解决问题的工具,并教会 CMake 如何将其作为构建过程的一部分来调用。代码生成、加密签名工具,甚至光线追踪器都曾用 CMake 语言编写,但这并不推荐。

由于我们希望全面探索语言特性,本步骤是教程顺序的一个例外。它既不以上一步为基础,也不是下一步的起点。这将是一个探索语言特性的沙盒,而无需构建任何软件。我们将在下一步继续学习教程程序。

注意

本教程致力于展示最佳实践和实际问题的解决方案。但是,在本步骤中,我们将重新实现一些内置的 CMake 函数。在“现实生活中”,请不要自己编写 list(APPEND)

背景

CMakeLang 中唯一的基础类型是字符串和列表。CMake 中的每个对象都是字符串,而列表本身也是包含分号作为分隔符的字符串。任何看起来操作非字符串(如布尔值、数字、JSON 对象等)的命令,实际上都是在解析一个字符串,执行一些内部转换逻辑(使用 CMakeLang 以外的语言),然后为任何潜在的输出转换回字符串。

我们可以使用 set() 命令创建一个变量,也就是一个字符串的名称。

set(var "World!")

可以使用花括号扩展来访问变量的值,例如,如果我们想使用 message() 命令来打印名为 var 的字符串。

set(var "World!")
message("Hello ${var}")
$ cmake -P CMakeLists.txt
Hello World!

注意

cmake -P 被称为“脚本模式”,它告诉 CMake 该文件不包含 project() 命令。我们不构建任何软件,而是仅将 CMake 用作命令解释器。

由于 CMakeLang 只有字符串,条件判断完全依赖于约定,即哪些字符串被认为是 true,哪些被认为是 false。这些值“应该”是直观的,“True”、“On”、“Yes”以及(代表)非零数字的字符串是 truthy(真值),而“False”、“Off”、“No”、“0”、“Ignore”、“NotFound”以及空字符串都被认为是 false(假值)。

然而,有些规则比这更复杂,因此花些时间查阅 if() 命令关于表达式的文档是值得的。建议在给定上下文中坚持使用一对值,例如“True”/“False”或“On”/“Off”。

如前所述,列表是包含分号的字符串。 list() 命令对于操作这些列表很有用,CMake 中的许多结构都期望使用这种约定。例如,我们可以使用 foreach() 命令来迭代列表。

set(stooges "Moe;Larry")
list(APPEND stooges "Curly")

message("Stooges contains: ${stooges}")

foreach(stooge IN LISTS stooges)
  message("Hello, ${stooge}")
endforeach()
$ cmake -P CMakeLists.txt
Stooges contains: Moe;Larry;Curly
Hello, Moe
Hello, Larry
Hello, Curly

练习 1 - 宏、函数和列表

CMake 允许我们创建自己的函数和宏。这在构建大量相似的目标(如测试)时非常有用,因为我们希望一遍又一遍地调用相似的命令集。我们可以使用 function()macro() 来实现。

macro(MyMacro MacroArgument)
  message("${MacroArgument}\n\t\tFrom Macro")
endmacro()

function(MyFunc FuncArgument)
  MyMacro("${FuncArgument}\n\tFrom Function")
endfunction()

MyFunc("From TopLevel")
$ cmake -P CMakeLists.txt
From TopLevel
      From Function
              From Macro

与许多语言一样,函数和宏的区别在于作用域。在 CMakeLang 中,function()macro() 都可以“看到”它们上方所有帧中创建的所有变量。然而,macro() 在语义上类似于文本替换,类似于 C/C++ 宏,因此宏产生的任何副作用都会在其调用上下文中可见。如果我们宏中创建或更改了变量,调用者将看到该更改。

function() 会创建自己的变量作用域,因此副作用对调用者不可见。为了将更改传播给调用函数的父级,我们必须使用 set(<var> <value> PARENT_SCOPE),这与 set() 的工作方式相同,但作用于调用者上下文中的变量。

注意

在 CMake 3.25 中,添加了 return(PROPAGATE) 选项,其工作方式与 set(PARENT_SCOPE) 相同,但提供了更好的可用性。

虽然本练习不需要,但值得一提的是,macro()function() 都支持通过 ARGV 变量(包含传递给命令的所有参数的列表)和 ARGN 变量(包含最后一个预期参数之后的所有参数)来处理可变数量的参数。

在本练习中,我们不会构建任何目标,因此我们将自己实现一个 list(APPEND) 的版本,它将一个值添加到列表中。

目标

实现一个宏和一个函数,它们将一个值追加到列表中,而不使用 list(APPEND) 命令。

这些命令的预期用法如下:

set(Letters "Alpha;Beta")
MacroAppend(Letters "Gamma")
message("Letters contains: ${Letters}")
$ cmake -P Exercise1.cmake
Letters contains: Alpha;Beta;Gamma

注意

这些练习的扩展名为 .cmake,这是 CMakeLang 文件在不包含在 CMakeLists.txt 中的标准扩展名。

有用资源

要编辑的文件

  • Exercise1.cmake

开始

Exercise1.cmake 的源代码在 Help/guide/tutorial/Step2 目录中提供。它包含用于验证上述追加行为的测试。

注意

您不需要处理追加到空列表或未定义列表的情况。但是,作为一个奖励,如果想尝试对 CMakeLang 条件判断的理解,可以测试这种情况。

完成 TODO 1TODO 2

构建并运行

我们将使用脚本模式来运行这些练习。首先导航到 Help/guide/tutorial/Step2 文件夹,然后您可以使用以下命令运行代码:

cmake -P Exercise1.cmake

脚本将报告命令是否已正确实现。

解决方案

这个问题依赖于对 CMake 变量机制的理解。CMake 变量是字符串的名称;换句话说,CMake 变量本身就是一个字符串,它可以进行花括号扩展,变成另一个字符串。

这导致了 CMake 代码中的一个常见模式,即函数和宏不是通过值传递,而是通过包含这些值的变量的名称来传递。因此,ListVar 不包含我们需要追加的列表的*值*,它包含的是列表的*名称*,而这个列表名称包含了我们需要追加的值。

当使用 ${ListVar} 扩展变量时,我们将得到列表的名称。如果我们使用 ${${ListVar}} 扩展该名称,我们将得到列表包含的值。

要实现 MacroAppend,我们只需要将对 ListVar 的理解与我们对 set() 命令的了解结合起来。

TODO 1:点击显示/隐藏答案
TODO 1: Exercise1.cmake
macro(MacroAppend ListVar Value)
  set(${ListVar} "${${ListVar}};${Value}")
endmacro()

我们不必担心作用域,因为宏在其父级的同一作用域内运行。

FuncAppend 几乎相同,事实上,它可以实现为同一行代码,但增加了 PARENT_SCOPE,但说明要求我们根据 MacroAppend 来实现它。

TODO 2:点击显示/隐藏答案
TODO 2: Exercise1.cmake
function(FuncAppend ListVar Value)
  MacroAppend(${ListVar} ${Value})
  set(${ListVar} "${${ListVar}}" PARENT_SCOPE)
endfunction()

MacroAppend 为我们转换了 ListVar,但它不会将结果传播到父作用域。因为这是一个函数,我们需要自己使用 set(PARENT_SCOPE) 来做到这一点。

练习 2 - 条件判断和循环

任何结构化编程语言中最常见的两个流程控制元素是条件判断及其紧密相关的兄弟——循环。CMakeLang 也不例外。如前所述,给定 CMake 字符串的真值是由 if() 命令确定的约定。

if() 接收到一个字符串时,它首先检查它是否是之前讨论过的已知常量值之一。如果字符串不是这些值之一,该命令假定它是一个变量,并检查该变量的花括号展开内容来确定条件的结果。

if(True)
  message("Constant Value: True")
else()
  message("Constant Value: False")
endif()

if(ConditionalValue)
  message("Undefined Variable: True")
else()
  message("Undefined Variable: False")
endif()

set(ConditionalValue True)

if(ConditionalValue)
  message("Defined Variable: True")
else()
  message("Defined Variable: False")
endif()
$ cmake -P ConditionalValue.cmake
Constant Value: True
Undefined Variable: False
Defined Variable: True

注意

现在是讨论 CMake 中引号的好时机。CMake 中的所有对象都是字符串,因此双引号 " 常常是不必要的。CMake 知道对象是字符串,一切都是字符串。

但是,在某些情况下需要它。包含空格的字符串需要双引号,否则它们会被视为列表;CMake 会用分号将元素连接起来。反之亦然,当花括号扩展列表时,如果我们想*保留*分号,则有必要在引号内进行。否则,CMake 会将列表项展开为空格分隔的字符串。

一些命令,例如 if(),能够区分带引号和不带引号的字符串。当字符串未加引号时,if() 只会检查该字符串是否代表一个变量。

最后,if() 提供了几种有用的比较模式,例如用于字符串匹配的 STREQUAL,用于检查变量是否存在的 DEFINED,以及用于正则表达式检查的 MATCHES。它还支持典型的逻辑运算符:NOTANDOR

除了条件判断,CMake 还提供了两种循环结构:while(),它遵循与 if() 相同的规则来检查循环变量;以及更常用的 foreach(),它迭代字符串列表,并在背景部分进行了演示。

在这个练习中,我们将使用循环和条件判断来解决一些简单的问题。我们将使用前面提到的 function() 中的 ARGN 变量作为要操作的列表。

目标

循环遍历列表,并返回所有包含字符串 Foo 的字符串。

注意

那些阅读过命令文档的人会知道,这就是 list(FILTER),请抑制使用它的诱惑。

有用资源

要编辑的文件

  • Exercise2.cmake

开始

Exercise2.cmake 的源代码在 Help/guide/tutorial/Step2 目录中提供。它包含用于验证上述追加行为的测试。

注意

这次您应该使用 list(APPEND) 命令将最终结果收集到一个列表中。输入可以从提供的函数的 ARGN 变量中获取。

完成 TODO 3

构建并运行

导航到 Help/guide/tutorial/Step2 文件夹,然后您可以使用以下命令运行代码:

cmake -P Exercise2.cmake

脚本将报告 FilterFoo 函数是否已正确实现。

解决方案

我们需要做三件事:循环遍历 ARGN 列表,检查该列表中的某个项是否匹配 "Foo",如果匹配,则将其追加到 OutVar 列表中。

虽然有几种方法可以调用 foreach(),但推荐的方法是允许命令通过 IN LISTS 来为我们进行变量扩展,从而访问 ARGN 列表项。

我们需要使用的 if() 比较是 MATCHES,它将检查 "FOO" 是否存在于该项中。剩下的就是将该项追加到 OutVar 列表。最棘手的部分是记住 OutVar 命名了一个列表,它本身不是列表,所以我们需要通过 ${OutVar} 来访问它。

TODO 3:点击显示/隐藏答案
TODO 3: Exercise2.cmake
function(FilterFoo OutVar)

  foreach(item IN LISTS ARGN)
    if(item MATCHES Foo)
      list(APPEND ${OutVar} ${item})
    endif()
  endforeach()

  set(${OutVar} ${${OutVar}} PARENT_SCOPE)
endfunction()

练习 3 - 使用 include 进行组织

我们已经讨论了如何包含包含其自身 CML 的子目录,方法是使用 add_subdirectory()。在后面的步骤中,我们将探讨 CMake 代码可以打包和跨项目共享的各种方式。

然而,对于小型 CMake 函数和实用工具,将它们放在项目 CML 之外、构建系统其余部分之外的自己的 .cmake 文件中,通常是有益的。这允许关注点分离,将项目特定元素从我们用来描述它们的实用工具中移除。

为了将这些单独的 .cmake 文件合并到我们的项目中,我们使用 include() 命令。此命令会立即在父 CML 的作用域中解释被 include() 的文件的内容。这就像整个文件都被当作宏来调用一样。

传统上,这类 .cmake 文件通常存放在项目根目录下的名为“cmake”的文件夹中。在本练习中,我们将使用 Step2 文件夹。

目标

使用来自练习 1 和 2 的函数来构建和过滤我们自己的项目列表。

有用资源

要编辑的文件

  • Exercise3.cmake

入门

Exercise3.cmake 的源代码在 Help/guide/tutorial/Step2 目录中提供。它包含用于验证先前两个练习中的函数正确用法的测试。

注意

实际上,它重用了 Exercise2.cmake 中的测试,可重用的代码对每个人都有益。

完成 TODO 4TODO 7

构建和运行

导航到 Help/guide/tutorial/Step2 文件夹,然后您可以使用以下命令运行代码:

cmake -P Exercise3.cmake

脚本将报告函数是否已正确调用和组合。

解决方案

include() 命令将完全解释包含的文件,包括前两个练习中的测试。我们不希望再次运行这些测试。感谢一些预见性,这些文件在运行测试之前会检查一个名为 SKIP_TESTS 的变量,将其设置为 True 将获得我们想要的行为。

TODO 4:点击显示/隐藏答案
TODO 4: Exercise3.cmake
set(SKIP_TESTS True)

现在我们准备好 include() 前面的练习,以获取它们的函数。

TODO 5:点击显示/隐藏答案
TODO 5: Exercise3.cmake
include(Exercise1.cmake)
include(Exercise2.cmake)

现在 FuncAppend 对我们可用,我们可以使用它将新元素追加到 InList 中。

TODO 6:点击显示/隐藏答案
TODO 6: Exercise3.cmake
FuncAppend(InList FooBaz)
FuncAppend(InList QuxBaz)

最后,我们可以使用 FilterFoo 来过滤整个列表。这里最棘手的部分是要记住,我们的 FilterFoo 希望通过 ARGN 来操作列表值,所以当调用 FilterFoo 时,我们需要扩展 InList

TODO 7:点击显示/隐藏答案
TODO 7: Exercise3.cmake
FilterFoo(OutList ${InList})