当遵循 Liskov 替换原则 (LSP) 时,子类可以实现其他接口



考虑这个 ruby 示例

class Animal
def walk
# In our universe all animals walk, even whales
puts "walking"
end
def run
# Implementing to conform to LSP, even though only some animals run
raise NotImplementedError
end
end
class Cat < Animal
def run
# Dogs run differently, and Whales, can't run at all
puts "running like a cat"
end
def sneer_majesticly
# Only cats can do this. 
puts "meh"
end
end

方法是否sneer_majesticly违反仅在 Cat 上定义的 LSP,因为此接口在 Animal 上未实现也不需要?

Liskov 替换原理与类无关。这是关于类型的。Ruby 没有类型作为语言特性,因此从语言特性的角度谈论它们并没有真正的意义。

在Ruby(以及一般的OO)中,类型基本上是协议。协议描述对象响应哪些消息,以及如何响应这些消息。例如,Ruby 中一个众所周知的协议是迭代协议,它由单个消息each组成,该消息接受一个块,但没有位置或关键字参数,并且按顺序将 s 元素yield到块。请注意,没有与此协议对应的类或混合。符合此协议的对象无法声明。

有一个混合取决于该协议,Enumerable.同样,由于没有与"协议"概念相对应的 Ruby 结构,因此Enumerable无法声明此依赖关系。它只在文档的介绍性段落中提到(粗体强调我的):

mixinEnumerable为集合类提供了多种遍历和搜索方法,并具有排序功能。该类必须提供一个方法each,该方法产生集合的连续成员。

就是这样。

Ruby 中不存在协议和类型。它们确实存在于 Ruby 文档中、Ruby 社区、Ruby 程序员的头脑中以及 Ruby 代码中的隐式假设中,但它们从未在代码中体现出来。

因此,用 Ruby 类来谈论 LSP 是没有意义的(因为类不是类型),但用 Ruby 类型来谈论 LSP 也没有什么意义(因为没有类型)。您只能根据脑海中的类型来谈论 LSP(因为您的代码中没有任何类型)。

好吧,咆哮过去。但这真的,真的,真的真的很重要。LSP 是关于类型的。类不是类型。有像C++,Java或C♯这样的语言,其中所有类也是自动类型的,但即使在这些语言中,将类型的概念(这是规则和约束的规范)与类的概念(它是对象状态和行为的模板)分开也很重要,如果仅仅是因为除了类之外还有其他东西也是这些语言中的类型(例如,接口Java和C♯以及Java中的原语)。事实上,Java中的interface是Objective-C的protocol的直接移植,而Objective-C又来自Smalltalk社区。

唷。所以,不幸的是,这些都没有回答你的问题:-D

LSP 到底是什么意思?LSP 谈论子类型。更准确地说,它定义了(在它被发明的时候)基于行为可替代性的子类型的新概念。很简单,LSP说:

我可以将 T 类型的对象

替换为S <:T类型的对象,而无需更改程序的所需属性。

例如,"程序不会崩溃"是一个理想的属性,因此我应该不能通过将超类型的对象替换为子类型的对象来使程序崩溃。或者你也可以从另一个方向查看它:如果我可以通过将 T 类型的对象替换为S类型的对象来违反程序的理想属性(例如,使程序崩溃),那么S不是T的子类型

我们可以遵循一些规则来确保我们不会违反 LSP:

方法参数类型
  • 是逆变的,即,如果重写方法,则子类型中的重写方法必须接受与重写方法相同类型或更常规类型的参数。
  • 方法返回类型
  • 是协变的,即子类型中的重写方法必须返回与重写方法相同或更具体的类型。

这两个规则只是函数的标准子类型规则,它们早在 Liskov 之前就已经知道了。

  • 子类型中的方法不得引发任何不仅由超类型中的重写方法引发的新异常,但其类型本身就是重写方法引发的异常的子类型的异常除外。

这三个规则是限制方法签名的静态规则。利斯科夫的关键创新是四条行为规则,特别是第四条规则("历史规则"):

  • 前提条件不能在子类型中加强,即如果用子类型替换对象,则不能对调用者施加额外的限制,因为调用方不知道它们。
  • 后置条件不能在子类型中弱化,即你不能放松超类型所做的保证,因为调用者可能依赖它们。
  • 不变量必须保留,即如果超类型保证某些东西总是为真,那么它在子类型中也必须始终为真。
  • 历史记录
  • 规则:操作子类型的对象不得创建无法从超类型对象观察到的历史记录。(这个有点棘手,它的意思如下:如果我只通过T类型的方法观察 S 类型的对象,我应该无法将对象置于一种状态,即观察者看到T类型的对象无法实现的状态,即使我使用S的方法操作它。

前三个规则在Liskov之前就已经知道了,但它们是以证明理论的方式制定的,没有考虑混叠。规则的行为制定和历史规则的添加使LSP适用于现代OO语言。

这是看待LSP的另一种方式:如果我有一个只知道和关心T的检查员,我递给他一个S型的物品,他能发现它是"假冒的"还是我可以欺骗他?

好的,最后回答您的问题:添加sneer_majesticly方法是否违反了 LSP?答案是:不。添加新方法可能违反 LSP 的唯一方法是,如果此方法以仅使用旧方法不可能发生的方式操作状态。由于sneer_majesticly不会操纵任何状态,因此添加它不可能违反 LSP。记住:我们的检查员只知道Animal,即他只知道walkrun。他不知道也不关心sneer_majesticly.

如果,OTOH,你bite_off_foot添加了一个方法,之后猫不能再走路,那么你就违反了 LSP,因为通过调用bite_off_foot,检查员只能使用他所知道的方法(walkrun)观察动物无法观察到的情况:动物总是可以走路, 但是我们的猫突然不能了!

然而! 理论上run可能违反LSP。请记住:子类型的对象不能更改超类型的所需属性。现在,问题是:Animal的理想性质是什么?问题是你没有提供任何文档Animal,所以我们不知道它的理想属性是什么。我们唯一能看的是代码,它总是raiseNotImplementedError(顺便说一句,它实际上会raiseNameError,因为在 Ruby 核心库中没有名为NotImplementedError的常量)。那么,问题是:例外的raise是否是理想属性的一部分?没有文档,我们无法分辨。

如果Animal定义如下:

class Animal
# …
# Makes the animal run.
#
# @return [void]
# @raise [NotImplementedError] if the animal can't run
def run
raise NotImplementedError
end
end

那么它就不是违反 LSP 的行为。

但是,如果Animal定义如下:

class Animal
# …
# Animals can't run.
#
# @return [never]
# @raise [NotImplementedError] because animals never run
def run
raise NotImplementedError
end
end

那么这将是违反 LSP 的行为。

换句话说:如果run规范是"总是引发异常",那么我们的检查员可以通过调用run并观察它没有引发异常来发现猫。但是,如果run规范是"让动物跑或引起异常",那么我们的检查员无法区分猫和动物。

您会注意到,在这个例子中Cat是否违反了 LSP 实际上完全独立于Cat!事实上,它也完全独立于Animal内部的代码!它取决于文档。这是因为我在一开始就试图澄清:LSP 是关于类型的。Ruby 没有类型,所以类型只存在于程序员的头脑中。或者在此示例中:在文档注释中。

LSP 表示您可以放入基本类型/接口的任何实现,它应该继续工作。因此,没有理由违反这一点,尽管它提出了有趣的问题,即为什么您需要在一个实现而不是其他实现中实现该附加接口。您是否遵循单一责任原则?

最新更新