这篇文章大致讲一些设计软件使用的原则和思想,以及一些具体的模式。其实软件行业发展这么多年来,已经解决了大量的问题。本文讲的这些内容其实全是这些为了解决问题而发明的有效的方法。

无论是适用广泛的 23 种设计模式,还是其他对于软件结构的抽象,其实类似这样的东西都能被叫做 Pattern。若干个 Pattern 组合起来,就构成了一个 Architecture。

面向对象的 SOLID 原则

在讲 23 种设计模式之前,我们先来看看 SOLID 原则。

其实在我之前的分享文章耦合和聚合以及软件设计的一些理论知识
中就已经提到过这些原则。在这边再详细展开说一下。

Single responsibility principle

单一职责原则,认为对象应该具有单一职责的概念。每个类都应该有单一的职责,并且该职责由这个类完全封装起来。
这样的结构让这个类非常健壮,修改自己类的功能,不会影响到其他的功能。
比如,一个报表需要导出,那么就专门搞一个导出器类来完成导出这个功能,而不是放在报表这个类中,这样即使修改导出的功能,也不会影响报表其他功能的正常使用。

Open–closed principle

开闭原则,软件中的对象(类,模块,函数等等)应该对于扩展是开放的,对于修改是封闭的。这是一个非常重要的原则。对于这个原则,有两种理解:

最初的理解:

  1. 如果一个模块仍可以扩展,那么它是一个开放的模块。
  2. 如果一个模块需要被其他模块使用,那么这个模块必须已经包含已定义好的稳定的描述,这些描述将不再可被修改。
  3. 如果需要将一个模块的功能进行重新修改,那么可以通过继承的方式,可以重用已有的实现。

后续的理解,也就是现在被大家接受的理解:

  1. 使用抽象的接口。
  2. 接口的实现是可以被修改的,并且可以通过多态在多种实现间实现替换。
  3. 由于继承自抽象接口,因此接口规范可以被重用,但是不能重用实现。
  4. 接口是不可以修改的,但是新的实现必须要实现这个接口。

旧理解,强调更多的是一个稳定的已定义好的默认实现;新理解,强调更多的是一个稳定的已定义好的抽象接口。

例如还是拿报表的导出来说,我们在设计导出逻辑时,使用的是一个抽象的导出接口,可以没有任何实现,然后可以提供一种导出器比如导出 xlsx 作为一个默认的实现。当我们要做扩展的时候,应该要去重新实现抽象的导出接口,而不是继承已有的 xlsx 导出器做修改,当然更不是直接修改 xlsx 导出器。这样新的导出器实现只是依赖导出器接口,而不是依赖默认的导出器,避免默认导出器修改时被影响,也就是面向接口编程。

Liskov substitution principle

里氏替换原则,在不改变程序正确性的同时,任何对象都可以由其子类型对象替换。

Let q(x) be a property provable about objects x of type T. Then q(y) should be true for objects y of type S where S is a subtype of T.

里氏替换原则有至少以下两种含义:

  1. 里氏替换原则是针对继承而言的,如果继承是为了实现代码重用,也就是为了共享方法,那么共享的父类方法就应该保持不变,不能被子类重新定义。子类只能通过新添加方法来扩展功能,父类和子类都可以实例化,而子类继承的方法和父类是一样的,父类调用方法的地方,子类也可以调用同一个继承得来的,逻辑和父类一致的方法,这时用子类对象将父类对象替换掉时,当然逻辑一致,相安无事。
  2. 如果继承的目的是为了多态,而多态的前提就是子类覆盖并重新定义父类的方法,为了符合 LSP,我们应该将父类定义为抽象类,并定义抽象方法,让子类重新定义这些方法,当父类是抽象类时,父类就是不能实例化的,所以也不存在可实例化的父类对象在程序里。也就不存在子类替换父类实例(根本不存在父类实例了)时逻辑不一致的可能。

不符合 LSP 的最常见的情况是,父类和子类都是可实例化的非抽象类,且父类的方法被子类重新定义,这一类的实现继承会造成父类和子类间的强耦合,也就是实际上并不相关的属性和方法牵强附会在一起,不利于程序扩展和维护。

尽量不要从可实例化的父类中继承,而是要使用基于抽象类和接口的继承。

正方形不是长方形。

Interface segregation principle

接口隔离原则,多个特定客户端的接口要优于一个通用的接口。

  1. 客户端客户端不应该依赖他不需要的接口;不依赖不需要的接口,降低耦合;
  2. 类见的依赖关系应该建立在最小的接口上;最小也不是无限制最小,而是达到要求的最小的接口,提高内聚。

Dependency inversion principle

依赖反转原则。

  1. 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口;降低层与层之间的耦合。
  2. 抽象接口不应该依赖于具体实现。而具体实现则应该依赖于抽象接口;面向接口编程。

由 Resource 依赖 Store 变成 Resource 依赖 StoreProvider,Store 实现 StoreProvider。

分门别类的学习设计模式

经过上述的学习,我们已经知道,这些原则都是前辈总结出来的使得软件清晰可读以及可扩展时可以应用的指南,都是一个思想,具体在代码中怎么运用还需要我们面对实际情况去灵活运用。
那么,设计模式就是更细层面上指导我们运用的细则。虽然说是细则,但是在不同的业务及需求背景下,也会有更灵活的运用,写出的代码可能不是任何设计模式的一种。
所以,还是要明确一点的是,我们学东西,重要的是学 idea,次要的是学 technique。我们通过学习更细层次上的设计模式,来将比较抽象难理解的 SOLID 原则搞懂,来学习如何合理的组织我们的代码,如何解耦,如何真正的达到对修改封闭对扩展开放的效果,让我们的代码更符合上面提到的面向对象的 SOLID 原则或者一些其他原则。我们是怀着这样的目的去学习设计模式的,而不是为了去背诵这些模式。

为了合理的利用设计模式,我们应该明白一个概念,叫做扩展点。扩展点不是天生就有的,而是设计出来的。我们设计一个软件的架构的时候,我们也要同时设计一下哪些地方以后可以改,哪些地方以后不能改。倘若你的设计不能满足现实世界的需要,那你就要重构,把有用的扩展点加进去,把没用的扩展点去除掉。

学习完设计模式我们要达成什么目标呢?假如看到一个代码结构,我们应该能:

  1. 讲出这个结构的代码有何优劣;
  2. 讲出符合哪些 SOLID 原则;
  3. 讲出扩展点在什么地方,如何扩展;
  4. 更厉害的是,如果需求向不同的方向变更,应该怎么将这段代码修改成一个更符合需求变更方向的结构。

如果能达成这个目标,那么我们的学习就是非常成功的。

抽象工厂模式(Abstract Factory)

todo

建造者模式(Builder Pattern)

适用场景

  • 隔离复杂对象的创建和使用,相同的方法,不同执行顺序,产生不同事件结果
  • 多个部件都可以装配到一个对象中,但产生的运行结果不相同
  • 产品类非常复杂或者产品类因为调用顺序不同而产生不同作用
  • 初始化一个对象时,参数过多,或者很多参数具有默认值
  • Builder模式不适合创建差异性很大的产品类,产品内部变化复杂,会导致需要定义很多具体建造者类实现变化,增加项目中类的数量,增加系统的理解难度和运行成本
  • 需要生成的产品对象有复杂的内部结构,这些产品对象具备共性;

具体案例

最主要的用处就是配合链式调用的,来实现一个 builder,使得大量参数的对象或者有很多默认值的对象能够清晰的创建。

至于其他的用法,见仁见智,我认为都可以使用一些简单工厂或者静态的 builder 方法来实现。
在很多 IDE 中,都有为复杂对象自动生成对应 builder 类的插件,比如在 intellij IDEA 中,就有类似的插件builder-generator

适配器模式(Adapter Pattern)

适配器模式适用场景

讲一个类的接口,转换成客户期望的另外一个接口。让原本接口不兼容的类可以合作无间。

分类

  • 使用组合来实现,对象适配器
    针对被适配的实例来进行适配,使用对象的组合来实现。在调用是,所有的方法,都被委托给被适配的对象。

    对象适配器 UML 图

  • 使用继承来实现,类适配器

    因为使用的是继承,所以更像是调用者和被适配者之间的粘合剂。

    总是使用一个适配器实例即可,不会在不同的被适配对象实例间创建不同的适配器。

    类适配器 UML 图