1. 类关系是设计模式的地基
1.1 为什么学模式要先过类关系这道关
设计模式学到一定程度,很多人会卡在一个地方:单个模式看讲解都能懂,一画UML图就露馅,两个模式往一起组合就懵。我碰到过不少读者私信问我,说策略模式和状态模式到底区别在哪、装饰器模式和代理模式怎么长得这么像。其实这些问题的根源往往不在模式本身,而在类与类之间的关系没拎清。
23种设计模式,说白了就是在编排类与类之间的互动方式。创建型模式处理的是“谁创建了谁”,结构型模式处理的是“谁持有谁、谁包装谁”,行为型模式处理的是“谁调用了谁”。这些“谁和谁”落到代码层面,最终都能归到六种基本类关系上:依赖、关联、聚合、组合、继承、实现。每一种设计模式,都是这六种关系的某种组合应用。
举几个例子你就明白了。策略模式的核心,是Context类与Strategy接口之间的关联关系,运行时通过替换不同的具体策略实现来改变行为。观察者模式里,Subject持有Observer的列表,这是一种聚合关系。装饰器模式里,装饰器拿着被装饰对象的引用,同时又继承同一个抽象类,这是关联加继承的混合。工厂模式里,工厂方法和具体产品之间是依赖加实现。可以说,类关系不理解透彻,设计模式就永远是浮在表面的名词。
所以这一篇我不急着讲新模式,先把类与类之间的六种关系掰开揉碎。这不仅是设计模式的共同语言,也是你以后看源码、画架构图、评审别人代码时判断设计好坏的基本功。
1.2 先建立六种关系的整体认知
在深入细节之前,先给你一张六种关系的总览表。这张表我建议你截图保存,或者抄在笔记本上。后续所有模式的分析,你都可以对照这张表来看。
| 关系类型 | 强度 | 典型代码特征 | UML表示 | 记忆锚点 |
|---|---|---|---|---|
| 依赖 | 最弱 | 局部变量、方法参数、静态调用 | 虚线 + 箭头 | 临时借工具,用完即还 |
| 关联 | 弱 | 成员字段 | 实线 + 箭头(可双向) | 长期合作伙伴,互相认识 |
| 聚合 | 中 | 成员字段,对象从外部传入 | 空心菱形 + 实线 | 整体与部分,可分可合 |
| 组合 | 强 | 成员字段,对象由整体new出来 | 实心菱形 + 实线 | 同生共死,不可分割 |
| 继承/泛化 | 强 | 类继承类 | 空心三角 + 实线 | is-a,我是你的一种 |
| 实现 | 中 | 类实现接口 | 空心三角 + 虚线 | 遵守契约,履行约定 |
这里的关系强度,是站在“类与类之间耦合程度”的维度去排序的。依赖最弱,因为是一次性的临时接触;组合最强,因为两部分的生命周期完全绑定。实战中,类关系从弱到强,对应的代码修改影响范围也从局部到整体。越弱的关系,越容易替换和测试,这也是为什么设计模式一直在强调“面向接口编程”“组合优于继承”——本质上是希望你把类之间的耦合控制在一个合适的强度。
接下来我按强度从弱到强,把每种关系拆开讲。
2. 六种关系的判定口诀与细节拆解
2.1 依赖关系:方法里一闪而过的临时接触
依赖是最容易理解也最容易被忽视的关系。它的定义很直白:一个类在某个方法的实现过程中,临时使用到了另一个类,使用完之后不保留任何痕迹。这个“临时使用”通常表现为三种形式:方法内部new了另一个类的对象、方法参数中接收了某个类并在方法内调用其方法、直接调用某个类的静态方法。
用代码说话:
class Logger { public: void log(const std::string& msg) { // 依赖Formatter,只是局部使用 Formatter fmt; std::string formatted = fmt.format(msg); std::cout << formatted << std::endl; } };这个例子里,Logger对Formatter就是依赖关系。Logger的方法里创建了Formatter对象,用完就销毁,Logger不持有Formatter的任何成员字段,Formatter也感知不到Logger的存在。
判定口诀:方法里一闪而过,用完就丢,不存字段。
为什么依赖关系值得专门讲?因为它是六种关系里耦合度最低的,也是你最容易无意识忽略的。很多人画类图的时候完全不画依赖关系,觉得它太琐碎。但在设计模式的语境里,依赖的方向往往决定了扩展点在哪里。比如工厂模式里,Creator依赖于Product接口——注意,是接口,不是具体类。如果Creator直接依赖具体产品类,那每加一种新产品就要改Creator,违反开闭原则。如果依赖接口,新产品的扩展就不需要动Creator了。
这里的实战体会是:依赖虽然弱,但它直接影响你的代码能不能“拔插”。依赖具体类是死结,依赖接口才是活口。这也是为什么很多设计模式强调“不要让高层模块依赖低层模块,而要都依赖抽象”——说的就是依赖关系的方向问题。
2.2 关联关系:类与类之间的长期合作伙伴
关联关系比依赖强一个档次。当一个类把另一个类作为自己的成员字段持有,并且这种持有关系是长期存在的,二者之间就是关联关系。关联关系可以单向也可以双向,可以是一对一也可以是一对多。
继续用C++示例:
class Teacher { private: std::vector<Student*> students_; public: void addStudent(Student* s) { students_.push_back(s); } }; class Student { private: Teacher* teacher_; public: void setTeacher(Teacher* t) { teacher_ = t; } };这个例子里,Teacher持有Student的列表,Student又持有Teacher的指针,这就是双向关联。如果只有一边持有,就是单向关联。
判定口诀:你认识我,我也认识你(或单方面认识),关系持续存在,不随方法调用结束而消失。
关联关系是设计模式中出现频率最高的关系。策略模式里Context持有Strategy的引用,观察者模式里Subject持有Observer的列表,模板方法模式里子类持有父类的引用……这些都是关联。为什么模式偏爱关联?因为它是一种“可替换”的关系——被关联的对象可以在运行时被替换成另一个实现,只要接口不变就行。
实操中有个容易踩坑的点:双向关联到底该不该用?很多新手为了图方便,让两个类互相持有对方的指针,结果代码耦合得死死的,改一方就要动另一方。我的建议是:能单向就不要双向。如果确实需要双向访问,优先考虑用观察者的思路来做反向通知,而不是直接互相引用。你画类图时也应该明确标注关联的方向,避免别人误用。
2.3 聚合关系:整体与局部的松散共存
聚合是关联关系加强版,它多了一层语义:整体与部分。聚合表达的是“整体拥有部分”的主从关系,但整体和部分在生命周期上互不依赖。经典例子是公司与员工:公司倒了,员工还在,可以跳槽去别的公司;员工走了,公司也还能继续招人。菱形放在整体那一端。
代码上,聚合和关联的写法几乎一样,区别在于语义和接口。聚合关系中,部分对象的创建通常不是由整体来完成的,而是外部创建好之后传进来。看这个例子:
class Company { private: std::vector<Employee*> employees_; public: void hireEmployee(Employee* e) { employees_.push_back(e); } }; class Employee { // 员工不持有Company引用,单向聚合即可 };Company的hireEmployee方法接收外部传入的Employee指针,Company负责管理这些员工,但不负责创建和销毁他们。这种“外部传入、整体管理”的代码结构就是典型的聚合。
判定口诀:整体没了,部分还能活;部分原本就属于外部,整体只是“借用”它。
聚合关系在模式中的典型代表是观察者模式。Subject(被观察者)持有Observer(观察者)的列表,但Observer对象完全是外部创建、外部管理的。Subject销毁时,Observer们还活得好好的。还有一个容易忽略的点:聚合有个重要的放松规则——部分可以被多个整体共享。一个员工可以同时是A公司外包、又在B公司兼职,这在聚合语义下是完全合法的。
实战中的价值在于:当你需要实现“整体-部分”结构时,如果部分对象的生命周期独立于整体,那就用聚合,给整体提供一个添加和删除部分的接口,而不是在整体内部直接new出部分对象。
2.4 组合关系:整体与局部的同生共死
组合是聚合的进一步强化。组合关系里,整体与部分是“同生共死”的绑定关系。整体被创建时,部分也随之创建;整体销毁时,部分一并销毁。部分的存在完全依赖整体。经典例子是人跟心脏的关系:人没了,心脏也就不复存在;反过来,心脏不可能脱离人体而独立保存。
代码上,组合的典型特征就是“整体在内部new出部分”。构造函数里创建部分对象,析构函数里销毁部分对象。看代码:
class Human { private: Heart* heart_; public: Human() { heart_ = new Heart(); } ~Human() { delete heart_; heart_ = nullptr; } };Human在自己的构造函数里创建了Heart对象,在析构函数里负责销毁。Human销毁的瞬间,Heart的生命也就结束了。这就是组合。
判定口诀:整体没了,部分也得没;部分由整体亲手创造,也由整体亲手埋葬。
组合关系在设计模式里同样无处不在。装饰器模式中,装饰器对被装饰对象的关系很微妙:装饰器持有被装饰对象的引用,同时装饰器又继承同一个抽象组件类,所以既有聚合的成分又有继承的成分。但更典型的组合是桥接模式里的Implementor,以及状态模式里Context对State的管理——Context在初始化或状态切换时负责创建和替换State对象,State的生命周期由Context完全掌控。
区分聚合和组合,有个非常实用的判断方法,问自己两个问题:
第一,部分离开整体后还能存活吗?能就是聚合,不能就是组合。第二,部分能被另一个整体共享吗?能共享更趋向聚合,不能共享就趋向组合。
我见过很多人在这两个关系上栽跟头,画图时菱形画空的还是实心的拿捏不准。你只要记住代码特征就够了:构造时new出来的是组合,外部传进来的是聚合。不用纠结语义上的细微差别。
2.5 继承(泛化)与实现:两种血缘关系
继承和实现放在一起讲,因为它们都涉及“类和类之间最直白的关系”,一个表达is-a(我是你的一种),一个表达“我遵守你的契约”。
继承(泛化)在C++里用class Dog : public Animal表示,在Java里用extends。Animal是父类,Dog是子类。继承表达的是“特化”语义:子类拥有父类的一切属性和行为,并可以扩展或重写。UML中,继承用实线加空心三角形表示,三角形指向父类。
实现关系在C++里体现为抽象类和派生类,或者接口类和实现类;在Java里体现为implements关键字。UML中,实现用虚线加空心三角形表示,三角形指向接口。
// 继承 class Animal { public: virtual void speak() = 0; }; class Dog : public Animal { public: void speak() override { std::cout << "Woof!" << std::endl; } }; // 实现 class IFlyable { public: virtual void fly() = 0; }; class Bird : public IFlyable { public: void fly() override { // 实现飞行行为 } };注意,在C++里抽象类和接口在语言层面没有Java区分得那么干净,所以“继承”和“实现”在C++里都表现为公有继承。但这不影响设计层面的区分:继承父类表达的是“复用与特化”,实现接口表达的是“能力与契约”。
判定口诀:继承是“我是你的一种”,实现是“我遵守你的约定”。
关于继承,我多说两句。继承关系是六种关系里耦合度极高的关系,因为子类会无条件获得父类的所有实现细节——包括你不想暴露的那些。所以GoF在《设计模式》里特别强调“组合优于继承”。不是说完全不用继承,而是要克制。模板方法模式和工厂方法模式是继承用得比较多的场景,因为它们需要在父类里定义骨架算法,让子类实现可变部分。但一旦你发现继承层次超过三层,或者子类为了复用父类的某个方法而被迫承担了无关的职责,就该停下来考虑用组合或委托替代了。
3. 从代码和UML两个维度把握类关系
3.1 UML类图的六种画法
画类图是理解设计模式的必修课。很多教材上来就丢一张复杂类图,容易劝退新手。其实UML类图里跟类关系相关的画法就六种,先记箭头比记标准简单得多。
| 关系类型 | UML画法 | 方向提示 |
|---|---|---|
| 依赖 | 虚线 + 普通箭头 | 箭头指向被依赖的类 |
| 关联 | 实线 + 普通箭头(可选) | 箭头指向被持有的类,可标1,*表示数量 |
| 聚合 | 空心菱形 + 实线 | 菱形在“整体”那端 |
| 组合 | 实心菱形 + 实线 | 菱形在“整体”那端 |
| 继承 | 实线 + 空心三角 | 三角形指向父类 |
| 实现 | 虚线 + 空心三角 | 三角形指向接口 |
这里面最关键的细节是菱形和三角的摆放方向。聚合和组合的菱形永远画在整体那一侧,它表示“这个类是主人”。继承和实现的空心三角永远指向父类或接口,它表示“箭头指向被依赖的抽象层”。
我见过不少新手画反了方向,把菱形画到部分一侧,这就完全颠倒语义了。画图时心里默念一遍:菱形代表“我有你”,所以在整体侧;三角代表“我是你”,所以指向你。
另外,我一直推荐手画UML图,而不是一上来就上工具。手画可以强迫你在动笔之际把关系理一遍,而工具会自动生成连线,看似快了,反而容易掩盖你的认知盲区。等你能随手画出六种模式以上的类图,再用工具也不迟。
3.2 代码里辨关系的四个信号
对应到代码,判断类之间是什么关系,只需要看四个信号。这个方法是我在实际工作中总结出来的,用来快速审查别人代码的设计意图特别实用。
第一个信号:目标类出现在方法的局部变量或参数里,用完就丢——这是依赖。第二个信号:目标类出现在成员字段里,并且这个对象不是本类创建,而是外部传入——这是关联或聚合。第三个信号:目标类出现在成员字段里,并且本类在构造函数里new了它——这是组合。第四个信号:类声明时继承了某个类或者在重写某个接口的方法——这是继承或实现。
在代码层面就写一个典型的组合示例帮大家巩固:注意构造时new出来的就是更强的关系。
class Order { private: std::vector<OrderItem*> items_; public: Order() { // OrderItem在Order内部创建,生命周期绑定,属于组合 items_.push_back(new OrderItem("apple", 2)); items_.push_back(new OrderItem("banana", 5)); } ~Order() { for (OrderItem* item : items_) { delete item; } items_.clear(); } };在业务代码里,我发现一个很有意思的规律:很多类关系混淆,其实都发生在“新对象到底是谁创建的”这个环节。你只要盯住new关键字出现在哪儿——如果是出现在构造函数里,大概率是组合;如果是外部创建后传进来,大概率是聚合或关联。这个方法屡试不爽。
4. 常用设计模式中的类关系实例
4.1 从类关系角度重新拆解经典模式
掌握了六种关系之后,再回头看设计模式,你会看到完全不同的风景。模式不再是一个个孤立的名词,而是一张张由类关系编织成的网。我挑几个典型的模式,给你画出它们内部的关系谱系。
策略模式:Context到Strategy是聚合关系(Context持有Strategy引用,运行时可替换),具体策略类到Strategy接口是实现关系。所以策略模式的主干就是“聚合 + 实现”的组合套件。Context不会在内部new具体的策略类,而是从外部注入,这保证了它的可扩展性。
观察者模式:Subject到Observer是聚合关系(Subject持有Observer列表,外部注册),ConcreteSubject继承Subject,ConcreteObserver实现Observer接口。观察者模式的关键是Subject聚合了Observer,而且这个聚合关系里的Observer可以随时注册和注销,所以是典型的聚合而非组合。
装饰器模式:这个稍微复杂一点。装饰器类实现了Component接口(或继承抽象组件),同时又持有Component类型的引用。所以装饰器到组件接口是实现关系,装饰器到被装饰对象是聚合关系(因为被装饰对象是从外部传入的)。这种“继承接口 + 聚合对象”的组合很有意思——既遵守了同一份契约,又可以用递归的方式套一层又一层的装饰器。
工厂模式:Creator(工厂)到Product是依赖关系,因为工厂方法内部会创建Product;ConcreteFactory继承Creator,ConcreteProduct实现Product接口。你会发现工厂模式的核心就是把“依赖具体类”转变成“依赖抽象接口”,让创建对象的责任从使用者手里剥离出来。
单例模式比较特殊,它最核心的关系是类到自身的关系——一个类持有自己的唯一静态实例。这算是关联关系的一个变种,自己认识自己。
状态模式:Context到State是组合关系(Context内部可以创建并替换State对象),具体状态类到State接口是实现关系。注意状态模式和策略模式在结构上几乎一致,区别在于策略替换是客户端主动选择的,状态替换是状态自身在运行时触发的。你看,一旦用类关系去切分,两个模式的差异立刻清楚了。
组合模式:Component是抽象组件,Leaf继承Component(实现关系),Composite继承Component并且持有Component的子节点列表(组合关系)。组合模式是“继承 + 组合”的典型代表,目的是让树形结构里的叶子节点和分支节点可以被一致对待。
我用一张表把这几个模式的类关系汇总一下,方便对照复习:
| 设计模式 | 主要类关系 | 关系强度 |
|---|---|---|
| 策略模式 | Context聚合Strategy | 中 |
| 观察者模式 | Subject聚合Observer | 中 |
| 装饰器模式 | 装饰器继承Component且聚合被装饰者 | 中-强 |
| 工厂方法模式 | Creator依赖Product,具体工厂继承Creator | 弱-中 |
| 状态模式 | Context组合State | 强 |
| 组合模式 | Composite组合子节点,节点继承Component | 中-强 |
4.2 类关系强弱如何影响模式选型
理解了类关系之后,最大的收获不是在考试里认出某个模式,而是你在设计一个新模块时,能更精准地判断该用哪种关系。
举个例子。你要做一个游戏中的角色系统,角色有攻击行为、防御行为、移动行为。新手最常见的做法是定义一个超大类,把所有技能都塞进去,子类继承覆盖。用不了多久就发现:不同角色的攻击方式完全不一样,继承体系臃肿不堪。这时候如果你心里有“继承是强耦合,聚合/关联是弱耦合”这个概念,你自然会想到把技能抽象成接口,让角色去持有技能对象——策略模式就这么出来了。
再举个例子。如果一个模块需要管理一组子任务,子任务的生命周期严格跟随主任务的生命周期,主任务销毁子任务也销毁——这是组合关系,直接用内部持有的方式实现最合适。如果子任务可以被多个主任务共享,生命周期独立——这是聚合关系,就应该由外部创建子任务后再传入。
掌握类关系的强度,其实就是在帮你做耦合度决策。弱关系带来的灵活性和可测试性高,但有时候也会因为过度抽象带来理解成本;强关系实现简单直接,但牺牲了扩展空间。真正好的设计,是在不同的场景选择恰到好处的耦合强度,而不是一味追求低耦合。高内聚低耦合说的是同一层级的组件之间,不是让你把所有类都拆成互相无关的碎块。
5. 类关系学习的常见误区和排查经验
5.1 常见的判断误区
我梳理一下最近几年带人和答疑过程中见到的高频误区。
第一,依赖和关联分不清。判断依据很简单:看那个对象有没有成为你类的成员字段。出现在字段里就是关联,只出现在方法参数或局部变量里就是依赖。有人会杠:方法参数里接收的对象调用了很多方法,这算不算关联?不算,参数用完就丢,没有持久化到字段里,就是依赖。
第二,聚合和组合分不清。这个问题上面讲过,判定法则就是看由谁创建。整体构造时new出来的,组合;外部造好了传进来的,聚合。再加一个辅助判据:问一句“部分离开了整体还能活吗”。活不了的是组合,能活的是聚合。
第三,继承和实现分不清。其实在C++里这俩在语法层面是同一个东西,但在设计语义上有明确界限。继承父类是为了复用代码和表达“is-a”,实现接口是为了表达“具备某种能力”。你要是看到某个类继承了一个只有纯虚函数的基类,那它本质上是实现关系,不是继承关系。
第四,画出双向关联就画两条线。UML规范里,双向关联是一条线两个箭头,不是两条线。这是很多新手画图的常见扣分点,也会让图变得很乱。一条线上,两端各标上多重性,比如1对*,一眼就能看懂。
5.2 实战排查技巧:五连问
拿到一段不熟悉的代码,判断类关系时,我会在脑子里过一套问题清单,你也可以试试。
第一问:这个类在哪些位置出现?局部变量、参数还是字段?第二问:这个对象是谁创建的?本类内部还是外部创建?第三问:这个对象的生命周期和本类同步吗?同步则组合,不同步则聚合或关联。第四问:类声明语句是继承还是实现接口?第五问:这段代码的关注点是复用行为还是能力契约?是复用行为用继承,能力契约用实现。
这套五连问基本可以覆盖日常会遇到的关系判断场景。我自己在代码评审时经常用这个思路去引导新人,比直接告诉他答案要有效得多。
5.3 我个人的学习建议
最后分享一点我在实战中沉淀下来的学习心法。设计模式这个东西,千万不要孤立地背名字,一定要自己画图。每学一个模式,就动手画它的UML类图,在边上标注出每一种类关系的类型和方向。画得多了,你会有一种“模式都是关系组合”的通透感。
我自己当年学设计模式的时候,把一个画好的架子上贴上便签,写上这几种关系的记忆锚点:依赖是借工具,关联是交朋友,聚合是搭班子,组合是过命的交情,继承是亲生的,实现是打工仔。虽然糙了点,但确实帮助我在项目里快速识别关系。你可以找你自己的记忆方式,重要的是把“关系”这个概念嵌进日常写代码的思考里,而不是把它当成考试的知识点。
熟练之后,建议你反过来做:拿到一个开源项目,打开一个核心类,不着急看里面的逻辑,先找它跟哪些类有依赖关系、跟哪些类是关联关系、构造函数里new了谁、继承了谁。把这些关系列出来,再去看它的行为逻辑,你会发现阅读源码的速度和理解深度都会有明显提升。这一套方法,是我认为类关系除了应付考试之外最值钱的实战价值。