第九章:生成器表达式——构建系统的“动态语法”
说实话,我第一次接触生成器表达式时,心里是有点抵触的。这玩意儿看起来像乱码,$<...> 这种写法,怎么看怎么像某种 shell 脚本的变种。但后来我在一个大型跨平台项目里被折腾得够呛——同一个 CMakeLists.txt 要在 Windows、Linux、macOS 上分别处理不同的编译选项、不同的路径格式、不同的库依赖。那时候我才意识到,没有生成器表达式,你只能写一堆 if-else 来硬编码,维护起来简直想哭。
生成器表达式,说白了就是 CMake 在生成构建系统(比如 Makefile 或 Visual Studio 工程)时才求值的“动态代码”。它不像普通变量那样在 configure 阶段就确定值,而是等到生成阶段才展开。这意味着你可以根据不同的构建配置、不同的目标属性,动态地生成不同的内容。
核心概念:生成器表达式以 $<...> 的形式出现,内部可以嵌套条件、路径操作、编译特性查询等。它只能用在支持生成器表达式的上下文中,比如 target_link_libraries、target_include_directories、add_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 条件表达式
条件表达式是生成器表达式的核心。除了 CONFIG 和 PLATFORM_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() 直接打印它的结果。不过有几个技巧可以帮你排查问题:
- 使用
file(GENERATE)输出中间结果:
# 将生成器表达式的结果写入文件
file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/gen_expr_debug.txt"
CONTENT "编译标志: $<TARGET_PROPERTY:myapp,COMPILE_OPTIONS>\n"
)
构建完成后,去 build 目录下查看这个文件,就能看到展开后的值。
- 用
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 就能看到输出。
- 检查生成器表达式是否被正确解析:
如果你看到构建系统里出现了 $<...> 的原始字符串,说明 CMake 没有把它当成生成器表达式。常见原因是你把它放在了普通字符串里,而不是支持生成器表达式的命令参数中。
避坑指南:我曾经在 set() 命令里用生成器表达式,结果死活不展开。后来才发现 set() 不支持生成器表达式,必须用 target_compile_definitions 或 file(GENERATE) 这类命令。记住:生成器表达式只活在“生成器上下文”里。
知识体系图
下面这张图总结了生成器表达式的核心逻辑和分类:
生成器表达式是 CMake 进阶的必经之路。刚开始可能会觉得语法别扭,但用顺手之后,你会发现它让 CMakeLists.txt 变得异常灵活。你想想看,没有它的话,你要写多少 if-else 来应对不同的配置和平台?
最后分享一个我自己的经验:不要试图一次性写出完美的生成器表达式。先写一个简单的版本,用 file(GENERATE) 看看展开结果,然后逐步调整。调试几次之后,你就能摸清它的脾气了。