我听说利斯科夫替换原则(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得到维护的有价值的工具。

其他回答

设q(x)是关于类型为T的x的对象的可证明属性,那么q(y)对于类型为S的对象y应该是可证明的,其中S是T的子类型。


实际上,公认的答案并不是利斯科夫原理的反例。正方形自然是一个特定的矩形,因此从类矩形继承是完全有意义的。你只需要以这样的方式实现它:

@Override
public void setHeight(double height) {
   this.height = height;
   this.width = height; // since it's a square
}

@Override
public void setWidth(double width) {
   setHeight(width);
}

所以,提供了一个很好的例子,然而,这是一个反例:

class Family:
-- getChildrenCount()

class FamilyWithKids extends Family:
-- getChildrenCount() { return childrenCount; } // always > 0

class DeadFamilyWithKids extends FamilyWithKids:
-- getChildrenCount() { return 0; }
-- getChildrenCountWhenAlive() { return childrenCountWhenAlive; }

在这个实现中,DeadFamilyWithKids不能从FamilyWithKids继承,因为getChildrenCount()返回0,而从FamilyWithKids它应该总是返回大于0的值。

LSP说“对象应该被它们的子类型替换”。 另一方面,这一原则指向

子类永远不应该破坏父类的类型定义。

通过以下示例,可以更好地理解LSP。

没有太阳能发电:

public interface CustomerLayout{

    public void render();
}


public FreeCustomer implements CustomerLayout {
     ...
    @Override
    public void render(){
        //code
    }
}


public PremiumCustomer implements CustomerLayout{
    ...
    @Override
    public void render(){
        if(!hasSeenAd)
            return; //it isn`t rendered in this case
        //code
    }
}

public void renderView(CustomerLayout layout){
    layout.render();
}

LSP修复:

public interface CustomerLayout{
    public void render();
}


public FreeCustomer implements CustomerLayout {
     ...
    @Override
    public void render(){
        //code
    }
}


public PremiumCustomer implements CustomerLayout{
    ...
    @Override
    public void render(){
        if(!hasSeenAd)
            showAd();//it has a specific behavior based on its requirement
        //code
    }
}

public void renderView(CustomerLayout layout){
    layout.render();
}

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

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

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

考虑下面的例子:

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。”然而,事实并非如此——只要孩子不违反父母的契约。

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

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

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

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

利斯科夫替换原则(来自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.