第一部分:6大设计原则
六大设计原则:
1)Single Responsibility Principle:单一职责原则
2)Open Closed Principle:开闭原则
3)Liskov Substitution Principle:里氏替换原则
4)Law of Demeter:迪米特法则
5)Interface Segregation Principle:接口隔离原则
6)Dependence Inversion Principle:依赖倒置原则
把这6个原则的首字母(里氏替换原则和迪米特法则的首字母重复,只取一个)联合起来就是SOLID(solid,稳定的),代表的含义就是把这6个原则结合使用的好处:建立稳定灵活、健壮的设计,而开闭原则有时重中之重,是最基础的原则,是其他5大原则的精神领袖。
1、单一职责原则
单一职责原则的定义:应该有且仅有一个原因引起类的变更
例:一个用户类:用户管理、修改用户的信息、增加机构(一个人属于多个机构)、增加角色等,用户有这么多的信息和行为要维护,写到一个接口中,都是用户管理类嘛,用户信息维护类图如下:
这个接口设计的一团糟,应该把用户的信息抽取成一个BO(Business Object,业务对象),把行为抽取成一个Biz(Business Logic,业务逻辑),重新封装成两个接口,IUserBO负责用户的属性,简单地说,IUserBOss的职责就是收集和反馈用户的属性信息;IUserBiz负责用户的行为,完成用户信息的维护和变更。
职责划分后的类图如下:
分清职责后的代码示例:
......
IUserInfo userInfo = new UserInfo();
//我要赋值了,我就认为它是一个纯粹的BO
IUserBO userBO = (IUserBO)userInfo;
userBO.setPassword("abc");
//我要执行动作了,我就认为它是一个业务逻辑类
IUserBiz userBiz = (IUserBiz)userInfo;
userBiz.deleteUser();
......
以上把一个接口拆分成两个接口的动作,就是依赖了单一职责原则
单一职责原则的好处:
1)类的复杂性降低,实现什么职责都有清晰明确的定义;
2)可读性提高;
3)可维护性提高;
4)变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其它的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
注:单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可估量的,因项目而异,因环境而异。
建议:接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。
2、里氏替换原则
两种定义:
1)最正宗的定义:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。
2)所有引用基类的地方必须能透明地使用其子类的对象。
第二个定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。
定义的4层含义
1)子类必须完全实现父类的方法
2)子类可以有自己的个性
3)覆盖或实现父类的方法时输入参数可以被放大
4)覆盖或实现父类的方法时输出结果可以被缩小
注:采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性,即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!
3、依赖倒置原则
依赖倒置原则原始定义:
High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.
三层含义:
1)高层模块不应该依赖低层模块,两者都应该依赖其抽象;
2)抽象不应该依赖细节;
3)细节应该依赖抽象。
依赖倒置原则在Java语言中的表现就是:
1)模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;
2)接口或抽象类不依赖于实现类;
3)实现类依赖接口或抽象类。
采用依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。
例:现在的汽车越来越便宜了,一个卫生间的造价就可以买到一辆不错的汽车,有汽车就必然有人来驾驶,司机驾驶奔驰车的类图如下:
引入依赖倒置原则后的类图如下:
依赖的三种写法:
1)构造函数传递依赖对象
在类中通过构造函数声明依赖对象,按照依赖注入的说法,这种方式叫做构造函数注入
代码清单:构造函数传递依赖对象
public interface IDriver{
//是司机就应该会驾驶汽车
public void drive();
}
public class Driver implements IDriver{
private ICar car;
//构造函数注入
public Driver(ICar _car){
this.car = _car;
}
//司机的主要职责就是驾驶汽车
public void drive(){
this.car.run();
}
}
2)Setter方法传递依赖对象
在抽象中设置Setter方法声明依赖关系,依照依赖注入的说法,这是Setter依赖注入
代码清单:Setter依赖注入
public interface IDriver{
//车辆型号
public void setCar(ICar car);
//是司机就应该会驾驶汽车
public void drive();
}
public class Driver implements IDriver{
private ICar car;
public void setCar(ICar car){
this.car = car;
}
//司机主要职责就是驾驶汽车
public void drive(){
this.car.run();
}
}
3)接口声明依赖对象
在接口的方法中声明依赖对象,该方法也叫做接口注入。
依赖倒置原则的本质就是通过抽象是(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合,在项目中怎么使用这个规则呢?只要遵循以下几个规则就可以:
1)每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备
2)变量的表面类型尽量是接口或者是抽象类
3)任何类都不应该从具体类派生
4)尽量不要覆写基类的方法
5)结合里氏替换原则使用
接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化。
注:依赖倒置原则是6个设计原则中最难以实现的原则,它是实现开闭原则的重要途径,依赖倒置原则没有实现,就别想实现对扩展开放,对修改关闭。在项目中,大家只要记住是“面向接口编程”就基本上抓住了依赖倒置原则的核心。
4、接口隔离原则
接口隔离原则的定义:
明确两种接口:
1)实例接口(Object Interface),在Java中声明一个类,然后用new关键字产生一个实例,它是对一个类型的事物的描述,这是一种接口。比如你定义Person这个类,然后使用Person zhangSanfeng = new Person()产生了一个实例,这个实例要遵从的标准就是Person这个类,Person类就是zhangSandra的接口。
2)类接口(Class Interface)Java中经常使用的interface关键字定义的接口。
隔离的两种定义
1)客户端不应该依赖它不需要的接口;
2)类间的依赖关系应该建立在最小的接口上。
我们可以把这两个定义概括为一句话:建立单一接口,不要建立臃肿庞大的接口。再通俗一点讲:接口尽量细化,同时接口中的方法尽量少。
接口隔离原则是对接口进行规范约束,包含以下4层含义:
1)接口要尽量小
根据隔离原则拆分接口时,首先必须满足单一职责原则。
2)接口要高内聚
高内聚就是提高接口、类、模块的处理能力,减少对外的交互。具体到接口隔离原则就是,要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越小,同时也有利于降低成本。
3)定制服务
一个系统或系统内的模块之间必然会有耦合,有耦合就要有相互访问的几口(并不一定就是Java中定义的Interface,也可能是一个类或单纯的数据交换),我们设计时就需要为各个访问者(即客户端)定制服务,什么是定制服务?定制服务就是单独为一个个体提供优良的服务。采用定制服务就必然有一个要求:只提供访问者需要的方法。
4)接口设计是有限度的
接口的设计粒度越小,系统越灵活,这是不争的事实。但是,灵活的同时也带来了结构的复杂化,开发难度增加,可维护性降低,这不是一个项目或产品所期望看到的,所以接口设计一定要注意适度。
注:怎么才能正确地使用接口隔离原则呢?答案是根据经验和常识决定接口的粒度大小,接口粒度太小,导致接口数据剧增,开发人员呛死在接口的海洋里;接口粒度太大,灵活性降低,无法提供地址服务,给整体项目带来无法预料的风险。怎么准确地实践接口隔离原则?实践、经验和领悟!
5、迪米特法则
定义:
迪米特法则(Law of Demeter ,LOD)也称为最少知识原则(Least Knowledge Principle,LKP):一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多public方法,我就调用这么多,其他的我一概不关心。
迪米特法则对类的低耦合提出了明确的要求,包含以下4层含义:
1)只和朋友交流
什么叫直接的朋友呢?每个对象都必然会和其它对象有耦合关系,两个对象之间的耦合就成为朋友关系,这种关系类型有很多,例如组合、聚合、依赖等。
例:老师想让体育委员确认一下全班女生来齐没有,就对他说:“你去把全班女生清一下。”,体育委员没听清楚,就问到:“呀,......那亲哪个?”老师无语了,我们来看这个笑话怎么用程序实现;老师要求清点女生类图如下:
Teacher类的command方法负责发送命令给体育委员,命令他清点女生,实现过程代码清单如下:
public class Teacher{
//老师对学生发布命令,清一下女生
public void commond(GroupLeader groupLeader){
List<Girl> listGirls = new ArrayList();
//初始化女生
for(int i=0;i<20;i++){
listGirls.add(new Girl());
}
//告诉体育委员开始执行清查任务
groupLeader.countGirls(listGirls);
}
}
体育委员类实现过程:
public class GroupLeader{
//清查女生数量
public void countGirls(List<Girl> listGirls){
System.out.println("女生数量是:"+listGirls.size());
}
}
Teacher类仅有一个朋友类——GroupLeader。为什么Girl不是朋友类呢?Teacher也对它产生了依赖关系呀!朋友类的定义是这样的:出现在成员变量、方法的输入输出参数中的类成为成员朋友类,而出现在方法体内部的类不属于朋友类,而Girl这个类就是出现在command方法体内,因此不属于Teacher类的朋友类。迪米特法则告诉我们一个类只和朋友类交流,但是我们刚刚定义的command方法却和Girl类有了交流,声明了一个List<Girls>动态数组,也就是与一个陌生的类Girl有了交流,这样就破坏了Teacher的健壮性。方法时类的一个行为,类竟然不知道自己的行为与其他类产生依赖关系,这是不允许的,严重违反了迪米特法则。
在类图中去掉Teacher对Girl类的依赖关系,修改后的类图如下:
代码清单:修改后的老师类如下
public class Teacher{
//老师对学生发布命令,清一下女生
public void commond(GroupLeader groupLeader){
//告诉体育委员开始执行清查任务
groupLeader.countGirls();
}
}
代码清单:修改后的体育委员类
public class GroupLeader{
private List<Girl> listGirls;
//传递全班的女生进来
public GroupLeader(List<Girl> _listGirls){
this.listGirls = _listGirls;
}
//清查女生数量
public void countGirls(){
System.out.println("女生数量是:"+this.listGirls.size());
}
}
在GroupLeader类中定义了一个构造函数,通过构造函数传递了依赖关系。
注:一个类只和朋友交友,不与陌生类交流,不要出现getA().getB().getC().getD()这种情况(在一种极端的情况下允许出现这种访问,即每一个点号后面的返回类型都相同),类与类之间的关系是建立在类间的,而不是方法间,因此一个方法尽量不引入一个类中不存在的对象,当JDK API提供的类除外。
2)朋友间也是有距离的
一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为private、package-private(包类型,在类、方法、变量前不加访问权限,则默认为包类型)、protected等访问权限,是否可以加上final关键字等。
注:迪米特法则要求类“羞涩”一点,尽量不要对外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、package-private、protected等访问权限。
3)是自己的就是自己的
在实际应用中经常会出现这样一个方法:放在本垒中也可以,放在其他类中也没有错,怎么去衡量呢?坚持这样一个原则:如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中。
4)谨慎使用Serializable
防止出现一个类或接口在客户端已经变更了,而服务器端却没有同步更新。
注:迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了难度。在采用迪米特法则时需要反复权衡,既做到让结构清晰,又做到高内聚低耦合。
6、开闭原则
开闭原则定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。
含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。
软件实体包括以下几个部分:
1)项目或软件产品中按照一定的逻辑规划划分的模块;
2)抽象和类;
3)方法。
一个软件产品只要在生命期内,都会发生变化,既然变化是一个既定的事实,我们就应该在设计是尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现“拥抱变化”。开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。
举例说明什么是开闭原则
以书店销售为例,书店售书类图如下:
IBook定义了数据的三个属性:名称、价格和作者。小说类NovelBook是一个具体的实现类,是所有小说书籍的总称,BookStore指的是书店,IBook书籍接口代码清单如下所示:
public interface IBook{
//书籍有名称
public String getName();
//书籍有售价
public int getPrice();
//书籍有作者
public String getAuthor();
}
小说类代码清单:
public class NovelBook implements IBook{
//书籍名称
private String name;
//书籍的价格
private int price;
//书籍的作者
private String author;
//通过构造函数传递书籍数据
public NovelBook(String _name,int _price,String _author){
this.name = _name;
this.price = _price;
this.author = _author;
}
//获得作者是谁
public String getAuthor(){
return this.author;
}
//书籍叫什么名字
public String getName(){
return this.name;
}
//获得书籍的价格
public int getPrice(){
return this.price;
}
}
注:我们把价格定义为int类型并不是错误,在非金融类项目中对货币处理时,一般取2位精度,通常的设计方法是在运算过程中扩大100倍,在需要展示时再缩小100倍,减少精度带来的误差。
代码清单:书店售书类
public class BookStore{
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
//static静态模块初始化数据,实际项目中一般是由持久层完成
static{
bookList.add(new NovelBook("天龙八部",3200,"金庸"));
bookList.add(new NovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new NovelBook("悲惨世界",3500,"雨果"));
bookList.add(new NovelBook("金瓶梅",4300,"兰陵笑笑生"));
}
//模拟书店买书
public static void main(String[] args){
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("---------------书店卖出去的书籍记录如下:---------------");
for(IBook book:bookList){
System.out.println("书籍名称:" + book.getName()+"\t书籍作者:" + book.getAuthor()+"\t书籍价格:"+formatter.format(book.getPrice()/100.0)+"元");
}
}
}
在BookStore中声明了一个静态模块,实现了数据的初始化,这部分应该是从持久层产生的,由持久层框架进行管理,运行结果如下:
---------------书店卖出去的书籍记录如下:---------------
书籍名称:天龙八部 书籍作者:金庸 书籍价格:¥32.00元
书籍名称:巴黎圣母院 书籍作者:雨果 书籍价格:¥56.00元
书籍名称:悲惨世界 书籍作者:雨果 书籍价格:¥35.00元
书籍名称:金瓶梅 书籍作者:兰陵笑笑生 书籍价格:¥43.00元
项目投产了,书籍正常销售出去,书店也盈利了。2008年开始经济下滑,书店为了生存开始打折销售:所有40元以上的书籍9折销售,其它的8折销售。对已经投产的项目来说,这就是一个变化,如何应对这样一个需求变化?有如下三种方法:
1)修改接口
在IBook上新增加一个方法getOffPrice(),专门用于进行打折处理,所有的实现类实现该方法。但是这样修改的后果就是,实现类NovelBook要修改,BookStores中的main方法也要修改,同时IBook作为接口应该是稳定且可靠的,不应该经常发生变化,否则接口作为契约的作用就失去了效能。因此,该方案否定。
2)修改实现类
修改NovelBook类中的方法,直接在getPrice()中实现打折处理,好方法,相信大家在项目中经常使用的就是这样的方法,通过class文件替换的方式可以完成部分业务变化(或是缺陷修复)。该方法在项目有明确的章程(团队内约束)或优良的架构设计时,是一个非常优秀的方法,但是该方法还是有缺陷的。例如采购书籍人员也是要看价格的,由于该方法已经实现了打折处理价格,因此采购人员看到的也是打折后的价格,会因信息不对称而出现决策失误的情况。因此,该方案也不是一个最优的方案。
3)通过扩展实现变化
增加一个子类OffNovelBook,腹泻getPrice方法,高层次的模块(也就是static静态模块区)通过OffNovelBook类产生新的对象,完成业务变化对系统的最小化开发。好方法,修改也少,风险也小,扩展后的书店售书类图如下:
OffNovelBookBook类继承了NovelBook,并覆写了getPrice方法,不修改原有的代码。新增加的子类OffNovelBook打折销售的小说类代码清单如下:
public class OffNovelBook extends NovelBook{
public OffNovelBook(String _name,int _price,String _author){
super(_name,_price,_author);
}
//覆写销售价格
@Override
public int getPrice(){
//原价
int selfPrice = super.getPrice();
int offPrice = 0;
if(selfPrice>4000){//原价大于40元,则打9折
offPrice = selfPrice * 90 /100;
}else{
offPrice = selfPrice * 80 /100;
}
return offPrice;
}
}
很简单,仅仅覆写了getPrice方法,通过扩展完成了新增加的业务。书店类BookStores需要依赖子类,代码稍作修改(只需要修改static静态代码块)如下:
static{
bookList.add(new OffNovelBook("天龙八部",3200,"金庸"));
bookList.add(new OffNovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new OffNovelBook("悲惨世界",3500,"雨果"));
bookList.add(new OffNovelBook("金瓶梅",4300,"兰陵笑笑生"));
}
注:开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,底层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。
我们把变化归纳为以下三种类型:
1)逻辑变化
只变化一个逻辑,而不涉及其它模块,比如原有的一个算法是a*b*c,现在需要修改为a*b*c,可以通过修改原有类中的方法的方式来完成,前提条件是所有依赖或关联类都按照相同的逻辑处理;
2)子模块变化
一个模块变化,会对其它的模块产生影响,特别是一个低层次的模块变化必然引起高层模块的变化,因此在通过扩展完成变化时,高层次的模块修改时必然的,刚刚书籍打折处理就是类似的处理模块,该部分的变化甚至会引起界面的变化 ;
3)可见视图变化
可见视图是提供给客户使用的界面,如JSP程序、Swing界面等,该部分的变化一般会引起连锁反应。若近视界面上按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化,什么意思呢?一个展示数据的列表,按照原有的需求是6列,突然有一天要增加1列,而且这一列要跨N张表,处理M个逻辑才能展现出来,这样的变化是比较恐怖的,但还是可以通过扩展来完成变化,这要看原有设计是否灵活。
开闭原则的重要性
1)开闭原则对测试的影响
在书店售书例子中,增加了一个打折销售需求,如果直接修改getPrice方法实现业务需求变化,就要修改单元测试类。若是一个复杂逻辑,你的测试类就要修改得面目全非。所以我们需要通过扩展来实现业务逻辑的变化,而不是修改。上面例子通过增加一个子类OffNovelBook来完成业务需求变化,测试时重新生成一个测试文件OffNovelBookTest,然后对getPrice测试,单元测试是孤立测试,只要保证我提供的方法正确就成了,其他的我不管。
2)开闭原则可以提高复用性
在面向对象设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个勒种独立实现一个业务逻辑。只有这样代码才可以复用,粒度越小,被复用的可能性就越大。那为什么要复用呢?减少代卖量,避免相同的逻辑分散在多个角落,避免日后的维护人员为了修改一个微小的缺陷或增加新功能而要在整个项目中到处查找相关的代码,然后发出对开发人员“极度失望”的感慨。怎么才能提高复用率呢?缩小逻辑粒度,知道一个逻辑不可再拆分为止。
3)开闭原则可以提高可维护性
一款软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能要对程序进行扩展,维护人员最乐意做的事情就是扩展一个类,而不是修改一个类,甭管原有代码写的多么优秀还是多么糟糕,让维护人员读懂原有的代码,然后再修改,是一件很痛苦的事情。
4)面向对象开发的要求
万物皆对象,我们需要把所有的事物都抽象成对象,然后针对对象进行操作,但是万物皆运动,有运动就有变化,有变化就要有策略应对,在设计之初考虑到所有可能变化的因素,然后留下接口,等待“可能”转变成“现实”。
如何使用开闭原则
1)抽象约束
通过接口或抽象类可以约束一组可能变化的行为,并且实现对扩展开放,其包含三层含义:第一,通过接口或抽象类约束扩展,对扩展进行辩解限定,不允许出现接口或抽象类中不存在的public方法;第二,参数类型、引用对象尽量使用接口或抽象类,而不是实现类;第三,抽象层尽量保持稳定,一旦确定即不允许修改。接口或抽象类一旦定义,就应该立即执行,不能有修改接口的思想,除非是彻底的大返工。
注:要实现对扩展开放,首要的前提条件就是抽象约束。
2)元数据(metadata)控制模块行为
编程很苦很累,怎么才能减轻我们的压力呢?答案是尽量使用元数据来控制程序的行为,减少重复开发。什么是元数据?用来描述环境和数据的数据,通俗地说就是配置参数,参数可以从文件中获得,也可以从数据库中获得。
3)制定项目章程
对项目来说,约定优于配置。
4)封装变化
对变化的封装包含两层含义:第一,将相同的变化封装到一个接口或抽象类中;第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。封装变化,也就是受保护的变化,找出预计有变化或不稳定的点,我们为这些变化点创建稳定的接口,准确地讲是封装可能发生的变化,一旦预测到有变化,就可以进行封装。
注:
下一篇为第二部分:23种设计模式(上)
推荐阅读:秦小波著《设计模式之禅》