0%

重新理解软件设计六大原则

软件设计六大原则包括:单一职责原则、里氏替换原则、依赖倒置原则、接口隔离原则、迪米特法则和开闭原则。

本文主要内容参考自《设计模式之禅》

单一职责原则

单一职责原则定义

单一职责原则的英文名称是Single Responsibility Principle,简称是SRP。单一职责原则的定义是:应该有且仅有一个原因引起类的变更

我们以用户管理为例进行说明,包含修改用户的信息、增加机构、增加角色等,用户有这么多的信息和行为要维护,我们就把这些写到一个用户管理接口中。类图如下:
IUserInfo

相信大家都看的出来,这个接口设计有问题,用户属性和用户行为没有分开,违背了单一职责设计原则。应该把用户的信息抽取成一个BO(Business Object,业务对象),把行为抽取成一个Biz(Business Logic,业务逻辑)。修改后的类图如下:
IUserInfo_v2

重新拆封成两个接口,IUserBO负责用户的属性,简单地说,IUserBO的职责就是收集和反馈用户的属性信息;IUserBiz负责用户的行为,完成用户信息的维护和变更。我们现在是面向接口编程,所以产生了这个UserInfo对象之后,当然可以把它当IUserBO接口使用。也可以当IUserBiz接口使用,这要看你在什么地方使用了。要获得用户信息,就当是IUserBO的实现类;要是希望维护用户的信息,就把它当作IUserBiz的实现类就成了。

1
2
3
4
5
6
7
IUserInfo userInfo = new UserInfo();
//我要赋值了,我就认为它是一个纯粹的BO
IUserBO userBO = (IUserBO)userInfo;
userBO.setPassword("abc");
//我要执行动作了,我就认为是一个业务逻辑类
IUserBiz userBiz = (IUserBiz)userInfo;
userBiz.deleteUser();

上面的实现虽然能够解决问题,但是在实际的使用中,我们更倾向于使用两个不同的类或接口:一个是IUserBO,一个是IUserBiz。类图如下:
IUserInfo_v3

单一职责原则的优点如下:

  • 类的复杂性降低。实现什么职责都有清晰明确的定义;
  • 可读性提高。复杂性降低,那当然可读性提高了;
  • 可维护性提高。可读性提高,那当然更容易维护了;
  • 变更引起的风险降低。变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。

单一职责原则最佳实践

单一职责适用于接口、类,同时也适用于方法。一个方法尽可能做一件事情,比如一个方法修改用户密码,不要把这个方法放到“修改用户信息”方法中,这个方法的颗粒度很粗,比如下图所示的方法。
IUserManager

IUserManager中定义了一个方法changeUser,根据传递的类型不同,把可变长度参数changeOptions修改到userBO这个对象上,并调用持久层的方法保存到数据库中。
更好的实现如下图所示,如果要修改用户名称,就调用changeUserName方法;要修改家庭地址,就调用changeHomeAddress方法;要修改单位电话,就调用changeOfficeTel方法。每个方法的职责非常清晰明确,不仅开发简单,而且日后的维护也非常容易,大家可以逐渐养成这样的习惯。
IUserManager_v2

虽然单一职责说起来比较简单,但是在工程实践中往往综合考虑项目工期、成本、人员技术水平等因素,这就导致了类设计很难严格遵守单一职责原则。因此,对于单一职责原则,建议是接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化

里氏替换原则

里氏替换原则定义

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

  • 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
  • 提高代码的重用性;
  • 子类可以形似父类,但又异于父类,“龙生龙,凤生凤,老鼠生来会打洞”是说子拥有父的“种”,“世界上没有两片完全相同的叶子”是指明子与父的不同;
  • 提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,君不见很多开源框架的扩展接口都是通过继承父类来完成的;
  • 提高产品或项目的开放性。

有有点肯定也有缺点,继承的缺点如下:

  • 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
  • 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
  • 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构

从整体上来看,继承利大于弊,怎么才能让“利”的因素发挥最大的作用,同时减少“弊”带来的麻烦呢?解决方案是引入里氏替换原则(Liskov Substitution Principle,LSP)。简单来讲,里氏替换原则含义如下:
只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类

里氏替换原则四层含义

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

  1. 子类必须完全实现父类的方法
    我们在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。

  2. 子类可以有自己的个性
    子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。

  3. 覆盖或实现父类的方法时输入参数可以被放大
    这个比较难理解,我们来看一个例子,我们先定义一个Father类:

    1
    2
    3
    4
    5
    6
    public class Father {           
    public Collection doSomething(HashMap map){
    System.out.println("父类被执行...");
    return map.values();
    }
    }

    这个类非常简单,就是把HashMap转换为Collection集合类型,然后再定义一个子类:

    1
    2
    3
    4
    5
    6
    7
    public class Son extends Father {     
    //放大输入参数类型
    public Collection doSomething(Map map){
    System.out.println("子类被执行...");
    return map.values();
    }
    }

    请注意,子类与父类的方法名相同,但又不是覆写(Override)父类的方法,而是重载(Overload)!父类和子类都已经声明了,场景类的调用如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Client {     
    public static void invoker(){
    //父类存在的地方,子类就应该能够存在
    Father f = new Father();
    HashMap map = new HashMap();
    f.doSomething(map);
    }
    public static void main(String[] args) {
    invoker();
    }
    }

    代码运行后的结果是:父类被执行...

    根据里氏替换原则,父类出现的地方子类就可以出现。将上面的类型替换为子类执行:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Client {     
    public static void invoker(){
    //父类存在的地方,子类就应该能够存在
    Son f =new Son();
    HashMap map = new HashMap();
    f.doSomething(map);
    }
    public static void main(String[] args) {
    invoker();
    }
    }

    运行结果还是一样。父类方法的输入参数是HashMap类型,子类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。这是正确的,如果你想让子类的方法运行,就必须覆写父类的方法。
    我们再反过来想一下,如果Father类的输入参数类型宽于子类的输入参数类型,会出现什么问题呢?

    1
    2
    3
    4
    5
    6
    public class Father {     
    public Collection doSomething(Map map){
    System.out.println("父类被执行...");
    return map.values();
    }
    }

    把父类的前置条件修改为Map类型,我们再修改一下子类方法的输入参数,相对父类缩小输入参数的类型范围。

    1
    2
    3
    4
    5
    6
    7
    public class Son extends Father {     
    //缩小输入参数范围
    public Collection doSomething(HashMap map){
    System.out.println("子类被执行...");
    return map.values();
    }
    }

    业务场景的源代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Client {     
    public static void invoker(){
    //有父类的地方就有子类
    Father f= new Father();
    HashMap map = new HashMap();
    f.doSomething(map);
    }
    public static void main(String[] args) {
    invoker();
    }
    }

    代码运行后的结果是:父类被执行...。那我们再把里氏替换原则引入进来会有什么问题?有父类的地方子类就可以使用,好,我们把这个Client类修改一下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Client {     
    public static void invoker(){
    //有父类的地方就有子类
    Son f =new Son();
    HashMap map = new HashMap();
    f.doSomething(map);
    }
    public static void main(String[] args) {
    invoker();
    }
    }

    代码运行后的结果是:子类类被执行...。完蛋了吧?!子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱。明显违背了里氏替换原则。

  4. 覆写或实现父类的方法时输出结果可以被缩小
    父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类。分两种情况,如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,子类覆写父类的方法,天经地义。如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的。

采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!

依赖倒置原则

依赖倒置原则的定义

依赖倒置原则含义如下:

  • 高层模块不应该依赖低层模块,两者都应该依赖其抽象;
  • 抽象不应该依赖细节;
  • 细节应该依赖抽象

高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。依赖倒置原则在Java语言中的表现就是:

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

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

依赖的三种写法

依赖是可以传递的,A对象依赖B对象,B又依赖C,C又依赖D……生生不息,依赖不止,记住一点:只要做到抽象依赖,即使是多层的依赖传递也无所畏惧!对象的依赖关系有三种方式来传递,如下所示:

  1. 构造函数传递依赖对象
    在类中通过构造函数声明依赖对象,按照依赖注入的说法,这种方式叫做构造函数注入。

  2. Setter方法传递依赖对象
    在抽象中设置Setter方法声明依赖关系,依照依赖注入的说法,这是Setter依赖注入。

  3. 接口声明依赖对象
    在接口的方法中声明依赖对象,该方法也叫做接口注入。

依赖倒置原则最佳实践

依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合,我们怎么在项目中使用这个规则呢?只要遵循以下的几个规则就可以:

  • 每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备
    这是依赖倒置的基本要求,接口和抽象类都是属于抽象的,有了抽象才可能依赖倒置。
  • 变量的表面类型尽量是接口或者是抽象类
  • 任何类都不应该从具体类派生
  • 尽量不要覆写基类的方法
    如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响。
  • 结合里氏替换原则使用
    接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化。

接口隔离原则

接口隔离原则的定义

接口隔离原则定义如下:

  • 客户端不应该依赖它不需要的接口
  • 类间的依赖关系应该建立在最小的接口上

我们把这两个定义剖析一下,先说第一种定义:“客户端不应该依赖它不需要的接口”,那依赖什么?依赖它需要的接口,客户端需要什么接口就提供什么接口,把不需要的接口剔除掉,那就需要对接口进行细化,保证其纯洁性;再看第二种定义:“类间的依赖关系应该建立在最小的接口上”,它要求是最小的接口,也是要求接口细化,接口纯洁,与第一个定义如出一辙,只是一个事物的两种不同描述。

我们可以把这两个定义概括为一句话:建立单一接口,不要建立臃肿庞大的接口。再通俗一点讲:接口尽量细化,同时接口中的方法尽量少。看到这里大家有可能要疑惑了,这与单一职责原则不是相同的吗?错,接口隔离原则与单一职责的审视角度是不相同的,单一职责要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分,而接口隔离原则要求接口的方法尽量少。例如一个接口的职责可能包含10个方法,这10个方法都放在一个接口中,并且提供给多个模块访问,各个模块按照规定的权限来访问,在系统外通过文档约束“不使用的方法不要访问”,按照单一职责原则是允许的,按照接口隔离原则是不允许的,因为它要求“尽量使用多个专门的接口”。专门的接口指什么?就是指提供给每个模块的都应该是单一接口,提供给几个模块就应该有几个接口,而不是建立一个庞大的臃肿的接口,容纳所有的客户端访问。

保证接口的纯洁性

接口隔离原则是对接口进行规范约束,其包含以下4层含义:

  1. 接口要尽量小
    这是接口隔离原则的核心定义,不出现臃肿的接口(Fat Interface),但是“小”是有限度的,首先就是不能违反单一职责原则。根据接口隔离原则拆分接口时,首先必须满足单一职责原则

  2. 接口要高内聚
    什么是高内聚?高内聚就是提高接口、类、模块的处理能力,减少对外的交互。具体到接口隔离原则就是,要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越少,同时也有利于降低成本

  3. 定制服务
    一个系统或系统内的模块之间必然会有耦合,有耦合就要有相互访问的接口。我们设计时就需要为各个访问者(即客户端)定制服务,什么是定制服务?定制服务就是单独为一个个体提供优良的服务。我们在做系统设计时也需要考虑对系统之间或模块之间的接口采用定制服务,采用定制服务就必然有一个要求:只提供访问者需要的方法

  4. 接口设计是有限度的
    接口的设计粒度越小,系统越灵活,这是不争的事实。但是,灵活的同时也带来了结构的复杂化,开发难度增加,可维护性降低,这不是一个项目或产品所期望看到的,所以接口设计一定要注意适度,这个“度”如何来判断呢?根据经验和常识判断,没有一个固化或可测量的标准。

接口隔离原则最佳实践

接口隔离原则是对接口的定义,同时也是对类的定义,接口和类尽量使用原子接口或原子类来组装。但是,这个原子该怎么划分是设计模式中的一大难题,在实践中可以根据以下几个规则来衡量:

  • 一个接口只服务于一个子模块或业务逻辑;
  • 通过业务逻辑压缩接口中的public方法,接口时常去回顾,尽量让接口达到“满身筋骨肉”,而不是“肥嘟嘟”的一大堆方法;
  • 已经被污染了的接口,尽量去修改,若变更的风险较大,则采用适配器模式进行转化处理;
  • 了解环境,拒绝盲从。每个项目或产品都有特定的环境因素,别看到大师是这样做的你就照抄。

迪米特法则

迪米特法则的定义

迪米特法则(Law of Demeter,LoD)也称为最少知识原则(Least Knowledge Principle,LKP)。一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多public方法,我就调用这么多,其他的我一概不关心。

迪米特法则三层含义

迪米特法则对类的低耦合提出了明确的要求,其包含以下4层含义。

  1. 只和朋友交流
    迪米特法则还有一个英文解释是:Only talk to your immediate friends(只与直接的朋友交流)。什么叫做直接的朋友呢?每个对象都必然会与其他对象有耦合关系,两个对象之间的耦合就成为朋友关系,这种关系的类型有很多,例如组合、聚合、依赖等。朋友类的定义是这样的:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类。一个类只和朋友交流,不与陌生类交流,不要出现getA().getB().getC().getD()这种情况。

  2. 朋友间也是有距离
    对朋友关系描述最贴切的故事就是:两只刺猬取暖,太远取不到暖,太近刺伤了对方,必须保持一个既能取暖又不刺伤对方的距离。迪米特法则就是对这个距离进行描述,即使是朋友类之间也不能无话不说,无所不知。
    我们在安装软件的时候,经常会有一个导向动作,第一步是确认是否安装,第二步确认License,再然后选择安装目录……这是一个典型的顺序执行动作,具体到程序中就是:调用一个或多个类,先执行第一个方法,然后是第二个方法,根据返回结果再来看是否可以调用第三个方法,或者第四个方法,等等,其类图如下所示:
    InstallSoftware
    在Wizard类中分别定义了三个步骤方法,每个步骤中都有相关的业务逻辑完成指定的任务。软件安装InstallSoftware类代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class InstallSoftware {          
    public void installWizard(Wizard wizard){
    int first = wizard.first();
    //根据first返回的结果,看是否需要执行second
    if(first>50){
    int second = wizard.second();
    if(second>50){
    int third = wizard.third();
    if(third >50){
    wizard.first();
    }
    }
    }
    }
    }

    Wizard类把太多的方法暴露给InstallSoftware类,两者的朋友关系太亲密了,耦合关系变得异常牢固。如果要将Wizard类中的first方法返回值的类型由int改为boolean,就需要修改InstallSoftware类,从而把修改变更的风险扩散开了。因此,这样的耦合是极度不合适的,我们需要对设计进行重构,重构后的类图如下所示。
    InstallSoftware_v2
    将三个步骤的访问权限修改为private,同时把InstallSoftware中的方法installWizad移动到Wizard方法中。通过这样的重构后,Wizard类就只对外公布了一个public方法,即使要修改first方法的返回值,影响的也仅仅只是Wizard本身,其他类不受影响,这显示了类的高内聚特性。

    1
    2
    3
    4
    5
    6
    public class InstallSoftware {     
    public void installWizard(Wizard wizard){
    //直接调用
    wizard.installWizard();
    }
    }

    一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为privatepackage-private(包类型,在类、方法、变量前不加访问权限,则默认为包类型)、protected等访问权限,是否可以加上final关键字等

  3. 是自己的就是自己的
    在实际应用中经常会出现这样一个方法:放在本类中也可以,放在其他类中也没有错,那怎么去衡量呢?你可以坚持这样一个原则:如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中

迪米特法则最佳实践

迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了难度。读者在采用迪米特法则时需要反复权衡,既做到让结构清晰,又做到高内聚低耦合。

开闭原则

开闭原则的定义

开闭原则的定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。那什么又是软件实体呢?软件实体包括以下几个部分:

  • 项目或软件产品中按照一定的逻辑规则划分的模块。
  • 抽象和类。
  • 方法

开闭原则最佳实践

开闭原则是一个非常虚的原则,前面5个原则是对开闭原则的具体解释,但是开闭原则并不局限于这么多,它“虚”得没有边界。可以通过以下4个方面来使用开闭原则:

  1. 抽象约束
    抽象是对一组事物的通用描述,没有具体的实现,也就表示它可以有非常多的可能性,可以跟随需求的变化而变化。因此,通过接口或抽象类可以约束一组可能变化的行为,并且能够实现对扩展开放,其包含三层含义:

    1. 通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法
    2. 参数类型、引用对象尽量使用接口或者抽象类,而不是实现类
    3. 抽象层尽量保持稳定,一旦确定即不允许修改
  2. 元数据(metadata)控制模块行为
    编程是一个很苦很累的活,那怎么才能减轻我们的压力呢?答案是尽量使用元数据来控制程序的行为,减少重复开发。什么是元数据?用来描述环境和数据的数据,通俗地说就是配置参数,参数可以从文件中获得,也可以从数据库中获得。

  3. 制定项目章程
    在一个团队中,建立项目章程是非常重要的,因为章程中指定了所有人员都必须遵守的约定,对项目来说,约定优于配置。

  4. 封装变化
    对变化的封装包含两层含义:第一,将相同的变化封装到一个接口或抽象类中;第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。封装变化,也就是受保护的变化(protected variations),找出预计有变化或不稳定的点,我们为这些变化点创建稳定的接口,准确地讲是封装可能发生的变化,一旦预测到或“第六感”发觉有变化,就可以进行封装。

原创不易,觉得文章写得不错的小伙伴,点个赞👍 鼓励一下吧~