理解 CMake 中的 Usage Requirements 及其传递方式

2023-08-28 CMake

Usage Requirements 是 CMake 中一个非常重要的概念。例如,使用 CMake 最常见的需求就是让某个 target 链接到它所依赖的 target(库)——通过 target_link_libraries() 这个命令。在使用这个命令的时候可以使用三种关键字 PUBLICPRIVATE 和 INTERFACE 中的一种来指定如何链接到这个库

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

这三个关键字是什么意思呢?实际上,它们控制了一个库的 Usage Requirements 应当如何传递。因此,想要理解这三个关键字,首先要理解什么是一个 target 的 Usage Requirements,这就是本文的核心内容。

不过,在讲述 Usage Requirements 之前,需要先明白一个 target 的 Build Specifications 是什么。

Target 的 Build Specifications

所谓 Build Specifications,就是指一个 target 应当如何被编译。具体来说,就是下面这三个:

  1. 头文件目录,即编译器 -I 选项所指定的内容;
  2. 预处理器定义,即编译器 -D 选项所指定的内容;
  3. 编译选项,如 -std=c++17

当我们编译(不考虑链接)一个源文件的时候,必然要考虑上述三种 Build Specifications,如果配置不当,就会编译失败或编译结果不符合预期。

CMake 提供了以下三个命令,分别用于为一个 target 设置上述三种 Build Specifications:

  1. target_include_directories()
  2. target_compile_definitions()
  3. target_compile_options()

它们的实际作用,就是为某个 target 分别设置以下三个 property 的内容:

  1. INCLUDE_DIRECTORIES
  2. COMPILE_DEFINITIONS
  3. COMPILE_OPTIONS

当这个 target 被编译的时候,这三个 property 的内容就会被 CMake 拿来设置编译器的 -I-D 和其他编译选项。因此,这三个 property 的内容也就是这个 target 的 Build Specifications,它们决定了这个 target 应当如何被编译。

同时,上述三种 property 还有三个对应的 INTERFACE_ 版本:

  1. INTERFACE_INCLUDE_DIRECTORIES
  2. INTERFACE_COMPILE_DEFINITIONS
  3. INTERFACE_COMPILE_OPTIONS

这三个 property 和不带 INTERFACE_ 的三个 property 的区别在于:它们不决定当前 target 如何被编译,而是决定当前 target 的依赖者(消费者)如何编译。这个很好理解:当你依赖某个 target 的时候,你很有可能需要增加一些头文件目录、宏定义和编译选项,才能正常和这个 target 链接起来。这三个 property 的内容,就是这个 target 的 Usage Requirements。(谁使用我,谁就要按这些要求来编译。)

如何设置一个 target 的 Usage Requirements

前面说的 target_include_directories()target_compile_definitions() 和 target_compile_options() 这三个命令都支持PRIVATEPUBLIC 和 INTERFACE 三种 mode:

  1. PRIVATE 模式只设置 非 INTERFACE_ 版 的 property;
  2. INTERFACE 模式只设置 INTERFACE_ 版 的 property;
  3. PUBLIC 模式同时设置两种版本的 property。

例如:

target_compile_definitions(archive
  PRIVATE BUILDING_WITH_LZMA
  INTERFACE USING_ARCHIVE_LIB
)

上述命令使得 archive 这个 target 的 COMPILE_DEFINITIONS 这个 property 的内容增加 BUILDING_WITH_LZMA这是 archive 自己的 Build Specifications;同时使它的 INTERFACE_COMPILE_DEFINITIONS 这个 property 的内容增加 USING_ARCHIVE_LIB这是 archive 的 Usage Requirements

这样,编译器在编译 archive 的时候,就会增加 -DBUILDING_WITH_LZMA 这个定义;而假如另一个 target myapp 链接到了 archive,那么编译器在编译 myapp 的时候,就会增加 -DUSING_ARCHIVE_LIB 这个定义。这就是这六种 target 的实际意义,确实非常好用。

Usage Requirements 的传递

在实际 C/C++ 的开发中经常出现一个库依赖另一个库的情况。我们就假设是以下这种情况:

   依赖      依赖
A  --->  B  ---> C

会出现以下三种情况中的一种:

  1. B 依赖 C 仅仅是为了自己的需要(实现),A 甚至意识不到 C 的存在,此时 C 的 Usage Requirements 不需要让 A 也遵守——此时应当使用 target_link_libraries(B PRIVATE C),让 C 的 Usage Requirements 只传递给 B,不继续传递下去。
  2. B 在自己的接口和实现中都使用了 C 的内容,那边 B 的编译肯定需要遵守 C 的 Usage Requirements;而此时 A 使用了 B 提供的接口,那必然也能意识到 C 的存在,也就需要遵守 C 的 Usage Requirements ——此时应当使用 target_link_libraries(B PUBLIC C),让 C 的 Usage Requirements 不仅传给 B,还传给 A。
  3. B 自身的编译不需要使用 C,C 仅仅是 B 接口的一部分。例如一个纯头文件的库 B,B 本身实际上没法编译 —— 此时应当使用 target_link_libraries(B INTERFACE C),让 C 的 Usage Requirements 能传递给 A。

也就是说,target_link_libraries() 这个命令的 PUBLICPRIVATEINTERFACE 控制了一个 target 的 Usage Requirements 是如何被传递的。这就解释了文章开头所说的。

总结

理解 CMake Usage Requirements 的核心是理解编译和链接过程,CMake 不过将其进行抽象,用 CMake 自己的语言表达出来罢了。对于一个合格的 C/C++ 开发者来说,编译和链接过程是必然需要深刻理解的,否则在 CMake 的使用过程中就会一头雾水,就像我以前一样 :)

参考