首先,你的答案:
class Base
{
[pure]
public virtual bool IsValid()
{
return false;
}
}
class Child : Base
{
public override bool IsValid()
{
return true;
}
}
基本上,LSP 说(它是“子类型”的定义):
如果对于每个 S 类型的对象 o1 都有一个 T 类型的对象 o2 使得对于所有以 T 定义的程序 P,当 o1 替换 o2 时 P 的行为不变,那么 S 是T.(里斯科夫,1987 年)
“但是我不能用任何o2 类型的Child 替换Base 类型的o1,因为它们的行为显然不同!”为了解决这个问题,我们不得不绕道而行。
什么是子类型?
首先,请注意 Liskov 不仅在谈论类,还谈论类型。类是类型的实现。类型的实现有好有坏。我们将尝试区分它们,尤其是在涉及子类型时。
里氏替换原则背后的问题是:什么是子类型? 通常,我们假设子类型是其超类型的特化和其能力的扩展:
> The intuitive idea of a subtype is one whose objects provide all the behavior of objects of another type (the supertype) plus something extra (Liskov, 1987)
另一方面,大多数编译器假定子类型是一个类,它至少具有相同的方法(相同的名称、相同的签名,包括协变和异常),无论是继承的还是重新定义的(或首次定义的),并且标签(inherits,extends,...)。
但是这些标准是不完整的并且会导致错误。以下是两个臭名昭著的例子:
-
SortedList 是 (?) List 的子类型:它表示已排序(特化)的列表。
-
Square 是 (?) Rectangle 的子类型:它表示一个具有四个相等边的矩形(特化)。
为什么SortedList 不是List?因为List 类型的语义。类型不仅是签名的集合,方法也具有语义。 通过语义,我指的是一个对象的所有授权使用(记住维特根斯坦:“一个词的含义是它在语言中的使用”)。例如,您希望在放置它的位置找到一个元素。但是如果列表总是排序的,一个新插入的元素将被移动到它的“正确”位置。因此,您不会在放置它的位置找到该元素。
为什么Square 不是Rectangle?想象一下你有一个方法set_width:用一个正方形,你也必须改变高度。但是set_width 的语义是改变宽度但保持高度不变。
(正方形不是长方形吗?这个问题有时会引起热烈的讨论,所以我会详细阐述这个问题。我们都知道正方形是长方形。但在纯数学的天空中是这样的,在哪里对象是不可变的。如果你定义了一个ImmutableRectangle(具有固定的宽度、高度、位置、角度和计算的周长、面积……),那么根据 LSP,ImmutableSquare 将是ImmutableRectangle 的子类型。乍一看,这样的不可变类似乎不是很有用,但有一种方法可以解决这个问题:用创建新对象的方法替换 setter,就像在任何函数式语言中所做的那样。例如,ImmutableSquare.copyWithNewHeight(h) 将返回一个新对象。 ..ImmutableRectangle,其高度为h,宽度为正方形的size。)
我们可以使用 LSP 来避免这些错误。
为什么我们需要 LSP?
但是为什么,在实践中,我们需要关心 LSP? 因为编译器不捕获类的语义。您可能有一个子类不是子类型的实现。
对于 Liskov(和 Wing,1999 年),类型规范包括:
- 类型的名称
- 类型值空间的描述
- 类型的不变量和历史属性的定义;
- 对于每个类型的方法:
- 它的名字;
- 其签名(包括信号异常);
- 它在前置条件和后置条件方面的行为
如果编译器能够为每个类强制执行这些规范,它就能够(在编译时或运行时,取决于规范的性质)告诉我们:“嘿,这不是子类型!”。
(实际上,有一种编程语言试图捕捉语义:Eiffel。在 Eiffel 中,不变量、前置条件和后置条件是类定义的重要组成部分。因此,你不要'不必关心 LSP:运行时会为您完成。这很好,但 Eiffel 也有限制。这种语言(任何语言?)不足以表达定义 isValid() 的完整语义,因为这种语义不包含在前置/后置条件或不变量中。)
现在,回到示例。在这里,我们对 isValid 语义的唯一指示是方法的名称:如果对象有效,它应该返回 true,否则返回 false。您显然需要上下文(可能还需要详细的规范或领域知识)来了解什么是有效的,什么是无效的。
实际上,我可以想象很多Base 类型的任何对象都有效,但Child 类型的所有对象都无效的情况(请参阅答案顶部的代码)。例如。将Base 替换为Passport,将Child 替换为FakePassword(假设假密码是密码......)。
因此,即使Base 类说:“我是有效的”,Base 类型说:“我的几乎所有实例都是有效的,但那些无效的应该说出来!”这就是为什么你有一个 Child 类实现 Base 类型(并派生 Base 类),它说:“我无效”。
一个更有趣的例子
但我认为您选择的示例不是检查前置/后置条件和不变量的最佳示例:由于该函数是纯函数,因此它可能不会在设计上破坏任何不变量;因为返回值是一个布尔值(2 个值),所以没有有趣的后置条件。如果你有一些参数,你唯一可以拥有的是一个有趣的前提条件。
让我们举一个更有趣的例子:一个集合。在伪代码中,您有:
abstract class Collection {
abstract iterator(); // returns a modifiable iterator
abstract size();
// a generic way to set a value
set(i, x) {
[ precondition:
size: 0 <= i < size() ]
it = iterator()
for i=0 to i:
it.next()
it.set(x)
[ postcondition:
no_size_modification: size() = old size()
no_element_modification_except_i: for all j != i, get(j) == old get(j)
was_set: get(i) == x ]
}
// a generic way to get a value
get(i) {
[ precondition:
size: 0 <= i < size() ]
it = iterator()
for i=0 to i:
it.next()
return it.get()
[ postcondition:
no_size_modification: size() = old size()
no_element_modification: for all j, get(j) == old get(j) ]
}
// other methods: remove, add, filter, ...
[ invariant: size_positive: size() >= 0 ]
}
这个集合有一些抽象方法,但set 和get 方法已经是实体方法。此外,我们可以说他们
对于链表是可以的,但对于由数组支持的列表则不行。让我们尝试为随机访问集合创建一个更好的实现:
class RandomAccessCollection {
// all pre/post conditions and invariants are inherited from Collection.
// fields:
// self.count = number of elements.
// self.data = the array.
iterator() { ... }
size() { return self.count; }
set(i, x) { self.data[i] = x }
get(i) { return self.data[i] }
// other methods
}
很明显get和set在RandomAccessCollection中的语义符合Collection类的定义。特别是,满足所有前置/后置条件和不变量。换句话说,LSP 的条件得到满足,因此 LSP 得到尊重:在每个程序中,我们可以用 @ 类型的 analog 对象替换 Collection 类型的任何对象987654372@ 不会破坏程序的行为。
结论
如您所见,尊重 LSP 比破坏它更容易。但有时我们会破坏它(例如,尝试创建一个继承 RandomAccessCollection 的 SortedRandomAccessCollection)。 LSP 清晰的表述有助于我们缩小问题的范围以及应该采取哪些措施来纠正设计。
更一般地说,如果基类有足够的肉体来实现方法,则虚拟(实体)布尔方法不是反模式。但是如果基类太抽象以至于每个子类都必须重新定义方法,那么就让方法抽象吧。
参考文献
Liskov 有两篇主要的原始论文:Data Abstraction and Hierarchy (1987) 和 Behavioral Subtyping Using Invariants and Constraints (1994, 1999, with J. M. Wing)。请注意,这些是理论论文。