第九章:生成器表达式——CMake 的“运行时魔法”

说实话,我第一次接触生成器表达式时,心里想的是:“这玩意儿是啥?怎么长得像乱码?”

但用了一段时间后,我彻底爱上了它。为什么呢?因为它解决了 CMake 里一个老大难问题:配置时不知道,生成时才知道的信息

举个例子。你在写 CMakeLists.txt 时,根本不知道用户用的是 Debug 还是 Release 配置。也不知道编译器是 GCC 还是 MSVC。更不知道目标平台是 32 位还是 64 位。这些信息,只有在 CMake 生成构建系统(比如 Makefile 或 Visual Studio 工程)时才会确定。

生成器表达式,就是用来处理这种“延迟求值”场景的。

9.1 什么是生成器表达式?

生成器表达式(Generator Expression)是 CMake 提供的一种特殊语法。它不会在 cmake .. 配置阶段被求值,而是在 cmake --build 或生成构建系统时被展开。

说白了,它就像 C++ 里的模板——写的时候是个占位符,用的时候才被替换成真实值。

它的通用语法长这样:

$<...>

尖括号里写的是具体的表达式。CMake 支持几十种内置的表达式,我挑最常用的给你讲。

9.2 条件表达式:让 CMake 学会“如果”

最基础的一类,是条件判断。语法是:

$<condition:value>

如果 condition 为真,就展开为 value;否则展开为空字符串。

举个实际例子。我在项目中经常需要根据编译器类型添加不同的编译选项:

target_compile_options(myapp PRIVATE
    $<$<CXX_COMPILER_ID:MSVC>:/W4>
    $<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra>
)

你看,这里用了嵌套的生成器表达式。内层 $<CXX_COMPILER_ID:MSVC> 判断编译器是不是 MSVC,如果是,外层就展开为 /W4;如果是 GNU,就展开为 -Wall -Wextra

小技巧: 条件表达式可以嵌套多层。我见过有人嵌套了五六层,虽然能跑,但可读性很差。我的建议是:超过三层就考虑用变量拆开。

9.3 编译选项中的实战用法

编译选项是生成器表达式最常用的场景之一。为什么?因为不同编译器、不同配置、不同平台,需要的编译选项天差地别。

我曾经维护过一个跨平台项目,Windows 用 MSVC,Linux 用 GCC,macOS 用 Clang。每个编译器对警告选项的命名都不一样。用生成器表达式,一份 CMakeLists.txt 搞定:

target_compile_options(myapp PRIVATE
    # MSVC 特有的选项
    $<$<CXX_COMPILER_ID:MSVC>:
        /W4 /utf-8 /Zc:__cplusplus
    >
    # GCC 和 Clang 共用的选项
    $<$<OR:$<CXX_COMPILER_ID:GNU>,$<CXX_COMPILER_ID:Clang>>:
        -Wall -Wextra -Wpedantic
    >
    # 只有 Debug 才加的选项
    $<$<CONFIG:Debug>:
        -g -O0 -DDEBUG
    >
    # 只有 Release 才加的选项
    $<$<CONFIG:Release>:
        -O2 -DNDEBUG
    >
)

嗯,这里要注意:$<OR:...> 是逻辑或操作,可以组合多个条件。类似的还有 $<AND:...>$<NOT:...>

9.4 链接中的生成器表达式

链接阶段同样离不开生成器表达式。最常见的场景是:Debug 配置链接 Debug 版本的库,Release 配置链接 Release 版本的库。

比如我有个第三方库,它的 Debug 版本叫 mylib_d.lib,Release 版本叫 mylib.lib。用生成器表达式可以优雅处理:

target_link_libraries(myapp PRIVATE
    $<$<CONFIG:Debug>:mylib_d>
    $<$<CONFIG:Release>:mylib>
)

另一个常见需求是:只在某个平台上链接特定库。比如 Windows 上需要链接 ws2_32,Linux 上不需要:

target_link_libraries(myapp PRIVATE
    $<$<PLATFORM_ID:Windows>:ws2_32>
)
注意: 生成器表达式不能用在 set()message() 等配置阶段执行的命令中。我曾经犯过这个错,在 message() 里打印 $<CONFIG>,结果输出的是原样字符串,不是实际值。因为 message() 在配置阶段就执行了,而生成器表达式要等到生成阶段才展开。

9.5 目标属性的条件设置

除了编译选项和链接库,生成器表达式还能用在 target_compile_definitions()target_include_directories() 等命令中。

举个例子,我想在 Debug 模式下定义一个 _DEBUG 宏:

target_compile_definitions(myapp PRIVATE
    $<$<CONFIG:Debug>:_DEBUG>
)

或者根据编译器版本决定是否启用某个特性:

target_compile_definitions(myapp PRIVATE
    $<$<VERSION_GREATER:$<CXX_COMPILER_VERSION>,7.0>:HAS_CXX17>
)

这个 $<VERSION_GREATER:...> 可以比较版本号。如果编译器版本大于 7.0,就定义 HAS_CXX17 宏。

9.6 常用生成器表达式速查表

我把最常用的几个整理成了表格,方便你查阅:

表达式 说明 示例
$<CONFIG:cfg> 当前配置是否为 cfg $<$<CONFIG:Debug>:-g>
$<PLATFORM_ID:platform> 当前平台是否为 platform $<$<PLATFORM_ID:Windows>:win>
$<CXX_COMPILER_ID:id> 编译器是否为 id $<$<CXX_COMPILER_ID:MSVC>:/EHsc>
$<TARGET_FILE:tgt> 目标 tgt 的输出文件路径 $<TARGET_FILE:myapp>
$<TARGET_PROPERTY:tgt,prop> 目标 tgt 的属性 prop 的值 $<TARGET_PROPERTY:myapp,OUTPUT_NAME>
$<JOIN:list,sep> 用 sep 连接列表 $<JOIN:$<TARGET_OBJECTS:myobj>,>
$<LOWER_CASE:string> 转小写 $<LOWER_CASE:Hello>hello
$<UPPER_CASE:string> 转大写 $<UPPER_CASE:Hello>HELLO

9.7 生成器表达式的执行时机

理解执行时机很重要。我画了一张图,帮你理清 CMake 的整个流程:

配置阶段 cmake .. 生成阶段 生成 Makefile / .sln 构建阶段 cmake --build 生成器表达式在此展开 $<...> → 实际值 set() / message() 等命令 在此阶段执行 编译器、链接器 在此阶段运行 关键:生成器表达式不在配置阶段求值!

从这张图可以看得很清楚:set()message() 这些命令在配置阶段就执行了。而生成器表达式要等到生成阶段才展开。这就是为什么你不能在 message() 里打印 $<CONFIG> 的原因——那时候它还是个占位符。

9.8 避坑指南:我踩过的几个坑

最后分享几个我实际踩过的坑,希望能帮你省点时间。

坑一:忘记嵌套条件

我曾经写过这样的代码:

# 错误写法
target_compile_options(myapp PRIVATE
    $<CONFIG:Debug>:-g
)

这不会报错,但也不会生效。正确的写法是嵌套一层:

# 正确写法
target_compile_options(myapp PRIVATE
    $<$<CONFIG:Debug>:-g>
)

坑二:在配置阶段使用生成器表达式

前面已经提过了,message() 里不能用。另外 if() 判断里也不能用。如果你需要在配置阶段做判断,用常规的 if() 语法。

坑三:字符串比较时忘记引号

有时候生成器表达式展开后可能包含空格或特殊字符。保险起见,在 target_compile_options 里用引号包一下:

target_compile_options(myapp PRIVATE
    "$<$<CONFIG:Debug>:-g -O0>"
)
核心总结: 生成器表达式是 CMake 中处理跨平台、多配置、多编译器场景的利器。它的核心价值在于“延迟求值”——把决策推迟到信息完备的生成阶段。掌握它,你的 CMakeLists.txt 会变得简洁、优雅、可维护。

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