导语
在上一节中,我们探索了接口库(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:>)等。
基本语法规则
- 不可嵌套于
${}中直接使用:虽然你可以在$<>内部使用${}变量,但反过来不行。 - 支持嵌套:生成器表达式可以层层嵌套,例如
$<$<CONFIG:Debug>:-O0>。 - 求值结果为零或一个字符串:条件类表达式在不满足时返回空字符串,满足时返回指定的值。
让我们看一个最经典的入门示例:根据构建类型自动定义宏。
add_executable(myapp main.cpp)
# 仅在 Debug 构建时定义 DEBUG_MODE 宏
target_compile_definitions(myapp PRIVATE
$<$<CONFIG:Debug>:DEBUG_MODE>
)
解析逻辑如下:
- 外层
$<...>是一个”条件-值”结构(下一节详述)。 - 内层
$<CONFIG:Debug>在生成阶段判断当前配置是否为 Debug。如果是,返回1(真);否则返回空字符串(假)。 - 当内层返回真时,外层表达式展开为
DEBUG_MODE;当为假时,整个表达式展开为空,相当于什么都没添加。
要点2:条件表达式——逻辑判断与分支
生成器表达式中最常用的就是条件表达式。它们让 CMake 能够根据不同的构建环境做出”智能”决策。
2.1 布尔与逻辑运算
$<BOOL:string>:将字符串转为布尔值。空字符串、0、FALSE、OFF、N、IGNORE、NOTFOUND为假,其余为真。$<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...>:判断目标平台(Linux、Windows、Darwin等)。
实战示例:针对不同平台设置不同的库搜索路径。
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:> 系列表达式,包括 TRANSFORM、REVERSE、SORT 等。如果你的项目允许使用较新版本,可以进一步简化列表处理逻辑:
# 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/bin 或 build/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_executable或add_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 项目。


没有回复内容