里氏替换原则:切忌按照常识实现类间的继承关系

什么是里氏替换原则

里氏替换原则(Liskov Substitution Principle LSP)定义为:任何基类可以出现的地方,子类一定可以出现。 LSP是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。

为什么需要里氏替换原则

里氏替换原则看起来好像没啥了不起的,不就是继承要注意的一丢丢细节么,年轻人呐,你这样的思想很危险啊。事实上里氏替换原则常常会被违反,我在下面举例说明吧:

我们定义了一个矩形类:

public class Rectangle {
    private int width;
    private int height;

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

        System.out.println("Rectangle width" + width);
    }

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

        System.out.println("Rectangle height" + height);
    }
}

从数学知识来看,我们认为正方形是特殊的矩形(长宽相等),那么如果我们需要一个正方形类,一般都会把代码写成下面那样:

public class Square extends Rectangle{

}

大家有没有想到,本来正方形只需要边长的值就足够完成它的需求了,但是,由于 Square 继承于 Rectangle,那么 Square 类中必然拥有 width 和 height,即使我们在设置它们大小的时候让它们同时改变,但是 width 和 height 一定有一个是多余的。那么如果我们需要画成千上万个正方形的时候,就会产生成千上万个多余的 width 或 height。

此外,让 Square 继承 Rectangle 还会出现很奇怪的问题:由于里氏替换原则,只要是 Rectangle 类能出现的地方,Square 类必须也能出现,那么,任何对 Rectangle 类的对象进行 setWidth()/setHeight() 方法操作的地方,应该都能使用 Square 类的对象进行相同的操作。但是,Square 类明明长宽相等,为什么要进行同样的操作两次呢?

可能大家觉得这个例子说服力不够,那我再举一个例子来说明即使我们重写了 setWidth()/setHeight() 方法,仍然会存在问题:

public class Square extends Rectangle{
    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);

        System.out.println("Square height" + height);
    }

    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);

        System.out.println("Square width" + width);
    }
}

然后在操作 Square 和 Rectangle 的类中添加一个这样的方法:

    public void initRec(Rectangle r){
        r.setWidth(6);
        r.setHeight(10);
    }

由于里氏替换原则,我们当然可以将 Square 类对象传入这个方法,那么问题就来了,此时 Square 类对象的边长到底是哪一个呢?我们分别将 Rectangle 和 Square 传入该方法,看看实际的输出:

Rec
Rectangle width6
Rectangle height10
Squ
Rectangle width6
Rectangle height6
Square width6
Rectangle width10
Rectangle height10
Square height10

大家也会发现了,这个时候 Squ 的行为已经变得很奇怪了,它的边长到底是6还是10呢?当然了,要修复这个 Bug 很简单,但这不代表代码是没有问题的,因为为了修正这个错误,我们又得回去修改类,以符合实际的情况,不信的话再看下面的例子:

矩形能够计算面积很正常对吧?那我们就为 Rectangle 类添加计算面积的方法:

    public int getRecArea(Rectangle r){
        return r.getHeight()*r.getWidth();
    }

然后把这个方法放到 initRec() 方法里面执行,那么,当我们把 Square 对象传到 initRec() 方法内部时肯定没有问题,但是计算出来的面积肯定有问题,因为我们刚刚就说了,连 Square 对象的边长都确定不了,我们要怎么去确定它的面积呢?

问题到底出在哪?

大家到现在也许会发现,即使是这么简单的 Square 和 Rectangle 类间关系,都会让我们在维护过程中痛苦不已,不断地回去修改类内的代码,添加各种各样规避错误的逻辑。很多人就会觉得很奇怪了,这样写类应该是没有问题的啊,为什么会出现这样的错误啊?

实际上,问题的根源在于,在程序设计时,Square 类并不能被看作 Rectangle 类的子类,即使在数学上正方形就是特殊的矩形。因为 Square 类的行为和属性和 Rectangle 类的行为和属性是不一致的,将两个类的行为和属性进行抽象我们会发现两者根本不能达到一致:

Square 的属性只有边长,而 Rectangle 有 width 和 height
Square 只需要一个设置边长的方法,而 Rectangle 则需要两个 set 方法。

所以从这个例子中我们也能发现,在程序设计的过程中,进行类间继承关系的设计并不能按照常识去执行,而是需要从实际出发,从类的抽象行为、抽象属性出发,考虑类间的关系是否能成为一个 is-a 关系,如果子类 B 和父类 A 不能实现完全的 is-a 关系,那么我们能就不能进行继承。换句话说,如果类 B 中的某些实现又需要依赖类 A 的某些实现,那么我们就该考虑将这部分实现转到接口之中,让类 A 和类 B 同时实现该接口。

你可能感兴趣的:(设计模式)