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

每个开发人员都应了解的 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 多平台发布

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值