1.4 SOLID设计原则
前面几节谈了设计代码结构所应遵循的理念。笔者通过一些例子,详细演示了面向对象编程(OOP)的四个基本概念,也就是A(抽象)、P(多态)、I(继承)、E(封装)。大家应该已经知道了类与类的实例在面向对象开发工作中所具备的意义,而且应该已经明白了如何创建各种类型的对象,如图1.11所示。
图1.11 有了Vehicle这个类,我们就能创建任意多个(或者说,N个)该类的实例,这些实例都是Vehicle类的对象(N为正整数)
类能够加以实例化从而产生该类的一个实例,这个实例也称为该类的一个对象。计算机必须在当前可用的内存空间(free memory,即空闲内存)中找个地方来安置这个对象。于是我们可以说,对象促使计算机为其分配内存空间。但具体到Java这门语言,这个空间并不是直接指计算机的物理内存空间,而是根据物理内存空间构造的虚拟空间。
为什么会是这样呢?我们前面说过,有一个叫作JVM的东西负责把编译过的字节码解释成能够在特定平台上执行的机器码(参见图1.3)。当时我们提到了JVM的各种功能,其中一项是内存管理。所以说,构造虚拟内存空间(virtual memory space)正是JVM的一项职责。有了这样的虚拟内存空间,我们就可以把某类的某一个实例安置在这个空间中(或者说,我们就可以在这样的虚拟内存空间中给某类的某一个实例分配内存)。时间久了,空间中会出现许多分散的小片空白区域,也就是出现内存碎片(memory fragmentation),不过没关系,JVM有特定的垃圾收集算法(garbage collection algorithm)能够清理无用的对象,从而消除碎片,这个话题已经超出了本书的讨论范围,大家可以查阅本章的参考资料,以深入研究。
每个程序员都是软件设计师,只不过,并非所有人都能立刻意识到这一点。代码当然是由程序员写的,但程序员不是为了写代码而写代码的,程序员是要通过写代码,把某个意思表述成计算机能解读的形式,至于怎么才能表述得准确、清晰,这就要靠设计了。
软件开发的手法已经演化了好几代,而且有许多文章都讨论过如何设计易于维护、易于复用的软件。其中一个重要节点出现在2000年,Robert C.Martin(Bob大叔)发表了一篇论文“Design Principles and Design Patterns”(参见本章的参考资料)。这篇论文讨论了软件开发中的一些设计与实现技术。2004年,出现了一个首字母缩略词,用来概括这些技术,这个词就是SOLID。
SOLID原则是为了帮助软件设计者做出良好的软件,让软件的结构更加持久,更容易复用,也更容易扩展。下面几小节将分别讲解SOLID中每个字母所对应的原则。
1.4.1 单一功能原则——每个类只负责一件事
SOLID原则的第一条是S,表示SRP(Single-Responsibility Principle,单一功能原则),这条原则清楚地指出应该如何设计类。每个类都只应该有一个存在的理由。换句话说,每个类都只应该为负责完成某一项功能而存在。这个类需要把它所负责完成的这一部分功能给封装起来。下面我们举例解释这条原则。还是以车辆为例,我们扩展车辆的Vehicle类,创建两个类——Engine与VehicleComputer,如图1.12所示。
图1.12 Vehicle类的实例会利用Engine与VehicleComputer类来让整个车辆正常运作,但Vehicle本身并不负责那两个类各自所应负责的事情,而且那两个类之间的分工也很明确
发动机(Engine)可以启动(start)也可以停止(stop),这都是它应该支持的功能,但Engine类不需要控制车灯。控制车灯是VehicleComputer类的职责。
1.4.2 开闭原则
开闭原则(Open-Closed Principle,OCP)的意思是,我们设计类或实体的时候,应该考虑让这个类或实体“对扩展开放,但是对修改封闭”[2]。这条原则与前面讲过的一些概念也有联系。比方说,我们设计了一个用来表示车辆的Vehicle接口给大家用,于是,有人就想让Car与Truck这两个类来实现Vehicle接口,因为它们都属于车辆。而且他们会认为,用来描述车辆共性的Vehicle接口有一个move方法,用来让车辆移动。
问题是,假如我们当初设计Vehicle接口时,没能把这个move方法给抽象出来,或者说,没能遵循开闭原则,那么其他人在扩展这个接口的时候,就不太容易了,对方只能指望我们先修正该接口的设计方案,然后才能加以复用,这就与OCP所追求的“易于扩展且无须频繁修改”这一理念相违背(参见范例1.5)。
范例1.5 虽然Truck和Car类都实现了Vehicle接口,但由于该接口没能适当抽象出所有车辆都应支持的move方法,因此即便这两个类本身均定义了move方法,我们也没有办法将其视为通用的Vehicle对象来加以移动
由于我们当初设计Vehicle接口时没有充分考虑OCP原则,因此现在必须回过头来修改此接口,只有这样,大家才能顺利地扩展它。对于本例来说,这个修改很容易完成(参见范例1.6)。
范例1.6 Vehicle接口提供move抽象方法
这个例子明确地告诉我们,如果不针对后续的扩展做出一些设计,那么以后可能就得回过头来修改当时的方案。
1.4.3 里氏替换原则——子类必须能够当作超类来使用
前面几条原则涉及了OOP的两个基本概念——继承与抽象。对于继承,细心的读者应该会意识到,它是类体系中处于下层的类,其对象可以当作上层类(即超类)的对象来使用(参见范例1.7)。下面以洗车为例,CarWash类的wash方法,能够清洗各种车辆。
里氏替换原则(Liskov Substitution Principle,LSP)意味着与某个类相似的子类型也可以当作这个类来使用。这最初来自Barbara Liskov在1987年会议上发表的主题演讲(参见本章第3个参考资料),该会议聚焦数据抽象与层级。该原则是想强调类实例的可替换性[3],并促进接口隔离。所以我们接下来将介绍接口隔离原则。
范例1.7 wash方法的vehicle参数是Vehicle型,因此,所有在类体系中处于Vehicle之下的类型,无论是它的直接子类型(例如,Car),还是间接子类型(例如,SportCar),其对象都能够当成Vehicle传给这个参数
1.4.4 接口隔离原则
接口隔离原则(Interface Segregation Principle,ISP)要求某个类的实例不应该依靠那些虽然抽象了出来但是却用不到的方法。该原则还能指导我们调整接口与抽象类的设计。换句话说,它促使我们把相关的方法分隔到一些更为细致的实体中,而不是抽象到一个大而无当的超类或接口中。这样的话,用户就能更为清晰地使用这些实体。我们现在举个反例,比方说,我们抽象出了下面这样一个Vehicle接口,并让Car与Bike这两个类实现该接口。这样做会导致它们必须实现该接口中定义的所有方法,无论这些方法它们用不用得到,都必须予以实现(参见范例1.8)。
有些读者可能已经看出,按照这种方式设计软件会导致我们为了确保软件运作得合理而必须无谓地添加一些机制(例如,异常),以处理那些虽然继承下来但是却无法支持的操作,从而令软件变得不够灵活[4]。面对这样的设计,我们应该遵循ISP原则,把它抽象得更为清楚。例如,我们可以再设置两个接口——HasEngine与HasPedals,这样就能把原来位于Vehicle接口里的相关方法分别转移到相应的接口中了(参见范例1.9)。而且这样做,会迫使我们重载printIsMoving方法。修改之后的代码,用户使用起来很清晰,因为我们不用像原来那样,为了确保软件运作得合理而添加特殊的处理逻辑,例如,我们不用再像刚才的范例1.8那样,通过异常来表达不受支持的方法,因为那样的方法现在根本不会出现在无须使用它的子类之中。
范例1.8 为继承下来但是却无法支持的抽象方法提供实现代码
范例1.9 根据功能,把原来的大接口拆分成小接口
添设HasEngine与HasPedals这两个接口不仅迫使我们改写原来较为混乱的printIsMoving方法,以便用相应的重载版本来处理相应的对象,而且还让代码变得更为清晰。
1.4.5 依赖反转原则
每一位程序员,或者更准确地说,每一位软件设计师,都要面对这样一个问题:如何将自己设计过的各种类体系恰当地组合起来。现在要讲的这条依赖反转原则(Dependency Inversion Principle,DIP)为这个问题提供了一个相当简单的建议。
这条原则要求高层类(也就是需要使用其他类来运作的类)不应该知晓底层类,而是应该把它需要使用的底层类抽象成一个接口,让各种具体的底层类都去实现这个接口,这样的话,高层类就可以转而依赖此接口了(参见范例1.10中的SportCar类)。
范例1.10 让表示停车场的Garage类去依赖抽象的车辆接口Vehicle,这样它就不用依赖类体系中表示各种具体车辆的那些类了
这条原则也意味着,我们在实现某项功能时,不应该直接说这项功能只针对某种特定的类,而应该说这项功能针对的是某种抽象的接口,并让那些特定的类去实现该接口(比方说,在范例1.10中,我们在实现Garage类的park功能时,不应该直接说这个停车场只能停哪几种车,而应该说凡是车辆都能停进来,换句话说,凡是实现了Vehicle接口的类,其实例都可以传给park方法)。