一、为什么要在意目录结构?

说实话,我刚开始用 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 里加上文档生成步骤,每次提交代码自动更新文档。省心又专业。

七、知识体系总览

下面这张图,概括了本章的核心内容。你可以把它当作一个检查清单:

CMake 最佳实践 目录结构规范 src/ 源码 include/ 公共头文件 tests/ 测试 cmake/ 自定义模块 避免全局污染 target_include_directories target_compile_definitions target_link_libraries 版本管理 project() 设置版本号 configure_file 生成头文件 Git 标签同步版本 模块化设计 每个模块独立 CMakeLists 只暴露必要接口 依赖关系清晰 文档生成 Doxygen + CMake 集成 代码注释即文档 CI 自动生成

八、总结一下

这一章我们聊了五个方面:目录结构怎么摆、怎么避免全局污染、版本管理怎么搞、模块化怎么设计、文档怎么自动生成。

说白了,这些都不是什么高深的技术。但就是这些「小事」,决定了你的项目能走多远。我见过太多项目,一开始图省事,后面重构成本高得吓人。

嗯,记住一句话:规范不是束缚,是给未来的自己铺路。


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