单一职责原则
定义
不要存在多于一个导致类变更的原因。通俗的说,即一个类只负责一项职责。
问题由来
类T负责两个不同的职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。
解决方案
遵循单一职责原则。分别建立两个类T1、T2,使T1完成职责P1功能,T2完成职责P2功能。这样,当修改类T1时,不会使职责P2发生故障风险;同理,当修改T2时,也不会使职责P1发生故障风险。
说到单一职责原则,很多人都会不屑一顾。因为它太简单了。稍有经验的程序员即使从来没有读过设计模式、从来没有听说过单一职责原则,在设计软件时也会自觉的遵守这一重要原则,因为这是常识。在软件编程中,谁也不希望因为修改了一个功能导致其他的功能发生故障。而避免出现这一问题的方法便是遵循单一职责原则。虽然单一职责原则如此简单,并且被认为是常识,但是即便是经验丰富的程序员写出的程序,也会有违背这一原则的代码存在。为什么会出现这种现象呢?因为有职责扩散。所谓职责扩散,就是因为某种原因,职责P被分化为粒度更细的职责P1和P2。
比如:类T只负责一个职责P,这样设计是符合单一职责原则的。后来由于某种原因,也许是需求变更了,也许是程序的设计者境界提高了,需要将职责P细分为粒度更细的职责P1,P2,这时如果要使程序遵循单一职责原则,需要将类T也分解为两个类T1和T2,分别负责P1、P2两个职责。但是在程序已经写好的情况下,这样做简直太费时间了。所以,简单的修改类T,用它来负责两个职责是一个比较不错的选择,虽然这样做有悖于单一职责原则。(这样做的风险在于职责扩散的不确定性,因为我们不会想到这个职责P,在未来可能会扩散为P1,P2,P3,P4……Pn。所以记住,在职责扩散到我们无法控制的程度之前,立刻对代码进行重构。)
举例说明,用一个类描述动物呼吸这个场景:
class Animal{
public void breathe(String animal){
System.out.println(animal+"呼吸空气");
}
}
public class Client{
public static void main(String[] args){
Animal animal = new Animal();
animal.breathe("牛");
animal.breathe("羊");
animal.breathe("猪");
}
}
运行结果:
牛呼吸空气
羊呼吸空气
猪呼吸空气
程序上线后,发现问题了,并不是所有的动物都呼吸空气的,比如鱼就是呼吸水的。修改时如果遵循单一职责原则,需要将Animal类细分为陆生动物类Terrestrial,水生动物Aquatic,代码如下:
class Terrestrial{
public void breathe(String animal){
System.out.println(animal+"呼吸空气");
}
}
class Aquatic{
public void breathe(String animal){
System.out.println(animal+"呼吸水");
}
}
public class Client{
public static void main(String[] args){
Terrestrial terrestrial = new Terrestrial();
terrestrial.breathe("牛");
terrestrial.breathe("羊");
terrestrial.breathe("猪");
Aquatic aquatic = new Aquatic();
aquatic.breathe("鱼");
}
}
运行结果:
牛呼吸空气
羊呼吸空气
猪呼吸空气
鱼呼吸水
我们会发现如果这样修改花销是很大的,除了将原来的类分解之外,还需要修改客户端。而直接修改类Animal来达成目的虽然违背了单一职责原则,但花销却小的多,代码如下:
class Animal{
public void breathe(String animal){
if("鱼".equals(animal)){
System.out.println(animal+"呼吸水");
}else{
System.out.println(animal+"呼吸空气");
}
}
}
public class Client{
public static void main(String[] args){
Animal animal = new Animal();
animal.breathe("牛");
animal.breathe("羊");
animal.breathe("猪");
animal.breathe("鱼");
}
}
可以看到,这种修改方式要简单的多。但是却存在着隐患:有一天需要将鱼分为呼吸淡水的鱼和呼吸海水的鱼,则又需要修改Animal类的breathe方法,而对原有代码的修改会对调用"猪""牛""羊"等相关功能带来风险,也许某一天你会发现程序运行的结果变为"牛呼吸水"了。这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却是最大的。还有一种修改方式:
class Animal{
public void breathe(String animal){
System.out.println(animal+"呼吸空气");
}
public void breathe2(String animal){
System.out.println(animal+"呼吸水");
}
}
public class Client{
public static void main(String[] args){
Animal animal = new Animal();
animal.breathe("牛");
animal.breathe("羊");
animal.breathe("猪");
animal.breathe2("鱼");
}
}
可以看到,这种修改方式没有改动原来的方法,而是在类中新加了一个方法,这样虽然也违背了单一职责原则,但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。这三种方式各有优缺点,那么在实际编程中,采用哪一中呢?其实这真的比较难说,需要根据实际情况来确定。我的原则是:只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则;
例如本文所举的这个例子,它太简单了,它只有一个方法,所以,无论是在代码级别上违反单一职责原则,还是在方法级别上违反,都不会造成太大的影响。实际应用中的类都要复杂的多,一旦发生职责扩散而需要修改类时,除非这个类本身非常简单,否则还是遵循单一职责原则的好。
遵循单一职责原的优点有:
- 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多;
- 提高类的可读性,提高系统的可维护性;
- 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。
需要说明的一点是单一职责原则不只是面向对象编程思想所特有的,只要是模块化的程序设计,都适用单一职责原则。
陈述:
就一个类而言,应该只有一个导致其变化的原因
分析: 一个职责就是一个变化的轴线。 一个类如果承担的职责过多,就等于将这些职责耦合在一起。一个职责的变化可能会虚弱或者抑止这个类完成其它职责的能力。
多职责将导致脆弱性的臭味。
示例1:
Rectangle类具有两个职责: 计算矩形面积的数学模型 将矩形在一个图形设备上描述出来
Rectangle类违反了SRP,具有两个职能——计算面积和绘制矩形 这种对SRP的违反将导致两个方面的问题: 包含不必要的代码 一个应用可能希望使用Retangle类计算矩形的面积,但是却被迫将绘制矩形相关的代码也包含进来 一些逻辑上毫无关联的原因可能导致应用失败 如果GraphicalApplication的需求发生了变化,从而对Rectangle类进行了修改。但是这样的变化居然会要求我们重新构建、测试以及部署ComputationalGeometryApplication,否则其将莫名其妙的失败。
修改后的设计如下:
示例2: 一个Modem的接口:
- Class Modem{
- public:
- virtual void dail(char* pno)=0;
- virtual void hangup( ) =0;
- virtual void send(char c) =0;
- virtual void recv( ) =0;
- };
Class Modem{ public: virtual void dail(char* pno)=0; virtual void hangup( ) =0; virtual void send(char c) =0; virtual void recv( ) =0; };
Modem类(可能)有两个职责:
- 拨号
- 通信
- 连接和通信可能独立变化
- 连接和通信同时变化
- 类的复杂性降低,实现什么职责都有清晰明确的定义;
- 可读性提高,复杂性降低,可维性提高;
- 变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影
- 持久化与业务规则的耦合。
- 如果一个类承担的职责过多,就等于把这些职责耦合在一起,一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计,当变化发生时,设计会遭受到意想不到的破坏。
- 我们会自然地把职责结合在一起。如果能想到多于一个动机去改变一个类,那么这个类就具有多于一个的职责,就应该考虑类的职责分离。
- 软件设计真正要做的许多内容,就是发现职责并把那些职责相互分离。
- SRP是所有原则中最简单、也是最难运用的。其余原则会以这样或那样的方式回到这个问题上。
- 单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口类或类设计得是否好,但“职责”和“变化原因”都是 不可度量的,因项目而异,依环境而异。
- 单一职责原则很难在项目中得到体现。考虑工期、成本、人员技术水平、硬件情况甚至项目之外的因素。
- 建议:接口一定做到单一职责,类的设计尽量做到只有一个原因引起变化。
最近在实践微服务化过程中,对其“单一职责”原则深有体会。那么只有微服务化才可以单一职责,才可以解耦吗?答案是否定的。
单一职责原则是这样定义的:单一的功能,并且完全封装起来。
我们做后端Java开发的,应该最熟悉的就是标准的3层架构了,尤其是使用Spring.io体系的:Controller、Service、Dao/Repository。为什么要分层?就是为了保证单一职责,数据模型的事情交给Controller,业务逻辑的事情交给Service,和数据打交道的事情就交给Dao/Repository。有时候或者有些人会分层分的更多,4层,5层,我自己也这样干过,说白了也是为了保证单一职责,3层不能满足单一职责了,耦合度高了,就分。
我们都知道一个webapp在经过一定时间的开发后,就惨不忍睹,即便是有标准的分层,页面或模板文件一大堆,最初的很清晰的3层标准架构也变味了,Controller,Service,Dao/Repository各层之间、Service之间、Dao/Repository之间互相调用,一团乱麻。这个时候没改一行代码都有可能一个老鼠害了一锅汤,bug就如同蚂蚁洞。
这些问题最后就造成:
- 可扩展性灵活性差,出现性能问题
- 业务变更和开发困难,维护成本很高,交付时间长
- 回归测试量很大
- ...
为了解决这些问题,就需要时时刻刻清楚的记住“单一职责”,单一职责可以用到软件开发的任何地方。
应该说职责分离来解耦是最常用最有效的架构方法,这能够很大限度的简化一切。
下面就从软件开发、设计、架构,以及重构/演进/进化,从小到大几个方面来说说单一职责:
类方法/函数
这应该是最小的能体现单一职责的程序单元了。最熟悉的最典型的莫过于Helper/Utils类方法了,但这种类方法的特征很明显,也很容易遵循单一职责,99%以上的开发人员都可以做到。但不仅仅这样的类方法要遵循单一职责原则,每一个类方法都应该遵循单一职责原则,尤其是一些处理业务逻辑的类方法更要遵循单一职责原则,处理业务的类方法通常要配合类的单一职责原则进行,下节中讨论。
因此,这也是为什么很多Team Leader要求类方法代码行数保持在20行左右,其实就是为了保证单一职责,20行左右是一个经验粗略数字,当然,10行或者30行来完成类方法也是可以的。大部分单一职责的类方法用20行左右的代码就够了,如果超过20行就要考虑是否保证了单一职责了。那我们在迭代重构的过程中就要考虑拆分这样的类方法来保证单一职责。
类方法的单一职责是最单纯的,很具体的,不掺杂任何额外信息,只关心输入、输出、和职责;一定要明确地定义类方法的职责,保证在迭代中不被错误的扩展,不被调用者错误地使用。
类/函数文件
要用面向对象的设计方法,单一职责原则来定义类。开发人员一定要很好地理解“单一职责原则”,具有面向对象的抽象思维能力。
当在迭代中一个类过于庞大或者快速膨胀,说明已经有坏味道了,这时候就需要考虑用单一职责原则或者面向对象的分析方法来重构和重新定义类了,通常就是要抽象和拆分类,否则将来会变成一个方法容器。
把类比作一个人,她的职责就是完成自己职责范围内的事情,如果她什么事情都管,就叫多管闲事,可以想象她多管闲事的后果,会搅得鸡犬不宁。同样,类也是,类如果多管闲事,那会搅得整个应用不稳定,漏洞百出,还很难修复。所以说定义一个类,要明确这个类的职责。使用面向对象的分析和设计方法,能很好地准确定义一个类的职责范围,通常会用到封装、继承、多态和抽象等设计方法。
包结构/文件夹
分层就是最常用的架构方法之一,分层具体体现在分包和分类,就是分门别类的意思。俗话说,物以类聚,人以群分。
包结构在单一职责原则上是类的补充,职责范围进一步扩大。如果把一个类叫做一个人,那么包就是一个最小单位的团队,职责就是负责一类特定事情。 如何分包呢?那就要用到分类学的知识了,要以什么特征来分,可能不仅仅只有一种特征,比如,先用公司域名来做基础包名,这里叫一级包名;然后再用一个特定的有意义的标识作为二级子包名;之后按分层(web,dao,service等等)方法做三级包名,也可以先按照业务再按分层。例如:
域名:tietang.wang 有个项目叫:social 那么我可以这样分: wang.tietang - social - web - service - dao - commons 也可以这样: wang.tietang - commons - user - web - service - dao - relation - web - service - dao
多工程/module
通常以多maven module或者gradle 多module形式存在,来保证单一职责。
当业务量还没有达到服务拆分的火候,通常在一个APP发展的太庞大时或者在工程建设初期时,需要规范和整理项目结构。这个时候需要多工程从文件系统上隔离,通过module依赖来集成。需要注意的是这样的架构或拆分不是随意的,要以单一职责原则来拆分,更具体一点就是要根据业务、技术框架功能等特性来拆分。
比如,按技术组件拆分,通常会有一些技术组件,可以把她放到commons module,如果有多种类型的技术组件,就拆分为commons module的子module;也可以直接将这些技术组件拆分为独立的工程,存在于独立的git/svn仓库,独立管理,专人负责,其他哪些module需要就依赖她。那拆分的这些技术组件的每一个应该遵循单一职责原则,例如数据分片的框架、NIO基础网络框架等等。
比如,按业务拆分,例如有用户、订单、商品、支付,那么就按照这些业务拆分为子module,每一个子module就只负责自己的业务逻辑,也遵循单一职责。
那每个module的职责范围又比类和包更大,这个时候职责也更模糊,有时候很难把握,对于技术组件可能相对清晰,而业务module就要熟悉业务,明确业务边界。
多module拆分后也是为将来服务化埋下伏笔,同时在物理文件系统比较清晰了,那在依赖管理上也要掌握好保持清晰的依赖逻辑,把握好单一职责原则。
微服务/可部署单元
微服务,从运行时隔离,但业务量发展到一定时候,从单体或者多module工程拆分或演化出来,可独立打包可独立部署并复合单一原则的application,当然了微服务所体现的价值不仅仅是隔离和独立部署,还有很多这里可以参考单体应用与微服务优缺点辨析。单一职责在微服务中的价值是最重要的,包含了app层面和开发app的团队层面,微服务的大部分优点都可以围绕单一职责来展开。
团队
先引用《韩非子·扬权》中的一段文字:
夫物者有所宜,材者有所施,各处其宜,故上下无为。 使鸡司夜,令狸执鼠,皆用其能,上乃无事。 上有所长,事乃不方。 矜而好能,下之所欺:辩惠好生,下因其材。 上下易用,国故不治。
各得其所,各司其职。所以,团队也要遵循单一职责原则,这样才能很好地管理团队成员的时间,提高效率。一个人专注做一件事情的效率远高于同时关注多件事情。同样一个人一直管理和维护同一份代码要比多人同时维护多份代码的效率高很多。每一个人都有自己的个性,他有自己的擅长,让每一个人专注自己擅长的事情,那肯定事半功倍,整个团队绩效肯定也很突出。
总之,引用古文名句说明了所有:
- 物以类聚,人以群分。
- 天下之事,分合交替,分久必合,合久必分!
- 使鸡司夜,令狸执鼠,皆用其能,上乃无事。
参考
为什么单一职责原则(SRP)是最难运用的 单一职责原则(SRP)已经几乎是每一个程序员都知道的设计原则。最早由Robert C. Martin在<<敏捷软件开发 — 原则、模式与实践>>中正式提出。书中作者在结论中提到: SRP是所有设计原则最简单的,但也是最难运用的。(中文翻译有之一,略去了)
现实工作中,关于一个类是否符合SRP,或者是否有必要符合SRP的讨论是经常发生的。争论的关键在于职责的定义,但我理解SRP真正的核心是关注于变化。这并不是我的新见解,全是来自Martin大叔的解释:
- 首先职责的定义是: 引起变化的原因,不是由分类所决定的。如果存在相对的变化,才要考虑分离。
- 其次,关于引起变化的因素,不要空想。一定确信有变化的可能,才会加以考虑。
他的提醒是非常中肯的。实践中正是常常基于功能的分类来定义职责的。
举个例子。假如我们要开发一个学校的教职员工管理系统。需要定义一个教师员工的类(炒菜的师傅先就不考虑了),考虑到老师和班主任两个角色,通常会认为他有两类职责: . 教师 (班主任很可能会带课) . 班级的管理 (组织班委,整治一下早恋之类的)
这时你拿着设计到了一个寄宿学校,校长可能会告诉你,他们这里的教师会轮流值班,兼做保育员,照看住校的学生。又是一个新的职责,怎么办?
如果遵守单一职责的原则,我们应该增加一个接口:
果真要如此吗? 注意,如果是在一般的学校,保育员不是老师的本职工作,可在这所寄宿学校里,却是教师的本职工作,是和老师一起变化的。校长的反馈是:
“我们学校的教师必须担任保育工作,我并不认为这会是什么新职责。作为教师,要么接受,要么离开。至于班主任工作,确实还是其特殊的地方,不然也不会给担任班主任的老师多一点津贴了。”。
请再体会一下,关于保育员职责的讨论。如果两个职责/角色不是同时变化的,才考虑分离。如果确定同时变化,就没有必要分离。除非有一天,某个劳动部门到该寄宿学校检查,认为他们这样不符合某个法律规定,强制规定老师可以选择是否担当保育员。如此一来,两个职责就又变成独立变化的了,就可以考虑分离职责。
再进一步,如果是针对一个只有一个支教教师的小学,极为偏僻。这里的校长会告诉你:
”这个学校里的每一个教师,唯一的一个,既是校长,也是老师。我不认为还需要明确班主任做什么,教师做什么,在这里,只要学生需要的都要做。并且这里很穷,五年内都不见得再有新老师来。”。
这个感人的故事告诉我们,在这所学校里, 他不在了,这所学校也就不在了,完全没有什么相对的变化,也没有什么可以确认的变化。所以在这里的管理系统里,教职员工只有一个实例类.
里氏替换原则
肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑。其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的。
定义 1
如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
定义 2
所有引用基类的地方必须能透明地使用其子类的对象。
问题由来
有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。
解决方案
当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。
继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。
继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。
举例说明继承的风险,我们需要完成一个两数相减的功能,由类A来负责。
class A{
public int func1(int a, int b){
return a-b;
}
}
public class Client{
public static void main(String[] args){
A a = new A();
System.out.println("100-50="+a.func1(100, 50));
System.out.println("100-80="+a.func1(100, 80));
}
}
运行结果:
100-50=50
100-80=20
后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。即类B需要完成两个功能:
- 两数相减。
- 两数相加,然后再加100。
由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下:
class B extends A{
public int func1(int a, int b){
return a+b;
}
public int func2(int a, int b){
return func1(a,b)+100;
}
}
public class Client{
public static void main(String[] args){
B b = new B();
System.out.println("100-50="+b.func1(100, 50));
System.out.println("100-80="+b.func1(100, 80));
System.out.println("100+20+100="+b.func2(100, 20));
}
}
类B完成后,运行结果:
100-50=150
100-80=180
100+20+100=220
我们发现原本运行正常的相减功能发生了错误。原因就是类B在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类B重写后的方法,造成原本运行正常的功能出现了错误。在本例中,引用基类A完成的功能,换成子类B之后,发生了异常。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
看上去很不可思议,因为我们会发现在自己编程中常常会违反里氏替换原则,程序照样跑的好好的。所以大家都会产生这样的疑问,假如我非要不遵循里氏替换原则会有什么后果?
后果就是:你写的代码出问题的几率将会大大增加。
依赖倒置原则
定义
高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。
问题由来
类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。
解决方案
将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。
依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。在java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。
依赖倒置原则的核心思想是面向接口编程,我们依旧用一个例子来说明面向接口编程比相对于面向实现编程好在什么地方。场景是这样的,母亲给孩子讲故事,只要给她一本书,她就可以照着书给孩子讲故事了。代码如下:
class Book{
public String getContent(){
return "很久很久以前有一个阿拉伯的故事……";
}
}
class Mother{
public void narrate(Book book){
System.out.println("妈妈开始讲故事");
System.out.println(book.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
运行良好,假如有一天,需求变成这样:不是给书而是给一份报纸,让这位母亲讲一下报纸上的故事,报纸的代码如下:
class Newspaper{
public String getContent(){
return "林书豪38+7领导尼克斯击败湖人……";
}
}
这位母亲却办不到,因为她居然不会读报纸上的故事,这太荒唐了,只是将书换成报纸,居然必须要修改Mother才能读。假如以后需求换成杂志呢?换成网页呢?还要不断地修改Mother,这显然不是好的设计。原因就是Mother与Book之间的耦合性太高了,必须降低他们之间的耦合度才行。
我们引入一个抽象的接口IReader。读物,只要是带字的都属于读物:
interface IReader{
public String getContent();
}
Mother类与接口IReader发生依赖关系,而Book和Newspaper都属于读物的范畴,他们各自都去实现IReader接口,这样就符合依赖倒置原则了,代码修改为:
class Newspaper implements IReader {
public String getContent(){
return "林书豪17+9助尼克斯击败老鹰……";
}
}
class Book implements IReader{
public String getContent(){
return "很久很久以前有一个阿拉伯的故事……";
}
}
class Mother{
public void narrate(IReader reader){
System.out.println("妈妈开始讲故事");
System.out.println(reader.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
mother.narrate(new Newspaper());
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
妈妈开始讲故事
林书豪17+9助尼克斯击败老鹰……
这样修改后,无论以后怎样扩展Client类,都不需要再修改Mother类了。这只是一个简单的例子,实际情况中,代表高层模块的Mother类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。
采用依赖倒置原则给多人并行开发带来了极大的便利,比如上例中,原本Mother类与Book类直接耦合时,Mother类必须等Book类编码完成后才可以进行编码,因为Mother类依赖于Book类。修改后的程序则可以同时开工,互不影响,因为Mother与Book类一点关系也没有。参与协作开发的人越多、项目越庞大,采用依赖导致原则的意义就越重大。现在很流行的TDD开发模式就是依赖倒置原则最成功的应用。
传递依赖关系有三种方式,以上的例子中使用的方法是接口传递,另外还有两种传递方式:构造方法传递和setter方法传递,相信用过Spring框架的,对依赖的传递方式一定不会陌生。
在实际编程中,我们一般需要做到如下3点:
- 低层模块尽量都要有抽象类或接口,或者两者都有。
- 变量的声明类型尽量是抽象类或接口。
- 使用继承时遵循里氏替换原则。
依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。
接口隔离原则
定义
客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
问题由来
类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法。
解决方案
将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则。
举例来说明接口隔离原则:
图 1 - 未遵循接口隔离原则的设计
这个图的意思是:类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。对类图不熟悉的可以参照程序代码来理解,代码如下:
interface I {
public void method1();
public void method2();
public void method3();
public void method4();
public void method5();
}
class A{
public void depend1(I i){
i.method1();
}
public void depend2(I i){
i.method2();
}
public void depend3(I i){
i.method3();
}
}
class B implements I{
public void method1() {
System.out.println("类B实现接口I的方法1");
}
public void method2() {
System.out.println("类B实现接口I的方法2");
}
public void method3() {
System.out.println("类B实现接口I的方法3");
}
//对于类B来说,method4和method5不是必需的,但是由于接口A中有这两个方法,
//所以在实现过程中即使这两个方法的方法体为空,也要将这两个没有作用的方法进行实现。
public void method4() {}
public void method5() {}
}
class C{
public void depend1(I i){
i.method1();
}
public void depend2(I i){
i.method4();
}
public void depend3(I i){
i.method5();
}
}
class D implements I{
public void method1() {
System.out.println("类D实现接口I的方法1");
}
//对于类D来说,method2和method3不是必需的,但是由于接口A中有这两个方法,
//所以在实现过程中即使这两个方法的方法体为空,也要将这两个没有作用的方法进行实现。
public void method2() {}
public void method3() {}
public void method4() {
System.out.println("类D实现接口I的方法4");
}
public void method5() {
System.out.println("类D实现接口I的方法5");
}
}
public class Client{
public static void main(String[] args){
A a = new A();
a.depend1(new B());
a.depend2(new B());
a.depend3(new B());
C c = new C();
c.depend1(new D());
c.depend2(new D());
c.depend3(new D());
}
}
可以看到,如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显然不是好的设计。如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。在这里我们将原有的接口I拆分为三个接口,拆分后的设计如图2所示:
图 2 - 遵循接口隔离原则的设计)
照例贴出程序的代码,供不熟悉类图的朋友参考:
interface I1 {
public void method1();
}
interface I2 {
public void method2();
public void method3();
}
interface I3 {
public void method4();
public void method5();
}
class A{
public void depend1(I1 i){
i.method1();
}
public void depend2(I2 i){
i.method2();
}
public void depend3(I2 i){
i.method3();
}
}
class B implements I1, I2{
public void method1() {
System.out.println("类B实现接口I1的方法1");
}
public void method2() {
System.out.println("类B实现接口I2的方法2");
}
public void method3() {
System.out.println("类B实现接口I2的方法3");
}
}
class C{
public void depend1(I1 i){
i.method1();
}
public void depend2(I3 i){
i.method4();
}
public void depend3(I3 i){
i.method5();
}
}
class D implements I1, I3{
public void method1() {
System.out.println("类D实现接口I1的方法1");
}
public void method4() {
System.out.println("类D实现接口I3的方法4");
}
public void method5() {
System.out.println("类D实现接口I3的方法5");
}
}
接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。本文例子中,将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的"契约",通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。
采用接口隔离原则对接口进行约束时,要注意以下几点:
- 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
- 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。
迪米特法则
定义
一个对象应该对其他对象保持最少的了解。
问题由来
类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
解决方案
尽量降低类与类之间的耦合。
自从我们接触编程开始,就知道了软件编程的总的原则:低耦合,高内聚。无论是面向过程编程还是面向对象编程,只有使各个模块之间的耦合尽量的低,才能提高代码的复用率。低耦合的优点不言而喻,但是怎么样编程才能做到低耦合呢?那正是迪米特法则要去完成的。
迪米特法则又叫最少知道原则,最早是在1987年由美国Northeastern University的Ian Holland提出。通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。迪米特法则还有一个更简单的定义:只与直接的朋友通信。首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。
举一个例子:有一个集团公司,下属单位有分公司和直属部门,现在要求打印出所有下属单位的员工ID。先来看一下违反迪米特法则的设计。
//总公司员工
class Employee{
private String id;
public void setId(String id){
this.id = id;
}
public String getId(){
return id;
}
}
//分公司员工
class SubEmployee{
private String id;
public void setId(String id){
this.id = id;
}
public String getId(){
return id;
}
}
class SubCompanyManager{
public List getAllEmployee(){
List list = new ArrayList();
for(int i=0; i<100; i++){
SubEmployee emp = new SubEmployee();
//为分公司人员按顺序分配一个ID
emp.setId("分公司"+i);
list.add(emp);
}
return list;
}
}
class CompanyManager{
public List getAllEmployee(){
List list = new ArrayList();
for(int i=0; i<30; i++){
Employee emp = new Employee();
//为总公司人员按顺序分配一个ID
emp.setId("总公司"+i);
list.add(emp);
}
return list;
}
public void printAllEmployee(SubCompanyManager sub){
List list1 = sub.getAllEmployee();
for(SubEmployee e:list1){
System.out.println(e.getId());
}
List list2 = this.getAllEmployee();
for(Employee e:list2){
System.out.println(e.getId());
}
}
}
public class Client{
public static void main(String[] args){
CompanyManager e = new CompanyManager();
e.printAllEmployee(new SubCompanyManager());
}
}
现在这个设计的主要问题出在CompanyManager中,根据迪米特法则,只与直接的朋友发生通信,而SubEmployee类并不是CompanyManager类的直接朋友(以局部变量出现的耦合不属于直接朋友),从逻辑上讲总公司只与他的分公司耦合就行了,与分公司的员工并没有任何联系,这样设计显然是增加了不必要的耦合。按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合。修改后的代码如下:
class SubCompanyManager{
public List getAllEmployee(){
List list = new ArrayList();
for(int i=0; i<100; i++){
SubEmployee emp = new SubEmployee();
//为分公司人员按顺序分配一个ID
emp.setId("分公司"+i);
list.add(emp);
}
return list;
}
public void printEmployee(){
List list = this.getAllEmployee();
for(SubEmployee e:list){
System.out.println(e.getId());
}
}
}
class CompanyManager{
public List getAllEmployee(){
List list = new ArrayList();
for(int i=0; i<30; i++){
Employee emp = new Employee();
//为总公司人员按顺序分配一个ID
emp.setId("总公司"+i);
list.add(emp);
}
return list;
}
public void printAllEmployee(SubCompanyManager sub){
sub.printEmployee();
List list2 = this.getAllEmployee();
for(Employee e:list2){
System.out.println(e.getId());
}
}
}
修改后,为分公司增加了打印人员ID的方法,总公司直接调用来打印,从而避免了与分公司的员工发生耦合。
迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个"中介"来发生联系,例如本例中,总公司就是通过分公司这个"中介"来与分公司的员工发生联系的。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。
开闭原则
定义
一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。
问题由来
在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。
解决方案
当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立稳定灵活的系统。开闭原则可能是设计模式六项原则中定义最模糊的一个了,它只告诉我们对扩展开放,对修改关闭,可是到底如何才能做到对扩展开放,对修改关闭,并没有明确的告诉我们。以前,如果有人告诉我"你进行设计的时候一定要遵守开闭原则",我会觉的他什么都没说,但貌似又什么都说了。因为开闭原则真的太虚了。
在仔细思考以及仔细阅读很多设计模式的文章后,终于对开闭原则有了一点认识。其实,我们遵循设计模式前面5大原则,以及使用23种设计模式的目的就是遵循开闭原则。也就是说,只要我们对前面5项原则遵守的好了,设计出的软件自然是符合开闭原则的,这个开闭原则更像是前面五项原则遵守程度的"平均得分",前面5项原则遵守的好,平均分自然就高,说明软件设计开闭原则遵守的好;如果前面5项原则遵守的不好,则说明开闭原则遵守的不好。
其实笔者认为,开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。
说到这里,再回想一下前面说的5项原则,恰恰是告诉我们用抽象构建框架,用实现扩展细节的注意事项而已:单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。
最后说明一下如何去遵守这六个原则。对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。我们用一幅图来说明一下。
图中的每一条维度各代表一项原则,我们依据对这项原则的遵守程度在维度上画一个点,则如果对这项原则遵守的合理的话,这个点应该落在红色的同心圆内部;如果遵守的差,点将会在小圆内部;如果过度遵守,点将会落在大圆外部。一个良好的设计体现在图中,应该是六个顶点都在同心圆中的六边形。
在上图中,设计1、设计2属于良好的设计,他们对六项原则的遵守程度都在合理的范围内;设计3、设计4设计虽然有些不足,但也基本可以接受;设计5则严重不足,对各项原则都没有很好的遵守;而设计6则遵守过渡了,设计5和设计6都是迫切需要重构的设计。
到这里,设计模式的六大原则就写完了。主要参考书籍有《设计模式》《设计模式之禅》《大话设计模式》以及网上一些零散的文章,但主要内容主要还是我本人对这六个原则的感悟。写出来的目的一方面是对这六项原则系统地整理一下,一方面也与广大的网友分享,因为设计模式对编程人员来说,的确非常重要。