UML 类图这东西,刚入行那会儿我总觉得是“画给领导看的”,直到有一次接手一个别人写了半年的模块,打开代码一看,七八个类互相调用,字段传来传去,我盯着屏幕愣是没理清谁是谁的爹。后来硬着头皮把类图补了一遍,才发现问题根本不在代码写得多烂,而在于当初设计的时候,类与类之间的关系就没想明白——该用组合的地方用了继承,该松耦合的地方搞成了强依赖,改一个类牵动一片。从那以后我就认了一个理:类图不是文档装饰,它是你脑子里那张“谁跟谁什么关系”的地图。
这篇内容我打算把 UML 类图里最核心的六种关系——继承、实现、依赖、关联、聚合、组合——从头到尾捋一遍。不是那种教科书式的定义罗列,而是结合 Java 代码和类图画法,讲清楚每种关系在代码里长什么样、在图上怎么画、什么时候该用哪种、用错了会出什么事。适合刚学面向对象设计的朋友打基础,也适合工作几年但一直对“聚合和组合到底差在哪”模棱两可的同行对照着查漏补缺。看完你至少能做到:拿到一段 Java 代码,能反推出对应的类图关系;反过来,画类图的时候手不抖,知道那根线该画实线还是虚线、该画空心三角还是实心菱形。
1. 先把类图这块“地图”的图例认全
在聊六种关系之前,得先把类图里那些符号认清楚。很多人画类图出问题,不是不懂关系,而是图例用错了——该用空心三角的地方画了个箭头,该用实心菱形的地方画了空心菱形,读图的人直接理解反了。所以这一节先把“零件”过一遍,后面讲关系的时候才不会卡壳。
1.1 一个类在图上到底画几格
UML 类图里,一个类通常画成一个矩形,分成三格。最上面一格写类名,中间一格写属性(字段),最下面一格写方法。属性的一般写法是可见性 名称: 类型,方法写成可见性 名称(参数列表): 返回类型。可见性用符号表示:+是 public,-是 private,#是 protected,~是包级私有。
举个具体的例子,一个订单类在图上大概长这样:
+------------------+ | Order | +------------------+ | - orderId: String| | - amount: double | +------------------+ | + getAmount(): double | | + addItem(item: Item): void | +------------------+这里有个实操中很容易被忽略的点:属性和方法不必全画。类图是沟通工具,不是代码镜像。你画一个包含三十个字段的类,读图的人眼睛都花了。我的习惯是只画跟当前讨论的关系相关的字段和方法,其他的省略。如果确实需要表达“还有别的成员”,可以在最后一格写个...表示省略。这一点在团队评审时特别重要,图越干净,讨论越聚焦。
1.2 六种关系对应的线型速查
六种关系在图上用的线型、箭头、菱形各不相同,这是最容易混淆的地方。我整理了一张对照表,建议先扫一眼,后面每个关系展开的时候再回来对照:
| 关系 | 线型 | 箭头/端点 | 方向语义 | Java 中的典型体现 |
|---|---|---|---|---|
| 继承(泛化) | 实线 | 空心三角箭头 | 子类指向父类 | extends |
| 实现 | 虚线 | 空心三角箭头 | 实现类指向接口 | implements |
| 依赖 | 虚线 | 普通箭头(开口) | 使用者指向被使用者 | 方法参数、局部变量、静态调用 |
| 关联 | 实线 | 普通箭头(可双向) | 持有方指向被持有方 | 成员字段 |
| 聚合 | 实线 | 空心菱形 + 普通箭头 | 整体指向部分 | 成员字段(弱持有) |
| 组合 | 实线 | 实心菱形 + 普通箭头 | 整体指向部分 | 成员字段(强持有) |
提示:菱形永远画在“整体”那一端,箭头指向“部分”。这个方向搞反是新手最常见的错误,画反了语义完全颠倒。
记住一个口诀:继承实现看三角,实线继承虚线实现;依赖关联看箭头,虚线依赖实线关联;聚合组合看菱形,空心聚合实心组合。这个口诀我当年贴在显示器边上贴了两个月,后来就刻进脑子里了。
1.3 为什么图例错了比不画还糟
我见过一个项目,类图里把组合画成了聚合,结果新来的同事以为那个部件对象可以从外部传进来复用,写了个构造函数注入,跑起来各种状态错乱。根因就是图例误导——聚合意味着“部分可以独立存在、可以从外部来”,组合意味着“部分的生命周期由整体完全掌控”。一根线的差别,背后是对象生命周期管理的根本分歧。
所以这一节我想强调的不是“记住符号”,而是符号背后对应的是代码里的真实约束。你画图的时候如果对某个关系拿不准,别硬画,回去看代码里那个字段是怎么初始化的、有没有对外暴露 setter、对象销毁时部分要不要跟着销毁。这些细节决定了你该画哪种线,而不是凭感觉挑一个好看的。
2. 继承与实现:站在“是不是一种”的角度做判断
继承和实现在图上都用空心三角,区别只在实线和虚线。这两个关系放在一起讲,是因为它们回答的是同一类问题:“这个类和那个类是不是同一种东西”。继承是“子类是一种父类”,实现是“这个类是一种接口所描述的能力”。判断标准清晰了,用哪个就不纠结了。
2.1 继承:is-a 关系的代码落地与类图画法
继承在 Java 里就是extends。比如有一个Vehicle类,Car继承它,语义是“车是一种交通工具”。类图上从Car画一条实线,末端空心三角指向Vehicle。
public class Vehicle { protected String brand; public void move() { System.out.println("moving"); } } public class Car extends Vehicle { private int doorCount; @Override public void move() { System.out.println("car moving on road"); } }继承的核心价值是复用父类的属性和行为,同时允许子类改写。但它也是最容易被滥用的关系。我踩过的一个典型坑:早期写业务代码,看到两个类有相似字段就想抽个父类,结果抽完之后父类越来越臃肿,子类被迫继承了一堆自己根本用不上的方法。这就是典型的“为了复用而继承”,违反了 is-a 的判断标准——如果子类不能理直气壮地说“我是一种父类”,那这个继承就是错的。
判断要不要用继承,我一般问三个问题:第一,子类是不是父类的一种特化?第二,父类的所有行为对子类都成立吗?第三,子类需不需要改写父类的某些行为?三个都答“是”,继承才站得住。只要有一个答“否”,就该考虑用组合或者接口替代。
2.2 实现:接口契约与虚线三角的表达
实现对应 Java 的implements,类图上用虚线加空心三角,从实现类指向接口。接口在类图里的画法和类类似,但通常会在名称上方标注<<interface>>。
public interface Payable { void pay(double amount); } public class AlipayPayment implements Payable { @Override public void pay(double amount) { System.out.println("paid via alipay: " + amount); } }实现和继承的本质区别在于:继承表达“是什么”,实现表达“能做什么”。一个类只能继承一个父类,但可以实现多个接口,这就是 Java 用接口来规避单继承限制的设计。类图上,一个类可以有多条虚线三角指向不同接口,读图的人一眼就能看出这个类承担了哪些契约。
实操中我有个习惯:优先定义接口,再考虑抽象类。接口把“能力契约”和“具体实现”彻底分开,调用方只依赖接口,替换实现的时候一行调用代码都不用改。这也是后面讲依赖倒置的基础。当然,如果多个实现类有大量公共代码,可以再抽一个抽象类来承载,但接口这一层别省。
2.3 继承 vs 实现:什么时候该抽接口而不是抽父类
这个问题我在代码评审里见过太多次争论。一个常见的场景:两个类有相似的方法签名,有人主张抽父类,有人主张抽接口。我的判断逻辑是这样的——
如果两个类共享的是行为实现(方法体几乎一样),且它们确实是同一族事物,抽抽象类合理;如果共享的只是方法签名(契约相同但实现各异),那必须抽接口。举个具体例子:FileLogger和ConsoleLogger都有log(String msg)方法,但一个写文件一个打控制台,实现完全不同,这时候抽Logger接口,而不是抽一个带空方法体的AbstractLogger父类。后者会让子类被迫继承一个没用的父类,还占掉了唯一的继承名额。
类图上区分也很直观:抽接口画虚线三角,抽父类画实线三角。评审的时候看到实线三角,我就会多问一句“这俩真的是 is-a 吗”,看到虚线三角则相对放心,因为接口实现的门槛低、耦合松。
3. 依赖:最弱但最容易被画错的那根虚线
依赖是六种关系里耦合最弱的一种,但恰恰是最容易画错、也最容易被忽视的。很多人画类图,只要两个类有关系就画实线,把依赖也画成关联,结果图上一堆实线,读图的人以为处处强耦合,实际上大部分只是临时用一下。这一节把依赖讲透。
3.1 依赖在代码里的四种典型形态
依赖的语义是“一个类在某个时刻需要用到另一个类,但不是长期持有”。类图上用虚线加开口箭头,从使用者指向被使用者。Java 里依赖通常出现在四个位置:
- 方法参数:
public void process(Order order),方法执行时需要Order,但类本身不持有它。 - 局部变量:方法体内
Item item = new Item(),用完就丢。 - 返回值:
public Report generate(),返回一个对象,调用方拿到就用。 - 静态方法调用:
MathUtils.calc(x),直接调工具类。
public class OrderService { // 依赖 PaymentGateway:作为方法参数传入 public void checkout(Order order, PaymentGateway gateway) { double amount = order.getAmount(); gateway.charge(amount); } // 依赖 ReportBuilder:作为局部变量创建 public String export(Order order) { ReportBuilder builder = new ReportBuilder(); return builder.build(order); } }注意上面代码里OrderService并没有把PaymentGateway或ReportBuilder存成字段,它们只是“路过”。这就是依赖和关联的分水岭——有没有成为成员字段。
3.2 依赖箭头方向:谁指向谁不能凭感觉
依赖箭头的方向是“使用者指向被使用者”,也就是“谁需要谁,箭头指向谁”。上面代码里OrderService需要PaymentGateway,所以箭头从OrderService指向PaymentGateway。这个方向搞反了,读图的人会以为PaymentGateway反过来依赖OrderService,语义完全错乱。
我见过一种常见错误:有人觉得“被依赖的东西更重要,所以箭头指向它表示强调”,这个理解是错的。UML 箭头的语义是“导航性”和“依赖方向”,不是“重要性”。画图的时候默念一句“我需要你,我指向你”,基本不会错。
3.3 依赖过多是设计在报警
依赖本身是正常的,但如果一个类的类图上伸出七八条虚线指向不同方向,这就是设计在报警了。依赖过多意味着这个类知道的太多、职责太杂,稍微改一下被依赖的类,它就可能受影响。我在实际项目里遇到过一个OrderController,类图上连了十几条依赖线,后来拆成三个类才把线降下来。
一个实用的自查方法:数一数一个类的方法参数里出现了多少个不同的业务类型。如果超过五个,基本可以考虑把部分逻辑下沉到专门的协作类里。依赖不是越少越好,但每一条依赖都应该是“这个类真的需要知道那个类”的合理理由,而不是顺手就调了。
4. 关联:字段持有关系,一对一还是一对多
关联是比依赖强一档的关系,语义是“一个类长期持有另一个类的引用”,在代码里体现为成员字段。类图上用实线加普通箭头(或双向箭头)。关联是业务建模里出现频率最高的关系,也是最需要把“多重性”标清楚的关系。
4.1 关联与依赖的分界线就在“字段”二字
还是拿代码说话。下面这个Customer类持有一个Address字段,这就是关联,不是依赖:
public class Customer { private Address address; // 关联:长期持有 public Customer(Address address) { this.address = address; } public Address getAddress() { return address; } }对比上一节的OrderService,它的PaymentGateway只是方法参数,用完就没了。区别一目了然:字段是关联,参数是依赖。类图上,Customer到Address画实线箭头,OrderService到PaymentGateway画虚线箭头。
这里有个细节值得说:关联字段可以是构造时注入的,也可以是 setter 注入的,但只要是字段,就是关联。有些人觉得“构造注入的才算关联,setter 注入的算依赖”,这是误解。判断标准只有一条——是不是成员字段。
4.2 多重性标注:1、0..1、* 到底怎么标
关联关系最容易被省略的信息是多重性(multiplicity)。一个客户有一个地址,标1;一个客户可能有零个或一个备用地址,标0..1;一个订单包含多个订单项,标*(等价于0..*)。这些标注写在箭头两端,读图的人立刻知道数量约束。
| 标注 | 含义 | Java 中的体现 |
|---|---|---|
1 | 恰好一个 | 单个非空字段 |
0..1 | 零个或一个 | 可空字段 |
*或0..* | 零个或多个 | List、Set等集合 |
1..* | 至少一个 | 集合 + 校验 |
public class Order { private List<OrderItem> items; // 多重性 * }多重性标注的价值在于它把“业务约束”画进了图里。一个订单至少有一个商品,标1..*,读图的人就知道创建订单时必须校验非空。如果只画一根光秃秃的线,这些约束就丢了,后面实现的时候容易漏。
4.3 双向关联的代价与单向化的取舍
关联可以是单向的,也可以是双向的。双向关联在代码里就是两个类互相持有对方的字段:
public class Student { private List<Course> courses; } public class Course { private List<Student> students; }这种写法在业务上很常见(学生选课、课程有学生),但维护起来有代价:任何一方的增删都要同步更新另一方,否则数据不一致。我的经验是能单向就单向,确实需要双向导航的时候,选一个“主控方”负责维护关系,另一方只读或者通过主控方反查。类图上双向关联画一条两端都带箭头的实线,或者两条单向线,前者更简洁,但要在图上注明哪边是主控。
5. 聚合与组合:整体与部分,差的不只是一个菱形
聚合和组合是六种关系里最容易被混为一谈的一对。两者都是“整体持有部分”,代码里都体现为字段,类图上都是实线加菱形,区别只在菱形是空心还是实心。但这一空一实之间,差的是生命周期的归属——部分能不能脱离整体独立存在,整体销毁时部分跟不跟着销毁。这一节把这对关系彻底掰开。
5.1 聚合:部分可以独立于整体存在
聚合的语义是“整体包含部分,但部分可以独立存在”。典型例子是“班级和学生”——班级解散了,学生还在,可以转到别的班级。类图上用空心菱形画在整体端,箭头指向部分。
public class Classroom { private List<Student> students; // 聚合:学生从外部传入 public Classroom(List<Student> students) { this.students = students; // 注意:直接引用外部传入的集合 } }关键特征:部分对象是在整体外部创建好之后传进来的,整体只是持有引用。学生对象的生命周期不由班级控制,班级销毁了,学生对象可能还被别的地方引用着。这就是空心的含义——关系比较“松”。
5.2 组合:部分的生命周期由整体完全掌控
组合的语义是“整体拥有部分,部分不能独立存在”。典型例子是“订单和订单项”——订单删了,订单项就没有意义了,跟着一起消失。类图上用实心菱形画在整体端。
public class Order { private final List<OrderItem> items = new ArrayList<>(); // 组合:内部创建 public void addItem(String productId, int qty) { items.add(new OrderItem(productId, qty)); // 部分在整体内部创建 } }关键特征:部分对象在整体内部创建,外部拿不到它的独立引用,整体销毁时部分跟着销毁。订单项离开了订单就没有存在意义,所以用组合。实心菱形表达的就是这种“强拥有”关系。
5.3 空心与实心:生命周期归属的判定清单
判断该用聚合还是组合,我一般用下面这张清单逐条过:
| 判定问题 | 答“是”倾向 | 说明 |
|---|---|---|
| 部分对象是否在整体外部创建后传入? | 聚合 | 外部能独立管理部分 |
| 部分对象是否在整体内部 new 出来? | 组合 | 整体完全掌控创建 |
| 整体销毁时,部分是否还有独立意义? | 聚合 | 部分能独立存活 |
| 部分是否只能属于一个整体? | 组合 | 强归属,不可共享 |
| 外部是否需要单独访问部分对象? | 聚合 | 需要独立引用 |
这张表不是绝对的,实际建模时会有灰色地带。比如“部门与员工”,员工可以被部门持有,但员工离职后依然存在,倾向聚合;但如果业务上规定“员工记录随部门撤销一并归档删除”,那就偏组合了。关系类型跟着业务语义走,不是跟着代码写法走。同样是List字段,语义不同,画的菱形就不同。
注意:聚合和组合在 Java 代码层面的差别其实很小,都是字段持有。真正的差别在对象创建权和生命周期管理上。画图的时候如果拿不准,就问自己一句“这个部分对象,我能不能在整体之外单独 new 出来用”,能就是聚合,不能就是组合。
6. 六种关系横向对比与选型决策
前面五节把六种关系逐个拆开了,这一节做横向对比,把容易混淆的几对放在一起对照,再给一套实际画图时的决策流程。这部分是我觉得最有价值的地方——单独看每种关系都懂,一到实际场景就选错,往往是因为缺少横向对照的视角。
6.1 三组高频混淆关系的对照拆解
第一组:继承 vs 实现。都是空心三角,区别在实线虚线。判断标准是“是不是一种”用继承,“能不能做”用实现。一个类只能继承一个,但能实现多个,所以接口优先。
第二组:依赖 vs 关联。都是“用到另一个类”,区别在“是不是字段”。方法参数、局部变量、返回值、静态调用都是依赖;成员字段才是关联。画图时先看代码里有没有那个字段。
第三组:聚合 vs 组合。都是“整体持有部分”,区别在生命周期归属。部分能独立存在是聚合(空心菱形),部分随整体生死是组合(实心菱形)。
这三组混淆覆盖了类图关系里百分之八十的错误。我把它们整理成一张决策表:
| 你的问题 | 答案 | 对应关系 |
|---|---|---|
| 子类是不是父类的一种? | 是 | 继承 |
| 这个类是不是要实现某个契约? | 是 | 实现 |
| 这个类只是临时用一下另一个类? | 是 | 依赖 |
| 这个类长期持有一个字段? | 是 | 关联 |
| 持有的对象能从外部传进来、能独立存活? | 是 | 聚合 |
| 持有的对象在内部创建、随整体销毁? | 是 | 组合 |
6.2 从代码反推类图关系的实操流程
拿到一段陌生代码,怎么快速反推出类图关系?我的流程是这样的:
- 先扫
extends和implements,把所有继承和实现关系画出来,这部分最机械,不会有歧义。 - 再扫成员字段,每个字段对应一条关联线,然后逐个判断是普通关联、聚合还是组合——看字段是构造注入还是内部 new,看有没有对外暴露独立引用。
- 最后扫方法签名,参数类型、返回类型、方法体内 new 出来的对象,这些对应依赖线。
- 补多重性,集合字段标
*,可空字段标0..1,必填单字段标1。
这个流程走一遍,一个中等规模模块的类图基本就出来了。我一般用纸笔先画草稿,理清关系之后再上工具。工具方面,文本化的 PlantUML 和 Mermaid 都行,但注意 Mermaid 在部分渲染环境里对菱形支持有限,PlantUML 的类图语法更完整,写起来也直观:
@startuml class Order { - orderId: String + addItem(item: OrderItem): void } class OrderItem class Customer class Address Order "1" *-- "1..*" OrderItem : 组合 Customer "1" o-- "0..1" Address : 聚合 Customer "1" -- "0..*" Order : 关联 @enduml上面这段里*--是组合,o--是聚合,--是普通关联,<|--是继承,<|..是实现,..>是依赖。记住这几个符号,写文本类图基本够用了。
6.3 关系选错之后的重构信号
最后聊聊关系选错了会有什么症状,这些症状就是重构的信号:
- 继承用错:子类里出现大量
throw new UnsupportedOperationException()的空实现,说明子类根本不是父类的一种,该改成组合或接口。 - 依赖画成关联:类里存了一堆只用一次的字段,构造函数参数越来越多,说明这些本该是方法参数,改成依赖能降低耦合。
- 组合画成聚合:部分对象被外部随意传入传出,状态经常不一致,说明生命周期该由整体掌控,改成组合。
- 聚合画成组合:部分对象想复用到别处却拿不到引用,只能复制一份,说明该放开成聚合。
这些信号我在实际项目里都遇到过,尤其是第一条和第三条,改起来动静不小,但改完之后类的职责清晰很多。类图关系的本质是用图形语言把设计意图固化下来,图画对了,代码结构往往也跟着对了。
7. 画类图时那些文档里不会写的经验
前面讲的都是“应该怎么画”,这一节聊点实操层面的东西——工具选择、评审习惯、常见翻车点。这些内容教科书上不会有,但真正画图的时候天天遇到。
7.1 工具选型:文本化优先,别一上来就拖拽
类图工具分两类:拖拽式的图形工具和文本化的描述语言。我的建议是优先用文本化工具,PlantUML 是首选,Mermaid 作为备选。原因很实际:文本类图能进版本库,改一个关系就是改一行文本,diff 看得清清楚楚;拖拽式工具画出来的图是二进制或者私有格式,改起来靠手拖,团队协作时冲突没法合并。
当然,如果是给非技术方做汇报,拖拽工具画出来的图确实更好看,这时候可以先用文本工具把关系理清楚,再导到图形工具里美化。但关系逻辑一定要先在文本层面定下来,别一边拖一边想,容易画着画着就乱了。
7.2 类图评审时我必问的三个问题
团队里评审类图,我一般只问三个问题,基本能覆盖大部分设计问题:
第一问:“这条实线是关联还是聚合?部分对象从哪来的?”——逼着画图的人说清楚生命周期归属。
第二问:“这个继承,子类能理直气壮说‘我是一种父类’吗?”——筛掉为了复用而滥用的继承。
第三问:“这个类伸出去这么多依赖线,它到底负责几件事?”——发现职责过载的类。
这三个问题问下来,类图上大部分含糊的地方都会被逼出明确答案。评审的目的不是挑刺,而是让画图的人把脑子里模糊的关系想清楚,图画清楚了,代码写起来自然顺。
7.3 别把类图画成代码镜像
最后一个经验,也是我踩过最深的坑:类图不是代码的逐行翻译。早期我画类图,恨不得把每个字段每个方法都画上去,结果图大得像一面墙,没人看。后来才明白,类图的价值在于突出关系结构,而不是罗列成员。一个类画三五个关键成员就够了,方法只画跟关系相关的,字段只画体现关联的。
具体做法:画图前先问“这张图要回答什么问题”。如果是回答“模块之间怎么依赖”,那就重点画依赖和关联,成员细节省略;如果是回答“继承体系怎么设计”,那就重点画继承和实现,字段可以全略。一张图只回答一个问题,这个原则让我画的类图从“没人看”变成了“评审时被追着问细节”。
类图这东西,画多了会发现它其实是一种思维训练——逼着你在写代码之前把关系想清楚。六种关系看起来是六个符号,背后是六种不同的耦合强度和生命周期约定。把这几组关系吃透,再回头看自己写过的代码,很多“当时觉得没问题、后来改起来要命”的地方,根子都在关系选错了。我现在的习惯是,动手写一个稍微复杂点的模块之前,先在纸上把类图草稿画出来,关系线画不顺的地方,代码结构八成也有问题。这个习惯帮我省下的返工时间,远比画图花的时间多。