第二十二章:库

Posted by lili on

与编写普通应用程序相比,创建和维护库通常更加复杂,特别是共享库。所有关于代码正确性和可维护性的常规问题仍然适用,但特别是共享库还带来了与 API 一致性、在发布之间保持二进制兼容性、符号可见性等方面的额外考虑。此外,每个平台通常都有其独特的特性和要求,使得跨平台库开发成为一项具有挑战性的任务。

然而,大多数情况下,所有主要平台都支持一组核心功能,只是定义或使用这些功能的方式有所不同。CMake 提供了许多功能,可以将这些差异抽象出来,使开发人员可以专注于功能,将实现细节留给构建系统处理。

构建基础知识

定义库的基本命令在之前的章节中已经涵盖过,并具有以下形式:

add_library(targetName [STATIC | SHARED | MODULE | OBJECT]
			[EXCLUDE_FROM_ALL]
			source1 [source2 ...])

如果提供了 SHARED 或 MODULE 关键字,则会生成共享库。另外,如果没有给出 STATIC、SHARED、MODULE 或 OBJECT 关键字,则在调用 add_library() 时,如果 BUILD_SHARED_LIBS 变量的值为 true,将生成共享库。

SHARED 和 MODULE 之间的主要区别在于,SHARED 库用于其他目标链接,而 MODULE 库则不是。MODULE 库通常用于诸如插件或其他可选库之类的东西,这些库可以在运行时加载。这些库的加载通常依赖于应用程序配置设置或检测某些系统特性。其他可执行文件和库通常不会链接到 MODULE 库。

在大多数基于 Unix 的平台上,默认情况下,STATIC 或 SHARED 库的文件名将以 lib 开头,而 MODULE 可能没有。苹果平台还支持框架和可加载 bundle,允许在一个明确定义的目录结构中捆绑其他文件与库。这在第 24.3 节“框架”中有详细说明。

在 Windows 平台上,无论库的类型如何,库名称都不会以 lib 前缀开头。静态库目标产生一个 .lib 归档文件,而共享库目标产生两个单独的文件,一个用于运行时(.dll 或动态链接库),另一个用于在构建时链接(即 .lib 导入库)。开发人员有时会因为相同的文件后缀而混淆导入库和静态库,但 CMake 通常会在不需要任何特殊干预的情况下正确处理它们。

在 Windows 上使用 GNU 工具(例如 MinGW 或 MSYS 项目生成器)时,CMake 具有将 GNU 导入库(.dll.a)转换为 Visual Studio 生成的相同格式(.lib)的能力。如果使用 GNU 工具构建的共享库要链接到使用 Visual Studio 构建的二进制文件,这可能会很有用。请注意,必须安装 Visual Studio 才能进行此转换。通过将 GNUtoMS 目标属性设置为 true,可以启用此转换。此目标属性在调用 add_library() 时通过 CMAKE_GNUtoMS 变量的值进行初始化。

22.2. 链接静态库

CMake 处理了一些特定于链接静态库的特殊情况。如果一个库 A 被列为静态库目标 B 的私有依赖项,那么在链接方面,A 实际上将被视为公共依赖项(仅限于链接)。这是因为私有库 A 仍然需要添加到链接到 B 的任何内容的链接器命令行中,以便在链接时找到 A 的符号。如果 B 是一个共享库,那么它所依赖的私有库 A 不需要列在链接器命令行中。这一切都由 CMake 透明地处理,因此开发人员通常无需关注细节,只需要通过 target_link_libraries() 指定公共、私有和接口依赖关系。

在典型项目中,静态库通常不包含循环依赖关系,即两个或多个库相互依赖的情况。然而,一些情况会导致这种情况的出现,只要相关的链接关系已经被指定(即通过 target_link_libraries()),CMake 将识别和处理循环依赖关系。CMake 文档中稍微修改的示例突出了这种行为:

add_library(A STATIC a.cpp)
add_library(B STATIC b.cpp)
target_link_libraries(A PUBLIC B)
target_link_libraries(B PUBLIC A)
add_executable(Main main.cpp)
target_link_libraries(Main A)

在上述示例中,Main 的链接命令将包含 A B A B。这种重复是由 CMake 在不需要开发人员干预的情况下自动提供的,但在某些病态情况下,可能需要多次重复。虽然 CMake 为此目的提供了 LINK_INTERFACE_MULTIPLICITY 目标属性,但这种情况通常指向需要重组项目的需求。OBJECT 库也可能是解决这种深层次互相依赖的有用工具,因为它们实际上像是一组源文件,而不是真正的库。在链接器命令行中对象文件的顺序通常不重要,而库的顺序通常很重要。

22.3. 共享库版本管理

一个不希望其库被项目外部使用的 CMake 项目通常不需要为其创建的共享库提供版本信息。整个项目在部署时通常一起更新,因此在版本之间保持二进制兼容性等问题很少。但如果项目提供了库,其他软件可能会链接到它们,那么库的版本控制就变得非常重要。库版本详细信息增加了更大的鲁棒性,允许其他软件指定他们期望在链接时使用的接口,并在运行时可用。

大多数平台提供了指定共享库版本号的功能,但其实现方式差异很大。平台通常具有将版本详细信息编码到共享库二进制文件中的能力,有时这些信息用于确定一个二进制文件是否可以被链接到它的另一个可执行文件或共享库。一些平台还有关于在文件和符号链接中设置不同级别版本号的约定。例如,在 Linux 上,一个共享库的常见文件和符号链接集合可能如下所示:

libMyStuff.so.2.4.3
libMyStuff.so.2 --> libMyStuff.so.2.4.3
libMyStuff.so --> libMyStuff.so.2

CMake 处理了大部分关于共享库版本处理的平台差异。当将一个目标链接到共享库时,它会遵循平台的约定,决定链接哪些文件或符号链接名称。在构建共享库时,如果提供了版本详细信息,CMake 将自动创建完整的文件和符号链接集合。

共享库的版本详细信息由 VERSION 和 SOVERSION 目标属性定义。这些属性的解释在 CMake 支持的各个平台上略有不同,但通过遵循语义版本控制原则,这些差异可以相对轻松地处理。

语义版本控制假设版本号以 major.minor.patch 的形式指定,其中每个版本组件都是一个整数。VERSION 属性将设置为完整的 major.minor.patch,而 SOVERSION 将设置为只有主要部分。随着项目的发展和发布,语义版本控制暗示版本详细信息应该按照以下方式修改:

  • 当进行不兼容的 API 更改时,增加主要部分的版本并将次要和补丁部分重置为 0。这意味着每次 API 断裂时,SOVERSION 属性都会更改,只有在 API 断裂时才会更改。
  • 当以向后兼容的方式添加功能时,增加次要部分并将补丁重置为 0。主要部分保持不变。
  • 当进行向后兼容的错误修复时,增加补丁值并保持主要和次要部分不变。

如果根据这些原则修改共享库的版本详细信息,则在运行时最小化 API 不兼容性问题。考虑以下示例,该示例在 Linux 上生成了先前显示的符号链接集合:

add_library(MyStuff SHARED source1.cpp ...)
set_target_properties(MyStuff PROPERTIES
	VERSION
	 2.4.3
	SOVERSION 2
)

在 Apple 平台上,可以使用 otool -L 命令打印编码到生成的共享库中的版本详细信息。上述示例生成的共享库的输出将报告版本详细信息为兼容性版本 2.0.0 和当前版本 2.4.3。任何与 MyStuff 库链接的内容将包含 libMyStuff.2.dylib 作为在运行时查找库的名称的编码名称。Linux 平台在其共享库的符号链接中显示类似的结构,通常使用仅主要部分的库 soname。

CMake 3.17 添加了 MACHO_COMPATIBILITY_VERSION 和 MACHO_CURRENT_VERSION 目标属性,以支持 Apple 平台的高级用例(通常与匹配 libtool 约定相关)。这些额外的属性允许将文件和符号链接命名与嵌入在 Mach-O 二进制文件中的内部名称解耦。项目很少需要这种更复杂的功能,并建议避免使用这些属性,除非特定情况需要它们。

在 Windows 上,CMake 的行为是从 VERSION 属性中提取主要.次要版本,并将其编码到 DLL 中作为 DLL 图像版本。Windows 没有 soname 的概念,因此不使用 SOVERSION 属性。然而,遵循语义版本控制原则至少可以确保 DLL 版本可用于确定与其链接的二进制文件的库的兼容性。

值得注意的是,语义版本控制并不是任何平台严格要求的。相反,它提供了一个明确定义的规范,从而在共享库之间的依赖管理以及使用它们的内容之间提供了一定的确定性。它恰好密切反映了大多数基于 Unix 的平台上通常解释库版本的方式,而 CMake 则旨在利用 VERSION 和 SOVERSION 目标属性提供遵循本地平台约定的共享库。项目应该注意,如果只设置了 VERSION 和 SOVERSION 目标属性中的一个,那么在大多数平台上,缺失的属性将被视为与提供的属性具有相同的值。这样做不太可能产生良好的版本处理,除非版本号仅使用单个数字(即没有次要或补丁部分)。这种版本编号可能在某些情况下是合适的,但项目通常应该努力遵循上述原则,以获得更灵活、更强大的运行时行为。

22.4. 接口兼容性

VERSION 和 SOVERSION 目标属性允许以与平台无关的方式在二进制级别指定 API 版本。CMake 还提供了其他属性,可以用来定义 CMake 目标在彼此链接时的兼容性要求。这些属性可以描述和强制执行版本号本身无法捕获的细节。考虑一个现实的例子,其中一个网络库只在适当的 SSL 工具包可用时提供对 https:// 协议和其他类似安全功能的支持。程序的其他部分可能需要根据 SSL 是否受支持来调整自己的功能,而整个程序应该在 SSL 功能可用性方面保持一致。这可以通过接口兼容性属性来强制执行。

可以定义几种不同类型的接口兼容性属性,但最简单的是布尔属性。基本思想是库指定一个用于广告特定布尔状态的属性名称。然后,它们使用相关值定义该属性。当被链接在一起的多个库为接口兼容性定义相同的属性名称时,CMake 将检查它们是否指定了相同的值,并在它们不同时发出错误。一个基本的例子如下所示:

add_library(Networking net.cpp)
set_target_properties(Networking PROPERTIES
			COMPATIBLE_INTERFACE_BOOL SSL_SUPPORT
			INTERFACE_SSL_SUPPORT YES
)
add_library(Util util.cpp)
set_target_properties(Util PROPERTIES
		COMPATIBLE_INTERFACE_BOOL SSL_SUPPORT
		INTERFACE_SSL_SUPPORT YES
)
add_executable(MyApp myapp.cpp)
target_link_libraries(MyApp PRIVATE Networking Util)
target_compile_definitions(MyApp PRIVATE
	$<$<BOOL:$<TARGET_PROPERTY:SSL_SUPPORT>>:HAVE_SSL>
)

两个库目标都宣布它们为属性名称 SSL_SUPPORT 定义了一个接口兼容性。COMPATIBLE_INTERFACE_BOOL 属性应包含一个名称列表,每个名称都需要与相同名称的 INTERFACE_ 前缀的属性在该目标上定义。当这些库一起作为 MyApp 的链接依赖项使用时,CMake 将检查两个库是否定义了 INTERFACE_SSL_SUPPORT,并且值相同。此外,CMake 还将自动将 MyApp 目标的 SSL_SUPPORT 属性填充为相同的值,然后可以作为生成器表达式的一部分,并作为 MyApp 的源代码中的编译定义使用。这使得 MyApp 代码可以根据 SSL 支持是否编译到它使用的库中来自定义。继续上面的示例,与其仅检测 SSL 支持是否可用,MyApp 可以通过明确定义其 SSL_SUPPORT 属性来指定要求,以便与库必须兼容。在这种情况下,而不是自动填充 MyApp 的 SSL_SUPPORT 属性,CMake 将比较值,并确保库与指定的要求一致。

# Require libraries to have SSL support
set_target_properties(MyApp PROPERTIES SSL_SUPPORT YES)

上述示例有些刻意,相同的约束可以以其他方式强制执行。接口兼容性规范的真正优势开始在项目变得更加复杂并且其目标分布在许多目录中或来自外部构建的项目时显现出来。接口兼容性被分配为目标的属性,因此它们只需要在一个地方定义,然后就可以在可以使用目标的任何地方使用而无需进一步努力。消费目标不需要知道接口兼容性是如何确定的细节,只需知道存储在目标的 INTERFACE_… 属性中的最终决定。

CMake 还支持将接口兼容性表示为字符串。这些工作方式与布尔情况基本相同,只是要求命名属性必须具有完全相同的值,并且可以包含任意内容。之前的示例可以修改为要求库使用相同的 SSL 实现,而不仅仅是同意它们是否支持 SSL:

add_library(Networking net.cpp)
set_target_properties(Networking PROPERTIES
	COMPATIBLE_INTERFACE_STRING SSL_IMPL
	INTERFACE_SSL_IMPL OpenSSL
)
add_library(Util util.cpp)
set_target_properties(Util PROPERTIES
	COMPATIBLE_INTERFACE_STRING SSL_IMPL
	INTERFACE_SSL_IMPL OpenSSL
)
add_executable(MyApp myapp.cpp)
target_link_libraries(MyApp PRIVATE Networking Util)
target_compile_definitions(MyApp PRIVATE
	SSL_IMPL=$<TARGET_PROPERTY:SSL_IMPL>
)

在上面的示例中,SSL_IMPL 属性用作字符串接口兼容性,库指定它们使用 OpenSSL 作为 SSL 实现。与布尔情况一样,MyApp 目标可以定义其 SSL_IMPL 属性以指定要求,而不是让 CMake 用库的值来填充它。

CMake 支持的另一种接口兼容性是数值。数值接口兼容性用于确定一组库中属性的最小或最大值,而不是要求属性具有相同的值。这可以用于允许目标检测其可能支持的最小协议版本或计算其链接到的库中所需的最大临时缓冲区大小。

add_library(BigFast strategy1.cpp)
set_target_properties(BigFast PROPERTIES
	COMPATIBLE_INTERFACE_NUMBER_MIN PROTOCOL_VER
	COMPATIBLE_INTERFACE_NUMBER_MAX TMP_BUFFERS
	INTERFACE_PROTOCOL_VER 3
	INTERFACE_TMP_BUFFERS 200
)
add_library(SmallSlow strategy2.cpp)
set_target_properties(SmallSlow PROPERTIES
	COMPATIBLE_INTERFACE_NUMBER_MIN PROTOCOL_VER
	COMPATIBLE_INTERFACE_NUMBER_MAX TMP_BUFFERS
	INTERFACE_PROTOCOL_VER 2
	INTERFACE_TMP_BUFFERS 15
)
add_executable(MyApp myapp.cpp)
	target_link_libraries(MyApp PRIVATE BigFast SmallSlow)
	target_compile_definitions(MyApp PRIVATE
	MIN_API=$<TARGET_PROPERTY:PROTOCOL_VER>
	TMP_BUFFERS=$<TARGET_PROPERTY:TMP_BUFFERS>
)

在上面的示例中,PROTOCOL_VER 被定义为最小数值接口兼容性,因此 MyApp 的 PROTOCOL_VER 属性将设置为其链接到的库的 INTERFACE_PROTOCOL_VER 属性中指定的最小值,本例中为 2。类似地,TMP_BUFFERS 被定义为最大数值接口兼容性,MyApp 的 TMP_BUFFERS 属性将接收其链接到的库的 INTERFACE_TMP_BUFFERS 属性中的最大值,本例中为 200。

此时,自然会考虑使用相同的属性既作为最小值又作为最大值的数值接口兼容性,以允许在父级中检测最小值和最大值。这是不可能的,因为 CMake 不允许(也无法)将同一属性用于多种类型的接口兼容性。如果一个属性用于多种类型的接口兼容性,那么 CMake 将无法确定要存储在父级结果属性中的值的类型。例如,在上面的示例中,如果 PROTOCOL_VER 同时是最小值和最大值的接口兼容性,CMake 将无法确定要存储在 MyApp 的 PROTOCOL_VER 属性中的值 - 应该存储最小值还是最大值?因此,必须使用单独的属性来实现这一点:

add_library(BigFast strategy1.cpp)
set_target_properties(BigFast PROPERTIES
	COMPATIBLE_INTERFACE_NUMBER_MIN PROTOCOL_VER_MIN
	COMPATIBLE_INTERFACE_NUMBER_MAX PROTOCOL_VER_MAX
	INTERFACE_PROTOCOL_VER_MIN 3
	INTERFACE_PROTOCOL_VER_MAX 3
)
add_library(SmallSlow strategy2.cpp)
set_target_properties(SmallSlow PROPERTIES
	COMPATIBLE_INTERFACE_NUMBER_MIN PROTOCOL_VER_MIN
	COMPATIBLE_INTERFACE_NUMBER_MAX PROTOCOL_VER_MAX
	INTERFACE_PROTOCOL_VER_MIN 2
	INTERFACE_PROTOCOL_VER_MAX 2
)
add_executable(MyApp myapp.cpp)
target_link_libraries(MyApp PRIVATE BigFast SmallSlow)
	target_compile_definitions(MyApp PRIVATE
	PROTOCOL_VER_MIN=$<TARGET_PROPERTY:PROTOCOL_VER_MIN>
	PROTOCOL_VER_MAX=$<TARGET_PROPERTY:PROTOCOL_VER_MAX>
)

上述示例的结果是,MyApp 根据其链接到的库使用的协议来确定它需要支持的协议版本范围。

如果一个目标定义了任何特定类型的接口兼容性,其他目标不需要也定义它。任何不定义匹配接口兼容性的目标对于该特定属性来说都将被忽略。这确保库只需要定义对它们相关的接口兼容性。当存在多个级别的库链接依赖关系时,接口兼容性的处理存在一些微妙的复杂性。考虑下面显示的结构,其中包含多个库和可执行目标以及它们的直接链接依赖关系。

如果所有链接依赖项都被视为私有(PRIVATE),那么只有 libNet 和 libUtil 是 MyApp 的直接链接依赖项,因此只有这两个库需要保持其 INTERFACE_FOO 属性的值一致。libCalc 库中的该属性值不被考虑,因为它不是 MyApp 的直接依赖项。此外,libUtil 的唯一直接链接依赖项是 libCalc,因此 libCalc 的 INTERFACE_FOO 属性与其他库无需保持一致。

尽管 libUtil 和 libCalc 都为相同的属性名称定义了接口兼容性,但由于它们不都是共同目标的直接链接依赖项,因此它们无需具有兼容的值。

现在考虑 libCalc 是 libUtil 的公共链接依赖项的情况。在这种情况下,最终的链接关系实际上如下所示:

当 libCalc 是 libUtil 的公共链接依赖项时,任何链接到 libUtil 的内容也会链接到 libCalc。因此,libCalc 成为 MyApp 的直接链接依赖项,因此它将参与与 libNet 和 libUtil 的接口兼容性检查。这意味着在定义接口兼容性时必须非常小心,以确保它们准确地表达正确的内容,因为它们的影响范围可以延伸到超出最初显而易见的目标,尤其是涉及公共链接关系时。

22.5. 符号可见性

简单来说,一个库可以被视为编译源代码的容器,提供各种函数和全局数据,其他代码可以调用或使用这些函数和数据。对于静态库来说,该容器实际上只是一组目标文件,将其组合在一起的工具有时被称为归档器或库管理器。另一方面,共享库是由链接器生成的,链接器会处理对象代码、归档文件等,并决定包含在最终共享库二进制文件中的内容。一些函数和全局数据可能被隐藏,意味着它们被标记为允许链接器用于解析内部代码依赖关系,但是共享库外部的代码不能调用或使用它们。其他符号被导出,因此共享库内部和外部的代码都可以访问它们。这被称为符号的可见性。

编译器有不同的方式来指定符号的可见性,并且它们还有不同的默认行为。有些编译器默认使所有符号可见,而其他编译器默认隐藏符号。编译器在标记单个函数、类和数据的可见性方面的语法也有所不同,这增加了编写可移植共享库的复杂性。为了避免一些复杂性,一些开发者选择简单地使所有符号可见,并避免显式标记任何符号导出。虽然这起初可能看起来是一个胜利,但它带来了一系列的缺点:

  • 这等同于说每个函数、类、类型、全局变量等都可以自由地供任何代码使用。这很少是可取的,但如果项目愿意依赖其文档来定义应视为公共的符号,那么这可能是可以接受的。
  • 通过使所有符号可见,无法阻止使用不应该使用的内容的消费代码。链接到库的其他代码可能会依赖于某些内部符号,这样做会使共享库在不破坏使用项目的情况下更改其实现或内部结构变得更加困难。
  • 当所有符号被视为可见时,链接器无法知道每个符号是否会被任何代码使用,因此它必须将它们全部包含在最终的共享库中。当只导出符号的子集时,链接器有机会识别永远不会被可见符号使用的代码,并因此将其丢弃,通常会导致更小的二进制文件,这有可能在运行时加载速度更快。
  • C++等支持模板的语言可能会定义大量的符号。如果默认情况下所有符号都可见,这可能会导致共享库的符号表变得非常庞大。在极端情况下,这可能会对运行时启动性能产生可测量的影响。
  • 在库的内部实现中使用的函数可能使用公开有关库功能或工作方式的细节的名称。在某些情况下,这可能是一个安全问题,或者可能会透露不应该对接收库的人可见的商业知识产权。

以上几点突显了符号可见性不仅是关于强制库 API 的公共-私有性质,也关乎共享库性能和包大小的低级机制。显然,只导出应被视为公共的那些符号有其优势,但是如何实现这一点的编译器和平台特定性质通常会给多平台项目带来巨大障碍。CMake 通过在几个属性、变量和辅助模块后面隐藏这些差异,大大简化了这个过程。

22.5.1. 指定默认可见性

默认情况下,Visual Studio 编译器假定所有符号都是隐藏的,除非明确导出。其他编译器,如 GCC 和 Clang,相反,将所有符号默认设置为可见,只有在明确要求时才会隐藏符号。如果项目希望在所有编译器和平台上具有相同的默认符号可见性,必须选择这两种方法之一,但是希望前面部分强调的缺点可以提供选择默认隐藏符号的强有力理由。

实施默认隐藏可见性的第一步是在共享库目标上定义<LANG>_VISIBILITY_PRESET一组属性。对于使用此功能的两种最常见的语言,属性名称分别为 C_VISIBILITY_PRESET 和 CXX_VISIBILITY_PRESET。应该将此属性的值设置为 hidden,以隐藏所有符号的默认可见性。其他支持的值包括 default、protected 和 internal,但这些值对于跨平台项目来说可能不太有用。它们要么指定了默认行为,要么是具有更专业意义的 hidden 变体。

第二步是指定内联函数默认也应该被隐藏。对于大量使用模板的 C++ 代码,这可以大幅减少最终共享库二进制文件的大小。此行为由 VISIBILITY_INLINES_HIDDEN 目标属性控制,适用于所有语言。它应该将布尔值设置为 TRUE,以便默认隐藏内联符号。

<LANG>_VISIBILITY_PRESET 和 VISIBILITY_INLINES_HIDDEN 可以在每个共享库目标上指定,或者可以通过相应的 CMake 变量设置默认值。当创建目标时,其<LANG>VISIBILITY_PRESET 属性由 CMake 变量 CMAKE_VISIBILITY_PRESET 的值初始化,并且其 VISIBILITY_INLINES_HIDDEN 属性由 CMAKE_VISIBILITY_INLINES_HIDDEN 变量初始化。通常,这比为每个目标单独设置属性更方便。

对于那些希望在所有平台上默认使所有符号可见的项目,这仅需要更改 Visual Studio 编译器的默认行为。从版本 3.4 开始,CMake 提供了 WINDOWS_EXPORT_ALL_SYMBOLS 目标属性,它提供了此行为,但需要注意的是。将此属性定义为 true 值将导致 CMake 编写一个包含用于创建共享库的所有对象文件的所有符号的 .def 文件,并将该 .def 文件传递给链接器。这种相对粗暴的方法阻止源代码选择性地隐藏任何符号,因此只有当所有符号都应该可见时才应使用。此目标属性在创建共享库目标时由 CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS CMake 变量初始化。

22.5.2. 指定单个符号可见性

大多数常见的编译器支持指定单个符号的可见性,但它们所采用的方法各不相同。一般来说,Visual Studio 使用一种方法,而大多数其他编译器则遵循 GCC 使用的方法。这两种方法有着相似的结构,但它们使用不同的关键字。这意味着像 C、C++ 和它们的衍生语言这样的源代码可以使用一个公共的预处理器定义来控制可见性,并且项目可以指示 CMake 提供适当的定义。

有三种主要情况可以指定符号的可见性:类、函数和变量。在下面的示例中包含了这三种情况的声明,请注意 MYTOOLS_EXPORT 的位置:

class MYTOOLS_EXPORT SomeClass {...};
 // Export non-private members of a class
MYTOOLS_EXPORT void someFunction();
 // Make a free function visible
MYTOOLS_EXPORT extern int myGlobalVar;
 // Make a global variable visible

当构建包含上述实现的共享库时,MYTOOLS_EXPORT 需要评估为关键字,以便其他库和可执行文件可以使用该符号。另一方面,如果相同的声明被共享库外部的目标的代码读取,MYTOOLS_EXPORT 必须评估为关键字,以便导入该符号。对于 Visual Studio 编译器,这些关键字采用 __declspec(…) 的形式,而 GCC 和兼容的编译器使用 attribute(…)。

为所有编译器以及导出和导入情况找到 MYTOOLS_EXPORT 的正确内容可能会有些混乱。加入开发者可能选择将库构建为共享库或静态库的因素后,复杂性会增加。幸运的是,CMake 提供了 GenerateExportHeader 模块,以非常方便的方式处理所有这些细节。该模块提供了以下函数:

generate_export_header(target
	[BASE_NAME baseName]
	[EXPORT_FILE_NAME exportFileName]
	[EXPORT_MACRO_NAME exportMacroName]
	[DEPRECATED_MACRO_NAME deprecatedMacroName]
	[NO_EXPORT_MACRO_NAME noExportMacroName]
	[STATIC_DEFINE staticDefine]
	[NO_DEPRECATED_MACRO_NAME noDeprecatedMacroName]
	[DEFINE_NO_DEPRECATED]
	[PREFIX_NAME prefix]
	[CUSTOM_CONTENT_FROM_VARIABLE var]
)

通常情况下,不需要任何可选参数,只需提供共享库目标名称。CMake 在当前二进制目录中写出一个头文件,使用目标名称的小写形式,并在其后附加 _export.h 作为头文件名。该头文件提供了一个符号导出的定义,具有类似结构的名称,这次使用目标名称的大写形式并附加 _EXPORT。以下演示了这种典型用法:

CMakeLists.txt

# Hide things by default
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN YES)
# NOTE: myTools.cpp must #include myTools.h
add_library(MyTools myTools.cpp)
target_include_directories(MyTools PUBLIC "${CMAKE_CURRENT_BINARY_DIR}"
)
# Write mytools_export.h to the current binary directory
include(GenerateExportHeader)
generate_export_header(MyTools)

myTools.h

#include"mytools_export.h"
class MYTOOLS_EXPORT SomeClass
{
// ...
};
MYTOOLS_EXPORT void someFunction();
MYTOOLS_EXPORT extern int myGlobalVar;

当前二进制目录不是默认的头文件搜索路径的一部分,因此需要将其添加为库的 PUBLIC 搜索路径,以确保 mytools_export.h 头文件可以被共享库的源代码和任何链接到共享库的目标的其他代码找到。有关处理这方面的可能更干净的方法,请参阅第 27.5.1 节“文件集”。

如果不希望使用目标名称作为头文件名或预处理器定义名称的一部分,可以使用 BASE_NAME 选项提供替代名称。它以相同方式转换,将其转换为小写形式,并在文件名后附加 _export.h,而在预处理器定义中附加 _EXPORT。

CMakeLists.txt

include(GenerateExportHeader)
generate_export_header(MyTools BASE_NAME fooBar)

myTools.h

#include "foobar_export.h"
class FOOBAR_EXPORT SomeClass
{
// ...
};
FOOBAR_EXPORT void someFunction();
FOOBAR_EXPORT extern int myGlobalVar;

如果希望为文件和预处理器定义使用不同的名称,而不是使用 BASE_NAME,则可以使用 EXPORT_FILE_NAME 和 EXPORT_MACRO_NAME 选项。与 BASE_NAME 不同,这两个选项提供的名称都不经过任何修改。

CMakeLists.txt

include(GenerateExportHeader)
generate_export_header(MyTools
EXPORT_FILE_NAME export_myTools.h
EXPORT_MACRO_NAME API_MYTOOLS
)

myTools.h

#include"export_myTools.h"
class API_MYTOOLS SomeClass
{
// ...
};
API_MYTOOLS void someFunction();
API_MYTOOLS extern int myGlobalVar;

generate_export_header() 函数提供的不仅仅是这个预处理器定义,它还提供其他可以用来标记符号为废弃或显式指定一个符号不应被导出的预处理器定义。后者可用于防止导出类的部分内容,否则将导出的类,例如用于共享库内部使用但不被外部代码使用的公共成员函数。默认情况下,这个预处理器定义的名称由目标名称(或如果指定了则是 BASE_NAME)加上 _NO_EXPORT 组成,但如果需要,可以使用 NO_EXPORT_MACRO_NAME 选项提供替代名称。

CMakeLists.txt

include(GenerateExportHeader)
generate_export_header(MyTools
NO_EXPORT_MACRO_NAME REALLY_PRIVATE
)

myTools.h

#include "mytools_export.h"
class MYTOOLS_EXPORT SomeClass
{
public:
REALLY_PRIVATE void doInternalThings();
// ...
};

该函数的废弃支持工作方式类似,提供一个以目标(或 BASE_NAME)名称大写后跟 _DEPRECATED 的预处理器定义,或允许通过 DEPRECATED_MACRO_NAME 选项指定自定义名称。也可以使用 DEFINE_NO_DEPRECATED 选项,它会提供一个额外的预处理器定义,其名称由通常的目标或 BASE_NAME 大写后跟 _NO_DEPRECATED 组成。与其他预处理器定义一样,这个名称也可以通过 NO_DEPRECATED_MACRO_NAME 选项进行覆盖。对于一些编译器,标记为废弃的符号可能会导致编译时警告,从而引起对其使用的注意力。这可以是一个有用的机制,鼓励开发者更新他们的代码,以不再使用废弃的符号。以下展示了如何使用废弃机制。

CMakeLists.txt

option(OMIT_DEPRECATED "Omit deprecated parts of MyTools")
if(OMIT_DEPRECATED)
	set(deprecatedOption "DEFINE_NO_DEPRECATED")
else()
	unset(deprecatedOption)
endif()
include(GenerateExportHeader)
generate_export_header(MyTools
	NO_DEPRECATED_MACRO_NAME OMIT_DEPRECATED
	${deprecatedOption}
)

myTools.h

#include"mytools_export.h"
class MYTOOLS_EXPORT SomeClass
{
	public:
	#ifndef OMIT_DEPRECATED
	MYTOOLS_DEPRECATED void oldImpl();
	#endif
	// ...
};

myTools.cpp

#include "myTools.h"
#ifndef OMIT_DEPRECATED
void SomeClass::oldImpl() { ... }
#endif

以上示例提供了一个 CMake 缓存变量来确定是否编译废弃的项目。开发者有能力在不编辑任何文件的情况下做出这个选择,因此验证是否使用了废弃的 API 部分的行为变得非常容易。如果已经设置了连续集成构建来测试库的使用和不使用废弃部分,这可能特别有用。它还可以在项目作为另一个项目的依赖项时发挥作用,让该项目的开发者通过更改 CMake 缓存变量来测试其代码是否使用了废弃的符号。

还有一个不太常见但仍然重要的情况也值得特别提及。有些项目可能希望建立同一库的共享和静态版本。在这种情况下,相同的源代码集需要允许在共享库构建时启用符号导出,但在静态库构建时禁用(也可以查看下一节,了解为什么这不总是这样)。当一个构建中需要同时存在这两种形式的库时,它们需要成为不同的构建目标,但 generate_export_header() 函数编写的头文件与单个目标紧密相关。为了支持这种情况,生成的头文件包含逻辑,检查另一个预处理器定义是否存在,然后才填充导出定义。这个特殊定义的名称再次遵循通常的模式,这次是大写目标或 BASE_NAME 后跟 _STATIC_DEFINE,或者通过 STATIC_DEFINE 选项提供自定义名称。当定义了这个特殊的预处理器定义时,导出定义会强制扩展为 nothing,这通常在将目标构建为静态库时所需。如果没有特殊的预处理器定义,则导出定义具有通常的内容,并在构建共享库目标时按预期工作。

当为相同的源文件集构建共享库和静态库时,generate_export_header() 函数应该给出与共享库对应的目标。然后将特殊的预处理器定义仅设置在静态库的目标上。BASE_NAME 选项通常也会用于使各种符号对于库的任何形式都是直观的,而不仅仅是针对共享库。以下演示了实现所需结果所需的结构:

# Same source list, different library types
add_library(MyShared SHARED ${mySources})
add_library(MyStatic STATIC ${mySources})

# Shared target used for generating export header
# with the name mytools_export.h, which will be suitable
# for both the shared and static targets
include(GenerateExportHeader)
generate_export_header(MyShared BASE_NAME MyTools)

# Static target needs special preprocessor define
# to prevent symbol import/export keywords being added
target_compile_definitions(MyStatic PRIVATE
	MYTOOLS_STATIC_DEFINE
)

正如前面讨论的那样,generate_export_header() 函数定义了许多不同的预处理器定义,并且不同的目标可能会意外地尝试使用其中至少一些相同的名称。为了帮助减少名称冲突,PREFIX_NAME 选项允许指定一个附加的字符串,该字符串将被添加到每个预处理器定义的名称之前。当使用时,该选项通常是与整个项目相关的内容,有效地将项目所有生成的预处理器名称放入类似项目特定命名空间的东西中。

还没有讨论的最后一个选项是 CUSTOM_CONTENT_FROM_VARIABLE,它只在 CMake 3.7 中添加。该选项允许在生成的头文件中的最后部分注入任意内容,在添加了所有各种预处理器逻辑之后。当使用时,该选项必须给出一个变量的名称,其内容应该被注入,而不是直接提供内容。

string(TIMESTAMP now)
set(customContents "/* Generated: ${now} */")
generate_export_header(MyTools
	CUSTOM_CONTENT_FROM_VARIABLE customContents
)

22.6. 混合静态库和共享库

当一个项目将所有的库都构建为静态库时,构建过程可能会对库链接依赖关系更加宽容。项目可能会忽略指定一个目标需要另一个目标,但当各种静态库被链接到最终可执行文件中时,缺失的库依赖关系会得到满足,因为它们在可执行文件中以所需的顺序被显式列出。构建然后成功进行,但可能需要经过一段时间的尝试和错误构建,让链接器抱怨缺失的符号,添加更多缺失的库或重新排序现有的库等等。

这种情况更多地是由于运气而不是良好设计而导致成功,但令人惊讶的是,这种情况非常普遍,特别是对于定义了许多小型库的项目来说。如果至少为一些静态库指定了链接依赖关系,CMake 就会自动处理这些依赖关系的传递链接。因此,即使依赖关系的 PRIVATE/PUBLIC 性质被错误地指定,对于静态库来说,它总是被视为 PUBLIC。这有时会使得构建工作正常,即使链接依赖关系没有被准确描述。

当库目标被定义为共享库和静态库的混合时,链接依赖关系的正确性变得更加重要。考虑以下一组目标:

如果 libUtil 和 libCalc 是静态库,上述的链接依赖关系是安全的。如果 libUtil 是一个共享库,那么上述的链接依赖安排就可能导致在整个应用程序中重复预期只有一个实例的数据。如果 libCalc 定义了全局数据,比如对于单例或类的静态数据可能是常见的,那么 MyApp 和 libUtil 可能会有自己独立的数据实例。这变得可能是因为 MyApp 和 libUtil 都需要链接器来解析符号,因此两个调用可能会决定需要全局数据,并在可执行文件或共享库内部设置一个内部实例。如果全局数据不是一个导出符号,那么链接器在链接 MyApp 时就不会看到已经在 libUtil 中创建的实例。最终的结果是在 MyApp 中创建了第二个实例,这几乎肯定会导致难以追踪的运行时问题。这种情况的典型表现是一个变量在从一个可执行文件或共享库调用的函数中神奇地改变值,然后再传递到另一个共享库中。

类似于上述情况的情况可能以许多不同的形式出现,但是在每种情况下都适用同样的基本原则。如果将静态库链接到共享库中,则不应将该共享库与也链接到相同静态库的任何其他库或可执行文件结合使用。理想情况下,如果共享和静态库被混合使用,那么静态库应该只被链接到一个共享库中,并且任何需要来自其中一个静态库的内容的内容都应该链接到共享库。共享库本质上有自己的 API,而静态库可以为其做出贡献。

使用静态库来构建共享库内容的这种方式在符号可见性方面存在一些问题。通常,静态库的代码不会被导出,因此它不会出现在共享库导出的符号中。解决这个问题的一种方法是在共享库上正常使用 generate_export_header() 函数,然后让静态库重新使用相同的导出定义。使其正常工作的关键是确保静态库具有共享库目标名称加上 _EXPORTS 的编译定义,这是生成的头文件用于检测代码是否作为共享库的一部分构建的方式。

CMakeLists.txt

add_library(MyShared SHARED shared.cpp)
add_library(MyStatic STATIC static.cpp)

include(GenerateExportHeader)
generate_export_header(MyShared BASE_NAME mine)

target_link_libraries(MyShared PRIVATE MyStatic)
target_include_directories(MyShared PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
target_include_directories(MyStatic PUBLIC ${CMAKE_CURRENT_BINARY_DIR})

# This makes the static library code appear to be part of the shared
# library as far as the generated export header is concerned
target_compile_definitions(MyStatic PRIVATE MyShared_EXPORTS)

shared.h

#include "mine_export.h"
MINE_EXPORT void sharedFunc();

static.h

#include "mine_export.h"
MINE_EXPORT void staticFunc();

另一个需要考虑的因素是链接器在链接共享库时是否会丢弃静态库中定义的代码或数据。如果它确定没有任何东西在使用特定符号,那么链接器可能会将其丢弃作为优化。可能需要采取特殊措施来阻止它这样做。

其中一种选择是让共享库显式使用要保留的所有符号。这样做的好处是对所有编译器和链接器都有效,但对于非平凡项目可能不可行。另一种选择实际上需要向链接器添加特定于链接器的标志,例如在 Unix 系统上使用 ld 链接器时的 --whole-archive,或者在 Visual Studio 中使用 /WHOLEARCHIVE。从 CMake 3.24 开始,可以使用 $<LINK_LIBRARY:WHOLE_ARCHIVE,…> 生成器表达式来添加必要的标志(详见 16.2 节,“自定义库的链接方式”)。但要注意,这种功能可能不适用于所有链接器。如果上述策略不合适,则可能值得考虑将这些静态库转换为共享库。

target_link_libraries(MyShared
	PRIVATE $<LINK_LIBRARY:WHOLE_ARCHIVE,MyStatic>
)

如果共享库仅以私有方式链接到静态库(意味着静态库的符号不需要被导出),那么情况就要容易得多。在某些平台上,只需要将共享库链接到静态库,无需进一步操作。在其他平台上,可能会出现一两个小问题需要解决。例如,在许多 64 位 Unix 系统上,如果要将代码放入共享库中,则必须将其编译为位置无关代码,而对于静态库则没有这个要求。但是,如果共享库链接到静态库,则必须将静态库构建为位置无关代码。CMake 提供了 POSITION_INDEPENDENT_CODE 目标属性作为一种在需要时透明处理位置无关行为的方法。设置为 true 时,这会使得该目标的代码构建为位置无关。对于 SHARED 和 MODULE 类型的目标,默认情况下该属性为 ON,对于所有其他类型的目标则为 OFF。可以通过设置 CMAKE_POSITION_INDEPENDENT_CODE 变量来覆盖默认设置,这样在创建目标时会使用它来初始化 POSITION_INDEPENDENT_CODE 目标属性。

add_library(MyShared SHARED shared.cpp)
add_library(MyStatic STATIC static.cpp)
target_link_libraries(MyShared PRIVATE MyStatic)
set_target_properties(MyStatic PROPERTIES
	POSITION_INDEPENDENT_CODE ON
)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
add_library(MyOtherStatic STATIC other.cpp)
target_link_libraries(MyShared PRIVATE MyOtherStatic)

22.7. 推荐做法

使用 MODULE 库来加载可选插件,并在需要时动态加载,使用 SHARED 库来进行链接。对于需要向库的使用者公开的符号必须严格控制的情况,无论是出于 API 目的还是为了隐藏敏感实现细节,应该使用共享库。在发布包中,通常情况下,共享库比静态库更受青睐。

如果一个目标使用库中的内容,它应该直接链接到该库。即使该库已经是其他目标链接的一个链接依赖,也不应该依赖于目标直接使用的间接链接依赖。如果那个其他目标改变了其实现,不再链接该库,主要目标将无法构建。此外,要表达正确类型的链接依赖关系;PRIVATE、PUBLIC 或 INTERFACE。这样可以确保 CMake 正确处理共享库和静态库的传递链接依赖。

对于确保 CMake 构建可靠的链接器命令行以及正确的库排序,指定所有直接依赖关系以及正确级别的可见性是至关重要的。

使用正确的链接可见性还有一个好处,即使用者目标无需知道内部使用的所有不同库依赖关系,它们只需要链接到一个库,让该库定义其自己的依赖关系。然后 CMake 负责确保所有所需的库在最终链接器命令行上以正确顺序指定。抵制简单地将所有链接依赖关系设置为 PUBLIC 的诱惑,因为这会将原本私有的库的可见性扩展到可能不希望出现的地方。这在打包项目以进行发布或分发时变得尤为重要。

考虑尽早采用库版本策略。一旦库被发布到外部,版本号对于二进制兼容性具有非常具体的含义。使用 VERSION 和 SOVERSION 目标属性来指定库版本,即使最初这些属性被设置为项目早期的一些基本占位符。如果没有其他策略,一个合理的选择是从 0.1.0 开始进行版本编号,因为人们倾向于将 0.0.0 解释为默认值或版本错误设置,而 1.0.0 有时被认为是第一个公共发布版本。强烈考虑采用语义化版本控制来处理之后的版本更改。还要记住,库版本的更改可能对发布流程、打包等产生意外的影响,开发人员需要提前学习库版本号对于共享库的意义。还要考虑项目版本和库版本是否应该有任何关联。一旦进行了第一次发布,更改这种关系可能会非常困难,因此在将它们关联起来之前要慎重考虑(将项目交付为一套完整的库作为 SDK 的项目是一种明显的强关联示例)。

一些项目可以在特定支持工具包、库等可用时可选择提供某些功能。为了使构建其他部分或者确保与可选功能或特性的一致性,接口兼容性细节可以提供。考虑到所讨论的功能是否需要超出库的可见性,例如允许使用者目标检测功能是否受支持,或者确认所选的实现是否提供了所需的所有功能。还要考虑指定和使用接口兼容性的额外复杂性是否带来足够的益处,因为库依赖层次结构变得更深时,使用接口兼容性可能变得更加困难。

尽早考虑符号可见性在项目生命周期的早期阶段,因为在后期回顾项目并添加符号可见性细节可能会非常困难。在创建库时,始终考虑特定类、函数或变量是否应该对库外部可访问。将具有外部可见性的任何内容视为难以更改,而内部内容可以根据需要在发布之间更加自由地修改。使用隐藏的可见性作为默认,并明确标记要导出的每个单独实体,最好使用 generate_export_header() 函数提供的宏,这样 CMake 就可以代表项目处理各种平台差异。还考虑使用该函数提供的废弃宏清楚地标识那些在未来版本可能会被移除的库 API 的部分。

在混合使用共享库和静态库时要特别小心。在可能的情况下,最好只使用其中一种而不是两者。这样可以避免与符号可见性控制和确保构建设置的一致性相关的一些困难。在混合使用两种库类型时,尽量确保静态库仅链接到一个共享库中,并且没有其他目标链接到那些静态库。将静态库视为共享库中的子组,外部目标只链接到共享库。不过更好的做法是考虑将代码从静态库直接提升到共享库中,从而完全摆脱静态库。在 Section 34.5.1 中介绍的技术演示了如何逐步向现有目标添加源文件,允许目标源文件方便地在子目录中积累。