第8章:函数和宏
目前为止,CMake的语法已经开始看起来很像一种编程语言。它支持变量、if-then-else逻辑、循环和文件包含等。CMake还支持函数和宏等常见编程概念,这一点也不奇怪。就像在其他编程语言中一样,函数和宏是项目和开发人员用于扩展CMake功能和封装重复任务的主要机制。其允许开发者定义可重用的CMake代码块,就像调用内置CMake命令一样调用这些代码块。它们也是CMake模块系统的基石(第11章中单独介绍)。
8.1. 基本概念
CMake中的函数和宏与C/C++中的同名函数和宏有非常相似的特征。函数引入了一个新的作用域,函数参数变成了可以在函数体中访问的变量。另一方面,宏有效地将其主体粘贴到调用点,并且宏参数可替换为简单的字符串。这些行为与#define
在C/C++的工作方式几乎相同。CMake函数或宏定义如下:
当定义函数或宏后,调用的方式与任何其他CMake命令完全相同。然后,函数或宏的主体在调用点执行。例如:
如上所示,name参数定义了用于调用函数或宏的名称,它应该只包含字母、数字和下划线。名称是大小写不敏感的,因此大写/小写的约定更多的是样式问题(CMake文档遵循的约定是命令名称都是小写的,单词由下划线分隔)。CMake的早期版本要求名称作为endfunction()或endmacro()的参数重复,但新项目应该避免这样做。
8.2. 参数处理要点
函数和宏的参数处理是相同的,除了一个非常重要的区别。对于函数来说,每个参数都是一个CMake变量,例如:可以作为变量在if()语句中进行判断。相比之下,宏参数是字符串替换项,因此无论使用什么作为宏调用的参数,基本上都会粘贴到宏主体中的参数中。如果在if()语句中使用宏参数,那么参数将视为字符串而不是变量。下面的例子和输出说明了其中差异:
除了这种差异外,函数和宏在参数处理方面都支持相同的特性。函数定义的每个参数都作为代表参数的(区分大小写的)标签。对于函数,该标签的作用类似于变量,而对于宏类似于字符串替换。该参数的值可以在函数或宏体中使用通常的变量符号访问。
对func()和macr()的调用会有相同的结果输出:
除了命名参数之外,函数和宏还有一组自定义的变量,允许处理命名参数之外的参数或代替命名参数:
ARGC
传递给函数的参数总数。它计算已命名参数和给定的其他未命名参数的数量。
ARGV
这是一个列表变量,包含传递给函数的每个参数,包括指定的参数和未命名参数。
ARGN
和ARGV类似,只包含命名参数以外的参数(即可选的、未命名参数)。
除此之外,每个单独的参数都可以用一个ARG#
形式的名称来引用,其中#是参数的编号(例如:ARG1、ARG2等)。其包括命名参数,所以第一个命名参数也可以通过引用ARG1来获取。
使用ARG…变量包括支持可选参数和实现可以处理任意数量项的命令。考虑一个定义可执行目标的函数,将该目标链接到某个库,并为其定义测试用例。在编写测试用例时经常会遇到这样的函数(将在第24章中介绍)。与其为每个测试用例重复这些步骤,函数允许只定义一次步骤,然后每个测试用例都变成一个简单的单行调用。
上面的例子说明了ARGN变量的特性,允许函数或宏接受数量可变的参数,但必须指定一组命名参数。但是,有一个特定的情况需要注意,哪些情况会导致意外的行为。因为宏的参数作为字符串替换而不是变量,如果他们在一个地方使用ARGN引用一个变量名,变量将在宏观的范围有效。下面的例子说明了这种情况:
输出如下:
LISTS关键字与foreach()一起使用时,必须给定一个变量名,但宏提供的ARGN不是一个变量名。当从另一个函数内部调用宏时,宏最终使用该封闭函数的ARGN变量,而不是宏本身的ARGN。将宏体的内容直接粘贴到调用它的函数中时(这是CMake会做的),情况就变得明了:
这种情况下,可以考虑将宏改为函数,或者如果必须保持为宏,则避免将参数视为变量。对于上面的示例,可以将dangerous()的实现改为使用foreach( IN ITEMS ${ARGN})的函数实现。
8.3. 关键字参数
上一节演示了如何使用ARG…变量来处理一组可变的参数。对于只需要一组变量或可选参数的简单情况,该功能就足够了,但是如果必须支持多个可选或可选参数集,处理就会变得相当繁琐。此外,与支持关键字参数和灵活参数排序的CMake内置命令相比,上面处理参数的方式相对比较死板。看一下target_link_libraries()命令:
targetName作为第一个参数,此后调用者可以以任何顺序提供任意数量的PRIVATE、PUBLIC或INTERFACE部分,每个部分允许包含任意数量的项。用户定义的函数和宏也可以通过使用cmake_parse_arguments()命令支持同样的灵活性:
cmake_parse_arguments()命令过去是由CMakeParseArguments模块提供的,但在CMake 3.5中成为了一个内置命令。include(CMakeParseArguments)行在CMake 3.5及以后版本中什么也不做,而在CMake的早期版本中,它将定义cmake_parse_arguments()命令(参见第11章了解更多关于include()的用法)。上面的使用方式,是为了确保无论使用的是什么CMake版本,命令都是可用的。
cmake_parse_arguments()接受argsToParse提供的参数,并根据指定的关键字集处理它们。通常,argsToParse指定为${ARGN},传递给封闭函数或宏的一组未命名参数。每个关键字参数都是该函数或宏支持的关键字名称的列表,因此都应该用引号括起来,以确保解析的正确性。
noValueKeywords定义为独立的关键字参数,其作用类似于布尔开关。singleValueKeywords在使用时关键字之后需要一个额外的参数,而multiValueKeywords在关键字之后需要零个或多个额外的参数。虽然不是必需的,但惯例是关键字都是大写的,如果需要,单词之间用下划线分隔。但是要注意,关键字不应该太长,否则会很麻烦。
cmake_parse_arguments()返回时,对于每个关键字,将有一个对应的变量可用,其名称由指定的前缀、下划线和关键字名称组成。例如,如果前缀为ARG,则对应于名为FOO的关键字的变量将是ARG_FOO。如果某个关键字没有出现在argsToParse中,则对应的变量将为空。下面的例子展示了三种不同的关键字类型是如何定义和处理的:
输出如下:
与使用命名参数和/或ARG…变量的基本参数处理相比,cmake_parse_arguments()有很多优点。
基于关键字,调用的方式提高了可读性,因为参数本质上变成了解释文档。阅读调用点的其他开发人员通常不需要查看函数实现或文档来理解每个参数的含义。
调用者可以选择参数的顺序。
调用者可以省略那些不需要的参数。
由于每个支持的关键字都必须传递给cmake_parse_arguments(),并且在函数的顶部调用它,因此函数支持什么参数通常就非常清楚了。
基于关键字的参数的解析是由cmake_parse_arguments()命令处理,而不是通过临时的、手动编码的解析器,因此消除了参数解析的错误。
8.4. 作用域
函数和宏之间的一个根本区别是,函数引入了新变量范围,而宏没有。函数内定义或修改的变量对函数外的同名变量没有影响。就变量而言,函数本质上是一个沙箱,这与宏不同,宏与调用者共享相同的变量作用域。注意,函数不引入新的策略范围(请参阅12.3节)。
与C/C++不同,CMake函数和宏不支持直接返回值。此外,由于函数引入了变量作用域,因此似乎没有简单的方法将信息传递回调用者,但事实并非如此。与7.1.2节中的add_subdirectory()相同,“Scope”也可以用于函数。set()命令的PARENT_SCOPE关键字可以用于修改调用作用域中的变量,而不是函数中的局部变量。虽然这与从函数返回值不同,但它确实允许将一个值(或多个值)传递回调用者。
一个常见的方法是允许变量名作为函数参数,调用者传递的参数仍在控制变量,函数结果的名称。这是cmake_parse_arguments()的使用方法,其前缀参数确定的所有变量名称的前缀需要在调用者的控制范围内。下面的例子演示了如何实现:
输出如下:
另一种方法是让函数记录设置的变量,而不是让调用者指定。这不太可取,因为它降低了函数的灵活性,并可能导致变量名冲突。最好使用上述方法让调用者控制正在设置或修改的变量名。
宏的处理方式与函数相同,通过将变量作为参数传入来指定要设置的变量的名称。唯一的区别是PARENT_SCOPE关键字不应该在宏中使用,因为它会修改了调用者作用域中的变量。宏会影响每次set()调用的调用范围,而函数只会在PARENT_SCOPE显式给定给set()时影响使用范围。
第7.3节中,我们讨论了return()语句作为提前结束文件或函数中的处理的一种方法。return()不返回值,它只返回父作用域。如果在函数中调用return(),处理程序立即返回给调用者,即跳过函数的其余部分。另一方面,return()在宏中的行为是非常不同的。因为宏没有引入新的作用域,所以return()语句的行为取决于调用宏的位置。在这种情况下,宏的任何return()语句实际上都是从宏的作用域返回,而不是从宏本身返回。考虑下面的例子:
输出如下:
为什么第二个函数没有打印,原因就是在宏中使用了return:
对于return()语句导致离开函数的原因已经清楚得多了,即使它最初是从宏内部调用的。由于宏不创建自己的作用域,return()语句的结果通常不是预期的。
8.5. 命令重载
当function()或macro()用来定义新命令时,如果命令名称已经存在,那么没有文档记录的CMake行为就是使用相同的名称来使用旧命令,除了前面有一个下划线。无论旧名称用于内建命令还是自定义函数或宏,这都适用。这种行为的开发人员有时会试图利用它,试图包装现有的命令,像这样:
如果该命令仅这样覆盖一次,那么似乎是工作的,但是如果再次覆盖,那么原始命令将不可再访问。前一个命令的前缀“保存”只适用于当前名称,不会递归应用于以前的所有重写。这有可能导致无限递归,如下面的例子所示:
人们会天真地期望输出如下:
但是,第一个实现从未被调用,因为第二个实现最终在一个无限循环中调用自己。CMake处理过程中,会发生以下情况:
printme的第一个创建并作为同名的可用命令。以前不存在该名称的命令,因此不需要其他操作。
遇到printme的第二个实现。CMake通过该名称找到一个现有命令,因此将名称定义为 _printme以指向旧命令,并将printme设置为指向新定义。
遇到了printme的第三个实现。CMake发现与现有的命令的名称重复,重新定义了名字_printme指向旧的命令(这是第二个实现)和printme设置为指向新定义。
当调用printme()时,执行进入第三个实现,该实现调用_printme()
。这进入第二个实现,它也调用_printme()
,但是_printme()
再次指向第二个实现和无限递归结果。执行永远不会到达第一个实现。
一般来说,只要函数或宏不像上面讨论的那样试图调用前面的实现,就可以重写它。项目应该简单地假设新实现会取代旧实现,旧实现认为是不再可用的。
8.6. 推荐
函数和宏是在整个项目中重用同一段CMake代码的方法。通常,最好使用函数而不是宏,因为在函数中使用新变量的作用域可以更好地隔离该函数对调用作用域的影响。宏一般只应该在宏主体的内容确实需要在调用者的范围内执行的情况下使用。要避免意外行为,还应避免从宏内部调用return()。
除了非常简单的函数或宏之外,强烈建议使用cmake_parse_arguments()提供的基于关键字参数的处理。这将带来更好的可用性和增强代码的健壮性(例如,不会混淆参数)。还允许在未来更容易地扩展函数,因为不依赖参数的顺序,或者始终提供所有参数(即使不相关)。
与在源中分布函数和宏不同,一种常见的做法是指定一个特定的目录(通常在项目的顶层之下),其中可以收集各种XXX.cmake文件。该目录就像一个即用功能的目录,可以方便地从项目中的任何地方访问。每个文件都可以提供适当的函数、宏、变量和其他特性。使用.cmake文件名后缀允许include()命令查找作为模块的文件,这个主题将在第11章中详细介绍。它还允许IDE工具识别文件类型并用CMake语法高亮显示。
不要定义或调用名称以单个下划线开头的函数或宏。特别是,当函数或宏重新定义现有命令时,不要依赖于未文档化的行为,即命令的旧实现通过这样的名称可用。一旦一个命令重写超过一次,那原始实现就不可再访问。这个未归档的行为甚至可能在未来的CMake版本中删除,所以不应该使用。类似地,不要覆盖任何内置CMake命令,将它们视为禁止范围,这样项目将能够确定内置命令的行为符合官方文档,并且不会有机会使原始命令变得不可访问。
Last updated
Was this helpful?