设计模式之6大设计原则

单一职责原则

单一职责原则(Single Responsibility Principle, SRP)的定义是: 应该有且仅有一个原因引起类或接口的变更。即一个类或接口只负责一个功能领域中的相应职责。

单一职责原则提出了一个编写程序的标准, 它使类的复杂性降低、提高了代码的可读性、可维护性和可扩展性、并降低了类或接口变更而引起的风险。但在实际项目中, 我们通常对"职责"没有一个量化的标准, 比如一个类到底要负责哪些职责?这些职责应该怎么细化?这种不可度量的外部因素给单一职责原则的实践带来了一定的困难性。

所以, 单一职责原则固然是一种非常好的理念, 但如果只会生搬硬套, 却又会引起类的剧增, 给维护带来很多不必要的麻烦, 而且过分细分类的职责(比如将一个类拆分成多个类, 类之间组合或聚合在一起)也会人为增加系统的复杂性。 

综上所述, 单一职责原则由于"职责"的不可度量性, 需要设计人员具有较强的分析设计能力和相关实践能力。在实际运用中, 给出的建议是: 接口一定要做到单一职责, 类的设计尽量做到只有一个原因引起变更。

里氏替换原则

里氏替换原则(Liskov Substitution Principle, LSP)最早是在1988年, 由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的, 它有两种定义: 

  • 第一种定义: 如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
  • 第二种定义: 所有引用基类的地方必须能透明地使用其子类的对象。

第二种定义是最清晰明确的, 即在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误或异常,反过来则不成立,如果软件中使用的是一个子类对象的话,那么它不一定能够替换成它的基类对象。

里氏替换原则为良好的继承定义了一个规范: 子类可以扩展父类的功能, 但不能改变父类原有的功能。具体可概括成以下4点: 

子类可以实现父类的抽象方法, 但不能覆写父类的非抽象方法

public class Parent {
    public int sum(int a, int b) {
        return a + b;
    }
}

public class Son extends Parent {
    @Override
    public int sum(int a, int b) {
        //doSomething, 不一定是求和, 即父类原有的功能已经改变
        return a - b;
    }
    public void study() {
    }
}

如上代码所示, 父类中的 sum() 方法对两个参数进行求和, 子类覆写了父类的方法并且改变了父类的原有的功能, 子类中的 sum() 方法不再是简单的进行求和运算。此时我们在父类Parent出现的地方替换为子类Son, 用代码描述就是将 new Parent().sum() 替换为 new Son().sum() , 原有的求和运算也被替换成了减法或乘法运算, 很明显这是两个不同的业务, 这也就违背了前文中的"所有引用基类的地方必须能透明地使用其子类的对象"这一定义。通常在实际业务中, 如果不得不覆写父类的方法, 可以有一个通用的做法: 令父类和子类都继承一个更通用的基类, 采用依赖、聚合、组合等关系代替原有的继承关系。

子类中可以增加自己特有的方法

此时子类Son扩展了一个在父类中并不存在的 study() 方法, 如果我们在实际业务中将子类出现的地方 new Son().study() 替换成父类对象 new Parent().study() 就会产生 java.lang.NoSuchMethodException 异常, 这又进一步验证了前文中的"如果软件中使用的是一个子类对象的话,那么它不一定能够替换成它的基类对象"这一反面论证。

当子类方法重载父类方法时, 子类方法的前置条件(即方法的形参)要比父类方法的前置条件更宽松

public class Parent {
    public void play(Map<String, String> map) {
        System.out.println("父类方法被执行...");
    }
}

public class Son extends Parent {
    public void play(HashMap<String, String> map) {
        System.out.println("子类方法被执行...");
    }
}

public class Demo {
    public static void main(String[] args) {
        HashMap<String, String> paramMap = new HashMap<String, String>();
        new Parent().play(paramMap);//父类方法被执行...
        new Son().play(paramMap);//子类方法被执行...
    }
}

如上, 子类重载父类的方法, 且子类方法的前置条件比父类方法的前置条件更严格, 将父类对象替换成子类对象后, 父类原有的方法不再被执行, 业务发生了改变, 这就违背了里氏替换原则的定义。但如果反过来, 子类方法的前置条件相对于父类更宽松, 将父类对象替换成子类对象后,  new Son().play(paramMap) 调用的仍然是父类方法, 这就符合了"所有引用基类的地方必须能透明地使用其子类的对象"这一定义。

当子类方法实现父类抽象方法时,子类方法的后置条件(即方法的返回值)要比父类方法的后置条件更严格

public class Parent {
    public List<String> getHobbys() {
        return new ArrayList<String>();
    }
}

public class Son extends Parent {
    @Override
    public ArrayList<String> getHobbys() {
        return new ArrayList<String>();
    }
}

public class Demo {
    public static void main(String[] args) {
        List<String> hobbys = new Parent().getHobbys();
        //List<String> hobbys = new Son().getHobbys();
    }
}

这个很容易理解, 根据里氏替换原则的定义, 如果子类方法的后置条件比父类更宽松, 将父类对象替换成子类对象后, 就会发生编译异常, 因为子类引用指向了父类对象 ArrayList<String> hobbys = List<String>类引用指向的实例对象 , 显然连基本的语法都不通过。

综上所述, 我们在程序中应尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换基类对象, 另外在使用继承时遵循里氏替换原则来提高代码的健壮性。

依赖倒置原则

依赖倒置原则(Dependency Inversion  Principle, DIP)的定义: 高层模块不应该依赖低层模块, 两者都应该依赖其抽象; 抽象不应该依赖细节; 细节应该依赖抽象。

依赖倒置原则的核心思想就是"面向接口编程", 它是实现开闭原则的一种必要手段。下面通过一段简单的代码示例来说明面向接口编程的好处。

public class Driver {
    //司机的主要职责就是驾驶汽车
    public void drive(Benz benz){
        benz.run();
    }
}

public class BMW {
    //宝马车当然也可以开动了
    public void run(){
        System.out.println("宝马汽车开始运行...");
    }
}

public class Benz {
    //汽车肯定会跑
    public void run(){
        System.out.println("奔驰汽车开始运行...");
    }
}

public class Client {
    public static void main(String[] args) {
        Driver zhangSan = new Driver();
        Benz benz = new Benz();
        //张三开奔驰车
        zhangSan.drive(benz);
    }
}

在上面的高层模块业务类Client中, 司机类和奔驰类紧密耦合在一起, 司机只能开奔驰车, 如果后期业务发生变更, 需要司机开宝马车或法拉利, 就需要对司机类进行修改, 其结果就是大大降低了系统的可维护性、可扩展性和稳定性, 这显然也不符合"面向对象设计"中的开闭原则

而且在实际项目中, 经常是多个开发人员协作开发, 可能是A开发人员负责司机类模块, B开发人员负责汽车类模块, 因为司机类依赖汽车类, 所以A开发人员需要等待B开发人员完成汽车类的编写后才能开展自己的工作, 这就大大的拖延了项目的进度, 即"增加了并行开发引起的风险"。

做过“Web Service"开发的应该知道一个”契约优先“的原则, 就是先定义好"WSDL"接口, 制定好双方的开发协议, 然后再各自实现。依赖倒置原则也给出了一个相似的规约, 即大家都面向接口编程, 不用去关心接口下具体的实现细节。

在上述代码示例中, 我们引入依赖倒置原则来对其进行重构, 重构后的UML类图如下: 

此时司机类不再直接依赖汽车类, 它们都依赖其各自实现的抽象接口, 而司机抽象接口又依赖汽车抽象接口。在高层业务模块中, 不用再关心低层的实现类细节, 基于接口编程, 司机的driver()方法形参类型设定为接口或抽象类, 后期如果有业务变动需要司机开宝马或法拉利, 只需要新增一个宝马类并在高层业务模块稍作修改即可, 而其他底层模块诸如Driver类则不需要做任何变动。另外在大型项目多人协作开发中, 大家也可以以此来约定好抽象类或接口, 然后各自根据定义好的接口进行具体实现, 就可以大大降低并行开发引起的风险。

在前文中提到, 依赖倒置原则的核心思想就是面向接口编程, 即将具体类的对象通过依赖注入的方式注入到其他对象中。依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。常用的注入方式有三种,分别是:构造注入,Setter方法注入和接口注入。构造注入是指通过构造函数来传入具体类的对象,设值注入是指通过Setter方法来传入具体类的对象,而接口注入是指通过在接口中声明的业务方法来传入具体类的对象。这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。

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

在实践中, 依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。为了确保该原则的应用,一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法,否则将无法调用到在子类中增加的新方法。

接口隔离原则

接口隔离原则(Interface  Segregation Principle, ISP)的定义: 客户端不应该依赖它不需要的接口, 类间的依赖关系应该建立在最小的接口上。

下面是引用https://blog.csdn.net/lovelion/article/details/7562842文章中的一段描述: 

根据接口隔离原则,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。这里的“接口”往往有两种不同的含义:一种是指一个类型所具有的方法特征的集合,仅仅是一种逻辑上的抽象;另外一种是指某种语言具体的“接口”定义,有严格的定义和结构,比如Java语言中的interface。对于这两种不同的含义,ISP的表达方式以及含义都有所不同:

  • 当把“接口”理解成一个类型所提供的所有方法特征的集合的时候,这就是一种逻辑上的概念,接口的划分将直接带来类型的划分。可以把接口理解成角色,一个接口只能代表一个角色,每个角色都有它特定的一个接口,此时,这个原则可以叫做“角色隔离原则”。
  • 如果把“接口”理解成狭义的特定语言的接口,那么ISP表达的意思是指接口仅仅提供客户端需要的行为,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。在面向对象编程语言中,实现一个接口就需要实现该接口中定义的所有方法,因此大的总接口使用起来不一定很方便,为了使接口的职责单一,需要将大接口中的方法根据其职责不同分别放在不同的小接口中,以确保每个接口使用起来都较为方便,并都承担某一单一角色。接口应该尽量细化,同时接口中的方法应该尽量少,每个接口中只包含一个客户端(如子模块或业务逻辑类)所需的方法即可,这种机制也称为“定制服务”,即为不同的客户端提供宽窄不同的接口。

下面通过一个简单的示例来说明接口隔离原则: 

上图这个设计未遵循接口隔离原则, 类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。下面根据接口隔离原则, 对接口I进行拆分, 拆分后的类图如下。

将接口I拆分成三个接口后, 类A和类C只能看到它所需要的方法, 同时拆分后的接口只专注为一个模块提供定制服务, 实现类也不用再实现和他职责无关的方法。

综上所述, 在使用接口隔离原则时,我们需要注意控制接口的粒度,接口不能太小,如果太小会导致系统中接口泛滥,不利于维护;接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。一般而言,接口中仅包含为某一类用户定制的方法即可,不应该强迫客户依赖于那些它们不用的方法。

迪米特法则

原文链接: https://blog.csdn.net/lovelion/article/details/7563445

迪米特法则(Law of  Demeter, LoD)也称为最少知识原则, 通俗点来讲, 就是一个软件实体应当尽可能少地与其他实体发生相互作用

如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易,这是对软件实体之间通信的限制,迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。

迪米特法则还有几种定义形式,包括不要和“陌生人”说话只与你的直接朋友通信等,在迪米特法则中,对于一个对象,其朋友包括以下几类:

  1. 当前对象本身(this);
  2. 以参数形式传入到当前对象方法中的对象;
  3. 当前对象的成员对象;
  4. 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
  5. 当前对象所创建的对象。

任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响。

迪米特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度

在将迪米特法则运用到系统设计中时,要注意下面的几点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限在类的设计上,只要有可能,一个类型应当设计成不变类在对其他类的引用上,一个对象对其他对象的引用应当降到最低

下面通过一个简单实例来加深对迪米特法则的理解: 

Sunny软件公司所开发CRM系统包含很多业务操作窗口,在这些窗口中,某些界面控件之间存在复杂的交互关系,一个控件事件的触发将导致多个其他界面控件产生响应,例如,当一个按钮(Button)被单击时,对应的列表框(List)、组合框(ComboBox)、文本框(TextBox)、文本标签(Label)等都将发生改变,在初始设计方案中,界面控件之间的交互关系可简化为如图1所示结构:

                                                                                    图1 初始设计方案结构图

在图1中,由于界面控件之间的交互关系复杂,导致在该窗口中增加新的界面控件时需要修改与之交互的其他控件的源代码,系统扩展性较差,也不便于增加和删除新控件。

现使用迪米特对其进行重构。

在本实例中,可以通过引入一个专门用于控制界面控件交互的中间类(Mediator)来降低界面控件之间的耦合度。引入中间类之后,界面控件之间不再发生直接引用,而是将请求先转发给中间类,再由中间类来完成对其他控件的调用。当需要增加或删除新的控件时,只需修改中间类即可,无须修改新增控件或已有控件的源代码,重构后结构如图2所示: 

开闭原则

开闭原则的定义(Open-Closed Principle, OCP): 一个软件实体如类、模块和函数应该对扩展开放, 对修改关闭。通俗点讲, 就是一个软件实体应该通过扩展来实现变化, 而不是通过修改已有的代码来实现变化。

在一个软件的生命周期内, 业务需求发生变化是一个常态, 这就要求我们在系统设计时要主动拥抱变化, 而开闭原则就是为软件实体的未来变更而制定的对现行开发设计进行约束的一个规则。

在前文中, 我们已介绍过其他5个原则,它们是指导设计的工具和方法, 而开闭原则是目标。单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。

那么我们在实践中怎么运用这6大设计原则呢?这里有一篇文章对此阐述的非常形象生动, 就转载过来了: https://blog.csdn.net/zhengzhb/article/details/7296944

对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。我们用一幅图来说明一下。

 图中的每一条维度各代表一项原则,我们依据对这项原则的遵守程度在维度上画一个点,则如果对这项原则遵守的合理的话,这个点应该落在红色的同心圆内部;如果遵守的差,点将会在小圆内部;如果过度遵守,点将会落在大圆外部。一个良好的设计体现在图中,应该是六个顶点都在同心圆中的六边形。

在上图中,设计1、设计2属于良好的设计,他们对六项原则的遵守程度都在合理的范围内;设计3、设计4设计虽然有些不足,但也基本可以接受;设计5则严重不足,对各项原则都没有很好的遵守;而设计6则遵守过渡了,设计5和设计6都是迫切需要重构的设计。

参考资料

设计模式系列一

设计模式系列二

设计模式之六大原则(转载)

<<设计模式之禅>>

猜你喜欢

转载自www.cnblogs.com/qingshanli/p/9808373.html