PEP 698 – 静态类型检查的 override 装饰器
- 作者:
- Steven Troxler <steven.troxler at gmail.com>,Joshua Xu <jxu425 at fb.com>,Shannon Zhu <szhu at fb.com>
- 发起人:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 类型标注
- 创建日期:
- 2022年9月5日
- Python 版本:
- 3.12
- 发布历史:
- 2022年5月20日, 2022年8月17日, 2022年10月11日, 2022年11月7日
- 决议:
- Discourse 消息
摘要
本 PEP 建议在 Python 类型系统中添加一个 @override 装饰器。这将允许类型检查器防止当基类更改派生类继承的方法时发生的一类错误。
动机
类型检查器的一个主要目的是在重构或更改破坏代码中预先存在的语义结构时进行标记,以便用户可以在整个项目中识别并进行修复,而无需手动审计代码。
安全重构
Python 的类型系统没有提供一种方法来识别当被重写的函数 API 更改时需要更改的调用站点。这使得重构和转换代码更加危险。
考虑这个简单的继承结构
class Parent:
def foo(self, x: int) -> int:
return x
class Child(Parent):
def foo(self, x: int) -> int:
return x + 1
def parent_callsite(parent: Parent) -> None:
parent.foo(1)
def child_callsite(child: Child) -> None:
child.foo(1)
如果超类上被重写的方法被重命名或删除,类型检查器只会提醒我们更新直接处理基类型的调用站点。但是类型检查器只能看到新代码,而看不到我们所做的更改,因此它无法知道我们可能还需要重命名子类上的相同方法。
类型检查器会欣然接受这段代码,尽管我们很可能引入错误
class Parent:
# Rename this method
def new_foo(self, x: int) -> int:
return x
class Child(Parent):
# This (unchanged) method used to override `foo` but is unrelated to `new_foo`
def foo(self, x: int) -> int:
return x + 1
def parent_callsite(parent: Parent) -> None:
# If we pass a Child instance we’ll now run Parent.new_foo - likely a bug
parent.new_foo(1)
def child_callsite(child: Child) -> None:
# We probably wanted to invoke new_foo here. Instead, we forked the method
child.foo(1)
这段代码将通过类型检查,但存在两个潜在的错误来源
- 如果我们将
Child实例传递给parent_callsite函数,它将调用Parent.new_foo中的实现,而不是Child.foo。这可能是一个错误——如果不需要自定义行为,我们最初大概就不会编写Child.foo。 - 我们的系统可能依赖于
Child.foo的行为方式与Parent.foo类似。但除非我们及早发现这一点,否则我们现在已经分叉了这些方法,并且在未来的重构中,很可能没有人会意识到new_foo行为的重大更改可能也需要更新Child.foo,这可能导致以后出现重大错误。
错误重构的代码是类型安全的,但可能不是我们想要的,并且可能导致我们的系统行为不正确。这个错误可能难以追踪,因为我们的新代码很可能在不抛出异常的情况下执行。测试不太可能发现问题,而无声错误可能需要更长时间才能在生产环境中追踪到。
我们知道多个类型化代码库中发生的几起生产中断事故是由此类不正确重构引起的。这是我们向类型系统添加 @override 装饰器的主要动机,它允许开发人员表达 Parent.foo 和 Child.foo 之间的关系,以便类型检查器可以检测问题。
基本原理
子类实现变得更加明确
我们相信,显式重写将使不熟悉的代码比隐式重写更容易阅读。开发人员阅读使用 @override 的子类实现时,可以立即看到哪些方法正在重写某个基类中的功能;如果没有这个装饰器,快速发现的唯一方法是使用静态分析工具。
其他语言和运行时库中的先例
其他语言中的静态重写检查
许多流行的编程语言支持重写检查。例如:
Python 中的运行时重写检查
目前,有一个 Overrides 库 提供了装饰器 @overrides [原文如此] 和 @final,并将在运行时强制执行它们。
PEP 591 添加了一个 @final 装饰器,其语义与 Overrides 库中的相同。但是运行时库的重写组件完全不支持静态检查,这给混合/匹配支持带来了一些困惑。
在静态检查中提供对 @override 的支持将增加价值,因为
- 错误可以更早地被捕获,通常在编辑器中。
- 静态检查没有性能开销,这与运行时检查不同。
- 即使在很少使用的模块中,错误也会很快被发现,而使用运行时检查,这些错误可能在没有所有导入的自动化测试的情况下 undetected 一段时间。
缺点
使用 @override 将使代码更加冗长。
规范
当类型检查器遇到用 @typing.override 装饰的方法时,除非该方法正在重写某个祖先类中兼容的方法或属性,否则它们应该将其视为类型错误。
from typing import override
class Parent:
def foo(self) -> int:
return 1
def bar(self, x: str) -> str:
return x
class Child(Parent):
@override
def foo(self) -> int:
return 2
@override
def baz(self) -> int: # Type check error: no matching signature in ancestor
return 1
@override 装饰器应该允许在类型检查器认为方法是有效重写的任何地方使用,这通常不仅包括普通方法,还包括 @property、@staticmethod 和 @classmethod。
重写兼容性没有新规则
本 PEP 专门关注新的 @override 装饰器的处理,它指定被装饰的方法必须重写祖先类中的某个属性。本 PEP 没有提出关于此类方法类型签名的任何新规则。
每个项目严格执行
我们相信,如果检查器还允许开发人员选择一种严格模式,即要求重写父类的方法使用装饰器,那么 @override 将最有用。为了向后兼容,严格执行应是可选的。
动机
要求 @override 的严格模式的主要原因是,只有当开发人员知道在整个项目中都使用了 @override 装饰器时,他们才能相信重构是重写安全的。
还有另一类与重写相关的错误,我们只能在严格模式下捕获。
考虑以下代码
class Parent:
pass
class Child(Parent):
def foo(self) -> int:
return 2
想象一下我们将其重构如下
class Parent:
def foo(self) -> int: # This method is new
return 1
class Child(Parent):
def foo(self) -> int: # This is now an override!
return 2
def call_foo(parent: Parent) -> int:
return parent.foo() # This could invoke Child.foo, which may be surprising.
我们代码的语义在这里发生了变化,这可能导致两个问题
- 如果代码更改的作者不知道
Child.foo已经存在(这在大型代码库中很可能发生),他们可能会惊讶地发现call_foo并不总是调用Parent.foo。 - 如果代码库作者试图在子类中编写重写时手动在所有地方应用
@override,他们很可能会忽略Child.foo在这里需要它的事实。
乍一看,这种变化似乎不太可能,但如果一个或多个子类具有开发人员后来意识到属于基类的功能,它实际上经常发生。
在严格模式下,每当这种情况发生时,我们都会提醒开发人员。
先例
我们研究过的大多数类型化的面向对象编程语言都有一个简单的方法来要求整个项目中的显式重写
- C#、Kotlin、Scala 和 Swift 总是要求显式重写
- TypeScript 有一个 --no-implicit-override 标志来强制显式重写
- 在 Hack 和 Java 中,类型检查器总是将重写视为可选的,但广泛使用的 linter 可以警告缺少显式重写的情况。
向后兼容性
默认情况下,@override 装饰器将是可选的。不使用它的代码库将像以前一样进行类型检查,而不会增加额外的类型安全。
运行时行为
在可能的情况下设置 __override__ = True
在运行时,@typing.override 将尽力尝试向其参数添加一个值为 True 的属性 __override__。这里的“尽力”是指我们将尝试添加属性,但如果失败(例如因为输入是具有固定槽的描述符类型),我们将静默返回原参数。
这与 @typing.final 装饰器所做的完全相同,动机也类似:它赋予运行时库使用 @override 的能力。作为一个具体示例,运行时库可以检查 __override__ 以便使用父方法文档字符串自动填充子类方法的 __doc__ 属性。
设置 __override__ 的限制
如上所述,添加 __override__ 在运行时可能会失败,在这种情况下,我们将简单地按原样返回参数。
此外,即使在有效的情况下,用户也可能难以正确处理多个装饰器,因为成功确保 __override__ 属性设置在最终输出上需要理解每个装饰器的实现
@override装饰器需要 在 使用包装器函数的普通装饰器(如@functools.lru_cache)之后 执行,因为我们希望在最外层包装器上设置__override__。这意味着它需要 位于 所有这些其他装饰器之上。- 但是
@override需要 在 许多基于描述符的特殊装饰器(如@property、@staticmethod和@classmethod)之前 执行。 - 如上所述,在某些情况下(例如具有固定槽的描述符或也进行包装的描述符),可能根本无法设置
__override__属性。
因此,设置 __override__ 的运行时支持仅是尽力而为,我们不期望类型检查器验证装饰器的顺序。
被拒绝的替代方案
依靠集成开发环境(IDE)确保安全
现代集成开发环境(IDE)通常提供在重命名方法时自动更新子类的功能。但我们认为这不足以解决几个原因
- 如果代码库被拆分为多个项目,IDE 将无济于事,并且在升级依赖项时会出现错误。类型检查器是快速捕获依赖项中破坏性更改的方法。
- 并非所有开发人员都使用此类 IDE。即使库维护者使用 IDE,也不应假定拉取请求作者使用相同的 IDE。我们更喜欢能够在持续集成中检测问题,而不对开发人员的编辑器选择做任何假设。
运行时强制
我们曾考虑让 @typing.override 在运行时强制执行重写安全,类似于 @overrides.overrides 今天 所做的那样。
我们拒绝了这一点,原因有四
- 对于静态类型检查的用户来说,这是否能带来任何好处尚不清楚。
- 将至少存在一些性能开销,导致项目在运行时强制执行时导入速度变慢。我们估计
@overrides.overrides的实现大约需要 100 微秒,这很快,但在百万行以上的代码库中仍然可能增加一秒或更多的额外初始化时间,而这正是我们认为@typing.override最有用的地方。 - 实现可能存在一些边缘情况,使其无法很好地工作(我们从一个此类闭源库的维护者那里听说这是一个问题)。我们期望静态强制执行是简单可靠的。
- 我们所知的实现方法并不简单。装饰器在类完成评估之前执行,因此我们所知的选项要么是检查调用者的字节码(如
@overrides.overrides所做),要么是使用基于元类的方法。这两种方法似乎都不是理想的。
标记基类以强制子类进行显式重写
我们曾考虑包含一个类装饰器 @require_explicit_overrides,它将提供一种方式让基类声明所有子类在方法重写时必须使用 @override 装饰器。Overrides 库 有一个混合类 EnforceExplicitOverrides,它在运行时检查中提供类似的行为。
我们反对这样做,因为我们预计大型代码库的所有者将从 @override 中获益最多,而对于这些用例,强制要求显式 @override 的严格模式(参见“向后兼容性”部分)比标记基类的方法提供更多好处。
此外,我们认为,那些认为额外的类型安全不值得使用 @override 带来的额外样板的项目的作者不应被迫这样做。拥有一个可选的严格模式将决定权交到项目所有者手中,而库中 @require_explicit_overrides 的使用将迫使项目所有者使用 @override,即使他们更喜欢不使用。
包含被重写的祖先类的名称
我们曾考虑允许 @override 的调用者指定一个特定的祖先类,其中应定义被重写的方法
class Parent0:
def foo(self) -> int:
return 1
class Parent1:
def bar(self) -> int:
return 1
class Child(Parent0, Parent1):
@override(Parent0) # okay, Parent0 defines foo
def foo(self) -> int:
return 2
@override(Parent0) # type error, Parent0 does not define bar
def bar(self) -> int:
return 2
这对于代码可读性可能很有用,因为它使得深层继承树的重写结构更加明确。它还可能通过促使开发人员检查重写实现是否仍然有意义来捕获错误,每当被重写的方法从一个基类移动到另一个基类时。
我们反对这样做,因为
- 支持这一点将增加
@override及其类型检查器支持的实现复杂性,因此需要相当大的好处。 - 我们认为它很少被使用,并且捕获的错误相对较少。
- Overrides 包 的作者 指出,他的库的早期版本包含此功能,但它很少有用,似乎没有什么好处。在它被移除后,用户从未要求过此功能。
参考实现
Pyre:Pyre 中实现了一个概念验证
- 装饰器 @pyre_extensions.override 可以标记重写
- Pyre 可以 按照此 PEP 的规定对该装饰器进行类型检查
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0698.rst