你好!我是郑晔。

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

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

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

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

所以,这一讲,我们就来看看可以把继承体系设计好的设计原则: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是一个B(A 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的根基在于继承,但显然接口继承才是重点。那我们该如何设计接口呢?我们下一讲来讨论。

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

思考题

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

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

评论