11. 生成器表达式进阶:平台与编译器检测、目标属性查询、链接与路径转换
生成器表达式,说白了就是 CMake 里的「三目运算符」—— 但它能干的事远不止条件判断。我个人觉得,这是 CMake 里最被低估的特性之一。很多项目写了一大堆 if(WIN32)、if(MSVC),其实用生成器表达式一行就能搞定,而且更干净、更跨平台。
这一节我们深入四个方向:平台与编译器检测、目标属性查询、链接相关表达式、路径转换。每个我都会结合项目里的实际坑点来讲。
11.1 平台与编译器检测表达式
先看最常用的:$<PLATFORM_ID> 和 $<CXX_COMPILER_ID>。
# 只在 Windows 上添加一个编译定义
target_compile_definitions(myapp PRIVATE
$<$<PLATFORM_ID:Windows>:MY_WIN32_PLATFORM>
)
# 只在 MSVC 下启用特定警告
target_compile_options(myapp PRIVATE
$<$<CXX_COMPILER_ID:MSVC>:/W4>
)
这里有个细节:$<PLATFORM_ID:Windows> 本身是一个布尔表达式,外面再套一层 $<...:value> 就是「如果条件为真,则输出 value」。两层嵌套,别写反了。
$<$<PLATFORM_ID:Windows>:$<CXX_COMPILER_ID:MSVC>:xxx>,可读性太差。拆成两行,或者用 AND 组合。
还有几个我常用的:
$<CXX_COMPILER_GCC>— 是不是 GCC 家族(包括 MinGW)$<CXX_COMPILER_CLANG>— 是不是 Clang$<COMPILE_LANGUAGE>— 当前编译的语言(C、CXX、CUDA 等)
举个例子,混合 C 和 C++ 的项目里,我经常这样用:
target_compile_options(mylib PRIVATE
$<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions>
)
这样 C 文件不受影响,C++ 文件才加这个 flag。干净利落。
11.2 目标属性查询表达式
这个功能我是在做大型重构时才真正用上的。以前我总用变量存路径,后来发现用 $<TARGET_PROPERTY:...> 更可靠。
# 查询目标的输出名称
add_executable(myapp main.cpp)
message("Output name: $<TARGET_PROPERTY:myapp,OUTPUT_NAME>")
# 查询目标的包含目录
target_include_directories(myapp PRIVATE src)
message("Include dirs: $<TARGET_PROPERTY:myapp,INCLUDE_DIRECTORIES>")
注意:生成器表达式在 message() 里不会展开,只有在生成构建系统时才会求值。所以上面这段代码在 cmake --build 阶段才能看到实际值。调试时可以用 file(GENERATE) 输出到文件查看。
$<TARGET_PROPERTY:target,INTERFACE_INCLUDE_DIRECTORIES> 去查一个没有设置该属性的目标,结果返回空字符串,导致后续路径拼接出错。后来我养成了先检查属性是否存在的习惯:$<TARGET_EXISTS:target> 配合使用。
常用的目标属性查询:
| 表达式 | 说明 |
|---|---|
$<TARGET_PROPERTY:target,prop> | 查询目标属性值 |
$<TARGET_EXISTS:target> | 目标是否存在(布尔) |
$<TARGET_NAME_IF_EXISTS:target> | 存在则返回目标名,否则空 |
$<TARGET_FILE:target> | 目标生成的文件路径(全路径) |
$<TARGET_FILE_NAME:target> | 仅文件名 |
$<TARGET_FILE_DIR:target> | 仅目录 |
11.3 链接相关表达式
链接表达式是生成器表达式里最「动态」的部分。我刚开始用的时候,总觉得它像魔法,后来理解了原理,才发现它其实很朴素。
核心是 $<LINK_ONLY:...> 和 $<LINK_LIBRARY:feature,lib>。
# 只在链接阶段添加一个库,不传播编译选项
target_link_libraries(myapp PRIVATE
$<LINK_ONLY:some_static_lib>
)
# 指定链接方式(CMake 3.24+)
target_link_libraries(myapp PRIVATE
$<LINK_LIBRARY:WHOLE_ARCHIVE,my_static_lib>
)
为什么要用 LINK_ONLY?举个例子:你有一个静态库,它只提供符号,不需要它的头文件路径。如果用普通的 target_link_libraries,CMake 会自动传播它的 INTERFACE_INCLUDE_DIRECTORIES。用 LINK_ONLY 可以切断这种传播。
$<LINK_LIBRARY:WHOLE_ARCHIVE,...> 之后,CMake 自动处理了 GCC、Clang、MSVC 的差异。省心不少。
还有 $<LINK_GROUP:feature,lib1,lib2>,用于处理链接组(比如 --start-group / --end-group)。这个我用的不多,但在处理循环依赖时确实有用。
11.4 路径转换表达式
路径转换,说白了就是让路径在不同上下文之间「说人话」。比如源码里的相对路径,到了构建目录就变了;或者 Windows 的 \ 和 Linux 的 / 混用。
CMake 提供了几个路径相关的生成器表达式:
# 相对路径转绝对路径
set(MY_PATH "src/foo/bar")
target_compile_definitions(myapp PRIVATE
MY_PATH="$<PATH:ABSOLUTE,${MY_PATH}>"
)
# 路径分隔符统一
target_compile_definitions(myapp PRIVATE
MY_PATH="$<PATH:NATIVE_PATH,${MY_PATH}>"
)
注意:$<PATH:...> 系列表达式是 CMake 3.20 才引入的。如果你的项目需要兼容旧版本,可以用 file(TO_CMAKE_PATH) 或 file(TO_NATIVE_PATH) 在配置阶段处理。
$<JOIN:...> 而不是直接字符串拼接。比如 $<JOIN:$<TARGET_FILE_DIR:myapp>,$<TARGET_FILE_NAME:myapp>,> 比 $<TARGET_FILE_DIR:myapp>/$<TARGET_FILE_NAME:myapp> 更安全,因为 JOIN 会自动处理分隔符。
还有一个我常用的:$<SHELL_PATH:...>。在交叉编译时,目标平台的路径格式可能和主机不同。这个表达式会把路径转换成目标平台 shell 能识别的格式。
# 在 Windows 主机上为 Linux 目标生成路径
target_compile_definitions(myapp PRIVATE
DATA_DIR="$<SHELL_PATH:/opt/data>"
)
# 输出:/opt/data (即使在 Windows 主机上)
知识体系图
下面这张图概括了生成器表达式进阶的四个方向及其关系:
避坑总结
最后,把我这些年用生成器表达式踩过的坑列一下:
- 嵌套层数别太多:超过三层嵌套,代码基本没法维护。我见过有人写五层嵌套的生成器表达式,调试时差点崩溃。
- 注意求值时机:生成器表达式在配置阶段不会展开,
message()里看不到。用file(GENERATE)输出到文件调试。 - 版本兼容性:
$<LINK_LIBRARY>需要 CMake 3.24+,$<PATH>需要 3.20+。如果你的项目要支持旧版本,记得加cmake_minimum_required检查。 - 引号问题:生成器表达式里如果包含空格或特殊字符,记得用双引号包起来。比如
"$<TARGET_FILE:myapp>"。
生成器表达式用好了,CMakeLists.txt 会变得非常简洁。我个人现在写新项目,几乎不用 if(WIN32) 这种老式写法了。生成器表达式 + 目标属性,基本能覆盖 90% 的平台差异场景。
公众号:蓝海资料掘金营,微信deep3321