Blender 是世界上最成功的高性能开源 3D 套件之一。对其核心模块运行 CppDepend 分析,会发现其整体可维护性评级出人意料地高——对于一个拥有超过 16,000 个类型和 136,000 个方法的庞大代码库来说,这是一项了不起的成就。
然而,当我们检查其核心模块之间的结构关系时,依赖结构矩阵(DSM)呈现出一幅复杂得多的画面,揭示出广泛存在的依赖循环:
在使用 CppDepend 评估 C++ 软件架构时,依赖结构矩阵(DSM)为依赖健康状况提供了不可否认的证据。在一个结构良好、层次分明的系统中,依赖关系单向流动——形成一个干净的三角矩阵,单元格只分布在主对角线的一侧。
然而,检查 Blender 核心引擎的矩阵,会发现围绕 bf_blenkernel 出现了一个引人注目的视觉模式。
一个由世界级工程师构建的项目,为何会出现如此多的架构循环?
根本原因并非开发者的疏忽。罪魁祸首是 C 和 C++ 遗留的预处理器模型:#include 指令。
几十年来,现代编程语言一直使用显式模块系统、命名空间和显式导出控制来管理依赖图。然而,C 和 C++ 继承了 20 世纪 70 年代预处理器的文本替换模型:#include 指令。
#include 指令最大的优势也正是其最危险的缺陷:极致的简单与灵活。文本替换让开发者可以在代码库的任何位置快速引入依赖,而无需严格的编译期强制约束,但这种不受限制的自由随着项目规模的扩大很快变成陷阱。在没有严格架构把关的情况下,#include 让引入微妙的依赖循环变得轻而易举,这些循环会悄无声息地将各个模块编织成一个紧耦合的整体。在大型 C++ 项目中,这些循环头文件链接会随时间累积成巨大的技术债务——使构建时间呈指数级增长,严重削弱单元测试能力,并让重构变成雷区。因此,维护和演进这样的代码库不再是常规的工程任务,而是需要资深开发者持续警惕地介入,在编译器无法做到的地方手动强制维护结构边界。
文本式包含的结构性缺陷
要理解 #include 为何损害架构,需要考察它与类定义和模块化的交互方式。
1. 包含具有传递性且会发生泄漏
当 FileA.h 包含 FileB.h,而 FileB.h 又包含 FileC.h 时,FileA.h 就间接依赖 FileC.h。FileB 的内部细节泄漏到了 FileA。随着时间推移,开发者会逐渐搞不清楚一个模块到底依赖什么,从而形成一张隐式的、隐藏的依赖网络。
2. 物理结构决定逻辑架构
在干净的设计中,接口边界决定依赖关系。而使用 #include 时,物理文件组织方式会强制架构决策。如果类 A 只需要 B.h 中定义的一个枚举,A 就必须包含 B.h,并随之引入 B.h 所依赖的所有其他类型、指针和模板头文件。
3. 物理需求强制产生循环依赖
由于 C++ 需要完整的类型定义来确定布局大小,开发者经常在头文件中放置 #include 指令,而本可以使用前向声明(class X;)。一旦两个头文件需要彼此的完整定义,预处理器就会制造循环包含,导致类型缺失错误、头文件保护技巧或脆弱的头文件顺序。
深入剖析:bf_blenkernel ↔ bf_bmesh 循环
Blender 中存在一个典型例子,展示了 #include 机制如何制造结构性的依赖循环:核心内核模块(bf_blenkernel)与网格编辑系统(bf_bmesh)之间的循环。
在干净的分层架构中,bf_bmesh(较高层的交互式编辑系统)应该依赖 bf_blenkernel(较低层的数据结构和数学内核)。然而,由于 #include 指令使得跨越边界变得轻而易举且缺乏结构把关,bf_blenkernel 直接引用了 BMesh 数据结构。
bf_bmesh 的调用图展示了一个非常干净的分层架构——依赖项(被使用的模块)以蓝色标注,被依赖项(使用它的模块)以绿色标注——唯一的显著例外是与 bf_blenkernel 之间的双向循环。
为了隔离内核从网格编辑模块消费的确切类型和方法,我们可以执行以下 CQLinq 查询:
以 bf_blenkernel(armature.cc)中这个函数使用的 BMesh 结构体为例:
为什么会形成架构循环
- 直接使用具体类型:内核函数调用
BKE_editmesh_bmesh_get(...)获取const BMesh *bm指针,并直接访问bm->vdata。 - 强制头文件包含:为了访问内部成员
bm->vdata,内核文件必须包含"bmesh.h"(或"bmesh_class.h")。 - 反向依赖:与此同时,
bf_bmesh的头文件和源文件也包含内核头文件(BKE_*.h),用于基本数据类型(Object、ID、CustomData)、内存管理和数学辅助函数。
这就形成了一个硬性双向依赖循环。bf_blenkernel 无法独立于 bf_bmesh 进行编译、单元测试或复用。
如何重构并打破循环
要打破这个循环并强制实现干净的单向层次结构(bf_bmesh → bf_blenkernel),必须将内核对 BMesh 的依赖进行反转或抽象化。
方案一:不透明抽象 / CustomData 偏移量提取
注意,BKE_armature_deform_coords_with_editmesh 访问 bm->vdata 只是为了提取一个 int 偏移量:cd_dvert_offset。该包装函数内部并不执行实际的网格拓扑操作。
不在 blenkernel 中传递或获取 BMesh*,而是将 cd_dvert_offset 直接传入内核函数,或使用函数指针 / 回调抽象:
bf_bmesh 中的调用方(在这里同时包含 bmesh.h 和 BKE_armature.h 在架构上是合理的)提取 cd_dvert_offset 并调用内核。bf_blenkernel 不再需要知道 BMesh 的存在。
方案二:通过回调或委托实现依赖倒置
如果 bf_blenkernel 在复杂操作中需要动态访问 BMesh 数据,可在 blenkernel 中定义抽象接口或函数委托:
// 在 BKE_armature.h 中(bf_blenkernel)
using BMeshOffsetGetter = std::function<int(const Object &ob)>;
// BMesh 逻辑由更高层注入,
// blenkernel 无需知道具体的 BMesh 布局
高层架构严谨性:GRASP 模式与高内聚
尽管 C++ 预处理器机制引入了头文件层面的依赖循环,Blender 的底层代码设计依然非常出色。深入观察其类层次结构和模块组织,可以发现它严格遵循了 GRASP(通用职责分配软件模式)和现代领域驱动设计原则:
多态与抽象:Blender 大量使用抽象接口类(bContext、空间类型定义和操作器抽象),将高层工作流与实现细节解耦。
高内聚:各个模块保持着鲜明的领域焦点——bf_bmesh 处理底层网格拓扑,bf_nodes 管理执行图,bf_gpu 隔离硬件抽象。这些模块内部的数据结构展现出高度的功能内聚性。实际上,被认为内聚性不足的类型不到 3%:
受保护的变化(Protected Variation):核心子系统通过定义良好的内部 API 将自身与底层平台和硬件的变化隔离开来,保持底层图形和操作系统抽象的整洁。
模块间循环依赖的存在并不是设计草率的征兆,而是使用文本式 #include 指令扩展数百万行 C/C++ 代码库时不可避免的副作用。没有语言层面的模块边界,即使是高内聚、接口驱动的架构最终也会屈服于传递性的头文件泄漏。
给 C++ 开发者的架构启示
#include 机制教给我们一个持久的教训:当编译器不强制架构边界时,熵就会获胜。
为了在自己的项目中减轻 #include 造成的架构损害:
- 优先使用前向声明:在头文件中,除非需要继承或直接存储值实例,否则始终优先使用
class MyClass;而不是#include "MyClass.h"。 - 强制分层规则:使用静态分析工具(如带有 CQLinq 的 CppDepend)设置质量门禁,当底层模块包含高层头文件时让构建失败。
- 迁移到 C++20 模块:在可能的情况下,用
import替换#include——它强制显式导出,避免宏泄漏,并彻底消除文本包含循环。
