设计模式之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原则

设计模式之适配器模式(学习笔记)

定义 适配器模式是一种结构型设计模式,它允许将一个类的接口转换为客户端希望的另一个接口。适配器使得原本由于接口不兼容而不能一起工作的类可以协同工作。通过创建适配器类,可以将现有类的接口转换成目标接口,从而使这些类能够在一起工作。 为什么使用适配器模式 兼容性 适配器模式能够解决由于接口不兼容而无法直

设计模式之抽象工厂模式(学习笔记)

定义 抽象工厂模式是一种创建型设计模式,它提供一个接口,用于创建一系列相关或依赖的对象,而无需指定它们的具体类。抽象工厂模式将对象的创建过程抽象化,允许子类通过实现具体工厂类来定制对象的创建。 为什么使用抽象工厂模式 产品族的一致性 抽象工厂模式确保同一产品族中的对象之间的一致性。 部分遵循开闭原则

设计模式之工厂模式(学习笔记)

定义 工厂方法模式是一种创建型设计模式,它定义了一个用于创建对象的接口,但由子类来决定实例化哪一个类。工厂方法使得类的实例化延迟到子类,这样可以让客户端在不需要知道具体类的情况下创建对象。工厂方法模式通过使用继承和多态性,允许子类来控制对象的创建方式,能够更好地应对对象创建的复杂性和变化性。 为什么

设计模式之简单工厂模式(学习笔记)

定义 简单工厂模式(Simple Factory Pattern)是一种创建型设计模式,它定义一个用于创建对象的接口,但由一个单独的类来实现实际创建的工作。简单工厂模式通过在一个类中集中管理对象的创建过程,可以减少客户端与具体类之间的耦合,使得代码结构更加清晰和易于维护。通过专门定义一个类来负责创建

设计模式之装饰模式(学习笔记)

定义 装饰模式(Decorator Pattern),又称为包装模式,是一种结构型设计模式。它允许在不改变现有对象结构的情况下,动态地添加新的功能。通过将每个功能封装在单独的装饰器类中,并且这些装饰器类通过引用原始对象来实现功能的组合,从而提供了灵活性和可扩展性的优势。装饰模式避免了通过继承方式增加

设计模式之工厂模式

待填

设计模式之抽象工厂

待填

设计模式之单例模式

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

设计模式之工厂模式

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