mdn-cmk-cpp-2e-merge-3

面向 C++ 的现代 CMake 第二版(四)

原文:zh.annas-archive.org/md5/4abd6886e8722cebdc63cd42f86a9282

译者:飞龙

协议:CC BY-NC-SA 4.0

第十二章:程序分析工具

编写高质量代码并非易事,即便是经验丰富的开发者也是如此。通过在我们的解决方案中加入测试,我们可以减少在主代码中犯基本错误的可能性。但这还不足以避免更复杂的问题。每一段软件都包含了大量的细节,要追踪所有这些细节几乎成为了一项全职工作。维护产品的团队会建立各种约定和特定的设计实践。

有一些问题与一致的编码风格有关:我们应该在代码中使用 80 列还是 120 列?我们应该允许使用 std::bind 还是坚持使用 Lambda 函数?使用 C 风格的数组是否可以接受?小函数是否应该写成一行?我们是否应该总是使用 auto,还是仅在提高可读性时使用?理想情况下,我们应该避免已知通常不正确的语句:无限循环、使用标准库保留的标识符、无意的数据丢失、不必要的 if 语句以及其他任何不符合“最佳实践”的东西(更多信息请参见 进一步阅读 部分)。

另一个需要考虑的方面是代码的现代化。随着 C++ 的发展,它引入了新的特性。跟踪所有可以更新到最新标准的地方可能是一个挑战。此外,手动进行这一操作既费时又增加了引入错误的风险,尤其是在大型代码库中。最后,我们还应检查在程序运行时其操作是否正常:运行程序并检查其内存。内存是否在使用后正确释放?我们是否访问了已正确初始化的数据?或者代码是否尝试访问不存在的指针?

手动管理所有这些挑战和问题既费时又容易出错。幸运的是,我们可以使用自动化工具来检查和强制执行规则,纠正错误,并使我们的代码保持最新。现在是时候探索程序分析工具了。在每次构建时,我们的代码将会被仔细审查,确保它符合行业标准。

本章将涵盖以下主要内容:

  • 强制格式化

  • 使用静态检查工具

  • 使用 Valgrind 进行动态分析

技术要求

你可以在 GitHub 上找到本章中提到的代码文件,网址为 github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch12

要构建本书中提供的示例,请始终使用以下推荐的命令:

cmake -B <build tree> -S <source tree>
cmake --build <build tree> 

确保将占位符 <build tree><source tree> 替换为适当的路径。提醒一下,build tree 是目标/输出目录的路径,source tree 是源代码所在的路径。

强制格式化

专业开发人员通常会遵循规则。有人说,资深开发人员知道什么时候可以打破规则,因为他们能为其必要性提供正当理由。另一方面,非常资深的开发人员通常避免打破规则,以节省解释自己选择的时间。关键是要关注真正影响产品的问题,而不是陷入琐碎的细节。

在编码风格和格式化方面,开发人员面临许多选择:我们应该使用制表符还是空格进行缩进?如果是空格,使用多少个?列或文件中的字符限制应该是多少?这些选择通常不会改变程序的行为,但可能引发冗长的讨论,增加的价值不大。

确实存在一些常见的做法,但讨论通常围绕个人偏好和轶事证据展开。例如,选择每列 80 个字符而非 120 个字符是任意的。重要的是保持一致的风格,因为不一致可能会妨碍代码的可读性。为了确保一致性,建议使用像clang-format这样的格式化工具。这个工具可以通知我们代码是否没有正确格式化,甚至可以自动修正。下面是格式化代码的示例命令:

clang-format -i --style=LLVM filename1.cpp filename2.cpp 

-i选项指示 clang-format 直接编辑文件,而--style指定要使用的格式化风格,例如LLVMGoogleChromiumMozillaWebKit或在文件中提供的自定义风格(更多细节请参见进一步阅读部分)。

当然,我们不想每次更改时都手动执行此命令;CMake应该作为构建过程的一部分来处理此事。我们已经知道如何在系统中找到clang-format(我们需要事先手动安装它)。我们尚未讨论的是如何将这个外部工具应用于所有源文件。为此,我们将创建一个便捷的函数,可以从cmake目录中包含该函数:

ch12/01-formatting/cmake/Format.cmake

function(Format target directory)
  find_program(CLANG-FORMAT_PATH clang-format REQUIRED)
  set(EXPRESSION h hpp hh c cc cxx cpp)
  list(TRANSFORM EXPRESSION PREPEND "${directory}/*.")
  file(GLOB_RECURSE SOURCE_FILES FOLLOW_SYMLINKS
       LIST_DIRECTORIES false ${EXPRESSION}
  )
  add_custom_command(TARGET ${target} PRE_BUILD COMMAND
    ${CLANG-FORMAT_PATH} -i --style=file ${SOURCE_FILES}
  )
endfunction() 

Format函数接受两个参数:targetdirectory。它将在目标构建之前格式化来自该目录的所有源文件。

从技术角度来看,目录中的所有文件不必都属于目标,目标的源文件可能分布在多个目录中。然而,追踪与目标相关的所有源文件和头文件是复杂的,特别是在需要排除外部库的头文件时。在这种情况下,聚焦于目录比聚焦于逻辑目标要容易。我们可以为每个需要格式化的目录调用该函数。

这个函数的步骤如下:

  1. 查找已安装的clang-format二进制文件。如果未找到该二进制文件,REQUIRED关键字会在配置过程中抛出错误。

  2. 创建一个文件扩展名列表以进行格式化(用作通配符表达式)。

  3. 在每个表达式前加上directory的路径。

  4. 递归搜索源文件和头文件(使用之前创建的列表),将找到的文件路径放入SOURCE_FILES变量中(但跳过任何找到的目录路径)。

  5. 将格式化命令附加到targetPRE_BUILD步骤。

这种方法适用于小到中型代码库。对于更大的代码库,我们可能需要将绝对文件路径转换为相对路径,并使用目录作为工作目录运行格式化命令。这可能是由于 shell 命令中的字符限制,通常限制大约为 13,000 个字符。

让我们来探讨一下如何在实践中使用这个功能。这是我们的项目结构:

- CMakeLists.txt
- .clang-format
- cmake
  |- Format.cmake
- src
  |- CMakeLists.txt
  |- header.h
  |- main.cpp 

首先,我们设置项目并将cmake目录添加到模块路径中,以便稍后包含:

ch12/01-formatting/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Formatting CXX)
enable_testing()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
add_subdirectory(src bin) 

接下来,我们为src目录填充listfile

ch12/01-formatting/src/CMakeLists.txt

add_executable(main main.cpp)
include(Format)
Format(main .) 

这很直接。我们创建一个名为main的可执行目标,包含Format.cmake模块,并在当前目录(src)为main目标调用Format()函数。

现在,我们需要一些未格式化的源文件。头文件包含一个简单的unused函数:

ch12/01-formatting/src/header.h

int unused() { return 2 + 2; } 

我们还将包括一个源文件,其中包含多余的、不正确的空白符:

ch12/01-formatting/src/main.cpp

#include <iostream>
                               using namespace std;
                       int main() {
      cout << "Hello, world!" << endl;
                                          } 

快完成了。我们只需要格式化工具的配置文件,通过--style=file命令行参数启用:

ch12/01-formatting/.clang-format

BasedOnStyle: Google
ColumnLimit: 140
UseTab: Never
AllowShortLoopsOnASingleLine: false
AllowShortFunctionsOnASingleLine: false
AllowShortIfStatementsOnASingleLine: false 

ClangFormat将扫描父目录中的.clang-format文件,该文件指定了确切的格式化规则。这使我们能够自定义每个细节。在我的案例中,我从 Google 的编码风格开始,并做了一些调整:140 字符列限制,不使用制表符,不允许短的循环、函数或if语句写在一行内。

在构建项目后(格式化会在编译前自动进行),我们的文件看起来像这样:

ch12/01-formatting/src/header.h(已格式化)

int unused() {
  return 2 + 2;
} 

即使头文件没有被目标使用,仍然进行了格式化。短函数不能写在一行内,正如预期的那样,添加了新行。main.cpp文件现在看起来也相当简洁。不需要的空白符消失了,缩进已标准化:

ch12/01-formatting/src/main.cpp(已格式化)

#include <iostream>
using namespace std;
int main() {
  cout << "Hello, world!" << endl;
} 

自动化格式化可以节省代码审查时的时间。如果你曾经因为空白符问题而不得不修改提交,你一定能体会到这带来的轻松。统一的格式化使你的代码保持整洁,无需费力。

对整个代码库应用格式化最有可能会在仓库中的大多数文件中引入一次性的大变动。如果你(或你的团队成员)正在进行一些工作,这可能会导致大量的合并冲突。最好在所有待处理的更改完成后再进行此类操作。如果这不可行,可以考虑逐步采用,可能按目录进行。你的团队成员会感激这一点。

尽管格式化工具在使代码视觉上保持一致方面表现出色,但它不是一个全面的程序分析工具。对于更高级的需求,其他专门用于静态分析的工具是必要的。

使用静态检查工具

静态程序分析涉及在不运行已编译版本的情况下检查源代码。始终使用静态检查器可以显著提高代码质量,使其更加一致,不易受到错误和已知安全漏洞的影响。C++社区提供了多种静态检查器,如Astréeclang-tidyCLazyCMetricsCppcheckCpplintCQMetricsESBMCFlawFinderFlintIKOSJoernPC-LintScan-BuildVera++等。

其中许多工具将CMake视为行业标准,并提供现成的支持或集成教程。一些构建工程师更喜欢不编写CMake代码,而是通过在线可用的外部模块来包含静态检查器。例如,Lars Bilke 在他的 GitHub 仓库中的集合:github.com/bilke/cmake-modules

一个普遍的看法是设置静态检查器很复杂。这种看法存在是因为静态检查器通常模拟实际编译器的行为来理解代码。但实际上并不一定要很难。

Cppcheck在其手册中概述了以下简单步骤:

  1. 定位静态检查器的可执行文件。

  2. 使用以下命令生成编译数据库

    • cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
  3. 使用生成的 JSON 文件运行检查器:

    • <path-to-cppcheck> --project=compile_commands.json

这些步骤应该集成到构建过程中,以确保它们不会被忽略。

由于CMake知道如何构建我们的目标,它是否也支持任何静态检查器?完全支持,而且比你想的要简单得多。CMake允许你为以下工具按目标启用检查器:

要启用这些检查器,将目标属性设置为包含检查器可执行文件路径和任何需要转发的命令行选项的分号分隔列表:

  • <LANG>_CLANG_TIDY

  • <LANG>_CPPCHECK

  • <LANG>_CPPLINT

  • <LANG>_INCLUDE_WHAT_YOU_USE

  • LINK_WHAT_YOU_USE

C替换<LANG>以处理 C 源代码,用CXX处理 C++源代码。如果你希望为所有项目目标启用检查器,可以设置一个以CMAKE_为前缀的全局变量——例如:

set(CMAKE_CXX_CLANG_TIDY /usr/bin/clang-tidy-3.9;-checks=*) 

在此语句之后定义的任何目标都会将其CXX_CLANG_TIDY属性设置为此值。请记住,启用此分析可能会稍微延长构建时间。另一方面,更详细地控制检查器如何测试目标是非常有用的。我们可以创建一个简单的函数来处理此操作:

ch12/02-clang-tidy/cmake/ClangTidy.cmake

function(AddClangTidy target)
  find_program(CLANG-TIDY_PATH clang-tidy REQUIRED)
  set_target_properties(${target}
    PROPERTIES CXX_CLANG_TIDY
    "${CLANG-TIDY_PATH};-checks=*;--warnings-as-errors=*"
  )
endfunction() 

AddClangTidy 函数包括两个基本步骤:

  1. 定位 clang-tidy 二进制文件并将其路径存储在 CLANG-TIDY_PATH 中。REQUIRED 关键字确保如果找不到二进制文件,配置将停止并抛出错误。

  2. 通过提供二进制路径和特定选项来启用目标的 clang-tidy,以激活所有检查并将警告视为错误。

要使用这个功能,我们只需包含模块并为所选目标调用它:

ch12/02-clang-tidy/src/CMakeLists.txt

add_library(sut STATIC calc.cpp run.cpp)
target_include_directories(sut PUBLIC .)
add_executable(bootstrap bootstrap.cpp)
target_link_libraries(bootstrap PRIVATE sut)
include(ClangTidy)
AddClangTidy(sut) 

这种方法简洁且非常有效。在构建解决方案时,clang-tidy 的输出将如下所示:

[  6%] Building CXX object bin/CMakeFiles/sut.dir/calc.cpp.o
/root/examples/ch12/04-clang-tidy/src/calc.cpp:3:11: warning: method 'Sum' can be made static [readability-convert-member-functions-to-static]
int Calc::Sum(int a, int b) {
          ^
[ 12%] Building CXX object bin/CMakeFiles/sut.dir/run.cpp.o
/root/examples/ch12/04-clang-tidy/src/run.cpp:1:1: warning: #includes are not sorted properly [llvm-include-order]
#include <iostream>
^        ~~~~~~~~~~
/root/examples/ch12/04-clang-tidy/src/run.cpp:3:1: warning: do not use namespace using-directives; use using-declarations instead [google-build-using-namespace]
using namespace std;
^
/root/examples/ch12/04-clang-tidy/src/run.cpp:6:3: warning: initializing non-owner 'Calc *' with a newly created 'gsl::owner<>' [cppcoreguidelines-owning-memory]
  auto c = new Calc();
  ^ 

请注意,除非您将 --warnings-as-errors=* 选项添加到命令行参数中,否则构建会成功完成。组织应决定一组必须严格遵循的规则,以防止不合规的代码进入代码库。

clang-tidy 还提供了一个有用的 --fix 选项,可以在可能的情况下自动修正您的代码。这个功能是一个宝贵的时间节省工具,尤其在扩展检查项列表时非常有用。与格式化类似,在将静态分析工具所做的更改添加到现有代码库时,要小心合并冲突。

根据您的情况、代码库的大小和团队的偏好,您应选择少量最适合您需求的检查工具。包含过多的检查工具可能会导致干扰。以下是 CMake 默认支持的检查工具的简要概述。

clang-tidy

这是官方文档中关于 clang-tidy 的介绍:

clang-tidy 是一个基于 clang 的 C++ 静态分析工具。它的目的是提供一个可扩展的框架,用于诊断和修复典型的编程错误,如风格违规、接口误用或通过静态分析可以推断出的错误。clang-tidy 是模块化的,并提供了一个方便的接口用于编写新的检查项。

该工具非常灵活,提供了超过 400 项检查。它与 ClangFormat 配合良好,能够自动应用修复(超过 150 项修复可用),以符合相同的格式文件。它提供的检查覆盖了性能、可读性、现代化、C++ 核心指南以及易出错的领域。

Cpplint

以下是 Cpplint 官方网站的描述:

Cpplint 是一个命令行工具,用于检查 C/C++ 文件的风格问题,遵循 Google 的 C++ 风格指南。Cpplint 由 Google 公司在 google/styleguide 上开发和维护。

这个静态代码分析工具旨在使您的代码符合 Google 的风格指南。它是用 Python 编写的,可能会为某些项目引入不必要的依赖。修复建议以 EmacsEclipseVS7Junit 格式提供,也可以作为 sed 命令使用。

Cppcheck

这是官方文档中关于 Cppcheck 的介绍:

Cppcheck 是一个用于 C/C++代码的静态分析工具。它提供独特的代码分析,检测错误,重点检查未定义的行为和危险的编码结构。目标是尽量减少误报。Cppcheck 设计为即使代码有非标准语法(在嵌入式项目中常见),也能够进行分析。

这个工具特别擅长最小化误报,使其成为可靠的代码分析选项。它已经存在超过 14 年,并且仍在积极维护。如果你的代码与 Clang 不兼容,它尤其有用。

include-what-you-use

这是来自官方官网的 include-what-you-use 描述:

include-what-you-use 的主要目标是去除多余的#includes。它通过找出此文件(包括.cc 和.h 文件)中实际上不需要的#includes,并在可能的情况下用前置声明替换#includes 来实现这一点。

虽然在小型项目中,包含过多头文件似乎不是什么大问题,但避免不必要的头文件编译所节省的时间,在大型项目中会迅速积累。

这里是CMake博客中关于“Link what you use”的描述:

这是一个内置的 CMake 功能,使用 ld 和 ldd 的选项打印出可执行文件是否链接了超出实际需求的库。

静态分析在医疗、核能、航空、汽车和机械等行业中起着至关重要的作用,因为软件错误可能会威胁生命。明智的开发者也会在非关键环境中采用这些实践,尤其是当成本较低时。在构建过程中使用静态分析不仅比手动发现和修复错误更具成本效益,而且通过CMake启用也非常简单。我甚至可以说,对于任何质量敏感的软件(包括涉及开发者以外的其他人的软件),几乎没有理由跳过这些检查。

这个功能还通过专注于消除不必要的二进制文件,帮助加速构建时间。不幸的是,并非所有的错误都能在运行程序之前被检测到。幸运的是,我们可以采取额外的措施,像使用Valgrind,来深入了解我们的项目。

使用 Valgrind 进行动态分析

Valgrind (www.valgrind.org) 是一个用于构建动态分析工具的*nix 工具框架,这意味着它在程序运行时进行分析。它配备了各种工具,适用于多种类型的调查和检查。一些工具包括:

  • Memcheck:检测内存管理问题

  • Cachegrind:分析 CPU 缓存,并识别缓存未命中和其他问题

  • CallgrindCachegrind的扩展,提供关于调用图的额外信息

  • Massif:一个堆分析器,显示程序不同部分如何随时间使用堆

  • Helgrind:一个用于数据竞争问题的线程调试器

  • DRDHelgrind的一个较轻量、功能较为有限的版本

列表中的每个工具在需要时都非常有用。大多数系统包管理器都知道Valgrind,并可以轻松地在你的操作系统上安装它。如果你使用的是 Linux,它可能已经安装了。此外,官方网站还提供了源代码,供那些喜欢自己编译的用户使用。

我们的讨论将主要集中在Memcheck上,这是Valgrind套件中最常用的工具(当开发者提到Valgrind时,通常指的是ValgrindMemcheck)。我们将探讨如何与CMake一起使用它,这将使得如果以后需要使用其他工具时,更容易采用套件中的其他工具。

Memcheck

Memcheck对于调试内存问题非常宝贵,尤其是在 C++中,这个话题可能特别复杂。程序员对内存管理有广泛的控制,因此可能会犯各种错误。这些错误可能包括读取未分配或已经释放的内存,重复释放内存,甚至写入错误的地址。这些漏洞往往容易被忽视,甚至渗透到简单的程序中。有时,仅仅是忘记初始化一个变量,就足以导致问题。

调用Memcheck看起来像这样:

valgrind [valgrind-options] tested-binary [binary-options] 

MemcheckValgrind的默认工具,但你也可以明确指定它,如下所示:

valgrind --tool=memcheck tested-binary 

运行Memcheck会显著降低程序的运行速度;手册(见进一步阅读中的链接)表示,使用它的程序可能会变得比正常速度慢 10 到 15 倍。为了避免每次运行测试时都需要等待Valgrind,我们将创建一个单独的目标,在需要测试代码时从命令行调用。理想情况下,这个步骤应该在任何新代码合并到主代码库之前完成。你可以将这个步骤包含在一个早期的 Git 钩子中,或作为持续集成CI)流水线的一部分。

要为Valgrind创建自定义目标,可以在CMake生成阶段之后使用以下命令:

cmake --build <build-tree> -t valgrind 

下面是如何在CMake中添加这样的目标:

ch12/03-valgrind/cmake/Valgrind.cmake

function(AddValgrind target)
  find_program(VALGRIND_PATH valgrind REQUIRED)
  add_custom_target(valgrind
    COMMAND ${VALGRIND_PATH} --leak-check=yes
            $<TARGET_FILE:${target}>
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction() 

在这个例子中,我们定义了一个名为AddValgrindCMake函数,它接受要测试的目标(我们可以在多个项目中重复使用它)。这里发生了两件主要的事情:

  1. CMake会检查默认的系统路径以查找valgrind可执行文件,并将其路径存储在VALGRIND_PATH变量中。如果没有找到该二进制文件,REQUIRED关键字将导致配置中断并报错。

  2. 创建了一个名为valgrind的自定义目标。它会在指定的二进制文件上运行Memcheck,并且总是检查内存泄漏。

Valgrind选项可以通过多种方式设置:

  • ~/.valgrindrc文件中(在你的主目录下)

  • 通过$VALGRIND_OPTS环境变量

  • ./.valgrindrc文件中(在工作目录下)

这些文件按顺序进行检查。另外,请注意,只有在文件属于当前用户、是常规文件且没有标记为全局可写时,最后一个文件才会被考虑。这是一个安全机制,因为提供给 Valgrind 的选项可能会有潜在的危害。

为了使用 AddValgrind 函数,我们将其与 unit_tests 目标一起使用,因为我们希望在像单元测试这样的精细控制环境中运行它:

ch12/03-valgrind/test/CMakeLists.txt(片段)

# ...
add_executable(unit_tests calc_test.cpp run_test.cpp)
# ...
**include****(Valgrind)**
**AddValgrind****(unit_tests)** 

记住,使用 Debug 配置生成构建树可以让 Valgrind 访问调试信息,从而使输出更加清晰。

让我们看看这个在实践中是如何工作的:

# cmake -B <build tree> -S <source tree> -DCMAKE_BUILD_TYPE=Debug
# cmake --build <build-tree> -t valgrind 

这将配置项目,构建 sutunit_tests 目标,并开始执行 Memcheck,它将提供一般信息:

[100%] Built target unit_tests
==954== Memcheck, a memory error detector
==954== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==954== Using Valgrind-3.18.1 and LibVEX; rerun with -h for copyright info
==954== Command: ./unit_tests 

==954== 前缀包含进程的 ID,有助于区分 Valgrind 的注释和被测试进程的输出。

接下来,像往常一样运行测试,使用 gtest

[==========] Running 3 tests from 2 test suites.
[----------] Global test environment set-up.
...
[==========] 3 tests from 2 test suites ran. (42 ms total)
[  PASSED  ] 3 tests. 

最后,呈现一个总结:

==954==
==954== HEAP SUMMARY:
==954==     in use at exit: 1 bytes in 1 blocks
==954==   total heap usage: 209 allocs, 208 frees, 115,555 bytes allocated 

哎呀!我们仍然使用了至少 1 字节。通过 malloc()new 分配的内存没有与适当的 free()delete 操作匹配。看起来我们的程序中有内存泄漏。Valgrind 提供了更多细节来帮助找到它:

==954== 1 bytes in 1 blocks are definitely lost in loss record 1 of 1
==954==    at 0x483BE63: operator new(unsigned long) (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==954==    by 0x114FC5: run() (run.cpp:6)
==954==    by 0x1142B9: RunTest_RunOutputsCorrectEquations_Test::TestBody() (run_test.cpp:14) 

by 0x<address> 开头的行表示调用栈中的单独函数。我已将输出截断(它有来自 GTest 的噪声),以便集中显示有趣的部分——最顶层的函数和源代码引用 run()(run.cpp:6)

最后,总结信息出现在底部:

==954== LEAK SUMMARY:
==954==    definitely lost: 1 bytes in 1 blocks
==954==    indirectly lost: 0 bytes in 0 blocks
==954==      possibly lost: 0 bytes in 0 blocks
==954==    still reachable: 0 bytes in 0 blocks
==954==         suppressed: 0 bytes in 0 blocks
==954==
==954== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) 

Valgrind 非常擅长发现复杂问题。有时,它甚至可以更深入地挖掘出一些不容易归类的问题,这些问题会出现在 “possibly lost” 行中。

让我们看看 Memcheck 这次发现了什么问题:

ch12/03-valgrind/src/run.cpp

#include <iostream>
#include "calc.h"
using namespace std;
int run() {
  **auto** **c =** **new****Calc****();**
  cout << "2 + 2 = " << c->Sum(2, 2) << endl;
  cout << "3 * 3 = " << c->Multiply(3, 3) << endl;
  return 0;
} 

没错:突出显示的代码是有问题的。实际上,我们创建了一个对象,而在测试结束之前没有删除它。这正是为什么拥有广泛的测试覆盖非常重要的原因。

Valgrind 是一个有用的工具,但在复杂程序中,其输出可能会变得难以应对。实际上,有一种更有效地管理这些信息的方法——那就是 Memcheck-Cover 项目。

Memcheck-Cover

像 CLion 这样的商业 IDE 可以直接解析 Valgrind 的输出,使得通过图形界面浏览变得更加容易,无需在控制台中滚动。如果你的编辑器没有这个功能,第三方报告生成器可以提供更清晰的视图。由 David Garcin 开发的 Memcheck-Cover 通过生成 HTML 文件提供了更好的体验,如下图所示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_12_01.png

图 12.1:由 Memcheck-Cover 生成的报告

这个简洁的小项目可以在 GitHub 上找到 (github.com/Farigh/memcheck-cover);它需要 Valgrindgawk(GNU AWK 工具)。为了使用它,我们将在一个单独的 CMake 模块中准备一个设置函数。它将包含两部分:

  1. 获取和配置工具

  2. 添加一个自定义目标来运行Valgrind并生成报告

这是配置的样子:

ch12/04-memcheck/cmake/Memcheck.cmake

function(AddMemcheck target)
  include(FetchContent)
  FetchContent_Declare(
   memcheck-cover
   GIT_REPOSITORY https://github.com/Farigh/memcheck-cover.git
   GIT_TAG        release-1.2
  )
  FetchContent_MakeAvailable(memcheck-cover)
  set(MEMCHECK_PATH ${memcheck-cover_SOURCE_DIR}/bin) 

在第一部分中,我们遵循与常规依赖项相同的做法:包括FetchContent模块,并通过FetchContent_Declare指定项目的仓库和所需的 Git 标签。接下来,我们启动获取过程并配置二进制路径,使用FetchContent_Populate(由FetchContent_MakeAvailable隐式调用)设置的memcheck-cover_SOURCE_DIR变量。

函数的第二部分是创建目标以生成报告。我们将其命名为memcheck(这样如果出于某些原因希望保留两个选项,它就不会与之前的valgrind目标重叠):

ch12/04-memcheck/cmake/Memcheck.cmake(续)

 add_custom_target(memcheck
    COMMAND ${MEMCHECK_PATH}/memcheck_runner.sh -o
      "${CMAKE_BINARY_DIR}/valgrind/report"
      -- $<TARGET_FILE:${target}>
    COMMAND ${MEMCHECK_PATH}/generate_html_report.sh
      -i "${CMAKE_BINARY_DIR}/valgrind"
      -o "${CMAKE_BINARY_DIR}/valgrind"
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction() 

这一过程包括两个命令:

  1. 首先,我们将运行memcheck_runner.sh包装脚本,它将执行ValgrindMemcheck并将输出收集到通过-o参数提供的文件中。

  2. 然后,我们将解析输出并使用generate_html_report.sh生成报告。这个脚本需要通过-i-o参数提供输入和输出目录。

这两个步骤应该在CMAKE_BINARY_DIR工作目录中执行,这样单元测试二进制文件就可以通过相对路径访问文件(如果需要的话)。

我们需要在我们的 listfiles 中添加的最后一件事,当然是调用这个函数:

ch12/04-memcheck/test/CMakeLists.txt(片段)

include(Memcheck)
AddMemcheck(unit_tests) 

在使用Debug配置生成构建系统后,我们可以使用以下命令构建目标:

# cmake -B <build tree> -S <source tree> -DCMAKE_BUILD_TYPE=Debug
# cmake --build <build-tree> -t memcheck 

然后,我们可以享受生成的格式化报告,它作为 HTML 页面生成。

总结

“你将花更多的时间阅读代码,而不是编写代码,所以要优化可读性而非可写性。” 这一原则在各种关于清洁代码的书籍中都有提及。许多软件开发人员的经验也支持这一点,这就是为什么连空格、换行符的数量,以及#import语句的顺序等小细节都要标准化。这种标准化不仅仅是为了精益求精;它是为了节省时间。遵循本章的做法,你可以忘记手动格式化代码。当你构建代码时,格式会自动调整,这本来就是你测试代码时要做的一步。借助ClangFormat,你可以确保格式符合你选择的标准。

除了简单的空格调整外,代码还应该遵循许多其他规范。这就是 clang-tidy 的用武之地。它帮助执行你团队或组织所达成的编码规范。我们深入讨论了这个静态检查工具,还涉及了其他选项,如CpplintCppcheck、include-what-you-use 和 Link What You Use。由于静态链接器的速度相对较快,我们可以将它们添加到构建过程中,投入非常小,而且通常非常值得。

我们还检查了Valgrind工具,重点介绍了Memcheck,它能帮助识别内存管理中的问题,如不正确的读取和写入。这个工具对于避免长时间的手动调试和防止生产环境中的 bug 非常宝贵。我们介绍了一种方法,通过Memcheck-Cover(一个 HTML 报告生成器)让Valgrind的输出更具用户友好性。在无法运行 IDE 的环境中,像 CI 流水线,这尤其有用。

本章只是一个起点。许多其他工具,无论是免费的还是商业的,都可以帮助您提高代码质量。探索它们,找到最适合您的工具。在下一章中,我们将深入探讨生成文档的过程。

进一步阅读

欲了解更多信息,您可以参考以下链接:

发表评论!

喜欢这本书吗?通过在亚马逊上留下评论,帮助像您一样的读者。扫描下面的二维码,获取您选择的免费电子书。

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/Review_Copy.png

第十三章:生成文档

高质量的代码不仅仅是编写得好、运行正常并经过测试——它还需要有完善的文档。文档能够帮助我们分享可能会丢失的信息,描绘更大的图景,提供上下文,揭示意图,最终——教育外部用户和维护者。

你还记得上次加入一个新项目时,迷失在一堆目录和文件中几个小时吗?这一问题是可以避免的。真正优秀的文档可以让一个完全陌生的人在几秒钟内找到他们需要的代码行。可惜,缺乏文档的问题经常被忽视。难怪如此——编写文档需要相当的技能,而我们中的许多人并不擅长这一点。此外,文档和代码很容易变得过时。除非实施严格的更新和审查流程,否则很容易忘记文档也需要关注。

一些团队(为了节省时间或因为管理层的鼓励)采用了编写自文档化代码的做法。通过为文件名、函数、变量等选择有意义且易读的标识符,他们希望避免编写文档的麻烦。即使是最好的函数签名,也不能确保传达所有必要的信息——例如,int removeDuplicates();很有描述性,但它并没有说明返回的是什么。它可能是发现的重复项数量,剩余项的数量,或其他什么——这并不明确。虽然良好的命名习惯是绝对正确的,但它不能替代用心的文档编写。记住:没有免费的午餐。

为了简化工作,专业人员使用自动文档生成器,这些生成器会分析代码和源文件中的注释,生成各种格式的全面文档。将此类生成器添加到 CMake 项目中非常简单——让我们来看看如何做!

本章我们将涵盖以下主要内容:

  • 将 Doxygen 添加到你的项目中

  • 使用现代化外观生成文档

  • 使用自定义 HTML 增强输出

技术要求

你可以在 GitHub 上找到本章中出现的代码文件,地址是:github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch13

要构建本书中提供的示例,请始终使用推荐的命令:

cmake -B <build tree> -S <source tree>
cmake --build <build tree> 

一定要将占位符<build tree><source tree>替换为适当的路径。提醒一下:build tree是目标/输出目录的路径,source tree是源代码所在的路径。

将 Doxygen 添加到你的项目中

用于从 C++ 源代码生成文档的最成熟和最流行的工具之一就是 Doxygen。当我说“成熟”时,我是认真的:第一个版本是由 Dimitri van Heesch 于 1997 年 10 月发布的。此后,它得到了巨大的发展,并得到了几乎 250 位贡献者的积极支持(github.com/doxygen/doxygen)。

你可能会担心将 Doxygen 纳入没有从一开始就使用文档生成的大型项目中的挑战。的确,注释每个函数的任务可能看起来非常繁重。然而,我鼓励你从小处开始。专注于记录你最近在最新提交中工作的元素。记住,即使是部分完成的文档也比完全没有要好,而且它逐渐有助于建立你项目的更全面的理解。

Doxygen 可以生成以下格式的文档:

  • 超文本标记语言 (HTML)

  • 富文本格式 (RTF)

  • 可移植文档格式 (PDF)

  • Lamport TeX (LaTeX)

  • PostScript (PS)

  • Unix 手册(man 页面)

  • 微软编译的 HTML 帮助 (.CHM)

如果你在代码中使用注释按照 Doxygen 指定的格式提供额外信息,它会解析这些注释以丰富输出文件。此外,代码结构还将被分析以生成有用的图表和图示。后者是可选的,因为它需要外部的 Graphviz 工具(graphviz.org/)。

开发者应该首先考虑以下问题:项目的用户只会接收文档,还是他们会自己生成文档(例如在从源代码构建时)?第一个选项意味着文档会与二进制文件一起分发,或者在线提供,或者(不那么优雅地)与源代码一起提交到代码库中。

这个考虑非常重要,因为如果你希望用户在构建过程中生成文档,他们的系统中必须存在这些依赖项。这并不是一个大问题,因为 Doxygen 和 Graphviz 可以通过大多数包管理器获得,所需要的只是一个简单的命令,比如针对 Debian 系统的命令:

apt-get install doxygen graphviz 

Windows 版本的二进制文件也可以使用(请查看项目网站的进一步阅读部分)。

然而,一些用户可能不愿意安装这些工具。我们必须决定是为用户生成文档,还是让他们在需要时添加依赖项。项目也可以像第九章中描述的那样,自动为用户添加这些依赖项,管理 CMake 中的依赖项。请注意,Doxygen 是使用 CMake 构建的,因此如果需要,你已经知道如何从源代码编译它。

当系统中安装了 Doxygen 和 Graphviz 时,我们可以将文档生成功能添加到项目中。与一些在线资料所建议的相反,这并不像看起来那么困难或复杂。我们无需创建外部配置文件,提供 Doxygen 可执行文件的路径,或添加自定义目标。自 CMake 3.9 起,我们可以使用来自 FindDoxygen 查找模块的 doxygen_add_docs() 函数,它会设置文档目标。

函数签名如下:

doxygen_add_docs(targetName [sourceFilesOrDirs...]
  [ALL] [WORKING_DIRECTORY dir] [COMMENT comment]) 

第一个参数指定目标名称,我们需要在 cmake-t 参数中显式构建该目标(生成构建树之后),如下所示:

# cmake --build <build-tree> -t targetName 

或者,我们可以通过添加 ALL 参数来确保始终构建文档,尽管通常不需要这样做。WORKING_DIRECTORY 选项非常简单;它指定了命令应在其中执行的目录。由 COMMENT 选项设置的值会在文档生成开始前显示,提供有用的信息或说明。

我们将遵循前几章的做法,创建一个包含辅助函数的工具模块(以便在其他项目中重用),如下所示:

ch13/01-doxygen/cmake/Doxygen.cmake

function(Doxygen input output)
  find_package(Doxygen)
  if (NOT DOXYGEN_FOUND)
    add_custom_target(doxygen COMMAND false
      COMMENT "Doxygen not found")
    return()
  endif()
  set(DOXYGEN_GENERATE_HTML YES)
  set(DOXYGEN_HTML_OUTPUT
    ${PROJECT_BINARY_DIR}/${output})
  doxygen_add_docs(doxygen
      ${PROJECT_SOURCE_DIR}/${input}
      COMMENT "Generate HTML documentation"
  )
endfunction() 

该函数接受两个参数——inputoutput 目录——并创建一个自定义的 doxygen 目标。以下是发生的过程:

  1. 首先,我们使用 CMake 内建的 Doxygen 查找模块来确定系统中是否安装了 Doxygen。

  2. 如果 Doxygen 不可用,我们会创建一个虚拟的 doxygen 目标,向用户提示并运行 false 命令,(在类 Unix 系统中)返回 1,导致构建失败。此时,我们会用 return() 终止该函数。

  3. 如果 Doxygen 可用,我们将其配置为在提供的 output 目录中生成 HTML 输出。Doxygen 是极其可配置的(更多信息请参阅官方文档)。要设置任何选项,只需按照示例调用 set() 并在选项名前加上 DOXYGEN_ 前缀。

  4. 设置实际的 doxygen 目标。所有 DOXYGEN_ 变量将被转发到 Doxygen 的配置文件中,并从提供的源树中的 input 目录生成文档。

如果您的文档需要由用户生成,步骤 2 可能应该涉及安装 Doxygen。

要使用这个函数,我们可以将其集成到项目的主列表文件中,如下所示:

ch13/01-doxygen/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Doxygen CXX)
enable_testing()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
add_subdirectory(src bin)
**include****(Doxygen)**
**Doxygen(src docs)** 

一点也不难!构建 doxygen 目标会生成类似这样的 HTML 文档:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_13_01.png

图 13.1:使用 Doxygen 生成的类参考

为了在成员函数文档中添加重要细节,我们可以在头文件中用适当的注释将 C++ 方法声明之前,像这样:

ch13/01-doxygen/src/calc.h(片段)

 /**
    Multiply... Who would have thought?
    @param a the first factor
    @param b the second factor
    @result The product
   */
   int Multiply(int a, int b); 

这种格式被称为 Javadoc。重要的是,注释块应该以双星号开始:/**。更多信息可以在 Doxygen 的 docblocks 描述中找到(参见 Further reading 部分的链接)。带有这种注释的 Multiply 函数将如下面的图所示呈现:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_13_02.png

图 13.2:参数和结果的注释

如前所述,如果安装了 Graphviz,Doxygen 会自动检测并生成依赖图,正如下图所示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_13_03.png

图 13.3:由 Doxygen 生成的继承和协作图

通过直接从源代码生成文档,我们建立了一种在开发周期中随代码更改迅速更新的流程。此外,在代码审查过程中,任何被忽视的注释更新也很容易被发现。

许多开发者表达了对 Doxygen 提供的设计显得过时的担忧,这让他们不愿意将生成的文档展示给客户。然而,这个问题有一个简单的解决方案。

生成现代化外观的文档

使用干净、清新的设计对项目进行文档化非常重要。毕竟,如果我们为我们的前沿项目投入了这么多精力编写高质量的文档,用户必须意识到这一点。尽管 Doxygen 功能丰富,但它并不以遵循最新视觉趋势而著称。然而,重新设计其外观并不需要大量的努力。

幸运的是,一位名为 jothepro 的开发者创建了一个名为 doxygen-awesome-css 的主题,它提供了一个现代化且可定制的设计。这个主题在下面的截图中展示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_13_04.png

图 13.4:doxygen-awesome-css 主题下的 HTML 文档

该主题不需要任何额外的依赖项,可以通过其 GitHub 页面轻松获取:github.com/jothepro/doxygen-awesome-css

尽管一些在线资源推荐使用多种应用程序的组合,比如通过 Breathe 和 Exhale 扩展将 Doxygen 的输出与 Sphinx 配合使用,但这种方法可能会很复杂且依赖较多(例如需要 Python)。通常来说,更简洁的方法更为实际,尤其是对于那些并非每个成员都深度了解 CMake 的团队来说。

我们可以通过自动化流程高效实现这一主题。让我们看看如何通过添加一个新宏来扩展我们的 Doxygen.cmake 文件以使用它:

ch13/02-doxygen-nice/cmake/Doxygen.cmake(片段)

macro(UseDoxygenAwesomeCss)
  include(FetchContent)
  FetchContent_Declare(doxygen-awesome-css
    GIT_REPOSITORY
      https://github.com/jothepro/doxygen-awesome-css.git
    GIT_TAG
      V2.3.1
  )
  FetchContent_MakeAvailable(doxygen-awesome-css)
  set(DOXYGEN_GENERATE_TREEVIEW     YES)
  set(DOXYGEN_HAVE_DOT              YES)
  set(DOXYGEN_DOT_IMAGE_FORMAT      svg)
  set(DOXYGEN_DOT_TRANSPARENT       YES)
  set(DOXYGEN_HTML_EXTRA_STYLESHEET
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome.css)
endmacro() 

我们已经从书中的前几章了解了所有这些命令,但为了确保完全清晰,让我们再重复一遍发生了什么:

  1. 使用 FetchContent 模块从 Git 获取 doxygen-awesome-css

  2. 配置 Doxygen 的额外选项(这些是主题的 README 文件中专门推荐的)

  3. 将主题的 css 文件复制到 Doxygen 的输出目录

如你所想,最好在Doxygen函数中调用这个宏,并在doxygen_add_docs()之前,如下所示:

ch13/02-doxygen-nice/cmake/Doxygen.cmake(片段)

function(Doxygen input output)
# ...
  **UseDoxygenAwesomeCss()**
  doxygen_add_docs (...)
endfunction()
macro(UseDoxygenAwesomeCss)
# ...
endmacro() 

记住,宏中的所有变量都在调用函数的作用域内设置。

我们现在可以在生成的 HTML 文档中享受现代风格,并骄傲地与世界分享它。然而,我们的主题提供了一些 JavaScript 模块来增强体验。我们该如何包含它们呢?

使用自定义 HTML 增强输出

Doxygen Awesome 提供了一些附加功能,可以通过在文档头部的 HTML <head> 标签内包含一些 JavaScript 片段来启用。它们非常有用,因为它们允许在亮色模式和暗色模式之间切换,添加代码片段的复制按钮,段落标题的永久链接,以及互动目录。

然而,实现这些功能需要将额外的代码复制到输出目录,并将其包含在生成的 HTML 文件中。

这是需要在</head>标签之前包含的 JavaScript 代码:

ch13/cmake/extra_headers

<script type="text/javascript" src="img/$relpath^doxygen-awesome-darkmode-toggle.js"></script>
<script type="text/javascript" src="img/$relpath^doxygen-awesome-fragment-copy-button.js"></script>
<script type="text/javascript" src="img/$relpath^doxygen-awesome-paragraph-link.js"></script>
<script type="text/javascript" src="img/$relpath^doxygen-awesome-interactive-toc.js"></script>
<script type="text/javascript">
    DoxygenAwesomeDarkModeToggle.init()
    DoxygenAwesomeFragmentCopyButton.init()
    DoxygenAwesomeParagraphLink.init()
    DoxygenAwesomeInteractiveToc.init()
</script> 

如你所见,这段代码首先会包含一些 JavaScript 文件,然后初始化不同的扩展。不幸的是,这段代码不能简单地添加到某个变量中。相反,我们需要用自定义文件覆盖默认的头文件。这个覆盖可以通过在 Doxygen 的HTML_HEADER配置变量中提供文件路径来完成。

若要创建一个自定义头文件而不硬编码整个内容,可以使用 Doxygen 的命令行工具生成默认的头文件,并在生成文档之前编辑它:

doxygen -w html header.html footer.html style.css 

虽然我们不会使用或修改footer.htmlstyle.css,但它们是必需的参数,因此我们仍然需要创建它们。

最后,我们需要自动将</head>标签与ch13/cmake/extra_headers文件的内容进行连接,以包含所需的 JavaScript。这可以通过 Unix 命令行工具sed来完成,它将原地编辑header.html文件:

sed -i '/<\/head>/r ch13/cmake/extra_headers' header.html 

现在我们需要用 CMake 语言将这些步骤编码。以下是实现这一目标的宏:

ch13/02-doxygen-nice/cmake/Doxygen.cmake(片段)

macro(UseDoxygenAwesomeExtensions)
  set(DOXYGEN_HTML_EXTRA_FILES
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome-darkmode-toggle.js
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome-fragment-copy-button.js
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome-paragraph-link.js
    ${doxygen-awesome-css_SOURCE_DIR}/doxygen-awesome-interactive-toc.js
  )
  execute_process(
   COMMAND doxygen -w html header.html footer.html style.css
   WORKING_DIRECTORY ${PROJECT_BINARY_DIR}
  )
  execute_process(
   COMMAND sed -i
   "/<\\/head>/r ${PROJECT_SOURCE_DIR}/cmake/extra_headers"   
   header.html
   WORKING_DIRECTORY ${PROJECT_BINARY_DIR}
  )
  set(DOXYGEN_HTML_HEADER ${PROJECT_BINARY_DIR}/header.html)
endmacro() 

这段代码看起来很复杂,但仔细检查后,你会发现它其实非常直接。它的功能如下:

  1. 将四个 JavaScript 文件复制到输出目录

  2. 执行doxygen命令以生成默认的 HTML 文件

  3. 执行sed命令以将所需的 JavaScript 注入头文件

  4. 使用自定义版本覆盖默认头文件

为了完成集成,在启用基本样式表之后,调用这个宏:

ch13/02-doxygen-nice/cmake/Doxygen.cmake(片段)

function(Doxygen input output)
 # …
  UseDoxygenAwesomeCss()
  **UseDoxygenAwesomeExtensions()**
# …
endfunction() 

该示例的完整代码以及实际示例可以在本书的在线仓库中找到。像往常一样,我建议在实际环境中查看和探索这些示例。

其他文档生成工具

本书没有涉及其他许多工具,因为我们专注于 CMake 支持的项目。不过,其中一些工具可能更适合你的使用场景。如果你感兴趣,可以访问我发现的两个有趣项目的官方网站:

  • Adobe 的 Hyde (github.com/adobe/hyde):Hyde 针对 Clang 编译器,生成的 Markdown 文件可以被 Jekyll (jekyllrb.com/) 等工具使用,Jekyll 是一个由 GitHub 支持的静态页面生成器。

  • Standardese (github.com/standardese/standardese):这个工具使用 libclang 来编译代码,并提供 HTML、Markdown、LaTex 和 man 页的输出。它的目标(相当大胆)是成为下一个 Doxygen。

总结

在这一章中,我们深入探讨了如何将强大的文档生成工具 Doxygen 添加到你的 CMake 项目中,并提升其吸引力。尽管这项任务看起来可能令人生畏,但实际上相当可控,并且显著提升了你解决方案中的信息流动和清晰度。如你所见,花时间添加和维护文档是值得的,尤其是当你或你的团队成员在理解应用中的复杂关系时。

在探索如何使用 CMake 内置的 Doxygen 支持来生成实际文档之后,我们稍微转了个方向,确保文档不仅具有可读性,还具有可理解性。

由于过时的设计可能让眼睛感到不适,我们探讨了生成的 HTML 的替代外观。这是通过使用 Doxygen Awesome 扩展来完成的。为了启用其附带的增强功能,我们通过添加必要的 JavaScript 自定义了标准头部。

通过生成文档,你可以确保它与实际代码的接近度,使得在逻辑同步的情况下,维护书面解释变得更加容易,尤其是当它们都在同一个文件中时。此外,作为程序员,你可能需要同时处理大量任务和细节。文档作为记忆辅助工具,帮助你保持和回忆项目的复杂性。记住,“即使是最短的铅笔也比最强的记忆要长。”做个对自己有益的事——把长的事情写下来,成功就会跟随而来。

总结来说,本章强调了 Doxygen 在你的项目管理工具包中的价值,帮助团队内部的理解和沟通。

在下一章中,我将带你了解如何通过 CMake 自动化打包和安装项目,进一步提升你的项目管理技能。

进一步阅读

加入我们社区的 Discord

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/vXN53A7ZcA

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/QR_Code94081075213645359.png

第十四章:安装和打包

我们的项目已经构建、测试并且文档化完毕。现在,终于到了将其发布给用户的时候。本章主要集中在我们需要采取的最后两步:安装和打包。这些是建立在我们迄今为止所学的所有内容之上的高级技术:管理目标及其依赖关系、临时使用要求、生成器表达式等等。

安装使我们的项目可以被发现并在系统范围内访问。我们将讨论如何导出目标以供其他项目使用而无需安装,以及如何安装我们的项目以便轻松地在系统范围内访问。我们还将学习如何配置项目,使其能够自动将各种工件类型放置到适当的目录中。为了处理更高级的场景,我们将介绍一些低级命令,用于安装文件和目录,以及执行自定义脚本和 CMake 命令。

接下来,我们将探讨如何设置可重用的 CMake 包,其他项目可以通过 find_package() 命令进行发现。我们将解释如何确保目标及其定义不局限于特定的文件系统位置。我们还将讨论如何编写基本的和高级的 配置文件,以及与包关联的 版本文件。然后,为了使事物更具模块化,我们将简要介绍组件的概念,无论是在 CMake 包还是在 install() 命令方面。所有这些准备工作将为本章的最终部分铺平道路:使用 CPack 生成归档、安装程序、捆绑包和包,这些包能够被不同操作系统中的各种包管理器识别。这些包可以分发预构建的工件、可执行文件和库。这是最终用户开始使用我们软件的最简单方法。

在本章中,我们将覆盖以下主要主题:

  • 无需安装即可导出

  • 在系统上安装项目

  • 创建可重用的包

  • 定义组件

  • 使用 CPack 打包

技术要求

你可以在 GitHub 上找到本章中出现的代码文件,网址是github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch14

为了构建本书中提供的示例,请始终使用推荐的命令:

cmake -B <build tree> -S <source tree>
cmake --build <build tree> 

要安装示例,请使用以下命令:

cmake --install <build tree> 

确保将 <build tree><source tree> 占位符替换为适当的路径。提醒一下:build tree 是指目标/输出目录的路径,source tree 是指源代码所在的路径。

无需安装即可导出

我们如何让项目A的目标对使用它的项目B可用?通常,我们会使用find_package()命令,但这要求创建一个包并将其安装到系统中。虽然有用,但这种方法需要一些工作。有时,我们只需要一种快速构建项目并使其目标可供其他项目使用的方法。

一种节省时间的方法是包含项目BA的主列表文件,该文件已经包含了所有目标定义。然而,这个文件也可能包含全局配置、带有副作用的 CMake 命令、额外的依赖项,甚至可能包含不适合B的目标(比如单元测试)。因此,这不是最好的方法。相反,我们可以为使用项目B提供一个目标导出文件,让它通过include()命令来包含:

cmake_minimum_required(VERSION 3.26.0)
project(B)
include(/path/to/A/TargetsOfA.cmake) 

这将使用像add_library()add_executable()这样的命令,定义所有目标并设置正确的属性。

你必须在TARGETS关键字后指定所有要导出的目标,并在FILE后提供目标文件名。其他参数是可选的:

export(**TARGETS [target1 [target2 [...]]]**
       [NAMESPACE <namespace>] [APPEND] **FILE** **<path>**
       [EXPORT_LINK_INTERFACE_LIBRARIES]
) 

这是对各个参数的解释:

  • NAMESPACE建议用来指示目标是从其他项目导入的。

  • APPEND防止 CMake 在写入前清除文件内容。

  • EXPORT_LINK_INTERFACE_LIBRARIES导出目标链接依赖关系(包括导入的和配置特定的变体)。

让我们将此导出方法应用到Calc库示例,它提供了两个简单的方法:

ch14/01-export/src/include/calc/basic.h

#pragma once
int Sum(int a, int b);
int Multiply(int a, int b); 

我们需要声明Calc目标,以便有东西可以导出:

ch14/01-export/src/CMakeLists.txt

add_library(calc STATIC basic.cpp)
target_include_directories(calc INTERFACE include) 

然后,为了生成导出文件,我们使用export(TARGETS)命令:

ch14/01-export/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.26)
project(ExportCalcCXX)
add_subdirectory(src bin)
set(EXPORT_DIR "${CMAKE_CURRENT_BINARY_DIR}/cmake")
export(TARGETS calc
  FILE "${EXPORT_DIR}/CalcTargets.cmake"
  NAMESPACE Calc::
) 

我们的导出目标声明文件将位于构建树的cmake子目录中(遵循.cmake文件的约定)。为了避免稍后重复此路径,我们将其设置为EXPORT_DIR变量。然后,我们调用export()生成目标声明文件CalcTargets.cmake,其中包含calc目标。对于包含此文件的项目,它将作为Calc::calc可见。

请注意,此导出文件还不是一个包。更重要的是,这个文件中的所有路径都是绝对路径并且硬编码为构建树中的路径,导致它们无法移动(在理解可移动目标的问题一节中讨论)。

export()命令也有一个简化版本,使用EXPORT关键字:

export(EXPORT <export> [NAMESPACE <namespace>] [FILE <path>]) 

然而,它需要预定义导出的名称,而不是一个导出目标的列表。此类<export>实例是由install(TARGETS)创建的目标名称列表(我们将在安装逻辑目标一节中讨论此命令)。

这是一个小示例,演示了这种简写是如何在实践中使用的:

ch14/01-export/CMakeLists.txt(续)

install(TARGETS calc EXPORT CalcTargets)
export(EXPORT CalcTargets
  FILE "${EXPORT_DIR}/CalcTargets2.cmake"
  NAMESPACE Calc::
) 

这段代码的工作方式与之前的示例类似,但现在它在export()install()命令之间共享一个单一的目标列表。

两种生成导出文件的方法产生类似的结果。它们包括一些样板代码和几行定义目标的代码。在将<build-tree>设置为构建树路径后,它们将创建类似于以下的目标导出文件

/cmake/CalcTargets.cmake (片段)

# Create imported target Calc::calc
add_library(Calc::calc STATIC IMPORTED)
set_target_properties(Calc::calc PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES
  **"/<source-tree>/include"**
)
# Import target "Calc::calc" for configuration ""
set_property(TARGET Calc::calc APPEND PROPERTY
  IMPORTED_CONFIGURATIONS NOCONFIG
)
set_target_properties(Calc::calc PROPERTIES
  IMPORTED_LINK_INTERFACE_LANGUAGES_NOCONFIG "CXX"
  IMPORTED_LOCATION_NOCONFIG "**/<build-tree>/libcalc.a**"
) 

通常,我们不会编辑或打开此文件,但需要注意的是,路径将被硬编码在其中(请参见高亮行)。在当前形式下,构建的项目无法重新定位。要改变这一点,需要一些额外的步骤。在下一节中,我们将解释什么是重新定位以及为什么它很重要。

将项目安装到系统中

第一章CMake 入门中,我们提到 CMake 为将构建的项目安装到系统中提供了命令行模式:

cmake --install <dir> [<options>] 

这里,<dir>是生成的构建树的路径(必需)。<options>包括:

  • --config <cfg>:选择多配置生成器的构建配置。

  • --component <comp>:将安装限制为指定的组件。

  • --default-directory-permissions <permissions>:设置已安装目录的默认权限(以<u=rwx,g=rx,o=rx>格式)。

  • --install-prefix <prefix>:指定非默认的安装路径(存储在CMAKE_INSTALL_PREFIX变量中)。在类 Unix 系统上默认为/usr/local,在 Windows 上默认为c:/Program Files/${PROJECT_NAME}。在 CMake 3.21 之前,您必须使用一个不太明确的选项:--prefix <prefix>

  • -v, --verbose:增加输出的详细程度(也可以通过设置VERBOSE环境变量实现)。

安装通常涉及将生成的产物和必要的依赖项复制到系统目录中。使用 CMake 为所有 CMake 项目引入了一个方便的安装标准,并提供了几个额外的好处:

  • 它为不同类型的产物提供平台特定的安装路径(遵循GNU 编码标准)。

  • 它通过生成目标导出文件来增强安装过程,允许其他项目直接重用项目的目标。

  • 它通过配置文件创建可发现的包,包装目标导出文件以及作者定义的特定于包的 CMake 宏和函数。

这些功能非常强大,因为它们节省了大量时间,并简化了以这种方式准备的项目的使用。执行基本安装的第一步是将构建的产物复制到目标目录。这将引导我们进入install()命令及其各种模式:

  • install(``TARGETS): 该命令用于安装输出的产物,如库文件和可执行文件。

  • install(FILES|PROGRAMS):安装单个文件并设置它们的权限。这些文件不需要是任何逻辑目标的一部分。

  • install(DIRECTORY):此命令安装整个目录。

  • install(SCRIPT|CODE):在安装过程中运行 CMake 脚本或代码片段。

  • install(EXPORT):此命令生成并安装目标导出文件。

  • install(RUNTIME_DEPENDENCY_SET <set-name> [...]):此命令安装项目中定义的运行时依赖集。

  • install(IMPORTED_RUNTIME_ARTIFACTS <target>... [...]):此命令查询导入的目标的运行时工件并安装它们。

将这些命令添加到你的列表文件中会在构建树中生成一个 cmake_install.cmake 文件。虽然可以通过 cmake -P 手动调用此脚本,但不推荐这么做。该文件是 CMake 在执行 cmake --install 时内部使用的。

每种 install() 模式都有一组全面的选项,其中一些选项在不同模式间是共享的:

  • DESTINATION:此选项指定安装路径。相对路径会与 CMAKE_INSTALL_PREFIX 一起使用,而绝对路径会按原样使用(并且不被 cpack 支持)。

  • PERMISSIONS:此选项设置支持平台上的文件权限。可用的值包括 OWNER_READOWNER_WRITEOWNER_EXECUTEGROUP_READGROUP_WRITEGROUP_EXECUTEWORLD_READWORLD_WRITEWORLD_EXECUTESETUIDSETGID。可以通过 CMAKE_INSTALL_DEFAULT_DIRECTORY_PERMISSIONS 变量设置安装时创建的默认目录权限。

  • CONFIGURATIONS:此选项指定配置(DebugRelease)。跟随此关键字的选项仅在当前构建配置位于列表中时才适用。

  • OPTIONAL:如果安装的文件不存在,则不会报错。

两个共享选项,COMPONENTEXCLUDE_FROM_ALL,用于特定组件的安装。这些选项将在本章稍后的定义组件部分讨论。现在,让我们先看看第一个安装模式:install(TARGETS)

安装逻辑目标

通过 add_library()add_executable() 定义的目标可以通过 install(TARGETS) 命令轻松安装。这意味着将构建系统生成的工件复制到适当的目标目录,并为它们设置合适的文件权限。此模式的通用签名如下:

install(TARGETS <target>... [EXPORT <export-name>]
        [<output-artifact-configuration> ...]
        [INCLUDES DESTINATION [<dir> ...]]
) 

在初始模式指定符(即 TARGETS)之后,我们必须提供我们希望安装的目标列表。在这里,我们可以选择性地通过 EXPORT 选项将它们分配给一个命名的导出,该导出可以在 export(EXPORT)install(EXPORT) 中使用,以生成目标导出文件。接着,我们需要配置输出工件的安装(按类型分组)。可选地,我们还可以提供一个目录列表,这些目录将添加到每个目标的 INTERFACE_INCLUDE_DIRECTORIES 属性的目标导出文件中。

[<output-artifact-configuration>...] 提供了一组配置块的列表。单个块的完整语法如下:

**<TYPE>** [DESTINATION <dir>]
       [PERMISSIONS permissions...]
       [CONFIGURATIONS [Debug|Release|...]]
       [COMPONENT <component>]
       [NAMELINK_COMPONENT <component>]
       [OPTIONAL] [EXCLUDE_FROM_ALL]
       [NAMELINK_ONLY|NAMELINK_SKIP] 

该命令要求每个输出产物块以<TYPE>开始(这是唯一必需的元素)。CMake 识别几种类型:

  • ARCHIVE:静态库(.a)和 Windows 系统的 DLL 导入库(.lib)。

  • LIBRARY:共享库(.so),但不包括 DLL。

  • RUNTIME:可执行文件和 DLL。

  • OBJECTS:来自OBJECT库的目标文件

  • FRAMEWORK:具有FRAMEWORK属性设置的静态库和共享库(这将使它们排除在ARCHIVELIBRARY之外)。这是特定于 macOS 的。

  • BUNDLE:标记为MACOSX_BUNDLE的可执行文件(也不属于RUNTIME)。

  • FILE_SET <set>:目标指定的<set>文件集中的文件。可以是 C++头文件或 C++模块头文件(自 CMake 3.23 起)。

  • PUBLIC_HEADERPRIVATE_HEADERRESOURCE:在目标属性中指定的文件,名称相同(在 Apple 平台上,它们应该设置在FRAMEWORKBUNDLE目标中)。

CMake 文档声称,如果你只配置了一种产物类型(例如LIBRARY),则仅安装这种类型。对于 CMake 3.26.0 版本而言,这不准确:所有产物都会像使用默认选项一样安装。可以通过为所有不需要的产物类型指定<TYPE> EXCLUDE_FROM_ALL来解决此问题。

一个install(TARGETS)命令可以包含多个产物配置块。然而,需要注意的是,每次调用时只能指定一种类型。也就是说,如果你想为DebugRelease配置设置不同的ARCHIVE产物目标路径,那么必须分别执行两次install(TARGETS ... ARCHIVE)调用。

你也可以省略类型名称并为所有产物指定选项。安装时将会对这些目标产生的每个文件执行安装,无论其类型如何:

install(TARGETS executable, static_lib1
  DESTINATION /tmp
) 

在许多情况下,你不需要显式地提供DESTINATION,因为有内建的默认值,但在处理不同平台时,有一些注意事项需要记住。

利用不同平台的默认安装路径

当 CMake 安装你的项目文件时,它会将文件复制到系统中的特定目录。不同的文件类型应该放在不同的目录中。该目录由以下公式确定:

${CMAKE_INSTALL_PREFIX} + ${DESTINATION} 

如前一节所述,你可以显式地提供安装的DESTINATION组件,或者让 CMake 根据产物类型使用内建的默认值:

产物类型 内建默认值 安装目录变量
RUNTIME bin CMAKE_INSTALL_BINDIR
LIBRARY``ARCHIVE lib CMAKE_INSTALL_LIBDIR
PUBLIC_HEADER``PRIVATE_HEADER``FILE_SETtype HEADERS include CMAKE_INSTALL_INCLUDEDIR

表 14.1:每种产物类型的默认目标路径

虽然默认路径非常有用,但并非总是适用。例如,CMake 默认将库的DESTINATION设置为lib。库的完整路径会被计算为 Unix 类系统上的/usr/local/lib,在 Windows 上则是类似C:\Program Files (x86)\<project-name>\lib的路径。然而,对于支持多架构的 Debian 来说,这并不理想,因为它需要一个架构特定的路径(例如,i386-linux-gnu),当INSTALL_PREFIX/usr时。为每个平台确定正确的路径是 Unix 类系统的常见挑战。为了解决这个问题,可以遵循GNU 编码标准,相关链接会在本章最后的进一步阅读部分提供。

我们可以通过设置CMAKE_INSTALL_<DIRTYPE>DIR变量来覆盖每个值的默认目标路径。与其开发算法来检测平台并为安装目录变量分配适当的路径,不如使用 CMake 的GNUInstallDirs工具模块。该模块通过相应地设置安装目录变量来处理大多数平台。只需在任何install()命令之前通过include()包含该模块,就可以完成配置。

需要自定义配置的用户可以通过命令行参数覆盖安装目录变量,如下所示:

-DCMAKE_INSTALL_BINDIR=/path/in/the/system 

然而,安装库的公共头文件仍然存在挑战。让我们来探讨一下原因。

处理公共头文件

在 CMake 中管理公共头文件时,最佳实践是将其存储在一个能表示其来源并引入命名空间的目录中,例如/usr/local/include/calc。这使得它们可以在 C++项目中通过包含指令使用:

#include <calc/basic.h> 

大多数预处理器将尖括号指令解释为请求扫描标准系统目录。我们可以使用GNUInstallDirs模块自动填充安装路径中的DESTINATION部分,确保头文件最终被放置在include目录中。

从 CMake 3.23.0 开始,我们可以通过target_sources()命令和FILE_SET关键字显式地添加要安装到适当目标的头文件。这个方法更为推荐,因为它处理了头文件的重定位问题。以下是语法:

target_sources(<target>
  [<PUBLIC|PRIVATE|INTERFACE>
   **[FILE_SET <name> TYPE <type> [BASE_DIR <dir>] FILES]**
   <files>...
  ]...
) 

假设我们的头文件位于src/include/calc目录中,以下是一个实际的示例:

ch14/02-install-targets/src/CMakeLists.txt (片段)

add_library(calc STATIC basic.cpp)
target_include_directories(calc INTERFACE include)
target_sources(calc PUBLIC FILE_SET HEADERS
                           BASE_DIRS include
                           FILES include/calc/basic.h
) 
 target file set called HEADERS. We’re using a special case here: if the name of the file set matches one of the available types, CMake will assume we want the file set to be of such type, eliminating the need to define the type explicitly. If you use a different name, remember to define the FILE_SET's type with the appropriate TYPE <TYPE> keyword.

在定义文件集后,我们可以像这样在安装命令中使用它:

ch14/02-install-targets/src/CMakeLists.txt (续)

...
include(GNUInstallDirs)
install(TARGETS calc ARCHIVE FILE_SET HEADERS) 

我们包含GNUInstallDirs模块并配置calc静态库及其头文件的安装。在安装模式下运行cmake,如预期那样工作:

# cmake -S <source-tree> -B <build-tree>
# cmake --build <build-tree>
# cmake --install <build-tree>
-- Install configuration: ""
-- Installing: /usr/local/lib/libcalc.a
-- Installing: /usr/local/include/calc/basic.h 

FILE_SET HEADERS关键字的支持是一个相对较新的更新,遗憾的是,并不是所有环境都能提供更新版的 CMake

如果你使用的版本低于 3.23,你需要在库目标的 PUBLIC_HEADER 属性中指定公共头文件(以分号分隔的列表),并手动处理重定位问题(更多信息请参见 理解可重定位目标的问题 部分):

ch14/03-install-targets-legacy/src/CMakeLists.txt (片段)

add_library(calc STATIC basic.cpp)
target_include_directories(calc INTERFACE include)
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER src/include/calc/basic.h
) 

你还需要更改目标目录,将库名包括在 include 路径中:

ch14/02-install-targets-legacy/src/CMakeLists.txt (续)

...
include(GNUInstallDirs)
install(TARGETS calc
  ARCHIVE
  **PUBLIC_HEADER**
  **DESTINATION** **${CMAKE_INSTALL_INCLUDEDIR}****/calc**
) 

显式地将 /calc 插入路径是必要的,因为在 PUBLIC_HEADER 属性中指定的文件不会保留其目录结构。即使这些文件嵌套在不同的基础目录中,它们也会被安装到同一个目标目录。这一重大缺点促使了 FILE_SET 的开发。

现在,你知道如何处理大多数安装情况,但对于更高级的场景应该如何处理呢?

低级安装

现代 CMake 正在逐渐避免直接操作文件。理想情况下,我们应将文件添加到逻辑目标中,使用目标作为一种高级抽象来表示所有底层资源:源文件、头文件、资源、配置等等。主要优势是代码的简洁性;通常,将文件添加到目标只需要更改一行代码。

不幸的是,并不是所有的安装文件都可以或方便地添加到一个目标中。在这种情况下,有三种选择:install(FILES)install(PROGRAMS)install(DIRECTORY)

使用 install(FILES) 和 install(PROGRAMS) 安装

FILESPROGRAMS 模式非常相似。它们可以用来安装各种资源,包括公共头文件、文档、脚本、配置文件以及运行时资源,如图片、音频文件和数据集。

这是命令签名:

install(<FILES|PROGRAMS> files...
        TYPE <type> | DESTINATION <dir>
        [PERMISSIONS permissions...]
        [CONFIGURATIONS [Debug|Release|...]]
        [COMPONENT <component>]
        [RENAME <name>] [OPTIONAL] [EXCLUDE_FROM_ALL]
) 

FILESPROGRAMS 之间的主要区别是对复制文件设置的默认文件权限。install(PROGRAMS) 会为所有用户设置 EXECUTE 权限,而 install(FILES) 不会(但两者都会设置 OWNER_WRITEOWNER_READGROUP_READWORLD_READ 权限)。

你可以通过使用可选的 PERMISSIONS 关键字来修改此行为,然后选择前导关键字(FILESPROGRAMS)作为指示所安装内容的标志。我们已经介绍了 PERMISSIONSCONFIGURATIONSOPTIONAL 的工作原理。COMPONENTEXCLUDE_FROM_ALL 将在后面的 定义组件 部分讨论。

在初始关键字后,我们需要列出所有想要安装的文件。CMake 支持相对路径和绝对路径,也支持生成器表达式。记住,如果文件路径以生成器表达式开头,它必须是绝对路径。

下一个必需的关键字是 TYPEDESTINATION。你可以显式地提供 DESTINATION 路径,也可以要求 CMake 查找特定 TYPE 文件的路径。与 install(TARGETS) 中不同,在此上下文中,TYPE 并不选择任何要安装的文件子集。不过,安装路径的计算遵循相同的模式(其中 + 符号表示平台特定的路径分隔符):

${CMAKE_INSTALL_PREFIX} + ${DESTINATION}

类似地,每个 TYPE 都有内置的默认值:

文件类型 内置默认值 安装目录变量
BIN bin CMAKE_INSTALL_BINDIR
SBIN sbin CMAKE_INSTALL_SBINDIR
LIB lib CMAKE_INSTALL_LIBDIR
INCLUDE include CMAKE_INSTALL_INCLUDEDIR
SYSCONF etc CMAKE_INSTALL_SYSCONFDIR
SHAREDSTATE com CMAKE_INSTALL_SHARESTATEDIR
LOCALSTATE var CMAKE_INSTALL_LOCALSTATEDIR
RUNSTATE $LOCALSTATE/run CMAKE_INSTALL_RUNSTATEDIR
DATA $DATAROOT CMAKE_INSTALL_DATADIR
INFO $DATAROOT/info CMAKE_INSTALL_INFODIR
LOCALE $DATAROOT/locale CMAKE_INSTALL_LOCALEDIR
MAN $DATAROOT/man CMAKE_INSTALL_MANDIR
DOC $DATAROOT/doc CMAKE_INSTALL_DOCDIR

表 14.2:每种文件类型的内置默认值

这里的行为遵循在 利用不同平台的默认目标 小节中描述的相同原则:如果没有为该 TYPE 文件设置安装目录变量,CMake 将提供一个内置的默认路径。同样,为了便于移植,我们可以使用 GNUInstallDirs 模块。

表中的一些内置猜测以安装目录变量为前缀:

  • $LOCALSTATECMAKE_INSTALL_LOCALSTATEDIR,或者默认为 var

  • $DATAROOTCMAKE_INSTALL_DATAROOTDIR,或者默认为 share

install(TARGETS) 一样,GNUInstallDirs 模块将提供平台特定的安装目录变量。我们来看一个示例:

ch14/04-install-files/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(InstallFiles CXX)
include(GNUInstallDirs)
install(FILES
  src/include/calc/basic.h
  src/include/calc/nested/calc_extended.h
  DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
) 

在这种情况下,CMake 将这两个仅包含头文件的库 basic.hnested/calc_extended.h 安装到系统范围的 include 目录下的项目特定子目录中。

GNUInstallDirs 源代码中,我们知道 CMAKE_INSTALL_INCLUDEDIR 对所有支持的平台都是相同的。然而,仍然推荐使用它以提高可读性,并与更动态的变量保持一致。例如,CMAKE_INSTALL_LIBDIR 会根据架构和发行版有所不同 —— liblib64lib/<multiarch-tuple>

从 CMake 3.20 开始,你可以在 install(FILES)install(PROGRAMS) 命令中使用 RENAME 关键字。该关键字后面必须跟一个新文件名,并且只有在命令安装单个文件时才有效。

本节中的示例演示了将文件安装到合适目录的简便性。然而,有一个问题 —— 请观察安装输出:

# cmake -S <source-tree> -B <build-tree>
# cmake --build <build-tree>
# cmake --install <build-tree>
-- Install configuration: ""
-- Installing: /usr/local/include/calc/basic.h
-- Installing: /usr/local/include/calc/calc_extended.h 

无论原始嵌套如何,两个文件都会被安装到同一目录中。有时,这并不理想。我们将在下一部分探讨如何处理这种情况。

处理整个目录

如果将单个文件添加到安装命令中不适用,你可以选择更广泛的方法,处理整个目录。install(DIRECTORY)模式就是为此设计的,它将指定的目录逐字复制到选定的目标位置。它的表现如下:

install(DIRECTORY dirs...
        TYPE <type> | DESTINATION <dir>
        [FILE_PERMISSIONS permissions...]
        [DIRECTORY_PERMISSIONS permissions...]
        [USE_SOURCE_PERMISSIONS] [OPTIONAL] [MESSAGE_NEVER]
        [CONFIGURATIONS [Debug|Release|...]]
        [COMPONENT <component>] [EXCLUDE_FROM_ALL]
        [FILES_MATCHING]
        [[PATTERN <pattern> | REGEX <regex>] [EXCLUDE]
        [PERMISSIONS permissions...]] [...]
) 

这里的许多选项类似于install(FILES)install(PROGRAMS)中的选项,并以相同的方式运行。一个关键细节是,如果在DIRECTORY关键字后提供的路径不以/结尾,路径的最后一个目录将被附加到目标位置。例如:

install(DIRECTORY aaa DESTINATION /xxx) 

该命令创建一个目录/xxx/aaa,并将aaa的内容复制到其中。相比之下,下面的命令将aaa的内容直接复制到/xxx

install(DIRECTORY aaa/ DESTINATION /xxx) 

install(DIRECTORY)还引入了其他文件不可用的机制:

  • 输出静默

  • 扩展权限控制

  • 文件/目录过滤

让我们从输出静默选项MESSAGE_NEVER开始。它在安装过程中禁用输出诊断。当我们安装的目录中包含大量文件,并且打印所有文件信息会产生太多噪声时,它非常有用。

关于权限,install(DIRECTORY)支持三种选项:

  • USE_SOURCE_PERMISSIONS设置已安装文件的权限,跟随原始文件的权限。仅在未设置FILE_PERMISSIONS时有效。

  • FILE_PERMISSIONS允许我们指定要设置的已安装文件和目录的权限。默认权限为OWNER_WRITEOWNER_READGROUP_READWORLD_READ

  • DIRECTORY_PERMISSIONS的工作方式与FILE_PERMISSIONS相似,但它会为所有用户设置额外的EXECUTE权限(因为在类 Unix 系统中,目录的EXECUTE权限表示允许列出其内容)。

请注意,CMake 会忽略不支持权限选项的平台上的权限设置。通过在每个过滤表达式后添加PERMISSIONS关键字,可以实现更精细的权限控制。通过此方式匹配到的文件或目录将会接收指定的权限。

让我们来谈谈过滤器或“通配”表达式。它们控制从源目录中安装哪些文件/目录,并遵循以下语法:

PATTERN <pat> | REGEX <reg> [EXCLUDE] [PERMISSIONS <perm>] 

有两种匹配方法可以选择:

  • 使用PATTERN(这是一个更简单的选项),你可以提供一个包含?占位符(匹配任何字符)和*通配符(匹配任何字符串)的模式。只有以<pat>结尾的路径才会被匹配。

  • REGEX选项更为高级,支持正则表达式。它允许匹配路径的任何部分,尽管^$锚点仍然可以表示路径的开始和结束。

可选地,FILES_MATCHING关键字可以在第一个过滤器之前设置,指定过滤器将应用于文件,而非目录。

记住两个警告:

  • FILES_MATCHING需要一个包含性过滤器。你可以排除一些文件,但除非你也包括一些文件,否则没有文件会被复制。然而,所有目录都会被创建,不管是否进行了过滤。

  • 所有子目录默认都会包含;你只能排除它们。

对于每个过滤方法,你可以选择使用EXCLUDE命令排除匹配的路径(这仅在未使用FILES_MATCHING时有效)。

可以通过在任何过滤器后添加PERMISSIONS关键字及所需权限的列表来为所有匹配的路径设置特定权限。我们通过一个例子来探讨这个,假设我们安装了三个目录,以不同的方式操作。我们有一些供运行时使用的静态数据文件:

data
- data.csv 

我们还需要一些位于src目录中的公共头文件,以及其他无关的文件:

src
- include
  - calc
    - basic.h
    - ignored
      - empty.file
    - nested
      - calc_extended.h 

最后,我们需要两个配置文件,具有两个级别的嵌套。为了让事情更有趣,我们将使/etc/calc/的内容仅对文件所有者可访问:

etc
- calc
  - nested.conf
- sample.conf 

为了安装包含静态数据文件的目录,我们首先使用install(DIRECTORY)命令的最基本形式启动项目:

ch14/05-install-directories/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.26)
project(InstallDirectories CXX)
install(DIRECTORY data/ DESTINATION share/calc) 

这个命令将简单地将我们data目录中的所有内容放入${CMAKE_INSTALL_PREFIX}share/calc中。请注意,我们的源路径以/符号结尾,表示我们不想复制data目录本身,只复制其内容。

第二种情况是相反的:我们不添加尾部的/,因为该目录应该被包含。原因是我们依赖于一个特定于系统的路径来处理INCLUDE文件类型,这是由GNUInstallDirs提供的(注意INCLUDEEXCLUDE关键字表示不同的概念):

ch14/05-install-directories/CMakeLists.txt(片段)

...
include(GNUInstallDirs)
install(DIRECTORY src/include/calc TYPE INCLUDE
  PATTERN "ignored" EXCLUDE
  PATTERN "calc_extended.h" EXCLUDE
) 

此外,我们已经从此操作中排除了两个路径:整个ignored目录和所有以calc_extended.h结尾的文件(记住PATTERN是如何工作的)。

第三种情况安装一些默认的配置文件并设置它们的权限:

ch14/05-install-directories/CMakeLists.txt(片段)

install(DIRECTORY etc/ TYPE SYSCONF
  DIRECTORY_PERMISSIONS
    OWNER_READ OWNER_WRITE OWNER_EXECUTE
  PATTERN "nested.conf"
    PERMISSIONS OWNER_READ OWNER_WRITE
) 

我们避免将etc从源路径附加到SYSCONF路径(因为GNUInstallDirs已经提供了这一点),以防止重复。我们设置了两个权限规则:子目录仅对所有者可编辑和列出,而以nested.conf结尾的文件仅对所有者可编辑。

安装目录涵盖了各种使用案例,但对于其他高级场景(如安装后配置),可能需要外部工具。我们如何将它们集成?

在安装过程中调用脚本

如果你曾经在类 Unix 系统上安装过共享库,你可能记得需要指示动态链接器扫描受信目录并使用ldconfig构建缓存(参考进一步阅读部分获取相关资料)。为了便于完全自动化的安装,CMake 提供了install(SCRIPT)install(CODE)模式。以下是完整的语法:

install([[SCRIPT <file>] [CODE <code>]]
        [ALL_COMPONENTS | COMPONENT <component>]
        [EXCLUDE_FROM_ALL] [...]
) 
to execute during installation. To illustrate, let’s modify the 02-install-targets example to build a shared library:

ch14/06-install-code/src/CMakeLists.txt

add_library(calc **SHARED** basic.cpp)
target_include_directories(calc INTERFACE include)
target_sources(calc PUBLIC FILE_SET HEADERS
                           BASE_DIRS include
                           FILES include/calc/basic.h
) 

在安装脚本中将工件类型从ARCHIVE更改为LIBRARY,然后添加逻辑以便在之后运行ldconfig

ch14/06-install-code/CMakeLists.txt(片段)

install(TARGETS calc **LIBRARY** FILE_SET HEADERS))
**if** **(UNIX)**
**install****(CODE** **"execute_process(COMMAND ldconfig)"****)**
**endif****()** 

if()条件确保命令适用于操作系统(ldconfig不应在 Windows 或 macOS 上执行)。提供的代码在 CMake 中必须是语法有效的(错误只会在安装时显示)。

运行安装命令后,通过打印缓存的库来确认安装成功:

# cmake -S <source-tree> -B <build-tree>
# cmake --build <build-tree>
# cmake --install <build-tree>
-- Install configuration: ""
-- Installing: /usr/local/lib/libcalc.so
-- Installing: /usr/local/include/calc/basic.h
# ldconfig -p | grep libcalc
        libcalc.so (libc6,x86-64) => /usr/local/lib/libcalc.so 

SCRIPTCODE模式都支持生成器表达式,增加了此命令的多功能性。它可以用于多种目的:打印用户消息、验证安装成功、进行广泛的配置、文件签名等。

接下来,我们深入探讨在 CMake 安装中管理运行时依赖项的方面,这是 CMake 的最新功能之一。

安装运行时依赖项

我们已经涵盖了几乎所有可安装的工件及其相应的命令。接下来要讨论的主题是运行时依赖项。可执行文件和共享库通常依赖于其他库,这些库必须存在于系统中并在程序初始化时动态加载。从版本 3.21 开始,CMake 可以为每个目标构建所需库的列表,并通过引用二进制文件的适当部分在构建时捕获其位置。然后可以使用该列表将这些运行时工件安装到系统中以备将来使用。

对于在项目中定义的目标,可以通过两步实现:

install(TARGETS ... RUNTIME_DEPENDENCY_SET <set-name>)
install(RUNTIME_DEPENDENCY_SET <set-name> <arg>...) 

或者,可以通过一个命令以相同的效果完成:

install(TARGETS ... RUNTIME_DEPENDENCIES <arg>...) 

如果目标是导入的,而不是在项目中定义的,那么它的运行时依赖项可以通过以下方式安装:

install(IMPORTED_RUNTIME_ARTIFACTS <target>...) 
RUNTIME_DEPENDENCY_SET <set-name> argument to create a named reference that can be later used in the install(RUNTIME_DEPENDENCY_SET) command.

如果这个功能对你的项目有益,建议查阅install()命令的官方文档了解更多信息。

现在我们了解了在系统上安装文件的各种方式,接下来让我们探索如何将它们转换为其他 CMake 项目可用的本地包。

创建可重用的包

在前面的章节中,我们广泛使用了find_package()并观察到了它的便捷性和简单性。为了通过此命令访问我们的项目,我们需要完成几个步骤,以便 CMake 可以将我们的项目视为一个一致的包:

  1. 使我们的目标可迁移。

  2. 目标导出文件安装到标准位置。

  3. 为包创建配置文件

  4. 为包生成版本文件

从头开始:为什么目标需要是可重定位的?我们该如何做到这一点?

了解可重定位目标的问题

安装解决了许多问题,但也引入了一些复杂性。CMAKE_INSTALL_PREFIX是平台特定的,用户可以在安装阶段通过--install-prefix命令行参数设置它。挑战在于,目标导出文件是在安装之前生成的,即在构建阶段,当安装后文件的最终位置尚未确定时。考虑以下代码:

ch14/03-install-targets-legacy/src/CMakeLists.txt

add_library(calc STATIC basic.cpp)
**target_include_directories****(calc INTERFACE include)**
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER src/include/calc/basic.h
) 

在这个例子中,我们特地将include directory 添加到calcinclude directories中。由于这是一个相对路径,CMake 导出的目标生成过程会自动将该路径与CMAKE_CURRENT_SOURCE_DIR变量的内容结合,指向包含此列表文件的目录。

include directory path still points to its source tree.

CMake 通过生成器表达式解决了这个本末倒置的问题,这些表达式会根据上下文被替换为它们的参数或空字符串:

  • $<BUILD_INTERFACE:...>:这会在常规构建中评估为‘...’参数,但在安装时排除。

  • $<INSTALL_INTERFACE:...>:这会在安装时评估为‘...’参数,但在常规构建时排除。

  • $<BUILD_LOCAL_INTERFACE:...>:当另一个目标在相同的构建系统中使用时,它会评估为‘...’参数(此功能在 CMake 3.26 中新增)。

这些表达式允许将选择使用哪条路径的决定推迟到后续阶段:构建和安装。以下是如何在实践中使用它们:

ch14/07-install-export-legacy/src/CMakeLists.txt(片段)

add_library(calc STATIC basic.cpp)
target_include_directories(calc INTERFACE
**"$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"**
**"$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>"**
)
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER "include/calc/basic.h"
) 

target_include_directories()中,我们关注最后两个参数。使用的生成器表达式是互斥的,这意味着最终步骤中只有一个参数会被使用,另一个会被删除。

对于常规构建,calc目标的INTERFACE_INCLUDE_DIRECTORIES属性将会使用第一个参数进行扩展:

"/root/examples/ch14/07-install-export/src/include" "" 

另一方面,在安装时,值将使用第二个参数进行扩展:

"" "/usr/lib/calc/include" 

引号在最终值中不存在;它们在此处用于表示空文本值,以便于理解。

关于CMAKE_INSTALL_PREFIX:它不应作为路径中指定目标的组件使用。它将在构建阶段进行评估,使得路径变为绝对路径,且可能与安装时提供的路径不同(如果使用了--install-prefix选项)。相反,应该使用$<INSTALL_PREFIX>生成器表达式:

target_include_directories(my_target PUBLIC
  $<INSTALL_INTERFACE:**$<INSTALL_PREFIX>**/include/MyTarget>
) 

或者,更好的是,你可以使用相对路径,它们会被自动加上正确的安装前缀:

target_include_directories(my_target PUBLIC
  $<INSTALL_INTERFACE:include/MyTarget>
) 

若需更多示例和信息,请参考官方文档(链接可以在进一步阅读部分找到)。

现在我们的目标是兼容安装的,我们可以安全地生成并安装它们的目标导出文件。

安装目标导出文件

我们之前在 不进行安装的导出 部分提到过目标导出文件。安装目标导出文件的过程非常相似,创建它们的命令语法也是如此:

install(EXPORT <export-name> DESTINATION <dir>
        [NAMESPACE <namespace>] [[FILE <name>.cmake]|
        [PERMISSIONS permissions...]
        [CONFIGURATIONS [Debug|Release|...]]
        [EXPORT_LINK_INTERFACE_LIBRARIES]
        [COMPONENT <component>]
        [EXCLUDE_FROM_ALL]) 

它是 export(EXPORT) 和其他 install() 命令的结合(其选项的功能类似)。请记住,它将创建并安装一个目标导出文件,用于通过 install(TARGETS) 命令定义的命名导出。这里的关键区别在于,生成的导出文件将包含在 INSTALL_INTERFACE 生成器表达式中评估的目标路径,而不像 export(EXPORT) 使用 BUILD_INTERFACE。这意味着我们需要小心处理包含文件和其他相对引用的文件。

再次强调,对于 CMake 3.23 或更高版本,如果正确使用 FILE_SET HEADERS,这将不再是问题。让我们看看如何为 ch14/02-install-export 示例中的目标生成并安装导出文件。为此,我们必须在 install(TARGETS) 命令之后调用 install(EXPORT)

ch14/07-install-export/src/CMakeLists.txt

add_library(calc STATIC basic.cpp)
target_sources(calc
  PUBLIC FILE_SET HEADERS BASE_DIRS include
  FILES "include/calc/basic.h"
)
include(GNUInstallDirs)
install(TARGETS calc EXPORT CalcTargets ARCHIVE FILE_SET HEADERS) **install****(****EXPORT** **CalcTargets**
 **DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
 **NAMESPACE Calc::**
**)** 

注意在 install(EXPORT) 中引用 CalcTargets 导出名。在构建树中运行 cmake --install 将导致导出文件在指定的目标位置生成:

...
-- Installing: /usr/local/lib/calc/cmake/CalcTargets.cmake
-- Installing: /usr/local/lib/calc/cmake/CalcTargets-noconfig.cmake 

如果你需要覆盖默认的目标导出文件名(<export name>.cmake),可以通过添加 FILE new-name.cmake 参数来更改它(文件名必须以 .cmake 结尾)。

不要混淆这一点——目标导出文件 不是 配置文件,因此你不能直接使用 find_package() 来消费已安装的目标。不过,如果需要,也可以直接 include() 导出文件。那么,我们如何定义一个可以被其他项目消费的包呢?让我们来看看!

编写基本配置文件

一个完整的包定义包括目标导出文件、包的配置文件和包的版本文件。然而,从技术上讲,find_package() 要正常工作只需要一个配置文件。它充当包定义,负责提供任何包功能和宏,检查需求,查找依赖项,并包含目标导出文件。

正如我们之前提到的,用户可以通过以下方式将包安装到系统的任何位置:

# cmake --install <build tree> --install-prefix=<path> 

这个前缀决定了已安装文件的复制位置。为此,你必须确保以下几点:

  • 目标属性中的路径是可重新定位的(如 理解可重新定位目标的问题 部分所述)。

  • 配置文件中使用的路径是相对于它的。

要使用已安装在非默认位置的包,消费项目在配置阶段需要通过 CMAKE_PREFIX_PATH 变量提供 <installation path>

# cmake -B <build tree> -DCMAKE_PREFIX_PATH=<installation path> 

find_package() 命令将以平台特定的方式扫描文档中列出的路径(请参见进一步阅读部分)。在 Windows 和类 Unix 系统上检查的一个模式是:

<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/(cmake|CMake) 

这表示将配置文件安装到像 lib/calc/cmake 这样的路径应该是可行的。此外,CMake 要求配置文件必须命名为 <PackageName>-config.cmake<PackageName>Config.cmake 才能被找到。

让我们将配置文件的安装添加到 06-install-export 示例中:

ch14/09-config-file/CMakeLists.txt(片段)

...
**install****(****EXPORT** **CalcTargets**
 **DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
 **NAMESPACE Calc::**
**)**
**install****(FILES** **"CalcConfig.cmake"**
 **DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
**)** 

此命令从相同的源目录安装 CalcConfig.cmakeCMAKE_INSTALL_LIBDIR 会评估为平台正确的 lib 路径)。

最简单的配置文件由包含目标导出文件的单行组成:

ch14/09-config-file/CalcConfig.cmake

include("${CMAKE_CURRENT_LIST_DIR}/CalcTargets.cmake") 

CMAKE_CURRENT_LIST_DIR 指的是配置文件所在的目录。在我们的示例中,CalcConfig.cmakeCalcTargets.cmake 安装在同一个目录下(由 install(EXPORT) 设置),因此目标导出文件将被正确包含。

为了验证我们包的可用性,我们将创建一个包含一个列表文件的简单项目:

ch14/10-find-package/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(FindCalcPackage CXX)
find_package(Calc REQUIRED)
include(CMakePrintHelpers)
message("CMAKE_PREFIX_PATH: ${CMAKE_PREFIX_PATH}")
message("CALC_FOUND: ${Calc_FOUND}")
cmake_print_properties(TARGETS "Calc::calc" PROPERTIES
  IMPORTED_CONFIGURATIONS
  INTERFACE_INCLUDE_DIRECTORIES
) 

为了测试这个,将 09-config-file 示例构建并安装到一个目录中,然后构建 10-find-package 并通过 DCMAKE_PREFIX_PATH 参数引用它:

# cmake -S <source-tree-of-08> -B <build-tree-of-08>
# cmake --build <build-tree-of-08>
# cmake --install <build-tree-of-08>
# cmake -S <source-tree-of-09> -B <build-tree-of-09>  
        -DCMAKE_PREFIX_PATH=<build-tree-of-08> 

这将产生以下输出(所有的 <*_tree-of_> 占位符将被实际路径替换):

CMAKE_PREFIX_PATH: <build-tree-of-08>
CALC_FOUND: 1
--
Properties for TARGET Calc::calc:
   Calc::calc.IMPORTED_CONFIGURATIONS = "NOCONFIG"
   Calc::calc.INTERFACE_INCLUDE_DIRECTORIES = "<build-tree-of-08>/include"
-- Configuring done
-- Generating done
-- Build files have been written to: <build-tree-of-09> 

该输出表示 CalcTargets.cmake 文件已被找到并正确包含,include directory 的路径遵循所选择的前缀。这种解决方案适用于基本的打包场景。现在,让我们学习如何处理更高级的场景。

创建高级配置文件

如果你需要管理多个目标导出文件,在你的配置文件中包含一些宏可能会很有用。CMakePackageConfigHelpers 工具模块提供了访问 configure_package_config_file() 命令的功能。使用它时,提供一个模板文件,CMake 变量将插入其中,以生成包含两个嵌入式宏定义的配置文件

  • set_and_check(<variable> <path>):这与 set() 类似,但它会检查 <path> 是否实际存在,否则会因 FATAL_ERROR 失败。建议在配置文件中使用此方法,以便及早发现路径错误。

  • check_required_components(<PackageName>):这是添加到 config 文件末尾的内容。它检查在 find_package(<package> REQUIRED <component>) 中,用户所需的所有组件是否已找到。

配置文件生成过程中,可以为复杂的目录树准备好安装路径。命令签名如下:

configure_package_config_file(<template> <output>
  INSTALL_DESTINATION <path>
  [PATH_VARS <var1> <var2> ... <varN>]
  [NO_SET_AND_CHECK_MACRO]
  [NO_CHECK_REQUIRED_COMPONENTS_MACRO]
  [INSTALL_PREFIX <path>]
) 

<template>文件将与变量进行插值并存储在<output>路径中。INSTALL_DESTINATION路径用于将存储在PATH_VARS中的路径转换为相对于安装目标的路径。INSTALL_PREFIX可以作为基本路径提供,表示INSTALL_DESTINATION相对于此路径。

NO_SET_AND_CHECK_MACRONO_CHECK_REQUIRED_COMPONENTS_MACRO选项告诉 CMake 不要将这些宏定义添加到生成的配置文件中。让我们在实际中看到这种生成方式,扩展07-install-export示例:

ch14/11-advanced-config/CMakeLists.txt(片段)

...
install(EXPORT CalcTargets
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
  NAMESPACE Calc::
)
**include****(CMakePackageConfigHelpers)**
**set****(LIB_INSTALL_DIR** **${CMAKE_INSTALL_LIBDIR}****/calc)**
**configure_package_config_file(**
  **${CMAKE_CURRENT_SOURCE_DIR}****/CalcConfig.cmake.in**
  **"${CMAKE_CURRENT_BINARY_DIR}/CalcConfig.cmake"**
  **INSTALL_DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
  **PATH_VARS LIB_INSTALL_DIR**
**)**
install(FILES **"${CMAKE_CURRENT_BINARY_DIR}/CalcConfig.cmake"**
  **DESTINATION** **${CMAKE_INSTALL_LIBDIR}****/calc/cmake**
) 

在前面的代码中,我们:

  1. 使用include()来包含带有帮助器的实用模块。

  2. 使用set()来设置一个变量,该变量将用于生成可重定位的路径。

  3. 使用CalcConfig.cmake.in模板为构建树生成CalcConfig.cmake配置文件,并提供LIB_INSTALL_DIR作为变量名称,计算为相对于INSTALL_DESTINATION${CMAKE_INSTALL_LIBDIR}/calc/cmake

  4. 将为构建树生成的配置文件传递给install(FILE)

请注意,install(FILES)中的DESTINATION路径与configure_package_config_file()中的INSTALL_DESTINATION路径相等,这确保了配置文件内正确的相对路径计算。

最后,我们需要一个config文件模板(它们的名称通常以.in结尾):

ch14/11-advanced-config/CalcConfig.cmake.in

@PACKAGE_INIT@
set_and_check(CALC_LIB_DIR "@PACKAGE_LIB_INSTALL_DIR@")
include("${CALC_LIB_DIR}/cmake/CalcTargets.cmake")
check_required_components(Calc) 

此模板以@PACKAGE_INIT@占位符开始。生成器将用set_and_checkcheck_required_components宏的定义填充它。

下一行将CALC_LIB_DIR设置为传递给@PACKAGE_LIB_INSTALL_DIR@占位符的路径。CMake 将使用列表文件中提供的$LIB_INSTALL_DIR填充它,但路径是相对于安装路径计算的。随后,该路径将用于include()命令来包含目标导出文件。最后,check_required_components()验证是否已找到使用此包的项目所需的所有组件。即使包没有任何组件,推荐使用此命令,以确保用户仅使用受支持的依赖项。否则,他们可能会错误地认为他们已成功添加了组件(这些组件可能仅存在于包的更新版本中)。

通过这种方式生成的CalcConfig.cmake配置文件如下所示:

#### Expanded from @PACKAGE_INIT@ by
  configure_package_config_file() #######
#### Any changes to this file will be overwritten by the
  next CMake run ####
#### The input file was CalcConfig.cmake.in  #####
get_filename_component(PACKAGE_PREFIX_DIR
  "${CMAKE_CURRENT_LIST_DIR}/../../../" ABSOLUTE)
macro(set_and_check _var _file) 
  # ... removed for brevity
endmacro()
macro(check_required_components _NAME) 
  # ... removed for brevity
endmacro()
##################################################################
set_and_check(CALC_LIB_DIR "${PACKAGE_PREFIX_DIR}/lib/calc")
include("${CALC_LIB_DIR}/cmake/CalcTargets.cmake")
check_required_components(Calc) 

以下图示展示了各种包文件之间的关系,帮助更好地理解:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_14_01.png

图 14.1:高级包的文件结构

包的所有必需的子依赖项也必须在包的配置文件中找到。这可以通过调用CMakeFindDependencyMacro帮助器中的find_dependency()宏来完成。我们在第九章《CMake 中的依赖管理》中学习了如何使用它。

任何暴露给消费项目的宏或函数定义应当放在一个单独的文件中,该文件通过包的配置文件包含。有趣的是,CMakePackageConfigHelpers也有助于生成包的版本文件。接下来我们将探讨这一点。

生成包的版本文件

随着你的包的发展,获得新功能并淘汰旧功能,跟踪这些变化并提供可供开发者使用的变更日志是至关重要的。当开发者需要某个特定功能时,使用你的包的开发者可以在find_package()中指定支持该功能的最低版本,如下所示:

find_package(Calc 1.2.3 REQUIRED) 

CMake 将搜索Calc的配置文件,并检查是否在同一目录下存在名为<config-file>-version.cmake<config-file>Version.cmake版本文件(例如,CalcConfigVersion.cmake)。该文件包含版本信息,并指定与其他版本的兼容性。例如,即使你没有安装确切的版本 1.2.3,可能会有 1.3.5 版本,而它被标记为与旧版本兼容。CMake 将接受该包,因为它向后兼容。

你可以使用CMakePackageConfigHelpers工具模块,通过调用write_basic_package_version_file()来生成包的版本文件

write_basic_package_version_file(
  <filename> [VERSION <ver>]
  COMPATIBILITY <AnyNewerVersion | SameMajorVersion |
                 SameMinorVersion | ExactVersion>
  [ARCH_INDEPENDENT]
) 

首先,提供生成物件的<filename>;确保它遵循之前讨论的命名规则。你可以选择性地传入显式的VERSION(采用 major.minor.patch 格式)。如果未提供,project()命令中指定的版本将被使用(如果项目没有指定版本,将会出现错误)。

COMPATIBILITY关键字表示:

  • ExactVersion必须匹配版本的所有三个组件,并且不支持版本范围:(例如,find_package(<package> 1.2.8...1.3.4))。

  • SameMinorVersion在前两个组件相同的情况下匹配(忽略patch)。

  • SameMajorVersion在第一个组件相同的情况下匹配(忽略minorpatch)。

  • AnyNewerVersion与其名称相反,它匹配任何旧版本(例如,版本 1.4.2 与find_package(<package> 1.2.8)兼容)。

对于依赖架构的包,需要精确的架构匹配。然而,对于与架构无关的包(如仅包含头文件的库或宏包),你可以指定ARCH_INDEPENDENT关键字来跳过此检查。

以下代码展示了如何为我们在07-install-export中开始的项目提供版本文件的实际示例:

ch14/12-version-file/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.26)
**project****(VersionFile VERSION** **1.2****.****3** **LANGUAGES CXX)**
...
**include****(CMakePackageConfigHelpers)**
**write_basic_package_version_file(**
  **"${CMAKE_CURRENT_BINARY_DIR}/CalcConfigVersion.cmake"**
  **COMPATIBILITY AnyNewerVersion**
**)**
install(FILES "CalcConfig.cmake"
  **"${CMAKE_CURRENT_BINARY_DIR}/CalcConfigVersion.cmake"**
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
) 

为了方便起见,我们在文件的顶部配置了包的版本,在project()命令中,将简短的project(<name> <languages>)语法切换为显式的完整语法,通过添加LANGUAGE关键字。

在包含助手模块之后,我们生成版本文件并将其与 CalcConfig.cmake 一起安装。通过跳过 VERSION 关键字,我们使用 PROJECT_VERSION 变量。该包标记为完全向后兼容,使用 COMPATIBILITY AnyNewerVersion。这会将 版本文件 安装到与 CalcConfig.cmake 相同的目标位置。就这样——我们的包已经完全配置好了。

通过此方法,我们完成了包创建的主题。现在我们知道如何处理重定位及其重要性,如何安装 目标导出文件,以及如何编写 配置文件版本文件

在下一节中,我们将探索组件及其在包中的使用。

定义组件

我们将首先讨论关于术语 组件 的潜在混淆。请看 find_package() 的完整签名:

find_package(<PackageName> 
             [version] [EXACT] [QUIET] [MODULE] [REQUIRED]
             **[[COMPONENTS] [components...]]**
             **[OPTIONAL_COMPONENTS components...]**
             [NO_POLICY_SCOPE]
) 

重要的是不要将这里提到的组件与 install() 命令中使用的 COMPONENT 关键字混淆。尽管它们使用相同的名称,但它们是不同的概念,必须分别理解。我们将在接下来的子章节中进一步探讨这一点。

如何在 find_package() 中使用组件

当使用 COMPONENTSOPTIONAL_COMPONENTS 列表调用 find_package() 时,我们向 CMake 指示我们只关心提供这些组件的包。然而,必须理解的是,验证这一需求是包本身的责任。如果包的供应商没有在配置文件中实现必要的检查,如 创建高级配置文件 一节所述,过程将不会按预期进行。

请求的组件通过 <package>_FIND_COMPONENTS 变量(包括可选和非可选组件)传递给配置文件。对于每个非可选组件,都会设置一个 <package>_FIND_REQUIRED_<component> 变量。包的作者可以编写宏来扫描此列表并验证是否提供了所有必需的组件,但这不是必需的。check_required_components() 函数可以完成这个任务。配置文件 应该在找到必需的组件时设置 <package>_<component>_FOUND 变量。文件末尾的一个宏将验证是否设置了所有必需的变量。

如何在 install() 命令中使用组件

不是所有生成的产物在每种情况下都需要安装。例如,一个项目可能为开发安装静态库和公共头文件,但默认情况下,它可能只需要安装共享库以供运行时使用。为了启用这种双重行为,产物可以通过 COMPONENT 关键字在所有 install() 命令中进行分组。希望限制安装到特定组件的用户可以通过执行以下区分大小写的命令来实现:

cmake --install <build tree> 
      --component=<component1 name> --component=<component2 name> 

COMPONENT 关键字分配给一个产物并不会自动将其从默认安装中排除。要实现这一排除,必须添加 EXCLUDE_FROM_ALL 关键字。

让我们通过一个代码示例来探讨这个概念:

ch14/13-components/CMakeLists.txt(片段)

install(TARGETS calc EXPORT CalcTargets
  ARCHIVE
    **COMPONENT lib**
  FILE_SET HEADERS
    **COMPONENT headers**
)
install(EXPORT CalcTargets
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
  NAMESPACE Calc::
  **COMPONENT lib**
)
install(CODE "MESSAGE(\"Installing 'extra' component\")"
  **COMPONENT extra**
  **EXCLUDE_FROM_ALL**
) 

上述install命令定义了以下组件:

  • lib:包含静态库和目标导出文件,默认情况下会安装。

  • headers:包含 C++头文件,默认情况下也会安装。

  • extra:执行一段代码以打印消息,默认情况下不安装。

让我们再强调一遍:

  • 没有--component参数的cmake --install将安装libheaders组件。

  • cmake --install --component headers将只安装公共头文件。

  • cmake --install --component extra将打印一个通常无法访问的消息(EXCLUDE_FROM_ALL关键字会阻止此行为)。

如果安装的工件没有指定COMPONENT关键字,则默认为Unspecified,这一点由CMAKE_INSTALL_DEFAULT_COMPONENT_NAME变量定义。

由于无法通过 cmake 命令行列出所有可用组件,彻底记录包的组件对用户非常有帮助。一个INSTALLREADME”文件是存放这些信息的好地方。

如果cmake在没有指定组件的情况下使用--component参数调用一个不存在的组件,命令将会成功完成,但不会安装任何内容,也不会出现警告或错误。

将我们的安装划分为多个组件使用户能够选择性地安装包的部分内容。现在让我们转向管理版本化共享库的符号链接,这对于优化您的安装过程非常有用。

管理版本化共享库的符号链接

您的安装目标平台可能使用符号链接来帮助链接器发现当前安装的共享库版本。在创建一个指向lib<name>.so.1文件的lib<name>.so符号链接后,可以通过将-l<name>参数传递给链接器来链接此库。

CMake 的install(TARGETS <target> LIBRARY)块在需要时处理创建此类符号链接的操作。不过,我们可以决定将该步骤移到另一个install()命令中,方法是向该块添加NAMELINK_SKIP

install(TARGETS <target> LIBRARY 
        COMPONENT cmp NAMELINK_SKIP) 

要将符号链接分配给另一个组件(而不是完全禁用它),我们可以对相同的目标重复执行install()命令,并指定不同的组件,后跟NAMELINK_ONLY关键字:

install(TARGETS <target> LIBRARY 
        COMPONENT lnk NAMELINK_ONLY) 

同样的效果可以通过NAMELINK_COMPONENT关键字实现:

install(TARGETS <target> LIBRARY 
        COMPONENT cmp NAMELINK_COMPONENT lnk) 

现在我们已经配置了自动安装,可以使用 CMake 自带的 CPack 工具为用户提供预构建的工件。

使用 CPack 进行打包

尽管从源代码构建项目有其优点,但它可能耗时且复杂,这对于最终用户尤其是非开发者来说并不理想。一个更方便的分发方式是使用二进制包,其中包含已编译的工件和其他必要的静态文件。CMake 支持通过一个名为cpack的命令行工具生成这样的包。

要生成一个软件包,请选择适合目标平台和软件包类型的生成器。不要将软件包生成器与构建系统生成器(如 Unix Makefile 或 Visual Studio)混淆。

以下表格列出了可用的软件包生成器:

生成器名称 生成的文件类型 平台
Archive 7Z, 7zip - (.7z)TBZ2 (.tar.bz2)TGZ (.tar.gz)TXZ (.tar.xz)TZ (.tar.Z)TZST (.tar.zst)ZIP (.zip) 跨平台
Bundle macOS Bundle (.bundle) macOS
Cygwin Cygwin 软件包 Cygwin
DEB Debian 软件包(.deb Linux
External 第三方打包工具的 JSON(.json)文件 跨平台
FreeBSD PKG (.pkg) *BSD, Linux, macOS
IFW QT 安装程序二进制文件 Linux, Windows, macOS
NSIS 二进制文件(.exe Windows
NuGet NuGet 包(.nupkg Windows
productbuild PKG (.pkg) macOS
RPM RPM (.rpm) Linux
WIX 微软安装程序(.msi Windows

表 14.3:可用的软件包生成器

这些生成器中的大多数具有广泛的配置。虽然本书的范围不涉及其所有细节,但你可以在进一步阅读部分找到更多信息。我们将专注于一个一般的使用案例。

要使用 CPack,请使用必要的install()命令配置项目的安装,并构建项目。生成的cmake_install.cmake文件将在构建目录中,供 CPack 根据CPackConfig.cmake文件准备二进制软件包。虽然你可以手动创建此文件,但在项目的清单文件中使用include(CPack)更为简便。它会在构建目录中生成配置,并在需要时提供默认值。

让我们扩展13-components示例,使用 CPack:

ch14/14-cpack/CMakeLists.txt(片段)

cmake_minimum_required(VERSION 3.26)
**project****(CPackPackage VERSION** **1.2****.****3** **LANGUAGES CXX)**
include(GNUInstallDirs)
add_subdirectory(src bin)
install(...)
install(...)
install(...)
**set****(CPACK_PACKAGE_VENDOR** **"Rafal Swidzinski"****)**
**set****(CPACK_PACKAGE_CONTACT** **"email@example.com"****)**
**set****(CPACK_PACKAGE_DESCRIPTION** **"Simple Calculator"****)**
**include****(CPack)** 

CPack 模块从project()命令中提取以下变量:

  • CPACK_PACKAGE_NAME

  • CPACK_PACKAGE_VERSION

  • CPACK_PACKAGE_FILE_NAME

CPACK_PACKAGE_FILE_NAME存储软件包名称的结构:

$CPACK_PACKAGE_NAME-$CPACK_PACKAGE_VERSION-$CPACK_SYSTEM_NAME 

在这里,CPACK_SYSTEM_NAME是目标操作系统的名称,如Linuxwin32。例如,在 Debian 上执行 ZIP 生成器时,CPack 将生成一个名为CPackPackage-1.2.3-Linux.zip的文件。

要在构建项目后生成软件包,请进入项目的构建目录并运行:

cpack [<options>] 

CPack 从 CPackConfig.cmake 文件中读取选项,但你可以覆盖这些设置:

  • -G <generators>:以分号分隔的软件包生成器列表。默认值可以在CPackConfig.cmake中的CPack_GENERATOR变量中指定。

  • -C <configs>:以分号分隔的构建配置(调试、发布)列表,用于生成软件包(对于多配置构建系统生成器是必需的)。

  • -D <var>=<value>:覆盖CPackConfig.cmake文件中设置的变量。

  • --config <config-file>:使用指定的配置文件,而不是默认的CPackConfig.cmake

  • --verbose, -V:提供详细输出。

  • -P <packageName>:此选项覆盖包名。

  • -R <packageVersion>:此选项覆盖包版本。

  • --vendor <vendorName>:此选项覆盖包供应商。

  • -B <packageDirectory>:此选项指定 cpack 的输出目录(默认为当前工作目录)。

让我们尝试为我们的 14-cpack 示例项目生成包。我们将使用 ZIP、7Z 和 Debian 包生成器:

cpack -G "ZIP;7Z;DEB" -B packages 

你应该获得这些包:

  • CPackPackage-1.2.3-Linux.7z

  • CPackPackage-1.2.3-Linux.deb

  • CPackPackage-1.2.3-Linux.zip

这些二进制包已经准备好发布到你项目的官方网站、GitHub 发布版或包仓库,供最终用户使用。

总结

编写跨平台安装脚本的复杂性可能令人畏惧,但 CMake 显著简化了这一任务。虽然它需要一些初始设置,但 CMake 精简了这一过程,并与我们在本书中探讨的概念和技术无缝集成。

我们首先了解了如何从项目中导出 CMake 目标,使其可以在其他项目中使用而无需安装。接着我们深入了解了如何安装已配置好导出的项目。探讨安装基础时,我们重点关注了一个关键方面:安装 CMake 目标。现在,我们已经掌握了 CMake 如何为各种工件类型分配不同的目标目录,以及对公共头文件的特殊处理。我们还研究了 install() 命令的其他模式,包括安装文件、程序和目录,并在安装过程中执行脚本。

然后我们的旅程带我们来到了 CMake 的可重用包。我们探讨了如何使项目目标可移动,从而便于用户自定义安装位置。这包括创建可通过find_package()消费的完全定义的包,涉及到准备目标导出文件配置文件版本文件。鉴于用户需求的多样性,我们学习了如何将工件和操作分组到安装组件中,并将其与 CMake 包的组件区分开来。我们的探索最终介绍了 CPack。我们发现如何准备基本的二进制包,提供了一种高效的方法来分发预编译的软件。虽然掌握 CMake 中安装和打包的细节是一个持续的过程,但这一章为我们打下了坚实的基础,使我们能够自信地处理常见场景并深入探索。

在下一章,我们将通过制作一个紧密结合、专业的项目,运用我们积累的知识,展示这些 CMake 技术的实际应用。

进一步阅读

加入我们的 Discord 社区

加入我们社区的 Discord 频道,与作者及其他读者进行讨论:

discord.com/invite/vXN53A7ZcA

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/QR_Code94081075213645359.png

第十五章:创建你的专业项目

我们已经掌握了构建专业项目所需的所有知识,包括结构化、构建、依赖管理、测试、分析、安装和打包。现在,是时候通过创建一个连贯的专业项目来应用这些技能了。重要的是要理解,即使是微不足道的程序也能从自动化质量检查和将原始代码转化为完整解决方案的无缝流程中受益。的确,实现这些检查和流程是一个重要的投资,因为它需要许多步骤才能正确设置一切。尤其是将这些机制添加到现有的代码库时,这些代码库通常庞大而复杂。这就是为什么从一开始就使用 CMake 并尽早建立所有必要的流程是有益的。这样配置起来更简单,也更高效,因为这些质量控制和构建自动化最终需要集成到长期项目中。

在本章中,我们将开发一个尽可能小的解决方案,同时充分利用我们到目前为止在本书中讨论的 CMake 实践。为了保持简单,我们将只实现一个实际的功能——两个数字相加。这样的基础业务代码将使我们能够专注于前几章中学习的与构建相关的项目方面。为了处理一个与构建相关的更具挑战性的问题,项目将同时包含一个库和一个可执行文件。库将处理内部业务逻辑,并作为 CMake 包可供其他项目使用。可执行文件面向最终用户,将提供一个演示库功能的用户界面。

总结一下,在本章中,我们将涵盖以下主要内容:

  • 规划我们的工作

  • 项目布局

  • 构建和管理依赖

  • 测试与程序分析

  • 安装与打包

  • 提供文档

技术要求

你可以在 GitHub 上找到本章中的代码文件,地址为 github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch15

要构建本书中提供的示例,请始终使用推荐的命令:

cmake -B <build tree> -S <source tree>
cmake --build <build tree> 

请确保将占位符 <build tree><source tree> 替换为适当的路径。提醒一下:构建树是目标/输出目录的路径,源代码树是源代码所在的路径。

本章使用 GCC 编译,以便与用于收集结果的 lcov 工具的代码覆盖率仪器兼容。如果你想使用 llvm 或其他工具链进行编译,请确保根据需要调整覆盖率处理。

要运行测试,执行以下命令:

ctest --test-dir <build tree> 

或者直接从构建树目录执行:

ctest 

注意,在本章中,测试结果将输出到 test 子目录。

规划我们的工作

本章我们将构建的软件并不打算过于复杂——我们将创建一个简单的计算器,能够将两个数字相加(图 15.1)。它将是一个控制台应用程序,采用文本用户界面,利用第三方库和一个独立的计算库,后者可以在其他项目中使用。尽管这个项目可能没有重大实际应用,但它的简洁性非常适合展示本书中讨论的各种技术的应用。

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_15_01.png

图 15.1:在终端中执行的我们的项目的文本用户界面,支持鼠标操作

通常,项目要么生成面向用户的可执行文件,要么生成供开发者使用的库。项目同时生成这两者的情况比较少见,尽管也会发生。例如,一些应用程序会附带独立的 SDK 或库,帮助开发插件。另一个例子是一个包含使用示例的库。我们的项目属于后一类,展示了该库的功能。

我们将通过回顾章节列表、回想每个章节的内容,并选择我们将用于构建应用程序的技术和工具来开始规划:

  • 第一章CMake 的第一步

本章提供了关于 CMake 的基本信息,包括安装和命令行使用方法,以构建项目。它还涵盖了项目文件的基本信息,如它们的作用、典型命名约定和特殊性。

  • 第二章CMake 语言

我们介绍了编写正确 CMake 列表文件和脚本所需的工具,涵盖了基本的代码内容,如注释、命令调用和参数。我们解释了变量、列表和控制结构,并介绍了一些有用的命令。这些基础将贯穿整个项目。

  • 第三章在流行的 IDE 中使用 CMake

我们讨论了三种 IDE——CLion、VS Code 和 Visual Studio IDE——并强调了它们的优点。在我们的最终项目中,选择使用哪个 IDE(或是否使用 IDE)由你决定。一旦决定,你可以在 Dev 容器中开始这个项目,只需通过几步构建一个 Docker 镜像(或者直接从 Docker Hub 获取)。在容器中运行镜像可以确保开发环境与生产环境一致。

  • 第四章设置你的第一个 CMake 项目

配置项目至关重要,因为它决定了生效的 CMake 策略、命名、版本控制和编程语言。我们将利用这一章来影响构建过程的基本行为。

我们还将遵循既定的项目划分和结构来确定目录和文件的布局,并利用系统发现变量适应不同的构建环境。工具链配置是另一个关键方面,它使我们能够强制指定一个特定的 C++ 版本和编译器支持的标准。按照本章的建议,我们将禁用源代码内构建,以保持工作空间的整洁。

  • 第五章与目标一起工作

在这里,我们了解了每个现代 CMake 项目如何广泛使用目标。我们当然也会使用目标来定义一些库和可执行文件(既用于测试,也用于生产),以保持项目的组织性,并确保遵守DRYDon’t Repeat Yourself)原则。通过学习目标属性和传递使用要求(传播属性),我们将能够将配置保持在目标定义附近。

  • 第六章使用生成器表达式

生成器表达式在整个项目中被大量使用。我们将尽量使这些表达式保持简单明了。项目将包含自定义命令,以生成 Valgrind 和覆盖率报告的文件。此外,我们还将使用目标钩子,特别是 PRE_BUILD,来清理覆盖率插桩过程产生的 .gcda 文件。

  • 第七章使用 CMake 编译 C++ 源代码

没有 C++ 项目是不需要编译的。基础知识非常简单,但 CMake 允许我们以多种方式调整这一过程:扩展目标的源文件、配置优化器、提供调试信息。对于这个项目,默认的编译标志已经足够,但我们还是会稍微玩一下预处理器:

  • 我们将在编译后的可执行文件中存储构建元数据(项目版本、构建时间和 Git 提交 SHA),并将其展示给用户。

  • 我们将启用头文件的预编译。这在如此小的项目中并不是必需的,但它有助于我们练习这一概念。

不需要 Unity 构建——这个项目不会大到需要添加它们才有意义。

  • 第八章链接可执行文件和库

我们将获得有关链接的一般信息,这在任何项目中默认都是有用的。此外,由于这个项目包含一个库,我们将明确引用一些特定的构建指令,具体如下:

  • 用于测试和开发的静态库

  • 用于发布的共享库

本章还概述了如何隔离 main() 函数以进行测试,这是我们将采用的做法。

  • 第九章在 CMake 中管理依赖项

为了增强项目的吸引力,我们将引入一个外部依赖项:一个基于文本的 UI 库。第九章 探讨了管理依赖项的各种方法。选择将很简单:FetchContent 工具模块通常是推荐的且最方便的。

  • 第十章使用 C++20 模块

尽管我们已经探讨了 C++20 模块的使用以及支持此功能的环境要求(CMake 3.28,最新编译器),但其广泛支持仍然不足。为了确保项目的可访问性,我们暂时不会引入模块。

  • 第十一章测试框架

实施适当的自动化测试对于确保我们解决方案的质量在时间的推移中保持一致至关重要。我们将集成 CTest,并组织我们的项目以便于测试,应用之前提到的 main() 函数分离。

本章讨论了两个测试框架:Catch2 和 GTest 配合 GMock;我们将使用后者。为了获取详细的覆盖率信息,我们将使用 LCOV 生成 HTML 报告。

  • 第十二章程序分析工具

对于静态分析,我们可以选择一系列工具:Clang-Tidy,Cpplint,Cppcheck,include-what-you-use 和 link-what-you-use。我们将选择 Cppcheck,因为 Clang-Tidy 与使用 GCC 构建的预编译头文件兼容性较差。

动态分析将使用 Valgrind 的 Memcheck 工具进行,并辅以 Memcheck-cover 包装器生成 HTML 报告。此外,我们的源代码将在构建过程中通过 ClangFormat 自动格式化。

  • 第十三章生成文档:

提供文档在将库作为我们项目的一部分时是至关重要的。CMake 通过使用 Doxygen 实现文档生成的自动化。我们将在更新的设计中采用这种方法,并结合 doxygen-awesome-css 主题。

  • 第十四章安装与打包

最后,我们将配置解决方案的安装和打包,并准备文件形成如上所述的包,同时包括目标定义。我们将通过包含 GNUInstallDirs 模块,安装解决方案及构建目标的产物到相应目录。我们还将配置一些组件来模块化解决方案,并为与 CPack 的使用做准备。

专业项目通常还包括一些文本文件:READMELICENSEINSTALL 等。我们将在本章末尾简要介绍这些文件。

为了简化过程,我们不会实现自定义逻辑来检查是否所有必需的工具和依赖项都可用。我们将依赖 CMake 来显示其诊断信息,并告诉用户缺少什么。如果你的项目得到广泛关注,可能需要考虑添加这些机制来改善用户体验。

确定了清晰的计划后,接下来我们将讨论如何实际构建项目,无论是从逻辑目标还是目录结构的角度。

项目布局

构建任何项目时,我们应该先清楚地理解将在其中创建哪些逻辑目标。在这种情况下,我们将遵循以下图所示的结构:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_15_02.png

图 15.2:逻辑目标的结构

让我们按照构建顺序来探索结构。首先,我们编译calc_obj,一个目标库。如果需要回顾目标库,请查看第五章与目标合作。然后,我们应关注静态 共享库

共享库与静态库

第八章链接可执行文件和库中,我们介绍了共享库和静态库。我们指出,当多个程序使用相同的库时,共享库可以减少整体内存使用。此外,用户通常已经安装了流行的库,或者知道如何快速安装它们。

更重要的是,共享库是独立的文件,必须放置在特定路径中,以便动态链接器能够找到它们。相比之下,静态库直接嵌入到可执行文件中,这使得使用时更快,因为无需额外步骤来定位内存中的代码。

作为库的作者,我们可以决定提供静态版本还是共享版本,或者我们可以同时提供这两种版本,并将此决定留给使用我们库的程序员。由于我们在应用我们的知识,我们将提供两个版本。

calc_test目标,包括用于验证库核心功能的单元测试,将使用静态库。虽然我们从相同的目标文件构建这两种类型的库,但测试任一库类型都是可以接受的,因为它们的功能应该是相同的。与calc_console_static目标关联的控制台应用程序将使用共享库。该目标还链接了一个外部依赖项,即 Arthur Sonzogni 的功能终端(X)用户界面(FTXUI)库(进一步阅读部分有指向 GitHub 项目的链接)。

最后的两个目标,calc_consolecalc_console_test,旨在解决测试可执行文件中的常见问题:测试框架和可执行文件提供的多个入口点的冲突。为避免此问题,我们故意将main()函数隔离到一个引导目标calc_console中,它仅调用calc_console_static中的主要功能。

在理解了必要的目标及其相互关系之后,我们的下一步是使用适当的文件和目录组织项目的结构。

项目文件结构

该项目由两个关键元素组成:calc库和calc_console可执行文件。为了有效地组织我们的项目,我们将采用以下目录结构:

  • src包含所有已发布目标和库头文件的源代码。

  • test包含上述库和可执行文件的测试。

  • cmake包含 CMake 用于构建和安装项目的工具模块和辅助文件。

  • 根目录包含顶级配置和文档文件。

这个结构(如图 15.3所示)确保了关注点的清晰分离,便于项目的导航和维护:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_15_03.png

图 15.3:项目的目录结构

以下是每个四个主要目录中的文件完整列表:

根目录 ./test
CHANGELOG CMakeLists.txt
CMakeLists.txt calc/CMakeLists.txt
INSTALL``LICENSE``README.md calc/calc_test.cpp``calc_console/CMakeLists.txt``calc_console/tui_test.cpp
./src ./cmake
CMakeLists.txt``calc/CMakeLists.txt``calc/CalcConfig.cmake``calc/basic.cpp``calc/include/calc/basic.h``calc_console/CMakeLists.txt``calc_console/bootstrap.cpp``calc_console/include/tui.h``calc_console/tui.cpp BuildInfo.cmake``Coverage.cmake``CppCheck.cmake``Doxygen.cmake``Format.cmake``GetFTXUI.cmake``Packaging.cmake``Memcheck.cmake``NoInSourceBuilds.cmake``Testing.cmake``buildinfo.h.in``doxygen_extra_headers

表 15.1:项目文件结构

虽然看起来 CMake 引入了相当大的开销,且cmake目录最初包含的内容比实际的业务代码还多,但随着项目功能的扩展,这种动态会发生变化。最初建立一个清晰、有序的项目结构需要付出较大的努力,但可以放心,这项投资在未来将带来显著的回报。

本章将详细讲解表 15.1中提到的所有文件,并逐步分析它们的功能以及在项目中的作用。这个过程将分为四个步骤:构建、测试、安装和提供文档。

构建和管理依赖项

所有构建过程都遵循相同的程序。我们从顶层的列表文件开始,向下推进到项目的源代码树中。图 15.4展示了构建过程中的项目文件,括号中的数字表示 CMake 脚本执行的顺序。

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_15_04.png

图 15.4:构建阶段使用的文件

顶层的CMakeLists.txt(1)列表文件配置了项目:

ch15/01-full-project/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Calc VERSION 1.1.0 LANGUAGES CXX)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(NoInSourceBuilds)
include(CTest)
add_subdirectory(src bin)
add_subdirectory(test)
include(Packaging) 

我们首先指定项目的基本信息,并设置 CMake 工具模块的路径(即项目中的cmake目录)。然后,我们通过自定义模块来防止源代码构建。接着,我们启用CTest模块(CMake 内置的测试模块)。此步骤应该在项目的根目录进行,因为该命令会在相对于源代码树位置的二进制树中创建CTestTestfile.cmake文件。如果放在其他地方,将导致ctest无法找到它。

接下来,我们包括两个关键目录:

  • src,包含项目源代码(在构建树中命名为bin

  • test,包含所有测试工具

最后,我们包括Packaging模块,相关内容将在安装与打包部分中讨论。

让我们检查一下 NoInSourceBuilds 实用模块,以理解它的功能:

ch15/01-full-project/cmake/NoInSourceBuilds.cmake

if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
  message(FATAL_ERROR
    "\n"
    "In-source builds are not allowed.\n"
    "Instead, provide a path to build tree like so:\n"
    "cmake -B <destination>\n"
    "\n"
    "To remove files you accidentally created execute:\n"
    "rm -rf CMakeFiles CMakeCache.txt\n"
  )
endif() 

这里没有什么意外,我们检查用户是否提供了一个单独的生成文件目标目录,使用 cmake 命令。它必须与项目的源代码树路径不同。如果没有,我们将指导用户如何指定该目录,并在他们出错时如何清理仓库。

我们的顶级 listfile 随后包括了 src 子目录,指示 CMake 处理其中的 listfile 文件:

ch15/01-full-project/src/CMakeLists.txt

include(Coverage)
include(Format)
include(CppCheck)
include(Doxygen)
add_subdirectory(calc)
add_subdirectory(calc_console) 

这个文件很简单——它包括了我们将要使用的 ./cmake 目录中的所有模块,并引导 CMake 到嵌套的目录中去执行那里的 listfile 文件。

接下来,让我们检查 calc 库的 listfile。它有些复杂,因此我们将其分解并按部分进行讨论。

构建 Calc 库

calc 目录中的 listfile 配置了该库的各个方面,但现在,我们只关注构建部分:

ch15/01-full-project/src/calc/CMakeLists.txt(片段)

add_library(calc_obj OBJECT basic.cpp)
target_sources(calc_obj
               PUBLIC FILE_SET HEADERS
               BASE_DIRS include
               FILES include/calc/basic.h
)
set_target_properties(calc_obj PROPERTIES
    POSITION_INDEPENDENT_CODE 1
)
# ... instrumentation of calc_obj for coverage
add_library(calc_shared SHARED)
target_link_libraries(calc_shared calc_obj)
add_library(calc_static STATIC)
target_link_libraries(calc_static calc_obj)
# ... testing and program analysis modules
# ... documentation generation
# ... installation 

我们定义了三个目标:

  • calc_obj,一个目标库,编译 basic.cpp 实现文件。它的 basic.h 头文件通过 target_sources() 命令中的 FILE_SET 关键字包含进来。这样隐式地配置了合适的包含目录,以便在构建和安装模式下正确导出。通过创建目标库,我们避免了对两个库版本的冗余编译,但启用 POSITION_INDEPENDENT_CODE 是必要的,这样共享库才能依赖于该目标。

  • calc_shared,一个依赖于 calc_obj 的共享库。

  • calc_static,一个同样依赖于 calc_obj 的静态库。

为了提供背景信息,以下是基础库的 C++ 头文件。这个头文件仅声明了 Calc 命名空间中的两个函数,帮助避免命名冲突:

ch15/01-full-project/src/calc/include/calc/basic.h

#pragma once
namespace Calc {
    int Add(int a, int b);
    int Subtract(int a, int b);
}  // namespace Calc 

实现文件也很直接:

ch15/01-full-project/src/calc/basic.cpp

namespace Calc {
  int Add(int a, int b) {
    return a + b;
  }
  int Subtract(int a, int b) {
    return a - b;
  }
} // namespace Calc 

这部分解释完了 src/calc 目录中的文件。接下来是 src/calc_console 以及如何使用该库构建控制台计算器的可执行文件。

构建 Calc 控制台可执行文件

calc_console 目录包含多个文件:一个 listfile,两个实现文件(业务逻辑和引导文件),以及一个头文件。这个 listfile 如下所示:

ch15/01-full-project/src/calc_console/CMakeLists.txt(片段)

add_library(calc_console_static STATIC tui.cpp)
target_include_directories(calc_console_static PUBLIC include)
target_precompile_headers(calc_console_static PUBLIC <string>)
include(GetFTXUI)
target_link_libraries(calc_console_static PUBLIC calc_shared
                      ftxui::screen ftxui::dom ftxui::component)
include(BuildInfo)
BuildInfo(calc_console_static)
# ... instrumentation of calc_console_static for coverage
# ... testing and program analysis modules
# ... documentation generation
add_executable(calc_console bootstrap.cpp)
target_link_libraries(calc_console calc_console_static)
# ... installation 

尽管这个 listfile 看起来复杂,但作为经验丰富的 CMake 用户,我们现在可以轻松地解读它的内容:

  1. 定义 calc_console_static 目标,包含没有 main() 函数的业务代码,以便与具有自己入口点的 GTest 进行链接。

  2. 配置包含目录。我们本可以通过 FILE_SET 单独添加头文件,但由于它们是内部文件,我们简化了这一步。

  3. 实现头文件预编译,这里仅用<string>头文件作为示例,尽管更大的项目可能会包含更多头文件。

  4. 包含一个自定义 CMake 模块,用于获取 FTXUI 依赖项。

  5. 将业务代码与共享的calc_shared库和 FTXUI 组件链接。

  6. 添加一个自定义模块,用于生成构建信息并将其嵌入到产物中。

  7. 为此目标概述额外的步骤:代码覆盖度仪器、测试、程序分析和文档生成。

  8. 创建并链接calc_console引导程序可执行文件,建立入口点。

  9. 概述安装过程。

我们将在本章后续的相关部分探索测试、文档和安装过程。

我们正在包含GetFTXUI实用模块,而不是在系统中查找config-module,因为大多数用户不太可能已经安装它。我们只需要获取并构建它:

ch15/01-full-project/cmake/GetFTXUI.cmake

include(FetchContent)
FetchContent_Declare(
FTXTUI
GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI.git
GIT_TAG        v0.11
)
**option****(FTXUI_ENABLE_INSTALL** **""****OFF****)**
**option****(FTXUI_BUILD_EXAMPLES** **""****OFF****)**
**option****(FTXUI_BUILD_DOCS** **""****OFF****)**
FetchContent_MakeAvailable(FTXTUI) 

我们正在使用推荐的FetchContent方法,该方法在第九章CMake 中的依赖管理》中有详细介绍。唯一不同的是调用了option()命令,这使我们可以绕过 FTXUI 的漫长构建步骤,并防止其安装步骤影响此项目的安装过程。有关更多详细信息,请参阅进一步阅读部分。

calc_console目录的列表文件包含另一个与构建相关的自定义实用模块:BuildInfo。该模块将捕获三条信息,并将其显示在可执行文件中:

  • 当前 Git 提交的 SHA。

  • 构建时间戳。

  • 顶级列表文件中指定的项目版本。

正如我们在第七章使用 CMake 编译 C++源代码》中学到的,CMake 可以捕获构建时的值并通过模板文件将其传递给 C++代码,例如使用一个结构体:

ch15/01-full-project/cmake/buildinfo.h.in

struct BuildInfo {
  static inline const std::string CommitSHA = "@COMMIT_SHA@";
  static inline const std::string Timestamp = "@TIMESTAMP@";
  static inline const std::string Version = "@PROJECT_VERSION@";
}; 

为了在配置阶段填充该结构体,我们将使用以下代码:

ch15/01-full-project/cmake/BuildInfo.cmake

set(BUILDINFO_TEMPLATE_DIR ${CMAKE_CURRENT_LIST_DIR})
set(DESTINATION "${CMAKE_CURRENT_BINARY_DIR}/buildinfo")
string(TIMESTAMP TIMESTAMP)
find_program(GIT_PATH git REQUIRED)
execute_process(COMMAND ${GIT_PATH} log --pretty=format:'%h' -n 1
                OUTPUT_VARIABLE COMMIT_SHA)
configure_file(
  "${BUILDINFO_TEMPLATE_DIR}/buildinfo.h.in"
  "${DESTINATION}/buildinfo.h" @ONLY
)
function(BuildInfo target)
  target_include_directories(${target} PRIVATE ${DESTINATION})
endfunction() 

在包含该模块后,我们设置变量以捕获所需的信息,并使用configure_file()生成buildinfo.h。最后一步是调用BuildInfo函数,将生成的文件目录包含到目标的include目录中。

生成的头文件如果需要,可以与多个不同的消费者共享。在这种情况下,你可能希望在列表文件的顶部添加include_guard(GLOBAL),以避免为每个目标都运行git命令。

在深入实现控制台计算器之前,我想强调的是,你不需要深入理解tui.cpp文件或 FTXUI 库的复杂细节,因为这对我们当前的目的并不重要。相反,让我们关注代码中的高亮部分:

ch15/01-full-project/src/calc_console/tui.cpp

#include "tui.h"
#include <ftxui/dom/elements.hpp>
**#****include****"buildinfo.h"**
**#****include****"calc/basic.h"**
using namespace ftxui;
using namespace std;
string a{"12"}, b{"90"};
auto input_a = Input(&a, "");
auto input_b = Input(&b, "");
auto component = Container::Vertical({input_a, input_b});
Component getTui() {
  return Renderer(component, [&] {
    **auto** **sum = Calc::****Add****(****stoi****(a),** **stoi****(b));**
    return vbox({
      **text****(****"CalcConsole "** **+ BuildInfo::Version),**
**text****(****"Built: "** **+ BuildInfo::Timestamp),**
**text****(****"SHA: "** **+ BuildInfo::CommitSHA),**
       separator(),
       input_a->Render(),
       input_b->Render(),
       separator(),
       **text****(****"Sum: "** **+** **to_string****(sum)),**
     }) |
     border;
   });
} 

这段代码提供了getTui()函数,该函数返回一个ftxui::Component对象,这个对象封装了交互式 UI 元素,如标签、文本字段、分隔符和边框。对于那些好奇这些元素的详细工作原理的人,更多的资料可以在进一步阅读部分找到。

更重要的是,包含指令链接到calc_obj目标和BuildInfo模块中的头文件。交互从 lambda 函数开始,调用Calc::Sum,并使用text()函数显示结果。

在构建时收集的buildinfo.h中的值会以类似的方式使用,并在运行时显示给用户。

tui.cpp旁边,有一个头文件:

ch15/01-full-project/src/calc_console/include/tui.h

#include <ftxui/component/component.hpp>
ftxui::Component getTui(); 

这个头文件被calc_console目标中的引导文件使用:

ch15/01-full-project/src/calc_console/bootstrap.cpp

#include <ftxui/component/screen_interactive.hpp>
#include "tui.h"
int main(int argc, char** argv) {
  ftxui::ScreenInteractive::FitComponent().Loop(getTui());
} 

这段简短的代码初始化了一个带有 FTXUI 的交互式控制台屏幕,显示getTui()返回的Component对象,并在循环中处理键盘输入。所有src目录下的文件都已处理完毕,我们现在可以继续进行程序的测试和分析。

测试和程序分析

程序分析和测试是确保我们解决方案质量的重要组成部分。例如,在运行测试代码时,使用 Valgrind 更加有效(因为它具有一致性和覆盖率)。因此,我们将把测试和程序分析配置在同一位置。图 15.5 展示了执行流程和设置它们所需的文件:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_15_05.png

图 15.5:用于启用测试和程序分析的文件

括号中的数字表示处理列表文件的顺序。从顶级列表文件开始,并添加srctest目录:

  • src中,包含CoverageFormatCppCheck模块,并添加src/calcsrc/calc_console目录。

  • src/calc中,定义目标并使用包含的模块进行配置。

  • src/calc_console中,定义目标并使用包含的模块进行配置。

  • test中,包含Testing(包括Memcheck),并添加test/calctest/calc_console目录。

  • test/calc中,定义测试目标并使用包含的模块进行配置。

  • test/calc_console中,定义测试目标并使用包含的模块进行配置。

让我们来查看一下test目录的列表文件:

ch15/01-full-project/test/CMakeLists.txt

**include****(Testing)**
add_subdirectory(calc)
add_subdirectory(calc_console) 

在这个层次中,包含了Testing实用模块,为两个目标组(来自calccalc_console目录)提供功能:

ch15/01-full-project/cmake/Testing.cmake(片段)

include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG v1.14.0
)
# For Windows: Prevent overriding the parent project's
# compiler/linker settings
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
**option****(INSTALL_GMOCK** **"Install GMock"****OFF****)**
**option****(INSTALL_GTEST** **"Install GTest"****OFF****)**
FetchContent_MakeAvailable(googletest)
# ... 

我们启用了测试并包含了 FetchContent 模块来获取 GTest 和 GMock。虽然 GMock 在本项目中没有使用,但它与 GTest 一起在同一仓库中提供,因此我们也进行了配置。关键的配置步骤是通过使用 option() 命令,防止这些框架的安装影响到我们项目的安装。

在同一个文件中,我们定义了一个 AddTests() 函数,以方便全面测试业务目标:

ch15/01-full-project/cmake/Testing.cmake (续)

# ...
include(GoogleTest)
include(Coverage)
include(Memcheck)
macro(AddTests target)
  message("Adding tests to ${target}")
  target_link_libraries(${target} PRIVATE gtest_main gmock)
  gtest_discover_tests(${target})
  **AddCoverage(****${target}****)**
 **AddMemcheck(****${target}****)**
endmacro() 

首先,我们包含了必要的模块:GoogleTest 与 CMake 一起捆绑提供,CoverageMemcheck 是项目中自定义的工具模块。接着,提供了 AddTests 宏,用于准备一个测试目标,应用覆盖率插桩和内存检查。AddCoverage()AddMemcheck() 函数分别在它们各自的工具模块中定义。现在,我们可以继续实现它们。

准备 Coverage 模块

在多个目标上添加覆盖率涉及几个步骤。Coverage 模块提供了一个函数,用于为指定目标定义覆盖率目标:

ch15/01-full-project/cmake/Coverage.cmake (片段)

function(AddCoverage target)
  find_program(LCOV_PATH lcov REQUIRED)
  find_program(GENHTML_PATH genhtml REQUIRED)
  add_custom_target(coverage-${target}
    COMMAND ${LCOV_PATH} -d . --zerocounters
    COMMAND $<TARGET_FILE:${target}>
    COMMAND ${LCOV_PATH} -d . --capture -o coverage.info
    COMMAND ${LCOV_PATH} -r coverage.info '/usr/include/*'
      -o filtered.info
    COMMAND ${GENHTML_PATH} -o coverage-${target}
      filtered.info --legend
    COMMAND rm -rf coverage.info filtered.info
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction()
# ... 

这个实现与 第十一章 中介绍的实现略有不同,因为它现在在输出路径中包括了目标名称,以防止名称冲突。接下来,我们需要一个函数来清除之前的覆盖率结果:

ch15/01-full-project/cmake/Coverage.cmake (续)

# ...
function(CleanCoverage target)
  add_custom_command(TARGET ${target} PRE_BUILD COMMAND
    find ${CMAKE_BINARY_DIR} -type f
    -name '*.gcda' -exec rm {} +)
endfunction()
# ... 

此外,我们还提供了一个函数来准备目标进行覆盖率分析:

ch15/01-full-project/cmake/Coverage.cmake (片段)

# ...
function(InstrumentForCoverage target)
  if (CMAKE_BUILD_TYPE STREQUAL Debug)
target_compile_options(${target}
                       PRIVATE --coverage -fno-inline)
    target_link_options(${target} PUBLIC --coverage)
  endif()
endfunction() 

InstrumentForCoverage() 函数应用于 src/calcsrc/calc_console,在执行目标 calc_objcalc_console_static 时生成覆盖率数据文件。

要为两个测试目标生成报告,请在配置项目并选择 Debug 构建类型后,执行以下 cmake 命令:

cmake --build <build-tree> -t coverage-calc_test
cmake --build <build-tree> -t coverage-calc_console_test 

接下来,我们希望对我们定义的多个目标进行动态程序分析,因此,要应用在 第十二章 中介绍的 Memcheck 模块,程序分析工具,我们需要稍作调整,以扫描多个目标。

准备 Memcheck 模块

生成 Valgrind 内存管理报告由 AddTests() 启动。我们通过其初始设置开始 Memcheck 模块:

ch15/01-full-project/cmake/Memcheck.cmake (片段)

include(FetchContent)
FetchContent_Declare(
  memcheck-cover
  GIT_REPOSITORY https://github.com/Farigh/memcheck-cover.git
  GIT_TAG        release-1.2
)
FetchContent_MakeAvailable(memcheck-cover) 

这段代码我们已经很熟悉了。现在,让我们来看一下创建必要目标以生成报告的函数:

ch15/01-full-project/cmake/Memcheck.cmake (续)

function(AddMemcheck target)
  set(MEMCHECK_PATH ${memcheck-cover_SOURCE_DIR}/bin)
  **set****(REPORT_PATH** **"${CMAKE_BINARY_DIR}/valgrind-${target}"****)**
  add_custom_target(memcheck-${target}
    COMMAND ${MEMCHECK_PATH}/memcheck_runner.sh -o
      "${REPORT_PATH}/report"
      -- $<TARGET_FILE:${target}>
    COMMAND ${MEMCHECK_PATH}/generate_html_report.sh
      -i ${REPORT_PATH}
      -o ${REPORT_PATH}
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction() 

我们稍微改进了 第十二章 中的 AddMemcheck() 函数,以便处理多个目标。我们使 REPORT_PATH 变量针对每个目标特定。

要生成 Memcheck 报告,请使用以下命令(请注意,当使用 Debug 构建类型进行配置时,生成报告更为有效):

cmake --build <build-tree> -t memcheck-calc_test
cmake --build <build-tree> -t memcheck-calc_console_test 

好的,我们定义了 CoverageMemcheck 模块(它们在 Testing 模块中使用),那么让我们看看实际的测试目标是如何配置的。

应用测试场景

为了实现测试,我们将遵循以下场景:

  1. 编写单元测试。

  2. 使用 AddTests() 定义并配置测试的可执行目标。

  3. 被测软件SUT)进行插桩,以启用覆盖率收集。

  4. 确保在构建之间清除覆盖率数据,以防止出现段错误。

让我们从必须编写的单元测试开始。为了简洁起见,我们将提供最简单(也许有些不完整)的单元测试。首先,测试库:

ch15/01-full-project/test/calc/basic_test.cpp

#include "calc/basic.h"
#include <gtest/gtest.h>
TEST(CalcTest, SumAddsTwoInts) {
  EXPECT_EQ(4, Calc::Add(2, 2));
}
TEST(CalcTest, SubtractsTwoInts) {
  EXPECT_EQ(6, Calc::Subtract(8, 2));
} 

接着测试控制台——为此我们将使用 FXTUI 库。同样,完全理解源代码并不是必要的;这些测试仅用于说明目的:

ch15/01-full-project/test/calc_console/tui_test.cpp

#include "tui.h"
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <ftxui/screen/screen.hpp>
using namespace ::ftxui;
TEST(ConsoleCalcTest, RunWorksWithDefaultValues) {
  auto component = getTui();
  auto document = component->Render();
  auto screen = Screen::Create(Dimension::Fit(document));
  Render(screen, document);
  auto output = screen.ToString();
  ASSERT_THAT(output, testing::HasSubstr("Sum: 102"));
} 

这个测试将 UI 渲染到一个静态的 Screen 对象,并检查字符串输出是否包含预期的和。虽然这不是一个很好的测试,但至少它是一个简短的测试。

现在,让我们通过两个嵌套的列表文件配置我们的测试。首先,针对库:

ch15/01-full-project/test/calc/CMakeLists.txt

add_executable(calc_test basic_test.cpp)
target_link_libraries(calc_test PRIVATE calc_static)
**AddTests(calc_test)** 

然后是可执行文件:

ch15/01-full-project/test/calc_console/CMakeLists.txt

add_executable(calc_console_test tui_test.cpp)
target_link_libraries(calc_console_test
                      PRIVATE calc_console_static)
**AddTests(calc_console_test)** 

这些配置使 CTest 可以执行测试。我们还需要为业务逻辑目标准备覆盖率分析,并确保覆盖率数据在构建之间得到更新。

让我们为 calc 库目标添加必要的指令:

ch15/01-full-project/src/calc/CMakeLists.txt(续)

# ... calc_obj target definition
**InstrumentForCoverage(calc_obj)**
# ... calc_shared target definition
# ... calc_static target definition
**CleanCoverage(calc_static)** 

插桩通过额外的 --coverage 标志添加到 calc_obj,但是 CleanCoverage() 被调用到 calc_static 目标。通常情况下,你会对 calc_obj 应用它以保持一致性,但我们在 CleanCoverage() 中使用了 PRE_BUILD 关键字,而 CMake 不允许在对象库上使用 PRE_BUILDPRE_LINKPOST_BUILD 钩子。

最后,我们还将插桩并清理控制台目标:

ch15/01-full-project/src/calc_console/CMakeLists.txt(续)

# ... calc_console_test target definition
# ... BuildInfo
**InstrumentForCoverage(calc_console_static)**
**CleanCoverage(calc_console_static)** 

通过这些步骤,CTestr 已设置好运行我们的测试并收集覆盖率。接下来,我们将添加启用静态分析的指令,因为我们希望在第一次构建以及后续所有构建中都保持项目的高质量。

添加静态分析工具

我们即将完成为我们的目标配置质量保证的工作。最后一步是启用自动格式化并集成CppCheck

ch15/01-full-project/src/calc/CMakeLists.txt(续)

# ... calc_static target definition
# ... Coverage instrumentation and cleaning
**Format(calc_static .)**
**AddCppCheck(calc_obj)** 

我们在这里遇到一个小问题:calc_obj 不能有 PRE_BUILD 钩子,因此我们改为对 calc_static 应用格式化。我们还确保 calc_console_static 目标被格式化并检查:

ch15/01-full-project/src/calc_console/CMakeLists.cmake(续)

# ... calc_console_test target definition
# ... BuildInfo
# ... Coverage instrumentation and cleaning
**Format(calc_console_static .)**
**AddCppCheck(calc_console_static)** 

我们仍然需要定义FormatCppCheck函数。从Format()开始,我们借用了第十二章程序分析工具中描述的代码:

ch15/01-full-project/cmake/Format.cmake

function(Format target directory)
  find_program(CLANG-FORMAT_PATH clang-format REQUIRED)
  set(EXPRESSION h hpp hh c cc cxx cpp)
  list(TRANSFORM EXPRESSION PREPEND "${directory}/*.")
  file(GLOB_RECURSE SOURCE_FILES FOLLOW_SYMLINKS
    LIST_DIRECTORIES false ${EXPRESSION}
  )
  add_custom_command(TARGET ${target} PRE_BUILD COMMAND
    ${CLANG-FORMAT_PATH} -i --style=file ${SOURCE_FILES}
  )
endfunction() 

要将CppCheck与我们的源代码集成,我们使用:

ch15/01-full-project/cmake/CppCheck.cmake

function(AddCppCheck target)
  find_program(CPPCHECK_PATH cppcheck REQUIRED)
  set_target_properties(${target}
    PROPERTIES CXX_CPPCHECK
    "${CPPCHECK_PATH};**--enable=warning;--error-exitcode=10**"
  )
endfunction() 

这很简单方便。你可能会发现它与 Clang-Tidy 模块(见第十二章程序分析工具)有些相似,展示了 CMake 在功能上的一致性。

cppcheck的参数如下:

  • --enable=warning:启用警告信息。要启用更多检查,请参阅 Cppcheck 手册(见进一步阅读部分)。

  • --error-exitcode=1:设置当cppcheck检测到问题时返回的错误代码。可以是1255之间的任何数字(因为0表示成功),尽管某些数字可能被系统保留。

所有srctest目录中的文件都已创建,我们的解决方案现在可以构建并完全测试。我们可以继续进行安装和打包步骤。

安装和打包

图 15.6显示了我们将如何配置项目进行安装和打包:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_15_06.png

图 15.6:配置安装和打包的文件

顶层的 listfile 包括Packaging工具模块:

ch15/01-full-project/CMakeLists.txt(片段)

# ... configure project
# ... enable testing
# ... include src and test subdirectories
**include****(Packaging)** 

Packaging模块详细描述了项目的包配置,我们将在使用 CPack 打包部分中探讨。我们现在的重点是安装三个主要组件:

  • Calc 库的工件:静态和共享库、头文件以及目标导出文件

  • Calc 库的包定义配置文件

  • Calc 控制台可执行文件

一切都已规划好,现在是配置库的安装的时候了。

库的安装

为了安装该库,我们首先定义逻辑目标及其工件目的地,利用GNUInstallDirs模块的默认值以避免手动指定路径。工件将按组件分组。默认安装将安装所有文件,但你可以选择只安装runtime组件,跳过development工件:

ch15/01-full-project/src/calc/CMakeLists.txt(续)

# ... calc library targets definition
# ... configuration, testing, program analysis
# Installation
include(GNUInstallDirs)
install(TARGETS calc_obj calc_shared calc_static
  EXPORT CalcLibrary
  ARCHIVE COMPONENT development
  LIBRARY COMPONENT runtime
  FILE_SET HEADERS COMPONENT runtime
) 

对于 UNIX 系统,我们还配置了共享库的安装后注册,使用ldconfig

ch15/01-full-project/src/calc/CMakeLists.txt(续)

if (UNIX)
  install(CODE "execute_process(COMMAND ldconfig)"
    COMPONENT runtime
  )
endif() 

为了在其他 CMake 项目中启用可重用性,我们将通过生成并安装一个目标导出文件和一个引用它的配置文件来打包该库:

ch15/01-full-project/src/calc/CMakeLists.txt(续)

install(EXPORT CalcLibrary
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
  NAMESPACE Calc::
  COMPONENT runtime
)
install(FILES "CalcConfig.cmake"
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
) 

为了简单起见,CalcConfig.cmake文件保持简洁:

ch15/01-full-project/src/calc/CalcConfig.cmake

include("${CMAKE_CURRENT_LIST_DIR}/CalcLibrary.cmake") 

这个文件位于 src/calc 中,因为它只包含库目标。如果有来自其他目录的目标定义,比如 calc_console,通常会将 CalcConfig.cmake 放在顶层或 src 目录中。

现在,库已经准备好在构建项目后通过 cmake --install 命令进行安装。然而,我们仍然需要配置可执行文件的安装。

可执行文件的安装

当然,我们希望用户能够在他们的系统上使用可执行文件,因此我们将使用 CMake 安装它。准备二进制可执行文件的安装非常简单;为此,我们只需要包含 GNUInstallDirs 并使用一个 install() 命令:

ch15/01-full-project/src/calc_console/CMakeLists.txt(续)

# ... calc_console_static definition
# ... configuration, testing, program analysis
# ... calc_console bootstrap executable definition
# Installation
include(GNUInstallDirs)
install(TARGETS calc_console
  RUNTIME COMPONENT runtime
) 

这样,可执行文件已经设置好可以安装了。现在,让我们继续进行打包。

使用 CPack 进行打包

我们可以配置多种支持的包类型,但对于这个项目,基本配置就足够了:

ch15/01-full-project/cmake/Packaging.cmake

# CPack configuration
set(CPACK_PACKAGE_VENDOR "Rafal Swidzinski")
set(CPACK_PACKAGE_CONTACT "email@example.com")
set(CPACK_PACKAGE_DESCRIPTION "Simple Calculator")
include(CPack) 

这样的最小配置对于标准存档(如 ZIP 文件)非常有效。为了在构建项目后测试安装和打包过程,可以在构建树内使用以下命令:

# cpack -G TGZ -B packages
CPack: Create package using TGZ
CPack: Install projects
CPack: - Run preinstall target for: Calc
CPack: - Install project: Calc []
CPack: Create package
CPack: - package: .../packages/Calc-1.0.0-Linux.tar.gz generated. 

这就结束了安装和打包的部分;接下来的任务是文档。

提供文档

一个专业项目的最后润色是文档。没有文档的项目在团队合作和与外部分享时都非常难以导航和理解。我甚至会说,程序员常常在离开某个特定文件后,重新阅读自己的文档,以便理解文件中的内容。

文档对于法律和合规性以及告知用户如何使用软件也非常重要。如果时间允许,我们应该投入精力为我们的项目设置文档。

文档通常分为两类:

  • 技术文档(涵盖接口、设计、类和文件)

  • 一般文档(涵盖所有其他非技术文档)

正如我们在第十三章中看到的,生成文档,大部分技术文档可以通过 CMake 使用 Doxygen 自动生成。

生成技术文档

虽然一些项目在构建阶段生成文档并将其包含在包中,但我们选择不这样做。尽管如此,也有可能出于某些有效原因选择这样做,比如如果文档需要在线托管。

图 15.7 提供了文档生成过程的概述:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/B19844_15_07.png

图 15.7:用于生成文档的文件

为了生成文档,我们将创建另一个 CMake 工具模块 Doxygen。首先使用 Doxygen find-module 并下载 doxygen-awesome-css 项目来获取主题:

ch15/01-full-project/cmake/Doxygen.cmake(片段)

find_package(Doxygen REQUIRED)
include(FetchContent)
FetchContent_Declare(doxygen-awesome-css
  GIT_REPOSITORY
    https://github.com/jothepro/doxygen-awesome-css.git
  GIT_TAG
    v2.3.1
)
FetchContent_MakeAvailable(doxygen-awesome-css) 

然后,我们需要一个函数来创建生成文档的目标。我们将调整在第十三章《生成文档》中介绍的代码,以支持多个目标:

ch15/01-full-project/cmake/Doxygen.cmake(续)

function(Doxygen target **input**)
  set(NAME "doxygen**-${target}**")
  set(DOXYGEN_GENERATE_HTML YES)
  set(DOXYGEN_HTML_OUTPUT   ${PROJECT_BINARY_DIR}/${output})
  UseDoxygenAwesomeCss()
  UseDoxygenAwesomeExtensions()
  doxygen_add_docs("doxygen**-${target}**"
      ${PROJECT_SOURCE_DIR}/${input}
      COMMENT "Generate HTML documentation"
  )
endfunction()
# ... copied from Ch13:
#     UseDoxygenAwesomeCss
#     UseDoxygenAwesomeExtensions 

通过调用库目标来使用此功能:

ch15/01-full-project/src/calc/CMakeLists.txt(片段)

# ... calc_static target definition
# ... testing and program analysis modules
**Doxygen(calc src/calc)**
# ... file continues 

对于控制台可执行文件:

ch15/01-full-project/src/calc_console/CMakeLists.txt(片段)

# ... calc_static target definition
# ... testing and program analysis modules
**Doxygen(calc_console src/calc_console)**
# ... file continues 

此设置为项目添加了两个目标:doxygen-calcdoxygen-calc_console,允许按需生成技术文档。现在,让我们考虑应该包含哪些其他文档。

为专业项目编写非技术性文档

专业项目应包括一组非技术性文档,存储在顶层目录中,对于全面理解和法律清晰度至关重要:

  • README: 提供项目的一般描述

  • LICENSE: 详细说明有关项目使用和分发的法律参数

你可能考虑的其他文档包括:

  • INSTALL: 提供逐步的安装说明

  • CHANGELOG: 提示版本之间的重要变更

  • AUTHORS: 列出贡献者及其联系方式,如果项目有多个贡献者的话

  • BUGS: 提供已知问题和报告新问题的详细信息

CMake 不会直接与这些文件交互,因为它们不涉及自动处理或脚本。然而,它们的存在对于一个良好文档化的 C++项目至关重要。以下是每个文档的最小示例:

ch15/01-full-project/README.md

# Calc Console
Calc Console is a calculator that adds two numbers in a
terminal. It does all the math by using a **Calc** library.
This library is also available in this package.
This application is written in C++ and built with CMake.
## More information
- Installation instructions are in the INSTALL file
- License is in the LICENSE file 

这很简短,可能有点傻。注意.md扩展名——它代表Markdown,这是一种基于文本的格式化语言,易于阅读。像 GitHub 这样的站点和许多文本编辑器会以丰富的格式呈现这些文件。

我们的INSTALL文件将如下所示:

ch15/01-full-project/INSTALL

To install this software you'll need to provide the following:
- C++ compiler supporting C++17
- CMake >= 3.26
- GIT
- Doxygen + Graphviz
- CPPCheck
- Valgrind
This project also depends on GTest, GMock and FXTUI. This
software is automatically pulled from external repositories
during the installation.
To configure the project type:
cmake -B <temporary-directory>
Then you can build the project:
cmake --build <temporary-directory>
And finally install it:
cmake --install <temporary-directory>
To generate the documentation run:
cmake --build <temporary-directory> -t doxygen-calc
cmake --build <temporary-directory> -t doxygen-calc_console 

LICENSE文件有点棘手,因为它需要一些版权法方面的专业知识(以及其他方面)。我们可以像许多其他项目一样,使用现成的开源软件许可证,而不是自己编写所有条款。对于这个项目,我们将使用 MIT 许可证,它非常宽松。请查看进一步阅读部分,获取一些有用的参考资料:

ch15/01-full-project/LICENSE

Copyright 2022 Rafal Swidzinski
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 

最后,我们有CHANGELOG。如前所述,保持文件中的变更记录很有帮助,这样使用你项目的开发者可以轻松找到支持他们所需功能的版本。例如,可能有用的是说明在版本 0.8.2 中为库添加了乘法功能。像以下这样简单的内容已经非常有帮助:

ch15/01-full-project/CHANGELOG

1.1.0 Updated for CMake 3.26 in 2nd edition of the book
1.0.0 Public version with installer
0.8.2 Multiplication added to the Calc Library
0.5.1 Introducing the Calc Console application
0.2.0 Basic Calc library with Sum function 

有了这些文档,项目不仅获得了操作结构,还有效地传达了其使用方法、变更和法律事项,确保用户和贡献者掌握所有必要的信息。

总结

在本章中,我们基于迄今为止学到的一切,整合了一个专业的项目。让我们快速回顾一下。

我们首先规划了项目结构,并讨论了哪些文件将位于哪个目录中。基于之前的经验和对更高级场景的实践需求,我们划分了面向用户的主要应用程序和另一个开发人员可能使用的库。这决定了目录结构和我们希望构建的 CMake 目标之间的关系。接着,我们为构建配置了各个目标:我们提供了库的源代码,定义了它的目标,并为其配置了独立位置代码参数以供使用。面向用户的应用程序也定义了它的可执行目标,提供了源代码,并配置了它的依赖:FTXUI 库。

拥有构建工件后,我们继续通过测试和质量保证来增强我们的项目。我们添加了覆盖率模块以生成覆盖报告,使用 Memcheck 在运行时通过 Valgrind 验证解决方案,并且还使用 CppCheck 执行静态分析。

现在这个项目已经准备好安装,因此我们使用迄今为止学到的技术为库和可执行文件创建了适当的安装条目,并为 CPack 准备了一个包配置。最后的任务是确保项目的文档是正确的,因此我们设置了自动文档生成(使用 Doxygen),并编写了一些基础文档来处理软件分发中的非技术性方面。

这使我们完成了项目配置,现在我们可以轻松地使用几个精确的 CMake 命令来构建并安装它。但如果我们能只用一个简单的命令来完成整个过程呢?让我们在最后一章:第十六章编写 CMake 预设中探索如何做到这一点。

进一步阅读

如需更多信息,您可以参考以下链接:

留下评论!

喜欢这本书吗?通过在亚马逊上留下评论,帮助像你一样的读者。扫描下方二维码,获取你选择的免费电子书。

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp-2e/img/Review_Copy.png

posted @   绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 按钮权限的设计及实现
历史上的今天:
2024-03-03 OpenDocCN 20240303 更新
2024-03-03 笨办法学 Python3 第五版(预览)(三)
2024-03-03 笨办法学 Python3 第五版(预览)(二)
2024-03-03 笨办法学 Python3 第五版(预览)(一)
2023-03-03 PyTorch 1.0 中文官方教程:用 numpy 和 scipy 创建扩展
点击右上角即可分享
微信分享提示