设计模式和原则

设计模式和原则

写在前面

最近在跟着《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属性和方法,并且声明与其他对象的依赖关系,抽象类则负责功能公共构造部分的实现,实现类准确地实现业务逻辑,同事在适当的时候对父类进行细化。
原创文章 23 获赞 28 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_43093006/article/details/102848581