为什么 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 allows/requires 派生 class 的重写中的逆变参数
功能?
- 逆变规则如何有助于实现 data/procedure 抽象?
- 有没有我们需要通过逆变的真实世界的例子
派生的 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 的方法。感谢逆变它会工作。
Liskov Substitution Principle 对派生 class 中的方法签名施加的规则之一是:
Contravariance of method arguments in the subtype.
如果我理解正确的话,它是说派生的 class 的覆盖函数应该允许逆变参数(超类型参数)。但是,我无法理解这条规则背后的原因。由于 LSP 主要讨论将类型与子类型(而不是超类型)动态绑定以实现抽象,因此允许超类型作为 derived class 中的方法参数让我很困惑。 我的问题是:
- 为什么 LSP allows/requires 派生 class 的重写中的逆变参数 功能?
- 逆变规则如何有助于实现 data/procedure 抽象?
- 有没有我们需要通过逆变的真实世界的例子 派生的 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"
处坚持或打破 LSPclass 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 的方法。感谢逆变它会工作。