面向对象六大原则——里式替换原则

说说继承

 继承是面向对象三大特性之一,是一种非常优秀的语言机制,它有如下有点:

  • 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性
  • 提高代码的重用性
  • 子类可以形似父类
  • 提高代码的可扩展性
  • 提高产品或项目的开放性

 继承有它的优点,但是也有一些致命的缺点:

  • 继承具有侵入性,只要子类继承了父类,那么子类必须拥有父类的所有属性和方法
  • 降低了代码的灵活性
  • 增强了耦合性。当父类中发生方法,属性的修改时需要考虑子类是否修改,而且在缺乏规范的情况下,还可能发生大段的代码重构

 正如前面所说,继承是面向对象非常优良的特性,使用继承有利也有弊,如何将继承的利最大化,弊最小化呢(这就是为什么说在开发时多用组合,少用继承),解决方案就是引入里式替换原则。

举例说说继承的缺点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//需要完成一个两数相减的功能,由类A来负责
class A{
public int func1(int a, int b){
return a-b;
}
}

public class Client{
public static void main(String[] args){
A a = new A();
System.out.println("100-50="+a.func1(100, 50));
System.out.println("100-80="+a.func1(100, 80));
}
}
//结果:
100-50=50
100-80=20

//现在增加一个功能:完成两数相加,然后再与100求和,由类B来负责
class B extends A{
public int func1(int a, int b){
return a+b;
}

public int func2(int a, int b){
return func1(a,b)+100;
}
}

public class Client{
public static void main(String[] args){
B b = new B();
System.out.println("100-50="+b.func1(100, 50));
System.out.println("100-80="+b.func1(100, 80));
System.out.println("100+20+100="+b.func2(100, 20));
}
}
结果:
100-50=150
100-80=180
100+20+100=220

 我们发现原本运行正常的相减功能发生了错误。原因就是类B在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类B重写后的方法,造成原本运行正常的功能出现了错误。在本例中,引用基类A完成的功能,换成子类B之后,发生了异常。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。(违反了里式替换原则)

什么是里式替换原则(LiskovSubstitution Principle, LSP)

  • 第一种定义:如果对每一个类型为T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有的对象o1都替换成o2时,程序P的行为没有变化,那么类型T2是类型T1的子类型。

  • 第二种定义:子类型必须能替换掉它们的基类型

理解: 第二种定义相对来说更易于理解一些,通俗来说就是:只要父类出现的地方子类就可以出现,而且提换为子类也不会出现任何的错误和异常。但是反过来是不行的,有子类出现的地方,父类未必能替换。

LSP的深层含义

 里式替换原则为良好的继承定义了一个规范,它包含四个深层含义:

  • 子类必须完全实现父类的方法, 但不能覆盖(重写)父类的非抽象方法:这个规则相对来说是很好理解的,我们定义了一个接口或抽象类,我们必须在子类中完全实现所有的抽象方法,其实这时我们已经使用了里式替换原则
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public abstract class A{
public abstract void run();
public abstract void fly();
public void walk(){
....
}
}
class B extends A{

@Override
public void run(){...}

@Override
public void fly(){...}
}

public calss test{
public static void main(String[] args){
A a = new B();
a.run();
a.fly();
a.walk();
}
}
  • 子类可以增加自己特有的方法

  • 当子类的方法重载父类的方法时,子类方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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();
}
}

public class Test {

public static void invoker() {
// 父类存在的地方,子类就应该能够存在
// Father f = new Father();
Son son = new Son();
HashMap map = new HashMap();
son.doSomething(map);
}

public static void main(String[] args){
invoker();
}

}
两个输出结果都是:父类被执行...

//假如将父类和子类的参数类型调换
则 f.doSomething(map) 输出结果为:父类被执行
son.doSomething(map) 输出结果为:子类被执行

 解释如下:在上面的例子中,子类中的doSomething(Map map)和父类中的doSomething(HashMap map)两个方法构成重载(并不是重写,因为参数列表不同,子类继承父类那么相应的父类方法就存在于子类的生命周期中,所以构成重载),而子类方法的形参范围比父类方法的形参范围要大。其实我们可以想一想,子类方法的形参范围比父类方法的形参范围要大,则子类代替父类传递参数到调用者中,子类的方法将永远不会被执行,这其实和里式交换原则是想符合的,父类的空间必须是子类的子区间,那么子类才能替换父类。而假如父类方法的形参范围大于子类方法的形参范围,子类方法在没有重写父类方法的前提下被执行了,这会引起业务逻辑的混乱,因为在实际应用中父类一般是抽象类,子类是实现类,你传递了一个这样的实现类就会“歪曲”父类的意图,引起一堆意想不到的逻辑混乱,所以子类中方法的前置条件必须与超类中被覆写的方法的前置条相同或更宽松。

  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更加严格: 如父类要求返回List,那么子类就应该返回List的实现ArrayList,父类是采用泛型,那么子类则不能采用泛型,而是具体的返回。

加深理解

  • 其实通俗说来,里式替换原则就是:子类可以扩展父类的功能,但不能改变父类原有的功能
  • 当继承不能满足里式替换原则时应该进行重构
    • 把冲突的派生类与基类的公共部分提取出来作为一个抽象基类,然后分别继承这个类。
    • 改变继承关系:从父子关系变为委托关系
  • 在类中调用其他类时务必要使用父类或接口, 如果不能使用父类或接口, 则说明类的设计已经违背了LSP原则
  • 如果子类不能完整地实现父类的方法, 或者父类的某些方法在子类中已经发生“畸变”, 则建议断开父子继承关系, 采用依赖、 聚集、 组合等关系代替继承

多态与LSP是否矛盾

 在学习Java里面的多态时,我们知道多态的前提就是要有子类继承父类并且子类重写父类的方法。那这是否和LSP矛盾呢?因为LSP要求我们只可以扩展父类的功能,但不能改变父类原有的功能,也就是不能对父类原有的方法进行重写,只能去实现父类的方法或重载。下面是我在知乎上找到的一种比较合理的解释:

  • 里式替换原则是针对继承而言的,如果继承是为了实现代码的重用,也 就是为了共享方法,那么共享的父类方法就应该保持不变,不能被子类重新定义。子类只能通过添加新的方法来扩展功能,父类和子类都可以实例化,而子类继承的方法和父类是一样的,父类调用方法的地方,子类也可以调用同一个继承得来的,逻辑和父类一致的方法,这时就可以使用子类对象将父类对象替换掉。
  • 如果继承的目的是为了多态,而多态的前提就是子类重写父类的方法,为了符合LSP,我们应该将父类重新定义为抽象类,并定义抽象方法,让子类重新定义这些方法。由于父类是抽象类,所以父类不能被实例化,也就不存在可实例化的父类对象在程序里,就不存在子类替换父类时逻辑不一致的可能。

不符合LSP最常见的情况就是:父类和子类都是非抽象类,且父类的方法被子类重新定义,这样实现继承会造成子类和父类之间的强耦合,将不相关的属性和方法搅和在一起,不利于程序的维护和扩展。所以总结一句:尽量不要从可实例化的父类中继承,而是要使用基于抽象类和接口的继承(也就是面向接口和抽象编程)

参考书籍与网站

-------------本文结束感谢您的阅读-------------
老铁,打赏一点儿呗