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

C++ UML 类图六种关系:依赖、关联、聚合、组合、泛化、实现全解析 C 项目写久了会发现真正让代码难改的往往不是算法本身而是类与类之间那根看不见的线。依赖、关联、聚合、组合、泛化、实现这六种关系在 UML 类图里各有各的箭头画法落到 C 代码里也各有各的写法可不少人写了几年继承依然说不清手上这两个类到底是关联还是聚合。这篇文章我打算把六种关系一次讲透每种关系先给出语义判定标准再落到具体的 C 代码形态最后对应到 UML 类图上的符号。读完你应该能做到两件事——看到一段代码立刻判断出它属于哪种关系看到一张类图能直接写出对应的头文件骨架。适合有一定 C 基础、正在带项目或准备做架构设计的同学刚上手的朋友也能看懂因为我会尽量用生活里的类比来解释抽象概念。1. 六个关系的本质是耦合强度的分级1.1 从那个两千行的 Manager 类说起前阵子帮同事看一段结算模块的代码一个OrderManager类里塞了库存查询、支付回调、日志落库和 Excel 导出头文件 include 了七八个模块。我问他Order和Inventory之间到底算什么关系他想了半天说“就是调用了”。这个回答不算错但它把六种关系压缩成了一种等于放弃了所有设计空间。问题从来不是“类之间该不该有联系”而是联系的紧密程度。紧到什么程度决定了三件事改一个类的时候会不会牵连另一个、能不能单独做单元测试、能不能把这个类换掉或者拆出去。OrderManager之所以难改就是因为它跟太多类建立了最强的那种联系任何一个依赖变化都会顺着线传回来。举个生活里的类比。你和楼下便利店的关系是“依赖”——需要的时候走进去买瓶水买完就走便利店关门了你换一家就行。你和房东的关系是“关联”——你长期持有他的一把钥匙他有事会直接找你。你和公司的关系是“聚合”——公司没了你不一定失业你可以被借调到别的部门。你和自己身体里那颗心脏的关系是“组合”——它不能脱离你单独存在。而“泛化”和“实现”则是“你是一个什么样的人”这种分类层面的关系比前面几种都硬。1.2 一条从弱到强的耦合刻度把这六种关系按耦合强度排序大致是这样一条刻度线关系语义C 里的典型形态耦合强度依赖临时使用函数参数、局部变量、返回值最弱关联长期持有成员指针或引用弱聚合整体与可独立存在的部分成员指针生命周期外部管理中组合整体与不可分割的部分值成员或独占智能指针强泛化is-a分类public 继承可含虚函数实现很强实现can-do契约public 继承纯虚接口很强排序这件事的意义在于能用弱关系就别用强关系。同一个需求用函数参数传进去依赖就能解决的别急着加成员变量关联用成员指针能解决的别急着搞继承。我在代码评审时经常看到有人为了复用两个函数就拉一个基类出来这相当于把两条本来可以随时剪断的线焊成了钢筋。判断一段代码属于哪种关系我习惯问三个问题按顺序问基本不会错这个类是作为成员变量存在的吗不是那大概率是依赖是进入下一问。谁负责它的生命周期如果生命周期由外部决定、可以被多个对象共享往聚合靠如果它的生死完全绑在这个类身上往组合靠。语义上是不是“是一个is-a”是才是泛化或实现二者再按“有没有默认实现、是不是纯接口”区分。第三个问题特别容易被跳过。很多人写继承的理由是“代码能复用”这是最危险的理由后面第 4 节会展开说。2. 依赖与关联临时借用和长期持有差在哪2.1 依赖出现即走不留痕迹依赖的判定标准很干脆——被依赖的类只出现在函数签名、函数体内部或者模板参数里绝不出现在类的成员声明中。它在 UML 里画成一条虚线箭头箭头指向被依赖的那一方。// formatter.h class Formatter { public: std::string format(const std::string raw) const; }; // report.h class Formatter; // 前向声明就够了 class Report { public: std::string render(const Formatter fmt) const; // 依赖 Formatter void save(const std::string path) const; // 依赖 std::string };这里有两个细节值得说。第一Report的头文件里只有前向声明没有#include formatter.h因为编译器在解析函数声明时只需要知道Formatter是个类型名不需要知道它的布局。把 include 挪到.cpp里头文件的编译依赖就断了这在大型项目里能省下可观的编译时间——我参与过一个头文件层层包含的老项目把三个高频头文件改成前向声明之后全量编译从十一分钟掉到了六分半。第二依赖关系是瞬时的。Report不持有Formatter函数返回之后两者就互不相干了。这意味着你可以随时在测试里传一个假对象进去单元测试变得非常容易。这也是为什么“依赖注入”被反复强调——它本质上就是把强关系降级成弱关系。依赖的一个常见误用是把它当成“什么都往上画”。UML 类图上如果每个类之间都拉着依赖虚线这张图就失去了信息量。我的做法是只画跨模块、跨层次的依赖同一个模块内部的调用关系默认存在不画。2.2 关联成员变量带来的长期绑定当被引用的对象变成成员变量关系就升级成关联。UML 里画成实线可以带箭头表示导航方向。class Customer; // 前向声明 class Order { public: explicit Order(Customer* owner) : owner_(owner) {} private: Customer* owner_; // 单向关联 std::vectorOrderItem* items_; // 一对多关联多重度 0..* };关联的核心问题是生命周期归属。Order持有Customer*但它不负责Customer的生死——客户是被更上层的对象管理的。这种“持有但不负责”的模式在 UML 里对应多重度和导航性的标注Order端标*Customer端标1箭头的方向表示谁能访问谁。关联还分单向和双向。双向关联在 C 里最容易踩的坑是循环包含Order要 includeCustomerCustomer也要 includeOrder编译直接报错。解决办法是在至少一侧使用前向声明加指针成员// customer.h class Order; // 前向声明不 include class Customer { public: void addOrder(Order* o); private: std::vectorOrder* orders_; // 指针可以用不完整类型 };注意前向声明只允许声明指针或引用成员不能声明值成员也不能在头文件里调用对方的方法。原因很简单编译器要确定sizeof才能给值成员分配空间而sizeof只有看到完整定义才知道。用不完整类型定义值成员报错信息通常是 “incomplete type is not allowed”遇到这个错误先去检查是不是漏了 include。双向关联还有一个隐性成本两个类互相认识改动就会互相传导。如果业务上只需要单向访问就别为了“方便”加上反向指针。真需要反向查询时考虑用一个第三方索引结构比如一个OrderIndex来维护映射而不是让两个实体类互相持有。2.3 关联和依赖的边界怎么划有一种情况容易纠结一个类持有对方的成员指针但从头到尾没调用过它的任何方法只是原样传出去。这是关联还是依赖从代码形态看是关联有成员变量但从设计意图看这个持有行为本身就是一种承诺——你声明了“我需要它长期在身边”。我的建议是不要为了形式好看去改代码而是反过来问为什么它需要长期持有如果答案是“因为构造时传进来一次后面就要用”那关联是合理的如果答案是“只是为了透传给另一个函数”那应该直接把它变成参数把关系降回依赖。这类“无意义的持有”在重构时是最容易收获正收益的地方删掉一个成员变量往往能顺手删掉一个 include、一个构造函数参数和一堆初始化代码。3. 聚合与组合都是“拥有”差别藏在生命周期3.1 聚合弱拥有部分可以独立存在聚合和关联在代码形态上几乎一样都是成员指针区别在语义聚合描述的是“整体—部分”而且部分可以脱离整体独立存在也可以同时被多个整体共享。UML 里在整体那一端画一个空心菱形。class Department; class Professor { public: void join(Department* d) { depts_.push_back(d); } void leave(Department* d); private: std::vectorDepartment* depts_; // 聚合教授可属于多个院系 };这里教授和院系是聚合关系。院系被撤销了教授这个对象依然存在只是要重新分配反过来教授离职了院系也照样运转。两个对象的生命周期互相独立这是聚合最重要的特征。用智能指针实现聚合时std::shared_ptr是常见选择因为它天然表达了“共享所有权”。但要注意shared_ptr会引入引用计数开销和潜在的循环引用问题。如果聚合关系里的整体和部分互相持有shared_ptr引用计数永远归不了零内存就泄漏了。解决办法是把其中一方改成std::weak_ptr——它不增加计数用的时候先lock()提升成shared_ptr再访问。3.2 组合强拥有部分随整体一起消亡组合的判定标准就一句话部分不能脱离整体独立存在整体的销毁意味着部分的销毁。UML 里在整体那一端画实心菱形。class Engine { public: void start(); }; class Car { public: void ignition() { engine_.start(); } private: Engine engine_; // 组合值成员生命周期与 Car 绑定 };Engine作为值成员直接内嵌在Car里Car被析构的时候engine_自动析构这种绑定是编译期就确定的零额外开销也最不容易出错。如果部分对象比较大或者需要多态可以用std::unique_ptrclass Car { private: std::unique_ptrEngine engine_; // 组合独占所有权 };unique_ptr表达的语义非常清晰——这块资源只有我一个人拥有别人想用得先问我要。它不可拷贝只能移动这在编译期就挡住了“不小心共享”这类错误。我在项目里的默认选择是能用值成员就用值成员需要多态或延迟构造就用unique_ptr只有在确实需要共享所有权的时候才考虑shared_ptr。这条经验我踩过坑——早期一个配置模块里到处是shared_ptr后来排查一个内存不释放的问题追了两天才发现是两个对象互相持有造成的循环引用。3.3 聚合还是组合几个真实场景的判定光看代码形态区分不了聚合和组合因为两者都可能是成员指针。真正的判据是业务语义。我整理了一张对照表遇到具体场景时可以直接套场景关系判断理由汽车与发动机组合发动机不单独出售车报废发动机也报废院系与教授聚合教授可同时属于多个院系离职后仍存在订单与订单明细组合明细脱离订单毫无意义订单删除明细必删项目与开发人员聚合人员可参与多个项目项目结束人员还在房子与房间组合房间不能脱离房子拆房子房间也没了公司与员工聚合员工会离职、会内调公司也会裁员人脸与眼睛组合眼睛脱离人脸无法存活球队与球员聚合球员会转会球队会解散但球员还在这张表背后其实是一个问题“部分”在业务上有没有独立身份一个订单明细能被单独查询吗不能它必须挂在某个订单下才有意义那它就是组合。一个教授能被单独查询吗能他有工号、有科研成果那它就是聚合。还有一个反直觉的点聚合和组合是设计层面的概念跟内存管理方式不是一一对应的。有人用shared_ptr实现组合语义上其实不准确也有人用裸指针实现聚合并在别处管理生命周期这完全没问题。UML 里的菱形描述的是业务语义不是让你去找一个对应的智能指针。把这两件事分开看画图的时候就不会纠结了。4. 泛化与实现继承的两种面孔4.1 泛化is-a 语义与虚函数共存泛化就是我们日常说的继承UML 里画成一条带空心三角的实线三角指向基类。判定标准是“派生类是一个基类”——注意是“是一个”不是“像”也不是“用了”。class Shape { public: virtual ~Shape() default; virtual double area() const { return 0.0; } // 有默认实现泛化 virtual void draw() const; }; class Circle : public Shape { public: double area() const override; void draw() const override; };Circle是一个Shape所以用Shape*指向Circle、放进vectorShape*统一处理这些操作在语义上都成立。这就是泛化的价值它让容器和多态分发变得自然。用泛化有三个硬性要求缺一个都会出问题。第一基类需要虚析构函数。如果通过基类指针delete一个派生类对象而基类析构函数不是虚的那就是未定义行为——通常表现是派生类的析构函数根本没被调用派生类里申请的资源全部泄漏。这个错误在运行时可能毫无征兆直到内存曲线慢慢涨上去才被发现。第二派生类覆盖函数时加override。看着多余实际能救命。我遇到过一次事故基类声明的是virtual void draw() const派生类写成了void draw()少了 const编译器把它当成一个全新的函数虚函数表里没有覆盖运行时调用的还是基类版本。加上override之后这个错误在编译期就直接报出来了。第三基类析构函数里不要调用虚函数。对象析构时派生类部分已经销毁此时虚函数指针指向的是基类版本调用结果和预期不符。同理构造函数里调虚函数也拿不到派生类的实现。4.2 实现纯虚接口与契约实现关系和泛化在 UML 里的区别只是一条线——泛化是实线实现是虚线都带空心三角。代码层面的区别是实现针对的是只有声明没有实现的纯虚函数也就是接口。class ILogger { // 接口只有纯虚函数 public: virtual ~ILogger() default; virtual void write(const std::string msg) 0; virtual void flush() 0; }; class FileLogger : public ILogger { public: void write(const std::string msg) override; void flush() override; };接口和抽象类的区别在实际工程里很重要。抽象类可以带数据成员和部分实现它承载的是“共通的骨架”接口不带任何状态只承载“能力契约”。一个类只能继承一个基类但可以实现任意多个接口这是 C 里突破单继承限制的主要手段。判断该用哪个的标准也简单如果派生类和基类之间共享实现和数据用抽象类泛化如果只是约定一组方法用接口实现。我在设计模块边界的时候对外暴露的一律是接口具体实现类藏在.cpp里不导出这样调用方连实现类的头文件都不需要 include改实现的时候别人重新编译的范围能小很多。4.3 public、protected、private 继承的差别C 允许三种继承方式但这三种在 UML 里对应的关系完全不同这点很多资料没讲清楚继承方式语义对应 UML 关系publicis-a泛化或实现protected只对派生类可见的 is-a极少使用通常说明设计有问题private用基类的实现来构造自己组合不是泛化private继承最容易被误解。它表达的是 “implemented-in-terms-of”也就是“我借你的实现用用”跟 is-a 毫无关系。按 UML 的表达习惯它应该画成组合而不是泛化。C 社区有个流传很广的建议能用组合的地方就别用 private 继承。因为 private 继承会把基类的所有非虚成员都带进来即使你只想用其中一个函数也会引入不必要的耦合还可能带来名字隐藏、空基类优化之类的意外。真正需要 private 继承的场景非常窄基本只剩“需要访问基类的 protected 成员”或者“需要空基类优化”这两种。5. UML 类图画法先把符号记牢再谈工具5.1 六种关系的符号对照UML 类图的符号不多但极易混淆的是菱形的方向和箭头的指向。先把这张表记住关系线型端点符号指向依赖虚线开放箭头指向被依赖者关联实线可带开放箭头指向被导航方聚合实线空心菱形菱形在整体一端组合实线实心菱形菱形在整体一端泛化实线空心三角三角指向基类实现虚线空心三角三角指向接口菱形方向是最常画错的地方。记住一句话菱形永远贴在“整体”身上不贴在“部分”身上。汽车和发动机菱形在汽车那一端。我见过不少人把菱形画在发动机那一端图上看起来好像也能理解但语义正好反了。类框本身分三格类名、属性、方法。可见性符号是公有、-私有、#保护、~包级。静态成员加下划线抽象类名用斜体。这几条规范在不同工具里实现方式不同但含义是统一的。5.2 画一张类图的顺序工具不是重点Visio、draw.io、StarUML 甚至白板都能画流程比工具重要。我通常按这样的顺序推进先把需求里的名词列出来圈出候选类。这一步不求准先把候选池子建起来。确定每个类的生命周期边界问“谁创建它、谁销毁它”。这一步能定下大部分组合和聚合关系。先画纵向的继承体系。泛化和实现画在最上层形成一个清晰的框架。纵向画完了横向的关系才好摆。再画组合和聚合实心菱形和空心菱形分别标出来。最后补关联和依赖只画跨模块的模块内部不画。顺手标上多重度1、0..1、*、1..*和导航性。回头检查一遍有没有孤岛。如果一个类跟谁都没关系要么它是个工具类那它应该被依赖要么它多余。孤岛类往往就是设计里没想清楚的地方。这个顺序有个好处先定纵向再定横向。纵向关系一旦确定横向关系的取舍就变得容易判断了——比如两个类都继承同一个接口它们之间通常不该再有继承关系。5.3 三个容易画错的细节多重度标注的位置。多重度标在线的两端表示这一端的实例数量对应另一端的一个实例有多少个。一个订单对应多个明细那么明细那一端标*订单那一端标1。写的时候容易把星号标反标反了图就读不通了。导航性箭头的方向。箭头指向“被访问”的一方。如果Order持有Customer*并且会调用它的方法箭头从Order指向Customer。如果两边都能访问可以不画箭头或者画双箭头。依赖别滥用。前面说过一张图上如果每个类之间都是依赖虚线这张图就废了。判断标准是这条依赖如果断掉会不会导致另一个模块编译不过会就画不会说明它只是模块内部的调用隐去。还有个小技巧类图不必画出所有细节。属性可以只列关键的三五个方法可以只列对外暴露的。我见过有人把每个getter、setter都画上去结果一张图占满了一面墙谁也看不懂。类图的作用是沟通设计意图不是生成文档能少画就少画。6. 从类图落到代码几个反复踩的坑6.1 循环依赖的排查与打断类图上一旦出现双向关联代码里十有八九会遇到循环包含。排查的姿势是这样的先看编译错误里提到的两个头文件然后在其中一个里把#include换成前向声明把 include 挪到.cpp。如果换完之后编译报 “incomplete type”说明那个类被用作值成员或者调用了方法这时候要么改成指针要么把方法调用搬到.cpp里。还有一种更隐蔽的循环依赖不是头文件互相包含而是通过模板或内联函数间接包含。这种问题在大型项目里特别难查我的做法是用clang -H或者编译器的头文件依赖输出功能把包含链打印出来一眼就能看到环在哪里。平时写代码的时候养成“头文件里尽量只放前向声明”的习惯能挡掉九成的循环依赖。6.2 智能指针的选择清单关系类型和智能指针的对应关系我用这张表来定关系推荐形态理由依赖引用或裸指针参数不涉及所有权关联裸指针或weak_ptr不负责生命周期聚合shared_ptr或裸指针加外部管理共享所有权组合值成员或unique_ptr独占所有权泛化基类指针加虚析构多态分发实现接口指针通常是unique_ptr依赖倒置shared_ptr是最容易被滥用的。它看起来“安全”实际上引入了引用计数、原子操作开销和循环引用风险三个问题。我在项目里的原则是默认unique_ptr只在确实需要多方共享所有权时才升级成shared_ptr。判断“确实需要”的标准是这个对象的销毁时机是否由多个持有者共同决定如果不是就不需要共享所有权。6.3 代码评审时我常问的三个问题最后分享我评审代码时的固定三连问用来快速定位关系设计的问题第一问“这个成员为什么是指针”如果答案是“因为它是通过构造函数传进来的”那就要追问为什么不做成参数很多无意义的关联就是这样混进来的。指针要么表达所有权要么表达可空和延迟绑定如果两个都不表达用引用或者值成员更合适。第二问“这个基类有几个派生类”只有一个那这个继承大概率是多余的把它改成组合往往更简单。基类的价值在于多态分发只有一个实现的时候多态不会带来任何收益只会带来一层间接和一堆虚函数开销。第三问“如果这个类要换成另一种实现需要改几个文件”这个问题直接检验依赖倒置做得怎么样。答案是一两个文件说明接口边界划得好答案是十几个文件说明具体实现被到处 include 了关系网太密。我自己的习惯是改完代码顺手把类图更新一下。不是因为我喜欢画图而是因为类图是你唯一能在五分钟内向新人解释清楚模块结构的东西。代码里那些关系是散的看十个头文件才能拼出全貌类图是收拢的一眼就能看到谁依赖谁、谁拥有谁。当然工具自动生成的类图我不太信——它只能告诉你代码里“有什么”不能告诉你设计上“为什么”这两个问题的答案差别很大。顺带说一个小技巧如果你在维护一个历史项目不确定某两个类该是什么关系先别急着改代码先把它们的名字写在一张纸上问自己“哪个是整体、哪个是部分、谁管谁的生死”。这三个问题问完画什么菱形、写什么智能指针基本就定了。