SOLID设计原则JavaScript描述

一刷看热闹,二刷看门道。看不懂的东西,看着看着就懂了;看懂的东西,看着看着就通了。

编程范式概述

在说 SOLID 原则前,还是有必要先了解下程序的编写模式,即,编程范式。如果说设计原则是为了更好得组织代码,那么,编程范式就是为了让我们有选择得使用不同的代码结构。

到目前为止,编程范式有:结构化编程,面向对象编程和函数式编程。编程范式的每次变革都对后世产生了深远的影响。

在「程序员」出现之前,编程是不受待见的。一个证明不了自身的玩意,如何让别人信服。Dijkstra 希望通过数学推导的方法来证明程序的正确性。

不过在此之前,Bohm 和 Jocopini 证明了顺序结构、分支结构和循环结构组合在一起可以构造出任何程序。

这也证明了构建可推导模块所需要的控制结构集与构建所有程序所需要的控制结构集的最小集是等同的。

在这里结构化编程算是诞生了,接下来 Dijkstra 只要证明这些结构的正确性及串联起这些结构所用代码的正确性,就可以推导出整个程序的正确性。最终,在结构化编程范式下,通过数学证明和科学证伪推导出了当下程序的正确性。

结构化编程范式中最有价值的地方就是,它赋予了我们创造可证伪程序单元的能力。

再说说面向对象编程,什么是面向对象编程,耳熟能详的就是面向对象编程的三大特性:封装、继承和多态。这些特性并非面向对象编程语言所独有,有的特性甚至不是面向编程语言的强项,唯一值得说的特性也就只有多态了。在面向编程语言里,多态的使用变得更加安全、更加方便了。

对于面向编程的解释,有说是「面向对象编程是一种对真实世界进行建模的方式」,我觉得更贴切的说法是,「面向对象是一种对人类生活的世界进行建模的方式」。在结构化编程范式中,验证大型系统的正确性,可以通过将大型系统拆分成模块和组件,进而拆分成更小、可证明的函数并对其进行测试。现在,我们再把这个过程反过来看,这些小函数或模块、组件,又是如何高效组合成一个大型的系统呢?在这一点上,人类很擅长。

我们都知道,国外有很多唐人街,而且有些唐人街存在很久了,即使住在唐人街的华人从未来过中国,但并不妨碍与土生土长的中国人进行交流。不管相隔的多远,繁衍了多少代,只要大家还有对中华文明的认同,就不会出现无法沟通的情况。这种认同感不仅可以跨越时间和空间,更是在需要的时候凝聚力量,由此次疫情(新冠肺炎)可见一斑。

面向对象中的多态,更像是不同组件对于某种契约的认同。而基于这种认同,我们可以实现依赖反转,摆脱控制流决定源代码依赖关系的现象。

面向对象编程就是以多态为手段来对源代码的依赖关系进行控制的能力,这种能力让软件架构师可以构造出某种插件式架构,让高层策略性组件与底层实现性组件分离,底层组件可以被编译成插件,实现独立与高层组件的开发和部署。

至于函数式编程,它所遵循的原理倒是很简单:函数式编程语言中的变量是不可变的。

仅从读《JavaScript 轻量级函数式编程》中,我们就可以看到变量的可变性带来的一些危害。鉴于此,把应用程序可分为可变和不可变的部分,将逻辑重点放在不可变组件中,并用合适的机制保护可变量。

事件溯源就很好体现出了面向函数编程的思维。

最后再用文中的话总结下三大编程范式:

多态是我们跨越架构边界的手段,函数式编程是我们规范和限制数据存放位置与访问权限的手段,结构化编程则是各种模块的算法实现基础。

这和软件架构的三大关注重点不谋而合:功能性、组件独立性以及数据管理。

设计原则

通过上文,我们已经知道大型的系统可以拆分为更小的函数。现在我们需要做的是,将函数和数据结构组合成模块,模块聚合成组件,组件耦合成系统。不论是模块,还是组件,抑或是其它的数据与函数的结合体,它们在某一层面上都是最小的单元。

如何高效的组装它们,就需要一个设计原则来指导它们达成最佳实践。这种思想,没有具体的模式却有明确的目标:

  • 使软件可容忍被改动。
  • 使软件更容易被理解。
  • 构建可在多个软件系统中复用的组件。

在上文中,我们把编程世界类比成了人类世界。人作为社会中的人,不仅要演好自己本职角色,同时也要扮演好社会赋予的角色。所以,我们经常会听到「人在江湖,身不由己」这样的感概。而在系统中,作为不同意义上的单元,也要处理好单元内部和单元之间的问题,在这里,更多的是指处理好单元间的依赖关系。用人话说,这种「依赖」多少有点「牵连」的意思。

设计原则是什么先不说,我们先谈谈依赖关系的重要性。(以下论述从组件的角度,依赖关系大同小异)

关于组件的依赖关系并不是从一开始就需要关注的,组件依赖关系更注重的是应用程序的构建性和维护性。如果一个软件系统自搭建以来从未发生过变化,以后也不会发生变化,我们也没必要理会所谓的设计原则,即使你用了这些最佳实践,意义又何在呢。

  • 无依赖环原则:组件依赖关系图中不应该出现环。
  • 稳定依赖原则:依赖关系必须要指向更稳定的方向。
  • 稳定抽象原则:一个组件的抽象化程度应该与其稳定性保持一致。

无依赖环原则:当组件耦合在一起时,是不应该出现循环依赖关系的,这个循环中的组件会构成一个更大的组件,对于构建发布是不利的。

稳定依赖原则:一个可维护的系统应该包含可变的和稳定的两部分,而且可变的应该依赖于稳定的。这种依赖是一种约束也是一种信任,反过来想想,如果不稳定的组件被稳定的组件所依赖,就意味着不稳定元素必须趋于稳定才能保证稳定组件的稳定,这种可变也将会变得难以修改。换句话说,一个组件被越多的组件所依赖,就越需要保证该组件稳定性。

稳定抽象原则:这就要求稳定的组件也应该是抽象的,可变的组件也应该是具体的。再结合上一条原则,就可以概括为,依赖关系应该指向更抽象的方向。

以上内容是从组件耦合的角度对组件提出的要求,接下来回到主题。

单一职责原则

任何一个软件模块都应该只对一类行为者负责。

对于我们来说,最熟悉的就是单一职责原则了,一个函数只完成一个功能,这是单一职责在实现细节上的体现,但是这并不代表单一职责的全部。

我们先把软件模块当成数据与函数的结合体,刚开始时,当一个软件模块对多类行为者负责时,说明该软件模块能够兼容多类行为者的功能。当多类行为者的差异愈加明显时,该软件模块也会愈加频繁的调整。从稳定依赖原则中,我们已经知道,「一个组件被越多的组件所依赖,就越需要保证该组件稳定性。」

同时,这个原则也指导我们要不要去复用一个软件模块。这个软件模块对哪类行为负责?将要依赖该模块的软件模块是否属于该类行为?现在流行的微服务架构,根据不同领域的划分,即使有共同的模块,也不见得会复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const Employee = {
regularHours(hour) {
let fakeHour = hour * 1.1;
return fakeHour;
},
CFOHours() {
console.log(`我是 CFO 的人,不服来战!工作时长为:${this.regularHours(6)}`)
},
COOHours() {
console.log(`我是 COO 的人,谁敢不服!工作时长为:${this.regularHours(6)}`)
}
}

// CFO
Employee.CFOHours();

// COO
Employee.COOHours();

// 我是 CFO 的人,不服来战!工作时长为:6.6000000000000005
// 我是 COO 的人,谁敢不服!工作时长为:6.6000000000000005

/*
这个模块中有 `CFOHours` 和 `COOHours`,而且这两个行为共享 `regularHours`,
如果 CFO 的人在 COO 不知情的情况下改了 `regularHours`,定会影响到 COO。

要想避免这种情况,就应该将不同类的行为分开。
*/

const Employee = {
regularHours(hour) {
let fakeHour = hour * 1.1;
return fakeHour;
}
}

const CFO = Object.create(Employee);
CFO.regularHours = function (hour) {
let fakeHour = hour * 1.2;
return fakeHour;
};
CFO.CFOHours = function () {
console.log(`我是 CFO 的人,不服来战!工作时长为:${this.regularHours(6)}`)
};
CFO.CFOHours();

const COO = Object.create(Employee);
COO.COOHours = function () {
console.log(` 我是 COO 的人,谁敢不服!工作时长为:${this.regularHours(6)}`)
}
COO.COOHours();
// 我是 CFO 的人,不服来战!工作时长为:7.199999999999999
// 我是 COO 的人,谁敢不服!工作时长为:6.6000000000000005

接口隔离原则

接口隔离原则(英语:interface-segregation principles, 缩写:ISP)指明客户(client)应该不依赖于它不使用的方法。接口隔离原则(ISP)的目的是系统解开耦合,从而容易重构,更改和重新部署。

从字面意思上看,单一职责是从软件模块开发者的角度出发,接口隔离则是从软件模块使用者的角度去考虑。如果说单一职责是对某一类行为的划分,而这行为的划分粒度就要取决于模块的使用者。

作为前端开发者,违背接口隔离原则的反例也不少见。在项目开发中,经常会使用第三方库,有时候即使只是使用了该库的极少数方法,也需要安装该库的全部依赖,而在安装第三方库时,经常会遇到该库依赖包版本不兼容的问题导致整个项目启动崩溃,这个不兼容的依赖包所提供的方法可能压根不会用到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/* 
`CFOHours` 和 `COOHours` 都是对各自行为者负责,但是对于公司来说,这些又都是公司自己内部的行为。
本来不同类的行为也会因为使用者的不同而耦合在一起,即使如此,上面的危害依然存在,此时就需要隔离了。
*/

const ICFO = {
regularHours() {
console.log('ICFO接口:regularHours')
},
CFOHours() {
console.log('ICFO接口:CFOHours')
}
}

const ICOO = {
COOHours() {
console.log('ICOO接口:COOHours')
}
}

const Employee = {
regularHours(hour) {
let fakeHour = hour * 1.1;
return fakeHour;
},
CFOHours(obj) {
if (Object.getPrototypeOf(obj) == ICFO) {
obj.CFOHours(this);
}
},
COOHours(obj) {
if (Object.getPrototypeOf(obj) == ICOO) {
obj.COOHours(this);
}
}
}

const CFO = Object.create(ICFO);
CFO.regularHours = function (hour) {
let fakeHour = hour * 1.2;
return fakeHour;
}
CFO.CFOHours = function (self) {
console.log(`我是 CFO 的人,不服来战!工作时长为:${this.regularHours(6)}`)
}
Employee.CFOHours(CFO)


const COO = Object.create(ICOO);
COO.COOHours = function (self) {
console.log(`我是 COO 的人,谁敢不服!工作时长为:${self.regularHours(6)}`)
}
Employee.COOHours(COO)

// 我是 CFO 的人,不服来战!工作时长为:7.199999999999999
// 我是 COO 的人,谁敢不服!工作时长为:6.6000000000000005

开闭原则

开闭原则规定“软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的”,这意味着一个实体是允许在不改变它的源代码的前提下变更它的行为。
设计良好的计算机软件应该易于扩展,同时抗拒修改。

这表示一个良好的系统应该是稳定的、灵活的。所谓稳定,不会因为需求小小的变动而导致系统大幅度修改。与其说抗拒修改,倒不说系统对修改的容忍程度比较高,这从组件耦合原则中可看出一二。而灵活则要求一个良好的软件系统应该在不需要修改的前提下可以轻易扩展。

开闭原则在理念上可以划分为两个派别,梅耶开闭原则和多态开闭原则。(本次只谈谈对多态开闭原则的理解)

梅耶开闭原则:梅耶的定义提倡实现继承。具体实现可以通过继承方式来重用,但是接口规格不必如此。已存在的实现对于修改是封闭的,但是新的实现不必实现原有的接口。
多态开闭原则:多态开闭原则的定义倡导对抽象基类的继承。接口规约可以通过继承来重用,但是实现不必重用。已存在的接口对于修改是封闭的,并且新的实现必须,至少,实现那个接口。

系统中包含着变化的和不变的两部分,把不变的封装起来,把变化的隔离起来。而对于变化的部分也可以求同存异,把变化中的相同部分与封装起来的不变部分约定一下,就成了实现多态的必备因素 —— 契约。实际上,这个契约应该由高级组件来定义,低级组件去遵守的,在依赖反转中有更详细的要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
var IHours = {
logHours() {
console.log('IHours接口:IHours')
}
}

var Employee = {
regularHours(hour) {
let fakeHour = hour * 1.1;
return fakeHour;
},
logHours(obj) {
if (Object.getPrototypeOf(obj) == IHours) {
obj.logHours(this);
}
}
}

var CFO = Object.create(IHours);
CFO.regularHours = function (hour) {
let fakeHour = hour * 1.2;
return fakeHour;
}
CFO.logHours = function (self) {
console.log(`我是 CFO 的人,不服来战!工作时长为:${this.regularHours(6)}`)
}
Employee.logHours(CFO)


var COO = Object.create(IHours);
COO.logHours = function (self) {
console.log(`我是 COO 的人,谁敢不服!工作时长为:${self.regularHours(6)}`)
}
Employee.logHours(COO)

// 我是 CFO 的人,不服来战!工作时长为:7.199999999999999
// 我是 COO 的人,谁敢不服!工作时长为:6.6000000000000005

里氏替换原则

“派生类(子类)对象可以在程序中代替其基类(超类)对象。”,这项原则的意思是如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件你可以相互替换。

如果这条原则重点是在强调可替换,那就和上一条原则差不多了,这个暂时放一放。如果是说关于继承的,个人的理解是,如果子类需要覆写父类,也就没有必要从该父类去继承。

依赖反转原则

依赖反转原则(Dependency inversion principle,DIP)是指一种特定的解耦形式,使得高层次的模块不依赖于低层次的模块的实现细节,依赖关系被颠倒(反转),从而使得低层次模块依赖于高层次模块的需求抽象。

关于这条原则,之前的博文有专门的写过,这里也不提了。见DIP、IoC、DI、JS

结语

以上即全部内容,关于代码实现没有使用类而是基于行为委托,就是因为简单省事。设计原则就是一种思想,思想应该是灵活的,而不应该拘泥于任何一种表现形式。

参考:

------------- The End -------------
显示评论