面向对象的六大原则

来源:互联网 发布:网卡mac地址修改 编辑:程序博客网 时间:2024/06/05 21:51

1 单一职责原则

1.1 定义

  单一职责原则的英文名称是Single Responsibility Principle,简称是SRP。SRP的原话解释是:There should never be more than one reason for a class to change。
  单一职责原则的定义是:应该有且仅有一个原因引起类的变更。简单的说就是:一个类中应该是一组相关性很高的函数、数据的封装,两个不一样的功能不应该放在一个类中。
  这个原则没有具体的划分界限,需要根据个人经验,具体业务逻辑而定。这也是优化代码的第一步。试想一下,如果所有的功能写在一个类里,那么这个类会越来越大,越来越复杂,越不易修改维护。那么根据功能,各自独立拆分出来,岂不是逻辑会清晰些。

1.2 案例解释

  电话通话的时候有4个过程发生:拨号、通话、回应、挂机,那我们写一个接口,其类图如图所示:
  这里写图片描述
  IPhone这个接口可不是只有一个职责,它包含了两个职责:一个是协议管理,一个是数据传送。dial()和hangup()两个方法实现的是协议管理(只管是否接通),分别负责拨号接通和挂机;chat()实现的是数据的传送(关心传递的是什么数据),把我们说的话转换成模拟信号或数字信号传递到对方,然后再把对方传递过来的信号还原成我们听得懂的语言。
  通过这样的分析,我们发现类图上的IPhone接口包含了两个职责,而且这两个职责的变化不相互影响,那就考虑拆分成两个接口,其类图如图所示(一个类实现了两个接口,把两个职责融合在一个类中,这个Phone就有两个原因引起变化的):
  这里写图片描述

1.3 重点

(1)接口一定要做到单一职责;
(2)类的单一职责比较难以实现, 尽量做到只有一个原因引起变化;
(3)一个方法尽可能做一件事, 能分解就分解, 分解到原子级别。

1.4 总结

(1)类的复杂性降低,实现什么职责都有清晰明确的定义;
(2)可读性提高;
(3)可维护性提高;
(4)变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。

2 里氏替换原则

2.1 定义

  Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it(LSP).(所有引用基类的地方必须能透明地使用其子类的对象)。简单地说就是将父类替换为他的子类是不会出现问题,反之,未必可以。那么里氏替换原则就是依赖于面向对象语言的继承与多态。核心原理是抽象。

2.2 案例解释

2.2.1 子类必须完全实现父类的方法

2.2.1.1 常见方法

  在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。我们举个例子来说明这个原则:

(1)UML图示:
  这里写图片描述

(2)源码如下:

/** * 枪支的抽象类 */public abstract class AbstractGun {    // 枪用来干什么的?杀敌!    public abstract void shoot();}/** * 手枪、步枪、机枪的实现类 */public class Handgun extends AbstractGun {    @Override    public void shoot() {        System.out.println("手枪射击...");// 手枪的特点是携带方便,射程短    }}public class Rifle extends AbstractGun {    @Override    public void shoot() {        System.out.println("步枪射击...");// 步枪的特点是射程远,威力大    }}public class MachineGun extends AbstractGun {    @Override    public void shoot() {        System.out.println("机枪扫射...");// 机枪的特点是频率快    }}/** * 士兵的实现类 */public class Soldier {    private AbstractGun gun; // 定义士兵的枪支    public void setGun(AbstractGun _gun) {        this.gun = _gun;// 给士兵一支枪    }    public void killEnemy() {        System.out.println("士兵开始杀敌人...");        gun.shoot();    }}/** * 场景类 */public class Client {    public static void main(String[] args) {         Soldier sanMao = new Soldier();// 产生三毛这个士兵        sanMao.setGun(new Rifle());// 给三毛一支枪        // sanMao.setGun(new MachineGun())        sanMao.killEnemy();    }}

(3)结果解释

士兵开始杀敌人...步枪射击...

  在这个程序中,我们给三毛这个士兵一把步枪,然后就开始杀敌了。如果三毛要使用机枪也可以,直接把sanMao.setGun(new Rifle())修改为sanMao.setGun(new MachineGun())即可,在编写程序时Solider士兵类根本就不用知道是哪个型号的枪(子类)被传入。

(4)总结
  在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了里氏替换原则。

2.2.1.2 异常方法

  如果我们有一个玩具手枪,该如何定义呢?我们先在前一个类图上增加一个类ToyGun,然后继承于AbstractGun类。
(1)修改后的类图如图:
  这里写图片描述

(2)源码如下:

public class ToyGun extends AbstractGun {     //玩具枪是不能射击的,但是编译器又要求实现这个方法,怎么办?虚构一个呗!    @Override    public void shoot() {        //玩具枪不能射击,这个方法就不实现了    }}public class Client {    public static void main(String[] args) {         //产生三毛这个士兵        Soldier sanMao = new Soldier();         sanMao.setGun(new ToyGun());         sanMao.killEnemy();    }}

(3)结果解释

士兵开始杀敌人...

  士兵拿着玩具枪来杀敌人,射不出子弹呀!在这种情况下,我们发现业务调用类已经出现了问题,正常的业务逻辑已经不能运行,那怎么办?
  
(4)解决办法:
  ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbastractGun建立关联委托关系。以在AbstractToy中声明将声音、形状都委托给AbstractGun处理,仿真枪嘛,形状和声音都要和真实的枪一样了,然后两个基类下的子类自由延展,互不影响。如图:
这里写图片描述

(5)总结
  如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。

2.2.2 子类可以有自己的个性

  类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。
(1)修改后的类图如图
  这里写图片描述

(2)源码如下

public class Rifle extends AbstractGun {    @Override    public void shoot() {        System.out.println("步枪射击...");// 步枪的特点是射程远,威力大    }    public void zoomOut() {        System.out.println("步枪观察...");    }}public class AUG extends Rifle {        @Override    public void zoomOut() {        System.out.println("通过望远镜察看敌人...");// 狙击枪都携带一个精准的望远镜    }    @Override    public void shoot() {        System.out.println("AUG射击...");    }}
public class Snipper extends Soldier {    private Rifle gun;     public void setRifle(Rifle _gun) {        super.setGun(_gun);        this.gun = _gun;// 给士兵一支枪    }    public void killEnemy() {         gun.zoomOut(); // 新子类方法:通过望远镜察看敌人...        super.killEnemy();    }}
public class Client {    public static void main(String[] args) {         // 产生小冠这个狙击手        Snipper xiaoguan = new Snipper();        xiaoguan.setGun(new AUG());         xiaoguan.killEnemy();    }}

(3)结果解释

通过望远镜察看敌人...士兵开始杀敌人...AUG射击...

2.3 重点

  在项目中,采用里氏替换原则时,尽量避免子类的“个性”,一旦子类有“个性”,这个子类和父类之间的关系就很难调和了,把子类当做父类使用,子类的“个性”被抹杀——委屈了点;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离——缺乏类替换的标准。

2.4 总结

  里氏替换原则核心是继承,同样它的优缺点也是继承的优缺点。

2.4.1 继承的优点

(1)代码共享 : 共享代码, 子类都拥有父类的方法和属性, 将 父类的代码共享给了子类;
(2)重用性 : 提高代码的重用性, 子类重用父类的代码;
(3)子父类异同 : 子类形似父类, 异于父类, 父子都不同;
(4)扩展性 : 提高代码的可扩展性, 子类就可以为所欲为了, 子类可以随意扩展父类;
(5)开放性 : 提高产品或项目的开放性, 父类随意扩展, 开放性随之增加了。

2.4.2 继承缺点

(1)侵入性 : 继承是侵入性的, 子类 强制继承 父类的方法和属性;
(2)灵活性 : 降低代码的灵活性, 子类必须拥有父类的属性和方法, 子类收到了父类的约束, 这是从子类的角度讲得;
(3)耦合性 : 增强了耦合性, 父类的属性和方法被修改时, 还需要顾及其子类, 可能会带来大量的重构, 这是从父类的角度讲的。

2.4.3 里氏替换原则的优缺点

(1)优点:将子类单独作为一个业务来使用, 会让代码间的耦合关系都复杂, 缺乏类替换标准;
(2)缺点 将子类当做父类用, 抹杀了子类的个性。

3 依赖倒置原则

3.1 定义

  依赖倒置原则(Dependence Inversion Principle,DIP),原始定义包含三层含义:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。
  高层模块和低层模块:每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。抽象就是指接口或抽象类,两者都是不能直接被实例化的。细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化,也就是可以加上一个关键字new产生一个对象。
  依赖倒置原则在Java语言中的表现就是:模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;接口或抽象类不依赖于实现类;实现类依赖接口或抽象类。更加精简的定义就是“面向接口编程”——OOD(Object-Oriented Design,面向对象设计)的精髓之一。
  依赖正置就是类间的依赖是实实在在的实现类间的依赖,也就是面向实现编程,这也是正常人的思维方式,我要开奔驰车就依赖奔驰车。而编写程序需要的是对现实世界的事物进行抽象,抽象的结果就是有了抽象类和接口,然后我们根据系统设计的需要产生了抽象间的依赖,代替了人们传统思维中的事物间的依赖,“倒置”就是从这里产生的。

3.2 问题由来

  类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。
  解决办法:将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。

3.3 案例解释

(1)不使用依赖倒置原则
这里写图片描述

public class 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);    }}

  张三司机不仅要开奔驰车,还要开宝马车,又该怎么实现呢?

(2)使用依赖倒置原则
  不使用依赖倒置原则就会加重类间的耦合性,降低系统的稳定性,增加并行开发引起的风险,降低代码的可读性和可维护性。承接上面的例子,引入依赖倒置原则后的类图如图所示:
这里写图片描述

/** * 在IDriver中,通过传入ICar接口实现了抽象之间的依赖关系,Driver实现类也传入了ICar接口, * 至于到底是哪个型号的Car,需要在高层模块中声明。 */public interface IDriver {     public void drive(ICar car);//是司机就应该会驾驶汽车}public class Driver implements IDriver{    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 Driver();//表面类型是IDriver,实际类型是Driver        ICar benz = new Benz();         zhangSan.drive(benz);//张三开奔驰车    }}

  在新增加低层模块时,只修改了业务场景类,也就是高层模块,对其他低层模块如Driver类不需要做任何修改,业务就可以运行,把“变更”引起的风险扩散降到最低。
  我们再来思考依赖倒置对并行开发的影响。两个类之间有依赖关系,只要制定出两者之间的接口(或抽象类)就可以独立开发了,而且项目之间的单元测试也可以独立地运行,而TDD(Test-Driven Development,测试驱动开发)开发模式就是依赖倒置原则的最高级应用。

3.5 依赖倒置注入实现方法

  依赖是可以传递的,A对象依赖B对象,B又依赖C,C又依赖D……生生不息,依赖不止。记住一点:只要做到抽象依赖,即使是多层的依赖传递也无所畏惧!

3.5.1 构造函数传递依赖对象

  在类中通过构造函数声明依赖对象,按照依赖注入的说法,这种方式叫做构造函数注入,按照这种方式的注入,IDriver和Driver的程序修改后如代码清单所示:

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();    }}

3.5.2 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.5.3 接口声明依赖对象

  在接口的方法中声明依赖对象,上面例子就采用了接口声明依赖的方式,该方法也叫做接口注入。

public interface IDriver {     public void drive(ICar car);//是司机就应该会驾驶汽车}public class Driver implements IDriver{    public void drive(ICar car){ //司机的主要职责就是驾驶汽车        car.run();    }}

3.6 总结

  依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合,我们怎么在项目中遵循以下的几个规则就可以:
(1)每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备这是依赖倒置的基本要求,接口和抽象类都是属于抽象的,有了抽象才可能依赖倒置。
(2)变量的表面类型尽量是接口或者是抽象类。
(3)任何类都不应该从具体类派生。
(4)尽量不要覆写基类的方法。如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响。
(5)结合里氏替换原则使用。得出这样一个通俗的规则: 接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化。
  依赖倒置原则的优点在小型项目中很难体现出来,例如小于10个人月的项目,使用简单的SSH架构,基本上不费太大力气就可以完成,是否采用依赖倒置原则影响不大。但是,在一个大中型项目中,采用依赖倒置原则有非常多的优点,特别是规避一些非技术因素引起的问题。项目越大,需求变化的概率也越大,通过采用依赖倒置原则设计的接口或抽象类对实现类进行约束,可以减少需求变化引起的工作量剧增的情况。

4 接口隔离原则

4.1 定义

(1)主角——接口,接口分为两种:①实例接口(Object Interface),在Java中声明一个类,然后用new关键字产生一个实例,它是对一个类型的事物的描述,这是一种接口。例如:new Person()。②类接口(Class Interface),Java中经常使用的interface关键字定义的接口。
(2)隔离有两种定义:
  ①Clients should not be forced to depend upon interfaces that they don’t use.(客户端不应该依赖它不需要的接口)。解释:依赖它需要的接口,客户端需要什么接口就提供什么接口,把不需要的接口剔除掉,那就需要对接口进行细化,保证其纯洁性。
  ②The dependency of one class to another one should depend on the smallest possible interface.(类间的依赖关系应该建立在最小的接口上)。解释:要求是最小的接口,也是要求接口细化,接口纯洁,与第一个定义如出一辙,只是一个事物的两种不同描述。
  把这两个定义概括为一句话:建立单一接口,不要建立臃肿庞大的接口。再通俗一点讲:接口尽量细化,同时接口中的方法尽量少。
(3)接口隔离原则与单一原则对比:他们审视角度是不相同的,单一职责要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分;而接口隔离原则要求接口的方法尽量少。
(4)按照单一职责原则是允许的,按照接口隔离原则是不允许的,因为它要求“尽量使用多个专门的接口”。专门的接口指什么?就是指提供给每个模块的都应该是单一接口,提供给几个模块就应该有几个接口,而不是建立一个庞大的臃肿的接口,容纳所有的客户端访问。

4.2 案例解释

  要成为一名美女就必须具备:面貌、身材和气质,用类图体现一下星探找美女的过程,如图4-1所示:
(1)修改后的类图如图
这里写图片描述
(2)源码如下

public interface IPettyGirl {     public void goodLooking();//要有姣好的面孔    public void niceFigure();//要有好身材    public void greatTemperament();//要有气质}public class PettyGirl implements IPettyGirl {    private String name; //美女都有名字    public PettyGirl(String _name){         this.name=_name;    }    public void goodLooking() {         System.out.println(this.name + "---脸蛋很漂亮!");    }    public void greatTemperament() {         System.out.println(this.name + "---气质非常好!");    }    public void niceFigure() {        System.out.println(this.name + "---身材非常棒!");    }}
public abstract class AbstractSearcher {     protected IPettyGirl pettyGirl;    public AbstractSearcher(IPettyGirl _pettyGirl){         this.pettyGirl = _pettyGirl;    }    //搜索美女,列出美女信息     public abstract void show();}public class Searcher extends AbstractSearcher{     public Searcher(IPettyGirl _pettyGirl){        super(_pettyGirl);    }       public void show(){             System.out.println("------美女的信息如下------");          super.pettyGirl.goodLooking(); //展示身材        super.pettyGirl.niceFigure(); //展示气质        super.pettyGirl.greatTemperament();    }}
public class Client {     //搜索并展示美女信息    public static void main(String[] args) {         IPettyGirl yanYan = new PettyGirl("嫣嫣");         AbstractSearcher searcher = new Searcher(yanYan);         searcher.show();    }}

(3)结果解释

--------美女的信息如下---------嫣嫣---脸蛋很漂亮!嫣嫣---身材非常棒!嫣嫣---气质非常好!

  思考一下IPettyGirl这个接口,这个接口是否做到了最优化设计?答案是没有,还可以对接口进行优化。
(4)总结
  审美观点都在改变,美女的定义也在变化。脸蛋不怎么样,身材也一般般,但是气质非常好,大部分人都会把这样的女孩叫美女,审美素质提升了,就产生了气质型美女,但是我们的接口却定义了美女必须是三者都具备,按照这个标准,气质型美女就不能算美女,那怎么办?
  分析到这里,我们发现接口IPettyGirl的设计是有缺陷的,过于庞大了,容纳了一些可变的因素,根据接口隔离原则,星探AbstractSearcher应该依赖于具有部分特质的女孩子,而我们却把这些特质都封装了起来,放到了一个接口中,封装过度了!

4.3 优化方案

  把原IPettyGirl接口拆分为两个接口,一种是外形美的美女IGoodBodyGirl,这类美女的特点就是脸蛋和身材极棒,超一流,但是没有审美素质,比如文化程度比较低;另外一种是气质美的美女IGreatTemperamentGirl,谈吐和修养都非常高。我们把一个比较臃肿的接口拆分成了两个专门的接口,灵活性提高了,可维护性也增加了,不管以后是要外形美的美女还是气质美的美女都可以轻松地通过PettyGirl定义。两种类型的美女定义如代码:
(1)修改后的类图如图
  这里写图片描述

(2)源码如下:

// 两种类型的美女定义public interface IGoodBodyGirl {     public void goodLooking(); //要有姣好的面孔    public void niceFigure();//要有好身材}public interface IGreatTemperamentGirl {     public void greatTemperament();//要有气质}
public class PettyGirl implements IGoodBodyGirl,IGreatTemperamentGirl {    private String name; //美女都有名字    public PettyGirl(String _name){         this.name=_name;    }    public void goodLooking() {         System.out.println(this.name + "---脸蛋很漂亮!");    }    public void greatTemperament() {         System.out.println(this.name + "---气质非常好!");    }    public void niceFigure() {        System.out.println(this.name + "---身材非常棒!");    }}

(3)总结
  以上把一个臃肿的接口变更为两个独立的接口所依赖的原则就是接口隔离原则,让星探AbstractSearcher依赖两个专用的接口比依赖一个综合的接口要灵活。接口是我们设计时对外提供的契约,通过分散定义多个接口,可以预防未来变更的扩散,提高系统的灵活性和可维护性。

4.4 接口隔离原则是对接口的规范约束

(1)接口要尽量小
  这是接口隔离原则的核心定义,不出现臃肿的接口(Fat Interface),但是“小”是有限度的,首先就是不能违反单一职责原则。为什么?单一职责原则中提到一个IPhone的例子,在这里,我们使用单一职责原则把两个职责分解到两个接口中,如类图:
  这里写图片描述
  仔细分析一下IConnectionManager接口是否还可以再继续拆分下去,挂电话有两种方式:一种是正常的电话挂断,一种是电话异常挂机,比如突然没电了,通信当然就断了。这两种方式的处理应该是不同的。
如果拆分了,那就不符合单一职责原则了,因为从业务逻辑上来讲,通信的建立和关闭已经是最小的业务单位了,再细分下去就是对业务或是协议(其他业务逻辑)的拆分了。
  所以,根据接口隔离原则拆分接口时,首先必须满足单一职责原则。
  
(2)接口要高内聚
  什么是高内聚?高内聚就是提高接口、类、模块的处理能力,减少对外的交互。
  例如:你告诉下属“到奥巴马的办公室偷一个××文件”,然后听到下属用坚定的口吻回答你:“是,保证完成任务!”一个月后,你的下属还真的把××文件放到你的办公桌上了。这种不讲任何条件、立刻完成任务的行为就是高内聚的表现。具体到接口隔离原则就是:要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越少,同时也有利于降低成本。

(3)定制服务
  一个系统或系统内的模块之间必然会有耦合,有耦合就要有相互访问的接口(并不一定就是Java中定义的Interface,也可能是一个类或单纯的数据交换),我们设计时就需要为各个访问者(即客户端)定制服务,什么是定制服务?定制服务就是单独为一个个体提供优良的服务。我们在做系统设计时也需要考虑对系统之间或模块之间的接口采用定制服务。采用
定制服务就必然有一个要求:只提供访问者需要的方法。
  举例说明,开发了一个图书管理系统,其中有一个查询接口,方便管理员查询图书,其类图如图:
这里写图片描述
  在接口中定义了多个查询方法,分别可以按照作者、标题、出版社、分类进行查询,最后还提供了混合查询方式。程序写好了并上线了,突然有一天发现系统速度非常慢,发现是访问接口中的complexSearch(Map map)方法并发量太大,导致应用服务器性能下降,然后继续跟踪下去发现这些查询都是从公网上发起的。
  进一步分析,找到问题:提供给公网的查询接口和提供给系统内管理人员的接口是相同的,都是IBookSearcher接口,但是权限不同,系统管理人员可以通过接口的complexSearch方法查询到所有的书籍,而公网的这个方法是被限制的,不返回任何值,在设计时通过口头约束,这个方法是不可被调用的。但是由于公网项目组的疏忽,这个方法还是公布了出去,虽然不能返回结果,但是还是引起了应用服务器的性能巨慢的情况发生,这就是一个臃肿接口引起性能故障的案例。
  需要把这个接口进行重构,将IBookSearcher拆分为两个接口,分别为两个模块提供定制服务,修改后的类图如图:
这里写图片描述
  提供给管理人员的实现类同时实现了ISimpleBookSearcher和IComplexBookSearcher两个接口,原有程序不用做任何改变,而提供给公网的接口变为ISimpleBookSearcher,只允许进行简单的查询,单独为其定制服务,减少可能引起的风险。
(4)接口设计是有限度的
  接口的设计粒度越小,系统越灵活,这是不争的事实。但是,灵活的同时也带来了结构的复杂化,开发难度增加,可维护性降低,这不是一个项目或产品所期望看到的,所以接口设计一定要注意适度,这个“度”如何来判断呢?根据经验和常识判断,没有一个固化或可测量的标准。

4.5 总结

4.5.1 原子接口划分原则

  接口隔离原则是对接口的定义,同时也是对类的定义,接口和类尽量使用原子接口或原子类来组装。但是,这个原子该怎么划分是设计模式中的一大难题,在实践中可以根据以下几个规则来衡量:
(1)接口模块对应关系 : 一个接口只服务于一个子模块 或 业务逻辑;
(2)方法压缩 : 通过业务逻辑, 压缩接口中得 public 方法, 减少接口的方法的数量;
(3)修改适配 : 尽量去修改已经污染的接口, 如果变更风险较大, 采用适配器模式进行转化处理。

4.5.2 实践、经验和领悟

  根据经验和常识决定接口的粒度大小,接口粒度太小,导致接口数据剧增,开发人员呛死在接口的海洋里;接口粒度太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。

5 迪米特法则

5.1 定义

  迪米特法则(Law of Demeter,LoD)也称为最少知识原则(Least Knowledge Principle,LKP),虽然名字不同,但描述的是同一个规则:一个对象应该对其他对象有最少的了解。通俗地讲:一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多public方法,我就调用这么多,其他的我一概不关心。

5.2 案例解释

  迪米特法则对类的低耦合提出了明确的要求,其包含以下4层含义:

5.2.1 只和朋友交流

  迪米特法则还有一个英文解释是:Only talk to your immediate friends(只与直接的朋友通信)。直接的朋友:每个对象都必然会与其他对象有耦合关系,两个对象之间的耦合就成为朋友关系,这种关系的类型有很多,例如组合、聚合、依赖等。下面我们将举例说明如何才能做到只与直接的朋友交流。
  
(1)不只和朋友交流的情况—–类图如图所示:
这里写图片描述

// 老师只有一个方法commond,老师对学生发布命令,清一下女生public class Teacher {     public void commond(GroupLeader groupLeader){        List 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());    }}// 老师类和体育委员类都对女生类产生依赖,而且女生类不需要执行任何动作,因此定义一个空类public class Girl {}public class Client {    public static void main(String[] args) {        Teacher teacher= new Teacher(); //老师发布命令        teacher.commond(new GroupLeader());    }}
女生数量是:20

  首先确定Teacher类有几个朋友类,它仅有一个朋友类——GroupLeader。为什么Girl不是朋友类呢?Teacher也对它产生了依赖关系呀!朋友类的定义是这样的:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类,而Girl这个类就是出现在commond方法体内,因此不属于Teacher类的朋友类。迪米特法则告诉我们一个类只和朋友类交流,但是我们刚刚定义的commond方法却与Girl类有了交流,声明了一个List动态数组,也就是与一个陌生的类Girl有了交流,这样就破坏了Teacher的健壮性。方法是类的一个行为,类竟然不知道自己的行为与其他类产生依赖关系,这是不允许的,严重违反了迪米特法则。

(2)只和朋友交流的情况—–修改后的类图如图所示:
这里写图片描述

// 在类图中去掉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());    }}public class Client {    public static void main(String[] args) {         //产生一个女生群体        List<Girl> listGirls = new ArrayList<Girl>(); //初始化女生        for(int i=0;i<20;i++) {             listGirls.add(new Girl());        }        Teacher teacher= new Teacher(); //老师发布命令        teacher.commond(new GroupLeader(listGirls));    }}
女生数量是:20

  对程序进行了简单的修改,把Teacher中对List的初始化移动到了场景类中,同时在GroupLeader中增加了对Girl的注入,避开了Teacher类对陌生类Girl的访问,降低了系统间的耦合,提高了系统的健壮性。

(3)注意:一个类只和朋友交流,不与陌生类交流,不要出现getA().getB().getC()这种情况,类与类之间的关系是建立在类间的,而不是方法间,因此一个方法尽量不引入一个类中不存在的对象,当然,JDK API提供的类除外。

5.2.2 朋友间也是有距离的

(1)朋友之间过于亲密情况—–类图如图所示:
这里写图片描述

public class Wizard {    private Random rand = new Random(System.currentTimeMillis());     public int first(){ //第一步        System.out.println("执行第一个方法...");    return rand.nextInt(100);    }    public int second(){ //第二步        System.out.println("执行第二个方法...");        return rand.nextInt(100);    }    public int third(){ //第三步        System.out.println("执行第三个方法...");        return rand.nextInt(100);    }}public class InstallSoftware {    public void installWizard(Wizard wizard){        int first = wizard.first(); //根据first返回的结果,看是否需要执行second        if(first>50){            int second = wizard.second();             if(second>50){                int third = wizard.third();                     if(third >50){                        wizard.first();                    }            }        }    }}public class Client {// 模拟安装软件的三个步骤    public static void main(String[] args) {        InstallSoftware invoker = new InstallSoftware();        invoker.installWizard(new Wizard());    }}

  Wizard类把太多的方法暴露给InstallSoftware类,两者的朋友关系太亲密了,耦合关系变得异常牢固。如果要将Wizard类中的first方法返回值的类型由int改为boolean,就需要修改InstallSoftware类,从而把修改变更的风险扩散开了。

(2)朋友之间关系适度的情况—–类图如图所示:
这里写图片描述

// 在Wizard类中增加一个installWizard方法,对安装过程进行封装,同时把原有的三个public方法修改为private方法public class Wizard {    private Random rand = new Random(System.currentTimeMillis());     private int first(){ //第一步        System.out.println("执行第一个方法...");    return rand.nextInt(100);    }    private int second(){ //第二步        System.out.println("执行第二个方法...");    return rand.nextInt(100);    }    private int third(){ //第三步        System.out.println("执行第三个方法...");        return rand.nextInt(100);    }    //软件安装过程     public void installWizard(){        int first = this.first();         //根据first返回的结果,看是否需要执行second        if(first>50){            int second = this.second();             if(second>50){                int third = this.third();                 if(third >50){                    this.first();                }            }        }    }}public class InstallSoftware {    public void installWizard(Wizard wizard){         wizard.installWizard();//直接调用    }}

  将三个步骤的访问权限修改为private,同时把InstallSoftware中的方法installWizad移动到Wizard方法中。通过这样的重构后,Wizard类就只对外公布了一个public方法,即使要修改first方法的返回值,影响的也仅仅只是Wizard本身,其他类不受影响,这显示了类的高内聚
特性。

(3)总结
  一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为private、package-private(包类型,在类、方法、变量前不加访问权限,则默认为包类型)、protected等访问权限,是否可以加上final关键字等。

5.2.3 是自己的就是自己的

  在实际应用中经常会出现这样一个方法:放在本类中也可以,放在其他类中也没有错,那怎么去衡量呢?你可以坚持这样一个原则:如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中。

5.2.4 谨慎使用Serializable

  在一个项目中使用RMI(Remote Method Invocation,远程方法调
用)方式传递一个VO(Value Object值对象),这个对象就必须实现Serializable接口把需要网络传输的对象进行序列化,否则就会出NotSerializableException异常。突然,客户端的VO修改了一个属性的访问权限,从private变更为public,访问权限扩大了,如果服务器上没有做出相应的变更,就会报序列化失败,就这么简单。但是这个问题的产生应该属于项目管理范畴,一个类或接口在客户端已经变更了,而服务器端却没有同步更新。

5.3 总结

  迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了难度。我们在采用迪米特法则时需要反复权衡,既做到让结构清晰,又做到高内聚低耦合。
  迪米特法则要求类间解耦,但解耦是有限度的,除非是计算机的最小单元——二进制的0和1。那才是完全解耦,在实际的项目中,需要适度地考虑这个原则,别为了套用原则而做项目。

6 开闭原则

6.1 定义

  开闭原则是Java世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统,先来看开闭原则的定义:Software entities like classes,modules and functions should be open for extension but closed for modifications.(一个软件实体如类、模块和函数应该对扩展开放,对修改关闭)。含义是:一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。

6.2 案例解释

这里写图片描述
  最初,书店按照此类图生产图书赚到了钱。但是从2008年开始书店为了生存开始打折销售:所有40元以上的书籍9折销售,其他的8折销售。对已经投产的项目来说,这就是一个变化,我们应该如何应对这样一个需求变化?有如下三种方法可以解决这个问题:
  ①修改接口:在IBook上新增加一个方法getOffPrice(),专门用于进行打折处理,所有的实现类实现该方法。但这样修改的后果就是:实现类NovelBook要修改,BookStore中的main方法也修改,同时IBook作为接口应该是稳定且可靠的,不应该经常发生变化,否则接口作为契约的作用就失去了效能。因此,该方案否定。
  ②修改实现类:修改NovelBook类中的方法,直接在getPrice()中实现打折处理,好办法,我相信大家在项目中经常使用的就是这样的办法,通过class文件替换的方式可以完成部分业务变化或缺陷修复。但是该方法还是有缺陷的,例如采购书籍人员也是要看价格的,由于该方法已经实现了打折处理价格,因此采购人员看到的也是打折后的价格,会因信息不对称而出现决策失误的情况。因此,该方案也不是一个最优的方案。
  ④通过扩展实现变化:增加一个子类OffNovelBook,覆写getPrice方法,高层次的模块(也就是static静态模块区)通过OffNovelBook类产生新的对象,完成业务变化对系统的最小化开发。修改后的类图如图所示:
  
这里写图片描述

// 仅仅覆写了getPrice方法,通过扩展完成了新增加的业务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;    }}

6.3 为什么采用开闭原则

(1)开闭原则对测试的影响
  所有已经投产的代码都是有意义的,并且都受系统规则的约束,这样的代码都要经过测试过程,不仅保证逻辑是正确的,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码”,因此有变化提出时,我们就需要考虑一下,原有的健壮代码是否可以不修改,仅仅通过扩展实现变化呢?否则,就需要把原有的测试过程回笼一遍,需要进行单元测试、功能测试、集成测试甚至是验收测试,现在虽然在大力提倡自动化测试工具,但是仍然代替不了人工的测试工作
(2)开闭原则可以提高复用性
  在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑。只有这样代码才可以复用,粒度越小,被复用的可能性就越大。
(3)开闭原则可以提高可维护性
  一款软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能要对程序进行扩展,维护人员最乐意做的事情就是扩展一个类,而不是修改一个类,让维护人员读懂原有的代码,然后再修改,是一件很痛苦的事情。
(4)面向对象开发的要求
  万物皆对象,我们需要把所有的事物都抽象成对象,然后针对对象进行操作,但是万物皆运动,有运动就有变化,有变化就要有策略去应对,怎么快速应对呢?这就需要在设计之初考虑到所有可能变化的因素,然后留下接口,等待“可能”转变为“现实”。

6.4 总结

  闭原则是一个非常虚的原则,前面5个原则是对开闭原则的具体解释,但是开闭原则并不局限于这么多,它“虚”得没有边界,就像“好好学习,天天向上”的口号一样,告诉我们要好好学习,但是学什么,怎么学并没有告诉我们,需要去体会和掌握。

原创粉丝点击