欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

设计模式和原则

程序员文章站 2022-05-26 07:57:32
...

设计模式和原则

写在前面

最近在跟着《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属性和方法,并且声明与其他对象的依赖关系,抽象类则负责功能公共构造部分的实现,实现类准确地实现业务逻辑,同事在适当的时候对父类进行细化。
相关标签: 设计模式和原则