You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

12 KiB

22 | Liskov替换原则用了继承子类就设计对了吗

你好!我是郑晔。

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

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

理论上,在定义了接口之后,我们就可以把继承这个接口的类完美地嵌入到我们设计好的体系之中。然而,用了继承,子类就一定设计对了吗?事情可能并没有这么简单。

新的类虽然在语法上声明了一个接口,形成了一个继承关系,但我们要想让这个子类真正地扮演起这个接口的角色,还需要有一个好的继承指导原则。

所以这一讲我们就来看看可以把继承体系设计好的设计原则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

基于行为的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);
t
  }
  
  @Override
  public void setHeight(int height) {
    this.setSide(height);
  }

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

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

Rectangle rect = new Square();
rect.setHeight(4); // 设置长度
rect.setWidth(5);  // 设置宽度
assertThat(rect.area(), is(20)); // 对结果进行断言

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

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

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

你可能听说过继承要符合IS-A的关系也就是说如果A是B的子类就需要满足A是一个BA is a B。但你有没有想过凭什么A是一个B呢判断依据从何而来呢

你应该知道,这种判定显然不能依靠直觉。其实,从前面的分析中,你也能看出一些端倪来,IS-A的判定是基于行为的只有行为相同才能说是满足IS-A的关系。

这个道理说起来很简单,但在实际的工作中,我们时常就会走上歧途。我给你举个例子,我要做一个图片制作的网站,创作者可以在上面创作自己的内容,还可以发布自己创作的一些素材在网站上销售。显然,这个网站要提供一个销售的能力,那这个可以销售的素材算不算商品呢?

如果站在销售的角度看,它确实是一个商品,我们需要给它定价,需要让它支持后续的购买行为等等。从行为上看,素材也确实是商品,但它又与创作相关,我们需要知道它的作者是谁,需要知道它所应用的不同创作阶段等等,这些行为又与商品完全无关。

其实在我们分析问题的时候答案就已经呼之欲出了。这里的“素材”就不是一个“素材”前面讲SRP的时候我们已经做过类似的分析了虽然我们在讨论的时候用的是一个词“素材”但创作者和销售却是两个不同的领域。

所以,如果我们把“素材”做一个拆分,这个问题就迎刃而解了。一个是“创作者素材”,一个是“可销售素材”,显然,“可销售素材”是一种商品,而“创作者素材”不是。

这是一种常见的概念混淆。产品经理在描述一个需求时,可能并不会注意到这是两个不同领域的概念,而程序员如果不好好分析一下,在概念上就会走偏,后续的问题将无穷无尽。

所以IS-A这个关系理解起来并不难但在实际工作中当它和其他一些问题混在一起的时候它就不像看起来那么简单了。

到这里你应该对LSP原则有了一些理解要满足LSP首先这个对象体系要有一个统一的接口而不能各行其是其次子类要满足IS-A的关系

有了对LSP的理解你再用它去衡量一些设计就会发现一些问题。比如程序员们最常用的数据结构List很多人都习惯地把它当做接口传来传去。在绝大多数场景下使用它的目的只是为了传递一些数据也就是为了从中读取数据但List接口本身一般都有写的方法。

所以尽管你的目的是读但还是有人不小心写了就会导致一些奇怪的问题。Google的Guava库提供了一个ImmutableList在概念上做了改进。但为了配合现有的各种程序它不得不继承自List接口实际上根本的问题并没有得到完全的解决。

还有一类常见的违反LSP的问题就是继承数据结构。比如我要实现包含多个学生的类结果声明成

class Students extends ArrayList<Student> {
  ...
}

这是一种非常直觉的设计只要一继承ArrayList添加、获取的方法就都有了。但从我们前面讲的内容上来看这显然是不好的因为Students不是一个ArrayList不能满足IS-A关系。这种做法想做的就是实现继承而我们在前面讲继承的时候就说过这种做法的问题。

你会发现LSP的关注点让人把注意力放到父类上而一旦子类成了重点我们必须小心谨慎。在前面讲继承的时候我们说过关心子类是一种实现继承的表现而实现继承是我们要努力摒弃的接口继承才是我们的努力方向而做好接口继承显然会更符合LSP。

更广泛的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);
}

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

总结时刻

今天我们讲了Liskov替换原则其主要意思是说子类型必须能够替换其父类型。

理解LSP我们需要站在父类的角度去看而站在子类的角度常常是破坏LSP的做法一个值得警惕的现象是代码中出现RTTI相关的代码。

继承需要满足IS-A的关系但IS-A的关键在于行为上的一致性而不能单纯凭日常的概念或直觉去理解。

LSP不仅仅可以用在类关系的设计上我们还可以把它用在更广泛的接口设计中。任何接口都是宝贵的在设计时都要精心考量。

这一讲你可以看到LSP的根基在于继承但显然接口继承才是重点。那我们该如何设计接口呢我们下一讲来讨论。

如果今天的内容你只能记住一件事,那请记住:用父类的角度去思考,设计行为一致的子类

思考题

在今天的内容中,我们提到了长方形正方形问题,我只分析了这个做法有问题的地方,现在我把解决这个问题的机会留给你,请你来动动脑,欢迎在留言区写下你的解决方案。

感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。