概述
一、概念
里氏替换原则,在设计模式之禅一书中有两种定义:
- 定义1:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2 时,程序P的行为没有发生变化,那么类型 S 是类型 T 的子类型。
- 定义2:所有引用基类的地方必须能透明地使用其子类的对象。
综合上面比较抽象的含义,换句话可能好理解些:其实就是对于同一个程序P,把出现父类对象的地方,用子类去替换父类对象执行时,程序P功能或者说行为没有改变,不会产生任何错误或异常;但是反过来就不行了,用父类去替换有子类的地方,由于子类可能扩展了一些功能方法,运行结果可能就会报错或者产生异常,所以父类未必能适应。
二、里氏替换原则
里氏替换原则主要是针对继承关系来说的,为继承定义了一个规范:
- 父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。
继承作为面向对象四大原则(封装、继承、多态、抽象)之一,肯定有很多好处,但是凡事都有两面性,在带了好处的同时也会有一些弊端,下面总结继承的优点和弊端:
优点:
- 代码共享,减少创建类的工作量,通过继承,每个子类都将继承父类的方法和属性;
- 提高代码的重用性,通过继承,子类可以重用父类的一些代码,减少代码量;
- 子类可以形似父类,但又异于父类,子类可以扩展自己的功能;
- 提高代码的可扩展性,很多开源框架的扩展接口都是通过继承父类来完成的;
弊端:
- 给程序带来侵入性,只要子类继承了父类,那么子类必须拥有父类的所有属性和方法;
- 程序可移植性下降,子类必须拥有父类的属性和方法;
- 增加了对象间的耦合性, 如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障;
下面列举一个违反里氏替换原则的例子。
三、示例
public class Test3 {
public static void main(String[] args) {
ClassA classA = new ClassA();
System.out.println(classA.compare(10, 20)); // false
// 按照里氏替换原则,子类对象替换出现父类对象的地方,观察程序运行行为有没有发生变化
ClassB classB = new ClassB();
System.out.println(classB.compare(10, 20)); // true
}
}
class ClassA {
public boolean compare(int a, int b) {
return a > b;
}
}
class ClassB extends ClassA {
// 无意中重写了父类中已实现的方法, 将父类方法的行为都改变了。
@Override
public boolean compare(int a, int b) {
return a < b;
}
}
运行结果:
可见,当出现父类对象的时候,我将它替换为子类对象,compare()方法的行为已经发生了变化,故违反了里氏替换原则。
- 解决方案:类B继承类A时,尽量不要去重写父类已经实现好的方法,可以通过增加方法来扩展新功能。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。
四、优化
下面通过两种方式进行优化:
【a】 通过新增方法来扩展功能,不重写父类方法
public class Test4 {
public static void main(String[] args) {
ClassA classA = new ClassA();
System.out.println(classA.compare(10, 20)); // false
// 按照里氏替换原则,子类对象替换出现父类对象的地方,观察程序运行行为有没有发生变化
ClassB classB = new ClassB();
System.out.println(classB.compare(10, 20)); // false
}
}
class ClassA {
public boolean compare(int a, int b) {
return a > b;
}
}
class ClassB extends ClassA {
// 通过新增方法来扩展新功能
public boolean compare2(int a, int b) {
return a < b;
}
}
运行结果:
这样子的话,子类对象完全可以替换父类对象去完成相同的功能。其实在工作中,尽量是一个类去继承抽象类,而不是去继承非抽象类,因为抽象类不能实例化。
【b】 断开之前的继承关系,将原先的子类以及父类中公共部分抽象出一个抽象类,然后原先两个类都继承这个抽象类,这样两个类的关系从原先的父子关系变成兄弟关系。
public class Test4 {
public static void main(String[] args) {
ClassA classA = new ClassA();
System.out.println(classA.compare(10, 20)); // false
ClassB classB = new ClassB();
System.out.println(classB.compare(10, 20)); // true
}
}
abstract class Calculate {
public abstract boolean compare(int a, int b);
}
class ClassA extends Calculate {
@Override
public boolean compare(int a, int b) {
return a > b;
}
}
class ClassB extends Calculate {
// 如果还想调用ClassA中的方法,可以采用组合等方式进行调用
@Override
public boolean compare(int a, int b) {
return a < b;
}
}
五、延伸
里氏替换原则为良好的继承定义了一个规范,主要有下面四个方面:
- 子类必须实现父类中未实现的方法,尽量不要重写父类的非抽象方法;
通过新增方法来扩展功能,如果非要重写父类方法,那么可以将父类和子类中公共的地方抽成一个抽象类,然后原先的类都继承这个抽象类,断开继承关系,使用组合、聚合、依赖等关联。
- 子类可以增加自己特有的方法;
当子类需要扩展新功能时,推荐使用增加自己特有的方法来实现。
- 当子类的方法覆写或实现父类的方法时,子类方法的前置条件(即方法的形参)要比父类方法的范围要大;
举例说明:
public class Test2 {
public static void main(String[] args) {
// 父类存在的地方,子类就应该能够存在
A a = new A();
B b = new B();
a.test("a"); // 【A】被執行...
b.test("a"); // 【A】被執行...
}
}
class A {
public void test(String string) {
System.out.println("【A】被執行...");
}
}
class B extends A {
public void test(Object obj) {
System.out.println("【B】被執行...");
}
}
分析: 子类的形参范围比父类的形参范围要大,那么子类子类代替父类传递到调用者中,子类的方法不会被执行,这是符合里氏替换原则的,如果想让子类的方法被执行,就必须覆写父类的方法。
下面我们来看一下,把父类的输入参数类型放大,子类的输入参数类型缩小,让子类的输入参数类型小于父类的输入参数类型,看看会出现什么情况?
public class Test2 {
public static void main(String[] args) {
// 父类存在的地方,子类就应该能够存在
A a = new A();
B b = new B();
a.test("a"); // 【A】被執行...
b.test("a"); // 【B】被執行...
}
}
class A {
public void test(Object obj) {
System.out.println("【A】被執行...");
}
}
class B extends A {
public void test(String string) {
System.out.println("【B】被執行...");
}
}
由运行结果可知,调用了子类,子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更加严格: 如父类要求返回List,那么子类就应该返回List的实现ArrayList,父类是采用泛型,那么子类则不能采用泛型,而是具体的返回。
public class Test2 {
public static void main(String[] args) {
// 父类存在的地方,子类就应该能够存在
A a = new A();
B b = new B();
a.test("a"); //
b.test("a"); //
}
}
class A {
public ArrayList<String> test(String string) {
System.out.println("【A】被執行...");
return null;
}
}
class B extends A {
@Override
public List<String> test(String string) {
System.out.println("【B】被執行...");
return null;
}
}
可见,如果子类重写父类的方法,但是返回值比父类范围大的话,编译都通不过,如上图报错,提示需要缩小返回值类性。
六、总结
里氏替换原则与多态区别:
- 多态前提条件:要有子类继承父类并且子类重写父类的方法;
- 里氏替换原则:尽量不要重写父类的方法,不能改变父类原有的功能,而是通过新增方法来扩展;
里式替换原则是针对继承而言的。
- 如果继承是为了实现代码的重用,也就是为了共享方法,那么共享的父类方法就应该保持不变,不能被子类重新定义。子类只能通过添加新的方法来扩展功能,父类和子类都可以实例化,而子类继承的方法和父类是一样的,父类调用方法的地方,子类也可以调用同一个继承得来的,逻辑和父类一致的方法,这时就可以使用子类对象将父类对象替换掉。
- 如果继承的目的是为了多态,而多态的前提就是子类重写父类的方法,为了符合LSP,我们应该将父类重新定义为抽象类,并定义抽象方法,让子类重新定义这些方法。由于父类是抽象类,所以父类不能被实例化,也就不存在可实例化的父类对象在程序里,就不存在子类替换父类时逻辑不一致的可能。
所以尽量不要从可实例化的父类中继承,而是要使用基于抽象类和接口的继承(也就是面向接口和抽象编程)。
参考书籍:
- 设计模式之禅 - 秦小波 ;
- 里式替换原则(ISP) - 简书;
- 设计模式六大原则(2):里氏替换原则_三级小野怪的专栏-CSDN博客_里氏替换原则;
最后
以上就是雪白洋葱为你收集整理的软件设计原则(四) 里氏替换原则的全部内容,希望文章能够帮你解决软件设计原则(四) 里氏替换原则所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复