设计模式之SOLID原则

设计模式,solid,原则 · 浏览次数 : 2

小编点评

**六大原则** * **单一职责原则** (Single Responsibility Principle) * **封装原则** (Encapsulation Principle) * **继承原则** (Inheritance Principle) * **组合原则** (Composite Principle) * **聚合原则** (Aggregate Principle) **7.迪米特法则** * 指导程序员设计一个算法时,应该首先考虑该算法能如何被其他算法实现。 **其他** * **组合/聚合** 是动态行为,即运行时行为。 * **继承** 多个缺点:继承复用破坏封装,因为继承将父类的实现细节暴露给子类。 * **组合/聚合** 是面向对象设计理论的早期,程序设计师十分热衷于继承,好像继承就是最好的复用手段,于是继承也成为了最容易被滥用的复用工具。然而,继承有多个缺点:继承复用破坏封装,因为继承将父类的实现细节暴露给子类。

正文

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)

[2] 面向对象基础设计原则:7.迪米特法则 - 知乎 (zhihu.com)

[3] 面向对象基础设计原则:5.组合/聚合复用原则 - 知乎 (zhihu.com)

与设计模式之SOLID原则相似的内容:

设计模式之SOLID原则

二十多种设计模式的遵循的原则,简称为SOLID原则

设计模式之工厂模式

待填

设计模式之抽象工厂

待填

设计模式之单例模式

单例模式简介 单例模式是一种设计模式,用于确保一个类只有一个实例,并提供全局访问点以获取该实例。它是一种创建型模式,通常用于需要严格控制某个类的实例数量的情况。单例模式确保一个类在整个应用程序生命周期中只有一个实例,因此可以节省系统资源,同时提供了一个集中的访问点,以便在需要时获取该实例。 以下是单

设计模式之工厂模式

工厂模式是一种创建型设计模式,它提供了一个用于创建对象的接口,但允许子类决定实例化哪个类。工厂方法让一个类的实例化延迟到其子类。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。 在软件开发中,对象的创建和使用是常见的操作。然而,对象的创建过程常常会涉及到复杂的逻辑和多变的需求。为了

设计模式之订阅发布模式

# 一、简介 订阅发布模式(Publish-Subscribe Pattern)是一种行之有效的解耦框架与业务逻辑的方式,也是一种常见的观察者设计模式,它被广泛应用于事件驱动架构中。 在这个模式中,发布者(或者说是主题)并不直接发送消息给订阅者,而是通过调度中心(或者叫消息代理)来传递消息。 发布者

设计模式之模板方法模式

# 一、简介 模板方法模式是一种行为型设计模式,它定义一个操作(模板方法)的基本组合与控制流程,将一些步骤(抽象方法)推迟到子类中,在使用时调用不同的子类,就可以达到不改变一个操作的基本流程情况下,即可修改其中的某些特定步骤。这种设计方式将特定步骤的具体实现与操作流程分离开来,实现了代码的复用和扩展

设计模式之不一样的责任链模式

责任链模式(Chain of Responsibility Pattern)是一种行为型设计模式,它通过将请求的发送者和接收者解耦,使多个对象都有机会处理请求。在这个模式中,请求沿着一个处理链依次传递,直到有一个对象能够处理它为止。 本文将详细介绍责任链模式的概述、应用场景以及代码示例,来帮助读者更

设计模式之工厂模式

工厂模式是一种对象创建型模式,它提供了一种创建对象的最佳实践。在工厂模式中,我们在创建对象时不使用 new 关键字,而是通过调用工厂方法来创建对象。工厂方法是一种在子类中定义的方法,该方法负责实例化对象。工厂方法可以返回不同的对象类型,因此工厂模式可以创建一组相关或不相关的对象。这样就可以将对象的创

一文带你读懂设计模式之责任链模式

翻了一下之前刚入职时候的学习笔记,发现之前在熟悉业务代码的时候曾经专门学习并整理过过设计模式中的责任链模式,之前只是对其简单了解过常用的设计模式有哪些,并未结合实例和源码深入对其探究,利用熟悉代码契机进行系统学习并整理文档如下。