第九章:生成器表达式——构建系统的“动态语法”

说实话,我第一次接触生成器表达式时,心里是有点抵触的。这玩意儿看起来像乱码,$<...> 这种写法,怎么看怎么像某种 shell 脚本的变种。但后来我在一个大型跨平台项目里被折腾得够呛——同一个 CMakeLists.txt 要在 Windows、Linux、macOS 上分别处理不同的编译选项、不同的路径格式、不同的库依赖。那时候我才意识到,没有生成器表达式,你只能写一堆 if-else 来硬编码,维护起来简直想哭。

生成器表达式,说白了就是 CMake 在生成构建系统(比如 Makefile 或 Visual Studio 工程)时才求值的“动态代码”。它不像普通变量那样在 configure 阶段就确定值,而是等到生成阶段才展开。这意味着你可以根据不同的构建配置、不同的目标属性,动态地生成不同的内容。

核心概念:生成器表达式以 $<...> 的形式出现,内部可以嵌套条件、路径操作、编译特性查询等。它只能用在支持生成器表达式的上下文中,比如 target_link_librariestarget_include_directoriesadd_custom_command 等命令的参数里。

9.1 生成器表达式基础

先看最简单的形式——布尔逻辑表达式。CMake 支持 $<BOOL:value> 这种写法,如果 value 为真(非空、非 0、非 OFF、非 NO 等),则展开为 1,否则为 0。但更常用的是条件表达式:

# 如果构建类型是 Debug,则添加 -g 标志
target_compile_options(myapp PRIVATE
    $<$<CONFIG:Debug>:-g>
)

# 如果目标平台是 Windows,则链接 ws2_32 库
target_link_libraries(myapp PRIVATE
    $<$<PLATFORM_ID:Windows>:ws2_32>
)

这里有个嵌套结构:外层 $<condition:value>,如果 condition 为真,则展开为 value,否则为空。condition 本身又是一个生成器表达式,比如 $<CONFIG:Debug> 在 Debug 配置下为真。

个人习惯:我一般把这种嵌套写法拆成多行,方便阅读。比如:

target_compile_options(myapp PRIVATE
    $<$<CONFIG:Debug>:
        -g -O0
    >
)

虽然 CMake 不要求换行,但这样一眼就能看出条件分支。

9.2 条件表达式

条件表达式是生成器表达式的核心。除了 CONFIGPLATFORM_ID,还有这些常用的:

表达式 说明 示例
$<CONFIG:cfg> 当前构建配置是否为 cfg $<$<CONFIG:Debug>:debug_flag>
$<PLATFORM_ID:id> 当前平台是否为 id $<$<PLATFORM_ID:Linux>:linux_src>
$<COMPILE_LANGUAGE:lang> 当前编译语言是否为 lang $<$<COMPILE_LANGUAGE:CUDA>:--cuda-path>
$<TARGET_EXISTS:target> 目标 target 是否存在 $<$<TARGET_EXISTS:some_lib>:link_flag>
$<STREQUAL:a,b> 字符串 a 和 b 是否相等 $<$<STREQUAL:${MY_VAR},ON>:enabled>

我曾经在一个项目里遇到这样的需求:同一个源文件,在 Debug 模式下要启用额外的日志宏,在 Release 模式下要禁用。用生成器表达式,一行搞定:

target_compile_definitions(myapp PRIVATE
    $<$<CONFIG:Debug>:ENABLE_DEBUG_LOG>
    $<$<CONFIG:Release>:DISABLE_DEBUG_LOG>
)

嗯,这里要注意:$<CONFIG:Debug> 匹配的是 构建配置的名称,不是编译器的优化级别。如果你自定义了配置名(比如叫 "FastBuild"),那就要用 $<CONFIG:FastBuild>

9.3 路径操作

路径操作是生成器表达式里非常实用的功能。跨平台开发时,路径分隔符、绝对路径、相对路径的处理经常让人头疼。CMake 提供了这些路径相关的生成器表达式:

  • $<JOIN:list,separator> —— 将列表用分隔符连接成字符串
  • $<REMOVE_DUPLICATES:list> —— 去重
  • $<FILTER:list,INCLUDE,regex> —— 过滤列表
  • $<PATH:path> —— 路径标准化(统一分隔符、解析相对路径)
  • $<RELATIVE_PATH:base,path> —— 计算 path 相对于 base 的路径

举个例子,假设你要把一组头文件路径传给编译器,但需要去掉重复项,并且统一成 Unix 风格的分隔符:

set(MY_INCLUDES
    /usr/local/include
    /home/user/project/include
    /usr/local/include  # 重复了
)

target_include_directories(myapp PRIVATE
    $<$<JOIN:$<REMOVE_DUPLICATES:${MY_INCLUDES}>,:>
)

注意这里 $<REMOVE_DUPLICATES:...> 返回的是一个列表(分号分隔),然后 $<JOIN:...,:> 把它用冒号连接起来。Windows 上编译器通常用分号,Unix 上用冒号,你可以根据平台动态选择分隔符。

我曾经踩过的坑:路径操作生成器表达式只对 生成器表达式上下文 中的字符串有效。如果你在普通变量里用 $<PATH:...>,它不会被展开,而是原样输出。一定要确保它出现在支持生成器表达式的命令参数里。

9.4 编译特性查询

编译特性查询是生成器表达式的高级用法。你可以查询某个目标是否支持特定的 C++ 标准、编译器特性等。比如:

# 检查目标 myapp 是否启用了 C++17
target_compile_features(myapp PRIVATE cxx_std_17)

# 在另一个地方查询
if($<TARGET_PROPERTY:myapp,CXX_STANDARD> GREATER_EQUAL 17)
    message("myapp 使用 C++17 或更高版本")
endif()

更常用的场景是:根据编译器是否支持某个特性,来决定是否启用某些代码。比如检查编译器是否支持 __attribute__((deprecated))

target_compile_definitions(myapp PRIVATE
    $<$<COMPILE_FEATURES:cxx_attribute_deprecated>:
        HAS_DEPRECATED_ATTR
    >
)

这里 cxx_attribute_deprecated 是 CMake 预定义的编译器特性名称。如果编译器支持,就定义 HAS_DEPRECATED_ATTR 宏,代码里就可以用 __attribute__((deprecated)) 了。

我个人习惯把这种查询封装成一个函数,方便复用:

function(check_compiler_feature feature_name define_name)
    target_compile_definitions(${ARGV0} PRIVATE
        $<$<COMPILE_FEATURES:${feature_name}>:${define_name}>
    )
endfunction()

# 使用
check_compiler_feature(myapp cxx_constexpr HAS_CONSTEXPR)
check_compiler_feature(myapp cxx_lambda_init_capture HAS_INIT_CAPTURE)

9.5 调试技巧

生成器表达式调试起来比较麻烦,因为它在生成阶段才求值,你不能用 message() 直接打印它的结果。不过有几个技巧可以帮你排查问题:

  1. 使用 file(GENERATE) 输出中间结果
# 将生成器表达式的结果写入文件
file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/gen_expr_debug.txt"
    CONTENT "编译标志: $<TARGET_PROPERTY:myapp,COMPILE_OPTIONS>\n"
)

构建完成后,去 build 目录下查看这个文件,就能看到展开后的值。

  1. add_custom_target 配合 COMMAND 打印
add_custom_target(debug_gen_expr
    COMMAND ${CMAKE_COMMAND} -E echo
        "包含路径: $<TARGET_PROPERTY:myapp,INCLUDE_DIRECTORIES>"
    COMMAND ${CMAKE_COMMAND} -E echo
        "链接库: $<TARGET_PROPERTY:myapp,LINK_LIBRARIES>"
)

然后运行 cmake --build . --target debug_gen_expr 就能看到输出。

  1. 检查生成器表达式是否被正确解析

如果你看到构建系统里出现了 $<...> 的原始字符串,说明 CMake 没有把它当成生成器表达式。常见原因是你把它放在了普通字符串里,而不是支持生成器表达式的命令参数中。

避坑指南:我曾经在 set() 命令里用生成器表达式,结果死活不展开。后来才发现 set() 不支持生成器表达式,必须用 target_compile_definitionsfile(GENERATE) 这类命令。记住:生成器表达式只活在“生成器上下文”里。

知识体系图

下面这张图总结了生成器表达式的核心逻辑和分类:

生成器表达式知识体系 生成器表达式 $<...> 动态求值 条件表达式 CONFIG / PLATFORM_ID COMPILE_LANGUAGE TARGET_EXISTS / STREQUAL 路径操作 JOIN / REMOVE_DUPLICATES FILTER / PATH RELATIVE_PATH 编译特性查询 COMPILE_FEATURES TARGET_PROPERTY CXX_STANDARD 等 调试技巧 file(GENERATE) add_custom_target 检查上下文 使用场景总结 根据构建配置动态调整编译选项、链接库 跨平台路径标准化、列表去重与过滤 查询编译器特性、目标属性,实现条件编译

生成器表达式是 CMake 进阶的必经之路。刚开始可能会觉得语法别扭,但用顺手之后,你会发现它让 CMakeLists.txt 变得异常灵活。你想想看,没有它的话,你要写多少 if-else 来应对不同的配置和平台?

最后分享一个我自己的经验:不要试图一次性写出完美的生成器表达式。先写一个简单的版本,用 file(GENERATE) 看看展开结果,然后逐步调整。调试几次之后,你就能摸清它的脾气了。


公众号:蓝海资料掘金营,微信deep3321