实战案例一:构建一个中型C++库(含测试、文档、安装)

好,前面我们聊了那么多CMake的理论和基础语法,是时候来点真家伙了。这一章,我会带你完整走一遍构建一个中型C++库的全过程。这个库不算大,但五脏俱全——有单元测试、有文档生成、还能一键安装到系统里。

我个人习惯,学一个新工具最好的方式就是动手做一个项目。光看语法,你永远记不住。只有踩过坑,才知道为什么CMake要这么设计。

项目结构设计

先说说这个项目长什么样。我们要构建一个叫 MathUtils 的数学工具库,提供一些常用的数值计算功能。项目结构如下:

MathUtils/
├── CMakeLists.txt          # 顶层构建文件
├── include/
│   └── MathUtils/
│       ├── MathUtils.h     # 库的主头文件
│       ├── Vector3.h       # 三维向量
│       └── Matrix3.h       # 3x3矩阵
├── src/
│   ├── CMakeLists.txt      # 源码子目录构建文件
│   ├── MathUtils.cpp
│   ├── Vector3.cpp
│   └── Matrix3.cpp
├── tests/
│   ├── CMakeLists.txt      # 测试子目录构建文件
│   ├── test_math.cpp
│   ├── test_vector.cpp
│   └── test_matrix.cpp
├── docs/
│   └── Doxyfile.in         # Doxygen配置文件模板
└── cmake/
    └── Config.cmake.in     # 安装后的CMake配置模板

你可能会问,为什么要把头文件放在 include/MathUtils/ 这样的嵌套目录里?嗯,这里有个小讲究。这样做的好处是,安装后用户通过 #include <MathUtils/MathUtils.h> 来引用,命名空间清晰,不容易跟其他库冲突。我在早期项目里吃过这个亏,头文件直接扔在 include/ 下,结果跟另一个库重名了,排查了半天。

顶层CMakeLists.txt

顶层文件是整个构建系统的入口。我们来看看怎么写:

cmake_minimum_required(VERSION 3.16)
project(MathUtils VERSION 1.0.0 LANGUAGES CXX)

# 设置C++标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# 设置安装路径
include(GNUInstallDirs)

# 开启测试
option(BUILD_TESTING "Build unit tests" ON)
option(BUILD_DOCS "Build documentation" OFF)

# 添加子目录
add_subdirectory(src)

if(BUILD_TESTING)
    enable_testing()
    add_subdirectory(tests)
endif()

if(BUILD_DOCS)
    find_package(Doxygen REQUIRED)
    add_subdirectory(docs)
endif()

# 生成安装配置文件
install(
    EXPORT MathUtilsTargets
    FILE MathUtilsConfig.cmake
    NAMESPACE MathUtils::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MathUtils
)

# 安装头文件
install(
    DIRECTORY include/
    DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
    FILES_MATCHING PATTERN "*.h"
)

这段代码里,有几个地方我想特别说一下。

第一CMAKE_CXX_EXTENSIONS OFF 这个设置。很多人会忽略它,但我觉得很重要。它确保编译器不使用GNU扩展,只使用标准C++。这样你的代码移植性更好。我曾经把一个项目从GCC迁移到MSVC,就是因为用了GNU扩展的语法,改得我头皮发麻。

第二GNUInstallDirs 这个模块。它会帮你自动设置好不同平台下的安装路径,比如Linux下库文件装到 lib/,头文件装到 include/。省心省力。

第三BUILD_TESTINGBUILD_DOCS 这两个选项。我习惯把它们做成可开关的。平时开发时开着测试,但发布时关掉。文档生成比较慢,默认关掉,需要时再开。

源码子目录的构建

接下来看 src/CMakeLists.txt

# 收集源文件
file(GLOB_RECURSE MATHUTILS_SOURCES "*.cpp")

# 创建库目标
add_library(MathUtils
    ${MATHUTILS_SOURCES}
)

# 指定头文件搜索路径
target_include_directories(MathUtils
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../include>
        $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)

# 设置库的版本信息
set_target_properties(MathUtils PROPERTIES
    VERSION ${PROJECT_VERSION}
    SOVERSION 1
    EXPORT_NAME MathUtils
)

# 安装库文件
install(
    TARGETS MathUtils
    EXPORT MathUtilsTargets
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)

这里有个关键点——$<BUILD_INTERFACE>$<INSTALL_INTERFACE> 生成器表达式。说白了,就是让头文件路径在构建时和安装后自动切换。构建时指向源码目录,安装后指向系统路径。这个技巧我是在一个大型项目中学会的,当时我们手动维护两套路径,经常搞错。

测试模块

测试我们用Google Test。先看 tests/CMakeLists.txt

# 查找Google Test
find_package(GTest REQUIRED)

# 收集测试源文件
file(GLOB_RECURSE TEST_SOURCES "*.cpp")

# 创建测试可执行文件
add_executable(test_math_utils ${TEST_SOURCES})

# 链接库
target_link_libraries(test_math_utils
    PRIVATE
        MathUtils
        GTest::GTest
        GTest::Main
)

# 注册测试
add_test(
    NAME MathUtilsTests
    COMMAND test_math_utils
)

写一个简单的测试用例:

#include <gtest/gtest.h>
#include <MathUtils/MathUtils.h>

TEST(MathTest, Add) {
    EXPECT_DOUBLE_EQ(math_add(1.0, 2.0), 3.0);
}

TEST(MathTest, DivideByZero) {
    EXPECT_THROW(math_divide(1.0, 0.0), std::runtime_error);
}

int main(int argc, char **argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

为什么要用Google Test而不是自己写个简单的测试框架?说实话,我以前也自己写过,但后来发现维护测试框架本身比写测试还累。Google Test成熟稳定,断言丰富,还能自动发现测试用例,省心。

文档生成

文档我们用Doxygen。在 docs/CMakeLists.txt 里:

# 配置Doxygen配置文件
configure_file(
    ${CMAKE_CURRENT_SOURCE_DIR}/Doxyfile.in
    ${CMAKE_CURRENT_BINARY_DIR}/Doxyfile
    @ONLY
)

# 添加文档生成目标
add_custom_target(docs
    COMMAND ${DOXYGEN_EXECUTABLE} ${CMAKE_CURRENT_BINARY_DIR}/Doxyfile
    WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
    COMMENT "Generating API documentation with Doxygen"
    VERBATIM
)

Doxyfile.in 模板文件里,我们用 @ 占位符来注入CMake变量:

PROJECT_NAME           = @PROJECT_NAME@
PROJECT_NUMBER         = @PROJECT_VERSION@
INPUT                  = @CMAKE_SOURCE_DIR@/include
OUTPUT_DIRECTORY       = @CMAKE_CURRENT_BINARY_DIR@/docs
RECURSIVE              = YES
GENERATE_HTML          = YES
GENERATE_LATEX         = NO

这样,文档的版本号会自动跟项目版本保持一致。不用手动改,省得忘了更新。

安装与导出

最后,我们还需要一个 cmake/Config.cmake.in 文件,让其他项目可以通过 find_package(MathUtils) 来找到我们:

@PACKAGE_INIT@

include("${CMAKE_CURRENT_LIST_DIR}/MathUtilsTargets.cmake")

check_required_components(MathUtils)

这个文件会在安装时被CMake处理,生成 MathUtilsConfig.cmake。其他项目只需要:

find_package(MathUtils REQUIRED)
target_link_libraries(my_app PRIVATE MathUtils::MathUtils)

就能直接用了。是不是很方便?

构建与测试

整个流程跑一遍:

# 配置
mkdir build && cd build
cmake .. -DBUILD_TESTING=ON -DBUILD_DOCS=ON

# 构建
cmake --build .

# 运行测试
ctest --output-on-failure

# 生成文档
cmake --build . --target docs

# 安装到本地
cmake --install . --prefix /usr/local

我个人习惯用 cmake --build . 而不是直接 make,因为这样跨平台。在Windows上它会自动用MSBuild,在Linux上用Makefile或Ninja,不用记不同的命令。

知识体系总览

下面这张图,帮你理清整个构建系统的脉络:

中型C++库构建系统知识体系 CMakeLists.txt (顶层) src/ 源码模块 add_library + target_include tests/ 测试模块 GTest + add_test + ctest docs/ 文档模块 Doxygen + configure_file libMathUtils.so / .a test_math_utils 可执行文件 HTML文档目录 安装与导出 (install + Config.cmake) find_package(MathUtils) → MathUtils::MathUtils 其他项目直接引用

核心要点:一个完整的中型C++库构建系统,需要同时处理好源码构建、测试集成、文档生成和安装导出四个环节。每个环节都有对应的CMake模块和最佳实践。

我的小建议:刚开始做项目时,别急着把所有功能都塞进去。先搭好骨架——能编译、能测试、能安装。文档可以后面再加。我见过太多人一开始就想搞个完美的构建系统,结果折腾了两周还没开始写代码。

注意file(GLOB_RECURSE) 虽然方便,但CMake官方并不推荐在正式项目中使用。因为如果新增了源文件,CMake不会自动重新配置,需要手动重新运行 cmake。更好的做法是显式列出所有源文件。不过对于中型项目,我个人觉得用GLOB问题不大,只要记得新增文件后重新配置就行。

好了,这一章的内容就到这里。你跟着走一遍,应该能体会到CMake在真实项目中的用法了。从顶层到底层,从构建到测试再到安装,每一步都有它的道理。下次你接手一个新项目,也可以按照这个模板来搭建构建系统。


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