前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >面向 C++ 的现代 CMake 教程(四)

面向 C++ 的现代 CMake 教程(四)

作者头像
ApacheCN_飞龙
发布2024-05-26 08:54:43
3000
发布2024-05-26 08:54:43
举报
文章被收录于专栏:信数据得永生信数据得永生

原文:zh.annas-archive.org/md5/125f0c03ca93490db2ba97b08bc69e99 译者:飞龙 协议:CC BY-NC-SA 4.0

第九章:程序分析工具

编写高质量代码并非易事,即使对于经验非常丰富的开发者也是如此。通过向我们的解决方案中添加测试,我们可以减少在业务代码中犯明显错误的风险。但这还不足以避免更复杂的问题。每一段软件都由如此多的细节组成,跟踪它们全部成为了一份全职工作。团队维护产品达成了数十种约定和多种特殊设计实践。

一些问题涉及一致的编码风格:我们的代码应该使用 80 列还是 120 列?我们应该允许使用std::bind还是坚持使用 Lambda 函数?使用 C 风格数组可以吗?小函数是否应该定义在单行中?我们是否应该始终坚持使用auto,或者只在提高可读性时使用?

理想情况下,我们还应避免任何已知在一般情况下不正确的语句:无限循环、使用标准库保留的标识符、无意中失去精度、冗余的if语句,以及其他不被认为是“最佳实践”的内容(参见进一步阅读部分获取参考资料)。

的另一件事是要关注代码现代化:随着 C++的发展,它提供了新特性。跟踪我们可以重构以适应最新标准的所有地方可能会很困难。此外,人工努力需要时间并引入了引入 bug 的风险,对于大型代码库来说这是相当可观的。

最后,我们应该检查事物在运动时的表现:执行程序并检查其内存。内存在使用后是否被正确释放?我们是否正确地访问了初始化的数据?或者代码试图解引用一些悬空指针?

手工管理所有这些问题和问题是不效率且容易出错的。幸运的是,我们可以使用自动工具来检查和强制执行规则、修复错误并现代化代码为我们。是时候发现程序分析工具了。我们的代码将在每次构建时进行检查,以确保其符合行业标准。

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

  • 强制格式化
  • 使用静态检查器
  • 使用 Valgrind 进行动态分析

技术要求

您可以在 GitHub 上找到本章中存在的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter09

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

代码语言:javascript
复制
cmake -B <build tree> -S <source tree>
cmake --build <build tree>

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

强制格式化

专业开发者通常遵循规则。他们认为高级开发者知道何时打破一条规则(因为他们可以证明需要这么做)。另一方面,有人说非常高级的开发者不打破规则,因为向他人解释理由是浪费时间。我说,选择你的战斗,专注于对产品有实际影响和实质性作用的事情。

当涉及到代码风格和格式化时,程序员面临着许多选择:我们应该使用制表符还是空格进行缩进?如果使用空格,是多少个?列字符数的限制是多少?文件呢?在大多数情况下,这些选择不会影响程序的行为,但它们确实会产生很多噪音,并引发长时间讨论,这些讨论对产品并没有太大价值。

有些实践是普遍认同的,但大多数时候,我们是在争论个人偏好和 anecdotal 证据。毕竟,将列中的字符数从 120 强制到 80 是一个任意选择。只要我们保持一致,我们选择什么并不重要。风格上的不一致是坏事,因为它影响软件的一个重要方面——代码的可读性。

避免这种情况的最佳方式是使用格式化工具,如clang-format。这可以警告我们的代码格式不正确,甚至在我们允许的情况下修复突出显示的问题。以下是一个格式化代码的命令示例:

代码语言:javascript
复制
clang-format -i --style=LLVM filename1.cpp filename2.cpp

-i选项告诉 ClangFormat 就地编辑文件。--style选择应使用哪种支持的格式化样式:LLVMGoogleChromiumMozillaWebKit或自定义,从file提供(在进一步阅读部分有详细信息的链接)。

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

chapter09/01-formatting/cmake/Format.cmake

代码语言:javascript
复制
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。它将格式化来自directory的所有源文件,在构建target之前。

从技术上讲,directory中的所有文件不一定都属于target(并且目标源代码可能位于多个目录中)。然而,找到所有属于目标(以及可能的依赖目标)的源文件和头文件是一个非常复杂的过程,尤其是当我们需要过滤掉属于外部库且不应该格式化的头文件时。在这种情况下,按目录工作更加可行。我们只需为每个格式化目标调用函数。

这个函数有以下几个步骤:

  1. 查找系统中安装的clang-format二进制文件。REQUIRED关键字将在找不到二进制文件时停止配置并显示错误。
  2. 创建一个要格式化的文件扩展名列表(用作通配符表达式)。
  3. 在每个表达式前加上directory的路径。
  4. 递归搜索源文件和头文件(使用之前创建的列表),跳过目录,并将它们的路径放入SOURCE_FILES变量中。
  5. 将格式化命令作为targetPRE_BUILD步骤。

这个命令对于小到中等大小的代码库来说效果很好。对于大量文件,我们需要将绝对文件路径转换为相对路径,并使用directory作为工作目录执行格式化(list(TRANSFORM)命令在这里很有用)。这可能是因为传递给 shell 的命令长度有限制(通常约为 13,000 个字符),而太多的长路径根本放不下。

让我们看看如何在实际中使用这个函数。我们将使用以下项目结构:

代码语言:javascript
复制
- CMakeLists.txt
- .clang-format
- cmake
  |- Format.cmake
- src
  |- CMakeLists.txt
  |- header.h
  |- main.cpp

首先,我们需要设置项目并将cmake目录添加到模块路径中,这样我们稍后才能包含它:

第九章/01-格式化/CMakeLists.txt

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
project(Formatting CXX)
enable_testing()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
add_subdirectory(src bin)

设置好之后,让我们为src目录填写列表文件:

第九章/01-格式化/src/CMakeLists.txt

代码语言:javascript
复制
add_executable(main main.cpp)
include(Format)
Format(main .)

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

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

第九章/01-格式化/src/header.h

代码语言:javascript
复制
int unused() { return 2 + 2; }

我们还会添加一个源文件,其中空格过多:

第九章/01-格式化/src/main.cpp

代码语言:javascript
复制
#include <iostream>
  using namespace std;
    int main() {
      cout << "Hello, world!" << endl;
    }

万事俱备,只差格式化器的配置文件(可在命令行中使用--style=file参数启用):

第九章/01-格式化/.clang-format

代码语言:javascript
复制
BasedOnStyle: Google
ColumnLimit: 140
UseTab: Never
AllowShortLoopsOnASingleLine: false
AllowShortFunctionsOnASingleLine: false
AllowShortIfStatementsOnASingleLine: false

Clang Format 将扫描父目录中的.clang-format文件,该文件指定确切的格式化规则。这允许我们指定每一个小细节,或者定制前面提到的标准之一。在我的案例中,我选择从 Google 的编码风格开始,并加入一些调整:限制列数为 140 个字符,移除制表符,并允许短循环、函数和if语句。

我们来看看在构建该项目后文件的变化情况(格式化会在编译前自动进行):

第九章/01-格式化/src/header.h(已格式化)

代码语言:javascript
复制
int unused() {
  return 2 + 2;
}

尽管目标没有使用头文件,但格式化器还是对其进行了格式化;不允许单行上有短函数。格式化器添加了新行,正如所期望的那样。main.cpp文件现在看起来也很酷:

第九章/01-格式化/src/main.cpp(已格式化)

代码语言:javascript
复制
#include <iostream>
using namespace std;
int main() {
  cout << "Hello, world!" << endl;
}

删除了不必要的空格,并将缩进标准化。

添加自动化格式化工具并不需要太多努力,而且在代码审查时节省你大量时间。如果你曾经不得不修改提交来修正一些空白字符,你就会明白这种感觉。一致的格式化让你的代码整洁而无需任何努力。

注意

将格式化应用到现有代码库中很可能会对大多数仓库中的文件引入一次性的巨大变化。如果你(或你的团队成员)有一些正在进行的工作,这可能会导致大量的合并冲突。最好协调这样的努力,在所有待处理的变化完成后进行。如果这不可能,考虑逐步采用,也许按目录 basis 进行。你的同事们会感谢你的。

格式化器是一个伟大而简单的工具,可以统一代码的视觉方面,但它不是一个完全成熟的程序分析工具(它主要关注空白字符)。为了处理更高级的场景,我们需要使用能够理解程序源代码的工具来执行静态分析。

使用静态检查器

静态程序分析是检查源代码而不实际运行编译版本的过程。严格应用静态检查器显著提高了代码的质量:它变得更加一致,更少出现错误。引入已知的安全漏洞的机会也减少了。C++社区已经创建了数十个静态检查器:Astrée、Clang-Tidy、CLazy、CMetrics、Cppcheck、Cpplint、CQMetrics、ESBMC、FlawFinder、Flint、IKOS、Joern、PC-Lint、Scan-Build、Vera++等等。

许多它们认识 CMake 作为行业标准,并提供开箱即用的支持(或集成教程)。一些构建工程师不想费心写 CMake 代码,他们通过包含在线可用的外部模块来添加静态检查器,例如 Lars Bilke 在他的 GitHub 仓库中收集的那些:github.com/bilke/cmake-modules

难怪,因为普遍的误解是你需要跳过很多障碍才能让你的代码进行检查。造成这种复杂性的原因是静态检查器的本质:它们经常模仿真实编译器的行为来理解代码中发生的事情。

Cppcheck 在其手册中推荐了以下步骤:

找到静态检查器的可执行文件。

使用以下方法生成编译数据库

代码语言:javascript
复制
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON .

在生成的 JSON 文件上运行检查器:

代码语言:javascript
复制
<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

像往常一样,<LANG>应该用所使用的语言替换,所以用C表示 C 源文件,用CXX表示 C++。如果你不需要针对每个目标控制检查器,可以通过设置一个前缀为CMAKE_的适当的全局变量,为项目中的所有目标指定一个默认值,例如以下:

代码语言:javascript
复制
set(CMAKE_CXX_CLANG_TIDY /usr/bin/clang-tidy-3.9;-checks=*)

在此声明之后定义的任何目标,其CXX_CLANG_TIDY属性将以此方式设置。只需记住,这将分析常规构建,使它们稍微变慢。

另一方面,更细粒度地控制检查器如何测试目标有一定的价值。我们可以编写一个简单的函数来解决这个问题:

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

代码语言:javascript
复制
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. target上启用 Clang-Tidy,提供二进制文件的路径和自定义选项以启用所有检查,并将警告视为错误。

要使用这个函数,我们只需要包含模块并针对所选目标调用它:

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

代码语言:javascript
复制
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 的输出:

代码语言:javascript
复制
[  6%] Building CXX object bin/CMakeFiles/sut.dir/calc.cpp.o
/root/examples/chapter09/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/chapter09/04-clang-tidy/src/run.cpp:1:1: warning: #includes are not sorted properly [llvm-include-order]
#include <iostream>
^        ~~~~~~~~~~
/root/examples/chapter09/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/chapter09/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 个)可以遵循相同的格式文件。提供的检查项包括性能改进、可读性、现代化、cpp-core-guidelines 和易出错命名空间等方面的改进。

Cpplint

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

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

这个代码检查工具旨在让您的代码符合上述的 Google 风格。它是用 Python 编写的,这可能会成为某些项目不愿依赖的库。提供的修复格式可以被 Emacs、Eclipse、VS7、Junit 以及作为sed命令的格式所消费。

Cppcheck

以下是从官方网站对 Cppcheck 的描述:

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

这个工具非常值得推荐,它能让您在使用时无忧无虑,避免由于误报而产生的不必要噪音。它已经相当成熟(已有 14 多年的历史),并且仍然维护得非常活跃。另外,如果你的代码不能与 Clang 编译,你可能会觉得它很有用。

包含你使用的(include-what-you-use)

以下是从官方网站对 include-what-you-use 的描述:

包含你使用的的主要目标是去除不必要的#include。它通过找出实际不需要包含的#include(对于.cc 和.h 文件),并在可能的情况下用前向声明替换#include 来实现这一点。

如果你的代码库比较瘦,太多的包含头文件可能看起来并不是一个大问题。在更大的项目中,避免不必要的头文件编译节省的时间会迅速累积。

链接你使用的(Link what you use)

以下是 CMake 博客上对 link-what-you-use 的描述:

这是一个内置的 CMake 功能,使用 ld 和 ldd 的选项来输出如果可执行文件链接了比实际需要更多的库。

这也加快了构建时间;在这种情况下,我们关注的是不需要的二进制文件。

静态分析在软件错误可能影响人们安全的领域至关重要,尤其是在医疗、核能、航空、汽车和机械工业中。明智的开发者知道,在要求不高的环境中遵循类似实践并不会有什么坏处,尤其是在采用成本如此之低的情况下。在构建过程中使用静态分析器不仅比手动查找和修复错误便宜得多;而且通过 CMake 很容易启用。我甚至可以说,在质量敏感的软件(即涉及除程序员以外的其他人的所有软件)中几乎没有任何理由跳过这些检查。

不幸的是,并非所有错误都能在程序执行之前捕获。我们能做些什么来更深入地了解我们的项目呢?

使用 Valgrind 进行动态分析

Valgrind (www.valgrind.org) 是一个允许构建动态分析工具的框架——即在程序运行时执行的分析。它提供了一个广泛的工具套件,允许进行各种调查和检查。其中一些工具如下:

  • Memcheck – 检测内存管理问题
  • Cachegrind – 分析 CPU 缓存,并定位缓存缺失和其他缓存问题
  • Callgrind – Cachegrind 的扩展,带有关于调用图的额外信息
  • Massif – 一种堆分析器,可以显示程序随时间使用堆的情况
  • Helgrind – 线程调试器,有助于解决数据竞争问题
  • DRD – Helgrind 的更轻量级、有限版本

这个列表中的每一个工具在适当的时候都非常方便。大多数包管理器都知道 Valgrind 并且可以轻松地在您的操作系统上安装它(如果您使用的是 Linux,可能已经安装了)。无论如何,官方网站提供了源代码,所以您可以自己构建它。

我们将重点关注套件中最有用的应用程序。当人们提到 Valgrind 时,他们经常会指的是 Valgrind 的 Memcheck。让我们找出如何使用它与 CMake 一起工作——这将为您需要它们时采用其他工具铺平道路。

Memcheck

Memcheck 在调试内存问题时可能不可或缺。在 C++ 中,这尤其棘手,因为程序员对自己如何管理内存有极大的控制权。可能出现各种错误:读取未分配的内存、读取已经释放的内存、尝试多次释放内存以及写入错误的地址。开发者显然试图避免这些错误,但由于这些错误如此微妙,它们甚至可以潜入最简单的程序中。有时,只需忘记一个变量的初始化,我们就陷入了困境。

调用 Memcheck 看起来像这样:

代码语言:javascript
复制
valgrind [valgrind-options] tested-binary [binary-options]

Memcheck 是 Valgrind 的默认工具,但您也可以明确选择它:

代码语言:javascript
复制
valgrind --tool=memcheck tested-binary

运行 Memcheck 代价昂贵;手册(参见进一步阅读中的链接)说,用它 instrumented 的程序可以慢 10-15 倍。为了避免每次运行测试时都要等待 Valgrind,我们将创建一个可以在需要测试代码时从命令行调用的独立目标。理想情况下,开发者会在将他们的更改合并到仓库的默认分支之前运行它。这可以通过早期 Git 钩子或添加为 CI 管道中的一个步骤来实现。在生成阶段完成后,我们将使用以下命令来构建自定义目标:

代码语言:javascript
复制
cmake --build <build-tree> -t valgrind

添加此类目标并不困难:

chapter09/03-valgrind/cmake/Valgrind.cmake

代码语言:javascript
复制
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()

在这个例子中,我们创建了一个 CMake 模块(这样我们就可以在不同的项目中重复使用同一个文件)来包装接受要测试的目标的函数。这里发生两件事:

  • CMake 会在默认的系统路径中搜索valgrind可执行文件,并将其存储在VALGRIND_PATH变量中。如果找不到二进制文件,REQUIRED关键字会导致配置出现错误而停止。
  • 创建了一个自定义目标valgrind;它将在target二进制文件上执行 Memcheck 工具。我们还添加了一个选项,始终检查内存泄漏。

谈到 Valgrind 选项时,我们可以提供命令行参数,也可以如下进行:

  1. ~/.valgrindrc文件(在你的家目录中)
  2. $VALGRIND_OPTS环境变量
  3. ./.valgrindrc文件(在工作目录中)

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

要使用AddValgrind函数,我们应该向其提供一个 unit_tests 目标:

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

代码语言:javascript
复制
# ...
add_executable(unit_tests calc_test.cpp run_test.cpp)
# ...
include(Valgrind)
AddValgrind(unit_tests)

请记住,使用Debug配置生成构建树可以让 Valgrind 访问调试信息,这使得它的输出更加清晰。让我们看看实际中这是如何工作的:

代码语言:javascript
复制
# cmake --build <build-tree> -t valgrind

这会构建sutunit_tests目标:

代码语言:javascript
复制
[100%] Built target unit_tests

启动 Memcheck 的执行,它将为我们提供一般信息:

代码语言:javascript
复制
==954== Memcheck, a memory error detector
==954== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==954== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==954== Command: ./unit_tests

==954==前缀包含进程 ID。这是为了区分 Valgrind 注释和被测试进程的输出而添加的。

接下来,使用gtest进行常规测试:

代码语言:javascript
复制
[==========] 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.

最后,会呈现一个总结:

代码语言:javascript
复制
==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 提供了更多细节来找到它:

代码语言:javascript
复制
==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)

最后,总结在底部找到:

代码语言:javascript
复制
==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 非常擅长找到非常复杂的错误。偶尔,它甚至能够更深入地挖掘,找到不能自动分类的值得怀疑的情况。此类发现将在可能丢失行中予以说明。

让我们看看 Memcheck 在此案例中发现的问题是什么:

chapter09/03-valgrind/src/run.cpp

代码语言:javascript
复制
#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

商业 IDE,如 CLion,原生支持解析 Valgrind 的输出,以便可以通过 GUI 轻松导航,而不必滚动控制台窗口以找到正确的消息。如果你的编辑器没有这个选项,你仍然可以通过使用第三方报告生成器获得更清晰的错误视图。由 David Garcin 编写的 Memcheck-cover 提供了一个更愉快的体验,以生成的 HTML 文件的形式,如图9.1所示:

图 9.1 – 由 memcheck-cover 生成的报告
图 9.1 – 由 memcheck-cover 生成的报告

图 9.1 – 由 memcheck-cover 生成的报告

这个小巧的项目在 GitHub 上可用(github.com/Farigh/memcheck-cover);它需要 Valgrind 和gawk(GNU AWK 工具)。要使用它,我们将在一个单独的 CMake 模块中准备一个设置函数。它将由两部分组成:

  • 获取和配置工具
  • 添加一个自定义目标,执行 Valgrind 并生成报告

配置如下所示:

chapter09/04-memcheck/cmake/Memcheck.cmake

代码语言:javascript
复制
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目标重叠):

chapter09/04-memcheck/cmake/Memcheck.cmake(继续)

代码语言:javascript
复制
  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包装脚本,该脚本将执行 Valgrind 的 Memcheck 并收集通过-o参数提供的文件输出的输出。
  2. 然后,我们将解析输出并使用generate_html_report.sh创建报告。这个脚本需要通过-i-o参数提供的输入和输出目录。

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

当然,我们还需要在我们的列表文件中添加的最后一样东西,当然是调用这个函数的调用。它的模式和AddValgrind一样:

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

代码语言:javascript
复制
include(Memcheck)
AddMemcheck(unit_tests)

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

代码语言:javascript
复制
cmake --build <build-tree> -t memcheck

然后我们可以享受我们的格式化报告。嗯,要真正享受它,我们还需要在run.cpp中添加那个缺失的delete c;,这样它就不会抱怨了(或者,更好的是,使用智能指针)。

总结

“你会在研究代码上花费的时间比在编写代码上多——因此,你应该优化阅读而不是编写。”

这句话在讨论整洁代码实践的书中被像咒语一样重复。难怪,因为这是非常正确的,很多软件开发者已经在实践中证明了这一点——以至于连像空格数、换行符以及#import声明的顺序这样的微小事情都有了规定。这并不是出于小气,而是为了节省时间。遵循本章概述的实践,我们无需担心手动格式化代码。它将自动在构建过程中作为副作用进行格式化——这是我们无论如何都必须执行的步骤,以检查代码是否正确运行。通过引入 ClangFormat,我们还可以确保它看起来正确。

当然,我们想要的不仅仅是简单的空格修正;代码必须符合几十个其他的小规定。这是通过添加 Clang-Tidy 并配置它强制执行我们选择的编码风格来完成的。我们详细讨论了这道静态检查器,但我们也提到了其他选项:Cpplint,Cppcheck,Include-what-you-use 和 Link-what-you-use。由于静态链接器相对较快,我们可以少量投资将它们添加到构建中,这通常是非常值得的。

最后,我们查看了 Valgrind 工具,特别是 Memcheck,它允许调试与内存管理相关的问题:不正确的读取、写入、释放等等。这是一个非常方便的工具,可以节省数小时的手动调查,并防止错误溜进生产环境。正如提到的,它的执行可能会慢一些,这就是我们创建一个单独的目标来显式地在提交代码之前运行它的原因。我们还学会了如何使用 Memcheck-Cover(一个 HTML 报告生成器)以更易接受的形式呈现 Valgrind 的输出。这在支持运行 IDE 的环境中(如 CI 管道)可能非常有用。

当然,我们不仅限于这些工具;还有很多:既有自由和开源项目,也有带有广泛支持的商业产品。这只是对这个主题的介绍。确保探索对你来说正确的东西。在下一章,我们将更详细地查看文档生成。

进一步阅读

要获取更多信息,你可以参考以下链接:

第十章:生成文档

高质量代码不仅编写得很好、运行正常且经过测试,而且还彻底进行了文档化。文档使我们能够分享否则可能丢失的信息,绘制更广阔的图景,提供上下文,揭示意图,最终——教育外部用户和维护者。

你还记得上次加入新项目时,在目录和文件迷宫中迷失了几个小时吗?这种情况是可以避免的。优秀的文档确实能引导一个完全的新手在几秒钟内找到他们想要查看的代码行。遗憾的是,缺失文档的问题常常被一笔勾销。难怪——这需要很多技巧,而且我们中的许多人并不擅长。此外,文档和代码真的可以很快分道扬镳。除非实施严格的更新和审查流程,否则很容易忘记文档也需要维护。

一些团队(出于时间考虑或受到经理的鼓励)遵循编写“自文档化代码”的做法。通过为文件名、函数、变量等选择有意义的、可读的标识符,他们希望避免文档化的繁琐工作。虽然良好的命名习惯绝对是正确的,但它不能取代文档。即使是最出色的函数签名也不能保证传达所有必要的信息——例如,int removeDuplicates();非常具有描述性,但它没有揭示返回值是什么!它可能是找到的重复项数量、剩余项的数量,或其他内容——是不确定的。记住:没有免费的午餐这种事。

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

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

  • 向您的项目添加 Doxygen
  • 使用现代外观生成文档

技术要求

您可以在 GitHub 上找到本章中出现的代码文件,链接如下:

github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter10

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

代码语言:javascript
复制
cmake -B <build tree> -S <source tree>
cmake --build <build tree>

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

向您的项目添加 Doxygen

能够从 C++源代码生成文档的最著名且最受欢迎的工具之一是 Doxygen。当我提到“著名”时,我的意思是:第一个版本是由 Dimitri van Heesch 在 1997 年 10 月发布的。从那时起,它得到了极大的发展,并且由其仓库的 180 多个贡献者积极参与支持(github.com/doxygen/doxygen)。

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

  • 超文本标记语言HTML
  • 富文本格式RTF
  • 便携式文档格式PDF
  • Lamport 的 TeXLaTeX
  • PostScriptPS
  • Unix 手册手册页
  • 微软编译的 HTML 帮助文件CHM

如果你用 Doxygen 指定的格式为代码添加注释,提供额外信息,它将被解析以丰富输出文件。更重要的是,将分析代码结构以生成有益的图表和图表。后者是可选的,因为它需要一个外部的 Graphviz 工具(graphviz.org/)。

开发者首先应该回答以下问题:*项目的用户只是获得文档,还是他们自己生成文档(也许是在从源代码构建时)?*第一个选项意味着文档与二进制文件一起提供,可供在线获取,或者(不那么优雅地)与源代码一起提交到仓库中。

答案很重要,因为如果我们希望用户在构建过程中生成文档,他们需要在他们的系统中拥有这些依赖项。由于 Doxygen 可以通过大多数包管理器(以及 Graphviz)获得,所需的就是一个简单的命令,比如针对 Debian 的这样一个命令:

代码语言:javascript
复制
apt-get install doxygen graphviz

针对 Windows 也有可用的二进制文件(请查看项目的网站)。

总结:为用户生成文档或处理需要时的依赖项添加。这在本章第七章使用 CMake 管理依赖项中有所涵盖,所以我们在这里不会重复这些步骤。请注意,Doxygen 是使用 CMake 构建的,因此你也可以轻松地从源代码编译它。

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

签名看起来像这样:

代码语言:javascript
复制
doxygen_add_docs(targetName [sourceFilesOrDirs...]
  [ALL] [USE_STAMP_FILE] [WORKING_DIRECTORY dir]
  [COMMENT comment])

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

代码语言:javascript
复制
cmake --build <build-tree> -t targetName

或者,我们总是可以通过添加 ALL 参数(通常不必要)来构建它。其他选项相当直观,除了可能 USE_STAMP_FILE。这允许 CMake 在源文件没有更改的情况下跳过文档的重新生成(但要求 sourceFilesOrDirs 只包含文件)。

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

chapter-10/01-doxygen/cmake/Doxygen.cmake

代码语言:javascript
复制
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 目标,该目标将通知用户并运行一个 false 命令,该命令(在 Unix-like 系统上)返回 1,导致构建失败。我们在此时终止函数并用 return()
  3. 如果系统中可用 Doxygen,我们将配置它以在提供的 output 目录中生成 HTML 输出。Doxygen 非常可配置(更多信息请参阅官方文档)。要设置任何选项,只需按照示例通过调用 set() 并将其名称前缀为 DOXYGEN_
  4. 设置实际的 doxygen 目标:所有 DOXYGEN_ 变量都将转发到 Doxygen 的配置文件中,并且将从源树中的提供的 input 目录生成文档。

如果你 documentation 要由用户生成,步骤 2 可能应该涉及安装必要的依赖项。

要使用这个函数,我们可以在我们项目的 main listfile 中添加它,如下所示:

chapter-10/01-doxygen/CMakeLists.txt

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
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 文档:

图 10.1 – 使用 Doxygen 生成的类参考
图 10.1 – 使用 Doxygen 生成的类参考

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_10.1_B17205.jpg)

图 10.1 – 使用 Doxygen 生成的类参考

你可以在成员函数文档中看到的额外描述是通过在头文件中添加适当注释来实现的:

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

代码语言:javascript
复制
   /**
    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 描述中找到更多信息(请参阅 进一步阅读 部分中的链接)。

如前所述,如果安装了 Graphviz,Doxygen 将检测到它并生成依赖关系图,如下所示:

图 10.2 – 使用 Doxygen 生成的继承和协作图
图 10.2 – 使用 Doxygen 生成的继承和协作图

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_10.2_B17205.jpg)

图 10.2 – 使用 Doxygen 生成的继承和协作图

直接从源代码生成文档,我们创建了一个机制,可以快速更新它,以反映在整个开发周期中发生的任何代码更改。此外,注释中任何遗漏的更新都有可能在代码审查期间被发现。

许多开发者会抱怨 Doxygen 提供的设计过时,这让他们犹豫是否向客户展示生成的文档。别担心——有一个简单的解决方案可以解决这个问题。

使用现代风格生成文档

拥有项目文档并以干净、清新的设计也是非常重要的。毕竟,如果我们为我们的前沿项目编写高质量文档付出这么多努力,用户必然会这样认为。Doxygen 拥有所有的功能,但它并不以遵循最新的视觉趋势而闻名。然而,这并不意味着我们需要付出很多努力来改变这一点。

幸运的是,一个名为jothepro的开发者创建了一个名为doxygen-awesome-css的主题,它提供了一个现代、可自定义的设计。它甚至还有夜间模式!您可以在以下屏幕快照中看到此内容:

![图 10.3 – 使用 doxygen-awesome-css 主题的 HTML 文档

(img/Figure_10.3_B17205.jpg)

图 10.3 – 使用 doxygen-awesome-css 主题的 HTML 文档

该主题不需要任何额外的依赖项,可以很容易地从其 GitHub 页面github.com/jothepro/doxygen-awesome-css获取。

注意

在线资源建议使用多个依次执行的应用程序来升级体验。一种流行的方法是使用 Breathe 和 Exhale 扩展与 Sphinx 一起转换 Doxygen 的输出。这个过程看起来有点复杂,并且会引入很多其他依赖项(如 Python)。我建议在可能的情况下保持工具简单。很可能会发现项目中的每个开发者并不都非常了解 CMake,这样的复杂过程会给他们带来困难。

我们将直接进入这个主题的自动化采用。让我们看看如何通过添加一个新的宏来扩展我们的Doxygen.cmake文件以使用它,如下所示:

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

代码语言:javascript
复制
macro(UseDoxygenAwesomeCss)
  include(FetchContent)
  FetchContent_Declare(doxygen-awesome-css
    GIT_REPOSITORY
      https://github.com/jothepro/doxygen-awesome-css.git
    GIT_TAG
      v1.6.0
  )
  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()

我们已经在书的 previous chapters 中了解到了所有这些命令,但为了完全清晰,让我们重申一下发生了什么,如下所示:

  1. doxygen-awesome-css通过FetchContent模块从 Git 中提取,并作为项目的一部分提供。
  2. 为 Doxygen 配置了额外的选项,如主题的README文件中所建议。
  3. DOXYGEN_HTML_EXTRA_STYLESHEET配置了主题的.css文件的路径。它将被复制到输出目录。

正如您所想象的,最好在Doxygen函数中调用这个宏,在doxygen_add_docs()之前,像这样:

chapter-10/02-doxygen-nice/cmake/Doxygen.cmake

代码语言:javascript
复制
function(Doxygen input output)
  ...
  UseDoxygenAwesomeCss()
  doxygen_add_docs (...)
endfunction()
macro(UseDoxygenAwesomeCss)
  ...
endmacro()

作为提醒,宏中的所有变量都在调用函数的作用域中设置。

现在我们可以享受我们生成的 HTML 文档中的现代风格,并自豪地与世界分享。

摘要

在本章中,我们介绍了如何将 Doxygen,这个文档生成工具,添加到 CMake 项目中,并使其变得优雅。这个过程并不复杂,将大大改善您解决方案中的信息流。花在添加文档上的时间是值得的,特别是如果您发现您或您的团队成员在理解应用程序中的复杂关系时遇到困难。

您可能担心将 Doxygen 添加到没有从开始就使用文档生成的较大项目中会很困难。要求开发者在每个函数中添加注释的工作量可能让开发者感到不堪重负。不要追求立即完整:从小处着手,只需填写您在最新提交中触摸的元素的描述。即使文档不完整,也比完全没有文档好。

请记住,通过生成文档,您将确保其与实际代码的接近性:如果它们都在同一个文件中,同步维护编写解释和逻辑要容易得多。另外,要意识到像大多数程序员一样,您可能是一个非常忙碌的人,并且最终会忘记您项目中的某些小细节。记住:最短的铅笔也比最长的记忆长。对自己好一点——把事情写下来,繁荣昌盛。

在下一章中,我们将学习如何使用 CMake 自动打包和安装我们的项目。

进一步阅读

其他文档生成工具

还有数十种其他工具未在此书中涉及,因为我们专注于由 CMake 支持的项目。然而,其中一些可能更适合您的用例。如果您想冒险,可以访问我在这里列出的两个我觉得有趣的项目的网站:

针对 Clang 编译器,Hyde 生成 Markdown 文件,这些文件可以被如 Jekyll(jekyllrb.com/)等工具消费,Jekyll 是一个由 GitHub 支持的静态页面生成器。

该工具使用libclang编译您的代码,并提供 HTML、Markdown、LaTex 和 man 页面的输出。它大胆地目标是成为下一个 Doxygen。

第十一章:安装和打包

我们的项目已经构建、测试并文档化。现在,终于到了将其发布给用户的时候。本章主要介绍我们将要采取的最后两个步骤:安装和打包。这些都是建立在迄今为止我们所学习的一切之上的高级技术:管理目标和它们的依赖关系、瞬态使用需求、生成器表达式等等。

安装使我们的项目能够在系统范围内被发现和访问。在本章中,我们将介绍如何导出目标,以便另一个项目可以在不安装的情况下使用它们,以及如何安装我们的项目,以便它们可以很容易地被系统上的任何程序使用。特别是,我们将学习如何配置我们的项目,使其可以自动将不同类型的工件放入正确的目录中。为了处理更高级的场景,我们将介绍用于安装文件和目录的低级命令,以及用于执行自定义脚本和 CMake 命令的命令。

接下来,我们将学习如何设置可重用的 CMake 包,以便它们可以被其他项目通过调用find_package()发现。具体来说,我们将解释如何确保目标和它们的定义不会固定在文件系统的特定位置。我们还将讨论如何编写基本和高级的配置文件,以及与包关联的版本文件

然后,为了使事情模块化,我们将简要介绍组件的概念,包括 CMake 包和install()命令。所有这些准备将为本章我们将要涵盖的最后方面铺平道路:使用 CPack 生成各种包管理器在不同操作系统中认识的归档文件、安装程序、捆绑包和包。这些可以用来携带预构建的工件、可执行文件和库。这是最终用户开始使用我们的软件的最简单方法。

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

  • 无需安装导出
  • 在系统上安装项目
  • 创建可重用的包
  • 定义组件
  • 使用 CPack 打包

技术要求

您可以在 GitHub 上找到本章的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter11

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

代码语言:javascript
复制
cmake -B <build tree> -S <source tree>
cmake --build <build tree>

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

无需安装导出

我们如何使项目A的目标对消费项目B可用?通常,我们会使用find_package()命令,但这意味着我们需要创建一个包并在系统上安装它。这种方法很有用,但需要一些工作。有时,我们只是需要一种快速的方法来构建一个项目,并使其目标对其他项目可用。

我们可以通过包含A的主列表文件来节省一些时间:它已经包含了所有的目标定义。不幸的是,它也可能包含很多其他内容:全局配置、需求、具有副作用的 CMake 命令、附加依赖项,以及我们可能不想在B中出现的目标(如单元测试)。所以,我们不要这样做。更好的方法是提供B,并通过include()命令包含:

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
project(B)
include(/path/to/project-A/ProjectATargets.cmake)

执行此操作将为A的所有目标提供正确的属性集定义(如add_library()add_executable()等命令)。

当然,我们不会手动写这样的文件——这不会是一个非常 DRY 的方法。CMake 可以用export()命令为我们生成这些文件,该命令具有以下签名:

代码语言:javascript
复制
export(TARGETS [target1 [target2 [...]]] 
  [NAMESPACE <namespace>] [APPEND] FILE <path>
  [EXPORT_LINK_INTERFACE_LIBRARIES])

我们必须提供所有我们想要导出的目标,在TARGET关键字之后,并提供目标文件名在FILE之后。其他参数是可选的:

  • NAMESPACE建议作为一个提示,说明目标已经从其他项目中导入。
  • APPEND告诉 CMake 在写入文件之前不要擦除文件的内容。
  • EXPORT_LINK_INTERFACE_LIBRARIES将导出目标链接依赖(包括导入和配置特定的变体)。

让我们用我们示例中的 Calc 库来看看这个功能,它提供了两个简单的方法:

chapter-11/01-export/src/include/calc/calc.h

代码语言:javascript
复制
#pragma once
int Sum(int a, int b);
int Multiply(int a, int b);

我们这样声明它的目标:

chapter-11/01-export/src/CMakeLists.txt

代码语言:javascript
复制
add_library(calc STATIC calc.cpp)
target_include_directories(calc INTERFACE include)

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

chapter-11/01-export/CMakeLists.txt(片段)

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
project(ExportCalcCXX)
add_subdirectory(src bin)
set(EXPORT_DIR "${CMAKE_CURRENT_BINARY_DIR}/cmake")
export(TARGETS calc
  FILE "${EXPORT_DIR}/CalcTargets.cmake"
  NAMESPACE Calc::
)
...

在前面的代码中,我们可以看到EXPORT_DIR变量已被设置为构建树中的cmake子目录(按照.cmake文件的约定)。然后,我们导出目标声明文件CalcTargets.cmake,其中有一个名为calc的单一目标,对于将包含此文件的工程项目,它将作为Calc::calc可见。

请注意,这个导出文件还不是包。更重要的是,这个文件中的所有路径都是绝对的,且硬编码到构建树中。换句话说,它们是不可移动的(我们将在理解可移动目标的问题部分讨论这个问题)。

export()命令还有一个更短的版本:

代码语言:javascript
复制
export(EXPORT <export> [NAMESPACE <namespace>] [FILE
  <path>])

然而,它需要一个<export>名称,而不是一个导出的目标列表。这样的<export>实例是由install(TARGETS)定义的目标的命名列表(我们将在安装逻辑目标部分介绍这个命令)。以下是一个演示如何在实际中使用这种简写法的微型示例:

chapter-11/01-export/CMakeLists.txt(续)

代码语言:javascript
复制
...
install(TARGETS calc EXPORT CalcTargets)
export(EXPORT CalcTargets
  FILE "${EXPORT_DIR}/CalcTargets2.cmake"
  NAMESPACE Calc::
)

前面的代码与之前的代码完全一样,但现在,export()install() 命令之间的单个目标列表被共享。

生成导出文件的两个方法会产生相同的结果。它们将包含一些模板代码和几行定义目标的内容。将 /tmp/b 设置为构建树路径时,它们看起来像这样:

/tmp/b/cmake/CalcTargets.cmake(片段)

代码语言:javascript
复制
# Create imported target Calc::calc
add_library(Calc::calc STATIC IMPORTED)
set_target_properties(Calc::calc PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES
  "/root/examples/chapter11/01-export/src/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 "/tmp/b/libcalc.a"
)

通常,我们不会编辑这个文件,甚至不会打开它,但我想要强调这个生成文件中的硬编码路径。以其当前形式,这个包是不可移动的。如果我们想要改变这一点,我们首先需要做一些跳跃。我们将在下一节探讨为什么这很重要。

在系统上安装项目

在第章 1 CMake 初学者中,我们提到 CMake 提供了一个命令行模式,可以在系统上安装构建好的项目:

代码语言:javascript
复制
cmake --install <dir> [<options>]

<dir> 是生成构建树的目标路径(必需)。我们的 <options> 如下:

  • --config <cfg>:这对于多配置生成器,选择构建配置。
  • --component <comp>:这限制了安装到给定组件。
  • --default-directory-permissions <permissions>:这设置了安装目录的默认权限(在 <u=rwx,g=rx,o=rx> 格式中)。
  • --prefix <prefix>:这指定了非默认的安装路径(存储在 CMAKE_INSTALL_PREFIX 变量中)。对于类 Unix 系统,默认为 /usr/local,对于 Windows,默认为 c:/Program Files/${PROJECT_NAME}
  • -v, --verbose:这会使输出详细(这也可以通过设置 VERBOSE 环境变量来实现)。

安装可以由许多步骤组成,但它们的本质是将生成的工件和必要的依赖项复制到系统上的某个目录中。使用 CMake 进行安装不仅为所有 CMake 项目引入了一个方便的标准,而且还做了以下事情:

  • 为根据它们的类型提供特定于平台的安装路径(遵循GNU 编码标准
  • 通过生成目标导出文件,增强安装过程,允许项目目标直接被其他项目重用
  • 通过配置文件创建可发现的包,这些文件封装了目标导出文件以及作者定义的特定于包的 CMake 宏和函数

这些功能非常强大,因为它们节省了很多时间,并简化了以这种方式准备的项目使用。执行基本安装的第一步是将构建好的工件复制到目标目录。

这让我们来到了 install() 命令及其各种模式:

  • install(TARGETS):这会安装输出工件,如库和可执行文件。
  • install(FILES|PROGRAMS):这会安装单个文件并设置它们的权限。
  • install(DIRECTORY): 这会安装整个目录。
  • install(SCRIPT|CODE):在安装期间运行 CMake 脚本或代码段。
  • install(EXPORT):这生成并安装一个目标导出文件。

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

注意

即将推出的 CMake 版本还将支持安装运行时工件和依赖集合,因此请务必查阅最新文档以了解更多信息。

每个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)命令安装。这意味着将构建系统产生的工件复制到适当的目标目录并将它们的文件权限设置为合适。此模式的通用签名如下:

代码语言:javascript
复制
install(TARGETS <target>... [EXPORT <export-name>]
        [<output-artifact-configuration> ...]
        [INCLUDES DESTINATION [<dir> ...]]
        )

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

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

代码语言:javascript
复制
<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的一部分)。
  • PUBLIC_HEADERPRIVATE_HEADERRESOURCE:在目标属性中指定相同名称的文件(在苹果平台上,它们应该设置在FRAMEWORKBUNDLE目标上)。

CMake 文档声称,如果你只配置了一种工件类型(例如,LIBRARY),只有这种类型将被安装。对于 CMake 3.20.0 版本,这并不正确:所有工件都将以默认选项配置的方式安装。这可以通过为所有不需要的工件类型指定<TYPE> EXCLUDE_FROM_ALL来解决。

注意

单个install(TARGETS)命令可以有多个工件配置块。但是,请注意,每次调用您可能只能指定每种类型的一个。也就是说,如果您想要为DebugRelease配置指定不同位置的ARCHIVE工件,那么您必须分别进行两次install(TARGETS ... ARCHIVE)调用。

你也可以省略类型名称,为所有工件指定选项:

代码语言:javascript
复制
install(TARGETS executable, static_lib1
  DESTINATION /tmp
)

安装过程将会对所有这些目标生成的文件进行,不论它们的类型是什么。

另外,你并不总是需要为DESTINATION提供安装目录。让我们看看原因。

为不同平台确定正确的目的地

目标路径的公式如下所示:

代码语言:javascript
复制
${CMAKE_INSTALL_PREFIX} + ${DESTINATION}

如果未提供DESTINATION,CMake 将使用每个类型的内置默认值:

虽然默认路径有时很有用,但它们并不适用于每种情况。例如,默认情况下,CMake 会“猜测”库的DESTINATION应该是lib。所有类 Unix 系统上的库的完整路径将被计算为/usr/local/lib,而在 Windows 上则是类似于C:\Program Files (x86)\<项目名称>\lib。这对于支持多架构的 Debian 来说不会是一个很好的选择,当INSTALL_PREFIX/usr时,它需要一个特定架构(例如i386-linux-gnu)的路径。为每个平台确定正确的路径是类 Unix 系统的一个常见问题。为了做到正确,我们需要遵循GNU 编码标准(在进一步阅读部分可以找到这个链接)。

在采用“猜测”之前,CMake 将检查是否为这种工件类型设置了CMAKE_INSTALL_<DIR>DIR变量,并使用从此处开始的路径。我们需要的是一个算法,能够检测平台并填充安装目录变量以提供适当的路径。CMake 通过提供GNUInstallDirs实用模块简化了此操作,该模块处理大多数平台并相应地设置安装目录变量。在调用任何install()命令之前只需include()它,然后你就可以正常使用了。

需要自定义配置的用户可以通过命令行使用-DCMAKE_INSTALL_BINDIR=/path/in/the/system提供安装目录变量。

然而,安装库的公共头文件可能会有些棘手。让我们来看看原因。

处理公共头文件

install(TARGETS)文档建议我们在库目标的PUBLIC_HEADER属性中(用分号分隔)指定公共头文件:

chapter-11/02-install-targets/src/CMakeLists.txt

代码语言:javascript
复制
add_library(calc STATIC calc.cpp)
target_include_directories(calc INTERFACE include)
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER src/include/calc/calc.h
)

如果我们使用 Unix 的默认“猜测”方式,文件最终会出现在/usr/local/include。这并不一定是最佳实践。理想情况下,我们希望能够将这些公共头文件放在一个能表明它们来源并引入命名空间的目录中;例如,/usr/local/include/calc。这将允许我们在这个系统上的所有项目中使用它们,如下所示:

代码语言:javascript
复制
#include <calc/calc.h>

大多数预处理器将尖括号中的指令识别为扫描标准系统目录的请求。这就是我们之前提到的GNUInstallDirs模块的作用。它为install()命令定义了安装变量,尽管我们也可以显式使用它们。在这种情况下,我们想要在公共头文件的目的地calc前加上CMAKE_INSTALL_INCLUDEDIR

chapter-11/02-install-targets/CMakeLists.txt

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
project(InstallTargets CXX) 
add_subdirectory(src bin)
include(GNUInstallDirs)
install(TARGETS calc
  ARCHIVE
  PUBLIC_HEADER
  DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
)

在从src包含列表文件,定义了我们的calc目标之后,我们必须配置静态库及其公共头文件的安装。我们已经包含了GNUInstallDirs模块,并明确指定了DESTINATIONPUBLIC_HEADERS。以安装模式运行cmake将按预期工作:

代码语言:javascript
复制
# 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/calc.h

这种方式对于这个基本案例来说很好,但有一个轻微的缺点:以这种方式指定的文件不保留它们的目录结构。它们都将被安装在同一个目的地,即使它们嵌套在不同的基本目录中。

计划在新版本中(CMake 3.23.0)使用FILE_SET关键字更好地管理头文件:

代码语言:javascript
复制
target_sources(<target>
  [<PUBLIC|PRIVATE|INTERFACE>
   [FILE_SET <name> TYPE <type> [BASE_DIR <dir>] FILES]
   <files>...
  ]...
)

有关官方论坛上的讨论,请参阅进一步阅读部分中的链接。在发布该选项之前,我们可以使用此机制与PRIVATE_HEADERRESOURCE工件类型。但我们如何指定更复杂的安装目录结构呢?

低级安装

现代 CMake 正在逐步放弃直接操作文件的概念。理想情况下,我们总是将它们添加到一个逻辑目标中,并使用这个更高层次的抽象来表示所有底层资产:源文件、头文件、资源、配置等等。主要优点是代码的简洁性:通常,我们添加一个文件到目标时不需要更改多于一行代码。

不幸的是,将每个已安装的文件添加到目标上并不总是可能的或方便的。对于这种情况,有三种选择可用:install(FILES)install(PROGRAMS)install(DIRECTORY)

使用 install(FILES|PROGRAMS) 安装文件集

FILESPROGRAMS 模式非常相似。它们可以用来安装公共头文件、文档、shell 脚本、配置文件,以及所有种类的资产,包括图像、音频文件和将在运行时使用的数据集。

以下是命令签名:

代码语言:javascript
复制
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 并不声称选择性地将要安装的文件子集安装到指定位置。然而,计算安装路径遵循相同的模式(+ 符号表示平台特定的路径分隔符):

代码语言:javascript
复制
${CMAKE_INSTALL_PREFIX} + ${DESTINATION}

同样,每个 TYPE 都会有内置猜测:

这里的行为遵循在 为不同平台计算正确的目的地 部分描述的相同原则:如果此 TYPE 文件没有设置安装目录变量,CMake 将退回到默认的“猜测”路径。再次,我们可以使用 GNUInstallDirs 模块以提高可移植性。

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

  • $LOCALSTATECMAKE_INSTALL_LOCALSTATEDIR 或默认为 var
  • $DATAROOTCMAKE_INSTALL_DATAROOTDIR 或默认为 share

install(TARGETS)类似,如果包含了GNUInstallDirs模块,它将提供特定于平台的安装目录变量。让我们来看一个例子:

chapter-11/03-install-files/CMakeLists.txt

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
project(InstallFiles CXX)
include(GNUInstallDirs)
install(FILES
  src/include/calc/calc.h
  src/include/calc/nested/calc_extended.h
  DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
)

在这种情况下,CMake 将在系统级include 目录的项目特定子目录中安装两个头文件库——即calc.hnested/calc_extended.h

注意

GNUInstallDirs源文件中我们知道CMAKE_INSTALL_INCLUDEDIR对所有支持的平台都包含相同的路径。然而,为了可读性和与更动态的变量保持一致,仍然建议使用它。例如,CMAKE_INSTALL_LIBDIR将根据架构和发行版而变化——liblib64lib/<multiarch-tuple>

CMake 3.20 还向install(FILES|PROGRAMS)命令添加了相当有用的RENAME关键字,后跟新文件名(仅当files...列表包含单个文件时才有效)。

本节中的示例展示了安装文件到适当目录是多么简单。不过有一个问题——看看安装输出:

代码语言:javascript
复制
# cmake -S <source-tree> -B <build-tree>
# cmake --build <build-tree>
# cmake --install <build-tree>
-- Install configuration: ""
-- Installing: /usr/local/include/calc/calc.h
-- Installing: /usr/local/include/calc/calc_extended.h

两个文件都被安装在同一个目录中,无论嵌套与否。有时,这可能不是我们想要的。在下一节中,我们将学习如何处理这种情况。

处理整个目录

如果你不想将单个文件添加到安装命令中,你可以选择更广泛的方法,而是处理整个目录。install(DIRECTORY)模式就是为了这个目的而创建的。它将列表中的目录原样复制到所选的目标位置。让我们看看它看起来像什么:

代码语言:javascript
复制
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|PROGRAMS)重复的。它们的工作方式是一样的。有一个值得注意的细节:如果在与DIRECTORY关键字提供的路径不以/结尾,路径的最后目录将被添加到目的地,如下所示:

代码语言:javascript
复制
install(DIRECTORY a DESTINATION /x)

这将创建一个名为/x/a的目录并将a的内容复制到其中。现在,看看以下代码:

代码语言:javascript
复制
install(DIRECTORY a/ DESTINATION /x)

这将直接将a的内容复制到/x

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

  • 静默输出
  • 扩展权限控制
  • 文件/目录过滤

让我们先从静默输出选项MESSAGE_NEVER开始。它禁用了安装过程中的输出诊断。当我们有很多要安装的目录中的文件,打印它们所有人会太吵时,这个功能非常有用。

接下来是权限。这个install()模式支持设置权限的三个选项:

  • USE_SOURCE_PERMISSIONS按预期工作——它设置了遵循原始文件权限的安装文件权限。只有当FILE_PERMISSIONS未设置时,这才会起作用。
  • FILE_PERMISSIONS也非常容易解释。它允许我们指定想要设置在安装的文件和目录上的权限。默认的权限有OWNER_WRITEOWNER_READGROUP_READWORLD_READ
  • DIRECTORY_PERMISSIONS与前面选项的工作方式类似,但它将为所有用户设置额外的EXECUTE权限(这是因为 Unix-like 系统将目录上的EXECUTE理解为列出其内容的权限)。

请注意,CMake 将在不支持它们的平台上忽略权限选项。通过在每一个过滤表达式之后添加PERMISSIONS关键字,可以实现更多的权限控制:任何被它匹配的文件或目录都将接收到在此关键字之后指定的权限。

让我们来谈谈过滤器或“通配符”表达式。你可以设置多个过滤器,控制从源目录安装哪些文件/目录。它们有以下语法:

代码语言:javascript
复制
PATTERN <p> | REGEX <r> [EXCLUDE] [PERMISSIONS
  <permissions>]

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

  • 使用PATTERN,这是更简单的选项,我们可以提供一个带有?占位符(匹配任何字符)和通配符,*(匹配任何字符串)的模式。只有以<pattern>结尾的路径才会被匹配。
  • 另一方面,REGEX选项更高级——它支持正则表达式。它还允许我们匹配路径的任何部分(我们仍然可以使用^$锚点来表示路径的开始和结束)。

可选地,我们可以在第一个过滤器之前设置FILES_MATCHING关键字,这将指定任何过滤器都将应用于文件,而不是目录。

记住两个注意事项:

  • FILES_MATCHING需要一个包含性过滤器,也就是说,你可以排除一些文件,但除非你也添加一个表达式来包含其中的一些,否则没有文件会被复制。然而,无论过滤与否,所有目录都会被创建。
  • 所有子目录默认都是被过滤进去的;你只能进行排除。

对于每种过滤方法,我们可以选择EXCLUDE匹配的路径(这只有在没有使用FILES_MATCHING时才有效)。

我们可以通过在任何一个过滤器之后添加PERMISSIONS关键字和一个所需权限的列表,为所有匹配的路径设置特定的权限。让我们试试看。在这个例子中,我们将以三种不同的方式安装三个目录。我们将有一些在运行时使用的静态数据文件:

代码语言:javascript
复制
data
- data.csv

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

代码语言:javascript
复制
src
- include
  - calc
    - calc.h
    - ignored
      - empty.file
    - nested
      - calc_extended.h

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

代码语言:javascript
复制
etc
- calc
  - nested.conf
- sample.conf

要安装具有静态数据文件的目录,我们将使用install(DIRECTORY)命令的最基本形式开始我们的项目:

chapter-11/04-install-directories/CMakeLists.txt(片段)

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
project(InstallDirectories CXX)
install(DIRECTORY data/ DESTINATION share/calc)
...

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

第二个案例正好相反:我们不添加尾随的/,因为目录应该被包含。这是因为我们依赖于GNUInstallDirs提供的特定于系统的INCLUDE文件类型路径(注意INCLUDEEXCLUDE关键词代表无关的概念):

第十一章/04-install-directories/CMakeLists.txt(片段)

代码语言:javascript
复制
...
include(GNUInstallDirs)
install(DIRECTORY src/include/calc TYPE INCLUDE
  PATTERN "ignored" EXCLUDE
  PATTERN "calc_extended.h" EXCLUDE
)
...

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

第三个案例安装了一些默认的配置文件并设置了它们的权限:

第十一章/04-install-directories/CMakeLists.txt(片段)

代码语言:javascript
复制
...
install(DIRECTORY etc/ TYPE SYSCONF
  DIRECTORY_PERMISSIONS 
    OWNER_READ OWNER_WRITE OWNER_EXECUTE
  PATTERN "nested.conf"
    PERMISSIONS OWNER_READ OWNER_WRITE
)

再次说明,我们不关心从源路径中添加etcSYSCONF类型的路径(这已经由包含GNUInstallDirs提供),因为我们会把文件放在/etc/etc中。此外,我们必须指定两个权限规则:

  • 子目录只能由所有者编辑和列出。
  • nested.conf结尾的文件只能由所有者编辑。

安装目录处理了很多不同的用例,但对于真正高级的安装场景(如安装后配置),我们可能需要使用外部工具。我们应该如何做到这一点?

在安装过程中调用脚本

如果你曾经在类 Unix 系统上安装过一个共享库,你可能记得在可以使用它之前,你可能需要告诉动态链接器扫描可信目录并调用ldconfig(在进一步阅读部分可以看到参考文献)来构建其缓存。如果你想要使你的安装完全自动化,CMake 提供了install(SCRIPT|CODE)命令来支持这类情况。以下是完整命令的签名:

代码语言:javascript
复制
install([[SCRIPT <file>] [CODE <code>]]
        [ALL_COMPONENTS | COMPONENT <component>]
        [EXCLUDE_FROM_ALL] [...])

你应该选择SCRIPTCODE模式并提供适当的参数——要么是一个运行 CMake 脚本的路径,要么是在安装过程中执行的 CMake 代码片段。为了了解这是如何工作的,我们将修改02-install-targets示例以构建一个共享库:

第十一章/05-install-code/src/CMakeLists.txt

代码语言:javascript
复制
add_library(calc SHARED calc.cpp)
target_include_directories(calc INTERFACE include)
set_target_properties(calc PROPERTIES
  PUBLIC_HEADER src/include/calc/calc.h
)

我们需要在安装脚本中将 artifact 类型从 ARCHIVE 更改为 LIBRARY 以复制文件。然后,我们可以在之后添加运行 ldconfig 的逻辑:

第十一章/05-install-code/CMakeLists.txt(片段)

代码语言:javascript
复制
...
install(TARGETS calc LIBRARY
  PUBLIC_HEADER
  DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
 )
if (UNIX)
  install(CODE "execute_process(COMMAND ldconfig)")
endif()

if()条件检查命令是否与操作系统匹配(在 Windows 或 macOS 上执行ldconfig是不正确的)。当然,提供的代码必须具有有效的 CMake 语法才能工作(不过,在初始构建期间不会进行检查;任何失败都会在安装时显现)。

运行安装命令后,我们可以通过打印缓存中的库来确认它是否工作:

代码语言:javascript
复制
# 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/calc.h
# ldconfig -p | grep libcalc
        libcalc.so (libc6,x86-64) => /usr/local/lib/libcalc.so

这两种模式都支持生成表达式,如果你需要的话。因此,这个命令和 CMake 本身一样多功能,可以用于所有 sorts of things:为用户打印消息,验证安装是否成功,进行详尽的配置,文件签名——你能想到的都有。

既然我们已经知道了将一组文件安装到系统上的所有不同方法,那么接下来让我们学习如何将它们转换为其他 CMake 项目可以原生使用的包。

创建可重用包

在之前的章节中,我们大量使用了find_package()。我们看到了它有多方便,以及它是如何简化整个过程的。为了使我们的项目通过这个命令可用,我们需要完成几步,以便 CMake 可以将我们的项目视为一个连贯的包:

  • 使我们的目标可移动。
  • 将目标导出文件安装到标准位置。
  • 为包创建配置文件和版本文件

让我们从开头说起:为什么目标需要可移动,我们又该如何实现?

理解可移动目标的问题

安装解决了许多问题,但不幸的是,它也引入了一些复杂性:不仅CMAKE_INSTALL_PREFIX是平台特定的,而且它还可以在安装阶段由用户使用--prefix选项进行设置。然而,目标导出文件是在安装之前生成的,在构建阶段,此时我们不知道安装的工件将去哪里。请看下面的代码:

chapter-11/01-export/src/CMakeLists.txt

代码语言:javascript
复制
add_library(calc STATIC calc.cpp)
target_include_directories(calc INTERFACE include)

在这个例子中,我们特别将包含目录添加到calc包含目录中。由于这是一个相对路径,CMake 生成的目标将隐式地将这个路径与CMAKE_CURRENT_SOURCE_DIR变量的内容相结合,该变量指向这个列表文件所在的目录。

然而,这还不够。已安装的项目不应再需要源代码或构建树中的文件。一切(包括库头文件)都被复制到一个共享位置,如 Linux 上的/usr/lib/calc/。由于这个片段中定义的目标的包含目录路径仍然指向其源树,所以我们不能在另一个项目中使用这个目标。

CMake 用两个生成表达式解决了这个问题,这些表达式将根据上下文过滤出表达式:

  • $<BUILD_INTERFACE>:这包括了常规构建的内容,但在安装时将其排除。
  • $<INSTALL_INTERFACE>:这包括了安装的内容,但排除了常规构建。

下面的代码展示了你如何实际上使用它们:

chapter-11/06-install-export/src/CMakeLists.txt

代码语言:javascript
复制
add_library(calc STATIC calc.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 src/include/calc/calc.h
)

对于常规构建,calc目标属性的INTERFACE_INCLUDE_DIRECTORIES值将像这样扩展:

代码语言:javascript
复制
"/root/examples/chapter-11/05-package/src/include" ""

空的双引号意味着在INSTALL_INTERFACE中提供的值被排除,并被评估为空字符串。另一方面,当我们安装时,该值将像这样扩展:

代码语言:javascript
复制
"" "/usr/lib/calc/include"

这次,在BUILD_INTERFACE生成表达式中提供的值被评估为空字符串,我们留下了另一个生成表达式的值。

关于CMAKE_INSTALL_PREFIX再说一句:这个变量不应该用作目标中指定路径的组件。它将在构建阶段进行评估,使路径成为绝对路径,并且不一定与在安装阶段提供的路径相同(因为用户可能使用--prefix选项)。相反,请使用$<INSTALL_PREFIX>生成表达式:

代码语言:javascript
复制
target_include_directories(my_target PUBLIC
  $<INSTALL_INTERFACE:$<INSTALL_PREFIX>/include/MyTarget>
)

或者,更好的做法是使用相对路径(它们会前缀正确的安装前缀):

代码语言:javascript
复制
target_include_directories(my_target PUBLIC
  $<INSTALL_INTERFACE:include/MyTarget>
)

请参阅官方文档以获取更多示例和信息(可以在进阶阅读部分找到此链接)。

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

安装目标导出文件

我们在无需安装导出部分稍微讨论了目标导出文件。打算用于安装的目标导出文件非常相似,创建它们的命令签名也是如此:

代码语言:javascript
复制
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生成表达式中评估的目标路径,而不是BUILD_INTERFACE,就像export(EXPORT)一样。

在此示例中,我们将为chapter-11/06-install-export/src/CMakeLists.txt中的目标生成并安装目标导出文件。为此,我们必须在顶层列表文件中调用install(EXPORT)

chapter-11/06-install-export/CMakeLists.txt

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
project(InstallExport CXX)
include(GNUInstallDirs) # so it's available in ./src/
add_subdirectory(src bin)
install(TARGETS calc EXPORT CalcTargets ARCHIVE
  PUBLIC_HEADER DESTINATION
    ${CMAKE_INSTALL_INCLUDEDIR}/calc
)
install(EXPORT CalcTargets 
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
  NAMESPACE Calc::
)

再次注意我们如何在install(EXPORT)中引用CalcTargets导出名称。

在构建树中运行cmake --install将导致导出文件在指定目的地生成:

代码语言:javascript
复制
...
-- 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()来消耗已安装的目标。然而,如果需要,您可以直接包含导出文件。那么,我们如何定义可以被其他项目消耗的包呢?让我们找出答案!

编写基本配置文件

一个完整的包定义包括目标导出文件、包的config 文件以及包的版本文件,但技术上来说,为了使find_package()工作只需要一个 config-file。它被视为一个包定义,负责提供任何包函数和宏,检查要求,查找依赖项,并包含目标导出文件。

如我们之前提到的,用户可以使用以下命令将您的包安装到他们系统上的任何位置:

代码语言:javascript
复制
cmake --install <build tree> --prefix=<installation path> 

这个前缀决定了安装文件将被复制到的位置。为了支持这一点,您至少必须确保以下几点:

  • 目标属性中的路径可以移动(如理解可移动目标的问题部分所述)。
  • 您 config-file 中使用的路径相对于它本身是相对的。

为了使用已安装在非默认位置的这类包,消费项目在配置阶段需要通过CMAKE_PREFIX_PATH变量提供<安装路径>。我们可以用以下命令来实现:

代码语言:javascript
复制
cmake -B <build tree> -DCMAKE_PREFIX_PATH=<installation path>

find_package()命令将按照文档中概述的路径(进一步阅读部分的链接)以平台特定的方式扫描。在 Windows 和类 Unix 系统中检查的一个模式如下:

代码语言:javascript
复制
<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/(cmake|CMake)

这告诉我们,将 config-file 安装在如lib/calc/cmake的路径上应该完全没有问题。另外,重要的是要强调 config-files 必须命名为<包名>-config.cmake<包名>Config.cmake才能被找到。

让我们将 config-file 的安装添加到06-install-export示例中:

chapter-11/07-config-file/CMakeLists.txt(片段)

代码语言:javascript
复制
...
install(EXPORT CalcTargets 
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
  NAMESPACE Calc::
)
install(FILES "CalcConfig.cmake"
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/calc/cmake
)

此命令将从同一源目录(CMAKE_INSTALL_LIBDIR将被评估为平台正确的lib路径)安装CalcConfig.cmake

我们能够提供的最基本的 config-file 由一条包含目标导出文件的直线组成:

chapter-11/07-config-file/CalcConfig.cmake

代码语言:javascript
复制
include("${CMAKE_CURRENT_LIST_DIR}/CalcTargets.cmake")

CMAKE_CURRENT_LIST_DIR变量指的是 config-file 所在的目录。因为在我们示例中CalcConfig.cmakeCalcTargets.cmake安装在同一个目录中(如install(EXPORT)所设置),目标导出文件将被正确包含。

为了确保我们的包可以被使用,我们将创建一个简单的项目,仅包含一个 listfile:

chapter-11/08-find-package/CMakeLists.txt

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
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
)

为了在实际中测试这个,我们可以将07-config-file示例构建并安装到一个目录中,然后在使用DCMAKE_PREFIX_PATH参数引用它的情况下构建08-find-package,如下所示:

代码语言:javascript
复制
# cmake -S <source-tree-of-07> -B <build-tree-of-07>
# cmake --build <build-tree-of-07>
# cmake --install <build-tree-of-07>
# cmake -S <source-tree-of-08> -B <build-tree-of-08>  
  -DCMAKE_PREFIX_PATH=<build-tree-of-07>

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

代码语言:javascript
复制
CMAKE_PREFIX_PATH: <build-tree-of-07>
CALC_FOUND: 1
--
 Properties for TARGET Calc::calc:
   Calc::calc.IMPORTED_CONFIGURATIONS = "NOCONFIG"
   Calc::calc.INTERFACE_INCLUDE_DIRECTORIES = "<build-tree-of-07>/include"
-- Configuring done
-- Generating done
-- Build files have been written to: <build-tree-of-08>

找到了CalcTargets.cmake文件,并正确地包含了它,*include 目录*的路径设置为遵循所选的前缀。这对于一个非常基础的打包情况解决了打包问题。现在,让我们学习如何处理更高级的场景。

创建高级配置文件

如果你管理的不仅仅是单个目标导出文件,那么在配置文件中包含几个宏可能是有用的。CMakePackageConfigHelpers工具模块让我们可以使用configure_package_config_file()命令。使用它时,我们需要提供一个模板文件,这个文件会被 CMake 变量插值,以生成一个带有两个内嵌宏定义的配置文件:

  • set_and_check(<variable> <path>): 这个命令类似于set(),但它会检查<path>是否存在,如果不存在则会导致FATAL_ERROR。建议在配置文件中使用它,以便尽早发现错误的路径。
  • check_required_components(<PackageName>): 这句话添加到配置文件的最后,将验证我们包中由用户在find_package(<package> REQUIRED <component>)中 required 的所有组件是否已经被找到。这是通过检查<package>_<component>_FOUND变量是否为真来完成的。

可以在生成配置文件的同时为更复杂的目录树准备安装阶段的路径。看看以下的签名:

代码语言:javascript
复制
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_DESTINATION的基路径来指示INSTALL_DESTINATION是相对于INSTALL_PREFIX的。

NO_SET_AND_CHECK_MACRONO_CHECK_REQUIRED_COMPONENTS_MACRO告诉 CMake 不要在生成的配置文件中添加这些宏定义。让我们在实践中看看这个生成过程。再次,我们将扩展06-install-export示例:

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

代码语言:javascript
复制
...
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. 设置一个变量,用于生成可移动路径。
  3. 使用位于源树中的CalcConfig.cmake.in模板生成构建树中的CalcConfig.cmake配置文件。最后,提供一个名为LIB_INSTALL_DIR的变量,它将被计算为相对于INSTALL_DESTINATION${CMAKE_INSTALL_LIBDIR}/calc/cmake的相对路径。
  4. 将构建树生成的配置文件传递给install(FILE)

请注意,install(FILE)中的DESTINATIONinstall(FILES)中的INSTALL_DESTINATION是相同的,这样就可以正确计算相对路径。

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

chapter-11/09-advanced-config/CalcConfig.cmake.in

代码语言:javascript
复制
@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命令的定义,以便它们可以消耗项目。您可能会认出这些@PLACEHOLDERS@来自我们的普通configure_file()——它们的工作方式与 C++文件中的相同。

接下来,我们将(CALC_LIB_DIR)设置为通过@PACKAGE_LIB_INSTALL_DIR@占位符传递的路径。它将包含列表文件中提供的$LIB_INSTALL_DIR的路径,但它将相对于安装路径进行计算。然后,我们使用它来包含目标导出文件。

最后,check_required_components()验证是否找到了包消费者所需的所有组件。即使包没有任何组件,建议添加此命令,以验证用户是否无意中添加了不受支持的要求。

通过这种方式生成的CalcConfig.cmake配置文件,看起来像这样:

代码语言:javascript
复制
#### 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
macro(check_required_components _NAME) # ... removed for
  brevity
###########################################################################
set_and_check(CALC_LIB_DIR
  "${PACKAGE_PREFIX_DIR}/lib/calc")
include("${CALC_LIB_DIR}/cmake/CalcTargets.cmake")
check_required_components(Calc)

以下图表展示了各种包文件之间的关系,从而提供了这种关系的视角:

图 11.1 – 高级包的文件结构
图 11.1 – 高级包的文件结构

图 11.1 – 高级包的文件结构

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

如果您决定向消耗项目暴露任何宏或函数,建议您将它们的定义放在一个单独的文件中,然后您可以从包的配置文件中include()它。

有趣的是,CMakePackageConfigHelpers也提供了一个辅助命令来生成包的版本文件。我们来了解一下。

生成包版本文件

随着您的包的增长,它将逐渐增加新功能,旧的将被标记为弃用,最终被移除。对于使用您的包的开发人员来说,保持这些修改的变更日志是很重要的。当需要特定功能时,开发者可以找到支持它的最低版本并将其用作find_package()的参数,如下所示:

代码语言:javascript
复制
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()生成包的版本文件

代码语言:javascript
复制
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<package>将与find_package(<package> 1.2.8)相匹配。

通常,所有包必须为与消费项目相同的架构构建(执行精确检查)。然而,对于不编译任何内容的包(仅头文件库、宏包等),您可以使用ARCH_INDEPENDENT关键词跳过此检查。

现在,是时候来一个实际例子了。以下代码展示了如何为我们在06-install-export示例中开始的项目提供版本文件

chapter-11/10-version-file/CMakeLists.txt(片段)

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
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关键词来使用显式、完整语法的语法。

在包含助手工具模块后,我们调用生成命令并将文件写入符合find_package()所需模式的构建树中。在这里,我们故意省略了VERSION关键词,以便从PROJECT_VERSION变量中读取版本。我们还标记我们的包为与COMPATIBILITY AnyNewerVersion完全向后兼容。之后,我们将包版本文件安装到与CalcConfig.cmake相同的目的地。就这样——我们的包已经完全配置好了。

在下一节中,我们将学习什么是组件以及如何将它们与包一起使用。

定义组件

我们将先讨论包组件,通过澄清一些关于find_package()术语可能的混淆:

代码语言:javascript
复制
find_package(<PackageName> [version] [EXACT] [QUIET]
[MODULE]
  [REQUIRED] [[COMPONENTS] [components...]]
             [OPTIONAL_COMPONENTS components...]
             [NO_POLICY_SCOPE])

这里提到的组件不应与在install()命令中使用的COMPONENT关键字混淆。它们是两个不同的概念,尽管它们共享相同的名字,但必须分别理解。我们将在下面的子节中更详细地讨论这一点。

如何在find_package()中使用组件

当我们调用find_package()并带有COMPONENTSOPTIONAL_COMPONENTS列表时,我们告诉 CMake 我们只对提供这些组件的包感兴趣。然而,重要的是要意识到,是否有必要检查这一要求取决于包本身,如果包的供应商没有在创建高级 config 文件小节中提到的 config 文件中添加必要的检查,那么什么也不会发生。

请求的组件将通过<package>_FIND_COMPONENTS变量传递给 config 文件(可选和非可选都有)。此外,对于每个非可选组件,将设置一个<package>_FIND_REQUIRED_<component>。作为包的作者,我们可以编写一个宏来扫描这个列表并检查我们是否提供了所有必需的组件。但我们不需要这样做——这正是check_required_components()所做的。要使用它,config 文件应在找到必要的组件时设置<Package>_<Component>_FOUND变量。文件末尾的宏将检查是否设置了所有必需的变量。

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

一些生成的工件可能不需要在所有场景中都进行安装。例如,一个项目可能为了开发目的安装静态库和公共头文件,但默认情况下,它只需安装共享库以供运行时使用。为了实现这种行为的双重性,我们可以使用在所有install()命令中可用的COMPONENT关键字来将工件分组,用户如果对限制安装到特定组件感兴趣,可以通过运行以下命令(组件名称区分大小写)来显式请求:

代码语言:javascript
复制
cmake --install <build tree> --component=<component name>

COMPONENT关键字标记一个工件并不意味着它不会被默认安装。为了防止这种情况发生,我们必须添加EXCLUDE_FROM_ALL关键字。

让我们通过一个代码示例来探索这些组件:

chapter-11/11-components/CMakeLists.txt(片段)

代码语言:javascript
复制
...
install(TARGETS calc EXPORT CalcTargets
  ARCHIVE
    COMPONENT lib
  PUBLIC_HEADER
    DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/calc
    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
)
...

这些安装命令定义了以下组件:

  • lib:这包含静态库和目标导出文件。它默认安装。
  • headers:包含公共头文件。它默认安装。
  • extra:通过打印一条消息执行一段代码。它不会被默认安装。

让我们重申这一点:

  • 不带--component参数的cmake --install将安装libheaders组件。
  • cmake --install --component headers将只安装公共头文件。
  • cmake --install --component extra将打印一条在其他情况下无法访问的消息(因为EXCLUDE_FROM_ALL关键字)。

如果安装的工件没有指定COMPONENT关键字,它将从CMAKE_INSTALL_DEFAULT_COMPONENT_NAME变量中获得默认值Unspecified

注意

由于没有简单的方法从cmake命令行列出所有可用的组件,您的包的用户将受益于详尽的文档,列出您的包的组件。也许在INSTALL文件中提到这一点是个好主意。

如果调用cmake时为不存在的一个组件提供了--component参数,那么该命令将成功执行,不带任何警告或错误。它只是不会安装任何东西。

将我们的安装划分为组件使得用户能够挑选他们想要安装的内容。我们 mostly 讨论了将安装文件划分为组件,但还有些程序步骤,比如install(SCRIPT|CODE)或为共享库创建符号链接。

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

您的安装目标平台可能使用符号链接来帮助链接器发现当前安装的共享库版本。在创建一个指向lib<name>.so.1文件的lib<name>.so符号链接之后,可以通过向链接器传递-l<name>参数来链接这个库。当需要时,此类符号链接由 CMake 的install(TARGETS <target> LIBRARY)块处理。

然而,我们可能决定将这个步骤移到另一个install()命令中,通过在这个块中添加NAMELINK_SKIP来实现:

代码语言:javascript
复制
install(TARGETS <target> LIBRARY COMPONENT cmp
  NAMELINK_SKIP)

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

代码语言:javascript
复制
install(TARGETS <target> LIBRARY COMPONENT lnk
  NAMELINK_ONLY)

同样,可以使用NAMELINK_COMPONENT关键字实现:

代码语言:javascript
复制
install(TARGETS <target> LIBRARY 
  COMPONENT cmp NAMELINK_COMPONENT lnk)

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

使用 CPack 进行打包

从源代码构建项目有其优点,但它可能需要很长时间并引入很多复杂性。这并不是终端用户所期望的最佳体验,尤其是如果他们自己不是开发者的话。对于终端用户来说,一种更加便捷的软件分发方式是使用包含编译工件和其他运行时所需静态文件的二进制包。CMake 通过名为cpack的命令行工具支持生成多种此类包。

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

这些生成器中的大多数都有广泛的配置。深入了解所有它们的细节超出了本书的范围,所以一定要查看完整的文档,您可以在“进一步阅读”部分找到。相反,我们将关注一般使用案例。

注意

包生成器不应该与构建系统生成器(Unix Makefiles,Visual Studio 等)混淆。

要使用 CPack,我们需要正确配置项目的安装,并使用必要的install()命令构建项目。在我们构建树中生成的cmake_install.cmake将用于cpack根据配置文件(CPackConfig.cmake)准备二进制包。虽然可以手动创建此文件,但使用include(CPack)更容易地在项目的列表文件中包含实用模块。它将在项目的构建树中生成配置,并在需要的地方提供所有默认值。

让我们看看如何扩展示例11-components,使其可以与 CPack 一起工作:

chapter-11/12-cpack/CMakeLists.txt (片段)

代码语言:javascript
复制
cmake_minimum_required(VERSION 3.20.0)
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

最后一个值将用于生成输出包。其结构如下:

代码语言:javascript
复制
$CPACK_PACKAGE_NAME-$CPACK_PACKAGE_VERSION-$CPACK_SYSTEM_NAME

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

在我们构建项目之后,我们可以在构建树中运行cpack二进制文件来生成实际的包:

代码语言:javascript
复制
cpack [<options>]

从技术上讲,CPack 能够读取放置在当前工作目录中的所有配置文件选项,但你也可以选择从命令行覆盖这些设置:

  • -G <generators>:这是一个由分号分隔的包生成器列表。默认值可以在CPackConfig.cmake中的CPACK_GENERATOR变量中指定。
  • -C <configs>:这是一个由分号分隔的构建配置(调试、发布)列表,用于生成包(对于多配置构建系统生成器,这是必需的)。
  • -D <var>=<value>: 这个选项会覆盖CPackConfig.cmake文件中设置的<var>变量,以<value>为准。
  • --config <config-file>: 这是你应该使用的配置文件,而不是默认的CPackConfig.cmake
  • --verbose, -V: 提供详细输出。
  • -P <packageName>: 覆盖包名称。
  • -R <packageVersion>: 覆盖包版本。
  • --vendor <vendorName>: 覆盖包供应商。
  • -B <packageDirectory>: 为cpack指定输出目录(默认情况下,这将是目前的工作目录)。

让我们尝试为我们的12-cpack输出生成包。我们将使用 ZIP、7Z 和 Debian 包生成器:

代码语言:javascript
复制
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 如何处理各种工件类型的不同目的地以及如何处理 somewhat special 的公共头文件。为了在较低级别管理这些安装步骤,我们讨论了install()命令的其他模式,包括安装文件、程序和目录以及在安装过程中调用脚本。

在解释了如何编码安装步骤之后,我们学习了 CMake 的可重用包。具体来说,我们学习了如何使项目中的目标可移动,以便包可以在用户希望安装的任何地方进行安装。然后,我们专注于形成一个完全定义的包,它可以通过find_package()被其他项目消费,这需要准备目标导出文件、配置文件以及版本文件

认识到不同用户可能需要我们包的不同部分,我们发现了如何将工件和动作分组在安装组件中,以及它们与 CMake 包组件的区别。

最后,我们提到了 CPack,并学习了如何准备基本的二进制包,以使用预编译的形式分发我们的软件。

要完全掌握安装和打包的所有细节和复杂性还有很长的路要走,但本章为我们提供了坚实的基础,以处理最常见的情况并自信地进一步探索它们。

在下一章中,我们将把我们到目前为止所学的所有内容付诸实践,通过创建一个连贯、专业的项目。

进一步阅读

要了解更多关于本章涵盖的主题,请查看以下资源:

首先,我们学习了如何从项目中导出 CMake 目标,以便它们可以在不安装它们的情况下被其他项目消费。然后,我们学习了如何安装已经为此目的配置好的项目。

在那之后,我们开始探索安装的基础知识,从最重要的主题开始:安装 CMake 目标。我们现在知道 CMake 如何处理各种工件类型的不同目的地以及如何处理 somewhat special 的公共头文件。为了在较低级别管理这些安装步骤,我们讨论了install()命令的其他模式,包括安装文件、程序和目录以及在安装过程中调用脚本。

在解释了如何编码安装步骤之后,我们学习了 CMake 的可重用包。具体来说,我们学习了如何使项目中的目标可移动,以便包可以在用户希望安装的任何地方进行安装。然后,我们专注于形成一个完全定义的包,它可以通过find_package()被其他项目消费,这需要准备目标导出文件、配置文件以及版本文件

认识到不同用户可能需要我们包的不同部分,我们发现了如何将工件和动作分组在安装组件中,以及它们与 CMake 包组件的区别。

最后,我们提到了 CPack,并学习了如何准备基本的二进制包,以使用预编译的形式分发我们的软件。

要完全掌握安装和打包的所有细节和复杂性还有很长的路要走,但本章为我们提供了坚实的基础,以处理最常见的情况并自信地进一步探索它们。

在下一章中,我们将把我们到目前为止所学的所有内容付诸实践,通过创建一个连贯、专业的项目。

进一步阅读

要了解更多关于本章涵盖的主题,请查看以下资源:

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2024-05-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 第九章:程序分析工具
  • 技术要求
  • 强制格式化
  • 使用静态检查器
    • Clang-Tidy
      • Cpplint
        • Cppcheck
          • 包含你使用的(include-what-you-use)
            • 链接你使用的(Link what you use)
            • 使用 Valgrind 进行动态分析
              • Memcheck
                • Memcheck-Cover
                • 总结
                • 进一步阅读
                • 第十章:生成文档
                • 技术要求
                • 向您的项目添加 Doxygen
                • 使用现代风格生成文档
                • 摘要
                • 进一步阅读
                  • 其他文档生成工具
                  • 第十一章:安装和打包
                  • 技术要求
                  • 无需安装导出
                  • 在系统上安装项目
                    • 安装逻辑目标
                      • 为不同平台确定正确的目的地
                      • 处理公共头文件
                    • 低级安装
                      • 使用 install(FILES|PROGRAMS) 安装文件集
                      • 处理整个目录
                    • 在安装过程中调用脚本
                    • 创建可重用包
                      • 理解可移动目标的问题
                        • 安装目标导出文件
                          • 编写基本配置文件
                            • 创建高级配置文件
                              • 生成包版本文件
                              • 定义组件
                                • 如何在find_package()中使用组件
                                  • 如何在install()命令中使用组件
                                    • 管理版本化共享库的符号链接
                                • 使用 CPack 进行打包
                                • 摘要
                                • 进一步阅读
                                • 进一步阅读
                                相关产品与服务
                                命令行工具
                                腾讯云命令行工具 TCCLI 是管理腾讯云资源的统一工具。使用腾讯云命令行工具,您可以快速调用腾讯云 API 来管理您的腾讯云资源。此外,您还可以基于腾讯云的命令行工具来做自动化和脚本处理,以更多样的方式进行组合和重用。
                                领券
                                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档