里氏替换原则Liskov Substitution Principle,LSP
里氏替换原则 的核心思想是:
子类对象必须能够替换掉其父类对象,而程序的逻辑不变。
换句话说,程序中任何使用父类对象的地方,如果替换成其子类对象,程序不应该产生任何错误或异常,行为也应该与预期一致
很多人初学时会有一个误解:“里氏替换不就是说子类继承了父类,所以能用在父类出现的地方吗?” 这其实只对了一半。语法上的可替换(编译不报错)只是最低要求,里氏替换更强调的是行为上的可替换(运行时不出错、逻辑一致)
public class Liskov {public static void main(String[] args) {// TODO Auto-generated method stubA a=new A();System.out.println(a.fun1(2, 3));B b = new B();System.out.println(b.fun1(0, 3));System.out.println(b.fun2(2,2));}}
class A{public int fun1(int num1,int num2) {return num1-num2; }
}
class B extends A{//无意识重写了fun1,导致修改了原逻辑,无法实现B的对象替换A的对象public int fun1(int num1,int num2) {return num1+num2;}public int fun2(int num1,int num2) {return fun1(num1,num2)+9;}
}
这里B继承A,所以理论上B的fun1是可以替换A的fun1的,但是显然,直接替换会有逻辑错误,原本的减法变成了加法。显然违背了LSP。
违反LSP的根源往往是紧耦合的继承关系。一个更优的设计是使用组合或者更抽象的接口。
修改后:
public class ImporveLiskov {public static void main(String[] args) {// TODO Auto-generated method stubA a=new A();System.out.println(a.fun1(2, 3));//由于B不在是A的子类,所以调用的时候不会认为b的方法和a方法功能一样B b = new B();System.out.println(b.fun1(0, 3));System.out.println(b.fun3(2,2));}}
interface Base{public int fun1(int num1,int num2);}
class A implements Base{public int fun1(int num1,int num2) {// TODO Auto-generated method stubreturn num1-num2;}
}class B implements Base{//使用组合来实现代码复用private A a=new A();//无意识重写了fun1,导致修改了原逻辑,无法实现B的对象替换A的对象public int fun1(int num1,int num2) {return num1+num2;}public int fun2(int num1,int num2) {return fun1(num1,num2)+9;}public int fun3(int num1,int num2) {return this.a.fun1(num1, num2);}}
此时A和B之间就没有了强制的“is-a”关系,它们只是共享了同一个接口。任何使用 Base 的地方,两者都可以替换,并且行为是符合各自定义的,不会出现意料之外的副作用
一个问题
Q:由于子类往往会重写父类方法,所以违背里氏替换原则。要遵循里氏替换原则,则需要使用组合和更抽象的接口吗?那么这种修改是否违背了继承
要理解这个问题,必须要有如下的认识:
第一个关键点
:不是"重写"本身违背LSP,而是"不恰当的重写"违背LSP。
第二个:关于"修改是否违背继承"的分析
继承的两种用途
1.实现继承(is-a关系) - 子类确实是父类的一种特殊形式
2.代码复用继承 - 仅仅为了复用代码,没有真正的is-a关系
问题的根源
很多违背LSP的情况,根源在于我们错误地使用了"实现继承"来表达"代码复用"的需求。所以,当考虑"使用组合和接口"时,实际上是在说:
“重新审视你的设计意图:你到底是需要真正的is-a关系,还是仅仅需要代码复用?”
如果是真正的is-a关系 → 使用继承,但要确保LSP
如果是代码复用 → 使用组合
所以说说,LSP原则不仅不会违背了继承,这恰恰是更好地理解了继承的本质。
由于传统误解:“继承就是代码复用”,而LSP揭示真相:“继承是行为的契约,代码复用只是副产品”
所以即使是修改为使用组合或者更加抽象的接口,这也不是在违背继承,而是在纠正对继承的误用
例如:
适合使用继承的场景:显然这里使用继承关系描述是很切合的
// 真正的is-a关系,行为一致
class Animal {public void breathe() { ... } // 所有动物都会呼吸
}class Mammal extends Animal { ... } // 哺乳动物确实是动物
class Fish extends Animal { ... } // 鱼确实是动物
适合组合的场景:这里的Car和Airplane需要使用Engine的功能,复用和重写其代码,但是显然,他们之间使用继承关系描述很不合适,这时候仅仅是为了复用代码,那么通过LSP判断,使用组合更加合适。
// 需要复用功能但行为不同
class Engine { ... }
class Car {private Engine engine; // 汽车有引擎,但汽车不是引擎
}class Airplane {private Engine engine; // 飞机有引擎,但飞机不是引擎
}
所以说:里氏替换存在的意义,就是提醒说,
如果是真正的is-a关系 → 使用继承,但要确保LSP
如果是代码复用 → 使用组合
而如果使用了继承,我们尽量不要在子类中重写父类方法,如果一定要重写,则考虑使用组合,聚合,依赖的方式来解决问题。即通用的做法是,让原本的父类和子类都继承一个更通俗的类,或者实现一个更抽象的接口,将原有的继承关系取消掉,采用使用组合,聚合,依赖等关系替代
