为什么“李斯科夫替代原则”需要论证是对立的?

Mac*_*Mac 7 liskov-substitution-principle contravariance

Liskov替代原理对派生类中的方法签名施加的规则之一是:

子类型中方法参数的矛盾性。

如果我理解正确的话,就是说派生类的重写函数应允许使用自变量(超类型自变量)。但是,我不明白这条规则背后的原因。由于LSP主要讨论动态地将类型与那里的子类型(而不是超类型)进行绑定以实现抽象,因此让超类型作为派生类中的方法参数对我来说是很困惑的。我的问题是:

  • 为什么LSP在派生类的覆写功能中允许/要求使用Contravariant参数?
  • 协方差规则如何有助于实现数据/过程抽象?
  • 在现实世界中,是否有任何示例需要将反参数传递给派生类的重写方法?

que*_*atl 6

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

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

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

// example of usage
adder = new BasicAdder

// elsewhere
Anything res = adder.Add( integer1, float2 );
Run Code Online (Sandbox Code Playgroud)

这里,“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
Run Code Online (Sandbox Code Playgroud)

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

另一方面,请注意协变返回类型是可以的: Complex as return type will fit Anything

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

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

// example of usage
adder = new SupersetComplexAdder

// elsewhere
Anything res = adder.Add( integer1, float2 ); // WIN
Run Code Online (Sandbox Code Playgroud)

现在一切正常,因为无论谁使用旧对象,现在也可以使用新对象,而不会影响使用点。

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

还值得注意的是,您可以在各个“级别”上坚持或打破 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)
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

很好……对我来说很明显。如果您创建组件,这些组件是可交换/可交换/可替换的:

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

然后添加一个新的:

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

假设它处理欧元和英镑输入值。旧货币的投入怎么样,比如美元?如果忽略这一点,那么新的组件不是替换旧的。您不能只是取出旧组件并插入新组件并希望一切正常。系统中的所有其他事物仍可能会发送美元值作为输入。

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

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

现在,如果没有任何问题,我们可以将一个替换为另一个,无论是美元还是英镑,还是单核还是多核。现在,从上一层看大图,如果不再需要关心具体的货币种类,那么我们成功地将其抽象出来,大图会更简单,当然,组件需要内部处理不知何故。

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

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


Mar*_*nik 6

短语“方法参数的逆变”可能很简洁,但它是模棱两可的。让我们以此为例:

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

class Derived {
  abstract void add(Xxx? x);
}
Run Code Online (Sandbox Code Playgroud)

现在,“方法参数的逆变”可能意味着Derived.add必须接受任何具有类型Banana或超类型的对象,例如? super Banana. 这是对 LSP 规则的错误解释。

实际解释是:“Derived.add必须使用类型声明Banana,就像 in 一样Base,或者使用Banana诸如 的某个超类型声明Fruit。” 您选择哪种超类型取决于您。

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

实际上,在子类中扩展类型很有用的例子并不多。我想这就是为什么大多数语言都懒得去实现它。要求严格相同的类型也会保留 LSP,只是不能在实现 LSP 的同时为您提供完全的灵活性。