一、为什么要在意目录结构?
说实话,我刚开始用 CMake 那会儿,也踩过不少坑。最典型的一个——把所有源文件扔到一个目录里,CMakeLists.txt 写得跟流水账似的。结果项目一大了,编译慢、找文件难、想复用模块?门儿都没有。
后来我慢慢摸索出一套习惯。嗯,今天就跟大家聊聊,一个规范的 CMake 项目,到底该怎么组织。
核心原则: 清晰、可复用、不污染全局、方便版本管理。
二、目录结构长什么样?
我个人习惯用这种结构。你想想看,一个中型 C++ 项目,大概长这样:
my_project/
├── CMakeLists.txt # 顶层构建文件
├── cmake/ # 自定义 CMake 模块
│ └── FindMyLib.cmake
├── src/ # 源代码
│ ├── CMakeLists.txt
│ ├── main.cpp
│ └── module_a/
│ ├── CMakeLists.txt
│ ├── a.h
│ └── a.cpp
├── include/ # 公共头文件
│ └── my_project/
│ └── config.h.in
├── tests/ # 测试代码
│ ├── CMakeLists.txt
│ └── test_a.cpp
├── docs/ # 文档
│ └── Doxyfile.in
├── extern/ # 第三方依赖
│ └── googletest/
├── scripts/ # 辅助脚本
│ └── build.sh
└── .gitignore
为什么这么分?说白了,就是让每个目录只干一件事。src 只管源码,tests 只管测试,cmake 只管自定义模块。这样你找东西快,别人接手也快。
三、避免全局污染
我在项目中遇到过最头疼的事——一个库的宏定义,莫名其妙影响了另一个模块。这就是典型的「全局污染」。
CMake 里怎么避免?记住几个关键点:
- 别用
include_directories()全局加头文件路径。用target_include_directories()加给具体目标。 - 别用
add_definitions()全局加宏。用target_compile_definitions()。 - 别用
link_libraries()全局链接库。用target_link_libraries()。
我曾经 接手过一个项目,顶层 CMakeLists.txt 里写了三行 include_directories(),结果子目录里一个头文件重名,编译报错查了两天。从那以后,我再也不用全局指令了。
正确的做法是这样:
# 不好的做法
include_directories(${PROJECT_SOURCE_DIR}/include)
# 好的做法
target_include_directories(my_lib
PUBLIC
$<INSTALL_INTERFACE:include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
)
四、版本管理怎么做?
版本管理不只是 Git 的事。CMake 本身也要管好版本号。我习惯在顶层 CMakeLists.txt 里这样写:
cmake_minimum_required(VERSION 3.16)
project(my_project
VERSION 1.2.3
DESCRIPTION "一个示例项目"
LANGUAGES CXX
)
# 生成版本头文件
configure_file(
${PROJECT_SOURCE_DIR}/include/my_project/config.h.in
${PROJECT_BINARY_DIR}/include/my_project/config.h
)
对应的 config.h.in 文件:
#ifndef MY_PROJECT_CONFIG_H
#define MY_PROJECT_CONFIG_H
#define MY_PROJECT_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define MY_PROJECT_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define MY_PROJECT_VERSION_PATCH @PROJECT_VERSION_PATCH@
#define MY_PROJECT_VERSION "@PROJECT_VERSION@"
#endif
这样版本号只在一个地方维护,代码里直接用宏就行。方便得很。
五、模块化设计
模块化说白了就是「高内聚、低耦合」。每个模块有自己的 CMakeLists.txt,自己管自己的依赖。
举个例子,src/module_a/CMakeLists.txt:
add_library(module_a
a.cpp
a.h
)
target_include_directories(module_a
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
)
target_link_libraries(module_a
PUBLIC
some_external_lib
)
然后在 src/CMakeLists.txt 里:
add_subdirectory(module_a)
add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE module_a)
你看,每个模块只暴露自己需要的接口。main 函数根本不用关心 module_a 内部用了什么第三方库。这就是模块化的好处。
六、文档生成
文档这事,很多人觉得麻烦。其实 CMake 配合 Doxygen 可以自动生成。我一般这样配置:
# 在顶层 CMakeLists.txt 里
find_package(Doxygen REQUIRED)
set(DOXYGEN_INPUT_DIR ${PROJECT_SOURCE_DIR}/include)
set(DOXYGEN_OUTPUT_DIR ${PROJECT_BINARY_DIR}/docs)
set(DOXYGEN_GENERATE_HTML YES)
doxygen_add_docs(docs
${PROJECT_SOURCE_DIR}/include
${PROJECT_SOURCE_DIR}/src
COMMENT "生成 API 文档"
)
然后运行 make docs 就能生成 HTML 文档。代码里写好注释就行:
/**
* @brief 计算两个数的和
* @param a 第一个数
* @param b 第二个数
* @return a + b
*/
int add(int a, int b);
小技巧: 在 CI 里加上文档生成步骤,每次提交代码自动更新文档。省心又专业。
七、知识体系总览
下面这张图,概括了本章的核心内容。你可以把它当作一个检查清单:
八、总结一下
这一章我们聊了五个方面:目录结构怎么摆、怎么避免全局污染、版本管理怎么搞、模块化怎么设计、文档怎么自动生成。
说白了,这些都不是什么高深的技术。但就是这些「小事」,决定了你的项目能走多远。我见过太多项目,一开始图省事,后面重构成本高得吓人。
嗯,记住一句话:规范不是束缚,是给未来的自己铺路。