六大设计原则超详细介绍(再不理解你打我)

软件设计的痛点

软件设计最大的难题就是应对需求的变化,但是纷繁复杂的需求变化又是不可预料的,我们要为不可预料的变化做好准备,这本身是一件非常痛苦的事情,但好在有大师们已经给我们提出了非常好的六大设计原则和23种设计模式来“封装”未来的变化。本文只针对六大设计原则进行介绍,设计模式放在后面的文章进行详解。

六大设计原则

六大设计原则主要是指:

  • 单一职责原则(Single Responsibility Principle);
  • 开闭原则(Open Closed Principle);
  • 里氏替换原则(Liskov Substitution Principle);
  • 迪米特法则(Law of Demeter),又叫“最少知道法则”;
  • 接口隔离原则(Interface Segregation Principle);
  • 依赖倒置原则(Dependence Inversion Principle)。

把这 6 个原则的首字母(里氏替换原则和迪米特法则的首字母重复,只取一个)联合起来就是:SOLID(稳定的),其代表的含义也就是把这 6 个原则结合使用的好处:建立稳定、灵活、健壮的设计。



单一职责原则

单一职责原则的定义是:应该有且仅有一个原因引起类的变更。

没错,单一职责原则就这一句话,不懂没关系,我们举个例子。

我们以打电话为例,电话通话的时候有 4 个过程发生:拨号、通话、回应、挂机。那我们写一个接口,类图如下:



代码为:



我们看这个接口有没有问题?相信大部分同学会觉得没问题,因为平常我们就是这么写的。没错,这个接口接近于完美,注意,是“接近”。单一职责原则要求一个接口或一个类只能有一个原因引起变化,也就是一个接口或者类只能有一个职责,它就负责一件事情,看看上面的接口只负责一件事情吗?明显不是。

IPhone这个接口包含了两个职责:协议管理和数据传送。dial 和 hangup 这两个方法实现的是协议管理,分别负责拨号接通和挂机,chat 方法实现的是数据传送。不管是协议接通的变化还是输出传送的变化,都会引起这个接口的变化。所以,IPhone这个接口并不符合单一职责原则。若要让IPhone满足单一职责原则,我们就要对其进行拆分,拆分后的类图如下:



这样设计就完美了,一个类实现了两个接口,把两个职责融合在一个类中。你会觉得这个Phone有两个原因引起变化了啊,是的,但是别忘了我们是面向接口编程,我们对外公布的是接口而不是实现类。

另外,单一职责原则不仅适用于接口和类,也适用于方法。一个方法尽可能只做一件事,比如一个修改用户密码的方法,不要把这个方法放到“修改用户信息”方法中。

单一职责的好处

1. 类的复杂性降低,实现什么职责都有清晰明确的定义;

2. 可读性高,复杂性降低,可读性自然就提高了;

3. 可维护性提高,可读性提高了,那自然更容易维护了;

4. 变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。

里氏替换原则

在面向对象的语言中,继承是必不可少的、非常优秀的语言机制,它有如下优点:

  1. 代码共享,减少创建类的工作量,每个子类都拥有父类的属性和方法;
  2. 提高代码的重用性;
  3. 子类可以形似父类,但又异于父类;
  4. 提高代码的可扩展性;
  5. 提高产品或项目的开放性。

有优点就必然存在缺点:

  1. 继承是侵入性的。只要继承,就必须拥有父类的属性和方法。
  2. 降低代码的灵活性。子类会多一些父类的约束。
  3. 增强了耦合性。当父类的常量、变量、方法被修改时,需要考虑子类的修改。

为了让“利”的因素发挥最大的作用,同时减少“弊”带来的麻烦,引入了里氏替换原则(LSP)。

历史替换原则最正宗的定义是:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代替o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。

通俗点讲,就是只要父类能出现的地方,子类就可以出现,而且替换为子类也不会产生任何错误或异常。

里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了4层含义。

1. 子类必须完全实现父类的方法。

我们在做系统设计的时候,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这里就已经使用了里氏替换原则。我们以打CS举例,来描述一下里面用到的枪。类图如下:



枪的主要职责是射击,如何射击在各个具体的子类中实现,在士兵类Soldier中定义了一个方法 killEnemy,使用枪来kill敌人,具体用什么枪,调用的时候才知道。

AbstractGun类源码如下:



手枪、步枪、机枪的实现类代码如下:







士兵类的源码为:



注意,士兵类的killEnemy方法中使用的gun是抽象的,具体时间什么枪需要由客户端(Client)调用Soldier的构造方法传参确定。

客户端Client源码如下:



注意:在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。

2. 孩子类可以有自己的个性。

孩子类当然可以有自己的属性和方法了,也正因如此,在子类出现的地方,父类未必就可以代替。

还是以上面的关于枪支的例子为例,步枪有 AK47、SKS狙击步枪等型号,把这两个型号的枪引入后的Rifle的子类图如下:



SKS狙击步枪可以配一个8倍镜进行远程瞄准,相对于父类步枪,这就是SKS的个性。源码如下:



狙击手Spinner类的源码如下:



狙击手因为只能使用狙击枪,所以,狙击手类中持有的枪只能是狙击类型的,如果换成父类步枪Rifle,则传递进来的可能就不是狙击枪,而是AK47了,而AK47是没有zoomOut方法的,所以肯定是不行的。这也验证了里氏替换原则的那一句话:有子类出现的地方,父类未必就可以代替。

3. 覆盖或实现父类的方法时,输入参数可以被放大。

来看一个例子,我们先定义一个Father类:



然后定义一个子类:



子类方法与父类方法同名,但又不是覆写父类的方法。你加个@Override看看,会报错的。像这种方法名相同,方法参数不同,叫做方法的重载。你可能会有疑问:重载不是只能在当前类内部重载吗?因为Son继承了Father,Son就有了Father的所有属性和方法,自然就有了Father的doSomething这个方法,所以,这里就构成了重载。

接下来看场景类:



根据里氏替换原则,父类出现的地方子类就可以出现,我们把上面的父类替换为子类:



我们发现运行结果是一样的。为什么会这样呢?因为子类Son继承了Father,就拥有了doSomething(HashMap map)这个方法,不过由于Son没有重写这个方法,当调用Son的这个方法的时候,就会自动调用其父类的这个方法。所以两次的结果是一致的。

举个反例,如果父类的输入参数类型大于子类的输入参数类型,会出现什么问题呢?我们直接看代码执行结果即可轻松看出问题:

扩大父类方法入参:



缩小子类方法入参:



场景类:



根据里氏替换原则,有父类的地方就可以有子类,我们把Father替换为Son看看结果:



两次运行结果不一致,违反了里氏替换原则,所以子类中方法的入参类型必须与父类中被覆写的方法的入参类型相同或更宽松。

4. 覆盖或实现父类的方法时,输出结果可以被缩小。

这句话的意思就是,父类的一个方法的返回值是类型T,子类的相同方法(重载或重写)的返回值为类型S,那么里氏替换原则就要求S必须小于等于T。为什么呢?因为重写父类方法,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这时重写父类方法的要求。

依赖倒置原则

依赖倒置原则在Java语言中的表现是:

1. 模块间的依赖通过抽象发生,实现类之间不直接发生依赖关系,其依赖关系是通过接口或抽象类产生的;

2. 接口或抽象类不依赖于实现类;

3. 实现类依赖接口或抽象类。

说白了,就是“面向接口编程”。

依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。

我们以汽车和司机举例,画出类图:



奔驰车源代码:



司机源代码:



客户端源代码:



通过以上的代码,完成了司机开动奔驰车的场景。可以看到,这个场景并没有引用依赖倒置原则,司机Driver类直接依赖奔驰车Benz类,这样会有什么隐患呢?试想,后期业务变动,司机又买了一辆宝马车,源代码如下:



由于司机现在只有开奔驰的方法,所以他是开不了宝马的。一个拿有C驾照的司机能开奔驰,不能开宝马?太不合理了。所以,这就暴露出上面的设计问题了。我们对上面的功能重新设计,首先新建两个接口。

汽车接口ICar:



司机接口IDriver:



IDriver中,通过传入ICar接口实现了抽象之间的依赖关系。

接下来创建汽车实现类:奔驰和宝马。



然后创建司机实现类:



最后是场景类调用:



Client属于高层业务逻辑,它对低层模块的依赖都建立在抽象上,driver的表面类型是IDriver,benz的表面类型是ICar。

依赖倒置原则的使用建议:

(1)每个类尽量都有接口或抽象类,或者接口和抽象类两者都具备。

(2)变量的表面类型尽量是接口或抽象类。

(3)任何类都不应该从具体类派生。

(4)尽量不要重写基类的方法。如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要重写。

(5)结合里氏替换原则使用。

接口隔离原则

接口隔离原则就是客户端不应该依赖它不需要的接口,或者说类间的依赖关系应该建立在最小的接口上。

我们以搜索美女为例,设计了如下的类图:



源代码如下。美女及其实现类:



搜索程序及其子类源代码如下:



最后是场景调用类:



上面实现了一个搜索美女的小程序。我们想象这个程序有没有问题?IPettyGirl接口是否做到了最优化?并没有。

每个人的审美观不一样,张三认为颜值高就是美女,即使身材和气质一般;李四认为身材好就行,不在乎颜值和气质;而王五则认为颜值和身材都是外在,只要有气质,那就是美女。这时,IPettyGirl接口就满足不了了,因为IPettyGirl的要求是颜值、身材、气质兼具才是美女。所以为了满足各种人的口味,我们需要重新设计接口的结构。把IPettyGirl拆分为3个接口,分别表示颜值高、身材好、气质佳。修改后的类图如下:



源代码如下。美女及其实现类:



搜索类及其子类如下:



通过重构以后,不管以后需要颜值美女,还是需要身材美女,抑或气质美女,都可以保持接口的稳定性。

以上把一个臃肿的接口拆分为三个独立的接口所依赖的原则就是接口隔离原则。接口隔离原则是对接口进行规范约束。

迪米特法则

迪米特法则(LoD)也叫最少知道法则:一个对象应该对其他对象有最少的了解。

1.只和朋友交流

迪米特法则还有一个英文解释是:Only talk to your immediate friends(只和直接的朋友交流)。每个对象都必然会与其他对象耦合,两个对象的耦合就成为朋友关系。下面我们通过体育课老师让班长清点女生人数为例讲解。

首先设计程序的类图:



编码实现:



场景类:



程序开发完了,我们首先看下Teacher类有几个朋友类,首先要知道朋友类的定义:出现在成员变量、方法的输入输出参数中的类称为成员朋友类。所以Teacher类只有一个GroupLeader朋友类。根据迪米特法则,一个类只能和朋友类交流,上面的Teacher类内部却与非朋友类Girl发生了交流,这就不符合迪米特法则,破坏了程序的健壮性。

我们对类图做下修改:



修改后的代码:



再看场景类调用:



总之,就是类与类之间的关系是建立在类间的,而不是方法间,因此一个方法尽量不引入一个类中不存在的对象。

2.朋友间也是有距离的

我们在开发中经常有这种场景:调用一个或多个类,先执行第一个方法,然后是第二个方法,根据返回结果再看是否执行第三个方法。我们以安装某个软件为例,其类图为:



代码如下:



场景类:



程序很简单,但也存在一些问题:Wizard类把太多方法暴露给InstallSoftware类了,两者的朋友关系太亲密了,耦合关系变的异常牢固,如果要把Wizard中first方法的返回值改为Boolean类型,则要同时修改InstallSoftware类,增加了风险。因此,这种耦合是不合适的,我们需要对其优化。重构后的类图如下:



代码如下。导向类:



我们把安装步骤改为私有方法,只向外暴露一个安装方法,这样,即使修改步骤的逻辑,也只是对Wizard自己有影响,只需要修改自己的安装方法逻辑即可,其他类不会受到影响。

安装类:



一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。所以,我们开发中尽量不要对外公布太多public方法和非静态的public变量,尽量内敛。

3.是自己的就是自己的

在实际开发中经常会出现这样一种情况:一个方法放在吧本类中也可以,放在其他类中也没有错。那这时,我们只需要坚持一个原则:如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中

总之,迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提升上去。

开闭原则

开闭原则是指一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。也就是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。我们以书店销售书籍为例来说明什么是开闭原则。

其类图如下:



书籍及其实现类代码如下:



书店类代码:



项目开发完了,开始正常卖书了。假如到了双十一,要搞打折活动,上面的功能是不支持的,所以需要修改程序。有三种方法可以解决这个问题:

(1)修改接口

在IBook接口里新增getOffPrice()方法,专门用于进行打折,所有的实现类都实现该方法。但这样修改的后果就是,实现类NovelBook要修改,书店类BookStore中的main方法也要修改,同时,IBook作为接口应该是稳定且可靠的,不应该经常发生变化,因此,该方案被否定。

(2)修改实现类

修改NovelBook类中的方法,直接在getPrice()方法中实现打折处理,这个方法可以是可以,但如果采购书籍的人员要看价格怎么办,由于该方法已经进行了打折处理,因此采购人员看到的也是打折后的价格,会因信息不对称出现决策失误的情况。因此,该方案也不是一个最优的方案。

(3)通过扩展实现变化

增加一个子类OffNovelBook,覆写getPrice方法,高层次的模块(也就是BookStore中static静态块中)通过OffNovelBook类产生新的对象,完成业务变化对系统的最小开发。这样修改也少,风险也小,修改后的类图如下:



OffNovelBook源码如下:



然后修改BookStore中的书籍类为OffNovelBook:



为什么要用开闭原则

1. 开闭原则非常著名,只要是做面向对象编程的,在开发时都会提及开闭原则。

2. 开闭原则是最基础的一个原则,前面介绍的5个原则都是开闭原则的具体形态,而开闭原则才是其精神领袖。

3. 开闭原则提高了复用性,以及可维护性。

总结六大设计原则

1. 单一职责原则:一个类或接口只承担一个职责。

2. 里氏替换原则:在继承类时,务必重写(override)父类中所有的方法,尤其需要注意父类的protected方法(它们往往是让你重写的),子类尽量不要暴露自己的public方法供外界调用。

3. 依赖倒置原则:高层模块不应该依赖于低层模块,而应该依赖于抽象。抽象不应依赖于细节,细节应依赖于抽象。

4. 接口隔离原则:不要对外暴露没有实际意义的接口。

5. 迪米特法则:尽量减少对象之间的交互,从而减小类之间的耦合。

6. 开闭原则:对软件实体的改动,最好用扩展而非修改的方式。

点个关注吧,我会持续更新更多干货~~

您也可以关注我的微信公众号:Java架构成长之路,每天为您推送各种干货!

发布于 2020-03-01 15:59