mdn-cmk-cpp-2e-merge-1

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

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

编程既是一门艺术,也是一项深具技术性的过程,正如我们都深知的,它是非常困难的。因此,我们应该尽可能优化这一过程。虽然我们很少能通过简单的开关来获得更好的结果,但使用集成开发环境(IDE)绝对是其中少数的例外之一。

如果你以前没有使用过合适的 IDE(或者你认为像 Emacs 或 Vim 这样的文本处理器已经是你能得到的最好工具),那么本章就是为你准备的。如果你是经验丰富的专业人士,已经熟悉这个话题,你可以将本章作为当前热门选择的快速概览,或者考虑换一个工具,甚至更好的是,确认你当前使用的工具就是最好的选择。

本章以强调为新手提供可访问性的方式,轻松介绍了选择 IDE 这一关键问题。我们将讨论为什么你需要一个 IDE 以及如何选择最适合你需求的 IDE。虽然市场上有许多选择,但像往常一样,有些选择显然比其他的更好。不幸的是,这并不是一个通用的、一刀切的解决方案。许多因素会影响你选择合适 IDE 后的生产力水平。我们将讨论一些在某些规模的组织中可能很重要的考虑因素,确保你能够掌握细微差别而不至于陷入复杂性。接下来,我们会快速介绍工具链,在那里我们将讨论可用的选择。

然后,我们将重点介绍几种流行 IDE 的独特特点,如复杂的 CLion、灵活的 Visual Studio Code,以及强大的 Visual Studio IDE。每一节都将展示这些环境的优点和高级功能,帮助你了解如何迈出使用 IDE 的第一步。此外,我们还将介绍一个从众多功能中主观挑选出的高级功能,让你知道如果决定使用这套工具时,可能会遇到哪些亮点。

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

  • 了解集成开发环境(IDE)

  • 从 CLion IDE 开始

  • 从 Visual Studio Code 开始

  • 从 Visual Studio IDE 开始

了解集成开发环境(IDE)

在本节中,我们将讨论 IDE 及其如何显著提高开发速度和代码质量。让我们先为那些对这个话题不熟悉的人解释一下什么是 IDE。

为什么以及如何选择一个 IDE?IDE(集成开发环境)是一种综合工具,它将各种专业工具结合起来,以简化软件开发过程。创建一个专业项目的过程包含多个步骤:设计、编码、构建、测试、打包、发布和维护。每个步骤都包含许多小任务,复杂性可能会让人感到压倒性。IDE 提供了解决方案,通过提供一个由 IDE 创建者策划和配置的工具平台,使你能够无缝使用这些工具,而无需为每个项目单独设置它们。

IDE 主要围绕代码编辑器、编译器和调试器设计。它们旨在提供足够的集成,使你能够编辑代码、立即编译并运行带有调试器的代码。IDE 可以包括构建工具链,或允许开发人员选择自己喜欢的编译器和调试器。编辑器通常是软件的核心部分,但通常可以通过插件大大扩展功能,例如代码高亮、格式化等。

更高级的 IDE 提供了非常复杂的功能,如热重载调试(在 Visual Studio 2022 中可用;继续阅读以了解更多)。这个功能允许你在调试器中运行代码,编辑它,并继续执行而无需重新启动程序。你还会发现重构工具,可以重命名符号或将代码提取到单独的函数中,以及静态分析工具,可以在编译之前识别错误。此外,IDE 还提供了与 Git 和其他版本控制系统的工具,这些工具对于解决冲突等问题非常有价值。

我相信你现在可以看到,早期学习如何使用 IDE 并在组织中标准化这种使用方式是多么有益。接下来,让我们了解一下为什么选择一个适合你的IDE 如此重要。

选择 IDE

有很多代码编辑器正处于被社区认定为功能完整的 IDE 的边缘。在选择一个具体的 IDE 之前,建议你先做一些研究,特别是考虑到当前软件发布周期的节奏以及该领域的快速变化。

在我几年的企业经验中,IDE 提供的功能足够吸引人,让人从一个 IDE 切换到另一个 IDE 的情况并不常见。开发人员的习惯几乎是第二天性,不能忽视。记住,一旦你在某个 IDE 中感到舒适,它很可能会成为你未来相当长时间的首选工具。这就是为什么你仍然会看到开发者使用 Vim(一个 1991 年发布的基于控制台的文本编辑器),并通过一堆插件扩展它,使它与现代的、基于 GUI 的 IDE 一样强大。所以不必感到压力。

程序员选择一个 IDE 而不是另一个 IDE 的原因各不相同;其中一些原因非常重要(速度、可靠性、全面性、完整性),而另一些则…没那么重要。我想分享一下我对这个选择的主观看法,希望你也能觉得有用。

选择一个全面的 IDE

如果你刚刚开始,你可能会考虑使用一个简单的文本编辑器并通过运行几个命令来构建代码。这种方法完全可行,尤其是在你尝试理解基础知识时(我鼓励你在本书中使用实际命令来跟踪你的进度)。它还帮助你理解没有 IDE 时初学者可能遇到的情况。

另一方面,IDE 是为了特定的目的而创建的。它们简化了开发人员在项目生命周期中处理的众多流程,这非常有价值。虽然最初可能会让人感到不知所措,但选择一个包括所有必要功能的综合 IDE。确保它尽可能完整,但要留意成本,因为 IDE 对于小型企业或个人开发者来说可能非常昂贵。这是一个在手动管理花费的时间和 IDE 提供的功能成本之间的平衡。

无论价格如何,总是选择一个有强大社区支持的 IDE,以便在遇到问题时获得帮助。浏览社区论坛和像StackOverflow.com这样的热门问答网站,看看用户是否能得到他们问题的答案。此外,选择一个由有声望的公司积极开发的 IDE。你不想浪费时间在一个已经很久没有更新、可能会在不久的将来被弃用或停产的工具上。例如,不久前,GitHub 创建的编辑器 Atom,在发布 7 年后被停用。

选择一个在你的组织中得到广泛支持的 IDE

出乎意料的是,这可能并不符合每个开发者的偏好。你可能已经习惯了来自大学、之前工作或个人项目中的其他工具。正如前面提到的,这样的习惯可能会诱使你忽视公司的建议,固守自己熟悉的工具。抵制这种诱惑。随着时间的推移,这样的选择会变得越来越具有挑战性。根据我在爱立信、亚马逊和思科的经历,只有一次,我努力配置和维护一个非标准 IDE 是值得的。那是因为我成功获得了足够的组织支持,能够共同解决问题。

你的主要目标应该是编写代码,而不是在一个不受支持的 IDE 中挣扎。学习推荐的软件可能需要一些努力,但它所需的精力少于违背常规的做法(是的,Vim 输了这一战;是时候继续前进了)。

不要根据目标操作系统和平台选择 IDE

你可能认为如果你在为 Linux 开发软件,你需要使用一台 Linux 机器和基于 Linux 的 IDE。然而,C++ 是一种可移植的语言,这意味着只要你编写正确,它应该能够在任何平台上以相同的方式编译和运行。当然,你可能会遇到库的问题,因为并不是所有的库都是默认安装的,有些可能是特定于你的平台的。

严格遵循目标平台并非总是必要的,有时甚至可能适得其反。例如,如果你要针对一个较旧或长期支持LTS)版本的操作系统进行开发,你可能无法使用最新的工具链版本。如果你希望在不同于目标平台的环境下进行开发,是完全可以实现的。

在这种情况下,可以考虑交叉编译远程开发。交叉编译是指使用专门的工具链,使得在一个平台(如 Windows)上运行的编译器能够为另一个平台(如 Linux)生成目标文件。这种方法在行业中广泛使用,并且得到 CMake 的支持。或者,我推荐远程开发,在这种情况下,你将代码发送到目标机器,并在那里使用本地工具链进行构建。这种方法得到了许多 IDE 的支持,并且提供了几个好处,我们将在下一节中进行探讨。

选择一款支持远程开发的 IDE

虽然这不应是你主要的选择标准,但在满足其他要求后,考虑 IDE 是否支持远程开发是很有帮助的。随着时间的推移,即便是经验丰富的开发者也会遇到需要不同目标平台的项目,这可能是由于团队、项目甚至公司发生变化。

如果你首选的 IDE 支持远程开发,你可以继续使用它,利用在不同操作系统上编译和调试代码的能力,并在 IDE 的 GUI 中查看结果。远程开发相比交叉编译的主要优势是其集成的调试支持,提供了一个更加简洁的过程,而无需进行 CMake 项目级别的配置。此外,公司通常会提供强大的远程机器,让开发者可以使用更便宜、轻量级的本地设备。

当然,有人认为交叉编译提供了更大的开发环境控制,使得可以为测试做临时性更改。它不需要带宽进行代码传输,支持低端的互联网连接或离线工作。然而,考虑到大多数软件开发都需要上网获取信息,这可能就不是一个特别重要的优势。使用像 Docker 这样的虚拟化环境可以运行本地生产副本并设置远程开发连接,提供安全性、可定制性,以及构建和部署容器的能力。

这里提到的考虑因素稍微倾向于在大型公司工作的情况,在这些公司中,事务进展较慢,且很难做出高影响力的改变。如果你决定根据你的使用场景优先考虑其他 IDE 的方面,这些建议并不否定使用 CMake 时能获得一个完全完整的体验的可能性。

安装工具链

正如我们之前讨论的,IDE 整合了所有必要的工具来简化软件开发。这个过程的一个关键部分是构建二进制文件,有时在后台或即时构建,以为开发人员提供附加信息。工具链是由编译器、链接器、归档工具、优化器、调试器和 C++标准库实现等工具组成的集合。它们还可能包括其他实用的工具,如bashmakegawkgrep等,这些工具用于构建程序。

一些 IDE 自带工具链或工具链下载器,而其他 IDE 则没有。最好直接运行已安装的 IDE,并检查是否能够编译一个基础的测试程序。CMake 通常会在配置阶段默认执行此操作,大多数 IDE 会在初始化新项目时作为一部分执行此过程。如果此过程失败,IDE 或操作系统的包管理器可能会提示你安装所需的工具。只需按照提示操作,因为这个流程通常已经做好了充分准备。

如果没有提示,或者如果你想使用特定的工具链,这里有一些根据平台不同的选项:

  • GNU GCC (gcc.gnu.org/) 用于 Linux、Windows(通过 MinGW 或 Cygwin)、macOS 及其他多个平台。GCC 是最受欢迎且广泛使用的 C++编译器之一,支持多种平台和架构。

  • Clang/LLVM (clang.llvm.org/) 用于 Linux、Windows、macOS 等多个平台。Clang 是 C、C++和 Objective-C 编程语言的编译器前端,利用 LLVM 作为其后端。

  • Microsoft Visual Studio/MSVC (visualstudio.microsoft.com/) 主要用于 Windows,同时通过 Visual Studio Code 和 CMake 提供跨平台支持。MSVC 是由微软提供的 C++编译器,通常在 Visual Studio IDE 中使用。

  • MinGW-w64 (mingw-w64.org/) 用于 Windows。MinGW-w64 是原 MinGW 项目的一个改进版,旨在提供对 64 位 Windows 和新 API 的更好支持。

  • Apple Clang (developer.apple.com/xcode/cpp/) 用于 macOS、iOS、iPadOS、watchOS 和 tvOS。Apple 版 Clang,经过针对 Apple 硬件和软件生态系统的优化,已集成在 Xcode 中。

  • Cygwin (www.cygwin.com/) 用于 Windows。Cygwin 为 Windows 提供了一个与 POSIX 兼容的环境,允许使用 GCC 和其他 GNU 工具。

如果你想快速开始,而不深入研究每个工具链的细节,你可以按照我的个人偏好:如果 IDE 没有提供工具链,可以选择在 Windows 上使用 MinGW,在 Linux 上使用 Clang/LLVM,在 macOS 上使用 Apple Clang。每种工具链都非常适合其主要平台,并通常提供最佳体验。

使用本书的示例与 IDE 配合

本书附带了一套丰富的 CMake 项目示例,已上传至官方 GitHub 仓库,链接如下:github.com/PacktPublishing/Modern-CMake-for-Cpp-2E

自然地,当我们探索 IDE 的主题时,出现了一个问题:我们如何在这里介绍的所有 IDE 中使用这个仓库呢?嗯,我们需要认识到,这本教你如何创建专业项目的书本身并不是一个专业项目。它是一个由多个不同完成度的项目组成的集合,在可能的情况下做了适当的简化。不幸的是(或者说,或许幸运的是?),IDE 并不是为加载成百上千的项目并方便管理它们而设计的。它们通常将功能集中于加载一个正在编辑的项目。

这让我们处于一个有些尴尬的境地:使用 IDE 来浏览示例集实际上很困难。当你使用 IDE 加载示例集时,通过选择示例目录来打开,绝大多数 IDE 会检测到多个 CMakeLists.txt 文件,并要求你选择一个。选定后,通常会执行初始化过程,写入临时文件,基本上会运行 CMake 配置和生成阶段,以便让项目进入可以构建的状态。正如你可能猜到的那样,这只对选中的 CMakeLists.txt 文件所在的示例有效。大多数 IDE 确实提供在工作区中切换不同目录(或项目)的方法,但这可能没有我们希望的那样简单直接。

如果你在这方面遇到困难,有两个选择:要么不使用 IDE 来构建示例(而是使用控制台命令),要么每次都将示例加载到一个新的项目中。如果你想练习命令,我推荐第一个选项,因为这些命令将来可能会派上用场,并且能帮助你更好地理解幕后发生了什么。这通常是构建工程师的一个不错选择,因为这些知识将被频繁使用。另一方面,如果你是在做单一项目,主要作为开发者关注代码的业务层面,或许尽早使用 IDE 是最佳选择。无论如何,选择一个并不妨碍你偶尔使用另一个。

说完这些,让我们集中精力回顾今天的顶级 IDE,看看哪个最适合你。无论你是否在公司工作,它们都会为你提供很好的服务。

从 CLion IDE 开始

CLion 是一款付费的跨平台 IDE,适用于 Windows、macOS 和 Linux,由 JetBrains 开发。没错——这款软件是基于订阅的;从 2024 年初开始,你可以以 $99.00 获得一年的个人使用许可。大型组织支付更多费用,初创公司支付较少。如果你是学生或发布开源项目,可以获得免费许可证。此外,还有 30 天的试用期来测试软件。这是本列表中唯一不提供“社区版”或简化版免费版本的 IDE。尽管如此,这仍然是一款由知名公司开发的强大软件,可能非常值得这个价格。

图 3.1 显示了 IDE 在浅色模式下的界面(深色模式是默认选项):

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

图 3.1:CLion IDE 的主窗口

正如你所看到的,这是一款功能全面的 IDE,能够应对你可能遇到的任何需求。接下来我们来聊聊它的独特之处。

你可能会喜欢它的原因

与其他选择不同,C 和 C++ 是 CLion 支持的第一个也是唯一的编程语言。这个 IDE 的许多功能专门设计用于支持这一环境,并符合 C/C++ 的思维模式。我们将其他 IDE 的功能与 CLion 进行对比时,这一点尤为明显:代码分析、代码导航、集成调试器和重构工具在像 Visual Studio IDE 这样的竞争软件中都有。然而,它们并没有像 CLion 一样深入且强大地面向 C/C++。当然,这一点很难客观衡量。

不管怎样,CMake 已在 CLion 中完全集成,并且是该 IDE 中项目格式的首选。不过,像 Autotools 和 Makefile 项目这样的替代方案目前正处于早期支持阶段,可以用来最终迁移到 CMake。值得注意的是,CLion 原生支持 CMake 的 CTest,并支持多种单元测试框架,并提供专门的流程来生成代码、运行测试、收集和展示结果。你可以使用 Google Test、Catch、Boost.Test 和 doctest。

我特别喜欢的一个功能是能够与 Docker 配合使用,在容器中开发 C++ 程序——稍后会详细介绍。与此同时,让我们看看如何开始使用 CLion。

踏出你的第一步

从官方网站下载 CLion(www.jetbrains.com/clion)后,你可以按照你所使用平台的常规安装流程进行安装。CLion 在 Windows(图 3.2)和 macOS(图 3.3)上都提供了一个足够直观的可视化安装程序。

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

图 3.2:Windows 上的 CLion 安装设置

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

图 3.3:macOS 上的 CLion 安装设置

在 Linux 上,你需要解压下载的档案并运行安装脚本:

tar -xzf CLion-<version>.tar.gz
./CLion-<version>/bin/CLion.sh 

这些说明可能已经过时,请确保通过 CLion 网站确认最新信息。

在第一次运行时,你将被要求提供许可证代码或启动 30 天免费试用。选择第二个选项将允许你尝试该 IDE,并决定它是否适合你。接下来,你将能够创建一个新项目并选择目标 C++ 版本。之后,CLion 将自动检测可用的编译器和 CMake 版本,并尝试构建一个测试项目,以确认所有设置正确。在某些平台(如 macOS)上,你可能会收到自动提示,要求根据需要安装开发者工具。在其他平台上,你可能需要自行设置并确保这些工具在 PATH 环境变量中可用。

接下来,确保根据你的需求配置工具链。工具链是按项目配置的,所以请创建一个默认的 CMake 项目。然后,导航到 设置/首选项 (Ctrl/Command + Alt + S),选择 构建、执行、部署 > CMake。在此标签页中,你可以配置构建配置文件(图 3.3)。如果需要,你可以添加一个 Release 配置文件,以便在不带调试符号的情况下构建优化后的文件。要添加该配置文件,只需点击配置文件列表上方的加号图标。CLion 会为你创建一个默认的 Release 配置文件。你可以通过主窗口顶部的下拉菜单在不同配置文件之间切换。

现在,你可以简单地按 F9 编译并运行程序,同时附加调试器。之后,阅读 CLion 的官方文档,因为还有很多有用的功能值得探索。我想向你介绍我最喜欢的功能之一:调试器。

高级功能:增强版调试器

CLion 的调试能力确实是前沿的,特别是为 C++ 设计的。我非常高兴发现了其中一个最新的功能——CMake 调试,它包括许多标准调试功能:代码逐步调试、断点、监视、内联值探索等。当某些事情无法按预期工作时,能够在不同作用域(缓存、ENV 和当前作用域)中查看变量是极其方便的。

对于 C++ 调试,你将获得由GNU 项目调试器GDB)提供的许多标准功能,如汇编视图、断点、逐步调试、监视点等,但也有一些重要的增强功能。在 CLion 中,你会发现一个并行堆栈视图,它可以让你以图形化的方式查看所有线程,并显示每个线程的当前堆栈帧。此外,还有一个高级内存视图功能,允许你查看正在运行的程序在 RAM 中的布局,并即时修改内存。CLion 提供了多个其他工具,帮助你了解程序的运行情况:寄存器视图、代码反汇编、调试控制台、核心转储调试、任意可执行文件的调试等。

作为补充,CLion 拥有一项非常出色的评估表达式功能,它可以大显身手,甚至允许在程序执行过程中修改对象。只需右键点击一行代码,并从菜单中选择此功能。

关于 CLion 的介绍就到这里;现在是时候看看另一个 IDE 了。

开始使用 Visual Studio Code

Visual Studio CodeVS Code)是由 Microsoft 开发的一款免费的跨平台集成开发环境,适用于 Windows、macOS 和 Linux。不要将它与另一款 Microsoft 产品——Visual Studio IDE 混淆(通常以发布年份命名,例如 Visual Studio 2022)。

VS Code 因其庞大的扩展生态系统和对数百种编程语言的支持而受到青睐(据估计,支持的语言超过 220 种!)。当 GitHub 被 Microsoft 收购时,VS Code 被推出作为 Atom 的替代品。

该 IDE 的整体设计非常出色,正如图 3.4所示。

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

图 3.4:VS Code 的主窗口

现在,让我们来看看是什么让 VS Code 如此特别。

为什么你可能会喜欢它

C++虽然不是 VSC 支持的语言优先项,但由于有许多复杂的语言扩展,它离榜单前列很近。这个权衡带来了在同一环境下按需切换多种语言的能力。

使用这个工具需要一些学习曲线,因为大多数扩展遵循基础 UI 功能,而不是实现独立的高级接口。许多功能可以通过命令面板访问(按F1键即可)。命令面板需要你输入命令名称,而不是点击图标或按钮。为了保持 VSC 简洁、快速且免费,这是一个合理的取舍。事实上,这款 IDE 加载速度非常快,即使在我不进行项目开发时,我也更愿意将它用作通用文本编辑器。

尽管如此,VS Code 凭借庞大的优秀扩展库而真正强大,其中绝大多数都是免费的。对于 C++和 CMake,特别提供了专用扩展,接下来我们将看看如何配置它们。

开始你的第一步

VSC 可以从官方网站获取:code.visualstudio.com/。该网站提供了适用于 Windows 和 macOS 的下载链接,还涵盖了多个 Linux 发行版:Debian、Ubuntu、Red Hat、Fedora 和 SUSE。根据你平台的常规安装流程安装软件。之后,你可以通过访问扩展市场Ctrl/Command + Shift + X)来安装一系列扩展。以下是推荐的初始扩展:

  • Microsoft 的 C/C++

  • Microsoft 的 C/C++扩展包

  • twxs 的 CMake

  • Microsoft 的 CMake 工具

它们将提供常规的代码高亮、编译、运行和调试代码的能力,但你可能需要自己安装工具链。通常,当你开始打开相关文件时,VS Code 会在弹出窗口中建议安装扩展,因此你不一定需要自己去找。

如果你参与远程项目,我还建议安装 Remote – SSH by Microsoft 扩展,因为这将使体验更加连贯和舒适;该扩展不仅负责文件同步,还能通过附加到远程机器上的调试器来启用远程调试。

然而,还有一个更有趣的扩展,它改变了处理项目的方式;让我们看看如何改变。

高级功能:开发容器

如果你将应用程序部署到生产环境中,无论是传输已编译的工件还是运行构建过程,确保所有依赖项都已存在至关重要。否则,你将遇到各种问题。即便所有依赖项都已考虑到,不同的版本或配置可能会导致你的解决方案在开发环境或预发布环境中表现不同。我在很多情况下都有过类似的经历。在虚拟化普及之前,处理环境问题就是生活的一部分。

随着像 Docker 这样的轻量级容器的引入,一切变得更加简单。突然间,你能够运行一个精简的操作系统,并将服务隔离到自己的空间。这种隔离使得所有依赖项都可以与容器一起打包,从而解除了开发者的一大困扰。

直到最近,在容器中开发涉及手动构建、运行并通过 IDE 与远程会话连接到容器。这个过程并不难,但它需要手动操作,而这些操作可能会因不同开发者而有所不同。

近年来,微软发布了一个开源标准——开发容器(containers.dev/),以帮助解决这个小小的不便。该规范主要由一个devcontainer.json文件组成,你可以将其放入你的项目仓库,指示 IDE 如何在容器中设置开发环境。

要使用此功能,只需安装 Dev Containers by Microsoft 扩展,并将其指向一个准备好的项目的仓库。如果你不介意修改主CMakeLists.txt,可以尝试使用本书的仓库:

git@github.com:PacktPublishing/Modern-CMake-for-Cpp-2E.git

我可以确认,其他 IDE,如 CLion,正在采用这一标准,所以如果你面临上述情况,采用这个标准似乎是一个不错的做法。接下来是微软家族的下一个产品。

从 Visual Studio IDE 开始

Visual Studio (VS) 是一款由微软开发的适用于 Windows 的 IDE。曾经也有适用于 macOS 的版本,但将在 2024 年 8 月停用。需要特别区分的是,它与微软的 另一款 IDE VS Code 是不同的。

VS 提供几种版本:社区版、专业版和企业版。社区版是免费的,适用于最多五个用户的公司。成熟的公司需要支付许可费用,费用从每个用户每月 $45 起。图 3.5 显示了 VS 社区版的界面:

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

图 3.5:VS 2022 的主窗口

像本章讨论的其他 IDE 一样,如果你更喜欢,可以启用暗黑模式。接下来,让我们看看这款 IDE 的一些值得注意的功能。

你可能会喜欢它的原因

这款 IDE 与 VS Code 共享许多特性,提供了类似的体验,但在更精致的形式下。整个套件功能丰富,许多功能都利用了 GUI、向导和可视化元素。这些功能大多可以开箱即用,而不是通过扩展来实现(尽管仍然有一个庞大且丰富的包市场,供你获取额外功能)。换句话说,它就像 VSC,但功能更为先进。

根据版本的不同,你的测试工具将涵盖广泛的测试类型:单元测试、性能测试、负载测试、手动测试、测试资源管理器、测试覆盖率、IntelliTest 和代码分析工具。尤其是分析工具,它是一个非常有价值的工具,并且在社区版中也有提供。

如果你正在设计 Windows 桌面应用程序,VS 提供了可视化编辑器和大量组件。对于 通用 Windows 平台 (UWP),它有着广泛的支持,UWP 是 Windows 10 中引入的 Windows 应用程序 UI 标准。该支持使得 UI 设计时尚现代,且对适应性控件进行了深度优化,可以很好地适配不同屏幕。

另一个值得一提的地方是,尽管 VS 是一款仅支持 Windows 的 IDE,但你仍然可以开发针对 Linux 和移动平台(Android 和 iOS)的项目。它还支持使用 Windows 原生库和 Unreal Engine 的游戏开发者。

想亲自体验它是如何工作的吗?下面是开始使用的方法。

开始你的第一步

这款 IDE 仅适用于 Windows,并遵循标准的安装流程。首先从 visualstudio.microsoft.com/free-developer-offers/ 下载安装程序。运行安装程序后,你将被要求选择版本(社区版、专业版或企业版)并选择你需要的工作负载:

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

图 3.6:VS IDE 安装器窗口

工作负载实际上是一些功能集,它们允许 VS 支持特定语言、环境或程序格式。一些工作负载包括 Python、Node.js 或 .NET。当然,我们更关注与 C++ 相关的工作负载(图 3.6);针对不同使用场景,提供了广泛的支持:

  • 使用 C++ 进行桌面开发

  • 通用 Windows 平台开发

  • 使用 C++ 进行游戏开发

  • 使用 C++ 进行移动开发

  • 使用 C++ 进行 Linux 开发

选择适合你所需应用的选项并点击安装。不必担心是否安装了所有选项——你可以随时通过重新运行安装程序来修改选择。如果你决定更精确地配置工作负载组件,请确保保留Windows 的 C++ CMake 工具Linux 的 C++ CMake 工具选项,以便获得 CMake 支持。

安装完成后,你可以启动 IDE 并在启动窗口中选择创建新项目。根据你之前安装的工作负载,系统会展示多个模板。如果要使用 CMake,选择CMake 项目模板。其他选项不一定会使用它。在创建项目后,你可以通过点击窗口顶部的绿色播放按钮来启动它;代码会编译,并且你会看到基本程序执行后的输出:

Hello CMake. 

现在,你已经准备好在 Visual Studio 中使用 CMake 了。

高级功能:Hot Reload 调试

尽管运行 Visual Studio 可能会消耗更多资源并且启动时间较长,但它提供了许多无可匹敌的功能。其中一个重大变化就是 Hot Reload。它的工作方式如下:打开一个 C++ 项目,附加调试器启动,修改代码文件,点击Hot Reload按钮(或Alt + F10),你的更改会立即反映到正在运行的应用程序中,同时保持应用程序的状态。

为确保启用 Hot Reload 支持,请在项目 > 属性 > C/C++ > 常规菜单中设置以下两个选项:

  • 调试信息格式必须设置为程序数据库用于编辑和继续 /ZI

  • 启用增量链接必须设置为是 /INCREMENTAL

Hot Reload 的幕后机制可能看起来像是魔法,但它是一个非常实用的功能。虽然存在一些限制,比如全局/静态数据的更改、对象布局的调整,或是“时间旅行”式的更改(比如修改已经构造好的对象的构造函数)。

你可以在官方文档中找到更多关于 Hot Reload 的信息,链接地址:learn.microsoft.com/en-us/visualstudio/debugger/hot-reload

本章节总结了三大主要的 IDE。虽然初学时可能会感到学习曲线陡峭,但我保证,当你进入更高级的任务时,投入的学习努力会很快得到回报。

总结

本章深入探讨了如何使用 IDE 来优化编程过程,特别关注那些与 CMake 深度集成的 IDE。它为初学者和经验丰富的专业人士提供了全面的指南,详细说明了使用 IDE 的好处以及如何选择最适合个人或组织需求的 IDE。

我们首先讨论了 IDE 在提升开发速度和代码质量中的重要性,解释了什么是 IDE 以及它如何通过整合代码编辑器、编译器和调试器等工具,简化软件开发中的各个步骤。接下来,我们简要提醒了工具链的重要性,解释了如果系统中没有安装工具链则必须进行安装,并列出了最常见的选择。

我们讨论了如何开始使用 CLion 及其独特功能,并深入了解了它的调试能力。VS Code 是微软推出的免费跨平台集成开发环境(IDE),因其庞大的扩展生态系统和对众多编程语言的支持而广受认可。我们引导您完成了初始设置及其关键扩展的安装,并介绍了一项名为 Dev Containers 的高级功能。专为 Windows 设计的 VS IDE 提供了一个精致且功能丰富的环境,满足各种用户需求。我们还涵盖了设置过程、关键功能以及先进的 Hot Reload 调试功能。

每个 IDE 部分都提供了关于为什么选择特定 IDE 的见解,启动步骤,以及一个突显该 IDE 特点的高级功能。我们还强调了远程开发支持的概念,突出了它在行业中的日益重要性。

总结来说,本章作为程序员了解和选择 IDE 的基础指南,提供了主要选项的清晰概述,介绍了它们的独特优势,以及如何有效地与 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 中,项目包含了所有源文件以及管理将我们的解决方案付诸实践所需的配置。配置开始时需要执行所有的检查:验证目标平台是否受支持,确保所有必要的依赖项和工具都已存在,并确认所提供的编译器是否与所需的功能兼容。

一旦初步检查完成,CMake 将生成一个针对所选构建工具量身定制的构建系统。然后,构建系统将被执行,这意味着编译源代码并将其与相关的依赖项链接在一起,生成最终的构件。

生成的构件可以通过不同的方式分发给用户。它们可以作为二进制包直接与用户共享,允许用户通过包管理器将其安装到他们的系统中。或者,它们可以作为单个可执行安装程序进行分发。此外,最终用户也可以通过访问开源仓库中的共享项目来自己创建这些构件。在这种情况下,用户可以利用 CMake 在自己的机器上编译项目,然后再进行安装。

充分利用 CMake 项目可以显著提升开发体验以及生成代码的整体质量。通过利用 CMake 的强大功能,许多繁琐的任务可以被自动化,例如构建后执行测试、运行代码覆盖率检查器、格式化工具、验证器、静态分析工具以及其他工具。这种自动化不仅节省了时间,还确保了开发过程中的一致性,并促进了代码质量的提升。

为了充分发挥 CMake 项目的优势,我们首先需要做出一些关键决策:如何正确配置整个项目,如何划分项目以及如何设置源代码树,以确保所有文件都能整齐地组织在正确的目录中。通过从一开始就建立一个连贯的结构和组织,CMake 项目可以在开发过程中得到有效管理和扩展。

接下来,我们将了解项目的构建环境。我们将探讨诸如我们使用的架构、可用的工具、它们支持的功能以及我们正在使用的语言标准等内容。为了确保一切都同步,我们将编译一个测试用的 C++ 文件,并查看我们选择的编译器是否符合我们为项目设定的标准要求。这一切都旨在确保我们的项目、所使用的工具以及选择的标准能够顺利配合。

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

  • 理解基本的指令和命令

  • 划分你的项目

  • 思考项目结构

  • 确定环境作用域

  • 配置工具链

  • 禁用源代码内构建

技术要求

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

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

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

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

理解基本的指令和命令

第一章CMake 入门中,我们已经看过了一个简单的项目定义。让我们再来回顾一下。它是一个包含几个配置语言处理器命令的 CMakeLists.txt 文件的目录:

chapter01/01-hello/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Hello)
add_executable(Hello hello.cpp) 

在同一章节中,在名为项目文件的部分中,我们学习了一些基本命令。这里我们将深入解释它们。

指定最低的 CMake 版本

在项目文件和脚本的最顶部使用 cmake_minimum_required() 命令非常重要。该命令不仅验证系统是否具有正确版本的 CMake,还隐式触发另一个命令 cmake_policy(VERSION),该命令指定用于项目的策略。这些策略定义了 CMake 中命令的行为,它们是在 CMake 发展的过程中引入的,以适应支持的语言和 CMake 本身的变化和改进。

为了保持语言的简洁,CMake 团队每当出现不兼容的更改时,就引入了新的策略。每个策略启用了与该更改相关的新行为。这些策略确保项目能够适应 CMake 不断发展的特性和功能,同时保持与旧代码库的兼容性。

通过调用 cmake_minimum_required(),我们告诉 CMake 需要根据参数中提供的版本应用默认策略。当 CMake 升级时,我们无需担心它会破坏我们的项目,因为新版本附带的新策略不会被启用。

策略可以影响 CMake 的各个方面,包括其他重要命令,如 project()。因此,重要的是在 CMakeLists.txt 文件中首先设置你正在使用的版本。否则,你将收到警告和错误。

每个 CMake 版本都会引入大量策略。然而,除非你在将旧项目升级到最新 CMake 版本时遇到问题,否则不必深入了解这些策略的细节。在这种情况下,建议参考官方文档,获取有关策略的全面信息和指导:cmake.org/cmake/help/latest/manual/cmake-policies.7.html

定义语言和元数据

即使从技术上讲,project() 命令并不一定要放在 cmake_minimum_required() 之后,但建议将其放在该位置。这样做可以确保我们在配置项目时使用正确的策略。我们可以使用它的两种形式之一:

project(<PROJECT-NAME> [<language-name>...]) 

或者:

project(<PROJECT-NAME>
        [VERSION <major>[.<minor>[.<patch>[.<tweak>]]]]
        [DESCRIPTION <project-description-string>]
        [HOMEPAGE_URL <url-string>]
        [LANGUAGES <language-name>...]) 

我们需要指定 <PROJECT-NAME>,但其他参数是可选的。调用此命令将隐式设置以下变量:

PROJECT_NAME
CMAKE_PROJECT_NAME (only in the top-level CMakeLists.txt)
PROJECT_IS_TOP_LEVEL, <PROJECT-NAME>_IS_TOP_LEVEL
PROJECT_SOURCE_DIR, <PROJECT-NAME>_SOURCE_DIR
PROJECT_BINARY_DIR, <PROJECT-NAME>_BINARY_DIR 

支持哪些语言?相当多。而且你可以同时使用多个语言!以下是你可以用来配置项目的语言关键字列表:

  • ASM, ASM_NASM, ASM_MASM, ASMMARMASM, ASM-ATT: 汇编语言的方言

  • C: C

  • CXX: C++

  • CUDA: Nvidia 的统一计算设备架构

  • OBJC: Objective-C

  • OBJCXX: Objective-C++

  • Fortran: Fortran

  • HIP: 跨平台异构计算接口(适用于 Nvidia 和 AMD 平台)

  • ISPC: 隐式 SPMD 程序编译器语言

  • CSharp: C#

  • Java: Java(需要额外的步骤,请参阅官方文档)

CMake 默认启用 C 和 C++,所以你可能需要明确指定只使用 CXX 来配置你的 C++ 项目。为什么?project() 命令会检测并测试你选择的语言所支持的编译器,因此声明所需的语言可以帮助你在配置阶段节省时间,跳过不必要的语言检查。

指定 VERSION 关键字将自动设置可以用于配置包或在头文件中暴露以供编译期间使用的变量(我们将在 第七章使用 CMake 编译 C++ 源代码配置头文件 部分中讲解):

PROJECT_VERSION, <PROJECT-NAME>_VERSION
CMAKE_PROJECT_VERSION (only in the top-level CMakeLists.txt)
PROJECT_VERSION_MAJOR, <PROJECT-NAME>_VERSION_MAJOR
PROJECT_VERSION_MINOR, <PROJECT-NAME>_VERSION_MINOR
PROJECT_VERSION_PATCH, <PROJECT-NAME>_VERSION_PATCH
PROJECT_VERSION_TWEAK, <PROJECT-NAME>_VERSION_TWEAK 

我们还可以设置 DESCRIPTIONHOMEPAGE_URL,这将为类似的目的设置以下变量:

PROJECT_DESCRIPTION, <PROJECT-NAME>_DESCRIPTION
PROJECT_HOMEPAGE_URL, <PROJECT-NAME>_HOMEPAGE_URL 

cmake_minimum_required()project() 命令将允许我们创建一个基本的列表文件并初始化一个空项目。虽然对于小型单文件项目,结构可能不那么重要,但随着代码库的扩展,这变得至关重要。你如何为此做准备?

划分你的项目

随着我们的解决方案在代码行数和文件数量上的增长,我们很快意识到必须解决一个迫在眉睫的挑战:要么开始划分项目,要么面临被复杂性淹没的风险。我们可以通过两种方式解决这个问题:拆分 CMake 代码和将源文件移动到子目录中。在这两种情况下,我们的目标都是遵循名为 关注点分离 的设计原则。简而言之,我们将代码拆分为更小的部分,将密切相关的功能组合在一起,同时保持其他代码部分分离,以建立清晰的边界。

我们在 第一章CMake 入门 中谈到了划分 CMake 代码时讨论的列表文件。我们讲解了 include() 命令,它允许 CMake 执行来自外部文件的代码。

这种方法有助于关注点分离,但仅仅有一点点——专门的代码被提取到独立的文件中,甚至可以跨不相关的项目共享,但如果作者不小心,它仍然可能污染全局变量作用域,带入其中的内部逻辑。

你看,调用include()并不会引入任何额外的作用域或隔离,超出文件内已经定义的内容。让我们通过一个例子来看这个潜在问题,假设有一个支持小型汽车租赁公司的软件,它将包含许多源文件,定义软件的不同方面:管理客户、汽车、停车位、长期合同、维修记录、员工记录等等。如果我们将所有这些文件放在一个目录中,查找任何文件都会是一场噩梦。因此,我们在项目的主目录中创建了多个目录,并将相关文件移到其中。我们的CMakeLists.txt文件可能看起来像这样:

ch04/01-partition/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(Rental CXX)
add_executable(Rental
               main.cpp
               **cars/car.cpp** 
               # more files in other directories
) 

这看起来很好,但正如你所看到的,我们仍然在顶层文件中包含了来自嵌套目录的源文件列表!为了增加关注点分离,我们可以将源列表提取到另一个 listfile 中,并将其存储在sources变量中:

ch04/02-include/cars/cars.cmake

set(sources
    cars/car.cpp
#   more files in other directories
) 

现在我们可以使用include()命令引用这个文件,以访问sources变量:

ch04/02-include/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(Rental CXX)
**include****(cars/cars.cmake)**
add_executable(Rental
               main.cpp
               ${sources} # for cars/
) 

CMake 实际上会将sources设置为与add_executable相同的作用域,并将变量填充为所有文件。这个解决方案是可行的,但也有一些缺陷:

  • 嵌套目录中的变量会污染顶层作用域(反之亦然)

虽然在一个简单的例子中并不是问题,但在更复杂的多级树结构中,尤其是过程中使用了多个变量时,它很容易变成一个难以调试的问题。如果我们有多个包含的 listfile,它们定义了各自的sources变量,该怎么办?

  • 所有目录将共享相同的配置

随着项目的成熟,这个问题会显现出来。如果没有任何粒度控制,我们只能将每个源文件视为相同,无法为某些代码部分指定不同的编译标志,选择更新的语言版本,或在代码的特定区域消除警告。所有内容都是全局的,这意味着我们需要同时对所有翻译单元进行更改。

  • 这里有共享的编译触发器

对配置的任何更改都会导致所有文件必须重新编译,即使这些更改对其中一些文件来说毫无意义。

  • 所有路径都是相对于顶层的

请注意,在cars.cmake中,我们必须提供cars/car.cpp文件的完整路径。这导致了大量重复的文本,破坏了可读性,也违反了不要重复自己DRY)的干净代码原则(不必要的重复容易导致错误)。重命名一个目录将变得非常困难。

另一种选择是使用add_subdirectory()命令,它引入了变量作用域等。让我们来看看。

使用子目录管理作用域

在项目结构中,按照文件系统的自然结构来组织项目是一种常见做法,其中嵌套的目录表示应用程序的离散元素,如业务逻辑、图形用户界面(GUI)、API 和报告,最后是单独的测试、外部依赖、脚本和文档目录。为了支持这一概念,CMake 提供了以下命令:

add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL]) 

正如之前所述,这将把源目录添加到我们的构建中。可选地,我们可以提供一个路径,将构建的文件写入该路径(binary_dir或构建树)。EXCLUDE_FROM_ALL关键字将禁用自动构建子目录中定义的目标(我们将在下一章中讨论目标)。这对于将项目中不需要用于核心功能的部分(如示例扩展)分离开来非常有用。

add_subdirectory()将评估source_dir路径(相对于当前目录),并解析其中的CMakeLists.txt文件。这个文件将在目录作用域内进行解析,从而消除了前述方法中的问题:

  • 变量被隔离在嵌套的作用域中。

  • 嵌套的产物可以独立配置。

  • 修改嵌套的CMakeLists.txt文件不需要重新构建不相关的目标。

  • 路径是本地化到目录的,如果需要,也可以将其添加到父级包含路径中。

这是我们的add_subdirectory()示例的目录结构:

├── CMakeLists.txt
├── cars
│   ├── CMakeLists.txt
│   ├── car.cpp
│   └── car.h
└── main.cpp 

在这里,我们有两个CMakeLists.txt文件。顶层文件将使用嵌套目录cars

ch04/03-add_subdirectory/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(Rental CXX)
add_executable(Rental main.cpp)
add_subdirectory(cars)
target_link_libraries(Rental PRIVATE cars) 

最后一行用于将cars目录中的产物链接到Rental可执行文件。这是一个特定于目标的命令,我们将在下一章中详细讨论:第五章与目标一起工作

让我们看看嵌套的列表文件长什么样:

ch04/03-add_subdirectory/cars/CMakeLists.txt

add_library(cars OBJECT
    car.cpp
#   more files in other directories
)
target_include_directories(cars PUBLIC .) 

在这个示例中,我使用了add_library()来生成一个全局可见的目标cars,并通过target_include_directories()cars目录添加到其公共包含目录中。这告诉 CMake cars.h文件的位置,因此在使用target_link_libraries()时,main.cpp文件可以直接使用头文件而无需提供相对路径:

#include "car.h" 

我们可以在嵌套列表文件中看到add_library()命令,那么在这个示例中我们是否开始使用库了呢?其实没有。因为我们使用了OBJECT关键字,我们表示我们只关心生成目标文件(就像在之前的示例中那样)。我们只是将它们归类到一个单独的逻辑目标(cars)下。你可能已经对目标有了初步的了解。保持这个想法——我们将在下一章详细解释。

何时使用嵌套项目

在前一节中,我们简要提到了 add_subdirectory() 命令中使用的 EXCLUDE_FROM_ALL 参数,用来标识我们代码库中的附加元素。CMake 文档建议,如果这些部分存在于源代码树中,它们应该在各自的 CMakeLists.txt 文件中有自己的 project() 命令,这样它们就可以生成自己的构建系统,并且可以独立构建。

还有其他场景会有用吗?当然。例如,一个场景是在你与多个 C++ 项目合作,它们在一个 CI/CD 管道中构建(可能是在构建一个框架或一组库时)。或者,可能是你正在将构建系统从一个遗留的解决方案(例如 GNU Make)迁移过来,它使用普通的 makefile。在这种情况下,你可能希望有一个选项,逐步将它们拆解成更独立的部分——可能是将它们放入一个独立的构建管道,或者仅仅是在一个更小的范围内工作,IDE 如 CLion 可以加载它们。你可以通过在嵌套目录中的列表文件中添加 project() 命令来实现这一点。只需别忘了在前面加上 cmake_minimum_required()

由于支持项目嵌套,我们是否可以以某种方式将并行构建的相关项目连接起来?

保持外部项目为外部项目

虽然从技术上讲,在 CMake 中引用一个项目的内部内容到另一个项目是可能的,但这并不是常规做法,也不推荐这样做。CMake 确实提供了一些支持,包括 load_cache() 命令用于从另一个项目的缓存中加载值。然而,使用这种方法可能会导致循环依赖和项目耦合的问题。最好避免使用这个命令,并做出决定:我们的相关项目应该嵌套在一起,通过库连接,还是合并为一个单独的项目?

这是我们可以使用的分区工具:包含列表文件添加子目录嵌套项目。那么我们该如何使用它们,才能让我们的项目保持可维护、易于导航和扩展呢?为了做到这一点,我们需要一个明确定义的项目结构。

思考项目结构

不是什么秘密,随着项目的增长,找到其中的内容会变得越来越困难——无论是在列表文件中还是在源代码中。因此,从一开始就保持项目的清晰性非常重要。

假设有这样一个场景,你需要交付一些重要且时间紧迫的变更,而这些变更在项目中的两个目录里都不太适合。现在,你需要额外推送一个 清理提交,以重构文件层次结构,使其更适合你的更改。或者,更糟的是,你决定将它们随便放在任何地方,并添加一个 TODO,计划以后再处理这个问题。

在一年中的过程中,这些问题不断积累,技术债务也在增长,维护代码的成本也随之增加。当在生产环境中出现严重漏洞需要快速修复时,或者当不熟悉代码库的人需要进行偶尔的更改时,这种情况会变得非常麻烦。

因此,我们需要一个好的项目结构。那么这意味着什么呢?我们可以借鉴软件开发中其他领域(如系统设计)的一些规则。项目应具有以下特征:

  • 易于导航和扩展

  • 边界清晰(项目特定的文件应仅包含在项目目录中)

  • 单独的目标遵循层次树结构

并没有唯一的解决方案,但在各种在线项目结构模板中,我建议使用这个模板,因为它简单且具有可扩展性:

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

图 4.1:项目结构示例

该项目概述了以下组件的目录:

  • cmake:共享宏和函数、find_modules,以及一次性脚本

  • src:二进制文件和库的源文件和头文件

  • test:自动化测试的源代码

在这个结构中,CMakeLists.txt 文件应该存在于以下目录中:顶层项目目录、testsrc 以及所有其子目录。主列表文件不应该自己声明任何构建步骤,而是应配置项目的一般设置,并通过 add_subdirectory() 命令将构建责任委托给嵌套的列表文件。这些列表文件如果需要,还可以将工作委托给更深层次的列表文件。

一些开发者建议将可执行文件和库分开,创建两个顶级目录而不是一个:srclib。CMake 对这两种工件的处理是相同的,在这个层级上的分离其实并不重要。如果你喜欢这种模型,可以随意使用它。

src 目录中拥有多个子目录对于大型项目非常有用。但如果你只构建一个单一的可执行文件或库,你可以跳过这些子目录,直接将源文件存储在 src 中。无论如何,记得在那里添加一个 CMakeLists.txt 文件,并执行任何嵌套的列表文件。这是你一个简单目标的文件树结构示例:

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

图 4.2:可执行文件的目录结构

图 4.1 中,我们看到 src 目录根目录下有一个 CMakeLists.txt 文件 – 它将配置关键的项目设置,并包含所有来自嵌套目录的列表文件。app1 目录(在 图 4.2 中可见)包含另一个 CMakeLists.txt 文件,以及 .cpp 实现文件:class_a.cppclass_b.cpp。还有一个包含可执行程序入口点的 main.cpp 文件。CMakeLists.txt 文件应该定义一个目标,使用这些源文件来构建一个可执行文件 – 接下来的一章中我们将学习如何做到这一点。

我们的头文件被放置在include目录中,可以用于为其他 C++翻译单元声明符号。

接下来,我们有一个lib3目录,它包含专门用于该可执行文件的库(在项目其他地方或外部使用的库应该位于src目录中)。这种结构提供了极大的灵活性,并且允许轻松扩展项目。当我们继续添加更多类时,我们可以方便地将它们分组到库中,以提高编译速度。让我们看看一个库是怎样的:

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

图 4.3:库的目录结构

库应遵循与可执行文件相同的结构,但有一个小区别:一个可选的lib1目录被添加到包含目录中。当库计划在项目外部使用时,该目录会被包含。它包含其他项目在编译过程中将使用的公共头文件。当我们开始构建自己的库时,我们将在第七章使用 CMake 编译 C++源文件中进一步讨论这个话题。

所以,我们已经讨论了文件在目录结构中的布局方式。现在,是时候看看各个CMakeLists.txt文件是如何汇聚在一起形成一个完整的项目,并且它们在更大场景中的作用是什么。

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

图 4.4:CMake 如何在一个项目中合并列表文件

在前面的图中,每个框表示一个CMakeLists.txt列表文件,位于每个目录中,而斜体标签表示每个文件执行的操作(从上到下)。让我们从 CMake 的角度再次分析这个项目(有关所有详细信息,请查看ch04/05-structure目录中的示例):

  1. 执行从项目根目录开始——也就是从位于源代码树顶部的CMakeLists.txt列表文件开始。该文件将设置 CMake 的最低要求版本及适当的策略,设置项目名称、支持的语言和全局变量,并包含cmake目录中的文件,以便它们的内容在全局范围内可用。

  2. 下一步是通过调用add_subdirectory(src bin)命令进入src目录的范围(我们希望将编译的产物放在<binary_tree>/bin而不是<binary_tree>/src)。

  3. CMake 读取src/CMakeLists.txt文件,发现它的唯一目的是添加四个嵌套的子目录:app1app2lib1lib2

  4. CMake 进入app1的变量范围,了解另一个嵌套的库lib3,它有自己的CMakeLists.txt文件;然后进入lib3的范围。正如你可能注意到的,这是一个深度优先遍历目录结构。

  5. lib3库添加了一个与其同名的静态库目标。CMake 返回到app1的父范围。

  6. app1子目录添加了一个依赖于lib3的可执行文件。CMake 返回到src的父范围。

  7. CMake 会继续进入剩余的嵌套作用域并执行它们的 listfiles,直到所有 add_subdirectory() 调用完成。

  8. CMake 返回到顶层作用域并执行剩余的命令 add_subdirectory(test)。每次,CMake 都会进入新的作用域并执行适当 listfile 中的命令。

  9. 所有的目标都被收集并检查其正确性。CMake 现在拥有生成构建系统所需的所有必要信息。

需要注意的是,前面的步骤是按我们在 listfiles 中编写命令的顺序执行的。在某些情况下,这个顺序非常重要,而在其他情况下,它可能不那么关键。我们将在下一章,第五章与目标工作 中深入探讨这个问题,理解它的含义。

那么,什么时候是创建包含项目所有元素的目录的合适时机呢?我们应该从一开始就创建所有未来需要的内容并保持目录为空,还是等到我们真正有了需要放入各自类别的文件时再创建?这是一个选择——我们可以遵循极限编程XP)规则YAGNI你不会需要它),或者我们可以尝试让我们的项目具有未来适应性,为未来的开发者打下良好的基础。

尝试在这些方法之间寻找良好的平衡——如果你怀疑你的项目将来可能需要一个 extern 目录,那么就添加它(你的版本控制系统可能需要一个空的 .keep 文件来将目录检查到仓库中)。

另一种有效的方式来引导他人放置外部依赖项是创建一个 README 文件,概述推荐的结构。这对于将来将参与项目的经验较少的程序员特别有帮助。你可能自己也观察到过:开发人员往往不愿意创建目录,特别是在项目的根目录下。如果我们提供一个良好的项目结构,其他人会更倾向于遵循它。

一些项目几乎可以在所有环境中构建,而另一些则对其要求非常特定。顶层的 listfile 是确定适当行动方案的理想位置。让我们看看如何做到这一点。

环境作用域

CMake 提供了多种方式来查询环境,通过 CMAKE_ 变量、ENV 变量和特殊命令。例如,收集到的信息可以用来支持跨平台脚本。这些机制使我们能够避免使用平台特定的 shell 命令,这些命令可能不容易移植,或者在不同环境中有不同的命名。

对于性能关键型应用,了解构建平台的所有特性(例如,指令集、CPU 核心数等)会非常有用。然后,这些信息可以传递给编译后的二进制文件,以便它们能够优化到最佳状态(我们将在下一章学习如何进行传递)。让我们来探索 CMake 提供的本地信息。

检测操作系统

有很多情况需要了解目标操作系统是什么。即便是像文件系统这样常见的事情,在 Windows 和 Unix 之间也有很大的不同,例如区分大小写、文件路径结构、扩展名的存在、权限等等。一台系统上可用的大多数命令在另一台系统上都不可用;它们可能会有不同的名称(例如,Unix 中是ifconfig,而 Windows 中是ipconfig),或者输出的内容完全不同。

如果你需要在一个 CMake 脚本中支持多个目标操作系统,只需检查CMAKE_SYSTEM_NAME变量,以便可以根据需要采取相应的措施。下面是一个简单的示例:

if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
  message(STATUS "Doing things the usual way")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
  message(STATUS "Thinking differently")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
  message(STATUS "I'm supported here too.")
elseif(CMAKE_SYSTEM_NAME STREQUAL "AIX")
  message(STATUS "I buy mainframes.")
else()
  message(STATUS "This is ${CMAKE_SYSTEM_NAME} speaking.")
endif() 

如果需要,可以使用一个包含操作系统版本的变量:CMAKE_SYSTEM_VERSION。不过,我的建议是尽量使你的解决方案尽可能不依赖于具体系统,并使用内置的 CMake 跨平台功能。特别是在文件系统操作方面,你应该使用在附录中描述的file()命令。

交叉编译 —— 主机和目标系统是什么?

交叉编译是指在一台机器上编译代码,以便在不同的目标平台上执行。例如,使用适当的工具集,您可以通过在 Windows 机器上运行 CMake 来为 Android 编译应用程序。尽管交叉编译超出了本书的范围,但了解它如何影响 CMake 的某些部分是很重要的。

允许交叉编译的必要步骤之一是将CMAKE_SYSTEM_NAMECMAKE_SYSTEM_VERSION变量设置为适合你正在编译的操作系统的值(CMake 文档称其为目标系统)。用于执行构建的操作系统被称为主机系统

无论配置如何,主机系统的信息始终可以通过名称中包含HOST关键字的变量访问:CMAKE_HOST_SYSTEMCMAKE_HOST_SYSTEM_NAMECMAKE_HOST_SYSTEM_PROCESSORCMAKE_HOST_SYSTEM_VERSION

还有一些包含HOST关键字的变量,记住它们明确引用的是主机系统。否则,所有变量引用的都是目标系统(通常情况下,这也是主机系统,除非我们在进行交叉编译)。

如果你有兴趣深入了解交叉编译,我建议参考 CMake 文档:cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html

缩写变量

CMake 会预定义一些变量,这些变量会提供主机和目标系统的信息。如果使用了特定的系统,则会将相应的变量设置为非 false 值(即 1true):

  • ANDROID, APPLE, CYGWIN, UNIX, IOS, WIN32, WINCE, WINDOWS_PHONE

  • CMAKE_HOST_APPLE, CMAKE_HOST_SOLARIS, CMAKE_HOST_UNIX, CMAKE_HOST_WIN32

WIN32CMAKE_HOST_WIN32 变量对于 32 位和 64 位版本的 Windows 和 MSYS 为 true(此值是为了兼容性保留的)。另外,UNIX 对于 Linux、macOS 和 Cygwin 为 true

主机系统信息

CMake 可以提供更多变量,但为了节省时间,它不会查询环境中不常需要的信息,比如 处理器是否支持 MMX总物理内存是多少。这并不意味着这些信息不可用——你只需要显式地请求它们,方法是使用以下命令:

cmake_host_system_information(RESULT <VARIABLE> QUERY <KEY>...) 

我们需要提供目标变量和我们感兴趣的键列表。如果只提供一个键,则变量将包含一个值;否则,它将是一个值的列表。我们可以请求关于环境和操作系统的许多详细信息:

Key 描述
HOSTNAME 主机名
FQDN 完全限定域名
TOTAL_VIRTUAL_MEMORY 总虚拟内存(单位:MiB)
AVAILABLE_VIRTUAL_MEMORY 可用虚拟内存(单位:MiB)
TOTAL_PHYSICAL_MEMORY 总物理内存(单位:MiB)
AVAILABLE_PHYSICAL_MEMORY 可用物理内存(单位:MiB)
OS_NAME 如果此命令存在,uname -s 的输出;可能为 WindowsLinuxDarwin
OS_RELEASE 操作系统子类型,例如 on Windows Professional
OS_VERSION 操作系统版本 ID
OS_PLATFORM On Windows,$ENV{PROCESSOR_ARCHITECTURE}。在 Unix/macOS 上,使用 uname -m

如果需要,我们甚至可以查询特定处理器的信息:

Key 描述
NUMBER_OF_LOGICAL_CORES 逻辑核心数量
NUMBER_OF_PHYSICAL_CORES 物理核心数量
HAS_SERIAL_NUMBER 如果处理器有序列号,则为 1
PROCESSOR_SERIAL_NUMBER 处理器序列号
PROCESSOR_NAME 人类可读的处理器名称
PROCESSOR_DESCRIPTION 人类可读的完整处理器描述
IS_64BIT 如果处理器是 64 位的,则为 1
HAS_FPU 如果处理器有浮点运算单元,则为 1
HAS_MMX 如果处理器支持 MMX 指令,则为 1
HAS_MMX_PLUS 如果处理器支持扩展 MMX 指令,则为 1
HAS_SSE 如果处理器支持 SSE 指令,则为 1
HAS_SSE2 如果处理器支持 SSE2 指令,则为 1
HAS_SSE_FP 如果处理器支持 SSE 浮点指令,则为 1
HAS_SSE_MMX 如果处理器支持 SSE MMX 指令,则为 1
HAS_AMD_3DNOW 如果处理器支持 3DNow 指令,则为 1
HAS_AMD_3DNOW_PLUS 如果处理器支持 3DNow+ 指令,则为 1
HAS_IA64 如果 IA64 处理器正在模拟 x86,则为 1

该平台是 32 位还是 64 位架构?

在 64 位架构中,内存地址、处理器寄存器、处理器指令、地址总线和数据总线都是 64 位宽。虽然这是一个简化的定义,但它大致说明了 64 位平台与 32 位平台的区别。

在 C++ 中,不同的架构意味着一些基本数据类型(intlong)以及指针的位宽不同。CMake 利用指针的大小来收集目标机器的信息。这些信息通过 CMAKE_SIZEOF_VOID_P 变量提供,对于 64 位,它的值为 8(因为指针宽度为 8 字节),对于 32 位,值为 4(4 字节):

if(CMAKE_SIZEOF_VOID_P EQUAL 8)
  message(STATUS "Target is 64 bits")
endif() 

系统的字节序是什么?

架构可以根据处理器的字节顺序(即数据的自然单位)分为 大端序小端序。在 大端序 系统中,最高有效字节存储在最低的内存地址,而最低有效字节存储在最高的内存地址。另一方面,在 小端序 系统中,字节顺序是反转的,最低有效字节存储在最低内存地址,最高有效字节存储在最高内存地址。

在大多数情况下,字节序并不重要,但当你编写需要便于移植的按位代码时,CMake 会为你提供一个存储在 CMAKE_<LANG>_BYTE_ORDER 变量中的 BIG_ENDIANLITTLE_ENDIAN 值,其中 <LANG> 可以是 CCXXOBJCCUDA

现在我们知道如何查询环境变量,让我们将注意力转向项目的关键设置。

配置工具链

对于 CMake 项目,工具链包含了构建和运行应用程序时使用的所有工具——例如工作环境、生成器、CMake 可执行文件本身以及编译器。

想象一下,当你的构建因为一些神秘的编译和语法错误停止时,一个经验较少的用户会有什么感受。他们必须深入源代码并尝试理解发生了什么。经过一个小时的调试,他们发现正确的解决方法是更新他们的编译器。我们能否为用户提供更好的体验,在开始构建之前检查编译器是否具备所有必需的功能?

当然!有方法可以指定这些要求。如果工具链不支持所有必需的功能,CMake 会提前停止并显示清晰的错误信息,提示用户介入。

设置 C++ 标准

我们可以考虑的初步步骤之一是指定编译器应该支持的所需 C++ 标准,以构建我们的项目。对于新项目,建议至少设置 C++14,但最好是 C++17 或 C++20。从 CMake 3.20 开始,如果编译器支持,可以将所需标准设置为 C++23。此外,从 CMake 3.25 开始,可以将标准设置为 C++26,尽管这目前只是一个占位符。

C++11 官方发布以来已经过去了十多年,它不再被视为 现代 C++ 标准。除非你的目标环境非常旧,否则不建议使用这个版本来启动项目。

坚持使用旧标准的另一个原因是如果你在构建难以升级的遗留目标。然而,C++ 委员会一直在努力保持 C++ 向后兼容,在大多数情况下,你不会遇到将标准提高到更高版本的任何问题。

CMake 支持在每个目标的基础上设置标准(如果你的代码库的某些部分非常旧,这很有用),但最好是让整个项目趋向于一个统一的标准。可以通过将 CMAKE_CXX_STANDARD 变量设置为以下值之一来完成:98111417202326,方法如下:

set(CMAKE_CXX_STANDARD 23) 

这将成为所有后续定义的目标的默认值(因此最好将其设置在根列表文件的顶部附近)。如果需要,你可以在每个目标的基础上覆盖它,方法如下:

set_property(TARGET <target> PROPERTY CXX_STANDARD <version>) 

或者:

set_target_properties(<targets> PROPERTIES CXX_STANDARD <version>) 

第二个版本允许我们在需要时指定多个目标。

坚持标准支持

前面提到的 CXX_STANDARD 属性不会阻止 CMake 继续构建,即使编译器不支持所需的版本——它被视为一种偏好设置。CMake 并不知道我们的代码是否实际使用了旧编译器中无法使用的新特性,它会尽力在现有的环境中工作。

如果我们确定这不会成功,可以设置另一个变量(该变量可以像之前的变量一样在每个目标上覆盖)以明确要求我们所需的标准:

set(CMAKE_CXX_STANDARD_REQUIRED ON) 

在这种情况下,如果系统中的编译器不支持所需的标准,用户将看到以下信息,构建将停止:

Target "Standard" requires the language dialect "CXX23" (with compiler extensions), but CMake does not know the compile flags to use to enable it. 

即使在现代环境中,要求支持 C++23 可能有些过头。但是,C++20 应该在最新的系统上没有问题,因为自 2021/2022 年以来,GCC/Clang/MSVC 都已经普遍支持它。

厂商特定的扩展

根据你所在组织实施的政策,你可能会对允许或禁用特定供应商扩展感兴趣。这些扩展是什么呢?我们可以这样说,C++标准的进展对于一些编译器生产商的需求来说有点慢,所以他们决定为语言添加自己的增强功能——如果你喜欢的话,叫做扩展。例如,C++ 技术报告 1TR1)是一个库扩展,它在这些功能普及之前就引入了正则表达式、智能指针、哈希表和随机数生成器。为了支持 GNU 项目发布的这类插件,CMake 会用-std=gnu++14替换标准编译器标志(-std=c++14)。

一方面,这可能是期望的,因为它提供了一些方便的功能。另一方面,如果你切换到不同的编译器(或者你的用户这样做了),你的代码将失去可移植性,无法编译通过。这也是一个按目标设置的属性,存在一个默认变量CMAKE_CXX_EXTENSIONS。CMake 在这里更为宽容,允许使用扩展,除非我们特别告诉它不要使用:

set(CMAKE_CXX_EXTENSIONS OFF) 

如果可能的话,我推荐这样做,因为这个选项将坚持使用与供应商无关的代码。这种代码不会给用户带来任何不必要的要求。与之前的选项类似,你可以使用set_property()按目标修改这个值。

跨过程优化

通常,编译器会在单一翻译单元的层面上优化代码,这意味着你的.cpp文件会被预处理、编译,然后优化。在这些操作过程中生成的中间文件会被传递给链接器,最终生成一个单一的二进制文件。然而,现代编译器具备在链接时执行跨过程优化(interprocedural optimization)的能力,也称为链接时优化(link-time optimization)。这使得所有编译单元可以作为一个统一的模块进行优化,原则上这将实现更好的结果(有时会以更慢的构建速度和更多的内存消耗为代价)。

如果你的编译器支持跨过程优化,使用它可能是一个不错的主意。我们将采用相同的方法。负责此设置的变量叫做CMAKE_INTERPROCEDURAL_OPTIMIZATION。但在设置它之前,我们需要确保它被支持,以避免错误:

include(CheckIPOSupported)
check_ipo_supported(RESULT ipo_supported)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ${ipo_supported}) 

如你所见,我们需要包含一个内建模块以访问check_ipo_supported()命令。如果优化不被支持,这段代码会优雅地失败,并回退到默认行为。

检查支持的编译器功能

正如我们之前讨论的,如果我们的构建失败了,最好尽早失败,这样我们可以给用户提供清晰的反馈信息并缩短等待时间。有时我们特别关心哪些 C++ 特性受支持(哪些不受支持)。CMake 会在配置阶段询问编译器,并将可用特性的列表存储在 CMAKE_CXX_COMPILE_FEATURES 变量中。我们可以编写一个非常具体的检查,询问是否有某个特性可用:

ch04/07-features/CMakeLists.txt

list(FIND CMAKE_CXX_COMPILE_FEATURES cxx_variable_templates result)
if(result EQUAL -1)
  message(FATAL_ERROR "Variable templates are required for compilation.")
endif() 

正如你可能猜到的,针对我们使用的每个特性编写测试文件是一项艰巨的任务。即使是 CMake 的作者也建议只检查某些高级的 元特性 是否存在:cxx_std_98cxx_std_11cxx_std_14cxx_std_17cxx_std_20cxx_std_23cxx_std_26。每个 元特性 表示编译器支持特定的 C++ 标准。如果你愿意,可以像我们在前面的例子中那样使用它们。

CMake 已知的特性完整列表可以在文档中找到:cmake.org/cmake/help/latest/prop_gbl/CMAKE_CXX_KNOWN_FEATURES.html

编译测试文件

当我使用 GCC 4.7.x 编译一个应用程序时,遇到了一个特别有趣的场景。我已经在编译器的参考文档中手动确认了我们使用的所有 C++11 特性都受支持。然而,解决方案仍然无法正常工作。代码默默地忽略了对标准 <regex> 头文件的调用。事实证明,这个特定的编译器存在一个 bug,导致 regex 库未实现。

没有任何单一的检查可以防止这种罕见的 bug(你也不需要去检查这些 bug!),但你可能想使用最新标准中的一些前沿实验性特性,而且你不确定哪些编译器支持它。你可以通过创建一个测试文件,使用那些特定需求的特性,在一个可以快速编译并执行的小示例中测试你的项目是否能正常工作。

CMake 提供了两个配置时命令,try_compile()try_run(),用于验证所需的所有内容是否在目标平台上受支持。

try_run() 命令提供了更多的灵活性,因为它不仅可以确保代码能够编译,还能验证代码是否正确执行(例如,你可以测试 regex 是否正常工作)。当然,这对于交叉编译场景来说不起作用(因为主机无法运行为不同目标平台构建的可执行文件)。请记住,这个检查的目的是为用户提供编译是否正常工作的快速反馈,因此它不用于运行单元测试或任何复杂的操作——保持文件尽可能简单。例如,像这样:

ch04/08-test_run/main.cpp

#include <iostream>
int main()
{
  std::cout << "Quick check if things work." << std::endl;
} 

调用try_run()其实并不复杂。我们首先设置所需的标准,然后调用try_run()并将收集到的信息打印给用户:

ch04/08-test_run/CMakeLists.txt

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
**try_run****(run_result compile_result**
        **${CMAKE_BINARY_DIR}****/test_output**
        **${CMAKE_SOURCE_DIR}****/main.cpp**
        **RUN_OUTPUT_VARIABLE output)**
message("run_result: ${run_result}")
message("compile_result: ${compile_result}")
message("output:\n" ${output}) 

这个命令一开始可能让人觉得有些复杂,但实际上只有几个参数是必需的,用于编译和运行一个非常基础的测试文件。我还额外使用了可选的RUN_OUTPUT_VARIABLE关键字来收集来自stdout的输出。

下一步是通过使用一些我们将在实际项目中使用的现代 C++特性,来扩展我们的基本测试文件——或许可以通过添加一个变参模板,看看目标机器上的编译器是否能够处理它。

最后,我们可以在条件块中检查收集到的输出是否符合我们的预期,并且当出现问题时,message(SEND_ERROR <error>)会被打印出来。记住,SEND_ERROR关键字允许 CMake 继续配置阶段,但会阻止生成构建系统。这对于在中止构建之前显示所有遇到的错误非常有用。现在我们已经知道如何确保编译可以完全完成。接下来,我们将讨论禁用源代码目录构建的问题。

禁用源代码目录构建

第一章CMake 入门中,我们讨论了源代码目录构建,并且建议始终指定构建路径为源代码之外。这不仅可以让构建树更干净、.gitignore文件更简单,还能减少你意外覆盖或删除源文件的风险。

如果你想提前停止构建,可以使用以下检查:

ch04/09-in-source/CMakeLists.txt

cmake_minimum_required(VERSION 3.26.0)
project(NoInSource CXX)
if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
  message(FATAL_ERROR "In-source builds are not allowed")
endif()
message("Build successful!") 

如果你想了解更多关于 STR 前缀和变量引用的信息,请回顾第二章CMake 语言

但是请注意,无论你在前面的代码中做什么,似乎 CMake 仍然会创建一个CMakeFiles/目录和一个CMakeCache.txt文件。

你可能会在网上看到一些建议,使用未文档化的变量来确保用户在任何情况下都不能在源目录中写入。依赖于未文档化的变量来限制在源目录中的写入是不推荐的。这些变量可能在所有版本中都不起作用,并且可能在没有警告的情况下被移除或修改。

如果你担心用户将这些文件留在源代码目录中,可以将它们添加到.gitignore(或等效文件)中,并更改提示信息,要求手动清理。

总结

本章介绍了有价值的概念,为构建健壮和具有未来保障的项目奠定了坚实的基础。我们讨论了设置最小 CMake 版本,以及配置项目的基本方面,如名称、语言和元数据字段。建立这些基础使得我们的项目能够有效地扩展。

我们探索了项目划分,比较了基本的include()add_subdirectory的使用,后者带来了诸如作用域变量管理、简化路径和增加模块化等好处。能够创建嵌套项目并分别构建它们,在逐步将代码分解为更独立的单元时证明是有用的。在理解了可用的划分机制之后,我们深入探讨了如何创建透明、弹性和可扩展的项目结构。我们考察了 CMake 如何遍历 listfile 以及配置步骤的正确顺序。接下来,我们研究了如何为目标和主机机器设置环境作用域,它们之间的差异是什么,以及通过不同查询可以获得哪些关于平台和系统的信息。我们还介绍了工具链配置,包括指定所需的 C++版本、处理特定供应商的编译器扩展以及启用重要的优化。我们学习了如何测试编译器所需的功能,并执行示例文件以测试编译支持。

尽管到目前为止涉及的技术方面对于项目至关重要,但它们不足以使项目真正有用。为了增加项目的实用性,我们需要理解目标(targets)的概念。我们之前简要地提到过这一话题,但现在我们已经准备好全面探讨它,因为我们已经充分理解了相关的基础知识。在下一章中将介绍的目标(targets),将在进一步提升项目的功能性和有效性方面发挥关键作用。

进一步阅读

有关本章涉及主题的更多信息,请参阅以下链接:

留下评论!

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

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

第五章:使用目标

在 CMake 中,整个应用程序可以从一个源代码文件(例如经典的 helloworld.cpp)构建。但同样,也可以创建一个项目,其中可执行文件由多个源文件构建:几十个甚至成千上万个。许多初学者遵循这种路径:他们只用几个文件来构建二进制文件,并让他们的项目自然发展,缺乏严格的规划。他们会根据需要不断添加文件,直到所有内容都直接链接到一个二进制文件,没有任何结构。

作为软件开发者,我们有意地划定边界并指定组件,以将一个或多个翻译单元(.cpp 文件)分组。我们这样做是为了提高代码可读性、管理耦合性和内聚性、加速构建过程,并最终发现和提取可重用的组件,使其成为自治单元。

每个大型项目都会促使你引入某种形式的分区。这就是 CMake 目标派上用场的地方。CMake 目标代表一个专注于特定目标的逻辑单元。目标之间可以有依赖关系,它们的构建遵循声明式方法。CMake 会负责确定构建目标的正确顺序,尽可能进行并行构建,并按需执行必要的步骤。作为一般原则,当一个目标被构建时,它会生成一个可以被其他目标使用的 artifact,或者作为构建过程的最终输出。

请注意 artifact 这个词的使用。我故意避免使用特定术语,因为 CMake 提供了灵活性,不仅限于生成可执行文件或库。实际上,我们可以利用生成的构建系统来生成各种类型的输出:额外的源文件、头文件、目标文件、档案、配置文件等等。唯一的要求是一个命令行工具(如编译器)、可选的输入文件和一个指定的输出路径。

目标是一个非常强大的概念,大大简化了构建项目的过程。理解它们的功能,并掌握如何以优雅和有组织的方式配置它们至关重要。这些知识确保了一个顺畅和高效的开发体验。

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

  • 理解目标的概念

  • 设置目标的属性

  • 编写自定义命令

技术要求

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

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

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

请务必用适当的路径替换 <build tree><source tree> 占位符。提醒一下:build tree 是目标/输出目录的路径,而 source tree 是你的源代码所在的路径。

理解目标的概念

如果你曾使用过 GNU Make,你已经见过目标的概念。实际上,它是一个构建系统遵循的配方,用来将一组文件编译成另一个文件。它可以是一个 .cpp 实现文件编译成的 .o 目标文件,也可以是多个 .o 文件打包成的 .a 静态库。在构建系统中,目标及其转换有很多种组合和可能性。

然而,CMake 允许你节省时间,并跳过定义这些步骤的中间过程;它在更高的抽象层次上工作。它理解大多数语言如何直接从源文件生成可执行文件。因此,你不需要像使用 GNU Make 那样编写明确的命令来编译你的 C++ 目标文件。只需使用 add_executable() 命令,后跟可执行目标的名称和源文件列表即可:

add_executable(app1 a.cpp b.cpp c.cpp) 

我们在前面的章节中已经使用过这个命令,我们也知道可执行目标在实际应用中的使用方式——在生成步骤中,CMake 会创建一个构建系统,并填充适当的配方,将每个源文件编译并链接成一个单一的二进制可执行文件。

在 CMake 中,我们可以使用这三个命令来创建一个目标:

  • add_executable()

  • add_library()

  • add_custom_target()

在构建可执行文件或库之前,CMake 会检查生成的输出是否比源文件更新。这个机制帮助 CMake 避免重新创建已经是最新的产物。通过比较时间戳,CMake 可以有效地识别哪些目标需要重新构建,从而减少不必要的重新编译。

所有定义目标的命令都要求将目标的名称作为第一个参数提供,以便在后续的命令中引用这些目标,诸如target_link_libraries()target_sources()target_include_directories()等命令都可以用到目标。我们稍后会学习这些命令,但现在,让我们仔细看看我们可以定义什么样的目标。

定义可执行目标

定义可执行目标的命令 add_executable() 不言自明(我们在前面的章节中已经依赖并使用了这个命令)。它的正式结构如下:

add_executable(<name> [WIN32] [MACOSX_BUNDLE]
               [EXCLUDE_FROM_ALL]
               [source1] [source2 ...]) 

如果我们为 Windows 编译,通过添加可选参数 WIN32 关键字,我们将生成一个不会显示默认控制台窗口的可执行文件(通常我们可以在控制台窗口中看到输出流 std::cout)。相反,应用程序将期望生成自己的图形用户界面(GUI)。

下一个可选参数MACOSX_BUNDLE在某种程度上类似;它使得为 macOS/iOS 生成的应用程序可以从 Finder 中启动,作为 GUI 应用程序。

EXCLUDE_FROM_ALL关键字在使用时,会阻止可执行目标在常规默认构建中被构建。这样的目标必须在构建命令中明确提到:

cmake --build -t <target> 

最后,我们需要提供将被编译成目标的源代码列表。支持以下扩展:

  • 对于 C 语言:cm

  • 对于 C++语言:CMc++cccppcxxmmmmppCPPixxcppmccmcxxmc++m

请注意,我们没有将任何头文件添加到源代码列表中。这可以通过提供包含这些文件的目录路径给target_include_directories()命令来隐式完成,或者通过使用target_sources()命令的FILE_SET功能(在 CMake 3.23 中新增)。这是可执行文件的重要话题,但由于其复杂且与目标相互独立,我们将在第七章使用 CMake 编译 C++源代码中深入探讨其细节。

定义库目标

定义库与定义可执行文件非常相似,但当然,它不需要定义如何处理 GUI 方面的关键字。以下是该命令的签名:

add_library(<name> [STATIC | SHARED | MODULE]
            [EXCLUDE_FROM_ALL]
            [<source>...]) 

关于名称、排除所有 和源代码匹配可执行目标的规则完全一致。唯一的区别在于STATICSHAREDMODULE关键字。如果你有使用库的经验,你会知道这些关键字定义了 CMake 将生成哪种类型的构件:静态链接库、共享(动态)库或模块。再一次,这确实是一个庞大的话题,将在第八章链接可执行文件和库中深入讲解。

自定义目标

自定义目标与可执行文件或库有所不同。它们通过执行明确给定的命令行,扩展了 CMake 默认提供的构建功能;例如,它们可以用于:

  • 计算其他二进制文件的校验和。

  • 运行代码清理工具并收集结果。

  • 将编译报告发送到指标管道。

正如你从这个列表中可以猜到的,自定义目标仅在相当复杂的项目中有用,因此我们将仅介绍基本内容,继续深入更重要的主题。

定义自定义目标时,使用以下语法(为了简洁,某些选项已被省略):

add_custom_target(Name [ALL] [COMMAND command2 [args2...] ...]) 

自定义目标有一些需要考虑的缺点。由于它们涉及 Shell 命令,可能是系统特定的,从而限制了可移植性。此外,自定义目标可能不会为 CMake 提供一种直接的方法来确定生成的具体构件或副产品(如果有的话)。

自定义目标与可执行文件和库不同,不会进行陈旧性检查(它们不会验证源文件是否比二进制文件更新),因为默认情况下,它们没有被添加到依赖关系图中(因此ALL关键字与EXCLUDE_FROM_ALL正好相反)。让我们来了解一下这个依赖关系图的内容。

依赖关系图

成熟的应用程序通常由多个组件构建而成,特别是内部库。从结构角度来看,将项目进行划分是有用的。当相关的内容被打包成一个单一的逻辑实体时,它们可以与其他目标进行链接:另一个库或一个可执行文件。当多个目标使用相同的库时,这尤其方便。请看一下图 5.1,它描述了一个示例性的依赖关系图:

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

图 5.1:BankApp 项目中依赖关系构建的顺序

在这个项目中,我们有两个库、两个可执行文件和一个自定义目标。我们的用例是为用户提供一个带有良好图形界面的银行应用程序(GuiApp),以及一个命令行版本,作为自动化脚本的一部分(TerminalApp)。这两个可执行文件都依赖于相同的Calculations库,但只有其中一个需要Drawing库。为了确保我们的应用程序二进制文件是从可靠来源下载的,我们还会计算一个校验和,并通过单独的安全渠道分发它。CMake 在为这样的解决方案编写 list 文件时非常灵活:

ch05/01-targets/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(BankApp CXX)
add_executable(terminal_app terminal_app.cpp)
add_executable(gui_app gui_app.cpp)
target_link_libraries(terminal_app calculations)
target_link_libraries(gui_app calculations drawing)
add_library(calculations calculations.cpp)
add_library(drawing drawing.cpp)
add_custom_target(checksum ALL
    COMMAND sh -c "cksum terminal_app>terminal.ck"
    COMMAND sh -c "cksum gui_app>gui.ck"
    BYPRODUCTS terminal.ck gui.ck
    COMMENT "Checking the sums..."
) 

我们通过使用target_link_libraries()命令将我们的库与可执行文件链接。如果没有这个命令,生成可执行文件时将因为未定义的符号而失败。你有没有注意到我们在声明任何库之前就调用了这个命令?当 CMake 配置项目时,它会收集有关目标及其属性的信息——它们的名称、依赖关系、源文件以及其他细节。

在解析所有文件之后,CMake 将尝试构建一个依赖关系图。像所有有效的依赖关系图一样,它们是有向无环图DAGs)。这意味着有明确的方向,指示哪个目标依赖于哪个,且这些依赖关系不能形成循环。

当我们在构建模式下执行cmake时,生成的构建系统将检查我们定义了哪些顶级目标,并递归地构建它们的依赖关系。让我们考虑一下图 5.1中的示例:

  1. 从顶部开始,构建第 1 组中的两个库。

  2. CalculationsDrawing库构建完成后,构建第 2 组——GuiAppTerminalApp

  3. 构建一个校验和目标;运行指定的命令行以生成校验和(cksum是一个 Unix 的校验和工具,这意味着该示例在其他平台上无法构建)。

但是有一个小问题——上述解决方案并不能保证校验和目标在可执行文件之后构建。CMake 不知道校验和依赖于可执行二进制文件的存在,因此它可以自由地先开始构建校验和。为了解决这个问题,我们可以将add_dependencies()命令放在文件的最后:

add_dependencies(checksum terminal_app gui_app) 

这将确保 CMake 理解校验和目标与可执行文件之间的关系。

这很好,但target_link_libraries()add_dependencies()之间有什么区别?target_link_libraries()是用来与实际库配合使用的,并允许你控制属性传播。第二个则仅用于顶级目标,用来设置它们的构建顺序。

随着项目复杂度的增加,依赖树变得越来越难以理解。我们如何简化这个过程?

可视化依赖关系

即使是小型项目也可能很难理解并与其他开发人员共享。一个简洁的图表在这里会大有帮助。毕竟,一图胜千言。我们可以像我在图 5.1中做的那样,自己动手绘制图表。但这既繁琐又需要在项目变动时更新。幸运的是,CMake 有一个很棒的模块,可以生成dot/graphviz格式的依赖图,并且它支持内部和外部依赖!

要使用它,我们可以简单地执行以下命令:

cmake --graphviz=test.dot . 

该模块将生成一个文本文件,我们可以将其导入到 Graphviz 可视化软件中,Graphviz 可以渲染图像或生成 PDF 或 SVG 文件,这些文件可以作为软件文档的一部分存储。每个人都喜欢出色的文档,但几乎没有人喜欢创建它——现在,你不需要做这件事了!

自定义目标默认不可见,我们需要创建一个特殊的配置文件CMakeGraphVizOptions.cmake,它将允许我们自定义图形。使用set(GRAPHVIZ_CUSTOM_TARGETS TRUE)命令可以在图形中启用自定义目标:

ch05/01-targets/CMakeGraphVizOptions.cmake

set(GRAPHVIZ_CUSTOM_TARGETS TRUE) 

其他选项允许添加图表名称、标题和节点前缀,并配置哪些目标应包含或排除在输出中(按名称或类型)。有关CMakeGraphVizOptions模块的完整描述,请访问官方的 CMake 文档。

如果你很着急,你甚至可以直接通过浏览器在这个地址运行 Graphviz:dreampuf.github.io/GraphvizOnline/

你所需要做的就是将test.dot文件的内容复制并粘贴到左侧窗口,你的项目就会被可视化(图 5.2)。很方便,不是吗?

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

图 5.2:BankApp 示例在 Graphviz 中的可视化

使用这种方法,我们可以快速查看所有显式定义的目标。

现在我们理解了目标的概念,知道如何定义不同类型的目标,包括可执行文件、库和自定义目标,以及如何创建依赖图并将其打印出来。让我们利用这些信息深入探讨,看看如何配置这些目标。

设置目标属性

目标具有类似于 C++ 对象字段的属性。这些属性中有些是可以修改的,有些则是只读的。CMake 定义了一个庞大的“已知属性”列表(见 进一步阅读 部分),根据目标的类型(可执行文件、库或自定义目标),可以使用不同的属性。你也可以根据需要添加自己的属性。使用以下命令来操作目标的属性:

get_target_property(<var> <target> <property-name>)
set_target_properties(<target1> <target2> ...
                      PROPERTIES <prop1-name> <value1>
                      <prop2-name> <value2> ...) 

要在屏幕上打印目标属性,我们首先需要将其存储在 <var> 变量中,然后通过消息将其传递给用户。属性的读取必须逐一进行;而在目标上设置属性时,可以同时为多个目标指定多个属性。

属性的概念并非目标独有;CMake 还支持设置其他作用域的属性:GLOBALDIRECTORYSOURCEINSTALLTESTCACHE。要操作各种属性,可以使用通用的 get_property()set_property() 命令。在某些项目中,你会看到这些低级命令用于执行 set_target_properties() 命令所做的事情,只是工作量稍大一些:

set_property`(`TARGET `<target>` PROPERTY `<name> <value>)` 

一般来说,尽量使用尽可能多的高级命令。在某些情况下,CMake 提供了简写命令,并附带了额外的机制。例如,add_dependencies(<target> <dep>) 是一个简写,用于将依赖项添加到 MANUALLY_ADDED_DEPENDENCIES 目标属性中。在这种情况下,我们可以像查询任何其他属性一样使用 get_target_property() 查询它。然而,我们不能使用 set_target_properties() 来修改它(它是只读的),因为 CMake 坚持使用 add_dependencies() 命令来限制操作只能是附加依赖项。

当我们在接下来的章节中讨论编译和链接时,我们会介绍更多的属性设置命令。与此同时,让我们专注于如何将一个目标的属性传递给另一个目标。

什么是传递性使用要求?

让我们达成共识,命名确实很难,有时最终会得到一个难以理解的标签。“传递性使用要求”不幸地就是你在 CMake 在线文档中会遇到的那些难以理解的标题之一。让我们拆解一下这个奇怪的名字,并尝试提出一个更容易理解的术语。

从中间术语开始:使用。正如我们之前讨论的,一个目标可能依赖于另一个目标。CMake 文档有时将这种依赖关系称为 使用,就像一个目标使用另一个目标一样。

会有这样的情况:当某个被使用的目标为自己设置了特定的属性依赖,这些属性或依赖反过来成为使用该目标的其他目标需求:链接一些库,包含一个目录,或者需要特定的编译器特性。

我们难题的最后一部分,传递性,正确描述了行为(可能可以简化一点)。CMake 将被使用目标的一些属性/需求附加到使用目标的属性中。可以说,一些属性可以隐式地在目标之间传递(或者简单地传播),因此更容易表达依赖关系。

简化这个概念,我将其视为传播的属性,它们在源目标(被使用的目标)和目标使用者(使用其他目标的目标)之间传播。

让我们通过一个具体的例子来理解它为什么存在以及它是如何工作的:

target_compile_definitions(<source> <INTERFACE|PUBLIC|PRIVATE> [items1...]) 

这个目标命令将填充COMPILE_DEFINITIONS属性到一个<source>目标中。编译定义就是传递给编译器的-Dname=definition标志,用于配置 C++预处理器定义(我们将在第七章使用 CMake 编译 C++源代码中详细讲解)。这里有趣的部分是第二个参数。我们需要指定三个值中的一个,INTERFACEPUBLICPRIVATE,来控制该属性应该传递给哪些目标。现在,别把这些和 C++的访问控制符混淆——这是一个独立的概念。

传播关键字是这样工作的:

  • PRIVATE设置源目标的属性。

  • INTERFACE设置目标使用者的属性。

  • PUBLIC设置源目标和目标使用者的属性。

当一个属性不需要传递给任何目标时,设置为PRIVATE。当需要进行这样的传递时,使用PUBLIC。如果你处在一种情况,源目标在其实现(.cpp文件)中并不使用该属性,而只在头文件中使用,并且这些头文件被传递给消费者目标,那么应该使用INTERFACE关键字。

这在背后是如何工作的呢?为了管理这些属性,CMake 提供了一些命令,比如前面提到的target_compile_definitions()。当你指定PRIVATEPUBLIC关键字时,CMake 会将提供的值存储到目标的属性中,在这个例子中是COMPILE_DEFINITIONS。此外,如果关键字是INTERFACEPUBLIC,CMake 会将值存储到带有INTERFACE_前缀的属性中——INTERFACE_COMPILE_DEFINITIONS。在配置阶段,CMake 会读取源目标的接口属性,并将它们的内容附加到目标使用者上。就是这样——传播的属性,或者 CMake 所称的传递性使用需求。

使用set_target_properties()命令管理的属性可以在cmake.org/cmake/help/latest/manual/cmake-properties.7.html中找到,位于目标上的属性部分(并非所有目标属性都是传递性的)。以下是最重要的属性:

  • COMPILE_DEFINITIONS

  • COMPILE_FEATURES

  • COMPILE_OPTIONS

  • INCLUDE_DIRECTORIES

  • LINK_DEPENDS

  • LINK_DIRECTORIES

  • LINK_LIBRARIES

  • LINK_OPTIONS

  • POSITION_INDEPENDENT_CODE

  • PRECOMPILE_HEADERS

  • SOURCES

我们将在接下来的几页中讨论这些选项,但请记住,所有这些选项当然在 CMake 手册中有详细描述。你可以通过以下链接找到它们的详细描述(将<PROPERTY>替换为你感兴趣的属性):https://cmake.org/cmake/help/latest/prop_tgt/<PROPERTY>.html

下一个问题是,这种传播会传播多远?属性只会设置到第一个目标目标,还是会传播到依赖图的最顶端?你可以自行决定。

为了在目标之间创建依赖关系,我们使用target_link_libraries()命令。这个命令的完整签名需要一个传播关键字:

target_link_libraries(<target>
                     <PRIVATE|PUBLIC|INTERFACE> <item>...
                    [<PRIVATE|PUBLIC|INTERFACE> <item>...]...) 

如你所见,这个签名还指定了一个传播关键字,它控制属性如何从源目标存储到目标目标图 5.3 展示了在生成阶段(配置阶段完成后)传播的属性会发生什么:

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

图 5.3:属性如何传播到目标目标

传播关键字的工作方式如下:

  • PRIVATE 将源值追加到源目标私有属性中。

  • INTERFACE 将源值追加到源目标接口属性中。

  • PUBLIC 会将值追加到源目标的两个属性中。

正如我们之前讨论的,接口属性仅用于进一步传播属性到链条下游(到下一个目标目标),而源目标在其构建过程中不会使用这些属性。

我们之前使用的基本命令target_link_libraries(<target> <item>...)隐式指定了PUBLIC关键字。

如果你正确设置了源目标的传播关键字,属性将自动被放置到目标目标上,除非出现冲突……

处理冲突的传播属性

当一个目标依赖于多个其他目标时,可能会出现传播的属性相互冲突的情况。比如一个使用的目标指定了POSITION_INDEPENDENT_CODE属性为true,而另一个则为false。CMake 会理解为冲突,并打印出类似以下的错误信息:

CMake Error: The INTERFACE_POSITION_INDEPENDENT_CODE property of "source_target" does not agree with the value of POSITION_INDEPENDENT_CODE already determined for "destination_target". 

接收到这样的消息是有用的,因为我们明确知道是我们引入了这个冲突,需要解决它。CMake 有自己的属性,这些属性必须在源目标和目标目标之间“一致”。

在少数情况下,这可能变得很重要——例如,如果你在多个目标中使用同一个库,然后将它们链接到一个单一的可执行文件。如果这些源目标使用的是不同版本的同一个库,你可能会遇到问题。

为确保我们只使用相同的特定版本,我们可以创建一个自定义接口属性INTERFACE_LIB_VERSION,并将版本存储在其中。但这还不足以解决问题,因为 CMake 默认不会传播自定义属性(该机制仅适用于内建目标属性)。我们必须显式地将自定义属性添加到“兼容”属性列表中。

每个目标都有四个这样的列表:

  • COMPATIBLE_INTERFACE_BOOL

  • COMPATIBLE_INTERFACE_STRING

  • COMPATIBLE_INTERFACE_NUMBER_MAX

  • COMPATIBLE_INTERFACE_NUMBER_MIN

将你的属性添加到其中之一会触发传播和兼容性检查。BOOL列表将检查所有传播到目标目标的属性是否评估为相同的布尔值。类似地,STRING将评估为字符串。NUMBER_MAXNUMBER_MIN略有不同——传播的值不必完全匹配,但目标目标将接收最高或最低的值。

这个示例将帮助我们理解如何在实践中应用这一点:

ch05/02-propagated/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(PropagatedProperties CXX)
add_library(source1 empty.cpp)
set_property(TARGET source1 PROPERTY **INTERFACE_LIB_VERSION** **4**)
set_property(TARGET source1 APPEND PROPERTY
             COMPATIBLE_INTERFACE_STRING LIB_VERSION)
add_library(source2 empty.cpp)
set_property(TARGET source2 PROPERTY **INTERFACE_LIB_VERSION** **4**)
add_library(destination empty.cpp)
target_link_libraries(destination source1 source2) 

我们在这里创建了三个目标;为了简化起见,所有目标都使用相同的空源文件。在两个源目标上,我们指定了带有INTERFACE_前缀的自定义属性,并将它们设置为相同的匹配库版本。这两个源目标都链接到目标目标。最后,我们为source1指定了一个STRING兼容性要求作为属性(这里没有加INTERFACE_前缀)。

CMake 会将这个自定义属性传播到目标目标,并检查所有源目标的版本是否完全匹配(兼容性属性可以只设置在一个目标上)。

现在我们了解了常规目标是什么,让我们来看一看那些看起来像目标、闻起来像目标、有时也像目标但实际上并不是目标的其他事物。

认识伪目标

目标的概念非常有用,如果它的一些行为可以借用到其他地方,那就更好了;这些地方并不代表构建系统的输出,而是输入——外部依赖、别名等。这些就是伪目标,或者说是那些没有进入生成的构建系统的目标:

  • 导入目标

  • 别名目标

  • 接口库

我们来看一下。

导入目标

如果你浏览过本书的目录,你就会知道我们将讨论 CMake 如何管理外部依赖——其他项目、库等等。IMPORTED目标本质上是这个过程的产物。CMake 可以通过find_package()命令定义它们。

你可以调整这种目标的目标属性:编译定义编译 选项包含目录等——它们甚至支持传递性使用要求。然而,你应该将它们视为不可变的;不要更改它们的源代码或依赖关系。

IMPORTED目标的定义范围可以是全局的,也可以是局部的,即只在定义该目标的目录中可见(在子目录中可见,但在父目录中不可见)。

别名目标

别名目标正如你所期待的那样——它们创建了一个不同名称的目标引用。你可以使用以下命令为可执行文件和库创建别名目标:

add_executable(<name> ALIAS <target>)
add_library(<name> ALIAS <target>) 

别名目标的属性是只读的,不能安装或导出别名(它们在生成的构建系统中不可见)。

那么,为什么要使用别名呢?它们在项目的某些部分(例如子目录)需要一个特定名称的目标,而实际的实现可能会根据情况以不同的名称出现时非常有用。例如,你可能希望构建一个与你的解决方案一起提供的库,或者根据用户的选择导入它。

接口库

这是一个有趣的构造——一个不编译任何东西,而是作为一个工具目标的库。它的整个概念是围绕传播的属性(传递性使用要求)构建的。

接口库有两个主要用途——表示仅包含头文件的库,以及将一堆传播的属性捆绑成一个逻辑单元。

使用add_library(INTERFACE)创建仅包含头文件的库非常简单:

add_library(Eigen **INTERFACE**
  src/eigen.h src/vector.h src/matrix.h
)
target_include_directories(Eigen INTERFACE
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/src>
  $<INSTALL_INTERFACE:include/Eigen>
) 
generator expressions (these are indicated with dollar sign and angle brackets, $<...> and will be explained in the next chapter), we set its include directories to be ${CMAKE_CURRENT_SOURCE_DIR}/src when a target is exported and include/Eigen when it’s installed (which will also be explained at the end of this chapter).

要使用这样的库,我们只需要链接它:

target_link_libraries(executable Eigen) 

这里不会发生实际的链接,但 CMake 会将此命令理解为要求将所有INTERFACE属性传播到executable目标。

第二种用法完全利用相同的机制,但目的是不同的——它创建一个逻辑目标,可以作为传播属性的占位符。然后,我们可以将这个目标作为其他目标的依赖,并以干净、方便的方式设置属性。这里是一个例子:

add_library(warning_properties **INTERFACE**)
target_compile_options(warning_properties INTERFACE
  -Wall -Wextra -Wpedantic
)
target_link_libraries(executable warning_properties) 

add_library(INTERFACE)命令创建一个逻辑的warning_properties目标,用于设置第二个命令中指定的编译选项,应用于executable目标。我推荐使用这些INTERFACE目标,因为它们提高了代码的可读性和可重用性。可以将其视为将一堆魔法值重构为一个命名良好的变量。我还建议明确地添加一个后缀,如_properties,以便轻松区分接口库和常规库。

目标库

目标库用于将多个源文件归为一个逻辑目标,并在构建过程中将它们编译成(.o目标文件。创建目标库的方法与其他库相同,只不过使用OBJECT关键字:

add_library(<target> OBJECT <sources>) 

在构建过程中生成的目标文件可以通过$<TARGET_OBJECTS:objlib>生成表达式作为编译元素并入到其他目标中:

add_library(... $<TARGET_OBJECTS:objlib> ...)
add_executable(... $<TARGET_OBJECTS:objlib> ...) 

另外,你也可以使用target_link_libraries()命令将它们作为依赖项添加。

在我们Calc库的上下文中,目标库将有助于避免为静态和共享版本的库重复编译库源文件。显式编译目标文件并启用POSITION_INDEPENDENT_CODE是共享库的先决条件。

回到项目的目标:calc_obj将提供编译好的目标文件,然后它们将用于calc_staticcalc_shared库。让我们探讨这两种库类型之间的实际区别,并理解为什么有时需要同时创建这两种类型。

伪目标是否已经穷尽了目标的概念?当然没有!那样就太简单了。我们仍然需要理解这些目标是如何用来生成构建系统的。

构建目标

“目标”这个术语可以根据项目中的上下文和生成的构建系统的不同而有不同的含义。在生成构建系统的上下文中,CMake 将用 CMake 语言编写的列表文件“编译”成所选构建工具的语言,例如为 GNU Make 创建一个 Makefile。这些生成的 Makefile 有自己的目标集合。部分目标是从列表文件中定义的目标直接转换而来,而其他目标则是作为构建系统生成过程的一部分隐式创建的。

一个这样的构建系统目标是ALL,这是 CMake 默认生成的,包含所有顶级列表文件目标,例如可执行文件和库(不一定是自定义目标)。当我们运行cmake --build <build tree>而不选择任何特定目标时,ALL会被构建。正如你在第一章中可能记得的,你可以通过在cmake构建命令中添加--target <name>参数来选择一个目标。

有些可执行文件或库在每次构建中可能都不需要,但我们希望它们作为项目的一部分保留,以备在少数需要时使用。为了优化我们的默认构建,我们可以像这样将它们从ALL目标中排除:

add_executable(<name> **EXCLUDE_FROM_ALL** [<source>...])
add_library(<name> **EXCLUDE_FROM_ALL** [<source>...]) 

自定义目标的工作方式正好相反——默认情况下,它们会被排除在ALL目标之外,除非你显式地使用ALL关键字将它们添加进去,就像我们在 BankApp 示例中所做的那样。

另一个隐式定义的构建目标是clean,它简单地从构建树中移除生成的产物。我们使用它来删除所有旧文件并从头开始构建。然而,重要的是要理解,它并不只是简单地删除构建目录中的所有内容。为了让clean正确工作,你需要手动指定你的自定义目标可能会创建的任何文件作为BYPRODUCTS(见 BankApp 示例)。

这就是我们探索目标及其不同方面的总结:我们知道如何创建目标,配置其属性,使用伪目标,并决定它们是否应该默认构建。此外,还有一种有趣的非目标机制,用于创建可以在所有实际目标中使用的自定义工件——自定义命令(不要与自定义目标混淆)。

编写自定义命令

使用自定义目标有一个缺点——一旦将它们添加到ALL目标中,或者让其他目标依赖它们,它们就会每次都被构建。有时,这是你想要的效果,但也有些情况下,出于某些原因,有必要生成不应被重新创建的文件,此时需要自定义行为:

  • 生成另一个目标依赖的源代码文件

  • 将另一种语言翻译成 C++

  • 在另一个目标构建之前或之后立即执行自定义操作

自定义命令有两个签名,第一个是add_custom_target()的扩展版本:

add_custom_command(OUTPUT output1 [output2 ...]
                   COMMAND command1 [ARGS] [args1...]
                   [COMMAND command2 [ARGS] [args2...] ...]
                   [MAIN_DEPENDENCY depend]
                   [DEPENDS [depends...]]
                   [BYPRODUCTS [files...]]
                   [IMPLICIT_DEPENDS <lang1> depend1
                                    [<lang2> depend2] ...]
                   [WORKING_DIRECTORY dir]
                   [COMMENT comment]
                   [DEPFILE depfile]
                   [JOB_POOL job_pool]
                   [VERBATIM] [APPEND] [USES_TERMINAL]
                   [COMMAND_EXPAND_LISTS]) 

正如你可能已经猜到的那样,自定义命令并不会创建一个逻辑目标,但和自定义目标一样,它必须被添加到依赖图中。这样做有两种方式——将其输出的工件作为可执行文件(或库)的源,或者显式地将其添加到自定义目标的DEPENDS列表中。

使用自定义命令作为生成器

诚然,并不是每个项目都需要从其他文件生成 C++代码。一个这样的情况可能是Google 的协议缓冲区Protobuf)的.proto文件编译。如果你不熟悉这个库,Protobuf 是一个平台中立的二进制序列化工具,用于结构化数据。

换句话说:它可以用于在二进制流中编码对象:文件或网络连接。为了保持 Protobuf 的跨平台性和快速性,Google 的工程师发明了他们自己的 Protobuf 语言,该语言在.proto文件中定义模型,如下所示:

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
} 

这样的文件可以用于在多种语言中编码数据——C++、Ruby、Go、Python、Java 等。Google 提供了一个编译器protoc,它读取.proto文件,并输出针对所选语言的结构和序列化源代码(稍后需要编译或解释)。聪明的工程师不会将这些生成的源文件提交到版本库,而是会使用原始的 Protobuf 格式,并在构建链中添加一步生成源文件的操作。

我们还不知道如何检测目标主机上是否存在(以及在哪里存在)Protobuf 编译器(我们将在第九章CMake 中的依赖管理中学习这一点)。所以,目前我们暂时假设编译器的protoc命令位于系统已知的位置。我们已经准备了一个person.proto文件,并且知道 Protobuf 编译器将输出person.pb.hperson.pb.cc文件。下面是我们如何定义一个自定义命令来编译它们:

add_custom_command(OUTPUT person.pb.h person.pb.cc
        COMMAND protoc ARGS person.proto
        DEPENDS person.proto
) 

然后,为了在我们的可执行文件中支持序列化,我们可以直接将输出文件添加到源代码中:

add_executable(serializer serializer.cpp person.pb.cc) 

假设我们正确处理了头文件的包含和 Protobuf 库的链接,当我们对 .proto 文件进行修改时,所有内容会自动编译并更新。

一个简化的(但不太实用的)示例是通过从另一个位置复制必要的头文件来创建它:

ch05/03-command/CMakeLists.txt

add_executable(main main.cpp constants.h)
target_include_directories(main PRIVATE ${CMAKE_BINARY_DIR})
add_custom_command(OUTPUT constants.h **COMMAND** **cp**
                   ARGS "${CMAKE_SOURCE_DIR}/template.xyz" constants.h) 

在这种情况下,我们的“编译器”是 cp 命令。它通过简单地从源代码树复制 constants.h 文件到构建树根目录,满足 main 目标的依赖。

使用自定义命令作为目标钩子

第二版本的 add_custom_command() 命令引入了一个机制,用于在构建目标之前或之后执行命令:

add_custom_command(**TARGET** **<target>**
                   PRE_BUILD | PRE_LINK | POST_BUILD
                   COMMAND command1 [ARGS] [args1...]
                   [COMMAND command2 [ARGS] [args2...] ...]
                   [BYPRODUCTS [files...]]
                   [WORKING_DIRECTORY dir]
                   [COMMENT comment]
                   [VERBATIM] [USES_TERMINAL]
                   [COMMAND_EXPAND_LISTS]) 

我们在第一个参数中指定希望“增强”的目标,并在以下条件下执行:

  • PRE_BUILD 会在为此目标执行任何其他规则之前运行(仅适用于 Visual Studio 生成器;对于其他生成器,它的行为像 PRE_LINK)。

  • PRE_LINK 将命令绑定到所有源文件编译完成后、目标链接(或归档)之前运行。它不适用于自定义目标。

  • POST_BUILD 会在所有其他规则为此目标执行完成后运行。

使用这个版本的 add_custom_command(),我们可以复制前面 BankApp 示例中的校验和生成:

ch05/04-command/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Command CXX)
add_executable(main main.cpp)
add_custom_command(TARGET main POST_BUILD
                   COMMAND cksum
                   ARGS "$<TARGET_FILE:main>" > "main.ck") 

main 可执行文件的构建完成后,CMake 会执行 cksum 命令并传入提供的参数。但是,首个参数发生了什么?它不是一个变量,否则它应该用大括号(${})括起来,而不是用尖括号($<>)。它是一个 生成器表达式,计算得到目标二进制文件的完整路径。这个机制在许多目标属性的上下文中非常有用,我们将在下一章中解释。

总结

理解目标是编写清晰、现代 CMake 项目的关键。在本章中,我们不仅讨论了目标的构成以及如何定义三种不同类型的目标:可执行文件、库和自定义目标,还解释了目标之间如何通过依赖图相互依赖,并学习了如何使用 Graphviz 模块来可视化它。通过这些基本理解,我们得以学习目标的一个关键特性——属性。我们不仅介绍了几个命令来设置目标的常规属性,还解决了传递使用要求的谜团,也就是所谓的传播属性。

这是一个很难解决的问题,因为我们不仅需要理解如何控制哪些属性会被传播,还要理解这种传播如何影响后续目标。此外,我们还发现了如何确保来自多个源的属性兼容性。

接着,我们简要讨论了伪目标:导入目标、别名目标和接口库。它们在后续的项目中将派上用场,特别是当我们知道如何将它们与传播属性结合起来,造福我们自己时。然后,我们讨论了生成的构建目标,以及配置阶段如何影响它们。之后,我们花了一些时间研究了一种类似于目标但并不完全相同的机制:自定义命令。我们简要提到它们如何生成供其他目标使用的文件(如编译、翻译等),以及它们的钩子功能:在目标构建时执行附加步骤。

有了如此坚实的基础,我们准备进入下一个主题——将 C++ 源代码编译成可执行文件和库。

进一步阅读

若想了解本章涉及的更多内容,您可以参考以下资源:

加入我们的 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 的基础知识,并专注于 C++ 方面,可以暂时跳过本章,稍后再回来。另一方面,我们现在讨论生成器表达式,是因为接下来的章节将在解释 CMake 的更深层次内容时引用这些知识。

我们将首先介绍生成器表达式的主题:它们是什么,它们的用途是什么,以及它们是如何构建和展开的。接下来会简要介绍嵌套机制,并更详细地描述条件扩展,这允许使用布尔逻辑、比较操作和查询。当然,我们还会深入探讨可用表达式的广度。

但是首先,我们将研究字符串、列表和路径的转换,因为在集中研究主要内容之前,了解这些基础内容是很有益的。最终,生成器表达式在实际应用中用于获取在构建的后期阶段中可用的信息,并将其呈现于适当的上下文中。确定这个上下文是它们价值的一个重要部分。我们将探讨如何根据用户选择的构建配置、当前平台和工具链来参数化我们的构建过程。也就是说,正在使用什么编译器,它的版本是什么,以及它具备哪些功能,这还不止:我们将弄清楚如何查询构建目标的属性及其相关信息。

为了确保我们能够充分理解生成器表达式的价值,我在本章的最后部分包括了一些有趣的使用示例。此外,还提供了如何查看生成器表达式输出的简短说明,因为这有些棘手。不过别担心,生成器表达式并不像它们看起来那么复杂,你很快就能开始使用它们了。

本章将介绍以下主要内容:

  • 什么是生成器表达式?

  • 学习一般表达式语法的基本规则

  • 条件扩展

  • 查询与转换

  • 试验示例

技术要求

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

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

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

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

生成器表达式是什么?

CMake 在三个阶段构建解决方案:配置、生成和运行构建工具。通常,在配置阶段所有所需的数据都是可用的。然而,有时我们会遇到类似“先有鸡还是先有蛋”这种悖论的情况。举个例子,来自第五章使用自定义命令作为目标钩子部分——某个目标需要知道另一个目标的二进制工件路径。不幸的是,这些信息只有在所有列表文件解析完毕并且配置阶段完成后才会变得可用。

那么,我们如何解决这样的问题呢?一种解决方案是为该信息创建一个占位符,并将其评估延迟到下一个阶段——生成阶段

这正是生成器表达式(也称为“genexes”)的作用。它们围绕目标属性构建,如LINK_LIBRARIESINCLUDE_DIRECTORIESCOMPILE_DEFINITIONS和传播的属性,尽管并非所有属性都如此。它们遵循类似于条件语句和变量评估的规则。

生成器表达式将在生成阶段进行评估(即配置完成并创建构建系统时),这意味着将它们的输出捕获到变量中并打印到控制台并不直接。

生成器表达式有很多种,从某种意义上说,它们构成了自己的一种领域特定语言——一种支持条件表达式、逻辑运算、比较、转换、查询和排序的语言。使用生成器表达式可以操作和查询字符串、列表、版本号、shell 路径、配置和构建目标。在本章中,我们将简要概述这些概念,重点介绍基础知识,因为在大多数情况下它们不是必需的。我们主要关注生成器表达式的主要应用,即从生成的目标配置和构建环境的状态中获取信息。欲了解完整参考资料,最好在线阅读官方 CMake 手册(请参阅进一步阅读部分以获取网址)。

一切通过示例解释更为清晰,因此我们直接进入,描述生成器表达式的语法。

学习常规表达式语法的基本规则

要使用生成器表达式,我们需要通过支持生成器表达式评估的命令将其添加到 CMake 列表文件中。大多数特定目标的命令都支持生成器表达式评估,还有许多其他命令(可以查看特定命令的官方文档了解更多)。一个常与生成器表达式一起使用的命令是target_compile_definitions()。要使用生成器表达式,我们需要将其作为命令参数提供,如下所示:

target_compile_definitions(foo PUBLIC BAR=$<TARGET_FILE:baz>) 

该命令将一个-D定义标志添加到编译器的参数中(暂时忽略PUBLIC),该标志设置BAR预处理器定义为foo目标生成的二进制文件路径。之所以有效,是因为生成器表达式以当前的形式存储在一个变量中。展开实际上推迟到生成阶段,此时许多内容已经完全配置并确定。

生成器表达式是如何形成的?

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

图 6.1:生成器表达式的语法

图 6.1所示,结构看起来相当简单且易于阅读:

  • 以美元符号和括号($<)打开。

  • 添加EXPRESSION名称。

  • 如果表达式需要参数,请添加冒号(:)并提供arg1arg2argN值,以逗号(,)分隔。

  • 使用>结束表达式。

有一些不需要任何参数的表达式,例如$<PLATFORM_ID>

需要注意的是,除非明确指出,否则表达式通常在使用该表达式的目标上下文中进行评估。这一关联可以从使用表达式的命令中推断出来。在前面的例子中,我们看到target_compile_definitions()foo作为它操作的目标。因此,在该命令中使用的特定目标生成器表达式将隐式地使用foo。然而,值得注意的是,例子中使用的生成器表达式$<TARGET_FILE>需要目标属性作为其操作的上下文。还有一些生成器表达式不接受目标作为参数(如$<COMPILE_LANGUAGE>),而是隐式使用封闭命令的目标。稍后会对这些进行更详细的讨论。

当使用生成器表达式的高级特性时,生成器表达式可能会变得非常混乱和复杂,因此在使用之前了解其具体内容非常重要。

嵌套

我们先从将生成器表达式作为参数传递给另一个生成器表达式的能力开始,换句话说,就是生成器表达式的嵌套:

$<UPPER_CASE:$<PLATFORM_ID>> 

这个例子并不复杂,但很容易想象当我们增加嵌套层数并与使用多个参数的命令配合工作时会发生什么。

为了让问题更复杂一些,可能还需要将常规变量展开与生成器表达式一起使用:

$<UPPER_CASE:${my_variable}> 

my_variable 变量会首先在配置阶段展开。随后,生成表达式将在生成阶段展开。这种特性有一些罕见的用法,但我强烈建议避免使用:生成器表达式几乎提供了所有必要的功能。将常规变量混入这些表达式中会增加难以调试的间接性层次。此外,在配置阶段收集的信息通常会过时,因为用户会在构建或安装阶段通过命令行参数覆盖生成器表达式中使用的值。

在讲解语法之后,让我们继续讨论生成器表达式中可用的基本机制。

条件展开

在生成器表达式中,是否应该展开一个表达式是通过布尔逻辑来确定的。尽管这是一个很棒的特性,但由于历史原因,它的语法可能不一致且难以阅读。它有两种形式。第一种形式同时支持顺利路径和错误路径:

$<IF:condition,true_string,false_string> 

IF 表达式依赖于嵌套才能发挥作用:你可以将任何参数替换为另一个表达式,生成相当复杂的求值(甚至可以将一个 IF 条件嵌套在另一个里面)。这种形式需要恰好三个参数,因此我们不能省略任何一个。为了跳过条件不满足时的值,最好的选择是:

$<IF:condition,true_string,> 

有一种简写形式,可以跳过 IF 关键字和逗号:

$<condition:true_string> 

如你所见,它打破了将 EXPRESSION 名称作为第一个标记的惯例。我猜这里的意图是缩短表达式,避免输入那些宝贵的字符,但结果可能很难合理化。这是来自 CMake 文档的一个示例:

$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CXX_COMPILER_ID:AppleClan
  g,Clang>>:COMPILING_CXX_WITH_CLANG> 

这个表达式只有在用 Clang 编译器编译的 C++ 代码中才会返回 COMPILING_CXX_WITH_CLANG(在其他所有情况下返回空字符串)。我希望语法能与常规 IF 命令的条件对齐,但遗憾的是并非如此。现在,如果你在某个地方看到第二种形式,你应该能够识别它,但为了可读性,最好避免在自己的项目中使用。

布尔值求值

生成器表达式会求值为两种类型之一——布尔值或字符串。布尔值由 1(真)和 0(假)表示。没有专门的数值类型;任何不是布尔值的东西都只是字符串。

需要记住的是,作为条件传递的嵌套表达式必须显式地求值为布尔值。

布尔类型可以隐式转换为字符串,但你需要使用显式的 BOOL 运算符(稍后解释)来实现反向转换。

布尔值的表达式有三种类型:逻辑运算符、比较表达式和查询。让我们快速看一下这些类型。

逻辑运算符

有四个逻辑运算符:

  • $<NOT:arg>:这个表达式用于否定布尔参数。

  • $<AND:arg1,arg2,arg3...>:如果所有参数都为真,则返回 true。

  • $<OR:arg1,arg2,arg3...>:如果任意一个参数为真,则返回 true。

  • $<BOOL:string_arg>:此操作将字符串类型的参数转换为布尔类型。

使用$<BOOL>进行字符串转换时,如果未满足以下条件,则会计算为布尔值 true(1):

  • 字符串为空。

  • 字符串是0FALSEOFFNNOIGNORENOTFOUND的大小写不敏感等价物。

  • 字符串以-NOTFOUND后缀结尾(区分大小写)。

比较

比较将根据其条件计算为1(满足条件时)或0(不满足条件时)。以下是一些你可能会觉得有用的常见操作:

  • $<STREQUAL:arg1,arg2>:此操作按大小写敏感方式比较字符串。

  • $<EQUAL:arg1,arg2>:此操作将字符串转换为数字并进行相等比较。

  • $<IN_LIST:arg,list>:此操作检查list列表中是否包含arg元素(区分大小写)。

  • $<VERSION_EQUAL:v1,v2>$<VERSION_LESS:v1,v2>$<VERSION_GREATER:v1,v2>$<VERSION_LESS_EQUAL:v1,v2>$<VERSION_GREATER_EQUAL:v1,v2>按组件逐一比较版本。

  • $<PATH_EQUAL:path1,path2>:此操作比较两个路径的词法表示,而不进行任何规范化(自 CMake 3.24 起)。

查询

查询会直接从一个变量返回布尔值,或作为某个操作的结果返回。最简单的查询之一是:

$<TARGET_EXISTS:arg> 

如你所料,如果目标在配置阶段已定义,则返回 true。

现在,你已经知道如何应用条件展开、使用逻辑运算符、比较以及基本查询来计算布尔值。单单这些就很有用,但生成器表达式能提供更多,特别是在查询的上下文中:它们可以在IF条件展开中使用,或者单独作为命令的参数使用。是时候在适当的上下文中介绍它们了。

查询和转换

有许多生成器表达式可用,但为了避免迷失在细节中,让我们专注于最常用的一些。我们将从对可用数据的一些基本转换开始。

处理字符串、列表和路径

生成器表达式仅提供了最低限度的操作,用于转换和查询数据结构。在生成器阶段处理字符串是可能的,以下是一些常用的表达式:

  • $<LOWER_CASE:string>$<UPPER_CASE:string>:此操作将string转换为所需的大小写。

列表操作直到最近才得到了很大的扩展。从 CMake 3.15 开始,以下操作可用:

  • $<IN_LIST:string,list>:如果list中包含string值,则返回 true。

  • $<JOIN:list,d>:此表达式使用d分隔符连接一个以分号分隔的list

  • $<REMOVE_DUPLICATES:list>:此操作去除list中的重复项(不排序)。

  • $<FILTER:list,INCLUDE|EXCLUDE,regex>:此操作使用regex包含或排除list中的项。

从 3.27 版本开始,添加了$<LIST:OPERATION>生成器表达式,其中OPERATION可以是以下之一:

  • LENGTH

  • GET

  • SUBLIST

  • FIND

  • JOIN

  • APPEND

  • PREPEND

  • INSERT

  • POP_BACK

  • POP_FRONT

  • REMOVE_ITEM

  • REMOVE_AT

  • REMOVE_DUPLICATES

  • FILTER

  • TRANSFORM

  • REVERSE

  • SORT

在生成器表达式中处理列表的情况比较少见,因此我们仅指示可能的情况。如果你遇到这些情况,请查看在线手册,了解如何使用这些操作。

最后,我们可以查询和变换系统路径。这是一个有用的补充,因为它在不同操作系统之间具有可移植性。自 CMake 3.24 起,以下简单查询已经可以使用:

  • $<PATH:HAS_ROOT_NAME,path>

  • $<PATH:HAS_ROOT_DIRECTORY,path>

  • $<PATH:HAS_ROOT_PATH,path>

  • $<PATH:HAS_FILENAME,path>

  • $<PATH:HAS_EXTENSION,path>

  • $<PATH:HAS_STEM,path>

  • $<PATH:HAS_RELATIVE_PART,path>

  • $<PATH:HAS_PARENT_PATH,path>

  • $<PATH:IS_ABSOLUTE,path>

  • $<PATH:IS_RELATIVE,path>

  • $<PATH:IS_PREFIX[,NORMALIZE],prefix,path>:如果前缀是路径的前缀,则返回 true。

类似地,我们可以检索我们能够检查的所有路径组件(自 CMake 3.27 起,可以提供路径列表,而不仅仅是一个路径):

  • $<PATH:GET_ROOT_NAME,path...>

  • $<PATH:GET_ROOT_DIRECTORY,path...>

  • $<PATH:GET_ROOT_PATH,path...>

  • $<PATH:GET_FILENAME,path...>

  • $<PATH:GET_EXTENSION[,LAST_ONLY],path...>

  • $<PATH:GET_STEM[,LAST_ONLY],path...>

  • $<PATH:GET_RELATIVE_PART,path...>

  • $<PATH:GET_PARENT_PATH,path...>

此外,3.24 版本引入了一些变换操作;我们将列出它们以供完整性参考:

  • $<PATH:CMAKE_PATH[,NORMALIZE],path...>

  • $<PATH:APPEND,path...,input,...>

  • $<PATH:REMOVE_FILENAME,path...>

  • $<PATH:REPLACE_FILENAME,path...,input>

  • $<PATH:REMOVE_EXTENSION[,LAST_ONLY],path...>

  • $<PATH:REPLACE_EXTENSION[,LAST_ONLY],path...,input>

  • $<PATH:NORMAL_PATH,path...>

  • $<PATH:RELATIVE_PATH,path...,base_directory>

  • $<PATH:ABSOLUTE_PATH[,NORMALIZE],path...,base_directory>

还有一个路径操作,它将提供的路径格式化为主机的 shell 支持的样式:$<SHELL_PATH:path...>

再次说明,之前介绍的表达式是为了以后参考,并不是现在就需要记住的信息。推荐的实际应用知识详细信息在随后的章节中。

参数化构建配置和平台

CMake 用户在构建项目时提供的关键信息之一是所需的构建配置。在大多数情况下,它将是DebugRelease。我们可以使用生成器表达式通过以下语句访问这些值:

  • $<CONFIG>:此表达式返回当前构建配置的字符串:DebugRelease或其他。

  • $<CONFIG:configs>:如果configs包含当前构建配置(不区分大小写比较),则返回 true。

我们在第四章设置你的第一个 CMake 项目中的理解构建环境部分讨论了平台。我们可以像读取配置一样阅读相关信息:

  • $<PLATFORM_ID>:这将返回当前平台 ID 的字符串形式:LinuxWindowsDarwin(针对 macOS)。

  • $<PLATFORM_ID:platform> 如果platform包含当前平台 ID,则为真。

这种特定于配置或平台的参数化是我们工具箱中的强大补充。我们可以将其与之前讨论的条件展开一起使用:

$<IF:condition,true_string,false_string> 

例如,我们在构建测试二进制文件时可能应用一个编译标志,而在生产环境中应用另一个:

target_compile_definitions(my_target PRIVATE
                           $<IF:$<CONFIG:Debug>,Test,Production>
) 

但这只是开始。还有许多其他情况可以通过生成器表达式来处理。当然,下一个重要的方面是系统中存在的工具。

工具链调优

工具链、工具包,或者说编译器和链接器,幸运的是(或不幸的是?)在不同供应商之间并不一致。这带来了各种后果。它们中有些是好的(在特殊情况下性能更好),而有些则不那么理想(配置风格多样,标志命名不一致等)。

生成器表达式在这里通过提供一系列查询来帮助缓解问题,并在可能的情况下改善用户体验。

与构建配置和平台一样,有多个表达式返回关于工具链的信息,无论是字符串还是布尔值。然而,我们需要指定我们感兴趣的语言(将#LNG替换为CCXXCUDAOBJCOBJCXXFortranHIPISPC之一)。对HIP的支持在 3.21 版本中添加。

  • $<#LNG_COMPILER_ID>:这将返回所使用的#LNG编译器的 CMake 编译器 ID。

  • $<#LNG_COMPILER_VERSION>:这将返回所使用的#LNG编译器的 CMake 编译器版本。

要检查 C++将使用哪个编译器,我们应该使用$<CXX_COMPILER_ID>生成器表达式。返回的值,即 CMake 的编译器 ID,是为每个支持的编译器定义的常量。你可能会遇到诸如AppleClangARMCCClangGNUIntelMSVC等值。完整列表请参考官方文档(进一步阅读部分中的 URL)。

类似于上一节,我们还可以在条件表达式中利用工具链信息。有多个查询可以返回true,如果任何提供的参数与特定值匹配:

  • $<#LNG_COMPILER_ID:ids>:如果ids包含 CMake 的#LNG编译器 ID,则返回 true。

  • $<#LNG_COMPILER_VERSION:vers>:如果vers包含 CMake 的#LNG编译器版本,则返回 true。

  • $<COMPILE_FEATURES:features>:如果features中列出的所有特性都被此目标的编译器支持,则返回 true。

在需要目标参数的命令中,如target_compile_definitions(),我们可以使用其中一种特定于目标的表达式来获取字符串值:

  • $<COMPILE_LANGUAGE>:返回编译步骤中源文件的语言。

  • $<LINK_LANGUAGE>:返回链接步骤中源文件的语言。

评估一个简单的布尔查询:

  • $<COMPILE_LANGUAGE:langs>:如果langs包含用于编译该目标的语言,则返回 true。可以使用此表达式为编译器提供特定语言的标志。例如,为了使用-fno-exceptions标志编译目标的 C++源文件:

    target_compile_options(myapp
      PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions>
    ) 
    
  • $<LINK_LANGUAGE:langs> – 它遵循与COMPILE_LANGUAGE相同的规则,如果langs包含用于该目标链接的语言,则返回 true。

或者,查询更复杂的场景:

  • $<COMPILE_LANG_AND_ID:lang,compiler_ids...>:如果lang语言用于此目标,并且compiler_ids列表中的某个编译器将用于此编译,则返回 true。这个表达式对于为特定编译器指定编译定义非常有用:

    target_compile_definitions(myapp PRIVATE
      $<$<COMPILE_LANG_AND_ID:CXX,AppleClang,Clang>:CXX_CLANG>
      $<$<COMPILE_LANG_AND_ID:CXX,Intel>:CXX_INTEL>
      $<$<COMPILE_LANG_AND_ID:C,Clang>:C_CLANG>
    ) 
    
  • 在这个示例中,对于使用AppleClangClang编译的 C++源文件(CXX),将设置-DCXX_CLANG定义。对于使用Intel编译器编译的 C++源文件,将设置-DCXX_INTEL定义标志。最后,对于使用Clang编译器编译的 C 源文件(C),将设置-DC_CLANG定义。

  • $<LINK_LANG_AND_ID:lang,compiler_ids...>:它的作用类似于COMPILE_LANG_AND_ID,但检查的是链接步骤中使用的语言。使用此表达式可以指定特定语言和链接器组合的链接库、链接选项、链接目录和链接依赖项。

这里需要注意的是,一个单独的目标可以由多种语言的源文件组成。例如,可以将 C 语言的产物与 C++链接(但我们应该在project()命令中声明这两种语言)。因此,引用特定语言的生成器表达式将用于某些源文件,但不会用于其他源文件。

让我们继续讨论下一个重要类别:与目标相关的生成器表达式。

查询与目标相关的信息

有许多生成器表达式可以查询目标属性并检查与目标相关的信息。请注意,直到 CMake 3.19,许多引用另一个目标的目标表达式会自动在它们之间创建依赖关系。但在 CMake 的最新版本中,这种情况不再发生。

一些生成器表达式会从被调用的命令中推断目标;最常用的是返回目标属性值的基本查询:

$<TARGET_PROPERTY:prop> 
  • 一个较少为人知,但在target_link_libraries()命令中非常有用的生成器表达式是$<LINK_ONLY:deps>。它允许我们存储PRIVATE链接依赖项,这些依赖项不会通过传递的使用要求传播;这些依赖项用于接口库,我们在第五章与目标的工作中的理解传递使用要求部分已经讨论过。

还有一组与安装和导出相关的表达式,它们通过上下文推断出目标。我们将在第十四章安装与打包中深入讨论这些表达式,因此现在我们只做一个简短的介绍:

  • $<INSTALL_PREFIX>:当目标通过install(EXPORT)导出,或在INSTALL_NAME_DIR中评估时,这返回安装前缀;否则,它为空。

  • $<INSTALL_INTERFACE:string>:当表达式与install(EXPORT)一起导出时,这返回string

  • $<BUILD_INTERFACE:string>:当表达式通过export()命令或由同一构建系统中的另一个目标导出时,这返回string

  • $<BUILD_LOCAL_INTERFACE:string>:当表达式被同一构建系统中另一个目标导出时,这返回string

然而,大多数查询要求明确提供目标名称作为第一个参数:

  • $<TARGET_EXISTS:target>:如果目标存在,这返回true

  • $<TARGET_NAME_IF_EXISTS:target>:如果目标存在,它返回target名称,否则返回空字符串。

  • $<TARGET_PROPERTY:target,prop>:这返回目标的prop属性值。

  • $<TARGET_OBJECTS:target>:这返回目标库目标文件列表。

你可以查询目标构件的路径:

  • $<TARGET_FILE:target>:这返回完整路径。

  • $<TARGET_FILE_NAME:target>:这只返回文件名。

  • $<TARGET_FILE_BASE_NAME:target>:这返回基本名称。

  • $<TARGET_FILE_NAME:target>:这返回没有前缀或后缀的基本名称(例如,对于libmylib.so,基本名称为mylib)。

  • $<TARGET_FILE_PREFIX:target>:这只返回前缀(例如,lib)。

  • $<TARGET_FILE_SUFFIX:target>:这只返回后缀(例如,.so.exe)。

  • $<TARGET_FILE_DIR:target>:这返回目录。

有一些表达式族提供与常规TARGET_FILE表达式类似的功能(每个表达式还接受_NAME_BASE_NAME_DIR后缀):

  • TARGET_LINKER_FILE:这查询用于链接到目标的文件路径。通常,它是目标生成的库(.a.lib.so)。但是,在使用动态链接库DLLs)的平台上,它将是与目标的 DLL 关联的 .lib 导入库。

  • TARGET_PDB_FILE:这查询链接器生成的程序数据库文件(.pdb)的路径。

管理库是一个复杂的话题,CMake 提供了许多生成器表达式来帮助解决。我们将在第八章链接可执行文件和库中引入它们,直到它们变得相关。

最后,还有一些特定于 Apple 包的表达式:

  • $<TARGET_BUNDLE_DIR:target>:这是目标的捆绑目录(my.appmy.frameworkmy.bundle)的完整路径。

  • $<TARGET_BUNDLE_CONTENT_DIR:target>:这是目标的完整路径,指向目标的捆绑内容目录。在 macOS 上,它是my.app/Contentsmy.frameworkmy.bundle/Contents。其他软件开发工具包SDKs)(例如 iOS)具有平坦的捆绑结构——my.appmy.frameworkmy.bundle

这些是处理目标的主要生成器表达式。值得知道的是,还有很多其他的表达式。我建议参考官方文档以获取完整列表。

转义

在少数情况下,你可能需要将一个具有特殊含义的字符传递给生成器表达式。为了转义这种行为,可以使用以下表达式:

  • $<ANGLE-R>:这是一个字面量的>符号

  • $<COMMA>:这是一个字面量的,符号

  • $<SEMICOLON>:这是一个字面量的;符号

最后的表达式可以在使用包含;的参数时防止列表扩展。

现在我们已经介绍了所有查询和转换,我们可以看看它们在实践中的应用。让我们通过一些示例来了解如何使用它们。

试验例子

当有一个好的实践例子来支持理论时,一切都会更容易理解。显然,我们希望编写一些 CMake 代码并试一试。然而,由于生成器表达式直到配置完成后才会被求值,因此我们不能使用像message()这样的配置时命令来进行实验。我们需要使用一些特殊的技巧来进行调试。要调试生成器表达式,你可以使用以下方法之一:

  • 将其写入文件(这个版本的file()命令支持生成器表达式):file(GENERATE OUTPUT filename CONTENT "$<...>")

  • 从命令行显式添加一个自定义目标并构建它:add_custom_target(gendbg COMMAND ${CMAKE_COMMAND} -E echo "$<...>")

我推荐第一种选项,便于简单练习。不过记住,我们无法在这些命令中使用所有表达式,因为有些表达式是针对特定目标的。介绍完这些之后,我们来看一些生成器表达式的应用实例。

构建配置

第一章使用 CMake 的第一步 中,我们讨论了构建类型,指定我们正在构建的配置——DebugRelease,等等。可能会有这种情况,你希望根据正在进行的构建类型采取不同的操作。一个简单易行的方法是使用$<CONFIG>生成器表达式:

target_compile_options(tgt $<$<CONFIG:DEBUG>:-ginline-points>) 

上面的例子检查配置是否等于DEBUG;如果是这种情况,嵌套的表达式将被求值为1。外部简写的if表达式将变为true,我们的-ginline-points调试标志被添加到选项中。了解这种形式很重要,这样你就能理解其他项目中的类似表达式,但我建议使用更为详细的$<IF:...>,以提高可读性。

系统特定的一行命令

生成器表达式还可以用来将冗长的if命令压缩成简洁的一行代码。假设我们有以下代码:

if (${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
     target_compile_definitions(myProject PRIVATE LINUX=1)
endif() 

它告诉编译器,如果这是目标系统,就将-DLINUX=1添加到参数中。虽然这并不算太长,但可以用一个相当简单的表达式替代:

target_compile_definitions(myProject PRIVATE
                           $<$<CMAKE_SYSTEM_NAME:LINUX>:LINUX=1>) 

这样的代码运行良好,但你能放入生成器表达式中的内容是有限的,一旦超过了这个限度,就会变得难以阅读。此外,许多 CMake 用户推迟学习生成器表达式,导致他们难以跟上发生的事情。幸运的是,完成本章后,我们将不再遇到这些问题。

带有特定编译器标志的接口库

如我们在第五章《与目标一起工作》中讨论的那样,接口库可以用来提供与编译器匹配的标志:

add_library(enable_rtti INTERFACE)
target_compile_options(enable_rtti INTERFACE
  $<$<OR:$<COMPILER_ID:GNU>,$<COMPILER_ID:Clang>>:-rtti>
) 

即使在这样一个简单的例子中,我们也能看到当我们嵌套太多生成器表达式时,表达式变得多么难以理解。不幸的是,有时这是实现所需效果的唯一方法。以下是该例子的解释:

  • 我们检查COMPILER_ID是否为GNU;如果是这样,我们将OR的值评估为1

  • 如果不是,我们检查COMPILER_ID是否为Clang,并将OR评估为1。否则,将OR评估为0

  • 如果OR的值被评估为1,则将-rtti添加到enable_rtti 编译选项中。否则,什么也不做。

接下来,我们可以将我们的库和可执行文件与enable_rtti接口库进行链接。如果编译器支持,它会添加-rtti标志。顺便提一下,RTTI代表运行时类型信息,在 C++中使用typeid等关键字来确定对象的类;除非你的代码使用了这个功能,否则不需要启用该标志。

嵌套的生成器表达式

有时,当我们尝试在生成器表达式中嵌套元素时,结果并不明显。我们可以通过生成测试输出到调试文件来调试表达式。

让我们尝试一些东西,看看会发生什么:

ch06/01-nesting/CMakeLists.txt

set(myvar "small text")
set(myvar2 "small text >")
file(GENERATE OUTPUT nesting CONTENT "
  1 $<PLATFORM_ID>
  2 $<UPPER_CASE:$<PLATFORM_ID>>
  3 $<UPPER_CASE:hello world>
  4 $<UPPER_CASE:${myvar}>
  5 $<UPPER_CASE:${myvar2}>
") 

按照本章技术要求部分的描述构建此项目后,我们可以使用 Unix cat命令读取生成的nesting文件:

# cat nesting
  1 Linux
  2 LINUX
  3 HELLO WORLD
  4 SMALL TEXT
  5 SMALL  text> 

下面是每行代码的工作原理:

  1. PLATFORM_ID的输出值是LINUX

  2. 嵌套值的输出将正确转换为大写LINUX

  3. 我们可以转换普通字符串。

  4. 我们可以转换配置阶段变量的内容。

  5. 变量会首先进行插值,并且闭合的尖括号(>)会被解释为生成器表达式的一部分,因此只有部分字符串会被转换为大写。

换句话说,要意识到变量的内容可能会影响生成器表达式扩展的行为。如果需要在变量中使用尖括号,请使用$<ANGLE-R>

条件表达式与 BOOL 操作符评估之间的区别

在评估布尔类型为字符串时,生成器表达式可能有些令人困惑。理解它们与常规条件表达式的不同之处是很重要的,从显式的IF关键字开始:

ch06/02-boolean/CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(Boolean CXX)
file(GENERATE OUTPUT boolean CONTENT "
  1 $<0:TRUE>
  2 $<0:TRUE,FALSE> (won't work)
  3 $<1:TRUE,FALSE>
  4 $<IF:0,TRUE,FALSE>
  5 $<IF:0,TRUE,>
") 

让我们使用 Linux 的 cat 命令查看生成的文件:

# cat boolean
  1
  2  (won't work)
  3 TRUE,FALSE
  4 FALSE
  5 

让我们检查每一行的输出:

  1. 这是一个布尔扩展,其中 BOOL0;因此,TRUE 字符串没有被写出。

  2. 这是一个典型的错误 – 作者打算根据 BOOL 值打印 TRUEFALSE,但由于它也是布尔 false 扩展,两个参数被视为一个并未打印出来。

  3. 这是一个反向值的相同错误 – 它是一个布尔 true 扩展,两个参数写在同一行。

  4. 这是一个正确的条件表达式,以 IF 开头 – 它打印 FALSE,因为第一个参数为 0

  5. 这是条件表达式的正确用法,但当我们不需要为布尔 false 提供值时,我们应使用第一行中使用的形式。

生成器表达式因其复杂的语法而闻名。本示例中提到的差异甚至会让经验丰富的构建者感到困惑。如果有疑问,可以将此类表达式复制到另一个文件,并通过添加缩进和空白来进行分析,以便更好地理解。

通过看到生成器表达式的工作示例,我们已经为实际使用它们做好了准备。接下来的章节将讨论许多与生成器表达式相关的主题。随着时间的推移,我们将涵盖更多它们的应用。

摘要

本章专门讲解了生成器表达式,或称“genexes”的细节。我们从生成和扩展生成器表达式的基础开始,探索了它们的嵌套机制。我们深入探讨了条件扩展的强大功能,它涉及布尔逻辑、比较操作和查询。生成器表达式在根据用户选择的构建配置、平台和当前工具链等因素调整构建过程时,展现出其强大的优势。

我们还涵盖了字符串、列表和路径的基本但至关重要的转换。一个亮点是使用生成器表达式查询在后期构建阶段收集的信息,并在上下文匹配要求时显示这些信息。我们现在也知道如何检查编译器的 ID、版本和功能。我们还探讨了如何查询构建目标属性并使用生成器表达式提取相关信息。本章以实际示例和在可能的情况下查看输出的指南结束。至此,你已经准备好在项目中使用生成器表达式了。

在下一章中,我们将学习如何使用 CMake 编译程序。具体来说,我们将讨论如何配置和优化这一过程。

进一步阅读

有关本章所涵盖主题的更多信息,您可以参考以下内容:

留下评论!

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

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

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

简单的编译场景通常由工具链的默认配置或者集成开发环境(IDE)提供。然而,在专业环境中,业务需求经常需要更高级的功能。可能需要更高的性能、更小的二进制文件、更强的可移植性、自动化测试或者更多的调试能力 – 不胜枚举。在一个一致、未来可靠的方式中管理所有这些很快就变成了一个复杂、纠结的问题(尤其是在需要支持多个平台时)。

编译的过程通常在 C++ 的书籍中解释得不够详细(像虚拟基类这样的深入主题似乎更有趣)。在本章中,我们将通过讨论编译的不同方面来解决这个问题:我们将了解编译的工作原理、它的内部阶段以及它们如何影响二进制输出。

之后,我们将专注于先决条件 – 我们将讨论可以用于微调编译过程的命令,如何从编译器要求特定功能,以及如何正确地告知编译器处理哪些输入文件。

然后,我们将专注于编译的第一阶段 – 预处理器。我们将提供包含头文件的路径,并学习如何通过预处理器定义从 CMake 和构建环境中插入变量。我们将涵盖最有趣的用例,并学习如何公开 CMake 变量以便从 C++ 代码中访问。

在此之后,我们将讨论优化器及其如何通过不同的标志影响性能。我们还将讨论优化的成本,特别是它如何影响生成的二进制文件的调试能力,以及如果不需要这些影响时应该怎么做。

最后,我们将解释如何通过使用预编译头文件和统一构建来管理编译过程,以减少编译时间。我们将学习如何调试构建过程并找出可能存在的任何错误。

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

  • 编译的基础知识

  • 预处理器的配置

  • 配置优化器

  • 管理编译过程

技术要求

你可以在 GitHub 上找到本章节中存在的代码文件,链接在github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch07

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

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

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

编译的基础知识

编译可以大致描述为将用高级编程语言编写的指令转换为低级机器码的过程。这使我们能够使用诸如类和对象等抽象概念来创建应用程序,而不必费力处理处理器特定的汇编语言。我们不需要直接操作 CPU 寄存器,考虑短跳或长跳,或管理堆栈帧。编译型语言更具表现力、可读性和安全性,并鼓励编写可维护的代码,同时尽可能提供最佳性能。

在 C++中,我们使用静态编译——这意味着整个程序必须在执行之前先被翻译成本地代码。这与像 Java 或 Python 这样的语言不同,后者每次用户运行程序时都会即时解释和编译程序。每种方法都有其独特的优点。C++旨在提供多种高级工具,同时提供本地性能。C++编译器可以为几乎所有架构生成一个自包含的应用程序。

创建并运行 C++程序涉及多个步骤:

  1. 设计你的应用程序:这包括规划应用程序的功能、结构和行为。一旦设计完成,按照代码可读性和可维护性的最佳实践,仔细编写源代码。

  2. 编译单个.cpp 实现文件,也称为翻译单元,成目标文件:这一步涉及将您编写的高级语言代码转换为低级机器码。

  3. 将链接 目标文件合并成单个可执行文件:在此步骤中,所有其他依赖项,包括动态库和静态库,也会被链接。这一过程创建了一个可以在预定平台上运行的可执行文件。

要运行程序,操作系统OS)将使用一种名为加载器的工具,将程序的机器码和所有所需的动态库映射到虚拟内存中。加载器随后读取程序头部,以确定执行应从哪里开始,并开始运行指令。

在这个阶段,程序的启动代码开始发挥作用。系统 C 库提供的一个特殊函数_start被调用。_start函数收集命令行参数和环境变量,启动线程,初始化静态符号,并注册清理回调函数。只有在此之后,它才会调用main(),这是程序员填入自己代码的函数。

如你所见,在幕后发生了大量工作。本章重点讲解早期列表中的第二步。通过考虑整体情况,我们可以更好地理解潜在问题可能来自哪里。尽管软件开发中的复杂性看起来似乎无法逾越,但开发中并不存在“魔法”。一切都有解释和原因。我们需要理解,由于我们如何编译程序,程序在运行时可能会出现问题,即使编译步骤本身看似成功。编译器不可能在其操作过程中检查所有边界情况。因此,让我们深入了解当编译器执行其工作时,实际发生了什么。

编译如何工作

如前所述,编译是将高级语言翻译成低级语言的过程。具体来说,这涉及生成机器代码,这些机器代码是特定处理器可以直接执行的指令,格式为平台独有的二进制目标文件。在 Linux 上,最常用的格式是可执行与可链接格式ELF)。Windows 使用 PE/COFF 格式规范,而在 macOS 上,我们会遇到 Mach 对象(Mach-O 格式)。

目标文件是单个源文件的直接翻译。每个文件必须单独编译,然后由链接器将其合并成一个可执行文件或库。这个模块化过程在修改代码时可以显著节省时间,因为只有程序员更新的文件需要重新编译。

编译器必须执行以下阶段才能创建目标文件

  • 预处理

  • 语言分析

  • 汇编

  • 优化

  • 代码生成

让我们更详细地解释一下它们。

预处理,虽然大多数编译器自动调用,但被视为实际编译之前的准备步骤。它的作用是对源代码进行基本的操作;执行#include指令、通过#define指令和-D标志替换标识符为已定义的值、调用简单的宏,并根据#if#elif#endif指令有条件地包含或排除部分代码。预处理器对实际的 C++代码毫不知情。从本质上讲,它充当一个高级的查找和替换工具。

然而,预处理器在构建高级程序中的作用至关重要。将代码分割成多个部分并在多个翻译单元之间共享声明的能力是代码可重用性的基础。

接下来是语言分析,在这一阶段,编译器进行更复杂的操作。它逐字符扫描预处理后的文件(现在已包含由预处理器插入的所有头文件)。通过一种称为词法分析的过程,它将字符分组为有意义的记号——这些记号可能是关键字、运算符、变量名等。

然后,令牌会被组装成链并进行检查,以验证它们的顺序和存在是否符合 C++的语法规则——这一过程称为语法分析或解析。通常,这是生成大多数错误信息的阶段,因为它识别了语法问题。

最后,编译器进行语义分析。在这个阶段,编译器检查文件中的语句是否在逻辑上是合理的。例如,它确保所有类型正确性检查都已满足(你不能将整数赋值给字符串变量)。这一分析确保程序在编程语言的规则范围内是合乎逻辑的。

汇编阶段本质上是将这些令牌翻译成基于平台可用指令集的 CPU 特定指令。有些编译器实际上生成汇出文件,然后传递给专门的汇编程序。该程序生成 CPU 可以执行的机器代码。其他编译器直接在内存中生成机器代码。通常,这些编译器还提供生成可供人类阅读的汇编代码的选项。然而,尽管这些代码是可以阅读的,但并不意味着它们容易理解或值得这么做。

优化并不仅仅局限于编译过程中的某一个步骤,而是在每个阶段逐步进行的。然而,在初步汇编生成后,有一个独立的阶段,专注于最小化寄存器使用并消除冗余代码。

一个有趣且值得注意的优化技术是内联展开或内联。在这个过程中,编译器有效地将函数体“剪切”并将其“粘贴”到函数调用的位置。C++标准并没有明确定义何时进行这种操作——它是依赖于实现的。内联展开可以提高执行速度并减少内存使用,但它也会对调试产生重大影响,因为执行的代码不再与源代码中的原始行对应。

代码生成阶段涉及将优化后的机器代码写入一个与目标平台规范对齐的目标文件中。然而,这个目标文件尚未准备好执行——它需要传递给链中的下一个工具:链接器。链接器的工作是适当地重新定位我们的目标文件的各个部分,并解决对外部符号的引用,有效地为文件的执行做准备。此步骤标志着美国信息交换标准代码ASCII)源代码转化为二进制可执行文件,这些文件可以直接由 CPU 处理。

这些阶段每个都非常重要,并且可以配置以满足我们的特定需求。让我们看看如何使用 CMake 来管理这个过程。

初始配置

CMake 提供了多个命令,可以影响编译过程中的每个阶段。

  • target_compile_features(): 这需要一个具有特定功能的编译器来编译此目标。

  • target_sources(): 该命令将源文件添加到已定义的目标中。

  • target_include_directories(): 该命令设置预处理器 包含路径

  • target_compile_definitions(): 该命令设置预处理器定义。

  • target_compile_options(): 该命令设置编译器特定的命令行选项。

  • target_precompile_headers(): 该命令设置外部头文件以便进行预编译优化。

每个命令接受类似格式的参数:

target_...(<target name> <INTERFACE|PUBLIC|PRIVATE> <arguments>) 

这意味着使用该命令设置的属性通过传递的使用要求传播,如 第五章与目标一起工作 中的 什么是传递使用要求? 部分所讨论的,可以用于可执行文件和库。另外,值得注意的是,所有这些命令都支持生成器表达式。

需要从编译器中获取特定的功能

第四章设置你的第一个 CMake 项目 中的 检查支持的编译器功能 部分所述,预见问题并确保在出现错误时给用户清晰的信息至关重要——例如,当一个可用的编译器 X 不提供所需的功能 Y 时。这种方法比让用户解读不兼容工具链所产生的错误更为友好。我们不希望用户将不兼容问题归咎于我们的代码,而是他们过时的环境。

你可以使用以下命令来指定目标构建所需的所有功能:

target_compile_features(<target> <PRIVATE|PUBLIC|INTERFACE>
                        <feature> [...]) 

CMake 支持以下 compiler_ids 的 C++ 标准和编译器功能:

  • AppleClang: 用于 Xcode 版本 4.4+ 的 Apple Clang

  • Clang: Clang 编译器版本 2.9+

  • GNU: GNU 编译器版本 4.4+

  • MSVC: Microsoft Visual Studio 版本 2010+

  • SunPro: Oracle Solaris Studio 版本 12.4+

  • Intel: Intel 编译器版本 12.1+

CMake 支持超过 60 个功能,你可以在官方文档中找到完整列表,详见解释 CMAKE_CXX_KNOWN_FEATURES 变量的页面。不过,除非你在寻找某个非常具体的功能,否则我建议选择一个表示一般 C++ 标准的高级元功能:

  • cxx_std_14

  • cxx_std_17

  • cxx_std_20

  • cxx_std_23

  • cxx_std_26

查看以下示例:

target_compile_features(my_target PUBLIC cxx_std_26) 

这基本上等同于在 第四章设置你的第一个 CMake 项目 中引入的 set(CMAKE_CXX_STANDARD 26)set(CMAKE_CXX_STANDARD_REQUIRED ON)。然而,区别在于 target_compile_features() 是按目标处理的,而不是为整个项目全局处理,这在你需要为项目中的所有目标添加时可能会显得麻烦。

在官方手册中查看 CMake 的 支持的编译器 的更多详细信息(请参见 进一步阅读 部分获取网址)。

管理目标的源文件

我们已经知道如何告诉 CMake 哪些源文件构成一个目标,无论它是可执行文件还是库。我们通过在使用add_executable()add_library()命令时提供一个文件列表来做到这一点。

随着您的解决方案扩展,每个目标的文件列表也在增长。这可能会导致一些相当冗长的add_...()命令。我们该如何处理呢?一种诱人的方法可能是使用file()命令的GLOB模式,这样可以从子目录中收集所有文件并将它们存储在一个变量中。我们可以将其作为参数传递给目标声明,再也不需要关心文件列表了:

file(GLOB helloworld_SRC "*.h" "*.cpp")
add_executable(helloworld ${helloworld_SRC}) 

然而,这种方法并不推荐。让我们理解一下为什么。CMake 根据列表文件中的更改生成构建系统。所以,如果没有检测到任何更改,您的构建可能会在没有任何警告的情况下失败(这是开发者的噩梦)。此外,省略目标声明中的所有源代码可能会破坏像 CLion 这样的 IDE 中的代码检查,因为它知道如何解析某些 CMake 命令来理解您的项目。

在目标声明中使用变量是不建议的,原因是:它会创建一个间接层,导致开发者在阅读项目时必须解包目标定义。为了遵循这个建议,我们又面临另一个问题:如何有条件地添加源文件?这在处理特定平台的实现文件时是一个常见场景,例如gui_linux.cppgui_windows.cpp

target_sources()命令允许我们将源文件附加到之前创建的目标:

ch07/01-sources/CMakeLists.txt

add_executable(main main.cpp)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
  target_sources(main PRIVATE gui_linux.cpp)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
  target_sources(main PRIVATE gui_windows.cpp)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
  target_sources(main PRIVATE gui_macos.cpp)
else()
  message(FATAL_ERROR "CMAKE_SYSTEM_NAME=${CMAKE_SYSTEM_NAME} not supported.")
endif() 

这样,每个平台都会得到一组兼容的文件。这很好,但如果源文件列表很长怎么办?嗯,我们只能接受某些事情尚不完美,并继续手动添加它们。如果您正在与一个非常长的列表作斗争,那么您很可能在项目结构上做错了什么:也许可以考虑将源文件划分为库。

现在我们已经涵盖了编译的基本知识,让我们深入了解第一步——预处理。像所有计算机科学的事物一样,细节决定成败。

配置预处理器

预处理器在构建过程中扮演着巨大的角色。也许这有点令人惊讶,因为它的功能看起来相当直接和有限。在接下来的章节中,我们将介绍如何提供包含文件的路径和使用预处理器定义。我们还将解释如何使用 CMake 配置包含的头文件。

提供包含文件的路径

预处理器的最基本功能是能够使用#include指令包含.h.hpp头文件,这有两种形式:

  • 尖括号形式:#include <path-spec>

  • 引号形式:#include "path-spec"

如我们所知,预处理器将把这些指令替换为 path-spec 中指定文件的内容。查找这些文件可能会很有挑战性。应该搜索哪些目录,以及按什么顺序搜索?不幸的是,C++ 标准并未明确规定这一点。我们必须查看所使用编译器的手册。

通常,尖括号形式将检查标准的 包含目录,这些目录包括系统中存储标准 C++ 库和标准 C 库头文件的目录。

引号形式首先会在当前文件的目录中搜索被包含的文件,然后再检查尖括号形式的目录。

CMake 提供了一条命令来操作搜索包含文件的路径:

target_include_directories(<target> [SYSTEM] [AFTER|BEFORE]
                           <INTERFACE|PUBLIC|PRIVATE> [item1...]
                          [<INTERFACE|PUBLIC|PRIVATE> [item2...]
...]) 

这使我们能够添加希望编译器扫描的自定义路径。CMake 将在生成的构建系统中将它们添加到编译器调用中,并为特定编译器提供适当的标志(通常是 -I)。

target_include_directories() 命令通过在目标的 INCLUDE_DIRECTORIES 属性中附加或预附加目录来修改它,具体取决于是否使用 AFTERBEFORE 关键字。然而,是否在默认目录之前或之后检查这些目录,仍然由编译器决定(通常是在之前)。

SYSTEM 关键字表示编译器应将给定的目录视为标准系统目录(用于尖括号形式)。对于许多编译器,这些目录是通过 -isystem 标志传递的。

预处理器定义

回想一下之前讨论的编译阶段中的预处理器 #define#if#elif 以及 #endif 指令。让我们看一下以下示例:

ch07/02-definitions/definitions.cpp

#include <iostream>
int main() {
#if defined(ABC)
    std::cout << "ABC is defined!" << std::endl;
#endif
#if (DEF > 2*4-3)
    std::cout << "DEF is greater than 5!" << std::endl;
#endif
} 

如此一来,这个例子没有任何效果,因为 ABCDEF 都没有被定义(在这个例子中,DEF 会默认为 0)。我们可以通过在代码的顶部添加两行来轻松改变这一点:

#define ABC
#define DEF 8 

编译并执行此代码后,我们可以在控制台中看到两条消息:

ABC is defined!
DEF is greater than 5! 

这看起来似乎足够简单,但如果我们想根据外部因素(如操作系统、架构或其他因素)来条件化这些部分怎么办?好消息是,你可以将值从 CMake 传递给 C++ 编译器,而且这并不复杂。

target_compile_definitions() 命令就足够了:

ch07/02-definitions/CMakeLists.txt

set(VAR 8)
add_executable(defined definitions.cpp)
target_compile_definitions(defined PRIVATE ABC "DEF=${VAR}") 

前面的代码将与两个 #define 语句的行为完全相同,但我们有灵活性使用 CMake 的变量和生成器表达式,并且可以将命令放入条件块中。

传统上,这些定义通过 -D 标志传递给编译器(例如,-DFOO=1),有些程序员仍然在这个命令中继续使用这个标志:

target_compile_definitions(hello PRIVATE -DFOO) 

CMake 能识别这一点,并会自动移除任何前导的 -D 标志。它还会忽略空字符串,因此以下命令是完全有效的:

target_compile_definitions(hello PRIVATE -D FOO) 

在这种情况下,-D 是一个独立的参数,移除后会变成空字符串,并随后被忽略,从而确保行为正确。

避免在单元测试中访问私有类字段

一些在线资源建议结合使用特定的 -D 定义与 #ifdef/ifndef 指令,用于单元测试。此方法最直接的应用是将 public 访问控制符包含在条件包含中,当 UNIT_TEST 被定义时,使所有字段都变为公共(默认情况下,类字段是私有的):

class X {
#ifdef UNIT_TEST
  public:
#endif
  int x_;
} 

尽管这种技术提供了便利(允许测试直接访问私有成员),但它并不会产生干净的代码。理想情况下,单元测试应该专注于验证公共接口内方法的功能,将底层实现视为黑盒。因此,我建议仅在不得已时使用这种方法。

使用 Git 提交跟踪已编译版本

让我们思考一些可以从了解环境或文件系统细节中受益的用例。一个典型的例子可能是在专业环境中,传递用于构建二进制文件的修订或提交 SHA。可以通过以下方式实现:

ch07/03-git/CMakeLists.txt

add_executable(print_commit print_commit.cpp)
execute_process(COMMAND git log -1 --pretty=format:%h
                OUTPUT_VARIABLE SHA)
target_compile_definitions(print_commit
                           PRIVATE "SHA=${SHA}") 

然后,SHA 可以在我们的应用中按如下方式使用:

ch07/03-git/print_commit.cpp

#include <iostream>
// special macros to convert definitions into c-strings:
#define str(s) #s
#define xstr(s) str(s)
int main()
{
#if defined(SHA)
    std::cout << "GIT commit: " << xstr(SHA) << std::endl;
#endif
} 

当然,前面的代码要求用户安装并在其 PATH 中能够访问 Git。这个功能在生产服务器上运行的程序是通过持续集成/部署流水线构建的情况下特别有用。如果我们的软件出现问题,可以迅速检查到底是哪个 Git 提交被用来构建有问题的产品。

跟踪确切的提交对于调试非常有帮助。将单个变量传递给 C++ 代码非常简单,但当需要将几十个变量传递给头文件时,我们该如何处理呢?

配置头文件

通过 target_compile_definitions() 传递定义可能会变得繁琐,尤其是当变量众多时。难道提供一个带有占位符的头文件,引用这些变量,并让 CMake 来填充它们,不更简单吗?绝对可以!

CMake 的 configure_file(<input> <output>) 命令允许你从模板生成新文件,示例如下:

ch07/04-configure/configure.h.in

#cmakedefine FOO_ENABLE
#cmakedefine FOO_STRING1 "@FOO_STRING1@"
#cmakedefine FOO_STRING2 "${FOO_STRING2}"
#cmakedefine FOO_UNDEFINED "@FOO_UNDEFINED@" 

你可以按如下方式使用此命令:

ch07/04-configure/CMakeLists.txt

add_executable(configure configure.cpp)
set(FOO_ENABLE ON)
set(FOO_STRING1 "abc")
set(FOO_STRING2 "def")
configure_file(configure.h.in configured/configure.h)
target_include_directories(configure PRIVATE
                           ${CMAKE_CURRENT_BINARY_DIR}) 

CMake 然后会生成一个类似以下的输出文件:

ch07/04-configure/<build_tree>/configured/configure.h

#define FOO_ENABLE
#define FOO_STRING1 "abc"
#define FOO_STRING2 "def"
/* #undef FOO_UNDEFINED */ 

如你所见,@VAR@${VAR} 变量占位符已被 CMake 列表文件中的值替换。此外,#cmakedefine 被已定义变量的 #define 和未定义变量的 /* #undef VAR */ 所取代。如果你需要显式的 #define 1#define 0 用于 #if 块,请改用 #cmakedefine01

你可以通过简单地在实现文件中包含这个配置好的头文件,将其集成到你的应用程序中:

ch07/04-configure/configure.cpp

#include <iostream>
#include "configured/configure.h"
// special macros to convert definitions into c-strings:
#define str(s) #s
#define xstr(s) str(s)
using namespace std;
int main()
{
#ifdef FOO_ENABLE
  cout << "FOO_ENABLE: ON" << endl;
#endif
  cout << "FOO_STRING1: " << xstr(FOO_STRING1) << endl;
  cout << "FOO_STRING2: " << xstr(FOO_STRING2) << endl;
  cout << "FOO_UNDEFINED: " << xstr(FOO_UNDEFINED) << endl;
} 

通过将二叉树添加到我们的包含路径中,并使用 target_include_directories() 命令,我们可以编译示例,并接收来自 CMake 的输出:

FOO_ENABLE: ON
FOO_STRING1: "abc"
FOO_STRING2: "def"
FOO_UNDEFINED: FOO_UNDEFINED 

configure_file() 命令还包括一系列格式化和文件权限选项,由于篇幅限制,我们不会在此深入探讨。如果你感兴趣,可以参考在线文档获取更多细节(请参阅本章的 进一步阅读 部分)。

在准备好完整的头文件和源文件编译后,让我们讨论在后续步骤中输出代码是如何形成的。尽管我们无法直接影响语言分析或汇编(因为这些步骤遵循严格的标准),但我们可以调整优化器的配置。让我们来探索一下这种配置如何影响最终结果。

配置优化器

优化器将分析前一阶段的输出,并使用多种策略,程序员通常不会直接使用这些策略,因为它们不符合干净代码原则。但这没关系——优化器的核心作用是提高代码性能,追求低 CPU 使用率、最小化寄存器使用和减少内存占用。当优化器遍历源代码时,它会将代码重构为几乎无法辨认的形式,专门为目标 CPU 量身定制。

优化器不仅会决定哪些函数可以删除或压缩,它还会重新排列代码,甚至大规模复制代码!如果它能够确定某些代码行是多余的,它会将这些行从重要函数中间删除(你甚至不会注意到)。它会回收内存,让多个变量在不同时间占用相同的位置。它甚至可以将你的控制结构重塑成完全不同的形式,如果这样做能节省几次 CPU 周期的话。

如果程序员手动将上述技术应用到源代码中,它将把代码变成一团糟,既难写又难理解。然而,当编译器应用这些技术时,它们是有益的,因为编译器严格遵循给定的指令。优化器是一只无情的野兽,服务的唯一目的就是加速执行速度,无论输出变得多么扭曲。这样的输出可能包含一些调试信息,如果我们在测试环境中运行它,或者可能不包含调试信息,以防止未授权的人篡改。

每个编译器都有自己独特的技巧,这与它支持的平台和所遵循的哲学一致。我们将查看 GNU GCC 和 LLVM Clang 中最常见的一些,以便了解哪些是实际可行的。

事情是这样的——许多编译器默认不会启用任何优化(包括 GCC)。在某些情况下这样没问题,但在其他情况下就不行了。为什么要慢呢,当你可以更快?为了解决这个问题,我们可以使用 target_compile_options() 命令,明确表达我们对编译器的期望。

该命令的语法与本章中的其他命令类似:

target_compile_options(<target> [BEFORE]
                       <INTERFACE|PUBLIC|PRIVATE> [items1...]
                      [<INTERFACE|PUBLIC|PRIVATE> [items2...]
...]) 

我们提供命令行选项,在构建目标时使用,并且还指定了传播关键字。当执行时,CMake 会将给定的选项附加到目标的适当 COMPILE_OPTIONS 变量中。如果我们希望将它们放在前面,可以使用可选的 BEFORE 关键字。在某些场景中,顺序可能很重要,因此能够选择顺序是有益的。

请注意,target_compile_options() 是一个通用命令。它也可以用于为编译器提供其他参数,例如 -D 定义,CMake 还提供了 target_compile_definition() 命令。建议尽可能使用最专业的 CMake 命令,因为它们在所有支持的编译器中保证以相同的方式工作。

现在是讨论细节的时候了。接下来的部分将介绍可以在大多数编译器中启用的各种优化。

一般级别

优化器的所有不同行为可以通过特定的标志来深入配置,这些标志我们可以作为 编译选项 传递。如果我们只是想要一个在大多数情况下都能很好工作的最佳解决方案,该怎么办?我们可以选择一个通用的解决方案——一个优化级别说明符。

大多数编译器提供四个基本的优化级别,从 03。我们通过 -O<level> 选项来指定它们。-O0 意味着 没有优化,通常这是编译器的默认级别。另一方面,-O2 被认为是 完全优化,它生成高度优化的代码,但代价是最慢的编译时间。

还有一个中间的 -O1 级别,这个级别(根据你的需求)可能是一个不错的折衷——它启用了合理的优化机制,同时不会过多地减慢编译速度。

最后,我们可以选择 -O3,这是完全优化,类似于 -O2,但采用更激进的子程序内联和循环向量化方法。

还有一些优化的变体,它们优化的是生成文件的大小(不一定是速度)——-Os。有一种超激进的优化 -Ofast,它是 -O3 优化,但不严格遵守 C++ 标准。最明显的区别是使用了 -ffast-math-ffinite-math 标志,这意味着如果你的程序涉及精确计算(大多数程序都是),你可能希望避免使用它。

CMake 知道并非所有编译器都是一样的,因此它通过为编译器提供一些默认标志来标准化开发者的体验。这些标志存储在系统范围内(而非特定目标)的变量中,用于所使用的语言(CXX 用于 C++)和构建配置(DEBUGRELEASE):

  • CMAKE_CXX_FLAGS_DEBUG 等于 -g

  • CMAKE_CXX_FLAGS_RELEASE 等于 -O3 -DNDEBUG

正如你所看到的,调试配置不会启用任何优化,而发布配置则直接使用 O3。如果你愿意,你可以通过 set() 命令直接更改它们,或者只需添加目标编译选项,这将覆盖默认行为。另两个标志(-g, -DNDEBUG)与调试相关——我们将在本章的 为调试器提供信息 部分讨论它们。

CMAKE_<LANG>_FLAGS_<CONFIG> 这样的变量是全局的——它们适用于所有目标。建议通过属性和命令(如 target_compile_options())来配置目标,而不是依赖于全局变量。这样,你可以更细粒度地控制你的目标。

通过选择优化级别 -O<level>,我们间接设置了一长串标志,每个标志控制着特定的优化行为。然后,我们可以通过追加更多标志来微调优化,如下所示:

  • 使用 -f 选项启用它们:-finline-functions

  • 使用 -fno 选项禁用它们:-fno-inline-functions

这些标志中的一些值得更好地理解,因为它们会影响你的程序的运行方式以及你如何调试它。让我们来看看。

函数内联

正如你可能记得的那样,编译器可以通过在类的声明块中定义一个函数,或通过显式使用 inline 关键字来鼓励内联一些函数:

struct X {
  void im_inlined(){ cout << "hi\n"; };
  void me_too();
};
**inline** void X::me_too() { cout << "bye\n"; }; 

内联一个函数的决定最终由编译器做出。如果启用了内联,并且该函数仅在一个位置使用(或是一个在少数地方使用的相对较小的函数),那么内联很可能会发生。

函数内联是一种有趣的优化技术。它通过将目标函数的代码提取出来并嵌入到所有调用该函数的位置来工作。这个过程替换了原始的调用,并节省了宝贵的 CPU 周期。

让我们考虑以下使用我们刚刚定义的类的示例:

int main() {
  X x;
  x.im_inlined();
  x.me_too();
  return 0;
} 

如果没有内联,代码将在main()框架中执行,直到方法调用为止。然后,它会为im_inlined()创建一个新框架,在一个单独的作用域中执行,并返回到main()框架。me_too()方法也会发生同样的情况。

然而,当发生内联时,编译器会替换调用,类似这样:

int main() {
  X x;
  cout << "hi\n";
  cout << "bye\n";
  return 0;
} 

这并不是精确的表示,因为内联发生在汇编或机器代码的层面(而非源代码层面),但它提供了一个大致的概念。

编译器使用内联来节省时间。它跳过了创建和销毁新调用框架的过程,避免了查找下一个要执行的指令地址(并返回)的需求,并且增强了指令缓存,因为它们彼此非常接近。

然而,内联确实带来了一些显著的副作用。如果一个函数被多次使用,它必须复制到所有调用位置,从而导致文件大小增大和内存使用增加。尽管今天这可能不像以前那么关键,但它仍然相关,尤其是在为低端设备(内存有限)开发软件时。

此外,内联对调试产生了重大影响。内联代码不再出现在原始的行号位置,这使得追踪变得更加困难,有时甚至变得不可能。这就是为什么在内联的函数上设置调试断点时,永远不会被触发(即使代码仍然以某种方式被执行)。为了解决这个问题,你需要在调试版本中禁用内联(这意味着无法测试完全相同的发布版本)。

我们可以通过为目标指定-O0(o-zero)级别,或直接修改负责内联的标志来实现:

  • -finline-functions-called-once:仅适用于 GCC。

  • -finline-functions:适用于 Clang 和 GCC。

  • -finline-hint-functions:仅适用于 Clang。

内联可以通过-fno-inline-...显式禁用,但是,若要了解详细信息,建议查阅特定编译器版本的文档。

循环展开

循环展开,也称为循环解开,是一种优化技术。该策略旨在将循环转换为一系列实现相同结果的语句。因此,这种方法将程序的小体积换成了执行速度,因为它消除了循环控制指令、指针运算和循环结束检查。

请看以下示例:

void func() {
  for(int i = 0; i < 3; i++)
    cout << "hello\n";
} 

上述代码将被转换为类似如下内容:

void func() {
    cout << "hello\n";
    cout << "hello\n";
    cout << "hello\n";
} 

结果将是一样的,但我们不再需要分配 i 变量、递增它或将其与值 3 比较三次。如果在程序的生命周期内多次调用 func(),即使是展开如此短小的函数,也会产生显著的差异。

然而,理解两个限制因素是很重要的。首先,循环展开只有在编译器知道或能够准确估计迭代次数时才有效。其次,循环展开可能会对现代 CPU 产生不良影响,因为增加的代码大小可能会妨碍有效的缓存。

每个编译器提供的此标志的版本略有不同:

  • -floop-unroll:这是用于 GCC 的选项。

  • -funroll-loops:这是用于 Clang 的选项。

如果你不确定,广泛测试此标志是否影响你特定的程序,并显式地启用或禁用它。请注意,在 GCC 中,它在 -O3 下隐式启用,作为隐式启用的 -floop-unroll-and-jam 标志的一部分。

循环向量化

被称为单指令多数据SIMD)的机制是在 1960 年代初期开发的,目的是实现并行性。顾名思义,它旨在同时对多个数据执行相同的操作。让我们通过以下示例来实际了解这一点:

int a[128];
int b[128];
// initialize b
for (i = 0; i<128; i++)
  a[i] = b[i] + 5; 

通常,这样的代码会循环 128 次,但在具备能力的 CPU 上,通过同时计算两个或更多的数组元素,代码的执行可以显著加速。这是因为连续元素之间没有依赖关系,且数组之间的数据没有重叠。聪明的编译器可以将前面的循环转换为如下形式(这发生在汇编级别):

for (i = 0; i<32; i+=4) {
  a[ i ] = b[ i ] + 5;
  a[i+1] = b[i+1] + 5;
  a[i+2] = b[i+2] + 5;
  a[i+3] = b[i+3] + 5;
} 

GCC 在 -O3 下会启用这种自动循环向量化。Clang 默认启用它。两种编译器都提供不同的标志来启用/禁用特定的向量化:

  • -ftree-vectorize -ftree-slp-vectorize:这是用于启用 GCC 中向量化的选项。

  • -fno-vectorize -fno-slp-vectorize:这是用于在 Clang 中禁用向量化的选项。

向量化的效率源于利用 CPU 制造商提供的特殊指令,而不仅仅是将原始的循环形式替换为展开的版本。因此,手动实现相同的性能水平是不可行的(此外,这也不会导致简洁的代码)。

优化器在提高程序运行时性能方面发挥着至关重要的作用。通过有效地利用其策略,我们可以获得更多的效益。效率不仅在编码完成后很重要,在软件开发过程中同样如此。如果编译时间过长,我们可以通过更好地管理过程来改进它。

管理编译过程

作为程序员和构建工程师,我们还必须考虑编译过程中的其他方面,例如完成时间以及在解决方案构建过程中识别和修正错误的便捷性。

降低编译时间

在需要频繁重新编译的繁忙项目中(可能每小时多次),确保编译过程尽可能快速是至关重要的。这不仅影响你的代码编译测试循环的效率,还会影响你的专注力和工作流程。

幸运的是,C++已经相当擅长管理编译时间,这要归功于分离的翻译单元。CMake 会确保只重新编译受到最近更改影响的源文件。然而,如果我们需要进一步改善,有几种技术可以使用:头文件预编译和统一构建。

头文件的预编译

头文件(.h)由预处理器在实际编译开始之前包含到翻译单元中。这意味着每当.cpp实现文件发生变化时,它们必须重新编译。此外,如果多个翻译单元使用相同的共享头文件,每次包含时都必须编译一次。这是低效的,但它已经是长期以来的标准做法。

幸运的是,从 CMake 3.16 版本开始,CMake 提供了一个命令来启用头文件预编译。这使得编译器可以将头文件与实现文件分开处理,从而加速编译过程。以下是该命令的语法:

target_precompile_headers(<target>
                          <INTERFACE|PUBLIC|PRIVATE> [header1...]
                         [<INTERFACE|PUBLIC|PRIVATE> [header2...]
...]) 

添加的头文件列表存储在PRECOMPILE_HEADERS目标属性中。正如我们在第五章与目标的协作中讨论的,在什么是传递的使用要求?部分,我们可以使用传播的属性,通过选择PUBLICINTERFACE关键字,将头文件与任何依赖目标共享;然而,对于使用install()命令导出的目标,不应这样做。其他项目不应被强迫使用我们的预编译头文件,因为这并不是一种常规做法。

使用在第六章使用生成器表达式中描述的$<BUILD_INTERFACE:...>生成器表达式,防止预编译头文件出现在目标的使用要求中,尤其是在它们被安装时。然而,它们仍会被添加到通过export()命令从构建树中导出的目标中。如果现在这看起来有点困惑,不用担心——在第十四章安装与打包中会做详细说明。

CMake 会将所有头文件的名称放入一个cmake_pch.hcmake_pch.hxx文件中,然后将该文件预编译为一个特定于编译器的二进制文件,扩展名为.pch.gch.pchi

我们可以在我们的列表文件中像这样使用它:

ch07/06-precompile/CMakeLists.txt

add_executable(precompiled hello.cpp)
target_precompile_headers(precompiled PRIVATE <iostream>) 

我们也可以在对应的源文件中使用它:

ch07/06-precompile/hello.cpp

int main() {
  std::cout << "hello world" << std::endl;
} 

请注意,在我们的main.cpp文件中,我们不需要包含cmake_pch.h或任何其他头文件——它将由 CMake 使用特定于编译器的命令行选项包含进来。

在前面的例子中,我使用了一个内置头文件;然而,你可以轻松地添加自己的包含类或函数定义的头文件。可以使用两种形式之一来引用头文件:

  • header.h(直接路径)被解释为相对于当前源目录的路径,并将以绝对路径包含。

  • [["header.h"]](双括号和引号)的路径将根据目标的 INCLUDE_DIRECTORIES 属性进行扫描,该属性可以通过 target_include_directiories() 配置。

一些在线参考资料可能会建议避免预编译那些不是标准库的一部分的头文件,比如 <iostream>,或者完全不使用预编译头文件。这是因为修改列表或编辑自定义头文件将导致目标中的所有翻译单元重新编译。使用 CMake 时,这个问题就没有那么严重,尤其是当你正确地组织项目(将项目结构划分为相对较小、聚焦于特定领域的目标)时。每个目标都有一个独立的预编译头文件,这样可以限制头文件更改的影响。

如果你的头文件被认为相对稳定,你可以决定在目标中重用预编译头文件。为此,CMake 提供了一个方便的命令:

target_precompile_headers(<target> REUSE_FROM <other_target>) 

这会设置目标的 PRECOMPILE_HEADERS_REUSE_FROM 属性,重用头文件,并在这些目标之间创建依赖关系。使用这种方法,消费目标将无法再指定自己的预编译头文件。此外,所有的编译选项编译标志编译定义必须在目标之间匹配。

注意要求,尤其是如果你有任何使用双括号格式([["header.h"]])的头文件。两个目标都需要适当设置它们的包含路径,以确保编译器能够找到这些头文件。

Unity 构建

CMake 3.16 引入了另一种编译时间优化功能——Unity 构建,也被称为统一构建超大构建。Unity 构建通过利用 #include 指令将多个实现源文件合并。这有一些有趣的影响,其中一些是有利的,而另一些可能是有害的。

最明显的优势是,当 CMake 创建统一构建文件时,避免了不同翻译单元中头文件的重新编译:

#include "source_a.cpp"
#include "source_b.cpp" 

当两个源文件中都有 #include "header.h" 行时,参考的文件只会被解析一次,得益于包含保护(假设它们已正确添加)。虽然不如预编译头文件精细,但这也是一种替代方案。

这种构建方式的第二个好处是,优化器现在可以在更大的范围内工作,优化所有捆绑源代码之间的过程间调用。这类似于我们在第四章设置你的第一个 CMake 项目中的过程间优化部分讨论的链接时间优化。

然而,这些好处是有权衡的。由于我们减少了目标文件和处理步骤的数量,我们也增加了处理较大文件所需的内存量。此外,我们减少了可并行工作的数量。编译器在多线程编译方面并不特别擅长,因为它们通常不需要这样做——构建系统通常会启动许多编译任务,以便在不同的线程上同时执行所有文件。将所有文件分组在一起会使这一过程变得复杂,因为 CMake 现在需要并行编译的文件变少了。

使用 Unity 构建时,你还需要考虑一些可能不容易察觉的 C++ 语义影响——匿名命名空间隐藏跨文件的符号,现在这些符号的作用域局限于 Unity 文件,而不是单独的翻译单元。静态全局变量、函数和宏定义也会发生同样的情况。这可能会导致名称冲突,或执行错误的函数重载。

Jumbo 构建在重新编译时表现不佳,因为它们会编译比实际需要的更多文件。它们最适合用于代码需要尽可能快地编译所有文件的情况。在 Qt Creator(一个流行的 GUI 库)上进行的测试表明,你可以期望性能提高 20%到 50%之间(具体取决于使用的编译器)。

要启用 Unity 构建,我们有两个选择:

  • CMAKE_UNITY_BUILD变量设置为true——它将初始化随后定义的每个目标上的UNITY_BUILD属性。

  • 手动将UNITY_BUILD目标属性设置为true,用于所有应使用 Unity 构建的目标。

第二种选择通过调用以下内容来实现:

set_target_properties(<target1> <target2> ...
                      PROPERTIES UNITY_BUILD true) 

在许多目标上手动设置这些属性当然需要更多的工作,并增加了维护成本,但你可能需要这样做,以便更精细地控制这一设置。

默认情况下,CMake 会创建包含八个源文件的构建,这些源文件由目标的UNITY_BUILD_BATCH_SIZE属性指定(该属性在目标创建时从CMAKE_UNITY_BUILD_BATCH_SIZE变量复制)。你可以更改目标属性或默认变量。

从版本 3.18 开始,你可以明确地定义文件应如何与命名组捆绑。为此,请将目标的UNITY_BUILD_MODE属性更改为GROUP(默认值是BATCH)。然后,通过将源文件的UNITY_GROUP属性设置为你选择的名称来将它们分配到组中:

set_property(SOURCE <src1> <src2> PROPERTY UNITY_GROUP "GroupA") 

然后,CMake 将忽略UNITY_BUILD_BATCH_SIZE并将该组中的所有文件添加到一个 Unity 构建中。

CMake 的文档建议默认情况下不要为公共项目启用统一构建。推荐的做法是,应用程序的最终用户应该能够决定是否希望使用 jumbo 构建,可以通过提供-DCMAKE_UNITY_BUILD命令行参数来实现。如果统一构建由于代码编写方式引发问题,你应该明确地将目标的属性设置为 false。然而,你可以自由地为内部使用的代码启用此功能,例如公司内部的代码或你自己的私人项目。

这些是使用 CMake 减少编译时间的最重要方面。编程中还有其他常常让我们浪费大量时间的因素——其中最臭名昭著的就是调试。让我们看看如何在这方面改进。

查找错误

作为程序员,我们花费大量时间在寻找 bug 上。不幸的是,这是我们职业的一个事实。识别错误并修复它们的过程常常让人焦躁不安,尤其是当修复需要长时间工作时。当我们缺乏必要的工具来帮助我们在这些困难的情况下航行时,这个难度会大大增加。正因如此,我们必须特别注意如何配置环境,使得这一过程变得更加简化,尽可能轻松和耐受。一种实现这一目标的方法是通过target_compile_options()配置编译器。那么,哪些编译选项可以帮助我们实现这一目标呢?

配置错误和警告

软件开发中有很多令人头疼的事情——在深夜修复关键性 bug,处理大型系统中的高可见度和高成本故障,或者面对恼人的编译错误。一些错误难以理解,而另一些则是繁琐且具有挑战性的修复任务。在你努力简化工作并减少失败的机会时,你会发现很多关于如何配置编译器警告的建议。

其中一个值得注意的建议是默认启用-Werror标志进行所有构建。从表面上看,这个标志的功能看起来很简单——它将所有警告视为错误,直到你解决每个警告,代码才会继续编译。虽然看起来似乎是一种有益的方法,但它通常并非如此。

你看,警告之所以不被归类为错误,是有原因的:它们的设计目的是提醒你。如何处理这些警告由你自己决定。特别是在你进行实验或原型开发时,能够忽视某些警告往往是非常宝贵的。

另一方面,如果你有一段完美的、没有警告的、无懈可击的代码,似乎不应该让将来的修改破坏这种完美的状态。启用它并保持在那里,似乎也没有什么坏处,至少在你的编译器没有升级之前是这样。新版本的编译器通常对已弃用的特性更加严格,或者在提供改进建议方面更加高效。虽然这在警告仍然是警告时有益,但它可能导致在代码没有更改的情况下出现意外的构建失败,或者更让人沮丧的是,当你需要快速修复与新警告无关的问题时。

那么,什么时候启用所有可能的警告是可以接受的呢?简短的答案是,当你在创建一个公共库时。在这种情况下,你会希望预防那些因环境比你严格而导致的代码问题的工单。如果你选择启用这个设置,请确保及时更新新的编译器版本及其引入的警告。还需要特别管理这个更新过程,与代码变更的管理分开进行。

否则,让警告保持原样,集中精力处理错误。如果你觉得有必要强求严格,可以使用-Wpedantic标志。这个特定的标志会启用严格的 ISO C 和 ISO C++标准要求的所有警告。然而,请记住,这个标志并不能确认标准的符合性;它只是标识出那些需要诊断消息的非 ISO 做法。

更宽容且脚踏实地的程序员将会满足于-Wall,可以选择与-Wextra搭配使用,增加一些精致的警告,这样就足够了。这些警告被认为是真正有用的,当有时间时,你应该在代码中处理这些警告。

根据你的项目类型,还有许多其他警告标志可能会有用。我建议你阅读所选编译器的手册,看看有哪些可用的选项。

调试构建

偶尔,编译会失败。这通常发生在我们尝试重构大量代码或清理我们的构建系统时。有时问题可以很容易解决;然而,也有一些复杂的问题需要深入调查配置步骤。我们已经知道如何打印更详细的 CMake 输出(如在第一章中讨论的《CMake 的第一步》),但我们如何分析每个阶段实际上发生了什么?

调试各个阶段

-save-temps,可以传递给 GCC 和 Clang 编译器,允许我们调试编译的各个阶段。这个标志会指示编译器将某些编译阶段的输出存储在文件中,而不是存储在内存中。

ch07/07-debug/CMakeLists.txt

add_executable(debug hello.cpp)
target_compile_options(debug PRIVATE **-save-temps=obj**) 

启用此选项将在每个翻译单元中生成两个额外的文件(.ii.s)。

第一个文件,<build-tree>/CMakeFiles/<target>.dir/<source>.ii,存储预处理阶段的输出,并附有注释,解释每部分源代码的来源:

# 1 "/root/examples/ch07/06-debug/hello.cpp"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# / / / ... removed for brevity ... / / /
# 252 "/usr/include/x86_64-linux-
  gnu/c++/9/bits/c++config.h" 3
namespace std
{
  typedef long unsigned int size_t;
  typedef long int ptrdiff_t;
  typedef decltype(nullptr) nullptr_t;
}
... 

第二个文件,<build-tree>/CMakeFiles/<target>.dir/<source>.s,包含语言分析阶段的输出,已准备好进入汇编阶段:

 .file   "hello.cpp"
        .text
        .section        .rodata
        .type   _ZStL19piecewise_construct, @object
        .size   _ZStL19piecewise_construct, 1
_ZStL19piecewise_construct:
        .zero   1
        .local  _ZStL8__ioinit
        .comm   _ZStL8__ioinit,1,1
.LC0:
        .string "hello world"
        .text
        .globl  main
        .type   main, @function
main:
( ... ) 

根据问题的类型,我们通常可以揭示实际问题。例如,预处理器的输出可以帮助我们识别错误,如错误的包含路径(可能提供错误版本的库),或定义中的错误导致的#ifdef评估错误。

与此同时,语言分析的输出对于针对特定处理器和解决关键优化问题尤其有益。

调试头文件包含问题

调试错误的包含文件可能是一个具有挑战性的任务。我应该知道——在我第一份公司工作时,我曾经需要将整个代码库从一个构建系统迁移到另一个。如果你发现自己处于一个需要精确理解用于包含所请求头文件的路径的情况,可以考虑使用-H编译选项:

ch07/07-debug/CMakeLists.txt

add_executable(debug hello.cpp)
target_compile_options(debug PRIVATE **-H**) 

产生的输出将类似于以下内容:

[ 25%] Building CXX object
  CMakeFiles/inclusion.dir/hello.cpp.o
. /usr/include/c++/9/iostream
.. /usr/include/x86_64-linux-gnu/c++/9/bits/c++config.h
... /usr/include/x86_64-linux-gnu/c++/9/bits/os_defines.h
.... /usr/include/features.h
-- removed for brevity --
.. /usr/include/c++/9/ostream 

目标文件的名称后,每一行输出都包含一个头文件路径。在这个例子中,行首的单个点表示顶级包含(#include指令位于hello.cpp中)。两个点表示此文件由后续文件(<iostream>)包含。每增加一个点,表示嵌套的层级增加。

在此输出的末尾,你还可能会看到一些关于如何改进代码的建议:

Multiple include guards may be useful for:
/usr/include/c++/9/clocale
/usr/include/c++/9/cstdio
/usr/include/c++/9/cstdlib 

虽然你不需要解决标准库中的问题,但你可能会看到一些你自己编写的头文件被列出。在这种情况下,你可能需要考虑进行修正。

为调试器提供信息

机器代码是一组神秘的指令和数据,以二进制格式编码。它并没有传达更深层次的意义或目标。这是因为 CPU 并不关心程序的目标是什么,或者所有指令的含义。唯一的要求是代码的正确性。编译器会将上述所有内容翻译成 CPU 指令的数字标识符,存储数据以初始化所需的内存,并提供成千上万的内存地址。换句话说,最终的二进制文件不需要包含实际的源代码、变量名、函数签名或程序员关心的任何其他细节。这就是编译器的默认输出——原始且裸露。

这样做主要是为了节省空间并减少过多的开销。巧合的是,我们也在一定程度上保护了我们的应用程序免受逆向工程的攻击。是的,即使没有源代码,你也可以理解每个 CPU 指令的作用(例如,将这个值复制到那个寄存器)。但是,即使是最基础的程序也包含太多这样的指令,难以理清它们的逻辑。

如果你是一个特别有动力的人,你可以使用一个叫做反汇编器的工具,通过大量的知识(和一点运气),你将能够解读可能发生的事情。然而,这种方法并不太实际,因为反汇编的代码没有原始符号,这使得解读程序的逻辑变得非常困难且缓慢。

相反,我们可以要求编译器将源代码与编译后代码与原始代码之间的引用映射一起存储到生成的二进制文件中。然后,我们可以将调试器附加到正在运行的程序上,并查看在任何时刻正在执行哪个源代码行。当我们在编写新功能或修复错误等代码时,这一点是不可或缺的。

这两个用例是两个构建配置的原因:DebugRelease。正如我们之前所看到的,CMake 默认会向编译器提供一些标志来管理此过程,并首先将它们存储在全局变量中:

  • CMAKE_CXX_FLAGS_DEBUG 包含 -g

  • CMAKE_CXX_FLAGS_RELEASE包含 -DNDEBUG

-g标志的意思是“添加调试信息”。它以操作系统的本地格式提供:stabs、COFF、XCOFF 或 DWARF。这些格式可以被像 gdb(GNU 调试器)这样的调试器访问。通常,这对于像 CLion 这样的集成开发环境(IDE)来说是足够的,因为它们在后台使用 gdb。在其他情况下,请参考所提供调试器的手册,检查适用于您所选择编译器的正确标志。

对于 Release 配置,CMake 会添加 -DNDEBUG 标志。这是一个预处理器定义,简单来说就是“不是调试构建”。一些面向调试的宏将被故意禁用,其中之一就是在 <assert.h> 头文件中可用的 assert。如果你决定在生产代码中使用断言,它们将不起作用:

int main(void)
{
    **assert****(****false****)**;
    std::cout << "This shouldn't run. \n";
    return 0;
} 

Release 配置中,assert(false) 调用不会产生任何效果,但在 Debug 配置中,它会正常停止执行。如果你正在实践断言编程,并且仍然需要在发布版本中使用 assert(),你可以选择更改 CMake 提供的默认设置(从 CMAKE_CXX_FLAGS_RELEASE 中移除 NDEBUG),或者在包含头文件之前实现硬编码的覆盖,方法是取消定义该宏:

#undef NDEBUG
#include <assert.h> 

更多信息请参见断言参考:en.cppreference.com/w/c/error/assert

如果您的断言可以在编译时完成,您可以考虑用 C++11 中引入的 static_assert() 替代 assert(),因为该函数不像 assert() 那样被 #ifndef(NDEBUG) 预处理器指令保护。

到这里,我们已经学会了如何管理编译过程。

总结

我们又完成了一个章节!毫无疑问,编译是一个复杂的过程。由于它的各种边界情况和特定要求,在没有强大工具的支持下很难管理。幸运的是,CMake 在这方面做得非常出色。

那么,到目前为止我们学到了什么呢?我们从讨论编译是什么以及它在构建和运行操作系统中的应用程序这一更广泛叙述中所处的位置开始。然后,我们检查了编译的各个阶段以及管理这些阶段的内部工具。这种理解对于解决我们未来可能遇到的复杂问题是非常宝贵的。

接下来,我们探索了如何使用 CMake 来验证主机上可用的编译器是否满足构建我们代码所需的所有必要要求。正如我们已经确立的那样,对于我们的解决方案的用户来说,看到一条友好的消息,提示他们升级编译器,远比看到由无法处理新语言特性的过时编译器打印出来的晦涩错误信息要好得多。

我们简要讨论了如何将源文件添加到已经定义的目标中,然后继续讲解了预处理器的配置。这是一个相当重要的主题,因为这一阶段将所有代码片段汇集在一起,并决定哪些部分会被忽略。我们谈到了如何提供文件路径并单独或批量添加自定义定义(以及一些用例)。接着,我们讨论了优化器;我们探讨了所有常见的优化级别以及它们隐式添加的标志。我们还详细讲解了一些标志——finlinefloop-unrollftree-vectorize

最后,是时候回顾更大的图景,并研究如何管理编译的可行性了。我们在这里解决了两个主要方面——减少编译时间(从而帮助保持程序员的专注力)和发现错误。后者对于识别哪些地方出了问题以及为什么会出问题至关重要。正确配置工具并理解事情发生的原因,有助于确保代码的质量(也有助于维护我们的心理健康)。

在下一章,我们将学习链接以及在构建库并在项目中使用它们时需要考虑的所有事项。

进一步阅读

欲了解更多信息,您可以参考以下资源:

加入我们的 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

posted @   绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· .NET Core 中如何实现缓存的预热?
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 如何调用 DeepSeek 的自然语言处理 API 接口并集成到在线客服系统
· 【译】Visual Studio 中新的强大生产力特性
历史上的今天:
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 创建扩展
点击右上角即可分享
微信分享提示