首页 文章

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

提问于
浏览
4

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

子类型中方法参数的逆变 .

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

  • 为什么LSP在派生类的重写功能中允许/需要Contravariant参数?

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

  • 我们是否需要将逆变参数传递给派生类的重写方法?

3 回答

  • 4

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

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

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

    这里,“数字”是类似数字类型,整数,浮点数,双精度等的基类型的概念 . 在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 参数,因为它实际上是复杂的参数 .

    另一方面,请注意协变返回类型是OK:复杂的返回类型适合 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规则如何有助于实现数据/过程抽象?

    这对我来说很明显 . 如果你创建了say,components,那些可以交换/可交换/可替换的组件:

    • BASE:天真地计算发票总额

    • DER-1:并行计算多个核心上的发票总和

    • DER-2:计算具有详细记录的发票总和

    然后添加一个新的:

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

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

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

    • 任何一个使用地点都不需要BASE,但实际上需要更多

    • 或我们的组件 is not a BASE (请注意这是一个措辞)

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

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

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

  • 2

    短语“方法论证的逆变”可能很简洁,但它含糊不清 . 我们以此为例:

    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 . ”你选择哪种超类型取决于你 .

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

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

  • 1

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

    class BasicTester
        {
           TestDrive(Car f)
    
        }
    
        class ExpensiveTester:BasicTester
        {
           TestDrive(Vehicle v)
        }
    

    旧类只能使用Car类型,而派生类更好,可以处理任何Vehicle . 此外,那些使用“旧”车型的新 class 的人也将被提供 .

    但是,您不能像C#中那样覆盖 . 您可以使用委托间接实现它:

    protected delegate void TestDrive(Car c)
    

    然后可以为其分配一个接受Vehicle的方法 . 谢谢它的逆转,它会起作用 .

相关问题