14. 4.2 模块与函数复用

引言:施工队的”标准工具箱”

在上一节中,我们学会了如何把一栋大楼的不同区域分包给各个施工小队(add_subdirectory),让项目从”独栋别墅”变成了”规划社区”。但随着社区规模扩大,你可能会发现一个头疼的问题:每个小队都在重复造轮子

A队在写如何开启C++17,B队也在写;C队在配置gtest,D队也要配一遍。这就好比每个施工小队上班前,都要自己亲手打造一把锤子和一副卷尺——不仅浪费时间,而且万一A队的锤子和B队的卷尺规格不一样,整个工地就乱套了。

聪明的包工头会怎么做?他会建立一个标准工具箱:把常用的锤子、扳手、安全操作规程都统一准备好,哪个小队需要就直接领取。在CMake的世界里,这个”标准工具箱”就是模块(Module)函数/宏(Function/Macro)

这一节,我们就来学习如何把重复的CMake代码封装成可复用的组件,让你的构建系统从”手工作坊”升级为”标准化工厂”。

要点1:include指令与模块文件——.cmake文件的编写与加载

在C++里,我们把可复用的代码放到头文件(.h)里,通过#include来引入。CMake也有类似的机制,只不过它的”头文件”扩展名是.cmake

一个.cmake文件里可以包含任意CMake命令:设置变量、定义函数、查找依赖包等等。当你在其他地方使用include(文件名)时,CMake会把那个文件的内容原封不动地复制粘贴到当前位置。

与add_subdirectory的关键区别

很多初学者会混淆includeadd_subdirectory。记住这个比喻:

  • add_subdirectory:在社区里新开一家分店(子项目),它有自己独立的账本(变量作用域)。
  • include:把一本操作手册直接复印一份,塞进当前小队的文件夹里,没有新账本,所有变量都在当前作用域。

因此,include更适合用来加载通用配置片段,而不是管理独立的构建目标。

动手写一个模块

假设我们在项目根目录下新建一个文件夹cmake,里面放一个叫compiler_warnings.cmake的文件:

# cmake/compiler_warnings.cmake
# 这是一个标准工具:给指定目标添加统一的编译警告

function(add_project_warnings target_name)
    if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
        target_compile_options(${target_name} PRIVATE
            -Wall -Wextra -Wpedantic -Werror
        )
    elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
        target_compile_options(${target_name} PRIVATE
            /W4 /WX
        )
    endif()
endfunction()

然后在主CMakeLists.txt或任意子目录中使用它:

# 加载工具手册
include(cmake/compiler_warnings.cmake)

add_executable(my_app main.cpp)

# 直接调用手册里定义的函数
add_project_warnings(my_app)

要点2:CMake模块搜索路径——CMAKE_MODULE_PATH的配置

上面的例子中,我们写了include(cmake/compiler_warnings.cmake),用的是相对路径。但如果模块文件很多,或者分布在不同位置,每次都写一长串路径就很麻烦。CMake提供了一个专门的变量来管理模块的”仓库地址”:CMAKE_MODULE_PATH

CMake在执行include(xxx)时,会按以下顺序寻找文件:

  1. 如果xxx是绝对路径,直接使用。
  2. 如果是相对路径,先在CMAKE_MODULE_PATH列出的目录中查找。
  3. 如果还没找到,再去CMake内置的模块目录(如/usr/share/cmake-3.x/Modules)里查找。

配置自定义模块路径

通常的做法是在项目根目录创建一个cmakecmake/modules文件夹,专门存放这些.cmake工具文件。然后在根目录的CMakeLists.txt最开始就配置好路径:

cmake_minimum_required(VERSION 3.14)
project(MyProject)

# 把项目自己的cmake工具目录加入搜索路径
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

# 现在可以直接用模块名,不用写相对路径了
include(compiler_warnings)
include(utility_helpers)

小贴士:使用list(APPEND ...)而不是set(...),这样可以保留系统或其他父项目已经设置好的路径,实现”追加”而非”覆盖”。

要点3:编写可复用的CMake函数——参数设计与文档规范

函数是标准化工厂里的核心。在CMake中定义函数使用function()命令,它的语法非常直观:

function(函数名 参数1 参数2 ...)
    # 函数体
endfunction()

但CMake的函数和C++函数有一个重要区别:CMake函数没有返回值。你不能写ret = my_func()。那么怎么把结果传出去呢?通常有两种办法:

  • 通过PARENT_SCOPE修改父作用域变量:适合传递单个结果。
  • 直接操作传入的目标名:比如给目标添加属性、源文件等,这是Modern CMake最推荐的方式。

函数作用域演示

function(get_greeting name out_var)
    set(greeting "Hello, ${name}!")
    # 关键点:要加PARENT_SCOPE,否则greeting只是函数内的局部变量
    set(${out_var} "${greeting}" PARENT_SCOPE)
endfunction()

get_greeting("CMake" result)
message(STATUS "结果:${result}")  # 输出:结果:Hello, CMake!

文档规范

为了让团队成员(包括三个月后的你自己)看懂这个函数是干什么的,建议在函数上方写注释:

#[[
    为指定目标配置标准编译选项

    参数:
        target_name - 要配置的目标名称
        cxx_standard - 需要的C++标准,如17、20

    示例:
        setup_standard_target(my_app 20)
#]]
function(setup_standard_target target_name cxx_standard)
    set_target_properties(${target_name} PROPERTIES
        CXX_STANDARD ${cxx_standard}
        CXX_STANDARD_REQUIRED ON
        CXX_EXTENSIONS OFF
    )
endfunction()

要点4:编写可复用的CMake宏——宏与函数的选择场景

CMake里除了function,还有一个叫macro的家伙。乍一看它俩很像,但底层机制截然不同:

  • function(函数):有自己的变量作用域。函数内部set(VAR 1)不会影响到外面,除非加PARENT_SCOPE
  • macro(宏):本质上就是文本替换(类似C语言的宏)。宏里没有独立作用域,它操作的就是调用者所在的位置

选择建议

场景 推荐 原因
需要封装独立逻辑,避免污染外部变量 function 作用域隔离,更安全
需要修改调用者的变量 macro 能直接访问和修改外部变量
需要return/break跳出 macro 宏的break能影响调用者的循环
作为语法糖,包装多个命令 function 逻辑清晰,调试方便

宏的示例

假设我们想写一个宏,批量给多个目标加上相同的包含目录:

macro(add_common_includes)
    # ARGN 是宏接收到的所有未命名参数
    foreach(target ${ARGN})
        target_include_directories(${target} PUBLIC "${CMAKE_SOURCE_DIR}/include")
    endforeach()
endmacro()

# 调用
add_common_includes(app1 app2 app3)

警告:因为宏是文本替换,如果你在宏内部使用了if()return(),可能会产生意想不到的后果。因此Modern CMake的座右铭是:能写函数,就别写宏。

要点5:参数解析——cmake_parse_arguments的完整用法

如果你的函数参数很多,或者有可选参数,靠位置传参(arg1 arg2 arg3)会让调用者很痛苦。想象一下:

# 这是什么意思?完全看不懂!
my_func(app ON 17 "a.cpp;b.cpp" "pthread;dl")

Modern CMake提供了cmake_parse_arguments,让你可以像调用命令行工具一样使用关键字参数

my_func(
    TARGET app
    ENABLE_WARNINGS ON
    STANDARD 17
    SOURCES a.cpp b.cpp
    DEPENDS pthread dl
)

语法详解

cmake_parse_arguments(
    <前缀>
    <布尔选项列表>
    <单值关键字列表>
    <多值关键字列表>
    ${ARGN}
)
  • 布尔选项:出现就是TRUE,没出现就是FALSE(不需要给值)。
  • 单值关键字:后面必须跟一个值。
  • 多值关键字:后面可以跟多个值,解析后会组成一个列表。

完整实战示例

我们来封装一个”万能加目标”函数:

function(add_my_executable)
    # 定义前缀为 ARG
    # 布尔选项:ENABLE_WARNINGS
    # 单值:NAME, CXX_STANDARD
    # 多值:SOURCES, LINK_LIBS
    cmake_parse_arguments(
        ARG
        "ENABLE_WARNINGS"
        "NAME;CXX_STANDARD"
        "SOURCES;LINK_LIBS"
        ${ARGN}
    )

    # 参数校验
    if(NOT ARG_NAME)
        message(FATAL_ERROR "add_my_executable: 必须提供 NAME 参数")
    endif()
    if(NOT ARG_CXX_STANDARD)
        set(ARG_CXX_STANDARD 17)
    endif()

    # 创建目标
    add_executable(${ARG_NAME} ${ARG_SOURCES})

    # 设置C++标准
    set_target_properties(${ARG_NAME} PROPERTIES
        CXX_STANDARD ${ARG_CXX_STANDARD}
        CXX_STANDARD_REQUIRED ON
    )

    # 链接库
    if(ARG_LINK_LIBS)
        target_link_libraries(${ARG_NAME} PRIVATE ${ARG_LINK_LIBS})
    endif()

    # 可选:添加警告
    if(ARG_ENABLE_WARNINGS)
        if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
            target_compile_options(${ARG_NAME} PRIVATE -Wall -Wextra)
        endif()
    endif()
endfunction()

调用方式非常优雅:

add_my_executable(
    NAME my_tool
    SOURCES main.cpp utils.cpp
    LINK_LIBS fmt::fmt
    ENABLE_WARNINGS
    CXX_STANDARD 20
)

注意:解析后的变量名会带上你定义的前缀,比如ARG_NAMEARG_SOURCES。如果调用者传了未定义的关键字,它们会被收集到ARG_UNPARSED_ARGUMENTS变量里,你可以据此报错或做兼容处理。

要点6:命名空间与命名规范——避免命名冲突的策略

当项目越来越大,或者你引入了很多第三方CMake模块时,命名冲突就成了隐形杀手。你的函数名叫setup_target(),第三方库也定义了一个setup_target(),CMake不会报错,但后面的定义会覆盖前面的,导致诡异的构建问题。

因此,从一开始就建立命名规范至关重要。

前缀命名法(推荐)

给你的所有自定义函数、宏和变量都加上项目前缀

# ❌ 不好的命名:太通用,容易冲突
function(setup_warnings target)
function(add_utilities target)

# ✅ 好的命名:带有项目标识
function(myproj_setup_warnings target)
function(myproj_add_utilities target)

# 变量也一样
set(MYPROJ_ENABLE_TESTS ON)
set(MYPROJ_VERSION "1.2.0")

内部辅助函数的标记

如果一个函数只是给模块内部使用的,不应该被外部调用,建议用下划线前缀标明:

# 这是公共API
function(myproj_add_executable)
    ...
endfunction()

# 这是内部实现,不要直接调用
function(_myproj_internal_parse_args)
    ...
endfunction()

变量作用域控制

在模块文件顶层定义的变量是全局的。为了避免污染,尽量:

  1. 在函数内部使用set(VAR value)(局部变量)。
  2. 如果必须在模块顶层设置变量,加上你的项目前缀。
  3. 使用unset(VAR)在不需要时清理临时变量。

总结:从重复劳动到优雅复用

在这一节中,我们给CMake施工队配备了一套完整的标准化工具箱

  • 通过include加载.cmake模块文件,把通用配置拆出去;
  • 利用CMAKE_MODULE_PATH建立模块仓库,不用写冗长的相对路径;
  • 使用function封装独立逻辑,配合PARENT_SCOPE传递结果;
  • 理解macro的文本替换本质,谨慎使用;
  • 掌握cmake_parse_arguments,告别”数参数位置”的痛苦;
  • 建立命名规范,用项目前缀为所有工具打上”防伪标签”。

当你把这些技巧组合起来,你会发现CMakeLists.txt不再是一团乱麻,而是一份清晰、可维护、可扩展的构建蓝图。下一节,我们将走出项目内部,看看如何把手伸向外部的开源世界——学习如何集成和管理外部依赖项目

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……