得到了这个非常简单的继承案例。
我阅读了一堆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__
可以用other
Parent
的任何实例来调用,我们假设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
作为other
给Child.__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)
顺便说一下,将返回类型限制为子类型是没有问题的。由于这篇文章太过冗长,我将把它作为一个练习,让读者推断为什么会这样。