模块化设计:将大型项目拆分为子目录
做大型项目,最怕什么?
我最怕打开一个目录,里面躺着上百个源文件。找文件靠翻,改代码靠猜。这种项目,维护起来简直要命。
模块化设计,说白了就是把大项目拆成小模块。每个模块独立编译,互不干扰。CMake 里实现这个功能的核心命令,就是 add_subdirectory。
为什么需要模块化?
我刚开始用 CMake 时,也喜欢把所有东西写在一个 CMakeLists.txt 里。项目小的时候还行,一旦超过 10 个源文件,问题就来了:
- 变量满天飞,改一个地方可能影响全局
- 编译慢,改一个文件要重新链接整个项目
- 多人协作时,合并冲突频繁
你想想看,一个团队 5 个人同时改同一个 CMakeLists.txt,那画面太美我不敢看。
核心思想:每个子目录只关心自己的事。父目录只负责调度,不干涉内部细节。
add_subdirectory 的基本用法
这个命令的语法很简单:
add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])
- source_dir:子目录的路径,里面必须有一个
CMakeLists.txt - binary_dir:编译输出目录(可选)。不指定时,默认在 build 目录下生成同名子目录
- EXCLUDE_FROM_ALL:让子目录中的目标不参与默认构建(比如测试代码)
举个例子。假设项目结构是这样的:
project/
├── CMakeLists.txt
├── src/
│ ├── CMakeLists.txt
│ ├── main.cpp
│ └── utils.cpp
└── lib/
├── CMakeLists.txt
└── math.cpp
根目录的 CMakeLists.txt 可以这样写:
cmake_minimum_required(VERSION 3.10)
project(MyProject)
add_subdirectory(src)
add_subdirectory(lib)
每个子目录的 CMakeLists.txt 各自定义自己的目标:
# src/CMakeLists.txt
add_executable(my_app main.cpp utils.cpp)
target_link_libraries(my_app PRIVATE math_lib)
# lib/CMakeLists.txt
add_library(math_lib STATIC math.cpp)
这样,src 和 lib 各管各的。父目录只负责把它们串起来。
我的习惯:每个子目录的 CMakeLists.txt 尽量短。超过 50 行,就该考虑再拆一层了。
变量隔离:子目录的"防火墙"
模块化最大的好处,就是变量隔离。
在 CMake 中,变量默认是全局的。但 add_subdirectory 会创建一个新的作用域。子目录中定义的变量,默认不会影响父目录。
看个例子:
# 根目录 CMakeLists.txt
set(MY_VAR "hello from root")
add_subdirectory(sub)
message("After sub: ${MY_VAR}") # 输出:hello from root
# sub/CMakeLists.txt
set(MY_VAR "hello from sub")
message("In sub: ${MY_VAR}") # 输出:hello from sub
输出结果:
In sub: hello from sub
After sub: hello from root
看到了吗?子目录改了 MY_VAR,父目录不受影响。这就是作用域隔离。
我曾经踩过的坑:如果子目录里用了 set(MY_VAR "xxx" PARENT_SCOPE),变量会传递到父目录。这个功能要慎用,滥用会导致变量污染。
什么时候用 PARENT_SCOPE?
说实话,我很少用 PARENT_SCOPE。但有一种情况例外:
子目录需要告诉父目录一些信息,比如编译选项、头文件路径。这时候可以用 PARENT_SCOPE 传递:
# sub/CMakeLists.txt
set(SUB_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include" PARENT_SCOPE)
父目录拿到后,可以传给其他子目录:
# 根目录 CMakeLists.txt
add_subdirectory(sub)
include_directories(${SUB_INCLUDE_DIR})
但更好的做法是使用 target 的属性来传递,而不是靠变量。这个我们后面会讲。
EXCLUDE_FROM_ALL 的妙用
项目里总有一些"辅助"模块:测试代码、示例程序、文档生成脚本。这些不需要默认构建。
用 EXCLUDE_FROM_ALL 可以排除它们:
add_subdirectory(tests EXCLUDE_FROM_ALL)
add_subdirectory(examples EXCLUDE_FROM_ALL)
这样,执行 make 时不会编译测试和示例。只有显式指定 make test_app 才会编译。
我的建议:所有非核心模块都用 EXCLUDE_FROM_ALL。CI 构建时再单独编译测试,能节省大量时间。
模块化设计的核心原则
做了这么多年项目,我总结了几条原则:
- 每个模块只做一件事。一个子目录不要既生成库又生成可执行文件,除非它们关系非常紧密。
- 模块间依赖要明确。用
target_link_libraries声明依赖,不要靠全局变量传递。 - 目录深度控制在 3 层以内。太深了,构建逻辑会变得复杂。
- 子目录的 CMakeLists.txt 要自包含。别人复制走也能独立编译。
知识体系图
下面这张图展示了模块化设计的核心逻辑:
常见问题与避坑指南
问题1:子目录找不到头文件
子目录中的 target_include_directories 默认只对当前目标生效。如果父目录需要引用子目录的头文件,要么用 PUBLIC 传递,要么在父目录手动添加路径。
我曾经犯过的错:在子目录里用 include_directories 添加路径,以为父目录也能用。结果编译报错"找不到头文件"。后来改用 target_include_directories 加 PUBLIC 才解决。
问题2:子目录构建顺序不对
CMake 会自动根据 target_link_libraries 的依赖关系决定构建顺序。但如果你用 add_subdirectory 的顺序来暗示依赖,那就错了。一定要显式声明依赖。
问题3:变量名冲突
虽然子目录有变量隔离,但如果你在子目录里用了 set(CMAKE_CXX_FLAGS ...) 这种全局变量,还是会互相影响。我的建议是:尽量用 target_compile_options 代替全局设置。
总结
模块化设计,说白了就是"分而治之"。用 add_subdirectory 把大项目拆成小模块,每个模块独立编译、独立维护。变量隔离机制让你不用担心命名冲突。
嗯,这里要注意:模块化不是越细越好。拆得太碎,管理成本反而上升。找到合适的粒度,才是关键。
一句话记住:一个子目录一个模块,一个模块只做一件事。用 add_subdirectory 串联,用变量隔离保护。
公众号:蓝海资料掘金营,微信deep3321