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

Python 增强提案

PEP 563 – 注解的推迟求值

作者:
Łukasz Langa <lukasz at python.org>
讨论至:
Python-Dev 列表
状态:
已取代
类型:
标准跟踪
主题:
类型标注
创建日期:
2017年9月8日
Python 版本:
3.7
发布历史:
2017年11月1日,2017年11月21日
取代者:
649, 749
决议:
Python-Dev 消息

目录

决议

本 PEP 中提出的功能从未成为默认行为,已被注解的延迟求值所取代,正如 PEP 649PEP 749 所提出的。

摘要

PEP 3107 引入了函数注解的语法,但语义被故意留白。 PEP 484 引入了注解的标准含义:类型提示。PEP 526 定义了变量注解,并明确将其与类型提示用例绑定。

本 PEP 提议更改函数注解和变量注解,使它们不再在函数定义时求值。相反,它们以字符串形式保留在 __annotations__ 中。

此更改将逐步引入,从 Python 3.7 中的 __future__ 导入开始。

基本原理和目标

PEP 3107 增加了对函数定义部分任意注解的支持。就像默认值一样,注解在函数定义时求值。这给类型提示用例带来了一些问题:

  • 前向引用:当类型提示包含尚未定义的名称时,该定义需要以字符串字面量形式表示;
  • 类型提示在模块导入时执行,这不是没有计算成本的。

推迟注解的求值解决了这两个问题。注意:PEP 649 提出了解决上述问题的替代方案,使本 PEP 面临被取代的风险。

非目标

就像在 PEP 484PEP 526 中一样,应该强调的是 Python 将仍然是一种动态类型语言,作者无意强制使用类型提示,即使是约定俗成也不行。

本 PEP 旨在解决类型注解中的前向引用问题。在注解之外仍然存在一些情况,前向引用将需要使用字符串字面量。这些将在本文档的后续部分列出。

无需强制求值的注解为改进类型提示语法提供了机会。这个想法将需要单独的 PEP,并且在本文档中不再讨论。

注解的非类型化用法

尽管注解仍可用于类型检查之外的任意用途,但值得一提的是,本 PEP 的设计及其前身(PEP 484PEP 526)主要由类型提示用例驱动。

在 Python 3.8 中,PEP 484 将从试用状态毕业。其他对 Python 编程语言的增强,如 PEP 544PEP 557PEP 560,已经在此基础上构建,因为它们依赖于 PEP 484 定义的类型注解和 typing 模块。事实上,PEP 484 在 Python 3.7 中保持试用状态的原因是为了能够为上述某些增强功能所需的另一个发布周期实现快速演进。

考虑到这一点,与上述 PEP 不兼容的注解用法应被视为已弃用。

实施

根据本 PEP,函数和变量注解将不再在定义时求值。相反,字符串形式将保留在各自的 __annotations__ 字典中。静态类型检查器将看不到行为上的差异,而那些在运行时使用注解的工具将不得不执行推迟求值。

字符串形式是在编译步骤中从 AST 中获得的,这意味着字符串形式可能不会保留源代码的确切格式。注意:如果注解已经是字符串字面量,它仍将被包装在字符串中。

注解需要是语法上有效的 Python 表达式,即使作为字符串字面量传递也是如此(即 compile(literal, '', 'eval'))。注解只能使用模块作用域中存在的名称,因为使用局部名称进行推迟求值是不可靠的(唯一的例外是 typing.get_type_hints() 解析的类级别名称)。

请注意,根据 PEP 526,局部变量注解根本不求值,因为它们在函数闭包之外无法访问。

在 Python 3.7 中启用未来行为

从 Python 3.7 开始,可以使用以下特殊导入来启用上述功能:

from __future__ import annotations

此功能的参考实现可在 GitHub 上获取。

运行时解析类型提示

要将运行时注解从其字符串形式解析为封装表达式的结果,用户代码需要对字符串求值。

对于使用类型提示的代码,typing.get_type_hints(obj, globalns=None, localns=None) 函数能够正确地从其字符串形式求值表达式。请注意,所有当前使用 __annotations__ 的有效代码都应该已经在这样做,因为类型注解可以表示为字符串字面量。

对于将注解用于其他目的的代码,一个常规的 eval(ann, globals, locals) 调用足以解析注解。

在这两种情况下,重要的是要考虑全局变量和局部变量如何影响推迟求值。注解不再在定义时求值,更重要的是,不再在 同一作用域 中求值。因此,通常不再可能在注解中使用局部状态。至于全局变量,定义注解的模块是推迟求值的正确上下文。

get_type_hints() 函数自动为函数和类解析 globalns 的正确值。它还自动为类提供正确的 localns

运行 eval() 时,全局变量的值可以通过以下方式收集:

  • 函数对象通过一个名为 __globals__ 的属性引用其各自的全局变量;
  • 类保存它们定义的模块名称,这可以用于检索相应的全局变量
    cls_globals = vars(sys.modules[SomeClass.__module__])
    

    请注意,需要对基类重复此操作才能求值所有 __annotations__

  • 模块应该使用它们自己的 __dict__

无法可靠地为函数检索 localns 的值,因为在调用时堆栈帧很可能不再存在。

对于类,可以通过连接给定类及其基类(按照方法解析顺序)的变量来组成 localns。由于槽只能在定义类之后填充,因此我们不需要为此目的查阅它们。

运行时注解解析与类装饰器

需要解析当前类的注解的元类和类装饰器将无法处理使用当前类名称的注解。例如:

def class_decorator(cls):
    annotations = get_type_hints(cls)  # raises NameError on 'C'
    print(f'Annotations for {cls}: {annotations}')
    return cls

@class_decorator
class C:
    singleton: 'C' = None

这在 PEP 之前就已经如此。类装饰器在类在当前定义作用域中被分配名称之前对类进行操作。

运行时注解解析与 TYPE_CHECKING

有时有些代码必须被类型检查器看到但不能执行。对于这种情况,typing 模块定义了一个常量 TYPE_CHECKING,它在类型检查期间被认为是 True,但在运行时被认为是 False。例如:

import typing

if typing.TYPE_CHECKING:
    import expensive_mod

def a_func(arg: expensive_mod.SomeClass) -> None:
    a_var: expensive_mod.SomeClass = arg
    ...

这种方法在处理导入循环时也很有用。

在运行时使用 typing.get_type_hints() 尝试解析 a_func 的注解将失败,因为名称 expensive_mod 未定义(TYPE_CHECKING 变量在运行时为 False)。这在 PEP 之前就已经如此。

向后兼容性

这是一个向后不兼容的更改。如果应用程序依赖于直接存在于注解中的任意对象,并且不使用 typing.get_type_hints()eval(),则会崩溃。

依赖于函数定义时局部变量的注解将无法在以后解析。例如:

def generate():
    A = Optional[int]
    class C:
        field: A = 1
        def method(self, arg: A) -> None: ...
    return C
X = generate()

稍后尝试使用 get_type_hints(X) 解析 X 的注解将失败,因为 A 及其封闭作用域已不再存在。Python 不会尝试禁止此类注解,因为它们通常仍能成功进行静态分析,这是注解的主要用例。

使用嵌套类及其各自状态的注解仍然有效。它们可以使用局部名称或完全限定名称。例如:

class C:
    field = 'c_field'
    def method(self) -> C.field:  # this is OK
        ...

    def method(self) -> field:  # this is OK
        ...

    def method(self) -> C.D:  # this is OK
        ...

    def method(self) -> D:  # this is OK
        ...

    class D:
        field2 = 'd_field'
        def method(self) -> C.D.field2:  # this is OK
            ...

        def method(self) -> D.field2:  # this FAILS, class D is local to C
            ...                        # and is therefore only available
                                       # as C.D. This was already true
                                       # before the PEP.

        def method(self) -> field2:  # this is OK
            ...

        def method(self) -> field:  # this FAILS, field is local to C and
                                    # is therefore not visible to D unless
                                    # accessed as C.field. This was already
                                    # true before the PEP.

如果注解不是语法上有效的表达式,则在编译时会引发 SyntaxError。然而,由于此时名称尚未解析,因此不会尝试验证所使用的名称是否正确。

弃用策略

从 Python 3.7 开始,需要 __future__ 导入才能使用所述功能。不会引发任何警告。

注意:这是否最终会成为默认行为目前尚不清楚,有待 PEP 649 决定。无论如何,依赖于其急切求值的注解用法与这两种提议都不兼容,并且不再受支持。

前向引用

在模块中未定义名称之前故意使用它称为前向引用。在本节中,我们还将任何在 if TYPE_CHECKING: 块内导入或定义的名称称为前向引用。

本 PEP 解决了 类型注解 中的前向引用问题。在这种情况下,将不再需要使用字符串字面量。然而,typing 模块中存在一些使用语言其他语法结构进行 API 调用,这些仍然需要通过字符串字面量来解决前向引用。列表包括:

  • 类型定义
    T = TypeVar('T', bound='<type>')
    UserId = NewType('UserId', '<type>')
    Employee = NamedTuple('Employee', [('name', '<type>'), ('id', '<type>')])
    
  • 别名
    Alias = Optional['<type>']
    AnotherAlias = Union['<type>', '<type>']
    YetAnotherAlias = '<type>'
    
  • 类型转换
    cast('<type>', value)
    
  • 基类
    class C(Tuple['<type>', '<type>']): ...
    

根据具体情况,上述某些情况可以通过将用法放置在 if TYPE_CHECKING: 块中来解决。这对于任何需要在运行时可用的代码(特别是基类和类型转换)都不起作用。对于具名元组,使用 Python 3.6 中引入的新类定义语法可以解决该问题。

通常,解决 所有 前向引用问题需要改变 Python 中模块实例化执行方式,从当前单遍自上而下的模型。这将是语言的一个重大变化,超出了本 PEP 的范围。

被拒绝的想法

保留在定义注解时使用函数局部状态的能力

通过推迟求值,这将需要保留对创建注解的帧的引用。例如,可以通过将所有注解存储为 lambda 而不是字符串来实现这一点。

这对于高度注解的代码来说会极其昂贵,因为帧会使它们的所有对象保持活动状态。这主要包括那些永远不会再次访问的对象。

为了能够处理类级别作用域,lambda 方法将需要在解释器中引入一种新型的单元格。这将增加 __annotations__ 中可能出现的类型数量,并且不像字符串那样易于内省。

请注意,在嵌套类的情况下,typing.get_type_hints() 提供了在定义时获取有效的“全局变量”和“局部变量”的功能。

如果一个函数生成一个带有必须使用局部变量的注解的类或函数,它可以直接填充给定生成对象的 __annotations__ 字典,而无需依赖编译器。

也禁止类使用局部状态

本 PEP 最初提议将注解中的名称限制为只允许来自模块级作用域的名称,包括类。作者认为这使得名称解析明确,包括在局部名称和模块级名称冲突的情况下。

在类的情况下,这个想法最终被拒绝了。取而代之,typing.get_type_hints() 被修改,以在需要类级别注解时正确填充局部命名空间。

拒绝这个想法的原因是它违背了 Python 中作用域工作方式的直觉,并且会破坏足够的现有类型注解,使过渡变得麻烦。最后,类装饰器需要局部作用域访问才能求值类型注解。这是因为类装饰器在类在外部作用域中接收其名称之前应用。

取而代之引入一个用于字符串字面量形式的新字典

Yury Selivanov 分享了以下想法:

  1. 为函数添加一个新的特殊属性:__annotations_text__
  2. __annotations__ 变为一个惰性动态映射,及时求值 __annotations_text__ 中相应键的表达式。

这个想法旨在解决向后兼容性问题,消除对新 __future__ 导入的需求。遗憾的是,这还不够。推迟求值改变了注解可以访问的状态。虽然推迟求值解决了前向引用问题,但它也使得无法再访问函数级别的局部变量。这本身就是向后不兼容的来源,因此需要一个弃用期。

一个 __future__ 导入是明确表示选择新功能的明显且明确的指示。它还使外部工具能够轻松识别使用旧方法或新方法的 Python 文件之间的区别。在前一种情况下,该工具将识别允许局部状态访问,而在后一种情况下,它将识别允许前向引用。

最后,如果稍后使用 get_type_hints()__annotations__ 中的即时求值是一个不必要的步骤。

使用 -O 丢弃注解

有两个原因导致这不符合本 PEP 的目的。

首先,这只解决了运行时成本问题,而不是前向引用问题,前向引用仍然无法在源代码中安全使用。库维护者将永远无法使用前向引用,因为那将迫使库用户使用这个新的假设性 -O 开关。

其次,这把婴儿和洗澡水一起倒掉了。现在 任何 运行时注解使用都无法执行。PEP 557 是一个最近的例子,说明在运行时求值类型注解是有用的。

话虽如此,未来可能会提供一个细粒度的 -O 选项来丢弃注解,因为它在概念上与现有的 -O 行为(丢弃文档字符串和断言语句)兼容。本 PEP 不会使这个想法失效。

将注解中的字符串字面量原样传递给 __annotations__

本 PEP 最初建议将字符串字面量的内容直接存储在 __annotations__ 中其相应的键下。这旨在简化对运行时类型检查器的支持。

Mark Shannon 指出这个想法存在缺陷,因为它没有处理字符串仅作为类型注解一部分的情况。

它的不一致性总是很明显,但鉴于它无论如何都不能完全阻止字符串双重包装的情况,因此不值得。

使 future import 的名称更详细

无需以下导入:

from __future__ import annotations

本 PEP 可以更明确地称呼该功能,例如 string_annotationsstringify_annotationsannotation_stringsannotations_as_stringslazy_annotationsstatic_annotations 等。

这些名称的问题在于它们非常冗长。除了 lazy_annotations 之外,每个名称都将成为 Python 中最长的未来功能名称。它们输入起来很长,而且比单字形式更难记住。

以前有过一个未来导入名称听起来过于通用,但在实践中用户很清楚它做什么的先例:

from __future__ import division

前期讨论

在 PEP 484 中

在最初起草 PEP 484 时讨论了前向引用问题,导致文档中出现以下声明:

一个折衷方案是,可以使用 __future__ 导入来将给定模块中的 所有 注解转换为字符串字面量,如下所示:
from __future__ import annotations

class ImSet:
    def add(self, a: ImSet) -> List[ImSet]: ...

assert ImSet.add.__annotations__ == {
    'a': 'ImSet', 'return': 'List[ImSet]'
}

这样的 __future__ 导入语句可能会在单独的 PEP 中提出。

python/typing#400

该问题在 typing 模块的 GitHub 项目中,在 问题 400 下进行了详细讨论。其中的问题描述包括对通用类型需要从 typing 导入的批评。这往往会让初学者感到困惑:

为什么是这样?
from typing import List, Set
def dir(o: object = ...) -> List[str]: ...
def add_friends(friends: Set[Friend]) -> None: ...

而不是这样?

def dir(o: object = ...) -> list[str]: ...
def add_friends(friends: set[Friend]) -> None ...

为什么是这样?

up_to_ten = list(range(10))
friends = set()

而不是这样?

from typing import List, Set
up_to_ten = List[int](range(10))
friends = Set[Friend]()

虽然类型可用性是一个有趣的问题,但它超出了本 PEP 的范围。具体来说,任何对 PEP 484 中标准化的类型语法的扩展都将需要各自的 PEP 和批准。

问题 400 最终建议推迟注解的求值,并将它们作为字符串保存在 __annotations__ 中,正如本 PEP 所指定的那样。这个想法受到了好评。Ivan Levkivskyi 支持使用 __future__ 导入,并建议在 compile.c 中反解析 AST。Jukka Lehtosalo 指出,在某些前向引用情况下,类型在注解之外使用,推迟求值将无济于事。对于这些情况,仍然需要使用字符串字面量表示法。本 PEP 的“前向引用”部分简要讨论了这些情况。

关于这个问题的最大争议是 Guido van Rossum 的担忧,他认为将注解表达式解标记回其字符串形式在 Python 编程语言中没有先例,感觉像是一个临时性的变通方法。他说:

我想到的一件事是,这是对语言的一个非常随意的改变。也许有一种更紧凑的方式来指示表达式的延迟执行(使用比 lambda: 更少的语法)会很有用。但是,既然已经有一个只需要极少语法就能解决这个特定用例的解决方案,为什么类型注解的用例会如此重要,以至于首先要改变语言来解决它(而不是提出一个更通用的解决方案)?

最终,Ethan Smith 和 schollii 表示,在 PyCon US 期间收集的反馈表明,前向引用的状态需要修复。Guido van Rossum 建议重新考虑 __future__ 的想法,指出为了防止滥用,重要的是要确保注解在语法上有效且在运行时求值正确。

python-ideas 上的初稿讨论

讨论主要在两个线程中进行,最初的公告 和一个后续线程,名为 PEP 563 和昂贵的向后兼容性

该 PEP 收到了相当热烈的反馈(4 人强烈支持,2 人支持但有顾虑,2 人反对)。前一个线程中最大的担忧是 Steven D'Aprano 的评论,他指出 PEP 的问题定义不足以证明破坏向后兼容性是合理的。在此回复中,Steven 主要担心 Python 不再支持求值依赖于局部函数/类状态的注解。

一些人表示担心有些库将注解用于非类型化目的。然而,这些库都不会被本 PEP 所废弃。它们确实需要适应新要求,即使用正确的 globalslocals 调用 eval() 对注解进行求值。

关于 globalslocals 必须正确这一细节被许多评论者提及。Alyssa (Nick) Coghlan 对将注解转换为 lambda 而不是字符串进行了基准测试,遗憾的是,这在运行时被证明比当前情况慢得多。

后一个线程由 Jim J. Jewett 发起,他强调正确求值注解的能力是一个重要的要求,并且在这方面的向后兼容性很有价值。经过一番讨论,他承认注解中的副作用是一种代码异味,并且是执行或不执行求值的模式支持是一个混乱的解决方案。他最大的担忧仍然是由于对全局和局部作用域的求值限制而导致的功能丧失。

Alyssa Coghlan 指出,通过巧妙地实现求值辅助函数,可以消除 PEP 中的一些求值限制,这甚至可以解决类装饰器形式的自引用类问题。她建议 PEP 应该在标准库中提供这个辅助函数。

python-dev 上的二稿讨论

讨论主要在 公告线程 中进行,随后在 Mark Shannon 的帖子 下进行了简短讨论。

Steven D'Aprano 担心在 PEP 提出的更改之后,注解中允许出现错别字是否可接受。Brett Cannon 回应说,类型检查器和其他静态分析器(如 linters 或编程文本编辑器)将捕获这种类型的错误。Jukka Lehtosalo 补充说,这种情况类似于函数体中的名称直到函数调用时才解析。

讨论的一个主要话题是 Alyssa Coghlan 的建议,即将注解存储为“thunk 形式”,换句话说,存储为一种专门的 lambda,它能够访问类级别作用域(并允许在调用时自定义作用域)。他提出了一个可能的设计(间接属性单元格)。这后来被视为与 Lisp 中的“特殊形式”等效。Guido van Rossum 表达了担忧,认为这种功能无法在十二周内(即在 Python 3.7 beta 冻结之前及时)安全实现。

过了一段时间,很明显,字符串形式的支持者和 thunk 形式的支持者之间的分歧点实际上在于注解应该被视为一个通用语法元素,还是一个与类型检查用例绑定的东西。

最后,Guido van Rossum 宣布他将拒绝 thunk 的想法,理由是它需要在解释器中引入一个新的构建块。这个块将暴露在注解中,使 __annotations__ 中存储的值的可能类型(任意对象、字符串以及现在的 thunks)倍增。此外,thunks 不像字符串那样易于内省。最重要的是,Guido van Rossum 明确表示有兴趣逐步将注解的使用限制到静态类型(带有可选的运行时组件)。

Alyssa Coghlan 也被 PEP 563 说服了,并立即开始对 __future__ 导入的名称进行强制性的“吹毛求疵”会议。许多辩论者同意 annotations 对于功能名称来说似乎过于宽泛。Guido van Rossum 短暂地决定将其命名为 string_annotations,但后来又改变了主意,认为 division 是一个具有明确含义的宽泛名称的先例。

Mark Shannon 在讨论中提出的对 PEP 的最终改进是拒绝了将字符串字面量原样传递给 __annotations__ 的诱惑。

围绕静态类型的运行时开销展开了一个旁支讨论,话题包括 typing 模块的导入时间(这与不带依赖项的 re 模块相当,计算依赖项时是 re 模块的三倍)。

致谢

本文档的完成离不开 Guido van Rossum、Jukka Lehtosalo 和 Ivan Levkivskyi 的宝贵投入、鼓励和建议。

该实现由 Serhiy Storchaka 进行了彻底审查,他发现了各种问题,包括错误、可读性差和性能问题。


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

最后修改: 2025-05-06 22:56:50 GMT