CMake 系列(十七):用函数和宏重用代码

任何编程语言中,函数都允许我们抽象(隐藏)细节并避免代码重复,CMake 也不例外。本篇将以宏和函数为例进行讨论,并介绍一个宏,用于方便地定义测试并设置测试的顺序。我们的目标是定义一个宏,用来替换 add_test 和 set_tests_properties,从而定义每个测试并设置每个测试的预期开销。

注意:本示例在 CMake 3.5 或更高版本中有效,并且已经在 GNU/Linux、macOS 和 Windows 上进行过测试。

准备工作

我们将基于此前的整数求和示例。main.cpp、sum_integers.cpp 和 sum_integers.hpp 保持不变,用来计算命令行参数提供的整数序列的和。单元测试(test.cpp)的源代码也没有改变。此外还需要 Catch 2 的头文件 catch.hpp。与之前不同的是,这次我们把源文件放到子目录中,形成如下文件树(CMake 代码稍后讨论):

.
├── CMakeLists.txt
├── src
│     ├── CMakeLists.txt
│     ├── main.cpp
│     ├── sum_integers.cpp
│     └── sum_integers.hpp
└── tests
      ├── catch.hpp
      ├── CMakeLists.txt
      └── test.cpp

具体实施

首先,声明 CMake 最低版本、项目名称和支持的语言,并要求支持 C++11 标准:

cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

根据 GNU 标准定义二进制文件和库的输出目录:

include(GNUInstallDirs)

set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

最后,使用 add_subdirectory 引入 src/CMakeLists.txt 和 tests/CMakeLists.txt:

add_subdirectory(src)

enable_testing()

add_subdirectory(tests)

src/CMakeLists.txt 定义了源码目标:

set(CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE ON)

add_library(sum_integers sum_integers.cpp)

add_executable(sum_up main.cpp)

target_link_libraries(sum_up sum_integers)

tests/CMakeLists.txt 中,构建并链接 cpp_test 可执行文件:

add_executable(cpp_test test.cpp)

target_link_libraries(cpp_test sum_integers)

然后定义一个新宏 add_catch_test:

macro(add_catch_test _name _cost)

  math(EXPR num_macro_calls "${num_macro_calls} + 1")

  message(STATUS "add_catch_test called with ${ARGC} arguments: ${ARGV}")

  set(_argn "${ARGN}")
  if(_argn)
    message(STATUS "oops - macro received argument(s) we did not expect: ${ARGN}")
  endif()

  add_test(
    NAME
      ${_name}
    COMMAND
      $<TARGET_FILE:cpp_test>
      [${_name}] --success --out
      ${PROJECT_BINARY_DIR}/tests/${_name}.log --durations yes
    WORKING_DIRECTORY
      ${CMAKE_CURRENT_BINARY_DIR}
    )

  set_tests_properties(
    ${_name}
    PROPERTIES
      COST ${_cost}
    )

endmacro()

接着,使用 add_catch_test 定义两个测试,并设置和打印变量值:

set(num_macro_calls 0)

add_catch_test(short 1.5)
add_catch_test(long 2.5 extra_argument)

message(STATUS "in total there were ${num_macro_calls} calls to add_catch_test")

现在进行测试。配置项目,可以看到如下输出:

$ mkdir -p build
$ cd build
$ cmake ..
-- add_catch_test called with 2 arguments: short;1.5
-- add_catch_test called with 3 arguments: long;2.5;extra_argument
-- oops - macro received argument(s) we did not expect: extra_argument
-- in total there were 2 calls to add_catch_test

最后,构建并运行测试:

$ cmake --build .
$ ctest

得益于 COST 属性,开销大的测试会先执行:

Start 2: long
1/2 Test #2: long ............................. Passed 0.00 sec
Start 1: short
2/2 Test #1: short ............................ Passed 0.00 sec

100% tests passed, 0 tests failed out of 2

工作原理

这个配置中新增了 add_catch_test 宏。这个宏需要两个参数 _name 和 _cost,在宏中使用这两个参数来调用 add_test 和 set_tests_properties。参数前面的下划线,是为了向读者表明这些参数只能在宏内部访问。另外,宏会自动填充 ${ARGC}(参数数量)和 ${ARGV}(参数列表),我们可以在输出中验证这一点:

-- add_catch_test called with 2 arguments: short;1.5
-- add_catch_test called with 3 arguments: long;2.5;extra_argument

宏还定义了 ${ARGN},用于保存最后一个参数之后的参数列表。此外,还可以使用 ${ARGV0}、${ARGV1} 等来处理各个参数。我们演示一下,如何捕捉调用中多出来的额外参数(extra_argument):

add_catch_test(long 2.5 extra_argument)

我们使用了以下方法:

set(_argn "${ARGN}")
if(_argn)
    message(STATUS "oops - macro received argument(s) we did not expect: ${ARGN}")
endif()

在这个 if 语句中,我们引入了一个新变量,但不能直接查询 ARGN,因为它不是通常意义上的 CMake 变量。使用这个宏,我们可以通过名称和命令来定义测试,还可以指示预期的开销——得益于 COST 属性,开销大的测试会在开销小的测试之前启动。

我们也可以用一个函数来实现它,而不是使用相同语法的宏:

function(add_catch_test _name _cost)
    ...
endfunction()

宏和函数之间的区别在于它们的变量作用域。宏在调用者的作用域中执行,而函数有自己的变量作用域。换句话说,如果需要设置或修改调用者可用的变量,就使用宏;如果不设置或修改输出变量,最好使用函数。我们注意到,可以在函数中修改父作用域的变量,但这必须使用 PARENT_SCOPE 关键字显式表示:

set(variable_visible_outside "some value" PARENT_SCOPE)

为了演示作用域,我们在定义宏之后编写了以下调用:

set(num_macro_calls 0)

add_catch_test(short 1.5)
add_catch_test(long 2.5 extra_argument)

message(STATUS "in total there were ${num_macro_calls} calls to add_catch_test")

在宏内部,将 num_macro_calls 加 1:

math(EXPR num_macro_calls "${num_macro_calls} + 1")

这时产生的输出:

-- in total there were 2 calls to add_catch_test

如果我们将宏更改为函数,测试仍然可以工作,但 num_macro_calls 在父作用域中的所有调用里始终为 0。把 CMake 宏想象成类似函数是很有用的,这些函数被直接替换到它们被调用的地方(类似 C 语言中的内联)。而把 CMake 函数想象成黑盒函数则很有必要:在黑盒中,除非使用 PARENT_SCOPE 显式声明,否则不会返回任何内容。CMake 中的函数没有返回值。

更多信息

可以在宏中嵌套函数调用,也可以在函数中嵌套宏调用,但这需要仔细考虑变量的作用域。如果某个功能可以用函数实现,那么函数通常比宏更好,因为它对父作用域状态提供了更多的默认控制。

我们还应该提到 src/CMakeLists.txt 中使用的 CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE:

set(CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE ON)

这个命令会将当前目录添加到 CMakeLists.txt 中所定义所有目标的 INTERFACE_INCLUDE_DIRECTORIES 属性中。换句话说,我们不需要使用 target_include_directories 来指定 cpp_test 所需头文件的位置。

系列导航


   转载规则


《CMake 系列(十七):用函数和宏重用代码》 由 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
CMake 系列(十八):将 CMake 源码分成模块 CMake 系列(十八):将 CMake 源码分成模块
项目通常从单个 CMakeLists.txt 文件开始,随着时间的推移,这个文件会逐渐增长。本篇演示一种将 CMakeLists.txt 拆分成更小单元的机制。将 CMakeLists.txt 拆分为模块有几个动机,这些模块可以包含在主 C
2022-07-12
下一篇 
CMake 系列(十六):用 Google Test 做单元测试 CMake 系列(十六):用 Google Test 做单元测试
本篇演示如何在 CMake 的帮助下使用 Google Test 框架实现单元测试。Google Test 不仅仅是一个头文件,更是一个库,包含需要构建和链接的源文件。可以把它们与我们的代码项目放在一起,但为了使代码项目更加轻量级,我们选择
2022-06-28
  目录