面向对象的编程类型为软件开发带来了新的设计思路。
这使得开发人员可以将相同目的/功能的数据合并到一个类中,以便处理单一目的,而不考虑整个应用程序。
但是,这种面向对象编程并不能防止混乱或难以维护的程序。
因此,Robert C. Martin制定了五个准则。这五个准则/原则使开发人员能够创建可读性和可维护性的程序。
这五个原则被称为S.O.L.I.D原则(缩写由Michael Feathers衍生)。
- S:单一责任原则
- O:开放封闭原则
- L:Liskov替换原则
- I:接口隔离原则
- D:依赖反转原则
我们将在下面详细讨论它们。
**注意:**本文中的大多数示例可能不足以代表真实情况,或在真实世界应用程序中不适用。这完全取决于你自己的设计和用例。最重要的是要理解并知道如何应用/遵循原则。
**提示:**使用像Bit(GitHub)这样的工具可以轻松地在项目和应用程序之间共享和重用组件(和小模块)。它还可以帮助你和你的团队节省时间,保持同步并共同构建更快。它是免费的,试试吧。
单一责任原则
“...你只有一个工作” — 洛基 对斯克尔格在《雷神3:诸神黄昏》中说
一个类应该只负责一件事情。
一个类应该只负责一件事情。如果一个类有多个职责,它就会变得耦合。一个职责的变化会导致对另一个职责的修改。
- **注意:**此原则不仅适用于类,还适用于软件组件和微服务。
例如,考虑以下设计:
class Animal {
constructor(name: string){ }
getAnimalName() { }
saveAnimal(a: Animal) { }
}
Animal类违反了SRP。
它如何违反SRP?
SRP规定类应该有一个职责,在这里,我们可以列出两个职责:动物数据库管理和动物属性管理。构造函数和getAnimalName管理Animal属性,而saveAnimal管理Animal存储在数据库中。
这个设计将来会如何引起问题?
如果应用程序以某种方式更改,从而影响数据库管理功能。使用Animal属性的类将不得不进行修改和重新编译,以补偿新的更改。
你会发现,这个系统充满了僵化,就像多米诺骨牌效应一样,触碰一张牌会影响到排在其后面的所有其他牌。
为了符合SRP,我们创建另一个类来处理存储动物到数据库的唯一职责:
class Animal {
constructor(name: string){ }
getAnimalName() { }
}class AnimalDB {
getAnimal(a: Animal) { }
saveAnimal(a: Animal) { }
}
在设计我们的类时,我们应该尝试将相关功能放在一起,这样无论何时它们倾向于改变,它们都会因同样的原因而改变。如果它们因不同的原因而改变,我们应该尝试将它们分开。 - Steve Fenton
通过正确应用这些准则,我们的应用程序变得高度内聚。
开放封闭原则
软件实体(类,模块,函数)应该对扩展开放,对修改关闭。
让我们继续使用Animal类。
class Animal {
constructor(name: string){ }
getAnimalName() { }
}
我们想要迭代一个动物列表并让它们发出声音。
//...
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')
log('roar');
if(a[i].name == 'mouse')
log('squeak');
}
}
AnimalSound(animals);
函数AnimalSound不符合开放封闭原则,因为它不能针对新类型的动物关闭。
如果我们添加一个新的动物,Snake:
//...
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')
log('roar');
if(a[i].name == 'mouse')
log('squeak');
if(a[i].name == 'snake')
log('hiss');
}
}AnimalSound(animals);
你会发现,对于每个新的动物,都会向AnimalSound函数添加新的逻辑。这是一个非常简单的例子。当你的应用程序增长并变得复杂时,你会发现在AnimalSound函数中的if
语句会一遍又一遍地重复,每次添加新动物时都会在整个应用程序中重复出现。
我们如何使其(AnimalSound)符合OCP?
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++) {
log(a[i].makeSound());
}
}
AnimalSound(animals);
Animal现在具有虚拟方法makeSound
。我们使每个动物扩展Animal类并实现虚拟makeSound方法。
每个动物在makeSound中添加自己的实现方式。AnimalSound迭代动物数组,只需调用其makeSound方法。
现在,如果我们添加一个新的动物,AnimalSound不需要更改。我们只需要将新动物添加到动物数组中即可。
AnimalSound现在符合OCP原则。
另一个例子:
假设你有一个商店,并使用此类为你最喜欢的客户提供20%的折扣:
class Discount {
giveDiscount() {
return this.price * 0.2
}
}
当你决定为VIP客户提供20%的双倍折扣时。你可以像这样修改类:
class Discount {
giveDiscount() {
if(this.customer == 'fav') {
return this.price * 0.2;
}
if(this.customer == 'vip') {
return this.price * 0.4;
}
}
}
不,这违反了OCP原则。OCP禁止它。如果我们想为不同类型的客户提供新的百分比折扣,你会发现将添加新逻辑。
为了使其遵循OCP原则,我们将添加一个新类,该类将扩展Discount。在这个新类中,我们将实现其新行为:
class VIPDiscount: Discount {
getDiscount() {
return super.getDiscount() * 2;
}
}
如果你决定为超级VIP客户提供80%的折扣,则应该如下所示:
class SuperVIPDiscount: VIPDiscount {
getDiscount() {
return super.getDiscount() * 2;
}
}
你会发现,扩展而无需修改。
Liskov替换原则
子类必须能够替换其超类
这个原则的目的是确保子类可以替换其超类而不出错。如果代码发现自己检查类的类型,那么它必须违反了这个原则。让我们使用动物的例子。
下面的代码违反了LSP原则(也违反了OCP原则)。它必须知道每个动物类型并调用相应的“计算腿数”函数。
每当创建新动物时,该函数必须修改以接受新动物。
为了使该函数遵循LSP原则,我们将遵循Steve Fenton提出的LSP要求:
- 如果超类(Animal)具有接受超类类型(Animal)参数的方法,则其子类(Pigeon)应接受超类类型(Animal类型)或子类类型(Pigeon类型)作为参数。
- 如果超类返回超类类型(Animal),则其子类应返回超类类型(Animal类型)或子类类型(Pigeon)。
现在,我们可以重新实现AnimalLegCount函数:
AnimalLegCount函数不关心传递的Animal类型,它只是调用LegCount方法。它只知道参数必须是Animal类型,即Animal类或其子类。
Animal类现在必须实现/定义一个LegCount方法:
它的子类也必须实现LegCount方法:
当它传递给AnimalLegCount函数时,它返回狮子的腿数。
你看,AnimalLegCount不需要知道Animal的类型就能返回它的腿数,它只是调用Animal类型的LegCount方法,因为按照契约,Animal类的子类必须实现LegCount函数。
接口隔离原则
制定客户特定的细粒度接口
客户不应被迫依赖它们不使用的接口。
这个原则处理实现大型接口的缺点。
让我们看看下面的IShape接口:
这个接口绘制正方形、圆形、矩形。实现IShape接口的Circle、Square或Rectangle类必须定义drawCircle()、drawSquare()、drawRectangle()方法。
上面的代码看起来相当有趣。矩形类实现(drawCircle和drawSquare)它没有用到的方法,同样的,正方形实现drawCircle和drawRectangle,圆形类实现drawSquare和drawSquare。
如果我们向IShape接口添加另一个方法,如drawTriangle(),
则类必须实现新方法,否则将抛出错误。
我们看到,无法实现一个可以绘制圆但不能绘制矩形、正方形或三角形的形状。我们只能实现抛出错误的方法,显示无法执行操作。
ISP不赞成IShape接口的设计。客户端(这里是Rectangle、Circle和Square)不应被迫依赖于它们不需要或不使用的方法。此外,ISP指出接口应该只执行一个作业(就像SRP原则一样),任何额外的行为分组都应该被抽象到另一个接口中。
在这里,我们的IShape接口执行应该由其他接口独立处理的操作。
为了使我们的IShape接口符合ISP原则,我们将操作分离到不同的接口中:
ICircle接口仅处理圆的绘制,IShape处理任何形状的绘制,ISquare处理仅正方形的绘制,IRectangle处理矩形的绘制。
或者,类(Circle、Rectangle、Square、Triangle等)可以继承IShape接口并实现自己的draw
行为。
我们可以使用I
-interfaces创建特定形状,如半圆、直角三角形、等边三角形、钝角矩形等。
依赖倒置原则
依赖应该是抽象的,而不是具体的。
A.高层模块不应依赖于低层模块。两者都应该依赖于抽象。
B.抽象不应该依赖于细节。细节应该依赖于抽象。
在软件开发中,有一个时刻我们的应用程序将大部分由模块组成。当这种情况发生时,我们必须通过使用_依赖注入_来澄清事情。高层组件依赖于低层组件以进行功能。
在这里,Http是高层组件,而HttpService是低层组件。这种设计违反了DIP A:高层模块不应依赖于低层级模块。它应该依赖于它的抽象。
Http类被迫依赖于XMLHttpService类。如果我们要更改Http连接服务,也许我们想通过Nodejs连接到Internet,甚至模拟http服务。我们将不得不费力地浏览所有Http实例来编辑代码,这违反了OCP原则。
Http类应该不关心你正在使用的Http服务类型。我们创建一个Connection接口:
Connection
接口有一个请求方法。有了这个,我们将一个类型为Connection
的参数传递给我们的Http
类:
所以现在,无论传递给Http的Http连接服务的类型是什么,它都可以轻松地连接到网络,而不必知道网络连接的类型。
我们现在可以重新实现XMLHttpService类以实现Connection接口:
我们可以创建许多HttpConnection
类型并将其传递给我们的Http类,而不必担心任何错误。
现在,我们可以看到高层模块和低层模块都依赖于抽象。Http
类(高层模块)依赖于Connection
接口(抽象),而Http服务类型(低层模块)反过来又依赖于Connection
接口(抽象)。
此外,此DIP将迫使我们不违反Liskov替换原则:Connection
类型Node
-XML
-MockHttpService
可以替换其父类型Connection
。# 结论
我们在这里介绍了每个软件开发人员必须遵守的五个原则。起初遵守所有这些原则可能会让人望而却步,但是通过持续的练习和遵守,它将成为我们的一部分,并对我们应用程序的维护产生巨大影响。
译自:https://blog.bitsrc.io/solid-principles-every-developer-should-know-b3bfa96bb688
评论(0)