首页 > 其他分享 >SOLID 原则

SOLID 原则

时间:2023-10-13 15:15:44浏览次数:38  
标签:依赖 原则 SOLID 代码 开闭 Shape drawAll

什么是 SOLID?

在程序设计领域, SOLID 是由 Robert C. Martin(敏捷宣言作者之一,曾任敏捷联盟主席)在21世纪早期提出的便于记忆的首字母缩写,指代面向对象编程和面向对象设计的五个基本原则:单一功能、开闭原则、里氏替换、接口隔离以及依赖反转。在这一章我们只关注这些原则各自的定位和它们之间的关系。

 

概括地讲(下文会具体展开):单一职责是所有设计原则的基础,开闭原则是设计的终极目标。里氏替换原则强调的是子类替换父类后程序运行时的正确性,它用来帮助实现开闭原则。而接口隔离原则用来帮助实现里氏替换原则,同时它也体现了单一职责。依赖倒置原则是过程式设计与面向对象设计的分水岭,同时它也被用来指导接口隔离原则。

 

什么时候应该应用原则?

设计原则和设计模式都是经验的总结,方便分析和解决问题,但并不能用来判断是否存在问题。例如下面这段代码:

 

  • 它违反了单一职责:这个 main 方法即获取了当前日期,又做了日期的处理,数据的写入。这个违反让它更难理解和维护了吗?
  • 它违反了开闭原则:很显然这个逻辑并不是可扩展的,只能通过修改的方式去增加功能。这个违反让它更难修改了吗?
  • 它违反了依赖倒置原则:main函数直接依赖底层模块fs的writeFile方法,调用main时必须和fs模块绑定。这个绑定让它无法在其他 fs 实现里复用,但是会有这个需求吗?

对于一个负责日常工作的、功能不太会扩展的小脚本而言,这段代码是比较完美的。当我们不认为它维护困难时,就不应该对它套用各种原则和模式来把问题复杂化(或过度设计)。当这段代码变得更复杂,我们发现它难以维护时,再去应用原则来诊断和分析问题。有这样的概念后,我们再来看具体的每项原则。

单一职责原则

内容:一个类只负责一件事,只有一个引起它变化的原因。

单一职责希望避免的问题很简单:避免修改一个地方反而引发另一个地方的问题。如果你的项目经常发生这样的问题,或你的 QA 同学经常有这样的质疑,那么应该抱着这个原则去重新审视你的业务逻辑。此外当一个类(或一个函数)足够复杂时,它可读性、健壮性、可复用性一定会存在问题:

  • 可读性差。无论是否有注释,谁都很难把握一个连续好几页的函数到底在做什么。
  • 脆弱。在很难理解一个函数职责的情况下,更改其中的一部分很容易犯错。尤其在存在很多横贯整个函数的变量时,或者存在大量条件分支的情况。
  • 难以复用。把多个职责包裹在一起就像打包销售一样,很少有其他场景恰好有需要完全一样的打包功能。

开闭原则

内容:软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。

笔者认为开闭原则是 SOLID 中最难理解的一项原则:如果一个类/函数对修改是封闭的,那么怎么扩展这个类的功能?这恰好是开闭原则要解决的问题,这个原则尝试去做到:新增功能时,已有的业务代码可以几乎完全保持不变。因为已有的类和函数都没有变更,因此我们才能有信心判断这次变更不会影响其他的功能,这样的设计才是健壮的。

应用单一职责只需要梳理和归纳,而应用开闭原则的关键则在于抽象。我们用一段代码示例来解释应用开闭原则的过程:

 

上述代码事实上没啥问题,但我们要把它想象成它要表达的有问题的代码,比如 shape 的类型不止两种,分支条件不止是 instanceof,每个条件下做的事情也不止一行。那么它就明显有问题了:

  • 脆弱:如果需要新增一种 Shape,我们必须继续更改这个函数,并继续新增条件语句。
  • 僵化:如果需要新增一种 Shape(比如 Triangle),我们必须改很多份代码(参考反模式 散弹枪手术),新增一个 drawTriangle 方法,再去 draw 函数里做相应的 import 和处理。
  • 牢固:drawAll 依赖于 Rectangle 和 Circle,无法拿出来复用。可复用和可测试很多情况下都是同义词,比如为了测试 drawAll 我们需要把所有 Shape 代码都准备好,具体的 Shape 存在问题也会导致 drawAll 的单元测试失败,而且很难 mock 掉 drawAll 的依赖。

这时我们需要对 drawAll 的功能进行“抽象”。比如我们发现 drawAll 其实并不关心 shape 的类型是 Circle 还是 Rectangle,只需要它能够 draw。因此我们让 drawAll、Circle、Rectangle 都依赖一个能够 draw 的 Shape 接口即可,解除 drawAll 对具体的 Circle 和 Rectangle 类型的依赖。重构后得到这样的代码:

 

上述代码是 TypeScript 实现,如果是 JavaScript 实现则 drawAll、Circle、Rectangle 都不再依赖 Shape。JavaScript 没有接口的概念,但这个约定仍然存在只是没有在源代码中显式地表达出来。我们称它为隐式接口,类似 C++ 模板中体现的隐式类型,只不过它是编译期检查,JavaScript 的隐式接口直接产生运行时错误。

注意应用开闭原则进行重构对上述代码产生的影响:

  • 因为我们从 drawAll 里干掉了条件分支,这份代码不再脆弱。
  • 也不再僵化,因为需要新增类型时我们只需要新增一个 Triangle 类,不需要对 drawAll 引入变更。
  • 也不再牢固,因为 drawAll 不再依赖任何具体的类,它的单元测试和复用也更容易。

设计是一种权衡的艺术,让某些事情变好的同时也让另外一些事情变坏了。思考:drawAll 在重构前后灵活性变好了还是变坏了?欢迎留下你的评论。

里氏替换原则

内容:若对每个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,使用o1替换o2后,程序P行为功能不变,则S是T的子类型。

如上是通过“子类型”的定义来描述里氏替换原则的,也可以用对子类型的要求来描述:子类型(subtype)必须能够替换掉它们的基类型(basetype)同时程序的功能保持不变。因此里氏替换原则可以确保新增的子类型不会影响基于父类型编写的代码,也就是说里氏替换原则是用来实现开闭原则的。这是一个很简单的原则,但实现起来却很 tricky。比如我们有一个 Rectangle 类,突然有一天需要一个 Square:

 我们让 Square 继承自 Rectangle 是有理论依据的:正方形是一种长方形。但是这一继承潜在地破坏了既有的使用方代码,因此违反了开闭原则:

 

这段既有代码会在新的 Square 上出错,所以是哪里出了问题?因为几何学上的 "is a" 关系和面向对象的 "is a" 关系是不同的。面向对象的 "is a" 是关于行为的:一个对象是另一个对象的子类型时,它应该拥有所有父类的行为(方法)和属性。这里的 setHeight 和 setWidth 的行为确实发生了变化,因为在设置长方形宽的时候,没人会想到它的高会发生变化。

这个 case 可以引申出另一个结论:模型的正确性不是内在的。一个模型,如果孤立地看,不具有真正意义上的正确性,它的正确性只能通过它的客户程序来体现。也就是说一个类/函数允许的使用方式,它的文档和单元测试,决定了它正确性,而不是代码实现本身是否自洽。为了让二者更加靠近,需要采用类似 防御性编程 的手段。

接口隔离原则

内容:不应该强制客户代码依赖它不需要的方法。

由于 JavaScript 没有接口的概念,接口在前端开发中很少接触,需要接口的地方都用文档来定义一个“隐式接口”(比如各种库的 options 结构),无法在编译期检查只能在运行时抛出乱七八糟的错误。但随着 TypeScript 的普及和 DefinitelyTyped 的努力,接口也在逐渐进入前端。

类似里氏替换原则,接口隔离原则是为了帮助实现开闭原则,它的思想也体现了单一职责原则。设想有一款实现了打印、扫描、传真、复印 4 个功能的打印机 PrinterA,它是一个具体的类。我们为这个类定义一个打印机接口 IPrinter,这个接口也同样有打印、扫描、传真、复印 4 个方法。

  • 第一个问题出在我们出现一个不支持传真的打印机 PrinterB 的时候:PrinterB 不支持传真接口,但是要实现 IPrinter 接口就必须实现一个假的,会抛错的传真方法。这样做的问题在于错误从编译期跑到了运行时。
  • 第二个问题出在我们给 PrinterA 新增方法(比如双面打印)的时候:我们需要把所有实现了 IPrinter 的打印机(包括 PrinterB)都新增对应方法。显然违反了开闭原则。
  • 那么让所有 Printer 继承自一个提供缺省实现的公共父类呢?这又违反了里氏替换原则(明显子类和父类的行为是不同的),同时也把错误从编译期延迟到了运行时。

应用接口隔离原则的方法也很直观:把 IPrinter 拆分成若干职责单一的小接口,比如 ICopy,IFax,IPrint。然后具体的 PrinterA、PrinterB 实现自己需要的那些接口。这样使用 PrinterB 上不支持的传真方法时,就可以在编译期抛出错误;由于不需要再提供缺省方法,修改 PrinterA 也不会导致 PrinterB 的变更,也不再需要一个公共父类了。

依赖倒置原则

内容:高层模块不应该依赖底层模块,两者都应该依赖抽象。

在业务代码的开发中,经常会发现一个现象:底层模块通常更容易被复用,也更容易写单元测试,高层模块很难被复用,也更难测试。这就是依赖倒置原则要解决的问题,高层代码和底层代码应当同样地可复用,同样地可测试。

和单一职责原则、开闭原则一样,应用依赖倒置的关键还是在于“抽象”和“归纳”。分析不变的部分(高层的、抽象的逻辑),和容易变的部分(底层的、细节的逻辑),把相对稳定的高层逻辑加以抽象,让它不再依赖于具体的底层逻辑。下面例子中的 PaintingRobot 的 drawShape 用来创建和绘制一个具体的图形:

 

这个 drawShape 就是高层逻辑,它的实现包括两个步骤:1. 创建依赖的对象,2. 执行绘制流程。并且可以看到 drawShape 依赖于底层的 Circle 和 Rectangle,下面就通过依赖倒置原则来解除这个。首先我们需要把创建过程封装到一个工厂类中:

 

 

然后把创建依赖的工作从 drawShape 中移除:

 注意:工厂依赖于具体的 Circle、Rectangle 等底层类,而 PaintingRobot 不依赖于这些底层类,也不依赖于这个工厂。这样 PaintingRobot 作为高层逻辑,会更容易复用和单元测试;在新增一个具体 Shape 时不再需要变更 drawShape 方法(也许你会说现在工厂依赖了具体的 Shape,但是我们用一个工厂提供了所有高层逻辑创建具体 Shape 的需求,而且在更彻底的依赖注入方案中这个工厂甚至可以替换成一个 XML 文件),因此符合了开闭原则。这个过程中依赖关系的变化如下图:

 

另一个角度看,依赖倒置是面向过程设计和面向对象设计的关键区别。体现在思考问题的方式倒置了:

  • 在面向过程设计中,先确定流程是“Robot类创建形状 -> 根据条件创建不同形状类 -> 执行后续流程”,再确定其中每一步的实现方式;
  • 在面向对象设计中,先考虑“存在不同的 Shape,对使用方来讲,它们有怎样的共同抽象”,再提供可用的 Shape 接口交给使用方,而具体的 Shape 类可以并行地开发。因此依赖倒置原则也是面向接口编程的基础。

总结

  • 单一职责原则是 SOLID 所有原则的基础和解决问题的思路。
  • 开闭原则是直接保障代码质量的原则,用来解决设计的脆弱性、僵化、难以阅读、难以复用等问题,应用开闭原则的关键在于如何“抽象”。
  • 里氏替换原则通过确保子类和父类是 "is a" 的关系,来帮助实现开闭原则。该原则的使用中,引申出面向对象角度的 "is a" 是关于行为的,以及模型的正确性不是内在的,而是由它的客户程序来体现。
  • 接口隔离原则提供了一种方案,在不违反里氏替换原则的情况下,如何实现开闭原则。同时接口隔离的思想本身也体现了单一职责原则。
  • 依赖倒置原则是过程式设计与面向对象设计的分水岭,通过适当的抽象,让高层模块和底层模块同样地可复用和可测试。同时它也被用来指导接口隔离原则。

 

 

 

 

 

标签:依赖,原则,SOLID,代码,开闭,Shape,drawAll
From: https://www.cnblogs.com/congmo/p/17762117.html

相关文章

  • Solidworks流体仿真插件安装及案例分析
    Solidworks流体仿真插件安装及案例分析1流体仿真插件的安装如图1所示,安装时勾选SolidworksFlowSimuation模块,一路“下一步”安装完毕。完成安装后打开软件,图2所示,点击Solidworks插件按钮,找到SolidworksFlowSimuation按钮,双击可打开说明插件安装成2案例分析2.1案例背景......
  • 什么是PMP里的沟通的5C原则?
    在项目管理和商务沟通中,5C原则通常被提及作为有效沟通的基本准则。PMP(项目管理专业人员)强调沟通的重要性,因为有效的沟通对于项目的成功至关重要。以下是5C原则:简洁(Clear):传达信息时,首先要确保信息是明确的,没有歧义。避免使用可能会引起混淆的行话或复杂的......
  • Go流程控制与快乐路径原则
    Go流程控制与快乐路径原则目录Go流程控制与快乐路径原则一、流程控制基本介绍二、if语句2.1if语句介绍2.2单分支结构的if语句形式2.3Go的if语句的特点2.3.1分支代码块左大括号与if同行2.3.2条件表达式不需要括号三、操作符3.1逻辑操作符3.2操作符的优先级三、if多......
  • 良好接地指导原则
    接地无疑是系统设计中最为棘手的问题之一。尽管它的概念相对比较简单,实施起来却很复杂,遗憾的是,它没有一个简明扼要可以用详细步骤描述的方法来保证取得良好效果,但如果在某些细节上处理不当,可能会导致令人头痛的问题。对于线性系统而言,"地"是信号的基准点。遗憾的是,在单极性电源系......
  • MySQL的最左原则
    一、简述MySQL索引的最左原则指的是,当使用多列索引时,MySQL会优先使用索引中最左边的列。如果查询条件中包含了索引的最左列,那么MySQL会使用这个索引来加速查询。更具体的描述:建立一个索引,对于索引中的字段,mysql会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配......
  • solidity入门
    1.solidity简介Solidity(中文名称:Solidity语言)是一种面向智能合约(SmartContracts)的高级编程语言,最初由以太坊(Ethereum)的团队开发并用于以太坊平台上的智能合约编写。Solidity的设计目标是简化以太坊智能合约的开发,使开发者能够创建安全、可靠的去中心化应用程序(DApps)。以下......
  • Solidworks 文件属性、自定义属性傻傻分不清?究竟是“李逵”还是“李鬼”?
    在此记录学习Solidworks的历程一步一个脚印,道阻且长,慢慢走吧问题:为什么同一零件中两个位置的自定义属性不一样?究竟是“李逵”还是“李鬼”?举例:通过“程序-属性选项卡编辑器20XX”修改零部件的属性后,新建一个零部件,分别打开“文件-属性”与“任务窗口-零部件属性”,会发现两个......
  • 架构师必须掌握的架构设计原则
    软件设计原则GRASP通用职责分配软件模式来自CraigLarman的软件设计书《UML和模式应用》,Larman在书中提出软件设计的关键任务是职责分配,并提炼总结出9种(5种核心+4种扩展)软件职责分配模式,这些模式是比GoF设计模式更抽象的元模式。信息专家(InformationExpe......
  • 架构师必须掌握的架构设计原则
    软件设计原则GRASP通用职责分配软件模式来自CraigLarman的软件设计书《UML和模式应用》,Larman在书中提出软件设计的关键任务是职责分配,并提炼总结出9种(5种核心+4种扩展)软件职责分配模式,这些模式是比GoF设计模式更抽象的元模式。信息专家(InformationExpe......
  • 架构师必须掌握的架构设计原则
    软件设计原则GRASP通用职责分配软件模式来自CraigLarman的软件设计书《UML和模式应用》,Larman在书中提出软件设计的关键任务是职责分配,并提炼总结出9种(5种核心+4种扩展)软件职责分配模式,这些模式是比GoF设计模式更抽象的元模式。信息专家(InformationExpe......