11. 3.3 生成器表达式(Generator Expressions)

导语

在上一节中,我们探索了接口库(Interface Library)如何通过纯头文件的方式封装编译选项与依赖关系,避免了大型项目中的重复配置。但 Modern CMake 的精妙之处远不止于此。如果你仔细观察过 CMake 3.x 的官方文档或优秀的开源项目,会发现一种形如 $<...> 的特殊语法无处不在——这就是生成器表达式(Generator Expressions)

如果说基于目标(Target-based)的构建是 Modern CMake 的骨架,那么生成器表达式就是赋予这副骨架灵活应变的”神经系统”。它让 CMake 在生成构建系统阶段(Generate-time)而非配置阶段(Configure-time)完成关键决策,从而实现了对构建类型、编译器差异、目标属性等上下文信息的精准响应。这意味着,你可以写出”一份代码,自适应多种构建场景”的构建逻辑。

本节是第三章中最具技术含量的一节。我们将从零开始,彻底掌握 $<> 的语法规则、条件逻辑、目标查询以及字符串/列表操作,并通过大量实战代码展示如何在真实项目中运用这一利器。

要点1:语法基础——$<> 结构的基本形式

生成器表达式(Generator Expressions)的核心语法非常简单:它以美元符号加尖括号包裹,形如 $<KEYWORD:...>。但背后的执行时机却与普通的 CMake 变量 ${VAR} 截然不同:

  • ${VAR}配置阶段(Configure-time)求值,即运行 cmake -B build 时就被替换为具体值。
  • $<...>生成阶段(Generate-time)求值,即 CMake 已经确定了生成器(如 Ninja、Makefiles、Visual Studio)并准备输出构建系统文件时才被解析。

由于求值时机靠后,生成器表达式能够访问到配置阶段无法确定的上下文信息,例如:最终的构建类型($<CONFIG>)、目标输出的绝对路径($<TARGET_FILE:>)等。

基本语法规则

  1. 不可嵌套于 ${} 中直接使用:虽然你可以在 $<> 内部使用 ${} 变量,但反过来不行。
  2. 支持嵌套:生成器表达式可以层层嵌套,例如 $<$<CONFIG:Debug>:-O0>
  3. 求值结果为零或一个字符串:条件类表达式在不满足时返回空字符串,满足时返回指定的值。

让我们看一个最经典的入门示例:根据构建类型自动定义宏。

add_executable(myapp main.cpp)

# 仅在 Debug 构建时定义 DEBUG_MODE 宏
target_compile_definitions(myapp PRIVATE
    $<$<CONFIG:Debug>:DEBUG_MODE>
)

解析逻辑如下:

  1. 外层 $<...> 是一个”条件-值”结构(下一节详述)。
  2. 内层 $<CONFIG:Debug> 在生成阶段判断当前配置是否为 Debug。如果是,返回 1(真);否则返回空字符串(假)。
  3. 当内层返回真时,外层表达式展开为 DEBUG_MODE;当为假时,整个表达式展开为空,相当于什么都没添加。

要点2:条件表达式——逻辑判断与分支

生成器表达式中最常用的就是条件表达式。它们让 CMake 能够根据不同的构建环境做出”智能”决策。

2.1 布尔与逻辑运算

  • $<BOOL:string>:将字符串转为布尔值。空字符串、0FALSEOFFNIGNORENOTFOUND 为假,其余为真。
  • $<AND:conditions...>:逻辑与,所有条件为真则返回 1
  • $<OR:conditions...>:逻辑或,任一条件为真则返回 1
  • $<NOT:condition>:逻辑非。
  • $<IF:condition,true_value,false_value>:三元运算符,CMake 3.8+。

下面的示例展示了如何仅在 Debug + GCC 环境下开启最详细的调试信息:

target_compile_options(myapp PRIVATE
    $<$<AND:$<CONFIG:Debug>,$<CXX_COMPILER_ID:GNU>>:-O0 -g3 -Wall>
)

nested 结构解读:

  • $<CONFIG:Debug>:构建类型是否为 Debug?
  • $<CXX_COMPILER_ID:GNU>:编译器是否为 GCC?
  • $<AND:...>:以上两者是否同时满足?
  • 最外层:满足则添加 -O0 -g3 -Wall,否则什么都不加。

2.2 比较表达式

  • $<STREQUAL:string1,string2>:字符串相等比较。
  • $<EQUAL:value1,value2>:数值相等比较。
  • $<CONFIG:cfgs...>:判断当前构建类型是否在给定的列表中(如 $<CONFIG:Debug,RelWithDebInfo>)。
  • $<PLATFORM_ID:platforms...>:判断目标平台(LinuxWindowsDarwin 等)。

实战示例:针对不同平台设置不同的库搜索路径。

target_link_directories(myapp PRIVATE
    $<$<PLATFORM_ID:Windows>:C:/third_party/lib>
    $<$<PLATFORM_ID:Linux>:/usr/local/lib>
    $<$<PLATFORM_ID:Darwin>:/opt/homebrew/lib>
)

2.3 使用 $<IF:> 进行显式分支

CMake 3.8 引入的三元运算符让代码更具可读性:

target_compile_definitions(myapp PRIVATE
    LOG_LEVEL=$<IF:$<CONFIG:Debug>,3,1>
)

含义:如果是 Debug,则定义 LOG_LEVEL=3;否则定义 LOG_LEVEL=1

要点3:目标相关表达式——查询构建产物

这是生成器表达式中最”强大”的一类,因为它直接操作 CMake 目标(Target),获取构建阶段的实际路径和属性。

3.1 目标文件路径查询

  • $<TARGET_FILE:target>:目标输出文件的完整绝对路径。
  • $<TARGET_FILE_NAME:target>:仅文件名(如 myapp.exe)。
  • $<TARGET_FILE_DIR:target>:输出文件所在的目录。
  • $<TARGET_NAME:target>:目标的逻辑名称(通常用于自定义命令中避免硬编码)。

3.2 目标属性查询

  • $<TARGET_PROPERTY:target,property>:读取指定目标的任意属性。
  • $<TARGET_PROPERTY:property>:读取当前目标的属性。
  • $<TARGET_EXISTS:target>:判断目标是否已定义(CMake 3.12+)。

3.3 对象库(Object Library)展开

  • $<TARGET_OBJECTS:objlib>:将对象库的对象文件列表展开,供其他目标链接或编译使用。

实战:构建后自动复制可执行文件

假设我们希望在每次构建完成后,自动将可执行文件复制到项目根目录的 bin 文件夹中:

add_executable(myapp main.cpp)

add_custom_command(TARGET myapp POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_SOURCE_DIR}/bin
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
        $<TARGET_FILE:myapp>              # 源:构建产出的绝对路径
        ${CMAKE_SOURCE_DIR}/bin/$<TARGET_FILE_NAME:myapp>  # 目标
    COMMENT "Deploying myapp to project bin directory..."
)

这里的关键优势在于:无论你在哪个平台、使用哪种生成器、输出目录如何变化,$<TARGET_FILE:myapp> 都能自动解析为正确的绝对路径。

实战:读取接口库的头文件目录

add_library(utils INTERFACE)
target_include_directories(utils INTERFACE ${CMAKE_SOURCE_DIR}/include)

add_executable(consumer main.cpp)
target_link_libraries(consumer PRIVATE utils)

# 在自定义命令中查询 consumer 最终继承到的包含目录(示例用途)
add_custom_target(print_includes
    COMMAND ${CMAKE_COMMAND} -E echo
        "Consumer include dirs: $<TARGET_PROPERTY:consumer,INCLUDE_DIRECTORIES>"
)

要点4:字符串操作表达式——数据处理

生成器表达式不仅能做逻辑判断,还能对字符串进行转换和拼接。这在处理由多个目标传递过来的属性时特别有用。

4.1 列表连接 $<JOIN:>

将分号分隔的 CMake 列表转换为指定分隔符的字符串。

set(MY_DEFS "FOO;BAR;BAZ")

# 将列表转为逗号分隔的字符串,作为宏定义传入
target_compile_definitions(myapp PRIVATE
    DEFS_LIST="$<JOIN:${MY_DEFS},,>"
)

注意:在 $<JOIN:> 内部使用 ${MY_DEFS} 是合法的,因为 ${} 会先展开,然后 $<> 在生成阶段处理展开后的列表。

4.2 大小写转换

  • $<UPPER_CASE:string>:转大写(CMake 3.13+)。
  • $<LOWER_CASE:string>:转小写(CMake 3.13+)。
# 生成一个与构建类型同名的大写宏
target_compile_definitions(myapp PRIVATE
    BUILD_TYPE_$<UPPER_CASE:$<CONFIG>>=1
)

4.3 嵌套求值 $<GENEX_EVAL:>

CMake 3.12+ 引入了 $<GENEX_EVAL:expr>,它可以对包含嵌套生成器表达式的字符串进行二次求值。这在处理由其他函数或目标传递过来的”字符串形式的生成器表达式”时非常有用。

set(GENEX_STR "$<IF:$<CONFIG:Debug>,debug_mode,release_mode>")

# 在另一个表达式中强制对其求值
target_compile_definitions(myapp PRIVATE
    MODE=$<GENEX_EVAL:${GENEX_STR}>
)

要点5:列表表达式——集合处理

当传递的属性是列表(如包含目录、源文件、编译定义)时,我们可能需要对其进行去重或过滤。

5.1 去重与过滤

  • $<REMOVE_DUPLICATES:list>:移除列表中的重复项(CMake 3.15+)。
  • $<FILTER:list,INCLUDE|EXCLUDE,regex>:基于正则表达式过滤列表(CMake 3.15+)。

示例:清理继承来的包含目录,移除所有指向 /usr/local/old 的路径:

add_library(core INTERFACE)
target_include_directories(core INTERFACE
    /usr/local/include
    /usr/local/old/include
    ${CMAKE_SOURCE_DIR}/include
)

add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE core)

# 使用 FILTER 排除包含 "old" 的路径(仅作演示)
target_include_directories(myapp PRIVATE
    $<FILTER:$<TARGET_PROPERTY:core,INTERFACE_INCLUDE_DIRECTORIES>,EXCLUDE,/old>
)

5.2 现代列表操作(CMake 3.27+)

CMake 3.27 引入了更强大的 $<LIST:> 系列表达式,包括 TRANSFORMREVERSESORT 等。如果你的项目允许使用较新版本,可以进一步简化列表处理逻辑:

# CMake 3.27+ 示例:将包含目录列表全部转为绝对路径形式(TRANSFORM)
target_include_directories(myapp PRIVATE
    $<LIST:TRANSFORM,$<TARGET_PROPERTY:core,INTERFACE_INCLUDE_DIRECTORIES>,PREPEND,${CMAKE_SOURCE_DIR}/>
)

在团队项目中,使用这些高级特性前请务必确认 CMake 最低版本要求。

要点6:实际应用场景

掌握了语法之后,关键在于”何时使用”。下面三个场景覆盖了日常开发中最常见的痛点。

场景1:条件编译与配置相关的编译选项

这是生成器表达式最常见的用途。通过 $<CONFIG:> 将编译选项与目标牢牢绑定,彻底告别全局的 if(CMAKE_BUILD_TYPE STREQUAL "Debug") 判断。

add_library(mylib src/mylib.cpp)

target_compile_options(mylib PRIVATE
    # MSVC 专用
    $<$<AND:$<CONFIG:Debug>,$<CXX_COMPILER_ID:MSVC>>:/Od /Zi /RTC1>
    # GNU/Clang 专用
    $<$<AND:$<CONFIG:Debug>,$<CXX_COMPILER_ID:GNU,Clang,AppleClang>>:-O0 -g3 -fno-omit-frame-pointer>
    # Release 通用
    $<$<CONFIG:Release>:-DNDEBUG>
)

target_link_options(mylib PRIVATE
    # Linux Release 模式下开启 LTO 链接选项
    $<$<AND:$<CONFIG:Release>,$<PLATFORM_ID:Linux>>:-flto=auto>
)

场景2:运行时库路径与资源复制

在开发跨平台应用时,经常需要将动态库(DLL、SO、Dylib)复制到可执行文件同级目录。结合 $<TARGET_FILE:>$<TARGET_RUNTIME_DLLS:>(CMake 3.21+),可以实现高度自动化的部署。

add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE some_shared_lib)

# 仅 Windows 平台:构建后将依赖的 DLL 复制到输出目录
add_custom_command(TARGET myapp POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
        $<$<PLATFORM_ID:Windows>:$<TARGET_RUNTIME_DLLS:myapp>>
        $<TARGET_FILE_DIR:myapp>
    COMMAND_EXPAND_LISTS
)

即使不使用 TARGET_RUNTIME_DLLS,仅通过 $<TARGET_FILE_DIR:>$<TARGET_FILE:> 也能稳定地处理资源文件的复制,而无需硬编码 build/binbuild/Release 等路径。

场景3:通过属性查询实现松耦合的接口设计

生成器表达式允许你在不直接依赖目标内部实现的情况下,读取其接口属性。这在设计插件系统或包装脚本时非常有用。

add_library(engine SHARED engine.cpp)
set_target_properties(engine PROPERTIES
    ENGINE_VERSION "2.5.0"
    ENGINE_API_LEVEL "11"
)

add_executable(game main.cpp)
target_link_libraries(game PRIVATE engine)

# 将引擎版本号作为编译定义传递给 game,game 无需直接硬编码版本
target_compile_definitions(game PRIVATE
    ENGINE_VER="$<TARGET_PROPERTY:engine,ENGINE_VERSION>"
    ENGINE_API=$<TARGET_PROPERTY:engine,ENGINE_API_LEVEL>
)

要点7:调试技巧——如何”看见”生成器表达式

生成器表达式的最大困扰在于:它发生在生成阶段,无法通过普通的 message(STATUS "...") 在配置时直接打印出来。 如果你写错了表达式(例如目标名拼写错误),CMake 在配置阶段往往不会报错,而是生成一个包含错误文本的构建命令,直到真正编译时才暴露问题。

7.1 使用 file(GENERATE) 输出到文件

这是官方推荐的调试手段。file(GENERATE) 命令会在生成阶段将内容写入文件,因此它可以完美地求值生成器表达式。

add_executable(myapp main.cpp)
target_compile_definitions(myapp PRIVATE FOO=1)

# 在生成阶段创建一个调试文件,输出所有关心的表达式值
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/debug_genex.txt" CONTENT
    "========== Generator Expressions Debug ==========n"
    "Build Config     : $<CONFIG>n"
    "Current Source Dir: $<CMAKE_CURRENT_SOURCE_DIR>n"
    "Compiler ID      : $<CXX_COMPILER_ID>n"
    "Target File      : $<TARGET_FILE:myapp>n"
    "Target Dir       : $<TARGET_FILE_DIR:myapp>n"
    "Target Name      : $<TARGET_NAME:myapp>n"
    "Compile Defs     : $<TARGET_PROPERTY:myapp,COMPILE_DEFINITIONS>n"
    "Is Debug?        : $<CONFIG:Debug>n"
    "=================================================n"
)

执行 cmake -B build 后,查看 build/debug_genex.txt,你就能看到所有表达式在生成阶段的真实展开结果。

7.2 利用自定义目标在构建时打印

如果你想在构建过程中动态查看,可以创建一个不执行实际工作的自定义目标:

add_custom_target(show_genex
    COMMAND ${CMAKE_COMMAND} -E echo
        "Building target myapp, output path = $<TARGET_FILE:myapp>"
    COMMAND ${CMAKE_COMMAND} -E echo
        "Current configuration = $<CONFIG>"
)

然后运行 cmake --build build --target show_genex,即可在终端看到输出。

7.3 常见错误排查清单

  • 目标名拼写错误$<TARGET_FILE:myapp> 中的 myapp 必须在同一 CMake 项目中被 add_executableadd_library 定义过。
  • if() 语句中使用if($<CONFIG:Debug>) 是无效的,因为 if() 在配置阶段求值,而生成器表达式还未展开。
  • 嵌套括号不匹配:复杂的嵌套表达式容易漏写尖括号,建议分层编写并配合 file(GENERATE) 验证。
  • 变量与生成器表达式混淆:记住 ${} 先求值,$<> 后求值。不要在需要动态决策的地方提前用 ${} 固定了值。

小结

生成器表达式是 Modern CMake 中连接”静态配置”与”动态构建”的桥梁。它解决了传统 CMake 中大量的 if/else 平台判断和全局变量污染问题,让你能够基于目标、基于上下文精确控制编译与链接行为。

本节我们系统学习了:

  • 语法基础$<> 的求值时机与嵌套规则。
  • 条件表达式$<IF:>$<AND:>$<CONFIG:> 等逻辑判断。
  • 目标表达式$<TARGET_FILE:>$<TARGET_PROPERTY:> 等路径与属性查询。
  • 字符串与列表$<JOIN:>$<UPPER_CASE:>$<REMOVE_DUPLICATES:> 等数据处理能力。
  • 调试技巧:利用 file(GENERATE) 窥探生成阶段的最终展开值。

从下一节开始,我们将继续深入 Modern CMake 的属性系统,全面解析目标属性、目录属性与全局属性的工作机制,帮助你构建更加健壮和可维护的 CMake 项目。

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……