December 7, 2018

SOLID Principle

SOLID是5项开发原则的简称,下面是一些理解

内容

  • S:单一职责原则
  • O:开闭原则
  • L:里氏替换原则
  • I:接口隔离原则
  • D:依赖倒置原则

和OO的关系

并没有严格的联系,OO是一种开发方法,但并不能保证用了就能写出容易维护的程序,所以才诞生了SOLID来帮助开发者写出可读性良好、容易维护的程序。(一般来说可读性良好也意味着容易维护)

软件复杂度越来越高,早已不是一个或者几个人可以轻松驾驭的工程领域,那么为了完成软件项目,则必然的需要一个团队,通常由不同分工的几个工程师组成,随着项目规模的增大,团队规模也势必随之调整。

不同工程师之间的沟通成本可能是随着团队规模增大最为凸显的问题,所以有SOLID原则来使得进行软件开发的时候可以让软件系统更加易读、可复用性高,从而达到容易维护的最终目的。

软件项目的最终目的是服务于商业需求,而软件工程的最终目的是构建可维护、易维护的软件项目,需求常常会变,一个可维护、易维护的软件系统能够在最快的时间内适应需求变化,让前期投入花在有用的地方,让软件系统跟随需求快速响应获得更大收益。

S

S:一个类只应该负责一件事。如果一个类有多个职责,那么它变成了耦合的。对一个职责的修改会导致对另一个职责的修改。 class Animal { constructor(name: string){ } getAnimalName() { } saveAnimal(a: Animal) { } } “这个例子saveAnimal可能是操作数据库把animal对象对应到数据库的表存储,和getAnimalName以及constructor这两个只管理animal属性的方法形成了两种职责,如果修改了saveAnimal的话,使用Animal属性的类也需要重新编译”

这种解释是没错误,但总感觉有些牵强。确实,如果我修改了saveAnimal也就是修改了Animal,使用这个类的代码就需要编译了么?我如果用的是动态语言不需要编译呢?难道不需要遵守SRP了么?

答案是否定的,这些原则应该是和实现、编程语言无关的。可能原先不像现在有这么多动态语言,一次编译也有成本,修改的少,可能重新编译所需时间就少一些。

O

O:软件实体(类、模块、函数)应该对扩展开放,对修改关闭。

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);

列表中如果新增一种animal(这是修改操作,应该对修改关闭),那这个AnimalSound函数也必须得修改才能正确返回结果,但是吧一般用了OO,AnimalSound也不至于这样写吧?肯定是调用一个animal的方法返回结果而不是hard code写if判断。

那么对于扩展开放的意思呢?

class Discount {
    giveDiscount() {
        if(this.customer == 'fav') {
            return this.price * 0.2;
        }
        if(this.customer == 'vip') {
            return this.price * 0.4;
        }
    }
}

这个例子,如果你想增加一种优惠力度比如svip,难道还要再写一个if吗?应该继承Discount,增加一个svip discount

class VIPDiscount: Discount {
    getDiscount() {
        return super.getDiscount() * 2;
    }
}

class SuperVIPDiscount: VIPDiscount {
    getDiscount() {
        return super.getDiscount() * 2;
    }
}

这个看起来和OO的继承联系很大,毕竟实现就是用了继承,但如果没用OO,也应该有类似的实现,内在逻辑是统一的。

L

L:子类必须可以替换它的超类。

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的方法来输出结果,当然我并没有刻意学过这个原则并遵循,可能是其他代码看得多了,有了一种感觉,其实我没写几行OO代码,大都是过程式的,脚本的。

也就是说如果新增一种animal,你得修改AnimalLegCount方法来适应新变化,这样好不好呢?肯定不好,虽说对于AnimalLegCount这个方法你一眼就能看得出该怎么修改,但如果你的软件系统中全是这样的代码,就生不如死了。

这个例子并不适合说明这项原则。这项原则的表示是:

如果超类(Animal)有一个方法接受超类类型(Anima)的参数,那么它的子类(Pigeon)应该接受超类类型(Animal 类型)或子类类型(Pigeon 类型)作为参数。

如果超类返回一个超类类型(Animal), 那么它的子类应该返回一个超类类型(Animal 类型)或子类类型(Pigeon 类型)。

还需要一个更合适的例子说明。

I

I:创建特定于客户端的细粒度接口。不应该强迫客户端依赖于它们不使用的接口。

interface IShape {
    drawCircle();
    drawSquare();
    drawRectangle();
}

这个例子就更加有趣了,印象中java对于接口的要求是如果实现一个接口,那么接口中声明的方法就需要全部实现,所以如果一个类,比如圆形类实现了IShape方法,那么圆形类还要实现drawSquare、drapRectangle这两个毫无相关的方法,难道写起来不觉得很奇怪么?如果再增加一个方法到IShape中,还会有更多无用的方法需要被实现。

这里首先很明确的是IShape应该遵循单一职责原则,接口的抽象级别也应该改成

interface IShape {
    draw();
}
interface ICircle {
    drawCircle();
}
...

如果有一个奇怪形状类,可以实现IShape自己实现draw完成绘制,其他形状类,比如圆形,只需实现ICircle

这个例子和SRP有重叠部分,我觉得不够好。

D

D:依赖应该是抽象的,而不是具体的;高级模块不应该依赖于低级模块。两者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。

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类依赖了XMLHttpService这种代码,我可能会修改HTTP的名字,起码改得得有XML相关的东西。由于HTTP协议可以有很多实现方式,只需遵循格式即可,所以这里只能是有一个抽象的方法或者接口被HTTP依赖,实现这个接口需要实现某些抽象方法,HTTP只需调用接口定义的方法(协议)即可。

比如这里用xml的方式实现抽象接口中的方法,http只需调用抽象方法就可以发送xml的请求等等。

小结一下,文中的例子不是特别合适。有时候按照思维方式写出的代码就已经遵循了部分原则,是因为有一定经验看过一些优秀的代码还是别的就不清楚了,但我觉得应该有更好的例子,让人一看就体会出原则的意义。


Refs

https://insights.thoughtworks.cn/do-you-really-know-solid/

Powered by Hugo & Kiss.