Skip to content
快速预览

里氏替换原则(LSP)

✍️ w 🕒 2023-07-23 12:23:51(10 months ago) 🔗 G.设计模式

里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP。这个原则最早是在 1986 年由 Barbara Liskov 提出,在 1996 年,Robert Martin 在他的 SOLID 原则中,重新描述了这个原则,综合两者的描述,将这条原则用中文描述出来,是这样的:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

在更加了解里式替换原则前,应该先分清 继承和里式替换原则

父子继承是面向对象编程中的一种机制,它允许子类继承父类的属性和方法。利用父子继承代码复用的机制,它可以避免重复编写代码,提高代码的复用性和可维护性。

里氏替换原则是一种设计原则,虽然也涉及到父类和子类之间的关系

虽然父子继承和里氏替换原则都涉及到父类和子类之间的关系,但是它们的目的和应用场景是不同的但是两者的角度不同因此具体总结为

  1. 继承是一种实现代码重用的语言机制,子类可以继承父类的数据和方法 ,里式替换原则是针对继承机制的设计原则,它规定了继承应当满足的条件。
  2. 继承是实现,描述了什么样的关系。里式替换原则是原则,规定了这种关系应该怎样才能合理。
  3. 继承本身并不要求一定要满足里式替换原则,一个继承关系可以违反里式替换原则。
  4. 满足里式替换原则的继承关系是合理的和安全的,不满足的继承关系是危险的。
  5. 里式替换原则要求子类型对象必须能够替换父类型对象,而程序行为不变。
  6. 里式替换原则限制了继承的使用,使得继承语义更明确。

虽然并不是所有的继承关系都满足里氏替换原则,但是里氏替换原则是对继承关系的规范,它要求子类对象必须能够替换父类对象,同时保证原有功能不变。遵守里氏替换原则,可以使继承关系更加合理可靠

具体了解

子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

  1. 子类不能违背父类声明要实现的功能,如果子类没有实现父类的所有抽象方法,那么子类就不能完全替换父类,这会违反里式替换原则,举个例子父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。

  2. 子类不能违背父类对输入、输出、异常的约定,子类的方法可以抛出更少或更宽泛的异常:子类的方法可以抛出比父类更少或更宽泛的异常,但不能抛出比父类更多或更严格的异常,因为这会破坏客户端代码对父类的假设。举个例子在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。

  3. 子类违背父类注释中所罗列的任何特殊说明,父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。

  4. 子类可以有自己的个性:子类在继承父类的方法后,可以有自己的功能实现,可以增加自己的方法。

  5. 覆盖或实现父类的方法时输入参数可以被放大:子类在覆盖或实现父类的方法时,输入参数类型可以是父类的输入参数类型的子类,这样可以增加代码的灵活性。

  6. 覆盖或实现父类的方法时输出结果可以被缩小:子类在覆盖或实现父类的方法时,输出结果类型可以是父类的输出结果类型的父类,这样可以增加代码的稳定性。

  7. 子类的实例必须能替换掉父类的实例:这是里氏替换原则的核心要求,只有满足这个条件,才能保证在使用父类的地方可以随时被子类替换,而不会产生任何错误。

  8. 子类不能有父类未有的行为:子类实现的新方法必须是对父类服务的,不能引入与父类不相关的新行为。

  9. 子类必须尽量保持父类的行为一致性:子类在覆盖或实现父类的方法后,尽量保持与父类的行为一致,否则可能会导致使用父类的代码在不知情的情况下出现错误。

总之,遵循里式替换原则可以让代码更加灵活、可扩展、易于维护和重用。但需要注意上述几点,以确保子类能够完全替换父类。

案例一

以下是一个违反里式替换原则的案例:

假设有一个父类 Animal,其中有一个方法 eat(),用于描述动物吃东西的行为。现在有一个子类 Bird,它继承了 Animal 类,并重写了 eat() 方法。但是,Bird 类中的 eat() 方法只能接受昆虫作为参数,而不能接受其他类型的食物。这就违反了里式替换原则,因为子类的行为与父类不一致,无法完全替换父类。

java
class Animal {
    public void eat(String food) {
        System.out.println("Animal is eating " + food);
    }
}

class Bird extends Animal {
    @Override
    public void eat(String food) {
        if (food.equals("insect")) {
            System.out.println("Bird is eating " + food);
        } else {
            throw new IllegalArgumentException("Bird can only eat insects");
        }
    }
}

在上面的例子中,Bird 类的 eat() 方法只能接受昆虫作为参数,而不能接受其他类型的食物。这就违反了里式替换原则,因为子类的行为与父类不一致,无法完全替换父类。

案例二

长方形和正方形是经典的违反里式替换原则的案例。假设有一个父类 Shape,其中有一个方法 area(),用于计算图形的面积。现在有一个子类 Rectangle,它继承了 Shape 类,并重写了 area() 方法。但是,又有一个子类 Square,它也继承了 Shape 类,但是它并没有重写 area() 方法,而是直接继承了父类的 area() 方法。这就违反了里式替换原则,因为子类的行为与父类不一致,无法完全替换父类。

java
class Shape {
    public double area() {
        return 0;
    }
}

class Rectangle extends Shape {
    private double width;
    private double height;

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

    @Override
    public double area() {
        return width * height;
    }
}

class Square extends Shape {
    private double side;

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

    // 没有重写 area() 方法
}

在上面的例子中,Square 类并没有重写 area() 方法,而是直接继承了父类的 area() 方法。但是,Square 类的面积计算方式与 Rectangle 类不同,这就违反了里式替换原则,因为子类的行为与父类不一致,无法完全替换父类。

案例三

在数学领域里,正方形毫无疑问是长方形,它是一个长宽相等的长方形。所以,我们开发的一个与几何图形相关的软件系统,就可以顺理成章的让正方形继承自长方形

长方形类(Rectangle):

java
public class Rectangle {
    private double length;
    private double width;

    public double getLength() {
        return length;
    }

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

    public double getWidth() {
        return width;
    }

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

正方形(Square):

由于正方形的长和宽相同,所以在方法setLength和setWidth中,对长度和宽度都需要赋相同值。

java
public class Square extends Rectangle {
    
    public void setWidth(double width) {
        super.setLength(width);
        super.setWidth(width);
    }

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

类RectangleDemo是我们的软件系统中的一个组件,它有一个resize方法依赖基类Rectangle,resize方法是RectandleDemo类中的一个方法,用来实现宽度逐渐增长的效果

java
public class RectangleDemo {
    
    public static void resize(Rectangle rectangle) {
        while (rectangle.getWidth() <= rectangle.getLength()) {
            rectangle.setWidth(rectangle.getWidth() + 1);
        }
    }

    //打印长方形的长和宽
    public static void printLengthAndWidth(Rectangle rectangle) {
        System.out.println(rectangle.getLength());
        System.out.println(rectangle.getWidth());
    }

    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle();
        rectangle.setLength(20);
        rectangle.setWidth(10);
        resize(rectangle);
        printLengthAndWidth(rectangle);

        System.out.println("============");

        Rectangle rectangle1 = new Square();
        rectangle1.setLength(10);
        resize(rectangle1);
        printLengthAndWidth(rectangle1);
    }
}

运行一下这段代码就会发现,假如我们把一个普通长方形作为参数传入resize方法,就会看到长方形宽度逐渐增长的效果,当宽度大于长度,代码就会停止,这种行为的结果符合我们的预期;假如我们再把一个正方形作为参数传入resize方法后,就会看到正方形的宽度和长度都在不断增长,代码会一直运行下去,直至系统产生溢出错误。所以,普通的长方形是适合这段代码的,正方形不适合。 我们得出结论:在resize方法中,Rectangle类型的参数是不能被Square类型的参数所代替,如果进行了替换就得不到预期结果。因此,Square类和Rectangle类之间的继承关系违反了里氏代换原则,它们之间的继承关系不成立,正方形不是长方形。

如何改进呢?此时我们需要重新设计他们之间的关系。抽象出来一个四边形接口(Quadrilateral),让Rectangle类和Square类实现Quadrilateral接口。

参考

里氏代换原则是什么?里氏代换原则介绍

里氏替换原则LSP

理论三:里式替换(LSP)跟多态有何区别?哪些代码违背了LSP

Released under the MIT License.