您的位置:首页 > 其它

设计模式之设计原则-里氏替换原则

2016-10-19 13:21 162 查看

父类和子类

面向对象语言中的继承,有以下优点:

代码共享,减少创建子类的工作量,只要继承了父类就拥有父类的方法和属性

提高代码重用性

子类可以完全继承父类的方法,又可以对父类的方法进行重写,既可以和父类保持一致,也可以有自身的特点

提高代码的扩展性,只要实现父类的方法就可以调用,许多开源框架都是继承父类来实现的

提高项目和产品的开放性

但是缺点也有:

继承具有侵入性,只要继承,就必须拥有父类的方法和属性,只能修改但是不能丢弃

降低代码灵活性,子类拥有的父类方法和属性,成为了子类的约束

耦合性增强,父类的常量,变量以及方法被修改时,同样会影响子类,或导致子类的实现发生变化,进而会导致实现代码需要重构

Java中类属于单一继承,多实现,接口是多继承,多实现,使用里氏替换原则,可以发挥继承的最大优势,里氏替换原则的定义如下:

程序P中有类F的实例Fo和类S的实例So,如果将So都替换为Fo而程序P的行为没发生变化的时候,就可以称S是F的子类.

所有引用基类的地方必须能够透明的使用其子类对象

第二种定义更简单易懂,所有能使用父类的地方,子类的对象就可以使用,因为子类完全拥有父类的属性和方法,所以使用子类不会产生任何错误或异常,使用者可能根本不知道使用的是子类还是父类,但是反过来,子类能够出现的地方,父类对象在这里可能就行不通。

规范定义

里氏替换原则为良好的继承关系定义了一个规范,包括四层含义:

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

在系统设计时,常会定义一个接口或者抽象类,然后定义子类来实现它,定义调用接口时使用接口或者抽象类,调用接口时传入其子类的对象,有这样一个例子:



在该例中,枪的主要功能是射击,所以我们定义一个抽象类AbstractGun,并定义一个抽象方法shoot,具体是如何射击的在子类中各自实现,士兵中有一把枪,具体是什么枪在实例化的时候由setGun传入,士兵可以杀敌,所以定义killEnemy方法,代码如下:

// 抽象的枪类
public abstract class AbstractGun {
void shoot();
}
// 枪的实现类
public class HandGun extends AbstractGun {
public void shoot() {
System.out.println("pa~pa~pa~...");
}
}
public class Rifle extends AbstractGun {
public void shoot() {
System.out.println("tu~tu~tu~...");
}
}
public class MachineGun extends AbstractGun {
public void shoot() {
System.out.println("biu~biu~biu~...");
}
}
// 士兵类定义
public class Solider {
private AbstractGun gun;
public void setGun(AbstractGun gun) {
this.gun = gun;
}
public void shoot() {
gun.shoot();
}
}
// 战斗场景,实例化类并传入参数
public class Battle {
public static void main(String[] args){
Soldier xuSanDuo = new Solider();
// 发枪
xuSanDuo.setGun(new Rifle());
// 突突突
xuSanDuo.killEnemy();
}
}


这个战斗场景中,setGun接口接受父类对象,那就可以接收任何子类的对象,但是我们在定义的时候不需要管是什么子类,只要设定为父类即可,在实际场景中可以根据需要设置使用什么子类对象.

进一步的,如果我们新增加了一种玩具枪,可以定义它继承AbstractGun,这样在战场上就可以使用玩具枪对象了:

public class ToyGun extends AbstractGun {
public void shoot() {
System.out.println("喷水~喷水~喷水~...");
}
}


但是这样我们就发现不对了,要是我们的战士拿着这玩意儿上战场,不用开枪就把敌人笑死了,这不是我们想要的效果,我们需要英勇战斗,拿真枪跟他们干,问题出在哪里?我们把玩具枪当成真枪玩儿了,可是这玩意儿毕竟不是枪,是个玩具,也就是说按照我们的业务要求,这两个东西不能相提并论,玩具枪违背了我们当初定义枪是上战场杀敌的初衷,那怎么办?分开,玩具是玩具,枪就是枪,我们定义一个玩具类AbstractToy:

public abstract class AbstractToy {
// 形状和声音委托给AbstractGun来处理,我们玩具主要供你娱乐
void play();
}
// 然后再用水枪了实例化玩具枪
public class WaterToyGun extends AbstractToy {
// 水枪玩起来会喷水
public void play() {
System.out.println("喷水~喷水~喷水~...");
}
}


这样一来,我们就不会让这个玩具影响我们的正常业务处理,闹出笑话了,上面的处理告诉我们,在实际应用场景中,如果子类不能正确完整的实现父类的业务,则需要断开继承关系,否则就会影响我们正常的业务执行。

子类的个性

作为子类当然需要有自己的实现,而且还是与众不同的实现,之所以这样,是因为里氏替换原则可以正向使用,却不能反向使用,在子类出现的地方,父类对象可能就不能够胜任,我们把刚才的步枪和士兵扩展一下:

// 定义一把AK47
public class AK47 extends Rifle {
}
// 再来一把狙击枪
public class AWM extends Rifle {
// 狙击枪自带瞄准镜
public void zoomOut() {
System.out.println("敌人放大5倍");
}
public void shoot() {
// 狙击枪一次只能打一发子弹
System.out.println("Boom~...");
}
}
// 定义一个狙击手
public class Snipper {
// 狙击手拥有一把狙击枪
private AWM gun;
// 只接受狙击枪
public void setGun(AWM gun) {
this.gun = gun;
}
// 杀敌方式也与众不同
public void killEnemy() {
gun.zoomOut();
gun.shoot();
}
}


通过这样的定义,我们有了一种特殊的士兵-狙击手,他对武器的要求非常严格,只能接受和使用狙击枪,所以我们在给他设置武器的时候就只能使用Rifle的子类AWM对象,而不能使用父类或者其他对象,如果一定要强制使用其他对象,就会在运行的时候抛出java.lang.ClassCastException,也就是强制向下转型会在运行时出现问题,这也符合里氏替换原则的要求,子类出现的地方未必父类对象就可以适用。

覆盖或实现父类方法时可以放大输入参数

在一个方法中,输入参数属于前置条件,表示如果想要执行该方法,就必须满足该前置条件,传入正确类型的实例方可,方法的返回值属于后置条件,就是说如果想要接收我这个方法执行后的返回值,需要满足我这个返回值类型,你需要使用和返回值类型一样的变量或者对象来接收返回结果,这种方式叫做契约设计,同时定义了前置条件和后置条件并且需要满足这两个条件才可以执行该方法并获取反馈结果,举个例子:

public class Father {
public Collection doSomething(HashMap map) {
System.out.println("父类被执行...");
return map.values();
}
}
// 子类
public class Son extends Father {
// 子类放大了输入参数类型
public Collection doSomething(Map map) {
System.out.println("子类被执行...");
return map.values();
}
}


需要注意的是,这里子类的方法没有加上@override,并且方法参数不一样,说明该方法属于重载(@overload),但是由于是继承关系,所以子类还是拥有父类的那个参数为HashMap的方法的,这也更加证明了子类中的方法属于重载,我们在使用场景中应该是这样的:

public class Client {
public static void main(String[] args) {
Father f = new Father();
HashMap map = new HashMap();
f.doSomething(map);
}
}


这个场景中,执行结果应该是”父类被执行…”,如果我们把场景中的Father替换为Son,执行结果还是一样的,这是因为子类继承了父类中参数为HashMap的方法,但是没有重写这个方法,所以还是执行的子类中的参数为HashMap的方法,但是由于没有重写,执行结果还是一样,输出的内容是”父类被执行…”,由于子类的输入范围比父类要大,所以如果参数是HashMap的话会永远执行继承父类的那个方法,不会执行子类的方法,如果想要执行子类方法的话,传入的参数就不能是HashMap以及其子类对象,或者是重写父类的这个参数为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();
}
}


在这个场景中,我们按照里氏替换原则,父类能够出现的地方就可以使用子类对象:

public class Client {
public static void main(String[] args) {
// 可以使用子类对象
Son f = new Son();
HashMap map = new HashMap();
f.doSomething(map);
}
}


运行后的结果是”子类被执行…”,这和我们的预期不符,我们原本认为,子类继承了父类的方法,并且这里的HashMap也确实是Map的子类,按照正常的里氏替换原则,子类只有在重写了父类的方法时才会执行子类方法,否则会执行父类中的参数为Map的方法,但是我们这里没有重写父类方法,仅仅是重载了该方法并缩小了该方法的前置参数的范围,就会导致执行子类的方法,在正常的业务逻辑中会引起混乱,实际应用中父类都是抽象类,子类为实现类,所以传递一个这样的实现,会拦截父类中本应该本执行的方法反而去执行子类方法,所以我们在设计中应该避免使用这样的设计方式,子类方法前置条件应该大于或者等于父类中被重写或者重载的方法前置条件范围。

覆写或者实现父类方法时可以缩小输出结果

里氏替换原则要求,子类中方法的返回值类型必须小于或者等于被重写的父类方法的返回值类型:

如果是重写方法,那么父类和子类中有一个相同的名称和输入参数的方法,就会要求子类方法返回值类型小于等于父类方法返回值

如果是重载方法,那么要求方法的输入参数或者数量不同,但是里氏替换原则要求,子类的输入参数要大于等于父类的输入参数,所以这个被重载的方法不会被调用

里氏替换原则的目的是增强程序的健壮性,即便是版本升级时也可以保持很好的兼容性,增加新的子类,原来的子类还可以使用,在实际项目中,每个子类对应不同的业务实现逻辑,定义时使用父类作为参数,使用时传递子类对象,就可以完成不同的业务实现逻辑。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息