单一职责原则

单一职责原则的英文名称是Single Responsibility Principle,简称是SRP。

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

单一职责原则的优点

:::success

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

单一职责原则是通过用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。因此切莫盲目使用设计模式,最合适的才是最好的。

单一职责原则最佳实践

接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。

:::warning 该原则建议一个接口最好只做一件事或一类事,因此,不同的功能需要不同的接口界定,可能会造成接口爆炸的情况。故而,最好在保证最小改动的情况下去设计,不要为了设计而设计。 :::

里式替换原则

里式替换原则(Liskov Substitution Principle),简称LSP。

在面向对象的语言中,继承是必不可少的,非常优异的语言机制。继承存在以下优点:

  • 代码共享,每个子类都拥有父类的方法和属性;
  • 提高代码的复用性;
  • 提高代码的可扩展性。子类只要重写父类的方法就可以实现自己独特的功能。

继承的缺点:

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

为了更好的发挥继承的优势和减少其带来的劣势,引入了里式替换原则。

里式替换原则有两种定义:

  1. 第一种定义,也是最正宗的定义:If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T,the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。)

  2. 第二种定义:Functions that use pointers or references to base classes mustbe able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类的对象。)第二个定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应

里式替换原则定义包含的含义:

  1. 子类必须完全实现父类的方法,且重写的方法行为与父类规定的具有一致性 。换句话说,子类可以扩展父类的功能,但不能改变父类原有的功能,否则违反了LSP原则。

里氏替换原则——面向对象设计原则中“几维鸟不是鸟”的例子中,由于几维鸟不会飞且重写了setSpeed()方法将flySpeed赋值为0,导致调用getFlyTime(double)是出现异常。解决该问题的办法有二:

  1. 通过instanceOf关键字判断,如果当前的调用者是几维鸟,则走异常逻辑;
  2. 几维鸟脱离继承,建立一个独立的父类。为了实现代码复用,即使用几维鸟和基类Bird都有的一些功能,可以与Bird类产生委托关系。当调用这些公有方法时,可以委托给Bird实现。

注意:在类中调用其他类时务必要使用其父类或父接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
注意:如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。


  1. 子类可以有自己的个性

子类可以有自己的属性和方法。里式替换原则可以正着用,但不能反过来使用。子类出现的地方,父类未必就能够胜任。


注意:有些时候我们就是只需要子类的某个功能,传入父类肯定会完成不了,而且强行将父类转为子类还可能出现ClassCastException异常。


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

也就是说,当我们重载某个父类的方法时,方法参数应该是被重载方法的超集。这样的话,当使用子类调用被重载方法(参数为被重载方法参数类型),调用到的还是超类的方法。例如:

  1. public class Father {
  2. public void fun(HashMap map){
  3. System.out.println("父类被执行...");
  4. }
  5. }
  6. public class Son extends Father {
  7. public void fun(Map map){
  8. System.out.println("子类被执行...");
  9. }
  10. }
  11. public class Client {
  12. public static void main(String[] args) {
  13. System.out.print("父类的运行结果:");
  14. Father father=new Father();
  15. HashMap map=new HashMap();
  16. father.fun(map);
  17. //父类存在的地方,可以用子类替代
  18. //子类B替代父类A
  19. System.out.print("子类替代父类后的运行结果:");
  20. Son sun=new Son();
  21. son.fun(map);
  22. }
  23. }

son.fun(map)仍然是调用到父类的方法,这是因为里式替换原则就是使用父类的地方可以使用任何子类替换。但是如果我们将子类重载的方法参数缩小,即:

  1. public class Father {
  2. public void fun(Map map){
  3. System.out.println("父类被执行...");
  4. }
  5. }
  6. public class Son extends Father {
  7. public void fun(HashMap map){
  8. System.out.println("子类被执行...");
  9. }
  10. }
  11. public class Client {
  12. public static void main(String[] args) {
  13. System.out.print("父类的运行结果:");
  14. Father father=new Father();
  15. HashMap map=new HashMap();
  16. father.fun(map);
  17. //父类存在的地方,可以用子类替代
  18. //子类B替代父类A
  19. System.out.print("子类替代父类后的运行结果:");
  20. Son son=new Son();
  21. son.fun(map);
  22. }
  23. }

son.fun(map)使用子类的fun方法被调用了,导致子类抢占了父类方法的调用。当我们在某个类中使用Father时,就不能使用子类类替换了。因为两个类对该方法的调用已经发生了改变。在子类没有重写父类方法的情况下,这个方法调用到了子类的实现,会造成一定的误解和影响。

  1. 覆写或实现父类的方法时输出结果可以被缩小

也就是说,父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类。

依赖倒置原则

依赖倒置原则(Dependence Inversion Principle,DIP),其原始定义为:

High level modules should not depend upon low level modules.Both shoulddepend upon abstractions.Abstractions should not depend upondetails.Details should depend upon abstractions.

也就是说,高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。
**
每一个业务功能都可以划分为多个子功能,不可划分的子功能属于低层模块,低层模块再组装就是高层模块。

由于在软件设计中,细节具有多变性,而抽象层则相对稳定,因此以抽象为基础搭建起来的架构要比以细节为基础搭建起来的架构要稳定得多。这里的抽象指的是接口或者抽象类,而细节是指具体的实现类。

使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给它们的实现类去完成。

总的来说,就是要面向接口编程,不要面向实现编程。
**

依赖倒置原则的优点

:::success

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

依赖倒置原则最佳实践

:::warning 依赖倒置原则的目的是通过要面向接口的编程来降低类间的耦合性,所以我们在实际编程中只要遵循以下4点,就能在项目中满足这个规则。

  1. 每个类尽量提供接口或抽象类,或者两者都具备。
  2. 变量的声明类型尽量是接口或者是抽象类。
  3. 任何类都不应该从具体类派生。
  4. 使用继承时尽量遵循里氏替换原则。 :::

接口隔离原则

接口隔离原则(Interface Segregation Principle,ISP),存在两个定义:

  1. 客户端不应该依赖他不需要的接口,这就要求对接口进行细化,只提供客户端需要的接口,其他接口一律剔除;
  2. 类间的依赖关系应该建立在最小的接口上。也同样是要求对接口进行细化

接口隔离原则单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:

  • 单一职责原则注重的是职责,是业务逻辑上的划分,而接口隔离原则注重的是对接口依赖的隔离,要求接口的方法尽可能的少;
  • 单一职责原则主要是约束类,它针对的是程序中的实现和细节;接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建。

接口隔离原则的优点

接口隔离原则是为了约束接口、降低类对接口的依赖性,遵循接口隔离原则有以下 5 个优点。

  1. 将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
  2. 接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性。
  3. 如果接口的粒度大小定义合理,能够保证系统的稳定性;但是,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。
  4. 使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
  5. 能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。

    接口隔离原则最佳实践

  6. 一个接口只服务于一个子模块或业务逻辑;

  7. 为依赖接口的类定制服务。只提供调用者需要的方法,屏蔽不需要的方法。
  8. 了解环境,拒绝盲从。每个项目或产品都有选定的环境因素,环境不同,接口拆分的标准就不同深入了解业务逻辑。
  9. 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。

迪米特法则

迪米特法则(Law of Demeter,LoD)也称为最少知识原则(Least KnowledgePrinciple,LKP),其定义规则为一个对象应该对其他对象有最少的了解。话句话说,如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
**

迪米特法则的优点

迪米特法则要求限制软件实体之间通信的宽度和深度,正确使用迪米特法则将有以下两个优点。

  1. 降低了类之间的耦合度,提高了模块的相对独立性。
    1. 由于亲合度降低,从而提高了类的可复用率和系统的扩展性。

注意:过度使用迪米特法则会使系统产生大量的中介类,从而增加系统的复杂性,使模块之间的通信效率降低。所以,在釆用迪米特法则时需要反复权衡,确保高内聚和低耦合的同时,保证系统的结构清晰。


迪米特法则最佳实践

从迪米特法则的定义和特点可知,它强调以下两点:

  1. 从依赖者的角度来说,只依赖应该依赖的对象;
  2. 从被依赖者的角度说,只暴露应该暴露的方法;

所以,在运用迪米特法则时要注意以下 6 点。

  1. 在类的划分上,应该创建弱耦合的类。类与类之间的耦合越弱,就越有利于实现可复用的目标。
  2. 在类的结构设计上,尽量降低类成员的访问权限。
  3. 在类的设计上,优先考虑将一个类设置成不变类。
  4. 在对其他类的引用上,将引用其他对象的次数降到最低。
  5. 不暴露类的属性成员,而应该提供相应的访问器(set 和 get 方法)。
  6. 谨慎使用序列化(Serializable)功能。

开闭原则

Software entities like classes,modules and functions should be open forextension but closed for modifications.(一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。)也就是说,一个软件实体应该通过扩展来实现变化,不应该通过修改已有的代码来实现变化。

开闭原则的优点

  1. 对测试友好。新增的类和逻辑只要新增对应的测试用例就行,不会影响以前的业务;
  2. 提高复用性。代码粒度越小,耦合性也低,复用性越优越;
  3. 提高可维护性。后继开发者维护项目时,有功能新增或去除时,不用执着原来的逻辑,直接进行扩展或删除;
  4. 面向对象开发的要求。

开闭原则最佳实践

  1. 抽象约束

抽象是对一组事物的通用描述,没有具体的实现,即可以有非常多的可能性,可以跟随需求的变化而变化。

  1. 通过接口或抽象类约束扩展,对扩展边界进行限定,不允许出现在接口或抽象类中不存在的public方法;
  2. 参数类型、引用对象尽量使用接口或抽象类,而不是实现类;
  3. 抽象层尽量保持稳定,一旦确定即不允许修改。
  1. 封装变化
    1. 将相同的变化封装到一个接口或抽象类中
    2. 将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中

      组合/聚合复用原则

      组合/聚合复用原则(Composition/Aggregation Reuse Principle,CARP),即尽量使用组合和聚合少使用继承的关系来达到复用的原则。也就是说,当两个类只有部分功能一致时,优先使用组合/聚合实现,对功能的实现进行委托。

组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;其次才考虑继承,在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。 由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为“黑箱”复用,相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作;合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。

组合/聚合复用原则最佳实践

合成和聚合均是关联的特殊情况。聚合用来表示“拥有”关系或者整体与部分的关系而合成则用来表示一种较为强烈的“拥有”关系。在一个合成关系里面,部分和整体的生命周期是一样的。一个合成的新的对象完全拥有对其组成部分的支配权,包括它们的创建和销毁等。使用程序语言的术语来说,组合而成的新对象对组成部分的内存分配、内存释放有绝对的责任。要正确的选择合成/复用和继承,必须透彻地理解里氏替换原则和Coad法则。(Coad法则由Peter Coad提出,总结了一些什么时候使用继承作为复用工具的条件。Coad法则:只有当以下Coad条件全部被满足时,才应当使用继承关系)

  1. 子类是基类的一个特殊种类,而不是基类的一个角色。区分“Has-A”和“Is-A”。只有“Is-A”关系才符合继承关系,“Has-A”关系应当用聚合来描述;
  2. 永远不会出现需要将子类换成另外一个类的子类的情况。如果不能肯定将来是否会变成另外一个子类的话,就不要使用继承。;
  3. 子类具有扩展基类的责任,而不是重写(override)或注销(Nullify)基类的责任。如果一个子类需要大量的置换掉基类的行为,那么这个类就不应该是这个基类的子类;
  4. 只有在分类学角度上有意义时,才可以使用继承。不要从工具类继承。

    参考

    里式替换法则