为什么 Liskov 替换原则需要参数是逆变的?

Why Liskov Substitution Principle needs the argument to be contravariant?

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

Contravariance of method arguments in the subtype.

如果我理解正确的话,它是说派生的 class 的覆盖函数应该允许逆变参数(超类型参数)。但是,我无法理解这条规则背后的原因。由于 LSP 主要讨论将类型与子类型(而不是超类型)动态绑定以实现抽象,因此允许超类型作为 derived class 中的方法参数让我很困惑。 我的问题是:

在这里,按照 LSP 的说法,"derived object" 应该可以用来替代 "base object"。

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

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

// example of usage
adder = new BasicAdder

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

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

让我们考虑一个 "specialized" 使用 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 参数,因为它实际上 需要 复杂的参数。

另一方面,请注意协变 return 类型是可以的:复杂的 return 类型将适合 Anything.

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

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

// example of usage
adder = new SupersetComplexAdder

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

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

当然,并不总是可以创建这样的"union"或"superset"类型,尤其是在数字方面,或者在一些自动类型转换方面。但是,我们不是在谈论特定的编程语言。整体思路很重要。

还值得注意的是,您可以在各种 "levels"

处坚持或打破 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)
    }
}

看起来确实符合 class/method 签名级别的 LSP。但是吗?通常不是,但这取决于很多事情。

How Contravariance rule is helpful in achieving data/procedure abstraction?

很好.. 对我来说很明显。如果你创建说,组件,那意味着 exchangeable/swappable/replaceable:

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

然后添加一个新的:

  • 计算不同货币的发票总和

假设它处理 EUR 和 GBP 输入值。以旧货币(比如美元)输入呢?如果您忽略了这一点,那么新组件 并不是 旧组件的替代品。您不能只是取出旧组件并插入新组件并希望一切都很好。系统中的所有其他事物可能仍会发送美元值作为输入。

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

  • 这两个地方不仅需要 BASE,实际上还需要更多
  • 或者我们的组件确实 不是 BASE(请注意 is-a 的措辞)

现在,如果什么都没有坏,我们可以拿一个换另一个,不管是美元还是英镑,单核还是多核。现在,在上一层看大图,如果不再需要关心特定类型的货币,那么我们成功地将它抽象出来大图会更简单,当然,组件将需要在内部处理不知何故。

如果这对 data/procedure 抽象没有帮助,那么看看相反的情况:

如果从 BASE 派生的组件不遵守 LSP,则当美元的合法值到达时它可能会引发错误。或者更糟的是,它不会注意到并将它们作为 GBP 处理。我们出现了问题。要解决这个问题,我们需要修复新组件(以遵守 BASE 的所有要求),或更改其他相邻组件以遵循 "now use EUR not USD, or the Adder will throw exceptions" 等新规则,或者我们需要向大图添加内容以使其正常工作周围即添加一些分支,这些分支将检测旧式数据并将它们重定向到旧组件。我们只是 "leaked" 邻居的复杂性(也许我们强迫他们打破 SRP)或者我们让 "big picture" 更复杂(更多适配器、条件、分支......)。

短语"contravariance of method arguments"可能很简洁,但有歧义。让我们以此为例:

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

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

现在,"contravariance of method argument" 可能意味着 Derived.add 必须接受任何类型为 Banana 或超类型的对象,例如 ? super Banana。这是对 LSP 规则的不正确解释。

实际的解释是:“Derived.add 必须声明为 Banana 类型,就像在 Base 中一样,或者 Banana 的某些超类型,例如 Fruit。”选择哪种超类型由你决定。

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

在实践中,子 class 中的这种扩展类型有用的例子并不多。我想这就是为什么大多数语言都懒得去实现它的原因。要求严格相同的类型也会保留 LSP,只是不会给您在仍然实现 LSP 的同时可以拥有的全部灵活性。

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

 class BasicTester
    {
       TestDrive(Car f)

    }

    class ExpensiveTester:BasicTester
    {
       TestDrive(Vehicle v)
    }

旧的 class 只能用于汽车类型,而派生的更好,可以处理任何车辆。此外,那些使用新 class 和 "old" 车型的人也将得到服务。

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

protected delegate void TestDrive(Car c)

然后可以为它分配一个接受 Vehicle 的方法。感谢逆变它会工作。