第 2 步:CMake 语言基础¶
在上一步中,为了尽快得到可用的构建程序,我们匆忙略过并模糊处理了 CMakeLists.txt 中所使用的 CMake 语言的几个方面。然而,在实际开发中,我们遇到的复杂性远不止描述源代码和头文件列表那么简单。
为了处理这种复杂性,CMake 提供了一种图灵完备的领域特定语言,用于描述软件的构建过程。当我们编写更复杂的 CML(CMake 列表文件)和其他 CMake 文件时,理解该语言的基础知识是必要的。该语言正式名称为“CMake 语言”,通俗地称为 CMakeLang。
注意
CMake 语言并不适合描述与软件构建无关的事项。虽然它具有一些通用用途的特性,但开发者在使用 CMake 语言解决与构建不直接相关的问题时应保持谨慎。
通常,正确的做法是使用通用编程语言编写工具来解决该问题,并告诉 CMake 如何在构建过程中调用该工具。代码生成、加密签名实用程序,甚至是光线追踪器都曾用 CMake 语言编写过,但这并不是推荐的做法。
由于我们想要全面探索语言特性,本步骤是教程顺序的一个特例。它既不建立在 Step1 之上,也不是 Step3 的起点。这将是一个探索语言特性的沙箱,无需构建任何软件。我们将在 Step3 中重新开始我们的教程程序。
注意
本教程力求展示最佳实践和实际问题的解决方案。然而,在这一步中,我们将重新实现一些 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!
由于 CMakeLang 只有字符串,条件判断完全取决于哪些字符串被视为真,哪些被视为假。这些“应该”是直观的,“True”、“On”、“Yes”和(表示)非零数字的字符串是真值,而“False”、“Off”、“No”、“0”、“Ignore”、“NotFound”和空字符串都被视为假。
然而,有些规则比这更复杂,因此花时间查阅 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 1 和 TODO 2。
构建并运行¶
我们将使用脚本模式来运行这些练习。首先导航到 Help/guide/tutorial/Step2 文件夹,然后你可以通过以下方式运行代码:
cmake -P Exercise1.cmake
脚本将报告命令是否实现正确。
解决方案¶
此问题依赖于对 CMake 变量机制的理解。CMake 变量是字符串的名称;或者换句话说,CMake 变量本身就是一个字符串,可以大括号扩展成另一个字符串。
这导致了 CMake 代码中的一种常见模式:函数和宏不是传递值,而是传递包含这些值的变量名。因此 ListVar 不包含我们需要追加到的列表的值,它包含列表的名称,该列表包含我们需要追加到的值。
当使用 ${ListVar} 扩展变量时,我们将得到列表的名称。如果我们用 ${${ListVar}}} 扩展该名称,我们将得到列表包含的值。
要实现 MacroAppend,我们只需将对 ListVar 的理解与对 set() 命令的了解结合起来即可。
TODO 1:点击显示/隐藏答案
macro(MacroAppend ListVar Value)
set(${ListVar} "${${ListVar}};${Value}")
endmacro()
我们不需要在这里担心作用域,因为宏在与父级相同的作用域中运行。
FuncAppend 几乎完全相同,事实上它可以以同样的单行代码实现,只是增加了一个 PARENT_SCOPE,但说明要求我们根据 MacroAppend 来实现它。
TODO 2:点击显示/隐藏答案
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。它还支持典型的逻辑运算符:NOT、AND 和 OR。
除了条件判断之外,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:点击显示/隐藏答案
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 进行组织¶
我们已经讨论过如何使用 add_subdirectory() 合并包含自身 CML 的子目录。在后续步骤中,我们将探索 CMake 代码可以被打包并跨项目共享的各种方式。
然而,对于小型 CMake 函数和实用程序,让它们存在于项目 CML 之外且独立于构建系统其余部分的 .cmake 文件中通常是有益的。这允许关注点分离,从我们用于描述它们的实用程序中删除特定于项目的元素。
为了将这些单独的 .cmake 文件合并到我们的项目中,我们使用 include() 命令。该命令会立即开始在父 CML 的作用域中解释 include() 文件的内容。这就好像整个文件被作为宏调用一样。
传统上,这类 .cmake 文件位于项目根目录下一个名为 “cmake” 的文件夹中。对于此练习,我们将改用 Step2 文件夹。
目标¶
使用练习 1 和 2 中的函数来构建和过滤我们自己的项目列表。
有用资源¶
要编辑的文件¶
Exercise3.cmake
入门¶
Exercise3.cmake 的源代码位于 Help/guide/tutorial/Step2 目录中。它包含验证前两个练习中我们函数正确用法的测试。
注意
实际上它重用了 Exercise2.cmake 中的测试,可重用代码对每个人都有好处。
完成 TODO 4 到 TODO 7。
构建和运行¶
导航到 Help/guide/tutorial/Step2 文件夹,然后你可以通过以下方式运行代码:
cmake -P Exercise3.cmake
脚本将报告函数是否已正确调用和组合。
解决方案¶
include() 命令将完整解释所包含的文件,包括前两个练习中的测试。我们不想再次运行这些测试。多亏了一些预见性,这些文件在运行测试之前会检查一个名为 SKIP_TESTS 的变量,将其设置为 True 将得到我们想要的行为。
TODO 4:点击显示/隐藏答案
set(SKIP_TESTS True)
现在我们准备好 include() 之前的练习以获取它们的函数。
TODO 5:点击显示/隐藏答案
include(Exercise1.cmake)
include(Exercise2.cmake)
现在 FuncAppend 可供我们使用,我们可以用它将新元素追加到 InList 中。
TODO 6:点击显示/隐藏答案
FuncAppend(InList FooBaz)
FuncAppend(InList QuxBaz)
最后,我们可以使用 FilterFoo 来过滤整个列表。这里需要记住的棘手部分是我们的 FilterFoo 想要通过 ARGN 对列表值进行操作,所以我们在调用 FilterFoo 时需要扩展 InList。
TODO 7:点击显示/隐藏答案
FilterFoo(OutList ${InList})