C++ UML 类图六种关系:依赖、关联、聚合、组合、泛化、实现 📅 发布时间:2026/9/18 19:57:31 👁 浏览次数: 接手一个十多万行的 C 老项目我做的第一件事往往不是翻业务代码而是先找它的 UML 类图。翻出来一看六个箭头的画法基本全错明明是组合被画成普通关联明明只是函数里用了一下硬是画成聚合泛化箭头和实现箭头混着用。当时觉得这只是文档问题等真动手重构这些画错的线一条条都变成了真实的坑——本该由外层统一管理的对象被内层偷偷 delete 了本该松耦合的两个模块因为一个私有成员指针缠死在了一起想给某个类换个序列化实现结果发现整条继承链都得跟着动。类与类之间的关系看着像是 UML 教材第一页的内容但真要在 C 里落地依赖、关联、聚合、组合、泛化、实现这六种关系每一种背后都对应着不同的内存布局、不同的生命周期责任以及不同的编译期代价。画错一条线代码不一定立刻报错但它会在某次重构、某次内存泄漏排查、某次编译时间从 30 秒涨到 5 分钟的时候一次性把账都算给你。这篇内容就是把这些年我在实际项目里对六种关系的理解整理出来每一种关系在 C 代码里长什么样、UML 类图上该怎么画、判定的时候看哪个特征最准以及新手最容易踩的坑。不管你是刚学完类与对象、正准备做课程设计的学生还是已经写了几年 C 但一直靠感觉写代码的工程师都能从里面找到能直接抄的判定标准和代码骨架。1. 六种关系先排个序耦合强弱这条线必须先捋直很多讲 UML 的资料喜欢把六种关系平铺开讲一条一条介绍完就算完事。但真正有用的是把它们排成一条从弱到强的光谱因为你在做设计决策的时候问的从来不是这算什么关系而是我该用多紧的耦合。顺序搞清楚了选型基本就有了答案。1.1 从一次代码评审翻车说起我在一次评审里见过这样一段代码一个Session类持有Connection*成员构造函数里由外部传进来析构函数里直接delete conn_;。写这段代码的同学说这是组合组合不就是整体销毁时部分也销毁吗所以我在析构里 delete 掉很合理。问题是那个Connection对象是从连接池里借出来的池子自己维护着所有连接的生命周期。Session一 delete池子里就留下了一个野指针下一次复用直接崩。更要命的是这个 bug 只在连接池启用复用的路径上触发压测了两周才冒出来。回到关系的判定上Connection能脱离Session独立存在吗能它被借出来之前就在池子里用完还回去还在池子里。那这就不是组合是聚合。聚合的整体不负责部分的生死Session只应该负责借和还而不是delete。这段代码如果当初 UML 类图上画的是空心菱形而不是实心菱形评审的时候大概率会被问一句这个菱形为什么是实的问题可能当场就暴露了。所以我一直觉得画类图这件事的价值不在于交付文档而在于它会逼你把到底谁负责谁的生死想清楚。1.2 耦合强度光谱和一份选型原则六种关系按耦合从弱到强排大致是这样的强度关系一句话概括典型代码特征改动的影响面1最弱依赖我在某个瞬间用到了你函数参数、局部变量、静态调用、返回值只影响调用点2关联我长期知道你但不管你生死成员指针或引用影响双方接口3聚合我拥有你但你可以独立活着指针容器不负责释放影响双方生命周期约定4组合我拥有你你死我也死值成员、unique_ptr成员强绑定替换成本高5最强泛化 / 实现我就是你的一种public继承改基类牵动全部派生类由此可以推出一条很实用的选型原则我基本每个项目都会跟新人强调一遍能用依赖解决的别升级成关联能用关联解决的别升级成组合能用组合解决的别升级成继承。每往上一级你换来的是一点点表达上的便利付出的是实打实的耦合成本。举个最直白的例子。一个类需要在某个函数里打一条日志你有两种写法一种是成员里放一个Logger*另一种是直接在函数里调Logger::instance().log(...)这个静态方法。功能上一模一样但前者引入了成员级别的关联类的每个对象都多了一个指针的开销构造和复制都变复杂了后者只是一个依赖改动面仅限于这一个函数。绝大多数情况下后者是更合理的选择。不过这句话也不能教条化。依赖弱但依赖同样会带来编译期耦合——你在头文件里 include 了对方的头文件对方一改你就得重编。所以下一节我们会专门聊怎么把依赖的编译期成本也压下去。2. 依赖最松的耦合也是最容易变成隐形炸弹的地方依赖关系在 UML 里是最不起眼的一条虚线箭头也最容易被忽略。但恰恰是它在大型项目里制造了绝大多数改一行头文件重编半小时的问题。理解依赖核心要抓两点运行时它有多松编译期它有多紧。2.1 依赖在 C 代码里的四种典型形态依赖的本质是临时使用一个类在某个操作的过程中需要另一个类但这个需求不跨越对象的生命周期。落到 C 代码上有四种非常典型的形态。第一种作为函数的参数class Order; class Logger; class OrderService { public: void submit(const Order order, Logger logger) { logger.log(submit order); // ... 处理订单 } };OrderService依赖Order和Logger但它没有任何成员变量持有它们submit返回之后关系就断了。这是最纯粹的依赖。第二种作为局部变量class ReportService { public: std::string buildReport() { HtmlFormatter formatter; // 局部对象出了作用域就销毁 return formatter.render(data_); } private: std::string data_; };这里ReportService依赖HtmlFormatter但HtmlFormatter的生命周期完全被限制在函数栈帧里外部完全感知不到。第三种调用对方的静态方法或者全局函数。这种情况在 C 里极常见比如数学工具类、字符串工具类调用点看起来像是没有依赖其实依赖关系明确存在而且比前两种更难解耦——因为静态调用没法通过传参替换实现。第四种作为返回值或者模板参数std::unique_ptrShape makeShape(ShapeType type); template typename Comparator void sortAll(std::vectorint v, Comparator cmp);返回值的依赖相对弱因为调用方不一定用模板参数的依赖则更强一些因为编译期就要求这个类型满足特定接口。四种形态的共同点是没有成员变量。这条是判定依赖最硬的标尺只要类里没有持有对方的成员那它就不可能是关联更不可能是聚合或组合。很多人把函数参数误判成关联就是没抓住这条。2.2 前置声明到底能省多少事什么时候省不了依赖关系最实际的收益是可以用前置声明来切断头文件包含从而把编译期耦合降下来。// order_service.h class Order; // 前置声明不必 include order.h class Logger; class OrderService { public: void submit(const Order order, Logger logger); };对应的order_service.cpp里再 include 真正需要的头文件#include order_service.h #include order.h #include logger.h void OrderService::submit(const Order order, Logger logger) { logger.log(submit order); }这样做的收益很直接order.h改了order_service.h的依赖方不需要重编只需要重编order_service.cpp。在一个有几百个源文件的项目里这个差别可能是 10 秒和三分钟的区别。但前置声明不是万能的下面这几种情况必须要完整的类型定义前置声明会直接编译失败值成员。Order order_;这种写法要求编译器知道Order的大小和布局前置声明提供的是不完整类型编译器算不出sizeof。基类。继承要求完整的类定义光有前置声明不够。内联函数体。如果成员函数的实现在头文件里内联展开而函数体里调用了Order的成员方法那必须知道完整定义。按值传参或按值返回。函数签名里如果写的是Order而不是Order或Order*调用点需要构造或析构对象同样需要完整类型。模板实例化。这个坑最深模板的实例化点往往在别的翻译单元里前置声明常常救不了只能老老实实 include。我在项目里定过一个很土但很有效的规矩头文件里能不 include 就不 include凡是能用指针或引用 前置声明的地方一律用前置声明。一开始新人会觉得别扭写了两个月之后增量编译时间的变化会让他们自己闭嘴。2.3 把依赖压到最低的三个实操手段依赖虽然是最弱的关系但在真实项目里它照样能制造麻烦尤其是下面这三种情况。我一般用三个手段来处理。第一个手段是参数传递代替成员持有。这个前面已经说过了不再展开。核心判断是如果这个对象只在某一个函数的执行期间被需要就别把它塞成成员。第二个手段是用抽象接口切断具体类型的依赖。假设OrderService需要发通知直接依赖EmailSender就意味着以后想加短信通知得改OrderService。改成依赖一个抽象接口具体用哪个实现由外部注入class INotifier { public: virtual ~INotifier() default; virtual void notify(const std::string msg) 0; }; class OrderService { public: explicit OrderService(INotifier notifier) : notifier_(notifier) {} void submit(const Order order) { notifier_.notify(order submitted); } private: INotifier notifier_; // 这里从依赖升级成了关联但换来的是可替换性 };注意这里有个取舍为了让实现可替换我把依赖升级成了关联代价是OrderService多了一个引用成员并且因为引用成员的存在OrderService的拷贝赋值会被隐式删除。这就是典型的用一点耦合换一点灵活值不值要具体看。第三个手段是把依赖收敛到一个薄薄的适配层。第三方库的依赖最容易失控因为它不受你控制。我的习惯是给每个第三方库写一层薄封装业务代码只依赖这层封装第三方升级的时候改动范围可控。这层封装本身可以用 PImpl 手法把第三方头文件从你的公开头文件里彻底赶出去。提示依赖关系的判定标尺只有一条——类里有没有持有对方的成员。没有就是依赖有就往关联那一档去看。3. 关联与聚合都持有对方但谁说了算关联和聚合的区别是六种关系里最容易混淆的一对。代码上看两者都表现为成员变量持有另一个对象UML 上看一个是普通实线一个带空心菱形。真正的差别只有一个生命周期由谁负责。3.1 关联的代码骨架与 UML 画法关联表示一种长期的、结构性的关系。Student和Course是最经典的例子一个学生选修若干门课这个关系会持续存在不是某次函数调用结束就没了。class Course; class Student { public: Student(std::string name, Course* course) : name_(std::move(name)), course_(course) {} private: std::string name_; Course* course_; // 关联长期持有但不负责 course_ 的生死 };UML 里画成实线可以带一个开放箭头表示导航方向。箭头从Student指向Course表示从学生能导航到课程如果两边都有成员指针就不画箭头或者画双向箭头。关联有几个可以标注的维度在实际画图时很有用多重性。在线的两端标注1、0..1、1..*、*等表达数量关系。学生选多门课就是Student 1 ---- * Course。导航性。单向还是双向取决于代码里是不是两边都持有指针。角色名。在线端标注成员变量的语义比如course_或者enrolledCourse。关联本身不涉及所有权所以用裸指针是合适的。很多人一看到裸指针就紧张其实在我只是知道它、不管它生死这个语义下裸指针恰恰是最准确的表达。换成shared_ptr反而会让语义变得混乱——你是在说共享所有权吗还是只是想让编译器帮你省心3.2 聚合的核心判据生命周期归谁管聚合是关联的一种特例专门用来表达整体—部分的语义但部分可以独立于整体存在。class Department; class University { public: void addDepartment(Department* dept) { departments_.push_back(dept); } void removeDepartment(Department* dept) { departments_.erase( std::remove(departments_.begin(), departments_.end(), dept), departments_.end()); } private: std::vectorDepartment* departments_; // 聚合只存不管 };判定聚合的核心问题就一句话如果整体被销毁了部分还能不能独立存在学院能离开大学独立存在吗现实里可能有点争议但在代码语义上只要这个对象是由外部创建、外部销毁的University只负责引用它那就是聚合。UML 里画成实线加空心菱形菱形画在整体那一端。这个细节很多人画反菱形指向的是拥有者不是被拥有者。写代码的时候也一样要想清楚谁是整体谁是部分。聚合有一个很实际的收益因为它不负责部分的生死所以整体和部分可以分别独立地被替换、被测试。你可以在单元测试里造一个假学院塞进去不用担心University析构的时候把它删了。3.3 引用成员埋下的三个坑有些人为了表达我一定持有某个对象会用引用作为成员变量class Reporter { public: explicit Reporter(Logger logger) : logger_(logger) {} private: Logger logger_; };语义上确实很清楚Reporter一定关联一个Logger而且不允许为空。但这么写会带来三个非常实际的麻烦。第一个坑拷贝赋值被隐式删除。引用成员一旦绑定就不能重新绑定所以编译器不会生成拷贝赋值运算符。Logger l1, l2; Reporter r1(l1), r2(l2); r1 r2; // 编译错误拷贝赋值运算符被删除这会导致这个类没法放进很多标准容器。std::vector::push_back在扩容时需要移动或者拷贝元素虽然移动构造还能用但一旦有代码路径需要赋值就直接编译不过。第二个坑对象不能默认构造。引用成员必须在初始化列表里绑定所以Reporter没法有默认构造函数。这在需要延迟初始化的场景里很别扭比如从配置文件里读到 logger 名字之后才能创建。第三个坑生命周期责任模糊。引用成员看起来像是我拥有它实际上引用并不表达所有权。如果使用者误以为Reporter会保证Logger活着在栈上构造一个临时Logger传进去出了作用域就是悬垂引用而且这个 bug 往往不会立刻崩而是在某个完全无关的地方表现异常排查起来极其费劲。我自己的实践是成员变量尽量不用引用。需要表达不可为空就用指针加断言或者干脆用值成员。引用适合做参数不适合做成员。注意用shared_ptr当成员的时候要特别小心。共享所有权在 UML 里没有直接对应的关系如果你把两个对象用shared_ptr互相持有那是循环引用引用计数永远归不了零内存泄漏跑不掉。这种情况要用weak_ptr打破环。4. 组合生命周期绑死之后代码怎么写才不出事组合是聚合的加强版整体销毁的时候部分也一起销毁。绑定越紧表达力越强但代价也越大。写组合的时候真正需要想清楚的问题是怎么在 C 里把同生共死这件事表达得既准确又不容易出内存问题。4.1 组合的三种实现方式与取舍组合在 C 里有三种常见写法各有各的适用场景。第一种值成员也是最推荐的一种class Engine { public: void start() { running_ true; } bool running() const { return running_; } private: bool running_ false; }; class Car { public: void start() { engine_.start(); } private: Engine engine_; // 值成员最直接、最安全的组合 };值成员的好处是零额外开销、没有内存管理负担、拷贝语义自动正确、析构顺序确定。只要Engine是个完整类型并且允许拷贝或者Car不需要拷贝这就是首选。缺点是Car的头文件必须 includeengine.h编译期耦合更强而且Car的大小会包含Engine有时候会比较大。第二种unique_ptr成员class Car { public: Car() : gearbox_(std::make_uniqueGearbox()) {} void shift(int gear) { gearbox_-shift(gear); } private: std::unique_ptrGearbox gearbox_; // 独占所有权仍然是组合 };unique_ptr表达的是独占所有权语义上完全对应组合。它的优势是可以前置声明Gearbox降低编译期耦合支持多态可以指向派生类对象大小只有一个指针。代价是每次访问要多一次间接寻址还有一次堆分配。第三种明确在析构函数里 delete 裸指针。这种写法我基本不推荐除非有非常特殊的原因比如老代码遗留、需要和 C 接口对接。因为它违反了 RAII异常路径上容易泄漏拷贝的时候需要自己实现深拷贝一不小心就是 double free。三种方式的选择顺序我一般是这么走的优先值成员需要多态或者需要降低编译期依赖就换unique_ptr裸指针只在维护老代码时被动使用。4.2 组合和聚合的判定清单这两者混淆的频率实在太高我整理过一个三问清单基本能覆盖九成场景。第一问这个部件对象是谁创建的如果是整体自己new出来的大概率是组合如果是从外部传进来的大概率是聚合。第二问这个部件对象由谁销毁如果整体析构的时候会销毁它是组合如果由外部或者别的容器负责销毁是聚合。第三问这个部件能脱离整体单独存在吗在业务语义上离不开是组合能独立存在或者同时属于多个整体是聚合。三个问题里有任意两个答案是整体管的就按组合处理有任意两个是外部管的就按聚合处理。举几个具体例子帮你校准场景判定理由汽车与发动机组合发动机拆下来汽车就不完整了通常随车报废大学与学院聚合学院可以独立存在也可以重组到别的大学订单与订单项组合订单项脱离订单没有意义订单删除时明细一起删订单与客户关联客户独立存在且被多个订单共享电脑与 CPU组合通常焊接或者整体更换由整机管理项目经理与项目聚合人可以同时参与多个项目项目结束人还在这张表不是绝对真理因为同一个业务场景在不同系统里的建模可能不同。但它能帮你快速建立一个直觉判断。4.3 组合下的拷贝、移动与析构顺序组合关系下写代码有几个容易被忽略但很致命的细节。先说析构顺序。C 里成员的析构顺序和声明顺序相反后声明的先析构。这听起来是基础知识但在组合嵌套的场景里会出问题。比如class Service { private: Logger logger_; // 先声明 Connection conn_; // 后声明先析构 };析构的时候conn_先走logger_后走。如果你在conn_的析构函数里想打一条日志logger_还活着没问题。但反过来声明conn_析构的时候logger_已经销毁了日志就写不进去了。这种 bug 表现得非常隐蔽——程序不崩只是日志少了一行等到真出故障需要日志的时候才发现关键信息缺失。再说移动语义。unique_ptr成员让类天然可移动但不可拷贝这是合理的一份独占所有权没法复制成两份。值成员的话移动是否可用取决于成员类型本身是否可移动。如果你的类里有std::mutex或者引用成员那移动构造也会被删掉这个类就彻底不能放进需要移动的容器里std::vectorService在扩容时会直接编译失败。最后说拷贝的语义难题。如果Car有值成员EngineCar的拷贝就是深拷贝两个Car各有各的Engine符合直觉。但如果Engine内部还有指针你就得自己实现拷贝构造和赋值并且遵守三法则或者五法则。这块是老生常谈但每次代码评审都能抓到新的违规。5. 泛化与实现继承是六种关系里最强的一种耦合继承在 UML 里被拆成两个概念泛化Generalization和实现Realization。它们在代码上都是public继承但语义和 UML 画法完全不同。搞混这两个类图的表达能力会大打折扣。5.1 泛化public 继承非接口类泛化表达的是 is-a 关系派生类是基类的一种。基类通常有实现成员变量和非纯虚函数派生类继承并可能扩展它。class Shape { public: virtual ~Shape() default; virtual double area() const { return 0.0; } void describe() const { std::cout area area() std::endl; } protected: std::string name_; }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) { name_ circle; } double area() const override { return 3.14159265 * r_ * r_; } private: double r_ 0.0; };UML 里画成实线加空心三角箭头箭头指向基类。注意是空心三角不是实心。实心三角在某些工具里表示别的东西容易引起歧义。泛化的使用有一条经典判据我每次评审都问派生类能不能在不需要任何额外信息的情况下替换掉基类如果不能那这个继承大概率是错的。最典型的错误用法是为了复用基类的代码而继承。比如Stack想复用Vector的实现就写class Stack : public Vector结果Stack自动获得了insert、erase这些破坏栈语义的方法这是典型的实现继承滥用。正确做法是把Vector作为成员也就是用组合。5.2 实现public 继承纯虚接口实现关系专门用来表达我遵守某个契约。基类通常是纯虚的抽象接口没有数据成员只定义行为。class IRenderer { public: virtual ~IRenderer() default; virtual void draw(const Shape shape) 0; virtual void flush() 0; }; class SvgRenderer : public IRenderer { public: void draw(const Shape shape) override { // 生成 SVG 片段 } void flush() override { // 落盘 } };UML 里画成虚线加空心三角箭头箭头指向接口。这个虚线的细节很多人画错一律画成实线导致类图分不清继承一个具体类和实现一个接口而这恰恰是最重要的设计信息之一。实现关系和泛化关系在代码上都是public继承为什么 UML 要分开因为它们的耦合强度差别很大。泛化绑定的是具体实现基类的任何改动都可能影响派生类实现绑定的是接口契约只要契约不变接口的实现可以完全换掉。在类图上一眼就能看出来这里用的是接口对后续替换实现、写单元测试、做依赖注入都有直接的指导价值。C 里写接口有两个细节要注意。一是虚析构函数必须写即使写成 default见下面一小节的说明。二是接口类要禁止拷贝因为拷贝一个抽象接口没有意义通常会把拷贝构造和赋值声明为 delete。5.3 虚析构、对象切片与替换原则的踩坑记录继承这条路有三个坑我几乎在每个项目里都见过至少一个。第一个坑基类析构函数不是虚的。class Shape { public: ~Shape() { } // 致命错误没有 virtual virtual double area() const { return 0.0; } }; Shape* p new Circle(1.0); delete p; // 未定义行为Circle 的析构不会被调用如果Circle里有std::vector或者std::string成员这块内存就永远不会释放。更隐蔽的情况是Circle持有文件句柄或者锁析构不执行意味着资源泄漏。只要一个类有可能被多态删除析构函数就必须是虚的。我个人的习惯是只要类里有虚函数析构函数一律加virtual不给自己留纠结的空间。第二个坑对象切片。void render(Shape shape); // 按值传参发生切片 void render(const Shape shape); // 正确写法按值传一个派生类对象给基类参数编译器会只拷贝基类那部分派生类的成员和虚函数表全被切掉。函数里的shape.area()会调用Shape::area()而不是Circle::area()。这个错误在代码上看不出来只有运行时结果不对才发现。防范手段很简单凡是多态用途的类函数参数一律用引用或指针同时把基类的拷贝构造删掉或者设为protected让编译器帮你拦下来。第三个坑违反替换原则。派生类不应该加强前置条件也不应该削弱后置条件。我见过一个Rectangle和Square的例子Square继承Rectangle但设置宽度的时候必须同时改高度导致Rectangle的调用方以为改宽度不影响高度逻辑就崩了。这种继承看起来符合直觉实际是错的。遇到这种情况要么把二者拆成独立的类要么让它们都从一个更抽象的接口继承。6. UML 类图落地六种关系的线型速查与画图流程前面讲了每一种关系的代码形态和判定方法这一节把 UML 侧的表达方式整理成可以直接查的表然后说清楚画图的实际流程。画图的工具选择反而没那么重要重要的是先想清楚关系。6.1 线型和箭头速查表这张表我贴在自己的笔记里好几年了评审的时候直接拿出来对关系线型端点符号方向C 判定特征依赖虚线开放箭头指向被使用者函数参数、局部变量、静态调用、返回值关联实线开放箭头可省指向被关联方成员指针或引用不管生死聚合实线空心菱形在整体端无箭头指针容器不负责释放组合实线实心菱形在整体端无箭头值成员或unique_ptr成员泛化实线空心三角指向基类public继承具体类实现虚线空心三角指向接口public继承纯虚接口最容易画错的三个地方第一泛化和实现的线型搞混一个实线一个虚线区别在于是继承具体类还是接口第二菱形画在了错误的一端记住菱形永远在整体那一侧第三把依赖画成关联区别就在于有没有成员变量。6.2 类框里的可见性、多重性和导航性怎么标一个完整的类框通常分三层类名、属性、方法。前面每行前面的符号表示可见性表示public-表示private#表示protected~表示包内可见C 里一般对应匿名命名空间或者模块内的可见性属性行的完整格式是可见性 名称: 类型 多重性比如- items: OrderItem[*]表示一个私有属性是个订单项集合。方法行的格式是可见性 名称(参数): 返回值比如 submit(order: Order): bool。多重性标注写在关系线的两端常规取值有这么几种标注含义C 对应1恰好一个引用或值成员0..1零个或一个指针可能为nullptr1..*至少一个容器且保证非空*或0..*任意多个可为零std::vector导航性方面单向关联画一个箭头双向关联省略箭头或者以两端都带箭头表示。在 C 里单向关联通常对应一方持有指针另一端完全不知道对方的存在双向关联则是双方都有指针这时候要注意循环引用的问题。6.3 用文本化工具画图的完整流程图形化工具的好处是直观但改起来麻烦版本管理也麻烦。我现在画类图基本都用文本化描述好处是能进 Git、能 diff、能自动生成图片。以 PlantUML 为例它的关系符号和 UML 一一对应startuml class Shape { - name: string area(): double } class Circle { - r: double } class IRenderer { draw(shape: Shape) } class SvgRenderer Circle --| Shape : 泛化 SvgRenderer ..| IRenderer : 实现 Car *-- Engine : 组合 University o-- Department : 聚合 Student -- Course : 关联 OrderService .. Logger : 依赖 enduml符号对应关系是--|泛化、..|实现、*--组合、o--聚合、--关联、..依赖。这套符号我第一次记的时候编了个顺口溜实线三角是继承虚线三角是实现实心菱形是组合空心菱形是聚合箭头是知道虚线箭头是用一下。实际画图的流程我一般是这么走的先在白纸上把类名和成员写出来然后用前面的判定清单确定每对类之间的关系再按关系强弱排序最后才落到工具里画。反过来的做法——先开工具再想关系——几乎没有一次画对过。还有一个实用建议类图上不要把每个 getter、setter 都画出来。类图是用来表达结构的不是代码的镜像。属性、关键方法、关系这三样画清楚就够了细节太多反而看不清设计意图。7. 常见问题与排查技巧实录前面都是正向的方法论这一节说说实际操作中高频出现的麻烦以及我这些年攒下来的应对手段。7.1 循环依赖和编译时间爆炸循环依赖是 C 项目里最典型的看起来无害、积累起来要命的问题。A.hincludeB.hB.hincludeA.h如果还有 include guard编译器不一定报错但语义可能已经不对了——某个类在另一个类被解析时还是不完整类型报一堆未定义类型的错误而且错误位置常常指向完全无关的行。解法分两步。第一步检查是不是所有依赖都需要完整类型。如果只是成员指针或者函数参数改成前置声明即可循环自然断开。第二步如果确实需要完整类型比如值成员那说明这两个类的耦合太紧了应该考虑把公共部分抽出来做成第三个类或者引入接口层。真正难缠的是模板和继承带来的循环这两种情况下前置声明救不了你只能重构。我在项目里见过一个特别典型的案例基类模板的某个成员函数依赖派生类的类型派生类又继承自这个模板实例形成的循环靠前置声明根本解不开最后是通过把那个成员函数抽成自由函数才解决的。至于编译时间爆炸常用的排查手段有这么几个。可以用编译器的依赖输出功能看某个头文件被多少人包含可以在-H选项下看每个翻译单元实际包含了多少头文件可以把项目的头文件分成稳定层和易变层前者用前置声明隔离后者允许频繁修改。我待过的一个项目做完这轮优化全量编译从三分半降到一分钟出头。7.2 关系误判速查表下面这张表是我从代码评审记录里整理出来的高频误判遇到类似情况可以对号入座。你看到的代码常见误判正确判定怎么改函数参数里出现某个类的引用关联依赖保持现状补前置声明成员是shared_ptrT构造时注入组合关联或聚合先明确所有权必要时换unique_ptr或裸指针成员裸指针析构里 delete关联组合换成值成员或unique_ptr继承一个有实现和数据的类只为了复用方法泛化应该是组合把基类改成成员继承纯虚类但基类没有虚析构实现结构缺陷补上virtual ~Base() default;双向持有指针都用shared_ptr双向关联循环引用其中一侧换weak_ptr这张表里的每一条我都在真实代码里见过至少三次。尤其是最后一条用shared_ptr做双向关联导致内存泄漏基本上是新人必踩的坑。7.3 几个项目攒下来的实操心得最后分享几条我认为最值钱的经验都是踩过坑之后才明白的。第一条先想清楚生命周期再决定关系。代码写之前先问三个问题这个对象谁创建、谁销毁、活多久。这三个问题答完了关系基本就定了代码怎么写也顺理成章。反过来先写代码再补关系往往要返工。第二条默认选最弱的关系。我见过太多因为图方便而把依赖升级成成员关联的代码后期想拆都拆不动。依赖能解决的事情不要引入成员变量组合能解决的事情不要引入继承。等到真的需要更强的耦合了再升级成本远低于从强往弱拆。第三条用类型系统表达所有权。unique_ptr表示独占、shared_ptr表示共享、裸指针或者引用表示不负责、值成员表示不可分割这些在 C 里都是可以读出来的信息。团队里约定好这套用法之后看一个类的成员声明就能猜出它和外部对象的关系评审效率会高很多。第四条类图只画到有疑问的地方为止。全项目的类图画得再漂亮如果没人看也是白费。我现在的做法是只在新增模块、重构核心链路、评审复杂依赖的时候画局部类图画完直接放进对应的设计文档里跟代码放一起维护。第五条写单元测试的时候关系会自己暴露出来。如果一个类很难写测试构造函数参数一大堆还要搭一堆假对象那基本可以断定它的关系太紧了。这时候先别急着写 mock回头看看是不是该把某个关联降级成依赖或者把某个组合换成关联。测试写得顺不顺手是关系设计好坏最直接的反馈。我最近在给一个新项目定编码规范关于类关系那部分我就写了三行成员变量用值或者unique_ptr跨模块传参用引用接口继承只继承纯虚类。三条规则看着简单但把前面六种关系的判定都覆盖进去了。团队跑了小半年代码评审里关于关系的争论基本消失了这大概是我觉得最划算的一条经验。