阅读 405

回归设计模式的本质:设计原则


专栏地址:xiaozhuanlan.com/fullstack


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

设计原则

首先,我们要明白使用设计模式的目的:为了代码可重用性、让代码更容易被他人理解、保证代码可靠性。那么,如果我们开发的应用并不是为了这些目的,其实就没必要使用设计模式,比如 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)是大家最熟悉、也最容易理解的一个设计原则了,其定义也是非常简单:

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

意思就是,导致类变更的原因不能超过一个。换句话说就是,一个类只负责一个职责。类的职责单一,类的复杂度就会降低,代码维护起来自然也更容易。我们都知道,如果一个类包含了很多职责,那这个类就会变得非常臃肿,不好维护。

其实,单一职责原则不只是适用于类,对于接口和方法也适用。

虽然单一职责原则非常简单,也非常好理解,但如果应用到实际开发中,其实又不是那么容易。要应用好单一职责原则,核心在于如何能做好职责的划分,如何定义职责的粒度大小,缺乏设计经验的人很容易将一个类的职责粒度定义得过粗或过细。所以,能把该设计原则应用得好,其实是需要很强的分析设计能力的。

如果再延伸出去,单一职责原则其实还广泛应用到架构中,如前后端分离、读写分离、架构分层、数据模型与业务逻辑分离等等,其实都是将大粒度的职责进行拆解分离。所谓大道至简,所以不要小看一个简单的单一职责原则。

接口隔离原则

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

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.

一个类对另一个类的依赖应该建立在最小的接口上。

我们知道,一个类如果要实现一个接口,就必须实现这个接口所要求的所有方法。那么,如果这个接口里包含了这个类不需要的方法,这其实就会造成接口污染。要避免接口污染,就需要将这个接口拆分,只提取出这个类需要的方法,组成一个新的接口,然后让这个类去实现这个新接口,这就是接口隔离原则

所谓接口隔离,隔离的其实就是多余的方法。遵循接口隔离原则,就可以避免建立庞大臃肿的接口,避免造成接口污染,可提高程序的灵活性和可维护性。

在具体的应用中,我们应该尽量细化接口,让接口中的方法尽量少。尽量为不同的类建立不同的专用接口,避免建立一个综合性的接口供多个不同需求的类调用。

不过,细化的程度也不是越细越好,如果过度细化,则会造成接口数量过多,反而使得程序复杂化,所以,细化接口也要适度。

另外,很多人都会发觉接口隔离原则跟单一职责原则很相似,其实两者的关注的角度不同。单一职责原则的关注点是业务逻辑上的职责划分,而接口隔离原则关注的则是接口数量要小。实际上,我们在平时设计接口时,应该两个原则都要遵循。

迪米特法则

迪米特法则(Law of Demeter,LoD)又叫作最少知识原则(Least Knowledge Principle,LKP),其定义也非常好理解:

Each unit should have only limited knowledge about other units: only units "closely" related to the current unit.

每个单元对其他单元只拥有有限的知识,只了解与当前单元紧密联系的单元

第一句话比较容易做到,只要尽量减少一个类对外暴露的方法即可。而第二句话,等同于下面这句对迪米特法则的另一个更直白的定义:

Only talk to your immediate friends.

只与直接的朋友通信

所谓直接的朋友,就是指在逻辑上有直接耦合关系的对象和类。一般来说,出现在成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。

迪米特法则的初衷在于降低类之间的耦合,让每个类尽量减少对其他类的依赖,才能提高代码的复用率。

你会发觉,迪米特法则也正好应对了高内聚低耦合的设计思想。减少一个类对外暴露的方法,从而让其他类减少对它的了解,这就是高内聚;只与直接的朋友通信,减少对其他类的依赖,这就是低耦合

开闭原则

开闭原则(Open Closed Principle,OCP)是我们今天要讲的最后一个原则,也是其他设计原则的基石,可以说,其他设计原则都只是实现开闭原则的一些手段。先来看看开闭原则的定义:

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

软件实体(类、模块、函数等)应对扩展开放,但对修改封闭。

意思就是说,当我们的软件实体需要变化时,要尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码。

我们知道,所有软件系统都不会一成不变,如果一个需求变化会导致多个依赖的模块都发生级联式的改动,说明程序已经呈现出“坏设计(Bad Design)”的特质了。这样的程序就会相应地变得脆弱、僵化、无法预期和无法重用。开闭原则的产生就是为了解决这些问题,它能够指导我们如何建立稳定灵活的系统,它推崇的是已经设计完成的模块应该从不改变。当需求变化时,可以通过添加新代码扩展这个模块的行为,而别去更改那些可以工作的旧代码。

那么,如何做到对扩展开放、对修改封闭呢?其实,抽象是关键。我们都知道,抽象的灵活性好、适应性广,只要抽象定义合理,基本可以保持软件架构的稳定,所以我们可以用抽象来构建框架。而易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。那么,总结为一句话就是:用抽象构建框架,用实现扩展细节

总结

本文总共讲解了七个主要的设计原则:依赖倒置原则让我们针对接口编程,只依赖于抽象,不依赖实现,因为依赖抽象易于扩展;合成复用原则建议我们优先使用组合或聚合来实现代码的复用,也是因为合成复用耦合度低,可以提高扩展性;里氏替换原则指导我们如何正确地使用继承,因此扩展的时候才不会产生不一致的结果;单一职责原则强调一个类只负责一个职责,以提高类的扩展性和可维护性;接口隔离原则强调接口的设计要精简,避免接口污染;迪米特法则告诉我们要尽量做到高内聚低耦合;开闭原则推崇对扩展开放,对修改封闭,是其他设计原则的总纲。


扫描以下二维码即可关注订阅号。