尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

三秒判断UML类图关系:依赖、关联、聚合、组合、泛化、实现

发布时间:2026/9/30 1:38:49

资讯中心
01
ARTICLE

三秒判断UML类图关系:依赖、关联、聚合、组合、泛化、实现

三秒判断UML类图关系:依赖、关联、聚合、组合、泛化、实现
上周做代码评审同事在白板上画了一张类图空心菱形和实心菱形画反了会议室里两派人为这到底是聚合还是组合争了二十分钟最后谁也没说服谁。散会以后我翻了翻项目里的旧设计文档发现依赖、泛化、实现、关联、聚合、组合这六个词被混用的次数远比画错的次数多。更麻烦的是很多人嘴上讲得头头是道落到代码里就露馅——把依赖写成关联把组合写成聚合把泛化当成万能胶水到处粘。这篇东西我打算把六个关系从头捋一遍。不是复述课本定义而是回答一个更实际的问题你在写代码、看别人的代码、或者被面试官追着问的时候凭什么判断这一段是依赖而不是关联凭什么说这两个类之间是组合而不是聚合。我会用 Java 举例因为大部分后端项目绕不开它但结论对所有面向对象语言都通用。无论你是刚学完 UML 画图的学生还是干了几年却一直靠感觉判断的老手看完之后应该能在三秒内说出一个关系的名字并且讲得出理由。1. 先把关系强弱这条线拉直六种关系其实坐在两把椅子上大多数人学这六个关系的时候是当成一个列表背下来的背完就乱。真正好用的办法是先承认一件事它们不在同一个维度上。依赖、关联、聚合、组合这四个描述的是两个类之间耦合从松到紧的一条连续谱泛化和实现描述的是另一件事——子类型和父类型之间的替代关系。两把椅子坐的人不一样硬塞到一条排序里当然会晕。1.1 用耦合度给依赖、关联、聚合、组合排个座次耦合度这个词听起来玄说白了就是A 变了B 要不要跟着改以及A 的生命周期B 管不管。按这个标准排下来从松到紧是依赖、关联、聚合、组合。依赖B 只在某个瞬间用到 A用完就忘。A 换了实现B 顶多改一行方法调用。关联B 长期知道 A 的存在持有一个 A 的引用但两者生死无关。聚合A 是整体B 是部分整体由多个部分组成但部分能脱离整体活着。组合A 是整体B 是部分部分的生命周期被整体牢牢攥在手里整体没了部分也就没了。你会发现这条线里藏着两个不同的判断标准在交替起作用前两个看的是持有时间后两个看的是生命周期归属。这也是为什么依赖还是关联和聚合还是组合这两组问题最容易混因为它们的判别依据根本不是同一套。我在实际项目里的做法是先判断这属于持有时间这一类还是生命周期这一类再看具体细节。上来就问这是聚合还是关联等于没想清楚问题。1.2 泛化和实现为什么不能塞进强弱排序里泛化是 is-a学生是人猫是动物。实现是 can-do可飞行、可序列化、可支付。它们描述的是类型之间的替代能力不是对象之间的持有关系。这两者的代码痕迹非常明显一眼就能认出来泛化对应extends类是白盒复用子类能拿到父类的 protected 成员也能覆写父类方法实现对应implements接口是黑盒契约实现类只能看到接口暴露的方法签名里面怎么写的完全无所谓。为什么说它们不能塞进强弱排序因为耦合度这个尺子在这儿不适用。你没法说继承比聚合耦合更紧这话只在特定场景下成立——经典的组合优于继承说的是复用方式的选择不是 UML 关系的耦合排序。把这两个维度搅在一起是很多人后面越学越乱的根源。真正需要排序的只有那四种持有关系泛化和实现单独拿出来讨论该不该用。1.3 一张表先把直觉建立起来先看这张表建立第一层直觉后面每一节再展开讲细节关系图示记号代码形态生命周期语义依赖虚线箭头方法参数、局部变量、静态调用无临时使用关联实线可带箭头成员字段各自独立长期知道聚合空心菱形成员字段外部注入各自独立整体-部分可分离组合实心菱形成员字段内部创建同生共死整体-部分强拥有泛化空心三角实线extends无关is-a实现空心三角虚线implements无关can-do提示菱形永远画在整体那一端。这一点很多人画反画反了整张图的意思就反了。记号这块还有个小细节值得说聚合和组合的菱形都贴在整体一侧箭头方向在关联里表示谁知道谁。如果你用的是只画线不画箭头的简化风格那至少要保证菱形的方向正确因为这决定了读者理解谁拥有谁。2. 依赖和关联的分界线临时借用还是长期持有这一组是我见过被混得最狠的。原因很简单在代码里它们看起来都是A 用了 B但一个是顺手借一下另一个是我认识他很久了。判断依据其实只有一条但这条依据经常被忽略——看 B 是不是作为字段长期存在。2.1 依赖出现在参数、局部变量、静态调用里依赖的信号非常明确只要出现下面任意一种基本就可以判定public class OrderService { // 1. 方法参数里出现 public void submit(Order order, PayChannel channel) { channel.pay(order.getAmount()); } // 2. 方法体里的局部变量 public String render(Long orderId) { Order order orderRepository.findById(orderId); return order.toString(); } // 3. 静态方法调用 public long now() { return System.currentTimeMillis(); } }方法执行完这个引用就断了。OrderService不会记住PayChannel下次调用可能换一个完全不同的实现。这就是依赖的本质用完即弃的临时关系。这里有个容易被忽略的点如果 B 是通过方法参数传进来的接口那么依赖关系其实发生在调用方和接口之间而不是和具体实现类之间。很多人在画图的时候把箭头指向实现类这是错的——运行时确实落到实现上但编译期的依赖是冲着接口去的。这一点在讲通面向接口编程的时候很关键。还有一类常被漏掉的依赖静态方法调用和new出来的临时对象。你可能会觉得new了一个对象就算是关联了其实不是只要它没被存进字段出了方法就没了仍然是依赖。判断标准永远是生命周期是否跨越方法调用不是有没有 new。2.2 关联字段里躺着对方的引用一旦 B 变成了 A 的成员字段关系立刻升级为关联public class Order { private Customer customer; // 关联订单始终知道是谁下的单 private ListOrderItem items; public Customer getCustomer() { return customer; } }关键在于这个引用会一直躺在对象里直到对象自己被回收。订单对象只要活着它就知道客户是谁。客户对象被销毁了订单还能拿到一个失效的引用——两者生命周期互不干涉这是关联区别于组合和聚合最重要的一条。关联还分单向和双向。上面这个例子是单向的订单知道客户客户不知道订单。如果Customer里也放了一个ListOrder那就是双向关联。双向关联在代码里写起来方便但维护成本高两边都得保证同步更新否则很容易出现订单里有这个客户客户里却没有这张订单的不一致。我的经验是能做成单向就别做双向实在需要反向导航用查询代替字段持有。2.3 多重性关联上那个容易忽略的数字关联线上通常还会标注多重性像1、0..1、*。这个数字不是装饰它直接决定了字段是单个对象还是集合多重性字段形态说明1private Customer customer;必须有且只有一个0..1private Customer customer;可能为 null*private ListOrderItem items;0 到多个1..*private ListOrderItem items;至少一个通常用校验保证很多人画图的时候随手写个*代码里却写成单个字段图和代码两张皮时间一长没人信图。我现在的习惯是图上的多重性必须和字段类型一一对上对不上就说明设计还没想清楚。2.4 我判断依赖还是关联用的三步法评审代码的时候我用一套很机械的流程来判断基本不会错看 B 有没有作为字段出现。有字段直接跳关联没字段往下走。看 B 是不是只在方法签名、局部变量、静态调用里出现。是判依赖。看这个引用会不会跨越多次方法调用存活。会就得回到第 1 步重新确认说明你可能漏了一个字段。这套流程听起来啰嗦用起来大概三秒钟。真正需要动脑子的情况只有一种B 存在某个缓存里、线程局部变量里、或者框架的上下文里。这时候严格来说它仍然是关联因为生命周期跨越了方法调用只是持有方式比较隐蔽。框架代码里这种写法很多读的时候要特别留意。3. 泛化与实现继承树上的是什么与能做什么这两者的代码痕迹太明显了反而容易让人停止思考——看到extends就写泛化看到implements就写实现机械填词。真正难的从来不是识别而是判断该不该这么设计。识别只是第一步选型才是要命的地方。3.1 泛化就是 is-a但 is-a 不等于就该继承泛化的判定标准是 is-aSavingsAccount是Account所以SavingsAccount extends Account。但现实里诱惑太多很多人会忍不住把能共享代码当成继承的理由// 反面例子为了复用几个工具方法而继承 public class OrderService extends BaseService { // BaseService 里有一堆 log、checkParam、toJson 方法 }OrderService不是BaseService的一种它只是借用了一些方法。这种继承会让类图变得极其难看而且一旦BaseService改了所有子类都要跟着重新测。正确的做法是把这些方法抽成工具类或者通过组合引入代码虽然多写几行但耦合度大幅下降。我在项目里立过一条规矩新增一处继承必须能说出一句子类是一种父类且这句话读起来不别扭。如果这句话需要加严格来说在某种场景下这种修饰词那就不是 is-a。3.2 实现是接口契约策略模式就是它的样板间实现的语义是 can-do。接口定义一组方法签名任何声明实现该接口的类都必须提供具体实现。策略模式是最典型的例子public interface DiscountStrategy { BigDecimal apply(BigDecimal price); } public class VipDiscount implements DiscountStrategy { Override public BigDecimal apply(BigDecimal price) { return price.multiply(new BigDecimal(0.8)); } } public class NormalDiscount implements DiscountStrategy { Override public BigDecimal apply(BigDecimal price) { return price; } }从类图上看VipDiscount到DiscountStrategy是虚线加空心三角也就是实现关系。调用方只依赖接口运行时注入哪个实现由配置或工厂决定。这就是实现关系最大的价值把变化点从调用方挪到装配层。你会发现实现关系在代码里天然就是多态入口。凡是看到接口被实现多次大概率就是一个可替换的扩展点。反过来如果一个接口全项目只有一个实现类还没人会去替换它那这个接口大概率是多余的——除非是为了将来做单元测试打桩。3.3 接口被当目录用之后实现关系就烂了见过不少项目把所有对外方法都塞进一个接口叫XxxService然后XxxServiceImpl去实现它。这个接口和实现类一一对应没有任何第二个实现也没有任何测试替身。这种实现关系在类图上看起来正确在设计上其实是在浪费一层抽象。更糟的是当接口变成目录以后本来该用关联表示的关系被记成了实现。比如某个类只是持有了UserService的引用用来查数据它并不是一种UserService但由于大家都在implements画图的人顺手就画成了实现关系。这就是机械识别的后果——只看关键字不看语义。我的判别方式很土如果一个关系可以用A 认识 B来描述那是关联只有当A 是一种 B或者A 能完成 B 承诺的所有动作时才写实现或者泛化。3.4 抽象类还是接口四条我常用的经验选型这块我总结过四条经验写下来给自己用需要共享状态字段和部分实现用抽象类。接口里放字段很别扭也不是它的本意。只是定义一组能力供不同体系实现用接口。比如Comparable、Serializable任意类都能实现。需要多重继承能力只能选接口。Java 里一个类只能继承一个父类但可以实现多个接口。预计未来接口还会长方法优先抽象类因为接口加方法会波及所有实现类Java 8 之后的默认方法缓解了这个问题但别把它当成万能补丁。还有一条经验是给团队用的接口一旦发布出去被外部依赖加方法就是破坏性变更。所以在设计初期宁可少放几个方法也不要为了以后可能用到塞一堆进去。4. 聚合与组合生命周期归属才是那道真正的分水岭终于到最难的一组了。聚合和组合在代码里长得几乎一模一样——都是成员字段都是整体持有部分。唯一的区别藏在一个你写代码时经常忽略的地方部分对象是由谁创建的又由谁负责销毁。这一条抓住了判断就能立刻分出胜负。4.1 聚合整体散了部分还能独立存在聚合的语义是拥有但不独占。最典型的例子是部门和员工public class Department { private ListEmployee employees; public Department(ListEmployee employees) { this.employees employees; // 从外部传入不自己创建 } public void remove(Employee e) { employees.remove(e); // 移除之后员工对象依然存在 } }员工对象是由外部创建好再传进来的部门解散了员工还在还能被分配到别的部门或者干脆作为独立对象继续存在于内存里。这就是聚合部分可以脱离整体独立生存。判断聚合只要问一句话把整体删了部分还能不能用能就是聚合。统考这个标准链路聚合这种网络概念跟 UML 聚合其实没关系名字撞了而已。数据库里的GROUP BY聚合函数也是同理别被同一个词骗了。4.2 组合整体销毁部分跟着一起走组合的语义是强拥有同生共死。订单和订单项是标准答案public class Order { private final ListOrderItem items new ArrayList(); public Order() { // 订单项由订单自己在内部创建 } public void addItem(String sku, int qty, BigDecimal price) { this.items.add(new OrderItem(sku, qty, price)); } }这里的关键差异在于OrderItem是Order自己在内部new出来的外部拿不到也不该拿到它的独立引用。订单被删除订单项就失去了存在的意义——它没有独立的业务身份离开订单毫无价值。这就是组合。判断组合也是问一句话把整体删了部分还有意义吗没有就是组合。注意组合的销毁在 Java 里不代表对象立刻被回收垃圾回收是另一回事。这里说的是语义上的生命周期归属不是内存管理。指望通过组合来避免内存泄漏是想多了该断的引用还是得断。4.3 从构造和销毁代码看两者的真实差别把两种写法并排放在一起差别一目了然维度聚合组合部分对象的创建者外部整体内部部分对象能否被外部引用能通常是共享的通常不暴露整体销毁后部分的状态依然可用失去意义构造函数形态部分作为参数传入整体内部自行new典型例子部门与员工、班级与学生订单与订单项、人与心脏我在代码评审时会重点看构造函数如果构造函数接收了一组子对象并直接赋值给字段这是聚合的信号如果构造函数里自己new出子对象并且不提供替换入口那是组合的信号。当然也有例外比如为了测试方便把子对象作为参数传进来但运行时行为上仍然是组合语义。这种情况我会在注释里写清楚免得后人看图疑惑。4.4 订单与订单项、部门与员工这两个经典例子的边界这两个例子被用烂了但边界其实比想象中模糊。举个实际场景电商系统里运营想把某个订单里的某件商品拆出来生成售后单。如果按严格的组合设计订单项不该被外部引用那你拆单就得复制一份数据。这种情况下业务上要的是部分可独立存在那就该往聚合靠。反过来有些团队把员工设计成组合理由是员工离职了这条记录就不该存在。但员工在现实世界里显然是可以独立存在的实体只是在这套系统的某一段时间里和部门绑定。硬套组合会导致数据模型扭曲比如把员工表的所有字段冗余进部门表。我的判断顺序是这样的先问业务这个部分有没有自己独立的身份标识和查询入口有多半是聚合。再问生命周期整体删除时业务上要不要一并清理部分要倾向组合。最后问实现部分对象是从外部传进来的还是内部造出来的这一条通常能验证前两条。三条都对得上判断基本不会错。5. 一段代码把六种关系全串起来光看片段容易记住串成一整段才看得清全貌。下面这个例子是简化过的订单支付流程六种关系全都出现了。5.1 完整示例// 泛化VipUser 是一种 User public class VipUser extends User { private int level; } // 实现两种支付方式各自实现支付契约 public interface Payable { boolean pay(BigDecimal amount); } public class WalletPay implements Payable { Override public boolean pay(BigDecimal amount) { return true; } } public class CardPay implements Payable { Override public boolean pay(BigDecimal amount) { return true; } } // 组合订单项由订单内部创建 public class OrderItem { private String sku; private int quantity; } // 关联 组合 依赖 public class Order { private User owner; // 关联 private final ListOrderItem items new ArrayList(); // 组合 public Order(User owner) { this.owner owner; } public void addItem(String sku, int qty) { items.add(new OrderItem(sku, qty)); } public boolean checkout(Payable payable) { // 依赖 BigDecimal total BigDecimal.ZERO; for (OrderItem item : items) { total total.add(BigDecimal.valueOf(item.getQuantity())); } return payable.pay(total); } } // 聚合购物车与商品商品来自外部 public class Cart { private ListOrderItem items; public Cart(ListOrderItem items) { this.items items; } }5.2 逐行对照关系把上面的代码和六种关系一一对应起来VipUser extends User泛化is-a。WalletPay implements Payable实现can-do。Order.owner字段关联订单长期持有用户引用用户和订单各自独立。Order.items字段配合内部的new OrderItem组合订单项由订单自己造订单没了订单项没意义。checkout(Payable payable)参数依赖只在方法执行期间使用。Cart.items字段配合构造传入聚合购物车清空后商品仍然存在。同一段代码里OrderItem同时出现在组合和聚合两个场景里。这说明什么关系不是类的固有属性而是两个类之间那条边的属性。同样两个类在不同上下文里可能是聚合也可能是组合。很多人卡在这就是因为把关系当成了类本身的标签。5.3 容易判错的四个位置结合实际评审经验这四个位置最容易判错购物车和商品很多人写组合理由是购物车删了商品就没了。错。商品在商品库里活得好好的购物车里的只是一个引用这是聚合。订单和支付记录写成组合的人也不少。但支付记录有独立的审计价值订单删了记录也得留这是聚合。用户和用户详情这个通常是组合详情数据离开用户没有独立身份且一般是一对一强绑定。线程池和任务任务是外部提交进来的虽然执行完就消失但线程池不拥有它这是聚合偏依赖的边界情况。严格说任务作为字段存在任务队列里时是聚合只是队列和任务的生命周期差异很大。判断的时候把这个部分有没有独立存在的理由这句话默念一遍八成能想清楚。6. 工程里那些同名不同义的依赖和组合这六个词之所以让人糊涂还有个外部原因它们在工程其他领域的含义和 UML 里完全不同。你要是没意识到这一点就会在脑海里把几个概念搅成一锅粥。我把常见的几个同名概念拉出来对一下。6.1 Spring 依赖注入里的依赖和 UML 依赖不是一回事Spring 里说这个 Bean 依赖那个 Bean指的是运行时需要另一个对象协作通常是通过构造函数注入或者字段注入实现的。这种依赖在 UML 里绝大多数对应的是关联而不是依赖——因为它是作为字段长期持有的。Service public class OrderService { private final UserRepository userRepository; // Spring 说是依赖UML 说是关联 public OrderService(UserRepository userRepository) { this.userRepository userRepository; } }我在面试里遇到过有人因为这个说错了。正确答案是Spring 的依赖注入是装配方式UML 的依赖是关系类型两者描述的不是同一个层面的事。一个类通过构造注入持有另一个类的引用这在 UML 里就是一条普通的单项关联只是它的实现手段恰好是注入而已。顺便说一句依赖倒置里的依赖也是这个意思——高层模块不直接依赖低层实现而是依赖抽象描述的是模块间的依赖方向不是 UML 的具体关系类型。6.2 Maven 依赖管理是构建期的事别往类图上搬Maven、Gradle 里的依赖指的完全是另一回事构建期需要哪些 jar 包。pom.xml里写一段dependency说的是编译和运行阶段要把哪个库放到 classpath 上和类图上的依赖关系没有任何对应关系。真正和 UML 有关联的是包依赖这个概念——A包用到了B包里的类这就构成包级别的依赖。但粒度完全不同类图上的依赖是两个类的关系Maven 的依赖是两个构件的关系。有些工具能从字节码里分析出包依赖图那个图倒是和 UML 的包图思路相通但和pom.xml里写的东西仍然不是一回事。6.3 组合优于继承这条原则和 UML 组合是近亲不是同一个人组合优于继承是设计原则组合在 UML 里是一种具体关系。它们确实有关系但不完全等同。这条原则里的组合是广义的指的是把对象作为成员持有也就是 UML 里关联、聚合、组合的统称。它对立的是用继承来复用代码。举个例子与其让Stack extends ArrayList不如让Stack内部持有一个List。后面这种做法在 UML 里可能表现为聚合如果 List 是外部传入的或者组合如果内部创建的取决于具体实现。所以当有人问你组合优于继承里的组合是不是 UML 组合准确回答是它指的是对象持有这一类复用方式具体落到哪种关系要看实现细节。7. 我在项目里踩过的几个判断坑理论讲完了说几个真实踩过的坑。这部分的经验基本没有文档会写但每一条都让我改过至少一次设计。7.1 把聚合写成组合删主对象的时候连带删了不该删的早年的一个后台系统我把标签设计成了文章的内部对象字段是private ListTag tags创建文章的时候顺手new出来。设计的时候觉得挺干净后来运营提需求标签库要能单独维护同一批标签要能复用到多篇文章上。这时候问题就暴露了——标签被文章独占改一篇的标签会影响另一篇因为大家共享的都是同一个对象不恰恰相反因为当初是内部 new 的每篇文章的标签都是副本改一处不会同步。最后只能推倒重来把标签改成从标签库查出来再注入关系从组合改成了聚合加关联。这次教训让我明白一件事在判断聚合还是组合之前先问业务上这个部分会不会被共享。只要存在共享的可能组合基本就是不合适的。7.2 关联写成依赖导致每次调用都重新查一次库另一个坑出现在查询服务里。有个方法每次调用都会userRepository.findById(id)查一次用户。单次调用没问题但在一个循环里反复调就变成了典型的 N1 查询。这个问题本身和 UML 关系不大但我发现根源恰恰是关系判断错了我潜意识里把这个用户当成了临时用一下的依赖所以每次用都重新取。如果当初就明确这是关联——服务在这一次业务操作里持续需要这个用户对象——就会自然地把它取出一次、放在方法上下文里复用。改法很简单把对象在方法开头取一次后面都复用。加一句缓存QPS 上去了数据库压力下来了。这件事让我意识到UML 关系不只是画图用的它会反过来影响你的代码结构。7.3 被追问时的回答思路面试被问到这是聚合还是组合的时候我的建议是别急着给结论先说判断依据。可以这样组织说明这个部分对象是谁创建的是内部new还是外部传入。说明整体销毁后部分的状态能不能独立存活。给出关系名称并补一句如果业务上允许共享我会改成聚合。这种回答方式的好处是即使你的结论和面试官预期不太一样只要依据说得通对方也能看出你是真懂而不是背的。我面别人的时候最怕的就是上来直接喊组合然后问为什么就答因为菱形是实心的。这是纯背图没有任何判断能力。再补一个日常沟通里的小技巧和产品经理讨论对象归属的时候别用聚合组合这种词对方听不懂。直接问这个东西删了那个东西还要不要留用业务语言对齐再回代码里翻译成 UML 关系沟通效率会高很多。实际操作中还有一个我常用的验证手段画完类图之后把图上的关系逐条翻译成代码看能不能对上。对不上的地方八成是图错了也有可能是代码写得不符合设计。不管哪种情况都说明你发现了问题。这个来回翻译的过程比重读一遍课本有用得多。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。