5f719456c29e13949b02db88d289a20e
018 | 回归设计模式的本质:设计原则

作为开发人员,或多或少都会熟悉或了解一些设计模式,如单例模式、工厂模式、观察者模式等等。但并非都能理解这些设计模式背后的本质,从而可能会导致对模式单纯的套用或滥用的情况出现。不要为了模式而模式,要明白使用模式的目的,要正确理解模式背后的设计原理,要理解背后的基本设计原则。

设计原则

首先,我们要明白使用设计模式的目的:为了代码可重用性、让代码更容易被他人理解、保证代码可靠性。那么,如果我们开发的应用并不是为了这些目的,其实就没必要使用设计模式,比如 Solidity 智能合约目前就不太适合直接套用设计模式。

其次,要理解设计模式背后一些重要的设计原则,所有设计模式基本都是基于这些设计原则总结出来的,这才是设计模式的本质和精髓所在。

人们总结出来的设计原则也很多,而从源头开始,GoF(Gang of Four)在《设计模式》一书中只提到两个设计原则:

  • 针对接口编程,而不是针对实现编程
  • 优先使用对象组合,而不是类继承

后来的人们给上面两个设计原则分别起了专业的名字:依赖倒置原则合成复用原则。而且,还总结出了其他设计原则,主要包括里氏替换原则、单一职责原则、接口隔离原则、迪米特法则开闭原则等。接下来就详细阐述下这几个设计原则。

依赖倒置原则

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

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 abstracts.

翻译过来就是:

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

所谓抽象,就是指接口或抽象类;所谓细节,就是指实现了接口或继承了抽象类的具体实现类。上面内容即是说,模块之间的依赖关系,应该通过接口或抽象类而产生,模块的实现类之间不要发生直接的依赖关系;而且接口或抽象类不应该依赖于实现类,实现类应该依赖于接口或抽象类。其核心思想也是 GoF 所提的针对接口编程,而不是针对实现编程。

我们知道,具体实现类是很有可能经常发生变更的,但接口或抽象类则很少会改变。因此,依赖于抽象,可以大大减低模块间的耦合度,以及可以提高模块的可复用性和程序的稳定性。不过,相应地,也会增加代码量。

很多设计模式都遵循了该原则,比如工厂类模式、观察者模式、适配器模式、策略模式等等。

在我们平时的实际开发中,如果想提高代码的可重用性、扩展性,那就应该尽量遵循该原则。但是,也不要陷入另一个误区,就是每一个类都抽象出一个对应的接口。

合成复用原则

合成复用原则(Composite Reuse Principle,CRP),也称为组合/聚合复用原则(Composition/Aggregate Reuse Principle,CARP),该原则提出:优先使用合成复用,而不是继承复用

我们知道,类的复用有两种方式:合成继承。合成即是组合或聚合。为什么要优先使用合成复用呢?这是因为继承复用主要有两个缺陷:

  1. 继承复用会破坏类的封装性,因为父类的实现细节直接暴露给子类了,这是白箱复用,要尽量避免;
  2. 如果父类发生改变,那子类的实现也不得不发生改变,这就导致父类和子类之间的高耦合,这不利于类的扩展与维护。

使用合成复用则可以将已有对象(也称为成员对象)纳入到新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能。因此,已有对象的内部实现细节对新对象就是不可见的,这就是黑箱复用,不会破坏类的封装性,其耦合度也相对较低,因此可以提高扩展性。

因此,需要复用时,我们要优先考虑能不能使用合成,实在不合适才考虑继承。而使用继承时,还需要遵循另一个设计原则:里氏替换原则。关于这个原则,后面再讲。

另外,使用合成复用时,还可以再结合上面的依赖倒置原则,让新对象和已有对象的交互通过接口或抽象类进行,从而可以更进一步减低耦合度。

里氏替换原则

里氏替换原则(Liskov Substitution Principle,LSP)主要用来规范如何正确地使用继承,其定义有两种:

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 的子类型。

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

翻译:所有引用基类的地方必须能透明地使用其子类的对象。

很明显,第二种定义更通俗易懂,其实就是说,只要基类出现的地方,都可以替换为子类,而且程序的功能不会发生变化

注意最后一点很关键,要保证替换为子类之后,程序功能不会发生变化,那么,子类不能重写(覆盖)父类已实现的方法。如果子类重写了父类已实现的方法,那很可能就会得到不一样的结果。因为替换成子类对象之后,调用该对象的方法时,实际上就会调用子类的方法,那结果就和调用父类方法不一样了。

虽然子类不能重写父类已实现的方法,但可以重载父类已实现的方法,但要求重载的方法形参要比父类方法的输入参数更宽松。比如,父类有一个方法为 func(HashMap map),那子类方法可以为 func(Map map),因为 MapHashMap 更宽松。假设父类实例为 fa,子类实例为 su,那 fa.func(HashMap或其子类)su.func(HashMap或其子类) 所调用的都是父类的方法 func(HashMap map),这样,替换之后的结果就能保证一致。而如果反过来,父类的形参为 Map,子类的形参为 HashMap,那调用 su.func(HashMap或其子类) 时就会优先调用子类的方法了,那结果和调用父类方法可能就不一样了,因此,这是违背里氏替换原则的。

一般来说,程序中的父类大多是抽象类,只定义了一个框架,具体功能需要子类来实现。而且父类中已实现的代码本身已经足够好,子类只需要进行扩展即可,尽量避免对其已经实现的方法再去重写。

单一职责原则

单一职责原则(Single Responsibility Principle,SRP)是大家最熟悉、也最容易理解的一个设计原则了,其定义也是非常简单:

top Created with Sketch.