每个开发人员都应了解的SOLID原则

面向对象的编程为软件开发带来了新的应用程序设计方法。 特别是,OOP允许程序员将实体(按共同的目标或功能)组合在单独的类中,这些实体旨在解决独立的问题并且独立于应用程序的其他部分。 但是,单独使用OOP并不意味着开发人员可以避免创建难以维护的晦涩,混乱的代码。 为了帮助所有希望开发高质量OOP应用程序的人,Robert Martin开发了面向对象编程和设计的五项原则,并在Michael Fazers的帮助下谈论了这五个原则,他们使用了缩写词SOLID。



我们今天出版的翻译材料专门针对SOLID的基础知识,供初学者使用。

什么是SOLID?


首字母缩写SOLID代表以下内容:

  • S:单一责任原则。
  • O:开闭原理。
  • L:李斯科夫替代原则(Barbara Liskov Substitution Principle)。
  • I:接口隔离原理。
  • D:依赖反转原理。

现在,我们将在原理图示例中考虑这些原理。 请注意,这些示例的主要目的是帮助读者理解SOLID的原理,学习如何应用它们以及在设计应用程序时如何遵循它们。 该材料的作者并没有努力达成可以在实际项目中使用的工作代码。

唯一责任原则


“一个差事。 只是一件事。” -洛基(Loki)在电影《雷神:诸神黄昏》中对Skurge说道。
每个班级只能解决一个问题。

一堂课只负责一件事情。 如果一个类负责解决几个问题,那么实现这些问题的解决方案的子系统将相互关联。 一个这样的子系统的变化导致另一个子系统的变化。

注意,该原理不仅适用于类,而且更广泛地适用于软件组件。

例如,考虑以下代码:

class Animal {    constructor(name: string){ }    getAnimalName() { }    saveAnimal(a: Animal) { } } 

这里介绍的Animal类描述了某种动物。 此类违反了唯一责任原则。 该原则到底有多违反?

按照唯一责任的原则,一个班级只能完成一项任务。 他通过使用saveAnimal方法中的数据仓库并在构造函数和getAnimalName方法中操纵对象的属性来解决这两个问题。

这样的类结构如何导致问题?

如果使用该应用程序使用的数据仓库的过程发生了变化,那么您将必须对与该仓库一起工作的所有类进行更改。 这种体系结构不灵活,某些子系统中的更改会影响其他子系统,这类似于多米诺骨牌效应。

为了使上面的代码符合唯一责任的原则,我们将创建另一个类,其唯一的任务是使用存储库,特别是在其中存储Animal类的对象:

 class Animal {   constructor(name: string){ }   getAnimalName() { } } class AnimalDB {   getAnimal(a: Animal) { }   saveAnimal(a: Animal) { } } 

这是史蒂夫·芬顿(Steve Fenton)所说的:“在设计类时,我们应努力集成相关的组件,即出于相同原因而发生更改的组件。 我们应该尝试将各个组成部分分开,以引起各种原因的更改。”

唯一责任原则的正确应用会导致模块内部元素的高度连接,也就是说,模块中解决的任务与其主要目标非常吻合。

开闭原理


应打开软件实体(类,模块,功能)以进行扩展,但不能进行修改。

我们继续从事Animal课。

 class Animal {   constructor(name: string){ }   getAnimalName() { } } 

我们想要对动物列表进行排序,每种动物都由Animal类的一个对象表示,并找出它们发出的声音。 想象我们使用AnimalSounds函数解决了这个问题:

 //... const animals: Array<Animal> = [   new Animal('lion'),   new Animal('mouse') ]; function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(a[i].name == 'lion')           return 'roar';       if(a[i].name == 'mouse')           return 'squeak';   } } AnimalSound(animals); 

这种体系结构的主要问题是该功能确定了动物在分析特定对象时发出的声音。 AnimalSound函数AnimalSound符合开放性-封闭性原则,因为例如当出现新型动物时,我们需要对其进行更改以使用它来识别它们发出的声音。

向数组添加一个新元素:

 //... const animals: Array<Animal> = [   new Animal('lion'),   new Animal('mouse'),   new Animal('snake') ] //... 

之后,我们必须更改AnimalSound函数的代码:

 //... function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(a[i].name == 'lion')           return 'roar';       if(a[i].name == 'mouse')           return 'squeak';       if(a[i].name == 'snake')           return 'hiss';   } } AnimalSound(animals); 

如您所见,在向数组添加新动物时,您将不得不补充功能代码。 一个示例非常简单,但是如果在实际项目中使用类似的体系结构,则该函数将必须不断扩展,并向其中添加新的if表达式。

如何使AnimalSound函数符合开闭原理? 例如,像这样:

 class Animal {       makeSound();       //... } class Lion extends Animal {   makeSound() {       return 'roar';   } } class Squirrel extends Animal {   makeSound() {       return 'squeak';   } } class Snake extends Animal {   makeSound() {       return 'hiss';   } } //... function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       a[i].makeSound();   } } AnimalSound(animals); 

您可能会注意到Animal类现在具有虚拟的makeSound方法。 使用这种方法,有必要设计用于描述特定动物的类来扩展Animal类并实现此方法。

结果,每个描述动物的类都将具有自己的makeSound方法,并且在AnimalSound函数中对具有动物的数组进行迭代时,对于数组的每个元素调用该方法就足够了。

如果现在将描述新动物的对象添加到数组,则不必更改AnimalSound函数。 我们使它符合开放性-封闭性原则。

考虑另一个例子。

假设我们有一家商店。 使用此类,我们为客户提供20%的折扣:

 class Discount {   giveDiscount() {       return this.price * 0.2   } } 

现在,决定将客户分为两组。 最喜欢( fav )的顾客将获得20%的折扣,而VIP顾客( vip )将获得两倍的折扣,即-40%。 为了实现此逻辑,决定对类进行如下修改:

 class Discount {   giveDiscount() {       if(this.customer == 'fav') {           return this.price * 0.2;       }       if(this.customer == 'vip') {           return this.price * 0.4;       }   } } 

这种方法违反了开放性-封闭性原则。 如您所见,在这里,如果我们需要为特定的客户群提供特殊折扣,我们必须在该类中添加一个新代码。

为了根据开放性-封闭性原则处理此代码,我们向项目添加了一个新类,该类扩展了Discount类。 在这个新的类中,我们正在实现一种新的机制:

 class VIPDiscount: Discount {   getDiscount() {       return super.getDiscount() * 2;   } } 

如果您决定为“超级VIP”客户提供80%的折扣,则应如下所示:

 class SuperVIPDiscount: VIPDiscount {   getDiscount() {       return super.getDiscount() * 2;   } } 

如您所见,这里使用的是类的授权,而不是它们的修改。

芭芭拉·李斯科夫(Barbara Liskov)的替代原则


子类必须替代其超类。

该原理的目的是在不中断程序的情况下,可以使用继承类而不是从其形成父类。 如果事实证明在代码中检查了类类型,则违反了替换原则。

考虑该原理的应用,返回到Animal类的示例。 我们将编写一个旨在返回有关动物肢体数量信息的函数。

 //... function AnimalLegCount(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(typeof a[i] == Lion)           return LionLegCount(a[i]);       if(typeof a[i] == Mouse)           return MouseLegCount(a[i]);       if(typeof a[i] == Snake)           return SnakeLegCount(a[i]);   } } AnimalLegCount(animals); 

该功能违反了替换原则(以及开放性-封闭性原则)。 该代码应了解其处理的所有对象的类型,并根据类型使用相应的函数来计算特定动物的肢体。 结果,当创建一种新型动物时,必须重写该函数:

 //... class Pigeon extends Animal {      } const animals[]: Array<Animal> = [   //...,   new Pigeon(); ] function AnimalLegCount(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(typeof a[i] == Lion)           return LionLegCount(a[i]);       if(typeof a[i] == Mouse)           return MouseLegCount(a[i]);        if(typeof a[i] == Snake)           return SnakeLegCount(a[i]);       if(typeof a[i] == Pigeon)           return PigeonLegCount(a[i]);   } } AnimalLegCount(animals); 

为了避免此功能违反替代原则,我们使用Steve Fenton提出的要求对其进行了转换。 它们的事实在于,接受或返回具有某些超类类型的值的方法(在本例中为Animal )也应接受并返回其类型为其子类( Pigeon )的值。

有了这些考虑,我们可以重做AnimalLegCount函数:

 function AnimalLegCount(a: Array<Animal>) {   for(let i = 0; i <= a.length; i++) {       a[i].LegCount();   } } AnimalLegCount(animals); 

现在,此函数对传递给它的对象类型不感兴趣。 她只是简单地调用他们的LegCount方法。 她对类型的了解仅是她处理的对象必须属于Animal类或其子类。

LegCount方法现在应该出现在Animal类中:

 class Animal {   //...   LegCount(); } 

他的子类需要实现此方法:

 //... class Lion extends Animal{   //...   LegCount() {       //...   } } //... 

结果,例如,在访问Lion类实例的LegCount方法时,将调用在该类中实现的方法,并确切地返回通过调用该方法可以预期的结果。

现在, AnimalLegCount函数不需要知道要处理的Animal类的特定子类中的哪个对象,就可以找到有关该对象代表的动物肢体数量的信息。 该函数仅调用Animal类的LegCount方法,因为此类的子类必须实现此方法,以便可以在不违反程序正确操作的情况下使用它们。

接口分离原理


创建为特定客户端设计的高度专业化的界面。 客户端不应依赖于不使用的接口。

该原理旨在解决与大型接口的实现相关的缺点。

考虑Shape接口:

 interface Shape {   drawCircle();   drawSquare();   drawRectangle(); } 

它描述了绘制圆形( drawCircle ),正方形( drawSquare )和矩形( drawRectangle )的方法。 结果,实现此接口并表示单个几何形状(例如圆形,正方形和矩形)的类必须包含所有这些方法的实现。 看起来像这样:

 class Circle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Square implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Rectangle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } 

原来很奇怪的代码。 例如,代表矩形的Rectangle类实现了根本不需要的方法( drawCircledrawSquare )。 分析其他两个类的代码时,可以看到相同的结果。

假设我们决定向Shape接口添加另一个方法来绘制三角形,该方法旨在绘制三角形:

 interface Shape {   drawCircle();   drawSquare();   drawRectangle();   drawTriangle(); } 

这将导致表示特定几何形状的类也必须实现drawTriangle方法。 否则,将发生错误。

如您所见,使用这种方法不可能创建一个类来实现用于输出圆的方法,但不能实现用于派生正方形,矩形和三角形的方法。 可以实施这样的方法,使得当它们被输出时,抛出错误,指示不能执行这样的操作。

界面分离的原则警告我们不要从我们的示例中创建诸如Shape界面。 客户端(我们具有CircleSquareRectangle类)不应实现不需要使用的方法。 此外,此原则表明接口仅应解决一个任务(在此类似于唯一责任的原则),因此,超出此任务范围的所有内容都应转移到另一个接口或多个接口。

在我们的案例中, Shape接口解决了需要创建单独接口的问题。 遵循这个想法,我们通过创建用于解决各种高度专业化任务的单独接口来对代码进行重做:

 interface Shape {   draw(); } interface ICircle {   drawCircle(); } interface ISquare {   drawSquare(); } interface IRectangle {   drawRectangle(); } interface ITriangle {   drawTriangle(); } class Circle implements ICircle {   drawCircle() {       //...   } } class Square implements ISquare {   drawSquare() {       //...   } } class Rectangle implements IRectangle {   drawRectangle() {       //...   } } class Triangle implements ITriangle {   drawTriangle() {       //...   } } class CustomShape implements Shape {  draw(){     //...  } } 

现在, ICircle界面仅用于绘制圆,以及用于绘制其他形状的其他专用界面。 Shape接口可以用作通用接口。

依赖倒置原则


依赖的对象应该是抽象的,而不是特定的东西。

  1. 上级模块不应依赖于下级模块。 两种模块都应依赖抽象。
  2. 抽象不应依赖细节。 细节应取决于抽象。

在软件开发过程中,有时会出现应用程序功能不再适合同一模块的情况。 发生这种情况时,我们必须解决模块依赖性问题。 结果,例如,可以证明高级组件依赖于低级组件。

 class XMLHttpService extends XMLHttpRequestService {} class Http {   constructor(private xmlhttpService: XMLHttpService) { }   get(url: string , options: any) {       this.xmlhttpService.request(url,'GET');   }   post() {       this.xmlhttpService.request(url,'POST');   }   //... } 

在这里, Http类是一个高级组件,而XMLHttpService是一个低级组件。 这种架构违反了依赖反转原则的条款A:“较高级别的模块不应依赖于较低级别的模块。 两种模块都应依赖抽象。”

Http类被强制依赖于XMLHttpService类。 如果我们决定更改Http类与网络交互所使用的机制,假设它将是Node.js服务或用于测试目的的存根服务,则必须通过更改相应的代码来编辑Http类的所有实例。 这违反了开放-封闭原则。

Http类不应该知道确切用于建立网络连接的内容。 因此,我们将创建Connection接口:

 interface Connection {   request(url: string, opts:any); } 

Connection接口包含对request方法的描述,我们将Connection类型参数传递给Http类:

 class Http {   constructor(private httpConnection: Connection) { }   get(url: string , options: any) {       this.httpConnection.request(url,'GET');   }   post() {       this.httpConnection.request(url,'POST');   }   //... } 

现在,无论使用什么来组织与网络的交互, Http类都可以使用传递给它的内容,而不必担心Connection接口背后隐藏的内容。

我们重写XMLHttpService类,以便它实现以下接口:

 class XMLHttpService implements Connection {   const xhr = new XMLHttpRequest();   //...   request(url: string, opts:any) {       xhr.open();       xhr.send();   } } 

结果,我们可以创建许多实现Connection接口的类,并适合在Http类中用于组织网络上的数据交换:

 class NodeHttpService implements Connection {   request(url: string, opts:any) {       //...   } } class MockHttpService implements Connection {   request(url: string, opts:any) {       //...   } } 

如您所见,这里的高级和低级模块取决于抽象。 Http类(高级模块)取决于Connection接口(抽象)。 XMLHttpServiceNodeHttpServiceMockHttpService (低级模块)也取决于Connection接口。

另外,值得注意的是,遵循依赖倒置的原理,我们观察到了替换芭芭拉·利斯科夫的原理。 即,事实证明类型XMLHttpServiceNodeHttpServiceMockHttpService可以替代基本类型Connection

总结


在这里,我们研究了每个OOP开发人员应遵循的五个SOLID原则。 刚开始时,这可能并不容易,但是如果您为此奋斗,增强实践的欲望,这些原则将成为工作流的自然组成部分,这对应用程序的质量具有巨大的积极影响,并极大地促进了它们的支持。

亲爱的读者们! 您在项目中使用SOLID原理吗?

Source: https://habr.com/ru/post/zh-CN426413/


All Articles