15. 函数与宏:function、macro、参数传递、返回值模拟

CMake 里的函数和宏,说白了就是帮你把重复的构建逻辑打包起来。我刚开始用 CMake 时,总觉得写个脚本就行了,何必搞这些?后来项目一复杂,几十个目标都要做同样的编译选项处理,我才意识到——嗯,没有抽象,真的会累死人。

15.1 function 与 macro 的区别

先看一个最直观的区别:作用域

  • function() 会创建新的变量作用域。函数内部修改的变量,不会影响外部。
  • macro() 不会创建新作用域。它本质上是代码替换,所有变量都在调用者的作用域里。

我举个例子你就明白了:

# 定义一个函数
function(my_func)
  set(result "inside function")
  message("函数内: ${result}")
endfunction()

# 定义一个宏
macro(my_macro)
  set(result "inside macro")
  message("宏内: ${result}")
endmacro()

set(result "outside")

my_func()
message("调用函数后: ${result}")  # 输出: outside

my_macro()
message("调用宏后: ${result}")   # 输出: inside macro

看到了吗?函数没改掉外部的 result,宏却直接覆盖了。我在项目中遇到过好几次这种坑——用宏定义了一个临时变量,结果不小心把外部的同名变量给冲掉了。从那以后,我养成了一个习惯:能用 function 就别用 macro,除非你真的需要那种“穿透”效果。

核心区别总结:

特性 function macro
作用域 独立作用域 调用者作用域
变量修改 不影响外部 影响外部
性能 稍慢(有调用开销) 快(直接展开)
推荐场景 大多数情况 需要修改外部变量时

15.2 参数传递的几种方式

CMake 的参数传递,说实话,刚开始挺让人困惑的。它不像 C 语言那样有明确的形参列表。你定义函数时,参数名其实只是占位符。

15.2.1 位置参数

最基础的方式:

function(print_info name version)
  message("名称: ${name}")
  message("版本: ${version}")
endfunction()

print_info("MyLib" "2.1.0")

这里 name 对应第一个参数,version 对应第二个。简单直接,但有个问题——如果参数多了,调用方很容易搞错顺序。我个人习惯是:超过 3 个位置参数,就该考虑用关键字参数了。

15.2.2 关键字参数

CMake 没有原生支持关键字参数,但我们可以用 cmake_parse_arguments 来模拟:

include(CMakeParseArguments)

function(configure_target)
  set(options STATIC SHARED)
  set(oneValueArgs NAME OUTPUT_DIR)
  set(multiValueArgs SOURCES DEPENDENCIES)

  cmake_parse_arguments(
    ARG
    "${options}"
    "${oneValueArgs}"
    "${multiValueArgs}"
    ${ARGN}
  )

  if(ARG_STATIC)
    add_library(${ARG_NAME} STATIC ${ARG_SOURCES})
  elseif(ARG_SHARED)
    add_library(${ARG_NAME} SHARED ${ARG_SOURCES})
  endif()

  target_link_libraries(${ARG_NAME} ${ARG_DEPENDENCIES})
endfunction()

# 调用时一目了然
configure_target(
  NAME mylib
  SOURCES src/a.cpp src/b.cpp
  DEPENDENCIES pthread
  STATIC
)

你看,调用方看到 NAMESOURCES 就知道该传什么。我曾经在一个大型项目中重构了所有构建脚本,全部改成这种风格——虽然前期改起来麻烦,但后期维护成本直线下降。

小技巧: ARGN 是 CMake 内置变量,代表所有未命名的剩余参数。用 cmake_parse_arguments 时,一定要把 ${ARGN} 传进去。

15.2.3 可变参数

有时候你希望函数能接受任意数量的参数。比如一个打印所有源文件的函数:

function(print_sources)
  foreach(src ${ARGN})
    message("源文件: ${src}")
  endforeach()
endfunction()

print_sources(a.cpp b.cpp c.cpp)

ARGN 会捕获所有超出形参列表的参数。这里形参列表是空的,所以所有参数都进了 ARGN

15.3 返回值模拟

CMake 的函数不支持 return 一个值。你想想看,这多不方便?我们只能通过一些技巧来“模拟”返回值。

15.3.1 通过变量名传参

最常用的方式:把输出变量的名字作为参数传进去,函数内部通过 set 修改它。

function(get_platform out_var)
  if(WIN32)
    set(${out_var} "windows" PARENT_SCOPE)
  elseif(APPLE)
    set(${out_var} "macos" PARENT_SCOPE)
  else()
    set(${out_var} "linux" PARENT_SCOPE)
  endif()
endfunction()

get_platform(platform)
message("当前平台: ${platform}")  # 输出: 当前平台: linux

注意这里用了 PARENT_SCOPE。因为函数有自己的作用域,不加这个关键字,外部的变量不会被修改。我刚开始学的时候,经常忘记加 PARENT_SCOPE,然后 debug 半天才发现变量根本没变——嗯,这种错误犯过一次就不会再犯了。

15.3.2 返回列表

如果要返回多个值,可以用列表:

function(get_build_info out_var)
  set(info "Release" "x86_64" "c++17")
  set(${out_var} ${info} PARENT_SCOPE)
endfunction()

get_build_info(build_info)
list(GET build_info 0 config)
list(GET build_info 1 arch)
list(GET build_info 2 standard)
message("配置: ${config}, 架构: ${arch}, 标准: ${standard}")

15.3.3 用宏实现“真”返回值

如果你实在受不了 PARENT_SCOPE 的写法,可以用宏——因为它不创建新作用域:

macro(get_compiler_id out_var)
  if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
    set(${out_var} "clang")
  elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
    set(${out_var} "gcc")
  elseif(MSVC)
    set(${out_var} "msvc")
  else()
    set(${out_var} "unknown")
  endif()
endmacro()

get_compiler_id(compiler)
message("编译器: ${compiler}")

宏里不需要 PARENT_SCOPE,因为它直接操作调用者的变量。但正如前面说的,这种“便利”是有代价的——小心变量污染。

避坑指南: 我曾经在一个宏里用了 set(result ...),结果调用者恰好也有一个叫 result 的变量,直接被覆盖了。debug 了整整一下午才找到原因。所以我的建议是:宏内部的临时变量,一定要加独特的前缀,比如 _my_macro_tmp_ 这种。

15.4 知识体系总览

下面这张图帮你理清本章的核心逻辑:

CMake 函数与宏知识体系 function() macro() 特性 独立作用域 · 需 PARENT_SCOPE 推荐大多数场景使用 特性 调用者作用域 · 代码替换 小心变量污染 参数传递方式 位置参数 关键字参数 (cmake_parse_arguments) 可变参数 (ARGN)

15.5 实战建议

说了这么多,最后给几条我在实际项目中的经验:

  1. 默认用 function,除非你明确需要宏的“穿透”特性。这样能避免 90% 的变量污染问题。
  2. 参数超过 2 个就用 cmake_parse_arguments。虽然多写几行代码,但调用方会感谢你。
  3. 返回值用变量名传参 + PARENT_SCOPE。这是 CMake 社区的标准做法,别搞花活。
  4. 函数内部临时变量加前缀,比如 _func_tmp_,防止意外覆盖。

记住,写 CMake 脚本和写 C++ 代码一样——可读性永远比“少写几行”重要。你想想看,三个月后你自己回来看这些脚本,是希望看到清晰的函数调用,还是一坨难以理解的魔法代码?

我的习惯: 每个函数开头都加一段注释,说明参数含义和返回值。虽然 CMake 没有类型检查,但注释能帮你和你的队友少踩很多坑。


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