ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UML类图六种关系核心解析:继承、实现、依赖、关联、聚合、组合

UML类图六种关系核心解析:继承、实现、依赖、关联、聚合、组合 前几天一个刚入职的同事拿了张类图过来找我指着一条线问“这个空心三角和实心三角到底啥区别为啥有的线是虚的有的线是实的”我看了一眼那是一张典型的“凭感觉画”的类图——接口实现用实线继承用虚线聚合组合干脆没画菱形。这个场景我见了太多次从实习生到做了四五年的开发能把 UML 类图里类、接口之间那几种关系画对的人真的不多。这篇文章我就把这些事一次性讲透类的规范画法该长什么样接口怎么表示六种关系背后的代码本质是什么以及从工具实操到避坑经验的完整链路。如果你是刚学 UML 的在校生、需要给项目画设计文档的开发者或者准备技术面试时被“类图箭头”卡住的人这篇都能直接拿来用。文末我还会分享自己在实际画图中踩过的坑和判断关系的经验这些才是常规教材里不写的东西。1. 先解决“画什么”类和接口在 UML 类图里的规范长相很多人画类图容易乱本质原因是连“元素本身怎么画”都没统一就开始画关系。线乱往往是因为点乱。所以第一步先把类和接口的基础形态钉死。1.1 类的三栏结构与可见性标记一个类在 UML 类图中是一个矩形分三个栏。最上面是类名中间是属性字段最下面是方法操作。类名栏直接写类名比如Order、UserService。属性栏每行格式是可见性 属性名: 类型方法栏格式是可见性 方法名(参数列表): 返回类型。可见性用三个符号表示是 public-是 private#是 protected。这个约定全球通用画图时别自己发明符号。比如---------------------- | User | ---------------------- | - id: Long | | - name: String | | - email: String | ---------------------- | login(pwd: String) | | logout() | ----------------------这里还有两个很容易忽略的细节。第一抽象类名要用斜体表示抽象方法和抽象属性同样用斜体。第二静态成员要加下划线。很多画图工具不会自动帮你做这两件事需要手动设置。如果交付文档里抽象类没斜体、静态方法没下划线严格来说这张图是不规范的评审的时候容易被较真的人挑出来。1.2 接口的两种表示法以及什么时候用哪种接口在 UML 类图里有两种画法。第一种是“构造型矩形”一个矩形类名位置上方加《interface》标记属性栏和方法栏的写法和普通类一样。这种画法最常用信息最完整能清楚看到接口暴露了哪些方法。第二种是“棒棒糖表示法”用一个圆圈加一条线表示接口圆圈旁边写接口名。这种画法在图比较直观时很好用比如组件图里展示某个类实现了一个小接口能大幅减少线交叉。但说实话完整的类图不推荐用棒棒糖它会把接口的方法列表藏掉读图的人看不到接口契约的全貌只能看到“有个接口”。我个人的建议是设计评审、文档交付用构造型矩形画系统架构里的模块交互时偶尔可以用棒棒糖做简化。混用也行但一张图里尽量统一风格别一会儿矩形一会儿圆圈。1.3 关系线的基本符号组合虚实、线型、箭头、菱形很多人记不住六种关系是因为每种关系都是几个符号的组合而他们背的是零散的“这条线啥意思、那条线啥意思”。换个思路UML 关系线的表达其实就三种可变化维度——实线还是虚线、有没有空心三角或实心三角箭头、整体端有没有空心菱形或实心菱形。把这三种维度组合起来六种关系就全出来了关系线型箭头/端点代码层本质继承泛化实线空心三角指向父类extends实现虚线空心三角指向接口implements依赖虚线开口箭头指向被依赖方方法参数、局部变量、返回值、静态调用关联实线普通箭头可省略指向被持有方成员字段聚合实线空心菱形整体端 普通箭头整体持有一部分部分可独立存在组合实线实心菱形整体端 普通箭头整体持有一部分部分生命周期绑定整体先把这个表刻进脑子里后面每个关系的细节就好理解了。菱形画在哪一端是重灾区后面我会专门讲。2. 泛化与实现两条“父子线”的正确画法这两种关系解决的是同一个问题——类与类、类与接口之间的“is-a”关系。但它们有一条很容易混淆我见过无数人把接口实现画出实线空心三角去继承父类反而画出虚线。问题就出在没理解两者的语义差异。2.1 继承泛化实线空心三角子类指向父类继承表达的含义是子类继承父类的属性和行为并且可以扩展或重写。代码里就是 Java 的extends。画法上从子类引出实线指向父类的一端画空心三角。比如public class Animal { public void eat() { ... } } public class Dog extends Animal { public void bark() { ... } } public class Cat extends Animal { public void meow() { ... } }对应类图是三条实线分别从Dog、Cat指向Animal三角贴在Animal那端。这里有个判断要点方向一定是“子类指向父类”也就是从具体、特殊的一方指向抽象、通用的一方。很多新手画反了从父类往外拉线指向子类这会导致整个图的方向语义混乱。继承在 UML 里的标准叫法是“泛化”Generalization它的核心价值在于代码复用和多态。画图时你还要注意一点如果父类有抽象方法比如Animal里有eat()是抽象的那么在Animal的类框里这个eat()方法名要用斜体同时Animal类名也应用斜体。这条细节在面试画图题里经常被拿来考察。2.2 实现虚线空心三角接口的契约关系实现关系表达的是一个类承诺满足某个接口定义的行为契约但具体怎么实现由类自己决定。代码里就是implements。画法是从实现类引出虚线指向接口的一端画空心三角。public interface PaymentService { void pay(BigDecimal amount); } public class AlipayPayment implements PaymentService { Override public void pay(BigDecimal amount) { ... } } public class WechatPayment implements PaymentService { Override public void pay(BigDecimal amount) { ... } }对应类图是两条虚线分别从AlipayPayment、WechatPayment指向PaymentService。PaymentService类名上方写《interface》。泛化和实现最直观的区别就是实线和虚线。为什么 UML 要把实现设计成虚线我的理解是接口本身不是一个具体的“类”它更像一份契约或规范实现类和接口之间没有真正“血缘”意义上的继承虚线能反映出这种“并不那么具体”的关系。你在画图时只要记住一句话实线继承类虚线实现接口。即使记不住术语这个口诀也够用了。2.3 抽象类和接口的抉择画图之外的设计判断这一小节严格说不算画图问题但直接影响你画出来的图对不对。很多新人画图之前没想清楚该用抽象类还是接口导致图上全是继承关系或者全是实现关系。我一般建议按两个维度判断语义上的“is-a”关系用抽象类能力上的“can-do”关系用接口。比如“企鹅是一种鸟”用抽象类更合适但“企鹅会游泳”应该用接口因为不是所有鸟都会游泳。另外如果多个实现类有大量公共代码要复用用抽象类如果系统里需要让不相关的类也有相同行为用接口。在类图上这个区别会直接影响关系的类型抽象类对应泛化实线空心三角接口对应实现虚线空心三角。画之前先问自己一句这个子类是真的“更具体的父类”还是“提供了一个额外的能力”这个问题想清楚了线就不会画错。3. 依赖、关联、聚合、组合从“用一下”到“生死绑定”接下来这四种关系是类图里最容易出问题的部分。它们的共同点是都表示类之间有“联系”但联系强度天差地别。你可以把这四种关系理解成一个强度谱系依赖最弱表示“偶尔用一下对方”关联稍强表示“我长期持有你”聚合是“我拥有你但你可以独立生活”组合最强是“我拥有你你没有我就活不了”。3.1 依赖虚线箭头最弱的关系依赖在 UML 里是一条从使用方指向被使用方的虚线末端带一个开口箭头。它表达的语义是一个类“用到”了另一个类——但只是临时使用不是在字段里长期持有。典型场景包括方法参数里使用另一个类方法返回值是另一个类方法内部创建了另一个类的局部对象调用另一个类的静态方法public class OrderService { public void checkout(Cart cart, PaymentGateway gateway) { Receipt receipt gateway.charge(cart.total()); EmailSender sender new EmailSender(); sender.sendReceipt(receipt); } }这段代码里OrderService依赖Cart参数、PaymentGateway参数、Receipt返回值、EmailSender局部变量。在类图上从OrderService分别引四条虚线指向这四个类这样表达完全没有问题。依赖关系虽然“弱”但并不是不重要。恰恰相反接口调用、外部服务访问这些核心业务逻辑往往都是以依赖的形式出现的。所以别因为教材上说“依赖是最弱的关系”就轻视它。它的“弱”指的是生命周期短、耦合度低不是业务无关紧要。我画图时的习惯是如果两个类之间的依赖里包含了外部系统接口调用我会在箭头上加个注释说明是远程调用方便读图的人注意性能和异常边界。3.2 关联实线箭头字段级的长久持有关联关系表达的是“一个类在结构上持有另一个类”代码层面对应成员字段。画法是实线可以加箭头也可以不加加箭头表示单向关联不加或画双向箭头表示双向关联。public class Customer { private ListOrder orders; }Customer关联Order因为Customer里有一个类型为Order的字段。这里可以标注多重性一个客户可以对应多个订单所以Order端标0..*。关联里最容易被忽略的是导航性。导航性回答的问题是通过 A 能拿到 B那通过 B 能不能拿到 A如果只存了单向引用图里就画单向箭头如果两边互相持有才画双向关联。图里每多一条方向的箭头就意味着代码里多一个字段引用意味着多一份耦合。所以画关联时不要习惯性画成双向——先去代码里确认到底有没有反向字段。没有反向字段却画双向箭头评审时会被一眼戳穿。3.3 聚合空心菱形整体与部分但部分可以独立聚合画法是实线在“整体”那一端画一个空心菱形。它表达的是“整体拥有部分”但部分的生命周期不完全受制于整体。举例来说Team团队和Member成员。一个团队聚合多个成员但成员离开团队之后依然是“成员”他还是他团队只是他的一段经历。public class Team { private ListMember members; public Team(ListMember members) { this.members members; } }注意这里的构造器传入了一个成员列表也就是说Member对象是在别处创建好、再“塞”给Team的。这是聚合的一个典型代码特征整体不负责创建部分部分可以在整体外部独立存在。判断一个关系是不是聚合最核心的问题就是如果整体被销毁了部分还能不能独立存活如果能就是聚合。团队解散了成员换个团队继续干活这显然能存活。3.4 组合实心菱形同生共死组合是比聚合更强的“整体—部分”关系。画法同样是在整体端画菱形但菱形是实心的。组合表达的是部分的生命周期完全绑定整体整体没了部分也就不存在了或者至少“失去了作为部分存在的意义”。public class Order { private ListOrderItem items; public Order() { this.items new ArrayList(); } public void addItem(Product product, int count) { this.items.add(new OrderItem(product, count)); } }Order创建OrderItem订单项脱离订单没有任何业务意义。订单删除订单项自然随之删除没有独立存在的场景。这就是组合。代码上的典型特征是部分通常由整体自己new出来或者由整体负责创建和销毁。聚合和组合的区别是所有 UML 类图讨论里争议最多的地方。我的判断方法很简单就一句话“有没有你它还是它自己吗”班级和学生是聚合因为学生的身份不依赖班级订单和订单项是组合因为订单项这个对象的意义从出生就绑定订单。这个类比想透了菱形填不填实心就不会再纠结。3.5 聚合与组合的对照速查维度聚合组合菱形空心实心生命周期部分独立整体销毁不影响部分部分依赖整体整体销毁部分随之消亡创建方式部分由外部传入部分通常由整体创建代码特征构造器或 setter 注入内部 new、工厂构建典型场景团队和成员、学校和老师订单和订单项、人和其他身体器官画图时还有一个小技巧如果不确定该用聚合还是组合先画成组合再问自己一个问题——“这个部分对象有没有可能在整体之外单独出现”如果真的能单独出现改成聚合如果不能就保持实心菱形。通过这种自问自答的方式通常不会错太远。4. 一个完整案例实操网上商城下单模块的类图前面都是零散的关系拆解这一节我串一个完整案例带你把六种关系全部用上。场景是一个最小化的网上商城下单模块类就这几个Customer客户 Order订单 OrderItem订单明细 PaymentService支付接口 AlipayPayment支付宝实现 WechatPayment微信实现 DiscountCalculator折扣计算工具类 PDFExporter文件导出工具如果按“拿到需求直接画”的思路很多人会从Customer开始往右拉线画到哪里算哪里。这种画法画出来的图通常又乱又漏。我的建议是反过来先把每个类的字段和方法列出来再决定类间关系。先看代码形态public class Customer { private ListOrder orders; } public class Order { private Customer customer; private ListOrderItem items; private BigDecimal totalAmount; } public class OrderItem { private Product product; private int count; } public interface PaymentService { void pay(BigDecimal amount); } public class AlipayPayment implements PaymentService { ... } public class WechatPayment implements PaymentService { ... } public class DiscountCalculator { public static BigDecimal apply(BigDecimal amount, String code) { ... } } public class PDFExporter { public void export(Order order) { ... } }然后逐一判定关系Customer到Order字段orders是关联且是单向关联标注多重性。一个客户有多个订单所以在Order端标1..*。Order到OrderItemOrder内部new出明细订单明细离开订单没有任何意义这是组合实心菱形画在Order端。多重性是一对多所以OrderItem端标1..*。AlipayPayment和WechatPayment到PaymentServiceimplements虚线空心三角指向PaymentService。这是实现关系。Order到PaymentService在代码里大概率是支付方法接收一个PaymentService参数或者持有PaymentService字段。如果只是方法参数就是依赖如果OrderService作为调用方持有支付服务那是一个独立的关联。为了避免混乱我把OrderService这个调用控制器类加进来OrderService依赖PaymentService同时依赖DiscountCalculator和PDFExporter。前者的pay是通过接口调用的黑盒后两者是工具类静态调用和临时对象使用这些都是依赖用虚线箭头。这一张图里六种关系全部出现了。画完之后一步自查所有菱形都在整体端所有依赖箭头都从使用方指向被使用方所有实现线都是虚线所有多重性都标了。到这里这张图就不是“凭感觉画的”而是能拿去评审的正式设计文档。5. 从现有代码反推类图不用靠猜看这几处就够了很多时候你面对的是一堆已经在运行的代码需要反推出一张类图来理清结构。这时候别一行一行读代码效率太低。我的做法是盯住五个位置基本就能把类间关系判定个八九不离十。第一看成员字段。字段类型如果是另一个类必有关联关系还得看是不是集合类型是集合就标多对多或一对多同时确认是单向还是双向。第二看构造器和 setter。如果整体类的构造方法接收另一个类的对象作为参数而且把它保存到字段里这往往是聚合。因为对象是外部传进来的不是自己创建的。第三看方法参数和返回值。方法参数里出现、方法内部 new 出来临时用的、作为返回值返回的都是依赖。这是依赖和关联最典型的区分点——一个在字段里躺着一个在方法调用里过路。第四看静态方法调用。一个类调用另一个类的静态方法必然画依赖。这跟 import 的包名无关哪怕两个类在同一个包里只要方法内部发生了Util.foo()这种调用就有一条虚线。第五看内部创建的对象。如果整体类在自己的构造函数或业务方法里new出了另一个类的对象并且这个对象被保存到字段里、生命周期跟着整体走这就是组合。如果只是new一下用完就丢那还是依赖。在反射、Spring 依赖注入这种场景下代码里没有new字段上直接标Autowired或Resource这时候怎么判断聚合还是组合我遇到过不少这类问题。我的经验是看容器的管理边界被 Spring 容器管理的对象它的生命周期由容器控制不属于任何一个业务整体。所以UserService注入了UserMapper我不会画成组合更倾向画成关联如果有多个 Service 共享同一个 Mapper甚至可以说是一种“弱聚合”。大家可以记住这个原则画类图反映的是对象之间的结构关系和生命周期边界不是代码写法。IoC 容器改变了对象的创建方式图上的关系语义也要跟着调。6. 工具实测IDEA、StarUML、Visio 怎么画类图更顺手关系搞清楚了工具也得选对。市面上工具很多我自己长期用过 IDEA 的类图生成功能、StarUML 和 Visio分别说下它们的定位和用法。先给一个整体对比工具适合场景自动生成手动绘制交付质量IDEA Diagrams逆向看懂现有代码结构强一键生成不支持手绘不适合直接交付适合分析StarUML正向设计新模块支持基础生成灵活可完全手绘高可导出图片/PDFVisio复杂架构图综合绘制弱灵活但操作繁琐中高看操作熟练度IDEA 生成类图的用法很简单。在包名或类名上右键选择 Diagrams再选 Show Diagram Popup就会生成当前类的继承和关联关系图。默认只展示当前类你可以用快捷键Space把相关的类添加进来。生成后每个类可以右键跳转源码点关系线能看到对应的具体代码位置逆向梳理代码结构特别方便。IDEA 的类图还能直接导出图片但导出效果不稳定字体和布局经常需要手动调整所以我通常只把 IDEA 当作“代码关系探测器”而不是最终交付工具。StarUML 是纯手动绘图工具里的性价比之选。它的操作重点是这样新建工程后在模型树里右键添加 Class Diagram然后从左侧工具箱拖Class或Interface元素出来双击类名编辑名称和成员通过类框右侧的快捷按钮直接生成属性、方法。画关系时工具箱里有 Generalization、Realization、Dependency、Association、Aggregation、Composition 六种连接线从源元素拖到目标元素即可。很多人第一次用 StarUML 容易在“从哪一端开始拖线”上栽跟头。规则是从语义上的“子方”拖到“父方”从“依赖方”拖到“被依赖方”从“整体方”拖到“部分方”。拖反了箭头方向就不对。画完记得检查每个元素是否设置了正确的 stereotypes接口要标《interface》抽象类要把 IsAbstract 属性设为 true否则导出的图里看不到斜体。Visio 则是微软系的传统选手适合画包含类图在内的复杂系统图。操作时找软件和数据库里的 UML 模型图模板拖拽 UML 类、UML 接口等形状关系类型通过连接线右侧的右键菜单去设置。Visio 的缺点是效率不高每次调整布局都要手动拖半天优点是排版精细适合最终交付进 Word 或 PDF 的正式文档。选型建议很简单要快速看懂别人代码用 IDEA要做正向设计用 StarUML要出正式交付文档且你时间够用 Visio。工具不追求多一个“生成型”加一个“手绘型”就足够覆盖绝大部分工作场景。7. 我踩过的坑以及怎样快速自查一张类图最后聊一聊实际画图中最容易踩的坑。这些坑我都踩过有些还踩了不止一次写出来帮你省点时间。坑一菱形方向画反或者画漏。聚合和组合的菱形必须画在“整体”那一端。我在 StarUML 里画聚合关系时如果从部分拖到整体最后的空心菱形就跑到了部分端整个图就完全错了。后来我的习惯是画完一张图先过一遍全部菱形端——念一句“菱形永远在整体”不对就立刻调整。坑二依赖和关联混用。判据就一条对方是字段还是方法参数是字段就是关联是方法里的临时使用就是依赖。我在设计一个报表模块时曾经把一个ExcelWriter对象画成关联关系因为它出现在好几个方法里实际上它每次都是new出来的局部变量根本不该升级到字段层级的关联。这种错误会导致阅读者误判类的耦合度。坑三接口实现画出实线。接口实现一定是虚线空心三角继承才是实线空心三角。这个问题的根源往往是把“接口”当成“父类”画了。每次画实现关系时心里默念“实现见接口虚线上三角”就不会翻车。坑四多重性漏标。很多手工画的类图一对多关系不标1..*多对多不标*..*读图的人只能靠猜。关联、聚合、组合三条线的目标端都应该尽量标注多重性。哪怕标的只是一个*也比什么都不标强。坑五一张图塞太多内容信息密度失控。类图是沟通工具不是代码副本。一张类图如果超过二十个类相互之间的连线交错得像蜘蛛网那它就没有任何沟通价值了。我见过有人把整个微服务项目所有实体、所有 Service、所有工具类画进一张图结果评审时没人能看下去。正确做法是按业务模块拆图比如下单模块一张图、支付模块一张图模块之间的交互用接口或组件图去表达。坑六类图和代码脱节。这是最隐蔽也最要命的坑。很多人先画好图再写代码写完代码不回头更新图最终图是图、代码是代码两者对不上。我自己的做法是设计阶段用类图表达意图写完代码后至少花二十分钟用 IDEA 生成实际类图对照一遍发现不一致当场改掉。把类图当成活文档来维护而不是交完作业就扔。除了这些具体的坑我还有一个判断六种关系的“心智模型”画图时快速过一遍先问是不是 is-a是就看是继承还是实现实线还是虚线再问是不是 has-a是就看生命周期是否绑定绑定是组合不绑定是聚合字段持有是关联以上都不是只是临时用一下那就是依赖。结尾类图这东西画得多了会发现它本质不是在画图而是在梳理你对系统结构的理解。箭头方向画反、菱形画错通常不是记号记错了而是对类之间的生命周期和耦合边界没想清楚。我自己的习惯是每设计一个新模块先花二十分钟在白板上画一版类图把关系确认下来再写代码写的过程中随时修正。实际用下来这二十分钟能省掉后面一大半重构的时间。就算你暂时没有画图需求建议也把“实线继承类、虚线实现接口、菱形在整体、依赖是虚线箭头”这四句话记牢。面试聊设计、Code Review 评结构用到时一张规范类图甩出来专业度立刻不一样。
返回列表