ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

UML类图六种关系速查:泛化、实现、依赖、关联、聚合、组合一次讲透

UML类图六种关系速查:泛化、实现、依赖、关联、聚合、组合一次讲透 UML类图的六种关系——泛化、实现、依赖、关联、聚合、组合——是我这些年评审代码和设计文档时见到出错率最高的一组概念。很多同学能把定义背得一字不差可一旦拿到真实业务场景手边没有教科书标准答案就开始纠结到底该画实线还是虚线、箭头朝哪边、菱形放在哪一端。我参与过的项目评审里几乎每一版类图都能挑出两三处关系画错的地方而且翻来覆去总是那几个问题依赖画成关联、聚合和组合分不清、泛化箭头方向画反。这篇文章不打算从教科书定义开始讲我想直接把判断标准和记忆方法掰开揉碎配合代码示例和一个完整系统的建模推导过程把六种关系一次说透。无论你是准备软考、写毕业设计、画架构图还是带团队做设计评审这套方法应该都能帮上忙。1. 先分大类再看细节六种关系不是排成一行的1.1 两类关系记忆和理解方式完全不同很多人把六种关系当成六个平级选项来背这是第一个误区。实际上它们天生就分两类区分开之后记忆负担至少减半。第一类是泛化和实现表达的是“类型家族”层面的关系。它们的核心语义是某个类型是不是另一种类型或者某个类型是否兑现了另一种类型定义的契约。你可以理解为“图纸与图纸”“图纸与标准”的关系和对象什么时候创建、什么时候销毁基本无关。第二类是依赖、关联、聚合、组合表达的是“对象与对象”之间的引用关系。它们解决的是另一个问题一个对象在运行时要认识哪些其他对象认识得有多深谁负责谁的生命周期。这两类关系混在一起记就会越背越乱。先分开再逐个击破思路会清晰很多。1.2 引用关系是一条从弱到强的谱系依赖、关联、聚合、组合这四种本质上是对象之间引用强度从弱到强的一条谱系关系强度一句话概括典型代码位置依赖最弱临时用到用完即走方法参数、局部变量、静态调用、返回值、抛出的异常关联中等长期持有对方引用但彼此独立成员变量、导航性、多重性聚合较强整体包含部分但是部分可以单独生活整体持有部分集合部分由外部传入组合最强整体和部分同生共死整体内部创建并管理部分生命周期理解这条谱系之后“六种关系”这个问题实际上就变成了两个子问题这是类型家族关系还是对象引用关系如果是对象引用强度到了哪一档1.3 为什么先建立这个整体框架原因是实际画图时你面对的不是“请选择哪种UML关系”的单选题而是一堆类名、字段、方法签名组成的业务场景。没有整体框架就只能凭着“好像应该用关联”的感觉去做画完心里没底。有了框架之后判断路径就变成了一条清晰的提问链两个类是不是父子继承或者接口实现关系如果不是再追问这种引用是临时的还是长期的如果长期持有部分能否脱离整体独立存在这条提问链就是文章后面要详细展开的内容也是我每次画图前都会在脑子里过一遍的固定步骤。2. 泛化与实现空心三角的两种用法方向永远指向父类型2.1 泛化子类指向父类实线空心三角泛化对应的就是面向对象里的继承语义是“子类是一种父类”。类图画法是一条实线末端带一个空心三角箭头箭头指向父类。注意这个方向箭头永远从子类指向父类而不是反过来。我见过不少新手把箭头从父类画到子类理由是“父类派生出子类”。这个直觉可以理解但UML的语义是“子类依赖父类的约定父类并不知道子类的存在”所以箭头表达的是“知道”的方向不是“产生”的方向。代码里一个很直观的对照就是Java的extends子类声明里明确写了父类是谁而父类源码里通常看不到任何子类信息。画泛化时还要注意一点如果存在父类A子类B、C同时继承A三条线不要画成从A放射出去的“伞形”而是分别从B、C指向A尽量避免线条交叉。一个类可以继承一个父类但可以被多个子类继承这在图里会出现“一对多”的指向关系。2.2 实现实现类指向接口虚线空心三角实现关系描述的是类兑现接口定义的契约画法是一条虚线末端带同一个空心三角箭头箭头指向接口。在Java里对应implements在C里对应纯虚类派生在Go里虽然没有implements关键字但结构体实现了接口的全部方法UML建模时同样会表现为一条实现关系。我用过一个类比来解释虚线和实线的差别泛化是“亲生的”子类实实在在地继承了父类的实现细节所以是实线实现是“盖章保证遵守条款”实现类和接口之间没有公共代码接口通常只有方法声明所以用虚线表示这种更“抽象”的连接。画图的时候空心三角的形状和位置最容易出问题。很多工具里实现关系的箭头样式和泛化几乎一样只是线的虚实不同这里一定要看清楚避免导出图片后整张图的关系线全部变成泛化。另外一个类实现了多个接口、一个接口被多个类实现在图中会出现多条虚线指向同一个接口这是完全正常的。2.3 泛化和实现的选择经验设计新类时到底用泛化还是实现本质上就是“继承抽象类还是实现接口”的建模选择。我的经验是如果多个子类之间确实共享了状态或者一段可复用的方法体用泛化如果只是想约定“必须提供哪些能力”不关心实现细节用实现。举个实际场景一个支付系统里有支付宝支付、微信支付、银行卡支付它们都有支付和退款的能力但支付流程差别很大几乎没有可以复用的公共代码这时候应该定义PaymentProcessor接口三个支付类去实现它反过来如果三个支付类有大量公共的日志记录、签名校验、异常兜底逻辑那就不妨抽一个AbstractPaymentProcessor抽象类用泛化关系把公共逻辑沉淀下来。这里还有个容易忽略的点UML类图上抽象类名通常用斜体表示接口名上方会加«interface»或图标标注。写成PlantUML时抽象类用abstract class接口用interface工具会自动渲染成对应图形。3. 依赖与关联临时使用和长期持有一线之隔3.1 依赖的本质是“用完就走”依赖是六种关系里最弱的一种也可以说它是所有引用关系的“默认最低档”。它描述的是A类在某个操作的过程中临时使用了一下B类但A类并不会把B类的实例长期保存下来。哪些代码形式会产生依赖方法参数、方法体内的局部变量、静态方法调用、返回类型、方法签名里抛出的异常类型都算。比如OrderService的某个方法接收了一个DiscountContext参数方法内部读取一下折扣信息就返回了OrderService本身并不保存DiscountContext这就是典型的依赖。画法上依赖是一条虚线带一个开放箭头箭头从“使用方”指向“被使用方”。这个方向很多人会记混我自己的记忆方法是箭头的含义是“我认识你、我需要你”所以谁依赖谁箭头就指向谁。OrderService依赖DiscountContext箭头从OrderService指向DiscountContext。依赖还有一个特点它的虚线箭头通常只在两个类之间画一条就算OrderService有三个方法都用到了DiscountContext也只需要一条依赖线不需要画三条。这一点和关联的多重性表示法不同。3.2 关联的本质是“长期保存引用”当A类把B类的实例作为自己的成员变量保存下来并且在多次操作中反复使用这个引用时关系就升级为关联了。关联描述的是结构性的、相对稳定的关系它比依赖要“重”得多。关联可以是单向的也可以是双向的。单向关联用一条实线加一个开放箭头箭头指向被关联的一方双向关联就是一条实线不加箭头或者两端都加箭头表示双方都知道对方的存在。比如Order和Customer订单知道自己是哪个客户的客户也能查到自己的订单列表这种业务下就是双向关联。关联还有两个常见标注导航性和多重性。导航性解释的是“谁能找到谁”上面那个例子里面如果订单需要反查客户客户不需要维护订单列表那就只画一条从Order指向Customer的箭头代表“订单可以导航到客户”。多重性描述的是数量关系表示为一个客户可以有多个订单一个订单恰好属于一个客户。这里要特别提醒关联和依赖在代码层面的分界是“成员变量”还是“方法局部”。如果一个类里出现了另一个类型的private字段那几乎可以肯定不是依赖而是关联如果只是方法的传参或局部变量那大概率是依赖。我用这个标准判断准确率非常高。3.3 怎么把代码翻译成线假设有下面这段简化代码public class OrderService { private OrderRepository orderRepository; private CustomerRepository customerRepository; public double calculateTotal(Order order, DiscountCalculator calculator) { Discount discount calculator.calculate(order); return order.getTotal() - discount.getAmount(); } }翻译成类图关系时OrderService和OrderRepository、CustomerRepository之间是关联因为它们是成员变量OrderService和DiscountCalculator之间是依赖因为它只是出现在方法参数里OrderService的返回值类型是double属于基本类型不参与建模如果calculateTotal返回的是某个领域对象的类型那个对象之间也很可能是依赖。我把这个例子放在实际评审里检验过大多数人的第一反应是把四条线全画成关联理由是“这个类都能访问那些类”。这种“按访问关系画线”的习惯是类图变得又乱又难维护的根源。类图不是调用关系图它要表达的是类型之间的结构性关系。3.4 依赖太多怎么办真实项目里一个业务方法可能会依赖十几个类型如果全部画成虚线整张图会变成一团蜘蛛网。这时候需要发挥人的判断力UML规范允许你在建模时只画“重要的依赖”而不是把所有依赖全部铺出来。我的习惯是只保留两类依赖一类是建立系统骨架的关键接口依赖比如上层服务对领域接口的依赖另一类是某个类在设计与实现上真正“耦合”到了另一个类比如传入了对方的实例并调用其业务方法。至于工具类、配置类、DTO类之间的依赖通常不画或者单独画一张“依赖视图”来展示。这个“筛选”的意识和能力比会画所有关系线更接近真实的架构设计工作。4. 聚合与组合菱形端才是整体判断标准是生命周期4.1 聚合整体不决定部分的生死聚合和组合都是“整体-部分”关系这是它们和普通关联最大的区别。聚合用一条实线整体那一端加一个空心菱形菱形画在整体类旁边。语义是整体包含部分但部分可以脱离整体独立存在。现实里最常用的例子是公司和员工。公司解散后员工这个人并不会随之消失他会去下一家公司同一个员工也可以同时是某几个项目组的成员项目组解散了员工还在。这种“整体没了部分依然活得好好的”场景就是聚合。代码层面聚合的表现通常是整体类持有部分的集合但部分的实例不是整体类内部创建的而是通过构造方法、Setter从外部传入。换句话说部分的生命周期在整体之外。比如Department类持有一组Employee但这组Employee背后另有维护者Department只是“借用”了它们的引用。4.2 组合部分跟着整体同生共死组合同样表示整体-部分关系但整体那一端画的是实心菱形。实心的含义是部分的生命周期完全由整体接管整体被销毁时部分也不能单独存在。最经典的例子是订单和订单项。订单项依赖订单才有意义删除订单时订单项必须一并删除没有“独立存在的订单项”这种事情。人和其他器官也是这个逻辑心脏不能脱离人体独立生活。代码层面组合通常表现为整体在自己的构造方法或内部逻辑里new出部分对象整体对外负责部分对象的整个生命周期析构或删除时同步销毁。从UML定义的严谨性来说组合还有一个额外的约束就是“一个部分在同一时刻最多只能属于一个整体”。订单项不能同时属于两个订单这个约束在聚合里是不存在的员工可以同时属于多个项目组。4.3 生命周期判定的具体操作判断聚合还是组合最实用的方法不是盯着图形符号而是做一次“删除实验”。问自己一个问题如果我把整体对象删掉这个部分对象还有存在的意义吗如果它还能被另一个整体接纳并继续工作那就是聚合如果它随着整体一起消失才是合理的那就是组合。我再用代码样例演示一下// 聚合Team 持有成员但成员从外部传入外部负责其创建和销毁 public class Team { private ListMember members; public Team(ListMember members) { this.members members; } } // 组合Order 内部创建 OrderItem外部拿不到独立的 OrderItem 生命周期 public class Order { private ListOrderItem items; public Order(ListProductSnapshot products) { this.items new ArrayList(); for (ProductSnapshot productSnapshot : products) { this.items.add(new OrderItem(productSnapshot)); } } }看代码结构基本就能得出答案Team的成员是外部传入的OrderItem是Order内部创建的。这就是聚合和组合在实现层面的直观差异。还要说明一个很多教材容易误导人的地方同一个业务场景在不同建模约定下聚合和组合的结论可能不同。比如“员工-部门”如果业务上规定部门注销后所有员工自动失业员工档案也随之销毁那它就是组合如果你的系统里部门没了、员工还在并且会转到其他部门那就是聚合。UML本身不会替你回答这类业务语义问题画图的人必须先和业务方对齐假设。我见过团队为这个问题争论一下午最后发现两边对“员工离职后档案归属”的定义不一致。所以画之前先明确业务边界比画完之后再争论符号要有用得多。5. 一个订单系统的完整建模推导六种关系一次画全5.1 需求背景与类清单理论都说透了接下来用一个我比较熟悉的订单系统案例把六种关系从头到尾推导一遍。假设系统包含以下类Person人员基类Customer客户继承自PersonProduct商品Order订单OrderItem订单项记录某个商品的下单快照与数量Payment支付记录PaymentProcessor支付处理器接口WechatPaymentProcessor、AlipayPaymentProcessor微信和支付宝支付实现OrderService订单服务InventoryService库存服务这个清单已经涵盖了泛化、实现、依赖、关联、聚合、组合可能出现的全部位置。接下来一条线一条线确定关系。5.2 逐个关系确认的过程第一步确定类型家族关系。Customer继承自Person这是泛化Customer指向Person。WechatPaymentProcessor和AlipayPaymentProcessor都实现了PaymentProcessor接口这是实现两个实现类分别用虚线带空心三角箭头指向接口。第二步处理关联关系。Order和Customer是长期持有的关系订单需要知道自己属于哪个客户客户也可能需要查自己的订单。我用一个双向关联表示并标注多重性Customer的订单集合是0..*Order的客户是1。OrderItem和Product也是关联关系订单项引用了商品但订单项的生命周期不归商品管商品删除后订单项里的快照数据依然有效所以这里不画聚合只画一个从OrderItem到Product的关联箭头。第三步找组合关系。Order和OrderItem是明显的组合订单项在订单内部创建订单删除时订单项随之删除订单项不可能独立存在实心菱形放在Order这一端。Order和Payment同样可以建模为组合因为一笔支付记录绑定在具体订单上脱离订单的支付记录没有业务意义。第四步找聚合关系。在这个例子里Product和OrderItem刚才没有用聚合因为业务上允许商品变更后订单项仍然保留快照。如果一定要在订单系统里找一个聚合可以加入一个Category类商品目录包含多个商品但商品可以调整目录甚至独立存在这就是合理的聚合。我一般会提醒团队不要为了把六种关系凑齐而强行设计概念真实建模时没有聚合关系本来就是正常的UML允许只使用其中几种关系。第五步识别依赖关系。OrderService的createOrder方法需要校验库存它会调用InventoryService的服务下单完成后需要计算支付方式OrderService还会依赖支付策略或者PaymentProcessor接口。这些调用发生在一个方法内部OrderService并不会长期保存InventoryService的实例所以画成依赖虚线方向从OrderService指向被调用方。这里的关键是OrderService如果持有InventoryService的成员变量就升级为关联如果只是方法内临时调用或作为方法参数传入就是依赖。5.3 把图落成可复盘的代码手动画图容易受工具干扰我通常先用PlantUML把关系确认下来再导入StarUML或Visio做美化。下面这段代码就是上面订单系统的类图描述可以直接放到PlantUML环境里渲染startuml abstract class Person class Customer class Product class Category class Order class OrderItem class Payment interface PaymentProcessor class WechatPaymentProcessor class AlipayPaymentProcessor class OrderService class InventoryService Person |-- Customer PaymentProcessor |.. WechatPaymentProcessor PaymentProcessor |.. AlipayPaymentProcessor Customer 1 -- 0..* Order : places Order 1 *-- 0..* OrderItem Order 1 *-- 0..* Payment Product 1 -- 0..* OrderItem : references Category o-- Product : contains OrderService .. InventoryService : uses OrderService .. PaymentProcessor : uses enduml渲染出来之后你会看到泛化是实线空心三角实现是虚线空心三角组合是实心菱形聚合是空心菱形依赖是虚线开放箭头关联是实线开放箭头。把这段代码保存下来以后做别的设计时可以直接改类名复用比每次从空白图开始拖拽快得多。我特别推荐用PlantUML先画“关系草稿”原因是它强制你用文本描述关系不容易被工具里那些自动吸附、自动对齐的样式问题带偏。等关系确认无误之后再在StarUML里调整布局心情会轻松很多。6. 画完图后的自查清单和几个反复入坑的细节6.1 用一套“石蕊测试”问题去检查每一条线画完一张类图不需要找专家评审先自己沿着每一条关系线做一遍测试。我把这套问题叫做“关系石蕊测试”按顺序问下来基本能把错误过滤掉八成这条线两端的类是“父子类型”还是“实现接口”如果是用泛化或实现并检查箭头是否指向父类/接口。如果不是类型家族关系再看A类是把B类存成成员变量长期使用还是仅仅在某个方法里临时用到成员变量是关联方法内临时使用是依赖。如果是成员变量再问B类的实例由A类负责创建和销毁吗如果完全由A类内部创建并随A销毁用组合如果B类实例从外部传入且外部管理其生命周期用聚合如果两者都不是只是保持引用用普通关联。如果这一步判断为聚合或组合检查菱形是否在整体那一端。菱形放错位置是我评审时最常看到的低级错误。这套问题配合前文那张强度谱系表几乎能覆盖类图绘制中90%的模糊地带。我把它们贴在工位旁边每次画图都过一遍。6.2 工具操作里容易犯的三个错误第一个错误出现在StarUML和类似工具里。选中聚合或组合工具后点击两个类的顺序会影响菱形落在哪一端。工具默认把菱形放在最后点击的类旁边如果你先点整体后点部分菱形就跑到部分那一端了。我见过很多人画完没检查结果图上菱形方向全部反了。解决办法是画完立即检查整体端必须顶着菱形。第二个错误来自IDEA等IDE的自动类图生成插件。这些插件默认会把所有成员变量都转成关联、把所有方法参数都转成依赖结果一张十几个类的图能生成几十条关系线几乎没法看。使用这类工具时要利用工具提供的“过滤依赖”或者“按字段/方法筛选”功能把非核心关系隐藏掉。工具生成的图只能当草稿不能直接作为设计文档交付。第三个错误出现在Visio等通用绘图工具里。Visio的UML模板里部分形状的线型、箭头样式和标准UML规范不完全一致特别是实现关系和依赖关系的虚线样式容易混。如果你用的是Visio画完之后一定要逐条检查线型是否和UML规范匹配必要的时候手动设置线条样式。这一点比较花时间所以我现在更倾向于用PlantUML定稿后再做展示图。6.3 我在评审中最常标注的关系错误复盘这些年的评审记录被标注最多的关系错误大概是三类。第一类是依赖和关联混用代码里明明有成员变量图上却画了依赖虚线这会让阅读者严重低估两个类之间的耦合程度我一般会直接评“建议修正为关联”。第二类是聚合组合不分团队里出现“公司-部门”这种经典场景时尤其容易争议解决办法不在地图符号上而在业务假设的确认上。第三类是泛化和实现的箭头方向画反这类出错没什么技术含量但对新手来说出奇地普遍评审时一抓一个准。还有一个小细节多重性标注不要漏。类的关联线上如果涉及1和0..*这种数量约束一定要标注出来否则审阅者无法判断一个客户到底可以拥有多少订单。多重性是关联关系的一半信息缺少它会直接导致下游表结构设计出现偏差。对我个人来说画类图最舒服的节奏是先写代码或者先画PlantUML草稿把关系敲定后再补布局和样式。工具只是呈现手段真正决定类图质量的永远是建模者对“类型关系到底是强是弱、生命周期归谁管”这两个核心问题的理解。希望这套分析思路能帮你少走几步弯路。
返回列表