在正式的开始工作之前,我打算把传说中的《设计模式之禅》中的23种设计模式都通读一遍,顺便审思一下过往的代码经验,进行一种阶段性的总结。这个系列的博客就是为了在琢磨这些设计模式的时候记录下来,既是对自己的督促也是对学习的总结。
设计模式这个事情最早我知道还是在实习面试的时候,当时接触的几个的实习面试都问到了设计模式相关的东西,尤其是单例模式,我其实到现在也不是很懂为什么面试的时候单例模式如此频发,下一篇准备好好写一下单例模式,这篇先说一下六大设计原则,如果说后续的23种设计模式是各种招式的话,那么这六大原则就是当之无愧的心法总纲。
第一条:单一职责原则(Single Responsibility Principle),这个原则的定义非常的清晰又非常的含糊,以Java为例,如何设计一个符合单一职责原则的类呢?那么看起来很简单,有且只有一种原因会引起类的变更,一个类只负责一种职责嘛,书上举了一个电话的例子,我觉得非常棒:
public interface Iphone{
//拨通电话
public void dial(String phoneNumber);
//通话
public void chat(Object o);
//通话完毕,挂电话
public void hangup();
}
好了,这个接口看起来是没有问题的,看起来好像也符合单一职责原则,但其实再一细想,你会发现,dial和hangup可以理解成开关,负责的是拨号接通和挂机,chat实现的则是具体的通话,负责进行数据的传输和转换,比如说把模拟信号转换成数字信号,那么想想看,其实无论是开关的方法变化了,还是具体的数据传输方法变化了,都会造成当前这个接口的变化,而这两个造成接口变化的原因其实又毫不相干,比如说,从电信换成联通导致拨号的变化,数据格式从字符型变成数值型导致通话的变化,而这两个原因又都相互独立,所以,那就考虑拆分成两个接口,这样一个接口负责开关,一个接口负责数据传输,看起来好像又很舒服了,然而再一想,一个手机类要把这两个接口组合到一起 才能使用,又会提高它的耦合性,那么是不是又会带来新的问题呢,文章里最后提出用一个类实现上述两个接口,但个人感觉也是一种比较牵强的做法,也就是说,这个单一职责的原则理论上是很酷的,但其实真正的使用时会很难定义,也就是这个职责具体怎么划分才会被认为可以了,算作一个‘原子’的感觉了,这个是很难的其实。所以书上最后总结说,单一职责原则在接口和方法时,要严格使用,而如果是类则只能尽量去靠拢,尽量保证只有一个原因引起变化。
第二条:里氏替换原则(Liskov Substitution Principle, LSP)Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.通俗点来说,就是父类能出现的地方,子类就可以出现,而且替换成子类也不会产生任何错误和异常,对于使用者来说,他不需要知道它到底是子类还是父类。这条规范其实在Java的设计思想中多有体现,在Java编程思想一书中有整整一个章节阐述Java的继承相关的规范,也是让人感到有些东西果然是通用的。
这条看似简单的原则其实包含很多内容,书中把它分成了四层定义:
1.子类必须完全实现父类的方法:这一点是毋庸置疑的,作为子类必须要实现所有的父类的方法,这样才能算作继承了父类。同样的,以Java为例,定义一个抽象类AbstractGun
public abstract class AbstractGun{
//这个就是作为枪的父类,只有一个功能,就是shoot!!!
public abstract void shoot();
}
那么作为子类,就是各种枪,手枪,步枪啥的,各种花式shoot!比如说:
定义子类手枪:
public class Handgun extends AbstractGun{
@override
//这个标识就是代表下面的方法是重写父类的方法;
public void shoot(){
System.out.println("用手枪来shoot!!");
}
}
定义子类步枪:
public class Rifle extends AbstractGun{
@override
//这个标识就是代表下面的方法是重写父类的方法;
public void shoot(){
System.out.println("用步枪来shoot!!");
}
}
下面来定义一个士兵类,它会用到枪;
public class Soldier{
private AbstractGun gun;
public setGun(AbstractGun gun){
this.gun=gun;
}
public void killEnemy(){
System.out.println("士兵开枪了");
gun.shoot();
}
}
这里就体现了继承的好处了,这里面士兵开枪的时候,用的是父类AbstractGun,但是其实在具体的场景实现中,既可以是步枪也可以是手枪,这个就是Java思想中的多态了,就是说在设计调用的接口时以通用的父类作为参数,然而具体的实现时可以以不同的子类去实现,以体现继承的优越性。当然了,必不可少的,子类一定要完全重写父类的方法。
2.子类当然可以有自己的个性:这个其实很容易理解,就好像上面提到的,各个枪有各种花式射法,但是这里要注意的就是,多态向下转型是可以的,向上是不允许的,回到最原始的定义那里去,父类能出现的地方,子类都可以,但是反过来子类可以的,父类就不见得可以了,Java里通常就会出现ClassCastException异常,也就是类型转换异常,想想也很容易理解,你一个手枪可以实现的功能不见得所有的枪都可以实现,总是会有自己独特的部分。
3.覆盖或实现父类的方法时输入参数可以被放大:这个对我来说是平时没太接触过的一点,还是先举个例子来看看:
public class Father{
public Collection doSomething(HashMap map){
System.out.println("父类被执行了");
return map.values();
}
}
好了,这个父类的意思就是说把hashmap转换成collection集合类型,下面是子类:
public class Son extends Father{
public Collection doSomething(Map map){
System.out.println("子类被执行");
return map.values();
}
}
这里需要注意到,这种方法就不是属于重写了,而是叫做重载(overload),其实是有点奇怪的,一般重载都是在同一个类当中,但是这种用法也并不错。下面来定义场景:
public class Client{
//父类存在的地方,子类就应该能够存在;
public static void invoker(){
Father f=new Father();
HashMap map=new HashMap();
f.doSomething(map);
}
}
好了,这个方法调用后显示的结果是:父类被执行,很容易理解
下面,我们把invoker方法中改一下:
变成
public class Client{
//父类存在的地方,子类就应该能够存在,所以这里用子类代替父类;
public static void invoker(){
Son f=new Son();
HashMap map=new HashMap();
f.doSomething(map);
}
}
运行的结果还是一样,也就是说,输入的hashmap的参数类型导致子类代替父类传递到了调用者中去,子类的方法并没有被执行,而是执行了父类的方法。下面我们再来变化一下,吧父类的前置条件扩大,也就是把父类的方法的类型参数变得更宽:
public class Father{
public Collection doSomething(Map map){
System.out.println("父类被执行了");
return map.values();
}
}
把子类的变得更小:public class Son extends Father{
public Collection doSomething(HashMap map){
System.out.println("子类被执行");
return map.values();
}
}
在父类的前置条件大于子类的前置条件的情况下,接着运行上述第二个client:就会发现结果是:子类被执行
而事实上,我们并没有去重写父类的方法,执行的却是子类重载的方法,这就会导致业务逻辑的混乱。
所以,其核心就在于,子类的方法中的前置条件必须与父类中被重写的方法的前置条件相同或者更宽松,按照我的理解就是,比如说,父类要求的输入参数,是大米,子类的重载的方法就得是食物,这样,才可以保证子类替换父类,否则的话,就会去执行子类重载的方法,造成混乱。
4.覆盖或实现父类的方法时输出结果可以被缩小:类似于上一种,如果父类方法得到的结果是米,那么子类的相同方法得到的返回值就得是粟米或者什么小于等于米的东西,那么如果是重写,子类和父类的同名方法的输入参数相同,返回值小于等于米;如果是重载,那么子类的输入参数更宽,参考上述第三点,这个方法实际上并不会被调用。
总结来说,这个第二点原则就是一种在继承时对子类的编码规范要求,核心就在于,父类能用的,子类都行,这样以此而延伸出的四点都是为了保证一以贯之的逻辑上的连贯,就是子类去替代父类时不能混乱。
第三条:依赖倒置原则(Dependence Inversion Principle DIP)包含三层含义:
1.高层模块不应该依赖低层模块,两者都应该依赖其抽象;
2.抽象不应该依赖细节
3.细节应该依赖抽象
这个其实像spring这类框架的依赖反转就有点这个意思,其实就是说,实现类之间发生直接的依赖关系而是借助于接口或者抽象类,同时,接口或者抽象类不依赖于实现类,而实现类却依赖接口或者抽象类,这个也是面向对象编程的思想精髓之一。
多说无益,还是上例子,以开车为例:
public class Driver{
//这个driver就是一个实现类,他的作用就是开车!
public void drive(Benz benz){
benz.run();
}
}
下面定义奔驰:
public class Benz{
public void run(){
System.out.println("奔驰车在跑!");
}
}
下面是场景类:
public class Client{
public static void main(String[] args){
Driver zhangSan =new Driver();
Benz benz=new Benz();
zhangsan.drive(benz);
}
}
这是上面这个项目的开车的场景,然而,如果出了一点小变化,就会发现,这段代码的耦合性太强了。
比如说,我们有了一辆宝马车:
public class BMW{
public void run(){
System.out.println("宝马车开始跑了!");
}
}
这个时候就发现,虽然有了宝马,但是没人能开,因为很遗憾我们的司机只会开奔驰,这就说明我们的程序设计出了问题,司机和奔驰车这两个实现类之间耦合性太强了,会导致系统的维护扩展都变得更难;另一方面,书上说会减小并行开发的风险。所以为了解决这类问题,需要引入了依赖倒置原则,把程序修改如下:
建立两个接口,一个是司机,一个是汽车:
public interface IDriver{
//司机就是会开车,什么车都得会
public void drive(Icar car);
}
public class Driver implements IDriver{
//用Driver作为实现类,来实现抽象的司机接口
public void drive(Icar car){
car.run();
}
}
同样的定义汽车接口:
public interface Icar{
public void run();
}
public class Benz implements Icar{
public void run(){
System.out.println("这是奔驰在跑");
}
}
public class BMW implements Icar{
public void run(){
System.out.println("这是宝马在跑");
}
}
而在具体的业务场景中,抽象不依赖 细节,所以业务场景的实现如下:public class Client{
public static void main(String[] args){
IDriver zhangsan=new IDriver();
Icar benz=new Benz();
zhangsan.drive(benz);
}
}
可以看到,这个时候,client所依赖的类型都是抽象的。
另外,依赖有三种写法的实现:
1.构造函数传递依赖对象:
在类中通过构造函数声明依赖对象:在spring中,这种叫做构造器注入。
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方法,然后以此来声明依赖关系。
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 Driver(Icar car){
this.car=car;
}
public void drive(){
this.car.run();
}
}
这两种是在spring中常用的两种方式。
3.第三种是接口声明依赖对象。
那么最后问题来了,如何在实践中践行这个原则呢,需要以下几点:
1.每个类尽量有接口或者抽象类,这个是必须的,只有有了抽象才存在这种依赖倒置
2.变量的表面类型尽量是接口或者是抽象类,表面类型就是声明的类型,而不是后面new出来的那个类型,
3.任何类都不应该从具体类派生,也就是说尽量不去继承一个具体的类,这点其实也很容易理解,依赖倒置最核心的就是抽象,如果继承的是具体的类,就不存在这种抽象的好处了;
4。尽量不要重写基类的方法:这个说的是,如果抽象类作为基类已经实现了某方法,那么子类尽量不要重写这个方法;
5.结合前述的里氏替换原则使用。
总结来说,我对依赖倒置的最初的理解就是来自于spring的框架,事实上这种万物抽象再继承的思想的确是ood的思想精髓,通过这种解耦而衍生出的极高的扩展性和可维护性也是显而易见的优点,事实上,也是从这个原则开始,我开始慢慢了解了一些解耦合的思想,随着日后的工作,理解应该会逐步加深的吧。
第四条:接口隔离原则
这条原则说白了就是使用多个隔离的接口,会比使用单个接口要好,这个原则我总是觉得和单一职责原则很像,书上的解释是这样的,对于单一职责来说,注重的是业务逻辑的划分,而接口隔离原则,是说,接口的方法尽量少,也就是说,一种职责如果包含十个方法,是符合单一职责的,但是就不太符合接口隔离方法了。还是举例子来说明吧,以星探找美女为例:
public interface IPrettyGirl{
//面容姣好
public void goodLooking();
//身材好
public void iceFigure();
//气质佳
public void greatTemperament();
}
这就是一个美女的抽象
下面是实现类:
public class PrettyGirl implement IPrettyGirl{
private String name;
public PettyGirl(String name){
this.name=name;
}
public void goodLooking(){
System.out.println("肤白貌美");
}
public void iceFigure(){
System.out.println("身材棒");
}
public void greatTemperament(){
System.out.println("气质佳");
}
}
下面定义星探的抽象类
public abstract class AbstractSearcher{
protected IPrettyGirl prettyGirl;
public AbstractSearcher(IPetttyGirl prettyGirl){
this.prettyGirl=prettyGirl;
}
//展示美女信息;
public void show();
}
实现类:
public class Searcher extends AbstractSearcher{
public Searcher(IprettyGirl prettyGirl){
super(prettyGirl);
}
public void show(){
System.out.println("美女信息如下:....");
}
}
场景类:
public class Client{
public static void main(String []args){
IPrettyGirl xiaohua=new PrettyGirl("xiaohua");
AbstractSearcher searcher =new Searcher(xiaohua);
searcher.show();
}
}
好了,这个实现是没问题的,结果也是意料之中的,那么再来看,这个接口是否还有进一步优化的空间。
答案是有的,比如说,如果长得一般,但是气质格外好,算不算美女,算!也就是说,这个接口里定义了太多的方法太庞大了,他应该依赖于美女,而不是必须满足三个标准的美女,那么我们可以重新定义两个接口,一类是外形好,一类是气质佳,而实现类去实现这两个接口的就是最标准的美女。
但是其实看到这里,我还是觉得,这个和单一职责的原则是重合的,我完全可以把这个拆分理解成美女的两种职责,一种是气质好,一种是外形好,所以对于这条原则,我是心存疑惑的,或许后续的学习中会有更深一步的体会吧。
第五条原则:狄米特法则,又名最小知识原则,就是说,一个对象应该对其他对象有最少的了解,这个法则的核心在于尽量和朋友交流而不是朋友的朋友,这个我读了一些博客上的文章,但是目前还没有特别清晰的认识。暂时先放过好了,以后认识加深了再来补上。
第六条原则:开闭原则,一个软件实体如类、模块和函数应该对扩展开放,对修改关闭,当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。这个应该说是前面五条原则的总结,如果说这六条原则是23种设计模式的总纲,那开闭原则就是这六条原则的总纲。看到一句话写的非常精髓,这里就直接记录下来了:“用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了”。
六大原则写到这里暂且告一段落,后续的设计模式的学习中会不断的贯彻深入。