面向对象的编程方式给软件开发带来了新的设计方法。
这使开发人员能够将具有相同目的/功能的数据聚合到一个类中,以达到该类要实现的唯一目的或功能,而不管应用程序整体上要做什么。
但是,这种面向对象的编程方式并不能完全防止开发者写出难以理解或难以维护的程序。 因此,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)
接下来,我们来详细论述这五个原则。
注意:本文中的大多数示例可能不足以满足实际情况,或者不适用于实际应用。这完全取决于你自己的设计和用例。但最重要的事情是理解和知道如何应用和遵循这些原则。
小贴士:用类似Bit这样的工具把 SOLID 原则应用于实践。它可以帮助你组织、发现和重用组件从而来组成新的应用程序。组件可以在项目之间被发现和共享,因此你可以更快地构建项目。Git 地址。
单一职责原则 (Single Responsibility Principle)
你只有一项工作。 —— 洛基,《雷神托尔:诸神黄昏》
一个类只应该有一项职责
一个类应该只负责做一件事情。如果一个类有多个职责,那么这多个职责就被耦合在了一起。一个功能发生变更会引起另一个功能发生不期望的变更。
- 注意:此原则不仅适用于类,还适用于组件开发和微服务。
例如下面的设计:
class Animal {
constructor(name: string){ }
getAnimalName() { }
saveAnimal(a: Animal) { }
}
复制代码
Animal 类违反了单一职责原则。
它为什么违反了单一职责原则?
单一职责原则规定,一个类应该只有一个职责,但这里我们可以看出两个职责: animal
数据库管理和 animal
属性管理。constructor
和 getAnimalName
方法管理 animal
的属性,而 saveAnimal
方法管理数据库中的 animal
存储。
这个设计在将来可能引起什么问题?
如果程序的变更需要影响到数据库管理功能,那么所有用到 animal
属性的类必须被修改并重新编译以兼容新的变化,
现在你可以感受到这个系统有股死板的味道,就像多米诺骨牌效应,触摸一张牌,它会影响到所有其他牌。
为了使这个设计符合单一职责原则,我们要创建另一个类,该类将专门负责将 animal
对象存储到数据库中:
class Animal {
constructor(name: string){ }
getAnimalName() { }
}
// animalDB专门负责在数据库中读写animal
class AnimalDB {
getAnimal(a: Animal) { }
saveAnimal(a: Animal) { }
}
复制代码
在我们设计类时,我们应该把相关的 feature 放在一起,所以每当它们倾向于改变时,它们都会因为相同的原因而改变。如果 feature 因不同原因发生变化,我们应该尝试将它们分开。--Steve Fenton
通过适当地应用这些设计,我们的应用程序将变得高度内聚。
开放-封闭原则 (Open-Closed Principle)
软件实体(类、模块、函数)等应当是易于扩展的,但是不可修改
让我们继续看 Animal
类:
class Animal {
constructor(name: string){ }
getAnimalName() { }
}
复制代码
我们想要遍历一个animal
数组,并让每个animal
发出对应的声音。
//...
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
方法不符合开放-封闭原则,因为它没有对新类型的 animal
对象关闭。
如果我们添加一个新的 animal
对象,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);
复制代码
现在你可以感受到,每新增一个 animal
,就需要增加一段新的逻辑到 AnimalSound
方法中。这是一个非常简单的例子,当你的程序变得庞大而复杂时,你将看到每次添加新的animal
时,if
语句都会在AnimalSound
方法中反复重复,直到充满整个应用。
那怎样让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++) {
log(a[i].makeSound());
}
}
AnimalSound(animals);
复制代码
Animal
类现在有了一个虚方法( virtual method ) —— makeSound
。我们让每只 animal 继承了 Animal
类并实现了父类的makeSound
方法。
每个 animal
子类添加并在自己内部实现了 makeSound
方法。在 AnimalSound
方法遍历 animal
对象数组时,只需要调用每个 animal
对象自身的 makeSound
方法即可。
现在,如果我们新增一个animal
,AnimalSound
方法不需要做出任何修改。我们需要做的仅仅是把新增的这个 animal
对象加入到数组当中。
现在 AnimalSound
方法遵从了开放-封闭原则。
再看一个例子:
假设你有一家店铺,而且你要通过这个 Discount
类给你喜欢的客户一个 2 折的折扣。
class Discount {
giveDiscount() {
return this.price * 0.2
}
}
复制代码
当你决定为 VIP 客户提供双倍的 20%折扣时。你可以这样修改 Discount
类:
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 客户8折的折扣,我们可以再新加一个 SuperVIPDiscount
类:
class SuperVIPDiscount: VIPDiscount {
getDiscount() {
return super.getDiscount() * 2;
}
}
复制代码
现在你可以感受到,在不做修改的情况下,我们实现了功能的扩展。
里氏替换原则 (Liskov Substitution Principle)
子类必须可以替代它的父类。
这一原则的目的是确定一个子类可以毫无错误地替代它的父类。让所有使用基类的地方都能透明地使用子类,如果代码在在检查类的类型,那么它一定违反了这个原则。
我们继续使用 Animal
类的例子:
//...
function AnimalLegCount(a: Array<Animal>) {
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);
复制代码
这段代码违反了理氏替换原则(也违背了开放-封闭原则)——它必须直到每个 Animal
对象的具体类型,并调用该对象所关联的 leg-counting
函数。
每新增一种 Animal
类,这个方法就必须作出修改,从而接收新的类型的 Animal
对象。
//...
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)
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);
复制代码
为了使该方法遵循里氏替换原则,我们将遵循 Steve Fenton
假定的里氏替换原则的几项要求:
- 如果父类(
Animal
)具有一个可以接收父类类型(Animal
)作为参数的方法。它的子类(Pigeon
)应该接受一个父类类型(Animal
)或子类类型(Pigeon
)作为参数。 - 如果父类返回父类类型(
Animal
)。那么它的子类应该返回一个父类类型(Animal
)或子类类型(Pigeon
)。
现在,我们来重新实现 AnimalLegCount
方法:
function AnimalLegCount(a: Array<Animal>) {
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() {
//...
}
}
//...
复制代码
当 lion(Lion
的一个实例)被传递到 AnimalLegCount
方法中时,方法会返回 lion 拥有的腿数。
现在你可以感受到,AnimalLegCount
方法不需要知道接收到的是什么类型的 animal
(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
、drawRectangle
)。
如果我们在IShape
接口中添加一个方法,比如绘制三角形 drawTriangle
,
interface IShape {
drawCircle();
drawSquare();
drawRectangle();
drawTriangle();
}
复制代码
所有实现IShape
接口的类都需要实现这个新增的方法,不然就会报错。
我们看到,用这个设计不可能实例化一个可以画圆(drawCircle
)但不能画矩形(drawRectangle
)、正方形(drawSquare
)或三角形(drawTriangle
)的shape
对象。我们只能实现接口中所有的方法,并且在违反逻辑的方法中抛出一个操作无法执行
的错误。
接口隔离原则不推荐这个 IShape
接口的设计。用户(这里指Rectangle
、Circle
和Square
类)不应该被强制地依赖于它们不需要或不使用的方法。另外,接口隔离原则指出,接口应该只负责单一职责(就像单一职责原则),任何额外的行为都应该被抽象到另一个接口。
在这里,我们的IShape
接口执行的操作应该由其他接口独立处理。
为了使我们的IShape
接口符合接口隔离原则,我们将操作分离到不同的接口:
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(){
//...
}
}
复制代码
ICircle
接口只负责绘制圆形,IShape
负责绘制任何形状,ISquare
只负责绘制正方形,IRectangle
负责绘制矩形。
或者
子类(Circle
、Rectangle
、Square
、Triangle等
)从 IShape
接口继承并实现自己的 draw
方法。
class Circle implements IShape {
draw(){
//...
}
}
class Triangle implements IShape {
draw(){
//...
}
}
class Square implements IShape {
draw(){
//...
}
}
class Rectangle implements IShape {
draw(){
//...
}
}
复制代码
然后我们可以使用I
-接口来创建特定的 Shape
实例,如半圆(Semi Circle
)、直角三角形(Right-Angled 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
是低级组件。这种设计违反了依赖倒置原则:
A:高级模块不应依赖于低级模块。它应该依赖于抽象。
Http
类被强制依赖 于XMLHttpService
类。如果我们要更改 Http
连接服务,可能是需要通过 Nodejs
连接,或者是模拟 Http
服务。为了编辑代码,我们将不得不费力地检查 Http
的所有实例,这违反了开放-封闭原则。
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
类的 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
服务类型(低级模块)也依赖于 Connection
接口(抽象)。
此外,依赖倒置原则迫使我们不要违反里氏替换原则:Connection
类型 Node
-XML
-MockHttpService
是可以透明替换其父类 Connection
的。
总结
在这里,我们介绍了每个软件开发者必须遵守的五个原则。做出改变通常都是痛苦的,但随着稳定的实践和坚持,它将成为我们的一部分,并将对我们的程序维护工作产生巨大的影响。
参考:
- Pro TypeScript Application-Scale JavaScript Development, Steve Fenton
- S.O.L.I.D: The First 5 Principles of Object Oriented Design, Samuel Oloruntoba
原文地址
SOLID Principles every Developer Should Know , Chidume Nnamdi