暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

面向对象设计原则之里氏替换原则(LSP)

程序媛和她的猫 2020-08-30
919

(1) 定义

1、第一种定义,是最正宗的定义,比较晦涩难懂

如果对每一个类型为T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有的对象o1替换为o2时,程序P的行为没有发生变化,那么类型T2是类型T1的子类型。

2、第二种定义,更通俗易懂些

所有引用基类的地方必须能透明地使用其子类的对象,也就是说子类对象可以替换其父类对象,而程序执行效果不变。

(2) 定义的解读

第一种定义讲的是OO中的继承,LSP跟继承有很密切的关系,它告诉我们继承需要注意什么问题、需要遵守什么规则。为什么要使用LSP对继承做限制呢?这是因为继承在给程序设计带来便利的同时,也带来了弊端。

  • 弊端1 继承是侵入性的。只要继承,子类就必须拥有父类的所有属性和方法。
  • 弊端2 降低代码的灵活性,只要继承,子类就必须拥有父类的所有方法和属性,在一定程度上约束了子类,降低了代码的灵活性。
  • 弊端3 增加了类与类之间的耦合,当父类的常量、变量或者方法被修改了,必须考虑所有子类的修改,因为一旦父类有了变动,很可能会导致子类功能的障碍。

而LSP就是为了避免继承的这些弊端,在LSP的第一种定义中,T1是父类,T2是子类,T2的对象o2替换了T1的对象o1,程序的行为没有发生变化,这意味着子类只是对父类的功能进行了它独有的增强,本质还是做了相同的事。总结一下就是,LSP要求在使用继承时,子类可以扩展父类的功能,但是不能改变父类原有的功能。要做到这一点,在程序设计中,需要我们遵循以下几个条件。

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
  • 子类中可以增加自己特有的方法。
  • 当子类的方法重载父类的方法时,方法的前置条件(即方法的入参)要比父类方法的输入参数更宽松。
  • 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法输出/返回值)要比父类更严格或相等。
    • 子类中的方法所抛出的受检异常只能是父类中对应方法所抛出的受检异常的子类。
    • 子类中的方法的返回值可以是对应的父类方法的返回值的子类,称之为"协变"(Covariant)。

第二种定义讲的是OO中的多态,而多态的前提就是子类重写父类的方法,为了符合LSP,我们应该将父类定义为抽象类,并定义抽象方法,让子类重写这些抽象方法

总结,通过上述对两种定义的讲解,如何使程序设计符合里氏替换原则,有以下两点要求

1、子类不要覆盖父类的非抽象方法

2、尽量不要从可实例化的父类中继承,而是要使用基于抽象类和接口的继承

(3) 里氏替换原则的两个经典案例

(a)企鹅不是鸟的案例

  • 违反LSP的设计(源码见com.zxj.package1.case1包)

从生物学的角度来划分,企鹅属于鸟类;但是从类的继承关系来看,由于企鹅不会飞,所以它不能被定义成鸟类的子类。如果要强行定义为鸟类的子类,在企鹅类中我们就必须覆盖鸟类的fly()方法,告诉方法的调用者,企鹅是不会飞的。这完全符合常理。但是,这样就违反了里氏替换原则。

图1
/**
 * 鸟类
 * 父类
 */

public class Bird {
    public void fly(){
        System.out.println("我在飞!");
    }
}

/**
 * 燕子类
 * 子类
 */

public class Swallow extends Bird {
    
}

/**
 * 企鹅类
 * 子类
 */

public class Penguin extends Bird {
    @Override
    public void fly() {
        System.out.println("我不会飞啊!");
    }
}

/**
 * 客户端
 */

public class Client {
    public static void main(String[] args) {
        Swallow swallow = new Swallow();
        swallow.fly();

        Penguin penguin = new Penguin();
        penguin.fly();
    }
}

输出结果:

我在飞!

我不会飞啊!

  • 遵循LSP的设计(源码见com.zxj.package1.case2包)
图2

正确的做法是取消企鹅类和鸟类的继承关系,定义鸟类和企鹅类的更一般的父类,动物类。动物类中有所有动物相同的行为,比如吃饭、睡觉等。动物之间不同的行为单独抽离出来,放在不同的动物类中。比如每种动物的运动行为不一样,鸟类是飞行动物,所以Bird类在继承Animal类的基础上,加上飞行方法fly()。企鹅是行走动物,所以Penguin类在继承Animal类的基础上,加上行走方法walk()。

/**
 * 动物类
 */

public class Animal {
    public void est(){
        System.out.println("我在吃饭!");
    }

    public void sleep(){
        System.out.println("我在睡觉!");
    }
}

/**
 * 鸟类
 */

public class Bird extends Animal {
    public void fly(){
        System.out.println("我在飞!");
    }
}

/**
 * 燕子类
 */

public class Swallow extends Bird {

}

/**
 * 企鹅类
 */

public class Penguin extends Animal {
    public void walk(){
        System.out.println("我在行走!");
    }
}

/**
 * 客户端
 */

public class Client {
    public static void main(String[] args) {
        Swallow swallow = new Swallow();
        swallow.fly();

        Penguin penguin = new Penguin();
        penguin.walk();
    }
}

输出结果:

我在飞!

我在行走!

(b)正方形不是长方形的案例

在数学定义中认为正方形是一种特殊的长方形,所以在程序设计时,我们本能地把将正方形设计为长方形的子类,但殊不知这样的设计是违反里氏替换原则的。

  • 设计1(源码见com.zxj.package2.case1包)
图3
/**
 * 长方形
 */

public class Rectangle {
    public long length;// 长度
    public long width;// 宽度

    public long getLength() {
        return length;
    }

    public void setLength(long length) {
        this.length = length;
    }

    public long getWidth() {
        return width;
    }

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

/**
 * 正方形
 */

public class Square extends Rectangle {

}

这样设计会产生两个问题:

1、内存消耗:Rectangle有length和width两个成员变量,Square并不需要同时具有lenghth和width,但是Square仍然会从Rectangle继承它们,显然这是浪费。如果我们需要创建成百上千个Square对象,内存浪费的程度是巨大的。

2、Square会从Rectangle继承setLength和setWidth函数,这两个函数对于Square来说是不合适的,因为正方形的长和宽是相等的。

  • 设计2(源码见com.zxj.package2.case2包) 设计1存在的第二个问题,解决方法是在Square中覆写父类Rectangle的setLength和setWidth函数,让length和width相等。
图4
/**
 * 长方形
 */

public class Rectangle {
    public long length;// 长度
    public long width;// 宽度

    public long getLength() {
        return length;
    }

    public void setLength(long length) {
        this.length = length;
    }

    public long getWidth() {
        return width;
    }

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

}

/**
 * 正方形
 */

public class Square extends Rectangle {

    public void setLength(long length) {
        super.setLength(length);
        super.setWidth(length);
    }

    public void setWidth(long width) {
        super.setLength(width);
        super.setWidth(width);
    }

}

/**
 * 客户端
 */

public class Client {

    /**
     * 增加长方形的宽度,直至宽度大于长度
     * @param rectangle
     */

    public static void resizeWidth(Rectangle rectangle) {
        while (rectangle.getWidth() <= rectangle.getLength()){
            rectangle.setWidth(rectangle.getWidth()+1);
            System.out.println("长度:" + rectangle.getLength() + " 宽度:" + rectangle.getWidth());
        }
        System.out.println("resize方法结束,长度:" + rectangle.getLength() + " 宽度:" + rectangle.getWidth());
    }

    @Test
    public void test1(){
        Rectangle rectangle = new Rectangle();
        rectangle.setWidth(10);
        rectangle.setLength(20);
        resizeWidth(rectangle);
    }

    @Test
    public void test2(){
        Square square = new Square();
        square.setWidth(10);
        square.setLength(20);
        resizeWidth(square);
    }
}

test1()输出结果:

长度:20 宽度:11
长度:20 宽度:12
长度:20 宽度:13
长度:20 宽度:14
长度:20 宽度:15
长度:20 宽度:16
长度:20 宽度:17
长度:20 宽度:18
长度:20 宽度:19
长度:20 宽度:20
长度:20 宽度:21
resize方法结束,长度:20 宽度:21

test2()输出结果:

长度:20 宽度:11
长度:20 宽度:12
长度:20 宽度:13
长度:20 宽度:14
长度:20 宽度:15
长度:20 宽度:16
长度:20 宽度:17
长度:20 宽度:18
长度:20 宽度:19
长度:20 宽度:20
长度:20 宽度:21
。。。。。。无限循环下去

现在,当设置Square对象的宽时,它的长会相应地改变,当设置Square对象的长时,它的宽也会随之改变,这样,就确保了Square要求的长和宽相等。Square对象是具有严格数学意义下的正方形,长和宽都相等。

但是,此时的设计是违反里氏替换原则的。在Client的resizeWidth方法中,如果我们向这个方法传递一个Rectangle对象,程序运行是没问题的,如果传递进来的是Square对象,程序就无法停止,进入死循环。对于resizeWidth方法来说,Square不能够替换Rectangle,因此Square和Rectangle之间的关系是违反LSP的。

  • 设计3,遵循LSP的设计(源码见com.zxj.package2.case3包)

因为正方形和长方形都属于四边形,所以我们抽象出一个四边形抽象类(或接口),正方形和长方形都继承它(或实现它)。

图5
/**
 * 四边形接口
 */

public interface Quadrangle {

    long getWidth();
    long getLength();
}

/**
 * 长方形类
 */

public class Rectangle implements Quadrangle {

    private long length;
    private long width;

    @Override
    public long getLength() {
        return length;
    }

    public void setLength(long length) {
        this.length = length;
    }

    @Override
    public long getWidth() {
        return width;
    }

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

/**
 * 正方形类
 */

public class Square implements Quadrangle {
    private long length;
    private long width;

    public void setLength(long length) {
        this.length = length;
        this.width = length;
    }

    public long getLength() {
        return this.length;
    }

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

    public long getWidth() {
        return this.width;
    }

}

(4)当程序违反LSP,如何对程序进行重构

如果两个类之间是"is-a"的关系,那么它们之间使用继承,不会有任何问题,如果不是"is-a"的关系,二者之间使用继承,就会造成违反LSP的设计。那我们应该怎么办呢?重构方案有两个。

1、让原来的父类和子类都继承一个更通俗的基类(这个基类是一个抽象类或者接口),原来的继承关系去掉,采用依赖聚合组合等关系替代。见(3)中的两个案例,企鹅不是鸟,正方形不是长方形。

2、将原来的父类和子类中公共部分抽取出来,成为一个基类(这个基类是一个抽象类或者接口),让原来的父类和子类去继承(或实现)这个基类。见下面这个案例,线段不是直线。

  • 违反LSP的设计
图6
/**
 * 直线
 */

public class Line {
    public Point p1;// 直线的头
    public Point p2;// 直线的尾

    // 构造器
    public Line(Point p1, Point p2) {
        this.p1 = p1;
        this.p2 = p2;
    }

    // 获取直线的坡度
    public double getSlope(){
        // 。。。
    }

    // 获取直线和y轴的交点
    public Point getIntercept(){
        // 。。。
    }

    public Point getP1() {
        return p1;
    }

    public Point getP2() {
        return p2;
    }

    // 点point是否在直线上,在的话,返回true,否则返回false。
    public boolean isOn(Point point){
        // 。。。
    }
}

/**
 * 线段
 */

public class LineSegment extends Line{
    public LineSegment(Point p1, Point p2) {
        super(p1, p2);
    }

    // 独有的方法,获取线段的长度
    public double getLength(){
        // 。。。
    }
}

由getIntercept方法返回的点是线和y轴的交点,对于Line实例,isOn(getIntercept()) == true,因为直线是无限延长的,和y轴总会有交点的。而线段是由固定长度的,对于很多LineSegment实例,它不一定会和y轴产生交点,此时isOn(getIntercept()) == true这条声明就会失效,违反了LSP原则。

  • 遵循LSP的设计 把Line和LineSegment这两个类的公共部分抽取出来作为一个抽象基类LnearObject,将Line类和LineSegment类的公共特性,getSlope()、getIntercept()、getP1()、getP2()和isOn(Point point)放在这个抽象基类中,getLength()是LineSegment类独有的方法,将其单独放在LineSegment类中。
图7
/**
 * 线性的实体
 */

public abstract class LnearObject {
    public Point p1;
    public Point p2;

    // 构造器
    public LnearObject(Point p1, Point p2) {
        this.p1 = p1;
        this.p2 = p2;
    }

    public double getSlope(){
        // 。。。
    }

    public Point getIntercept(){
        // 。。。
    }

    public Point getP1() {
        return p1;
    }

    public Point getP2() {
        return p2;
    }

    public abstract boolean isOn(Point point);
}

/**
 * 直线
 */

public class Line extends LnearObject {
    @Override
    public boolean isOn(Point point) {
        // 。。。
    }
}

/**
 * 线段
 */

public class LineSegment  extends LnearObject {
    @Override
    public boolean isOn(Point point) {
        // 。。。
    }

    // 独有的方法,获取线段的长度
    public double getLength(){
        // 。。。
    }
}

(5)采用里氏替换原则的好处

1、里氏替换原则是实现开闭原则的重要方式之一。

2、它克服了继承中重写父类造成的可复用性变差的缺点。

3、它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。

(6)总结

里氏替换原则(Liskov Substitution Principle,LSP)由麻省理工学院计算机科学实验室的里斯科夫(Liskov)女士在 1987 年的“面向对象技术的高峰会议”(OOPSLA)上发表的一篇文章《数据抽象和层次》(Data Abstraction and Hierarchy)里提出来的,她提出:继承必须确保超类所拥有的性质在子类中仍然成立(Inheritance should ensure that any property proved about supertype objects also holds for subtype objects)。

里氏替换原则主要阐述了有关继承的一些原则,也就是什么时候应该使用继承,什么时候不应该使用继承,使用继承需要注意什么,以及其中蕴含的原理。OCP(开闭原则)是OOD(面向对象设计方法)的核心,如果OCP这个原则应用地有效,应用程序就会具有更多地可维护性、可重用性以及健壮性,LSP(里氏替换原则)是使OCP成为可能的主要原则之一。LSP原则是面向对象几种设计原则中最难懂最晦涩的,我们需要好好理解。

源码位置:https://github.com/52Hzhaha/designPrinciple,LSP.zip。

文章转载自程序媛和她的猫,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论