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
决议:
Python-Dev 消息

目录

摘要

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 可以通过链接给定类及其基类的 vars(按方法解析顺序)来组成。由于插槽只能在类定义后填充,因此我们不需要为此目的咨询它们。

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

需要为当前类解析注解的元类和类装饰器将对使用当前类名称的注解失败。例如

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,而这些 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 指出这个想法是有缺陷的,因为它没有处理字符串只是类型注释的一部分的情况。

它始终存在不一致性,但考虑到它并不能完全阻止双层包装字符串的情况,所以不值得。

使未来导入的名称更详细

而不是要求以下导入

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]'
}

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

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 回应说类型检查器和其他静态分析器(如 linter 或编程文本编辑器)会捕获这种类型的错误。Jukka Lehtosalo 补充说这种情况类似于函数体中的名称只有在函数被调用时才会解析。

讨论的另一个主要话题是 Alyssa Coghlan 关于将注释存储在“thunk 形式”中的建议,换句话说,它是一个能够访问类级范围(并允许在调用时定制范围)的特殊 lambda 函数。他对此提出了一个可能的方案(间接属性单元)。后来发现这等同于 Lisp 中的“特殊形式”。Guido van Rossum 表达了担忧,认为这种功能无法在 12 周内安全地实现(即在 Python 3.7 beta 版冻结之前)。

过了一段时间,人们意识到字符串形式的支持者与 thunk 形式的支持者之间分歧的症结实际上在于注释应该被视为一个通用的语法元素,还是与类型检查用例绑定的东西。

最后,Guido van Rossum 宣布他拒绝了 thunk 的想法,理由是这将需要在解释器中添加一个新的构建块。这个构建块将在注释中暴露,从而使存储在 __annotations__ 中的值类型增加(任意对象、字符串,现在还有 thunk)。此外,thunk 不如字符串那么可内省。最重要的是,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

最后修改:2024-03-24 01:43:58 GMT