C++ UML类图六种关系详解:依赖、关联、聚合、组合、泛化、实现

C++ UML类图六种关系详解:依赖、关联、聚合、组合、泛化、实现 写了七八年 C我发现一个挺有意思的现象能在面试里把虚函数表、对象内存布局讲明白的人一抓一大把但让他画一张 UML 类图把依赖、关联、聚合、组合这几个箭头摆到正确的位置上很多人就开始犹豫笔尖在纸上绕圈。这不是记性问题而是大多数人学 C 的时候是按语法点学的——类、继承、多态、模板一个一个啃过去很少停下来问一句这两个类之间到底是什么关系就是这么一个看起来偏文档的话题在实际项目里杀伤力其实很大。你把两个本该是组合关系的类画成了聚合接手的人就会以为被包含的对象可以独立存在、可以外部复用于是他在别处 new 了一个实例塞进去程序照跑不误但资源的释放时机就悄悄错位了。反过来把关联误判成组合本来可以共享的对象被迫各自持有一份拷贝内存直接翻倍。这篇东西面向的是正在学 C、准备做课程设计或毕业设计、或者第一次参与中型项目的朋友也适合那些写了几年代码但一直没系统梳理过类关系的人。我会把依赖、关联、聚合、组合、泛化继承、实现这六种关系一个个拆开讲清楚它们在代码里长什么样、在 UML 类图里怎么画、箭头该往哪指、以及判断的时候依据什么标准。中间会穿插大量可直接抄的代码片段和一张完整案例类图最后把我在真实项目里踩过的坑整理成一张速查表。1. 先把这六种关系在心里排个队1.1 为什么大家总把依赖和关联搞混道理其实不复杂。C 里表达我认识你的方式太多了你可以把一个类当函数参数也可以在成员里塞一根指针还可以直接嵌一个对象进去。语言层面根本不管你叫它依赖还是关联编译器只认语法。于是判断关系这件事就落到了人头上而人一旦缺少统一的尺子就会凭感觉走。感觉最不靠谱的地方在于同一段代码用不同视角看会得出不同结论。比如void print(const Logger log)有人说是依赖有人说是关联因为参数传进来的东西确实被用到了。这种争论没有意义关键是你得先定义清楚用什么维度去切分这六种关系。我个人的做法是只认一个维度生命周期归属。谁负责创建、谁负责销毁、谁离不开谁这三个问题的答案基本就决定了这段关系该归到哪一类。语法形态只是表象生命周期才是本质。1.2 一把叫生命周期归属的筛子把筛子想象成三个格子。第一个格子问这个对象是我创建、我销毁它离开我就活不了吗答案是是那基本就是组合。第二个格子问这个对象是我持有的但它的创建和销毁由外部决定我只是用用而已吗答案是是那大概率是聚合也可能是关联取决于它是不是我整体的一部分。第三个格子问我压根没存它只是在某个方法执行期间短暂地用了一下吗答案是是那就是依赖最弱的那种。拿生活里的东西类比一下。人跟心脏是组合关系——人没了这颗心脏作为这个人的心脏也就不存在了。人跟手机是聚合关系——手机可以换、可以送给别人人没了手机还能继续用。人跟租赁公司是关联关系——你得记着有这回事但对方不归你管。人跟路边问路的路人是依赖关系——问完就走谁也不欠谁的。这套类比听着土但在评审会上特别管用。当两个人对着一张类图吵起来你只要问一句甲方没了之后乙方还能独立存在吗争论基本当场结束。1.3 六种关系速查对照在展开细节之前先给一张总览表。这张表建议截图存着看代码或者画图卡住的时候掏出来对一眼。关系类型代码里的典型形态UML 符号箭头/线方向耦合强度依赖函数参数、局部变量、返回值、静态调用虚线 开放箭头使用者指向被使用者最弱关联指针或引用成员变量实线可带开放箭头持有方指向被持有方中聚合指针成员 外部注入的容器空心菱形 实线菱形在整体一侧中偏强组合值成员或独占智能指针成员实心菱形 实线菱形在整体一侧强泛化class Derived : public Base空心三角 实线子类指向父类强实现继承纯虚接口类空心三角 虚线实现类指向接口强表格里有个细节值得单独强调聚合和组合的菱形永远画在整体那一侧也就是容器那一端而不是被包含的那一端。这和泛化的三角画法正好相反泛化的三角指向父类也就是指向更一般的那一方。我见过不少人的类图就是栽在箭头方向上图和代码对不上评审的时候又讲不出所以然。至于耦合强度那一列它决定了你重构时候的优先级。依赖可以随时改代价极小组合一旦定死想解耦就得动类结构成本高得多。所以设计阶段真正需要反复推敲的其实是聚合和组合的边界——这两个选错后期改起来最肉疼。2. 依赖最弱的关系也是最容易写错的关系2.1 依赖在代码里长什么样依赖的核心特征就一句对方只在我的方法执行期间存在我没有任何成员变量存着它。看一段常见代码。class TaxRule; class Order { public: // 参数形式最典型的依赖 double calcTotal(const TaxRule rule) const; // 局部变量形式也是依赖 void dump() const { DebugLogger logger; logger.write(order dumped); } // 返回值形式依然是依赖 std::string toJson() const; private: std::vectorItem items_; };这三种用法参数、局部变量、返回值在 UML 里都算依赖。除此之外还有几种容易被漏掉的在头文件里用了static方法、在模板实参里提到对方、甚至sizeof(SomeType)这种编译期依赖严格来说都算。注意上面例子里Order并没有保存TaxRule的任何副本或指针税率是算的时候传进来的。这就是依赖的判定标准——用完即走不留痕迹。有人会问那我在成员函数内部写了new TaxRule()然后delete算不算依赖算但这是很糟糕的依赖因为它把创建逻辑硬编码进了使用方。正确的做法是通过参数注入或者工厂把创建职责挪出去。这一点在单元测试里尤其明显如果你的类在内部偷偷 new 了某个具体类型你就没法用 mock 替换它。2.2 依赖的 UML 画法与常见误用依赖的 UML 符号是虚线加一个开放箭头就是一个简单的 V 形箭头从使用方指向被使用方。写出来是这样startuml class Order class TaxRule class DebugLogger Order .. TaxRule Order .. DebugLogger enduml画依赖的时候有几个常见的误用。第一个误用是滥用依赖箭头。有些人的类图里所有能被看到的调用关系全用虚线连上一张图上几十条虚线看的人完全抓不住重点。类图不是代码覆盖率的可视化它服务于沟通。我的建议是只有那些在设计层面真正关心、后续可能演化的依赖才画出来纯粹的日志工具、格式化工具这类基础设施依赖完全可以省略。第二个误用是依赖箭头方向反了。记住原则谁使用谁指出去。Order用了TaxRule所以是Order指向TaxRule。这个方向背后的含义是变化的传播方向——TaxRule的接口一旦改动Order就得跟着改而反过来不一定。第三个误用是把本该是关联的关系画成了依赖。如果你发现某个对象被反复传来传去几乎每个方法都要带它当参数那就说明它是这个类的长期协作者应该提升为成员变量也就是关联。这种参数满天飞的代码味道是重构里很典型的一个信号。2.3 什么时候要警惕依赖依赖本身不是坏事它是模块之间最低成本的协作方式。真正需要警惕的是依赖数量失控。一个类如果依赖于几十个其他类它的#include会变成一团乱麻编译时间直线上升改动任何一处都容易触发大范围重编。我做过一个统计某个模块的头文件平均包含二十多个其他头文件全量重编一次要七八分钟改一行代码等一分钟才出结果开发体验差到极点。解决思路有两种。一是前向声明 指针/引用把#include换成class Foo;只在.cpp里包含真正的头文件能砍掉大量编译期依赖。二是接口隔离把一个大而全的参数对象拆成几个小的让使用方只依赖它真正需要的那部分。还有一个实际场景值得提如果你在头文件里为了声明一个函数参数而包含了某个模板库的巨型头文件那编译时间基本就没救了。这种情况下用前向声明配合引用传参能省下相当可观的时间。这个技巧我在好几个项目里都用过效果立竿见影。3. 关联持有但不拥有3.1 成员变量就是关联的落地形式关联和依赖的分界线很清晰关联会留下成员变量。看下面这段。class Customer; class Order { public: Order(Customer* c) : customer_(c) {} const Customer* customer() const { return customer_; } private: Customer* customer_; // 这就是关联 std::vectorItem items_; };Order里存了一根Customer*说明订单和客户之间是一种持续性的关系而不是一次性的调用。用 UML 表达就是一条实线箭头从Order指向Customer单向关联或者两边都不带箭头双向关联在 C 里对应互持指针。这里有个技术细节必须讲清楚C 中一般用指针实现关联而不是引用。原因有两个。第一引用必须在构造函数的初始化列表里绑定而且绑定后不能改这在你需要更换关联对象的场景下会非常难受。第二引用成员会导致类不可赋值拷贝赋值运算符被隐式删除如果你还需要这个类能被放进容器或者做赋值操作用引用成员基本就把路堵死了。std::reference_wrapper是个折中方案它可赋值、可放进容器语义上也表达了必须有值。但它用起来稍显啰嗦我一般只在明确知道对象生命周期绝对安全的情况下才用。3.2 单向、双向和多重性关联还有两个维度的描述方向和数量。方向这块单向关联只在一端有箭头表示我知道你你不知道我。双向关联两端都不画箭头或者两端都画表示互相持有。双向关联在 C 里通常意味着两个类互相包含对方的指针这就要用前向声明来解决循环包含问题。循环依赖是双向关联最直接的代价。两个头文件互相#include编译直接报错改成前向声明能编过但两个类的实现细节就彻底绑在一起了谁也没法单独复用。我在项目里的做法是能单向就绝不双向实在需要回指的让被引用方通过参数或者回调拿到的信息而不是长期持有指针。数量这块UML 用多重性Multiplicity来标注1表示恰好一个0..1表示可选*或者0..*表示零到多个1..*表示至少一个。这些标注直接对应 C 里的类型选择。UML 多重性C 类型选择说明1T*或T必须存在构造时注入0..1T*可能为 nullptr或std::optionalstd::reference_wrapperT可选关联0..*std::vectorT*容器持指针生命周期外部管理1..*std::vectorT* 构造时校验非空至少要有一个把这张表记住画类图的时候顺手标上多重性读图的人立刻就知道哪些地方需要做空指针检查、哪些地方需要校验容器非空。这是我从别人写的类图里学到的最实用的一招比任何注释都管用。3.3 指针关联与引用关联的取舍前面说了一般用指针但有些场景引用确实更合适。判断标准是这个关联对象是否在整个生命周期内都不会变、且一定存在。如果答案是肯定的比如一个Transaction必然属于某一个Account而且绝不允许改挂到别的账户上那用引用成员表达这种约束是合理的构造函数注入编译器帮你保证非空。代价是失去了赋值能力需要用std::reference_wrapper或者干脆用指针加const来变通。如果关联对象可能被替换、可能为空、可能延迟设置那就老老实实用指针并且在构造和每个使用点上都把空指针的可能性想清楚。这里有个我踩过的坑早期写代码的时候我用裸指针做关联默认它非空结果在一次业务调整中某个上游模块开始传nullptr进来程序在几个月后的一次线上操作里崩了查了两天才定位到。从那以后我在构造函数里对关联指针一律加断言宁可在测试环境早点炸也不要在生产环境里埋雷。4. 聚合与组合空心菱形和实心菱形这两个是最容易搞混的一对因为它们长得很像——都是整体和部分的关系都用菱形表示代码形态也都可能是成员变量。区别只有一条部分的生死是否绑定于整体。4.1 聚合可以独立存在先看聚合的代码。class Employee; class Department { public: void add(Employee* e) { members_.push_back(e); } private: std::vectorEmployee* members_; // 聚合 };Department拥有一个员工列表但员工本身不是部门生出来的。员工可以从一个部门调走调到另一个部门甚至两个部门共享一个顾问。对象的创建和销毁都由外部负责部门只负责组织它们。UML 里聚合画成空心菱形加实线菱形贴着整体这一端也就是Department一侧。读法是部门由员工聚合而成。聚合在代码上的典型特征是成员以指针或引用的形式存放容器本身不负责被包含对象的析构。上面这个例子里Department析构时vector被销毁但Employee对象依然活得好好的——它们可能是栈上的对象也可能是别的容器管理着。这个特性带来一个很现实的风险悬垂指针。如果Employee对象提前被销毁了而Department里还存着它的地址下一次访问就是未定义行为。我处理这类问题的方式有两种一种是明确约定拓扑顺序谁先创建谁后销毁写进文档另一种是引入std::weak_ptr在访问前lock()检查对象是否还活着。后者有一点性能开销但对生命周期难以保证的场景非常好用。4.2 组合同生共死组合的语义就强硬多了。class Engine; class Car { private: Engine engine_; // 值成员强组合 std::unique_ptrGearbox gearbox_; // 独占智能指针也是组合 };Car里直接嵌了一个Engine对象这个发动机的生命完全依附于这辆车。车报废了发动机作为这台车的发动机也就不存在了。std::unique_ptr那一段同样是组合虽然用堆分配但所有权是独占的Car析构时Gearbox一定跟着被销毁。组合用实心菱形表示菱形同样贴在整体一侧。语义上它意味着部分不能脱离整体单独存在也不允许被共享。在 C 里实现组合首选是值成员因为它零额外开销、缓存友好、析构自动完成。只有当对象很大、需要多态、或者需要延迟构造时才考虑std::unique_ptr。裸指针new/delete的组合我是完全不推荐的异常安全一塌糊涂。这里插一个实践细节值成员组合会带来一个问题——Car的头文件必须包含Engine的完整定义不能只做前向声明。这意味着Engine的头文件一改所有包含Car的编译单元都得重编。如果Engine是个经常变动的类编译时间会很难看。这时候改用unique_ptr加前向声明就能把依赖切断代价是多一次堆分配和一次指针间接访问。这个取舍在设计阶段就要想清楚不要等到编译时间爆炸了再回头改。4.3 判断清单这个部分到底能不能独立存在我给自己总结了一套五问清单遇到拿不准的情况就挨个问一遍。第一问这个对象是不是在整体之外被别的地方引用过如果是那就不是组合因为组合意味着独占所有权。第二问整体销毁之后这个对象还能不能有意义比如订单里的收货地址在订单删除后地址实体本身依然有存在的价值因为这个用户可能还有别的订单那它更接近聚合或者关联。第三问这个对象是不是在构造整体的时候一定会被创建组合通常满足这一点聚合往往是通过 setter 或者 add 方法后加进来的。第四问两个对象的生命周期是否在代码上有物理上的绑定值成员和unique_ptr成员是强绑定的信号。第五问从领域模型上看它在概念上是不是整体的组成部分发动机是汽车的组成部分这句话成立但员工是部门的组成部分这句话在中文里成立、在领域语义上其实有点勉强员工更接近归属而非构成。五问下来基本就有答案了。还要提醒一句聚合和组合的界限在很多时候是模糊的不同团队有不同约定。重要的不是判定结果绝对正确而是团队内保持一致并且在图旁边用文字把假设写清楚。我见过因为这个问题争论半天的会最后发现两个人对存在的理解根本不是一回事。5. 泛化与实现继承体系的两种箭头5.1 泛化继承空心三角加实线泛化就是我们平时说的继承。class Shape { public: virtual ~Shape() default; virtual double area() const 0; virtual void draw() const 0; protected: Point origin_; }; class Circle : public Shape { public: double area() const override; void draw() const override; private: double radius_; };UML 里泛化画成一条实线加空心三角形三角形指向父类。所以是Circle指向Shape。这个方向和我们说Circle 继承自 Shape是一致的——箭头指着被继承的那一方。判断一个继承到底该画成泛化还是实现有个简单标准父类有没有实际的状态和实现。Shape里有一个origin_成员也有析构函数的实现那Circle和Shape之间就是泛化。如果父类里全是纯虚函数、没有任何数据成员那关系更偏向实现按理说应该画虚线三角。不过 C 没有像 Java 那样独立的interface关键字抽象基类同时承担了接口和可复用基类两种角色所以在实际类图里泛化和实现经常混用。我的习惯是纯接口无状态、全纯虚用虚线实现箭头带状态的基类用实线泛化箭头这样读图的人一眼就能区分出这是抽象契约还是这是有实现继承的基类。5.2 实现空心三角加虚线实现关系对应的就是继承一个纯虚接口类。class IUserRepository { public: virtual ~IUserRepository() default; virtual std::optionalUser findById(int id) const 0; virtual void save(const User u) 0; }; class SqlUserRepository : public IUserRepository { public: std::optionalUser findById(int id) const override; void save(const User u) override; };UML 里SqlUserRepository到IUserRepository画一条虚线加空心三角三角指向IUserRepository。实现关系的价值在于它把依赖抽象这件事图形化了。当你的类图上一堆箭头都指向接口类而不是具体类的时候说明依赖倒置原则落到位了反过来如果所有箭头都指向具体实现那这个设计基本没法做单元测试。这里有个容易被忽视的细节接口类的析构函数必须是虚的而且最好带默认实现。virtual ~IUserRepository() default;这一行看起来多余但如果你用unique_ptrIUserRepository管理对象缺少虚析构就会导致派生类的析构函数不被调用这是实打实的资源泄漏。我审过好几份代码问题都出在这一行上。5.3 继承带来的坑虚析构、对象切片与菱形继承继承是六种关系里耦合最强的一种代价也最大有三类坑必须提前知道。第一类虚析构的缺失。上面已经提过只要是准备通过基类指针删除的对象基类析构函数就必须是虚的。这条规则没有例外忘了就是内存泄漏或者未定义行为。第二类对象切片。当你按值传递一个派生类对象给基类参数时派生类特有的部分会被切掉。void render(Shape s); // 按值传参危险 Circle c; render(c); // c 被切片成 Shaperadius_ 丢失这个问题编译器不会报错运行时行为诡异是新手最容易掉进去的坑之一。解决办法很简单多态场景一律按引用或指针传递写成void render(const Shape s)。同时把基类的拷贝构造函数保护起来或者删除能从编译期挡住一部分误用。第三类多重继承与菱形继承。如果一个类同时继承两个有共同基类的类就会出现两份基类子对象。C 提供了虚继承来解决但虚继承会带来对象布局复杂、构造顺序特殊、性能开销等一连串问题。class Animal { public: virtual ~Animal() default; }; class Swimmer : virtual public Animal {}; class Flyer : virtual public Animal {}; class Duck : public Swimmer, public Flyer {};我个人的建议是多重继承只用来实现多个纯接口绝不用于继承多个有状态的基类。这条规则我在团队里推了好几年效果很好几乎消灭了所有跟继承相关的诡异问题。接口继承是安全且有用的状态继承是麻烦的源头。6. 画一张真正能用的 UML 类图6.1 工具选型Visio、PlantUML 和 IDE 插件怎么挑工具这件事我的观点是别纠结能出图就行但不同场景确实有不同选择。Visio 是最传统的选择。它有现成的 UML 类图模板左侧形状栏里能找到UML 类模具拖一个类形状到画布上右键可以添加属性、操作、可见性标记双击能直接编辑。关系连接线也是现成的形状需要哪一种关系就拖哪一条。Visio 的优势是图形精细、适合放进正式文档和答辩 PPT缺点是纯手工劳动代码一改图就过时维护成本高。如果你的类图只需要画一次、提交给导师或者评审Visio 完全够用。PlantUML 走的是文本描述路线用几行代码就能生成一张图。它的最大好处是可以进版本控制跟着代码一起演进改一个字的成本几乎为零。startuml class Order { - items : vectorItem calcTotal(rule : TaxRule) : double } class Customer class Department class Employee class Car class Engine class Shape { abstract } class Circle class IUserRepository { interface } class SqlUserRepository Order -- Customer : 下单人 Department o-- Employee : 成员 Car *-- Engine : 包含 Circle --| Shape : 泛化 SqlUserRepository ..| IUserRepository : 实现 Order .. Shape : 依赖 enduml这十几行描述出来的图比 Visio 里画十分钟还清楚而且改起来不用重新拖框。我现在的项目文档基本都是 PlantUML 写的评审的时候直接贴图代码变了图同步变。IDE 插件这条路也值得一试。很多 IDE 能从代码自动反向生成类图改完代码重新生成一次就行省掉了手工同步的环节。缺点是自动生成的图往往把所有关系都画出来信息密度过大需要手工裁剪。我一般用它来做现状分析看清楚代码里真实存在的关系然后再手工画一份设计意图版本两者对照着看经常能发现实现和设计已经脱节的地方。6.2 一个完整案例停车场系统的类图设计光讲概念太空我们用一个停车场管理的简化场景走一遍完整流程。需求大概是这样停车场里有若干车位车位分为普通车位和充电车位车辆入场时分配一个车位出场时释放系统要能计算停车费用费用规则可能变化每笔停车记录要保存下来方便后续查询。按照前面讲的思路先找出候选类。ParkingLot停车场、ParkingSpot车位进一步派生RegularSpot和ChargingSpot、Vehicle车辆派生Car和Truck、ParkingTicket停车记录、FeeCalculator费用计算接口、DefaultFeeCalculator默认实现、TicketRepository记录存储接口。接下来逐个确定关系。ParkingLot和ParkingSpot之间是聚合。车位可以在停车场系统之外被创建和维护停车场只是组织它们、分配它们。用std::vectorstd::unique_ptrParkingSpot持有但所有权在这个场景里其实是停车场承担的所以严格说这里有点偏组合。我倾向于这样处理如果车位对象只能在停车场内部创建、且停车场销毁后车位也没意义了那就是组合如果车位是外部注入的比如从配置文件读取后传进来那就是聚合。这个例子里我选组合因为车位是停车场的一部分。ParkingTicket和Vehicle之间是关联。一张停车票记着对应的车牌或车辆引用但车辆对象本身由别的地方管理。ParkingTicket和ParkingSpot之间也是关联记录它停在哪个位置。ParkingLot和FeeCalculator之间是关联——停车场持有一个计算器的引用具体实现可以替换所以用指针。DefaultFeeCalculator和FeeCalculator之间是实现关系接口纯粹、没有状态。Car和Vehicle之间是泛化。ParkingLot在处理费用的时候需要用到ParkingTicket的数据但如果它只是把票传进去、没有长期持有票对象那也可以理解为依赖。这里我为了简化让它直接持有当前的活跃票列表算关联。关系梳理完画出来的图大概是这个结构中心是ParkingLot它连出去四条线——实心菱形连ParkingSpot、实线连FeeCalculator、实线连TicketRepository、实线连活跃的ParkingTicket集合。ParkingSpot和Vehicle分别用泛化三角连到各自的抽象基类。两条实现虚线从具体类指向两个接口。6.3 图与代码互相印证的关键点画完图别急着交一定要回头对一遍代码。我总结的核对清单有五条。第一条每个成员变量都能在图上找到对应的线。如果代码里有一个指针成员图上却没有连出去要么是图漏了要么是这个成员不该存在。第二条每条组合/聚合的菱形的方向都对。菱形在整体一侧这一点我在评审里见过太多次错误值得单独检查一遍。第三条多重性和容器类型一致。图上标了1代码里却是vector这就是矛盾得改。第四条接口类的析构函数是虚的。这条不在图上体现但属于画图时顺手能查出来的问题。第五条没有双向关联。如果图上有双向线问自己一句能不能拆成单向加回调十有八九能拆掉。这套核对做完类图的可用性基本就有保障了。我见过太多类图只存在于答辩 PPT 里代码和它毫无关系那种图还不如不画。7. 常见问题与排查技巧实录7.1 常见疑问速查表下面这张表是我在带新人和做代码评审时被问得最多的问题整理在一起方便对照。疑问判断依据建议做法参数传对象是依赖还是关联看有没有存进成员变量只当参数用就是依赖指针成员是聚合还是关联看被指对象是不是整体的组成部分是组成部分就是聚合否则是关联值成员一定是组合吗基本是但要确认对象是否被外部引用被外部持有就不能算组合unique_ptr是组合还是聚合独占所有权通常意味着组合语义上不构成部分时按关联处理继承抽象类算泛化还是实现看基类有没有状态和实现有状态用实线纯接口用虚线双向关联能不能用会带来循环依赖和耦合优先单向需要回指用回调多个接口继承算多重继承吗接口无状态风险低接口多重继承可以放心用这张表里最需要记住的是第二行和第三行。聚合和关联的界限以及值成员和组合的对应关系是实际项目里争议最集中的地方。判断的时候不要纠结于标准答案先问清楚这段关系的设计意图意图清楚了自己就浮出来了。7.2 我踩过的几个真实的坑第一个坑是关联对象的生命周期没管住。早期做过一个模块Session类里持有一个User*构造函数注入。上线之后偶发崩溃查了很久才发现某个调用方在Session还活着的时候就把User对象提前销毁了。这类问题用裸指针根本防不住。后来的做法是构造函数里加断言、关键路径上改用weak_ptr并且在文档里明确写出拓扑顺序。教训就是关联关系的双方生命周期必须有明确的约定写在代码里或者写在文档里绝不能靠默契。第二个坑是把组合写成了聚合导致的内存泄漏。有一段代码用vectorFoo*存了一批对象创建的时候new但析构的时候容器只销毁了指针、没销毁对象。跑短任务的时候完全看不出来跑长驻服务的时候内存曲线一路往上爬。后来改成vectorunique_ptrFoo之后问题消失而且代码反而更短了。能用值成员就用值成员能用unique_ptr就别用裸指针这条现在是我写代码的铁律。第三个坑是依赖膨胀导致的编译时间失控。有个模块的头文件里包含了十几个其他头文件改一行要等将近两分钟。清理的办法是逐个替换成前向声明能移到.cpp的#include全部移过去最后编译时间降到了二十多秒。这个过程不复杂但很枯燥需要一个个试探哪些能前向声明、哪些必须完整定义。判断标准是只要不用到类型的完整定义不调用成员、不做sizeof、不继承就可以前向声明。第四个坑是接口类忘了虚析构。有个ITask接口被十几个实现类继承代码用unique_ptrITask管理。运行几个月后内存缓慢增长最后定位到这个接口的析构函数不是虚的。加了一行virtual ~ITask() default;之后问题立刻消失。这件事之后我养成了一个习惯写任何带虚函数的基类第一件事就是把虚析构的声明补上其他的再说。最后一个体会是关于类图的定位。我一开始把类图当成交付物觉得画得越全越好后来发现完全不是这么回事。类图的真正价值在于沟通和推演——它逼你把谁拥有谁谁依赖于谁这些问题显式地回答一遍而这些问题在写代码的时候往往是被跳过的。一张只画了五个类但每个关系都推敲过的图远比一张画了五十个类、关系全靠猜的图有价值。所以现在我做设计宁可先在纸上手绘几张草图把关系吵明白了再上工具画正式的效率高得多。