为什么Liskov替换原则需要论证是逆变的?

时间:2016-10-31 10:24:46

标签: contravariance liskov-substitution-principle

Liskov Substitution Principle对派生类中的方法签名施加的规则之一是:

  

子类型中方法参数的反演。

如果我理解正确,那就是说派生类的重写函数应该允许反变量参数(超类型参数)。但是,我无法理解这条规则背后的原因。由于LSP主要是关于动态地将类型与子类型(而不是超类型)绑定以实现抽象,因此允许超类型作为派生类中的方法参数对我来说非常困惑。 我的问题是:

  • 为什么LSP在派生类的重写中允许/需要Contravariant参数 funtion?
  • Contravariance规则如何有助于实现数据/过程抽象?
  • 是否有任何真实世界的例子,我们需要通过逆变 派生类的重写方法的参数?

3 个答案:

答案 0 :(得分:4)

短语"方法论证的逆转"可能很简洁,但它含糊不清。我们以此为例:

class Base {
  abstract void add(Banana b);
}

class Derived {
  abstract void add(Xxx? x);
}

现在,"方法论证的逆转"可能意味着Derived.add必须接受任何具有Banana类型或超类型的对象,例如? super Banana。这是对LSP规则的不正确的解释。

实际解释是:" Derived.add必须使用Banana类型声明,就像Base一样,或Banana的某些超类型声明Fruit&#34。你选择哪种超类型取决于你。

我相信使用这种解释不难看出规则是完全合理的。您的子类与父API兼容,但它也可以选择性地涵盖基类没有的额外情况。因此它的LSP可替代基类。

在实践中,没有很多例子可以在子类中扩展这种类型。我认为这就是为什么大多数语言都不愿意实现它的原因。要求严格相同的类型也可以保留LSP,但是在获得LSP的同时,并没有给你足够的灵活性。

答案 1 :(得分:3)

这里,按照LSP的说法,“派生对象”应该可以用作“基础对象”的替代。

假设你的基础对象有一个方法:

class BasicAdder
{
    Anything Add(Number x, Number y);
}

// example of usage
adder = new BasicAdder

// elsewhere
Anything res = adder.Add( integer1, float2 );

这里,“Number”是类数数据类型,整数,浮点数,双精度等的基类型的概念。在C ++中不存在这样的东西,但是,我们在这里不讨论特定的语言。同样,仅仅出于举例的目的,“Anything”描述了任何类型的无限制值。

让我们考虑一个“专门”使用Complex的派生对象:

class ComplexAdder
{
    Complex Add(Complex x, Complex y);
}

// example of usage
adder = new ComplexAdder

// elsewhere
Anything res = adder.Add( integer1, float2 ); // FAIL

因此,我们刚刚破坏了LSP:它不能用作原始对象的替代品,因为它无法接受integer1, float2参数,因为它实际上需要复杂参数。 / p>

另一方面,请注意协变返回类型是正确的:复杂的返回类型适合Anything

现在,让我们考虑另一种情况:

class SupersetComplexAdder
{
    Anything Add(ComplexOrNumberOrShoes x, ComplexOrNumberOrShoes y);
}

// example of usage
adder = new SupersetComplexAdder

// elsewhere
Anything res = adder.Add( integer1, float2 ); // WIN

现在一切正常,因为无论谁使用旧对象,现在也能够使用新对象,对使用点没有任何影响。

当然,并不总是可以创建这样的“联合”或“超集”类型,特别是在数字方面,或者在某些自动类型转换方面。但是,我们不是在讨论特定的编程语言。总体思路很重要。

值得注意的是,您可以在各种“级别”上遵守或破坏LSP

class SmartAdder
{
    Anything Add(Anything x, Anything y)
    {
        if(x is not really Complex) throw error;
        if(y is not really Complex) throw error;

        return complex-add(x,y)
    }
}

它肯定看起来像在类/方法签名级别符合LSP。但是吗?通常不会,但这取决于很多事情。

  
    

Contravariance规则如何有助于实现数据/过程抽象?

  

对我来说很明显。如果你创建了可以交换/可交换/可替换的说,组件:

  • BASE:天真地计算发票总额
  • DER-1:并行计算多个核心上的发票总和
  • DER-2:计算具有详细记录的发票总和

然后添加一个新的:

  • 以不同货币计算发票总额

让我们说它处理欧元和英镑的输入值。那么输入旧货币呢,比如美元?如果省略,那么新组件不是替代旧组件。您不能只取出旧组件并插入新组件并希望一切正常。系统中的所有其他内容仍可能将美元值作为输入发送。

如果我们创建从BASE派生的新组件,那么每个人都可以安全地假设他们可以在之前需要BASE的任何地方使用它。如果某个地方需要BASE,但是使用了DER-2,那么我们应该能够在那里插入新的组件。这是LSP。如果我们做不到,那么事情就会被打破:

  • 任何一个使用地点都不需要BASE,但实际上需要更多
  • 或我们的组件确实不是BASE (请注意这是一个措辞)

现在,如果没有任何损失,我们可以拿一个并替换另一个,无论是美元还是英镑,还是单核或多核。现在,看一级以上的大局,如果不再需要关心特定类型的货币,那么我们成功地将其抽象化,大局将更简单,当然,组件需要在内部处理不知。

如果这对于数据/过程抽象没有帮助,那么请看相反的情况:

如果从BASE派生的组件不遵守LSP,那么当美元的合法值到达时,它可能会引发错误。或者更糟糕的是,它不会注意到并将它们作为GBP处理。我们出现了问题。要解决这个问题,我们需要修复新组件(以遵守BASE的所有要求),或者更改其他邻居组件以遵循新规则,例如“现在使用EUR而不是USD,或者Adder会抛出异常”,或者我们需要在大图中添加东西来处理它,即添加一些分支,它们将检测旧式数据并将它们重定向到旧组件。我们只是“泄露”了邻居的复杂性(也许我们强迫他们打破SRP)或者我们让“大局”变得更加复杂(更多的适配器,条件,分支......)。

答案 2 :(得分:1)

我知道这是一个很老的问题,但我认为更现实的生活用途可能会有所帮助:

 class BasicTester
    {
       TestDrive(Car f)

    }

    class ExpensiveTester:BasicTester
    {
       TestDrive(Vehicle v)
    }

旧类只能使用Car类型,而派生类则更好,并且可以处理任何Vehicle。此外,还将为那些使用“旧”汽车类型的新班级的人提供服务。

但是,您不能像C#中那样重写。 您可以使用委托间接实现该目标:

protected delegate void TestDrive(Car c)
然后可以为

分配一个接受Vehicle的方法。谨防矛盾会起作用。