面向对象编程范式得到了广大开发者的青睐,在做面向对象软件设计的同仁也或多或少曾经心存困惑过。比如,怎么样才是正确的封装?如何恰当的继承?何时应该抽象? 对于设计,我们很难说对与错,通常只有好与不好的区分,而所谓的最佳实践也只是 – 在当前约束下,人们所能找到的最佳解决方案。

最近我在给ThoughtWorks内部某海外交付团队的核心成员(Tech Lead & Second Tier)做OO Bootcamp的培训,在分享讨论和编码实践的过程中加强了对面向对象设计的理解,本文我来聊一聊面向对象中关于继承设计的IS-A的这个工具。


IS-A是把好尺子

在做面向对象设计的时候,我们心中始终会装着三大武器:封装继承多态,设计出的软件也得有它们的身影。然而,很多时候并不是没有它们,而是它们的影子太多了(滥用或误用)。就拿继承来说,我们会经常使用IS-A来审视两个类的继承关系。比如以下场景:

  1. A Parrot IS A Bird(鹦鹉是一只鸟)
  2. A Man IS A Person(男人是一个人)
  3. A Square IS A Rectangle(正方形是一个矩形)

以上关系,单纯从自然属性来思考都好像是正确的,所以我们在设计继承关系的时候通常会很容易类似写出以下代码:

class Man extends Person{ }

class Person {
    private int age;
    private double height;
    
    public void walk(){ }
}

class Square extends Rectangle{ }

class Rectangle{ }

因为IS-A这把尺子的辅助,我们很容易地采用了继承,继承之后,子类什么也不用做就拥有了父类的特征和行为能力。看起来很好,它如期达到我们复用的期望。


IS-A的失效区

Square IS A Rectangle来说,我们都知道正方形是一个矩形,这话没毛病。然而,当我们按照真实业务要求完善Rectangle之后可能是这样子的:

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;
    }
}

此时我们有一个客户类这样使用Rectangle:

public class SizeChanger {
    private double newWidth;
    private double newHeight;

    public SizeChanger(double newWidth, double newHeight) {
        this.newWidth = newWidth;
        this.newHeight = newHeight;
    }

    public double resize(Rectangle rectangle) {
        rectangle.setWidth(newWidth);
        rectangle.setHeight(newHeight);
        return rectangle.calculateArea();
    }
}

resize方法接受一个Rectangle对象参数,而Square作为子类,也可以被传入到这个方法中,比如我们测试客户类:

class SizeChangerTest {
    @Test
    void should_calculate_correct_area_after_resize() {
        SizeChanger sizeChanger = new SizeChanger(5, 10);
        Rectangle rectangle = new Rectangle();
        rectangle.setWidth(4);
        rectangle.setHeight(5);

        assertThat(sizeChanger.resize(rectangle)).isEqualTo(50);
    }
}

我们期望resize的返回值是50,没毛病。但我们如果把Rectangle子类对象传给resize方法就会挂掉:

class SizeChangerTest {
    @Test
    void should_calculate_correct_area_after_resize() {
        SizeChanger sizeChanger = new SizeChanger(5, 10);
        Rectangle rectangle = new Square();
        rectangle.setWidth(4);
        rectangle.setHeight(5);

        assertThat(sizeChanger.resize(rectangle)).isEqualTo(50); // 100 not 50
    }
}

作为客户程序就会产生疑惑了:”我调用resize方法的表现时而不一样,这让我很焦虑,没法信任你的程序,既然A Square IS A Rectangle,给resize传入SquareRectangle的结果应该是跟期望一致的。” 所以从resize的角度来看,A Square IS NOT A Rectangle。而导致这一现象的原因是SquareRectangle的行为方式发生了改变,它们的setWidthsetHeight行为不一样。

行为是面向对象设计的关键所在,我们通过封装将对象属性隐藏,以API的方式来服务于客户程序,这些公开的API就是一系列行为,这些行为正是客户程序想使用的(客户程序依赖这些行为),它们也构成了我们软件的功能。

所有,不难理解LSP(里氏替换原则)强调IS-A的关系是针对行为方式来讲的,这也是面向对象软件设计中与真实世界的对象关系的微妙差别,当子类与父类针对某个具体的行为发生改变时,这个继承就违背了LSP


拯救IS-A的铁弹

IS-A是基于行为方式的,也就是说,当你的子类改变了父类的某个具体行为时,IS-A就需要重新审视了。

如何重新审视?你需要进一步进行抽象,进一步提取抽象概念,此时需要念出面向抽象编程的六字真经了,抽出多态这把匕首,并移步让里氏替换原则为你效力



期待与你交流,我的联系方式:邮箱 | 微信

Posted by 袁慎建@ThoughtWorks • December 25th, 2018

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

原文链接:https://sjyuan.cc/talking-about-is-a-in-ood/
支持原创
⤧  Next post 基于Account的能力建设4D环 ⤧  Previous post 让里氏替换原则为你效力