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


当前回答

以下是这篇文章的摘录,很好地澄清了事情:

(. .为了理解一些原则,重要的是要意识到它什么时候被违反了。这就是我现在要做的。

违反这一原则意味着什么?它意味着对象不履行用接口表示的抽象所施加的契约。换句话说,这意味着您错误地识别了抽象。

考虑下面的例子:

interface Account
{
    /**
     * Withdraw $money amount from this account.
     *
     * @param Money $money
     * @return mixed
     */
    public function withdraw(Money $money);
}
class DefaultAccount implements Account
{
    private $balance;
    public function withdraw(Money $money)
    {
        if (!$this->enoughMoney($money)) {
            return;
        }
        $this->balance->subtract($money);
    }
}

是否违反LSP?是的。这是因为帐户合同告诉我们帐户将被提取,但情况并非总是如此。那么,我该怎么做才能解决这个问题呢?我只是修改了合同:

interface Account
{
    /**
     * Withdraw $money amount from this account if its balance is enough.
     * Otherwise do nothing.
     *
     * @param Money $money
     * @return mixed
     */
    public function withdraw(Money $money);
}

Voilà,现在合同已得到满足。

这种微妙的违反通常会使客户有能力区分所使用的具体对象之间的差异。例如,给定第一个Account的契约,它看起来像下面这样:

class Client
{
    public function go(Account $account, Money $money)
    {
        if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
            return;
        }
        $account->withdraw($money);
    }
}

而且,这自动违反了开闭原则(即取款要求)。因为你永远不知道如果违反合同的对象没有足够的钱会发生什么。它可能什么都不返回,可能会抛出异常。所以你必须检查它是否hasEnoughMoney()——这不是接口的一部分。因此这种强制的依赖于具体类的检查违反了OCP。

这一点也解决了我经常遇到的关于LSP违反的误解。它说:“如果父母的行为在孩子身上改变了,那么它就违反了LSP。”然而,事实并非如此——只要孩子不违反父母的契约。

其他回答

让我们用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() { ... }
}

使用LSP的一个重要例子是在软件测试中。

如果我有一个类a,它是B的一个符合lsp的子类,那么我可以重用B的测试套件来测试a。

为了完全测试子类A,我可能需要添加更多的测试用例,但至少我可以重用所有超类B的测试用例。

实现这一点的一种方法是构建McGregor所说的“用于测试的并行层次结构”:我的ATest类将继承BTest。然后需要某种形式的注入来确保测试用例使用类型A的对象而不是类型B的对象(一个简单的模板方法模式就可以了)。

注意,对所有子类实现重用超级测试套件实际上是一种测试这些子类实现是否与lsp兼容的方法。因此,人们也可以主张应该在任何子类的上下文中运行超类测试套件。

另请参阅对Stackoverflow问题的回答“我是否可以实现一系列可重用测试来测试接口的实现?”

LSP关注不变量。

经典示例由以下伪代码声明给出(实现略):

class Rectangle {
    int getHeight()
    void setHeight(int value) {
        postcondition: width didn’t change
    }
    int getWidth()
    void setWidth(int value) {
        postcondition: height didn’t change
    }
}

class Square extends Rectangle { }

现在我们有一个问题,尽管接口匹配。原因是我们违反了源自正方形和矩形数学定义的不变量。getter和setter的工作方式,矩形应该满足以下不变量:

void invariant(Rectangle r) {
    r.setHeight(200)
    r.setWidth(100)
    assert(r.getHeight() == 200 and r.getWidth() == 100)
}

然而,Square的正确实现必须违反这个不变量(以及显式后置条件),因此它不是Rectangle的有效替代品。

假设我们在代码中使用了一个矩形

r = new Rectangle();
// ...
r.setDimensions(1,2);
r.fill(colors.red());
canvas.draw(r);

在几何课上,我们学过正方形是一种特殊类型的矩形,因为它的长宽相等。让我们根据下面的信息创建一个Square类:

class Square extends Rectangle {
    setDimensions(width, height){
        assert(width == height);
        super.setDimensions(width, height);
    }
} 

如果我们在第一个代码中将矩形替换为正方形,那么它将会中断:

r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);

这是因为正方形有一个我们在矩形类中没有的新前提条件:width == height。根据LSP,矩形实例应该被矩形子类实例替代。这是因为这些实例通过了矩形实例的类型检查,因此它们将在代码中导致意外错误。

这是wiki文章中“在子类型中不能加强先决条件”部分的一个例子。因此,总而言之,违反LSP可能会在某些时候导致代码错误。

一些补充:我想知道为什么没有人写基类的不变量、前提条件和后置条件,这些派生类必须遵守。 对于派生类D来说,基类B完全可转换,类D必须服从某些条件:

基类的内变体必须由派生类保留 派生类不能加强基类的先决条件 派生类不能削弱基类的后置条件。

因此派生类必须知道基类施加的上述三个条件。因此,子类型的规则是预先确定的。这意味着,只有当子类型遵守某些规则时,才应该遵守'IS A'关系。这些规则,以不变量、前置条件和后置条件的形式,应该由正式的“设计契约”来决定。

关于这个问题的进一步讨论可以在我的博客:利斯科夫替换原理