ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

UML类图六种关系怎么画:依赖、关联、聚合、组合、泛化与实现速查

UML类图六种关系怎么画:依赖、关联、聚合、组合、泛化与实现速查 1. 为什么这六种关系总让人记不住1.1 从耦合强度这条主线把六个概念串起来刚学 UML 类图那会儿我最怕的就是画关系。方框画完了属性和方法都填好了轮到连线的时候整个人就卡住了这俩类之间到底该画实线还是虚线菱形是空心的还是实心的三角箭头指向谁问同事吧同事说看语义可语义这东西太虚了写完代码回头补图十有八九画错。后来我发现问题不在记性而在方法。硬背书上的定义——依赖是一种使用关系关联是一种结构关系——这种句子看一百遍也记不住因为它们没有告诉你判断的抓手。真正的抓手只有一个耦合强度。把这六种关系按两个类绑得有多紧从松到紧排一条线判断的时候只要问自己一句它俩到底黏到什么程度答案基本就出来了。这条线大致是这样的依赖最松关联次之聚合再紧一点组合最紧而泛化和实现走的是另一条纵向的轴线——它们描述的不是谁持有谁而是谁是谁的一种。前面四种是横向的持有/使用关系后面两种是纵向的继承/契约关系。这个二分法是我踩了无数坑之后总结出来的比死记定义管用得多。你可能会问为什么一定要分这么细业务代码能跑不就行了。这话在写单个功能时没错但一旦系统变大类图就是团队沟通的公共语言。你标成聚合别人理解成组合负责销毁资源的那个人就会懵到底要不要手动释放这个对象一个菱形画错可能就是一整块内存泄漏或者一处悬空引用的来源。所以这不是学究式的较真而是直接关系到代码正确性的东西。这篇文章我打算按是什么、怎么判断、代码怎么写、什么场景用什么这四步把六种关系逐个拆开讲。Java 和 Python 的写法都会给因为不同语言对这些关系的表达能力差异挺大理解了这个差异你反而更容易记住它们的本质。适合刚接触面向对象设计的朋友也适合写了几年代码、画图时仍然凭感觉连线的同行。1.2 一张速查表六种关系的语义与生命周期差异在展开细节之前先给你一张表兜底。这张表是我自己整理后贴在显示器边上的画图卡壳时扫一眼就能定位。表中的生命周期一列是判断聚合与组合的关键很多人分不清这两个就是因为没想过部分的生死到底由谁决定。关系中文名语义方向生命周期绑定UML 画法通俗说法Dependency依赖A 用到 B无虚线 开放箭头临时借一下Association关联A 持有 B无实线可带箭头长期认识Aggregation聚合A 拥有 B否可独立实线 空心菱形可拆卸的配件Composition组合A 强拥有 B是同生共死实线 实心菱形身体与器官Generalization泛化A 是 B 的一种不适用实线 空心三角父子继承Realization实现A 实现 B 的契约不适用虚线 空心三角接口落地看这张表的时候注意两件事。第一生命周期绑定这一列只有聚合和组合有值且正好相反这就是它俩唯一的核心区别其他描述都是辅助。第二泛化和实现的箭头都指向父的一方——泛化指向父类实现指向接口。这个指向很容易画反我当年就画错过好几次记住一句话箭头永远指向被继承、被实现的那个抽象也就是从具体指向抽象。注意菱形永远画在整体那一端也就是持有方。有些人习惯把菱形画在部分上读图的人会完全理解反。这个细节没有商量余地。2. 依赖与关联那些被当成没关系的弱连接2.1 依赖临时借用用完就还依赖是六种关系里最弱的一种。它的定义很朴素一个类的变化会影响到另一个类。落到代码上通常表现为 A 类的方法里用到了 B 类——可能是 B 作为参数传进来可能是 A 在方法内部 new 了一个 B也可能是 A 调用了 B 的静态方法。关键在于A 的对象里不会长期保存 B 的引用用完这一下关系就断了。举个我项目里的例子。有个OrderService类它有个方法calculateTotal(Coupon coupon)方法内部拿优惠券算了个折后价就返回了OrderService的字段里压根没有Coupon这个东西。这种情况就是典型的依赖。Coupon 改个方法签名OrderService 得跟着改但它俩之间没有拥有的关系。依赖在类图里画成虚线加一个开放箭头箭头从使用者指向被使用者。为什么用虚线因为它是临时的、非结构性的。实线代表结构上真的连着虚线代表只是过程里碰了一下。这个视觉区分很符合直觉你画多了会形成肌肉记忆。什么时候该强调依赖我的经验是当两个模块之间只有方法级的调用、没有字段级持有时明确标出依赖能帮后来人判断改动的影响半径。因为依赖意味着改一个可能牵动另一个是潜在的风险点。但对大多数业务代码来说依赖关系太普遍了全画出来图会变成一团毛线所以实践中常常省略只在关键链路上标注。2.2 关联长期持有但不负责生死关联比依赖紧一档。区别在哪关联是一个类把另一个类作为自己的字段长期持有。注意字段和长期这两个词。依赖是方法里的匆匆一瞥关联是写进属性表里的稳定关系。比如Student类里有个字段private School school;那就说明 Student 和 School 之间是关联。学生知道自己属于哪所学校这个引用一直存着。再看一个更典型的Teacher有字段ListStudent students;一个老师带一批学生这也是关联只不过是一对多的关联。关联的画法是实线可以带箭头表示导航方向。如果是双向都能访问就不带箭头如果只能从 A 找到 BB 找不到 A就在指向 B 的那端加个箭头。这个导航性经常被忽略但它在实现层很有意义——单向关联意味着可以省掉一边的引用减少循环依赖的风险。关联还有两个修饰细节值得说。一个是多重性写成1、0..1、*、1..*这种标在线的两端说明一个 A 对应几个 B。另一个是角色名标在线的端点附近告诉你这个引用在对方眼里叫什么。这两个东西看起来是装饰实际上直接影响建表一个1..*的关联在关系型数据库里往往就要拆成一张中间表。实操心得单向关联能表达清楚的就别画双向。双向关联在代码里是两个字段互相引用序列化时容易死循环垃圾回收时也可能互相拖住。我见过太多因为图省事画了双向关联、结果上线后序列化爆栈的案例。2.3 依赖和关联的边界到底怎么划这两个最容易混。我总结了一个特别简单的判断法看这个引用活多久。如果 B 只在 A 的某个方法的执行期间存在方法一返回关系就没了那是依赖。如果 B 随着 A 的对象一起存在A 的对象活着期间一直能通过字段找到 B那是关联。用一句话概括依赖是过程级的关联是对象级的。还有一个容易踩的坑静态工具类的调用算依赖还是关联我的判断是算依赖。因为 A 并没有持有 B 的实例只是借用了 B 的静态能力B 在 A 的对象结构里没有位置。当然如果是那种把工具类实例作为字段注入的写法那就变成关联了。同一个逻辑关系代码写法不同类图上的画法就可能不同——这也是为什么我一直建议先想语义再写代码而不是反过来从代码硬推类图。这两个关系在真实项目里的比例其实很悬殊。依赖遍地都是关联才是需要认真设计的那一类因为关联决定了对象的引用网络而引用网络直接关系到内存占用、序列化行为、循环依赖这些实际问题。所以画图时对关联要较真对依赖可以适当放宽。3. 泛化与实现纵向轴线上的父子与契约3.1 泛化is-a 关系的纵向延伸泛化就是我们平时说的继承。Dog extends Animal那么 Dog 是 Animal 的一种这就是泛化。它描述的是一般到特殊的层次结构——父类是抽象的、通用的子类是具体的、特化的。画法是实线加空心三角三角指向父类。为什么是实线因为继承是结构性最强的关系之一子类在结构上真的是父类的一部分它继承了父类的所有可访问成员。空心三角表示指向更抽象的那一端这是 UML 里表示抽象方向的一个统一约定。泛化有三个特征值得记住它们也是判断该不该用继承的依据。第一子类拥有父类的全部接口你拿到一个 Dog可以当 Animal 用这叫里氏替换。第二继承是白盒复用子类能看到父类的实现细节所以父类一改子类可能悄悄坏掉——这就是所谓的脆弱的基类问题。第三继承是编译期就固定的一旦定下来运行期没法换。正因为这三点我们看到大量用继承实现代码复用的做法最后都变得难以维护。你想复用某个方法就随便继承一个类结果把不相干的接口也一起继承了语义就脏了。所以业界的共识是继承要表达真正的 is-a 语义仅仅为了复用代码应该优先考虑组合。这句话你可能听过很多遍但真正理解它、并在项目里坚持是需要交学费的。3.2 实现接口契约的落地实现关系指的是一个类实现了一个接口或抽象契约。ArrayList implements ListArrayList 承诺提供 List 规定的所有方法这就是实现。画法是虚线加空心三角三角同样指向接口。为什么用虚线因为接口本身没有实现类只是承诺做到两者之间是契约关系而非结构继承。这个虚线的选择很讲究它提醒你接口那端是空的真正的东西在实现类里。实现和泛化的核心差异在于有没有代码继承。泛化时子类自动拿到父类的实现实现时类只能拿到方法签名具体怎么做全靠自己。这个差异在语言层面体现得很清楚Java 的接口在很长一段时间里不能有方法体后来的 default 方法算是个折中Python 则干脆用抽象基类来模拟。接口实现最大的价值是解耦。调用方只依赖接口不依赖具体实现于是可以在不改调用方的前提下替换实现——测试时塞个 Mock生产时换成真实实现发布时按配置切换不同策略。这是所有面向对象设计原则里最实用的一条没有之一。注意一个类只能泛化一个父类单继承但可以实现多个接口。这个限制决定了你在设计时能放进接口的能力就放接口别都堆在父类上否则子类的扩展空间会被堵死。3.3 为什么实现接口不叫继承接口这个问题我当年被面试官问过答得磕磕巴巴。后来想明白了区别在于语义的侧重。说继承强调的是我拿到了什么——代码、字段、行为是从上往下传递的。说实现强调的是我承诺了什么——一组接口、一份契约是从下往上兑现的。接口继承接口的时候确实还有传递接口的味道但类对接口始终是实现关系因为类给出的是一份落地实现而不是仅仅拿到声明。这个细微差别其实会影响你的设计心态。当你抱着实现一份契约的心态去写类时你会下意识思考接口要求我提供什么我提供的语义符合约定吗边界条件怎么处理而当你抱着继承的心态时容易变成反正父类有我拿来用忽略了契约的约束力。心态不一样代码质量真的不一样。4. 聚合与组合最让人纠结的一对4.1 聚合共享的可拆卸部件聚合描述的是整体包含部分但部分可以独立存在的关系。经典的例子是汽车和轮胎汽车有轮胎但轮胎可以拆下来单独卖给别的车用。部分的生命周期不依赖于整体。画法是实线加空心菱形菱形画在整体那一端。空的菱形在视觉上暗示松——这个包含关系是可以解开的。我记这个的方法是空心等于松实心等于死。空心的部件可以拆下来,实心的一生绑定。代码上聚合通常表现为对象从外部传入然后被整体持有。比如public class Car { private ListTire tires; public Car(ListTire tires) { this.tires tires; } }注意轮胎是通过构造函数传进来的不是 Car 自己造的。这意味着轮胎在交给 Car 之前就已经存在了也可能在 Car 销毁之后继续存在比如被换到另一辆车上。这就是聚合的代码特征部分由外部创建、外部管理整体只是持有引用。判断聚合的问句是这个部件能脱离整体单独存在、单独使用吗 答案是能那就是聚合。团队和成员就是典型的聚合——成员离职了团队还在成员换个团队也照样是人。4.2 组合同生共死的强拥有组合比聚合紧一档描述的是整体强拥有部分部分与整体同生共死的关系。经典例子是人和心脏心脏是人身体的一部分人没了心脏也就没有独立存在的意义而且心脏也不能被换到别人身上去这个类比在医学上有瑕疵但表达语义足够了。画法是实线加实心菱形菱形同样在整体那端。实心菱形意味着钉死了。代码特征很鲜明部分在整体内部被创建外部拿不到它的引用。看这段public class Human { private final Heart heart; public Human() { this.heart new Heart(); } }这里 Heart 是在 Human 的构造函数里 new 出来的外部完全不知道这个心脏对象的存在也没法把它传给别的对象。Human 一旦被销毁Heart 也随之可以被回收前提是没有别的地方引用它。这就是组合的写法。判断组合的问句和聚合一样只是答案相反这个部件能脱离整体单独存在吗 答案是不能或没有独立意义那就是组合。订单和订单项也是组合的典型——订单项离开订单就只是个无意义的行数据订单删了订单项就该跟着删。这一点直接决定了数据库设计订单表删记录时订单项要级联删除。4.3 用生命周期和代码写法验证你选对了没有聚合和组合是六种关系里争议最多的。我见过同一个场景有人标聚合有人标组合最后吵起来。要避免这种情况用下面这个三重验证法三个问题都问一遍答案一致才下笔第一问创建权在谁手上外部创建后传进来偏向聚合内部自己 new偏向组合。第二问能否被共享这个部件能被多个整体同时引用偏向聚合只能专属一个整体偏向组合。第三问删除整体时部件怎么办整体删了部件还要独立存活偏向聚合整体删了部件就该跟着走偏向组合。这三个问题里第三问最权威。因为生命周期才是聚合与组合的本质区别前两问只是它在代码上的投影。有时候一个场景的代码写法和语义不一致比如代码里是从外部传入的但语义上这个部件确实不该独立存在那就应该改代码去匹配语义而不是降低语义标准去迁就代码。实操心得如果你实在拿不准某处是聚合还是组合先想想数据库的级联删除策略。需要级联删的通常是组合删了主记录、附属记录还留在那的通常是聚合。这个映射几乎不用想能直接帮你定下来。5. 落到代码六种关系在各语言里的写法对照5.1 Java 里的六种关系实现Java 是这类概念表达最完整的语言因为它有显式的接口、继承、内部类等语法支撑。我把六种关系在 Java 里最能体现语义的写法整理成了一张对照表关系Java 写法要点关键代码特征依赖方法参数、局部变量、静态调用不出现在字段里关联普通字段持有private B b;聚合字段持有构造/Setter 传入this.b b;组合字段持有内部 newthis.b new B();泛化extends单继承可覆写方法实现implements可多实现须提供方法体看表里的聚合和组合两行区别只在字段怎么赋值。这么小的代码差异却对应着完全不同的语义和生命周期责任这就是为什么单纯看代码很难反推类图——你得知道设计者的意图。反过来如果你先定了语义代码该怎么写就水到渠成了。Java 里还有一个能强化组合语义的技巧把字段声明成private final并在构造函数里初始化让这个部件在整个对象生命周期内不可替换。这从语言层面帮你锁死了同生共死的语义编译器会帮你把关。5.2 Python 里的写法差异Python 没有接口关键字继承也是动态的所以这六种关系的表达方式和 Java 有区别但语义完全通用。对应的写法大致是这样依赖就是函数参数或局部变量关联是实例属性在__init__或别的方法里赋值聚合是把对象当参数传进__init__再存成属性组合是在__init__里直接构造那个部件泛化就是class Dog(Animal)实现则用抽象基类或者干脆靠鸭子类型来约定。from abc import ABC, abstractmethod class Shape(ABC): abstractmethod def area(self): pass class Circle(Shape): def __init__(self, radius): self.radius radius def area(self): return 3.14159 * self.radius ** 2 class Drawing: def __init__(self): self.shapes [] def add(self, shape): self.shapes.append(shape)这段代码里Circle(Shape)是典型的实现关系——Shape 是抽象基类Circle 兑现了 area 契约。Drawing里的 shapes 列表是聚合因为形状对象是在外面造好再塞进来的。Python 用鸭子类型时接口约束是隐性的但你在类图上依然要画出实现关系因为它表达了设计意图跟语言能不能强制无关。注意Python 里如果你想让某个字段表现出组合语义用self.heart Heart()直接在__init__里造想表现聚合就在__init__里接收一个已存在的对象。这个区分在 Python 里比 Java 更依赖约定因为没有 final 之类的关键字帮你兜底全靠自觉。6. 我踩过的坑与判定速查清单6.1 常见误判速查表这六种关系我几乎每一种都画错过把典型的误判整理成表你对号入座就行你可能会画成实际应该是判定的关键点关联依赖引用是否只活在方法里依赖关联引用是否被字段长期持有组合聚合部件能否独立于整体存在聚合组合部件是否在整体内部创建实现泛化父端是接口还是类泛化实现子类是否继承了父类的实现双箭头关联单向关联反向是否真的需要导航表里出现频率最高的是聚合和组合的误判以及泛化和实现的误判。前者是因为代码差异太小后者是因为两者的视觉符号只差实线虚线手一抖就画反了。还有一个隐蔽的坑把接口继承接口画成实现。接口 A 继承接口 B那是泛化都是抽象契约的传递不是实现。实现一定是发生在具体类和抽象之间。我在别人的设计文档里见过多次这个错误读图的人会误以为那个接口有实现体非常误导。6.2 几个真实场景的判定过程光看规则还是虚我拿几个真实项目里的场景走一遍判断过程你看完大概就上手了。场景一博客系统和评论。一篇博客有多条评论评论能独立存在吗离开博客评论本身没有意义而且博客删了评论理应一起删。所以是组合。数据库里 comments 表要带 blog_id 外键删除博客时级联删评论。场景二用户和角色。一个用户对应几个角色角色能脱离用户存在吗能角色是独立的、可以被多个用户共用的。用户删了角色还在。这是聚合甚至是更弱的关联看你怎么定义。如果角色只是一串权限标识、不建独立对象那可能连聚合都算不上就是普通关联。场景三支付服务和支付宝。PaymentService 里调用了 AlipayClient 的方法做支付但 PaymentService 的字段里没有 AlipayClient。这是依赖。哪天要加微信支付你会通过接口来解耦把 AlipayClient 抽象成 PaymentGateway这时候 PaymentService 就实现了对接口的依赖——注意接口在这里扮演的是被依赖方的抽象。场景四日志工具。业务类里直接调Log.info(...)这是依赖因为没持有 Log 实例。如果你为了可测试性把 Logger 作为字段注入进来那就变成了关联。同一个语义从依赖升格成关联动机通常是为了解耦和测试。走完这四个场景你应该能感觉到判断是有章法的先问是持有还是使用再问生命周期绑不绑最后问这俩是不是父子/契约关系。三个问题下来答案基本唯一。一个私人的经验每次画类图前我先把每个类的一句话职责写下来然后逐个关系问那三连问。写不出职责说明类没设计好三连问摇摆说明关系没想清楚。这一步花十分钟能省掉后面返工的两小时。7. 顺带说清泛化这个词的撞车7.1 面向对象里的泛化和机器学习里的泛化不是一回事搜这个词的时候你会发现结果很分裂一半是 UML 类图一半是机器学习里的泛化误差泛化能力。这两个泛化含义完全不同但确实共用了同一个词根容易让人恍惚。面向对象的泛化Generalization指的是从多个特殊类型里抽出共同特征形成一般类型的过程Dog、Cat 抽出 Animal这是抽象层次的构建。机器学习的泛化Generalization指的是模型在没见过的数据上依然表现良好的能力训练集上表现好、测试集上也好才叫泛化能力好。前者是建模动作后者是性能指标二者只是中文翻译撞了车。为什么都叫泛化因为它们背后共享一个朴素的直觉——从个别走向一般。OOP 是从具体类走向抽象类ML 是从具体样本走向一般规律。理解了这个共同直觉你就不会再混了还能顺带记住两边的本质。7.2 数据泛化与类图泛化的类比再深一层数据领域里的数据泛化也有类似的味道——把低层次的原始数据替换成高层次的概括值比如把具体年龄 23、27、31 概括成青年中年这种区间。这就是把个体归约成一般类别的过程。拿它和类图的泛化对照你会发现结构惊人地相似类图泛化是多个具体类 → 一个抽象父类数据泛化是多个具体值 → 一个抽象区间。两者都在做同一件事——消解细节提取共性。这种跨领域的结构相似性恰恰说明泛化这个概念的普适性也解释了为什么不同学科会不约而同地选用同一个词。我在做数据建模时会把这两个视角合起来用先用数据的分布把用户聚成几个典型群体数据泛化再为每个群体抽一个抽象画像类类图泛化画出来的用户模型会清晰很多。这不是什么高深技巧只是把两个泛化摆在一起看思路会自然打开。说到底这六种关系的核心不在于背定义而在于每次下笔之前你真的想明白了两个类之间到底黏在哪。判断依赖和关联看引用的寿命判断聚合和组合看部件的生死判断泛化和实现看父端是类还是接口判断方向永远从具体指向抽象判断菱形永远画在整体那端。这几条抓住剩下就是熟练度的问题了。我到现在偶尔还会在组合和聚合之间犹豫但只要把这个部件删了整体还能不能活这个问句念一遍答案立刻就清楚了。
返回列表