第十章:生成器表达式

Posted by lili on

在运行CMake时,开发人员往往将其视为一个单一步骤,该步骤涉及读取项目的CMakeLists.txt文件并生成相关的特定于生成器的项目文件集(例如Visual Studio解决方案和项目文件、Xcode项目、Unix Makefiles或Ninja输入文件)。然而,实际上涉及到两个完全不同的步骤。运行CMake时,输出日志的末尾通常如下所示:

-- Configuring done
-- Generating done
-- Build files have been written to: /some/path/build

当调用CMake时,首先读取并处理源代码树顶部的CMakeLists.txt文件,包括它引入的任何其他文件。随着执行命令、函数等,项目的内部表示形式被创建。这称为配置步骤。在此阶段产生了控制台日志的大部分输出,包括来自message()命令的任何内容。在配置步骤结束时,将打印 – Configuring done 的消息。

一旦CMake完成读取和处理CMakeLists.txt文件,它就执行生成步骤。这是使用在配置步骤中建立的内部表示形式创建构建工具的项目文件的地方。大多数情况下,开发人员倾向于忽略生成步骤,只把它看作是配置的最终结果。控制台日志几乎总是在配置步骤完成后立即显示 – Generating done 消息,因此这是可以理解的。但在某些情况下,了解分为两个不同阶段的区分尤为重要。

考虑一个使用多配置CMake生成器(如Xcode、Visual Studio或Ninja Multi-Config)处理的项目。当读取CMakeLists.txt文件时,CMake不知道目标将为哪个配置构建。这是一个多配置设置,因此有多个选择(例如Debug、Release等)。开发人员在构建时选择配置,这是在CMake完成之后的事情。如果CMakeLists.txt文件想要执行像将文件复制到给定目标的最终可执行文件相同目录的操作,这似乎会引发问题,因为该目录的位置取决于正在构建哪个配置。需要一个占位符告诉CMake:“对于正在构建的任何配置,请使用最终可执行文件的目录”。这是生成器表达式提供的功能的一个典型示例。它们提供了一种编码某些逻辑的方式,该逻辑在配置时不进行评估,而是在生成阶段写入项目文件时延迟评估。它们可用于执行条件逻辑,输出提供有关构建的各个方面的信息的字符串,如目录、名称、平台详细信息等。它们甚至可以用于根据是进行构建还是安装来提供不同的内容。

生成器表达式不能在所有地方使用,但在许多地方都得到支持。在CMake参考文档中,如果特定命令或属性支持生成器表达式,文档将提到它。支持生成器表达式的属性集随时间扩展,某些CMake版本还扩展了支持的表达式集。项目应确认对于它们所需的最低CMake版本,正在修改的属性确实支持所使用的生成器表达式。

10.1. 简单的布尔逻辑

生成器表达式使用 $<…> 语法进行指定,尖括号之间的内容可以采用几种不同的形式。正如将很快明确的那样,一个基本功能是内容的条件包含。用于此目的的最基本的生成器表达式如下:

$<1:...>
$<0:...>

对于 \$<1:…>,表达式的结果将是 … 部分,而对于 $<0:…>,… 部分将被忽略,表达式的结果将是空字符串。这基本上是真和假的条件表达式,但与变量不同,真和假的概念只允许这两个特定的值。在条件表达式中,除了0或1之外的任何值都将被CMake拒绝,并导致致命错误。还可以使用另一个生成器表达式使布尔表达式的评估更加灵活,并确保内容评估为0或1:

$<BOOL:...>

这将以与 if() 命令评估布尔常量相同的方式评估 … 内容,因此它理解所有通常的特殊字符串,如 OFF、NO、FALSE 等。一个非常常见的模式是将其用于包装预期保存布尔值的变量的评估,但该变量可能不仅限于0或1(请参见稍后表格中的示例)。 逻辑运算也得到支持:

$<AND:expr[,expr...]>
$<OR:expr[,expr...]>
$<NOT:expr>

每个 expr 预期评估为1或0。AND 和 OR 表达式可以接受任意数量的逗号分隔参数,并提供相应的逻辑结果,而 NOT 仅接受单个表达式并将产生其参数的否定。由于 AND、OR 和 NOT 要求它们的表达式只能评估为0或1,请考虑将这些表达式包装在 \$<BOOL:…> 中,以强制更宽容的逻辑来考虑什么是真或假的表达式。 在CMake 3.8及更高版本中,也可以使用专用的 \$<IF:…> 表达式非常方便地表达 if-then-else 逻辑:

$<IF:expr,val1,val0>

像往常一样,expr 必须评估为1或0。如果 expr 评估为1,则结果是 val1,如果 expr 评估为0,则结果是 val0。在CMake 3.8之前,等效的逻辑将不得不以以下更冗长的方式表达,这需要两次给出表达式:

$<expr:val1>$<$<NOT:expr>:val0>

生成器表达式可以嵌套,允许构建任意复杂度的表达式。上面的示例显示了一个嵌套条件,但生成器表达式的任何部分都可以嵌套。以下示例演示了迄今为止讨论的功能:

Expression Result
$<1:foo> foo
$<0:foo>  
$<true:foo> Error, not a 1 or 0
$<$<BOOL:true>:foo> foo
$<$<NOT:0>:foo> foo
$<$<NOT:1>:foo>  
$<$<NOT:true>:foo> Error, NOT requires a 1 or 0
$<$<AND:1,0>:foo>  
$<$<OR:1,0>:foo> foo
$<1:$<$<BOOL:false>:foo>>  
$<IF:$<BOOL:${foo}>,yes,no> Result will be yes or no depending on ${foo}

就像对于if()命令一样,CMake还提供了对生成器表达式中的字符串、数字和版本进行测试的支持,尽管语法略有不同。以下所有条件如果满足则评估为1,否则为0。

$<STREQUAL:string1,string2>
$<EQUAL:number1,number2>
$<VERSION_EQUAL:version1,version2>
$<VERSION_GREATER:version1,version2>
$<VERSION_LESS:version1,version2>

另一个非常有用的条件表达式是测试构建类型:

$<CONFIG:arg>

如果arg对应于实际正在构建的构建类型,则此表达式将评估为1,对于所有其他构建类型则为0。这常用于仅为调试构建提供编译器标志或选择不同的实现以适应不同的构建类型。例如:

add_executable(MyApp src1.cpp src2.cpp)
# 在CMake 3.8之前
target_link_libraries(MyApp PRIVATE
    $<$<CONFIG:Debug>:CheckedAlgo>
    $<$<NOT:$<CONFIG:Debug>>:FastAlgo>
)
# CMake 3.8或更高版本允许更简洁的形式
target_link_libraries(MyApp PRIVATE $<IF:$<CONFIG:Debug>,CheckedAlgo,FastAlgo>)

上述代码将在Debug构建中将可执行文件链接到CheckedAlgo库,在所有其他构建类型中链接到FastAlgo库。\$CONFIG:…生成器表达式是提供此类功能的唯一健壮方式,适用于所有CMake项目生成器,包括多配置生成器如Xcode、Visual Studio或Ninja Multi-Config。关于这个特定主题的更详细信息在第14.2节“常见错误”中有详细介绍。 CMake还提供了基于平台和编译器详细信息、CMake策略设置等的更多条件测试。开发人员应查阅CMake参考文档以获取支持的完整条件表达式集。

10.2. 目标详细信息

生成器表达式的另一个常见用途是提供有关目标的信息。可以使用以下两种形式之一获取目标的任何属性:

$<TARGET_PROPERTY:target,property>
$<TARGET_PROPERTY:property>

第一种形式提供了指定目标的命名属性的值,而第二种形式将从使用生成器表达式的目标中检索属性。 虽然TARGET_PROPERTY是一种非常灵活的表达式类型,但它并不总是获取有关目标信息的最佳方式。例如,CMake还提供了其他表达式,提供了有关目标构建二进制文件的目录和文件名的详细信息。这些更直接的表达式负责提取出一些属性的部分或基于原始属性计算值。其中最通用的是TARGET_FILE一组生成器表达式:

TARGET_FILE

这将产生目标二进制文件的绝对路径和文件名,包括平台相关的文件前缀和后缀(例如.exe,.dylib)。对于在文件名中通常包含版本详细信息的共享库的基于Unix的平台,这些信息也将被包括在内。

TARGET_FILE_NAME

与TARGET_FILE相同,但不包括路径(即仅提供文件名部分)。

TARGET_FILE_DIR

与TARGET_FILE相同,但不包括文件名。这是获取最终可执行文件或库所构建的目录的最健壮的方式。在使用多配置生成器(如Xcode、Visual Studio或Ninja Multi-Config)时,其值在不同的构建配置下是不同的。

上述三个TARGET_FILE表达式在定义用于在构建后步骤中复制文件的自定义构建规则时特别有用(参见第19.2节,“向现有目标添加构建步骤”)。除了TARGET_FILE表达式之外,CMake还提供了一些特定于库的表达式,其作用类似,只是它们在处理文件名前缀和/或后缀详细信息时略有不同。这些表达式的名称以TARGET_LINKER_FILE和TARGET_SONAME_FILE开头,通常不像TARGET_FILE表达式那样频繁使用。

CMake 3.15增加了对与目标相关的文件名提取基本名称、前缀和后缀的附加生成器表达式的支持。需要这些更精细的详细信息的项目应查阅CMake文档,但这样的需求应该是罕见的。

支持Windows平台的项目还可以获取有关给定目标的PDB文件的详细信息。同样,这些信息主要用于自定义构建任务。以TARGET_PDB_FILE开头的表达式遵循与TARGET_PROPERTY相似的模式,为在使用生成器表达式的目标上使用的PDB文件提供路径和文件名详细信息。

还有一个与目标相关的生成器表达式值得特别提及。CMake允许将库目标定义为对象库,这意味着它不是通常意义上的库,它只是CMake与目标关联的一组对象文件,实际上并不会创建最终的库文件。在使用CMake 3.11或更早版本时,无法链接到对象库。相反,必须以与添加源文件相同的方式将对象库添加到目标中。然后,CMake在链接阶段将这些对象文件包含在与编译该目标的源文件创建的对象文件类似的形式中。这是使用\$<TARGET_OBJECTS:…>生成器表达式完成的,该表达式列出了适合add_executable()或add_library()使用的对象文件,如下例所示:

# 定义一个对象库
add_library(ObjLib OBJECT src1.cpp src2.cpp)
# 定义两个可执行文件,每个文件都有自己的源文件
# 以及ObjLib的对象文件
add_executable(App1 app1.cpp $<TARGET_OBJECTS:ObjLib>)
add_executable(App2 app2.cpp $<TARGET_OBJECTS:ObjLib>)

在上述示例中,没有为ObjLib创建单独的库,但src1.cpp和src2.cpp源文件仍然只编译一次。对于某些构建来说,这可能更方便,因为它可以避免创建静态库的构建时间成本或为动态库的符号解析带来的运行时成本,但仍然避免了多次编译相同的源文件。

从CMake 3.12开始,可以直接链接到对象库而不是使用上述的\$<TARGET_OBJECTS:…>。此类链接存在一些限制,详细信息见第18.2节“库”。

10.3. 通用信息

生成器表达式不仅可以提供有关目标的信息,还可以获取有关正在使用的编译器、为其构建目标的平台、构建配置的名称等的信息。这些表达式通常用于更高级的情况,例如处理自定义编译器或解决特定于特定编译器或工具链的问题。这些表达式也容易被滥用,因为它们可能看起来提供了一种构造路径的方式,而这些路径本可以通过更健壮的方法获取,如使用TARGET_FILE表达式或其他CMake功能。开发人员在依赖更一般的信息生成器表达式来解决问题时应谨慎考虑。尽管如此,其中一些表达式确实具有有效的用途。以下是一些较常见的作为进一步阅读的起点:

\$<CONFIG>

评估为构建类型。与CMAKE_BUILD_TYPE变量相比,应首选使用此变量,因为该变量不在像Xcode、Visual Studio或Ninja Multi-Config这样的多配置项目生成器上使用。CMake的早期版本使用了现在已弃用的\$<CONFIGURATION>表达式,但现在的项目应该只使用\$<CONFIG>。

\$<PLATFORM_ID>

标识为其构建目标的平台。在交叉编译情况下,尤其是在构建可能支持多个平台(例如设备和模拟器构建)的情况下,这可能很有用。此生成器表达式与CMAKE_SYSTEM_NAME变量密切相关,项目应考虑在特定情况下是否使用该变量会更简单。

\$<C_COMPILER_VERSION>,\$<CXX_COMPILER_VERSION> 在某些情况下,仅在编译器版本较旧或较新时才添加内容可能很有用。通过\$<VERSION_xxx:…>生成器表达式可以实现这一点。例如,要在C++编译器版本低于4.2.0时生成字符串OLDCXX,可以使用以下表达式:

$<$<VERSION_LESS:$<CXX_COMPILER_VERSION>,4.2.0>:OLDCXX>

这些表达式通常仅在已知编译器类型并且项目需要以某种特殊方式处理编译器的特定行为的情况下使用。在特定情况下,这可能是一种有用的技术,但如果项目过于依赖此类表达式,可能会降低项目的可移植性。

10.4. 路径表达式

CMake 3.24增加了对两个处理路径的表达式的支持:

\$<PATH_EQUAL:path1,path2>

这是\$<STREQUAL:string1,string2>的路径等效项。当预期要比较的两个事物是路径而不是任意字符串时,\$<PATH_EQUAL:…>更清晰地表达了这一期望。它的优势在于逐个比较路径的每个部分,有效地将多个连续的目录分隔符合并为单个分隔符(预期使用正斜杠)。路径可以用\$PATH:CMAKE_PATH,…包装,以确保它们具有所需的形式:

$<PATH_EQUAL:$<PATH:CMAKE_PATH,path1>,$<PATH:CMAKE_PATH,path2>>

\$<PATH:subcommand,…>

这本质上是生成器表达式等效于配置时的cmake_path()命令(在第20.1.1节“cmake_path()”中详细介绍)。支持相同的一组操作,尽管语法有一些差异。在官方CMake文档的生成器表达式手册中可以找到支持的子命令的完整列表。以下是一些示例,以展示可能的功能。

$PATH:IS_ABSOLUTE,somePath
$PATH:IS_PREFIX,NORMALIZE,prefix,fullPath
$PATH:GET_FILENAME:somePath

10.5. 实用表达式

一些生成器表达式修改内容或替换特殊字符。以下是一些更常用或容易被误解的表达式。

\$<COMMA>

在某些情况下,可能需要在生成器表达式中包含逗号,但这样做可能会干扰生成器表达式语法本身。为了解决这种情况,可以使用\$而不是将其解析为表达式语法的一部分。

\$<SEMICOLON> 类似于上面的情况,嵌入在生成器表达式中的分号可能会被CMake解析为命令参数分隔符。通过使用\$,参数解析将不会看到原始的分号字符,因此不会发生参数拆分。

\$<LOWER_CASE:…>,$<UPPER_CASE:…>

可以使用这些表达式将任何内容转换为小写或大写。在执行字符串比较之前进行这样的转换可能特别有用。例如:

$<STREQUAL:$<UPPER_CASE:${someVar}>,FOOBAR>

\$<JOIN:list,…>

这个生成器表达式的效果是用…内容替换列表中的每个分号,有效地在每个项目之间用…连接列表项。请注意,此生成器表达式不应在不引用整个表达式的情况下使用。这可以防止列表中的分号在使用生成器表达式的命令中充当参数分隔符(有关这个特定主题的更深入讨论,请参见第8.8节“参数处理问题”)。引用也是为了防止…部分中的任何空格充当参数分隔符。以下是此生成器表达式被错误使用的一个非常常见的示例:

set(dirs here there)
# dirs = here;there
# ERROR: space and ; treated as argument separators
set_target_properties(Foo PROPERTIES
    CUSTOM_INC -I$<JOIN:${dirs}, -I>
)
# OK: Whole generator expression is quoted
set_target_properties(Foo PROPERTIES
    CUSTOM_INC "-I$<JOIN:${dirs}, -I>"
)
$<GENEX_EVAL:...>,$<TARGET_GENEX_EVAL:target,...>

这两个生成器表达式是在CMake 3.13中引入的。在某些更高级的情况下,可能出现生成器表达式的评估结果本身包含生成器表达式的情况。一个例子是使用\$<TARGET_PROPERTY:…>评估目标属性时,检索的属性值包含另一个生成器表达式。通常,检索的属性不会进一步评估以展开其中包含的任何生成器表达式,但可以使用\$<GENEX_EVAL:…>或\$<TARGET_GENEX_EVAL:…>来强制展开,如下所示:

# 在当前上下文中评估
$<GENEX_EVAL:$<TARGET_PROPERTY:MY_PROP>>
# 为特定目标“foo”评估
$<TARGET_GENEX_EVAL:foo,$<TARGET_PROPERTY:foo,MY_PROP>>

项目很少需要使用这两个生成器表达式。上面的示例展示了它们被添加到CMake中的主要原因,但在大多数项目中,这种情况通常不会发生。

10.6. 推荐做法

与其他功能相比,生成器表达式是CMake的一个较新的功能。因此,关于CMake的在线和其他地方的大部分材料通常不使用它们。这是不幸的,因为生成器表达式通常比较旧的方法更健壮,提供更多的通用性。有一些常见的例子,良好意图的指导会导致仅适用于支持的项目生成器或平台子集的逻辑,而使用适当的生成器表达式则不会有此类限制。这在涉及尝试针对不同的构建类型执行不同操作的项目逻辑方面尤为真实。因此,开发人员应该熟悉生成器表达式提供的功能。上面提到的这些表达式只是CMake支持的一部分,但它们为涵盖大多数开发人员可能面临的情况提供了一个良好的基础。

谨慎使用生成器表达式可以使CMakeLists.txt文件更加简洁。例如,根据构建类型有条件地包含源文件可以相对简洁地完成,就像前面介绍的\$<CONFIG:…>示例一样。这样的用法减少了if-then-else逻辑的数量,提高了可读性,只要生成器表达式不太复杂。生成器表达式也非常适合处理根据目标或构建类型而变化的内容。在CMake中,没有其他机制提供与处理可能影响特定目标属性所需的最终内容相关的灵活性和通用性。

相反,很容易过分追求,并试图将一切都变成生成器表达式。这可能导致过于复杂的表达式,最终会混淆逻辑并且难以调试(第13.5节“调试生成器表达式”提供了一些帮助的技术)。与以往一样,开发人员应该优先选择清晰而不是巧妙,这在生成器表达式方面尤其如此。首先考虑CMake是否已经提供了专门的设施来实现相同的结果。各种CMake模块提供了更具针对性的功能,旨在针对特定的第三方软件包或执行某些特定任务。还有各种变量和属性,可以简化或替代生成器表达式的需求。仅仅花几分钟查阅CMake参考文档就可以节省许多小时,避免构建实际上并不需要的复杂生成器表达式。