作者:靓仔
设计模式 :)
面向对象设计原则
1. 单一职责原则(Single Responsibility Principle)
1.1 定义
一个对象应该只包含一个职责,并且该职责被完整地封装在一个类中。
1.2 单一职责原则分析
一个类(或者大到模块,小到方法)承担的职责越多,它被复用的可能性越小,而且如果一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作。
1.3 实例解析
举例说明,用一个登录场景来描述:
根据单一职责原则,可以对上述代码进行重构,如下图:
2. 开闭原则(Open-Closed Principle)
2.1 定义
一个软件实体应当对对于拓展开放,对修改关闭。
2.2 开闭原则分析
开闭原则是面向对象设计的目标。其中一个软件实体包括:
- 一个软件模块
- 一个由多个类组成的局部结构
- 一个独立的类
开闭原则的关键是进行抽象化设计。
2.3 实例解析
某图形界面工具提供各种不同形状的按钮:
使用开闭原则进行重构后:
3. 里氏替代原则(Liskov Substitution Principle)
3.1 定义
所有引用基类(父类)的地方必须能透明地使用其子类的对象。把基类替换为子类不会有任何的错误和异常,反过来则不成立。
3.2 里氏替代原则解析
开闭原则的核心是对于系统进行抽象化,并且从抽象化导出到具体化。从抽象化到具体化的过程需要使用继承关系及里氏替代原则
3.3 实例解析
- 可查看开闭原则的实例解析
- 有人说他喜欢动物,那他也喜欢猫,因为猫也是一种动物 :)
4. 依赖倒置原则(Dependence Inversion Principle)
4.1 定义
高层模块不应该依赖低层模块,它们都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。简单来说,依赖倒置原则就是代码要依赖于抽象的类,而不要依赖具体的类;要针对接口或抽象编程,而不要针对具体类编程。
4.2 依赖倒置原则
如果说开闭原则是面向对象设计的目标,那么依赖倒置原则就是面向对象设计的主要手段。
依赖倒置原则要求客户端依赖于抽象耦合,以抽象方式耦合是依赖倒置原则的关键。由于一个抽象耦合关系总要涉及到具体类从抽象类继承,并且需要保证在任何引用到基类的地方可以替换为子类,因此,里氏替代原则是依赖倒置原则的基础。
4.3 实例解析
假设我们设计一辆汽车:先设计轮子,然后根据轮子大小设计底盘,接着根据底盘设计车身,最后根据车身设计好整个汽车。这里就出现了一个“依赖”关系:汽车依赖车身,车身依赖底盘,底盘依赖轮子。
为了理解这几个概念,我们还是用上面汽车的例子。只不过这次换成代码。我们先定义四个Class,车,车身,底盘,轮胎。然后初始化这辆车,最后跑这辆车。代码结构如下:
为了理解这几个概念,我们还是用上面汽车的例子。只不过这次换成代码。我们先定义四个Class,车,车身,底盘,轮胎。然后初始化这辆车,最后跑这辆车。代码结构如下:
这样的设计看起来没问题,但是可维护性却很低。假设设计完工之后,上司却突然说根据市场需求的变动,要我们把车子的轮子设计都改大一码,这样整个设计几乎都得改!
我们现在换一种思路。我们先设计汽车的大概样子,然后根据汽车的样子来设计车身,根据车身来设计底盘,最后根据底盘来设计轮子。这时候,依赖关系就倒置过来了:轮子依赖底盘, 底盘依赖车身, 车身依赖汽车。
这时候,上司再说要改动轮子的设计,我们就只需要改动轮子的设计,而不需要动底盘,车身,汽车的设计了。
依赖注入方式:
- 构造注入(Constructor Injection)
- 设值注入(Setter Injection)
- 接口注入(Interface Injection)
5. 接口隔离原则(Interface Segregation Principle)
5.1 定义
客户端不应该依赖那些他不需要的接口,换句话说,一旦一个接口太大,则需要将它分割成一些更细小的接口,使用接口的客户端仅需要知道与之相关的方法即可。
5.2 接口隔离原则分析
- 接口隔离原则是指使用多个专门的接口,而不使用单一的总接口。每一个接口应该承担一种相对独立的角色,不多不少,不干不该干的事,该干的事都要干。这种缩小的接口也称为角色接口。
- 使用接口隔离原则拆分接口时,首先必须满足单一职责原则。将一组相关的操作定义在一个接口中,且在满足高内聚的前提下,接口中的方法越少越好。
- 可以在进行系统设计时采用定制服务的方式,即为不同的客户提供宽窄不同的接口,只提供用户所需要的行为,而隐藏掉用户不需要的行为。
5.3 实例解析
例如有一个用户多个客户类的系统,在系统中定义了一个巨大的接口(胖接口),如下:
如果客户类ClientA只需针对方法operationA()进行编程,但由于提供的是一个胖接口,AbstractService的实现类ConcreteService必须实现在AbstractService中声明的所有三个方法,而且在ClientA中除了能看到operationA(),还能看到与之不相关的方法,在一定程度上也影响了系统的封装性。使用接口隔离原则重构后:
在使用接口隔离原则的时候,要注意接口的粒度,接口不能太小,如果太小会导致系统中的接口泛滥,不利于维护;接口也不能太大,如果接口太大将违背接口隔离原则,灵活性较差,使用起来很不方便。一般而言,接口中仅包含为某一类用户定制的方法即可。
6. 合成复用原则(Composite Reuse Principle)
6.1 定义
合成复用原则又称为组合/聚合复用原则,其定义为:尽量使用对象组合,而不是继承(这里指的继承是实现继承,而不是接口继承)来达到复用目的。
6.2 合成复用原则分析
关联关系的主要优势在于不会破坏类的封装性,而且继承是一种耦合度比较大的静态关系,无法在程序运行时动态拓展。在软件开发阶段,关联关系虽然不会比继承关系减少编码量,但到了软件维护阶段,由于关联关系具有较好的松耦合性,因此使得系统更加容易维护。当然,关联关系的缺点是会比继承关系要多创建更多的对象。
为什么继承会破坏类的封装性呢?
子类依赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。因而,子类必须要跟着其超类的更新而演变,除非超类是专门为了扩展而设计的,并且具有很好的文档说明。
这里我们举个例子,我们编写一个关于HashSet
的程序,看一看它被创建依赖添加了多少个元素:
@Data
public class HashSetTest<E> extends HashSet<E> {
private int count;
public HashSetTest() {
super();
}
public HashSetTest(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
}
@Override
public boolean add(E e) {
count++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
count += c.size();
return super.addAll(c);
}
public static void main(String[] args) {
HashSetTest<Integer> test = new HashSetTest<>();
test.addAll(Arrays.asList(1, 2, 3));
System.out.println(test.getCount());
}
}
复制代码
此时我们期望getAddCount
方法能返回3,但是实际上它返回的是6。哪里出错了呢?在HashSet
的内部,addAll
方法是基于它的add
方法来实现的,即使HashSet
的文档中并没有说明这样的实现细节,这也是合理的。
导致子类脆弱的一个相关的原因是,它们的超类在后续的发行版本中可以获得新的方法。假设一个程序的安全性依赖于这样的事实:所有被插入某个集合中的元素都满足某个先决条件。下面的做法就可以确保这一点:对集合进行子类化,并覆盖所有能够添加元素的方法,以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的发行版本中,超类中没有增加能插入元素的新方法,这种做法就可以正常工作。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被子类覆盖的新方法,而将“非法的”元素添加到子类的实例中。
那什么时候用继承呢?
只有当子类真正是超类的子类型(subtype)时,才适合用继承。换句话说,对于两个类A和B,只有当两者之间确实存在“is-a”关系的时候,类B才应该扩展类A。通常情况下,B应该包含A的一个私有实例,并且暴露一个较小的、较简单的API:A本质上不是B的一部分,只是它的实现细节而已。
在这里我们不对继承的缺点和使用场景做过多的讲解,如有兴趣,可以查看<<effective java>>第18、19条。
7. 迪米特法则(Law of Demeter)
7.1 定义
迪米特法则(Law of Demeter, LoD)又称为最少知识原则(Least Knowledge Principle, LKP),它有多种定义方法,其中几种典型定义如下:
(1) 不要和“陌生人”说话。
(2) 只与你的直接朋友通信。
(3) 每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
7.2 迪米特法则分析
迪米特法则就是指一个软件实体应当尽可能少的与其他实体发生相互作用。这样,当一个模块进行修改时,就会尽量少的影响其他的模块,拓展会相对容易。
在迪米特法则中,对于一个对象,其朋友包括以下几类:
(1) 当前对象本身(this);
(2) 以参数形式传入到当前对象方法中的对象;
(3) 当前对象的成员对象;
(4) 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
(5) 当前对象所创建的对象。
public class Client {
private static final A a = new A();
private static List<B> b;
public static void method(C C) {
D d = new D();
return;
}
}
复制代码
在狭义的迪米特法则中,如果两个类之间不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,如果其中的一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。
7.3 实例解析
某系统界面类(如Form1、Form2等类)与数据访问类(如DAO1、DAO2等类)之间的调用关系较为复杂,如图所示:
用迪米特法则重构后:
设计模式概述
起源
1990年,软件工程界开始关注Christopher Alexander等在这一住宅、公共建筑与城市规划领域的重大突破,最早将该模式的思想引入软件工程方法学的是1991-1992年以“四人组(Gang of Four,GoF)”自称的四位著名软件工程学者,他们在1994年归纳发表了23种在软件开发中使用频率较高的设计模式,旨在用模式来统一沟通面向对象方法在分析、设计和实现间的鸿沟。
为什么需要设计模式?
设计模式(Design Pattern)是一套被反复利用、多数人知晓的、经过分类编目的、代码设计经验的总结,使用设计模式是为了可重用代码、让代码更容易让人理解、保证代码的可靠性。
在GOF(Gang of Four)的经典著作《设计模式:可复用对象对象软件的基础》一书中一共描述了23中的设计模式,23种设计模式如下所示:
创建型模式
创建型模式隐藏了类的实例的创建细节,通过隐藏对象如何被创建和组合在一起达到使整个系统独立的目的。
获取苹果的两种方式:
- 自己种苹果树
- 去超市买
自己种苹果树就类似于用构造函数创建一个类,而去超市买就类似于用创建型模式去创建一个对象。
创建型模式又分为对象创建型模式和类创建型模式。对象创建型模式处理对象的创建,类创建型模式处理类的创建。详细地说,对象创建型模式把对象创建的一部分推迟到另一个对象中,而类创建型模式将它对象的创建推迟到子类中。
1. 工厂方法模式(Factory Method Pattern)
1.1 定义
在工厂方法模式中,工厂父类负责定义创建产品对象的公共接口,而工厂子类则负责生成具体的产品对象,这样做的目的是将产品类的实例化操作延迟到工厂子类中完成,即通过工厂子类来确定究竟应该实例化哪一个具体产品类。
1.2 实例解析
我们举一个电视机厂生产电视机的栗子,该实例用工厂方法构造后如下:
- 抽象产品类
/**
* 抽象产品类TV
**/
public interface Tv {
void play();
}
复制代码
- 具体产品类
/**
* 具体产品类
**/
public class HaierTv implements Tv {
@Override
public void play() {
System.out.println("海尔电视机正在播放....");
}
}
复制代码
/**
* 具体产品类
**/
public class HisenseTv implements Tv {
@Override
public void play() {
System.out.println("海信电视机正在播放...");
}
}
复制代码
- 抽象工厂类
/**
* 抽象工厂类
**/
public interface TvFactory {
Tv productTv();
}
复制代码
- 具体工厂类
/**
* 具体工厂类
**/
public class HaierTvFactory implements TvFactory {
@Override
public Tv productTv() {
return new HaierTv();
}
}
复制代码
- 客户端
public class Client {
public static void main(String[] args) {
TvFactory haierTvFactory = new HaierTvFactory();
TvFactory hisenseTvFactory = new HisenseTvFactory();
Tv haierTv = haierTvFactory.productTv();
Tv hisenseTv = hisenseTvFactory.productTv();
haierTv.play();
hisenseTv.play();
}
}
复制代码
运行结果:
1.3 优缺点
工厂方法模式的优点:
1.在工厂方法模式中,工厂方法用来创建客户所需要的产品,同时还向客户隐藏了哪种具体产品类将被实例化这一细节,用户只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体产品类的类名。
2.在系统中加入新产品时,无须修改抽象工厂和抽象产品提供的接口,无须修改客户端,也无须修改其他的具体工厂和具体产品,而只要添加一个具体工厂和具体产品就可以了。这样,系统的可扩展性也就变得非常好,完全符合“开闭原则”。
缺点:在于增加新产品的同时需要增加新的工厂,导致系统类的个数成对增加,在一定程度上增加了系统的复杂性。
1.4 应用
java.util.Collection
接口的iterator()
方法:- 在
JDBC
中也大量使用了工厂方法模式,在创建连接对象Connection
、语句对象Statement
和结果集对象ResultSet
时都使用了工厂方法,具体代码如下:
Connection conn=DriverManager.getConnection("jdbc:microsoft:sqlserver://localhost:1433; DatabaseName=DB;user=sa;password=");
Statement statement=conn.createStatement();
ResultSet rs=statement.executeQuery("select * from UserInfo");
复制代码
2. 抽象工厂方法模式(Abstract Factory Pattern)
2.1 模式动机
在工厂方法模式中具体工厂负责生产具体的产品,每一个具体工厂对应一种具体产品,工厂方法也具有唯一性,一般情况下,一个具体工厂中只有一个工厂方法或者一组重载的工厂方法。但是有时候我们需要一个工厂可以提供多个产品对象,而不是单一的产品对象。
为了清晰地理解工厂模式的动机,先引入两个概念:
- 产品等级结构:产品等级结构即产品的继承结构,如一个抽象类是电视机,其子类有海尔电视机、海信电视机、TCL电视机,则抽象电视机与具体品牌的电视机之间构成了一个产品等级结构,抽象电视机是父类,而具体品牌的电视机是其子类。
- 产品族:在抽象工厂模式中,产品族是指由同一个工厂生产的,位于不同产品等级结构中的一组产品,如海尔电器工厂生产的海尔电视机、海尔电冰箱,海尔电视机位于电视机产品等级结构中,海尔电冰箱位于电冰箱产品等级结构中。
2.2 定义
抽象工厂模式是工厂模式的泛化版,定义:抽象工厂模式提供了一个创建一系列相关或相互依赖的对象的接口,而无须指定它们具体的类。
2.3 实例解析
一个电器工厂可以产生多种类型的电器,如海尔工厂可以生产海尔电视机、海尔空调等,TCL工厂可以生产TCL电视机、TCL空调等,相同品牌的电器构成一个产品族,而相同类型的电器构成了一个产品等级结构,现使用抽象工厂模式模拟该场景:
此用例可参考工厂模式的用例,此处只列举客户端代码:
public class Client implements AirConditioner {
public static void main(String[] args) {
EFactory haierFactory = new HaierFactory();
HaierTv haierTv = haierFactory.produceTelevision();
HaierAirConditioner haierAirConditioner = haierFactory.produceAirConditioner();
haierTv.play();
haierAirConditioner.play();
EFactory tclFactory = new TclFactory();
TclTv tclTv = tclFactory.produceTelevision();
TclAirConditioner tclAirConditioner = tclFactory.produceAirConditioner();
tclTv.play();
tclAirConditioner.play();
}
}
复制代码
2.4 优缺点
优点:
- 抽象工厂方法隔离了具体类的生成,使得客户端并不需要知道什么被创建。
- 当一个产品族中的多个对象被设计成一起工作时,它能够保证客户端只使用同一个产品组中的对象。对于一些需要根据当前环境来决定其行为的软件来说,是一个非常实用的设计模式。
- 增加产品族:对于增加新的产品组,抽象工厂模式很好地支持了“开闭原则”,只需要增加一个具体工厂即可,对已有代码无须做任何修改。
缺点: - 增加新的产品等级结构:对于增加新的产品等级结构,需要修改所有的工厂角色,包括抽象工厂类,在所有的工厂类中都需要增加生产新产品的方法,不能很好地支持“开闭原则”。抽象工厂模式的这种性质被称为”开闭原则的倾斜性“。
2.5 应用
在JDBC
中也应用了抽象工厂方法,如下图所示:
3. 建造者模式(Builder Pattern)
3.1 模式动机
无论是在现实世界中还是在软件系统中,都存在一些复杂的对象,它们拥有多个组成部分,如汽车,它包括车轮、方向盘、发送机等各种部件。而对于大多数用户而言,无须知道这些部件的装配细节,也几乎不会使用单独某个部件,而是使用一辆完整的汽车,可以通过建造者模式对其进行设计与描述,建造者模式可以将部件和其组装过程分开,一步一步创建一个复杂的对象。用户只需要指定复杂对象的类型就可以得到该对象,而无须知道其内部的具体构造细节。
3.2 定义
建造者模式是将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。建造者模式关注该复杂对象是如何一步步建造而成的,对于用户而言,无须知道创建过程和内部组成细节,只需要使用创建好的完整对象即可。
在建造者模式中引入了一个指挥者类Director,该类的作用主要有两个:
1.隔离了客户与生产过程
2.控制产品的生成过程
指挥者针对抽象构造者编程,客户端只需要知道具体指挥者的类型,即可通过指挥者调用建造者的相关方法,返回一个具体的产品。
3.3 实例解析
建造者模式可以用于描述KFC如何创建套餐:套餐是一个复杂对象,它一般包含主食(如汉堡、鸡肉卷等)和饮料(如果汁、可乐等)等组成部分,不同的套餐有不同的组成部分,而KFC的服务员可以根据顾客的要求,一步一步装配这些组成部分,构造一份完整的套餐,然后返回给顾客。
- 产品类
import lombok.Data;
/**
* 套餐
**/
@Data
public class Meal {
private String food;
private String drink;
}
复制代码
- 抽象建造者
/**
* 抽象建造者
**/
public abstract class MealBuilder {
private Meal meal = new Meal();
public abstract void buildFood();
public abstract void buildDrink();
public Meal getMeal() {
return meal;
}
}
复制代码
- 具体建造者
/**
* 具体建造者类
**/
public class SubMealBuilderA extends MealBuilder {
@Override
public void buildFood() {
meal.setFood("鸡腿堡");
}
@Override
public void buildDrink() {
meal.setDrink("可乐");
}
}
复制代码
/**
* 具体建造者类
**/
public class SubMealBuilderB extends MealBuilder {
@Override
public void buildFood() {
meal.setFood("鸡肉卷");
}
@Override
public void buildDrink() {
meal.setDrink("果汁");
}
}
复制代码
- 指挥者
/**
* 指挥者
**/
public class KFCWaiter {
private MealBuilder mealBuilder;
public KFCWaiter (MealBuilder mealBuilder) {
this.mealBuilder = mealBuilder;
}
public Meal getMeal() {
mealBuilder.buildDrink();
mealBuilder.buildFood();
return mealBuilder.getMeal();
}
}
复制代码
- 客户端代码
/**
* 客户端
**/
public class Client {
public static void main(String[] args) {
MealBuilder mealBuilder1 = new SubMealBuilderA();
KFCWaiter waiter1 = new KFCWaiter(mealBuilder1);
Meal meal = waiter1.getMeal();
System.out.println("套餐一:");
System.out.println(meal.getDrink());
System.out.println(meal.getFood());
System.out.println("=============================================");
MealBuilder mealBuilder2 = new SubMealBuilderB();
KFCWaiter waiter2 = new KFCWaiter(mealBuilder2);
meal = waiter2.getMeal();
System.out.println("套餐二:");
System.out.println(meal.getDrink());
System.out.println(meal.getFood());
}
}
复制代码
运行结果:
3.4 优缺点
优点:
- 在建造者模式中,客户端不必知道产品内部组成的细节,将产品本身与产品的创建过程解耦,使得相同的创建过程可以创建不同的产品对象
- 每一个具体建造者都相对独立,而与其他的具体建造者无关,因此可以很方便地替换具体建造者或增加新的具体建造者,用户使用不同的具体建造者即可得到不同的产品对象。
- 可以更加精细地控制产品的创建过程
缺点: - 建造者模式所创建的产品一般具有较多的共同点,其组成部分相似,如果产品之间的差异性很大,则不适合使用建造者模式,因此其使用范围受到一定的限制。
- 如果产品的内部变化复杂,可能会导致需要定义很多具体建造者类来实现这种变化,导致系统变得很庞大
3.5 拓展
3.5.1 抽象模式的简化
建造者模式在实际应用过程中通常可以进行简化,以下是几种常用的简化方式:
- 省略抽象建造者角色
如果系统中只需要一个具体建造者的话,可以省略抽象建造者的角色。 - 省略指挥者角色
在具体建造者只有一个的情况下,如果抽象建造者的角色已经被省略掉,那么还可以省略指挥者角色,让Builder
角色扮演指挥者和建造者双重角色。
3.5.2 当构造函数参数过多时使用建造者模式
- 普通类:
import lombok.Data;
@Data
public class OralClass {
private int year;
private int month;
private int day;
private int hour;
private int minute;
private int second;
public OralClass() {
}
public OralClass(int year, int month, int day, int hour, int minute, int second) {
this.year = year;
this.month = month;
this.day = day;
this.hour = hour;
this.minute = minute;
this.second = second;
}
}
复制代码
- 使用建造者模式:
import lombok.Getter;
import lombok.ToString;
@Getter
@ToString
public class BuilderClass {
private final int year;
private final int month;
private final int day;
private final int hour;
private final int minute;
private final int second;
private BuilderClass(Builder builder) {
this.year = builder.year;
this.month = builder.month;
this.day = builder.day;
this.hour = builder.hour;
this.minute = builder.minute;
this.second = builder.second;
}
public static class Builder {
private int year;
private int month;
private int day;
private int hour;
private int minute;
private int second;
public Builder() {
this.year = 0;
this.month = 0;
this.day = 0;
this.hour = 0;
this.minute = 0;
this.second = 0;
}
public Builder year(int year) {
this.year = year;
return this;
}
public Builder month(int month) {
this.month = month;
return this;
}
public Builder day(int day) {
this.day = day;
return this;
}
public Builder hour(int hour) {
this.hour = hour;
return this;
}
public Builder minute(int minute) {
this.minute = minute;
return this;
}
public Builder second(int second) {
this.second = second;
return this;
}
public BuilderClass build() {
return new BuilderClass(this);
}
}
}
复制代码
- 客户端代码
/**
* 客户端
**/
public class Client {
public static void main(String[] args) {
// 使用构造函数
OralClass constructClass = new OralClass(1,2 ,3 ,4, 5, 6);
// 使用setter
OralClass setterClass = new OralClass();
setterClass.setYear(1);
setterClass.setMonth(1);
setterClass.setDay(1);
setterClass.setHour(1);
setterClass.setMinute(1);
setterClass.setSecond(1);
// 使用Builder模式
BuilderClass builderClass = new BuilderClass.Builder()
.year(1)
.month(2)
.day(3)
.hour(4)
.minute(5)
.second(6).build();
}
}
复制代码
3.5.3 建造者模式和工厂模式区别:
- 意图不同
在工厂方法模式里,我们关注的是一个产品整体,如超人整体,无须关心产品的各部分是如何创建出来的;但在建造者模式中,一个具体产品的产生是依赖各个部件的产生以及装配顺序,它关注的是“由零件一步一步地组装出产品对象”。简单地说,工厂模式是一个对象创建的粗线条应用,建造者模式则是通过细线条勾勒出一个复杂对象,关注的是产品组成部分的创建过程。 - 产品的复杂度不同
工厂方法模式创建的产品一般都是单一性质产品,如成年超人,都是一个模样,而建造者模式创建的则是一个复合产品,它由各个部件复合而成,部件不同产品对象当然不同。这不是说工厂方法模式创建的对象简单,而是指它们的粒度大小不同。一般来说,工厂方法模式的对象粒度比较粗,建造者模式的产品对象粒度比较细。
4.原型模式(Prototype Pattern)
在软件系统中,有时候需要多次创建某一类型的对象,为了简化创建过程,可以只需要创建一个对象,通过对一个已有对象的复制获取更多对象。Java语言提供了较为简单的原型模式解决方案(在Object
中提供了一个clone
方法,需要被克隆的类实现Cloneable
接口),只需要创建一个原型对象,然后通过在类中定义的克隆方法复制自己。
5.单例模式(Singleton Pattern)
5.1 模式动机
对于系统中的某些类来说,只有一个实例很重要。
5.2 定义
单例模式确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类被称为单例类,它提供全局访问的方法。单例模式的要点有三个:
- 一个类只能有一个实例。
- 它必须自行创建这个实例。
- 它必须向整个系统提供这个实例。
5.3 实例解析
5.3.1 懒汉式,线程不安全
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
复制代码
这段代码简单明了,而且使用懒加载模式,但是却存在致命的问题。当有多个线程并发调用getInstance()
的时候就会创建多个实例。也就是说在多线程情况下不能正常的工作。
5.3.2 懒汉式,线程安全
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
复制代码
虽然做到了线程安全,并且解决多实例的问题,但是它并不高效。因为在任何时候都只能有一个方法调用getInstance()
方法。但是同步操作只需要在第一次调用时才被需要,即第一次创建单例实例对象时。这就引出了双重检验锁。
5.3.3 双重检验锁
双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查instance == null
,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
复制代码
因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了。
这段代码看起来很完美,很可惜,它是有问题。主要在于instance = new Singleton()
这句,这并非是一个原子操作,事实上在JVM中这句话大概做了下面3件事情。
- 给
instance
分配内存 - 调用
Singleton
的构造函数来初始化成员变量 - 将
instance
对象指向分配的内存空间(执行完这步instance
就为非null
了)
但是在JVM的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是1-2-3也可能是1-3-2。如果是后者,则在3执行完毕、2未执行之前,被线程二抢占了,线程二刚好执行到最外层的singleton == null
,这时instance
已经是非null
了(但却没有初始化),所以线程二会直接返回instance
,然后使用,然后顺理成章地报错。
我们只需要将instance
变量声明成volatile
就可以了。
5.3.4 饿汉式
这种方法非常简单,因为单例的实例被声明成static
和final
变量了,在第一次加载类到内存中时就会初始化,所以创建实例本身是线程安全的。
public Singleton {
private static final Sigleton singleton = new Singleton();
private Sigleton() {}
public static synchronized Singleton getInstance() {
return singleton;
}
}
复制代码
这种写法如果完美的话,就没必要在啰嗦那么多双检锁的问题了。缺点是它不是一种懒加载模式(lazyinitialization),单例会在加载类后一开始就被初始化,即使客户端没有调用getInstance()
方法。饿汉式的创建方式在一些场景中将无法使用:譬如Singleton
实例的创建是依赖参数或者配置文件的,在getInstance()
之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。
5.3.5 静态内部类
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
复制代码
这种写法仍然使用JVM本身机制保证了线程安全问题;由于SingletonHolder
是私有的,除了getInstance()
之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖JDK版本。
5.3.6 枚举类
用枚举写单例实在太简单了!这也是它最大的优点。下面这段代码就是声明枚举实例的通常做法。
public enum EasySingleton{
INSTANCE;
}
复制代码
我们可以通过EasySingleton.INSTANCE
来访问实例,这比调用getInstance()
方法简单多了。创建枚举默认就是线程安全的,因为枚举类是不可变的,而且还能防止反序列化导致重新创建新的对象。
5.4 应用
- JDK中就有许多单例模式的应用实例,如
java.lang.Runtime
类,在每一个Java应用程序里面,都有唯一的一个Runtime
对象,通过这个Runtime
对象,应用程序可以与其环境发生相互作用,源代码片段如下:
public class Runtime {
private static Runtime currentRuntime = new Runtime();
public static Runtime getRuntime() {
return currentRuntime;
}
private Runtime() {}
......
}
复制代码
- Spring中Bean默认是单例类的。
5.5 优缺点
优点:
- 提供了对唯一实例的受控访问。
- 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式无疑可以提高系统的性能。
- 允许可变数目的实例。我们可以基于单例模式进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例。
缺点:
- 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
- 单例类的职责过重,在一定程度上违背了“单一职责原则”。
- 滥用单例将带来一些负面问题, 如数据库连接池。