设计模式之SOLID原则
1. 概述
二十多种设计模式的遵循的原则,简称为SOLID原则,通常是的是以下五大原则:
- S - 单一职责原则(Single Responsibility Principle)
- O - 开闭原则(Open/Closed Principle)
- L - 里氏替换原则(Liskov Substitution Principle)
- I - 接口隔离原则(Interface Segregation Principle)
- D - 依赖倒置原则(Dependency Inversion Principle)
有的资料中描述为六大原则,较上面相比新加入了:
- D: 迪米特法则(Law of Demeter)
又有的资料描述为七大原则,较六大原则而言新加入了:
- 组合/聚合复用原则 (Composite/Aggregate Reuse Principle)
2. 五大原则
2.1 单一职责原则
2.1.1 阐述
一个类(Class)、函数(Function)甚至是模块(Module)的功能尽可能精简,甚至是只能有一个
2.1.2 目的
减少复杂度
2.1.3 口语化举例
一个200行代码实现的复杂功能,可以使用几个功能精简而且清晰的简单函数去组合实现
在日后修改和维护这个功能时,往往更容易读懂,更容易修改(只需修改一两个简单函数)
2.1.4 代码示例
现实现一个计算函数,会根据运算提示词进行四则运算
function calculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
switch(operator) {
case '+':
return num1 + num2;
case '-':
return num1 - num2;
case '*':
return num1 * num2;
case '/':
return num1 / num2;
default:
return 'Invalid operator';
}
}
// Examples
calculate(1, 2, '+'); // 3
calculate(5, 2, '-'); // 3
calculate(10, 5, '*'); // 50
calculate(20, 5, '/'); // 4
上述函数虽然一个函数就解决了四则运算,但是这个一个函数略显复杂,现拆分为多个个精简而清晰的函数
function calculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
switch(operator) {
case '+':
return add(num1, num2);
case '-':
return subtract(num1, num2);
case '*':
return multiply(num1, num2);
case '/':
return divide(num1, num2);
default:
return 'Invalid operator';
}
}
function add(num1, num2) {
return num1 + num2;
}
function subtract(num1, num2) {
return num1 - num2;
}
function multiply(num1, num2) {
return num1 * num2;
}
function divide(num1, num2) {
if (num2 !== 0) {
return num1 / num2;
} else {
return 'Invalid division';
}
}
这里将四则运算的实现方法抽离出来了
优点是:
- 更易看懂(通常情况下)
- 可以复用四则运算方法
- 每个函数的功能更为精简而清晰
缺点是:
- 代码量增加,有时反而觉得变得更加复杂
- 拆分需要消耗时间和精力
2.2 开闭原则
2.2.1 阐述
一个类(Class)、函数(Function)甚至是模块(Module)的功能对于扩展“开放”,对于修改则应是“封闭”
2.2.2 目的
在实现新功能时能保持已有代码不变
2.2.3 口语化举例
在团队开发协作中,需要在已有功能基础上添加新功能,通常我们希望不直接修改原来的代码,而是能够将新增的代码合并进去
再口语化一点,我们希望能保留之前写的代码(即封闭修改),同时又能较为容易的加入后来写的代码(即开放扩展)
2.2.4 代码示例
现实现一个计算函数,会根据运算提示词进行四则运算
function calculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
switch(operator) {
case '+':
return num1 + num2;
case '-':
return num1 - num2;
case '*':
return num1 * num2;
case '/':
return num1 / num2;
default:
return 'Invalid operator';
}
}
// Examples
calculate(1, 2, '+'); // 3
calculate(5, 2, '-'); // 3
calculate(10, 5, '*'); // 50
calculate(20, 5, '/'); // 4
上述函数虽然一个函数就解决了四则运算,但是这个函数接收的运算只能是加减乘除,现改为可以接受其他运算符的函数
const operators = {
'+': (num1, num2) => num1 + num2,
'-': (num1, num2) => num1 - num2,
'*': (num1, num2) => num1 * num2,
'/': (num1, num2) => num1 / num2,
};
function calculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
if (operator in operators) {
return operators[operator](num1, num2);
} else {
return 'Invalid operator';
}
}
这里将运算符以及对应的实现方法抽离出来了
优点是:
- 开放扩展(添加新的运只需要在
operators
里添加新的运算符以及对应的实现方法) - 封闭修改(较之前的函数而言这里的
calculate
函数无需修改)
缺点是:
- 为了能兼容其他运算符不得不进行函数抽象提取,有时反而觉得变得更加复杂
- 拆分需要消耗时间和精力
2.3 里氏替换原则
2.3.1 阐述
扩展一个类(Class)、函数(Function)甚至是模块(Module)的功能需要保持其兼容性,基类实现的功能扩展的子类也应当实现
2.3.2 目的
保持兼容性
2.3.3 口语化举例
有一个编辑器软件的某一模块,是实现JavaScript代码语法检查,现欲添加支持TypeScript代码语法检查,那么一个比较好的思路是在原有支持JavaScript语法检查的基础上添加TypeScript支持(即兼容原有功能),而不是修改为只支持TypeScript
2.3.4 代码示例
现实现一个计算函数,会根据运算提示词进行四则运算
function calculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
switch(operator) {
case '+':
return num1 + num2;
case '-':
return num1 - num2;
case '*':
return num1 * num2;
case '/':
return num1 / num2;
default:
return 'Invalid operator';
}
}
// Examples
calculate(1, 2, '+'); // 3
calculate(5, 2, '-'); // 3
calculate(10, 5, '*'); // 50
calculate(20, 5, '/'); // 4
上述函数虽然一个函数就解决了四则运算,但是这个函数接收的运算只能是加减乘除,现改为扩展为一个能接受其他运算符的函数
function newCalculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
switch(operator) {
case '+':
return num1 + num2;
case '-':
return num1 - num2;
case '*':
return num1 * num2;
case '/':
return num1 / num2;
case '//':
return num1 / num2 / num2;
default:
return 'Invalid operator';
}
}
这里添加了一个//
运算符(自定义的)
优点是:
- 兼容基类(原来支持的运算现在也支持)
缺点是:
- 代码量增加(因为是在原有基础上扩展)
2.4 接口隔离原则
2.4.1 阐述
扩展的类(Class)、函数(Function)甚至是模块(Module)不应出现其不使用的方法
2.4.2 目的
简化扩展后的代码
2.4.3 口语化举例
为了保持兼容性,在扩展一个原有基类时我们会(甚至是不得不)实现其原定义的所有方法,如果有的方法是用不到的,这就会显得代码冗余
再举一个例子,有一个编辑器软件的某一模块,是实现JavaScript代码语法检查和代码风格检查,现欲添加支持TypeScript代码语法检查而不需要代码风格检查,需求如此所以就应该只需要TypeScript代码语法检查而不需要代码风格检查,但是由于我们扩展的这个模块规定了需要实现代码风格检查,所以不得不实现代码风格检查,但这是冗余的功能,不是我们需要的
解决办法之一就是拆分原有基类,扩展的新类只需继承需要的那几个基类即可
2.4.4 代码示例
现实现一个计算函数,会根据运算提示词进行四则运算
function calculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
switch(operator) {
case '+':
return num1 + num2;
case '-':
return num1 - num2;
case '*':
return num1 * num2;
case '/':
return num1 / num2;
default:
return 'Invalid operator';
}
}
// 扩展的新函数(必须实现原有的功能)
function newCalculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
switch(operator) {
case '+':
return num1 + num2;
case '-':
return num1 - num2;
case '*':
return num1 * num2;
case '/':
return num1 / num2;
case '//':
return num1 / num2 / num2;
default:
return 'Invalid operator';
}
}
上述函数虽然一个函数就解决了四则运算,但是扩展这个函数就必须实现加减乘除,现拆分为四个基函数,并扩展出一个新的calculate
函数
function add(num1, num2) {
return num1 + num2;
}
function subtract(num1, num2) {
return num1 - num2;
}
function multiply(num1, num2) {
return num1 * num2;
}
function divide(num1, num2) {
if (num2 !== 0) {
return num1 / num2;
} else {
return 'Invalid division';
}
}
// 扩展的新函数(必须实现原有的功能,因为扩展的加减函数,所以只需实现加减功能)
function newCalculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
switch(operator) {
case '+':
return add(num1, num2);
case '-':
return subtract(num1, num2);
case '//':
return num1 / num2 / num2;
default:
return 'Invalid operator';
}
}
这里新增加的newCalculate
函数就不那么冗余
优点是:
- 精简代码,减少冗余
缺点是:
- 拆分基类会增加代码量(所以不建议过度拆分)
2.5 依赖倒置原则
2.5.1 阐述
上层模块不应该依赖底层模块,它们都应该依赖于抽象
抽象不应该依赖于细节,细节应该依赖于抽象
2.5.2 目的
保持上层模块与底层模块的相对独立
2.5.3 口语化举例
在现在流行的前后端分离的开发模式下,前端与后端开发人员会在开始前约定接口(即API),然后前后端可以开始同时开发,前端开发人员无需等待后端开发好即可预知后端数据API,而前端开发好后,后端修改也无需前端再次修改,只需保证API与原来一致即可
可以理解为,前端就是上层模块,它不关注后端怎么实现的,只需要符合约定的API即可,后端是底层模块,它怎么实现都行,但是要符合约定的API,前端和后端相互独立
如果没有约定API,那么前端接收到什么数据由后端决定,这就是上层模块依赖底层模块,而API约定就是实现依赖倒置
2.5.4 代码示例
现实现一个计算函数,会根据运算提示词进行四则运算
function calculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
switch(operator) {
case '+':
return num1 + num2;
case '-':
return num1 - num2;
case '*':
return num1 * num2;
case '/':
return num1 / num2;
default:
return 'Invalid operator';
}
}
现将上述calculate
函数改为由外部operators
决定具体运算
const operators = {
'+': (num1, num2) => num1 + num2,
'-': (num1, num2) => num1 - num2,
'*': (num1, num2) => num1 * num2,
'/': (num1, num2) => num1 / num2,
};
function calculate(num1, num2, operator) {
num1 = parseFloat(num1);
num2 = parseFloat(num2);
if (operator in operators) {
return operators[operator](num1, num2);
} else {
return 'Invalid operator';
}
}
这里将运算符以及对应的实现方法抽离出来了,具体的运算由外部operators
实现
优点是:
- 相对独立(修改运算方法只需要在
operators
里修改)
缺点是:
- 为了实现相互独立,需要将底层实现与上传调用抽离,有时比较麻烦
3. 六大原则
较五大原则新加入了迪米特法则(Law of Demeter)
3.1 迪米特法则
3.1.1 阐述
只与你的直接朋友交谈,不跟“陌生人”说话
一个类(Class)、函数(Function)甚至是模块(Module)应当尽可能少的与其他实体发生相互作用
3.1.2 目的
降低耦合度,提高模块的相对独立性
3.1.3 口语化举例
军队里面有元帅、军官和士兵,元帅认识军官,军官认识自己管辖的士兵。元帅要攻击敌军,他不必直接对士兵下命令,只需要下命令给自己认识的军官,由军官将指令转发给自己所辖士兵即可。用迪米特法则解释,元帅和军官、军官和士兵是“朋友”,元帅和士兵是“陌生人”,元帅只应该与自己直接的“朋友”——军官说话,不要跟“陌生人”——士兵说话
3.1.4 代码示例
现给出一个四则运算的Calculator
类,它使用外部的加减乘除函数实现四则运算
function add(num1, num2) {
return num1 + num2;
}
function subtract(num1, num2) {
return num1 - num2;
}
function multiply(num1, num2) {
return num1 * num2;
}
function divide(num1, num2) {
if (num2 !== 0) {
return num1 / num2;
} else {
return 'Invalid division';
}
}
class Calculator {
#num1;
#num2;
calculate(num1, num2, operator) {
this.#num1 = parseFloat(num1);
this.#num2 = parseFloat(num2);
switch(operator) {
case '+':
return add(this.#num1, this.#num2);
case '-':
return subtract(this.#num1, this.#num2);
case '*':
return multiply(this.#num1, this.#num2);
case '/':
return divide(this.#num1, this.#num2);
default:
return 'Invalid operator';
}
}
现修改为Calculator
调用内部函数
class Calculator {
#num1;
#num2;
#add() {
return this.#num1 + this.#num2;
}
#subtract() {
return this.#num1 - this.#num2;
}
#multiply() {
return this.#num1 * this.#num2;
}
#divide() {
return this.#num1 / this.#num2;
}
calculate(num1, num2, operator) {
this.#num1 = parseFloat(num1);
this.#num2 = parseFloat(num2);
switch(operator) {
case '+':
return this.#add();
case '-':
return this.#subtract();
case '*':
return this.#multiply();
case '/':
return this.#divide();
default:
return 'Invalid operator';
}
}
}
修改后的类,只调用了内部方法,同时只暴露了calculate
函数给外部
优点是:
- 独立性较高,只调用自己的或者相关的
- 暴露的部分较少,安全性较高
缺点是:
- 只和"朋友"交流会导致"朋友"部分的代码较多
- 高度封闭自己,外部调用有时不方便
4. 七大原则
较六大原则新加入了较上面相比新加入了组合/聚合复用原则 (Composite/Aggregate Reuse Principle)
4.1 组合/聚合复用原则
4.1.1 阐述
优先使用组合或聚合关系,而不是继承关系
4.1.2 目的
提高代码的灵活性和可维护性
4.1.3 口语化举例
一台计算机,是由CPU、内存、输入设备、输出设备和外存等组装而成。计算机对象为整体,CPU、内存、输入设备、输出设备和外存等为部分,它们是聚合关系。如果一台计算机没有打印功能,可以加入一个打印机,使打印机成为计算机的一部分,从而重用打印机的打印功能。换一种角度来看,如果需要计算机有打印的责任,那么就可以将该责任委托给作为部分的打印机
4.1.4 优缺点
使用组合/聚合实现复用有如下好处:
- 新对象存取成分对象的唯一方法是通过成分对象的接口
- 这种复用是黑箱复用,因为成分对象的内部细节是新对象所看不见的
- 这种复用所需的依赖较少
- 每一个新的类可以将焦点集中在一个任务上
- 这种复用可以在运行时间内动态进行,作为整体的新对象可以动态地引用与部分对象类型相同的对象。也就是说,组合/聚合是动态行为,即运行时行为。可以通过使用组合/聚合的方式在设计上获得更高的灵活性
当然,这种复用也有缺点。其中最主要的缺点就是系统中会有较多的对象需要管理
继承是面向对象语言特有的复用工具。由于使用继承关系时,新的实现较为容易,因父类的大部分功能可以通过继承的关系自动进入子类;同时,修改和扩展继承而来的实现较为容易。于是,在面向对象设计理论的早期,程序设计师十分热衷于继承,好像继承就是最好的复用手段,于是继承也成为了最容易被滥用的复用工具。然而,继承有多个缺点:
- 继承复用破坏封装,因为继承将父类的实现细节暴露给子类。由于父类的内部细节常常是对于子类透明的,所以这种复用是透明的复用,又称“白箱”复用
- 如果父类发生改变,那么子类的实现也不得不发生改变
- 从父类继承而来的实现是静态的,也就是编译时行为,不可能在运行时间内发生改变,没有足够的灵活性
正是因为继承有上述缺点,所以应首先使用组合/聚合,其次才考虑继承,达到复用的目的
5. 参考资料
[1] 六大设计原则(SOLID) - 简书 (jianshu.com)