我听说利斯科夫替换原则(LSP)是面向对象设计的基本原则。它是什么?它的一些使用例子是什么?


当前回答

大局:

利斯科夫替换原理是什么?它是关于什么是(什么不是)给定类型的子类型。 为什么它如此重要?因为子类型和子类之间是有区别的。

例子

与其他答案不同的是,我不会从违反Liskov替换原则(LSP)开始,而是从符合LSP开始。我使用Java,但在每种OOP语言中几乎都是一样的。

圆和彩色圆

几何例子在这里似乎很流行。

class Circle {
    private int radius;

    public Circle(int radius) {
        if (radius < 0) {
            throw new RuntimeException("Radius should be >= 0");
        }
        this.radius = radius;
    }

    public int getRadius() {
        return this.radius;
    }
}

半径不允许为负。这是一个子类:

class ColoredCircle extends Circle {
    private Color color; // defined elsewhere

    public ColoredCircle(int radius, Color color) {
        super(radius);
        this.color = color;
    }

    public Color getColor() {
        return this.color;
    }
}

从LSP来看,这个子类是Circle的子类型。

LSP的状态为:

如果对于类型S的每个对象o1,都有一个类型T的对象o2,使得对于所有用T定义的程序P,当o1取代o2时,P的行为不变,那么S是T的子类型(Barbara Liskov,“数据抽象和层次结构”,SIGPLAN通知,23,5(1988年5月))。

这里,对于每个ColoredCircle实例o1,考虑Circle实例具有相同的半径o2。对于每个使用Circle对象的程序,如果您将o2替换为o1,则任何使用Circle的程序的行为在替换之后都将保持不变。(注意,这只是理论上的:使用ColoredCircle实例会比使用Circle实例更快地耗尽内存,但这与本文无关。)

我们如何根据o1求出o2 ?我们只是去掉color属性,保留radius属性。我称这个变换为o1 - >o2是CircleColor空间在Circle空间上的投影。

反例

让我们再创建一个例子来说明LSP的违反。

圆形和方形

想象一下前面Circle类的子类:

class Square extends Circle {
    private int sideSize;

    public Square(int sideSize) {
        super(0);
        this.sideSize = sideSize;
    }

    @Override
    public int getRadius() {
        return -1; // I'm a square, I don't care
    }

    public int getSideSize() {
        return this.sideSize;
    }
}

LSP违反

现在,看看这个程序:

public class Liskov {
    public static void program(Circle c) {
        System.out.println("The radius is "+c.getRadius());
    }

我们用一个Circle对象和一个Square对象测试程序。

    public static void main(String [] args){
        Liskov.program(new Circle(2)); // prints "The radius is 2"
        Liskov.program(new Square(2)); // prints "The radius is -1"
    }
}

发生了什么事?直观地说,虽然Square是Circle的一个子类,但Square不是Circle的子类型,因为没有一个常规的Circle实例的半径是-1。

形式上,这违反了利斯科夫替换原则。

我们有一个用Circle定义的程序,在这个程序中没有Circle对象可以替换新的Square(2)(顺便说一下,也没有任何Square实例),并且保持行为不变:记住,任何Circle的半径总是正的。

子类和子类型

现在我们知道为什么子类并不总是子类型。当子类不是子类型时,即存在LSP违反时,某些程序(至少有一个)的行为并不总是预期的行为。这是非常令人沮丧的,通常被解释为一个错误。

在理想的情况下,编译器或解释器将能够检查给定的子类是否是真正的子类型,但我们并不是在理想的情况下。

静态类型

如果存在一些静态类型,则在编译时被父类签名绑定。Square.getRadius()不能返回String或List。

如果没有静态类型,如果一个参数的类型是错误的(除非类型是弱的)或参数的数量不一致(除非语言是非常允许的),您将在运行时得到一个错误。

关于静态类型的注意:有返回类型的协方差(S的方法可以返回T的相同方法的返回类型的子类)和参数类型的逆变性(S的方法可以接受T的相同方法的相同参数的超类)的机制,这是下面解释的先决条件和后置条件的具体情况。

合同设计

有更多的。有些语言(我想到了Eiffel)提供了一种机制来强制执行LSP。

先不说确定初始对象o1的投影o2,如果用o1代替o2 if,对于任何参数x和任何方法f,我们可以期望任何程序都有相同的行为:

如果o2.f(x)是一个有效调用,那么o1.f(x)也应该是一个有效调用(1)。 o1.f(x)的结果(返回值,在控制台上显示等)应该等于o2.f(x)的结果,或者至少同样有效(2)。 o1.f(x)应该让o1处于内部状态,o2.f(x)应该让o2处于内部状态,这样下一次函数调用将确保(1),(2)和(3)仍然有效(3)。

(注意,如果函数f是纯函数,则(3)是免费给出的。这就是为什么我们喜欢使用不可变对象。)

这些条件是关于类的语义(期望什么),而不仅仅是类的语法。而且,这些条件非常强。但是它们可以用契约式编程设计中的断言来近似。这些断言是确保支持类型语义的一种方法。破坏契约会导致运行时错误。

前提条件定义了什么是有效的调用。当子类化一个类时,前提条件只能被削弱(S.f接受的比T.f多)(a)。 后置条件定义了什么是有效结果。当子类化一个类时,后置条件只能被加强(S.f比T.f提供更多)(b)。 不变量定义了什么是有效的内部状态。当子类化一个类时,不变量必须保持不变(c)。

我们可以大致看到,(a)保证了(1),(b)保证了(2),但是(c)比(3)弱。此外,断言有时难以表达。

假设一个类Counter有一个唯一的方法Counter. Counter(),该方法返回下一个整数。怎么写后置条件呢?假设一个类Random有一个方法Random.高斯(),该方法返回一个介于0.0和1.0之间的浮点数。如何编写后置条件来检查分布是否为高斯分布?这也许是可能的,但成本太高,我们将依赖于测试而不是后置条件。

结论

不幸的是,子类并不总是子类型。这可能会导致意想不到的行为——bug。

面向对象语言提供了避免这种情况的机制。首先在语法层面。在语义层面上也是如此,这取决于编程语言:可以使用断言在程序文本中编码一部分语义。但是,由您来确保子类是子类型。

还记得你什么时候开始学习OOP吗?“如果关系是is - a,那么使用继承”。另一种方式也是如此:如果使用继承,请确保关系是is - a。

LSP在比断言更高的级别上定义了什么是子类型。断言是确保LSP得到维护的有价值的工具。

其他回答

我在每个答案中都看到了矩形和正方形,以及如何违反LSP。

我想用一个真实的例子来展示LSP是如何符合的:

<?php

interface Database 
{
    public function selectQuery(string $sql): array;
}

class SQLiteDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // sqlite specific code

        return $result;
    }
}

class MySQLDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // mysql specific code

        return $result; 
    }
}

这种设计符合LSP,因为无论我们选择使用哪种实现,行为都不会改变。

是的,你可以在这个配置中违反LSP,做一个简单的改变,像这样:

<?php

interface Database 
{
    public function selectQuery(string $sql): array;
}

class SQLiteDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // sqlite specific code

        return $result;
    }
}

class MySQLDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // mysql specific code

        return ['result' => $result]; // This violates LSP !
    }
}

现在子类型不能以同样的方式使用,因为它们不再产生相同的结果。

LSP的这种形式太强大了:

如果对于每个类型为S的对象o1,都有一个类型为T的对象o2,使得对于所有用T定义的程序P,当o1取代o2时,P的行为不变,那么S是T的子类型。

这基本上意味着S是t的另一个完全封装的实现,我可以大胆地认为性能是P行为的一部分……

因此,基本上,任何延迟绑定的使用都违反了LSP。当我们用一种类型的对象替换另一种类型的对象时,获得不同的行为是OO的全部意义所在!

维基百科引用的公式更好,因为属性取决于上下文,并不一定包括程序的整个行为。

利斯科夫替换原则(来自Mark Seemann的书)指出,我们应该能够在不破坏客户端或实现的情况下,用另一个接口的实现替换一个接口的实现。正是这一原则使我们能够解决未来出现的需求,即使我们今天不能预见它们。

If we unplug the computer from the wall (Implementation), neither the wall outlet (Interface) nor the computer (Client) breaks down (in fact, if it’s a laptop computer, it can even run on its batteries for a period of time). With software, however, a client often expects a service to be available. If the service was removed, we get a NullReferenceException. To deal with this type of situation, we can create an implementation of an interface that does “nothing.” This is a design pattern known as Null Object,[4] and it corresponds roughly to unplugging the computer from the wall. Because we’re using loose coupling, we can replace a real implementation with something that does nothing without causing trouble.

让我们用Java来说明:

class TrasportationDevice
{
   String name;
   String getName() { ... }
   void setName(String n) { ... }

   double speed;
   double getSpeed() { ... }
   void setSpeed(double d) { ... }

   Engine engine;
   Engine getEngine() { ... }
   void setEngine(Engine e) { ... }

   void startEngine() { ... }
}

class Car extends TransportationDevice
{
   @Override
   void startEngine() { ... }
}

这里没有问题,对吧?汽车绝对是一种交通工具,在这里我们可以看到它重写了其超类的startEngine()方法。

让我们添加另一个交通工具:

class Bicycle extends TransportationDevice
{
   @Override
   void startEngine() /*problem!*/
}

现在一切都不按计划进行了!是的,自行车是一种交通工具,但是,它没有发动机,因此,startEngine()方法不能实现。

这些都是违反利斯科夫代换法的问题 原则导致,他们通常可以被一个公认的 方法,该方法什么也不做,甚至不能实现。

这些问题的解决方案是一个正确的继承层次结构,在我们的例子中,我们将通过区分带引擎和不带引擎的运输设备类别来解决问题。尽管自行车是一种交通工具,但它没有发动机。在这个例子中,我们对交通工具的定义是错误的。它不应该有引擎。

我们可以像下面这样重构TransportationDevice类:

class TrasportationDevice
{
   String name;
   String getName() { ... }
   void setName(String n) { ... }

   double speed;
   double getSpeed() { ... }
   void setSpeed(double d) { ... }
}

现在我们可以为非机动设备扩展TransportationDevice。

class DevicesWithoutEngines extends TransportationDevice
{  
   void startMoving() { ... }
}

并为机动设备扩展TransportationDevice。这里更适合添加Engine对象。

class DevicesWithEngines extends TransportationDevice
{  
   Engine engine;
   Engine getEngine() { ... }
   void setEngine(Engine e) { ... }

   void startEngine() { ... }
}

因此,我们的Car类变得更加专门化,同时坚持利斯科夫替换原则。

class Car extends DevicesWithEngines
{
   @Override
   void startEngine() { ... }
}

我们的Bicycle类也遵循利斯科夫替换原理。

class Bicycle extends DevicesWithoutEngines
{
   @Override
   void startMoving() { ... }
}

长话短说,让我们留下矩形矩形和正方形,实际的例子,当扩展一个父类时,你必须要么保留确切的父API,要么扩展IT。

假设您有一个基本ItemsRepository。

class ItemsRepository
{
    /**
    * @return int Returns number of deleted rows
    */
    public function delete()
    {
        // perform a delete query
        $numberOfDeletedRows = 10;

        return $numberOfDeletedRows;
    }
}

以及扩展它的子类:

class BadlyExtendedItemsRepository extends ItemsRepository
{
    /**
     * @return void Was suppose to return an INT like parent, but did not, breaks LSP
     */
    public function delete()
    {
        // perform a delete query
        $numberOfDeletedRows = 10;

        // we broke the behaviour of the parent class
        return;
    }
}

然后,您可以让客户端使用Base ItemsRepository API并依赖它。

/**
 * Class ItemsService is a client for public ItemsRepository "API" (the public delete method).
 *
 * Technically, I am able to pass into a constructor a sub-class of the ItemsRepository
 * but if the sub-class won't abide the base class API, the client will get broken.
 */
class ItemsService
{
    /**
     * @var ItemsRepository
     */
    private $itemsRepository;

    /**
     * @param ItemsRepository $itemsRepository
     */
    public function __construct(ItemsRepository $itemsRepository)
    {
        $this->itemsRepository = $itemsRepository;
    }

    /**
     * !!! Notice how this is suppose to return an int. My clients expect it based on the
     * ItemsRepository API in the constructor !!!
     *
     * @return int
     */
    public function delete()
    {
        return $this->itemsRepository->delete();
    }
} 

当用子类替换父类破坏了API的契约时,LSP就被破坏了。

class ItemsController
{
    /**
     * Valid delete action when using the base class.
     */
    public function validDeleteAction()
    {
        $itemsService = new ItemsService(new ItemsRepository());
        $numberOfDeletedItems = $itemsService->delete();

        // $numberOfDeletedItems is an INT :)
    }

    /**
     * Invalid delete action when using a subclass.
     */
    public function brokenDeleteAction()
    {
        $itemsService = new ItemsService(new BadlyExtendedItemsRepository());
        $numberOfDeletedItems = $itemsService->delete();

        // $numberOfDeletedItems is a NULL :(
    }
}

你可以在我的课程中学习更多关于编写可维护软件的知识:https://www.udemy.com/enterprise-php/