每个开发人员都应了解的 SOLID 原则
面向对象的编程类型为软件开发带来了全新的设计。
这使开发人员能够将具有相同目的/功能的数据合并到一个类中,以处理该类的唯一目的,而无需考虑整个应用程序。
但是,这种面向对象编程并不能防止程序混乱或无法维护。
因此,罗伯特-C-马丁Robert C. Martin制定了五项准则。这五条准则/原则使开发人员能够轻松创建可读和可维护的程序。
这五项原则被称为 S.O.L.I.D 原则(缩写由 Michael Feathers 提出)。
S:单一责任原则
O: 开放-封闭原则
L:利斯科夫替代原则
I:接口隔离原则
D:依赖反转原则
下面我们将详细讨论这些原则。
注:本文中的大多数示例可能并不适合实际情况,或不适用于现实世界的应用。这完全取决于你自己的设计和用例。最重要的是理解并知道如何应用/遵循这些原则。
提示:使用 Bit (GitHub) 等工具可以轻松地在项目和应用程序中共享和重用组件(和小模块)。
它还能帮助您和您的团队节省时间、保持同步并加快共同开发的速度。它是免费的,不妨一试。
作者的推荐协作工具:
跨应用程序和项目轻松共享组件 组件发现与协作 - Bit
Bit 是开发人员共享组件和协作的地方,让他们共同打造令人惊叹的软件。发现共享组件...
单一责任原则
"......你只有一项工作"--《雷神索尔:毁灭之战》中洛基对斯库奇说
一个类应该只有一项工作。
一个类只能负责一件事。如果一个类有多个职责,它就会变得耦合。一个职责的改变会导致另一个职责的修改。
注:这一原则不仅适用于类,也适用于软件组件和微服务。 例如,请考虑以下设计
class Animal {
constructor(name: string){ }
# 构造函数(名称:字符串)
getAnimalName() { }
saveAnimal(a: Animal) { }
}
Animal 类的构造违反了 SRP
如何违反 SRP 的?
SRP 规定类应有一项职责,在这里可以得出两项职责:
动物数据库管理和动物属性管理。
构造函数和 getAnimalName 管理动物属性,而 saveAnimal 则管理动物在数据库中的存储。
这种设计将来会产生什么问题?
如果应用程序发生变化,影响到数据库管理功能。使用动物属性的类就必须修改并重新编译,以适应新的变化。
你看充满了僵化的味道,就像多米诺骨牌效应,触动一张牌就会影响到其他所有的牌。
为了使这个系统符合 SRP,我们创建了另一个类,专门负责将动物存储到数据库中:
class Animal {
constructor(name: string){ }
getAnimalName() { }
}
class AnimalDB {
getAnimal(a: Animal) { }
saveAnimal(a: 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 不符合开放-封闭原则,因为它不能对新的动物种类进行封闭。
如果我们添加一种新的动物,蛇:
//...
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 == '蛇')
log('hiss');
}
}
AnimalSound(animals);
你看,每出现一种新动物,AnimalSound 函数就会增加一个新逻辑。
这只是一个简单的例子。当您的应用程序发展壮大并变得复杂时,您会发现每次添加新动物时,if 语句都会在 AnimalSound 函数中重复出现,遍布整个应用程序。
我们如何使它(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);
动物现在有了一个虚拟方法 makeSound。我们让每个动物扩展 Animal 类并实现虚拟 makeSound 方法。
每种动物都会在 makeSound 中添加自己的发声方法。AnimalSound 会遍历动物数组,并调用其 makeSound 方法。
现在,如果我们添加一个新的动物,AnimalSound 不需要更改。我们只需将新动物添加到动物数组中即可。
现在,AnimalSound 符合 OCP 原则。
另一个例子:
假设你有一家商店,你可以使用这个类给你最喜欢的顾客打八折:
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;
}
}
你看,扩展无需修改。
利斯科夫替代原则
子类必须可以替代其超类
该原则的目的是确保子类可以替代其超类而不会出错。如果代码发现自己在检查类的类型,那么它一定违反了这一原则。
让我们以动物为例。
//...
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] == 蛇)
log(SnakeLegCount(a[i]));
}
}
AnimalLegCount(animals);
这违反了 LSP 原则以及 OCP 原则。它必须知道每一种动物类型,并调用相关的计算腿函数。
每创建一个新动物,都必须修改函数以接受新动物。
//...
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);
为了使该函数遵循 LSP 原则,我们将遵循 Steve Fenton 提出的 LSP 要求:
如果超类 Animal有一个接受超类类型 Animal参数的方法。其子类 Pigeon 应接受超类类型 Animal 类型或子类类型 Pigeon 类型 作为参数。
现在,我们可以重新实现 AnimalLegCount 函数:
function AnimalLegCount(a: Array<Animal>) {
for(let i = 0; i <= a.length; i++) {
a[i].LegCount();
}
}
AnimalLegCount(animals);
AnimalLegCount 函数不关心传递的动物类型,它只是调用 LegCount 方法。
它只知道参数必须是 Animal 类型,要么是 Animal 类,要么是它的子类。
现在,动物类必须实现/定义一个 LegCount 方法:
class Animal {
//...
LegCount();
}
它的子类必须实现 LegCount 方法:
//...
class Lion extends Animal{
//...
LegCount() {
//...
}
}
//...
当传递给 AnimalLegCount 函数时,它会返回狮子的腿数。
你看,AnimalLegCount 不需要知道 Animal 的类型就能返回它的腿数,它只需调用 Animal 类型的 LegCount 方法,
因为根据契约,Animal 类的子类必须实现 LegCount 函数。
接口隔离原则
制作客户机专用的细粒度接口
不应强迫客户依赖他们不使用的接口。
这一原则解决了实现大型接口的弊端。
让我们看看下面的 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(){
//...
}
}
上面的代码非常有趣:
矩形类实现了它用不上的方法 drawCircle 和 drawSquare;
正方形类实现了drawCircle 和 drawRectangle;
圆形类实现了 drawSquare、drawSquare;
如果我们在 IShape 接口中再添加一个方法,
如 drawTriangle()
interface IShape {
drawCircle();
drawSquare();
drawRectangle();
drawTriangle();
}
这些类必须实现新方法,否则会出错。
我们看到,要实现一个能画圆但不能画矩形、正方形或三角形的形状是不可能的。我们只需实现抛出错误的方法,说明无法执行操作即可。
ISP 不赞成这种 IShape 接口的设计。
客户端(此处为矩形、圆形和正方形)不应被迫依赖于它们不需要或不使用的方法。
此外,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(){
//...
}
}
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 接口创建半圆、直角三角形、等边三角形、钝角矩形等特定形状。
依赖反转原则
应依赖于抽象而非具体事物
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 接口有一个请求方法。有了它,我们就可以向 Http 类传递 Connection 类型的参数:
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 连接类型,并将其传递给我们的 Http 类,而不必担心出错。
class NodeHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}
class MockHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}
现在,我们可以看到高层模块和低层模块都依赖于抽象。
Http 类(高级模块)依赖于 Connection 接口(抽象),而 Http 服务类型(低级模块)反过来也依赖于 Connection 接口(抽象)。
此外,DIP 将迫使我们不违反利斯科夫替代原则:连接类型 Node-XML-MockHttpService 可替代其父类型 Connection。
结论
我们在这里介绍了每个软件开发人员都必须遵守的五项原则。一开始,遵守所有这些原则可能会让人望而生畏,但通过不断的实践和坚持,这些原则将成为我们的一部分,并将对我们应用程序的维护产生巨大的影响。
本文由 mdnice 多平台发布