【CMake】《CMake构建实战:项目开发卷》笔记-Chapter3-基础语法

第3章 基础语法

使用CMake工具构建项目的第一步是编写CMake目录程序,目录程序主要用于描述项目的结构,包括对构建目标的定义及其相互依赖关系的定义等。同时,在CMake目录程序中还可以检测系统环境来配置项目的编译条件、生成源文件等。CMake目录程序需要使用CMake脚本语言来编写,因此熟练掌握CMake脚本语言的语法是学习CMake工具过程中基础而重要的一步。

尽管CMake脚本语言最主要的用途是编写组织项目构建的CMake目录程序,但它其实是一个图灵完备的脚本语言,也可以用于编写通用的功能脚本。在使用CMake工具构建项目时,时常会在目录程序中调用使用CMake脚本语言编写的脚本程序或模块程序来完成一些复杂的功能。

本章将对CMake脚本语言的基础语法进行介绍,几乎不涉及与构建相关的概念。另外,第4章将介绍CMake脚本语言中常用的命令,第5章则是一个综合应用CMake脚本语言的实例。如果读者已经掌握了CMake脚本语言,想立刻了解与构建相关的概念,可以直接从第6章开始阅读。

3.1 CMake程序

CMake程序根据文件名,可分为以下两种类型:

  • 名为CMakeLists.txt的文件;

  • 扩展名为.cmake的程序。

CMakeLists.txt文件用于组织构建项目源程序的目录结构,与构建过程息息相关;扩展名为.cmake的程序又分为脚本程序和模块程序两种类型。

3.1.1 目录(CMakeLists.txt)

当CMake被用于构建时,显然需要处理项目的源程序。处理的入口,就是项目顶层目录下的 CMakeLists.txt。这与Makefile相似。另外,在CMakeLists.txt中,可能还会通过add_subdirectory命令将一些子目录追加到构建目录中。当然,这要求子目录中也有一个CMakeLists.txt。

CMakeLists.txt构成了源程序逻辑上的目录结构,CMake还会根据这个源文件目录的逻辑结构生成用于构建的目录结构,作为构建对应源程序时的工作目录和二进制输出目录。

为了将脚本语言和构建过程分开讲解,本章很少出现CMakeLists.txt的应用。

3.1.2 脚本(

指定-P参数运行CMake命令行工具可以执行脚本类型的CMake程序。这种CMake程序不会配置生成任何构建系统,因此一些与构建相关的CMake命令是不允许出现在脚本中的。本章内容基本围绕脚本程序展开。

3.1.3 模块(.cmake)

CMake的目录程序和脚本程序均可以通过include等命令引用CMake模块程序。CMake提供了很多预制模块供用户使用,多数与环境检测、搜索使用第三方库有关。CMake模块是一种主要的代码复用单元。

3.2 注释

CMake程序中的注释有两种形式:单行注释和方括号注释。它们有个共同特点,那就是都需要由#开头。

3.2.1 单行注释

顾名思义,单行注释(line comment)就是只有一行文本的注释。类似但不同于C语言中由//开头的单行注释,CMake中的单行注释由#开头。

复制代码
# 这是一行注释
message("a" # 这是一行注释
        "b") # 这是一行注释

如果#位于引号参数或括号参数之中,或是被\转义,则其引领的文本就不再被当作注释文本。相关概念请参阅3.4节。

3.2.2 括号注释

括号注释(bracket comment)常用于多行注释,也可以用于在程序中间插入一段注释,类似C语言中由/**/组成的注释。但CMake的多行注释标记比较特别。先看一个例子:

复制代码
#[[ 这是一个括号注释
它可以由多行文本组成,直到遇到两个终止方括号]]
message("a" #[=[程序中间也可以插入一段注释]=] "b")

括号注释依然由#开头,紧接着依次是左方括号[、若干等号=(也可以不加等号)、左方括号[。括号注释的终止标记与起始标记对称,但不含#,即依次由右方括号]、若干等号=、右方括号]构成,其中等号的数量需要与起始标记相同。例如,#[[#[=[#[===[都是有效的括号注释的起始标记,对应的终止标记依次为]]]=]]===]。在括号注释的起始括号和终止括号之间的内容,就是注释文本。

括号注释其实是"#"与括号参数的组合,括号参数相关内容请参见3.4.5节。

与单行注释一样,在引号参数和括号参数中,或#被转义时,上述形式的代码并不能算作注释文本。

3.3 命令调用

CMake程序几乎完全由命令调用构成。之所以说"几乎",是因为除此之外,也就只剩下注释和空白符了。CMake程序中的if条件分支、for循环等程序结构统一采用命令调用形式。

CMake的命令调用类似于其他编程语言中的函数调用,但语法有些不同。先书写命令名称,其后跟括号括起来的命令参数。CMake的命令名称不区分大小写,一般使用小写,如下所示。

复制代码
message(a b c) # 输出"abc"

如果有多个参数,不同于其他编程语言常用逗号分隔参数,在CMake中应当使用空格或换行符等空白符将它们分隔开。像上述实例中调用message时,实际上传递了三个参数,分别是a、b和c。而空格仅用于分隔每一个参数,并不是参数内容,因此最终输出的消息是"abc",并不包含空格。3.4节将会探索不同类型的命令参数,相信届时读者就会知道该如何输出"a b c"了。

3.4 命令参数

命令参数在命令调用的括号中书写。命令参数一共有以下三种类型:

  • 引号参数(quoted argument);

  • 非引号参数(unquoted argument);

  • 括号参数(bracket argument)。

3.4.1 引号参数

顾名思义,引号参数是用引号包裹在内的参数,而且CMake规定它必须使用双引号。引号参数会作为一个整体传递给命令,引号中间的空白符都会作为这个整体中的一部分。也就是说,引号参数中不仅能够包含空格,还可以包含换行符。因此如下所示的这段程序是合法的。

复制代码
message("CMake
您好!")

它会输出:

复制代码
CMake
您好!

在引号参数中,代码行末的反斜杠\可以避免参数内容中出现换行。换句话说,反斜杠后的换行符将被忽略。如下所示。

复制代码
message("\
CMake\
您好!\
")

这段程序会输出:

复制代码
CMake您好!

另外,引号参数还支持变量引用和转义字符,接下来的小节会对此进行详细介绍。

3.4.2 非引号参数

非引号参数自然指未被引号包裹的参数。这种参数中不能包含任何空白符,也不能包含圆括号、#符号、双引号和反斜杠,除非经过转义。非引号参数也支持变量引用和转义字符。

非引号参数不总是作为一个整体传递给命令,它有可能被拆分成若干参数传递。实际上,非引号参数在被传递前,会被当作CMake列表来处理,而列表中的每一个元素都会作为一个单独的参数传递给命令。CMake列表会在3.6节详细介绍,这里先对它简单做个不够准确的定义:CMake列表是一种特殊的字符串,由分号分隔各个元素。

非引号参数的实例如下所示。

复制代码
message("x;y;z") # 引号参数
message(x y z) # 多个非引号参数
message(x;y;z) # 非引号参数

其结果如下:

复制代码
> cd CMake-Book/src/ch003/命令参数
> cmake -P 非引号参数.cmake
x;y;z
xyz
xyz

可见,对于非引号参数x;y;z来说,虽然它在语法上是一个非引号参数,但在实际传递给命令时,由于列表语法的存在,其中的每个元素都会作为独立的参数来传递。因此,第三个message 和第二个message的输出结果完全一样,而第一个message由于接受的是作为整体传递的引号参数,并不会将其内容拆分后输出。

3.4.3 变量引用

变量引用(variable reference)类似于很多编程语言提供的字符串插值(string interpolation)语法,可以在参数内容中插入一个变量的值。

CMake变量引用形式为${变量},即在$符号后面使用一对花括号包裹变量名。CMake变量引用可用在引号参数和非引号参数中,CMake会将其替换为对应变量的值。若变量名未定义,CMake 并不会报错,而是将其替换为空字符串。另外,变量引用还支持嵌套的递归引用,如下所示。

复制代码
set(var_a 您好)
set(var_b a)

message(${var_${var_b}})

程序中的set命令用于为变量赋值,后面会详细介绍。该程序的输出结果是 "您好",也就是变量var_a的值。

此处嵌套引用的解析流程如下:首先将内层的${var_b}替换为变量var_b的值,也就是a;这样整个变量引用就转化为了${var_a},它又会被替换为变量var_a 的值,也就是最终要输出的"您好"。

后面还会介绍其他一些变量类型,包括缓存变量和环境变量。对这两种变量的引用需要使用稍微不同的语法:

复制代码
$CACHE{缓存变量}
$ENV{环境变量}

其中,缓存变量既可以通过上述特定语法来引用,又可以通过普通变量的引用语法来引用,而环境变量只能通过上述特定语法来引用。不过,当存在同名的普通变量和缓存变量时,普通变量的引用语法会优先匹配到普通变量,无法匹配到缓存变量。

3.4.4 转义字符

一个反斜杠和紧跟其后的一个字符构成一个转义字符,它基本分为以下四种情况。

  • 如果其后跟随的字符不是字母、数字或分号,转义的结果就是该字符本身。例如,"\?"就是"?"

  • "\t"``"\r""\n"分别会转义成Tab符、回车符和换行符。

  • "\;"的转义又分为以下情况。

    • 如果它被用于变量引用或非引号参数中,则转义为分号";"。但在非引号参数,转义后分号不用于分隔列表元素,即其前后相邻文本包括分号本身会作为一个整体。
    • 其他情况,则不进行转义,即反斜杠保留,仍为\;
  • 其他情况则是错误的转义。

转义字符实例如下所示。

复制代码
cmake_minimum_required(VERSION 3.20)

set("a?b" "变量a?b")

# \? 转义为 ?
message(${a\?b})
message(今天是几号\?) 

# \n 转义为换行符,\t 转义为制表符,\! 转义为 !
message(回答:\n\t今天是1号\!)

set("a;b" "变量a;b")

# 非引号参数中 \; 转义为 ;,且不分隔变量
message(x;y\;z)
# 引号参数中 \; 不转义
message("x;y\;z")
# 变量引用中 \; 转义为 ;
message("${a\;b}")

本例程序中的cmake_minimum_required命令与CMake策略相关,详见第10章。为了方便读者理解,这里简单解释一下:由于历史原因,CMake的转义行为在不同版本中会有所不同,需要指定该CMake程序要求的最低版本,以保证CMake能够采取该版本中明确的转义行为,避免CMake版本升级带来的兼容性问题。

本例执行的结果如下:

复制代码
> cd CMake-Book/src/ch003/命令参数
> cmake -P 转义字符.cmake
变量a?b
今天是几号?
回答:
    今天是1号!
xy;z
x;y\;z
变量a;b

3.4.5 括号参数

与引号参数一样,CMake的括号参数也会作为一个整体传递给命令。括号参数类似C++11中的原始字符串字面量(raw string literal),通过自定义的特殊括号将原始文本包括在其中。它不处理文本中的任何特殊字符(包括转义字符)或变量引用语法,直接保留原始文本。

括号参数的语法结构与括号注释十分相近,唯一的区别就是括号参数的起始标记没有#,其具体的语法结构参见3.2.2小节。如下所示是一些实例。

复制代码
message([===[
abc
def
]===])

message([===[abc
def
]===])

message([===[
随便写终止方括号并不会导致文本结束,
因此右边这两个括号]]也会包括在原始文本中。
下一行中最后的括号也是原始文本的一部分,
因为等号的数量与起始括号不匹配。]==]
]===])

其运行结果如下:

复制代码
> cd CMake-Book/src/ch003/命令参数
> cmake -P 括号参数.cmake
abc
def
 
abc
def
 
随便写终止方括号并不会导致文本结束,
因此右边这两个括号]]也会包括在原始文本中。
下一行中最后的括号也是原始文本的一部分,
因为等号的数量与起始括号不匹配。]==]

如果第一个换行符紧随起始括号之后,则该换行符会被忽略。这主要是为了让第一行内容不必跟随括号参数的起始括号书写,显得更加整齐。例如,上述所示实例程序中前两个message命令中的参数是等价的,但显然第一个写法更为整齐。

3.5 变量

同大多数编程语言一样,CMake中的变量也是存储数据的基本单元,但CMake变量有些与众不同:其数据类型总是文本型的,只不过在使用时,文本型的变量可能被一些命令解释成数值、列表等,以实现更加丰富的功能。

变量的分类

尽管CMake变量的数据类型只有一种,但CMake却有三种变量分类。

  • 普通变量。大多数变量都是普通变量,它们具有特定的作用域。

  • 缓存变量。顾名思义,它就是能够被缓存起来的变量,会被持久化到缓存文件CMakeCache.txt。CMake程序每次被执行时,都会从被持久化的缓存文件中读取缓存变量的值。这可以用于避免每次都执行一些耗时的过程来获得数据。例如,当使用CMake构建项目时,它第一次配置时会检测编译器路径,然后将其作为缓存变量持久化,这样可以避免每次执行都重新进行检测。缓存变量主要用于构建过程,cmake -P执行脚本程序时不会对缓存变量进行修改。缓存变量具有全局作用域。

  • 环境变量。即操作系统中的环境变量,因此它对于CMake进程而言具有全局的作用域。

变量的作用域

普通变量会绑定到某个作用域中。作用域分为两种。

  • 函数作用域。在用户自定义的函数命令中会有一个独立的作用域。默认情况下,函数内定义的变量只在函数内部或函数中调用的其他函数中可见。

  • 目录作用域。对于CMake的目录程序而言,每一个目录层级,都有它的一个作用域。子目录的程序被执行前,会先将父目录作用域中的所有变量复制一份到子目录的作用域中。因此,子目录的程序可以访问但无法修改父目录作用域中的变量。对于CMake脚本程序而言,目录作用域相当于只有一层。

保留标识符

CMake会将以下3种形式的名称作为保留标识符,自定义变量或命令时应当注意避开它们:

  • "CMAKE_"开头的名称(不区分大小写);

  • "_CMAKE_"开头的名称(不区分大小写);

  • 下画线"_"加上CMake中任意一个预定义命令的名称,如"_message"

3.5.1 预定义变量

CMake中有很多预定义的普通变量和环境变量,它们一般以"CMAKE_"开头,即属于保留标识符。预定义变量往往与系统配置、运行环境、构建行为、编译工具链、编程语言等信息相关。

CMake中的预定义变量全部可以在其官方文档中找到,本书也会陆续涉及很多常用的预定义变量。在此先简单看一些预定义变量的例子。

  • CMAKE_ARGC 表示CMake脚本程序在被cmake -P命令行调用执行时,命令行传递的参数个数。

  • CMAKE_ARGV0、CMAKE_ARGV1表示CMake脚本程序在被命令行调用执行时,命令行传递的第一个、第二个参数。如果有更多参数,可以以此类推增加变量名称末尾的数值来获得。

  • CMAKE_COMMAND 表示CMake命令行程序所在的路径。

  • CMAKE_HOST_SYSTEM_NAME 表示宿主机操作系统(运行CMake的操作系统)名称。

  • CMAKE_SYSTEM_NAME 表示CMake构建的目标操作系统名称。默认与宿主机操作系统一致,一般用于交叉编译时,由开发者显式设置。

  • CMAKE_CURRENT_LIST_FILE 表示当前运行中的CMake程序对应文件的绝对路径。

  • CMAKE_CURRENT_LIST_DIR 表示当前运行中的CMake程序所在目录的绝对路径。

  • MSVC 表示在构建时CMake当前使用的编译器是否为MSVC。

  • WIN32表示当前目标操作系统是否为Windows。

  • APPLE表示当前目标操作系统是否为苹果操作系统(包括macOS、iOS、tvOS、watchOS等)。

  • UNIX表示当前目标操作系统是否为UNIX或类UNIX平台(包括Linux、苹果操作系统及Cygwin平台)。

如下所示是一个预定义变量的例程,用于输出CMake命令行程序的路径,以及宿主机操作系统的名称。

复制代码
message("CMake命令行:${CMAKE_COMMAND}")
message("OS:${CMAKE_HOST_SYSTEM_NAME}")

它在Windows操作系统中的执行结果如下:

复制代码
> cd CMake-Book\src\ch003\变量
> cmake -P 预定义变量.cmake
CMake命令行:C:/Program Files/CMake/bin/cmake.exe
OS:Windows

3.5.2 定义变量

set命令可以用于定义或赋值一个普通变量、缓存变量或环境变量。这里为了严谨,采用"定义或赋值"的说法,因为CMake并不强制要求变量在定义后才能读取。在CMake中,读取未定义变量的值不会产生错误,而是会读取到空字符串,因此"定义"和"赋值"往往不必特别区分。

定义普通变量
复制代码
set(<变量> <值>... [PARENT_SCOPE])

定义普通变量非常直接,第一个参数写变量名,紧接着写变量的值即可。变量的值可以由若干参数来提供,这些参数会被分号分隔连接成一个列表的形式,作为最终的变量值。值参数也可以被省略,此时,该变量会从当前作用域中移除,相当于对该变量调用了unset命令。

最后,还可以通过可选参数PARENT_SCOPE将变量定义到父级作用域中。对于目录而言,就是将变量定义到父目录作用域中;对于函数而言,就是将变量定义到函数调用者所在的作用域中。如下所示展示了一些实例。

复制代码
function(f)
    set(a "我是修改后的a")
    set(b "我是b")
    set(c "我是c" PARENT_SCOPE)
endfunction()

set(a "我是a")
f()

message("a: ${a}")
message("b: ${b}")
message("c: ${c}")

其中涉及一对新命令function和endfunction,它们主要用于演示PARENT_SCOPE参数的作用,读者现在只需知道它们用于定义一个函数命令。

那么3个变量的值最终能够成功输出吗?不妨来运行一下。

复制代码
> cd CMake-Book/src/ch003/变量
> cmake -P 定义普通变量.cmake
a: 我是a
b:
c: 我是c

显而易见,变量a能够被成功输出,因为它的定义与message命令的调用在同一作用域;然而其值仍然是原始值,而非修改后的值。这是因为函数内部并非修改了外部作用域的变量a,而是创建了一个函数内部作用域的变量a,外部作用域的变量a的值不会被修改。

变量b和c均位于函数内部,与message命令调用处于不同的作用域。因此,只有在定义时指定了PARENT_SCOPE参数的变量c才能够在上层调用方的作用域中访问到。

定义缓存变量
复制代码
set(<变量> <值>... CACHE <变量类型> <变量描述> [FORCE])

定义缓存变量的命令比定义普通变量的命令多了CACHE和FORCE参数,以及一些与变量相关的元信息------类型和描述。当然,因为缓存变量具有全局的作用域,也就不需要 PARENT_SCOPE参数了。这里的"值"也可以是由若干参数组成的列表,与定义普通变量并无分别。

缓存变量一般应用于目录程序中,便于对构建过程的一些配置进行持久化。CMake也提供了一个拥有可视化界面的CMake GUI程序(cmake-gui),可以方便地对缓存变量的值进行设置。

<变量类型>有5种取值,它们在CMake GUI程序中也会对应不同的配置方式,参见下表。其中,STRING类型的缓存变量可以通过缓存变量属性STRINGS枚举一系列可供选择的字符串,此时它在CMake GUI中会以下拉选择框的形式配置,这种用法会在讲解属性时具体介绍。

类型参数 描述 CMake GUI中的配置方式
BOOL 布尔型 选择框(checkbox)
FILEPATH 文件路径类型 打开文件对话框
PATH 目录路径类型 打开目录对话框
STRING 文本型 文本框或下拉选择框
INTERNAL 内部使用(隐含设置FORCE参数) 不显示

<变量描述>参数用于给出这个缓存变量的详细说明。CMake GUI程序中,当鼠标悬停于变量之上时,就会有一个写着说明文字的提示框显现。

FORCE可选参数用于强制覆盖缓存变量的值。默认情况下,如果缓存变量已经被定义, CMake会忽略后续对该缓存变量的set赋值命令,除非这个set命令中指定了 FORCE参数。换句话说,仅当set命令定义的缓存变量不存在或命令参数中包含FORCE时,set命令才会真正定义缓存变量为指定的值。

布尔型缓存变量还可以使用option命令定义:

复制代码
option(<变量> <变量描述> [<ON|OFF>])

option命令的参数形式要简化很多,非常适合定义一些用作开关配置的缓存变量。

缓存变量除了可以通过在程序中使用set命令和option命令定义外,还可以通过直接修改持久化缓存文件CMakeCache.txt的方式来定义或覆盖其值。另外,CMake命令行工具的-D 参数也可以用于定义或覆盖缓存变量的值,而且有以下两种定义形式:

复制代码
-D <变量>:<缓存变量类型>=<值>
-D <变量>=<值>

其中,第二种形式省略了类型,书写简单,因此比较常见。CMake会根据程序中set命令中对该缓存变量的定义将其类型信息补全。

当缓存变量是PATH或FILEPATH类型,且通过命令行为定义的变量值是一个相对路径时,set命令会将这个相对路径根据当前目录转换为绝对路径。这样的做法是合理的:如果该缓存变量出现在某些工具的命令行参数中,而这些工具的工作目录并非当前目录,为了避免相对路径带来的歧义,缓存变量中的路径就应该是绝对路径。

另外,在程序之外定义缓存变量的值通常会优先覆盖程序中定义的值(除非程序中指定了FORCE参数)。因此,缓存变量常常作为项目的配置参数,程序中提供预定义值,而用户可以通过命令行参数等方式设置自定义值。这也是为什么CMake还提供了GUI来修改缓存变量。

复制代码
cmake_minimum_required(VERSION 3.20)
project(Notepad)

set(path_to_notepad "" CACHE FILEPATH "Path to notepad.exe")

# 下面的命令将会用记事本打开同一目录中的in.txt
execute_process(COMMAND "cmd" "/c" 
    ${path_to_notepad} ${CMAKE_CURRENT_LIST_DIR}/in.txt)

在前面讲解变量引用时提到过,引用缓存变量有一种特殊语法$CACHE{...}。如下所示例程展示了不同的变量引用语法的匹配差异。

复制代码
cmake_minimum_required(VERSION 3.20)
project(MatchOrder)

set(a 缓存变量 CACHE STRING "")
set(a 普通变量)

message("\${a}: ${a}")
message("\$CACHE{a}: $CACHE{a}")

运行这个例程需要使用CMake命令行的构建模式。读者如果还不熟悉CMake的构建模式和CMake目录程序,此处可以先大概浏览。其运行过程如下:

复制代码
> cd CMake-Book/src/ch003/变量/缓存变量/匹配
> mkdir build
> cd build
> cmake ..
-- ...
${a}: 普通变量
$CACHE{a}: 缓存变量
-- Configuring done
-- Generating done
-- ...

可见,同名的普通变量和缓存变量同时存在时,普通变量引用语法优先匹配普通变量。

定义环境变量
复制代码
set(ENV{<环境变量>} [<值>])

环境变量具有全局作用域,不支持使用参数列表来定义值,也没有其他元信息,因此定义环境变量的命令形式是最简单的。另外,通过CMake的set命令定义的环境变量只会影响当前的CMake进程,不会影响到父进程或系统的环境变量配置。

命令中的值参数虽然不能是多个参数构成的列表,但仍然是可选的。如果不填写值参数,CMake 则会将对应环境变量的值清空。

如下所示的例程中,我们首先将PATH环境变量定义为了"path"文本,并在修改前后输出了环境变量的值,后面又通过execute_process命令调用另一个CMake脚本程序setenv.cmake。该脚本程序如下所示,它会将PATH环境变量的值清空,同时输出清空前后的值。

回到主程序,execute_process会捕获子进程的标准输出,默认不输出到终端中,因此需要借助OUTPUT_VARIABLE参数获取捕获的标准输出。如下所示的例程中就将标准输出获取到了out变量中并输出到终端。最后,主程序会再次输出PATH环境变量的值。

复制代码
message("main \$ENV{PATH}: $ENV{PATH}")
set(ENV{PATH} "path")
message("main \$ENV{PATH}: $ENV{PATH}")

execute_process(
    COMMAND ${CMAKE_COMMAND} -P setenv.cmake
    OUTPUT_VARIABLE out
)
message("${out}")

message("main \$ENV{PATH}: $ENV{PATH}")

message("before setenv \$ENV{PATH}: $ENV{PATH}")
set(ENV{PATH}) # 清空
message("after setenv \$ENV{PATH}: $ENV{PATH}")

读者可以猜猜看这里到底会输出什么,谜底就在下面的运行过程中(其中省略号略去了部分环境变量PATH的输出):

复制代码
> cd CMake-Book/src/ch003/变量/环境变量
> cmake -P main.cmake
main $ENV{PATH}: C:\Program Files\...
main $ENV{PATH}: path
before setenv $ENV{PATH}: path
after setenv $ENV{PATH}:
 
main $ENV{PATH}: path

CMake的set命令定义的环境变量仅对当前CMake进程有效,CMake子进程将PATH环境变量的值清空并不影响CMake父进程中的PATH环境变量,因此最终的输出仍是path。

3.6 列表

前面在介绍非引号参数时已经见识过了CMake中的列表------用分号隔开的字符串。

定义列表变量

既然列表也是字符串,那么定义列表变量并不会有什么特别的。利用前面介绍的set命令就可以定义列表变量。

首先,可以利用引号参数直接定义一个包含分号的字符串,这就是一个列表。其次,set命令还支持指定多个作为变量值的参数,这样引号参数和非引号参数都可以使用。如下所示的例程中分别定义了三个列表变量,其中后两个列表变量的定义方式其实是等价的,都是通过向set命令传递多个参数来实现的。

复制代码
include(print_list.cmake)

set(a "a;b;c")
set(b a;b;c)
set(c a b c)

print_list(a) # 输出:a | b | c
print_list(b) # 输出:a | b | c
print_list(c) # 输出:a | b | c

该程序还通过include命令引用了另一个程序print_list.cmake,其中包含一个用于输出列表元素的函数print_list,它会将指定列表的每一个元素用空格和竖线分隔开并输出。这里暂不关注其具体实现。

可见,例程中的3种定义方式殊途同归,定义了3个表示相同列表的变量。实际上,以上几种定义方式还可以混合使用:

复制代码
set(a a "b;c") # 等价于 set(a "a;b;c")
特殊的分号

列表的每一个元素都是被分号隔开的,但不是每一个分号都用于分隔元素。当分号前面有一个用于转义的反斜杠时,这个分号不会用作分隔符。另外,如果一个分号前面存在未闭合的方括号时,该分号也不会被当作元素的分隔符。例如,"[;"``"[;]"``"[[];"中的分号都无法分隔列表元素,而"[[]];"中的分号是元素的分隔符,因为它前面所有的方括号都已经被闭合。

为什么要多此一举呢?实际上,方括号在某些场景中有特殊的含义,而且在这些场景中,分号也承担着不同的功能,所以将这种情况区分开来是必需的。这个特殊的场景就是Windows操作系统中的注册表项,如指定get_filename_component命令的参数为注册表项:

复制代码
get_filename_component(
    SDK_ROOT_PATH
    "[HKEY_LOCAL_MACHINE\\SOFTWARE\\PACKAGE;Install_Dir]" 
    ABSOLUTE CACHE)

这表示注册表项为[HKEY_LOCAL_MACHINE\SOFTWARE\PACKAGE;Install_Dir]。分号前为注册表项所在的目录(主键),分号后则是要取值的注册表项(子键)。尽管注册表语法仅在get_filename_componentfind_libraryfind_pathfind_programfind_file命令中会被解析为对应注册表项的值,但方括号中的分号在任何命令的参数中都不会被当作列表的分隔符。

最后不妨看一些实例熟悉一下这些特殊情况,如下所示。

复制代码
include(print_list.cmake)

set(a "a;b\;c")
set(b "a[;]b;c")
set(c "a[[[;]]]b;c")
set(d "a[;b;c")
set(e "a[];b")

print_list(a) # 输出:a | b;c
print_list(b) # 输出:a[;]b | c
print_list(c) # 输出:a[[[;]]]b | c
print_list(d) # 输出:a[;b;c 
print_list(e) # 输出:a[] | b 

逐条分析一下。

对于"a;b;c",很明显这里是被转义的分号,因此最终的列表包含两个元素,其中第二个元素中包含一个分号作为其值的一部分。

对于"a[;]b;c"和"a[[[;]]]b;c",第一个分号都位于方括号内容之中,自然也不会被当作分隔符;而第二个分号都位于已经闭合的方括号之后,所以它是列表的分隔符。因此,最终列表都包含两个元素。

对于"a[;b;c",由于方括号从未闭合,因此它后面的所有分号都不能被视作列表的分隔符,最终的列表也就只有一个元素,就是这个字符串本身。

对于"a[];b",唯一的分号位于已闭合的方括号之后,因此是列表的分隔符,最终列表包含两个元素。

3.7 控制结构

还记得上文说过CMake中几乎一切都是命令吗?当时就提到if、 for这些结构在CMake中统统都是命令的形式。

CMake的控制结构与我们平常所熟知的控制结构别无二致,可能更接近Basic、Pascal等语言,使用"end"一类的代码来结束一段控制结构,而非使用花括号来标记一段结构。

3.7.1 if条件分支

当条件成立或不成立时,程序会分别走向两条不同的分支,因此这样的控制结构称作条件分支结构。 CMake中提供了if条件分支结构,与C语言中的if语句几乎相同。其最简单的形式如下:

复制代码
if(<条件>)
    <命令>...
endif()

CMake中的控制结构都是通过命令来组织的,if也不例外,因此需要通过成对的if和endif命令来构造一个条件分支结构。二者之间就是条件成立时会被执行的命令序列。<条件>的语法将在后面详细介绍。当然,CMake的条件分支也支持else结构和elseif结构:

复制代码
if(<条件>)
    <命令>...
elseif(<条件>)
    <命令>...
else()
    <命令>...
endif()

其中,else和elseif都是可选的,elseif可以连续存在多个。这与常见的编程语言中的条件分支结构如出一辙:当if中的条件不成立时,会依次判断后面每一个elseif中的条件。如果某个条件成立,就会进入对应的程序块中执行命令,不再进行后续的判断;如果条件均不成立,则会进入最后的else对应的程序块(如果存在)中执行命令。

3.7.2 while判断循环

复制代码
while(<条件>)
    <命令>...
endwhile()

与其他编程语言中的while循环一样,当条件成立时,while和endwhile 之间的命令会被重复执行,直到条件不成立时终止。这里的"条件"和if中的"条件"具有相同的语法,将在3.8节详细介绍。

3.7.3 foreach遍历循环

遍历循环常用于对列表中的元素分别执行一系列相同的命令,它共有四种形式:简单列表遍历、区间遍历、高级列表遍历和打包遍历。

简单列表遍历
复制代码
foreach(<循环变量> <循环项的列表>)
    <命令>...
endforeach()

<循环项的列表>是一个CMake列表,列表中的元素由分号或空白符分隔。这个循环体的循环次数就是由<循环项的列表>中元素的个数决定的,<循环变量>会被依次赋值为当前遍历到的列表元素。因此,在循环体内部的命令中,可以通过对<循环变量> 的变量引用依次访问列表中的每一个元素,如下所示。

复制代码
foreach(x A;B;C D E F)
    message("x: ${x}")
endforeach()

message("---")

set(list X;Y;Z)
foreach(x ${list})
    message("x: ${x}")
endforeach()

其执行结果如下:

复制代码
> cd CMake-Book/src/ch003/遍历循环
> cmake -P 简单列表遍历.cmake
x: A
x: B
x: C
x: D
x: E
x: F
---
x: X
x: Y
x: Z
区间遍历
复制代码
foreach(<循环变量> RANGE [<起始值>] <终止值> [<步进>])

区间遍历与C语言中传统的for循环结构很类似,或者说更像Python中的 for ... in range(...)循环结构。<循环变量>会先被赋值为<初始值> ,然后每一次循环都会给其增加<步进>指定的大小;当<循环变量>的值大于 <终止值>时,循环终止,且本次循环体不会被执行。

<起始值>被省略时,默认为0。<步进>被省略时,默认为1。

CMake要求<起始值>、<终止值>和<步进>这三个参数都是非负整数,且 <终止值>必须大于等于<起始值>。也就是说,在CMake中区间遍历的 <循环变量>只能递增。这个要求相对其他编程语言来说较为严格。

如下所示的例程展示了区间遍历的用法。

复制代码
foreach(x RANGE 2 11 2)
    message("x: ${x}")
endforeach()

其执行结果如下:

复制代码
> cd CMake-Book/src/ch003/遍历循环
> cmake -P 区间遍历.cmake
x: 2
x: 4
x: 6
x: 8
x: 10
高级列表遍历
复制代码
foreach(<循环变量> IN [LISTS [<列表变量名的列表>]] [ITEMS [<循环项的列表>]])

"高级列表遍历"是"简单列表遍历"的超集:如果上述循环的参数中省略LISTS部分,仅保留ITEMS部分,那么它与"简单列表遍历"是等价的。即下面两种写法等价:

复制代码
foreach(<循环变量> IN ITEMS <循环项的列表>)
foreach(<循环变量> <循环项的列表>)

因此这里不再赘述ITEMS部分的参数写法和用途。回到LISTS 部分中的<列表变量名的列表>。它是一个变量名称列表,也就是说,它的每一个元素都是一个变量名称,由分号和空白符分隔。每一个对应的变量又被视为列表变量,foreach循环结构会依次遍历这些列表变量中的每一个元素。如下所示的例程展示了高级列表遍历的用法。

复制代码
set(a A;B)
set(b C D)
set(c "E F")
set(d G;H I)
set(e "")

foreach(x IN LISTS a b c;d;e ITEMS a b c;d;e)
    message("x: ${x}")
endforeach()

其输出结果如下:

复制代码
> cd CMake-Book/src/ch003/遍历循环
> cmake -P 高级列表遍历.cmake
x: A
x: B
x: C
x: D
x: E F
x: G
x: H
x: I
x: a
x: b
x: c
x: d
x: e

简言之,LISTS后面跟着的是一个个变量名,代表不同的列表;ITEMS后面跟着的则是一个个列表元素。记住这一点就很容易理解了。

打包遍历
复制代码
foreach(<循环变量>... IN ZIP_LISTS <列表变量名的列表>)

打包遍历中的<列表变量名的列表>也是一个变量名称列表。打包遍历会对每一个列表变量同时进行遍历,并把各个列表当次遍历到的元素赋值给不同的循环变量。它类似Python语言中的zip函数。其具体执行规则如下:

  • 如果只指定了一个<循环变量>,那么当前遍历到的每一个列表变量的元素会依次赋值给"<循环变量>_"(其中"N"对应列表变量的次序);

  • 如果指定了多个<循环变量>,<循环变量>的个数应当与<列表变量名的列表>中的元素个数一致;

  • 遍历循环次数以最长的列表变量元素个数为准。如果<列表变量名的列表>中某个列表变量的元素个数比其他列表少,则遍历到后面时会将其对应元素的值视为空字符串。

如下所示的例程展示了打包遍历的用法。

复制代码
set(a A;B;C)
set(b 0;1;2)
set(c X;Y)

foreach(x IN ZIP_LISTS a;b c)
    message("x_0: ${x_0}, x_1: ${x_1}, x_2: ${x_2}")
endforeach()

foreach(x y z IN ZIP_LISTS a b;c)
    message("x:   ${x}, y:   ${y}, z:   ${z}")
endforeach()

foreach(x y IN ZIP_LISTS a b c) # 报错
endforeach()

其执行结果如下:

复制代码
> cd CMake-Book/src/ch003/遍历循环
> cmake -P 打包遍历.cmake
x_0: A, x_1: 0, x_2: X
x_0: B, x_1: 1, x_2: Y
x_0: C, x_1: 2, x_2:
x:   A, y:   0, z:   X
x:   B, y:   1, z:   Y
x:   C, y:   2, z:
CMake Error at 4.打包遍历.cmake:13 (foreach):
  Expected 2 list variables, but given 3

前两个打包遍历循环分别体现了两种不同循环变量写法的行为,验证了第一条执行规则;第三个打包遍历循环则因为循环变量个数与列表变量的个数不匹配而报错,验证了第二条执行规则;最后一条执行规则可以通过输出结果中最后的空元素来得到验证。

3.7.4 跳出和跳过循环:break和continue

跳出循环
复制代码
while(...)
    ...
    break()
    ...
endwhile()

break命令会使循环终止,跳出其所在的最内层循环体。在循环体中,break命令之后的命令都不会再被执行,也不会再次进行条件判断或遍历进入后续循环。该命令同时适用于判断循环结构和遍历循环结构。

跳过本次循环
复制代码
while(...)
    ...
    continue()
    ...
endwhile()

continue命令用于"继续"到下次循环,即跳过本次循环的后续命令,直接进入下次循环的开头。当然,如果根据条件或遍历位置判断后不存在下次循环,则循环即结束。与break类似,该命令同样适用于判断循环结构和遍历循环结构。

3.8 条件语法

在一般的编程语言中,条件就是表达式,遵照条件表达式(布尔表达式)的语法来写即可。但CMake中几乎一切都是命令,哪有表达式语法呀!条件语法名义上是"语法",但充其量是命令中的某种特定的参数形式罢了。

3.8.1 常量、变量和字符串条件

常量、变量和字符串条件3种条件在形式上完全一致,需要根据上下文及量的值来判断具体是哪一种条件。

常量条件

常量条件仅由一个常量组成,常量分为真值常量(true constant)和假值常量(false constant),如下表所示。在if命令中使用常量条件的形式如下:

复制代码
if(<常量>)
常量类型 常量值 条件结果
真值常量 1、ON、YES、TRUE、Y,或非零数值(不区分大小写)
假值常量 0、OFF、NO、FALSE、N、IGNORE、空字符串、NOTFOUND,或以-NOTFOUND结尾的字符串(不区分大小写)

如果<常量>取值不在上表中提到的常量值范围内,则不认为它是常量,应当将其按照变量或字符串条件处理。

变量和字符串条件

如果条件中仅包含一个字符串,且这个字符串不是真值常量或假值常量,那么它还有可能是一个变量的名称。如果以这个字符串为名的变量确实存在,则它是一个变量条件,否则是一个字符串条件。它们的形式如下:

复制代码
if(<字符串|变量>)
  • 如果条件中的字符串是一个变量的名称,且这个变量的值不是一个假值常量,那么条件为真。

  • 在其他情况下(如指定变量的值为假值常量或变量未定义时),条件为假。

如下所示的例程充分展示了上述各种情形。

复制代码
if(ABC)
else()
    message("ABC不是一个已定义的变量,因此条件为假")
endif()

set(a "XYZ")
set(b "0")
set(c "a-NOTFOUND")

if(a)
    message("a是一个变量,其值非假值常量,因此条件为真")
endif()

if(b)
else()
    message("b是一个变量,其值为假值常量,因此条件为假")
endif()

if(c)
else()
    message("c是一个变量,其值为假值常量,因此条件为假")
endif()

另外,这里还有一个有趣的例程,它定义了一个名为on的奇怪变量,如下所示。

复制代码
cmake_minimum_required(VERSION 3.20)

set(on "OFF")

if(on)
    message("ON")
else()
    message("OFF")
endif()

if(${on})
    message("ON")
else()
    message("OFF")
endif()

之所以说它奇怪,是因为这个变量名本身是一个真值常量,而其定义的值又是一个假值常量。那么,如果将它放到条件中会发生什么呢?此处揭晓一下答案,但具体的原因留待读者自行分析(读者注:因为它是先判断是不是"常量条件"再判断是不是"变量和字符串条件"):

复制代码
> cd CMake-Book/src/ch003/条件语法
> cmake -P 奇怪的变量.cmake
ON
OFF

3.8.2 逻辑运算

条件语法中可以包含与(AND)、或(OR)、非(NOT)三种逻辑运算,参与运算的也是符合条件语法的参数:

复制代码
if(<条件1> AND <条件2>)
if(<条件1> OR <条件2>)
if(NOT <条件>)
  • AND两侧的条件都为真时,整个条件为真,否则为假。

  • OR两侧的条件有一个为真时,整个条件为真,否则为假。

  • NOT后面的条件为假时,整个条件为真,否则为假。

如下所示是一些实例。

复制代码
cmake_minimum_required(VERSION 3.20)

if(NOT OFF)
    message("NOT OFF为真")
endif()

if(ON AND YES)
    message("ON AND YES为真")
endif()

if(TRUE AND NOTFOUND)
else()
    message("TRUE AND NOTFOUND为假")
endif()

if(A-NOTFOUND OR YES)
    message("A-NOTFOUND OR YES为真")
endif()

3.8.3 单参数条件

单参数条件,即根据单个参数进行判断的条件,一般用于存在性判断和类型判断。CMake中支持的单参数条件如下表所示。

条件语法 条件判断类型 描述
if(COMMAND <命令名称>) 命令判断 当<命令名称>指代一个可被调用的命令、宏或函数时,条件为真,否则为假
if(POLICY <策略名称>) 策略判断 当<策略名称>指代一个已定义的策略时,条件为真,否则为假
if(TARGET <目标名称>) 目标判断 当<目标名称>指代一个在任意目录用add_executableadd_libraryadd_custom_target命令创建的目标时,条件为真,否则为假
if(TEST <测试名称>) 测试判断 当<测试名称>指代一个用add_test命令创建的测试时,条件为真,否则为假
if(DEFINED <变量名称>) 变量定义判断 当<变量名称>指代一个变量时,条件为真,否则为假
if(CACHE{<缓存变量名称>}) 缓存变量定义判断 当<缓存变量名称>指代一个缓存变量时,条件为真,否则为假
if(ENV{<环境变量名称>}) 环境变量定义判断 当<环境变量名称>指代一个环境变量时,条件为真,否则为假
if(EXISTS <文件或目录路径>) 文件或目录存在判断 当指定的<文件或目录路径>确实存在时,条件为真,否则为假。该条件要求路径为绝对路径。另外,如果路径指向一个符号链接,那么仅当符号链接对应的文件或目录存在时,条件为真
if(IS_DIRECTORY <目录路径>) 目录判断 当指定的<目录路径>确实存在且是一个目录时,条件为真,否则为假。该条件要求路径为绝对路径
if(IS_SYMLINK <文件路径>) 符号链接判断 当指定的<文件路径>确实存在且是一个符号链接时,条件为真,否则为假。该条件要求路径为绝对路径
if(IS_ABSOLUTE <路径>) 绝对路径判断 当指定的<路径>是一个绝对路径时,条件为真,否则为假
实例:单参数条件

单参数条件例程如下所示。

复制代码
set(a 1)

if(DEFINED a)
    message("DEFINED a为真")
endif()

if(CACHE{b})
else()
    message("CACHE{b}为假")
endif()

if(COMMAND set)
    message("COMMAND set为真")
endif()

if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/逻辑运算.cmake")
    message("EXISTS \"${CMAKE_CURRENT_LIST_DIR}/逻辑运算.cmake\"为真")
endif()

3.8.4 双参数条件

双参数条件通过两个参数的取值来决定条件是否为真,一般用于比较关系的判断。

数值比较

下面是一组数值比较双参数条件,从上到下分别用于判断"小于""大于""等于""小于或等于""大于或等于"这5种比较关系。当关系成立时,条件为真,否则为假:

复制代码
if(<字符串|变量> LESS <字符串|变量>) # 小于
if(<字符串|变量> GREATER <字符串|变量>) # 大于
if(<字符串|变量> EQUAL <字符串|变量>) # 等于
if(<字符串|变量> LESS_EQUAL <字符串|变量>) # 小于或等于
if(<字符串|变量> GREATER_EQUAL <字符串|变量>) # 大于或等于

对于<字符串>或<变量>的取值规则,与前面介绍的"变量或字符串条件"中的规则类似:如果它是一个存在的变量名,则取变量的值,否则取字符串本身作为用于比较的值。由于这一组条件仅用于数值比较,取值会被转换为数值类型后再进行比较。

字符串比较

下面一组双参数条件则用于字符串比较,同样也有5种比较关系,只不过比较时会根据字典序决定两个字符串取值的大小:

复制代码
if(<字符串|变量> STRLESS <字符串|变量>) # 小于
if(<字符串|变量> STRGREATER <字符串|变量>) # 大于
if(<字符串|变量> STREQUAL <字符串|变量>) # 等于
if(<字符串|变量> STRLESS_EQUAL <字符串|变量>) # 小于或等于
if(<字符串|变量> STRGREATER_EQUAL <字符串|变量>) # 大于或等于
字符串匹配

下面是字符串特有的一个条件语法,可以用于判断字符串是否匹配指定的<正则表达式>,仅当匹配成功时,条件为真,否则为假:

复制代码
if(<字符串|变量> MATCHES <正则表达式>)

正则表达式的语法会在4.2.2小节中具体讲解。

版本号比较

下面一组双参数条件很有意思,是用于比较版本号的双参数条件:

复制代码
if(<字符串|变量> VERSION_LESS <字符串|变量>) # 小于
if(<字符串|变量> VERSION_GREATER <字符串|变量>) # 大于
if(<字符串|变量> VERSION_EQUAL <字符串|变量>) # 等于
if(<字符串|变量> VERSION_LESS_EQUAL <字符串|变量>) # 小于或等于
if(<字符串|变量> VERSION_GREATER_EQUAL <字符串|变量>) # 大于或等于

版本号的格式如下:

复制代码
主版本号[.次版本号[.补丁版本号[.修订版本号]]]

版本号的每一个部分都是一个整数,被省略的部分会被当作0来处理。对于版本号的比较,则是从主版本号开始,依次比较每一部分。

列表元素判断

下面这个条件语法用于判断列表中的元素是否存在。当第二个参数<列表变量>的元素中存在第一个参数的取值时,条件为真,否则为假:

复制代码
if(<字符串|变量> IN_LIST <列表变量>)
实例:双参数条件

如下所示的例程展示了一些双参数条件的应用。

复制代码
cmake_minimum_required(VERSION 3.20)

set(a 10)
set(b "abc")
set(list 1;10;100)

if(11 GREATER a)
    message("11 GREATER a为真")
endif()

if(1 LESS 2)
    message("1 LESS 2为真")
endif()

if(b STRLESS "b")
    message("b LESS \"b\"为真")
endif()

if(1.2.3 VERSION_LESS 1.10.1)
    message("1.2.3 LESS 1.10.1为真")
endif()

if(abc MATCHES a..)
    message("abc MATCHES a..为真")
endif()

if(ab MATCHES a..)
else()
    message("ab MATCHES a..为假")
endif()

if(a IN_LIST list)
    message("a IN_LIST list为真")
endif()

3.8.5 括号和条件优先级

这里给出两个示例来讲解:

复制代码
if(NOT <条件1> AND <条件2> OR <条件3>)
if(NOT ((<条件1>) AND (<条件2> OR <条件3>)))

不同的条件语法具有不同的优先级,因此会导致求值顺序的不同,结果的真假也就不同。内层括号中的条件会被优先求值,因此上面两种写法的条件具有完全不同的含义。 CMake中条件语法求值的优先级由高到低依次为:

  • 当前最内层括号中的条件;

  • 单参数条件;

  • 双参数条件;

  • 逻辑运算条件NOT;

  • 逻辑运算条件AND;

  • 逻辑运算条件OR。

再来分析一下上面两个示例中的第一个。由于NOT的优先级高于AND, AND的优先级又高于OR,则第一个条件也就相当于这样的表达:

复制代码
if(((NOT <条件1>) AND <条件2>) OR <条件3>) 

如下所示的例程真实反映了这一点。

复制代码
cmake_minimum_required(VERSION 3.20)

if(NOT TRUE AND FALSE OR TRUE)
    message("NOT FALSE AND TRUE OR FALSE为真")
endif()

if(NOT (TRUE AND (FALSE OR TRUE)))
else()
    message("NOT FALSE AND TRUE OR FALSE为假")
endif()

3.8.6 变量展开

在条件语法中,可以直接通过变量名而不是变量引用语法来访问变量的值,并对其进行条件判断。这是为什么呢?

一般将条件语法中直接访问变量值的这种行为称作变量展开(variable expansion)。它类似于变量引用功能,但仅适用于条件语法中,且与变量引用语法显然不同,需要加以区分。

其实,如果没有变量展开这种特性,统一使用变量引用语法,反而更简洁清晰,还能够避免一些容易产生歧义的情况。但CMake没有选择这样做,或者说,它没有办法选择这样做。因为历史原因,if命令的诞生早于变量引用语法,条件语法中的变量展开特性也随之产生。事到如今,虽然有了变量引用语法,但条件语法也不能轻易做出不兼容的修改了。

判断一个可能未被定义的变量是否为真值时,使用变量展开可能更加方便,直接用if(A)就可以了。如果使用变量引用的语法if(${A}),那么当变量A未被定义或为空值时,CMake反而会认为没有向if传递任何参数而报错。

对于命令而言,在参数中使用的变量引用语法它是完全感知不到的。这是因为变量引用在被求值替换以后才会被作为参数传入命令中。这其实有可能产生一些容易误会的场景,如下所示。

复制代码
cmake_minimum_required(VERSION 3.20)

set(A FALSE)
set(B "A")

if(B)
    message("B为真")
endif()

if(${B})
else()
    message("\${B}为假")
endif()

while(NOT ${B})
    message("NOT \${B}为真")
    break()
endwhile()

其执行结果如下:

复制代码
> cd CMake-Book/src/ch003/条件语法
> cmake -P 变量引用的展开.cmake
B为真
${B}为假
NOT ${B}为真

解释如下。

  • 在第一个if命令中,条件为B。这属于变量条件,变量B的值为A,不是假值常量,因此该条件为真。

  • 在第二个if命令中,条件为${B},这是一个变量引用,会在该参数被真正传递给if命令之前替换为变量B的值。因此,if命令实际上接收到的条件为A。这也是变量条件,变量A的值为FALSE,是假值常量,因此该条件为假。

  • 在while命令中同理,变量引用仍然是最先执行的,因此最终while命令接收到的条件为NOT A,由于变量A的值是假值常量,该条件在对其取反后为真。

在条件语法中,凡是涉及<字符串|变量名称>参数形式的地方,只要条件确实是一个变量的名称,都会进行变量展开。

展开的时机

观察如下所示的例程,在第一个if命令的条件中,变量引用将 ${B}替换为了A,而A又会被作为变量展开为NOT A,因此最终条件不成立;但在第二个if命令中,条件竟变为真。

复制代码
cmake_minimum_required(VERSION 3.20)

set(A "NOT A")
set(B "A")

if(${B} STREQUAL "A")
else()
    message("\${B} STREQUAL \"A\"为假")
endif()

if("${B}" STREQUAL "A")
    message("\"\${B}\" STREQUAL \"A\"为真")
endif()

二者唯一的区别在于字符串比较的第一个参数是否是引号参数,这说明了什么呢?

事实上,在CMake条件语法中,引号参数和括号参数中的变量都不会被展开。这很实用:有时候我们不得不使用引号参数或括号参数的写法,以避免与某些变量的名称产生歧义,从而做出不正确的比较。就像该例程,如果确实想与字符串A作比较,而不是与名为A的变量值作比较,那么引号参数或括号参数就是必需的了。

另外,变量展开只适用于普通变量,缓存变量和环境变量的值在条件语法中只能通过其特定变量引用语法 C A C H E . . . 和 CACHE{...}和 CACHE...和ENV{...}来访问。

3.9 命令定义

在本节中,读者会了解到两种不同的CMake命令的定义方式,可以根据需要自由选择。

另外,本节还会花相当多的篇幅介绍如何处理调用方传递的命令参数。这是因为CMake对参数的处理相比其他编程语言来说别具一格。毕竟CMake中"一切皆命令":命令的定义也是通过命令来完成的。也就是说,命令的形式参数,同时也是定义这个命令时传递的实际参数。

3.9.1 宏定义

复制代码
macro(<宏名> [<参数1>...])
    <命令>...
endmacro()

macro命令可以将其与endmacro命令之间的命令序列定义为一个名为<宏名> 的宏(macro)。宏所包含的命令序列仅在宏被调用时执行,且执行时不会产生额外的作用域。对于宏的行为,有一个更为形象的理解:宏就是把它所包含的命令序列直接复制到它被调用的地方来执行,因此宏本身不会拥有一个作用域,而是与调用上下文共享作用域。

在CMake中,命令的名称不区分大小写,宏作为一种命令,其名称也不例外。不过,建议调用时书写的宏名与定义时的宏名保持一致。另外,CMake中的命令习惯上使用全小写、加下画线的命名法。如下所示是一个包含宏的定义和调用的例程。

复制代码
macro(my_macro a b)
    set(result "参数a: ${a}, 参数b: ${b}")
endmacro()

my_macro(x y)
message("${result}") # 输出:参数a: x, 参数b: y

MY_macro(A;B)
message("${result}") # 输出:参数a: A, 参数b: B

MY_MACRO(你 好)
message("${result}") # 输出:参数a: 你, 参数b: 好

由于宏名不区分大小写,例程中的my_macroMY_macroMY_MACRO都会调用最开始定义的my_macro宏。宏定义中通过set命令定义的result变量,确实在宏之外也能访问到。这证实了宏不会产生作用域这一点。

另外,调用宏时传递的实际参数会依次赋值给宏定义中的形式参数,于是宏内部的命令就可以通过形式参数访问到实际参数的值了。

3.9.2 函数定义

复制代码
function(<函数名> [<参数1>...])
    <命令>...
endfunction()

function命令将其与endfunction命令之间的命令序列定义为一个名为<函数名> 的函数。函数会产生一个新的作用域,因此函数内部直接使用set命令定义的变量是不能被外部访问的。为了实现这个目的,必须为set命令指定PARENT_SCOPE参数,使得变量定义到外部作用域。函数定义的例程如下所示。

复制代码
function(my_func a b)
    set(result "参数a: ${a}, 参数b: ${b}" PARENT_SCOPE)
endfunction()

my_func(x y)
message("${result}") # 输出:参数a: x, 参数b: y

MY_func(A;B)
message("${result}") # 输出:参数a: A, 参数b: B

MY_FUNC(你 好)
message("${result}") # 输出:参数a: 你, 参数b: 好

函数也是命令,其名称自然也不区分大小写。

3.9.3 参数的访问

引用形式参数

形式参数(formal parameter)就是在宏或函数定义时指定的参数。在宏或函数定义的内部命令序列中,可以通过变量引用的语法引用形式参数的名称,从而获得调用时传递过来的实际参数的值。上面的两个例程中,${a}${b}引用了形式参数。

列表或索引访问参数

除了直接引用形式参数外,CMake的宏和函数还都支持使用列表或索引来访问某一个参数:

  • ${ARGC}表示参数的个数;

  • ${ARGV}表示完整的实际参数列表,其元素为用户传递的每一个参数;

  • ${ARGN}表示无对应形式参数的实际参数列表,其元素为从第(N+1)个用户传递的参数开始的每一个参数,N为函数或宏定义中形式参数的个数;

  • ${ARGV0}${ARGV1}${ARGV2}依次表示第1个、第2个、第3个实际参数的值,以此类推。

下面举例演示了上述语法,注意例程中宏和函数的定义中都包含一个形式参数"p"。例程代码如下所示。

复制代码
macro(my_macro p)
    message("ARGC: ${ARGC}")
    message("ARGV: ${ARGV}")
    message("ARGN: ${ARGN}")
    message("ARGV0: ${ARGV0}, ARGV1: ${ARGV1}")
endmacro()

function(my_func p)
    message("ARGC: ${ARGC}")
    message("ARGV: ${ARGV}")
    message("ARGN: ${ARGN}")
    message("ARGV0: ${ARGV0}, ARGV1: ${ARGV1}")
endfunction()

my_macro(x y z)
my_func(x y z)

其输出结果如下,注意观察ARGV与ARGN的不同:

复制代码
> cd CMake-Book/src/ch003/命令定义
> cmake -P 03.列表或索引访问参数.cmake
ARGC: 3
ARGV: x;y;z
ARGN: y;z
ARGV0: x, ARGV1: y
ARGC: 3
ARGV: x;y;z
ARGN: y;z
ARGV0: x, ARGV1: y

这些访问参数的方法在宏和函数中都适用,因此输出结果一致。

3.9.4 参数的设计与解析

在前面几个例程中,引用函数或宏的参数还是非常简单直接的。实际上,设计一个用户友好的命令并不简单。我们往往会陷入思维定势,按照其他编程语言设计函数接口的思路来设计CMake的命令,而这样的设计多半不够友好。

在CMake中,命令的设计有一些约定俗成的规范,而且CMake也提供了一个简单实用的命令 cmake_parse_arguments,可以按照这个规范来解析用户传递的命令参数。我们首先来了解一下CMake命令参数的设计规范。

使用其他编程语言时,通常可以借助强大的IDE等工具,在代码导航、智能感知等功能的辅助下,轻松地了解调用的函数在什么位置定义,这个函数需要传递哪些参数,参数的类型和名称......CMake暂时没法提供这么好的"待遇"------不仅仅是因为支持CMake的工具本来就不多也不够强大,还因为CMake的命令参数过于动态和灵活,难以被静态分析。因此,对于命令的使用者来说,常常需要对照说明文档来调用CMake命令。对于程序维护者而言,阅读这样的命令调用也很令人头疼。

参数的设计规范

CMake的命令参数往往由两部分组成:一部分是用户提供的参数值;另一部分则是一些关键字,用于构成参数的结构。这些关键字的名称往往由全大写的字母组成。另外,这些关键字可以分为如下三种类型。

  • 开关选项(option):调用者可以通过指定该参数来启用某个选项。开关选项参数可以理解为一种表示布尔值的参数。

  • 单值参数关键字(one-value keyword):它的后面会且仅会跟随一个参数值,相当于键值映射,一个关键字对应一个实际参数值。

  • 多值参数关键字(multi-value keyword):它的后面可以跟随多个参数值,相当于一个接受列表的参数。这类似其他编程语言中的可变数组型参数。

cmake_parse_arguments的通用形式

cmake_parse_arguments命令正是用于解析符合这个规范的参数。该命令有两种形式:一种是在函数或宏中均可使用的通用形式,但它无法解析一些包含特殊符号的单值参数;另一种形式则不存在这一缺陷,但只支持在函数中使用。首先来了解一下它的通用形式:

复制代码
cmake_parse_arguments(
    <结果变量前缀名> 
    <开关选项关键字列表> <单值参数关键字列表> <多值参数关键字列表>
    <将被解析的参数>...
)

其通用形式既能在函数中使用,又能在宏中使用。它通过指定的三种关键字的列表解析传递给它的<将被解析的参数>,并将每一种关键字对应的参数值存放到一些结果变量中。这些结果变量的名称以<结果变量前缀名>加一个下画线"_"作为前缀,后面则是对应关键字的名称。例如,我们定义一个命令abc_f,将其参数解析到前缀名为abc的结果变量中,如下所示。

复制代码
function(abc_f)
    cmake_parse_arguments(abc "ENABLE" "VALUE" "" ${ARGN})
    message("abc_ENABLE: ${abc_ENABLE}")
    message("abc_VALUE: ${abc_VALUE}")
endfunction()

abc_f(VALUE a ENABLE)

其执行结果如下:

复制代码
> cd CMake-Book/src/ch003/命令定义
> cmake -P 解析结果命令前缀.cmake
abc_ENABLE: TRUE
abc_VALUE: a

可见,ENABLE开关选项的实际参数值被存入了结果变量abc_ENABLE中, VALUE单值参数的实际参数值存入了结果变量abc_VALUE中。

另外需要注意的是,三个<关键字列表>参数是三个代表列表类型的字符串参数。因此,如果有多个关键字属于同一个类型,应当使用分号将它们隔开,并通过引号参数或括号参数来指定它们,如下所示。

复制代码
function(abc_f)
    cmake_parse_arguments(abc "A0;A1" "B0;B1" [=[C0;C1]=] ${ARGN})
    
    # 下面是错误的示范
    # cmake_parse_arguments(abc A0 A1 B0 B1 C0 C1 ${ARGN})
    # cmake_parse_arguments(abc A0;A1 B0;B1 C0;C1 ${ARGN})

    message("A0: ${abc_A0}\nA1: ${abc_A1}")
    message("B0: ${abc_B0}\nB1: ${abc_B1}")
    message("C0: ${abc_C0}\nC1: ${abc_C1}")
endfunction()

abc_f(A0 A1 B0 a B1 b C0 x y C1 c d)

其执行结果如下:

复制代码
> cd CMake-Book/src/ch003/命令定义
> cmake -P 解析参数的关键字列表.cmake
A0: TRUE
A1: TRUE
B0: a
B1: b
C0: x;y
C1: c;d

但如果使用注释掉的两种错误写法之一替换原先的正确写法,执行结果会变为

复制代码
A0: TRUE
A1:
B0: a;B1;b;C0;x;y;C1;c;d
B1: b
C0: x;y
C1: c;d

显然,cmake_parse_arguments无从知晓三种关键字的分界,只会将第一个关键字A0作为开关选项关键字列表的唯一元素,将第二个关键字A1作为单值参数关键字列表的唯一元素,而把后面的全部关键字作为多值参数关键字的列表元素。

cmake_parse_arguments针对函数优化的形式

由于cmake_parse_arguments命令的通用形式存在一些缺陷,它还提供了如下针对函数优化的形式,可以解析包含特殊字符的参数:

复制代码
cmake_parse_arguments(PARSE_ARGV
    <N>
    <结果变量前缀名> 
    <开关选项关键字列表> <单值参数关键字列表> <多值参数关键字列表>
)

该命令形式只能在函数中使用,不支持在宏中使用。它直接对每一个函数参数进行解析,因此无须通过列表的形式传递函数参数。<N>是一个从0开始的整数,表示从函数的第几个实际参数开始解析参数,换句话说,前N个参数都是不需要关键字、需要调用者直接依次传参的参数。该形式中的其他参数与通用形式中的对应参数含义完全一致。

两个特殊的结果变量

cmake_parse_arguments除了将解析的参数存放到对应关键字的结果变量中,还会将一些未能解析的参数、没有提供值的关键字等信息存放到另外两个特殊的结果变量中:

  • <结果变量前缀名>_UNPARSED_ARGUMENTS存放所有未能解析到某一关键字中的实际参数值;

  • <结果变量前缀名>_KEYWORDS_MISSING_VALUES存放所有未提供实际参数值的关键字名称。

这两个结果变量存放的值可能有多个,因此均为列表类型。如下所示演示了二者的作用。

复制代码
function(my_copy_func)
    set(options OVERWRITE MOVE)
    set(oneValueArgs DESTINATION)
    set(multiValueArgs PATHS)

    cmake_parse_arguments(
        PARSE_ARGV 0
        my 
        "${options}" "${oneValueArgs}" "${multiValueArgs}"
    )

    message("my_UNPARSED_ARGUMENTS: ${my_UNPARSED_ARGUMENTS}")
    message("my_KEYWORDS_MISSING_VALUES: ${my_KEYWORDS_MISSING_VALUES}")

endfunction()

my_copy_func(COPY "../dir" DESTINATION PATHS)

其执行结果如下:

复制代码
> cd CMake-Book/src/ch003/命令定义
> cmake -P 两个特殊的结果变量.cmake
my_UNPARSED_ARGUMENTS: COPY;../dir
my_KEYWORDS_MISSING_VALUES: DESTINATION;PATHS
实例:复制文件命令

本例将设计一个用于复制或移动文件的命令:它可以直接将几个路径参数指定的文件复制或移动到另一个路径参数指定的目录中。这个设计需求恰好可以覆盖三种参数类型,参数如下:

  • OVERWRITE开关选项,用于确定是否要覆盖已存在的文件;

  • MOVE开关选项,用于确定是否要移动文件(默认为复制);

  • DESTINATION单值参数,用于指定复制或移动的目标目录的路径;

  • PATHS多值参数,用于指定多个要被复制或移动的文件的路径。

由于我们目前还不了解CMake中的文件操作命令,本例暂不实现它的功能,而是先来定义命令并解析这个命令的参数,如下所示。

复制代码
function(my_copy_func)
    message("ARGN: ${ARGN}")

    set(options OVERWRITE MOVE)
    set(oneValueArgs DESTINATION)
    set(multiValueArgs PATHS)

    cmake_parse_arguments(
        my 
        "${options}" "${oneValueArgs}" "${multiValueArgs}"
        ${ARGN}
    )

    message("OVERWRITE:\t${my_OVERWRITE}")
    message("MOVE:\t\t${my_MOVE}")
    message("DESTINATION:\t${my_DESTINATION}")
    message("PATHS: \t\t${my_PATHS}")
    message("---")
endfunction()

my_copy_func(DESTINATION ".." PATHS "1.txt" "2.txt" OVERWRITE)
my_copy_func(MOVE DESTINATION "../.." PATHS "3.txt" "4.txt")
my_copy_func(DESTINATION "../folder;name" PATHS 1.txt;2.txt)

本例中,将第一个参数<结果变量前缀名>设置为my,因此在后面的 message命令中访问参数值时,都是通过前缀为my_的变量获取的。

另外,本例将三个关键字列表分别定义为三个变量,在cmake_parse_arguments中通过引号参数引用这些变量来指定参数关键字。推荐采用这种方法,这样不必在引号参数中用分号分隔,以免显得拥挤。

最后一个参数是<将被解析的参数>,在这里指定为函数的参数列表${ARGN},以解析其全部参数。

执行该例程验证一下结果:

复制代码
> cd CMake-Book/src/ch003/命令定义
> cmake -P 解析参数实例.cmake
OVERWRITE:      TRUE
MOVE:           FALSE
DESTINATION:    ..
PATHS:          1.txt;2.txt
---
OVERWRITE:      FALSE
MOVE:           TRUE
DESTINATION:    ../..
PATHS:          3.txt;4.txt
---
OVERWRITE:      FALSE
MOVE:           FALSE
DESTINATION:    ../folder
PATHS:          1.txt;2.txt
---

开关选项类型的参数取值为TRUE或FALSE;单值参数的取值则为用户传递的实际参数值;多值参数的取值是一个列表,其元素为用户传递的每一个参数,且顺序保持一致。

不过,在第三次调用的输出结果中,DESTINATION单值参数的值似乎少了一部分,这是为什么?

cmake_parse_arguments命令的通用形式通过最后一个参数接受宏或函数的参数列表,从而完成解析。这个机制存在一个缺陷,那就是包含分号的参数值不能被正确解析。这是因为CMake 的列表本质上只是字符串,并不支持列表嵌套。

拿前例中的第三个调用来说,${ARGN}的值实际上是

复制代码
DESTINATION;../folder;name;PATHS;1.txt;2.txt  

原本应当整体作为一个参数值的../folder;name,此时成为了${ARGN}中的两个元素 ../foldernamecmake_parse_arguments命令并不知道这一细节,自然只能把它当作两个参数来解析了。由于DESTINATION关键字后面应当仅跟随一个单值参数值,第二个元素name就被忽略了。

然而,不论是在Windows还是Linux操作系统中,分号都是目录或者文件名中允许存在的符号。也就是说,支持解析存在分号的参数值应当是非常合理的需求。使用cmake_parse_arguments命令针对函数优化的形式可以解决这个问题,如下所示。

复制代码
function(my_copy_func)
    set(options OVERWRITE MOVE)
    set(oneValueArgs DESTINATION)
    set(multiValueArgs PATHS)

    cmake_parse_arguments(
        PARSE_ARGV 0
        my 
        "${options}" "${oneValueArgs}" "${multiValueArgs}"
    )

    message("OVERWRITE:\t${my_OVERWRITE}")
    message("MOVE:\t\t${my_MOVE}")
    message("DESTINATION:\t${my_DESTINATION}")
    message("PATHS: \t\t${my_PATHS}")
    message("---")
endfunction()

my_copy_func(DESTINATION "../folder;name" PATHS 1.txt;2.txt)

其执行结果如下:

复制代码
> cd CMake-Book/src/ch003/命令定义
> cmake -P 仅支持函数的解析形式.cmake
OVERWRITE:      FALSE
MOVE:           FALSE
DESTINATION:    ../folder;name
PATHS:          1.txt;2.txt
---

可见DESTINATION单值参数值确实被正确地解析为../folder;name了。

3.9.5 宏和函数的区别

通过前面的讲解,相信读者已经发现宏和函数的一些区别了。本小节将对这些区别进行总结,并讲解这些区别可能带来的问题。

执行上下文和作用域

宏和函数最明显的区别就是作用域。宏会与调用上下文共享作用域,因此它的执行上下文就是调用上下文。宏相当于将其定义的命令序列直接复制到调用上下文中去执行,这一过程可以称为"宏展开"。

函数拥有独立的作用域,也就会拥有独立的执行上下文。当函数被执行时,控制流会从调用上下文中转移到函数体内部。

CMake提供了一个命令return,可用于结束当前函数、当前CMake目录或文件的执行。如果在宏中调用return,并不只是宏的执行被中断,宏所在的函数、CMake目录或文件也会被中断。因此,在宏中一定要避免使用return等影响父作用域的命令。

定义外部可见的变量

定义变量的区别正是作用域的区别导致的。在宏中,可以直接通过set命令最简单的形式定义宏之外可以访问的变量,而在函数内就必须为set命令指定PARENT_SCOPE参数了。

CMake命令没有返回值这一概念,定义外部可见的变量其实是CMake函数"返回值"的形式。 CMake中的return命令并不负责返回值,仅用于结束当前执行上下文。

函数的独立作用域使得与"返回值"无关的非结果变量得以隐藏,提升了封装性,避免了变量名称的全局污染,因此更被推荐使用。一般来说,能用函数时就不要使用宏。

预定义变量

函数体中可以通过访问CMake预定义的变量,获取关于当前执行中的函数的一些信息:

  • CMAKE_CURRENT_FUNCTION,值为当前函数名称;

  • CMAKE_CURRENT_FUNCTION_LIST_DIR,值为定义当前函数的CMake程序文件所在的目录;

  • CMAKE_CURRENT_FUNCTION_LIST_FILE,值为定义当前函数的CMake程序文件的完整路径;

  • CMAKE_CURRENT_FUNCTION_LIST_LINE,值为当前函数在CMake程序文件中定义时对应的代码行行号。

参数访问

宏和函数均可通过引用形式参数、参数列表或索引等方式来访问实际参数。虽然二者表面上几乎完全一样,但实际上却大有不同。在函数中,包括形式参数、ARGC、ARGV、ARGN等都是真正的CMake变量,且定义在当前函数的作用域内。

但在宏中,由于没有独立的作用域,这些用于访问参数的符号并非真正的变量,否则会污染调用上下文。CMake在展开宏时,会对宏的命令序列进行预处理,对引用这些符号的地方直接进行文本替换。这就带来一些问题:在宏中,不能直接将这些访问参数的符号作为变量条件用于条件语法,也不能利用变量嵌套引用语法访问这些符号。如下所示的例程演示了这两种情况下宏和函数的区别。

复制代码
macro(my_macro p)
    message("-- my_macro --")

    if(p)
        message("p为真")
    endif()

    set(i 1)
    message("ARGV i: ${ARGV${i}}")
endmacro()

function(my_func p)
    message("-- my_func --")

    if(p)
        message("p为真")
    endif()

    set(i 1)
    message("ARGV i: ${ARGV${i}}")
endfunction(my_func)

my_macro(ON x)
my_func(ON x)

其执行结果如下:

复制代码
> cd CMake-Book/src/ch003/命令定义
> cmake -P 宏与函数参数的区别.cmake
-- my_macro --
ARGV i:
-- my_func --
p为真
ARGV i: x

在调用宏后,程序并不能正确输出用户想要的结果,而函数则没有任何问题。

3.10 小结

本章首先介绍了三种CMake程序:目录程序、脚本程序、模块程序。它们分别用于组织项目构建、编写通用脚本逻辑,以及实现代码复用。然后,本章介绍了CMake的基础语法,包括注释、命令调用、命令参数、变量、控制结构、自定义命令等各种语法结构。

掌握语法是编写出正确的CMake程序的前提,这就像学会了怎样去搭积木。但这还不够,总要有最基本的积木单元,才能搭建出精美的作品。第4章将带领大家认识CMake提供的常用命令,这样就可以了解CMake中有哪些五花八门的"积木"可供使用了。

另外,由于本章仍有极少数内容涉及了与构建相关的概念,建议读者在读完第6章后,再回顾一下本章内容。

相关推荐
刀法如飞7 天前
探索MVC、MVP、MVVM和DDD架构在不同编程语言中的实现差异
架构·mvc·软件构建
Anima.AI16 天前
AI代理到底怎么玩?
人工智能·python·深度学习·语言模型·机器人·软件构建
hope_wisdom17 天前
实战设计模式之解释器模式
设计模式·解释器模式·软件工程·软件构建·架构设计
Python数据分析与机器学习1 个月前
《基于锂离子电池放电时间常数的自动化电量评估系统设计》k开题报告
运维·性能优化·自动化·软件工程·软件构建·个人开发
思茂信息1 个月前
CST直角反射器 --- A求解器, 远场源, 距离像, 逆ChirpZ变换(ICZT)
开发语言·javascript·人工智能·算法·ai·软件工程·软件构建
butteringing1 个月前
BuildFarm Worker 简要分析
linux·软件构建·bazel·re api
hope_wisdom2 个月前
实战设计模式之外观模式
设计模式·架构·软件工程·软件构建·外观模式·架构设计
思茂信息2 个月前
CST的TLM算法仿真5G毫米波阵列天线及手机
网络·人工智能·5g·智能手机·软件工程·软件构建
星糖曙光2 个月前
基于HarmonyOS 3.0的智能理财APP开发方案
经验分享·华为·软件工程·软件构建·harmonyos