第24章:对象库与接口库:OBJECT库,INTERFACE库,纯头文件库的管理

说到CMake的库类型,很多人第一反应就是STATIC和SHARED。但实际项目中,还有两种库类型特别实用——OBJECT库和INTERFACE库。尤其是做纯头文件库的时候,INTERFACE库简直就是救星。

我个人习惯把这两种库叫做「中间态库」。它们不直接生成可执行文件或传统意义上的库文件,但却是构建系统中不可或缺的拼图。今天我们就来把它们彻底讲透。

24.1 OBJECT库:编译后的对象文件集合

OBJECT库说白了,就是一堆编译好的.o文件打包在一起。它不链接,不生成.a或.so,就是纯粹的中间产物。

为什么要用OBJECT库?我遇到过好几次这样的场景:一个大型项目,多个可执行文件共享同一批源文件。如果每个可执行文件都重新编译这些源文件,编译时间会翻好几倍。用STATIC库虽然能复用,但链接时会把不需要的符号也带进来,而且静态库的链接顺序经常让人头疼。

OBJECT库完美解决了这个问题。来看个例子:

# 创建一个OBJECT库
add_library(my_objects OBJECT
    src/core.cpp
    src/utils.cpp
    src/network.cpp
)

# 指定编译选项
target_include_directories(my_objects PUBLIC include)
target_compile_definitions(my_objects PRIVATE USE_OBJECT_LIB=1)

# 多个可执行文件复用这些对象
add_executable(app1 main1.cpp $<TARGET_OBJECTS:my_objects>)
add_executable(app2 main2.cpp $<TARGET_OBJECTS:my_objects>)

注意那个奇怪的语法$<TARGET_OBJECTS:my_objects>。这是CMake的生成器表达式,专门用来展开OBJECT库中的对象文件列表。我第一次看到这个语法时也觉得别扭,但用顺手了就会发现它非常强大。

核心要点:OBJECT库的源文件只编译一次,然后被多个目标共享。编译时间大幅缩短,而且没有链接顺序问题。

24.2 OBJECT库的避坑指南

我曾经在一个跨平台项目里吃过OBJECT库的亏。当时在Linux上跑得好好的,换到Windows上就报链接错误。排查了半天,发现是OBJECT库的源文件里用了一些平台特定的API,但编译选项没处理好。

这里有几个关键点要注意:

  • 编译选项必须一致:所有使用OBJECT库的目标,编译选项必须兼容。否则会出现符号冲突或链接失败。
  • 生成器表达式是必须的:别想着手动列出.o文件路径,不同平台、不同构建工具生成的路径都不一样。老老实实用$<TARGET_OBJECTS>
  • 不要对OBJECT库调用target_link_libraries:OBJECT库不链接,所以不能给它加链接库。但你可以给它的源文件设置编译依赖。

警告:OBJECT库的源文件如果使用了PRIVATE编译选项,这些选项不会传递给使用它的目标。如果你需要某些宏定义在所有使用者中都生效,记得用PUBLIC或INTERFACE。

24.3 INTERFACE库:纯头文件库的最佳实践

INTERFACE库是我个人最喜欢的CMake特性之一。它没有源文件,不编译任何东西,纯粹用来传递依赖信息。说白了,就是一个「虚拟库」。

纯头文件库(header-only library)在现代C++项目中越来越常见。比如nlohmann/json、spdlog、Catch2这些库,都是纯头文件的。用INTERFACE库来管理它们,简直天作之合。

# 创建一个INTERFACE库
add_library(my_header_lib INTERFACE)

# 指定头文件路径
target_include_directories(my_header_lib INTERFACE
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>
)

# 传递编译定义
target_compile_definitions(my_header_lib INTERFACE
    USE_HEADER_LIB=1
)

# 传递链接依赖
target_link_libraries(my_header_lib INTERFACE
    Threads::Threads
    fmt::fmt
)

注意所有关键字都是INTERFACE。因为INTERFACE库没有自己的源文件,所以不存在PRIVATE或PUBLIC的概念——所有东西都是给使用者看的。

小技巧:使用$<BUILD_INTERFACE>$<INSTALL_INTERFACE>生成器表达式,可以让头文件路径在构建时和安装后自动切换。这样你的库既能在开发阶段直接引用,也能在安装后正常使用。

24.4 纯头文件库的版本管理

纯头文件库没有编译产物,所以版本管理全靠头文件本身。我建议在头文件中定义一个版本宏:

// include/mylib/version.hpp
#pragma once

#define MYLIB_VERSION_MAJOR 1
#define MYLIB_VERSION_MINOR 2
#define MYLIB_VERSION_PATCH 0

#define MYLIB_VERSION "1.2.0"

然后在CMake中读取这个版本信息:

# 从头文件中提取版本号
file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/include/mylib/version.hpp"
    MYLIB_VERSION_MAJOR REGEX "#define MYLIB_VERSION_MAJOR "
)
file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/include/mylib/version.hpp"
    MYLIB_VERSION_MINOR REGEX "#define MYLIB_VERSION_MINOR "
)

# 设置库版本
add_library(mylib INTERFACE)
set_target_properties(mylib PROPERTIES
    VERSION "${MYLIB_VERSION_MAJOR}.${MYLIB_VERSION_MINOR}.0"
    SOVERSION "${MYLIB_VERSION_MAJOR}"
)

这样做的好处是,版本号只维护一份——在头文件里。CMake自动读取,不会出现版本号不一致的情况。

24.5 知识体系结构图

下面这张图展示了OBJECT库和INTERFACE库在整个构建系统中的位置和作用:

CMake 库类型体系 源文件 (.cpp/.c) OBJECT 库 编译后的 .o 文件集合 可执行文件 1 app1 可执行文件 2 app2 头文件 (.h/.hpp) INTERFACE 库 纯头文件库 / 依赖传递 使用者目标 自动获得头文件路径和依赖 关键区别 OBJECT库:编译源文件,生成 .o 文件,供多个目标复用 INTERFACE库:不编译任何文件,只传递头文件路径、宏定义和链接依赖

24.6 实战:管理一个纯头文件日志库

假设我们要用INTERFACE库管理一个纯头文件的日志库。这个库依赖fmt和spdlog,还定义了一些自己的宏。

# CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(MyLogger VERSION 1.0.0 LANGUAGES CXX)

# 创建INTERFACE库
add_library(mylogger INTERFACE)

# 头文件路径
target_include_directories(mylogger INTERFACE
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>
)

# 传递编译定义
target_compile_definitions(mylogger INTERFACE
    MYLOGGER_ENABLE_DEBUG=1
    MYLOGGER_MAX_LOG_SIZE=4096
)

# 传递链接依赖
find_package(fmt REQUIRED)
find_package(spdlog REQUIRED)
target_link_libraries(mylogger INTERFACE
    fmt::fmt
    spdlog::spdlog
)

# 安装
install(TARGETS mylogger EXPORT MyLoggerConfig)
install(DIRECTORY include/ DESTINATION include)
install(EXPORT MyLoggerConfig DESTINATION cmake)

使用者只需要一行代码就能引入所有依赖:

target_link_libraries(myapp PRIVATE mylogger)

这一行背后,CMake自动帮你做了三件事:添加头文件搜索路径、定义宏、链接fmt和spdlog。这就是INTERFACE库的魅力——把复杂性封装在库内部,使用者零成本接入。

个人建议:如果你的项目中有任何纯头文件库,或者你想把一组编译选项、头文件路径打包复用,优先考虑INTERFACE库。它比手动设置target_include_directories要优雅得多,而且天然支持传递依赖。

24.7 总结

OBJECT库和INTERFACE库是CMake构建系统中两个被低估的特性。OBJECT库适合需要共享编译产物但不想用静态库的场景,INTERFACE库则是纯头文件库和依赖传递的最佳方案。

我在实际项目中,几乎每个CMake项目都会用到INTERFACE库。不管是封装第三方库的依赖,还是管理内部的纯头文件组件,它都能让构建系统变得干净、可维护。OBJECT库虽然用得少一些,但在大型项目中的编译时间优化效果非常显著。

记住一点:库类型的选择不是非黑即白的。STATIC、SHARED、OBJECT、INTERFACE各有各的适用场景。理解了它们的本质区别,你就能在合适的场景用合适的工具。


公众号:蓝海资料掘金营,微信deep3321