在软件工程中,设计模式(design pattern)是对软件设计中普遍存在的各种问题,所提出的解决方案。设计模式并不是固定的一套代码,而是针对某一特定问题的具体解决思路与方案。可以认为是一种最佳实践,因为他是无数软件开发人员经过长时间的实践总结出来的。
在了解设计模式之前就我们首先要了解一下面向对象的六大原则。
单一职权原则(Single Responsibility Principle, SRP)
定义:就一个类而言,应该仅有一个引起它变化的原因
如果一个类承担的职责过多,就等于把这些职责耦合在一起,一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计,当变化发生时,设计会遭到意想不到的破坏。
软件设计真正要做的许多内容,就是发现职责并把那些职责相互分离,其实要去判断是否应该分离出来,也不难,那就是如果你能够想到多余一个的动机去改变一个类,那么这个类就是对于一个的职责
在我们现实遇到的需求场景中,完全遵守单一职权原则也不是一件很好的事。比如我们在12306购票的下单的时候,需要对我们的身份信息做检查,根据单一职权原则我们单独编写了一个对身份信息验证。但是随着产品体验的优化,需要在添加一个重复订单的验证,如果根据单一职权原则我们还要写一个检查重复订单的类进行重复订单的校验。但是此时我们的代码结构已经定义好了,重新写一个类,然后修改调用方法就显得比较复杂,此时我们就可以对检查类进行简单的修改,编写一个检查方法,实现对身份检查和重复订单检查的调用。此时我们的单一职权原则可以应用到我们的方法上。虽然这样做对于类而言有悖于单一职权原则,但从下单前的校验角度思考它有遵循于单一职权原则。(这样做的风险在于职责扩散的不确定性,可能以后还需要做更多的检查,所以记住,在职责扩散到我们无法控制的程度之前,立刻对代码进行重构。可根据不同的检查类型细分为不同的检查类)
遵循单一职责原的优点有:
- 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多;
- 提高类的可读性,提高系统的可维护性;
- 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。
需要说明的一点,单一职权原则并不是面向对象编程语言特有的原则,只要是模块化的程序设计,都适用单一职责原则。
里氏替换原则(Liskov Substitution Principle,LSP)
定义:子类型必须能够替换掉他们的父类型。
对于里氏替换原则这个名称不用太纠结,觉得苦涩难懂,其实是因为这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的,就是单纯的一个名字。
如果把里氏替换原则翻译成大白话就是一个软件实体如果使用的是一个父类的话,那么一定适用于其子类,而且它察觉不出父类对象和子类对象的区别,也就是说在软件里面把父类都替换成它的子类,程序的行为没有变化
里氏替换原则主要对于继承而言,B继承A ,在B中添加新的方法的时候,尽量不要重写A的方法,也尽量不要重载父类A的方法。
继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。
举例说一下集成的风险
class A{
public int func1(int a, int b){
return a-b;
}
}
public class Client{
public static void main(String[] args){
A a = new A();
System.out.println("100-50="+a.func1(100, 50));
System.out.println("100-80="+a.func1(100, 80));
}
}
运行结果:
100-50=50
100-80=20
后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。即类B需要完成两个功能:
- 两数相减。
- 两数相加,然后再加100。
由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下:
class B extends A{
public int func1(int a, int b){
return a+b;
}
public int func2(int a, int b){
return func1(a,b)+100;
}
}
public class Client{
public static void main(String[] args){
B b = new B();
System.out.println("100-50="+b.func1(100, 50));
System.out.println("100-80="+b.func1(100, 80));
System.out.println("100+20+100="+b.func2(100, 20));
}
}
类B完成后,运行结果:
100-50=150
100-80=180
100+20+100=220
我们发现原本运行正常的相减功能发生了错误。原因就是类B在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类B重写后的方法,造成原本运行正常的功能出现了错误。在本例中,引用基类A完成的功能,换成子类B之后,发生了异常。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
依赖倒置原则(Dependence Inversion Principle)
定义:
- 高层模块不应该依赖底层模块。两个都应该依赖抽象。
- 抽象不应该依赖细节。细节应该依赖抽象。
依赖倒置原则定义比较绕口,说白了就是针对接口编程,不要针对实现编程。
依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。在java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。
同样我们举个例子说明,双十一即将来临,商城搞满减活动。
public class Client {
public static void main(String[] args) {
Activity activity = new Activity();
activity.sale(new Manjian());
}
}
class Activity {
public void sale(Manjian manjian) {
manjian.activityMode();
}
}
class Manjian {
public void activityMode() {
System.out.println("活动方式:满300减100");
}
}
运行输出活动方式:满300减100
过了一天,产品又提出一个打折的需求,但是如果实现就必须需要修改我们的活动类,以此类推,每次不同的活动都要去修改。这显然不合理,Activity
和Dicount
耦合性太高了,因此我们抽象一个优惠类
public interface Reduce {
void activityMode();
}
而Discount
和 ManJian
都实现Reduce
public class Client {
public static void main(String[] args) {
Activity activity = new Activity();
activity.sale(new Manjian());
activity.sale(new Discount());
}
}
class Activity {
public void sale(Reduce reduce) {
reduce.activityMode();
}
}
class Manjian implements Reduce{
@Override
public void activityMode() {
System.out.println("活动方式:满300减100");
}
}
class Discount implements Reduce{
@Override
public void activityMode() {
System.out.println("活动方式:打八折");
}
}
输出活动方式:满300减100
和活动方式:打八折
这样修改后无论怎么修改活动方式都不需要修改Activity
类了
传递依赖关系有三种方式,以上的例子中使用的方法是接口传递,另外还有两种传递方式:构造方法传递和setter方法传递,相信用过Spring框架的,对依赖的传递方式一定不会陌生。
在实际编程中,我们一般需要做到如下3点:
- 低层模块尽量都要有抽象类或接口,或者两者都有。
- 变量的声明类型尽量是抽象类或接口。
- 使用继承时遵循里氏替换原则。
依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。
接口隔离原则(Interface Segregation Principle)
定义:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
接口隔离原则简单来说就是根据类的职责将接口进行更细粒度的拆分,使一个臃肿的接口分散成几个接口,由实现者根据自身需求去分别实现。
举个:我们在封装JDBC方法的时候会有单表查询
、添加查询
、分页
等等。如果我们封装到一个接口里面,有些不需要这么多功能的类也要实现这些逻辑,就会造成代码的臃肿。这里拿通用Mapper
举例
public interface SelectOneMapper {
/**
* 根据实体中的属性进行查询,只能有一个返回值,有多个结果是抛出异常,查询条件使用等号
*
* @param record
* @return
*/
@SelectProvider(type = BaseSelectProvider.class, method = "dynamicSQL")
T selectOne(T record);
}
public interface SelectMapper {
/**
* 根据实体中的属性值进行查询,查询条件使用等号
*
* @param record
* @return
*/
@SelectProvider(type = BaseSelectProvider.class, method = "dynamicSQL")
List select(T record);
}
public interface SelectAllMapper {
/**
* 查询全部结果
*
* @return
*/
@SelectProvider(type = BaseSelectProvider.class, method = "dynamicSQL")
List selectAll();
}
将每一种查询都封装成一个方法,然后写一个通用的接口
public interface Mapper extends
BaseMapper,
ExampleMapper,
RowBoundsMapper,
Marker {
}
public interface BaseMapper extends
BaseSelectMapper,
BaseInsertMapper,
BaseUpdateMapper,
BaseDeleteMapper {
}
这样我们就可以根据不同的需要进行选择性继承相应功能的接口就可以实现符合我们需要的接口。
接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。本文例子中,将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。
采用接口隔离原则对接口进行约束时,要注意以下几点:
- 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
- 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
迪米特法则(Law Of Demeter)
定义:如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,如果其中一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。
迪米特法则也叫最少知识原则。强调的是一个对象应该对其他对象保持做少的了解,在类的结构设计上,每一个类都应当尽量降低成员的访问权限,也就是说,一个类包装好自己的private
状态,不需要让别的类知道的字段或行为就不要公开。
迪米特法则其根本思想,是强调了类之间的松耦合,类之间耦合越弱,越利于复用,一个处在弱耦合的类被修改,不会对有关系的类造成波及。
迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。
开闭原则
定义:软件实体(类、模块、函数等等)应该可以扩展,但是不可修改。
开闭原则有两个特征
1.对扩展是开放的(Open for extension)
2.对更改是封闭的(Closed for modification)
我们在做任何系统的时候,都不可能一开始指定需求就不在发生变化,但是每次需求的变化都会引起对原有代码的修改,很有可能会给旧的代码引入错误,也可能会使我们不得不对整个功能进行重构,并且还要测试一遍原有的代码。
绝对的对修改关闭是不现实的,这就要求设计人员必须对于他设计的代码应该应对那种变化封闭做出选择。他必须先猜测出来最有可能变化的种类,然后构造抽象来隔离那些变化。但是我们是很难进行预先的猜测,这样要求我们等到变化发生时立即采取行动,当发生变化时,我们就创建抽象来隔离以后发生的同类变化
开闭原则是面向对象设计的核心所在,遵循这个原则可以带来面向对象技术所声称的巨大好处,也就是可维护、可扩展、可复用、灵活性好。
其实,我们遵循设计模式前面5大原则,以及使用23种设计模式的目的就是遵循开闭原则。也就是说,只要我们对前面5项原则遵守的好了,设计出的软件自然是符合开闭原则的,这个开闭原则更像是前面五项原则遵守程度的“平均得分”,前面5项原则遵守的好,平均分自然就高,说明软件设计开闭原则遵守的好;如果前面5项原则遵守的不好,则说明开闭原则遵守的不好。
再回想一下前面说的5项原则,恰恰是告诉我们用抽象构建框架,用实现扩展细节的注意事项而已:单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。
最后说明一下如何去遵守这六个原则。对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。我们用一幅图来说明一下。
图中的每一条维度各代表一项原则,我们依据对这项原则的遵守程度在维度上画一个点,则如果对这项原则遵守的合理的话,这个点应该落在红色的同心圆内部;如果遵守的差,点将会在小圆内部;如果过度遵守,点将会落在大圆外部。一个良好的设计体现在图中,应该是六个顶点都在同心圆中的六边形。
在上图中,设计1、设计2属于良好的设计,他们对六项原则的遵守程度都在合理的范围内;设计3、设计4设计虽然有些不足,但也基本可以接受;设计5则严重不足,对各项原则都没有很好的遵守;而设计6则遵守过渡了,设计5和设计6都是迫切需要重构的设计。