第20章:库

与编写普通应用程序相比,创建和维护库通常更加复杂,尤其是动态库。所有关于代码正确性和可维护性的常见问题仍然存在,但动态库会带来与API一致性、版本之间的二进制兼容性、符号可见性等相关的额外考虑。此外,每个平台通常都有自己的独特的特性和需求,这使得跨平台库的开发成为一项具有挑战的任务。

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

20.1. 构建的基础知识

定义库的基本命令已经在前面的章节中介绍过了,它的形式如下:

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

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

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

大多数基于Unix的平台上,STATIC或SHARED库的文件名在默认情况下使用lib前缀,而模块可能不使用。Apple平台还支持框架和可加载包,允许附加文件以良好的目录结构与库绑定。

Windows平台上,不管库的类型是什么,库名称都没有前缀。STATIC库目标产生单个的.lib,而SHARED库目标会产生两个独立的文件,一个用于运行时(.dll或动态链接库),另一个用于构建时链接(即.lib导入库)。开发人员有时会混淆导入库和静态库,因为它们使用了相同的文件后缀,但CMake通常可以正确地处理它们,而不需要任何特殊的操作。

当使用GNU工具在Windows(例如MinGW或MSYS项目生成器),CMake有能力将GNU导入库(.dll和.a)以相同的格式导出成Visual Studio生成库(lib)。如果发布用GNU工具构建的动态库,使其能够链接到Visual Studio构建的二进制文件,那么这很有用。请注意,要实现此转换,必须安装Visual Studio。通过将动态库的GNUtoMS目标属性设置为true来启用转换。这个目标属性在调用add_library()时由CMAKE_GNUtoMS变量的值初始化。

20.2. 连接静态库

CMake可以处理特定于链接静态库的特殊情况。如果库A为静态库目标B的私有依赖项,就链接而言(并且仅用于链接),A将视为一个公共依赖项。因为私有的A库仍然需要添加到链接到B的链接器命令行中,以便在链接时找到来自A的符号。如果B库是共享库,则不需要在链接器命令行中列出它所依赖的私有库A。这一切都是由CMake处理,因此开发人员关心除了指定PUBLIC、PRIVATE和INTERFACE依赖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目标属性,但这种情况意味着需要重新构造项目。对象库可能是解决这种深度依赖关系的有用工具,因为它们实际上就是一组源,而不是实际的库。链接器命令行上对象文件的顺序通常不重要,而库的顺序却很重要。

20.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目标属性定义。在平台上,这些属性的解释是不同的,但按照语义版本控制原则,这些差异可以无缝的方式处理。版本化假设在major.minor.patch中每个版本组件都是整数。VERSION属性将被设置为完整的major.minor.patch格式,而SOVERSION仅设置为major。随着项目的发展和发布,版本化意味着版本信息应该有如下修改:

  • 当对不兼容的API进行更改时,增加版本的major部分,并将minor和patch部分重置为0。这意味着SOVERSION属性将在每次出现API破坏时(且仅在出现API破坏时)更改。

  • 当以向后兼容的方式添加功能时,将minor部分和patch部分重置为0。major部分保持不变。

  • 当对向后兼容的bug进行修复时,增加patch值并保持major部分和minor部分不变。

如果根据这些原则修改了动态库的版本信息,在所有平台上运行时的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平台在动态库的符号链接中显示了类似的结构,通常做法是只使用库动态库名称的主要部分。

Windows上,CMake的行为是从VERSION属性中提取major.minor,并将其编码到DLL中作为DLL映射版本。Windows没有动态库名称的概念,因此不使用SOVERSION属性。然而,遵循版本化的原则是,至少确保使用DLL版本来确定,库与链接的二进制文件间的兼容性。

需要注意的是,版本化并不是平台严格要求的。它提供了一个定义良好的规范,为动态库及其使用对象之间的依赖关系管理带来了一些确定性。它恰好反映了库版本,在大多数基于Unix的平台上是如何解释的,CMake是充分利用VERSION和SOVERSION目标属性,来提供遵循本地平台约定的动态库。

项目应该知道,如果只设置了VERSION和SOVERSION目标属性中的一个,丢失的那个属性将视为与提供的那个属性具有相同的值。这不太可能产生良好的版本处理,除非版本号只使用单一的数字(即没有次要或补丁部分)。这样的版本号在某些情况下可能是合适的,但是为了更灵活和更健壮的运行时行为,项目应该努力遵循上面的原则。

20.4. 接口的兼容性

VERSION和SOVERSION目标属性,允许在操作系统级别以一种与平台无关的方式指定API版本控制。CMake还提供了其他属性,这些属性可用于定义CMake目标之间相互链接时的兼容性。这些可用于描述和强制执行版本编号无法捕获的信息。

考虑一个现实的示例,如果有适当的SSL工具包,网络库只提供对https://protocol支持(相关的)安全功能。程序的其他部分需要基于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>
)

两个库目标都为属性名SSLSUPPORT定义了接口兼容性。COMPATIBLE_INTERFACE_BOOL属性包含一个名称列表,每个名称都需要在该目标上定义具有相同名称的INTERFACE prepended属性。当这两个库作为myApp的链接依赖项使用时,CMake检查这两个库是否用相同的值定义了INTERFACE_SSL_SUPPORT。此外,CMake还将自动用相同的值填充myApp目标的SSL_SUPPORT属性,然后将其用作生成器表达式的一部分,并将其作为编译定义提供给myApp的源代码,如下所示。这允许myApp代码根据是否将SSL支持编译到它使用的库中进行自我调整,myApp不只是简单地检测SSL支持是否可用,它可以通过显式定义其SSL_SUPPORT属性来指定一个需求,以保存库必须兼容的值。这种情况下,CMake不会自动填充myApp的SSL_SUPPORT属性,而是会比较这些值并确保库与指定的要求一致。

# 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(bigAndFast strategy1.cpp)
set_target_properties(bigAndFast PROPERTIES
 COMPATIBLE_INTERFACE_NUMBER_MIN PROTOCOL_VER
 COMPATIBLE_INTERFACE_NUMBER_MAX TMP_BUFFERS
 INTERFACE_PROTOCOL_VER 3
 INTERFACE_TMP_BUFFERS 200
)

add_library(smallAndSlow strategy2.cpp)
set_target_properties(smallAndSlow 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 bigAndFast smallAndSlow)
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就不可能知道应该使用哪种类型来计算存储在父元素result属性中的值。例如,在上面的例子中,如果PROTOCOL_VER同时是接口兼容性的最小值和最大值,CMake就不能确定存储在myApp的PROTOCOL_VER属性中的值应该存储最小值还是最大值?所以必须使用单独的属性来实现:

add_library(bigAndFast strategy1.cpp)
set_target_properties(bigAndFast 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(smallAndSlow strategy2.cpp)
set_target_properties(smallAndSlow 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 bigAndFast smallAndSlow)
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的PUBLIC链接的依赖关系。在这种情况下,最终的链接关系实际上是这样的::

当libCalc是libUtil的PUBLIC链接依赖项时,任何链接到libUtil的内容也将链接到libCalc。因此,libCalc成为myApp的直接链接依赖项,因此它需要参与libNet和libUtil的接口兼容性检查。因为影响范围可以扩展到超出涉及公共链接关系时的目标,所以在定义接口兼容性时,必须非常小心,以确保能准确地表达了正确的内容。

20.5. 符号可见性

简单地说,库可以看作是编译源代码的容器,其他代码可以调用或使用的各种函数和全局数据。静态库容器实际上是对象文件的集合,将其组合在一起的工具称为归档器。另一方面,动态库是由链接器生成的,链接器处理目标代码、归档文件等,并决定在最终的动态库文件中包含什么。一些函数和全局数据可隐藏,动态库之外的代码不能调用或使用它们。其他符号会进行导出,这样动态库内部和外部的代码都可以访问它们,这称为符号的可见性。编译器有不同的方法来指定符号的可见性,也有不同的默认行为。一些默认情况下使所有符号可见,而另一些默认情况下隐藏所有符号。

编译器将单个函数、类和数据标记为可见或不可见的语法也有所不同,这增加了编写可移植共享库的复杂性。为了避免这种复杂性,一些开发人员选择简单地使所有符号可见,从而避免必须显式地标记用于导出的任何符号。虽然这一招一开始看起来不错,但它也有一些缺点:

  • 每个函数、类、类型、全局变量等等都可以使用。如果项目内容依赖于文档定义的公共标志,那么可以接受。

  • 通过所有符号可见,调用代码不能禁止使用这些他们不应做的事。其他链接到库的代码可能会依赖于一些内部符号,这使得动态库很难在不破坏使用项目的情况下更改其实现或内部结构。

  • 当所有符号都视为可见时,链接器无法知道每个符号是否会使用,因此它必须将它们全部包含在最终的动态库中。只有一个子集的符号导出时,链接器可以识别代码不能使用的符,将其可见性丢弃,会让生成的文件更小,从而在运行时加载的更快。

  • 像C++这样支持模板的语言有可能定义了大量符号。默认情况下所有符号都可见,这可能会导致动态库的符号表变得非常大。在极端情况下,这对运行时启动性能会产生影响。

  • 函数的内部实现中,使用的库可以通过使用名称暴露。某些上下文中,这可能是一个安全问题,或者它可能会暴露不应该对库使用者可见的商业IP。

上面的几点强调了符号可见性是关于实现库API的公私性质,就像它是关于动态库性能和包大小的底层机制一样。显然,只导出那些认为是公开的符号是有好处的,但是编译器和平台特有的特性通常为多平台项目带来了实质性的障碍。CMake将这些差异抽象为几个属性、变量和帮助模块后,简化了这个过程。

20.5.1. 指定默认的可见性

默认情况下,Visual Studio编译器假设所有符号都是隐藏的,除非显式导出。其他的编译器,如GCC和Clang则相反,默认情况下所有符号可见,只有在显式地告知时才隐藏符号。如果项目希望在所有编译器和平台上具有相同的默认符号可见性,则必须选择这两种方法中的一种,但希望上一节强调的缺点为选择默认隐藏符号提供了有力的论据。实现隐藏默认可见性的第一步是在动态库目标上定义<LANG>_VISIBILITY_PRESET属性集。对于使用此功能的两种最常见语言,C和C++的属性名分别是C_VISIBILITY_PRESET和CXX_VISIBILITY_PRESET。该属性的值应该隐藏,这将改变默认的可见性,以隐藏所有符号。其他支持的值包括default、protected和internal,这些值对跨平台项目不太有用。要么指定为默认行为,要么是隐藏,在某些上下文中可能具有更特殊的含义。

第二步是指定内联函数在默认情况下应该隐藏。对于大量使用模板的C++代码,这可极大地减少最终动态库文件的大小。此行为由目标属性VISIBILITY_INLINES_HIDDEN控制,并适用于所有语言。默认情况下,它应该为TRUE来隐藏内联符号。

<LANG>_VISIBILITY_PRESETVISIBILITY_INLINES_HIDDEN都可以在每个动态库目标上指定,或者可以通过适当的CMake变量设置默认值。创建目标时,<LANG>_VISIBILITY_PRESET属性由CMake变量CMAKE_<LANG>_VISIBILITY_PRESET 值初始化,其VISIBILITY_INLINES_HIDDEN属性由CMAKE_VISIBILITY_INLINES_HIDDEN变量初始化。这通常比为每个目标分别设置属性更为方便。

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

20.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

构建包含上述实现的动态库时,需要用关键字替换MYTOOLSEXPORT,这些关键字指定为其他库和可执行文件导出该符号。另一方面,如果属于动态库之外的代码读取了相同的声明,那么必须用指定应该导入该符号的关键字替换MYTOOLSEXPORT。Windows上,这些关键字采用由__declspec(...)的格式,而GCC和兼容的编译器使用`__attribute(...)`的格式。

为所有编译器以及导出和导入情况下的MYTOOLS_EXPORT提供正确的内容可能有些麻烦。再加上开发人员可能选择构建动态或静态库的混合,复杂性就会增加。幸运的是,CMake提供了GenerateExportHeader模块,可以以非常方便的方式处理这些细节。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 out 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头文件都可以找到库的源代码和目标连接到动态库。

如果不希望使用目标名称作为头文件名或预处理程序定义名称的一部分,可以使用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;

如果为文件和预处理器定义使用不同的名称,可以提供EXPORT_FILE_NAME和EXPORT_MACRO_NAME选项,而不是使用BASE_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选项覆盖。一些编译器中,标记为deprecated的符号会导致编译时警告,提醒人们注意它们的使用。这是一种有用的机制,鼓励开发人员更新他们的代码,不再使用废弃的符号。下面展示了如何使用废弃机制。

CMakeLists.txt

option(OMIT_DEPRECATED "Leave out 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选项提供的自定义名称。定义这个特殊的预处理定义时,导出定义展开为无内容,这通常是将目标构建为静态库时所需要的。如果没有特殊的预处理器定义,导出定义通常具有内容,并在构建动态库目标时按预期进行。

当为同一组源文件构建动态库和静态库时,应该为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
)

20.6. 混合静态和动态库

当项目将其所有库构建为静态时,该构建可能会对库链接依赖关系更加宽容一些。项目可能会忽略目标间的依赖关系,但是当各种静态库链接到最终的可执行文件中时,缺少的库依赖关系就会得到满足,因为它们会按照需要的顺序为可执行文件显式地列出。构建会成功,但可能要经过一段时间的试错构建,让链接器报出丢失的符号,添加更多丢失的库或重新排序现有的库等等。

这种情况的成功更多的是靠运气,而不是良好的设计,特别是在许多小型库的项目中。如果链接指定的依赖项又静态库,CMake会自动处理并链接到这些依赖项。因此,即使PRIVATE/PUBLIC性质的指定依赖不正确,但静态库总是视为PUBLIC,这有时会让构建工作尽管依赖的联系并地描述不准确,但也能正确构建。

库目标定义为动态和静态混合时,链接依赖关系的正确性就变得更为重要。考虑以下一系列目标:

如果libUtil和libCalc是静态库,上述链接依赖关系是安全的。如果libUtil是一个动态库,上述链接依赖关系可能会复制一个实例的数据。如果libCalc定义了全局数据,比如对于一个类的单例或静态数据可能是通用的,myApp和libUtil可能都有自己独立的数据实例。这之所以成为可能,是因为myApp和libUtil都需要链接器来解析符号,所以两个调用都可能决定需要全局数据,并在可执行库或动态库中设置内部实例。如果不是一个全局数据导出的符号,链接器不会看到已经创建的实例在libUtil需要连接到myApp中。最终结果是在myApp中创建了第二个实例,这会导致难以跟踪的运行时问题。这种情况的典型表现是,一个变量神奇地出现在函数调用中,将值从一个可执行程序或动态库更改为另一个动态库使用的值。

类似于上述场景的情况可能以多种不同的形式出现,相同的原则适用于每一种情况。如果静态库链接到一个动态库中,这个动态库不应该与链接同一个静态库的任何其他库或可执行文件结合在一起。理想情况下,如果动态库和静态库混合在一起,静态库应该只链接到一个动态库中,而需要从这些静态库中获得符号的目标都应该链接到动态库。动态库本来就有自己的API,而静态库可能对这些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
# 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和--no-whole-archive,或者Visual Studio中的/WHOLEARCHIVE,但是并非所有链接器都具有这种功能。如果确保动态库使用由其静态库导出的每个符号是不切实际的,那么可以考虑将这些静态库转换为动态库。

如果动态库仅以私有方式链接到静态库(意味着不需要导出静态库的符号),那么情况就简单得多。某些平台上,除了简单地将动态库链接到静态库之外,不需要进一步操作。其他情况下,可能会出现一两个需要解决的小问题。例如,在许多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)

20.7. 推荐

构建按需加载的插件可以使用MODULE库,以及用于链接的SHARED库。使用SHARED库时,无论是出于暴露API的目的,还是为了隐藏敏感的实现细节,必须严格控制要向库的使用者公开符号。如果目标是将一个库作为发布包的一部分进行交付,在大多数情况下,动态库往往比静态库更受青睐。

如果目标使用来自库的内容,它应该直接链接到库。即使库已经是其他的链接依赖,也不要依赖于目标直接使用的间接链接依赖。如果其他目标更改了实现,并且不再链接到库,那么主目标将不再构建。此外,表达正确的链接依赖类型:PRIVATE、PUBLIC或INTERFACE。这确保了CMake正确地处理动态库和静态库的链接依赖关系。指定与正确可见级别的所有直接依赖关系,对于确保CMake构造的库顺序正确,且形成可靠链接器命令行来说至关重要。

使用正确的链接可见性,目标不需要知道内部使用的所有不同的库依赖关系,只需要链接到一个库,并让库定义自己的依赖关系。然后,CMake会确保所有的库都在最终的链接器命令行中以正确的顺序指定。抵制简单地将所有链接依赖公开的诱惑,这会让私有库的可见性扩展到不需要的地方。当项目准备发布或发布时,这一点尤其重要。

考虑尽早使用库版本控制策略。当一个库发布,版本号就有了一些关于二进制兼容性的含义。使用VERSION和SOVERSION目标属性来指定库的版本(即使在项目早期这些属性被设置为一些基本占位符),在没有其他策略的情况下,一个合理的选择是将版本编号从0.1.0开始,人们倾向于将0.0.0认为是默认值,或者错误地认为没有设置版本,而1.0.0有时认为是第一个公开发行版本。强烈考虑采用语义版本控制来处理以后的版本变更,库版本的变化可能会对发布过程、打包等方面产生巨大影响,在这些库公开发布之前,开发人员需要花时间了解动态库版本号的含义。还要考虑项目版本和库版本之间是否应该有任何关系。一旦发布了第一个版本,就很难改变这种关系,所以要谨慎地连接它们,除非它们之间有很强的关联(作为SDK交付一组一致的库的项目就是这种强关联的一个例子)。

如果有特定的支持工具集、库可用,一些项目可以选择性地提供某些功能。为了允许构建的其他部分,或其他项目的检测,或检查与可选功能或特性的一致性,可以提供兼容性接口的详细信息。考虑是否有问题的特性需要在库之外具有可见性,比如允许目标检测是否支持该特性,或者确认所选的实现是否提供了所需的所有功能。还要考虑指定和使用兼容性接口带来的利益与增加的复杂性比例是否合适。因为库依赖层次结构越深,高效地使用兼容性接口就越困难。

项目生命周期的早期就要考虑符号的可见性,因为回头在考虑符号的可见性细节可能会翻新整个项目,实施起来会非常困难。创建库时,始终要养成这样一种习惯:考虑一个特定的类、函数或变量是否应该在库之外访问。认为任何外部可见性都很难改变,而内部之间的事情可以自由地修改。使用隐藏可见性作为默认值,并显式地标记要导出的每个实体,理想情况下使用generate_export_header()函数提供的宏,以便CMake处理各种平台差异。还要考虑使用该函数提供的废弃宏,以清楚地标识库API中已弃用,可能在未来版本中删除的那些部分API。

混合使用动态库和静态库时要格外小心。可能的话,最好使用其中之一,而不是两者都使用,这样可以避免一些与构建设置一致性和符号可见性控制相关的问题。如果混合使用这两种库类型是有意义的,请尝试确保静态库只链接到一个动态库中。将静态库视为动态库中的子组,外部目标仅链接到动态库。更好的方式是,考虑直接将代码从静态库中提取到动态库中,完全摆脱静态库。28.5.1节中会介绍相关的技术,并演示如何逐步向现有目标添加源,从而允许目标源在子目录中进行累积。

Last updated