4. 列表与字符串操作:list()命令详解、字符串操作、正则表达式匹配、文件路径操作

说实话,CMake 里最容易被低估的两个东西,就是列表和字符串。很多人觉得 CMake 就是个构建系统,能写变量、调库就够了。但我在实际项目中踩过不少坑,发现如果不懂列表和字符串操作,写出来的 CMakeLists.txt 要么又臭又长,要么根本跑不通。

这一章,我们就来彻底搞懂它们。

核心观点:CMake 中一切皆字符串,列表只是分号分隔的字符串。理解这一点,后面所有操作都顺了。

4.1 list() 命令详解

我个人习惯把 list() 当成 CMake 里的“数组”来用。虽然它本质上还是字符串,但操作起来非常顺手。

4.1.1 基本操作:增删改查

先看几个最常用的子命令:

# 创建一个列表
set(MY_LIST "a" "b" "c")

# 获取长度
list(LENGTH MY_LIST len)
message("长度: ${len}")  # 输出: 3

# 获取某个索引的值(从0开始)
list(GET MY_LIST 1 item)
message("索引1: ${item}")  # 输出: b

# 追加元素
list(APPEND MY_LIST "d" "e")
message("追加后: ${MY_LIST}")  # 输出: a;b;c;d;e

# 在开头插入
list(INSERT MY_LIST 0 "z")
message("插入后: ${MY_LIST}")  # 输出: z;a;b;c;d;e

# 删除元素
list(REMOVE_ITEM MY_LIST "b")
message("删除b后: ${MY_LIST}")  # 输出: z;a;c;d;e

# 按索引删除
list(REMOVE_AT MY_LIST 0)
message("删除索引0后: ${MY_LIST}")  # 输出: a;c;d;e

嗯,这里要注意:REMOVE_ITEM 是按值删除,REMOVE_AT 是按索引删除。我曾经在项目里搞混过,结果删错了文件列表,编译直接报错。那场面,挺尴尬的。

4.1.2 查找与排序

查找元素在不在列表里,用 FIND

set(FRUITS "apple" "banana" "cherry")
list(FIND FRUITS "banana" idx)
if(idx GREATER -1)
    message("找到了,索引: ${idx}")  # 输出: 1
endif()

排序也很简单:

set(NUMS "3" "1" "4" "1" "5" "9")
list(SORT NUMS)
message("排序后: ${NUMS}")  # 输出: 1;1;3;4;5;9

默认是升序。想降序?加个 COMPARE 参数就行。不过说实话,我很少在 CMake 里做排序,更多是处理源文件列表时用 FILTER 做过滤。

4.1.3 列表去重与反转

set(DUP "a" "b" "a" "c" "b")
list(REMOVE_DUPLICATES DUP)
message("去重后: ${DUP}")  # 输出: a;b;c

list(REVERSE DUP)
message("反转后: ${DUP}")  # 输出: c;b;a

去重这个功能,我在处理第三方库的依赖列表时经常用。有时候多个模块重复添加了同一个库,不去重的话链接阶段会报一堆警告。

4.2 字符串操作

字符串操作在 CMake 里比列表更常用。毕竟,你写的每个变量本质上都是字符串。

4.2.1 查找与替换

set(TEXT "Hello, CMake World!")

# 查找子串
string(FIND "${TEXT}" "CMake" pos)
message("CMake 的位置: ${pos}")  # 输出: 7

# 替换
string(REPLACE "World" "CMake" NEW_TEXT "${TEXT}")
message("替换后: ${NEW_TEXT}")  # 输出: Hello, CMake CMake!

注意:FIND 返回的是第一个匹配的位置,从0开始。如果没找到,返回 -1。我刚开始用的时候,忘了检查返回值,结果拿 -1 去截取字符串,直接崩了。

4.2.2 截取与拼接

set(PATH "/usr/local/include/foo.h")

# 截取子串:从索引5开始,取4个字符
string(SUBSTRING "${PATH}" 5 4 part)
message("子串: ${part}")  # 输出: loca

# 拼接
string(CONCAT PREFIX "/usr" SUFFIX "/local")
message("拼接: ${PREFIX}")  # 输出: /usr/local

# 用分隔符拼接
string(JOIN ":" PARTS "a" "b" "c")
message("JOIN: ${PARTS}")  # 输出: a:b:c

我个人习惯用 CONCAT 而不是直接 set() 拼接,因为 CONCAT 不会自动加空格,更可控。

4.2.3 大小写与前后缀

set(NAME "cmake")

# 转大写
string(TOUPPER "${NAME}" UPPER)
message("大写: ${UPPER}")  # 输出: CMAKE

# 转小写
string(TOLOWER "${NAME}" LOWER)
message("小写: ${LOWER}")  # 输出: cmake

# 去除前后空格
string(STRIP "  hello  " STRIPPED)
message("去除空格: '${STRIPPED}'")  # 输出: 'hello'

# 检查前后缀
if("${NAME}" MATCHES "^cm")
    message("以 cm 开头")
endif()

if("${NAME}" MATCHES "ke$")
    message("以 ke 结尾")
endif()

这里有个小技巧:用 STRIP 处理用户输入的路径,能避免很多因为多余空格导致的诡异问题。

4.3 正则表达式匹配

正则表达式是 CMake 字符串操作的进阶玩法。说实话,CMake 的正则语法和标准正则略有不同,但核心功能都有。

4.3.1 基本匹配

set(EMAIL "user@example.com")

# 检查是否匹配邮箱格式
string(REGEX MATCH "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" RESULT "${EMAIL}")
if(RESULT)
    message("邮箱格式正确")
else()
    message("邮箱格式错误")
endif()

注意:CMake 的正则里,反斜杠需要转义。比如 \\. 表示匹配一个点号。我第一次写的时候忘了转义,结果匹配了一堆乱七八糟的东西。

4.3.2 提取匹配组

set(URL "https://cmake.org/cmake/help/latest/")

# 提取协议和域名
string(REGEX MATCHALL "^(https?)://([^/]+)" MATCHES "${URL}")
if(MATCHES)
    message("完整匹配: ${CMAKE_MATCH_0}")  # 输出: https://cmake.org
    message("协议: ${CMAKE_MATCH_1}")      # 输出: https
    message("域名: ${CMAKE_MATCH_2}")      # 输出: cmake.org
endif()

CMAKE_MATCH_0CMAKE_MATCH_9 是 CMake 自动填充的匹配组变量。这个特性我在解析编译选项时经常用,比如从 -DDEBUG=1 里提取出 DEBUG1

4.3.3 替换与过滤

set(TEXT "foo123bar456baz")

# 替换所有数字为 #
string(REGEX REPLACE "[0-9]+" "#" RESULT "${TEXT}")
message("替换后: ${RESULT}")  # 输出: foo#bar#baz

# 过滤出所有数字
string(REGEX MATCHALL "[0-9]+" NUMBERS "${TEXT}")
message("数字列表: ${NUMBERS}")  # 输出: 123;456

过滤功能配合 list() 使用效果更佳。比如从一堆源文件里只提取 .cpp 文件:

set(FILES "a.cpp" "b.h" "c.cpp" "d.hpp")
foreach(FILE IN LISTS FILES)
    string(REGEX MATCH "\\.cpp$" MATCHED "${FILE}")
    if(MATCHED)
        list(APPEND CPP_FILES "${FILE}")
    endif()
endforeach()
message("CPP文件: ${CPP_FILES}")  # 输出: a.cpp;c.cpp

小提示:如果你需要频繁做正则匹配,建议把正则表达式定义成变量,方便复用。比如 set(CPP_REGEX "\\.cpp$"),后面直接引用。

4.4 文件路径操作

文件路径操作是 CMake 里最实用的功能之一。我敢说,90% 的 CMakeLists.txt 都会用到。

4.4.1 获取路径信息

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

# 获取文件名
get_filename_component(FILENAME "${FULL_PATH}" NAME)
message("文件名: ${FILENAME}")  # 输出: main.cpp

# 获取不带扩展名的文件名
get_filename_component(BASENAME "${FULL_PATH}" NAME_WE)
message("基础名: ${BASENAME}")  # 输出: main

# 获取扩展名
get_filename_component(EXT "${FULL_PATH}" EXT)
message("扩展名: ${EXT}")  # 输出: .cpp

# 获取目录路径
get_filename_component(DIR "${FULL_PATH}" DIRECTORY)
message("目录: ${DIR}")  # 输出: /home/user/project/src

# 获取绝对路径(相对路径转绝对)
get_filename_component(ABS_PATH "relative/path" ABSOLUTE)
message("绝对路径: ${ABS_PATH}")

我曾经在项目里用 NAME_WE 来生成目标名称。比如 main.cpp 生成 main 目标,这样就不用手动写目标名了。

4.4.2 路径拼接与规范化

# 路径拼接
set(BASE_DIR "/usr/local")
set(SUBDIR "include")
set(FILENAME "foo.h")

# 推荐用 file() 拼接
file(JOIN RESULT_PATH "${BASE_DIR}" "${SUBDIR}" "${FILENAME}")
message("拼接: ${RESULT_PATH}")  # 输出: /usr/local/include/foo.h

# 路径规范化(去除 .. 和 .)
set(MESSY_PATH "/usr/local/../include/./foo.h")
file(REAL_PATH "${MESSY_PATH}" CLEAN_PATH)
message("规范化: ${CLEAN_PATH}")  # 输出: /usr/include/foo.h

注意:file(REAL_PATH) 要求路径必须存在,否则会报错。如果只是做字符串级别的规范化,可以用 get_filename_component(... REALPATH)

4.4.3 相对路径与绝对路径

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

# 计算相对路径
file(RELATIVE_PATH REL "${BASE}" "${TARGET}")
message("相对路径: ${REL}")  # 输出: src/main.cpp

# 判断是否为绝对路径
if(IS_ABSOLUTE "${TARGET}")
    message("是绝对路径")
else()
    message("不是绝对路径")
endif()

这个 RELATIVE_PATH 我在写跨平台项目时经常用。不同模块的路径不一样,用相对路径可以保持 CMakeLists.txt 的可移植性。

4.5 知识体系总览

下面这张图总结了本章的核心内容,方便你快速回顾:

列表与字符串操作知识体系 list() 命令 增删改查 查找与排序 去重与反转 过滤与变换 字符串操作 查找与替换 截取与拼接 大小写转换 前后缀处理 正则表达式 基本匹配 提取匹配组 替换与过滤 CMAKE_MATCH 变量 文件路径操作 路径信息提取 路径拼接 规范化 相对/绝对路径 核心思想:CMake 中一切皆字符串,列表是分号分隔的字符串 实践建议:多用 file() 处理路径,少用手动拼接;正则表达式先测试再使用

4.6 避坑指南与最佳实践

最后,分享几个我踩过的坑:

坑1:列表中的空元素

我曾经在循环里往列表追加空字符串,结果列表长度变大了,但元素是空的。用 list(REMOVE_ITEM list_name "") 可以清理掉。

坑2:正则中的反斜杠

CMake 里写正则,反斜杠要双写。比如匹配点号要写 \\.,而不是 \.。我刚开始写的时候,经常因为这个匹配失败。

最佳实践:

  • 路径操作优先用 file() 系列命令,而不是手动拼接字符串
  • 正则表达式先写在小脚本里测试,再放到 CMakeLists.txt 中
  • 列表操作时,注意变量作用域。用 PARENT_SCOPE 把结果传出去
  • 字符串比较用 STREQUAL 而不是 EQUAL,后者只适用于数字

好了,这一章的内容就到这里。列表和字符串操作是 CMake 的基石,掌握好了,后面写复杂的构建脚本会顺手很多。


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