类是属性和方法(行为)的容器,但它不是垃圾桶,更不能是四像八不像。
类是对技术领域和业务领域客观实体(可能是虚拟实体)的抽象和表达,必须反映其真实的本质的属性和行为,杜绝人为想象和臆造,不良的设计中充斥着形色各异的四像八不像的怪兽,怪兽横行系统自然难于驾控。为了避免此类状况的发生,设计出良好的类,请遵循如下基本原则。
软件实体(模块,类,方法等)应该对扩展开放,对修改关闭。
开闭原则是软件系统设计的最基本的原则,各种设计模式都是为了遵守OCP,而针对某种具体应用场景的一种设计方法。因此软件工程师必须努力理解和深刻体会开闭原则,并在设计实践中时刻考虑如何遵守该原则;
开闭原则比较抽象,不利于理解和实践,这时需要认真学习设计模式,并搞清楚每种模式适用于何种场景,是如何贯彻开闭原则的,它对什么开了,对什么闭了,并在实践过程中灵活运用,这叫做学招,模式是套路和招数,当你顿悟了,OCP已经深入到了你的骨髓,你举手投足都会遵循OCP,你就无招胜有招了,你就可以忘掉那些模式了,但你却在自然而然地、恰当地、了无痕迹地,妙用着那些模式,或创建着更适合于场景的新模式;因此OCP是软件设计的“道”,道之为用也,无言无为。
关于设计模式我们会另文介绍,但实际上这些模式通常主要运用于较复杂的系统和平台级软件,通常的行业应用中较少用到,最常用的工厂方法,我们的平台已经为你提供万能通用工厂,你知道如何使用就可以了,单例模式我们也有标准的实现范例,当你需要时参照使用即可,因此你可以不急于了解和掌握各种设计模式,更实用的是你认真了解和掌握本文介绍的原则和方法。
根据开闭原则,在设计一个软件系统模块(类,方法)的时候,应该可以在不修改原有的模块(修改关闭)的基础上,能扩展其功能(扩展开放)。
OCP保证了系统:
1)稳定性。开闭原则要求扩展功能不修改原来的代码,这可以让软件系统在变化中保持稳定。
2)扩展性。开闭原则要求对扩展开放,通过扩展提供新的或改变原有的功能,让软件系统具有灵活的可扩展性。
而稳定性和扩展性是软件系统最重要的属性,因此OCP如此重要。
开闭原则的实现方法
不允许修改却能够拓展,听起来好像矛盾和不可思议,的确是这样,如果什么也不变系统怎么能够拓展呢,必须要新增或变动些什么才能满足系统拓展的要求,该原则的目的是指导你怎样以最合理和最小的变动来达到拓展的目的,这里我把最合理放在前面是非常关键的,该变的东西是可以变的,不该变的是不可以变的(似乎是废话,关键是你有时分不清什么改变什么不该变,关键就在这里),其实比OCP更基础的一个原则就是:
变与不变分离、数据与逻辑分离
这在上面已经提到过,这是一个至关重要的隐性原则,如果一个系统的设计能够将变与不变的严格分离,数据与逻辑严格分离,你就已经遵循了OCP,你的系统就一定具有最好的稳定性和扩展性。
实现OCP的主要方法就是分离变与不变,例如:
1) 把不变的行为加以抽象成稳定的接口,不修改接口而拓展(变革)实现,就可以扩展系统;
2)接口的最小功能设计原则。根据这个原则,原有的接口要么可以应对未来的扩展;不足的部分可以通过定义新的接口来实现;这样不至于对原有接口不断调整和修改。但一定要注意不要以此作为不修改接口的理由,如果发现接口设计不合理,应尽可能地早修改。
3)模块之间的调用通过抽象接口进行,这样即使实现层发生变化,也无需修改调用方的代码。
接口是更加稳定的抽象,具体实现可能随需而变。
4)将参数提炼出来,必要时修改参数就可以实现拓展。
开闭原则的相对性
软件系统的构建是一个需要不断重构的过程,在这个过程中,模块的功能抽象,模块与模块间的关系,都不会从一开始就非常清晰明了,所以构建100%满足开闭原则的软件系统是相当困难的,这就是开闭原则的相对性。但在设计过程中,通过对模块功能的抽象(接口定义),模块之间的关系的抽象(通过接口调用),数据与逻辑分离等,可以尽量接近满足开闭原则。
只能让一个类有且仅有一个职责。这也是单一职责原则含义。
类要短小。这样你就可以避免制造怪兽了。
永远不要让一个类存在多个改变的理由。
换句话说,如果一个类需要改变,改变它的理由永远只有一个。如果存在多个改变它的理由,就需要重新设计该类。
事实上,如果你在设计类时,使之仅描述一个独立的客观事务,而不是将相关、不相关的事务搅在一起描述,你基本就可以不违背这样原则。
职责的划分
既然一个类不能有多个职责,那么怎么划分职责呢?
Robert.C Martin给出了一个著名的定义:所谓一个类的一个职责是指引起该类变化的一个原因。
If you can think of more than one motive for changing a class, then that class has more than one responsibility.
如果你能想到一个类存在多个使其改变的原因,那么这个类就存在多个职责。
SRP的原文里举了一个Modem的例子来说明怎么样进行职责的划分,这里我们也沿用这个例子来说明一下:
SRP违反例:Modem.java
interface Modem { public void dial(String pno); //拨号 public void hangup(); //挂断 public void send(char c); //发送数据 public char recv(); //接收数据 }
咋一看,这是一个没有任何问题的接口设计。但事实上,这个接口包含了2个职责:第一个是连接管理(dial, hangup);另一个是数据通信(send, recv)。很多情况下,这2个职责没有任何共通的部分,它们因为不同的理由而改变,被不同部分的程序调用,所以它违反了SRP原则。
下面将它的2个不同职责分成2个不同的接口,这样至少可以让客户端应用程序使用具有单一职责的接口:
interface Connection { public void dial(String pno); //拨号 public void hangup(); //挂断 } interface DataChannel { public void send(char c); //发送数据 public char recv(); //接收数据 }
不能强迫用户去依赖那些他们不使用的接口。换句话说,使用多个专门的接口比使用单一的总接口总要好,即接口功能要专一化。
它包含了2层意思:
- 接口的设计原则:接口的设计应该遵循最小接口原则,不要把用户不使用的方法塞进同一个接口里。
如果一个接口的方法没有被使用到,则说明该接口过胖,应该将其分割成几个功能专一的接口。
- 接口的依赖(继承)原则:如果一个接口a依赖(继承)另一个接口b,则接口a相当于继承了接口b的方法,那么继承了接口b后的接口a也应该遵循ISP,不应该包含用户不使用的方法。
反之,则说明接口a被b给污染了(b接口中的某些方法a用不到,这意味着a不应该继承b),应该重新设计它们的关系。
下面我们举例说明怎么设计接口或类之间的关系,使其不违反ISP原则。
假如有一个Door,有lock,unlock功能,另外,可以在Door上安装一个Alarm而使其具有报警功能。用户可以选择一般的Door,也可以选择具有报警功能的Door。
有以下几种设计方法:
违反ISP原则的例子:
在Door接口里定义所有的方法。图:
但这样一来,依赖Door接口的CommonDoor却不得不实现不使用的alarm()方法。违反了ISP原则。
遵循ISP原则的例:
通过多重继承实现,有两种方案:
在Alarm接口定义alarm方法,在Door接口定义lock,unlock方法。接口之间无继承关系。CommonDoor实现Door接口,AlarmDoor有2种实现方案:
1)同时实现Door和Alarm接口。
2)继承CommonDoor,并实现Alarm接口。该方案是继承方式的Adapter设计模式的实现。
第2)种方案更具有实用性。
这两种设计都遵循了ISP设计原则。
ISP为我们对接口抽象的颗粒度上建立了判断基准:在为系统设计接口的时候,使用多个专门的接口代替单一的胖接口是更好的设计思路。
依赖倒置原则的核心意思是说,要依赖抽象,而不要依赖具体,它包括:
A. 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
B. 抽象不应该依赖于细节,细节应该依赖于抽象
因为抽象(抽象类或接口)是稳定的本质的,具体实现类则是细节的,易变的。
依赖:在程序设计中,如果一个类a使用了另一个类b,我们称a依赖b。
为什么叫做依赖倒置(Dependency Inversion)呢?
因为传统的结构化程序设计中,高层模块总是依赖于低层模块;这样底层模块的变动严重影响高层模块,违反开闭原则,面向对象程序设计中,我们遵守DIP可以将这种关系倒转过来,这就是DIP名称的由来。
“高层”依赖“低层”是一种不良设计,它会导致牵一发动全身,使系统僵硬、脆弱、不易拓展。
一个良好的设计应该是系统的每一部分都是可替换的、易替换的。
但是系统中这种依赖关系又是必然存在的,例如我们前面强调要将基础业务层与功能分离,一分离就需要耦合,而按普通java的依赖方法,我们势必要在功能层的类中出现如下的类似代码:
xxxBasSrv xxx = new xxxBasSrv(); //实例化基础服务类
xxx.doXxxSrv(); //执行具体服务处理;
这就违反了DIP原则(高层依赖了底层),你马上会说如果这样就算违背DIP,那我们可能总在违背。是的,关于这一点我们后面会给出答复。我们先阐述一下概念,类有几种耦合关系,有三种:
A) 零耦合;两个类毫无关联,称为零耦合。
B) 具体耦合;一个具体的类直接在属性或方法中使用另一个类。
C) 抽象耦合;一个类通过接口或抽象类与另一个类耦合。
DIP原则实际就是要求我们在设计时使用抽象耦合,减少具体耦合。
DIP是实现OCP的重要机制和手段,但是对设计也提出了更高的要求,必须设计和提炼出相对稳定的接口或抽象类,同时通过工厂方法,避免类似
xxxBasSrv xxx = new xxxBasSrv(); //实例化基础服务类的具体依赖语句;因此你会发现遵守DIP需要付出代价(当然你也会得到丰厚的回报),当然如果你机械地遵守,可能反受其累,所以必须掌握好分寸,为此给出如下指导性原则:
1) 在关键的层与层之间必须严格遵守DIP,并通过相应的工厂获取具体类对象。
2) 对于底层通用方法、工具类等发生变化的可能性很小(尤其是方法参数很稳定)的,通过抽象耦合能够带来的好处极其有限,这时使用具体耦合反而更好。
我们外设管理体系就很好地贯彻了DIP原则,首先我们定义了几类外设的操作接口,为使用者提供一个设备服务工厂,具体类在使用外设时通过该工厂就可以拿到具体的外设操作类,通过接口就可以使用外设了,工厂能够根据具体的外设描述参数,决定实例化哪个具体类,这样隔离了变化,同时为变更和修改带来了灵活性。
里氏替换原则LSP的概念解说
所有引用基类的地方必须能透明地使用其子类的对象。也就是说,只有满足以下2个条件的OO设计才可被认为是满足了LSP原则:
A)不应该在代码中出现if/else之类对子类的类型进行判断。以下代码就违反了LSP定义。
if (obj typeof Class1) { do something } else if (obj typeof Class2) { do something else }
B)子类应当可以替换父类并出现在父类能够出现的任何地方,或者说如果我们把代码中使用基类的地方用它的子类所代替,代码还能正常工作。
里氏替换原则LSP是使代码符合开闭原则的一个重要保证。同时LSP体现了:
- 类的继承原则:如果一个继承类的对象可能会在基类出现的地方出现运行错误,则该子类不应该从该基类继承,或者说,应该重新设计它们之间的关系。
- 动作正确性保证:符合LSP设计原则的类的扩展不会给已有的系统引入新的错误。
一个规范的类中任何方法,都应有一个前提条件以及一个后续条件,前提条件说明方法接受什么样的参数数据等,只有前提条件得到满足时,这个方法才能被执行;同时后续条件用来说明这个方法完成时的状态,如果一个方法的执行会导致这个方法的后续条件不成立,那么这个方法也不应该正常返回。
LSP要求子类中如果覆盖父类的方法,应该满足:
1)前提条件不强于父类.
2)后续条件不弱于父类.
在很多情况下,在设计初期我们类之间的关系不是很明确,LSP则给了我们一个判断和设计类之间关系的基准:需不需要继承,以及怎样设计继承关系。这很重要,继承是OO的最重要特征,但乱继承则是制造混乱的元凶,我们看客观世界,继承仅发生在直系亲缘的个体之间,而我们在进行软件设计时往往会乱点鸳鸯谱,胡乱继承,制造混乱,LSP指导我们做好继承关系的设计。
合成复用原则就是在一个新的类中使用一些已有的类,使之成为新类的一部分;新的类通过向这些类的委派达到复用已有功能的目的。这我们经常在使用,似乎并不是一个什么原则,但我们应该注意它强调的是:要恰当地使用合成/聚合,而不要轻易使用继承。
虽然继承与合成/聚合都是复用已有类的重要方式,但继承必须慎用,父类与子类之间必须满足is-a的关系,即子类是父类的一种;而has-a的关系要采用合成/聚合方式。滥用继承的例子很多,我们在设计过程也会不经意的犯类似错误,包括在Java的API中也存在违反CRP的例子:java.util.Properties
class Properties extends Hashtable<Object,Object> { …… }
Properties 是用来存放键值对的对象,它并不是Hashtable的一种,只是想通过Hashtable存放kev-val,其实也可以使用其他容器存放key-val,这是一个典型的has-a,使用继承是非常不妥当的,是乱认亲属,会带来许多不良后果。
类似的情况我们必须高度重视,换一种话说在设计过程一定要慎用继承,但并不是不用继承,恰恰相反,我们要求在应用体系设计时,某一品种的类必须继承该种的父类(或接口),不允许出现特立独行,天下至尊的类,关于这一定后面还会提到。
又叫迪米特法则(Law of Demeter),该原则的意思是说:一个类应该尽可能少地了解其他类。在类设计这个领域,朋友圈越小越好(只要保证履行你的职责),不提倡四海之内皆兄弟;最好是小国寡民、使民(类)无知。它有多重表达方式:
只与你直接的朋友通信;
不要跟陌生人说话;
每个软件单元与其他单元都只有最少的知识,而且仅通过最少的接口交互。
该原则就是在贯彻软件设计的基本教义—信息隐藏,信息隐藏可以使子系统(或类)之间脱耦,从而允许它们独立地开发、优化、使用、阅读和修改,降低相互之间的影响。
一个系统越大信息隐藏就越重要,LKP原则主要用途就是努力实现软件单元之间的解耦,通过信息隐藏控制信息过载,提高系统的灵活性和拓展能力。运用LKP原则在设计要注意一下几点:
宏观层面:
A)系统与系统之间必须通过公布的API接口调用,不允许直接使用另一个系统的具体类,更不允许直接访问对方数据库,万不得已可以使用另一系统提供的数据库视图。
B)一个系统内部不同层之间必须通过API接口或服务工厂实现调用。
微观层面:
A) 降低类的访问权限,例如不需要外界使用的类,就不要使用public,而使用缺省的package-private,这样这些类修改时的影响范围就仅局限于内部了。
B) 尽可能降低类中成员的访问权,能私有的就不定义为包级,能定义为包级的就不要定义为public,类中的属性尽可能不提供修改方法(数据类除外)。
C) 尽可能降低一个类对其他类的依赖。
D) 限制局部变量的有效范围,在哪使用在哪定义。
总结:以上各原则都是在贯彻OCP原则,设计的关键在于抽象,抽象是在对具体事务进行认真分析、归纳、总结基础上的升华,它拨云见日,令你看到事务的本质,抽象是设计的基础,抽象的能力需要你在设计实践过程中不断提高。依赖抽象的设计才是设计,依赖具体的开发其实就是功能导向,简单模仿,一定是糟糕的设计,做好设计必须遵循上述原则,努力做好抽象、信息隐藏、分离变与不变、分离数据和逻辑。
抽象你可能还是不理解,下面举例说明:
1)通过对业务领域的分析,提炼出系统使用的全部数据类型、数据字典,名词及命名字典。
2)通过对业务领域的分析,提炼出该领域的全部实体对象,并合理地分类,建立结构清晰合理的实体类图结构,同时完成数据库设计。
3)通过对业务领域全部功能的分析,提炼出支撑各功能的传输对象,并合理地分类,建立结构清晰合理的行为类图结构。
4)通过对业务领域全部功能的分析,提炼出支撑各功能的后台服务接口,并合理地分类,建立结构清晰合理的服务接口类图结构。
5)通过对业务领域全部功能的细节的深层分析,提炼出支撑基础处理服务类,确立相应接口,并合理地分类,建立结构清晰合理的基础服务类图结构。
事实上你能够做到这些,就已经很好地完成了系统的框架设计,具体方法逻辑的设计可在详细设计过程中完成,这些就是抽象的具体成果。
为了使你的编码很快提升一个层次,建议你从头学习:编程规则,你如果能够很好地理解并认真实践,你会有质的飞跃。
下篇:http://blog.csdn.net/xabcdjon/article/details/6707955