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是一个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的根基在于继承,但显然接口继承才是重点。那我们该如何设计接口呢?我们下一讲来讨论。

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

思考题

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

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

精选留言

  • zcc

    2020-07-17 07:58:37

    那从父类的角度来考虑的话,应该是定义一个几何图形的接口,接口有计算面积的方法。然后长方形、正方形、圆形、三角形……都实现这个接口,然后各自实现计算面积的方法。各自有自己特别的关键属性,根据属性计算各自面积:长*宽、边长²、πr²、(底长*高)/2、……
    作者回复

    嗯,这个解决方案的味道不错。

    2020-07-19 07:54:28

  • Janenesome

    2020-10-23 18:19:46

    千万要遏制自己写 if 的念头,一旦开了这个头,后续的代码也将变得难以维护。刚好前段时间看到过一种说法:以多态应用为荣,以分支判断为耻。哈哈
    作者回复

    这个观点真好!

    2021-01-27 11:49:36

  • Geek_3b1096

    2020-08-09 06:25:26

    时刻提醒自己: 千万要遏制写if的念头
    作者回复

    非常好的经验总结!

    2020-08-09 09:30:13

  • escray

    2020-09-26 23:40:10

    刚知道 Liskov 是位女士,我原来记的是“里氏科夫”,所以就想当然的以为是男的了。

    以前在考虑继承或者子类的时候,其实没有想到要“行为相同”,更多的是把相同的方法提取到父类,自以为 DRY,洋洋自得。

    “创作者素材”和“可销售素材”这个例子有点晦涩了,经过文中的分析,能看明白是两个领域,但是还是有一点疑惑,这两种素材之间不可以转换么?比如从创作者素材转为可销售素材?

    可能比较简单直接的做法是在素材类里面设置一个状态——是否可销售,不过这样一来的确会给编码带来很多麻烦。

    用解析器代替 if 语句,代码顿时就“高大上”起来了。

    LSP 的重点在于接口继承。

    在专栏后续的依赖倒置原则里面,有一条编码规则:任何类都不应继承自具体类。
  • 段启超

    2020-09-10 17:53:45

    发表一下我的想法:长方形正方形问题反应出的是大多人认为的正方形是一种特殊的长方形,在面向对象的世界中不成立的。面向对象的出发点是对象的行为,只要行为一致,他们就可以是一类东西,这个和《head first 设计模式》一文中开篇的那个各种不同类型的鸭子是一个道理,虽然有的是玩具鸭,有的是实实在在的鸭子, 只要有叫的行为,飞的行为,游泳的行为,那么他们就可以成为“鸭子”。
    最近在写代码的过程中发现一个有趣的问题,我们总在对象中依赖各种对象,但是实际上只是依赖了那个对象中的某个行为(方法,或者是能力),依赖的具体类多了,免不了就会把那些不需要的能力引进来;而且具体的类依赖的多了,循环依赖不可避免的就会出现。 所以我想,以后引入某个依赖的时候不妨先考虑一下,我这里需要的真的是这个对象么? 如果不是,能否把自己需要的那个能力单独放到一个接口或者是一个单独的对象中呢?
  • 桃子-夏勇杰

    2020-07-29 06:49:22

    这个设计原则看着非常简单,提出者居然能获得图灵奖,可见这个设计原则的价值非常大。郑老师,这个设计原则的价值到底有多大呢?
    作者回复

    LSP告诉我们什么样的继承是对的,而继承使用范围太广了。

    2020-07-29 18:45:12

  • Janenesome

    2020-10-23 18:16:56

    郑老师给出的业务开发的案例真的挺接地气。

    运行时类型识别我以前写过,后面回去维护的时候想打死自己,怎么写出这样的烂代码。

    还有业务分析里的素材分类,太真实了,平时做需求时很多概念有点相似,放在一起好像也可以而且看起来还能省点力气,没有太深入思考的话就会放在一起了,一些差异化的部分就单独存或者用条件判断。
    作者回复

    今天“偷懒”,明天要付出更多。

    2021-01-27 11:50:57

  • 三生

    2020-07-22 00:43:07

    所有的形状都有求面积的方式,但是计算方式都不同,这行为应该是“正常的”,但是设置长和宽的行为不正确,因为长方体有宽和高,正方形只有宽或高,这里只能抽象出计算面积这个方法。

    比如企鹅和麻雀,我们认为所有的鸟都会飞,但企鹅不会飞,而他却具有了飞的行为,这是“不正常”的
  • Geek_q29f6t

    2020-07-20 17:52:32

    RequestParser 中还是免不了用多个 if 来判断 identifier,从而返回特定的子类吧
    作者回复

    不一定,可以通过一个Map实现。

    2020-07-20 20:57:15

  • Being

    2020-07-18 08:02:33

    全篇一直在强调行为,我想这也是思考题的突破口。长宽是数据,而Rectangle并没有将行为抽象出来,导致Rectangle和Square不能成为IS-A的关系,我们只要把求面积的行为放在Rectangle下,子类分别去实现面积的方法就好了。
    作者回复

    “把求面积的行为放在Rectangle下,子类分别去实现面积的方法”,可以解决这个问题吗?

    2020-07-19 07:50:38

  • 呆呆狗的兽

    2021-07-19 11:23:11

    lsp这一分享,很精髓也很精彩,is-a这个太重要了,遵循is-a会让系统越来越稳定且易拓展
    作者回复

    嗯,这个是关键的。

    2021-07-24 17:32:44

  • Jxin

    2020-07-17 10:22:03

    如果业务场景合适,约束功能也不失为一个解决办法。让宽高不可变,初始化时就必须赋值。这样就能符合现实中的特性。自然也没有长宽赋不同值的麻烦。
    作者回复

    setter 确实是一个有杀伤力的东西,但回避 setter并不是在解决我们提出的问题。

    2020-07-19 07:53:31

  • 小麦

    2023-06-16 09:17:29

    千万要遏制自己写 if 的念头,一旦开了这个头,后续的代码也将变得难以维护
    public class Animal {
    public void makeSound(String animalType) {
    if ("cat".equals(animalType)) {
    System.out.println("meow");
    } else if ("dog".equals(animalType)) {
    System.out.println("woof");
    } else if ("cow".equals(animalType)) {
    System.out.println("moo");
    } else {
    throw new IllegalArgumentException("Unknown animal type: " + animalType);
    }
    }
    }



    public interface Animal {
    void makeSound();
    }

    public class Cat implements Animal {
    @Override
    public void makeSound() {
    System.out.println("meow");
    }
    }

    public class Dog implements Animal {
    @Override
    public void makeSound() {
    System.out.println("woof");
    }
    }

    public class Cow implements Animal {
    @Override
    public void makeSound() {
    System.out.println("moo");
    }
    }
  • ifelse

    2022-05-18 13:18:00

    用父类的角度去思考,设计行为一致的子类--记下来
    课后问题: 是否设计一个矩形父类,长方形和正方形都继承矩形呢?
  • BBQ

    2021-07-04 11:44:14

    关于不同客户的不同格式问题,我们单独开发了一套系统,在这个系统里面做接口格式映射,然后再调用标准接口。
    由于这个系统的受众是实施人员,所以界面做到可以通过拖拽来实现映射。
    当然实际功能更多,包括聚合,转换,以及enrich 功能
    总之,把这个映射的关注点单独独立成了一个系统。
    作者回复

    如果有特定的需求,这么做是没有问题的

    2021-07-13 22:28:41

  • CPP

    2020-08-29 18:09:03

    两个set函数改成一个,setparam(int height,int width);
    作者回复

    即便两个函数合成一个,也是有问题的。

    2021-02-04 17:50:53

  • 阳仔

    2020-07-17 09:10:57

    Liskov替换的意思是子类型能够替换父类型,且在继承体系中保持接口的一致
    长方形与正方形计算面积的行为接口是一样的,但是定义长方形和正方形的接口是不一样的,所以这两个行为可以分别抽离出来
    作者回复

    长方形和正方形接口不一样,这是一个点。

    2020-07-19 07:54:05

  • Stay_Gold

    2025-01-20 14:05:23

    之前一直对于LSP的理解不够深入,看老师前面的例子也觉得大部分历史符合子类可以替换父类的情况。
    深入了解其实LSP是指导我们子类应该是对父类的扩展或者说增强,而不是去修改父类的行为预期。
    如果实际情况子类需要有不符合父类的行为创建,代表我们需要进一步重构优化我们的继承关系,提出新的满足情况的继承关系。
    作者回复

    这个理解很到位

    2025-01-23 12:56:02

  • pangou

    2024-04-20 21:55:14

    为了避免违反里氏替换原则(LSP)并解决Square和Rectangle的问题,我们可以重新设计这些类的结构。一种方法是使用一个共同的基类或接口,例如Shape,然后让Rectangle和Square各自独立地实现这个接口或继承自这个基类。这样,我们就不会期望Square能够替换Rectangle,从而避免了违反LSP的问题。

    以下是使用接口的一个示例解决方案:
    ```java
    // 定义一个形状接口,包含计算面积的方法
    interface Shape {
    int area();
    }

    // 长方形类实现形状接口
    class Rectangle implements Shape {
    private int height;
    private int width;

    public Rectangle(int height, int width) {
    this.height = height;
    this.width = width;
    }

    public void setHeight(int height) {
    this.height = height;
    }

    public void setWidth(int width) {
    this.width = width;
    }

    @Override
    public int area() {
    return this.height * this.width;
    }
    }

    // 正方形类实现形状接口
    class Square implements Shape {
    private int side;

    public Square(int side) {
    this.side = side;
    }

    public void setSide(int side) {
    this.side = side;
    }

    @Override
    public int area() {
    return this.side * this.side;
    }
    }
    ```

    在这个解决方案中,Rectangle和Square都实现了Shape接口,这意味着它们都必须提供area()方法的实现。这样,我们就可以在需要形状对象时使用这个接口,而不用担心LSP问题,因为现在没有隐含的假设说一个Square应该能够替换一个Rectangle。
  • 6点无痛早起学习的和尚

    2023-09-22 09:19:55

    看完还是有点迷糊,这里对于最后一个案例(优化 if)有个思考,这个优化也结合了开闭原则去优化的吧。