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

标签 contravariance liskov-substitution-principle

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

Contravariance of method arguments in the subtype.



如果我理解正确,它是说派生类的覆盖函数应该允许逆变参数(父类(super class)型参数)。但是,我无法理解这条规则背后的原因。由于 LSP 主要讨论将类型与子类型(而不是父类(super class)型)动态绑定(bind)以实现抽象,因此允许父类(super class)型作为派生类中的方法参数对我来说很困惑。
我的问题是:
  • 为什么 LSP 在派生类的重写中允许/需要逆变参数
    功能?
  • 逆变规则如何有助于实现数据/过程抽象?
  • 是否有任何现实世界的例子需要我们传递逆变
    派生类的重写方法的参数?
  • 最佳答案

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

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

    class BasicAdder
    {
        Anything Add(Number x, Number y);
    }
    
    // example of usage
    adder = new BasicAdder
    
    // elsewhere
    Anything res = adder.Add( integer1, float2 );
    

    在这里,“数字”是类似数字的数据类型、整数、浮点数、 double 数等的基本类型的概念。即 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 参数,因为它实际上 需要 复杂参数。

    另一方面,请注意协变返回类型是可以的: Complex 作为返回类型将适合 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。但是是吗?通常不会,但这取决于很多事情。

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



    很好……对我来说很明显。如果您创建组件,这些组件是可交换/可交换/可替换的:
  • BASE:天真地计算发票总和
  • DER-1:并行计算多个内核上的发票总和
  • DER-2:使用详细日志记录计算发票总和

  • 然后添加一个新的:
  • 计算不同币种发票的总和

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

    如果我们创建从 BASE 派生的新组件,那么每个人都应该安全地假设他们可以在之前需要 BASE 的任何地方使用它。如果某个地方需要 BASE,但使用了 DER-2,那么我们应该能够在那里插入新组件。这是 LSP。如果我们不能,那么某些东西就坏了:
  • 任何一个使用地点都不需要 BASE 但实际上需要更多
  • 或我们的组件确实 不是 BASE (请注意 is-a 措辞)

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

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

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

    关于contravariance - 为什么 Liskov 替换原则需要参数是逆变的?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/40340317/

    相关文章:

    generics - Java 数组协方差是否违反 Liskov 替换原则?

    scala - 对不可变集合使用上限

    c++ - 继承 C++ 接口(interface)和 LSP 冲突

    typescript - 如何在 typescript 中为带有参数但没有任何参数的函数类型指定类型保护?

    c# - 在字典中存储多个逆变委托(delegate)

    c# - ReadOnlyCollection 类是不良设计的好例子吗?

    design-patterns - 了解合约和里氏替换原则

    scala - 为什么参数处于反位置?

    scala - 逆变的例子

    c# - C# 3 中具有接口(interface)继承(co(ntra)-variance?)的泛型类型推断