设计模式和原则
设计模式和原则
写在前面
最近在跟着《Java设计模式及实践》学习,此博客为笔记
1. 单一职责原则(Single responsibility principle,SRP)
单一职责原则可以被视为使得封装工作达到最佳状态的良好实践。
目的:希望一个类只负责一个职责,修改的时候,不至于引起一系列的更改,从而导致破坏与其他更改原因相关的功能。
书中提到的例子是使用数据库来持久保存对象,其中涉及增、删、改、查操作。
假设我们有Car类,我们先来看下面的类结构
这种情况下,Car类不仅仅封装了逻辑,还封装了数据库的操作,这样Car类就有了两个职责,那么未来不论是希望修改逻辑,还是修改数据库系统,都有要修改Car类的需求。这里我们就可以理解为,Car类有了两个职责,就有了两个被修改的理由,如果我们的类都有多个被修改的理由,将会使得我们的代码难以维护和测试。
我们如何进行改进呢,就是把职责分离,对于以上的Car类,我们可以把它的逻辑和数据库操作分离开来:创建两个类,一个用于封装Car逻辑,另一个用于负责持久性,如下图
当修改逻辑的时候只需要修改Car类中的代码,当修改数据库系统的时候只需要修改CarDao类中的代码,虽然看起来两个类混在一起的时候一样可以正常修改,但是当类变大,修改起来就会很麻烦,也容易引入影响其他类的更改。
此外,每个更改后的职责、理由都会增加新的依赖关系,使得冗大的类更难修改维护,不健壮。
2. 开闭原则(Open Closed Principle,OCP)
“模块、类和函数应该对扩展开放,对修改关闭”
我们必须想象:开发的软件应该是一个复杂的结构,一旦我们完成了它的一部分,就应该把它视为一个黑盒,保证它是健壮的,不再需要修改的,可以在它的基础上继续建设的。我们开发软件过程中,一旦开发并测试了一个模块,在它基础上继续建设了一段时间要修改它,将会带来一系列的修改和一系列的测试。这是非常让人头疼的,所以我们在设计、实现模块的时候需要坚持开闭原则:“模块、类和函数应该对拓展开放,对修改关闭”
对修改关闭的理解很简单,至于“扩展开放”,应该是尝试在完成后保持模块不变,而通过继承和多态来扩展它的新功能。
例如我们计算器实现了第一版,只有加减乘除,已经基于它做了一定量的开发,这时候我们希望添加新功能,我们不直接修改它,而是通过继承或者多态来添加新功能,完成扩展需求。
至于使用的时候如何选择到底用Calculator还是Calculator-x,我们通过其他方式选择,这里不做讨论,只是Calculator是一个标准,它适用于任何场景,但是特殊场景需要特殊功能的时候,不需要都修改Calculator,而是通过继承或者多态的方式进行添加功能和使用。
3. 里氏替换原则(Liskov Substitution Principle,LSP)
派生类型必须完全可替代其基类型
基于面向对象的语言中的子类型多态,派生对象可以用其父类型替换。例如,如果有一个Car对象,它可以在代码中用作Vehicle。
当派生类型被其父类型替换时,其余代码就像它是子类型那样使用它。也就是说,派生类型应该有和父类型一样的行为。这个我们成为强行为子类型。
那么如何理解和父类型有一样的行为呢?在代码层面上,就是子类型和父类型拥有同一个方法,但是内部实现不同,这样就使得从外界看来他们提供的接口都一样,但是不同的是他们这个方法内部的实现。我们举个例子:
密码箱,它是一个箱子,可以装玩具,它通常有一个密码锁。想要玩这个玩具的小朋友需要有钥匙Key来加锁或者解锁。我们定义了一个Box类,现在创建一个Key类并且在Box类中添加lock和unlock方法。我们给小朋友添加了一个相应的方法,小朋友检查钥匙是否匹配密码箱
public class Boy{
void checkKey(Box box,Key key){
if(box.lock(key) == false) System.out.println("wrong key , wrong box or the lock is broken");
}
}
但是有的密码箱的设计很奇特,为了方便存取玩具,没有密码锁,它只是叫做密码箱而已,我们就创造了一个继承自Box的SpecialBox类
SpecialBox类没有锁,所以无法锁定或者解锁,但是对应的lock和unlock方法也要实现,这样小孩子不管拿到什么箱子,都可以做检查钥匙是否匹配箱子的操作,换句话说,在检查钥匙是否匹配的操作上,在孩子的眼里所有箱子都一个样。
public boolean lock(Key key){
//this is a SpecialBox, so it can't be locked return false;
return false;
}
类似这样各种不同行为的箱子,对应于里氏替换原则,都要能够像父亲那样对外表现,不破坏它的行为,在外界看来,可以对它们调用一样的方法,而不用担心该方法导致错误,或者不存在该方法。
4. 接口隔离原则(interface Segregation Principle, ISP)
客户端不应该依赖于它所不需要的接口
一个类对另一个类的依赖应该建立在最小的接口上
Robert Martin提出接口隔离原则(interface Segregation Principle, ISP),他意识到如果接口隔离原则被破坏,客户端*依赖它们不使用的接口时,代码就会变得紧密耦合。
这里我借用别人的一幅图先进行解释
这个图的意思是:类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。对于类B和类D来说,虽然他们都存在着用不着的方法,但是由于实现了接口I,所以也必须要实现这些用不到的方法。
代码如下:
interface I{
void method1();
void method2();
void method3();
void method4();
void method5();
}
class A{
public void depend1(I i){
i.method1();
}
public void depend2(I i){
i.method2();
}
public void depend3(I i){
i.method3();
}
}
class B implements I{
public void method1(){
//类B实现接口I的方法1
}
public void method2(){
//类B实现接口I的方法2
}
public void method3(){
//类B实现接口I的方法3
}
//对类B来说,method4和method5不是必须的,但是必须实现,所以方法体为空
public void method4(){}
public void method5(){}
}
类B实现了接口的方法,但是违反了ISP原则,没有建立在最小的接口上。类B*实现了完全不需要的method4和method5.
可以看到,如果接口过于臃肿,导致*实现不需要的方法,这显然是不好的设计。如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。这里将原有的接口I拆分为三个接口,拆分后的设计如下图:
程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
我们给出更具体的示例,以画作为示例。现在我们要对IPaint接口实现一个名为Paint的类。类Scaler(缩放机器)通过接口IPaint依赖类Paint。类Scaler依赖接口IPaint中的方法scale()
,而不依赖sell()
,或者说接口IPaint提供的方法超出了类Scaler所需要的。此外,类Paint是对IPaint的实现,却*实现了sell方法。
我们现在有两个类,类Scaler和类Dealer,他们分别依赖接口提供的scale()
方法和sell()
方法,我们对原有接口IPaint进行拆分,分成IScaleable和ISellable两个接口,类Paint是对这两个接口的实现。
接口隔离原则看起来和之前的单一原则很相似,其实不然。首先,单一职责原则更注重的是职责,接口隔离原则注重对接口依赖的隔离。其次,单一职责原则主要是约束类,而后才是约束接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口,主要针对抽象,针对程序整体框架的构建。
采用接口隔离原则对接口进行约束的时候,要注意下面几点:
- 接口尽量小,但是如果过小就会造成接口数量过多,使设计复杂化。
- 为依赖接口的类定制服务,只暴露给调用的类它需要的方法。比如IScaleable接口,只把scale()暴露给调用的类它需要的方法,其他不需要的方法,比如
sell()
则隐藏起来。 - 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
5. 依赖倒置原则(Dependence Inversion Principle,DIP)
“高级模块不应该依赖低级模块,两者都应该依赖抽象”
“抽象不应该依赖于细节,细节应该依赖于抽象”
在Java语言中,抽象就是借口和抽象类,两者都不能被直接实例化。细节就是实现类,实现接口或者继承抽象类而产生的类就是细节,可以被直接实例化。在Java语言中,依赖倒置原则表现如下:
- 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或者抽象类产生的
- 接口或抽象类不依赖于实现类
- 实现类依赖于接口或者抽象类
现在我们先不考虑依赖倒置原则(DIP),看一下如下的设计:
从上面类图可以看出,学生类和数学类都属于细节,但是并没有实现或者继承抽象类,他们是对象级别的耦合。
通过类图可以看出学生类有一个dowork()方法,用来写作业,数学类有一个calculate()方法,用来表示解答数学题,并且数学类依赖于学生类,用户模块表示高层模块,负责调用学生类和数学类等。
public class Student{
//学生的主要职责就是写作业
public void dowork(Math math){
math.calculate();
}
}
public class Math{
//数学题需要计算
public void calulate(){
System.out.println("1+1=2");
}
}
//高层模块
public class Client{
public static void main(String[] args){
Student xiaoming = new Student();
Math mathHomework = new Math();
//小明做数学作业
xiaoming.dowork(mathHomework);
}
}
这样的设计乍一看没什么问题,小明只管写数学作业就好,但是假如有一天他还得写英语作业了,怎么办呢,我们当然可以创建一个英语类,给一个run()方法,但是学生类里面并没有英语类的依赖,而且方法调用也不对。
我们重新进行设计
//将学生模块抽象为一个借口
public interface IStudent{
//是学生就应该要写作业
public void dowork(IWork work);
}
public class Student implements IStudent{
//学生的主要职责就是写作业
public void dowork(IWork work){
work.run();
}
}
//将作业模块抽象为一个借口,可以是数学作业,也可以是英语作业
public interface IWork{
//是作业就应该能做
public void run();
}
public class Math implements IWork{
//数学作业肯定能做
public void run(){
System.out.println("做数学作业..,");
}
}
public class English implements IWork{
//英语作业肯定能做
public void run(){
System.out.println("做英语作业....");
}
}
//高层模块
public class Client{
public static void main(String[] args){
IStudent xiaoming= new Student();
IWork mathHomework = new IWork();
//小明做数学作业
xiaoming.dowork(mathHomework);
}
}
如此一来,在新增底层模块时候,只修改了高层模块(业务场景类),对其他底层模块(Student类)不需要做任何修改。
除了以上接口声明依赖对象的写法,还有以下几种写法:
- 构造函数传递依赖对象:在类中通过构造函数声明依赖对象,采用构造器注入
//将学生模块抽象为一个借口
public interface IStudent{
public void dowork();
}
public class Student implements IStudent{
private IWork homework;
//注入
public void Student(IWork work){
this.homework = work;
}
public void dowork(){
this.homework.run();
}
}
- Setter方法传递依赖对象:在抽象中设置Setter方法声明依赖对象
public interface IStudent{
//注入依赖
public void setHomework(IWork work);
public void dowork();
}
public class Student implements IStudent{
private IWork homework;
public void setHomework(IWork work){
this.homework = work;
}
public void dowork(){
this.homework.run();
}
}
依赖倒置原则的本质就是通过抽象(Java中的接口或者抽象类)使各个类或者模块实现彼此独立,不相互影响,实现模块间的松耦合。
- 每个类尽量都要有接口或者抽象类,或者抽象类和接口都有:依赖倒置原则的基本要求,有抽象才能依赖倒置
- 变量的表面类型(给外界调用、直接引用的类型)尽量是接口或者抽象类
- 任何类都不应该从具体类派生
- 尽量不要重写基类已经写好的方法(里氏替换原则)
- 结合里氏替换原则来使用:结合LSP和DIP我们可以得出一个通俗的规则,接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类则负责功能公共构造部分的实现,实现类准确地实现业务逻辑,同事在适当的时候对父类进行细化。