
1. 为什么CMake脚本需要foreach做CMake项目时间长了你会发现构建脚本里写来写去逃不开一个需求把一堆长得差不多的东西批量处理。今天要处理三个子目录明天要链接五六个静态库后天要生成几十个相同格式的配置文件如果全都在CMakeLists.txt里一行行手写不仅冗长而且改一个列表名就要全局搜索替换完全是给自己挖坑。foreach命令就是CMake里专门用来干这件事的。它可以在构建脚本里对列表、数字区间、甚至多个列表做循环遍历把重复劳动压缩成几行代码。CMake本身不是一门语言但foreach配合if、set、list、string这些命令构成了一个轻量但够用的脚本环境支撑起大部分构建逻辑的自动化。这篇文章适合两类人刚接触CMake、想把构建脚本写得干净一点的新手以及已经在项目里手写过几十个相似target、想系统性梳理foreach各种玩法的老手。我会从最常用的用法讲到带坑的边界情况最后放几个可以直接抄到项目里的实战片段。别的我不承诺读完让你对foreach的理解至少超过团队里八成工程师。1.1 没有foreach时构建脚本长什么样先看一个细节。假设项目里有三个模块分别叫core、ui、util每个模块都有自己的源文件目录传统写法是这样的add_library(core STATIC core/impl.cpp core/core.cpp) target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/core) add_library(ui STATIC ui/widget.cpp ui/dialog.cpp ui/theme.cpp) target_include_directories(ui PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/ui) add_library(util STATIC util/string.cpp util/file.cpp) target_include_directories(util PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/util)三个模块还能凑合但如果模块数量涨到十个呢每个模块还可能有多个源文件、多个依赖、多套编译选项这段脚本就会无限膨胀。更麻烦的是新增一个模块时你得记住在所有该出现的地方都加上对应的三行代码漏掉一个就是编译失败或者奇奇怪怪的链接错误。这种重复劳动非常不适合人来做因为人天生会漏。foreach的引入本质上是把对每个模块做同一组操作这一逻辑显式化让CMake替你遍历模块列表。1.2 CMake列表的真实面目要真正理解foreach必须先理解CMake的列表是什么。很多习惯了Python或者Shell的人下意识觉得列表应该是独立的数据结构但CMake里的列表就是字符串只是用分号分隔的字符串。set(MODULES core ui util)这行代码在内存里存的实际是core;ui;util这个单字符串。foreach遍历的时候本质上是把这个字符串按分号拆开然后逐个处理。这个特性带来了两个推论。第一分号是CMake列表的分隔符所以列表元素里如果有分号会被拆成多个元素这是一个常见的坑。第二因为列表和字符串同构很多命令既能操作字符串也能操作列表比如list(APPEND)和string(REPLACE)这让foreach的适用场景宽泛了很多。2. foreach的四种形态与选型逻辑foreach在CMake里不是只有一种写法官方文档提供了好几种变体适用不同场景。很多人拿到手只记得最简单的foreach(item ${LIST})用别的语法就懵了。这一节把主流形态过一遍重点放在什么时候该用哪种上。2.1 标准列表遍历两种写法怎么选最简单的形式遍历一个列表set(MODULES core ui util) foreach(module IN LISTS MODULES) message(STATUS current: ${module}) endforeach()IN LISTS是CMake 3.3以后推荐的形式它的作用是明确告诉CMake去遍历名为MODULES的列表。也可以写成更古老的旧式语法foreach(module ${MODULES}) message(STATUS current: ${module}) endforeach()两种写法对于简单场景完全等价但旧式写法有个坑如果你在${MODULES}外面再加一层引用比如foreach(module ${MODULES})由于双引号的存在CMake会把它当成单个字符串而不是列表去遍历结果循环只执行一次。IN LISTS则没有这个问题所以新代码里尽量用IN LISTS语义清晰也少踩引号相关的坑。如果列表里包含空格比如路径C:/Program Files/xxx用IN LISTS也能正确处理。因为列表元素的分隔符是分号不是空格双引号只是为了让编译器正确识别路径不会影响遍历逻辑。2.2 RANGE遍历数字循环的细节除了遍历列表foreach还能直接生成数列最常见的用法是foreach(i RANGE 1 10)会依次执行10次i分别等于1到10。foreach(i RANGE 1 10) message(STATUS i ${i}) endforeach()这里有个容易忽略的地方RANGE也可以只传一个参数比如foreach(i RANGE 5)含义是从0到5包括0和5所以循环会执行6次。很多初学者拿这个当执行5次来用结果多了一次必须留意。还可以指定步长foreach(i RANGE 1 10 2) # i 1, 3, 5, 7, 9 endforeach()第三个参数是增量。这个在生成序列号、批量创建编号文件时很实用。比如要生成一个从0到19的数组可以写foreach(i RANGE 0 19)如果需要打乱步长就加增量。注意增量不一定是正数但从大到小遍历时写负数也可以CMake同样支持。2.3 ZIP并行遍历多列表同步取值更高级的形态是ZIP_LISTS它把多个列表按索引对齐一次循环同时取出各列表对应位置的元素。set(NAMES a b c) set(VALUES 1 2 3) foreach(name value IN ZIP_LISTS NAMES VALUES) message(STATUS ${name} - ${value}) endforeach()这个特性的版本要求相对高CMake 3.8以上才支持IN ZIP_LISTS。它的实用场景是你有两组数据天然是一一对应的比如源文件名和对应的编译选项、模块名和它的依赖列表。不用ZIP的话就得先算好一个公共索引再用list(GET)逐个取代码难看且容易越界。需要注意ZIP遍历时如果两个列表长度不一致CMake并没有报错而是以较短的列表为准多余的元素直接忽略。这个行为可能在你的项目里不是预期所以使用前最好先确认列表长度是相等的或者设计时保证它们天生一样长。2.4 嵌套循环与循环控制语句在foreach里面再套一个foreach是常有的事比如遍历模块的多个变体foreach(module IN LISTS MODULES) foreach(variant IN LISTS VARIANTS) message(STATUS build ${module} with ${variant}) endforeach() endforeach()嵌套不限制层数但每多一层可读性就下降一次一般两到三层层级比较合理。更关键的是循环控制语句break()和continue()。CMake在3.19版本中加入了这两个命令用来提前终止循环或者跳过当前迭代。foreach(item IN LISTS ITEMS) if(item STREQUAL skip_me) continue() endif() if(item STREQUAL stop_all) break() endif() # 处理 endforeach()如果项目里还在用老版本CMake就只能用ifelse配合标记变量模拟跳过逻辑代码会丑不少。所以如果条件允许尽量把CMake版本提到3.19以上写循环的体验会好很多。3. 实战用foreach改造真实构建脚本前面讲了语法和选型这一节直接上实战。我挑三个高频场景详细演示你几乎可以在任何正经项目的CMakeLists.txt里找到类似的影子。3.1 批量添加源文件并分组很多项目习惯把不同模块的源文件分开存放比如src下按模块拆目录。传统写法是每个模块写一段但有了foreach可以先把模块列表和对应目录列表管理起来然后统一循环处理。set(MODULES core ui util) set(MODULE_DIRS src/core src/ui src/util ) foreach(module IN LISTS MODULES) # 用 CURRENT 关键字依次取目录列表中的对应项 list(FIND MODULES ${module} _idx) list(GET MODULE_DIRS ${_idx} module_dir) # 收集该目录下的所有 .cpp 文件 file(GLOB module_sources CONFIGURE_DEPENDS ${module_dir}/*.cpp) add_library(${module} STATIC ${module_sources}) target_include_directories(${module} PUBLIC ${module_dir}) endforeach()这段代码用了list(FIND)和list(GET)来把模块名和目录对应起来当然用ZIP_LISTS会更简洁但这里展示的是老版本CMake也通用的写法。这里有个我自己一直坚持的习惯file(GLOB)尽量配合CONFIGURE_DEPENDS使用。因为file(GLOB)是在配置阶段收集文件如果后面新增了源文件而不重新运行CMake构建系统不会感知到加上CONFIGURE_DEPENDS后CMake会在每次构建前重新检查一下文件变化能省掉一堆因为新增源文件导致的诡异编译错误。3.2 遍历子目录统一创建target如果你的项目把每个子模块都放在一个独立子目录里且每个子目录都有自己独立的CMakeLists.txt来定义同名target那么根目录的CMakeLists.txt可以用foreach一步到位。set(SUB_PROJECTS math network storage ) foreach(sub IN LISTS SUB_PROJECTS) add_subdirectory(${sub}) endforeach() # 把所有子模块串成一个总库 add_library(everything STATIC) target_link_libraries(everything PUBLIC math network storage )这种做法在代码结构上保证了整个构建的可扩展性新增模块时只要在SUB_PROJECTS里加一个名字然后创建对应目录和CMakeLists.txt根目录不需要再改任何东西。同时所有子模块的target命名要规范化避免foreach循环中产生莫名的名字冲突。3.3 动态生成编译选项foreach不只能操作target也能和字符串处理配合动态生成一系列编译参数。下面这个例子演示了如何根据平台或者开关生成宏定义列表。set(FEATURES ENABLE_LOGGING ENABLE_SSL ENABLE_COMPRESSION ) set(FEATURE_DEFS ) foreach(feature IN LISTS FEATURES) if(${feature}) list(APPEND FEATURE_DEFS -D${feature}1) else() list(APPEND FEATURE_DEFS -D${feature}0) endif() endforeach() target_compile_definitions(app PRIVATE ${FEATURE_DEFS})注意这里${feature}本身作为if判断的变量名前提是这些feature变量都提前用option定义了。foreach在这里的价值是让你不用挨个手写if块逻辑清晰而且想增加一个特性开关只需要在FEATURES列表里加一行。这种写法在跨平台项目里非常常见因为不同平台的宏定义管理往往比源码逻辑更容易乱。再补一个更复杂的例子把多个依赖库字符串拼接成一条链接选项。set(LIBS_CORE zlib pthread m ) set(EXTRA_LINK_FLAGS ) foreach(lib IN LISTS LIBS_CORE) string(APPEND EXTRA_LINK_FLAGS -l${lib}) endforeach() # EXTRA_LINK_FLAGS 最终形如 -lzlib -lpthread -lm这个例子的精髓在于string(APPEND)它在循环里不断追加文本巧妙利用foreach把列表转换成单个字符串参数。4. 常见问题与避坑指南开发过程中几乎每个人都会遇到几个典型的foreach陷阱。我把这几年积累的典型问题和解决思路整理成表格每一行都是真实踩过的坑。问题表现根本原因解决方案循环里的set没生效CMake变量作用域限制foreach相当于子作用域在循环体内使用set(... PARENT_SCOPE)或者提前把结果累积到父作用域列表列表元素带空格被拆开加了对引号让CMake把整个列表当成一个字符串使用IN LISTS语法避免额外双引号包裹整个列表变量在循环里修改被遍历的列表修改操作本身改变了迭代过程可能出现死循环或者漏元素先复制一份原列表遍历副本再修改原列表旧版CMake不能使用break和continuebreak/continue是3.19新增的老版本不支持升级CMake版本或者用if/标记变量做等效处理ZIP列表长度不一致导致丢数据ZIP遍历以较短列表为准不会警告使用前检查两列表长度或者设计时确保等长4.1 变量作用域陷阱循环体内set为什么不管用很多人第一次在foreach里写set时都会遇到同一个问题循环内部用set设置的变量循环结束后外面根本拿不到值。这是因为CMake的foreach不是一个普通的连续执行流它内部会形成新的变量作用域循环中定义的变量在循环退出后会被丢弃。set(MY_LIST ) foreach(item IN LISTS ITEMS) set(MY_LIST ${MY_LIST} ${item}) endforeach() message(STATUS my ${MY_LIST}) # 输出为空这样写是无效的。正确的做法是把累积结果放到父作用域里set(MY_LIST ) foreach(item IN LISTS ITEMS) set(MY_LIST ${MY_LIST} ${item} PARENT_SCOPE) endforeach()不过PARENT_SCOPE只能向上传递一层如果是两层嵌套就要根据实际层级判断。最稳妥的做法是在endforeach之后再截取整个循环的结果。很多人习惯在循环里累积一个字符串循环结束后再使用对于这种场景更推荐用list(APPEND)配合在循环外事先定义好的变量再通过endforeach之后的set一次性赋值。我的建议是在循环外定义一个列表变量循环内用list(APPEND)不断追加循环结束后再用这个列表变量。list(APPEND)是在修改已有的列表不会触发作用域问题因为列表本身是在父作用域创建的你只是往它里面加元素。4.2 引号与空格问题路径被拆成多个元素CMake的列表既支持路径中带空格也容易被分号误拆。最典型的错误场景是遍历一个路径列表时路径本身包含空格比如C:\Program Files\CMake。set(MY_PATHS C:/Program Files/CMake C:/My Tools/Compiler ) foreach(path IN LISTS MY_PATHS) message(STATUS path${path}) endforeach()上面这段代码能正确遍历因为IN LISTS处理的是分号分隔的列表不会去管元素内部的空格。但如果有人手贱写成foreach(path ${MY_PATHS})在旧式语法下CMake会先展开${MY_PATHS}为一个字符串然后按分号拆开如果路径里有空格但在变量定义时加了双引号一般也没问题。但如果把双引号加在循环变量引用的地方比如foreach(path ${MY_PATHS})问题就出现了CMake会把所有路径合并成一个带有分号的单个字符串最终循环只执行一次。这也是我强烈建议新代码使用IN LISTS语法的原因它明确告诉CMake这是列表不依赖展开后的引号状态。如果你手头还有很多旧式写法继续维护时要多留个心眼。4.3 在循环里修改列表索引错乱与死循环有这样一个场景你想要遍历一个列表然后把其中满足条件的元素删除。很多人第一反应是直接在foreach里套list(REMOVE_ITEM)结果代码跑起来非常诡异或者干脆死循环。set(ITEMS a b c b d) foreach(item IN LISTS ITEMS) if(item STREQUAL b) list(REMOVE_ITEM ITEMS ${item}) endif() endforeach()这段代码的问题是在foreach遍历过程中修改正在遍历的列表迭代器状态会失效。有些情况下会跳到不可预测的元素最坏的情况是无限循环。解决办法是先复制一份原列表遍历副本在循环体内修改原列表。set(ITEMS_COPY ${ITEMS}) foreach(item IN LISTS ITEMS_COPY) if(item STREQUAL b) list(REMOVE_ITEM ITEMS ${item}) endif() endforeach()这个陷阱不仅对foreach有效对任何语言里的容器遍历都适用。在CMake中因为列表就是字符串迭代器失效的表现更隐蔽很多人甚至意识不到是这里出了问题。所以记住一条铁律循环内不要修改被遍历的列表本身要么遍历副本要么换用别的思路。4.4 性能与可读性权衡在CMake脚本里谈性能有点敏感因为绝大多数项目的配置时间都在秒级foreach循环体本身的操作开销远小于CMake启动和编译器调用。但这不代表可以随便乱写。如果你在循环里频繁调用file(GLOB)、execute_process()这类外部进程或者文件系统扫描的命令配置时间可能会成倍增长。我见过一个项目在foreach里写了20次file(GLOB)每次都要扫描磁盘目录结果CMake配置阶段跑了接近一分钟。后来把文件收集挪到循环外一次性收集所有目录的源文件配置时间降到几秒。优化空间是存在的。至于可读性最大的建议是循环变量命名要语义化。不要写foreach(i ...)然后循环体里到处是${i}尤其在多层嵌套时看了一眼根本不知道这个变量代表什么。命名上多花几秒后面排查问题能省大量时间。5. 正确理解foreach和其他CMake命令的分工foreach不是CMake里唯一能处理多目标的手段很多人容易把它和别的命令混淆使用这一节稍微聊聊边界避免误用。5.1 foreach与file(GLOB)的关系file(GLOB)是收集文件的方式foreach是处理文件的方式。前者负责得到列表这一步后者负责遍历列表这一步。它们经常配合使用但不能互相替代。如果你发现自己在foreach里反复用file(GLOB)找文件说明可以把文件收集提到循环外一次搞定然后循环里只处理结果。这个区分非常重要。因为file(GLOB)的结果是配置阶段的固定快照如果在foreach里调用每次循环都重新扫描一次既慢又容易出现难以调试的时序问题。5.2 foreach与function的关系如果你的循环体里要执行一组逻辑而这个逻辑还在很多地方重复使用更合适的做法是把它抽成function然后在foreach里调用function。function(make_module module_name module_dir) file(GLOB sources ${module_dir}/*.cpp) add_library(${module_name} STATIC ${sources}) target_include_directories(${module_name} PUBLIC ${module_dir}) endfunction() foreach(module IN LISTS MODULES) make_module(${module} ${module_dir}) endforeach()这样循环体里只有一行调用逻辑全部封装在函数内维护性立刻上升。很多大型项目的CMake结构都遵循这个模式foreach负责驱动循环function负责一个模块的完整构建规则。两者不冲突配合起来很顺手。5.3 foreach与list命令的配合foreach经常需要和list命令找个搭档。比如你想给列表每个元素加前缀再继续处理set(RAW_ITEMS a b c) set(PREFIXED ) foreach(item IN LISTS RAW_ITEMS) list(APPEND PREFIXED prefix_${item}) endforeach()这种模式在处理依赖名、源文件名时很常见。如果不用foreach就得用list(TRANSFORM)这个命令它可以直接对列表里的每个元素做替换list(TRANSFORM RAW_ITEMS PREPEND prefix_)所以如果你的目的只是批量修改列表内容优先看看list(TRANSFORM)它比foreach更简洁。foreach更适合需要针对每个元素执行多行逻辑的场景而不是简单的一对一映射。CMake自带命令非常多写脚本前先扫一眼官方文档往往能找到比循环更直接的解决方案。这类选型的判断其实是CMake脚本水平的洗牌点。老手和新手的区别不在于会不会用foreach而在于知道什么场景用foreach是合适的什么场景应该用专有命令什么场景应该抽成函数。6. 我实测下来的几个foreach风格建议最后分享几条我在多个项目里沉淀出的个人习惯算不上教科书规范但每次按这套来写出问题的概率都明显下降。第一条尽可能使用IN开头的现代语法。IN LISTS、IN ZIP_LISTS都比老式写法更不容易踩引号坑也更便于后来者理解。第二条循环体保持轻量。两层以上的嵌套每一步逻辑尽量短。如果循环体超过三十行强烈建议把核心逻辑抽到函数里。写脚本不是写算法题可读性永远是第一位的。第三条永远不要在foreach内修改正在遍历的列表。这条规则我没有见过例外如果真的需要删除元素遍历副本改原列表。第四条强制要求代码风格统一。循环变量名、缩进、endforeach后面要不要跟着注释在团队里统一一下省得互相review时为了风格吵个不停。第五条注意CMake版本兼容性。如果你要写一个分发给别人的项目ZIP_LISTS、break()、continue()这些特性需要明确标注最低版本要求。老版本上跑起来要么报错要么行为诡异用户体验很差。foreach说简单很简单说复杂也复杂。它的核心价值不仅是省几行代码而是让构建脚本从枚举式变成描述式。一旦习惯了这种写法你会发现CMake项目的维护成本成倍下降新增模块、调整编译选项这类事都不再是负担。