让里氏替换原则为你效力

面向对象的基石

从事软件开发的朋友或多或少都听过以下一些原则:比如KiSS、DRY、LKP、COC、DbC、SoC、HP、SOLID等。这些原则已经在业界被证实了自身的价值,尤其当谈到面向对象设计的时候,SOLID则是一个避不开的主题。

作为面向对象的基本原则,SOLID本身就是一个明显的招牌 - 坚固的磐石,撑起了面向对象设计大厦。

SOLID由五大原则构成:

  1. Single Responsibility Principle【单一职责原则】
  2. Open Close Principle【开闭原则】
  3. Liskov Substitution Principle【里氏替换原则】
  4. Interface Segregation Principle【接口隔离原则】
  5. Dependency Inversion Principle【依赖倒置原则】

对于大部分OO程序员,这五大原则的名字可能已经耳熟能详,却总不能很清晰的描述出SOLID是如何为我们服务,因为SOLID从来也没有告诉我们How,它只在说:"这就是你最终要达到的目的地"。

本文我将带着我的思考来捋一下LSP,LSP可能是一个很容易被破坏的原则,理解了它将能够很好地驱动我们去思考如何正确地做抽象设计。


打破里氏替换原则

In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program (correctness, task performed, etc.) -- Barbara Liskov in 1987

简单描述LSP:一个子类实例对象替换掉其父类实例对象,不会引发程序的任何变化。

如果要保证这点,我们在设计类的继承关系的时候,子类不应重写父类的方法,这样保证了父类的行为没有被修改。来看个代码示例,一个Square类继承自Rectangle类,我们计算它们的面积:

class RectangleTest {
    @Test
    void should_return_area_when_calculate_given_width_and_height_valid() {
        Rectangle rectangle = new Rectangle();
        rectangle.setHeight(3);
        rectangle.setWidth(5);

        assertThat(rectangle.calculateArea()).isEqualTo(15);
    }
    @Test
    void should_return_area_when_calculate_given_width_and_height_valid() {
        Rectangle rectangle = new Square();
        rectangle.setHeight(3);
        rectangle.setWidth(5);

        assertThat(rectangle.calculateArea()).isEqualTo(25);
    }
}

前者返回的是15,而后者返回的是25。这两者的差别在于我们使用了Square替换掉Rectangle,从而导致了程序的行为发生了改变。

看看RectangleSquare的实现,不难发现子类Square重写了setHeightsetWidth方法,修改了父类行为,导致了替换失败。

public class Rectangle {
    protected double width;
    protected double height;
    public void setWidth(double width) { this.width = width; }
    public void setHeight(double height) { this.height = height; }
    public double calculateArea() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setHeight(double height) {
        this.height = height;
        this.width = height;
    }
    @Override
    public void setWidth(double width) {
        this.height = width;
        this.width = width;
    }
}

所以按照LSP的观点,这个继承关系被扣上不良的帽子,注意这里我用了不良,而非错误。因为我知道你可能会怼我:"我设计这个子类我就想基于父类做一个扩展,不同的子类有不同的实现,没让你在使用的时候去替换父类呀! " (我敢打赌你在项目中遇到过这种覆写父类的实现,并且软件还能正常Work)。在我怼回去之前,请先跟我来回顾一下面向对象特性。


从复用来看继承

从一踏入职场那一刻,我就在面试中多次被问过:请谈谈你对面向对象的三大特性的理解?

简单捋一下面向对象的三大特性:

  • 封装:隐藏对象的属性和实现细节,仅对外公开接口。比如Rectangle类,首先对自身属性widthheight进行了隐藏,通过calculateArea方法提供服务,将依赖自身数据的计算细节也进行了隐藏
  • 继承:允许子类在不需要重新编写父类的前提下,复用父类的所有功能,并能够按需进行扩展。比如Square继承了Rectangle,就具有了calculateArea的功能。(当心:这个继承不合理)
  • 多态:允许对象在运行期表现出不同的形体

恰巧LSP中提到了子类和父类的概念,所以不得不说说继承。在这之前,我假设:在做面向对象软件设计的你认同面向对象设计的价值 -- 提升软件的对变化的响应力

继承的最核心的目的之一是为了复用,很多时候我们为了复用采用了继承。如果我们设计继承单纯为了复用,你可能会问为啥不用组合?而且很多时候提倡组合优于继承。这就需要我们思考面向对象的设计初衷:面向对象建立在对真实世界的抽象前提上,它很大程度上反映了我们的真实世界。比如一只鹦鹉是一只鸟,鸟能飞,鹦鹉也能飞,所以让鹦鹉继承自鸟,鹦鹉具备了飞的能力。

public class Bird {
    public void fly() {
        System.out.println("I am flying");
    }
}

public class Parrot extends Bird {}

如何决定继承关系,你可以用Is-A来进行初步验证,比如A parrot is a bird。当你使用Is-A读起来就能让自己发笑得的时候,就说明这个继承就明显不合理了。比如,你有一架飞机,它也能飞,为了复用你让飞机继承鸟 -- A Plane is a Bird。当关系不是那么明显的时候怎么办?比如,A Square is a Rectangle,下文我将给出答案。

所以,继承首先它应该体现一种现实世界的真实规则Is-A,复用是它提供的一个核心能力,也是我们期望在设计上能获得的好处,而要达到复用,就要遵守一个规则:子类不去更改父类已有的行为,否则就与复用不沾边了(复用,代表你啥也不用做,直接具备的行为,如果你重新实现了,那叫新的实现,你付出了新的努力。至于你要添加新的行为,这属于扩展,按需就好)。

我想你已经能够运用Is-A来避免很多明显恰当的继承关系。而当你面临模棱两可的继承场景时,从复用的视角出发,LSP提供了很好的校验规则。


抽象是为了更好地复用

回到文章一开始的例子,使用Is-A来解读:A Square is a Rectangle,好像还凑合,但有点把握不准。

Square继承自RectangleSquare能够复用Rectangle中的所有行为,假如你不对Square做任何事情就能完美复用,但这样子出来的正方形可能宽和高就不一样了(无法满足客户真实需求,这可都是无用功哟)。为了满足客户需求,你就不得不对setWidthsetHeight进行重写:

public class Square extends Rectangle {
    @Override
    public void setHeight(double height) {
        this.height = height;
        this.width = height;
    }
    @Override
    public void setWidth(double width) {
        this.height = width;
        this.width = width;
    }
}

一旦重写,当你将下面代码的Rectangle替换成Square时候就挂了:

@Test
void should_return_area_when_calculate_given_width_and_height_valid() {
    // Replace with Square
    Rectangle rectangle = new Rectangle(); 
    rectangle.setHeight(3);
    rectangle.setWidth(5);

    // 25 if using Square
    assertThat(rectangle.calculateArea()).isEqualTo(15); 
}

所以,Liskov就开始呐喊了:"说好的Square只是复用Rectangle的呢,为啥把Rectangle的行为改了,程序挂了,你不守规矩,怎么回事!"

那规矩又是什么呢?此时你必须重写setWidthsetHeight,毕竟满足客户才是你首要目的。到这个时候,已经说明了该继承关系出了点问题。你需要做的是跳出来,重新审视一下你的设计:

SquareRectangle都有宽和高,并且计算面积的方式一样,不同的是setWidthsetHeight。是否可以将共同的特征进一步抽象提炼。就这样逼着自己去思考,你可能很快就抽象出一个四边形,因为setWidthsetHeight行为不确定,先将它们抽象化。

你很快用Java代码实现:

public abstract class Quads {
    protected int width;
    protected int height;
    
    public abstract void setWidth(int width);
    public abstract void setHeight(int height);
    
    public int calculateArea() { return width * height; }
}

然后你让RectangleSquare分别继承自Quads,各自在自己的类中实现setWidthsetHeight。这时候你使用RectangleSquare的方式也改了:

class QuadsTest {
    @Test
    void should_return_area_when_calculate_given_width_and_height_valid() {
        Quads quads = new Rectangle();
        quads.setHeight(3);
        quads.setWidth(5);

        assertThat(quads.calculateArea()).isEqualTo(15);
    }
    @Test
    void should_return_area_when_calculate_given_width_and_height_valid() {
        Quads quads = new Square();
        quads.setWidth(5);
        assertThat(quads.calculateArea()).isEqualTo(25);
    }
}

通过进一步抽象,你改变了继承关系,Rectangle is A QuadsSquare is A Quads,此时这种继承关系就更加清楚明显了,并且没有违背LSP(Quads是一个抽象类,不能实例化对象,所以不会出现子类实例对象替换父类实例对象的场景)。

到这里,你已经成功通过了进一步抽象拯救了这个继承关系,而且新的继承关系更加合理,更加符合面向对象设计,也最大化发挥了继承的核心能力 -- 复用

很多时候我们遇到这种,可能是因为我们过于着急写代码或是疏忽大意,那些貌似像Is-A的关系也被我们用上了继承,这也促成了继承被滥用。而解决办法也很简单,LSP这个工具提供了很大的帮助,最终你会发现大多是由于恰当抽象的缺失。


总结

如果用一句话来形容LSP,我觉得是:当你无法根据Is-A 来判断继承关系是否合理时,你应该思考如何进行下一步抽象,从而避免让继承产生二义性

借用极限编程的理念来讲:将我们认同的有效软件开发原理和实践应用到极限。我们在做面向对象设计时,不妨拿起LSP这个现成的工具,帮助我们有效地减少继承的滥用、模糊意图等设计缺陷,提升软件设计。

当然,很多不符合LSP的软件也能工作,这就像很多软件充斥着坏味道照样能工作一样(比如代码注释)。而它背后隐含的逻辑应该是:

我们应该积极去思考更好的设计,而不是过早放弃思考的机会


注释

  • KiSS: Keep it simple, stupid
  • DRY: Don't Repeat Yourself
  • LKP: Least Knowledge Principle (LOD: Law of Demeter)
  • CoC: Convention over Configuration
  • DbC: Design by Contract
  • SoC: Segregation of Concerns
  • HP: Hollywood Principle

参考阅读


Posted by 袁慎建@ThoughtWorks

版权声明:自由转载•非商用•非衍生•保持署名 | Creative Commons BY-NC-ND 4.0

原文链接:https://sjyuan.cc/make-lsp-working-for-you/

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区