深入浅出设计模式
前言
设计模式原则
设计模式的基础是一些设计原则,这些原则为我们提供了编写可维护、可扩展和可重用代码的指导。这些原则尤其在面向对象设计中具有重要意义,帮助我们创建更灵活和更健壮的系统。以下是面向对象设计中常见的设计原则,它们是众多设计模式背后的核心思想:
xxxxxxxxxx //取消http代理git config –global –unset http.proxy//取消https代理 git config –global –unset https.proxyJS
- 定义:每个类应该只有一个引起它变化的原因,即类应该只有一个职责。
- 解释:一个类只负责完成一件事,如果一个类承担了过多的职责,当某个职责发生变化时,可能会影响其他职责,导致类变得复杂和难以维护。
- 好处:有助于简化代码,提升代码的可维护性和可读性。
例子:
- 如果一个类既处理用户数据的持久化(数据库操作),又负责用户界面的逻辑,那么它就违反了单一职责原则。可以将其拆分成两个类:一个负责数据库操作,一个负责 UI 逻辑。
开闭原则 (Open/Closed Principle, OCP)
- 定义:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。
- 解释:这意味着我们应该能够通过扩展已有的功能来改变系统的行为,而不应该通过修改已有的代码来实现。这可以通过使用接口、继承、抽象类等手段实现。
- 好处:减少对已有代码的修改风险,提高系统的稳定性和可扩展性。
例子:
- 设计一个图形处理系统时,如果要支持新形状(如圆形、正方形等),我们可以通过新增类来实现,而不需要修改现有的类。
里氏替换原则 (Liskov Substitution Principle, LSP)
- 定义:子类对象应该能够替换掉基类对象,并且程序的行为不会改变。
- 解释:派生类必须能够替换基类,且不影响程序的正确性。这意味着子类在遵循基类的契约时,不能违背其预期行为。
- 好处:通过保证子类和父类之间的兼容性,增强了代码的灵活性和扩展性。
例子:
- 如果一个方法期望接收一个基类对象,那么你应该能够传入任何一个子类对象而不改变方法的行为。比如,如果一个方法能够接受一个
Bird
类型的对象,那么它也应该能正确处理Penguin
类型(Bird
的子类)的对象。
接口隔离原则 (Interface Segregation Principle, ISP)
- 定义:客户端不应该被迫依赖它们不需要的接口;即,一个类对另一个类的依赖应该建立在最小的接口上。
- 解释:接口应该小而精,以便不同的类可以只实现它们真正需要的功能。如果一个接口过大,类就不得不实现那些它们不需要的功能。
- 好处:通过拆分大接口,降低了类的复杂度,避免了类依赖不需要的功能,提高了代码的灵活性和可维护性。
例子:
- 如果一个
Printer
接口包含打印、扫描、复印等方法,而某个具体的打印机只支持打印功能,那么它就不应该被强迫实现扫描和复印功能。可以将Printer
接口拆分成更小的接口,如PrintInterface
和ScanInterface
。
依赖倒置原则 (Dependency Inversion Principle, DIP)
- 定义:高层模块不应该依赖于低层模块,两者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。
- 解释:这意味着应该通过接口或抽象类来依赖抽象,而不是直接依赖具体实现。这样可以降低类之间的耦合度,提升系统的灵活性。
- 好处:通过依赖抽象,系统的模块变得更加松耦合,容易扩展和维护。
例子:
- 假设有一个
EmailService
类用于发送邮件,而一个UserNotification
类依赖EmailService
来发送通知。为了遵循依赖倒置原则,UserNotification
应该依赖于一个抽象的NotificationService
接口,而不是具体的EmailService
。这样可以更轻松地替换或扩展为其他通知方式(如 SMS)。
合成复用原则 (Composite Reuse Principle, CRP)
- 定义:尽量使用对象组合而不是继承来达到代码复用的目的。
- 解释:通过组合,可以将不同类的功能组合起来使用,而不用继承每个类。这种方法比继承更加灵活,因为它允许在运行时动态改变行为。
- 好处:减少了类之间的紧密耦合,增强了代码的可扩展性。
例子:
- 你可以通过组合一个
FlyBehavior
类和一个QuackBehavior
类来创建不同种类的鸭子,而不是为每种鸭子都创建新的子类。
迪米特法则 (Law of Demeter, LoD)
- 定义:一个对象应当对其他对象有最少的了解。即:一个对象应该只与直接相关的对象交互,不应了解其他不必要的对象内部的细节。
- 解释:通过限制对象之间的依赖关系,减少代码的耦合,提升系统的可维护性和灵活性。简而言之,一个对象不应该调用另一个对象的内部对象的属性或方法(即避免“链式调用”)。
- 好处:降低对象之间的耦合度,减少对外部对象实现细节的依赖。
例子:
- 如果对象
A
通过对象B
获取对象C
的信息,然后调用C
的方法,这违反了迪米特法则。对象A
不应该直接与对象C
交互,而是应该通过B
间接操作。
8. 单例原则 (Singleton Principle)
- 定义:确保一个类只有一个实例,并提供一个全局访问点。
- 解释:单例模式通过控制实例化过程,确保某个类的对象只被创建一次,这在需要控制全局状态的场景中非常有用。
- 好处:避免了全局对象的重复实例化,节省资源。
例子:
- 日志类通常是单例,因为应用程序中的所有模块都共享同一个日志实例。
总结
以上这些设计原则是许多经典设计模式(如工厂模式、单例模式、观察者模式等)背后的基础。这些原则帮助开发者编写出高内聚、低耦合的代码,提升系统的可维护性、可扩展性和灵活性。
常用的设计原则可以简要总结为:
- 单一职责:一个类只负责一项职责。
- 开闭原则:对扩展开放,对修改关闭。
- 里氏替换:子类可以替代父类,但不会破坏程序的功能。
- 接口隔离:让类依赖小而专注的接口。
- 依赖倒置:依赖抽象而不是具体实现。
这些原则相互配合,使得代码设计更加清晰、灵活,适用于变化频繁或需要扩展的场景。
概述
设计模式在面试中的考点通常是介绍其原理并说出优缺点。或者对比几个比较相似的模式的异同点。在笔试中可能会出现画出某个设计模式的 UML 图这样的题。虽说面试中占的比重不大,但并不代表它不重要。恰恰相反,设计模式于程序员而言相当重要,它是我们写出优秀程序的保障。设计模式与程序员的架构能力与阅读源码的能力息息相关,非常值得我们深入学习。
面向对象的特点是 可维护、可复用、可扩展、灵活性好,它最强大的地方在于:随着业务变得越来越复杂,面向对象依然能够使得程序结构良好,而面向过程却会导致程序越来越臃肿。
让面向对象保持结构良好的秘诀就是设计模式,面向对象结合设计模式,才能真正体会到程序变得可维护、可复用、可扩展、灵活性好。设计模式对于程序员而言并不陌生,每个程序员在编程时都会或多或少的接触到设计模式。无论是在大型程序的架构中,亦或是在源码的学习中,设计模式都扮演着非常重要的角色。
设计模式的六大原则
设计模式的世界丰富多彩,比如生产一个个“产品”的工厂模式,衔接两个不相关接口的适配器模式,用不同的方式做同一件事的策略模式,构建步骤稳定、根据构建过程的不同配置构建出不同对象的建造者模式等等。
无论何种设计模式,都是基于六大设计原则:
- 开闭原则:一个软件实体如类、模块和函数应该对修改封闭,对扩展开放。
- 单一职责原则:一个类只做一件事,一个类应该只有一个引起它修改的原因。
- 里氏替换原则:子类应该可以完全替换父类。也就是说在使用继承时,只扩展新功能,而不要破坏父类原有的功能。
- 依赖倒置原则:细节应该依赖于抽象,抽象不应依赖于细节。把抽象层放在程序设计的高层,并保持稳定,程序的细节变化由低层的实现层来完成。
- 迪米特法则:又名“最少知道原则”,一个类不应知道自己操作的类的细节,换言之,只和朋友谈话,不和朋友的朋友谈话。
- 接口隔离原则:客户端不应依赖它不需要的接口。如果一个接口在实现时,部分方法由于冗余被客户端空实现,则应该将接口拆分,让实现类只需依赖自己需要的接口方法。
第一章:构建型模式 Creational Patterns
工厂模式 Factory
在平时编程中,构建对象最常用的方式是 new 一个对象。乍一看这种做法没什么不好,而实际上这也属于一种硬编码。每 new 一个对象,相当于调用者多知道了一个类,增加了类与类之间的联系,不利于程序的松耦合。其实构建过程可以被封装起来,工厂模式便是用于封装对象的设计模式。
简单工厂模式
举个例子,直接 new 对象的方式相当于当我们需要一个苹果时,我们需要知道苹果的构造方法,需要一个梨子时,需要知道梨子的构造方法。更好的实现方式是有一个水果工厂,我们告诉工厂需要什么种类的水果,水果工厂将我们需要的水果制造出来给我们就可以了。这样我们就无需知道苹果、梨子是怎么种出来的,只用和水果工厂打交道即可。
水果工厂:
1 | public class FruitFactory { |
调用者:
1 | public class User { |
事实上,将构建过程封装的好处不仅可以降低耦合,如果某个产品构造方法相当复杂,使用工厂模式可以大大减少代码重复。比如,如果生产一个苹果需要苹果种子、阳光、水分,将工厂修改如下:
1 | public class FruitFactory { |
调用者的代码则完全不需要变化,而且调用者不需要在每次需要苹果时,自己去构建苹果种子、阳光、水分以获得苹果。苹果的生产过程再复杂,也只是工厂的事。这就是封装的好处,假如某天科学家发明了让苹果更香甜的肥料,要加入苹果的生产过程中的话,也只需要在工厂中修改,调用者完全不用关心。
不知不觉中,我们就写出了简单工厂模式的代码。工厂模式一共有三种:
- 简单工厂模式
- 工厂方法模式
- 抽象工厂模式
注:在 GoF 所著的《设计模式》一书中,简单工厂模式被划分为工厂方法模式的一种特例,没有单独被列出来。
总而言之,简单工厂模式就是让一个工厂类承担构建所有对象的职责。调用者需要什么产品,让工厂生产出来即可。它的弊端也显而易见:
- 一是如果需要生产的产品过多,此模式会导致工厂类过于庞大,承担过多的职责,变成超级类。当苹果生产过程需要修改时,要来修改此工厂。梨子生产过程需要修改时,也要来修改此工厂。也就是说这个类不止一个引起修改的原因。违背了单一职责原则。
- 二是当要生产新的产品时,必须在工厂类中添加新的分支。而开闭原则告诉我们:类应该对修改封闭。我们希望在添加新功能时,只需增加新的类,而不是修改既有的类,所以这就违背了开闭原则。
工厂方法模式
为了解决简单工厂模式的这两个弊端,工厂方法模式应运而生,它规定每个产品都有一个专属工厂。比如苹果有专属的苹果工厂,梨子有专属的梨子工厂,代码如下:
苹果工厂:
1 | public class AppleFactory { |
梨子工厂:
1 | public class PearFactory { |
调用者:
1 | public class User { |
有读者可能会开喷了,这样和直接 new 出苹果和梨子有什么区别?上文说工厂是为了减少类与类之间的耦合,让调用者尽可能少的和其他类打交道。用简单工厂模式,我们只需要知道 FruitFactory,无需知道 Apple 、Pear 类,很容易看出耦合度降低了。但用工厂方法模式,调用者虽然不需要和 Apple 、Pear 类打交道了,但却需要和 AppleFactory、PearFactory 类打交道。有几种水果就需要知道几个工厂类,耦合度完全没有下降啊,甚至还增加了代码量!
这位读者请先放下手中的大刀,仔细想一想,工厂模式的第二个优点在工厂方法模式中还是存在的。当构建过程相当复杂时,工厂将构建过程封装起来,调用者可以很方便的直接使用,同样以苹果生产为例:
1 | public class AppleFactory { |
调用者无需知道苹果的生产细节,当生产过程需要修改时也无需更改调用端。同时,工厂方法模式解决了简单工厂模式的两个弊端。
- 当生产的产品种类越来越多时,工厂类不会变成超级类。工厂类会越来越多,保持灵活。不会越来越大、变得臃肿。如果苹果的生产过程需要修改时,只需修改苹果工厂。梨子的生产过程需要修改时,只需修改梨子工厂。符合单一职责原则。
- 当需要生产新的产品时,无需更改既有的工厂,只需要添加新的工厂即可。保持了面向对象的可扩展性,符合开闭原则。
OK,学以致用,接下来我们来做两个思考题。同样地,在以后的每一篇文章后面,都会附上几个小练习供大家思考。希望大家能够独立思考出问题的答案,当然,在必要时也可参考底部的解析。
问 1:现有医用口罩和 N95 口罩两种产品,都继承自 Mask 类:
1 | abstract class Mask { |
请使用简单工厂模式完成以下代码:
1 | public class MaskFactory { |
使其通过以下客户端测试:
1 | public class Client { |
答案:
1 | public class MaskFactory { |
问 2:如何用工厂方法模式实现呢?
客户端测试代码:
1 | public class Client { |
答案:
1 | public class SurgicalMaskFactory{ |
抽象工厂模式 Abstract factory
上一节中的工厂方法模式可以进一步优化,提取出公共的工厂接口:
1 | public interface IFactory { |
然后苹果工厂和梨子工厂都实现此接口:
1 | public class AppleFactory implements IFactory { |
此时,调用者可以将 AppleFactory 和 PearFactory 统一作为 IFactory 对象使用,调用者代码如下:
1 | public class User { |
可以看到,我们在创建时指定了具体的工厂类后,在使用时就无需再关心是哪个工厂类,只需要将此工厂当作抽象的 IFactory 接口使用即可。这种经过抽象的工厂方法模式被称作抽象工厂模式。
由于客户端只和 IFactory 打交道了,调用的是接口中的方法,使用时根本不需要知道是在哪个具体工厂中实现的这些方法,这就使得替换工厂变得非常容易。
例如:
1 | public class User { |
如果需要替换为吃梨子,只需要更改一行代码即可:
1 | public class User { |
IFactory 中只有一个抽象方法时,或许还看不出抽象工厂模式的威力。实际上抽象工厂模式主要用于替换一系列方法。例如将程序中的 SQL Server 数据库整个替换为 Access 数据库,使用抽象方法模式的话,只需在 IFactory 接口中定义好增删改查四个方法,让 SQLFactory 和 AccessFactory 实现此接口,调用时直接使用 IFactory 中的抽象方法即可,调用者无需知道使用的什么数据库,我们就可以非常方便的整个替换程序的数据库,并且让客户端毫不知情。
抽象工厂模式很好的发挥了开闭原则、依赖倒置原则,但缺点是抽象工厂模式太重了,如果 IFactory 接口需要新增功能,则会影响到所有的具体工厂类。使用抽象工厂模式,替换具体工厂时只需更改一行代码,但要新增抽象方法则需要修改所有的具体工厂类。所以抽象工厂模式适用于增加同类工厂这样的横向扩展需求,不适合新增功能这样的纵向扩展。
问:上一节中提到的问题如何用抽象工厂模式实现呢?
客户端测试代码:
1 | public class Client { |
答案:
1 | public interface IFactory { |
单例模式 Singleton
单例模式非常常见,某个对象全局只需要一个实例时,就可以使用单例模式。它的优点也显而易见:
- 它能够避免对象重复创建,节约空间并提升效率
- 避免由于操作不同实例导致的逻辑错误
单例模式有两种实现方式:饿汉式和懒汉式。
饿汉式
饿汉式:变量在声明时便初始化。
1 | public class Singleton { |
可以看到,我们将构造方法定义为 private,这就保证了其他类无法实例化此类,必须通过 getInstance 方法才能获取到唯一的 instance 实例,非常直观。但饿汉式有一个弊端,那就是即使这个单例不需要使用,它也会在类加载之后立即创建出来,占用一块内存,并增加类初始化时间。就好比一个电工在修理灯泡时,先把所有工具拿出来,不管是不是所有的工具都用得上。就像一个饥不择食的饿汉,所以称之为饿汉式。
懒汉式
懒汉式:先声明一个空变量,需要用时才初始化。例如:
1 | public class Singleton { |
先声明了一个初始值为 null 的 instance 变量,当需要使用时判断此变量是否已被初始化,没有初始化的话才 new 一个实例出来。就好比电工在修理灯泡时,开始比较偷懒,什么工具都不拿,当发现需要使用螺丝刀时,才把螺丝刀拿出来。当需要用钳子时,再把钳子拿出来。就像一个不到万不得已不会行动的懒汉,所以称之为懒汉式。
懒汉式解决了饿汉式的弊端,好处是按需加载,避免了内存浪费,减少了类初始化时间。
上述代码的懒汉式单例乍一看没什么问题,但其实它不是线程安全的。如果有多个线程同一时间调用 getInstance 方法,instance 变量可能会被实例化多次。为了保证线程安全,我们需要给判空过程加上锁:
1 | public class Singleton { |
这样就能保证多个线程调用 getInstance 时,一次最多只有一个线程能够执行判空并 new 出实例的操作,所以 instance 只会实例化一次。但这样的写法仍然有问题,当多个线程调用 getInstance 时,每次都需要执行 synchronized 同步化方法,这样会严重影响程序的执行效率。所以更好的做法是在同步化之前,再加上一层检查:
1 | public class Singleton { |
这样增加一种检查方式后,如果 instance 已经被实例化,则不会执行同步化操作,大大提升了程序效率。上面这种写法也就是我们平时较常用的双检锁方式实现的线程安全的单例模式。
但这样的懒汉式单例仍然有一个问题,JVM 底层为了优化程序运行效率,可能会对我们的代码进行指令重排序,在一些特殊情况下会导致出现空指针,为了防止这个问题,更进一步的优化是给 instance 变量加上 volatile 关键字。
有读者可能会有疑问,我们在外面检查了 instance == null, 那么锁里面的空检查是否可以去掉呢?
答案是不可以。如果里面不做空检查,可能会有两个线程同时通过了外面的空检查,然后在一个线程 new 出实例后,第二个线程进入锁中又 new 出一个实例,导致创建多个实例。
除了双检锁方式外,还有一种比较常见的静态内部类方式保证懒汉式单例的线程安全:
1 | public class Singleton { |
虽然我们经常使用这种静态内部类的懒加载方式,但其中的原理不一定每个人都清楚。接下来我们便来分析其原理,搞清楚两个问题:
- 静态内部类方式是怎么实现懒加载的
- 静态内部类方式是怎么保证线程安全的
Java 类的加载过程包括:加载、验证、准备、解析、初始化。初始化阶段即执行类的 clinit 方法(clinit = class + initialize),包括为类的静态变量赋初始值和执行静态代码块中的内容。但不会立即加载内部类,内部类会在使用时才加载。所以当此 Singleton 类加载时,SingletonHolder 并不会被立即加载,所以不会像饿汉式那样占用内存。
另外,Java 虚拟机规定,当访问一个类的静态字段时,如果该类尚未初始化,则立即初始化此类。当调用Singleton 的 getInstance 方法时,由于其使用了 SingletonHolder 的静态变量 instance,所以这时才会去初始化 SingletonHolder,在 SingletonHolder 中 new 出 Singleton 对象。这就实现了懒加载。
第二个问题的答案是 Java 虚拟机的设计是非常稳定的,早已经考虑到了多线程并发执行的情况。虚拟机在加载类的 clinit 方法时,会保证 clinit 在多线程中被正确的加锁、同步。即使有多个线程同时去初始化一个类,一次也只有一个线程可以执行 clinit 方法,其他线程都需要阻塞等待,从而保证了线程安全。
懒加载方式在平时非常常见,比如打开我们常用的美团、饿了么、支付宝 app,应用首页会立刻刷新出来,但其他标签页在我们点击到时才会刷新。这样就减少了流量消耗,并缩短了程序启动时间。再比如游戏中的某些模块,当我们点击到时才会去下载资源,而不是事先将所有资源都先下载下来,这也属于懒加载方式,避免了内存浪费。
但懒汉式的缺点就是将程序加载时间从启动时延后到了运行时,虽然启动时间缩短了,但我们浏览页面时就会看到数据的 loading 过程。如果用饿汉式将页面提前加载好,我们浏览时就会特别的顺畅,也不失为一个好的用户体验。比如我们常用的 QQ、微信 app,作为即时通讯的工具软件,它们会在启动时立即刷新所有的数据,保证用户看到最新最全的内容。著名的软件大师 Martin 在《代码整洁之道》一书中也说到:不提倡使用懒加载方式,因为程序应该将构建与使用分离,达到解耦。饿汉式在声明时直接初始化变量的方式也更直观易懂。所以在使用饿汉式还是懒汉式时,需要权衡利弊。
一般的建议是:对于构建不复杂,加载完成后会立即使用的单例对象,推荐使用饿汉式。对于构建过程耗时较长,并不是所有使用此类都会用到的单例对象,推荐使用懒汉式。
问:双检锁单例模式中,volatile 主要用来防止哪几条指令重排序?如果发生了重排序,会导致什么样的错误?
答案:
1 | instance = new Singleton(); |
这一行代码中,执行了三条重要的指令:
- 分配对象的内存空间
- 初始化对象
- 将变量 instance 指向刚分配的内存空间
在这个过程中,如果第二条指令和第三条指令发生了重排序,可能导致 instance 还未初始化时,其他线程提前通过双检锁外层的 null 检查,获取到“不为 null,但还没有执行初始化”的 instance 对象,发生空指针异常。
建造者模式 Builder
建造者模式用于创建过程稳定,但配置多变的对象。在《设计模式》一书中的定义是:将一个复杂的构建与其表示相分离,使得同样的构建过程可以创建不同的表示。
经典的“建造者-指挥者”模式现在已经不太常用了,现在建造者模式主要用来通过链式调用生成不同的配置。比如我们要制作一杯珍珠奶茶。它的制作过程是稳定的,除了必须要知道奶茶的种类和规格外,是否加珍珠和是否加冰是可选的。使用建造者模式表示如下:
1 | public class MilkTea { |
可以看到,将 MilkTea 的构造方法设置为私有的,所以外部不能通过 new 构建出 MilkTea 实例,只能通过 Builder 构建。对于必须配置的属性,通过 Builder 的构造方法传入,可选的属性通过 Builder 的链式调用方法传入,如果不配置,将使用默认配置,也就是中杯、加珍珠、不加冰。根据不同的配置可以制作出不同的奶茶:
1 | public class User { |
运行程序,输出如下:
1 | 一份中杯、加珍珠、不加冰的原味奶茶 |
使用建造者模式的好处是不用担心忘了指定某个配置,保证了构建过程是稳定的。在 OkHttp、Retrofit 等著名框架的源码中都使用到了建造者模式。
建造者模式最适合用于创建哪种对象?
A 构建过程稳定,配置稳定的对象
B 构建过程稳定,配置易变的对象
C 构建过程易变,配置稳定的对象
答案 B
解析:建造者模式主要应用于构建过程稳定,可通过不同配置建造出不同对象的场景
原型模式 Prototype
原型模式:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。
定义看起来有点绕口,实际上在 Java 中,Object 的 clone() 方法就属于原型模式,不妨简单的理解为:原型模式就是用来克隆对象的。
举个例子,比如有一天,周杰伦到奶茶店点了一份不加冰的原味奶茶,你说我是周杰伦的忠实粉,我也要一份跟周杰伦一样的。用程序表示如下:
奶茶类:
1 | public class MilkTea { |
下单:
1 | private void order(){ |
好像没什么问题,将周杰伦的奶茶直接赋值到你的奶茶上就行了,看起来我们并不需要 clone 方法。但是这样真的是复制了一份奶茶吗?
当然不是,Java 对非基本类型对象的赋值只是传递地址。这样赋值之后,yourMilkTea 仍然指向的周杰伦的奶茶,并不会多一份一样的奶茶。
那么我们要怎么做才能点一份一样的奶茶呢?将程序修改如下就可以了:
1 | private void order(){ |
只有这样,yourMilkTea 才是 new 出来的一份全新的奶茶。我们设想一下,如果有一千个粉丝都需要点和周杰伦一样的奶茶的话,按照现在的写法就需要 new 一千次,并为每一个新的对象赋值一千次,造成大量的重复。
更糟糕的是,如果周杰伦临时决定加个冰,那么粉丝们的奶茶配置也要跟着修改:
1 | private void order(){ |
大批量的修改无疑是非常丑陋的做法,这就是我们需要 clone 方法的理由!
运用原型模式,在 MilkTea 中新增 clone 方法:
1 | public class MilkTea{ |
下单:
1 | private void order(){ |
这就是原型模式,Java 中有一个语法糖,让我们并不需要手写 clone 方法。这个语法糖就是 Cloneable 接口,我们只要让需要拷贝的类实现此接口即可。
1 | public class MilkTea implements Cloneable{ |
值得注意的是,Java 自带的 clone 方法是浅拷贝的。也就是说调用此对象的 clone 方法,只有基本类型的参数会被拷贝一份,非基本类型的对象不会被拷贝一份,而是继续使用传递引用的方式。如果需要实现深拷贝,必须要自己手动修改 clone 方法才行。
单选题
使用原型模式后,粉丝们最终喝到了周杰伦的奶茶吗?
A 心诚则灵,喝到了
B 童话里都是骗人的,没有喝到
答案 :B
解析:没有喝到,原型模式只是根据周杰伦的奶茶配置,拷贝了一份,并不是周杰伦
小结 Summary
- 工厂方法模式:为每一类对象建立工厂,将对象交由工厂创建,客户端只和工厂打交道。
- 抽象工厂模式:为每一类工厂提取出抽象接口,使得新增工厂、替换工厂变得非常容易。
- 建造者模式:用于创建构造过程稳定的对象,不同的 Builder 可以定义不同的配置。
- 单例模式:全局使用同一个对象,分为饿汉式和懒汉式。懒汉式有双检锁和内部类两种实现方式。
- 原型模式:为一个类定义 clone 方法,使得创建相同的对象更方便。
第二章:结构型模式 Structural Patterns
适配器模式 Adapter
说到适配器,我们最熟悉的莫过于电源适配器了,也就是手机的充电头。它就是适配器模式的一个应用。
试想一下,你有一条连接电脑和手机的 USB 数据线,连接电脑的一端从电脑接口处接收 5V 的电压,连接手机的一端向手机输出 5V 的电压,并且他们工作良好。
中国的家用电压都是 220V,所以 USB 数据线不能直接拿来给手机充电,这时候我们有两种方案:
- 单独制作手机充电器,接收 220V 家用电压,输出 5V 电压。
- 添加一个适配器,将 220V 家庭电压转化为类似电脑接口的 5V 电压,再连接数据线给手机充电。
如果你使用过早期的手机,就会知道以前的手机厂商采用的就是第一种方案:早期的手机充电器都是单独制作的,充电头和充电线是连在一起的。现在的手机都采用了电源适配器加数据线的方案。这是生活中应用适配器模式的一个进步。
适配器模式:将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类能一起工作。
适配的意思是适应、匹配。通俗地讲,适配器模式适用于有相关性但不兼容的结构,源接口通过一个中间件转换后才可以适用于目标接口,这个转换过程就是适配,这个中间件就称之为适配器。
家用电源和 USB 数据线有相关性:家用电源输出电压,USB 数据线输入电压。但两个接口无法兼容,因为一个输出 220V,一个输入 5V,通过适配器将输出 220V 转换成输出 5V 之后才可以一起工作。
让我们用程序来模拟一下这个过程。首先,家庭电源提供 220V 的电压:
1 | class HomeBattery { |
USB 数据线只接收 5V 的充电电压:
1 | class USBLine { |
先来看看适配之前,用户如果直接用家庭电源给手机充电:
1 | public class User { |
运行程序,输出如下:
1 | 家庭电源提供的电压是 220V |
这时,我们加入电源适配器:
1 | class Adapter { |
然后,用户再使用适配器将家庭电源提供的电压转换为充电电压:
1 | public class User { |
运行程序,输出如下:
1 | 家庭电源提供的电压是 220V |
这就是适配器模式。在我们日常的开发中经常会使用到各种各样的 Adapter,都属于适配器模式的应用。
但适配器模式并不推荐多用。因为未雨绸缪好过亡羊补牢,如果事先能预防接口不同的问题,不匹配问题就不会发生,只有遇到源接口无法改变时,才应该考虑使用适配器。比如现代的电源插口中很多已经增加了专门的 USB 充电接口,让我们不需要再使用适配器转换接口,这又是社会的一个进步。
补充:
收到不少读者的反馈,认为文中的这个例子举得过于简单。但实际上这个例子已经反映出了适配器模式的基本概念了。适配器模式的作用类似下图:
在本例中,适配前的 A 表示 220V,适配后的 B 表示 5V,C 就是适配过程,本例中的适配过程是使用电阻、电容等器件将其降低为输出 5V。适配器的核心思想就是使用适配器包装适配过程,这个适配器通常被命名为 Adapter 或者 Wrapper。
只不过通常我们见到的适配器模式是基于接口的适配。那么我们不妨看一个接口适配的例子。
设想我们已经有了一个 Task 类,实现了 Callable 接口:
1 | public class Task implements Callable<Integer> { |
这时我们需要这个 Task 类在一个子线程中执行:
1 | public class Client { |
但我们会发现,Thread thread = new Thread(callable);
这一行是无法编译通过的,因为 Thread 中需要接收的参数类型是 Runnable。
在业务上来说,Runnable 的 run 方法和 Callable 的 call 方法意义是一样的,这里的问题是接口不一致。所以我们可以通过接口适配器将接口转换成一致的。
1 | public class RunnableAdapter implements Runnable { |
可以看到,在 RunnableAdapter 中,我们包装了原始的 Callable 接口,并实现了新的 Runnable 接口。在 Runnable 接口的 run 方法中,调用 Callable 接口的 call 方法。实现同样的业务功能。
在客户端中使用 RunnableAdapter 类:
1 | public class Client { |
运行程序,输出如下:
1 | I'm called. |
可以看到,通过添加适配器,使得原本不兼容的两个接口能够正常工作了。适配器在其中的职责是包装了原有的接口,这样的适配器称为接口适配器。类似地,包装一个对象的适配器被称之为对象适配器。
适配器模式的核心思想是添加一个中间件,包装原有的接口或对象,将其转换为另一个接口或对象,以适应新的业务场景。适配器模式和后文介绍的装饰者模式、代理模式同属于包装模式。与其他两种包装模式不同的是,适配器模式重在转换,不改变原有的功能。而装饰者模式会在包装的基础上增强或添加功能,代理模式用于加强对原有类的控制。
单选题
在开发中遇到接口不一致的问题,首先应该考虑哪种方案:
A 使用刚学会的适配器模式!
B 修改接口,使接口一致。
正确答案是 :B
解析:适配器模式就像是一个补丁,只有在遇到接口无法修改时才应该考虑适配器模式。如果接口可以修改,那么将接口改为一致的方式会让程序结构更加良好。
桥接模式 Bridge
考虑这样一个需求:绘制矩形、圆形、三角形这三种图案。按照面向对象的理念,我们至少需要三个具体类,对应三种不同的图形。
抽象接口 IShape:
1 | public interface IShape { |
三个具体形状类:
1 | class Rectangle implements IShape { |
接下来我们有了新的需求,每种形状都需要有四种不同的颜色:红、蓝、黄、绿。
这时我们很容易想到两种设计方案:
- 为了复用形状类,将每种形状定义为父类,每种不同颜色的图形继承自其形状父类。此时一共有 12 个类。
- 为了复用颜色类,将每种颜色定义为父类,每种不同颜色的图形继承自其颜色父类。此时一共有 12 个类。
乍一看没什么问题,我们使用了面向对象的继承特性,复用了父类的代码并扩展了新的功能。
但仔细想一想,如果以后要增加一种颜色,比如黑色,那么我们就需要增加三个类;如果再要增加一种形状,我们又需要增加五个类,对应 5 种颜色。
更不用说遇到增加 20 个形状,20 种颜色的需求,不同的排列组合将会使工作量变得无比的庞大。看来我们不得不重新思考设计方案。
形状和颜色,都是图形的两个属性。他们两者的关系是平等的,所以不属于继承关系。更好的的实现方式是:将形状和颜色分离,根据需要对形状和颜色进行组合,这就是桥接模式的思想。
桥接模式:将抽象部分与它的实现部分分离,使它们都可以独立地变化。它是一种对象结构型模式,又称为柄体模式或接口模式。
官方定义非常精准、简练,但却有点不易理解。通俗地说,如果一个对象有两种或者多种分类方式,并且两种分类方式都容易变化,比如本例中的形状和颜色。这时使用继承很容易造成子类越来越多,所以更好的做法是把这种分类方式分离出来,让他们独立变化,使用时将不同的分类进行组合即可。
说到这里,不得不提一个设计原则:合成 / 聚合复用原则。虽然它没有被划分到六大设计原则中,但它在面向对象的设计中也非常的重要。
合成 / 聚合复用原则:优先使用合成 / 聚合,而不是类继承。
继承虽然是面向对象的三大特性之一,但继承会导致子类与父类有非常紧密的依赖关系,它会限制子类的灵活性和子类的复用性。而使用合成 / 聚合,也就是使用接口实现的方式,就不存在依赖问题,一个类可以实现多个接口,可以很方便地拓展功能。
让我们一起来看一下本例使用桥接模式的程序实现:
新建接口类 IColor,仅包含一个获取颜色的方法:
1 | public interface IColor { |
每种颜色都实现此接口:
1 | public class Red implements IColor { |
在每个形状类中,桥接 IColor 接口:
1 | class Rectangle implements IShape { |
测试函数:
1 |
|
运行程序,输出如下:
1 | 绘制红矩形 |
这时我们再来回顾一下官方定义:将抽象部分与它的实现部分分离,使它们都可以独立地变化。抽象部分指的是父类,对应本例中的形状类,实现部分指的是不同子类的区别之处。将子类的区别方式 —— 也就是本例中的颜色 —— 分离成接口,通过组合的方式桥接颜色和形状,这就是桥接模式,它主要用于两个或多个同等级的接口。
单选题
桥接模式体现了什么设计原则?
A 合成原则
B 聚合原则
C 复用原则
D 合成 / 聚合复用原则
答案: D
解析:合成 / 聚合复用原则:优 先使用合成 / 聚合,而不是类继承。
组合模式 Composite
上文说到,桥接模式用于将同等级的接口互相组合,那么组合模式和桥接模式有什么共同点吗?
事实上组合模式和桥接模式的组合完全不一样。组合模式用于整体与部分的结构,当整体与部分有相似的结构,在操作时可以被一致对待时,就可以使用组合模式。例如:
- 文件夹和子文件夹的关系:文件夹中可以存放文件,也可以新建文件夹,子文件夹也一样。
- 总公司子公司的关系:总公司可以设立部门,也可以设立分公司,子公司也一样。
- 树枝和分树枝的关系:树枝可以长出叶子,也可以长出树枝,分树枝也一样。
在这些关系中,虽然整体包含了部分,但无论整体或部分,都具有一致的行为。
组合模式:又叫部分整体模式,是用于把一组相似的对象当作一个单一的对象。组合模式依据树形结构来组合对象,用来表示部分以及整体层次。这种类型的设计模式属于结构型模式,它创建了对象组的树形结构。
考虑这样一个实际应用:设计一个公司的人员分布结构,结构如下图所示。
我们注意到人员结构中有两种结构,一是管理者,如老板,PM,CFO,CTO,二是职员。其中有的管理者不仅仅要管理职员,还会管理其他的管理者。这就是一个典型的整体与部分的结构。
不使用组合模式的设计方案
要描述这样的结构,我们很容易想到以下设计方案:
新建管理者类:
1 | public class Manager { |
新建职员类:
1 | public class Employee { |
客户端建立人员结构关系:
1 | public class Client { |
运行测试方法,输出如下(为方便查看,笔者添加了缩进):
1 | 我是老板,我正在唱怒放的生命 |
这样我们就设计出了公司的结构,但是这样的设计有两个弊端:
- name 字段,job 字段,work 方法重复了。
- 管理者对其管理的管理者和职员需要区别对待。
关于第一个弊端,虽然这里为了讲解,只有两个字段和一个方法重复,实际工作中这样的整体部分结构会有相当多的重复。比如此例中还可能有工号、年龄等字段,领取工资、上下班打卡、开各种无聊的会等方法。
大量的重复显然是很丑陋的代码,分析一下可以发现, Manager 类只比 Employee 类多一个管理人员的列表字段,多几个增加 / 移除人员的方法,其他的字段和方法全都是一样的。
有读者应该会想到:我们可以将重复的字段和方法提取到一个工具类中,让 Employee 和 Manager 都去调用此工具类,就可以消除重复了。
这样固然可行,但属于 Employee 和 Manager 类自己的东西却要通过其他类调用,并不利于程序的高内聚。
关于第二个弊端,此方案无法解决,此方案中 Employee 和 Manager 类完全是两个不同的对象,两者的相似性被忽略了。
所以我们有更好的设计方案,那就是组合模式!
使用组合模式的设计方案
组合模式最主要的功能就是让用户可以一致对待整体和部分结构,将两者都作为一个相同的组件,所以我们先新建一个抽象的组件类:
1 | public abstract class Component { |
管理者继承自此抽象类:
1 | public class Manager extends Component { |
职员同样继承自此抽象类:
1 | public class Employee extends Component { |
修改客户端如下:
1 | public class Client { |
运行测试方法,输出结果与之前的结果一模一样。
可以看到,使用组合模式后,我们解决了之前的两个弊端。一是将共有的字段与方法移到了父类中,消除了重复,并且在客户端中,可以一致对待 Manager 和 Employee 类:
- Manager 类和 Employee 类统一声明为 Component 对象
- 统一调用 Component 对象的 addComponent 方法添加子对象即可。
组合模式中的安全方式与透明方式
读者可能已经注意到了,Employee 类虽然继承了父类的 addComponent 和 removeComponent 方法,但是仅仅提供了一个空实现,因为 Employee 类是不支持添加和移除组件的。这样是否违背了接口隔离原则呢?
接口隔离原则:客户端不应依赖它不需要的接口。如果一个接口在实现时,部分方法由于冗余被客户端空实现,则应该将接口拆分,让实现类只需依赖自己需要的接口方法。
答案是肯定的,这样确实违背了接口隔离原则。这种方式在组合模式中被称作透明方式.
透明方式:在 Component 中声明所有管理子对象的方法,包括 add 、remove 等,这样继承自 Component 的子类都具备了 add、remove 方法。对于外界来说叶节点和枝节点是透明的,它们具备完全一致的接口。
这种方式有它的优点:让 Manager 类和 Employee 类具备完全一致的行为接口,调用者可以一致对待它们。
但它的缺点也显而易见:Employee 类并不支持管理子对象,不仅违背了接口隔离原则,而且客户端可以用 Employee 类调用 addComponent 和 removeComponent 方法,导致程序出错,所以这种方式是不安全的。
那么我们可不可以将 addComponent 和 removeComponent 方法移到 Manager 子类中去单独实现,让 Employee 不再实现这两个方法呢?我们来尝试一下。
将抽象类修改为:
1 | public abstract class Component { |
可以看到,我们在父类中去掉了 addComponent 和 removeComponent 这两个抽象方法。
Manager 类修改为:
1 | public class Manager extends Component { |
Manager 类单独实现了 addComponent 和 removeComponent 这两个方法,去掉了 @Override 注解。
Employee 类修改为:
1 | public class Employee extends Component { |
客户端建立人员结构关系:
1 | public class Client { |
运行程序,输出结果与之前一模一样。
这种方式在组合模式中称之为安全方式。
安全方式:在 Component 中不声明 add 和 remove 等管理子对象的方法,这样叶节点就无需实现它,只需在枝节点中实现管理子对象的方法即可。
安全方式遵循了接口隔离原则,但由于不够透明,Manager 和 Employee 类不具有相同的接口,在客户端中,我们无法将 Manager 和 Employee 统一声明为 Component 类了,必须要区别对待,带来了使用上的不方便。
安全方式和透明方式各有好处,在使用组合模式时,需要根据实际情况决定。但大多数使用组合模式的场景都是采用的透明方式,虽然它有点不安全,但是客户端无需做任何判断来区分是叶子结点还是枝节点,用起来是真香。
问:组合模式中的安全方式与透明方式有什么区别?
答案:透明方式:在 Component 中声明所有管理子对象的方法,包括 add 、remove 等,这样继承自 Component 的子类都具备了 add、remove 方法。对于外界来说叶节点和枝节点是透明的,它们具备完全一致的接口。
安全方式:在 Component 中不声明 add 和 remove 等管理子对象的方法,这样叶节点就无需实现它,只需在枝节点中实现管理子对象的方法即可。
装饰模式 Decorator
提到装饰,我们先来想一下生活中有哪些装饰:
- 女生的首饰:戒指、耳环、项链等装饰品
- 家居装饰品:粘钩、镜子、壁画、盆栽等等
我们为什么需要这些装饰品呢?我们很容易想到是为了美,戒指、耳环、项链、壁画、盆栽都是为了提高颜值或增加美观度。但粘钩、镜子不一样,它们是为了方便我们挂东西、洗漱。所以我们可以总结出装饰品共有两种功能:
- 增强原有的特性:我们本身就是有一定颜值的,添加装饰品提高了我们的颜值。同样地,房屋本身就有一定的美观度,家居装饰提高了房屋的美观度。
- 添加新的特性:在墙上挂上粘钩,让墙壁有了挂东西的功能。在洗漱台装上镜子,让洗漱台有了照镜子的功能。
并且我们发现,装饰品并不会改变物品本身,只是起到一个锦上添花的作用。装饰模式也一样,它的主要作用就是:
- 增强一个类原有的功能
- 为一个类添加新的功能
并且装饰模式也不会改变原有的类。
装饰模式:动态地给一个对象增加一些额外的职责,就增加对象功能来说,装饰模式比生成子类实现更为灵活。其别名也可以称为包装器,与适配器模式的别名相同,但它们适用于不同的场合。根据翻译的不同,装饰模式也有人称之为“油漆工模式”。
用于增强功能的装饰模式
我们用程序来模拟一下戴上装饰品提高我们颜值的过程:
新建颜值接口:
1 | public interface IBeauty { |
新建 Me 类,实现颜值接口:
1 | public class Me implements IBeauty { |
戒指装饰类,将 Me 包装起来:
1 | public class RingDecorator implements IBeauty { |
客户端测试:
1 | public class Client { |
运行程序,输出如下:
1 | 我原本的颜值:100 |
这就是最简单的增强功能的装饰模式。以后我们可以添加更多的装饰类,比如:
耳环装饰类:
1 | public class EarringDecorator implements IBeauty { |
项链装饰类:
1 | public class NecklaceDecorator implements IBeauty { |
客户端测试:
1 | public class Client { |
运行程序,输出如下:
1 | 我原本的颜值:100 |
可以看到,装饰器也实现了 IBeauty 接口,并且没有添加新的方法,也就是说这里的装饰器仅用于增强功能,并不会改变 Me 原有的功能,这种装饰模式称之为透明装饰模式,由于没有改变接口,也没有新增方法,所以透明装饰模式可以无限装饰。
装饰模式是继承的一种替代方案。本例如果不使用装饰模式,而是改用继承实现的话,戴着戒指的 Me 需要派生一个子类、戴着项链的 Me 需要派生一个子类、戴着耳环的 Me 需要派生一个子类、戴着戒指 + 项链的需要派生一个子类…各种各样的排列组合会造成类爆炸。而采用了装饰模式就只需要为每个装饰品生成一个装饰类即可,所以说就增加对象功能来说,装饰模式比生成子类实现更为灵活。
用于添加功能的装饰模式
我们用程序来模拟一下房屋装饰粘钩后,新增了挂东西功能的过程:
新建房屋接口:
1 | public interface IHouse { |
房屋类:
1 | public class House implements IHouse{ |
新建粘钩装饰器接口,继承自房屋接口:
1 | public interface IStickyHookHouse extends IHouse{ |
粘钩装饰类:
1 | public class StickyHookDecorator implements IStickyHookHouse { |
客户端测试:
1 | public class Client { |
运行程序,显示如下:
1 | 房屋原有的功能:居住功能 |
这就是用于新增功能的装饰模式。我们在接口中新增了方法:hangThings,然后在装饰器中将 House 类包装起来,之前 House 中的方法仍然调用 house 去执行,也就是说我们并没有修改原有的功能,只是扩展了新的功能,这种模式在装饰模式中称之为半透明装饰模式。
为什么叫半透明呢?由于新的接口 IStickyHookHouse 拥有之前 IHouse 不具有的方法,所以我们如果要使用装饰器中添加的功能,就不得不区别对待装饰前的对象和装饰后的对象。也就是说客户端要使用新方法,必须知道具体的装饰类 StickyHookDecorator,所以这个装饰类对客户端来说是可见的、不透明的。而被装饰者不一定要是 House,它可以是实现了 IHouse 接口的任意对象,所以被装饰者对客户端是不可见的、透明的。由于一半透明,一半不透明,所以称之为半透明装饰模式。
我们可以添加更多的装饰器:
新建镜子装饰器的接口,继承自房屋接口:
1 | public interface IMirrorHouse extends IHouse { |
镜子装饰类:
1 | public class MirrorDecorator implements IMirrorHouse{ |
客户端测试:
1 | public class Client { |
运行程序,输出如下:
1 | 房屋原有的功能:居住功能 |
现在我们仿照透明装饰模式的写法,同时添加粘钩和镜子装饰试一试:
1 | public class Client { |
我们会发现,第二次装饰时,无法获得上一次装饰添加的方法。原因很明显,当我们用 IMirrorHouse 装饰器后,接口变为了 IMirrorHouse,这个接口中并没有 hangThings 方法。
那么我们能否让 IMirrorHouse 继承自 IStickyHookHouse,以实现新增两个功能呢?可以,但那样做的话两个装饰类之间有了依赖关系,那就不是装饰模式了。装饰类不应该存在依赖关系,而应该在原本的类上进行装饰。这就意味着,半透明装饰模式中,我们无法多次装饰。
有的同学会问了,既增强了功能,又添加了新功能的装饰模式叫什么呢?
—— 举一反三,肯定是叫全不透明装饰模式!
—— 并不是!只要添加了新功能的装饰模式都称之为半透明装饰模式,他们都具有不可以多次装饰的特点。仔细理解上文半透明名称的由来就知道了,“透明”指的是我们无需知道被装饰者具体的类,既增强了功能,又添加了新功能的装饰模式仍然具有半透明特性。
看了这两个简单的例子,是不是发现装饰模式很简单呢?恭喜你学会了 1 + 1 = 2,现在你已经掌握了算数的基本思想,接下来我们来做一道微积分题练习一下。
I/O 中的装饰模式
I/O 指的是 Input/Output,即输入、输出。我们以 Input 为例。先在 src
文件夹下新建一个文件 readme.text,随便写点文字:
1 | 禁止套娃 |
然后用 Java 的 InputStream 读取,代码一般长这样:
1 | public void io() throws IOException { |
这样写有一个问题,如果读取过程中出现了 IO 异常,InputStream 就不能正确的关闭,所以我们要用 try...finally
来保证 InputStream 正确关闭:
1 | public void io() throws IOException { |
这种写法实在是太丑了,而 IO 操作又必须这么写,显然 Java 也意识到了这个问题,所以 Java 7 中引入了 try(resource)
语法糖,IO 的代码就可以简化如下:
1 | public void io() throws IOException { |
这种写法和上一种逻辑是一样的,运行程序,显示如下:
1 | 禁止套娃 |
观察获取 InputStream 这句代码:
1 | InputStream in = new BufferedInputStream(new FileInputStream("src/readme.txt")); |
是不是和我们之前多次装饰的代码非常相似:
1 | // 多次装饰 |
事实上,查看 I/O 的源码可知,Java I/O 的设计框架便是使用的装饰者模式,InputStream 的继承关系如下:
其中,InputStream 是一个抽象类,对应上文例子中的 IHouse,其中最重要的方法是 read 方法,这是一个抽象方法:
1 | public abstract class InputStream implements Closeable { |
这个方法会读取输入流的下一个字节,并返回字节表示的 int 值(0~255),返回 -1 表示已读到末尾。由于它是抽象方法,所以具体的逻辑交由子类实现。
上图中,左边的三个类 FileInputStream、ByteArrayInputStream、ServletInputStream 是 InputStream 的三个子类,对应上文例子中实现了 IHouse 接口的 House。
右下角的三个类 BufferedInputStream、DataInputStream、CheckedInputStream 是三个具体的装饰者类,他们都为 InputStream 增强了原有功能或添加了新功能。
FilterInputStream 是所有装饰类的父类,它没有实现具体的功能,仅用来包装了一下 InputStream:
1 | public class FilterInputStream extends InputStream { |
我们以 BufferedInputStream 为例。原有的 InputStream 读取文件时,是一个字节一个字节的读取的,这种方式的执行效率并不高,所以我们可以设立一个缓冲区,先将内容读取到缓冲区中,缓冲区读满后,将内容从缓冲区中取出来,这样就变成了一段一段的读取,用内存换取效率。BufferedInputStream 就是用来做这个的。它继承自 FilterInputStream:
1 | public class BufferedInputStream extends FilterInputStream { |
我们先来看它的构造方法,在构造方法中,新建了一个 byte[] 作为缓冲区,从源码中我们看到,Java 默认设置的缓冲区大小为 8192 byte,也就是 8 KB。
然后我们来查看 read 方法:
1 | public class BufferedInputStream extends FilterInputStream { |
在 read 方法中,调用了 fill 方法,fill 方法的作用就是往缓冲区中填充读取的内容。这样就实现了增强原有的功能。
在源码中我们发现,BufferedInputStream 没有添加 InputStream 中没有的方法,所以 BufferedInputStream 使用的是透明的装饰模式。
DataInputStream 用于更加方便的读取 int、double 等内容,观察 DataInputStream 的源码可以发现,DataInputStream 中新增了 readInt、readLong 等方法,所以 DataInputStream 使用的是半透明装饰模式。
理解了 InputStream 后,再看一下 OutputStream 的继承关系,相信大家一眼就能看出各个类的作用了:
这就是装饰模式,注意不要和适配器模式混淆了。两者在使用时都是包装一个类,但两者的区别其实也很明显:
- 纯粹的适配器模式仅用于改变接口,不改变其功能,部分情况下我们需要改变一点功能以适配新接口。但使用适配器模式时,接口一定会有一个
回炉重造
的过程。 - 装饰模式不改变原有的接口,仅用于增强原有功能或添加新功能,强调的是
锦上添花
。
掌握了装饰者模式之后,理解 Java I/O 的框架设计就非常容易了。但对于不理解装饰模式的人来说,各种各样相似的 InputStream 非常容易让开发者感到困惑。这一点正是装饰模式的缺点:容易造成程序中有大量相似的类。虽然这更像是开发者的缺点,我们应该做的是提高自己的技术,掌握了这个设计模式之后它就是我们的一把利器。现在我们再看到 I/O 不同的 InputStream 装饰类,只需要关注它增强了什么功能或添加了什么功能即可。
单选题
半透明装饰模式中的“半透明”是指什么?
外观模式 Facade
外观模式非常简单,体现的就是 Java 中封装的思想。将多个子系统封装起来,提供一个更简洁的接口供外部调用。
外观模式:外部与一个子系统的通信必须通过一个统一的外观对象进行,为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。外观模式又称为门面模式。
举个例子,比如我们每天打开电脑时,都需要做三件事:
- 打开浏览器
- 打开 IDE
- 打开微信
每天下班时,关机前需要做三件事:
- 关闭浏览器
- 关闭 IDE
- 关闭微信
用程序模拟如下:
新建浏览器类:
1 | public class Browser { |
新建 IDE 类:
1 | public class IDE { |
新建微信类:
1 | public class Wechat { |
客户端调用:
1 | public class Client { |
运行程序,输出如下:
1 | 上班: |
由于我们每天都要做这几件事,所以我们可以使用外观模式,将这几个子系统封装起来,提供更简洁的接口:
1 | public class Facade { |
客户端就可以简化代码,只和这个外观类打交道:
1 | public class Client { |
运行程序,输出与之前一样。
外观模式就是这么简单,它使得两种不同的类不用直接交互,而是通过一个中间件——也就是外观类——间接交互。外观类中只需要暴露简洁的接口,隐藏内部的细节,所以说白了就是封装的思想。
外观模式非常常用,(当然了!写代码哪有不封装的!)尤其是在第三方库的设计中,我们应该提供尽量简洁的接口供别人调用。另外,在 MVC 架构中,C 层(Controller)就可以看作是外观类,Model 和 View 层通过 Controller 交互,减少了耦合。
享元模式 Flyweight
享元模式体现的是程序可复用的特点,为了节约宝贵的内存,程序应该尽可能地复用,就像《极限编程》作者 Kent 在书里说到的那样:Don’t repeat yourself. 简单来说享元模式就是共享对象,提高复用性,官方的定义倒是显得文绉绉的:
享元模式:运用共享技术有效地支持大量细粒度对象的复用。系统只使用少量的对象,而这些对象都很相似,状态变化很小,可以实现对象的多次复用。由于享元模式要求能够共享的对象必须是细粒度对象,因此它又称为轻量级模式。
有个细节值得注意:有些对象本身不一样,但通过一点点变化后就可以复用,我们编程时可能稍不注意就会忘记复用这些对象。比如说伟大的超级玛丽,谁能想到草和云更改一下颜色就可以实现复用呢?还有里面的三种乌龟,换一个颜色、加一个装饰就变成了不同的怪。
在超级玛丽中,这样的细节还有很多,正是这些精湛的复用使得这一款红遍全球的游戏仅有 40KB 大小。正是印证了那句名言:神在细节之中。
代理模式 Proxy
现在我们有一个人
类,他整天就只负责吃饭、睡觉:
人
类的接口:
1 | public interface IPerson { |
人
类:
1 | public class Person implements IPerson{ |
客户端测试:
1 | public class Client { |
运行程序,输出如下:
1 | 我在吃饭 |
我们可以把这个类包装到另一个类中,实现完全一样的行为:
1 | public class PersonProxy implements IPerson { |
将客户端修改为调用这个新的类:
1 | public class Client { |
运行程序,输出如下:
1 | 我在吃饭 |
这就是代理模式。
笔者力图用最简洁的代码讲解此模式,只要理解了上述这个简单的例子,你就知道代理模式是怎么一回事了。我们在客户端和 Person 类之间新增了一个中间件 PersonProxy,这个类就叫做代理类,他实现了和 Person 类一模一样的行为。
代理模式:给某一个对象提供一个代理,并由代理对象控制对原对象的引用。
现在这个代理类还看不出任何意义,我们来模拟一下工作中的需求。在实际工作中,我们可能会遇到这样的需求:在网络请求前后,分别打印将要发送的数据和接收到数据作为日志信息。此时我们就可以新建一个网络请求的代理类,让它代为处理网络请求,并在代理类中打印这些日志信息。
新建网络请求接口:
1 | public interface IHttp { |
新建 Http 请求工具类:
1 | public class HttpUtil implements IHttp { |
新建 Http 代理类:
1 | public class HttpProxy implements IHttp { |
到这里,和我们上述吃饭睡觉的代码是一模一样的,现在我们在 HttpProxy 中新增打印日志信息:
1 | public class HttpProxy implements IHttp { |
客户端验证:
1 | public class Client { |
运行程序,输出如下:
1 | 发送数据:request data |
这就是代理模式的一个应用,除了打印日志,它还可以用来做权限管理。读者看到这里可能已经发现了,这个代理类看起来和装饰模式的 FilterInputStream 一模一样,但两者的目的不同,装饰模式是为了增强功能或添加功能,代理模式主要是为了加以控制。
动态代理
上例中的代理被称之为静态代理,动态代理与静态代理的原理一模一样,只是换了一种写法。使用动态代理,需要把一个类传入,然后根据它正在调用的方法名判断是否需要加以控制。用伪代码表示如下:
1 | public class HttpProxy { |
伪代码看起来还是很简单的,实现起来唯一的难点就是怎么让 httpUtil 调用任意方法时,都通过一个方法间接调用。这里需要用到反射技术,不了解反射技术也没有关系,不妨把它记做固定的写法。实际的动态代理类代码如下:
1 | public class HttpProxy implements InvocationHandler { |
先看 getInstance 方法,Proxy.newProxyInstance 方法是 Java 系统提供的方法,专门用于动态代理。其中传入的第一个参数是被代理的类的 ClassLoader,第二个参数是被代理类的 Interfaces,这两个参数都是 Object 中的,每个类都有,这里就是固定写法。我们只要知道系统需要这两个参数才能让我们实现我们的目的:调用被代理类的任意方法时,都通过一个方法间接调用。现在我们给系统提供了这两个参数,系统就会在第三个参数中帮我们实现这个目的。
第三个参数是 InvocationHandler 接口,这个接口中只有一个方法:
1 | public Object invoke(Object proxy, Method method, Object[] args) throws Throwable; |
这就是反射调用函数的写法,如果不了解可以记做固定写法,想要了解的同学可以看之前的这篇文章:详解Java 反射。虽然这个函数没有返回值,但我们还是将 result 返回,这是标准做法。
如果现在调用的方法是 onSuccess,那么打印接收到的数据,并反射继续执行当前方法。
修改客户端验证一下:
1 | public class Client { |
运行程序,输出与之前一样:
1 | 发送数据:request data |
动态代理本质上与静态代理没有区别,它的好处是节省代码量。比如被代理类有 20 个方法,而我们只需要控制其中的两个方法,就可以用动态代理通过方法名对被代理类进行动态的控制,而如果用静态方法,我们就需要将另外的 18 个方法也写出来,非常繁琐。这就是动态代理的优势所在。
单选题
大雨初晴的一天,公司 UI 小姐姐的电脑坏了,紧急求助帅气的程序员小哥哥,小哥哥满口答应下来。但 IT 界众所周知,敲代码和修电脑是两回事。于是小哥哥当天晚上偷偷将电脑带到商场,交给专业维修人员修好了电脑。第二天上班时,将完好无损的电脑交给了 UI 小姐姐并随口说到:“对我来说,这点电脑问题就是洒洒水,小意思啦,下次有什么问题尽管找我。”绝口不提昨天自掏腰包花出去的几百大洋。在这个过程中,谁担任了代理者的角色?
- A UI 小姐姐
- B 程序员小哥哥
- C 电脑维修人员
- D 几百大洋
正确答案是 B
解析:程序员小哥哥负责代理了整个流程,实际维修工作由电脑维修人员完成。
小结 Summary
- 适配器模式:用于有相关性但不兼容的接口
- 桥接模式:用于同等级的接口互相组合
- 组合模式:用于整体与部分的结构
- 外观模式:体现封装的思想
- 享元模式:体现面向对象的可复用性
- 代理模式:主要用于对某个对象加以控制
第三章:行为型模式 Behavioral Patterns
责任链模式 Chain of responsibility
我们每个人在工作中都承担着一定的责任,比如程序员承担着开发新功能、修改 bug 的责任,运营人员承担着宣传的责任、HR 承担着招聘新人的责任。我们每个人的责任与这个责任链有什么关系吗?
——答案是并没有太大关系。
但也不是完全没有关系,主要是因为每个人在不同岗位上的责任是分散的,分散的责任组合在一起更像是一张网,无法组成一条链。
同一个岗位上的责任,就可以组成一条链。
举个切身的例子,比如:普通的程序员可以解决中等难度的 bug,优秀程序员可以解决困难的 bug,而菜鸟程序员只能解决简单的 bug。为了将其量化,我们用一个数字来表示 bug 的难度,(0, 20]
表示简单,(20,50]
表示中等, (50,100]
表示困难,我们来模拟一个 bug 解决的流程。
“解决 bug” 程序 1.0
新建一个 bug 类:
1 | public class Bug { |
新建一个程序员类:
1 | public class Programmer { |
客户端:
1 | import org.junit.Test; |
代码逻辑很简单,我们让三种类型的程序员依次尝试解决 bug,如果 bug 难度在自己能解决的范围内,则自己处理此 bug。
运行程序,输出如下:
1 | 菜鸟程序员解决了一个难度为 20 的 bug |
输出没有问题,说明功能完美实现了,但在这个程序中,我们让每个程序员都尝试处理了每一个 bug,这也就相当于大家围着讨论每个 bug 该由谁解决,这无疑是非常低效的做法。那么我们要怎么才能优化呢?
“解决 bug” 程序 2.0
实际上,许多公司会选择让项目经理来分派任务,项目经理会根据 bug 的难度指派给不同的人解决。
引入 ProjectManager 类:
1 | public class ProjectManager { |
我们让项目经理管理所有的程序员,并且根据 bug 的难度指派任务。这样一来,所有的 bug 只需传给项目经理分配即可,修改客户端如下:
1 | import org.junit.Test; |
运行程序,输出如下:
1 | 项目经理将这个简单的 bug 分配给了菜鸟程序员 |
看起来很美好,除了项目经理在骂骂咧咧地反驳这个方案。
在这个经过修改的程序中,项目经理一个人承担了分配所有 bug 这个体力活。程序没有变得简洁,只是把复杂的逻辑从客户端转移到了项目经理类中。
而且项目经理类承担了过多的职责,如果以后新增一类程序员,必须改动项目经理类,将其处理 bug 的职责插入分支判断语句中,违反了单一职责原则和开闭原则。
所以,我们需要更优的解决方案,那就是——责任链模式!
“解决 bug” 程序 3.0
责任链模式:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。
在本例的场景中,每个程序员的责任都是“解决这个 bug”,当测试提出一个 bug 时,可以走这样一条责任链:
- 先交由菜鸟程序员之手,如果是简单的 bug,菜鸟程序员自己处理掉。如果这个 bug 对于菜鸟程序员来说太难了,交给普通程序员
- 如果是中等难度的 bug,普通程序员处理掉。如果他也解决不了,交给优秀程序员
- 优秀程序员处理掉困难的 bug
有的读者会提出疑问,如果优秀程序员也无法处理这个 bug 呢?
——那当然是处理掉这个假冒优秀程序员。
修改客户端如下:
1 | import org.junit.Test; |
三个嵌套的 if 条件句就组成了一条 菜鸟-> 普通 -> 优秀
的责任链。我们使 handleBug 方法返回一个 boolean 值,如果此 bug 被处理了,返回 true;否则返回 false,使得责任沿着 菜鸟-> 普通 -> 优秀
这条链继续传递,这就是责任链模式的思路。
运行程序,输出如下:
1 | 菜鸟程序员解决了一个难度为 20 的 bug |
熟悉责任链模式的同学应该可以看出,这个责任链模式和我们平时使用的不太一样。事实上,这段代码已经很好地体现了责任链模式的基本思想。我们平时使用的责任链模式只是在面向对象的基础上,将这段代码封装了一下。那么接下来我们就来对这段代码进行封装,将它变成规范的责任链模式的写法。
“解决 bug” 程序 4.0
新建一个程序员抽象类:
1 | public abstract class Programmer { |
在这个抽象类中:
- next 对象表示如果自己解决不了,需要将责任传递给的下一个人;
- handle 方法表示自己处理此 bug 的逻辑,在这里判断是自己解决或者继续传递。
新建菜鸟程序员类:
1 | public class NewbieProgrammer extends Programmer { |
新建普通程序员类:
1 | public class NormalProgrammer extends Programmer { |
新建优秀程序员类:
1 | public class GoodProgrammer extends Programmer { |
客户端测试:
1 | import org.junit.Test; |
在客户端中,我们通过 setNext() 方法将三个程序员组成了一条责任链,由菜鸟程序员接收所有的 bug,发现自己不能处理的 bug,就传递给普通程序员,普通程序员收到 bug 后,如果发现自己不能解决,则传递给优秀程序员,这就是规范的责任链模式的写法了。
责任链思想在生活中有很多应用,比如假期审批、加薪申请等,在员工提出申请后,从经理开始,由你的经理决定自己处理或是交由更上一层的经理处理。
再比如处理客户投诉时,从基层的客服人员开始,决定自己回应或是上报给领导,领导再判断是否继续上报。
理清了责任链模式,笔者突然回想起,公司的测试组每次提出 bug 后,总是先指派给我!一瞬间仿佛明白了什么了不得的道理,不禁陷入了沉思。
责任链模式小结
通过这个例子,我们已经了解到,责任链主要用于处理 职责相同,程度不同的类。
其主要优点有:
- 降低了对象之间的耦合度。在责任链模式中,客户只需要将请求发送到责任链上即可,无须关心请求的处理细节和请求的传递过程,所以责任链将请求的发送者和请求的处理者解耦了。
- 扩展性强,满足开闭原则。可以根据需要增加新的请求处理类。
- 灵活性强。可以动态地改变链内的成员或者改变链的次序来适应流程的变化。
- 简化了对象之间的连接。每个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的条件判断语句。
- 责任分担。每个类只需要处理自己该处理的工作,不该处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则。不再需要 “项目经理” 来处理所有的责任分配任务。
但我们在使用中也发现了它的一个明显缺点,如果这个 bug 没人处理,可能导致 “程序员祭天” 异常。其主要缺点有:
- 不能保证每个请求一定被处理,该请求可能一直传到链的末端都得不到处理。
- 如果责任链过长,请求的处理可能涉及多个处理对象,系统性能将受到一定影响。
- 责任链建立的合理性要靠客户端来保证,增加了客户端的复杂性,可能会由于责任链拼接次序错误而导致系统出错,比如可能出现循环调用。
多选题
5 个海盗抢得 100 枚金币,他们的代号为 ABCDE,他们将按照字母顺序依次提出金币的分配方案。首先由 A 提出分配方案,然后 5 人表决,如果支持此方案的人数不超过投票人数的一半,那么 A 将被扔入大海喂鲨鱼,然后由 B 继续提方案,依此类推。
假定每个海盗都绝顶聪明,并且相当理智,那么海盗 A 应该提出怎样的分配方案才能够使自己的收益最大化?
- A ABCDE 分别得:20,20,20,20,20
- B ABCDE 分别得:0,25,25,25,25
- C ABCDE 分别得:97,0,1,0,2
- D ABCDE 分别得:97,0,1,2,0
正确答案是 C D
解析:倒着推,因为规则是超过投票人数一半才不会死,所以如果只剩下 DE 两人,D 提出任何方案都会被 E 反对,然后被 E 独得 100 金币。D 知晓这一点,所以 D 一定会保 C 不死,C 知道 D 一定会帮自己,所以 C 一定会把 100 个金币都收走,DE 什么都得不到。B 知道这一点,所以 B 只需要给 D 和 E 每人一个金币,剩下 98 个给自己。因为对于支持 C 而言,支持 B 收益更多,所以 D 和 E 都会支持 B 的提议。所以对于 A 来说,A 只需要多给 D 或者 E 一枚金币,再给什么都没有的 C 一枚金币,让这两个人同意自己的方案即可。
那么这个问题和责任链模式有什么关系吗?这个…见本文第二段。
命令模式 Command
近年来,智能家居越来越流行。躺在家中,只需要打开对应的 app,就可以随手控制家电开关。但随之而来一个问题,手机里的 app 实在是太多了,每一个家具公司都想要提供一个 app 给用户,以求增加用户粘性,推广他们的其他产品等。
站在用户的角度来看,有时我们只想打开一下电灯,却要先看到恼人的 “新式电灯上新” 的弹窗通知,让人烦不胜烦。如果能有一个万能遥控器将所有的智能家居开关综合起来,统一控制,一定会方便许多。
说干就干,笔者立马打开 PS,设计了一张草图:
“咳咳,我对这个 app 的设计理念呢,是基于 “简洁就是美” 的原则。一个好的设计,首先,最重要的一点就是 ‘接地气’。当然,我也可以用一些华丽的素材拼接出一个花里胡哨的设计,但,那是一个最低级的设计师才会做的事情…”
翻译:He has no UI design skills.
总之 UI 设计完成啦,我们再来看下四个智能家居类的结构。
大门类:
1 | public class Door { |
电灯类:
1 | public class Light { |
电视类:
1 | public class Tv { |
音乐类:
1 | public class Music { |
由于是不同公司的产品,所以接口有所不同,接下来就一起来实现我们的万能遥控器!
万能遥控器 1.0
不一会儿,我们就写出了下面的代码:
1 | // 初始化开关 |
这份代码很直观,在每个开关状态改变时,调用对应家居的 API 实现打开或关闭。
只有这样的功能实在是太单一了,接下来我们再为它添加一个有趣的功能。
万能遥控器 2.0
一般来说,电视遥控器上都有一个回退按钮,用来回到上一个频道。相当于文本编辑器中的 “撤销” 功能,既然别的小朋友都有,那我们也要!
设计狮本狮马不停蹄地设计了 UI 2.0:
UI 设计倒是简单,底部添加一个按钮即可。代码设计就比较复杂了,我们需要保存上一步操作,并且将其回退。
一个很容易想到的想法是:设计一个枚举类 Operation,代表每一步的操作:
1 | public enum Operation { |
然后在客户端定义一个 Operation 变量,变量名字叫 lastOperation,在每一步操作后,更新此变量。然后在撤销按钮的点击事件中,根据上一步的操作实现回退:
1 | public class Client { |
大功告成,不过这份代码只实现了撤销一步,如果我们需要实现撤销多步怎么做呢?
思考一下,每次回退时,都是先将最后一步 Operation 撤销。对于这种后进先出的结构,我们自然就会想到——栈结构。使用栈结构实现回退多步的代码如下:
1 | public class Client { |
我们将每一步 Operation 记录到栈中,每次撤销时,弹出栈顶的 Operation,再使用 switch 语句判断,将其恢复。
虽然实现了功能,但代码明显已经变得越来越臃肿了,因为遥控器知道了太多的细节,它必须要知道每个家居的调用方式。以后有开关加入时,不仅要修改 Status 类,增加新的 Operation,还要修改客户端,增加新的分支判断,导致这个类变成一个庞大的类。不仅违背了单一权责原则,还违背了开闭原则,所以我们不得不思考怎么优化这份代码。
万能遥控器 3.0
我们期待能有一种设计,让遥控器不需要知道家居的接口。它只需要负责监听用户按下开关,再根据开关状态发出正确的命令,对应的家居在收到命令后做出响应。就可以达到将 “行为请求者” 和 ”行为实现者“ 解耦的目的。
先定义一个命令接口:
1 | public interface ICommand { |
接口中只有一个 execute 方法,表示 “执行” 命令。
定义开门命令,实现此接口:
1 | public class DoorOpenCommand implements ICommand { |
关门命令:
1 | public class DoorCloseCommand implements ICommand { |
开灯命令:
1 | public class LightOnCommand implements ICommand { |
关灯命令:
1 | public class LightOffCommand implements ICommand { |
电视、音乐的命令类似。
可以看到,我们将家居控制的代码转移到了命令类中,当命令执行时,调用对应家具的 API 实现开启或关闭。
客户端代码:
1 | // 初始化命令 |
现在,遥控器只知道用户控制开关后,需要执行对应的命令,遥控器并不知道这个命令会执行什么内容,它只负责调用 execute 方法,达到了隐藏技术细节的目的。
与此同时,我们还获得了一个附带的好处。由于每个命令都被抽象成了同一个接口,我们可以将开关代码统一起来。客户端优化如下:
1 | public class Client { |
不知不觉中,我们就写出了命令模式的代码。来看下命令模式的定义:
命令模式:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。
使用命令模式后,现在我们要实现撤销功能会非常容易。
首先,在命令接口中,新增 undo 方法:
1 | public interface ICommand { |
开门命令中新增 undo:
1 | public class DoorOpenCommand implements ICommand { |
关门命令中新增 undo:
1 | public class DoorCloseCommand implements ICommand { |
开灯命令中新增 undo:
1 | public class LightOnCommand implements ICommand { |
关灯命令中新增 undo:
1 | public class LightOffCommand implements ICommand { |
电视、音乐命令类似。
客户端:
1 | public class Client { |
我们同样使用了一个栈结构,用于存储所有的命令,在每次执行命令前,将命令压入栈中。撤销时,弹出栈顶的命令,执行其 undo 方法即可。
命令模式使得客户端的职责更加简洁、清晰了,命令执行、撤销的代码都被隐藏到了命令类中。唯一的缺点是 —— 多了很多的命令类,因为我们必须针对每一个命令都设计一个命令类,容易导致类爆炸。
除了撤销方便外,命令模式还有一个优点,那就是宏命令的使用,宏命令也就是组合多个命令的 “宏大的命令”。
宏命令
在我们学习宏命令前,先来了解一下宏。在使用 word 时,有时会弹出一个提示:是否启用宏?
在笔者小的时候(当然现在也没有很老),小小的眼睛里有大大的疑惑:这个 “宏” 是什么意思呢?简简单单一个字,却看起来如此的高大上,一定是一个很难的东西吧。
其实宏一点也不难,宏(英语:Macro)的意思是 “批量处理”,能够帮我们实现合并多个操作。
比如,在 word 中,我们需要设置一个文字加粗、斜体和字号 36。通常来说,我们需要三个步骤:
- 选中文字,设置加粗
- 选中文字,设置斜体
- 选中文字,设置字号 36
如果有一个设置,能一键实现这三个步骤,这个设置就称为一个宏。
(就这?)
如果我们有大量的文字需要这三个设置,定义一个宏就可以省下许多重复操作。
听起来是不是很像格式刷,不过宏远比格式刷要强大。比如宏可以实现将一段文字一键加上 【】
,在 Excel 中的宏还可以一键实现 居中
+ 排序
等操作。
比如笔者写的一个宏,效果是运行时给两个汉字自动加上中括号:
这个宏对应的 vba 代码长这样:
1 | Sub Macro1() |
它执行的逻辑就是先添加【
,再向后移动两个字符,再添加】
,这个宏帮我们一键实现了三个步骤。
当然这份 vba 代码完全是笔者为了秀一秀,不是我们讲解的重点。
重点是了解了宏,就不难理解宏命令了。宏命令就是 将多个命令合并起来组成的命令。
接下来我们给遥控器添加一个 “睡眠” 按钮,按下时可以一键关闭大门,关闭电灯,关闭电视、打开音乐(听着音乐睡觉,就是这么优雅)。UI 设计…就不看了吧,这时就可以使用宏命令:
1 | public class MacroCommand implements ICommand { |
有了宏命令,我们就可以任意组合多个命令,并且完全不会增加程序结构的复杂度。
客户端代码如下:
1 | // 定义睡眠宏命令 |
可以看到,我们将 doorCloseCommand, lightOffCommand, turnOffTvCommand, musicPlayCommand 三个命令组合到了宏命令 sleepCommand 中,这个宏命令的使用方式和普通命令一模一样,因为它本身也是一个实现了 ICommand 接口的命令而已。
请求排队
前文的定义中讲到,命令模式还可以用于请求排队。那么怎么实现请求排队功能呢?
要实现请求排队功能,只需创建一个命令队列,将每个需要执行的命令依次传入队列中,然后工作线程不断地从命令队列中取出队列头的命令,再执行命令即可。
事实上,安卓 app 的界面就是这么实现的。源码中使用了一个阻塞式死循环 Looper,不断地从 MessageQueue 中取出消息,交给 Handler 处理,用户的每一个操作也会通过 Handler 传递到 MessageQueue 中排队执行。
命令模式小结
命令模式可以说将封装发挥得淋漓尽致。在我们平时的程序设计中,最常用的封装是将拥有一类职责的对象封装成类,而命令对象的唯一职责就是通过 execute 去调用一个方法,也就是说它将 “方法调用” 这个步骤封装起来了,使得我们可以对 “方法调用” 进行排队、撤销等处理。
命令模式的主要优点如下:
- 降低系统的耦合度。将 “行为请求者” 和 ”行为实现者“ 解耦。
- 扩展性强。增加或删除命令非常方便,并且不会影响其他类。
- 封装 “方法调用”,方便实现 Undo 和 Redo 操作。
- 灵活性强,可以实现宏命令。
它的主要缺点是:
- 会产生大量命令类。增加了系统的复杂性。
单选题
公司新来了一个程序员小 A,技术主管给他分配了一个重构任务,经过小 A 的重构之后,这个类由一个类变成了十多个类,请问最大的可能性是什么?
- A 不懂代码逻辑,瞎改
- B 使用了设计模式!
正确答案是 B
解析:设计模式的工作常常是将功能细化,很容易造成类膨胀,在命令模式中体现得尤其明显。新手程序员很容易滥用设计模式,实际上使用设计模式需要慎重,在确实有必要的情况下再使用设计模式。作为一名合格的程序员,心中始终要将产品放在第一位,代码的易读性比炫技更重要。
解释器模式 Interpreter
我国 IT 界历来有一个汉语编程梦,虽然各方对于汉语编程争论不休,甚至上升到民族大义的高度,本文不讨论其对与错,但我们不妨来尝试一下,定义一个简单的中文编程语法。
在设计模式中,解释器模式就是用来自定义语法的,它的定义如下。
解释器模式(Interpreter Pattern):给定一门语言,定义它的文法的一种表示,并定义一个解释器,该解释器使用该表示来解释语言中的句子。
解释器模式较为晦涩难懂,但本文我们仍然深入浅出,通过一个简单的例子来学习解释器模式:使用中文编写出十以内的加减法公式。比如:
- 输入“一加一”,输出结果 2
- 输入“一加一加一”,输出结果 3
- 输入“二加五减三”,输出结果 4
- 输入“七减五加四减一”,输出结果 5
- 输入“九减五加三减一”,输出结果 6
看到这个需求,我们很容易想到一种写法:将输入的字符串分割成单个字符,把数字字符通过switch-case
转换为数字,再通过计算符判断是加法还是减法,对应做加、减计算,最后返回结果即可。
的确可行,但这实在太面向过程了。众所周知,面向过程编程会有耦合度高,不易扩展等缺点。接下来我们尝试按照面向对象的写法来实现这个功能。
按照面向对象的编程思想,我们应该为公式中不同种类的元素建立一个对应的对象。那么我们先分析一下公式中的成员:
- 数字:
零到九
对应0 ~ 9
- 计算符:
加、减
对应+、-
公式中仅有这两种元素,其中对于数字的处理比较简单,只需要通过 switch-case
将中文名翻译成阿拉伯数字即可。
计算符怎么处理呢?计算符左右两边可能是单个数字,也可能是另一个计算公式。但无论是数字还是公式,两者都有一个共同点,那就是他们都会返回一个整数:数字返回其本身,公式返回其计算结果。
所以我们可以根据这个共同点提取出一个返回整数的接口,数字和计算符都作为该接口的实现类。在计算时,使用栈结构存储数据,将数字和计算符统一作为此接口的实现类压入栈中计算。
talk is cheap, show me the code.
数字和计算符公共的接口:
1 | interface Expression { |
上文已经说到,数字和计算符都属于表达式的一部分,他们的共同点是都会返回一个整数。从表达式计算出整数的过程,我们称之为解释
(interpret)。
对数字类的解释实现起来相对比较简单:
1 | public class Number implements Expression { |
在 Number 类的构造函数中,先将传入的字符转换为对应的数字。在解释时将转换后的数字返回即可。
无论是加法还是减法,他们都是对左右两个表达式进行操作,所以我们可以将计算符提取出共同的抽象父类:
1 | abstract class Operator implements Expression { |
在此抽象父类中,我们存入了两个变量,表达计算符左右两边的表达式。
加法类实现如下:
1 | class Add extends Operator { |
减法类:
1 | class Sub extends Operator { |
加法类和减法类都继承自 Operator 类,在对他们进行解释时,将左右两边表达式解释出的值相加或相减即可。
数字类和计算符内都定义好了,这时我们只需要再编写一个计算类将他们综合起来,统一计算即可。
计算类:
1 | class Calculator { |
在计算类中,我们使用栈结构保存每一步操作。遍历 expression 公式:
- 遇到数字则将其压入栈中;
- 遇到计算符时,先将栈顶元素 pop 出来,再和下一个数字一起传入计算符的构造函数中,组成一个计算符公式压入栈中。
需要注意的是,入栈出栈过程并不会执行真正的计算,栈操作只是将表达式组装成一个嵌套的类对象而已。比如:
- “一加一”表达式,经过入栈出栈操作后,生成的对象是
new Add(new Number('一'), new Number('一'))
- “二加五减三”表达式,经过入栈出栈操作后,生成的对象是
new Sub(new Add(new Number('二'), new Number('五')), new Number('三'))
最后一步 stack.pop().interpret()
,将栈顶的元素弹出,执行 interpret()
,这时才会执行真正的计算。计算时会将中文的数字和运算符分别解释成计算机能理解的指令。
测试类:
1 | public class Client { |
这就是解释器模式,我们将一句中文的公式解释给计算机,然后计算机为我们运算出了正确的结果。
分析本例中公式的组成,我们可以发现几条显而易见的性质:
- 数字类不可被拆分,属于计算中的最小单元;
- 加法类、减法类可以被拆分成两个数字(或两个公式)加一个计算符,他们不是计算的最小单元。
在解释器模式中,我们将不可拆分的最小单元称之为终结表达式,可以被拆分的表达式称之为非终结表达式。
解释器模式具有一定的拓展性,当需要添加其他计算符时,我们可以通过添加 Operator 的子类来完成。但添加后需要按照运算优先级修改计算规则。可见一个完整的解释器模式是非常复杂的,实际开发中几乎没有需要自定义解释器的情况。
解释器模式有一个常见的应用,在我们平时匹配字符串时,用到的正则表达式就是一个解释器。
单选题
正则表达式中哪一部分表示终结表达式?
- A 表示一个字符的表达式
- B 表示字符出现个数的表达式
正确答案是 A
解析:正则表达式中,表示一个字符的表达式属于终结表达式,除终结表达式外的所有表达式都属于非终结表达式。
迭代器模式 Iterator
设想一个场景:我们有一个类中存在一个列表。这个列表需要提供给外部类访问,但我们不希望外部类修改其中的数据。
1 | public class MyList { |
通常来说,将成员变量提供给外部类访问有两种方式:
- 将此列表设置为 public 变量;
- 添加 getData() 方法,返回此列表。
但这两种方式都有一个致命的缺点,它们无法保证外部类不修改其中的数据。外部类拿到 data 对象后,可以随意修改列表内部的元素,这会造成极大的安全隐患。
那么有什么更好的方式吗?使得外部类只能读取此列表中的数据,无法修改其中的任何数据,保证其安全性。
分析可知,我们可以通过提供两个方法实现此效果:
- 提供一个
String next()
方法,使得外部类可以按照次序,一条一条的读取数据; - 提供一个
boolean hasNext()
方法,告知外部类是否还有下一条数据。
代码实现如下:
1 | public class MyList { |
客户端就可以使用一个 while 循环来访问此列表了:
1 | public class Client { |
由于没有给外部类暴露 data 成员变量,所以我们可以保证数据是安全的。
但这样的实现还有一个问题:当遍历完成后,hasNext() 方法就会一直返回 false,无法再一次遍历了,所以我们必须在一个合适的地方把 index 重置成 0。
在哪里重置比较合适呢?实际上,使用 next() 方法和 hasNext() 方法来遍历列表是一个完全通用的方法,我们可以为其创建一个接口,取名为 Iterator,Iterator 的意思是迭代器,迭代的意思是重复反馈,这里是指我们依次遍历列表中的元素。
1 | public interface Iterator { |
然后在 MyList 类中,每次遍历时生成一个迭代器,将 index 变量放到迭代器中。由于每个迭代器都是新生成的,所以每次遍历时的 index 自然也就被重置成 0 了。代码如下:
1 | public class MyList { |
客户端访问此列表的代码修改如下:
1 | public class Client { |
这就是迭代器模式,《设计模式》一书中将其定义如下:
迭代器模式(Iterator Pattern):提供一种方法访问一个容器对象中各个元素,而又不需暴露该对象的内部细节。
迭代器模式的核心就在于定义出 next() 方法和 hasNext() 方法,让外部类使用这两个方法来遍历列表,以达到隐藏列表内部细节的目的。
事实上,Java 已经为我们内置了 Iterator 接口,源码中使用了泛型使得此接口更加的通用:
1 | public interface Iterator<E> { |
并且,本例中使用的迭代器模式是仿照 ArrayList 的源码实现的,ArrayList 源码中使用迭代器模式的部分代码如下:
1 | public class ArrayList<E> { |
我们平时常用的 for-each 循环,也是迭代器模式的一种应用。在 Java 中,只要实现了 Iterable 接口的类,都被视为可迭代访问的。Iterable 中的核心方法只有一个,也就是刚才我们在 MyList 类中实现过的用于获取迭代器的 iterator() 方法:
1 | public interface Iterable<T> { |
只要我们将 MyList 类修改为继承此接口,便可以使用 for-each 来迭代访问其中的数据了:
1 | public class MyList implements Iterable<String> { |
客户端使用 for-each 访问:
1 | public class Client { |
这就是迭代器模式。基本上每种语言都会在语言层面为所有列表提供迭代器,我们只需要直接拿来用即可,这是一个比较简单又很常用的设计模式。
单选题
下列哪个场景更符合迭代器模式的思想?
- A 工厂流水线,生产一个个的产品
- B 请假单经公司领导层层审批
- C 军训时,从左至右依次报数
正确答案是 C
解析:迭代器的思想是依次访问,A 是工厂模式的思想,B 是责任链模式的思想。
中介者模式 Mediator
顾名思义,中介这个名字对我们来说实在太熟悉了。平时走在上班路上就会经常见到各种房产中介,他们的工作就是使得买家与卖家不需要直接打交道,只需要分别与中介打交道,就可以完成交易,用计算机术语来说就是减少了耦合度。
当类与类之间的关系呈现网状时,引入一个中介者,可以使类与类之间的关系变成星形。将每个类与多个类的耦合关系简化为每个类与中介者的耦合关系。
举个例子,在我们打麻将时,每两个人之间都可能存在输赢关系。如果每笔交易都由输家直接发给赢家,就会出现一种网状耦合关系。
我们用程序来模拟一下这个过程。
玩家类:
1 | class Player { |
此类中有一个 money 变量,表示自己的余额。当自己赢了某位玩家的钱时,调用 win 方法修改输钱的人和自己的余额。
需要注意的是,我们不需要输钱的方法,因为在 win 方法中,已经将输钱的人对应余额扣除了。
客户端代码:
1 | public class Client { |
在客户端中,每两位玩家需要进行交易时,都会增加程序耦合度,相当于每位玩家都需要和其他所有玩家打交道,这是一种不好的做法。
此时,我们可以引入一个中介类 —— 微信群,只要输家将自己输的钱发到微信群里,赢家从微信群中领取对应金额即可。网状的耦合结构就变成了星形结构:
此时,微信群就充当了一个中介者的角色,由它来负责与所有人进行交易,每个玩家只需要与微信群打交道即可。
微信群类:
1 | class Group { |
此类中只有一个 money 变量表示群内的余额。
玩家类修改如下:
1 | class Player { |
玩家类中新增了一个构造方法,在构造方法中将中介者传进来。每当自己有输赢时,只需要将钱发到群里或者在群里领取自己赢的钱,然后修改自己的余额即可。
客户端代码对应修改如下:
1 | public class Client { |
可以看到,通过引入中介者,客户端的代码变得更加清晰了。大家不需要再互相打交道,所有交易通过中介者完成即可。
事实上,这段代码还存在一点不足。因为我们忽略了一个前提:微信群里的钱不可以为负数。也就是说,输家必须先将钱发到微信群内,赢家才能去微信群里领钱。这个功能可以用我们在 Java 多线程王国奇遇记 中学到的 wait/notify
机制完成,与中介者模式无关,故这里不再给出相关代码,感兴趣的读者可以自行实现。
总而言之,中介者模式就是用于将类与类之间的多对多关系
简化成多对一、一对多关系
的设计模式,它的定义如下:
中介者模式(Mediator Pattern):定义一个中介对象来封装一系列对象之间的交互,使原有对象之间的耦合松散,且可以独立地改变它们之间的交互。
中介者模式的缺点也很明显:由于它将所有的职责都移到了中介者类中,也就是说中介类需要处理所有类之间的协调工作,这可能会使中介者演变成一个超级类。所以使用中介者模式时需要权衡利弊。
单选题
之前的文章中说到,享元模式又被称为轻量级模式。事实上,中介者模式也有另一个名字。根据你对中介者模式的了解,猜猜他的另一个名字叫什么?
- A管理者模式
- B黑中介模式
- C调解者模式
- D组合模式
正确答案是 C
解析:Mediator 的意思是调解员,中介者承担了中转和协调的作用。
备忘录模式 Memento
备忘录模式最常见的实现就是游戏中的存档、读档功能,通过存档、读档,使得我们可以随时恢复到之前的状态。
当我们在玩游戏时,打大 Boss 之前,通常会将自己的游戏进度存档保存,以保证自己打不过 Boss 的话,还能重新读档恢复状态。
玩家类:
1 | class Player { |
我们为玩家定义了两个属性:生命值和魔法值。其中有一个 fightBoss() 方法,每次打 Boss 都会扣减 100 点体力、100 点魔法值。如果生命值小于等于 0,则提示用户已“壮烈牺牲”。
客户端实现如下:
1 | public class Client { |
客户端中,我们在 fightBoss() 之前,先去存档,把自己当前的生命值和魔法值保存起来。打完 Boss 发现自己牺牲之后,再回去读档,将自己恢复到打 Boss 之前的状态。
这就是备忘录模式……吗?不完全是,事情并没有这么简单。
还记得我们在 原型模式 中,买的那杯和周杰伦一模一样的奶茶吗?开始时,为了克隆一杯奶茶,我们将奶茶的各个属性分别赋值成和周杰伦买的那杯奶茶一样。但这样存在一个弊端:我们不可能为一千个粉丝写一千份挨个赋值操作。所以最终我们在奶茶类内部实现了 Cloneable 接口,定义了 clone() 方法,来实现一行代码拷贝所有属性。
备忘录模式也应该采取类似的做法。我们不应该采用将单个属性挨个存取的方式来进行读档、存档。更好的做法是将存档、读档交给需要存档的类内部去实现。
新建备忘录类:
1 | class Memento { |
在此类中,管理需要存档的数据。
玩家类中,通过备忘录类实现存档、读档:
1 | class Player { |
客户端类对应修改如下:
1 | public class Client { |
这才是完整的备忘录模式。这个设计模式的定义如下:
备忘录模式:在不破坏封装的条件下,通过备忘录对象存储另外一个对象内部状态的快照,在将来合适的时候把这个对象还原到存储起来的状态。
备忘录模式的优点是:
- 给用户提供了一种可以恢复状态的机制,使用户能够比较方便的回到某个历史的状态
- 实现了信息的封装,使得用户不需要关心状态的保存细节
缺点是:
- 消耗资源,如果类的成员变量过多,势必会占用比较大的资源,而且每一次保存都会消耗一定的内存。
总体而言,备忘录模式是利大于弊的,所以许多程序都为用户提供了备份方案。比如 IDE 中,用户可以将自己的设置导出成 zip,当需要恢复设置时,再将导出的 zip 文件导入即可。这个功能内部的原理就是备忘录模式。
多选题
以下哪些说法是正确的?
- A 备忘录模式可以和命令模式结合,实现“撤销”功能
- B 备忘录模式可以和迭代器模式结合,以获取当前迭代器的状态,并在需要的时候进行回滚
- C 原型模式可以作为备忘录模式的简化版本
观察者模式 Observer
观察者模式非常常见,近年来逐渐流行的响应式编程就是观察者模式的应用之一。观察者模式的思想就是一个对象发生一个事件后,逐一通知监听着这个对象的监听者,监听者可以对这个事件马上做出响应。
生活中有很多观察者模式的例子,比如我们平时的开关灯。当我们打开灯的开关时,灯马上亮了;当我们关闭灯的开关时,灯马上熄了。这个过程中,灯就对我们控制开关的事件做出了响应,这就是一个最简单的一对一观察者模式。当力扣公众号发表一篇文章,所有关注了公众号的读者立即收到了文章,这个过程中所有关注了公众号的微信客户端就对公众号发表文章的事件做出了响应,这就是一个典型的一对多观察者模式。
再举个例子,比如警察一直观察着张三的一举一动,只要张三有什么违法行为,警察马上行动,抓捕张三。
这个过程中:
- 警察称之为观察者(Observer)
- 张三称之为被观察者(Observable,可观察的)
- 警察观察张三的这个行为称之为订阅(subscribe),或者注册(register)
- 张三违法后,警察抓捕张三的行动称之为响应(update)
众所周知,张三坏事做尽,是一个老法外狂徒了,所以不止一个警察会盯着张三,也就是说一个被观察者可以有多个观察者。当被观察者有事件发生时,所有观察者都能收到通知并响应。观察者模式主要处理的是一种一对多
的依赖关系。它的定义如下:
观察者模式(Observer Pattern):定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
我们使用程序来模拟一下这个过程。
观察者的接口:
1 | public interface Observer { |
接口中只有一个 update 方法,用于对被观察者发出的事件做出响应。
被观察者的父类:
1 | public class Observable { |
被观察者中维护了一个观察者列表,提供了三个方法:
- addObserver:将 observer 对象添加到观察者列表中
- removeObserver:将 observer 对象从观察者列表中移除
- notifyObservers:通知所有观察者有事件发生,具体实现是调用所有观察者的 update 方法
有了这两个基类,我们就可以定义出具体的罪犯与警察类。
警察属于观察者:
1 | public class PoliceObserver implements Observer { |
警察实现了观察者接口,当警察收到事件后,做出响应,这里的响应就是简单的打印了一条日志。
罪犯属于被观察者:
1 | public class CriminalObservable extends Observable { |
罪犯继承自被观察者类,当罪犯有犯罪行为时,所有的观察者都会收到通知。
客户端测试:
1 | public class Client { |
在客户端中,我们 new 了一个张三,为其添加了三个观察者:police1,police2,police3。
运行程序,输出如下:
1 | 罪犯正在放狗咬人 |
可以看到,所有的观察者都被通知到了。当某个观察者不需要继续观察时,调用 removeObserver 即可。
这就是观察者模式,它并不复杂,由于生活中一对多的关系非常常见,所以观察者模式应用广泛。
Java 源码中的观察者模式
实际上,Java 已经为我们提供了的 Observable 类和 Observer 类,我们在用到观察者模式时,无需自己创建这两个基类,我们来看一下 Java 中提供的源码:
java.util.Observer 类:
1 | public interface Observer { |
Observer 类和我们上例中的定义基本一致,都是只有一个 update 方法用于响应 Observable 的事件。区别有两点:
- update 方法将 Observable 对象也提供给了 Observer
- update 方法中的参数类型变成了 Object
这两点区别都是为了保证此 Observer 的适用范围更广。
java.util.Observable 类:
1 | public class Observable { |
Observable 类和我们上例中的定义也是类似的,区别在于:
- 用于保存观察者列表的容器不是 ArrayList,而是 Vector
- 添加了一个 changed 字段,以及 setChanged 和 clearChanged 方法。分析可知,当 changed 字段为 true 时,才会通知所有观察者,否则不通知观察者。所以当我们使用此类时,想要触发 notifyObservers 方法,必须先调用 setChanged 方法。这个字段相当于在被观察者和观察者之间添加了一个可控制的阀门。
- 提供了 countObservers 方法,用于计算观察者数量
- 添加了一些 synchronized 关键字保证线程安全
这些区别仍然是为了让 Observable 的适用范围更广,核心思想与本文介绍的都是一致的。
多选题
在电影《让子弹飞》中,马县长、县长夫人和康师爷一起吃着火锅,唱着歌,突然被马匪张麻子给劫了!这个过程中体现了哪些观察者模式?
- A 马县长观察着火锅
- B 张麻子观察着县长车队
- C 县长夫人观察着窗外的风景
- D 康师爷观察着县长夫人
状态模式 State
状态模式(State Pattern):当一个对象的内在状态改变时允许改变其行为,这个对象看起来像是改变了其类。
通俗地说,状态模式就是一个关于多态的设计模式。
如果一个对象有多种状态,并且每种状态下的行为不同,一般的做法是在这个对象的各个行为中添加 if-else 或者 switch-case 语句。但更好的做法是为每种状态创建一个状态对象,使用状态对象替换掉这些条件判断语句,使得状态控制更加灵活,扩展性也更好。
举个例子,力扣的用户有两种状态:普通用户和 PLUS 会员。PLUS 会员有非常多的专享功能,其中“模拟面试”功能非常有特色,我们便以此为例。
- 当普通用户点击模拟面试功能时,提示用户:模拟面试是 Plus 会员专享功能;
- 当 PLUS 会员点击模拟面试功能时,开始一场模拟面试。
先来看看不使用状态模式的写法,看出它的缺点后,我们再用状态模式来重构代码。
首先定义一个用户状态枚举类:
1 | public enum State { |
NORMAL 代表普通用户状态,PLUS 代表 PLUS 会员状态。
用户的功能接口:
1 | public interface IUser { |
本例中我们只定义了一个模拟面试的方法,实际开发中这里可能会有许许多多的方法。
用户状态切换接口:
1 | public interface ISwitchState { |
此接口中定义了两个方法:purchasePlus 方法表示购买 Plus 会员,用户状态变为 PLUS 会员状态,expire 方法表示会员过期,用户状态变为普通用户状态。
力扣用户类:
1 | public class User implements IUser, ISwitchState { |
用户类实现了 IUser 接口,IUser 接口中的每个功能都需要判断用户是否为 Plus 会员,也就是说每个方法中都有if (state == State.PLUS) {} else {}
语句,如果状态不止两种,还需要用上 switch-case
语句来判断状态,这就是不使用状态模式的弊端:
- 判断用户状态会产生大量的分支判断语句,导致代码冗长;
- 当状态有增加或减少时,需要改动多个地方,违反开闭原则。
在《代码整洁之道》、《重构》两本书中都提到:应使用多态取代条件表达式。接下来我们就利用多态特性重构这份代码。
为每个状态新建一个状态类,普通用户:
1 | class Normal implements IUser { |
PLUS 会员:
1 | class Plus implements IUser { |
每个状态类都实现了 IUser 接口,在接口方法中实现自己特定的行为。
用户类:
1 | class User implements IUser, ISwitchState { |
即可。
客户端测试:
1 | public class Client { |
可以看到,用户状态改变后,行为也随着改变了,这就是状态模式定义的由来,它的优点是:将与特定状态相关的行为封装到一个状态对象中,使用多态代替 if-else 或者 switch-case 状态判断。缺点是必然导致类增加,这也是使用多态不可避免的缺点。
策略模式 Strategy
策略模式用一个成语就可以概括 —— 殊途同归。当我们做同一件事有多种方法时,就可以将每种方法封装起来,在不同的场景选择不同的策略,调用不同的方法。
策略模式(Strategy Pattern):定义了一系列算法,并将每一个算法封装起来,而且使它们还可以相互替换。策略模式让算法独立于使用它的客户而独立变化。
我们以排序算法为例。排序算法有许多种,如冒泡排序、选择排序、插入排序,算法不同但目的相同,我们可以将其定义为不同的策略,让用户自由选择采用哪种策略完成排序。
首先定义排序算法接口:
1 | interface ISort { |
接口中只有一个 sort 方法,传入一个整型数组进行排序,所有的排序算法都实现此接口。
冒泡排序:
1 | class BubbleSort implements ISort{ |
选择排序:
1 | class SelectionSort implements ISort { |
插入排序:
1 | class InsertSort implements ISort { |
这三种都是基本的排序算法,就不再详细介绍了。接下来我们需要创建一个环境类,将每种算法都作为一种策略封装起来,客户端将通过此环境类选择不同的算法完成排序。
1 | class Sort implements ISort { |
在此类中,我们保存了一个 ISort 接口的实现对象,在构造方法中,将其初始值传递进来,排序时调用此对象的 sort 方法即可完成排序。
我们也可以为 ISort 对象设定一个默认值,客户端如果没有特殊需求,直接使用默认的排序策略即可。
setSort 方法就是用来选择不同的排序策略的,客户端调用如下:
1 | public class Client { |
这就是基本的策略模式,通过策略模式我们可以为同一个需求选择不同的算法,以应付不同的场景。比如我们知道冒泡排序和插入排序是稳定的,而选择排序是不稳定的,当我们需要保证排序的稳定性就可以采用冒泡排序和插入排序,不需要保证排序的稳定性时可以采用选择排序。
策略模式还可以应用在图片缓存中,当我们开发一个图片缓存框架时,可以通过提供不同的策略类,让用户根据需要选择缓存解码后的图片
、缓存未经解码的数据
或者不缓存任何内容
。在一些开源的图片加载框架中,就采用了这种设计。
策略模式扩展性和灵活性都相当不错。当有新的策略时,只需要增加一个策略类;要修改某个策略时,只需要更改具体的策略类,其他地方的代码都无需做任何调整。
但现在这样的策略模式还有一个弊端,如本系列第一篇文章中的工厂模式所言:每 new 一个对象,相当于调用者多知道了一个类,增加了类与类之间的联系,不利于程序的松耦合。
所以使用策略模式时,更好的做法是与工厂模式结合,将不同的策略对象封装到工厂类中,用户只需要传递不同的策略类型,然后从工厂中拿到对应的策略对象即可。接下来我们就来一起实现这种工厂模式与策略模式结合的混合模式。
创建排序策略枚举类:
1 | enum SortStrategy { |
在 Sort 类中使用简单工厂模式:
1 | class Sort implements ISort { |
利用简单工厂模式,我们将创建策略类的职责移到了 Sort 类中。如此一来,客户端只需要和 Sort 类打交道,通过 SortStrategy 选择不同的排序策略即可。
客户端:
1 | public class Client { |
通过简单工厂模式与策略模式的结合,我们最大化地减轻了客户端的压力。这是我们第一次用到混合模式,但实际开发中会遇到非常多的混合模式,学习设计模式的过程只能帮助我们各个击破,真正融会贯通还需要在实际开发中多加操练。
需要注意的是,策略模式与状态模式非常类似,甚至他们的 UML 类图都是一模一样的。两者都是采用一个变量来控制程序的行为。策略模式通过不同的策略执行不同的行为,状态模式通过不同的状态值执行不同的行为。两者的代码很类似,他们的区别主要在于程序的目的不同。
- 使用策略模式时,程序只需选择一种策略就可以完成某件事。也就是说每个策略类都是完整的,都能独立完成这件事情,如上文所言,强调的是
殊途同归
。 - 使用状态模式时,程序需要在不同的状态下不断切换才能完成某件事,每个状态类只能完成这件事的一部分,需要所有的状态类组合起来才能完整的完成这件事,强调的是
随势而动
。
单选题
策略模式的思想是?
A
人妖殊途
B
殊途同归
C
同归于尽
模板方法模式 Template method
模板方法模式(Template Method Pattern):定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
通俗地说,模板方法模式就是一个关于继承的设计模式。
每一个被继承的父类都可以认为是一个模板,它的某些步骤是稳定的,某些步骤被延迟到子类中实现。
这和我们平时生活中使用的模板也是一样的。比如我们请假时,通常会给我们一份请假条模板,内容是已经写好的,只需要填写自己的姓名和日期即可。
本人 ____ 因 ____ 需请假 ___ 天,望批准!
这个模板用代码表示如下:
1 | abstract class LeaveRequest { |
在这份模板中,所有的其他步骤(固定字符串)都是稳定的,只有姓名、请假原因、请假时长是抽象的,需要延迟到子类去实现。
继承此模板,实现具体步骤的子类:
1 | class MyLeaveRequest extends LeaveRequest { |
测试:
1 | // 输出:本人阿笠因参加力扣周赛需请假0.5天,望批准 |
在使用模板方法模式时,我们可以为不同的模板方法设置不同的控制权限:
- 如果不希望子类覆写模板中的某个方法,使用 final 修饰此方法;
- 如果要求子类必须覆写模板中的某个方法,使用 abstract 修饰此方法;
- 如果没有特殊要求,可使用 protected 或 public 修饰此方法,子类可根据实际情况考虑是否覆写。
单选题
模板方法模式体现了面向对象的什么特性?
A
继承
B
封装
C
多态
访问者模式 Visitor
许多设计模式的书中都说访问者模式是最复杂的设计模式,实际上只要我们对它抽丝剥茧,就会发现访问者模式的核心思想并不复杂。
以我们去吃自助餐为例,每个人喜欢的食物是不一样的,比如 Aurora 喜欢吃龙虾和西瓜,Kevin 喜欢吃牛排和香蕉,餐厅不可能单独为某一位顾客专门准备食物。所以餐厅的做法是将所有的食物都准备好,顾客按照需求自由取用。此时,顾客和餐厅之间就形成了一种访问者与被访问者的关系。
准备好各种食物的餐厅:
1 | class Restaurant { |
在餐厅类中,我们提供了四种食物:龙虾、西瓜、牛排、香蕉。
为顾客提供的接口:
1 | public interface IVisitor { |
接口中提供了四个方法, 让顾客依次选择每种食物。
在餐厅中提供接收访问者的方法:
1 | class Restaurant { |
在 welcome 方法中,我们将食物依次传递给访问者对应的访问方法。这时候,顾客如果想要访问餐厅选择自己喜欢的食物,只需要实现 IVisitor 接口即可。
比如顾客 Aurora 类:
1 | public class Aurora implements IVisitor { |
在此类中,顾客根据自己的喜好依次选择每种食物。
客户端测试:
1 | public class Client { |
运行程序,输出如下:
1 | Aurora gets a lobster |
可以看到,Aurora 对每一种食物做出了自己的选择,这就是一个最简单的访问者模式,它已经体现出了访问者模式的核心思想:将数据的结构
和对数据的操作
分离。
访问者模式(Visitor Pattern):表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。
本例中,顾客需要选择餐厅的食物,由于每个顾客对食物的选择是不一样的,如果在餐厅类中处理每位顾客的需求,必然导致餐厅类职责过多。所以我们并没有在餐厅类中处理顾客的需求,而是将所有的食物通过接口暴露出去,欢迎每位顾客来访问。顾客只要实现访问者接口就能访问到所有的食物,然后在接口方法中做出自己的选择。
相信这个例子还是非常简单直观的,看起来访问者模式也不是那么难理解。那么为什么很多书中说访问者模式是最复杂的设计模式呢?原因就在于《设计模式》一书中给访问者模式设计了一个“双重分派”的机制,而 Java 只支持单分派,用单分派语言强行模拟出双重分派才导致了访问者模式看起来比较复杂。要理解这一点,我们先来了解一下何谓单分派、何谓双重分派。
单分派与双重分派
先看一段代码:
Food 类:
1 | public class Food { |
Watermelon 类,继承自 Food 类:
1 | public class Watermelon extends Food { |
在 Watermelon 类中,我们重写了 name()
方法。
客户端:
1 | public class Client { |
思考一下,在客户端中,我们 new 出了一个 Watermelon 对象,但他的声明类型是 Food,当我们调用此对象的 name 方法时,会输出 “food” 还是 “watermelon” 呢?
了解过 Java 多态特性的同学都知道,这里肯定是输出 “watermelon” ,因为 Java 调用重写方法时,会根据运行时的具体类型来确定调用哪个方法。
再来看一段测试代码:
1 | public class Client { |
在这段代码中,我们仍然 new 出了一个 Watermelon 对象,他的声明类型是 Food,在客户端中有 eat(Food food)
和 eat(Watermelon watermelon)
两个重载方法,这段代码会调用哪一个方法呢?
我们运行这段代码会发现输出的是:
1 | eat food |
这是由于 Java 在调用重载方法时,只会根据方法签名中声明的参数类型来判断调用哪个方法,不会去判断参数运行时的具体类型是什么。
从这两个例子中,我们可以看出 Java 对重写方法和重载方法的调用方式是不同的。
- 调用重写方法时,与对象的运行时类型有关;
- 调用重载方法时,只与方法签名中声明的参数类型有关,与对象运行时的具体类型无关。
了解了重写方法和重载方法调用方式的区别之后,我们将其综合起来就能理解何谓双重分派了。
测试代码:
1 | public class Client { |
在这段测试代码中,仍然是 new 出了一个 Watermelon 对象,它的声明类型为 Food。运行 test()
函数,输出如下:
1 | eat food: watermelon |
在面向对象的编程语言中,我们将方法调用称之为分派
,这段测试代码运行时,经过了两次分派:
- 调用重载方法:选择调用
eat(Food food)
还是eat(Watermelon watermelon)
。虽然这里传入的这个参数实际类型是Watermelon
,但这里会调用eat(Food food)
,这是由于调用哪个重载方法是在编译期就确定了的,也称之为静态分派
。 - 调用重写方法:选择调用
Food
的name
方法还是Watermelon
的name
方法。这里会根据参数运行时的实际类型,调用Watermelon
的name
方法,称之为动态分派
。
单分派、双重分派的定义如下:
方法的接收者和方法的参数统称为方法的宗量。 根据分派基于多少个宗量,可以将分派分为单分派和多分派。单分派是指根据一个宗量就可以知道应该调用哪个方法,多分派是指需要根据多个宗量才能确定调用目标。
这段定义可能不太好理解,通俗地讲,单分派和双重分派的区别就是:程序在选择重载方法和重写方法时,如果两种情况都是动态分派的,则称之为双重分派;如果其中一种情况是动态分派,另一种是静态分派,则称之为单分派。
说了这么多,这和我们的访问者模式有什么关系呢?首先我们要知道,架构的演进往往都是由复杂的业务驱动的,当程序需要更好的扩展性,更灵活的架构便诞生出来。
上例中的程序非常简单,但它无法处理某种食物有多个的情形。接下来我们就来修改一下程序,来应对每种食物有多个的场景。
自助餐程序 2.0 版
在上面的例子中,为了突出访问者模式的特点,我们将每种食物都简化为了 String 类型,实际开发中,每种食物都应该是一个单独的对象,统一继承自父类 Food:
1 | public abstract class Food { |
继承自 Food 的四种食物:
1 | public class Lobster extends Food { |
1 | public class Watermelon extends Food { |
1 | public class Steak extends Food { |
1 | public class Banana extends Food { |
四个子类中分别重写了 name 方法,返回自己的食物名。
IVisitor 接口对应修改为:
1 | public interface IVisitor { |
每种食物都继承自 Food,所以我们将接口中的方法名都修改为了 chooseFood。
餐厅类修改如下:
1 | class Restaurant { |
餐厅类中新增了 prepareFoods
方法,在这个方法中,我们简单模拟了准备多个食物的过程,将每种食物添加了 10 份。在接收访问者的 welcome
方法中,遍历所有食物,分别提供给顾客。
看起来很美好,实际上,visitor.chooseFood(food)
这一行是无法编译通过的,原因就在于上一节中提到的单分派机制。虽然每种食物都继承自 Food 类,但由于接口中没有 chooseFood(Food food)
这个重载方法,所以这一行会报错 “Cannot resolve method chooseFood”。
试想,如果 Java 在调用重载方法时也采用动态分派,也就是根据参数的运行时类型选择对应的重载方法,这里遇到的问题就迎刃而解了,我们的访问者模式讲到这里也就可以结束了。
但由于 Java 是单分派语言,所以我们不得不想办法解决这个 bug,目的就是 使用单分派的 Java 语言模拟出双分派的效果,能够根据运行时的具体类型调用对应的重载方法。
我们很容易想到一种解决方式,采用 instanceOf 判断对象的具体子类型,再将父类强制转换为具体子类型,调用对应的接口方法:
1 | // 通过 instanceOf 判断具体子类型,再强制向下转型 |
的确可行,在某些开源代码中便是这么做的,但这种强制转型的方式既冗长又不符合开闭原则,所以《设计模式》一书中给我们推荐了另一种做法。
首先在 Food 类中添加 accept(Visitor visitor) 抽象方法:
1 | public abstract class Food { |
在具体子类中,实现此方法:
1 | public class Lobster extends Food { |
经过这两步修改,餐厅类就可以将接收访问者的方法修改如下:
1 | class Restaurant { |
经过这三步修改,我们将访问者来访的代码由:
1 | visitor.chooseFood(food); |
改成了
1 | food.accept(visitor); |
这样我们就将重载方法模拟成了动态分派。这里的实现非常巧妙,由于 Java 调用重写方法时是动态分派的,所以 food.accept(visitor)
会调用具体子类的 accept 方法,在具体子类的 accept 方法中,调用 visitor.chooseFood(this)
,由于这个 accept 方法是属于具体子类的,所以这里的 this 一定是指具体的子类型,不会产生歧义。
再深入分析一下:之前的代码中,调用 visitor.chooseFood(food)
这行代码时,由于重载方法不知道 Food 的具体子类型导致了编译失败,但实际上这时我们是可以拿到 Food 的具体子类型的。利用重写方法会动态分派的特性,我们在子类的重写方法中去调用这些重载的方法,使得重载方法使用起来也像是动态分派的一样。
顾客 Aurora 类:
1 | public class Aurora implements IVisitor { |
顾客 Kevin 类:
1 | public class Kevin implements IVisitor { |
客户端测试:
1 | public class Client { |
运行程序,输出如下:
1 | Aurora gets a lobster |
这就是访问者模式,它的核心思想其实非常简单,就是第一小节中体现的将数据的结构
与对数据的操作
分离。之所以说它复杂,主要在于大多数语言都是单分派语言,所以不得不模拟出一个双重分派,也就是 用重写方法的动态分派特性将重载方法也模拟成动态分派。
但模拟双重分派只是手段,不是目的。有的文章中说模拟双重分派是访问者模式的核心,还有的文章中说双分派语言不需要访问者模式,笔者认为这些说法都有点舍本逐末了。
单选题
关于重载方法与重写方法,下列说法正确的是:
- A 重载方法的参数列表必须修改
- B 重载方法的返回类型必须修改
- C 重写方法可以新增异常类型
- D 重写方法可以将 public 方法改为 private
小结 Summary
行为型模式重点关注类与类之间的交互与协作。如同在工作中,每个人的行为都可能影响到其他同事,同时每个人也会受到别人的影响。我们一边接收上级的指令,一边派发任务给下级,在这样的协作中完成一项项伟大的工作。程序在运行时,每个对象都不是孤立的,他们可以通过通信与协作完成种种复杂的功能。
- 责任链模式:处理职责相同,程度不同的对象,使其在一条链上传递
- 命令模式:封装“方法调用”,将行为请求者和行为实现者解耦
- 解释器模式:定义自己的语法规则
- 迭代器模式:定义 next() 方法和 hasNext() 方法,让外部类使用这两个方法来遍历列表,以达到隐藏列表内部细节的目的
- 中介者模式:通过引入中介者,将网状耦合结构变成星型结构
- 备忘录模式:存储对象的状态,以便恢复
- 观察者模式:处理一对多的依赖关系,被观察的对象改变时,多个观察者都能收到通知
- 状态模式:关于多态的设计模式,每个状态类处理对象的一种状态
- 策略模式:殊途同归,用多种方法做同一件事
- 模板方法模式:关于继承的设计模式,父类是子类的模板
- 访问者模式:将数据的结构和对数据的操作分离