软件设计原则这个话题看上去很大,乍一看确实不小,但是如果仔细去分析的话可以发现这些原则其实就是为了避免一些问题而提出的一些建议,这些建议呢普遍使用于软件的各个领域,所以给这些建议提高了一个档次就叫做原则了,哈哈,纯属本人理解。
首先,为什么要有软件设计原则?软件设计原则的目的是为了让我们编写出更好的代码,那什么是“更好的代码”?“更好的代码”就是使代码更简洁、更易读、更具有可维护性以及更具有可扩展性。那么我们写代码或者设计代码结构的时候不遵循软件设计原则可以吗?答案是可以的。因为软件设计原则不像是Java语法一样的硬性要求,不这么做编译就不通过,你的程序就运行不了,相反,不遵循这七大设计原则你的代码照样能够运行。那么所谓的设计原则就是在大量的工程实践的基础上以及科学研究的基础上总结出来的一些经验和理念,我们在设计以及编写代码的过程中要尽量地借鉴前人的一些好的经验来使我们自己少走弯路,这也是软件设计原则的意义所在。
那么软件设计的过程中有哪七大原则呢?分别是开闭原则(Open-Closed Principle,OCP)、依赖倒置原则(Dependence Inversion Principle,DIP)、单一职责原则(Simple Responsibility Pinciple,SRP)、接口隔离原则(Interface Segregation Principle,ISP)、迪米特原则(Law of Demeter,LoD)、里氏替换原则(Liskov Substitution Principle,LSP)和合成复用原则(Composite/Aggregate Reuse Principle,CARP)。下面就逐一介绍一下。
一、开闭原则(Open-Closed Principle,OCP)
开闭原则即对扩展开放对修改关闭,即用扩展而不是修改来适应需求的变化。我们知道系统在正式发布上线之后如果去修改既有代码那么可能会引入一些风险,比如我们修改A功能的代码可能会使既有的B功能受到影响,如果控制不当的话就会出现A功能符合了新需求的变化但同时B功能出问题了,所以我们要进行回归测试来最大限度地降低这种风险的存在。那么开闭原则就是针对类似这种现象而提出来的,并且这个原则是其他六个设计原则的基础,或者说其他六个设计原则是开闭原则在不同方面的实现。
我们来举一个例子来说明开闭原则:张三买了一辆宝马,然后就开这辆宝马。我们来看一下代码实现:
这样看没有任何问题。现在需求变更了:张三不开宝马了,改成开奔驰了。那如果不遵循OCP就得将Person类里面的drive方法的入參由BMW改成Benz:
在调用方(main方法)也要跟着改:
也就是说如果张三不停地换车,代码从上到下都要频繁地跟着改,前面说过更改既有的代码意味着引入风险。所以,就要用OCP来约束这种情况的发生。看以下代码的改造:
调用方面向接口编程或者抽象编程:
这样无论张三再怎么换车,调用方(main方法)和Person类的drive方法都不需要跟着修改了,只需要在入口处注入不同品牌车的实例即可,这样一来代码就灵活了,扩展性就强了。平时我们说的面相接口编程或者面相抽象编程其实就是对OCP原则的一种应用。所以要深刻理解这句话——用扩展来适应需求的变化而不是用修改来适应变化,这就是OCP。
二、依赖倒置原则(Dependence Inversion Principle,DIP)
在介绍依赖倒置原则之前先说两个概念和一个前提。这两个概念是高层模块和底层模块,高层模块就是服务的调用方,底层模块就是服务的提供方。前提就是基于一个事实:抽象层的东西是不经常变动的,实现是经常会变动的,如果抽象层的东西经常变动的话那么所说的这些原则都没有什么意义了。
DIP说得正式一点就是:高层模块不依赖于低层模块,二者应依赖其(低层模块)抽象;抽象不依赖于细节,细节应依赖于抽象。说这些可能不太好理解,其实说白了DIP就解决一件事情——如何让高层模块不随着低层模块的变化而变化,换句话说就是无论服务提供者怎么变,只要接口不变那么服务调用方就不会变。什么意思?还是拿上面OCP的例子来举例。
main方法作为调用方是高层模块,BMW或者Benz是具体的实现为低层模块,那么在main方法里面直接new一个BMW出来就将高层模块和低层模块直接产生了关联也就是直接耦合在了一起,如果需求发生变更:张三不开宝马了改成了开车奔驰,那么调用方第31行代码必须作出修改:将new BMW()改成new Benz()。那么如何解决低层模块的变化不影响到高层模块呢?就是二者共同依赖低层模块的抽象ICar——调用者面向接口ICar编程,这样张三在换车的时候注入进来新车的实例即可而无需做任何改动。最后我们可以看到张三换车这个例子不仅很好地解释了OCP原则也很好地解释了DIP原则,所以这里也要很好地感谢张三频繁地换车。
三、单一职责原则(Simple Responsibility Pinciple,SRP)
这个原则从名字就可以看出来,就是要求一个类或者一个方法只干一件事。我们说类是对现实实体的抽象,比如人类。人类具有姓名、性别等这些属性,还有吃饭和睡觉等一些列行为,那么我们可以抽象成一个Person,将这些属性和行为封装进去。同时犬也是一种实体,我们可以封装成Dog类,但是将人类和犬类的所有属性和方法都封装进Person类肯定不合适吧!这样Person类就是对现实中两类不同实体的抽象,明显不合适,这就是类的单一职责。方法的单一职责也是一样的,一个方法只干一件事,如果想在这个方法中干另一件事就不妨另写一个方法,然后用这个方法去调用。这样既有利于代码的可读性也使代码更加简洁,同时也降低了“改逻辑1的代码会影响逻辑2代码”这种情况的发生,这也使程序的可维护性大大提高。单一职责原则就这么简单,也没必要写代码来说明了。
四、接口隔离原则(Interface Segregation Principle,ISP)
接口隔离原则就是说要使用多个具体的接口而不是使用单一的总接口,程序不应该依赖于它所不需要的接口。什么意思?举个例子就明白了。现在我有一个总体的接口IAnimal,它里面包括个各种动物的方法,什么天上飞的、地上跑的、水里游的都包括:
问题在截图的注释里描述得很清楚了,解决方法就是将总接口插分成不同的小接口,然后让Horse类去实现它所需要的接口即可。
五、迪米特原则(Law of Demeter,LoD)
一听这个名字就会有人问:迪米特这哥们一定很牛叉吧,用他的名字来命名软件设计原则,是不是和詹姆斯高斯林一样啊?呵呵...首先迪米特不是哥们儿,也不是姐们儿,是一个项目的名字,它的提出人是伊恩·荷兰(Ian Holland)。这个原则是说一个类(或者类的对象)要对其他类(或对象)保持最少的了解,又称最少知道原则;一个类要和它的直接朋友打交道不和陌生人打交道。什么是“一个类的直接朋友”?类的成员变量所属的类、类方法入參的类型、方法返回值的类型是这个类的“直接朋友”,其他的比如方法里面用到类都是“间接朋友”或者“陌生人”。是不是有点像黑社会?我们平时看港片经常会有这样的情节:警察抓到了一个贩毒集团的下线,这个人只知道跟他直接交易的上线是谁,上线的上线就不知道了,因为知道的越少对他越有利;当一个杀手用枪指着一个人的时候,在临死前问这个杀手为什么要杀他,得到的回答就一句话:“因为你知道的太多了!”
用一个实际的例子来说明迪米特法则:开班会之前老师让班长去买水果,然后把买的是什么水果告诉她。这个里面可以把老师抽象出一个实体,学生抽象出一个实体,水果抽象出一个实体。老师让学生去买水果,所以学生和水果打交道就可以了,老师不需要和水果打交道,老师只是发出一个命令最后要得到水果名字这样一个结果。按照LoD原则,老师和学生是“直接朋友”,老师和水果是“间接朋友”。
在上面这个例子中,Fruit作为Teacher类command方法的入參类型,也就是Fruit成为了Teacher的“直接朋友”,这明显不符合LoD原则。既然老师不需要和水果打交道,那应该把创建水果对象的工作放到学生类里面。改造如下:
这样就符合了迪米特法则。
六、里氏替换原则(Liskov Substitution Principle,LSP)
首先Liskov确实是一个人,她是美国第一个获得计算机科学博士学位的女性。Liskov于1987年提出了一个关于继承的原则“Inheritance should ensure that any property proved about supertype objects also holds for subtype objects.”——“继承必须确保超类所拥有的性质在子类中仍然成立。”也就是说,当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有is-A关系。(摘自百度百科:https://baike.baidu.com/item/Barbara%20Liskov/1578598?fr=aladdin)
她的主要观点翻译过来就是:若对每个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,用o1替换o2后,程序P的行为不变,则S是T的子类型。这样看有点不知所云,我再给翻译一下其实就是一句话:用子类去完全替换父类的话那么程序的行为应该保持不变。为了达到这个目的可以细分为以下三点:
1、子类不能复写父类的方法。
子类对象完全替换父类对象之后使程序的行为保持不变的最好的方式就是子类不要去复写父类的方法。原来用父类的对象调用的一定是父类的方法,现在换成了子类的对象,如果子类复写了这个方法那么在多态的情况下就会调用子类的方法,那么就很可能出现和父类方法不一致的行为。网上有好多博客在解释LSP的时候用长方形和正方形来举例子,这个例子其实不错,读者有兴趣可以去参考其他资料来学习。这里我想举一个更形象化的例子来说明LSP,假设有这样一个场景:一个人想骑着一只鸟到天上飞。
这个例子已经充分地说明子类复写了父类的方法之后表现出了截然相反的功能,而接口fly()方法是没有变化的,所以调用者感知不到底层实现已经被替换,等上线之后会发现fly()接口干的已经不是以前的事情了。这就是为什么在LSP的原则下为了保证原有程序不变而强制要求子类不能复写父类的方法。
2、子类可以实现父类的抽象方法也可以添加新的方法。
这个就不必多说了,但是要注意子类实现父类的抽象方法的规则和子类复写父类非抽象方法的规则是一样的,这些规则包括方法名、参数列表、方法访问控制权限、方法返回值类型范围、声明抛出异常的范围,这一点下面会用到。
3、当子类重载父类的方法时,方法入參的范围要比父类的更大。我们说方法重载的要求是方法名一定要一样,方法的参数列表(类型、数量和顺序)必须不一样,对其他的诸如返回值类型等没有要求,而LSP在重载规则基础上提出了入參类型范围的要求。其实理解这点非常简单,只要我们知道方法的重载有个入參类型的就近原则以及LSP的目的就知道为什么要这么规定了。我们来review一下就近原则:
这就是方法重载的就近原则——当有多个方法可以接收某个入參时会选择离入參类型范围最近的那个方法去执行,所以会调用父类的met方法,这也是LSP的目的——当有子类重载父类的方法时最终要保证调用的是父类的方法,这样才可以保证被子类替换后程序保持不变的行为。
在网上有好多资料都说还有第4点——当子类的方法实现(重写/重载或实现抽象方法)父类的方法时,方法返回值的类型范围要比父类更小或相等。其实在经过仔细分析之后我认为这一点的提出有点鸡肋,可有可无,所以在本篇博客中我大胆地把这一点去掉了。我们可以详细地分析一下这第4点:
子类实现父类的方法有三种方式——重写/重载或实现抽象方法,前面也说过LSP不允许子类重写父类的方法,所以实现方式就剩两个了——重载或实现抽象方法,我们来看重载的情况。在上面的第3点说过子类重载父类的方法时入參的范围必须比父类大,这样才能保证被替换了子类之后调用的还是父类的方法,从而保证程序行为的不变。那既然这样无论子类怎么重载,在子类中这个重载的方法都不会被调用到,所以它的返回值的范围大小也就无所谓了,是不是这样!再来看最后一个子类实现父类方法的方式——实现抽象方法。前面我们说子类不能复写父类的方法的非抽象方法,但是可以实现父类的抽象方法,而实现抽象方法的规则和复写非抽象方法的规则是一样的,这个规则是Java语法层面规定的,是强制的,不这么做编译就不通过。什么意思呢?“当子类实现父类的抽象方法时,方法返回值的类型范围要比父类更小或相等”这个规则不是LSP提出来的,是Java语法就这么规定的,不这么做编译就不通过,而且肯定是先有Java语法再有里氏替换原则。我们说Java语法是必须遵守的,软件设计的七大原则是前人的经验总结,不是硬性要求,并且Java也不可能把软件设计的原则提高到语法层面来做强制要求。所以,“当子类实现父类的抽象方法时,方法返回值的类型范围要比父类更小或相等”这句话是和LSP半毛钱关系没有,放在这只是把Java的语法重复一遍。经过以上的分析我们可以看到第4点没有存在的必要,这仅代表本人的观点,如有不当之处欢迎指正!
七、合成复用原则(Composite/Aggregate Reuse Principle,CARP)
这个原则是说优先使用方法的组合或者聚合来达到代码复用的目的。我们先来看一下继承的缺点以及关联、聚合、组合是什么概念。
首先继承有其弊端,比如破坏了封装、加强了类之间的耦合,那么既要实现代码的复用又想避免继承的弊端可以使用聚合来代替继承。A聚合B,就是在A类里面持有一个B类的成员变量。A类和B类是完全独立的,A类可以使用B类的对象来调用B类的方法或者属性,达到对B类代码复用的目的。
下面说一下继承、关联、聚合、组合的区别:
1、继承
不用多说了,A继承B,那么A就是B,但反过来B不一定是A。所以继承是is-a的关系,是一种纵向的关系。
2、关联
关联是两个类之间有联系,这种联系是很微弱的或者说是临时的,比如人过河要坐船,人和船就产生了联系,但是这种联系是临时的,人过了河就跟船没关系了。表现在代码层面就是A关联B,那么B以A的成员变量的形式存在在类A中。
3、聚合
聚合是关联关系的一种,也是两个事物要产生关系,但是这个关系的强度要大于关联关系,不是微弱或者临时的,是比较长久的关联,比如员工和公司的关系。但是这种互相关联的两个对象彼此又是独立的,各有各的生命周期,如果拆散这种关系那么各自也能玩儿的转。就好比员工和公司是一种长期的关系,但是公司和员工是彼此独立的,各有各的生命周期,公司没有张三这个员工也能玩儿的转,反过来张三不在这个公司去别的公司也玩儿的转。这种关系体现的是has-a的关系,代码表现和关联关系一样。
4、组合
组合是比聚合更紧密的关联关系,是一种强关联关系,具有组合关系的两个事物谁都不能缺少谁,如果断开这种关系各自都玩儿不转了,比如人和大脑的关系。这中关系是contains-a的关系,代码表现和关联关系一样。
所以从代码层面上看关联、聚合和组合表现形式都是一样的,得从语义上来区分,他们三个都是横向的关系。
那什么时候用继承又什么时候用组合呢?如果你要对现实世界建模,比如要描述狗类和动物的时候就要用继承,因为这些在概念上具有层次关系;如果你想表述你个实体要借助另一个实体来完成某项任务或者想表达has-a或者contains-a的时候就要用组合;如果只是在实现层面想达到代码复用的目的,那么优先使用组合。
相信大家已经知道聚合和组合该怎么使用了,我就不用用代码来举例子了。
软件设计的七大原则就说到这里,欢迎大家一起讨论。