第二十二讲:生成器表达式——$<...>语法的使用,条件编译与平台判断

生成器表达式,说白了就是 CMake 在构建时才会求值的「魔法字符串」。

我第一次接触这东西时,觉得它就是个花哨的语法糖。直到有一次,我在一个跨平台项目里被折腾得够呛——Windows 上要链接 ws2_32.lib,Linux 上要链接 pthread,macOS 上又得用 CoreFoundation。那时候我还在用 if-else 堆变量,代码写得又臭又长。

后来我发现了生成器表达式,嗯,真香。

什么是生成器表达式?

生成器表达式以 $<...> 的形式出现。它不像普通变量那样在 CMakeLists.txt 解析时就展开,而是等到构建系统生成时才求值。

为什么要等到构建时?因为有些信息在配置阶段根本拿不到。比如:

  • 当前构建的是 Debug 还是 Release?
  • 目标平台是 Android 还是 iOS?
  • 编译器是 GCC 还是 MSVC?

这些信息,生成器表达式都能帮你搞定。

核心区别:

  • 普通变量 ${VAR}:配置阶段求值
  • 生成器表达式 $<...>:生成阶段求值

基本语法与常用表达式

生成器表达式的语法其实很简单,就两种形式:

# 条件式
$<condition:value>

# 纯值式
$<value>

条件式里,如果 condition 为真,就返回 value;否则返回空字符串。纯值式则直接返回 value。

我整理了一份常用表达式清单,你写代码时可以直接查:

表达式 含义 典型场景
$<CONFIG> 当前构建配置 Debug/Release 区分
$<PLATFORM_ID> 目标平台标识 Windows/Linux/Android
$<CXX_COMPILER_ID> 编译器标识 GNU/MSVC/Clang
$<TARGET_FILE:tgt> 目标文件路径 获取输出路径
$<BOOL:expr> 布尔表达式求值 自定义条件

条件编译:让代码在不同平台下「变脸」

条件编译是生成器表达式最常用的场景。我举个例子,假设你要给不同平台添加不同的编译宏:

target_compile_definitions(myapp PRIVATE
  $<$<PLATFORM_ID:Windows>:PLATFORM_WINDOWS>
  $<$<PLATFORM_ID:Linux>:PLATFORM_LINUX>
  $<$<PLATFORM_ID:Darwin>:PLATFORM_MACOS>
)

这段代码的意思是:如果目标平台是 Windows,就定义 PLATFORM_WINDOWS;如果是 Linux,就定义 PLATFORM_LINUX;如果是 macOS,就定义 PLATFORM_MACOS

你想想看,如果没有生成器表达式,你得写三个 if() 块,每个块里再写 target_compile_definitions。代码量翻倍不说,可读性还差。

我的习惯: 我会把平台相关的宏定义集中写在一个地方,用生成器表达式一次性搞定。这样后续维护时,打开文件扫一眼就知道哪些平台有特殊处理。

平台判断:不止是操作系统

平台判断不只是判断操作系统。编译器、架构、构建类型,这些都属于「平台」的范畴。

来看一个更复杂的例子:

target_link_libraries(myapp PRIVATE
  # Windows 下链接 ws2_32
  $<$<PLATFORM_ID:Windows>:ws2_32>
  
  # Linux 下链接 pthread
  $<$<PLATFORM_ID:Linux>:pthread>
  
  # 如果是 MSVC 编译器,添加特定链接选项
  $<$<CXX_COMPILER_ID:MSVC>:msvcrt.lib>
)

这里我做了三件事:

  • Windows 平台链接 ws2_32
  • Linux 平台链接 pthread
  • MSVC 编译器额外链接 msvcrt.lib

你可能会问:为什么不用 if(WIN32)

因为 if(WIN32) 是在配置阶段判断的,而生成器表达式是在生成阶段判断的。如果你的项目是交叉编译,配置阶段的 WIN32 变量可能不准确。生成器表达式用的是 PLATFORM_ID,它反映的是真正的目标平台。

我曾经踩过的坑: 有一次我在做 Android NDK 交叉编译,用 if(WIN32) 判断主机平台,结果死活不对。后来换成 $<PLATFORM_ID:Windows> 才搞定。记住:生成器表达式判断的是目标平台,不是主机平台。

嵌套与组合:生成器表达式的进阶玩法

生成器表达式可以嵌套使用。比如,你想在 Debug 模式下开启某个功能,但只在 Linux 平台上:

target_compile_definitions(myapp PRIVATE
  $<$<AND:$<CONFIG:Debug>,$<PLATFORM_ID:Linux>>:FEATURE_DEBUG_LINUX>
)

这里用了 $<AND:...> 组合条件,只有 Debug 配置且 Linux 平台时,才定义 FEATURE_DEBUG_LINUX

常用的逻辑运算符有:

  • $<AND:cond1,cond2,...>:全部为真才为真
  • $<OR:cond1,cond2,...>:任一为真即为真
  • $<NOT:cond>:取反

我个人建议,嵌套不要超过三层。超过三层的话,可读性会急剧下降。这时候不如拆成多个 target_compile_definitions 调用,或者用变量先算好中间结果。

实战:一个完整的跨平台示例

我们来写一个完整的 CMakeLists.txt,把今天讲的东西串起来:

cmake_minimum_required(VERSION 3.20)
project(CrossPlatformDemo CXX)

add_executable(demo main.cpp)

# 平台相关的编译宏
target_compile_definitions(demo PRIVATE
  $<$<PLATFORM_ID:Windows>:OS_WINDOWS>
  $<$<PLATFORM_ID:Linux>:OS_LINUX>
  $<$<PLATFORM_ID:Darwin>:OS_MACOS>
)

# 编译器相关的编译选项
target_compile_options(demo PRIVATE
  $<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra>
  $<$<CXX_COMPILER_ID:MSVC>:/W4>
  $<$<CXX_COMPILER_ID:Clang>:-Wall -Weverything>
)

# 构建类型相关的链接库
target_link_libraries(demo PRIVATE
  $<$<CONFIG:Debug>:debug_helper>
  $<$<CONFIG:Release>:release_helper>
)

# 平台相关的链接库
target_link_libraries(demo PRIVATE
  $<$<PLATFORM_ID:Windows>:ws2_32>
  $<$<PLATFORM_ID:Linux>:pthread>
)

这段代码覆盖了平台、编译器、构建类型三个维度的条件判断。你拿到任何平台,只要跑一遍 CMake,它就能自动适配。

生成器表达式的知识体系

为了让你更直观地理解生成器表达式的整体结构,我画了一张图:

生成器表达式知识体系 $<...> 生成器表达式 条件编译 编译宏定义:target_compile_definitions 编译选项:target_compile_options 平台判断 操作系统:PLATFORM_ID 编译器:CXX_COMPILER_ID 构建类型:CONFIG 链接与路径 链接库:target_link_libraries 目标文件路径:TARGET_FILE 核心优势:配置阶段不可知 → 生成阶段动态求值

避坑指南

生成器表达式虽然强大,但用不好也会踩坑。我分享几个亲身经历:

  • 不要在 message() 里用生成器表达式message() 在配置阶段执行,生成器表达式还没求值,你只会看到一串 $<...> 字符串。
  • 注意引号:生成器表达式里如果包含空格,记得加引号。比如 $<$<CONFIG:Debug>:"-DDEBUG=1">
  • 不要过度嵌套:超过三层的嵌套,连我自己都看不懂。这时候用变量拆分会更清晰。
  • 版本兼容性:有些生成器表达式是 CMake 3.x 才支持的。如果你的项目需要兼容旧版本,记得检查 cmake_minimum_required 的版本号。

调试小技巧: 如果你不确定生成器表达式是否生效,可以用 add_custom_command 配合 COMMAND echo 来输出。虽然 message() 不行,但构建时的命令可以拿到求值后的结果。

好了,生成器表达式的内容就讲到这里。这东西说白了就是 CMake 给你的一个「延迟求值」的能力,让你在构建时再做决策。掌握了它,你的 CMakeLists.txt 会变得更简洁、更灵活、更跨平台。


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