面向对象的编程模式为软件开发带来了新的设计理念。
这使开发人员能够在一个类中组合具有相同目的/功能的数据,无论整个应用程序如何,这个类处理特定的事情。
但是,这种面向对象的编程还是不能预防写出令人困惑或不可维护的程序。
因此,Robert C. Martin制定了五项指导方针。 这五个准则/原则使开发人员可以轻松创建可读和可维护的程序。
这五个原则被称为S.O.L.I.D原则(首字母缩写词由Michael Feathers派生)。
- S:Single Responsibility Principle 单一职责原则
- O:Open-Closed Principle 开发-关闭原则
- L:Liskov Substitution Principle 里氏替换原则
- I:Interface Segregation Principle 接口隔离原则
- D:Dependency Inversion Principle 依赖倒置原则
我们接下来会详细的讨论
注意: 本文中的大多数示例可能不足以满足实际需要或不适用于实际应用程序。 这一切都取决于您自己的设计和用例。 最重要的是要了解并知道如何应用/遵循原则。
Single Responsibility Principle 单一职责原则
“…You had one job” — Loki to Skurge in Thor: Ragnarok
A class should have only one job.
一个类应该只负责一件事。 如果一个类有多个职责,那么它就会变得耦合。修改一项职责会导致要去修改另一项职责。
注意:这项原则不仅适用于类,也适用于组件和微服务
举个例子,考虑如下的设计:
class Animal {
constructor(name: string){ }
getAnimalName() { }
saveAnimal(a: Animal) { }
}
这个Animal
类违反了SPR
(单一职责)。
为什么?
SRP
声明类应该有一个责任,在这里,我们可以提出两个职责:动物数据库管理和动物属性管理。saveAnimal
管理数据库上的Animal
的存储时,构造函数和getAnimalName
管理Animal
属性。
这种设计以后会导致什么问题?
如果程序以影响数据库管理功能的方式更改,必须触及并重新编译使用Animal属性的类以抵偿新的更改。
这个系统看起来很有弹性,就像多米诺骨牌效应一样,触摸一张卡就会影响所有其他卡片。
为了使其符合SRP,我们创建了另一个类,它将负责将动物存储到数据库:
class Animal {
constructor(name: string){ }
getAnimalName() { }
}
class AnimalDB {
getAnimal(a: Animal) { }
saveAnimal(a: Animal) { }
}
我们在设计类的时候,应该将相关的功能放在一起,每当要变化的时候,他们会出于同样的原因而改变。 如果功能因不同原因而发生变化,我们应该尝试将功能分开。 - 史蒂夫芬顿
通过正确使用这些,我们的应用程序变得高度凝聚力。
Open-Closed Principle 开放关闭原则
软件实体(类,模块,函数)应该是可以扩展的,而不是修改。
我们继续看我们的Animal
类:
class Animal {
constructor(name: string){ }
getAnimalName() { }
}
我们想要遍历一个动物的列表,并使它们发出声音。
//...
const animals: Array = [
new Animal('lion'),
new Animal('mouse')
];
function AnimalSound(a: Array) {
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
方法不符合开放式原则,因为它不能对新类型的动物进行封闭。
如果我们添加一个新的动物:蛇
//...
const animals: Array = [
new Animal('lion'),
new Animal('mouse'),
new Animal('snake')
]
//...
我们必须去修改AnimalSound
这个方法:
//...
function AnimalSound(a: Array) {
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) {
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原则,我们将添加一个将扩展折扣的新类。在这个新类中,我们将实现其新行为:
class VIPDiscount: Discount {
getDiscount() {
return super.getDiscount() * 2;
}
}
如果您决定向超级VIP客户提供80%的折扣,它应该是这样的:
class SuperVIPDiscount: VIPDiscount {
getDiscount() {
return super.getDiscount() * 2;
}
}
你看到了,拓展而没有修改。
Liskov Substitution Principle 里氏替换原则
子类必须可替代其超类
这个原则的目的是确定一个子类可以毫无错误地占据其超类的位置。如果代码发现自己检查类的类型,那么它一定违反了这个原则。
我们使用动物的例子:
//...
function AnimalLegCount(a: Array) {
for(int i = 0; i <= a.length; i++) {
if(typeof a[i] == Lion)
log(LionLegCount(a[i]));
if(typeof a[i] == Mouse)
log(MouseLegCount(a[i]));
if(typeof a[i] == Snake)
log(SnakeLegCount(a[i]));
}
}
AnimalLegCount(animals);
这违反了LSP原则(以及OCP原则)。它必须知道每种Animal类型并调用相关的Leg-Count
功能。
随着动物的每一次新创造,该功能必须修改以接受新动物。
//...
class Pigeon extends Animal {
}
const animals[]: Array = [
//...,
new Pigeon();
]
function AnimalLegCount(a: Array) {
for(int i = 0; i <= a.length; i++) {
if(typeof a[i] == Lion)
log(LionLegCount(a[i]));
if(typeof a[i] == Mouse)
log(MouseLegCount(a[i]));
if(typeof a[i] == Snake)
log(SnakeLegCount(a[i]));
if(typeof a[i] == Pigeon)
log(PigeonLegCount(a[i]));
}
}
AnimalLegCount(animals);
为了使这个功能遵循LSP原则,我们将遵循Steve Fenton假设的LSP要求:
- 如果超类(Animal)有一个方法将一个超类类型(Animal)作为参数,它的子类(Pigeon)必须可以接受一个超类类型(Animal)或者子类类型(Pigeon)作为参数。
- 如果超类返回一个超类类型(Animal)。它的子类应该返回一个超类类型(Animal)或者子类类型(Pigeon)。
现在,我们可以重新实现AnimalLegCount函数:
function AnimalLegCount(a: Array) {
for(let i = 0; i <= a.length; i++) {
a[i].LegCount();
}
}
AnimalLegCount(animals);
AnimalLegCount函数更少关注Animal传递的类型,它只调用LegCount方法。它只知道参数必须是Animal类型,Animal类或其子类。
Animal类现在必须实现/定义LegCount方法:
class Animal {
//...
LegCount();
}
并且它的子类必须实现LegCount方法:
//...
class Lion extends Animal{
//...
LegCount() {
//...
}
}
//...
当它传递给AnimalLegCount函数时,它返回狮子的腿数。
你看,AnimalLegCount不需要知道Animal的类型来返回它的腿数,它只是调用Animal类型的LegCount方法,因为通过约定,Animal类的子类必须实现LegCount函数。
Interface Segregation Principle 接口隔离原则
制作客户特定的细粒度接口
不应强迫客户端依赖于他们不使用的接口。
该原则处理实现大接口的缺点。
我们来看看下面的IShape接口:
interface IShape {
drawCircle();
drawSquare();
drawRectangle();
}
此接口绘制正方形,圆形,矩形。实现IShape接口的类Circle,Square或Rectangle必须定义drawCircle(),drawSquare(),drawRectangle()方法。
class Circle implements IShape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Square implements IShape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Rectangle implements IShape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
看看上面的代码很有趣。类Rectangle实现了它没有使用的方法(drawCircle和drawSquare),同样类Square实现了drawCircle,drawRectangle,类Circle(drawSquare,drawSquare)。
如果我们向IShape接口添加另一个方法,比如drawTriangle(),
interface IShape {
drawCircle();
drawSquare();
drawRectangle();
drawTriangle();
}
类必须实现新方法否则将抛出错误。
我们发现,实现一个可以绘制圆形而不是矩形或正方形或三角形的形状是不可能的。实现方法时抛出一个错误,表明无法执行操作。
ISP对IShape接口的设计不满意。客户端(此处为Rectangle,Circle和Square)不应强制依赖于他们不需要或不使用的方法。此外,ISP声明接口应该只执行一件事情(就像SRP原则一样)任何额外的行为都应该被抽象到另一个接口。
在这里,我们的IShape接口执行应由其他接口独立处理的操作。
为了使我们的IShape接口符合ISP原则,我们将操作分离到不同的接口:
interface IShape {
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 IShape {
draw(){
//...
}
}
The ICircle interface handles only the drawing of circles, IShape handles drawing of any shape :), ISquare handles the drawing of only squares and IRectangle handles drawing of rectangles.
ICircle接口只画圆形,IShape接口可以画任何形状 :),ISquare接口只画正方形,IRectangle接口只画矩形。
或者
类(圆形,矩形,正方形,三角形等)可以从IShape接口继承并实现它们自己的绘制行为。
class Circle implements IShape {
draw(){
//...
}
}
class Triangle implements IShape {
draw(){
//...
}
}
class Square implements IShape {
draw(){
//...
}
}
class Rectangle implements IShape {
draw(){
//...
}
}
然后我们可以使用I -interfaces来创建Shape特征,如Semi Circle,Right-Angleled Triangle,Equilateral Triangle,Blunt-Edged Rectangle等。
Dependency Inversion Principle 依赖倒置原则
依赖性应该是抽象而非凝聚
A.高级模块不应该依赖于低级模块。 两者都应该依赖于抽象。
B.抽象不应该依赖于细节。 细节应取决于抽象。
在软件开发中有一点我们的应用程序将主要由模块组成。 当发生这种情况时,我们必须通过使用依赖注入来清除问题。 高级组件取决于要运行的低级组件。
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是高级组件,而HttpService是低级组件。 此设计违反DIP A:高级模块不应依赖于低级模块。 它应该取决于它的抽象。
Http类被迫依赖于XMLHttpService类。 如果我们要改变Http连接服务,也许我们想通过Nodejs连接到互联网,甚至模拟http服务。 我们必须改所有的Http实例代码,这违反了OCP原则。
Http类应该更少关注您正在使用的Http服务的类型。 我们创建一个Connection接口:
interface Connection {
request(url: string, opts:any);
}
Connection
接口有一个请求方法。 有了这个,我们将一个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的Http连接服务的类型如何,它都可以轻松连接到网络,而无需知道网络连接的类型。
我们现在可以重新实现我们的XMLHttpService类来实现Connection接口:
class XMLHttpService implements Connection {
const xhr = new XMLHttpRequest();
//...
request(url: string, opts:any) {
xhr.open();
xhr.send();
}
}
我们可以创建许多Http Connection类型并将它传递给我们的Http类,而不用担心错误。
class NodeHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}
class MockHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}
现在,我们可以看到高级模块和低级模块都依赖于抽象。 Http类(高级模块)依赖于Connection接口(抽象)和Http Service类(低级模块)依赖于Connection接口(抽象)。
此外,DIP将强制我们不要违反里氏替换原则:连接类型Node-XML-MockHttpService可替换其父类型Connection。
结论
这里涵盖了每个软件开发人员必须遵守的五项原则。 一开始可能难以遵守所有这些原则,但通过稳定的实践和遵守,它将成为我们编程的一部分,并将极大地影响我们的应用程序的维护。
注:本篇文章为译文,原文链接
阅读更多精彩的文章,请点个关注,或者前往我的个人博客网站 :)