Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

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 是一份历史文档:请参阅 @override@typing.override 以获取最新的规范和文档。规范的类型提示规范在 类型提示规范站点 上维护;CPython 文档中描述了运行时类型提示行为。

×

请参阅 类型提示规范更新流程,了解如何提出对类型提示规范的更改建议。

摘要

此 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.fooChild.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 中实现


来源:https://github.com/python/peps/blob/main/peps/pep-0698.rst

上次修改时间:2024-06-11 22:12:09 GMT