为了提高软件系统的可维护性和可复用性,增加软件的可扩展性和灵活性,程序员要尽量根据6条原则来开发程序,从而提高软件开发效率、节约软件开发成本和维护成本。

要说设计原则,首先应该搞清楚一个概念:「抽象类」和「接口」的区别。

  • 一句说明:抽象类是对根源(实物)的抽象,接口是对动作的抽象

举个例子🌰:狗能 吃 和 睡。

  1. //通过抽象类定义
  2. public abstract class Dog {
  3. public abstract void eat();
  4. public abstract void sleep();
  5. }
  6. //通过接口定义
  7. public interface Dog {
  8. public abstract void eat();
  9. public abstract void sleep();
  10. }

现在增加一个特殊技能,导盲(Guide the blind)

  1. 将「导盲」写进同个抽象类,继承 狗 类的子类都会 导盲?不合适
  2. 将「导盲」写进同个接口, 当需要 导盲 功能时,不得不重写 吃 和 睡。不合适。

吃 和 睡 是狗与生俱来的行为,而「导盲」是后天行为,只能算狗类的引申,两者不属于一个范畴,所以应该将「导盲」这个动作单独抽象出来,选用 interface,而狗都会的,就对根源抽象,用 abstract class.

  1. //定义接口,含有钻火圈方法
  2. public interface GuideTheBlind() {
  3. public abstract void GuideTheBlind();
  4. }
  5. //定义抽象类狗类
  6. public abstract class Dog {
  7. public abstract void eat();
  8. public abstract void sleep();
  9. }
  10. //继承抽象类且实现接口
  11. class GuideDog extends Dog implements GuideTheBlind {
  12. public void eat() {
  13. //....
  14. }
  15. public void sleep() {
  16. //....
  17. }
  18. public void drillFireCircle() () {
  19. //....
  20. }
  21. }

3.1 开闭原则「Open Closed Principle」


对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。简言之,是为了使程序的扩展性好,易于维护和升级。

想要达到这样的效果,我们需要使用接口和抽象类。

因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节可以从抽象派生来的实现类来进行扩展,当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以了。

以 搜狗输入法 的皮肤为例介绍开闭原则的应用。

分析:搜狗输入法 的皮肤是输入法背景图片、窗口颜色和声音等元素的组合。用户可以根据自己的喜爱更换自己的输入法的皮肤,也可以从网上下载新的皮肤。这些皮肤有共同的特点,可以为其定义一个抽象类(AbstractSkin),而每个具体的皮肤(DefaultSpecificSkin和HeimaSpecificSkin)是其子类。用户窗体可以根据需要选择或者增加新的主题,而不需要修改原代码,所以它是满足开闭原则的。

3 「软件设计原则」 - 图1
实现代码
测试代码

开闭原则是最基础的一个原则,下面的5个原则都是开闭原则的具体形态, 也就是说五个原则就是指导设计的工具和方法,而开闭原则才是其精神领袖

总之开闭原则是非常重要的,可通过以下几个方面来理解其重要性。

1. 开闭原则对测试的影响

  • 所有已经投产的代码都是有意义的,并且都受系统规则的约束,这样的代码都要经 过“千锤百炼”的测试过程,不仅保证逻辑是正确的,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码”(Poisonous Code),因此有变化提出时,就需要考虑一下, 原有的健壮代码是否可以不修改,仅仅通过扩展实现变化呢?否则,就需要把原有的测试过程回笼一遍,需要进行单元测试功能测试集成测试甚至是验收测试。
  • 单元测试通过,显示绿条。在单元测试中,有一句非常有名的话,叫做“Keep the bar green to keep the code clean”,即保持绿条有利于代码整洁,绿条就是Junit运行的两种结果中的一种:要么是红条,单元测试失败;要么是绿条,单元测试通过。
  • 一个方法的测试方法一般不少于3种。首先是正常的业务逻辑要保证测试到,其次是边界条件要测试到,然后是异常要测试到,比较重要的方法的测试方法甚至有十多种;单元测试是对的测试,类中的方法耦合是允许的,这样的条件下,如果再想着通过修改一个方法或多个方法代码来完成变化,基本上就是痴人说梦,该类的所有测试方法都要重构,想象一下你在一堆你并不熟悉的代码中进行重构时的感觉。

2. 开闭原则可以提高复用性

面向对象的设计中,所有的逻辑都是从原子逻辑组合而来,不是在一个类中独立 实现一个业务逻辑。这样代码才可以复用,粒度越小,被复用可能性就越大。为什么要复用?减少代码量,避免相同的逻辑分散在多个角落,避免日后的维护人员为了修改一个微小的缺陷或增加新功能而要在整个项目中到处查找相关的代码。怎么才能提高复用率?缩小逻辑粒度,直到一个逻辑不可再拆分为止。


3. 开闭原则可以提高可维护性

软件投产后,维护人员的工作不仅是对数据进行维护,还可能要对程序进行扩 展,维护人员最乐意做的事情就是扩展一个类,而不是修改一个类,不管原有的代码写得多么优秀还是多么糟糕,让维护人员读懂原有的代码,然后再修改,是一件很痛苦的事情。


4. 面向对象开发的要求

万物皆对象,需要把所有的事物都抽象成对象,然后针对对象进行操作;但是万物 皆运动,有运动就有变化,有变化就要有策略去应对,如何快速应对?需要在设计之初考虑到所有可能变化的因素,然后留下接口,等待“可能”转变为“现实”。


📌如何使用开闭原则


1. 抽象约束

  • 抽象是对一组事物的通用描述,没有具体的实现,也就表示它可以有非常多的可能性, 可以跟随需求的变化而变化。
  • 因此,通过接口或抽象类可以约束一组可能变化的行为,并且能够实现对扩展开放,其包含三层含义:
    • 第一,通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法
    • 第二,参数类型、引用对象尽量使用接口或者抽象类,而不是实现类
    • 第三,抽象层尽量保持稳定,一旦确定即不允许修改

2. 元数据(metadata)控制模块行为

什么是元数据?用来描述环境和数据的数据,通俗地说就是「配置参数」,参数可以从文件中获得,也可以从数据库中获得。

  • 其中达到极致的就是控制反转(Inversion of Control),就是Spring容器中的精髓。

3. 制定项目章程

团队开发,要建立项目章程,所有开发人员必须遵守约定,对项目而言,约定优于配置。


4. 封装变化

  • 对变化的封装包含两层含义:
    • 第一,将相同的变化封装到一个接口或抽象类中
    • 第二, 将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中
  • 封装变化,也就是受保护的变化(protected variations),找出预计有变化或不稳定的点,为这些变化点创建稳定的接口,准确地讲是封装可能发生的变化,一旦预测到有变化,就可以进行封装,23个设计模式都是从各个不同的角度对变化进行封装的。

3.2 里氏代换原则「Liskov Substitution Principle」

面向对象的语言中,继承是必不可少的,它有如下优点:

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

缺点:

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

LSP原则定义:

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

  • 所有引用基类的地方必须能透明地使用其子类的对象

里氏代换原则:任何基类可以出现的地方,子类一定可以出现。反过来就不行了,有子类出现的地方,父类未必就能适应。通俗理解:子类可以扩展父类的功能,但不能改变父类原有的功能。换句话说,子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法

如果通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。

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

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

3 「软件设计原则」 - 图2

  • 枪类能够射击,设计为抽象类父类。
  • 三种类型的枪实现枪类(父类)
  • 士兵能用抽象枪,抽象枪能杀敌「killEnemy()」,还需要杀敌前先配枪「setGun」
  • 又用户「Client」来给士兵选择枪。

    1. public abstract class AbstractGun {
    2. // 枪用来杀敌
    3. public abstract void shoot();
    4. }
    5. public class Rifle extends AbstractGun{
    6. @Override
    7. public void shoot() {
    8. System.out.println("使用步枪射击...");
    9. }
    10. }
    11. public class Soldier {
    12. private AbstractGun abstractGun;
    13. // 配枪
    14. public void setAbstractGun(AbstractGun abstractGun) {
    15. this.abstractGun = abstractGun;
    16. }
    17. public void killEnemy() {
    18. System.out.println("士兵准备击杀敌人...");
    19. abstractGun.shoot();
    20. }
    21. }
    22. // 测试
    23. class Client {
    24. @Test
    25. public void testLSP() {
    26. Soldier soldier = new Soldier();
    27. soldier.setAbstractGun(new Rifle());
    28. soldier.killEnemy();
    29. }
    30. }

    测试结果: 士兵准备击杀敌人… 使用步枪射击…

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

  • 现在添加一把玩具手枪「ToyGun」

3 「软件设计原则」 - 图3

  • 但是玩具枪无法射击,杀不了敌人,不应该写在shoot方法中。

    1. public class ToyGun extends AbstractGun{
    2. @Override
    3. public void shoot() {
    4. // 玩具枪无法射击,所以方法做空实现
    5. }
    6. }
  • 这种情况下,按照继承原则,还是这样实现的话,原则上是没有问题的,但是在具体应用场景中就要考 虑下面这个问题了:子类是否能够完整地实现父类的业务,否则就会出现像上面的拿枪杀敌 时却发现是把玩具枪的笑话。

这时候有两种方法解决:

  1. 在Soldier类中增加instanceof的判断,如果是玩具枪,就不用来杀敌。这个方法可以 解决问题,但是你要知道,在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,这是不可行的。如果你的产品出现了这个问题,因为修正了这样一个Bug,就要求所有 与这个父类有关系的类都增加一个判断。
  2. ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbastractGun建 立关联委托关系

3 「软件设计原则」 - 图4
玩具枪父类就可以有一些自己的属性,比如玩具枪的声音,声音,颜色 etc.

  • 于是就需要特别注意了:

    如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发 生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。


实现代码
测试代码

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

  • 简单的说就是里氏替换原则不能反着用,子类可以有自己的方法和属性,子类出现的地方,父类未必可以胜任。

3 「软件设计原则」 - 图5

  • 举个例子🌰:著名的AK47自动步枪和AUG狙击步枪。对于狙击手Snipper而言,是使用AUG狙击枪的。

    1. //狙击枪都携带一个精准的望远镜
    2. public class AUG extends Rifle {
    3. public void zoomOut() {
    4. System.out.println("通过望远镜察看敌人...");
    5. }
    6. @Override
    7. public void shoot() {
    8. System.out.println("AUG射击...");
    9. }
    10. }
    11. public class Snipper {
    12. public void killEnemy(AUG aug) {
    13. aug.zoomOut(); //首先用狙镜观察敌人
    14. aug.shoot(); //开始射击
    15. }
    16. }
    17. //Test
    18. public class Client {
    19. public static void main(String[] args) {
    20. //产生三毛这个狙击手
    21. Snipper snipper = new Snipper();
    22. snipper.setRifle(new AUG()); // 直接调用子类
    23. snipper.killEnemy();
    24. }
    25. }
  • 上面配枪直接配了AUG,但是如果通过父类来调用子类呢

    1. public class Client {
    2. public static void main(String[] args) {
    3. Snipper snipper = new Snipper();
    4. snipper.setRifle((AUG) (new Rifle())); // 通过父类来调用子类
    5. snipper.killEnemy();
    6. }
    7. }

    java.lang.ClassCastException 抛出异常,这就是向下转型的不安全性。

  • 也是验证了里氏替换原则的就是有子类出现的地方父类未必可以出现。


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

  • 里氏替换原则要求制定一个契约,就是父类或接口,这种设计方法也叫做 Design by Contract(契约设计)。
  • 契约制定了,也就同时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条 件就是我执行完了需要反馈,标准是什么。

  • 比较难理解。举个例子🌰:

    1. public class Father {
    2. public Collection doSomething(HashMap map) {
    3. System.out.println("父类被调用....");
    4. return map.values();
    5. }
    6. }
    7. public class Son extends Father{
    8. // 放大输入参数类型
    9. //@Override 报错
    10. public Collection doSomething(Map map) {
    11. System.out.println("子类被调用...");
    12. return map.values();
    13. }
    14. }
  • 注意,子类方法名与父类一样,但是不是覆写@Override,加上注解会报错。

  • 虽然方法名一样,但是方法的输入参数不同,这属于重载Overload,是不同类下的重载。

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

    父类被调用….

  • 根据里氏替换原则,父类出现的地方子类就可以出现,我们把上面的粗体部分修改为子 类

    1. public static void invoker2() {
    2. //父类存在的地方,子类就应该能够存在
    3. Son f = new Son();
    4. HashMap map = new HashMap();
    5. f.doSomething(map);
    6. }

    父类被调用….

  • 结果相同,原因如下:

  • 父类方法的输入参数是HashMap类型,子 类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行
  • 如果你想让子类的方法运行,就必须覆写Override父类的方法。

  • 在一个Invoker类中关联了一个父类,调用了一个父类的方法,子类可以覆写这个方法,也可以重载这个方法,前提是要扩大这个前置条件,就是输入参数的类型宽于父类的类型覆盖范围。

  • 可以反过来想,Father类入参扩大成Map,Son类入参缩小到HashMap,然后正常调用。

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

    父类被执行…

  • 再调用子类 Son f = new Son();

    子类被执行…

  • 子类在没有覆写父类的方法的前提下,子类方法被执行了,会引起业务 逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类就会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松

实现代码
测试代码


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

  • 父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆 写)的返回值为S,那么里氏替换原则就要求S必须小于等于T
  • 也就是说,要么S和T是同一个类型,要么S是T的子类
  • 分两种情况:
  1. 如果是覆写Override,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,子类覆写父类的方法,天经地义。可以这样想,需要调用子类的覆写方法时,返回值同父类T一样,这是正常用法,如果返回类型S是T的子类,则调用此覆写方法的地方返回了S类型,也能成功应用,这是多态的正确用法。
  2. 如果是重载Overload,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数(上面第三条验证的),也就是说你写的这个方法是不会被调用的,参考上面讲的前置条件。

总结:

  • 采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容 性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,完美!
  • 在项目中,采用里氏替换原则时,尽量避免子类的“个性”,一旦子类有“个性”,这个子 类和父类之间的关系就很难调和了,把子类当做父类使用,子类的“个性”被抹杀;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离——缺乏类替换的标准。

3.3 依赖倒置原则「Dependence Inversion Principle」

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

翻译过来,包含三层含义:

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

不可分割的 原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。

Java中,抽象就是指接口抽象类,两者都是不能直接被实例化的;细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化。

所以用Java的话定义就是:

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

举个例子🌰:
3 「软件设计原则」 - 图6

  • 司机可以开车 drive,奔驰可以跑动run,客户端Client负责分配车给司机
  • 设计的Driver和Benz都是普通类,然后司机依赖于奔驰类(入参需要奔驰类型的车)

  • 现在要求司机同时也要开 BMW,于是又增加一个单独的类,然后将其放入Driver类中。这就违背了代码健壮性了,增加一个宝马类需要改动原来的司机类,不符合规范。而且这种开发不符合现在大环境下的工程开发,一个Driver没写完所有功能,就不能独立地测试。一个工作变成了“单线程”工作,甲写完才能给乙继续写,而不是并行地开发,能进行独立的测试。

  • 如果不使用依赖倒置原则就会加重类间的耦合性,降低系统的稳定性, 增加并行开发引起的风险,降低代码的可读性和可维护性。于是就需要依赖倒置原则开发:

3 「软件设计原则」 - 图7

  • 用接口抽象出驾驶动作,汽车运行动作。实体类实现接口,降低耦合。

    1. public interface ICar {
    2. public void run(); // 汽车运行
    3. }
    4. public interface IDriver {
    5. public void drive(ICar car); // 驾驶
    6. }
    7. public class Driver implements IDriver{
    8. public void drive(ICar car) {
    9. car.run();
    10. }
    11. }
    12. public class BMW implements ICar{
    13. public void run() {
    14. System.out.println("BMW is running...");
    15. }
    16. }
    17. public class Benz implements ICar{
    18. public void run() {
    19. System.out.println("Benz is running...");
    20. }
    21. }
    22. // Test
    23. class Clent {
    24. @Test
    25. public void testDIP() {
    26. IDriver driver = new Driver();
    27. ICar car = new Benz();
    28. driver.drive(car);
    29. }
    30. }

    Benz is running…

  • 抽象是对实现的约束,对依赖者而言,也是一种契约,不仅仅约束自己,还同时约束自 己与外部的关系,其目的是保证所有的细节不脱离契约的范畴,确保约束双方按照既定的契约(抽象)共同发展,只要抽象这根基线在,细节就脱离不了这个圈圈。

实现代码
测试代码

1.对象的依赖关系有三种方式来传递

  1. 构造函数传递依赖对象
  • 就是用有参构造函数,目标接口入参传进来,给成员变量赋值。
  1. Setter方法传递依赖对象
  • setter方法不用多说了。。。
  1. 接口声明依赖对象
    1. public interface IDriver {
    2. public void drive(ICar car); // 驾驶
    3. }
  • 刚才案例里的,依赖对象已经在接口声明就已经规范好了。
  • 这种方法也叫做接口注入

2.最佳实践

怎么在项目中使用这个规则?只要遵循以下的几个规则就可以:

  • 每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备
    • 这是依赖倒置的基本要求,接口和抽象类都是属于抽象的,有了抽象才可能依赖倒置。
  • 变量的表面类型尽量是接口或者是抽象类
    • 你要使用类的clone方法,就必须 使用实现类,这个是JDK提供的一个规范。
  • 何类都不应该从具体类派生
    • 如果一个项目处于开发状态,确实不应该有从具体类派生出子类的情况,但这也不是绝 对的,因为人都是会犯错误的,有时设计缺陷是在所难免的,因此只要不超过两层的继承都是可以忍受的。特别是负责项目维护的同志,基本上可以不考虑这个规则,为什么?维护工 作基本上都是进行扩展开发,修复行为,通过一个继承关系,覆写一个方法就可以修正一个很大的Bug,何必去继承最高的基类呢?(当然这种情况尽量发生在不甚了解父类或者无法 获得父类代码的情况下。)
  • 尽量不要覆写基类的方法
    • 如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。类间依赖的是 抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响。
  • 结合里氏代换原则使用
    • 父类出现的地方子类就能出现,再结合本节,我们可以得出这样一个通俗的规则: 接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化。

总结

到底什么是“倒置”呢?先说“正置”是什么意思,依赖正置就是类间的依赖是实实在在的实现类间的依赖,也就是面向实现编程,这也是正常人的思维方式,我要开奔驰车就依赖奔驰车,我要使用笔记本电脑就直接依赖笔记本电脑,而编写程序需要的是对现实世界的事物进行抽象,抽象的结果就是有了抽象类和接口,然后我们根据系统设计的需要产生了抽象间的依赖,代替了人们传统思维中的事物间的依赖,“倒置”就是从这里产生的。

依赖倒置原则是6个设计原则中最难以实现的原则,它是实现开闭原则的重要途径,依 赖倒置原则没有实现,就别想实现对扩展开放,对修改关闭。项目中,只要记住 是“面向接口编程”就基本上抓住了依赖倒置原则的核心。


3.4 接口隔离原则「Interface Segregation Principle」

  • 实例接口(Object Interface),在Java中声明一个类,然后用new关键字产生一个实 例,它是对一个类型的事物的描述,这是一种接口。其实就是一个类。
  • 类接口(Class Interface),Java中经常使用的interface关键字定义的接口。

定义:

Clients should not be forced to depend upon interfaces that they don’t use.

  • 客户端不应该依 赖它不需要的接口

    The dependency of one class to another one should depend on the smallest possible interface

  • 类间的依赖关系应该建立在最小的接口上

理解:

  • 客户端依赖它需要的接口,客户端需要什么接口就提供什么接口,把不需要的接口剔除掉,那就需要对接口进行细化,保证其纯洁性
  • 要求接口细化,接口纯洁,与第一句话一个道理。

举个例子🌰:
3 「软件设计原则」 - 图8

  • 美女(拥有美的面貌,身材和气质)抽象成接口;星探,可以发现美女,同时也具有美女的名字。
  • 具体实现看图即可。

  • 但是美女接口范围太大,比如唐朝女性以胖为美,实现此接口显然不合适。

  • 将接口范围缩小,分为拥有外形漂亮的美女IGoodBodyGirl,气质美的美女IGreatTemperamentGirl

3 「软件设计原则」 - 图9

  1. public interface IGoodBodyGirl {
  2. public void goodLooking(); //要有较好的面孔
  3. public void niceFigure(); //要有好身材
  4. }
  5. public interface IGreatTemperamentGirl {
  6. public void greatTemperament(); // 要有气质
  7. }
  8. public class PrettyGirl implements IGoodBodyGirl, IGreatTemperamentGirl{
  9. private String name; // 美女的名字
  10. public PrettyGirl(String name) {
  11. this.name = name;
  12. }
  13. public void goodLooking() {
  14. System.out.println(name + " has good looking");
  15. }
  16. public void niceFigure() {
  17. System.out.println(name + " has nice figure");
  18. }
  19. public void greatTemperament() {
  20. System.out.println(name + " has great temperament");
  21. }
  22. }
  23. public abstract class AbstractSearcher {
  24. protected IGreatTemperamentGirl iGreatTemperamentGirl;
  25. protected IGoodBodyGirl iGoodBodyGirl;
  26. public AbstractSearcher(IGreatTemperamentGirl iGreatTemperamentGirl, IGoodBodyGirl iGoodBodyGirl) {
  27. this.iGreatTemperamentGirl = iGreatTemperamentGirl;
  28. this.iGoodBodyGirl = iGoodBodyGirl;
  29. }
  30. public AbstractSearcher(IGoodBodyGirl iGoodBodyGirl) {
  31. this.iGoodBodyGirl = iGoodBodyGirl;
  32. }
  33. public abstract void show(); // 列出美女信息
  34. }
  35. public class Searcher extends AbstractSearcher{
  36. public Searcher(IGreatTemperamentGirl iGreatTemperamentGirl, IGoodBodyGirl iGoodBodyGirl) {
  37. super(iGreatTemperamentGirl, iGoodBodyGirl);
  38. }
  39. public Searcher(IGoodBodyGirl iGoodBodyGirl) {
  40. super(iGoodBodyGirl);
  41. }
  42. public void show() {
  43. iGoodBodyGirl.goodLooking();
  44. iGoodBodyGirl.niceFigure();
  45. iGreatTemperamentGirl.greatTemperament();
  46. }
  47. }
  48. // Test
  49. class Client {
  50. @Test
  51. public void testIsp() {
  52. // 代码没判空,所以初始化两个实体。
  53. IGoodBodyGirl iGoodBodyGirl = new PrettyGirl("Majiko");
  54. IGreatTemperamentGirl iGreatTemperamentGirl = new PrettyGirl("Taylor");
  55. AbstractSearcher searcher = new Searcher(iGreatTemperamentGirl, iGoodBodyGirl);
  56. searcher.show();
  57. }
  58. }

Majiko has good looking Majiko has nice figure Taylor has great temperament

实现代码
测试代码

1.证接口的纯洁性

  1. 接口要尽量小
  • “小”是指多小?至少要满足 「单一职责原则」
  1. 接口要高内聚
  • 要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越 少,同时也有利于降低成本。
  1. 定制服务
  2. 接口设计是有限度的
  • 接口的设计粒度越小,系统越灵活,这是不争的事实。但是,灵活的同时也带来了结构 的复杂化,开发难度增加,可维护性降低,这不是一个项目或产品所期望看到的,所以接口设计一定要注意适度,这个“度”如何来判断呢?根据经验和常识判断,没有一个固化或可测量的标准。

    2.最佳实践

  • 一个接口只服务于一个子模块或业务逻辑

  • 通过业务逻辑压缩接口中的public方法,接口时常去回顾,尽量让接口达到“满身筋骨 肉”,而不是“肥嘟嘟”的一大堆方法
  • 已经被污染了的接口,尽量去修改,若变更的风险较大,则采用适配器模式进行转化 处理;
  • 了解环境,拒绝盲从。每个项目或产品都有特定的环境因素,环境不同,接口拆分的标准就不同。深入了解业务逻辑,最好的接口设计就出自你的手中。

3.5 迪米特法则「Law of Demeter」

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

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

1.只和朋友交流

举个例子🌰:
3 「软件设计原则」 - 图10

  • 老师「Teacher」让体育委员「GroupLeader」清点班上的女生人数。如图所示
  • 女生在这里只做空实现。

    1. public class Teacher {
    2. public void commond(GroupLeader groupLeader) {
    3. List<Girl> listGirls = new ArrayList();
    4. //初始化女生
    5. for (int i = 0; i < 20; i++) {
    6. listGirls.add(new Girl());
    7. }
    8. //告诉体育委员开始执行清查任务
    9. groupLeader.countGirls(listGirls);
    10. }
    11. }
    12. public class GroupLeader {
    13. //清查女生数量
    14. public void countGirls(List<Girl> listGirls) {
    15. System.out.println("女生数量是:" + listGirls.size());
    16. }
    17. }
    18. public class Girl {
    19. // 空实现
    20. }
    21. // Test
    22. class Client {
    23. @Test
    24. public void testLOD() {
    25. Teacher teacher = new Teacher();
    26. teacher.commond(new GroupLeader());
    27. }
    28. }

    女生数量是:20

  • 结果正确,可是这样出现一个问题。

  • Teacher类只有一个朋友类GroupLeader,但是与Girl产生依赖关系,声明 List<Girl> 与Girl产生关系,破坏健壮性。方法是类的一个行为,类却不知道自己的行为与其他类产生依赖关系,这就破坏了迪米特法则。
  • 朋友类的定义:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内 部的类不属于朋友类

  • 鉴于上述分析,做出以下调整

3 「软件设计原则」 - 图11

  • 去掉Teacher对Girl的依赖关系,将Girl存在GrouLeader的成员变量中。

    1. public class Teacher {
    2. public void commond(GroupLeader groupLeader) {
    3. //告诉体育委员开始执行清查任务
    4. groupLeader.countGirls();
    5. }
    6. }
    7. public class GroupLeader {
    8. private List<Girl> listGirls;
    9. // 有参构造传入女生
    10. public GroupLeader(List<Girl> listGirls) {
    11. this.listGirls = listGirls;
    12. }
    13. // 清查女生数量
    14. public void countGirls() {
    15. System.out.println("女生数量是:" + this.listGirls.size());
    16. }
    17. }
    18. // Test
    19. class Client {
    20. @Test
    21. public void testLOD() {
    22. //产生一个女生群体
    23. List<Girl> listGirls = new ArrayList<Girl>();
    24. // 初始化女生
    25. for (int i = 0; i < 20; i++) {
    26. listGirls.add(new Girl());
    27. }
    28. Teacher teacher = new Teacher();
    29. //老师发布命令
    30. teacher.commond(new GroupLeader(listGirls));
    31. }
    32. }
  • 对程序进行了简单的修改,把Teacher中对List<Girl>的初始化移动到了场景类Client中,同时 在GroupLeader中增加了对Girl的注入,避开了Teacher类对陌生类Girl的访问,降低了系统间的耦合,提高了系统的健壮性。

📌注:一个类只和朋友交流,不与陌生类交流,不要出现getA().getB().getC().getD()这种 情况(在一种极端的情况下允许出现这种访问,即每一个点号后面的返回类型都相同),类与类之间的关系是建立在间的,而不是方法间,因此一个方法尽量不引入一个类中不存在的对象,当然,JDK API提供的类除外。


2. 朋友间也是有距离的

🌰:我们在安装软件的时候,经常会有一个导向动作,第一步是确认是否安装,第二步确认 License,再然后选择安装目录……这是一个典型的顺序执行动作,具体到程序中就是:调用一个或多个类,先执行第一个方法,然后是第二个方法,根据返回结果再来看是否可以调用第三个方法,或者第四个方法,等等;
3 「软件设计原则」 - 图12

  • 很简单的类图,实现软件安装的过程,其中first方法定义第一步做什么,second方法定 义第二步做什么,third方法定义第三步做什么

    1. public class Wizard {
    2. private Random rand = new Random(System.currentTimeMillis());
    3. //第一步
    4. public int first() {
    5. System.out.println("执行第一个方法...");
    6. return rand.nextInt(100);
    7. }
    8. //第二步
    9. public int second() {
    10. System.out.println("执行第二个方法...");
    11. return rand.nextInt(100);
    12. }
    13. //第三个方法
    14. public int third() {
    15. System.out.println("执行第三个方法...");
    16. return rand.nextInt(100);
    17. }
    18. }
    19. public class InstallSoftware {
    20. public void installWizard(Wizard wizard) {
    21. int first = wizard.first();
    22. //根据first返回的结果,看是否需要执行second
    23. if (first > 50) {
    24. int second = wizard.second();
    25. if (second > 50) {
    26. int third = wizard.third();
    27. if (third > 50) {
    28. wizard.first();
    29. }
    30. }
    31. }
    32. }
    33. }
    34. // Test
    35. class Client {
    36. @Test
    37. public void testLOD() {
    38. InstallSoftware invoker = new InstallSoftware();
    39. invoker.installWizard(new Wizard());
    40. }
    41. }

    结果是随机的…

  • 程序虽然简单,但是隐藏的问题可不简单,思考一下程序有什么问题。 Wizard类把太多的方法暴露给InstallSoftware类,两者的朋友关系太亲密了,耦合关系变得异常牢固。如果要将Wizard类中的first方法返回值的类型由int改为boolean,就需要修改InstallSoftware类,从而把修改变更的风险扩散开了。

  • 因此,这样的耦合是极度不合适的,我们需要对设计进行重构:

3 「软件设计原则」 - 图13

  • 在Wizard类中增加一个installWizard方法,对安装过程进行封装,同时把原有的三个 public方法修改为private方法
  1. public class Wizard {
  2. private Random rand = new Random(System.currentTimeMillis());
  3. public int first() {
  4. System.out.println("执行第一个方法...");
  5. return rand.nextInt(100);
  6. }
  7. public int second() {
  8. System.out.println("执行第二个方法...");
  9. return rand.nextInt(100);
  10. }
  11. public int third() {
  12. System.out.println("执行第三个方法...");
  13. return rand.nextInt(100);
  14. }
  15. //软件安装过程
  16. public void installWizard() {
  17. int first = this.first();
  18. //根据first返回的结果,看是否需要执行 second
  19. if (first > 50) {
  20. int second = this.second();
  21. if (second > 50) {
  22. int third = this.third();
  23. if (third > 50) {
  24. this.first();
  25. }
  26. }
  27. }
  28. }
  29. }
  • 将三个步骤的访问权限修改为private,同时把InstallSoftware中的方法installWizad移动到 Wizard方法中。通过这样的重构后,Wizard类就只对外公布了一个public方法,即使要修改first方法的返回值,影响的也仅仅只是Wizard本身,其他类不受影响,这显示了类的高内聚特性。

    1. public class InstallSoftware {
    2. public void installWizard(Wizard wizard) {
    3. //直接调用
    4. wizard.installWizard();
    5. }
    6. }
  • 场景类Client没有任何改变,可以直接测试

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


3. 是自己的就是自己的

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


4. 谨慎使用Serializable

在实际应用中,这个问题是很少出现的,即使出现也会立即被发现并得到解决。是怎么 回事呢?举个例子来说,在一个项目中使用RMI(Remote Method Invocation,远程方法调 用)方式传递一个VO(Value Object,值对象),这个对象就必须实现Serializable接口(仅仅是一个标志性接口,不需要实现具体的方法),也就是把需要网络传输的对象进行序列 化,否则就会出现NotSerializableException异常。
突然有一天,客户端的VO修改了一个属性的访问权限,从private变更为public,访问权限扩大了,如果服务器上没有做出相应的变更,就会报序列化失败,就这么简单。但是这个问题的产生应该属于项目管理范畴,一个类或接口在客户端已经变更了,而服务器端却没有同步更新,难道不是项目管理的失职吗?

实现代码


3.6 单一职责原则「Single Responsibility Principle」

There should never be more than one reason for a class to change.

  • 有且仅有一个原因引起类的变更

上例子🌰:
image.png

  • 电话通话的时候有4个过程发生:拨号(dial)、通话(chat)、回应、挂机(hangup)
  • IPhone这个接口可不是只有一个职责,它包含了两个职责:一个是协议管理,一个是数 据传送
    • dial()和hangup()两个方法实现的是协议管理,分别负责拨号接通和挂机;
    • chat()实现的是数据的传送,把我们说的话转换成模拟信号或数字信号传递到对方,然后再把对方传递 过来的信号还原成我们听得懂的语言。
  • 可以这样考虑这个问题,协议接通的变化会引起这个接口或实现类的变化吗?会的!那数据传送(想想看,电话不仅仅可以通话,还可以上网)的变化会引起这个接口或实现类的变化吗?会的!这里有两个原因都引起了类的变化。这两个职责会相互影响吗?电话拨号,只要能接通就成,甭管是电信的还是网通的协议;电话连接后还关心传递的是什么数据吗?通过这样的分析,发现类图上 的IPhone接口包含了两个职责,而且这两个职责的变化不相互影响,那就考虑拆分成两个接口。

image.png

  • 这个类图看上去有点复杂了,完全满足了单一职责原则的要求,每个接口职责分明,结 构清晰,但是设计的时候肯定不会采用这种方式,一个手机类要把 ConnectionManager和DataTransfer组合在一块才能使用。组合是一种强耦合关系,你和我都有共同的生命期,这样的强耦合关系还不如使用接口实现的方式呢,而且还增加了类的复杂性,多了两个类。经过这样的思考后,再修改一下类图:

image.png
这样的设计才是完美的,一个类实现了两个接口,把两个职责融合在一个类中。你会觉得这个Phone有两个原因引起变化了呀,是的,但是别忘记了是面向接口编程,对外公布的是接口而不是实现类。而且,如果真要实现类的单一职责,这个就必须使用上面的组合模式了,这会引起类间耦合过重、类的数量增加等问题,人为地增加了设计的复杂性。

总结一下单一职责原则好处:

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

📌注意 :单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。


「总结」


「SOLID原则」

image.png

  • 单一职责原则
    • 一个类只负责完成一个职责或者功能,不要存在多于一种导致类变更的原因。
    • 单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、松耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
  • 开闭原则
    • 添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。
    • 开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
    • 很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
  • 里氏替换原则
    • 子类可以扩展父类的功能,但不能改变父类原有的功能。
    • 子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
  • 接口隔离原则
    • 调用方不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。 接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
  • 依赖反转原则
    • 高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
  • 迪米特法则
    • 一个对象应该对其他对象保持最少的了解
  • 合成复用原则
    • 尽量使用合成/聚合的方式,而不是使用继承。

一句就是:

  • 单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则总纲,告诉我们要对扩展开放,对修改关闭。

https://juejin.cn/post/6954378167947624484