7.7 add_subdirectory的限定范围

NOTE:此示例代码可以在 https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-7/recipe-07 中找到,其中有一个C++示例。该示例在CMake 3.5版(或更高版本)中是有效的,并且已经在GNU/Linux、macOS和Windows上进行过测试。

本章剩下的示例中,我们将讨论构建项目的策略,并限制变量的范围和副作用,目的是降低代码的复杂性和简化项目的维护。这个示例中,我们将把一个项目分割成几个范围有限的CMakeLists.txt文件,这些文件将使用add_subdirectory命令进行处理。

准备工作

由于我们希望展示和讨论如何构造一个复杂的项目,所以需要一个比“hello world”项目更复杂的例子:

我们的代码将能够计算任何256个基本细胞自动机,例如:规则90 (Wolfram代码):

7.7 add_subdirectory的限定范围 - 图1

我们示例代码项目的结构如下:

  1. .
  2. ├── CMakeLists.txt
  3. ├── external
  4. ├── CMakeLists.txt
  5. ├── conversion.cpp
  6. ├── conversion.hpp
  7. └── README.md
  8. ├── src
  9. ├── CMakeLists.txt
  10. ├── evolution
  11. ├── CMakeLists.txt
  12. ├── evolution.cpp
  13. └── evolution.hpp
  14. ├── initial
  15. ├── CMakeLists.txt
  16. ├── initial.cpp
  17. └── initial.hpp
  18. ├── io
  19. ├── CMakeLists.txt
  20. ├── io.cpp
  21. └── io.hpp
  22. ├── main.cpp
  23. └── parser
  24. ├── CMakeLists.txt
  25. ├── parser.cpp
  26. └── parser.hpp
  27. └── tests
  28. ├── catch.hpp
  29. ├── CMakeLists.txt
  30. └── test.cpp

我们将代码分成许多库来模拟真实的大中型项目,可以将源代码组织到库中,然后将库链接到可执行文件中。

主要功能在src/main.cpp中:

  1. #include "conversion.hpp"
  2. #include "evolution.hpp"
  3. #include "initial.hpp"
  4. #include "io.hpp"
  5. #include "parser.hpp"
  6. #include <iostream>
  7. int main(int argc, char *argv[]) {
  8. // parse arguments
  9. int length, num_steps, rule_decimal;
  10. std::tie(length, num_steps, rule_decimal) = parse_arguments(argc, argv);
  11. // print information about parameters
  12. std::cout << "length: " << length << std::endl;
  13. std::cout << "number of steps: " << num_steps << std::endl;
  14. std::cout << "rule: " << rule_decimal << std::endl;
  15. // obtain binary representation for the rule
  16. std::string rule_binary = binary_representation(rule_decimal);
  17. // create initial distribution
  18. std::vector<int> row = initial_distribution(length);
  19. // print initial configuration
  20. print_row(row);
  21. // the system evolves, print each step
  22. for (int step = 0; step < num_steps; step++) {
  23. row = evolve(row, rule_binary);
  24. print_row(row);
  25. }
  26. }

external/conversion.cpp文件包含要从十进制转换为二进制的代码。

我们在这里模拟这段代码是由src外部的“外部”库提供的:

  1. #include "conversion.hpp"
  2. #include <bitset>
  3. #include <string>
  4. std::string binary_representation(const int decimal) {
  5. return std::bitset<8>(decimal).to_string();
  6. }

src/evolution/evolution.cpp文件为一个时限传播系统:

  1. #include "evolution.hpp"
  2. #include <string>
  3. #include <vector>
  4. std::vector<int> evolve(const std::vector<int> row, const std::string rule_binary) {
  5. std::vector<int> result;
  6. for (auto i = 0; i < row.size(); ++i) {
  7. auto left = (i == 0 ? row.size() : i) - 1;
  8. auto center = i;
  9. auto right = (i + 1) % row.size();
  10. auto ancestors = 4 * row[left] + 2 * row[center] + 1 * row[right];
  11. ancestors = 7 - ancestors;
  12. auto new_state = std::stoi(rule_binary.substr(ancestors, 1));
  13. result.push_back(new_state);
  14. }
  15. return result;
  16. }

src/initial/initial.cpp文件,对出进行初始化:

  1. #include "initial.hpp"
  2. #include <vector>
  3. std::vector<int> initial_distribution(const int length) {
  4. // we start with a vector which is zeroed out
  5. std::vector<int> result(length, 0);
  6. // more or less in the middle we place a living cell
  7. result[length / 2] = 1;
  8. return result;
  9. }

src/io/io.cpp文件包含一个函数输出打印行:

  1. #include "io.hpp"
  2. #include <algorithm>
  3. #include <iostream>
  4. #include <vector>
  5. void print_row(const std::vector<int> row) {
  6. std::for_each(row.begin(), row.end(), [](int const &value) {
  7. std::cout << (value == 1 ? '*' : ' ');
  8. });
  9. std::cout << std::endl;
  10. }

src/parser/parser.cpp文件解析命令行输入:

  1. #include "parser.hpp"
  2. #include <cassert>
  3. #include <string>
  4. #include <tuple>
  5. std::tuple<int, int, int> parse_arguments(int argc, char *argv[]) {
  6. assert(argc == 4 && "program called with wrong number of arguments");
  7. auto length = std::stoi(argv[1]);
  8. auto num_steps = std::stoi(argv[2]);
  9. auto rule_decimal = std::stoi(argv[3]);
  10. return std::make_tuple(length, num_steps, rule_decimal);
  11. }

最后,tests/test.cpp包含两个使用Catch2库的单元测试:

  1. #include "evolution.hpp"
  2. // this tells catch to provide a main()
  3. // only do this in one cpp file
  4. #define CATCH_CONFIG_MAIN
  5. #include "catch.hpp"
  6. #include <string>
  7. #include <vector>
  8. TEST_CASE("Apply rule 90", "[rule-90]") {
  9. std::vector<int> row = {0, 1, 0, 1, 0, 1, 0, 1, 0};
  10. std::string rule = "01011010";
  11. std::vector<int> expected_result = {1, 0, 0, 0, 0, 0, 0, 0, 1};
  12. REQUIRE(evolve(row, rule) == expected_result);
  13. }
  14. TEST_CASE("Apply rule 222", "[rule-222]") {
  15. std::vector<int> row = {0, 0, 0, 0, 1, 0, 0, 0, 0};
  16. std::string rule = "11011110";
  17. std::vector<int> expected_result = {0, 0, 0, 1, 1, 1, 0, 0, 0};
  18. REQUIRE(evolve(row, rule) == expected_result);
  19. }

相应的头文件包含函数声明。有人可能会说,对于这个小代码示例,项目包含了太多子目录。请注意,这只是一个项目的简化示例,通常包含每个库的许多源文件,理想情况下,这些文件被放在到单独的目录中。

具体实施

让我们来详细解释一下CMake所需的功能:

  1. CMakeLists.txt顶部非常类似于第1节,代码重用与函数和宏:

    1. cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
    2. project(recipe-07 LANGUAGES CXX)
    3. set(CMAKE_CXX_STANDARD 11)
    4. set(CMAKE_CXX_EXTENSIONS OFF)
    5. set(CMAKE_CXX_STANDARD_REQUIRED ON)
    6. include(GNUInstallDirs)
    7. set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
    8. ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
    9. set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
    10. ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
    11. set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
    12. ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
    13. # defines targets and sources
    14. add_subdirectory(src)
    15. # contains an "external" library we will link to
    16. add_subdirectory(external)
    17. # enable testing and define tests
    18. enable_testing()
    19. add_subdirectory(tests)
  2. 目标和源在src/CMakeLists.txt中定义(转换目标除外):

    1. add_executable(automata main.cpp)
    2. add_subdirectory(evolution)
    3. add_subdirectory(initial)
    4. add_subdirectory(io)
    5. add_subdirectory(parser)
    6. target_link_libraries(automata
    7. PRIVATE
    8. conversion
    9. evolution
    10. initial
    11. io
    12. parser
    13. )
  3. 转换库在external/CMakeLists.txt中定义:

    1. add_library(conversion "")
    2. target_sources(conversion
    3. PRIVATE
    4. ${CMAKE_CURRENT_LIST_DIR}/conversion.cpp
    5. PUBLIC
    6. ${CMAKE_CURRENT_LIST_DIR}/conversion.hpp
    7. )
    8. target_include_directories(conversion
    9. PUBLIC
    10. ${CMAKE_CURRENT_LIST_DIR}
    11. )
  4. src/CMakeLists.txt文件添加了更多的子目录,这些子目录又包含CMakeLists.txt文件。src/evolution/CMakeLists.txt包含以下内容:

    1. add_library(evolution "")
    2. target_sources(evolution
    3. PRIVATE
    4. evolution.cpp
    5. PUBLIC
    6. ${CMAKE_CURRENT_LIST_DIR}/evolution.hpp
    7. )
    8. target_include_directories(evolution
    9. PUBLIC
    10. ${CMAKE_CURRENT_LIST_DIR}
    11. )
  5. 单元测试在tests/CMakeLists.txt中注册:

    1. add_executable(cpp_test test.cpp)
    2. target_link_libraries(cpp_test evolution)
    3. add_test(
    4. NAME
    5. test_evolution
    6. COMMAND
    7. $<TARGET_FILE:cpp_test>
    8. )
  6. 配置和构建项目产生以下输出:

    1. $ mkdir -p build
    2. $ cd build
    3. $ cmake ..
    4. $ cmake --build .
    5. Scanning dependencies of target conversion
    6. [ 7%] Building CXX object external/CMakeFiles/conversion.dir/conversion.cpp.o
    7. [ 14%] Linking CXX static library ../lib64/libconversion.a
    8. [ 14%] Built target conversion
    9. Scanning dependencies of target evolution
    10. [ 21%] Building CXX object src/evolution/CMakeFiles/evolution.dir/evolution.cpp.o
    11. [ 28%] Linking CXX static library ../../lib64/libevolution.a
    12. [ 28%] Built target evolution
    13. Scanning dependencies of target initial
    14. [ 35%] Building CXX object src/initial/CMakeFiles/initial.dir/initial.cpp.o
    15. [ 42%] Linking CXX static library ../../lib64/libinitial.a
    16. [ 42%] Built target initial
    17. Scanning dependencies of target io
    18. [ 50%] Building CXX object src/io/CMakeFiles/io.dir/io.cpp.o
    19. [ 57%] Linking CXX static library ../../lib64/libio.a
    20. [ 57%] Built target io
    21. Scanning dependencies of target parser
    22. [ 64%] Building CXX object src/parser/CMakeFiles/parser.dir/parser.cpp.o
    23. [ 71%] Linking CXX static library ../../lib64/libparser.a
    24. [ 71%] Built target parser
    25. Scanning dependencies of target automata
    26. [ 78%] Building CXX object src/CMakeFiles/automata.dir/main.cpp.o
    27. [ 85%] Linking CXX executable ../bin/automata
    28. [ 85%] Built target automata
    29. Scanning dependencies of target cpp_test
    30. [ 92%] Building CXX object tests/CMakeFiles/cpp_test.dir/test.cpp.o
    31. [100%] Linking CXX executable ../bin/cpp_test
    32. [100%] Built target cpp_test
  7. 最后,运行单元测试:

    1. $ ctest
    2. Running tests...
    3. Start 1: test_evolution
    4. 1/1 Test #1: test_evolution ................... Passed 0.00 sec
    5. 100% tests passed, 0 tests failed out of 1

工作原理

我们可以将所有代码放到一个源文件中。不过,每次编辑都需要重新编译。将源文件分割成更小、更易于管理的单元是有意义的。可以将所有源代码都编译成一个库或可执行文件。实际上,项目更喜欢将源代码编译分成更小的、定义良好的库。这样做既是为了本地化和简化依赖项,也是为了简化代码维护。这意味着如在这里所做的那样,由许多库构建一个项目是一种常见的情况。

为了讨论CMake结构,我们可以从定义每个库的单个CMakeLists.txt文件开始,自底向上进行,例如src/evolution/CMakeLists.txt:

  1. add_library(evolution "")
  2. target_sources(evolution
  3. PRIVATE
  4. evolution.cpp
  5. PUBLIC
  6. ${CMAKE_CURRENT_LIST_DIR}/evolution.hpp
  7. )
  8. target_include_directories(evolution
  9. PUBLIC
  10. ${CMAKE_CURRENT_LIST_DIR}
  11. )

这些单独的CMakeLists.txt文件定义了库。本例中,我们首先使用add_library定义库名,然后定义它的源和包含目录,以及它们的目标可见性:实现文件(evolution.cpp:PRIVATE),而接口头文件evolution.hpp定义为PUBLIC,因为我们将在main.cpptest.cpp中访问它。定义尽可能接近代码目标的好处是,对于该库的修改,只需要变更该目录中的文件即可;换句话说,也就是库依赖项被封装。

向上移动一层,库在src/CMakeLists.txt中封装:

  1. add_executable(automata main.cpp)
  2. add_subdirectory(evolution)
  3. add_subdirectory(initial)
  4. add_subdirectory(io)
  5. add_subdirectory(parser)
  6. target_link_libraries(automata
  7. PRIVATE
  8. conversion
  9. evolution
  10. initial
  11. io
  12. parser
  13. )

文件在主CMakeLists.txt中被引用。这意味着使用CMakeLists.txt文件,构建我们的项目。这种方法对于许多项目来说是可用的,并且它可以扩展到更大型的项目,而不需要在目录间的全局变量中包含源文件列表。add_subdirectory方法的另一个好处是它隔离了作用范围,因为子目录中定义的变量在父范围中不能访问。

更多信息

使用add_subdirectory调用树构建项目的一个限制是,CMake不允许将target_link_libraries与定义在当前目录范围之外的目标一起使用。对于本示例来说,这不是问题。在下一个示例中,我们将演示另一种方法,我们不使用add_subdirectory,而是使用module include来组装不同的CMakeLists.txt文件,它允许我们链接到当前目录之外定义的目标。

CMake可以使用Graphviz图形可视化软件(http://www.graphviz.org )生成项目的依赖关系图:

  1. $ cd build
  2. $ cmake --graphviz=example.dot ..
  3. $ dot -T png example.dot -o example.png

生成的图表将显示不同目录下的目标之间的依赖关系:

7.7 add_subdirectory的限定范围 - 图2

本书中,我们一直在构建源代码之外的代码,以保持源代码树和构建树是分开的。这是推荐的方式,允许我们使用相同的源代码配置不同的构建(顺序的或并行的,Debug或Release),而不需要复制源代码,也不需要在源代码树中生成目标文件。使用以下代码片段,可以保护您的项目免受内部构建的影响:

  1. if(${PROJECT_SOURCE_DIR} STREQUAL ${PROJECT_BINARY_DIR})
  2. message(FATAL_ERROR "In-source builds not allowed. Please make a new directory (called a build directory) and run CMake from there.")
  3. endif()

认识到构建结构与源结构类似很有用。示例中,将message打印输出插入到src/CMakeLists.txt中:

  1. message("current binary dir is ${CMAKE_CURRENT_BINARY_DIR}")

build下构建项目时,我们将看到build/src的打印输出。

在CMake的3.12版本中,OBJECT库是组织大型项目的另一种可行方法。对我们的示例的惟一修改是在库的CMakeLists.txt中。源文件将被编译成目标文件:既不存档到静态库中,也不链接到动态库中。例如:

  1. add_library(io OBJECT "")
  2. target_sources(io
  3. PRIVATE
  4. io.cpp
  5. PUBLIC
  6. ${CMAKE_CURRENT_LIST_DIR}/io.hpp
  7. )
  8. target_include_directories(io
  9. PUBLIC
  10. ${CMAKE_CURRENT_LIST_DIR}
  11. )

CMakeLists.txt保持不变:automata可执行目标将这些目标文件链接到最终的可执行文件。使用也有要求需求,例如:在对象库上设置的目录、编译标志和链接库,将被正确地继承。有关CMake 3.12中引入的对象库新特性的更多细节,请参考官方文档: https://cmake.org/cmake/help/v3.12/manual/cmake-buildsystem.7.html#object-libraries