4. 列表与字符串操作:list()命令详解、字符串处理string()命令、正则表达式在CMake中的应用

说实话,很多CMake初学者都会忽略列表和字符串操作。他们觉得:「不就是变量赋值吗?有什么好学的?」

嗯,我以前也这么想。直到有一次,我需要处理一个包含上百个源文件的跨平台项目,手动拼接路径、过滤文件类型、处理编译器标志……那叫一个痛苦。后来我认真啃了一遍CMake的list()string()命令,才发现原来这些工具能省下我80%的重复劳动。

今天我们就来彻底搞懂它们。你想想看,掌握了这些,你的CMakeLists.txt会变得又短又清晰。

核心观点:CMake中的列表本质上是分号分隔的字符串。所有列表操作,底层都是字符串操作。理解这一点,后面的内容就顺了。

4.1 list()命令:列表的增删改查

先看一个最简单的例子。我在项目中经常需要维护一个源文件列表:

set(SOURCES
    src/main.cpp
    src/utils.cpp
    src/network.cpp
)

# 追加文件
list(APPEND SOURCES src/config.cpp src/log.cpp)

# 获取长度
list(LENGTH SOURCES len)
message("源文件数量: ${len}")  # 输出: 4

这里要注意,list(APPEND ...)可以一次追加多个元素,用空格隔开就行。我个人习惯把每个文件单独一行,这样diff时看得清楚。

4.1.1 常用子命令一览

子命令 作用 示例
APPEND 追加元素到末尾 list(APPEND mylist a b c)
INSERT 在指定位置插入 list(INSERT mylist 1 x y)
REMOVE_ITEM 删除指定值 list(REMOVE_ITEM mylist a)
REMOVE_AT 删除指定索引 list(REMOVE_AT mylist 0 2)
FIND 查找元素索引 list(FIND mylist "value" idx)
GET 获取指定索引的值 list(GET mylist 0 2 out)
JOIN 用分隔符合并成字符串 list(JOIN mylist "," out)
SUBLIST 截取子列表 list(SUBLIST mylist 1 3 out)

我的小技巧:调试时我经常用 message("list: ${mylist}") 来查看列表内容。CMake会以分号分隔的形式打印出来,一目了然。

4.1.2 避坑:空列表与未定义变量

我曾经踩过一个坑:如果一个变量从未被定义,直接对它做list(APPEND ...)会怎样?

# 假设 MY_LIST 从未被 set 过
list(APPEND MY_LIST "hello")
message("${MY_LIST}")  # 输出: hello

CMake会自动把未定义的变量当作空列表处理。这很方便,但也容易隐藏bug。我建议在关键位置用if(DEFINED MY_LIST)做一次检查。

4.2 string()命令:字符串的十八般武艺

string()命令比list()更强大。它几乎覆盖了你日常需要的所有字符串操作:查找、替换、大小写转换、去除空白、正则匹配……

我最早接触string()是在处理编译器版本号的时候。当时需要从CMAKE_CXX_COMPILER_VERSION中提取主版本号,用string(REGEX ...)一行就搞定了。

4.2.1 常用子命令

子命令 说明 示例
TOLOWER / TOUPPER 大小写转换 string(TOLOWER "Hello" out)
LENGTH 获取字符串长度 string(LENGTH "abc" len)
SUBSTRING 截取子串 string(SUBSTRING "hello" 1 3 out)
STRIP 去除首尾空白 string(STRIP " abc " out)
REPLACE 替换子串 string(REPLACE "old" "new" input out)
FIND 查找子串位置 string(FIND "hello" "ll" pos)
REGEX MATCH 正则匹配 string(REGEX MATCH "[0-9]+" input out)
REGEX REPLACE 正则替换 string(REGEX REPLACE "\\s+" " " input out)

注意:CMake中的正则表达式使用POSIX扩展语法。反斜杠需要转义,比如 \\s 表示空白字符。我第一次写的时候忘了双反斜杠,排查了半天……

4.2.2 实战:处理路径与文件名

在跨平台项目中,路径处理是个大麻烦。Windows用反斜杠,Linux用正斜杠。还好CMake提供了cmake_path()命令(3.20+),但如果你还在用老版本,string()也能搞定:

set(FULL_PATH "/home/user/project/src/main.cpp")

# 提取文件名
string(REGEX MATCH "[^/]+$" FILENAME ${FULL_PATH})
message("文件名: ${FILENAME}")  # main.cpp

# 提取目录
string(REGEX REPLACE "/[^/]+$" "" DIR ${FULL_PATH})
message("目录: ${DIR}")  # /home/user/project/src

# 替换后缀
string(REGEX REPLACE "\\.cpp$" ".h" HEADER ${FULL_PATH})
message("对应头文件: ${HEADER}")  # /home/user/project/src/main.h

我个人习惯把这类操作封装成函数,放在一个utils.cmake里,方便复用。

4.3 正则表达式在CMake中的应用

正则表达式是string()命令中最强大的部分。它让你能灵活地匹配和提取文本模式。

为什么会需要正则?举个例子:你想从编译器的输出版本中提取数字部分。用string(FIND)string(SUBSTRING)当然可以,但代码会变得又臭又长。正则一行搞定。

4.3.1 基本语法

CMake的正则支持以下常用结构:

  • . — 匹配任意单个字符
  • * — 匹配前一个字符0次或多次
  • + — 匹配前一个字符1次或多次
  • ? — 匹配前一个字符0次或1次
  • [...] — 字符集,如 [0-9] 匹配数字
  • ^ — 行首
  • $ — 行尾
  • () — 分组捕获

重要:CMake中正则匹配默认是「部分匹配」,只要字符串中某一部分符合模式就算匹配成功。如果你需要「完全匹配」,记得加上 ^$

4.3.2 捕获组的使用

我最常用的场景是从版本号中提取主版本、次版本:

set(VERSION_STR "3.27.5")

string(REGEX MATCH "([0-9]+)\\.([0-9]+)\\.([0-9]+)" _ ${VERSION_STR})

# 注意:CMAKE_MATCH_1, CMAKE_MATCH_2, CMAKE_MATCH_3 存储捕获组
message("主版本: ${CMAKE_MATCH_1}")  # 3
message("次版本: ${CMAKE_MATCH_2}")  # 27
message("补丁版本: ${CMAKE_MATCH_3}")  # 5

这里有个坑:CMAKE_MATCH_<n> 变量只在当前作用域有效。如果你在函数里调用,函数结束后这些变量就没了。我曾经因为这个在子目录里拿不到匹配结果,折腾了好久。

4.3.3 正则替换实战

假设你有一个包含空格和特殊字符的字符串,想把它变成合法的C++标识符:

set(INPUT "My Awesome Library v2.0!")

# 替换非字母数字下划线为下划线
string(REGEX REPLACE "[^a-zA-Z0-9_]" "_" SANITIZED ${INPUT})
message("${SANITIZED}")  # My_Awesome_Library_v2_0_

嗯,这里要注意末尾可能多出一个下划线。你可以再加一步string(REGEX REPLACE "_+$" "" SANITIZED ${SANITIZED})去掉它。

4.4 知识体系总览

下面这张图总结了本章的核心内容。你可以把它当作一个快速参考:

CMake 列表与字符串操作知识体系 list() 命令 • APPEND / INSERT • REMOVE_ITEM / REMOVE_AT • FIND / GET • JOIN / SUBLIST 本质:分号分隔字符串 空列表 = 未定义变量 索引从0开始 string() 命令 • TOLOWER / TOUPPER • LENGTH / SUBSTRING • STRIP / REPLACE • FIND / REGEX MATCH 路径处理常用 大小写转换 空白去除 正则表达式 • . * + ? 基本元字符 • [0-9] 字符集 • ^ $ 行首行尾 • () 捕获组 POSIX扩展语法 CMAKE_MATCH_<n> 部分匹配 vs 完全匹配 核心原则:列表是字符串的变体,正则是最灵活的字符串处理工具

4.5 综合示例:构建一个跨平台源文件过滤器

最后,我把今天讲的内容串起来,写一个实用的例子。假设你有一个项目,需要根据平台和编译器过滤源文件:

# 定义所有源文件
set(ALL_SOURCES
    src/core.cpp
    src/win/win_specific.cpp
    src/linux/linux_specific.cpp
    src/test/test_main.cpp
)

# 1. 过滤掉测试文件(发布构建时)
if(NOT BUILD_TESTING)
    list(FILTER ALL_SOURCES EXCLUDE REGEX ".*/test/.*")
endif()

# 2. 根据平台保留特定文件
if(WIN32)
    list(FILTER ALL_SOURCES INCLUDE REGEX ".*/win/.*|.*/core.*")
elseif(UNIX)
    list(FILTER ALL_SOURCES INCLUDE REGEX ".*/linux/.*|.*/core.*")
endif()

# 3. 检查是否有重复文件
list(REMOVE_DUPLICATES ALL_SOURCES)

# 4. 生成编译单元列表
string(REPLACE "src/" "obj/" OBJECT_LIST "${ALL_SOURCES}")
string(REGEX REPLACE "\\.cpp$" ".o" OBJECT_LIST "${OBJECT_LIST}")

message("最终源文件列表: ${ALL_SOURCES}")
message("对应目标文件: ${OBJECT_LIST}")

你看,list(FILTER ... REGEX ...) 结合了列表操作和正则表达式,一行就能完成复杂的过滤逻辑。这在处理大型项目时特别有用。

我的建议:把常用的列表/字符串操作封装成宏或函数。比如我写了一个 sanitize_filename() 函数,专门用来把任意字符串转成合法的文件名。这样整个项目都能复用,而且改一处就行。

好了,关于列表和字符串操作就讲到这里。记住:list() 管结构,string() 管内容,正则表达式是它们之间的桥梁。掌握了这三样,你的CMake脚本会变得既简洁又强大。


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