我的和基本继承



得到了这个非常简单的继承案例。

我阅读了一堆mypy文档,但仍然无法弄清楚如何正确处理这些基本情况。

对我来说,这是非常标准的 OOP 继承,所以我无法想象mypy没有一种干净的方式来处理这些情况。

from __future__ import annotations
from dataclasses import dataclass

@dataclass
class Parent:
a: int = 0
def __add__(self, other: Parent) -> Parent:
a = self.a + other.a
return self.__class__(a)

@dataclass
class Child(Parent):
b: int = 0
def __add__(self, other: Child) -> Child:
a = self.a + other.a
b = self.b + other.b
return self.__class__(a, b)

obj1 = Child(1)
obj2 = Child(1, 42)
print(obj1 + obj2)

mypy错误消息:

foo.py:18: error: Argument 1 of "__add__" is incompatible with supertype "Parent"; supertype defines the argument type as "Parent"
foo.py:18: note: This violates the Liskov substitution principle
foo.py:18: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides

版本:

$ python --version
Python 3.10.4
$ mypy --version
mypy 0.971 (compiled: yes)

从您的后续评论来看,我假设您实际上对一种为mypy和其他类型检查器提供具有正确类型推断的广义__add__方法的方法感兴趣。泛型一如既往地节省了一天。

这是一个工作示例:

from __future__ import annotations
from dataclasses import dataclass, fields
from typing import TypeVar
T = TypeVar("T", bound="Parent")
@dataclass
class Parent:
a: int = 0
def __add__(self: T, other: T) -> T:
return self.__class__(**{
field.name: getattr(self, field.name) + getattr(other, field.name)
for field in fields(self.__class__)
})
class Child(Parent):
b: int = 0
if __name__ == '__main__':
c1 = Child(a=1, b=2)
c2 = Child(a=2, b=3)
c3 = c1 + c2
print(c3)
reveal_type(c3)  # this line is for mypy

输出当然是Child(a=3, b=5)mypy状态:

note: Revealed type is "[...].Child"

显然,一旦Parent的任何子类引入了具有不可添加类型的字段,这将中断。


至于子类型问题,mypy实际上告诉你一切。您违反了 LSP。这不是特定于mypy甚至Python的。正如@Wombatz所说,这就是类型(和子类型)必须工作才能被认为是合理的。

如果你有一个T类型的子类型,它的接口必须是T接口的超集,而不是严格子集。

附言

让我尝试用一个稍微改变的例子来扩展 LSP 问题。

假设我有以下父类和子类:

from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Parent:
n: float
def __add__(self, other: Parent) -> Parent:
return Parent(self.n + other.n)
class Child(Parent):
def round_n(self) -> float:
return round(self.n, 0)

现在我编写一个简单的函数,它接受两个Parent实例,将它们相加并打印结果:

def foo(obj1: Parent, obj2: Parent) -> None:
print(obj1 + obj2)

目前为止,一切都好。这里没有问题。

foo的签名要求两个参数都是Parent的实例,这意味着它们也可以是Parent的任何子类的实例。这意味着它们也可以是Child的实例。

假设foo是内部类型安全的,则以下每个调用都是完全安全的:

parent = Parent(1.0)
child = Child(0.2)
foo(parent, parent)
foo(parent, child)
foo(child, parent)
foo(child, child)

输出显然是Parent(n=2.0)Parent(n=1.2)Parent(n=1.2)Parent(n=0.4)

但是,如果我现在决定重写Child类的__add__方法,将其限制为仅接受其他Child实例,会发生什么情况?假设我是这样写的:

class Child(Parent):
def __add__(self, other: Child) -> Child:
return Child(self.round_n() + other.round_n())
def round_n(self) -> float:
return round(self.n, 0)

暂时忽略继承,这似乎是完全合理且类型安全的。我们用Child注释了__add__other参数,它告诉类型检查器只能将Child的实例添加到Child的实例中。这意味着我们可以安全地在other上调用round_n方法,因为所有Child实例都有该方法。

值得注意的是,Parent实例没有round_n方法。但这很好,因为我们以排除Parent实例的方式注释other

但是我们现在的foo功能呢?

请记住,它允许两个参数都Parent实例,这也意味着Child实例。LSP背后的整个想法在这里发挥作用。我们不想关心Parent的某些子类可能具有的细节。我们假设无论子类做什么,都不会破坏它继承的Parent接口。

具体来说,我们假设虽然子类可以覆盖__add__方法,但这些更改不会限制我们可以调用它的方式。由于Parent.__add__可以用otherParent的任何实例来调用,我们假设Child.__add__也可以用任何Parent实例来调用。

不然怎么可能?Parent可以有无限多个子类,并且不可能期望foo验证它们的每个接口是否仍然与Parent的接口兼容。这就是作为亚型应该保证的。这就是利斯科夫替换原则:

如果foo是正确的并且它接受类型Parent的参数,则替换子类型Child的参数一定不会影响foo的正确性。

我们已经确定foo是正确的。但是,如果现在尝试与以前相同的调用(使用更改后的Child类),其中一个将失败:

foo(child, parent)

它失败(对我们来说是可以预见的),并带有以下回溯:

Traceback (most recent call last):
File "[...].py", line x, in <module>
foo(child, parent)
File "[...].py", line y, in foo
print(obj1 + obj2)
File "[...].py", line z, in __add__
return Child(self.round_n() + other.round_n())
AttributeError: 'Parent' object has no attribute 'round_n'

我们正在传递一个Parent作为otherChild.__add__,这是行不通的。

我希望这能更好地说明这个问题。

<小时 />

现在怎么办?

除了链接帖子中的建议外,最肮脏的解决方案如下:

class Child(Parent):
def __add__(self, other: Parent) -> Child:
if not isinstance(other, Child):
raise RuntimeError
return Child(self.round_n() + other.round_n())
def round_n(self) -> float:
return round(self.n, 0)

这在技术上是正确的,只是对任何用户都不是很好。用户可能会期望他实际上可以使用Child.__add__方法,other是一个Parent,所以在实践中你可能会实现一些逻辑来返回一些合理的内容,如下所示:

class Child(Parent):
def __add__(self, other: Parent) -> Child:
if not isinstance(other, Child):
return Child(self.round_n() + other.n)
return Child(self.round_n() + other.round_n())
def round_n(self) -> float:
return round(self.n, 0)

顺便说一下,将返回类型限制为子类型是没有问题的。由于这篇文章太过冗长,我将把它作为一个练习,让读者推断为什么会这样。

最新更新