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
[sic] 和 @final
,并在运行时强制执行它们。
PEP 591 添加了一个 @final
装饰器,其语义与 Overrides 库中的相同。但是,运行时库的覆盖组件根本不受静态支持,这在混合/匹配支持方面引起了一些混淆。
在静态检查中提供对 @override
的支持将增加价值,因为
- 错误可以在早期被捕获,通常是在编辑器中。
- 与运行时检查不同,静态检查没有性能开销。
- 即使在很少使用的模块中,错误也会很快被捕获,而对于运行时检查,如果没有所有导入的自动化测试,这些错误可能会在一段时间内未被检测到。
缺点
使用 @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
将尽最大努力尝试向其参数添加一个名为 __override__
的属性,其值为 True
。 所谓“尽最大努力”,是指我们将尝试添加该属性,但如果失败(例如,因为输入是具有固定槽的描述符类型),我们将静默地按原样返回参数。
这正是 @typing.final
装饰器所做的,其动机也类似:它使运行时库能够使用 @override
。 作为一个具体的例子,运行时库可以检查 __override__
以便使用父方法文档字符串自动填充子类方法的 __doc__
属性。
设置 __override__
的限制
如上所述,在运行时添加 __override__
可能会失败,在这种情况下,我们将简单地按原样返回参数。
此外,即使在它确实起作用的情况下,用户也很难正确地使用多个装饰器,因为要成功地确保在最终输出上设置了 __override__
属性需要了解每个装饰器的实现。
@override
装饰器需要在使用包装器函数的普通装饰器(如@functools.lru_cache
)之后执行,因为我们希望在最外层的包装器上设置__override__
。 这意味着它需要放在所有这些其他装饰器的上面。- 但是
@override
需要在许多特殊的基于描述符的装饰器(如@property
、@staticmethod
和@classmethod
)之前执行。 - 如上所述,在某些情况下(例如具有固定槽的描述符或也进行包装的描述符),可能根本无法设置
__override__
属性。
因此,设置 __override__
的运行时支持仅为尽力而为,我们不希望类型检查器验证装饰器的顺序。
被拒绝的替代方案
依靠集成开发环境来保证安全
现代集成开发环境 (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
上次修改时间:2024-06-11 22:12:09 GMT