为何可变成员的covariant subtyping在常规Python类中被允许?
可变集合的Invariance
Python中内置可变集合类型为何是invariant,相关PEP文档和Mypy文档已有充分解释,Mypy文档中还给出了专门说明list为何是invariant的示例:
class Shape: ... class Circle(Shape): # rotate方法仅在Circle中定义,Shape没有该方法 def rotate(self): ... def add_one(things: list[Shape]) -> None: things.append(Shape()) my_circles: list[Circle] = [] add_one(my_circles) # 看似安全,但 my_circles[-1].rotate() # 此处会运行失败,因为最后一个元素是Shape而非Circle
我们可以对列表执行append操作;如果list[Circle]被视为list[Shape]的子类型,传入list[Circle]类型的参数调用add_one看似可行。但该函数会向列表中添加一个Shape实例,导致列表不再是list[Circle]类型,后续调用最后一个元素的rotate方法会失败。
简而言之,如果一个复合¹类型是可变的,使其相对于所含类型成为covariant将不符合类型安全。这部分逻辑很清晰。
可变Protocol成员的Invariance
Mypy文档还通过另一个示例解释了为何可变protocol成员的covariant subtyping被视为不安全:
(为方便演示,稍作修改²,使用dataclasses)
from dataclasses import dataclass from typing import Protocol class P(Protocol): x: float def fun(arg: P) -> None: arg.x = 3.14 @dataclass class C: x: int c = C(42) fun(c) # 这并不安全 c.x << 5 # 因为此处会运行失败!
C看似是P的structural subtype,因为它的x属性是int类型,而int是float的子类型。但Mypy会判定传入C实例调用fun是错误的,因为fun可能(本示例中确实)会修改参数,为x属性赋值float类型的值,后续对x执行位运算会失败。
此处的核心问题依然是类常规成员固有的可变性³,这也合乎逻辑。
常规类中的Covariant Subtyping?
这正是困惑所在:为何同样的逻辑不适用于常规类?来看一个简单示例(使用dataclasses简化代码):
from dataclasses import dataclass @dataclass class Foo: x: float @dataclass class Bar(Foo): x: int def f(obj: Foo) -> None: obj.x = 3.14 bar = Bar(1) f(bar) bar.x << 5 # 运行时会失败
这段代码可以通过mypy --strict检查且无错误,说明此处对x属性的类型应用了covariant子类型化。
理解将f(bar)判定为错误并不合理,因为Bar显然是Foo的nominal subtype⁴。但为何Bar的定义本身可以被允许?
当然,int是float的子类型,这一点毋庸置疑。但我们此处仍在处理复合类型,难道不应该像之前的案例一样考虑variance吗?
类成员默认是可变的。那么常规类难道不应该像protocol一样,相对于其成员被视为invariant吗?
因此原本预期Bar定义中的x: int会触发错误,逻辑与之前一致,但实际并未触发。这是为什么?
实用性与一致性
这类决策(判定类型是covariant/contravariant/invariant)需要考虑实用性。甚至有观点主张在方法参数类型的语境下违背里氏替换原则(将其视为covariant而非contravariant)。部分论点围绕"世界是协变的"展开,例如2003年的相关论文。无论这些观点是否合理,至少应该保持一致性。
但无法理解上述案例中的一致性何在。
脚注:
¹ 此处的"复合"仅指该类型包含其他具有独立类型的组件。
² Mypy文档中的原始代码片段仅在C的定义中将x = 42设为类属性,足以说明问题,但为了更精准,修改为让C.x成为纯实例属性。
³ Mypy文档的该部分还通过建议将x定义为不可设置的property来解决错误,进一步强调了成员x的可变性是问题根源。
⁴ 相比之下,protocol的structural subtype关系仅在调用/赋值时检查,只有当对象被判定为不是变量类型的子类型时才会触发错误。
内容的提问来源于stack exchange,提问作者Daniel Fainberg

