现代C++ CMake 指南 – 5 使用目标(Target)

在 CMake 中,整个应用程序可以从单个源代码文件(如经典的 helloworld.cpp )构建。但同样可能创建一个由多个源文件构建可执行文件的项目:几十个甚至上千个文件。许多初学者遵循这样的路径:他们最初只用少数文件构建二进制文件,让项目自然增长而不做严格规划。随着需求增加不断添加文件,不知不觉中所有内容都直接链接到单个二进制文件,没有任何结构可言。

作为软件开发人员,我们会有意划定边界并指定组件来分组一个或多个翻译单元( .cpp 文件)。这样做是为了提高代码可读性、管理耦合与共生关系、加快构建过程,并最终发现和提取可复用组件为自治单元。

每个大型项目都会促使你引入某种形式的分区。这正是 CMake 目标发挥作用的地方。一个 CMake 目标代表专注于特定目标的逻辑单元。目标可以依赖于其他目标,其构建遵循声明式方法。CMake 负责确定构建目标的正确顺序,尽可能通过并行构建进行优化,并相应地执行必要的步骤。作为一般原则,当构建一个目标时,它会生成一个可供其他目标使用或作为构建过程最终输出的产物。

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

目标是极其强大的概念,能极大简化项目构建流程。理解其运作原理并掌握优雅有序的配置方法至关重要,这些知识将确保开发过程流畅高效。

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

理解目标的概念

设置目标属性

编写自定义命令

理解目标的概念

如果您曾使用过 GNU Make,那么您已经接触过目标(target)的概念。本质上,它是构建系统遵循的一套规则,用于将一组文件编译成另一个文件。这可以是将 .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 )的可执行文件。相反,应用程序需要自行生成图形用户界面。

下一个可选参数 MACOSX_BUNDLE 在某种程度上非常相似;它使得为 macOS/iOS 构建的应用程序可以从 Finder 作为 GUI 应用启动。

当使用 EXCLUDE_FROM_ALL 关键字时,将阻止可执行目标在常规默认构建中被编译。此类目标必须在构建命令中显式指定:

cmake --build -t <target>

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

对于 C 语言: c 、 m
对于 C++: C 、 M 、 c++ 、 cc 、 cpp 、 cxx 、 m 、 mm 、 mpp 、 CPP 、 ixx 、 cppm 、 ccm 、 cxxm 、 c++m

注意我们并未将任何头文件添加到源文件列表中。这可以通过 target_include_directories()  命令隐式地提供头文件所在目录路径来实现,或使用 target_sources() 命令的 FILE_SET 功能(CMake 3.23 新增)。这对可执行文件是个重要主题,但由于其复杂性且与目标正交相关,我们将在第 7 章  使用 CMake 编译 C++源文件 》中深入探讨。

定义库目标

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

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

关于名称、 全局排除以及源文件的规则与可执行目标完全一致。唯一区别在于 STATIC 、 SHARED 和 MODULE 关键字。如果您有库文件相关经验,就会知道这些关键字定义了 CMake 将生成的构件类型:静态链接库、共享(动态)库或模块。这同样是个非常广泛的主题,我们将在第 8 章  可执行文件与库的链接 》中深入探讨。

自定义目标

自定义目标与可执行文件或库略有不同。它们通过执行明确给定的命令行,扩展了 CMake 开箱即用的构建功能,例如可用于:

计算其他二进制文件的校验和。
运行代码清理器并收集结果。
将编译报告发送至指标流水线。

从这份清单可以想见,自定义目标仅在相当高级的项目中才有用,因此我们只介绍基础知识,以便继续讨论更重要的主题。

要定义自定义目标,请使用以下语法(为简洁起见已移除部分选项):

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

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

自定义目标也不会像可执行文件和库那样应用陈旧性检查(它们不会验证源文件是否比二进制文件更新),因为默认情况下它们不会被添加到依赖关系图中(所以 ALL 关键字的作用与 EXCLUDE_FROM_ALL 相反)。让我们来了解这个依赖关系图的具体含义。

依赖关系图

成熟的应用程序通常由多个组件构成,特别是内部库。从结构角度来看,项目划分非常有益。当相关功能被打包成单一逻辑实体时,它们就能与其他目标进行链接:无论是另一个库还是可执行文件。当多个目标使用同一个库时,这种方式尤为便利。请参阅图 5.1,其中展示了一个典型的依赖关系图:

本项目包含两个库、两个可执行文件和一个自定义目标。我们的应用场景是提供一个带美观图形界面的银行应用程序(GuiApp)和一个用于自动化脚本的命令行版本(TerminalApp)。这两个可执行文件都依赖相同的 Calculations 计算库,但只有其中一个需要 Drawing 绘图库。为确保应用二进制文件来自可信来源,我们还将计算校验和,并通过独立的安全渠道分发。CMake 在为此类解决方案编写列表文件时非常灵活:

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组中的两个库。
计算库绘图库构建完成后,再构建第 2 组—— 图形界面应用终端应用 
构建校验和目标;运行指定命令行生成校验和( 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 可视化软件进行渲染,输出图片或生成 PDF/SVG 格式文件,作为软件文档的一部分存储。人人都爱完善的文档,却少有人愿意编写——现在你无需亲自动手了!

默认情况下自定义目标不可见,我们需要创建特殊的配置文件 CMakeGraphVizOptions.cmake 来定制依赖图。使用 set(GRAPHVIZ_CUSTOM_TARGETS TRUE) 命令可在图中启用自定义目标:

set(GRAPHVIZ_CUSTOM_TARGETS TRUE)

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

如果时间紧迫,你甚至可以直接在浏览器中通过这个地址运行 Graphviz:Graphviz Online。

你只需将 test.dot 文件内容复制粘贴到左侧窗口,项目就会自动可视化( 图 5.2)。相当方便,不是吗?

使用此方法,我们可以快速查看所有明确定义的目标。

既然我们已经理解了目标的概念,知道了如何定义不同类型的目标(包括可执行文件、库和自定义目标),以及如何创建依赖关系图并打印它。现在让我们利用这些信息进行更深入的探讨,看看如何配置它们。

设置目标属性

目标具有类似于 C++对象字段的属性。其中一些属性可被修改,而另一些则是只读的。CMake 定义了大量”已知属性”(参见延伸阅读部分),这些属性根据目标类型(可执行文件、库或自定义)而有所不同。您也可以根据需要添加自定义属性。使用以下命令来操作目标的属性:

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

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

属性概念并非目标独有;CMake 还支持为其他作用域设置属性: GLOBAL 、 DIRECTORY 、 SOURCE 、 INSTALL 、 TEST 和 CACHE 。要操作各类属性,可使用通用的 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...])

此目标命令将填充 <source> 目标的 COMPILE_DEFINITIONS 属性。 编译定义本质上就是传递给编译器的 -Dname=definition 标志,用于配置 C++预处理器的定义(我们将在第 7 章使用 CMake 编译 C++源代码中详细讨论)。这里有趣的部分是第二个参数。我们需要指定三个值之一: INTERFACE 、 PUBLIC 或 PRIVATE ,以控制该属性应传递给哪些目标。请注意不要将这些与 C++的访问说明符混淆——这是完全独立的概念。

传播关键词的工作原理如下:

PRIVATE 设置源目标的属性。
INTERFACE 设置目标目标的属性。
PUBLIC 同时设置源目标和目标目标的属性。

当某个属性不需要过渡到任何目标目标时,将其设为 PRIVATE 。若需要此类过渡,则使用 PUBLIC 。如果源目标在其实现文件( .cpp )中未使用该属性,仅在头文件中使用,且这些属性会传递给消费者目标,则应使用关键字 INTERFACE 。

这是如何实现的?为了管理这些属性,CMake 提供了几个命令,比如前面提到的 target_compile_definitions() 。当你指定 PRIVATE 或 PUBLIC 关键字时,CMake 会将提供的值存储到目标的属性中,本例中即 COMPILE_DEFINITIONS 。此外,如果关键字是 INTERFACE 或 PUBLIC ,它会将值存储到带有 INTERFACE_ 前缀的属性中——即 INTERFACE_COMPILE_DEFINITIONS 。在配置阶段,CMake 会读取源目标的接口属性,并将其内容附加到目标目标上。这就是传播属性,CMake 称之为传递性使用要求(Transitive Usage Requirements)。

通过 set_target_properties() 命令管理的属性可在 cmake-properties(7) — CMake 4.1.0-rc1 Documentation 的目标属性(Properties on Targets) 部分找到(并非所有目标属性都具有传递性)。以下是最重要的几个:

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 展示了在生成阶段(配置阶段完成后)传播属性的变化情况:

传播关键词的工作原理如下:

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_MAX 和 NUMBER_MIN 则略有不同——传播的值不必匹配,但目标属性只会接收最高值或最低值。

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

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 会将此自定义属性传播至目标对象 ,并检查所有源目标的版本是否完全匹配(兼容性属性只需在一个目标上设置即可)。

既然我们已经了解了常规目标是什么,现在让我们来看看那些看起来像目标、闻起来像目标、有时行为也像目标,但实际上并非真正目标的其他事物。

认识伪目标

目标(target)这一概念非常实用,若能将其部分行为也借用于其他事物将大有裨益——这些事物并非代表构建系统的输出,而是输入项,如外部依赖项、别名等。这些就是伪目标(pseudo targets),或者说不会出现在最终生成的构建系统中的目标:

导入目标(Imported targets)
别名目标
接口库

让我们来看看。

导入的目标

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

你可以调整此类目标的属性: 编译定义  编译选项  包含目录等——它们甚至支持传递性使用要求。但应将其视为不可变对象,不要更改其源文件或依赖项。

IMPORTED 目标的定义范围可以是全局的,也可以仅限于定义它的目录(在子目录中可见,但在父目录中不可见)。

别名目标

别名目标正如其名——它们以不同名称创建对目标的另一个引用。您可以通过以下命令为可执行文件和库创建别名目标:

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

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

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

接口库

这是一个有趣的结构——一个不编译任何内容而是作为实用目标的库。它的整个概念都围绕传播属性(Transitive Usage Requirements)构建。

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

使用 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>
)

在前面的代码片段中,我们创建了一个包含三个头文件的 Eigen 接口库。接着,使用生成器表达式 (这些表达式以美元符号和尖括号表示, $<...> ,将在下一章详细说明),我们将其包含目录设置为:当目标被导出时为 ${CMAKE_CURRENT_SOURCE_DIR}/src ,安装时则为 include/Eigen (这也将在本章末尾进行解释)。

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

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_static 和 calc_shared 两个库。让我们探讨这两类库的实际区别,并理解为何可能需要同时创建两者。

伪目标是否耗尽了目标的概念?当然不是!那样就太简单了。我们仍需理解这些目标如何被用于生成构建系统。

构建目标

术语“target”的含义会因项目上下文及生成的构建系统而异。在生成构建系统的上下文中,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 目标之外,除非像我们在 BankApp 示例中那样使用 ALL 关键字显式添加它们。

另一个隐式定义的构建目标是 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 Protocol BufferProtobuf)的 .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 等。谷歌提供了一个编译器 protoc ,它能读取 .proto 文件并输出适用于所选语言的结构化与序列化源代码(这些代码后续仍需编译或解释)。精明的工程师不会将这些生成的源文件提交到代码库,而是会使用原始的 Protobuf 格式,并在构建链中添加生成源文件的步骤。

我们尚不清楚如何在目标主机上检测(以及检测位置)是否存在 Protobuf 编译器(将在第 9 章 CMake 中的依赖管理 》中学习)。因此,目前我们暂时假设系统已知晓编译器 protoc 命令的所在位置。我们已经准备好了一个 person.proto 文件,并且知道 Protobuf 编译器将输出 person.pb.h 和 person.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 文件进行更改时,所有内容都将自动编译并更新。

一个简化(且实用性较低)的示例是通过从其他位置复制来创建必要的头文件:

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 示例中校验和的生成过程:

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++源代码编译为可执行文件和库。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容