任何编程语言中,函数都允许我们抽象(隐藏)细节并避免代码重复,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 所需头文件的位置。