Liskov替换原则:用了继承,子类就设计对了吗?

488 阅读9分钟

也许,不负光阴就是最好的努力,而努力就是最好的自己。

前言

上一篇,我们讲了开放封闭原则,想要让系统符合开放封闭原则,最重要的就是我们要构建起相应的扩展模型,所以,我们要面向接口编程。

而大部分的面向接口编程要依赖于继承实现,继承的重要性不如封装和多态,但在大部分面向对象程序设计语言中,继承却是构建一个对象体系的重要组成部分。

理论上,在定义了接口之后,我们就可以把继承这个接口的类完美地嵌入到我们设计好的体系之中。然而,用了继承,子类就一定设计对了吗?事情可能并没有这么简单。新的类虽然在语法上声明了一个接口,形成了一个继承关系,但我们要想让这个子类真正地扮演起这个接口的角色,还需要有一个好的继承指导原则。

所以,这一篇,我们就来看看可以把继承体系设计好的设计原则:Liskov 替换法则。

Liskov 替换原则

2008 年,图灵奖授予 Barbara Liskov,表彰她在程序设计语言和系统设计方法方面的卓越工作。她在设计领域影响最深远的就是以她名字命名的 Liskov 替换原则(Liskov substitution principle,简称 LSP)。

1988 年,Barbara Liskov 在描述如何定义子类型时写下这样一段话:

这里需要如下替换性质:若每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2,使得在所有针对 T 编程的程序 P 中,用 o1 替换 o2 后,程序 P 行为保持不变,则 S 是 T 的子类型。

用通俗的讲法来说,意思就是,子类型(subtype)必须能够替换其父类型(base type)

这句话看似简单,但是违反这个原则,后果是很严重的,比如,父类型规定接口不能抛出异常,而子类型抛出了异常,就会导致程序运行的失败。

虽然很好理解,但你可能会有个疑问,我的子类型不都是继承自父类型,咋就能违反 LSP 呢?这个 LSP 是不是有点多此一举呢?

我们来看个例子,有不少的人经常写出类似下面这样的代码:

void handle(final Handler handler) {
  if (handler instanceof ReportHandler) {
    // 生成报告
    ((ReportHandler)handler).report();
    return;
  }
  
  if (handler instanceof NotificationHandler) {
    // 发送通知
    ((NotificationHandler)handler).sendNotification();
  }
  ...
}

根据上一篇的内容,这段代码显然是违反了 OCP 的。另外,在这个例子里面,虽然我们定义了一个父类型 Handler,但在这段代码的处理中,是通过运行时类型识别(Run-Time Type Identification,简称 RTTI),也就是这里的 instanceof,知道子类型是什么的,然后去做相应的业务处理。

但是,ReportHandler 和 NotificationHandler 虽然都是 Handler 的子类,但它们没有统一的处理接口,所以,它们之间并不存在一个可以替换的关系,这段代码也是违反 LSP 的。这里我们就得到了一个经验法则,如果你发现了任何做运行时类型识别的代码,很有可能已经破坏了 LSP

再来看一个实例,也是违法了LSP

public class TestA {
    public void fun(int a,int b){
        System.out.println(a+"+"+b+"="+(a+b));
    }

    public static void main(String[] args) {
        System.out.println("父类的运行结果");
        TestA a=new TestA();
        a.fun(1,2);
        //父类存在的地方,可以用子类替代
        //子类B替代父类A
        System.out.println("子类替代父类后的运行结果");
        TestB b=new TestB();
        b.fun(1,2);
    }
}
class TestB extends TestA{
    @Override
    public void fun(int a, int b) {
        System.out.println(a+"-"+b+"="+(a-b));
    }
}

大家肯定也都能猜出来结果是什么样子的:

父类的运行结果
1+2=3
子类替代父类后的运行结果
1-2=-1

Process finished with exit code 0

我们想要的结果是“1+2=3”。可以看到,方法重写后结果就不是了我们想要的结果了,也就是这个程序中子类B不能替代父类A。这违反了里氏替换原则原则,从而给程序造成了错误。

子类中可以增加自己特有的方法

public class TestA {
    public void fun(int a,int b){
        System.out.println(a+"+"+b+"="+(a+b));
    }

    public static void main(String[] args) {
        System.out.println("父类的运行结果");
        TestA a=new TestA();
        a.fun(1,2);
        //父类存在的地方,可以用子类替代
        //子类B替代父类A
        System.out.println("子类替代父类后的运行结果");
        TestB b=new TestB();
        b.fun(1,2);
        b.newFun();
    }
}
class TestB extends TestA{
    public void newFun(){
        System.out.println("这是子类的新方法...");
    }
}

这次运行出来的代码结果就是我们意料中的内容:

父类的运行结果
1+2=3
子类替代父类后的运行结果
1+2=3
这是子类的新方法...

Process finished with exit code 0

基于行为的 IS-A

如果你去阅读关于 LSP 的资料,很有可能会遇到一个有趣的问题,也就是长方形正方形问题。在我们对于几何通常的理解中,正方形是一种特殊的长方形。所以,我们可能会写出这样的代码:

class Rectangle {
    private int height;
    private int width;

    // 设置长度
    public void setHeight(int height) {
        this.height = height;
    }

    // 设置宽度
    public void setWidth(int width) {
        this.width = width;
    }

    //面积
    public int area() {
        return this.height * this.width;
    }
}

class Square extends Rectangle {
    // 设置边长
    public void setSide(int side) {
        this.setHeight(side);
        this.setWidth(side);
    }

    @Override
    public void setHeight(int height) {
        this.setSide(height);
    }

    @Override
    public void setWidth(int width) {
        this.setSide(width);
    }
}

这段代码看上去一切都很好,然而,它却是有问题的,因为它在下面这个测试里会失败:

import org.junit.Assert;

import static org.hamcrest.CoreMatchers.is;

public class Test {

    public static void main(String[] args) {
        Rectangle rect = new Square();
        rect.setHeight(4); // 设置长度
        rect.setWidth(5);  // 设置宽度
        Assert.assertThat(rect.area(), is(20));//对结果进行断言
    }
}

如果想保证断言(assert)的正确性,Rectangle 和 Square 二者在这里是不能互相替换的。使用 Rectangle 的代码必须知道自己使用的到底是 Rectangle 还是 Square。

出现这个问题的原因就在于,我们构建模型时,会理所当然地把我们直觉中的模型直接映射到代码模型上。在我们直觉中,正方形确实是一种长方形。

在我们设计的这个对象体系中,边长是可以调整的。然而,在几何的体系里面,长方形的边长是不能随意改变的,设置好了就是设置好了。换句话说,两个体系内,“长方形”的行为是不一致的。所以,在这个对象体系中,正方形边长即使可以调整,但正方形也并不是一个长方形,也就是说,它们之间不满足 IS-A 关系。

你可能听说过继承要符合 IS-A 的关系,也就是说,如果 A 是 B 的子类,就需要满足 A 是一个 B(A is a B)。但你有没有想过,凭什么 A 是一个 B 呢?判断依据从何而来呢?你应该知道,这种判定显然不能依靠直觉。其实,从前面的分析中,你也能看出一些端倪来,IS-A 的判定是基于行为的,只有行为相同,才能说是满足 IS-A 的关系。

更广泛的 LSP

如果理解了 LSP,你会发现,它不仅适用于类级别的设计,还适用于更广泛的接口设计。比如,我们在开发中经常会遇到系统集成的问题,有不同的厂商都要通过 REST 接口把他们的统计信息上报到你的系统中,但是,有一个大厂上报的消息格式没法遵循你定义的格式,因为他的系统改动起来难度比较大。你该怎么办呢?

也许,专门为大厂设计一个特定接口是最简单的想法,但是,一旦开了这个口子,后面的各种集成接口都要为这个大厂开发一份特殊的,而且,如果未来再有其他大厂也提出要求,你要不要为它们也设计特殊接口呢?事实上,很多项目功能不多,但接口特别多,就是因为在这种决策的时候开了口子。请记住,公开接口是最宝贵的资源,千万不能随意添加

如果我们用 LSP 的角度看这个问题,通用接口就是一个父类接口,而不同厂商的内容就相当于一个个子类。让厂商面对特定接口,系统将变得无法维护。后期随着人员变动,接口只会更加膨胀,到最后,没有人说清楚每个接口到底是做什么的。

好,那我们决定采用统一的接口,可是不同的消息格式该怎么处理呢?首先,我们需要区分出不同的厂商,办法有很多,无论是通过 REST 的路径,还是 HTTP 头的方式,我们可以得到一个标识符。然后呢?

很容易想到的做法就是写出一个 if 语句来,像下面这样:

if (identfier.equals("SUPER_VENDOR")) {
  ...
}

但是,千万要遏制自己写 if 的念头,一旦开了这个头,后续的代码也将变得难以维护。我们可以做的是,提供一个解析器的接口,根据标识符找到一个对应的解析器,像下面这样:

RequestParser parser = parsers.get(identifier);
if (parser != null) {
  return parser.parse(request);
}

这样一来,即便有其他厂商再因为某些奇怪的原因要求有特定的格式,我们要做的只是提供一个新的接口实现。这样一来,所有代码的行为就保持了一致性,核心的代码结构也保持了稳定。

总结

  1. Liskov 替换原则,其主要意思是说子类型必须能够替换其父类型。
  2. 理解 LSP,我们需要站在父类的角度去看,而站在子类的角度,常常是破坏 LSP 的做法,一个值得警惕的现象是,代码中出现 RTTI 相关的代码。
  3. 继承需要满足 IS-A 的关系,但 IS-A 的关键在于行为上的一致性,而不能单纯凭日常的概念或直觉去理解。
  4. LSP 不仅仅可以用在类关系的设计上,我们还可以把它用在更广泛的接口设计中。任何接口都是宝贵的,在设计时,都要精心考量。
  5. LSP 的根基在于继承,但显然接口继承才是重点。