实战案例一:构建一个中型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_TESTING 和 BUILD_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++库构建系统,需要同时处理好源码构建、测试集成、文档生成和安装导出四个环节。每个环节都有对应的CMake模块和最佳实践。
我的小建议:刚开始做项目时,别急着把所有功能都塞进去。先搭好骨架——能编译、能测试、能安装。文档可以后面再加。我见过太多人一开始就想搞个完美的构建系统,结果折腾了两周还没开始写代码。
注意:file(GLOB_RECURSE) 虽然方便,但CMake官方并不推荐在正式项目中使用。因为如果新增了源文件,CMake不会自动重新配置,需要手动重新运行 cmake。更好的做法是显式列出所有源文件。不过对于中型项目,我个人觉得用GLOB问题不大,只要记得新增文件后重新配置就行。
好了,这一章的内容就到这里。你跟着走一遍,应该能体会到CMake在真实项目中的用法了。从顶层到底层,从构建到测试再到安装,每一步都有它的道理。下次你接手一个新项目,也可以按照这个模板来搭建构建系统。