面向 C++ 的现代 CMake 教程(一)(3)https://developer.aliyun.com/article/1526964
未引用的参数
在编程世界中,最后一种参数确实比较少见。我们习惯了字符串要以某种方式进行分隔,例如,使用单引号、双引号或反斜杠。CMake 与这个约定不符,并引入了未引用的参数。我们可能会认为省略分隔符可以使代码更容易阅读,就像省略分号一样。这是真的吗?我会让你自己形成自己的看法。
未引用的参数评估转义序列和变量引用。然而,要小心分号(;
),因为在 CMake 中,这被视为分隔符。CMake 会将包含它的参数拆分为多个参数。如果你需要使用它,用反斜杠(\;
)转义它。这就是 CMake 如何管理列表的方式。我将在使用列表部分详细解释。
你可能会发现这些参数是最让人困惑的,所以这里有一个说明来帮助澄清这些参数是如何划分的:
[外链图片转存中…(img-hNS5KcPP-1716544491730)]
](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
中的空格在显式转义时被正确打印。 - 然而,
twoarguments
和threeseparatearguments
被粘在一起,因为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
变量中,传递的参数数量将在CMAKE_ARGC
变量中。
使用环境变量
这是最简单的变量类型。CMake 会复制启动cmake
过程时使用的环境中的变量,并使它们在一个单独的全局作用域中可用。要引用这些变量,请使用$ENV{}
语法。
CMake 还允许您设置(set()
)和取消设置(unset()
)这些变量,但更改只会对运行中的cmake
过程中的本地副本进行修改,而不会对实际系统环境进行修改;此外,这些更改不会对后续的构建或测试运行可见。
要修改或创建一个变量,请使用set(ENV{} )
命令,如下所示:
set(ENV{CXX} "clang++")
要清除一个环境变量,请使用unset(ENV{})
,如下所示:
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{}
语法引用。
要设置一个缓存变量,请使用以下语法使用set()
:
set( CACHE [FORCE])
正如你所见,有一些新的必需参数(与正常变量的 set()
命令相比),它还引入了第一个关键字:CACHE
和 FORCE
。
将 CACHE
指定为 set()
参数意味着我们打算改变在配置阶段提供的内容,并强制提供变量 和
值。这是因为这些变量可以由用户配置,GUI 需要知道如何显示它。以下类型被接受:
BOOL
: 是/否的布尔值。GUI 将显示一个复选框。FILEPATH
: 磁盘上一个文件的路径。GUI 将打开一个文件对话框。PATH
: 磁盘上一个目录的路径。GUI 将打开一个目录对话框。STRING
: 一行文本。GUI 提供一个文本字段来填写。它可以被调用set_property(CACHE STRINGS )
的下拉控件替换。INTERNAL
: 一行文本。GUI 跳过内部条目。内部条目可用于在多次运行之间持久化存储变量。使用此类型隐式添加FORCE
关键字。
值只是一个标签,它将由 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 简单地用当前作用域的所有变量的副本填充它。随后的命令将影响这些副本。但一旦嵌套作用域的执行完成,所有的副本都被删除,并恢复原始的父作用域。
让我们考虑以下场景:
- 父作用域将
VAR
变量设置为ONE
。 - 嵌套作用域开始,并将
VAR
打印到控制台。 VAR
变量被设置为TWO
,并将VAR
打印到控制台。- 嵌套作用域结束,并将
VAR
打印到控制台。
控制台的输出将如下所示:ONE
,TWO
,ONE
。这是因为嵌套作用域结束后,复制的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()
条件支持NOT
、AND
和OR
逻辑运算符:
NOT <条件>
<条件> AND <条件>
<条件> OR <条件>
此外,条件的嵌套是可能的,匹配的括号对(()
)。像所有体面的语言一样,CMake 语言尊重评估的顺序,并从最内层的括号开始:
(<条件>) AND (<条件> OR (<条件>))
字符串和变量的评估
由于历史原因(因为变量引用(${}
)语法并非一直存在),CMake 会尝试将未引用的参数评估为变量引用。换句话说,在条件中使用普通的变量名(例如,VAR
)等同于写${VAR}
。下面是一个供你考虑的示例和一个陷阱:
set(VAR1 FALSE) set(VAR2 "VAR1") if(${VAR2})
if()
条件在这里有点复杂——首先,它会评估${VAR2}
为VAR1
,这是一个已知的变量,进而将其评估为FALSE
字符串。字符串只有等于以下常量之一时才被认为是布尔真(这些比较不区分大小写):
ON
、Y
、YES
或TRUE
- 非零数字
这使我们得出结论,前一个示例中的条件将评估为假。
然而,这里还有一个陷阱——一个未引用的参数的条件评估会怎样,这个参数的名称包含一个值为BAR
的变量呢?考虑以下代码示例:
set(FOO BAR) if(FOO)
根据我们迄今为止的说法,这将是false
,因为BAR
字符串不符合评估为布尔true
值的准则。不幸的是,这不是事实,因为 CMake 在未引用的变量引用方面做出了例外。与引号引用的参数不同,FOO
不会被评估为BAR
以产生if("BAR")
语句(这将是false
)。相反,CMake 只会评估if(FOO)
为false
,如果它是以下常量之一(这些比较不区分大小写):
OFF
,NO
,FALSE
,N
,IGNORE
,NOTFOUND
- 以
-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>})
比较值
比较操作支持以下操作符:
EQUAL
,LESS
,LESS_EQUAL
,GREATER
,和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_
变量中。
简单检查
我们已经提到了一个简单的检查,DEFINED
,但还有其他简单的返回true
如果条件满足的检查。
我们可以检查以下内容:
- 如果值在列表中:
<变量名|字符串> IN_LIST <变量名>
- 如果一个命令可以被调用:
COMMAND <命令名>
- 如果存在 CMake 策略:
POLICY <策略 ID>
(这在第三章中有介绍,设置你的第一个 CMake 项目) - 如果一个 CTest 测试是用
add_test()
添加的:TEST <测试名称>
- 如果定义了一个构建目标:
TARGET <目标名称>
我们将在第四章,《使用目标》中探索构建目标,但现在,让我们说目标是为项目创建的逻辑构建过程中的单位,该项目已经调用了add_executable()
、add_library()
或add_custom_target()
命令。
检查文件系统
CMake 提供了许多处理文件的方法。我们很少需要直接操作它们,通常我们更愿意使用高层次的方法。为了参考,本书将在附录部分提供一个简短的与文件相关的命令列表。但大多数时候,只需要以下操作符(对于绝对路径的行为定义得很清楚):
EXISTS
:检查文件或目录是否存在
这解决了符号链接(如果符号链接的目标存在,则返回true
)。
IS_NEWER_THAN
:检查哪个文件更新
如果file1
比(或等于)file2
新,或者这两个文件中的一个不存在,则返回true
。
IS_DIRECTORY path-to-directory
:检查一个路径是否是目录IS_SYMLINK file-name
:检查一个路径是否是符号链接IS_ABSOLUTE path
:检查一个路径是否是绝对的
面向 C++ 的现代 CMake 教程(一)(5)https://developer.aliyun.com/article/1526968