6、CMake条件与循环:if/elseif/else、foreach、while、函数与宏(function/macro)

说实话,很多C++开发者写CMakeLists.txt时,就是一路平铺直叙地写下去。项目小的时候还行,一旦项目复杂起来——比如要支持多平台、多编译器、可选功能模块——你就会发现,没有条件判断和循环,根本没法写。

我个人习惯把CMake里的条件、循环、函数和宏,看作是「CMake的编程能力核心」。掌握了它们,你写的就不是配置文件了,而是一个真正的构建脚本。

6.1 条件判断:if/elseif/else

条件判断是CMake里最常用的控制结构。我见过不少新手在if里写错变量引用方式,导致条件永远不成立。咱们先看基本语法:

if(condition)
    # 条件为真时执行
elseif(another_condition)
    # 另一个条件
else()
    # 都不满足
endif()

这里有个关键点:CMake的if对变量名和值有一套自己的解析规则。比如:

set(MY_VAR "ON")
if(MY_VAR)
    message("MY_VAR为真")
endif()

# 等价于
if(${MY_VAR})
    message("MY_VAR为真")
endif()

嗯,这里要注意:if(MY_VAR) 这种写法,CMake会先尝试把 MY_VAR 当作变量名去取值,如果取到了非空且非假的值,就视为真。但如果你的变量名和某个关键字冲突了呢?比如:

set(TRUE "FALSE")  # 别这么干!
if(TRUE)
    # 这里依然为真,因为TRUE是内置常量
endif()
避坑指南:我曾经在一个跨平台项目里,因为变量名不小心叫了"ANDROID",结果在Android平台上条件判断总是出问题。后来发现CMake把"ANDROID"当成了平台变量来处理。建议变量名加个前缀,比如 MY_ANDROID

常用的条件表达式:

表达式 说明 示例
if(DEFINED var) 变量是否已定义 检查变量是否存在
if(NOT condition) 逻辑非 if(NOT WIN32)
if(var1 STREQUAL var2) 字符串相等 比较两个字符串
if(var1 GREATER var2) 数值大于 版本号比较
if(EXISTS path) 文件或目录是否存在 检查头文件
if(TARGET name) 目标是否存在 检查库是否已定义
小技巧:我个人习惯在复杂的条件判断里,把变量用 ${} 显式引用出来,虽然CMake会自动解析,但这样代码可读性更好,别人一看就知道这是个变量。

6.2 循环:foreach 和 while

循环在CMake里主要用来处理列表。你想想看,如果你有几十个源文件要添加到同一个目标里,一个一个写 target_sources() 不得累死?

foreach 循环

foreach 有几种用法,我按常用程度列出来:

# 用法1:遍历列表
set(SOURCES main.cpp utils.cpp network.cpp)
foreach(src ${SOURCES})
    message("处理源文件: ${src}")
endforeach()

# 用法2:范围遍历
foreach(i RANGE 1 10)
    message("迭代: ${i}")
endforeach()

# 用法3:同时遍历多个列表(CMake 3.17+)
foreach(src IN ZIP_LISTS HEADERS SOURCES)
    message("${src_0} -> ${src_1}")
endforeach()

我在项目中遇到过最典型的场景,就是根据不同的编译选项,动态生成源文件列表:

set(BASE_SRCS core.cpp math.cpp)
if(ENABLE_NETWORK)
    list(APPEND BASE_SRCS tcp_client.cpp http_parser.cpp)
endif()
if(ENABLE_GUI)
    list(APPEND BASE_SRCS window.cpp widget.cpp)
endif()

# 然后统一添加
foreach(src ${BASE_SRCS})
    target_sources(myapp PRIVATE src/${src})
endforeach()

while 循环

while 用得少一些,但某些场景下很顺手:

set(counter 0)
while(counter LESS 5)
    message("计数: ${counter}")
    math(EXPR counter "${counter} + 1")
endwhile()
注意:CMake的while没有 breakcontinue 语句(至少3.20之前没有)。如果你需要提前退出循环,得用 if 包一下逻辑。我曾经因为这个特性,写了一个死循环,CMake跑了5分钟还没结束……后来加了超时检测。

6.3 函数与宏:function/macro

函数和宏是CMake里实现代码复用的核心手段。说白了,就是把你重复写的那些 add_librarytarget_include_directories 之类的逻辑,封装成一个可调用的单元。

函数(function)

函数会创建一个新的作用域。这意味着函数内部定义的变量,默认不会影响外部:

function(add_my_library lib_name)
    add_library(${lib_name} STATIC ${lib_name}.cpp)
    target_include_directories(${lib_name} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
    message("库 ${lib_name} 已创建")
endfunction()

# 调用
add_my_library(utils)
add_my_library(network)

如果你想从函数里往外传值,可以用 set(PARENT_SCOPE)

function(get_output_dir out_var)
    set(${out_var} "${CMAKE_BINARY_DIR}/output" PARENT_SCOPE)
endfunction()

get_output_dir(MY_OUTPUT)
message("输出目录: ${MY_OUTPUT}")

宏(macro)

宏和函数很像,但宏不创建新作用域。宏里的变量直接操作调用者的作用域:

macro(print_var var_name)
    message("${var_name} = ${${var_name}}")
endmacro()

set(MY_VAR "hello")
print_var(MY_VAR)  # 输出: MY_VAR = hello

嗯,这里要注意:宏里如果用了 set(),它会直接修改调用者的变量。这既是优点也是缺点——用得好可以省去 PARENT_SCOPE 的麻烦,用不好就会莫名其妙地污染变量。

特性 function macro
作用域 独立作用域 调用者作用域
变量修改 需 PARENT_SCOPE 直接修改
参数传递 值传递 文本替换
推荐场景 封装复杂逻辑 简单代码替换
我的建议:除非你很清楚自己在做什么,否则优先用 function。宏的文本替换机制有时候会带来意想不到的副作用。我在一个大型项目里见过因为宏嵌套调用,导致变量名被意外展开成奇怪字符串的bug,查了两天才定位到。

6.4 知识体系总览

下面这张图把本章的核心知识点串起来了,你可以对照着回顾一下:

CMake 条件与循环知识体系 条件判断 if / elseif / else 循环 foreach / while 函数与宏 function / macro • 变量存在性: DEFINED • 字符串比较: STREQUAL • 数值比较: GREATER / LESS • 文件存在: EXISTS • 目标存在: TARGET • 列表遍历: foreach(list) • 范围遍历: foreach(RANGE) • 多列表: foreach(ZIP_LISTS) • 条件循环: while • 数学运算: math(EXPR) • 独立作用域: function • 共享作用域: macro • 传值: PARENT_SCOPE • 参数: ARGV / ARGN • 推荐: 优先用function 核心原则 1. 条件判断注意变量解析规则,避免关键字冲突 2. 循环优先用 foreach,while 注意防止死循环 3. 封装逻辑用 function,简单替换用 macro

6.5 实战经验总结

最后,分享几个我在实际项目中积累的经验:

  • 条件判断里多用括号:复杂的条件用 () 分组,可读性会好很多。比如 if((A AND B) OR (C AND NOT D))
  • foreach 里慎用 list 修改:在 foreach 循环里用 list(REMOVE_ITEM)list(APPEND) 修改正在遍历的列表,行为是未定义的。我踩过这个坑。
  • 函数参数用命名参数:CMake 3.18+ 支持 cmake_parse_arguments,建议用这个来解析命名参数,比靠位置传参清晰得多。
  • 宏里不要用 return():宏里用 return() 会直接跳出调用者的上下文,而不是宏本身。这个特性让很多人吃过亏。
一句话总结:条件判断让CMake有了「思考」能力,循环让CMake有了「重复劳动」能力,函数和宏让CMake有了「封装复用」能力。三者结合,你就能写出优雅、可维护的构建脚本了。

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