虽然大家都认为SOLID是一个很重要的设计原则,对每一个原则都很熟悉,但是我发现大部分开发者并没有真正理解它。为了获得最大的利益,有必要了解它们之间的关系并将所有这些原则一起应用。只有将SOLID作为一个整体,才有可能构建出实体(Solid)的软件。不幸的是,我们所看到的书籍和文章都是罗列每一个原则,而没有从整体上看,甚至连提出SOLID原则的Bob大叔也未能将其解释透彻。所以我试着解释一下我的理解。我先抛出我的观点:单一职责是所有设计原则的基础,开闭原则是设计的最终目标。里氏替换原则强调子类替换父类后程序运行时的正确性,用于帮助实现开闭原则。接口隔离原则在帮助实现里氏替换原则的同时,也体现了单一职责。依赖倒置原则是过程式编程和OO编程的分水岭,也被用来指导接口隔离原则。关系如下:单一职责原则(SRP)单一职责是最容易理解的设计原则,但也是最容易违反的设计原则之一。真正理解和正确应用单一职责原则并不是那么容易。单一职责就像“一点盐”,很难把握。RobertC.Martin(又名“Bob大叔”)将责任定义为改变的原因,并将单一责任描述为“一个类应该只有一个改变的理由”。也就是说,如果有多个原因导致一个类改变Modification,那么这个类就违反了单一职责原则。那么问题来了,“变化的原因”是什么?利益相关者的作用是变革的一个重要原因。不同的角色会有不同的需求,导致改变的原因不同。作为居民,家用的电线是普通的220V电线,而对于电网建设者来说,使用的是高压电线。使用Wire类同时充当这两个角色通常是一种难闻的气味。变更频率是另一个值得考虑的变更原因。即使是同一类型的角色,需求变更的频率也会有所不同。最典型的例子就是业务处理的需求比较稳定,而业务展示的需求更容易发生变化。毕竟人总是喜新厌旧的。因此,这两类需求通常在不同的类中实现。单一职责原则是一种关注点分离。分离不同角色的关注点,分离不同时代的关注点。在实践中,如何运用单一职责原则?什么时候拆分,什么时候合并?来看看新手厨师在学做菜时是如何掌握“一点盐”的。他会一直品尝,直到味道恰到好处。编写代码也是如此。您需要识别需求变化的信号并不断“品尝”您的代码。当“味道”不够好时,继续重构,直到“味道”恰到好处。开闭原则(Open-closedPrinciple)开闭原则是指软件实体(类、模块等)应该对扩展开放,对修改关闭。这听起来很不合理,不能修改,只能扩展?那我怎么写代码呢?我们先来看看为什么会有开闭原则。假设您是一位成功的开源库作者,许多开发人员使用您的库。如果有一天你想扩展功能,只能通过修改一些代码来完成。结果,类库的用户需要修改代码。更可怕的是,他们被强制修改代码后,其他家属也可能被强制修改代码。这种情况绝对是一场灾难。如果你的设计满足开闭原则,那就是完全不同的场景了。您可以通过扩展而不是修改软件来更改软件的行为,从而最大限度地减少对依赖方的影响。这不就是设计的最终目的吗?解耦、高内聚、低耦合等设计原则最终不都是为了这个目标吗?想象一下,类、模块和服务不需要修改,但是通过扩展可以改变它的行为。像计算机一样,组件可以很容易地扩展。硬盘太小?直接换大一点的,显示器不够大?8K的怎么样?什么时候应该应用开闭原则以及如何应用?没有人能够一开始就识别出所有的扩展点,也不可能到处留出扩展点的代价是无法接受的。所以一定是需求变化驱动的。如果您有领域专家的支持,他可以帮助您确定变化点。否则,您应该在发生变化时进行更改,因为没有任何理由就预先设计太多是违反Yagni的。实现开闭原则的关键是抽象。在BertrandMeyer提出开闭原则的时代(1980年代),向类库添加属性或方法不可避免地需要修改依赖此类库的代码。这显然让软件难以维护,所以他的重点是让类可以通过继承来扩展。随着技术的发展,我们实现开闭原则的方式越来越多,包括接口、抽象类、策略模式等,我们可能永远无法完全实现开闭原则,但这并不妨碍它是设计的最终目标。SOLID的其他原理直接或间接服务于开闭原理,比如接下来要介绍的里氏代换原理。里氏代换原则(TheLiskovSubstitutionPrinciple)里氏代换原则说派生类(子类)对象可以用来代替它们的基类(父类)对象。学过OO的同学都知道,子类可以替代父类,为什么需要里氏代换原则呢?这里强调的不是编译错误,而是程序在运行时的正确性。程序运行的正确性一般可以分为两类。一类是不能出现运行时异常,最典型的是UnsupportedOperationException,即子类不支持父类的方法。第二类是业务正确性,这取决于业务上下文。在下面的例子中,由于java.sql.Date不支持父类的toInstance方法,当父类被它替换时,程序不能正常运行,破坏了父类和调用者之间的契约,从而违反了Liskov替换原则。packagejava.sql;publicclassDateextendsjava.util.Date{@OverridepublicInstanttoInstant(){thrownewjava.lang.UnsupportedOperationException();}}接下来,让我们看一下破坏业务正确性的示例。最典型的例子就是Bob大叔在《敏捷软件开发:原则、模式与实践》里说的正方形也继承了长方形的例子。一般来说,正方形是长方形的一种,但是这种继承关系破坏了业务的正确性。publicclassRectangle{doublewidth;doubleheight;publicdoublearea(){returnwidth*height;}}publicclassSquareextendsRectangle{publicvoidsetWidth(doublewidth){this.width=width;this.height=width;}publicvoidsetHeight(doubleheight){this.height=width;this.width=width;}}publicvoidtestArea(Rectangler){r.setWidth(5);r.setHeight(4);assert(r.area()==20);//!如果r是正方形,则面积为16}如果代码中testArea方法的参数是正方形,则面积为16,而不是预期的20,所以结果显然不正确。如果你的设计满足里氏替换原则,那么子类(或者接口的实现类)就可以在保证正确性的前提下替换父类(或者接口),改变系统的行为,实现扩展。BranchByAbstraction和strangler模式都是基于Liskov替换原则来实现系统的扩展和演化。这意味着它对修改是封闭的,对扩展是开放的,所以里氏代换原则是实现开闭原则的一种解决方案。而要实现里氏代换原则,就需要接口隔离原则。接口隔离原则(InterfaceSegregationPrinciple)接口隔离原则说,不应该强迫客户端依赖它不使用的方法。简而言之,更小、更具体的瘦接口优于笨重的胖接口。如果胖接口的职责过多,容易违反单一职责原则,还会导致实现类抛出UnsupportedOperationException等异常,违反里氏代换原则。因此,接口应该设计得更薄。如何在接口上减肥?接口之所以存在,就是为了解耦。开发人员常常错误地认为实现类需要接口。事实上,消费者需要接口,而实现类只是提供服务,所以接口应该由消费者(客户端)来定义。只有理解了这一点,我们才能正确地从消费者的角度定义Role接口,而不是从实现类中提取Header接口。什么是角色接口?比如一块砖头(Brick)可以被建筑工人用来盖房子,也可以用来防身:TeamBuildingHouse}publicvoiddefense(){//...正当防卫}}如果直接提取如下接口,这是HeaderInterface:publicinterfaceBrickInterface{voidbuildHouse();voiddefense();}大众需要的是一个可以防御的武器,不需要用砖头盖房子。当普通大众(Person)被迫依赖他们不需要的接口方法时,就违反了接口隔离原则。正确的做法是从消费者的角度抽象出Role接口:publicinterfaceBuildHouse{voidbuild();}publicinterfaceStrickCompetence{voiddefense();}publicclassBrickimplementBuildHouse,StrickCompetence{}有了Role接口,作为消费者的普通大众和建筑工人就可以分别使用你自己的接口:Worker.javabrick.build();Person.javabrick.strike();接口隔离原则本质上是单一职责原则的体现,同时也服务于里氏代换原则。接下来介绍的依赖倒置原则可以用来指导接口隔离原则的实现。依赖倒置原则(DependenceInversionPrinciple)依赖倒置原则说高层模块不应该依赖低层模块,两者都应该依赖于它的抽象。这个原则其实就是在指导如何实现接口隔离原则。上面说了,高层消费者不应该依赖具体的实现,而应该由消费者自己定义,依赖于Role接口。底层的具体实现也依赖于Role接口,因为它实现了这个接口。依赖倒置原则将过程式编程与面向对象编程区分开来。过程式编程中没有依赖倒置,本文ASimpleDIPExample|C#中的敏捷原则、模式和实践以开关和灯的示例很好地说明了这一点。在上图的关系中,当Button直接调用灯的开关时,Button依赖于灯。它的代码完全是程序化编程:publicclassButton{privateLamplamp;publicvoidPoll(){if(/*somecondition*/)lamp.TurnOn();}}如果Button还想控制电视,那微波炉呢?处理这种变化的方式是抽象,抽象出Role接口ButtonServer:不管是灯还是电视,只要实现了ButtonServer,就可以控制Button。这就是面向对象编程。总结一般而言,单独应用单一的SOLID原则并不能使收益最大化。它应该作为一个整体来理解和应用,以更好地指导您的软件设计。单一职责是所有设计原则的基础,开闭原则是设计的最终目标。里氏替换原则强调子类替换父类后程序运行时的正确性,用于帮助实现开闭原则。接口隔离原则在帮助实现里氏替换原则的同时,也体现了单一职责。依赖倒置原则是过程式编程和OO编程的分水岭,也被用来指导接口隔离原则。【本文为专栏作家《ThoughtWorks》原创稿件,微信公众号:Thinkworker,转载请联系原作者】点此查看该作者更多好文
