再谈一次Lisk替换的原理,或者OOP中的继承语义

继承是OOP的支柱之一。 继承用于重用通用代码。 但是,通用代码并非始终需要重用,而继承也不总是重用代码的最佳方法。 事实证明,在两个不同的代码段(类)中有一个相似的代码,但是对它们的要求是不同的,即 类实际上是彼此继承的,可能不值得。

通常,为了说明此问题,他们使用了一个有关从Rectangle类继承Square类的示例,反之亦然。

让我们有一个矩形类:

class Rectangle: def __init__(self, width, height): self._width = width self._height = height def set_width(self, width): self._width = width def set_height(self, height): self._height = height def get_area(self): return self._width * self._height ... 

现在我们想编写Square类,但是为了重用面积计算代码,从Rectangle继承Square似乎是合乎逻辑的:

 class Square(Rectangle): def set_width(self, width): self._width = width self._height = width def set_height(self, height): self._width = height self._height = height 

看起来Square和Rectangle类的代码是一致的。 似乎Square保留了正方形的数学属性,即 和一个矩形。 这意味着我们可以传递Square对象而不是Rectangle。

但是,如果这样做,则会违反Rectangle类的行为

例如,有一个客户端代码:

 def client_code(rect): rect.set_height(10) rect.set_width(20) assert rect.get_area() == 200 

如果将Square类的实例作为该函数的参数传递,则该函数的行为将有所不同。 这违反了Rectangle类的行为的约定,因为使用基类的对象进行的操作应产生与后代类的对象完全相同的结果。

如果正方形类是矩形类的后代,则使用正方形并执行矩形的方法,我们甚至都不会注意到它不是矩形。

您可以解决此问题,例如,如下所示:

  1. 做出一个与该类完全匹配的断言,或者对不同的类做出不同的判断
  2. 在Square中,设置set_size()方法并覆盖set_height,set_width方法,以便它们引发异常

从某种意义上说,这样的代码和这样的类将起作用。

另一个问题是,使用Square类或Rectangle类的客户端代码将需要了解基类及其行为,或后代及其行为。

随着时间的流逝,我们可以获得:

  • 后代类将覆盖大多数方法
  • 重构或向基类添加方法将使用后代破坏代码
  • 在使用基类对象的代码中,将存在ifs,检查对象的类,并且后代和基类的行为不同

事实证明,为基类编写的客户端代码变得依赖于基类和后代类的实现。 随着时间的推移,这极大地使开发复杂化。 OOP的创建只是为了使您可以彼此独立地编辑基类和子代类。

早在上世纪80年代,我们注意到为了使类继承能够很好地用于代码重用,我们必须确定要知道可以使用后代类而不是基类。 即 继承语义-这不仅应该是行为,而且应该不是数据。 继承人不应“破坏”基类的行为。

实际上,这是Lisk替换的原则,或者是基于强行为分类的行为来确定子类型的原则: 如果您可以编写至少一些有意义的代码,其中用后代类对象替换基类对象,那么它将破坏,那么这是不值得的彼此继承。 我们应该在后代中扩展基类的行为,而不应对其进行重大更改。 使用基类的函数应该能够在不知道子类的情况下使用子类对象。 实际上,这就是OOP中继承的语义。

在实际的工业代码中,强烈建议遵循并遵循所描述的继承语义。 有了这个原则,有几个微妙之处。

该原则不应满足于域级别的抽象,而应满足于代码抽象-类。 从几何角度看,正方形是矩形。 从类继承层次结构的角度来看,正方形的类是否将成为矩形类的继承人,取决于我们对这些类的要求。 取决于我们如何以及在什么情况下使用此代码。

如果Rectangle类只有两种方法-计算面积和渲染,而没有重新绘制和调整大小的可能性,则在这种情况下,具有重写构造函数的Square将满足Lisky替换原则。

即 这些类满足替换原则:

 class Rectangle: def draw(): ... def get_area(): ... class Square(Rectangle): pass 

尽管这当然不是很好的代码,甚至可能不是类设计的反模式,但是从形式上来看,它满足了Liskov原理。

另一个例子 。 集是多集的子类型。 这是领域抽象的比率。 但是可以编写代码,以便我们从Bag继承Set类,并且违反替换原则,或者可以编写代码,以遵守该原则。 具有相同的主题域语义。

通常,类的继承可以视为关系“ IS”的实现,而不是主题区域的实体之间,而是类之间。 后代类是否是基类的子类型,取决于客户端代码使用(原则上可以使用)哪些限制和类行为契约。

约束,不变式和基类契约在代码中不是固定的,而是在编辑和阅读代码的开发人员的头脑中固定的。 什么是“破坏”,什么是破坏“合同”,不是由代码决定的,而是由开发人员负责人的类的语义确定的。

如果我们将其替换为后代类的对象,则对于基类的对象有意义的任何代码都不应中断。 有意义的代码是在基类的语义和限制的框架内使用基类的对象(及其子代)的任何客户端代码。

要理解的极其重要的一点是,在基类中实现的抽象限制通常不包含在程序代码中。 这些限制由开发人员理解,了解和支持。 它监视抽象和代码的一致性。 让代码表达其含义。

例如,矩形有另一个方法可返回json中的视图

 class Rectangle: def to_dict(self): return {"height": self.height, "width": self.width} 

在Square中,我们重新定义它:

 class Square: def to_dict(self): return {"size": self.height} 

如果我们认为Rectangle类to_json的行为的基本约定具有高度和宽度,则代码

 r = rect.to_dict() log(r['height'], r['width']) 

对于基类Rectangle的对象将是有意义的。 用类替换基类的对象时,Square继承代码会更改其行为并违反合同,从而违反Lisk替换的原理。

如果我们认为Rectangle类的行为的基本约定是to_dict返回可以在不依赖特定字段的情况下进行序列化的字典,那么这种to_dict方法将是可以的。

顺便说一句,这是一个很好的例子,它打破了不变性免于违反原理而保存的神话。

形式上,任何后代类中方法的重载以及对基类中逻辑的更改都是危险的。 例如,后代类经常适应于基类的“不正确”行为,并且在基类中修复错误后,它们便会中断。

可以将合同的所有条件和不变量尽可能多地转移到代码中,但是在一般情况下,行为的语义都位于代码外部-在问题区域中,并得到开发人员的支持。 关于to_dict的示例是可以在代码中描述合同的示例,但是例如,要验证get_hash方法确实返回具有哈希的所有属性(而不仅仅是行)的哈希是不可能的。

当开发人员使用其他开发人员编写的代码时,他可以直接通过代码,方法名称,文档和注释来了解类的语义。 但是无论如何,语义通常是人的领域,因此是错误的。 最重要的结果是:仅通过语法上的代码,就不可能验证是否符合Liskov原则,并且您需要依赖(通常)模糊的语义。 没有正式的(数学的)手段来验证强行为类型的可验证且有保证的方式。

因此,通常使用合同编程中前提条件和后置条件的形式规则来代替Liskov原理:

  • 子类中的先决条件不能得到加强-子类的要求不应超过基类的要求
  • 子类的后置条件不能放宽-子类的提供(承诺)不应少于基类
  • 基类的不变量必须保留在后代类中。

例如,在后代类方法中,我们不能添加不在基类中的必需参数-因为这是我们加强前提条件的方式。 否则我们无法在重写的方法中引发异常,因为 违反基类的不变式。 等等

重要的不是该类的当前行为,而是什么类更改意味着该类的责任或语义。

该代码会不断纠正和更改。 因此,如果代码现在满足替换原理,这并不意味着代码中的更改不会改变它。

假设有一个Rectangle库类的开发人员,以及一个从Rectangle继承Square的应用程序开发人员。 当应用程序开发人员从Rectangle继承Square时,一切都很好,这些类满足了替换原则。

在某个时候,负责该库的开发人员向Rectangle基类添加了reshape或set_width / set_height方法。 从他的角度来看,基类的扩展刚刚发生。 但是实际上,后代类所依赖的语义和契约发生了变化。 现在,类不再满足该原理。

通常,在OOP中继承时,基类中的更改看起来像接口的扩展—将添加其他方法或字段可能会违反先前的“自然”合同,从而实际上更改了语义或职责。 因此,向基类添加任何方法都是危险的。 您可能会无意间更改合同。

从实际的角度来看,在具有矩形和类的示例中,重要的是现在是否存在reshape或set_width / set_height方法。 从实际的角度来看,库代码中此类更改的可能性有多大很重要。 阶级责任的语义或界限是否暗示了这种变化。 如果隐含了,则大大增加了错误和/或进一步需要重构的可能性。 而且,即使可能性很小,也最好不要相互继承此类。

即使对于具有清晰语义的简单类,也很难基于行为来维护子类型定义 ,更不用说具有复杂业务逻辑的企业了。 尽管基类和继承类是不同的代码,但是对于它们,您仍需要仔细仔细地考虑一下接口和职责。 即使类的语义发生了微小的变化-这也无法以任何方式避免,我们也必须查看相关类的代码,检查新协定或不变式是否违反了已编写(!)和已使用的内容。 由于分支类层次结构中的几乎所有更改,我们都需要查看并检查许多其他代码。

这就是为什么有些人不真正喜欢OOP中的经典继承的原因之一。 因此,他们通常更喜欢类的组成,接口的继承等,等等。 而不是行为的经典继承。

公平地说,有些规则最有可能不违反替代原则。 如果禁止所有危险结构,则可以尽可能保护自己。 例如,对于C ++, Oleg撰写了有关此内容的文章。 但是总的来说,这样的规则不会将类转变为经典意义上的类。

使用管理方法,任务也不能很好地解决。 在这里,您可以阅读Martin叔叔在C ++中的工作方式以及它是如何工作的。

但是在实际的工业法规中,经常会违反Liskov原理,这并不可怕 。 很难遵循该原则,因为 1)类的职责和语义通常不是明确的,也没有在代码中表达出来; 2)类的职责可以更改-在基类和后代类中都可以。 但是,这并不总是会导致某些真正可怕的后果。 最常见,最简单和最基本的违反是重写方法修改行为。 例如在这里:

 class Task: def close(self): self.status = CLOSED ... class ProjectTask(Task): def close(self): if status == STARTED: raise Exception("Cannot close a started Project Task") ... 

在Task类的对象正常工作的情况下,ProjectTask的close方法将引发异常。 通常,重新定义基类的方法通常会导致违反替换原则,但不会成为问题。

实际上,在这种情况下,开发人员不会将继承视为“ IS”关系的实现,而只是将其视为重用代码的一种方式。 即 子类只是子类,而不是子类型。 在这种情况下,从务实和实用的角度来看,这更重要-但是,将存在或已经存在的客户端代码注意到后代类和基类方法的不同语义的可能性是什么?

是否有很多代码期望基类的对象,但是我们将后代类的对象传递给该代码呢? 对于许多任务,此类代码根本不会存在。

何时违反LSP会导致大问题? 当由于行为上的差异而必须在后代类中进行更改时,必须重写客户端代码,反之亦然。 如果此客户端代码是无法更改的库代码,则这尤其成为问题。 如果将来重用该代码将无法在客户端代码和类代码之间建立依赖关系,那么即使违反了Liskov替换原则,此类代码也不会造成大问题。

通常,在开发过程中,可以从两个角度考虑继承:子类是具有合同编程和Lisk原则所有局限性的子类型,子类是一种可重用代码的方法,但存在所有潜在的问题。 即 您可以考虑并设计类的职责和合同,而不必担心客户代码。 要么考虑可能是什么客户端代码,将如何使用类,并为潜在的问题做准备,但是在较小程度上关心遵守替代原则。 像往常一样,该决定取决于开发人员,最重要的是,在特定情况下的选择是有意识的,并且了解该解决方案伴随着哪些利弊。

Source: https://habr.com/ru/post/zh-CN463385/


All Articles