设计原则
一 简介
设计原则是代码设计的核心也是设计模式的基础,设计模式往往都有特定的应用场景和优缺点,没法在所有代码中都使用,但是设计原则的应用范围更加广泛,熟练地运用好设计原则往往可以让代码质量获得质的提高。
二 内聚和耦合
2.1 内聚性和耦合性
在讨论设计原则之前,我先介绍两个概念,内聚性和耦合性;
内聚性:又称块内联系,指模块的功能强度的度量,即一个模块内部各个元素彼此结合的紧密程度的度量。
内聚性是对一个模块内部各个组成元素之间相互结合的紧密程度的度量指标。模块中组成元素结合的越紧密,模块的内聚性就越高,模块的独立性也就越高。理想的内聚性要求模块的功能应明确、单一,即一个模块只做一件事情。
耦合性:耦合性也叫耦合度,是对模块间关联程度的度量。耦合的强弱取决与模块间接口的复杂性、调用模块的方式以及通过界面传送数据的多少。
模块间的耦合度是指模块之间的依赖关系,包括控制关系、调用关系、数据传递关系。模块间联系越多,其耦合性越强,同时表明其独立性越差。
2.2 高内聚和低耦合
软件设计中通常用耦合度和内聚度作为衡量模块独立程度的标准。所以这里就要提到一个非常重要的原则,那就是高内聚和低耦合,这也是很多设计原则的基础。
高内聚: 一般就是指模块内聚性强,内部元素结合紧密,功能明确单一,整体独立性强,对外依赖程度低等。
低耦合: 就是指模块间尽量减少依赖,每个模块独立性高。
高内聚和低耦合是一个非常重要也是非常基础的设计思想,高内聚一般用来指导模块本身的设计,低耦合用来指导模块与模块之间依赖关系的设计。这里的模块可以大到一个业务系统,小到一个具体的方法,都可以运用高内聚低耦合的思想去设计。
三 SOLID原则
了解了一些比较基础的信息,我们再来探讨些常用的设计原则,首先我们先介绍下,最为知名的 SOLID原则。
SOLID 可以翻译为稳定的,可信赖的,最早是Michael Feathers在Robert C. Martin 提出的原则基础上进行创造的名称,一般是指单一职责原则(S),开放/封闭原则(O), Liskov替换原则(L),接口隔离原则(I)和依赖倒置原则(D) 五种原则首字母的拼接,
这些原则并不是一个人总结的,而是分别由不同的人在不同时间提出,不过最早由Robert C. Martin 将这5个原则合并到一块进行介绍,这些原则也是设计模式的基础和核心。
3.1 单一职责原则(Single Responsibility Principle,SRP)
Robert C. Martin 是这样描述单一职责原则的:
A class should have one, and only one, reason to change.
翻译成中文就是:就一个类而言,应该仅有一个引起它变化的原因。
这里把职责定义为“变化的原因”,每一个职责都代表一个变化的原因,如果需求发生变化,就会导致类对应的职责发生变化,如果一个类承担了多于一个的职责,把这些职责耦合在一起,那么一个职责的变化就会削弱或抑制这个类完成其他职责的能力。
这种耦合就会导致脆弱的设计,一旦变化发生时,原设计会遭受到意想不到的破坏。
简单理解就是如果一个类中含有过多的职责,那么需求变化时,这个类因为含有过多职责,那被修改的可能就会比较大,但即使只修改类中某个逻辑,也很很容易影响到其他逻辑,引起意想不到的错误,业务也变的难以维护和扩展。
如何判断违反了单一职责原则?
如果你能想到多个动机去改变一个类,那这个类就具有多于一个的职责,就违反了单一职责原则。
具象化的一些判断,可以参考以下情况:
- 类中的代码行数、函数或者属性过多;
2.类依赖的其他类过多,或者依赖类的其他类过多;
3. 私有方法过多;
4. 比较难给类起一个合适的名字;
5. 类中大量的方法都是集中操作类中的某几个属性。
单一职责的扩展
单一职责不仅用在类的维度上,我们可以对其进行扩展,将单一职责的定义进行简单的修改:
一个类或者一个模块,应该仅有一个引起它变化的原因。
这里的模块即指细粒度的方法或多个方法的聚合,也可指多个类或者系统的聚合。简单来说就是细粒度到每个方法需要符合单一职责,粗粒度到一个模块或者一个系统也都需要符合单一职责原则。
单一职责的运用
实际开发中尽量不要设计大而全的类,而是要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。
但是在真正的软件开发中,我们也没必要过于未雨绸缪,过度设计。所以,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候我们就可以将这个粗粒度的类,拆分成几个更细粒度
的类,保持持续重构。
单一职责原则一般是通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是需要注意的是如果拆分得
过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
单一职责总结:
单一职责原则是所有原则中比较简单,但也是最难应用的原则之一,如何衡量职责单一是一个永恒的话题,比如有时候细粒度看职责不单一,但是粗粒度看职责却是单一的,如果为了职责单一而单一,将类拆的太细,反而影响代码的维护。
3.2 开放/封闭原则 (Open/Closed Principle,OCP)
Bertrand Meyer在1988年在他的书《面向对象的软件构建》(Object-Oriented Software Construction)中谈到了这一点。他将开放/封闭原则解释为:
“软件实体(类、模块、函数等)应该开放扩展,但关闭修改。
任何系统在其生命周期都会发生变化,变化往往就是导致代码腐化,系统变得难以维护的主要原因。OCP就是让我们对于实体的扩展进行开放,当需求变化时我们可以对模块进行扩展,使其满足那些改变的新行为。但是对实体的修改应该关闭,当模块
进行扩展时,不改动模块本身的代码。从而尽量避免一处改动,引起一系列相关模块的改动,从而降低代码的可维护性。
OCP中的扩展就是在不修改已有代码的基础上扩展代码,比如新增模块、类、方法等等。而修改就是对已有代码进行修改,比如直接修改已有代码的模块、类、方法等等。
怎么实现OCP?
OCP主要是解决变化问题引起的代码腐化问题,所以我们首先需要识别出代码中可变化和不可变化两部分内容,对其中不可变化部分进行抽象,对可变化部分进行封装隔离,保证互不影响。所以OCP的核心技巧就是抽象,将模块中任意个可能的行为可
以抽象到一个抽象体中,也就是不可变化部分。其余可变化部分则依赖这个抽象体。
那么具体如何抽象?首先需要对现有的功能进行梳理,找到可能的一些行为,抽出其中共性和不可变部分。同时也需要尽量预测未来可能的变化,从而提前做好扩展点。但未来如何变化实际开发中并不能很好的进行预测,所以我们需要提前和需求方商议讨论后续需求发展
的可能方向,从而提前做好设计。最后我们还需配合重构,等变化真正发生时,如果原有代码结构不再适用,那就再对原有结构进行重构,使其符合OCP原则。
OCP 总结
OCP是大多数设计模式的核心,比如抽象工程模式,模板方法模式,桥接模式等等。其对代码的灵活性,可重用性以及可维护性都起到很大的作用,但是如何做好抽象也是一个值得思考的问题,一般认为,仅仅只对程序中频繁变化的部分进行抽象即可,
不可滥用抽象。
同时我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,但我们又要注意不可对未来预测的变化方向,做过多的设计,不然整体就会过度设计,反而影响后续维护。可以等到真正发生变化时,
再进行修改和重构。
3.3 Liskov替换原则(Liskov Substitution Principle, LSP)
Liskov 替换原则是Barbara Liskov在 1987 年的会议主题演讲“数据抽象”中介绍的。后来,她与 Jeanette Wing 发表了一篇论文,他们将这一原则定义为:
Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.
上面这段有些过于抽象,Robert Martin 在他的 SOLID 原则中重新描述了这个原则:
子类型(subtype)必须能够替换它们的基类型(base type)。
上面这段描述又过于简单,所以我结合两者定义,把LSP做以下描述:
子类型(subtype)必须能够替换它们的基类型(base type),并且能够保证原来程序的逻辑行为不变。
LSP的重要性不言而喻,如果子类替换父类型会改变程序原本的逻辑行为,那使用多态替换子类时,就会造成无法预知的问题,整个系统的可维护性,可重用性也会很差,甚至可以说埋了一个地雷在那。
但大多数情况下一个程序的逻辑行为是很多的,如果再加上各种异常行为那就更多了,那怎么保证原来程序的逻辑行为不变呢?这里可以使用Bertrand Meyer提出的基于契约设计(Design By Contract,DBC)来对代码进行设计。
契约设计(Design By Contract,DBC)
契约设计(Design By Contract,DBC),要求类的编写者需要显示的规定针对该类的契约。客户代码的编写者可以通过该契约获悉可以依赖的行为方式。契约是通过为每个方法声明前置条件和后置条件来指定的。要使一个方法得以执行,前置条件必须为真。
执行完毕后,该方法后置条件也必须为真。
DBC可以简单理解为,类设计时必须显示规定一些协议,子类在设计的时候,必须要查看父类的协议,并且严格遵守。子类重写父类方法的时候,可以改变方法的内部实现逻辑,但绝不能改变父类方法规定的协议。这里的协议应该至少包括:方法声明中要
实现的功能;输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明等等。同时接口和实现类之间的关系也应该遵守此协议。
那么怎么实现DBC? 在子类中只能使用相等或者更弱的前置条件来替换原始的前置条件,只能使用相等或更强的后置条件来替换原始的后置条件。
LSP 总结
LSP是一个很基础的原则,也可以说是OCP实现的基础,正是子类型的可替换性,才可以使基类型在无需修改的情况下进行扩展。LSP应该还是每个开发日常开发中都必须尽量去遵守的原则,这对提高代码的可维护性,可重用性以及代码的健壮性非常重要。
3.4 接口隔离原则(Interface Segregation Principle, ISP)
接口隔离原则是由 Robert C. Martin 在为施乐咨询以帮助他们为新的打印机系统构建软件时定义的。他将其定义为:
“Clients should not be forced to depend upon interfaces that they do not use.”
翻译下就是:不应强迫客户依赖于它们不用的方法。
如果强迫客户依赖于那些它们不用的方法,那么客户就面临着这些方法的改变而带来的变更。这会造成所有客户之间的耦合。
所以ISP主要是用来解决胖接口的问题,如果一个类的接口不是内聚的,就表示该类具有胖的接口,我们可以把胖的接口拆分成多组方法,每组方法都服务于一组不同的客户。
怎么隔离接口?
可以通过按照客户的调用方式来间接的把接口拆分成多个接口。
拆分后的接口可通过如下方式,进行调用:
1. 使用委托: 使用委托而非实现的方式,避免类依赖那些它们不用的方法。
2. 使用多重继承: 将多个接口使用多重继承方式进行依赖,客户只需要实现它需要的一些接口即可。
接口隔离原则扩展:
ISP中指的接口主要是指一个接口类,但我们可以将其扩展成一组 API 接口集合,
在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。
接口隔离原则运用
接口的设计要尽量单一,细粒度,不强迫调用者依赖其不会被用到的接口。通过调用者如何使用接口来间接地划分成不同的细粒度的接口。
ISP总结
ISP是一个针对接口设计非常重要的原则,特别是现在大量使用微服务系统结构的今天,服务化接口设计更是需要参考接口隔离原则,并且此原则在重构中用处也会起到很大作用。
3.5 依赖倒置原则(Dependency Inversion Principle,DIP)
Robert C. Martin 对依赖倒置原则的定义由两部分组成:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions.
翻译为中文就是:
a. 高层模块不应该依赖于低层模块。二者都应该依赖于抽象。
b. 抽象不应该依赖于细节。细节应该依赖于抽象。
这里的倒置是指相对于传统的过程化设计而言,传统的过程化设计所创建的依赖关系结构,策略是依赖于细节的。而DIP认为,应该倒置这种关系,使得细节和策略都依赖于抽象,并且常常是一些接口。同时这里也有对接口所有权的倒置,应用DIP,
会使客户拥有抽象接口,而服务提供者负责去实现这些接口。这样可以创建一个更灵活,持久易改变的结构。
DIP可以简单理解为,代码设计上尽量将高层模块和低层模块通过抽象来互相依赖,与实际实现细节隔离,使高层模块完全独立于低层模块,这样任何实现细节的变动都不会影响高层模块,高层模块也非常容易通用。
怎么应用DIP?
找出高层模块与低层模块之间依赖的抽象,往往是那些不随着具体细节改变而改变的行为,进行抽象后,我们可以通过动态多态性和静态多态性进行实现这种DIP。
DIP总结
DIP也是一个非常低层的原则,也被认为是框架设计的核心原则,它对于构建在变化面前富有弹性的代码是非常重要的。一般认为符合DIP的设计可以认为是面向对象设计,反之不符合DIP的设计可以认为是过程化设计。
四 其他常用原则
日常中除了SOLID之外,还有很多常用的设计原则,这里选择一些比较常用的进行介绍。
4.1 迪米特原则(Law of Demeter,LOD)
LOD又被叫作最小知识原则 即The Least Knowledge Principle。描述如下:
Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.
翻译如下:
每个模块只应该了解那些与它关系密切的模块的有限知识。或者说,每个模块只和自己的朋友“说话”,不和陌生人“说话”。
LOD主要是用来指导类或模块之间的耦合关系的设计,简单理解就是不同模块之间不应该有直接依赖关系,如果需要有依赖关系,那应该尽量只依赖必要的抽象。
LOD是希望减少模块之间的耦合,让模块越独立越好。每个类都应该尽量少依赖系统的其他部分,这样可以尽量减少类的变化。
LOD原则影响也非常大,经常和SOLID并列去做介绍,其实LOD和 SRP挺像的,但SRP更多的是从自身提供的功能出发,而LOD则是更多的处理模块之间的关系。
4.2 YAGNI 原则
YAGNI 原则是:You Ain’t Gonna Need It 的简写。意思也很明确:你不会需要它。
简单理解就是不要做过度设计,日常开发的时候不要去设计当前用不到的功能,不要去编写当前用不到的代码。比如上面封闭原则说过对预测变化进行设计的事情,如果对未发生的变化做过多的设计,后续变化未按照预期发展时,反而会引起
问题,所以不要对当前未发生的变化做过多的设计,当前不需要的就尽量不要去做。
4.3 DIY原则
DIY原则是Don’t Repeat Yourself. 的简写,意思是不要重复。
我们一般理解的重复代码是两段代码相同或非常类似,但实际上重复可以分为逻辑重复、功能语义重复和代码执行重复。
1.逻辑重复:这种重复就是我们常见的代码重复,代码逻辑完全相同,但如果代码不同也有可能逻辑完全相同,这都算重复。
2.功能语义重复:如果两段代码的实现逻辑不重复,但是功能重复,那么也是属于重复代码,比如有两个校验身份证号的方法,虽然实现方式逻辑完全不同,但功能都是一样的,都是校验身份证号是否合法,那这两个方法就是重复的。
3.代码执行重复:如果同一段代码或者一个方法,在一次请求中被多次重复调用,那也可以认为是重复的,不过属于是执行重复。
对于违反DIY原则的重复代码,不同的重复方式处理方法不一样,但一般可以按照下述方式进行处理:
逻辑重复的代码,我们可以通过抽象成更细粒度函数的方式来解决。
功能语义重复的代码如果没有其他特殊用处,那就可以选择删除其中一个重复的代码。
执行重复则需要梳理调用链,尽量禁止重复调用。
4.4 KISSS原则
kiss原则是 Keep It Simple and Stupid. 的简写,意思也很明确:尽量保持简单。
同样的功能,实现方式可以有很多种,但如果选择使用尽量简单的实现,往往代码的可读性和可维护性都会很好,kiss原则就是要求我们在实现基本功能的前提下,尽量保持代码简单,越简单的代码越容易理解,也方便后续维护。
代码是否简单是一件很主观的事情,每个人看法都不一样,比如如果代码中运用了大量的设计模式,熟悉设计模式的人会觉得简单,而不熟悉的人看着就很费劲。而且对于实现复杂业务的代码,还想要尽量保持代码简单可不是一件容易的
事情,所以kiss原则虽然看起来很简单,但想在日常设计中做好还是一件比较难的事情。
但有些共性的问题还是可以提取出来探讨,比如我们可以遵守以下一些规范:
- 尽量不要使用同事可能不懂的技术来实现代码,如果非要使用最后写好注释,如有必要可以内部做个培训。
- 要善于使用已经有的工具类库,尽量不要自己重复去造轮子。一般自己去实现这些类库,出 bug 的概率会更高,而且有时也难以理解,容易被误用。
- 不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
- 进行复杂度检测,复杂度过高的代码不可提交。
4.5 组合/聚合复用原则 (Composite/Aggregate Reuse Principle,CARP)
CARP,是指使用对象组合(HAS-A)/ 聚合(Contanis-a)而不是继承来达到软件多路复用的目的。它可以使系统更加灵活,减少类与类之间的耦合,尽量使一个类的变化对其他类的影响相对较小。
组合和聚合
这里先介绍下组合和聚合,组合和聚合都是一种类之间的关联关系,一般都用来表示整体与部分的关系。但聚合一种较为松散的关系,部分和整体的生命周期未必一致,比如公司和员工的关系,公司没了员工还是可以作为一个独立的
个体存在。而组合则是一种更加紧密的关系,组合中部分和整体的生命周期必须一致,整体一旦消失,部分也将会同时消失,比如公司和部门的关系,如果公司没了,那么公司下部门也将消失。
在组合关系中,部分的实例化过程会在整体中进行。而聚合关系中,部分的实例化过程在整体外进行,然后通过某种方式注入给整体。
为什么少用继承?
继承是面向对象基本特性之一,可以解决代码复用问题,但是也会带来诸多问题,比如继承层级过深,继承关系过于复杂,导致代码可读性变差,难以维护。而且继承也会导致基类的实现细节暴露给子类,破坏类的封装性,基类和子类
关联较深,如果基类的实现发生了改变,则子类的实现也不得不改变等等。所以实际开发中,尽量使用组合或聚合方式来替代继承。
CARP总结
代码设计中,类与类之间的关系,我们应该尽量坚持CARP原则,这样可以降低类与类之间的耦合程度,提高系统的灵活性。但我们也要注意,不可为了替换继承而去完全不用继承,这就属于过分使用CARP原则了,如果是继承结构稳定,
层次不深,还是可以继续使用继承的。CARP原则替换继承,是需要进行更细粒度的拆分,定义出更多的类,如果为了把一些简单的继承关系改成聚合或组合形式,反而是增加了代码的复杂度。
4.6 包设计原则
前面说的设计原则都是主要针对类的,但实际开发中,我们不仅需要维护类这一维度的关系,还需要维护多个类组成的一个包,或者说一个大的模块。
包内的设计原则一般分为内聚性和耦合性两类:
其中内聚性有以下几个设计原则:
REP(重用发布等价原则),重用的粒度就是发布的粒度
CCP(共同重用原则),包中所有类都应该是共同重用的,相互之间没有紧密联系的类不应该放到一个包中。
CRP(共同封闭原则),包中所有类对同一类性质的变化应该是共同封闭的,一个变化应该对所有类产生影响,而不对其他包产生影响。
耦合性包含以下几个设计原则:
ADP(无依赖原则),包的依赖关系中不允许存在环。细节不应该被依赖
SDP(稳定依赖原则),朝着稳定方向去依赖。
SAP(稳定抽象原则),一个包的抽象程度应该和其他稳定程度一致。
包设计原则可以参考PPP,这里就不过多介绍。
4.7 NO ELSE 原则
NO ELSE 原则,简单来说就是尽量做到消除else语句。
if else 是程序语言的基本结构之一,使用频率非常高,但是如果不能很好地使用,嵌套过深,将会使代码结构变的极为复杂,难以理解。所以日常开发时我们应该尽量遵守NO ELSE原则,如果我们能够尽量减少或不写else,将极大降低
if else嵌套过深而带来的代码复杂度问题,便于代码的理解和维护。
而else语句,也可以有很多方式代替,比如使用些卫语句,封装分支逻辑到单个方法等等。
NO ELSE 原则,虽然可以降低代码复杂度,但使用时也需要注意平衡,尽量是在不会造成增加过多代码或导致难以理解的前提下,做到替换else分支。而不可为了替换else语句增加大量代码,或者将代码改造的难以理解,这就本末倒置了。
五 总结
设计原则本质上就是基于长期代码设计经验的总结,我们可以充分利用这些前人总结的设计原则去进行日常代码设计,这些设计原则我们需要做到不仅仅只是了解,而是要结合日常工作实际运用到日常代码设计中,并结合自己的实践经
验不断总结加深理解。我们也可以根据自己实际工作经验总结出自己的一些小的设计原则,或者说小的技巧。比如上面介绍的NO ElSE原则其实就是我结合日常代码总结出来的,并一直在代码中去实践运用。
同时本文介绍的设计原则都有自己各自的应用场景和适用范围,所以就要像YAGNI 原则说的那样,不要去为了运用设计原则而去过度使用设计原则,我个人认为,设计原则在代码初始设计时很有用处,但是在重构中其实用处更大,如同
Robert C. Martin所说的那样,我们可以尽量不应对一个庞大的预先设计应用这些设计原则和模式,而是将这些原则和模式应用在一次次的迭代中,力度使代码及设计保持干净。
参考书籍或文章:
1. PPP
2. 设计与模式之美