14. 4.2 模块与函数复用

导语

在上一节中,我们学习了如何通过 add_subdirectory 将大型项目拆分为多个子目录,让每个模块拥有独立的 CMakeLists.txt,从而建立起清晰的目录层级。然而,随着项目规模进一步扩大,你会逐渐发现:不同子目录之间常常需要重复编写相似的配置逻辑。例如,每个可执行文件目标都需要统一设置 C++ 标准、开启一致的编译警告、或者链接同一套第三方库。

如果把这些重复逻辑散布在各处的 CMakeLists.txt 中,一旦需要调整(比如把警告级别从 -Wall 提升到 -Wall -Wextra -Werror),你就得逐个文件修改——这显然违背了”Don’t Repeat Yourself”的原则。

本节要解决的正是这个问题:如何将 CMake 配置逻辑提取为可复用的模块、函数与宏,并在整个项目中安全、规范地共享它们。这是从”会写 CMake”迈向”写好 CMake”的关键一步。

1. include 指令与模块文件

在 C/C++ 中,我们通过 #include 引入头文件来复用声明;在 CMake 中,对应的机制是 include() 指令,用于加载以 .cmake 为后缀的模块文件。被 include 的文件会原地展开,就像把该文件的内容直接复制粘贴到调用处一样。

1.1 基本用法

假设我们在项目根目录下创建一个 cmake/ 目录,用于存放所有的复用模块:

# cmake/CompilerWarnings.cmake
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
    set(MY_PROJECT_WARNINGS
        -Wall
        -Wextra
        -Wpedantic
        -Wshadow
        -Wconversion
    )
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
    set(MY_PROJECT_WARNINGS /W4 /permissive-)
endif()

然后在主 CMakeLists.txt 中引入它:

cmake_minimum_required(VERSION 3.20)
project(MyProject)

# 加载模块
include(cmake/CompilerWarnings.cmake)

add_executable(app main.cpp)
target_compile_options(app PRIVATE ${MY_PROJECT_WARNINGS})

1.2 include 与 add_subdirectory 的核心区别

很多初学者容易混淆 includeadd_subdirectory,它们的关键差异如下:

  • 作用域add_subdirectory 会进入子目录,创建新的变量作用域;而 include 在当前作用域内”原地展开”,不创建新的作用域。这意味着 include 中设置的变量在调用处依然可见(也更容易造成污染,后面会讲如何避免)。
  • 目标归属add_subdirectory 中创建的目标(add_executable/add_library)仍然属于整个构建系统;而 include 的文件只是配置片段,不能单独构成一个子项目。
  • 适用场景add_subdirectory 适合管理有独立源码的组件;include 适合抽取纯配置逻辑工具函数

2. CMake 模块搜索路径

上面的例子中我们使用了相对路径 include(cmake/CompilerWarnings.cmake)。但如果模块文件很多,或者你想像使用官方 FindXXX.cmake 那样直接写 include(MyHelpers),就需要配置模块搜索路径。

2.1 CMAKE_MODULE_PATH 的工作原理

CMake 在解析 include(<mod-name>)find_package(<pkg>)(Module 模式)时,会依次搜索以下路径:

  1. CMake 内置模块目录(如 /usr/share/cmake/Modules
  2. CMAKE_MODULE_PATH 中列出的所有目录

2.2 配置自定义模块路径

最佳实践是在项目根目录的 CMakeLists.txt 顶部,将项目专属的 cmake/ 目录追加到 CMAKE_MODULE_PATH

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

# 现在可以直接通过模块名引入,无需相对路径
include(CompilerWarnings)
include(Utils)

注意:务必使用 list(APPEND ...) 而不是 set(CMAKE_MODULE_PATH ...),否则可能会覆盖其他工具链或父项目已经设置好的路径。

2.3 模块文件命名规范

  • 文件名使用大驼峰(PascalCase),如 CompilerWarnings.cmakeStandardProjectSettings.cmake
  • 文件名应与内容主题一致,避免使用泛泛的名字如 common.cmakeutils.cmake
  • 模块内不应直接创建目标(add_executable/add_library),除非该模块的设计意图就是封装一个目标的创建模板

3. 编写可复用的 CMake 函数

当逻辑复杂到变量替换无法胜任时,就应该使用 function() 将其封装为可调用的命令。函数拥有独立的作用域,参数传递清晰,是 Modern CMake 中组织复用逻辑的首选。

3.1 函数基础与参数访问

CMake 函数的参数没有类型,全部以字符串形式传入,通过位置参数名或 ARGNARGCARGV 等特殊变量访问。

# cmake/TargetHelpers.cmake
function(add_warnings TARGET_NAME)
    # ARGV0 = TARGET_NAME
    # ARGN  = 其余所有参数(本例中没有)
    message(STATUS "为目标 ${TARGET_NAME} 添加编译警告")
    
    if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
        target_compile_options(${TARGET_NAME} PRIVATE
            -Wall -Wextra -Wpedantic
        )
    elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
        target_compile_options(${TARGET_NAME} PRIVATE /W4)
    endif()
endfunction()

调用方式:

add_executable(demo src/demo.cpp)
add_warnings(demo)

3.2 函数的作用域规则

函数内部通过 set() 创建的变量默认只在函数内部可见,不会污染调用方。如果需要返回值,可以通过 PARENT_SCOPE 显式提升:

function(get_source_files RESULT_VAR)
    set(${RESULT_VAR} main.cpp utils.cpp helper.cpp PARENT_SCOPE)
endfunction()

get_source_files(MY_SOURCES)
message(STATUS "源码列表: ${MY_SOURCES}")  # 输出: main.cpp;utils.cpp;helper.cpp

重要提示PARENT_SCOPE 只会向上提升一层。如果你在函数 A 中调用函数 B,B 中设置 PARENT_SCOPE,则变量会提升到 A 的作用域,而不是 A 的调用方。

3.3 函数文档规范

为了让团队其他成员(以及三个月后的你自己)能快速理解函数的用途,建议在函数上方添加规范的注释头:

#[=======================================================================[
@brief 为目标统一添加项目标准的编译警告
@param TARGET_NAME 需要添加警告的目标名称
@usage
    add_warnings(my_target)
#]=======================================================================]
function(add_warnings TARGET_NAME)
    # 实现...
endfunction()

4. 编写可复用的 CMake 宏

宏(macro())与函数(function())在外部调用时几乎看不出区别,但内部机制截然不同。

4.1 宏的本质

宏不是一次函数调用,而是文本替换。宏体内的代码直接在调用者的作用域中执行,因此宏内设置的变量会直接出现在调用方,不需要 PARENT_SCOPE

macro(set_project_version MAJOR MINOR PATCH)
    set(PROJECT_VERSION_MAJOR ${MAJOR})
    set(PROJECT_VERSION_MINOR ${MINOR})
    set(PROJECT_VERSION_PATCH ${PATCH})
    set(PROJECT_VERSION "${MAJOR}.${MINOR}.${PATCH}")
endmacro()

# 调用后,这四个变量直接存在于当前作用域
set_project_version(1 2 3)
message(STATUS "Version: ${PROJECT_VERSION}")  # 输出 1.2.3

4.2 函数 vs 宏:如何选择?

特性 function macro
作用域 新建独立作用域 在调用者作用域执行
变量隔离 内部变量默认不泄露 内部变量直接暴露
return() 只退出函数 会退出整个 CMake 文件(危险)
适用场景 封装工具逻辑、目标操作 注册一组变量或简化重复声明

建议:在 Modern CMake 实践中,优先使用 function。宏的作用域穿透特性容易引发难以调试的变量污染问题,只在需要”让调用者获得一组变量”这类特殊场景下谨慎使用。

5. 参数解析:cmake_parse_arguments

当函数需要接收命名参数、可选开关或多值列表时,手动处理 ARGN 会非常繁琐且易错。CMake 提供的 cmake_parse_arguments() 命令(CMake 3.5+)正是解决这一痛点的利器。

5.1 基本语法

cmake_parse_arguments(
    <prefix>
    <options>           # 布尔开关,无值参数
    <one_value_keywords> # 单值参数
    <multi_value_keywords> # 多值参数
    <args_to_parse>      # 要解析的参数,通常是 ${ARGN}
)

5.2 完整实战示例

假设我们要写一个 add_my_library 函数,支持以下调用方式:

add_my_library(
    NAME        mylib
    TYPE        SHARED
    SOURCES     a.cpp b.cpp c.cpp
    DEPS        fmt::fmt Eigen3::Eigen
    WARNINGS_AS_ERRORS
)

模块实现如下:

# cmake/AddMyLibrary.cmake
function(add_my_library)
    # 1. 定义参数规格
    set(options WARNINGS_AS_ERRORS NO_INSTALL)
    set(oneValueArgs NAME TYPE)
    set(multiValueArgs SOURCES DEPS INCLUDES)

    # 2. 解析参数
    cmake_parse_arguments(
        ARG                         # 前缀,解析后的变量都以 ARG_ 开头
        "${options}"                # 布尔选项
        "${oneValueArgs}"           # 单值关键词
        "${multiValueArgs}"         # 多值关键词
        ${ARGN}                     # 要解析的原始参数
    )

    # 3. 参数校验
    if(NOT ARG_NAME)
        message(FATAL_ERROR "add_my_library: 必须指定 NAME 参数")
    endif()
    
    if(NOT ARG_TYPE)
        set(ARG_TYPE STATIC)  # 默认值
    endif()

    if(NOT ARG_SOURCES)
        message(FATAL_ERROR "add_my_library: 必须至少提供一个源文件")
    endif()

    # 4. 创建目标
    add_library(${ARG_NAME} ${ARG_TYPE} ${ARG_SOURCES})

    # 5. 处理依赖
    if(ARG_DEPS)
        target_link_libraries(${ARG_NAME} PUBLIC ${ARG_DEPS})
    endif()

    # 6. 处理包含目录
    if(ARG_INCLUDES)
        target_include_directories(${ARG_NAME} PUBLIC ${ARG_INCLUDES})
    endif()

    # 7. 处理布尔开关
    if(ARG_WARNINGS_AS_ERRORS)
        if(MSVC)
            target_compile_options(${ARG_NAME} PRIVATE /WX)
        else()
            target_compile_options(${ARG_NAME} PRIVATE -Werror)
        endif()
    endif()

    # 8. 处理另一个布尔开关
    if(NOT ARG_NO_INSTALL)
        install(TARGETS ${ARG_NAME}
            LIBRARY DESTINATION lib
            ARCHIVE DESTINATION lib
            RUNTIME DESTINATION bin
        )
    endif()
endfunction()

5.3 未识别参数的处理

如果调用者传入了未定义的关键词(比如拼写错误 SOURCE 而不是 SOURCES),cmake_parse_arguments 会将其收集到 <prefix>_UNPARSED_ARGUMENTS 变量中。建议在函数末尾进行检查:

    if(ARG_UNPARSED_ARGUMENTS)
        message(WARNING "add_my_library: 未识别的参数 ${ARG_UNPARSED_ARGUMENTS}")
    endif()
endfunction()

5.4 旧版兼容注意事项

在 CMake 3.4 及更早版本中,cmake_parse_arguments 位于 CMakeParseArguments 模块中,需要先 include(CMakeParseArguments)。CMake 3.5+ 已将其作为内置命令,无需 include。考虑到本系列面向现代 CMake,我们建议直接要求 CMake 3.15+。

6. 命名空间与命名规范

当项目中有数十个模块、上百个自定义函数时,命名冲突的风险会急剧上升。CMake 没有 C++ 的 namespace 关键字,因此需要通过命名约定来模拟命名空间。

6.1 前缀命名法(Pseudo-Namespace)

这是业界最广泛采用的策略:为项目中所有的自定义函数、宏、全局变量添加统一的项目前缀。

# 不推荐
function(add_warnings target) ... endfunction()

# 推荐
function(myproj_add_warnings target) ... endfunction()

# 对于通用工具模块,可以使用模块名作为前缀
function(util_add_test NAME SOURCES) ... endfunction()
function(util_generate_export_header TARGET) ... endfunction()

如果项目缩写是 MYPROJ,那么所有函数应以 myproj_ 开头;如果是公司内部共享的通用 CMake 工具库,可以使用更独特的前缀如 company_cmake_

6.2 变量名的隔离策略

模块内部使用的临时变量,应尽量避免使用通用名字如 TEMPRESULTFLAGS。推荐做法:

  • 在函数内部,优先使用函数参数名或 cmake_parse_arguments 的前缀机制(如 ARG_NAME
  • 在模块顶层(include 的文件中),使用 _myproj_internal_ 前缀标记内部变量
  • 对于 cache 变量,务必添加项目前缀,如 MYPROJ_BUILD_TESTS
# cmake/InternalHelpers.cmake
set(_myproj_internal_compiler_checked FALSE CACHE INTERNAL "")

if(NOT _myproj_internal_compiler_checked)
    # 执行一次性检查...
    set(_myproj_internal_compiler_checked TRUE CACHE INTERNAL "")
endif()

6.3 避免修改全局状态

一个设计良好的模块应当尽可能地”无副作用”。避免在模块文件中直接修改以下全局变量,除非这正是该模块的设计目的:

  • CMAKE_CXX_FLAGS(应使用 target_compile_options
  • CMAKE_MODULE_PATH(修改后应在文档中明确说明)
  • CMAKE_PREFIX_PATH
  • 各种 CMAKE_*_OUTPUT_DIRECTORY(如果修改,应提供选项开关)

6.4 目录层级的模块可见性

在大型项目中,不同层级的子项目可能需要不同版本的”同名”辅助函数。此时可以通过不将辅助目录加入 CMAKE_MODULE_PATH,而是使用相对路径 include 来控制可见性:

# 子项目 A 使用自己的版本
add_subdirectory(libs/A)
# libs/A/CMakeLists.txt
include(cmake/AHelpers.cmake)  # 仅影响 A 及其子目录

# 子项目 B 使用自己的版本
add_subdirectory(libs/B)
# libs/B/CMakeLists.txt  
include(cmake/BHelpers.cmake)

7. 综合实战:编写一个项目标准初始化模块

最后,让我们将本节所有知识融会贯通,编写一个实际项目中常见的 StandardProjectSettings.cmake 模块。该模块的目标是让主 CMakeLists.txt 只需一行 include 就能获得 Modern CMake 的最佳实践初始化。

# cmake/StandardProjectSettings.cmake
#
# @brief 为本项目提供统一的初始化设置,包括:
#        - 默认构建类型
#        - C++ 标准
#        - IPO/LTO 支持检测
#        - 统一的输出目录

include_guard(GLOBAL)  # 防止重复 include

# ---------------------------------------------------------
# 1. 默认构建类型
# ---------------------------------------------------------
function(myproj_set_default_build_type)
    set(default_build_type "RelWithDebInfo")
    if(EXISTS "${CMAKE_SOURCE_DIR}/.git")
        set(default_build_type "Debug")
    endif()
    
    if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
        message(STATUS "设置默认构建类型为 ${default_build_type}")
        set(CMAKE_BUILD_TYPE "${default_build_type}" CACHE STRING "选择构建类型" FORCE)
        set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
            "Debug" "Release" "MinSizeRel" "RelWithDebInfo"
        )
    endif()
endfunction()

# ---------------------------------------------------------
# 2. C++ 标准
# ---------------------------------------------------------
function(myproj_set_cxx_standard TARGET_NAME)
    set_target_properties(${TARGET_NAME} PROPERTIES
        CXX_STANDARD 17
        CXX_STANDARD_REQUIRED YES
        CXX_EXTENSIONS NO
    )
endfunction()

# ---------------------------------------------------------
# 3. 输出目录统一
# ---------------------------------------------------------
macro(myproj_set_output_directories)
    set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
    set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
    set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
endmacro()

# ---------------------------------------------------------
# 4. IPO/LTO 检测与启用
# ---------------------------------------------------------
function(myproj_enable_ipo_if_supported TARGET_NAME)
    include(CheckIPOSupported)
    check_ipo_supported(RESULT result OUTPUT output)
    if(result)
        set_property(TARGET ${TARGET_NAME} PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
    else()
        message(WARNING "IPO/LTO 不被当前编译器支持: ${output}")
    endif()
endfunction()

# ---------------------------------------------------------
# 模块被 include 时自动执行的初始化
# ---------------------------------------------------------
myproj_set_default_build_type()
myproj_set_output_directories()

message(STATUS "StandardProjectSettings 模块加载完成")

主项目使用:

cmake_minimum_required(VERSION 3.20)
project(MyAwesomeApp VERSION 1.0.0)

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(StandardProjectSettings)

add_executable(app main.cpp)
myproj_set_cxx_standard(app)
myproj_enable_ipo_if_supported(app)

小结

本节我们系统学习了 CMake 项目组织中的复用技术:

  • 使用 include 加载 .cmake 模块文件,将纯配置逻辑从 CMakeLists.txt 中剥离。
  • 通过 CMAKE_MODULE_PATH 管理模块搜索路径,实现”按名引入”的便捷体验。
  • 使用 function 封装带独立作用域的可复用逻辑,配合 PARENT_SCOPE 实现可控的返回值。
  • 理解 macro 的文本替换本质,谨慎使用,优先选择 function
  • 掌握 cmake_parse_arguments,为自定义函数赋予类似内置命令的声明式参数接口。
  • 建立命名空间规范,通过统一前缀、内部变量隔离和避免全局副作用,确保模块化架构的健壮性。

掌握了这些技能,你的 CMake 代码将不再是一堆 sprawling 的线性脚本,而是一个结构清晰、易于维护、可随处复用的构建系统。下一节,我们将把目光投向更广阔的生态系统——如何使用 ExternalProjectFetchContent 集成外部项目,让你的工程轻松借力开源社区。

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……