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
)
你看,调用方看到 NAME、SOURCES 就知道该传什么。我曾经在一个大型项目中重构了所有构建脚本,全部改成这种风格——虽然前期改起来麻烦,但后期维护成本直线下降。
小技巧: 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 知识体系总览
下面这张图帮你理清本章的核心逻辑:
15.5 实战建议
说了这么多,最后给几条我在实际项目中的经验:
- 默认用 function,除非你明确需要宏的“穿透”特性。这样能避免 90% 的变量污染问题。
- 参数超过 2 个就用 cmake_parse_arguments。虽然多写几行代码,但调用方会感谢你。
- 返回值用变量名传参 + PARENT_SCOPE。这是 CMake 社区的标准做法,别搞花活。
- 函数内部临时变量加前缀,比如
_func_tmp_,防止意外覆盖。
记住,写 CMake 脚本和写 C++ 代码一样——可读性永远比“少写几行”重要。你想想看,三个月后你自己回来看这些脚本,是希望看到清晰的函数调用,还是一坨难以理解的魔法代码?
我的习惯: 每个函数开头都加一段注释,说明参数含义和返回值。虽然 CMake 没有类型检查,但注释能帮你和你的队友少踩很多坑。
公众号:蓝海资料掘金营,微信deep3321