面向对象七原则,单例模式,简单工厂模式

最近忧心忡忡……该沉下心来,好好运动,好好学习了。

面向对象七原则

面向对象编程有七个设计原则,相较于设计模式来说,它们是“根基”——

  • 单一职责原则(Single Responsibility Principle,SRP)
  • 开闭原则(Open-Closed Principle,OCP)
  • 里氏代换原则(Liskov Substitution Principle,LSP)
  • 依赖倒转原则(Dependency Inversion Principle,DIP)
  • 接口隔离原则(Interface Segregation Principle,ISP)
  • 合成复用原则(Composite Reuse Principle,CRP)
  • 迪米特法则(Law of Demeter,LoD)

面向对象的程序应当是可复用,易维护的,而要做到这一点,遵守七个原则是不可或缺的。

单一职责原则,就是要求控制类的粒度,让一个类只负责一个功能领域中的相应职责。也就是说,让各个类各司其职,将不同的职责封装到不同的类中,不能让某个类一手包办所有事情。因为,一个类的职责越多,它被复用的可能性就越小

开闭原则,就是要求软件对扩展开放,对修改封闭,即尽量在不修改原有代码的情况下进行扩展。

里氏代换原则,就是要求所有应用基类的地方都可以使用子类对象且不发生错误(用术语说,这玩意是透明的)。也就是说尽量使用基类类型,而不使用子类类型。父类应当是抽象类或接口。在 Java 编程中,我们常常使用数据结构的接口作为要使用的数据结构的类型也是遵循了这样一个原则。如List<Integer> arr = new ArrayList<>();。基类的数组可以引用子类对象也是这样一个原理。

依赖倒转原则,就是要求细节应当依赖于抽象,而非抽象依赖于细节。也就是说,要针对接口编程,而非针对实现编程。Java 将数据结构定义成接口遵循了这个原则。上面的例子List<Integer> arr = new ArrayList<>();也实现了依赖倒转原则,它将一个具体对象依赖注入到了一个抽象的接口中,所谓针对抽象层编程(所使用的对象只能使用抽象层中定义的方法)。

一般来说,抽象是依赖于细节的。比如,我们从数字,字符串等数据类型里抽象出 Comparable 接口;从向量,链表里抽象出 iterable 接口(实际上是抽象出来这些数据类型所共有的某些性质)……这些接口,也就是“抽象”,是从细节中来的!而我们在定义新的数据类型的时候,是根据需要和设计,让这个数据类型实现所需的一些功能,这时候就为这些功能规定相应接口(或者已有的接口)并 implements,这时就有了“倒转”,细节是依赖于抽象了!

接口隔离原则,就是要求使用多个专门的接口,而非一个总接口,也就是说,客户端不应该依赖于那些它不需要的接口(这会破坏程序的封装性,带来很多无用代码)。这和单一职责原则是相似的。

合成复用原则,就是要求尽量使用组合,而非继承达到复用的目的。也就是说,尽量使用委托的手段。因为继承会让子类与父类耦合程度很高,父类的实现细节会暴露给子类,从而破坏封装性。而且从基类继承的实现是静态的,无法在运行时改变。

迪米特法则,就是要求对象与尽量少的其他对象进行通信。符合迪米特法则的系统在要修改某个模块时,会尽量少地影响其他模块,从而降低系统的耦合度。具体来说,就是系统中的对象只和它的“朋友”通信。且这里的“朋友”要尽量少。

创建对象的艺术——创建型模式

创建型模式(Creational Pattern)关注对象的创建过程,以将对象的创建和使用分离,在使用对象时无需关注其创建细节,从而降低系统的耦合度

创建型模式主要回答三个问题——创建什么(What)?谁创建(Who)?什么时候创建(When)?

单例模式

所谓单例模式,就是只有单个实例的类。显然,要实现单例模式,构造函数必须对外界不可见。

一个问题是,为什么要使用单例模式,而非使用全为 static 方法(成员)的类呢?但就其达到的结果来说,两者是等同的,但是两者的语义是不同的。static 关键字代表所有实例共同持有,其虽然能够保证成员唯一性,但是这并非是它的直接意图。并且单例模式的类可以有继承,能够实现面相抽象层编程的需要。像 Spring 中使用的 Bean 一般来说都是单例的。

使用场景

其使用场景是那些确实只需要一个实例的情况,比如 windows 的任务管理器,无论有多少个实例,其所表示的东西都是相同的,如当前各进程的情况,CPU,内存的占用率等等。

单例模式有三种实现——懒汉式,饿汉式,IoDH(延迟加载)。懒汉式在客户端第一次获取该类实例时进行初始化,其后直接返回单例,懒汉式为了维护线程安全性需要进行特殊处理;饿汉式在类加载时进行初始化,保证线程安全,但是其可能在初始化后得不到使用,从而有资源(内存)上的浪费;IoDH 结合了懒汉式和饿汉式的优点,保证在第一次请求该类实例时进行初始化,而其线程安全性由 Java 虚拟机保证

饿汉式单例

饿汉式是最简单的单例模式的实现。顾名思义,饿汉,所以它要贪婪,无论这个类调不调用,先构造这个单例再说。所以,饿汉式是指类加载时即进行初始化

一个简单的实例如下——

1
2
3
4
5
6
7
8
class EagerSingleton {
private static final EagerSingleton instance = new EagerSingleton(); // !
private EagerSingleton () { /* . . . */ }
public static EagerSingleton getInstance() {
return instance;
}
// . . .
}

显然,它会造成一定的资源的浪费,因为可能 getInstance() 并没有被执行时,实例就已经被初始化,从而造成资源上的浪费。

需要注意的是,实例和构造函数应当是 private 的。

懒汉式单例

懒汉式是懒惰(lazy)的,它只在需要它的时候上场。也就是说,懒汉式指在第一次请求实例时进行初始化。

1
2
3
4
5
6
7
8
9
10
class LazySingleton {
private static final LazySingleton instance = null; //!
private LazySingleton () { /* . . . */ }
public static LazySingleton getInstance() {
if (instance == null) // 未初始化
instance = new LazySingleton();
return instance;
}
// . . .
}

显然,getInstance 方法是线程不安全的,在多线程条件下无法保证只有一个实例。给该方法添加 synchronized 关键字可以保证线程安全性,但是这会非常影响系统性能,那么,只给构造这一行 synchronized 关键字如何呢?这样如果已经被初始化,同步代码块就不会被执行了!比如这样——

1
2
3
4
5
6
7
8
public static LazySingleton getInstance() {
if (instance == null) {// 未初始化
synchronized (LazySingleton.class) {
instance = new LazySingleton();
}
}
return instance;
}

但这仍旧是线程不安全的!如果线程 a,b 并发执行,同时达到 synchronized 代码块,仍然会创建多个实例。必须要在 synchronized 关键字内再次进行检查(这个称为双重检查锁定),并且要对 instance 使用 volatile 关键字进行修饰以保证其可见性。结果代码如下——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class LazySingleton {
private static final LazySingleton instance = null; //!
private LazySingleton () { /* . . . */ }
public static LazySingleton getInstance() {
if (instance == null) {// 未初始化
synchronized (LazySingleton.class) {
if (instance == null) {
instance = new LazySingleton();
}
}
}
return instance;
}
// . . .
}

IoDH(延迟加载)式单例

延迟加载(IoDH)使用 Java 提供的静态内部类并让实例由其持有,实例如下——

1
2
3
4
5
6
7
8
9
10
class Singleton {
private Singleton () { /* . . . */ }
private static class HolderClass {
private static final Singleton instance = new Singleton();
}
public static Singleton getInstance() {
return HolderClass.instance;
}
// . . .
}

顾名思义,这个 instance 只有在 getInstance 方法第一次被调用时才加载。且线程安全性被 Java 虚拟机所保证。是最优解。但是该手段是局限于编程语言的,很多语言不支持它。

单例模式的优缺点

单例模式的优点在于,其可以严格控制客户端的访问,且只有一个对象实例,因此可以节约系统资源。同时其可以进行扩展,比如允许一定数量的实例(称为多例类)。

缺点在于,其没有抽象层,因而扩展有困难,且职责过重,违背了单一职责原则,其既要负责业务,又要提供创建对象的方法(工厂方法),对象的创建和对象本身的功能耦合了。最后,单例的对象如果丢失了引用,可能被 GC 掉,这很多时候是不符合预期的。

简单工厂模式

简单工厂模式并不属于 23 条设计模式,但是也是比较常用的。

所有工厂模式的初衷是将类的创建和使用分离,具体来说,如果客户端要使用一个类,它不应该既创建这个类,又使用这个类,否则会造成耦合。

简单工厂模式其实就是将所需类的创建的责任交给一个所谓的工厂类,从而客户端不需要再负责类的创建,只需要负责类的使用,即进行了解耦。

一个例子

ver 0.1

考虑书中所举的例子——给用户提供一个图表库,其给定参数生成相应的图表…假设现在已经有了柱状图,饼状图,折线图…下面的代码为了简便,省略掉初始化图表所需要的其他数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Chart {
private String type;
public Chart(String type) {
this.type = type;
if (type.equalsIgnoreCase("histogram")) {
// 初始化柱状图
}
else if (type.equalsIgnoreCase("pie")) {
//初始化饼状图
}
else if (type.equalsIgnoreCase("line")) {
// 初始化折线图
}
}
public void display() {
if (type.equalsIgnoreCase("histogram")) {
// 展示柱状图
}
else if (type.equalsIgnoreCase("pie")) {
//展示饼状图
}
else if (type.equalsIgnoreCase("line")) {
// 展示折线图
}
}
}

客户端将如下调用——

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Chart graph = new Chart("line");
graph.display();
}
}

问题在哪里呢?首先,太多 if-else 了,可能有性能上的影响(而且也太过不优雅了);然后,类的创建和类的使用都耦合在这一个类里,职责过重,违反了单一职责原则;最后,如果要添加新产品,需要更改源代码(而且是两处),违反了开闭原则。简单工厂只能解决这里的“然后”。

ver 0.2

第一步的优化是抽象出这些图表的共同性质(哲学上的),定义一个抽象父类(图表),而这些具体的图表则作为对这个父类的子类或实现。这样,可以将 Chart 类拆分,将每个具体实现的代码分别放置到不同的类中。在这里,父类使用抽象类实现(书中使用的是接口,我认为抽象类更符合这里的语义)。总之得到这样的类图(子类一定有成员变量,其构造函数是一定有参数的,这里省略了)——

之后,客户端应如下调用——

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Chart graph = new LineChart();
graph.display();
}
}

现在,没有那些 if-else 逻辑了,如果要新增需求(新的表),只需要编写新的子类即可。但是这里类的创建和类的使用仍然是客户端同时负责的,因此仍旧有耦合。

ver 1.0

为了解决这耦合,可以使用简单工厂模式。而为此,上面的 if-else 又要被请回来了(这绝非必须,只不过是这个设计模式要求这样而已)。

总之,这里定义一个工厂类,它要接管所有 Chart 类的构造和初始化(区分开是因为构造后可能仍旧并非是所需的实例)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ChartFactory {
public static Chart getChart(String type) {
if (type.equalsIgnoreCase("histogram")) {
// 返回一个柱状图的实例
}
else if (type.equalsIgnoreCase("pie")) {
//返回一个饼状图的实例
}
else if (type.equalsIgnoreCase("line")) {
// 返回一个折线图的实例
}
return null; // 或者抛个异常之类的
}
}

客户端将这样调用——

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Chart graph = ChartFactory.getChart("line");
graph.display();
}
}

现在,解决了创建和使用关系的问题,客户端只有使用的职责,创建的职责交由工厂类执行。但是仍有一个问题——如果要更改要展示的图表,需要更改源代码,违反了开闭原则。这样每次更改后都需要重新编译,无法”热更新“。解决方式是将这个字符串保存在配置文件中,使用一定的库进行读取。这里不需要赘述了。

一个简化的点是,可以将工厂类和抽象产品类结合,作为抽象产品类的一个方法。这样可以简化掉一个类,但是会导致类间的关系有点古怪。

1
2
3
4
5
6
abstract class Chart{
// . . .
public static Chart factoryMethod() {
// . . .
}
}

总结

简单工厂模式可以归纳出如下角色——工厂,抽象产品,具体产品。其关系如下——

抽象产品作为产品的父类,应当持有所有产品的公共代码,各个具体产品持有各自的私有代码。工厂是简单工厂模式的核心,它负责创建所有产品实例的内部逻辑。

使用工厂模式创建对象,相较于使用 new 或反射机制等创建对象,其优势在于对对象的创建和使用进行了解耦,使实现更加灵活。如果在构造后还需要进行其他处理,只需要在工厂类中进行处理即可,不需再付诸在客户端中。如果产品需求更新,只需更改工厂类的源代码即可。

简单工厂模式的优点在于——

  1. 分离了对象的创建和使用,降低耦合性。
  2. 客户端无需知道所创建实例的具体类型,只需给定参数即可。
  3. 参数可以由配置文件给出,系统更加灵活。

缺点在于——

  1. 工厂类负责了所有产品的创建逻辑,其职责仍旧是过重的
  2. 如果增加新的具体产品,需要修改工厂类的创造逻辑,增加维护成本。(下一个要学习的设计模式——工厂方法模式将解决这个问题,使新增产品不需要修改已有代码)
  3. 系统中类的个数会增加,不利于理解和维护。
  4. 工厂方法是静态的,不利于继承。(我猜测抽象工厂模式可以解决这个问题)