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

Python 增强提案

PEP 649 – 使用描述符推迟评估注解

作者:
Larry Hastings <larry at hastings.org>
讨论至:
Discourse 帖子
状态:
最终版
类型:
标准跟踪
主题:
类型标注
创建日期:
2021年1月11日
Python 版本:
3.14
发布历史:
2021年1月11日, 2021年4月12日, 2021年4月18日, 2021年8月9日, 2021年10月20日, 2021年10月20日, 2021年11月17日, 2022年3月15日, 2022年11月23日, 2023年2月7日, 2023年4月11日
取代:
563
决议:
2023年5月8日

目录

重要

本PEP是一份历史文档。最新的、规范的文档现在可以在注解找到。

×

有关如何提出更改,请参阅 PEP 1

摘要

注解是一种Python技术,允许表达关于Python函数、类和模块的类型信息及其他元数据。但是Python原始的注解语义要求它们在被注解对象绑定时立即求值。这给使用“类型提示”的静态类型分析用户带来了持续的问题,原因在于前向引用和循环引用问题。

Python通过接受PEP 563解决了这个问题,该PEP引入了一种名为“字符串化注解”的新方法,其中注解被Python自动转换为字符串。这解决了前向引用和循环引用问题,也促进了注解元数据的新用途。但字符串化注解反过来又给注解的运行时用户带来了持续的问题。

本PEP提出了一种新的、全面的注解表示和计算的第三种方法。它增加了一种新的内部机制,通过一个名为__annotate__的新对象方法,按需延迟计算注解。这种方法,结合将注解值强制转换为其他格式的新颖技术,解决了上述所有问题,支持所有现有用例,并应促进注解的未来创新。

概述

本PEP为支持注解的对象(函数、类和模块)添加了一个新的“dunder”属性。这个新属性名为__annotate__,是对一个函数的引用,该函数计算并返回该对象的注解字典。

在编译时,如果对象的定义包含注解,Python编译器会将计算注解的表达式写入其自己的函数中。运行时,该函数将返回注解字典。然后Python编译器将此函数的引用存储在对象上的__annotate__中。

此外,__annotations__被重新定义为一个“数据描述符”,它调用此注解函数一次并缓存结果。

这种机制将注解表达式的求值延迟到注解被检查时,这解决了许多循环引用问题。

本PEP还定义了Python标准库中两个函数的新功能:inspect.get_annotationstyping.get_type_hints。该功能通过一个新的仅限关键字参数 format 访问。format 允许用户以特定格式从这些函数请求注解。格式标识符始终是预定义整数值。本PEP定义的格式有

  • inspect.VALUE = 1

    默认值。该函数将返回注解的常规Python值。此格式与Python 3.11中这些函数的返回值相同。

  • inspect.FORWARDREF = 2

    该函数将尝试返回注解的常规Python值。但是,如果它遇到未定义名称或尚未与值关联的自由变量,它会动态创建一个代理对象(一个ForwardRef)来替换表达式中的该值,然后继续求值。生成的字典可能包含代理和实际值的混合。如果在调用函数时所有实际值都已定义,则inspect.FORWARDREFinspect.VALUE会产生相同的结果。

  • inspect.SOURCE = 3

    该函数将生成一个注解字典,其中值已被替换为包含注解表达式原始源代码的字符串。这些字符串可能只是近似值,因为它们可能是从另一种格式逆向工程而来,而不是保留原始源代码,但差异会很小。

如果被接受,本PEP将取代PEP 563PEP 563的行为将被弃用并最终移除。

注解语义比较

注意

本节中呈现的代码为清晰起见进行了简化,在某些关键方面有意不准确。此示例仅旨在传达所涉及的高级概念,而不会陷入细节。但读者应注意,实际实现方式在几个重要方面都大不相同。有关本PEP从技术层面提出的更准确描述,请参阅本PEP后面的实现部分。

考虑这个示例代码

def foo(x: int = 3, y: MyType = None) -> float:
    ...
class MyType:
    ...
foo_y_annotation = foo.__annotations__['y']

如我们所见,注解在运行时通过函数、类和模块上的__annotations__属性可用。当在这些对象之一上指定注解时,__annotations__是一个字典,将字段名称映射到指定为该字段注解的值。

Python的默认行为是在函数、类或模块绑定时评估注解的表达式,并构建注解字典。在运行时,上述代码实际上是这样工作的

annotations = {'x': int, 'y': MyType, 'return': float}
def foo(x = 3, y = "abc"):
    ...
foo.__annotations__ = annotations
class MyType:
    ...
foo_y_annotation = foo.__annotations__['y']

这里的关键细节是,值intMyTypefloat在函数对象绑定时被查找,并且这些值存储在注解字典中。但是这段代码不会运行——它在第一行抛出NameError,因为MyType尚未定义。

PEP 563的解决方案是在编译期间将表达式反编译回字符串,并将这些字符串作为值存储在注解字典中。等效的运行时代码可能如下所示

annotations = {'x': 'int', 'y': 'MyType', 'return': 'float'}
def foo(x = 3, y = "abc"):
    ...
foo.__annotations__ = annotations
class MyType:
    ...
foo_y_annotation = foo.__annotations__['y']

这段代码现在成功运行。但是,foo_y_annotation不再是对MyType的引用,而是字符串'MyType'。要将字符串转换为真实值MyType,用户需要使用evalinspect.get_annotationstyping.get_type_hints来评估该字符串。

本PEP提出了第三种方法,通过在自己的函数中计算注解来延迟注解的求值。如果本PEP处于活动状态,生成的代码将大致如下所示

class function:
    # __annotations__ on a function object is already a
    # "data descriptor" in Python, we're just changing
    # what it does
    @property
    def __annotations__(self):
        return self.__annotate__()

# ...

def annotate_foo():
    return {'x': int, 'y': MyType, 'return': float}
def foo(x = 3, y = "abc"):
    ...
foo.__annotate__ = annotate_foo
class MyType:
   ...
foo_y_annotation = foo.__annotations__['y']

重要的变化是构建注解字典的代码现在在一个函数中——这里称为annotate_foo()。但是这个函数直到我们请求foo.__annotations__的值时才会被调用,而我们直到MyType定义之后才这样做。因此,这段代码也成功运行,并且foo_y_annotation现在具有正确的值——类MyType——尽管MyType直到注解定义之后才被定义。

2017年11月对该方法的错误拒绝

在围绕PEP 563讨论的早期,在2017年11月comp.lang.python-dev的一个帖子中,曾简要讨论了使用代码延迟注解求值的想法。当时,这项技术被称为“隐式lambda表达式”。

Guido van Rossum——当时的Python BDFL——回复称,这些“隐式lambda表达式”将不起作用,因为它们只能解析模块级作用域的符号

在我看来,方法上的注解无法引用类级别定义,这几乎扼杀了这个想法。

https://mail.python.org/pipermail/python-dev/2017-November/150109.html

这引发了一场简短的讨论,关于扩展lambda化的方法注解,使其能够引用类级别定义,通过维护对类级别作用域的引用。这个想法也很快被拒绝了。

PEP 563总结了上述讨论

本PEP所采取的方法不受这些限制的影响。注解可以访问模块级定义、类级定义,甚至局部变量和自由变量。

动机

注解的历史

Python 3.0发布时带有一个新的语法特性,“注解”,定义在PEP 3107中。这允许指定一个Python值,该值将与Python函数的参数或该函数返回的值相关联。换句话说,注解为Python用户提供了一个接口,用于提供关于函数参数或返回值的丰富元数据,例如类型信息。函数的所有注解都一起存储在一个新属性__annotations__中,在一个“注解字典”中,将参数名(或在返回注解的情况下,使用名称'return')映射到它们的Python值。

为了促进实验,Python有意没有定义这种元数据应该采取何种形式,或者应该使用什么值。用户代码几乎立即开始试验这个新功能。但是,利用此功能的流行库却迟迟未能出现。

经过多年进展甚微,BDFL选择了一种表达静态类型信息的特定方法,称为类型提示,定义在PEP 484中。Python 3.5发布时带有一个新的typing模块,该模块很快变得非常流行。

Python 3.6 添加了用于注解局部变量、类属性和模块属性的语法,采用了 PEP 526 中提出的方法。静态类型分析的流行度持续增长。

然而,静态类型分析用户越来越对一个不便的问题感到沮丧:前向引用。在经典的Python中,如果一个类C依赖于一个稍后定义的类D,这通常不是问题,因为用户代码通常会等到两者都定义后才尝试使用。但是注解增加了一个新的复杂性,因为它们是在被注解对象(函数、类或模块)绑定时计算的。如果类C上的方法用类型D注解,并且这些注解表达式在方法绑定时计算,D可能尚未定义。如果D中的方法也用类型C注解,那么你就遇到了一个无法解决的循环引用问题。

最初,静态类型用户通过将有问题的注解定义为字符串来解决此问题。这之所以有效,是因为包含类型提示的字符串对于静态类型分析工具同样可用。而且静态类型分析工具的用户很少在运行时检查注解,因此这种表示本身并没有造成不便。但是手动将类型提示字符串化既笨拙又容易出错。此外,代码库添加了越来越多的注解,这消耗了越来越多的CPU时间来创建和绑定。

为了解决这些问题,BDFL接受了PEP 563,该PEP为Python 3.7添加了一个新功能:“字符串化注解”。它通过一个未来导入来激活

from __future__ import annotations

通常,注解表达式在对象绑定时求值,其值存储在注解字典中。当字符串化注解处于活动状态时,这些语义会发生变化:相反,在编译时,编译器将该模块中的所有注解转换为其源代码的字符串表示——因此,自动地将用户的注解转换为字符串,从而省去了像以前那样手动字符串化的需要。PEP 563建议用户如果需要在运行时获取实际值,可以使用eval评估此字符串。

(从现在开始,本PEP将把PEP 3107PEP 526的经典语义,即在对象绑定时计算注解表达式的值,称为“Stock”语义,以区别于新的PEP 563“字符串化”注解语义。)

注解用例的现状

尽管注解有许多特定的用例,但参与本PEP讨论的注解用户倾向于分为以下四类。

静态类型用户

静态类型用户使用注解为其代码添加类型信息。但他们大多不会在运行时检查注解。相反,他们使用静态类型分析工具(mypy、pytype)来检查其源代码树,并确定其代码是否一致地使用了类型。这几乎是当今注解最流行的用例。

许多注解使用类型提示,如同PEP 484(以及许多后续PEP)所定义。类型提示是被动对象,仅仅是类型信息的表示;它们不做任何实际工作。类型提示通常用其他类型或其他类型提示进行参数化。由于它们对这些实际值一无所知,类型提示与ForwardRef代理对象配合良好。静态类型提示的用户发现,在Stock语义下进行广泛的类型提示通常会造成难以解决的大规模循环引用和循环导入问题。PEP 563专门设计用于解决此问题,并且该解决方案对这些用户非常有效。将字符串化注解渲染成真实值的困难基本上不会给这些用户带来不便,因为他们很少在运行时检查注解。

静态类型用户通常将PEP 563if typing.TYPE_CHECKING惯用法结合使用,以防止他们的类型提示在运行时被加载。这意味着他们通常无法在运行时评估其字符串化注解并生成实际值。在极少数情况下,他们确实在运行时检查注解,他们通常放弃eval,而是直接对字符串化注解进行词法分析。

在本PEP下,静态类型用户可能更喜欢 FORWARDREFSOURCE 格式。

运行时注解用户

运行时注解用户使用注解作为表达函数和类的丰富元数据的方式,他们将其作为运行时行为的输入。具体的用例包括运行时类型验证 (Pydantic) 和将 Python API 暴露到另一个领域 (FastAPI, Typer) 的胶水逻辑。注解可能是也可能不是类型提示。

由于运行时注解用户在运行时检查注解,因此传统上,Stock语义更能满足他们的需求。此用例与PEP 563,特别是与if typing.TYPE_CHECKING惯用法在很大程度上不兼容。

在本PEP下,运行时注解用户很可能会偏好 VALUE 格式,尽管有些用户(例如,如果他们在装饰器中急切地评估注解并希望支持前向引用)也可能使用 FORWARDREF 格式。

包装器

包装器是包装用户函数或类并添加功能的函数或类。示例包括 dataclass()functools.partial()attrswrapt

包装器是运行时注解用户的一个独特子类别。尽管它们在运行时使用注解,但它们可能实际上并不检查它们包装的对象的注解——这取决于包装器提供的功能。通常,它们应该将包装对象的注解传播到它们创建的包装器,尽管它们可能会修改这些注解。

包装器通常设计用于在Stock语义下良好工作。它们在PEP 563语义下是否良好工作取决于它们检查包装对象注解的程度。通常包装器不关心值本身,只关心注解的特定信息。即便如此,PEP 563if typing.TYPE_CHECKING惯用法仍可能使包装器难以在运行时可靠地确定所需信息。这是一个持续存在的慢性问题。在本PEP下,包装器可能更喜欢FORWARDREF格式用于其内部逻辑。但包装对象需要为其用户支持所有格式。

文档

PEP 563 字符串化注解对于机械化构建文档的工具来说是一个福音。

字符串化的类型提示非常适合文档;源代码中表达的类型提示通常简洁易读。然而,在运行时,这些相同的类型提示可能会产生运行时值,其repr是一个庞大、嵌套、难以理解的混乱。因此,文档用户从PEP 563中受益匪浅,但在Stock语义下却服务不周。

在本PEP下,文档用户预计将使用SOURCE格式。

此PEP的动机

Python原始的注解语义由于前向引用问题,使其在静态类型分析中的使用变得痛苦。PEP 563解决了前向引用问题,许多静态类型分析用户很快就欣然接受了它。但其非传统的解决方案给上述两个用例带来了新问题:运行时注解用户和包装器。

首先,字符串化注解不允许引用局部变量或自由变量,这意味着许多有用、合理的创建注解的方法不再可行。这对于包装现有函数和类的装饰器尤其不便,因为这些装饰器通常使用闭包。

其次,为了使eval能够正确查找字符串化注解中的全局变量,必须首先获取对正确模块的引用。但是类对象不保留对其全局变量的引用。PEP 563建议通过名称在sys.modules中查找类的模块——这对于语言级别功能来说是一个令人惊讶的要求。

此外,复杂但合法的构造可能会使确定正确的全局和局部字典以传递给eval以正确评估字符串化注解变得困难。更糟糕的是,在某些情况下可能根本不可行。

例如,某些库(例如typing.TypedDictdataclasses)包装一个用户类,然后将该类的所有基类的所有注解合并到一个累积注解字典中。如果这些注解是字符串化的,则稍后对其调用eval可能无法正常工作,因为用于eval的全局字典将是定义用户类的模块,这可能与定义注解的模块不同。但是,如果注解由于前向引用问题而被字符串化,则过早对其调用eval也可能无法工作,因为前向引用尚未可解析。这已被证明难以调和;在下面链接的三个错误报告中,只有一个已被标记为已修复。

即使有适当的全局变量局部变量,eval在字符串化注解上也可能不可靠。eval只有在注解中引用的所有符号都已定义时才能成功。如果字符串化注解引用了已定义和未定义符号的混合,则对该字符串的简单eval将失败。这对于需要检查注解的库来说是一个问题,因为它们无法可靠地将这些字符串化注解转换为实际值。

  • 一些库(例如 dataclasses)通过放弃真实值并对字符串化注解执行词法分析来解决此问题,这需要大量工作才能正确完成。
  • 其他库仍然存在这个问题,这可能会导致令人惊讶的运行时行为。https://github.com/python/cpython/issues/97727

此外,eval()很慢,而且并非总是可用;出于某些平台上的空间原因,有时会将其删除。eval()在MicroPython上不支持locals参数,这使得在运行时将字符串化注解转换为实际值更加困难。

最后,PEP 563 要求 Python 实现将其注解字符串化。这是一种令人惊讶的行为——对于语言级别的功能来说是前所未有的,其实现复杂,并且必须在每次添加新操作符时进行更新。

这些问题促使了对寻找新方法解决注解用户面临的问题的研究,从而产生了本PEP。

实施

注解表达式的观察语义

对于任何支持注解的对象o,只要在定义o之前所有在注解表达式中求值的名称都被绑定,并且之后从不重新绑定,那么在“Stock”语义活跃和本PEP活跃时,o.__annotations__将产生相同的注解字典。特别是,在这两种情况下,名称解析将以相同的方式执行。

当本PEP处于活动状态时,o.__annotations__的值直到o.__annotations__本身首次求值时才会被计算。所有注解表达式的求值都延迟到此时,这也意味着

  • 注解表达式中引用的名称将使用它们在此时的当前值,并且
  • 如果求值注解表达式引发异常,则该异常将在此时引发。

一旦o.__annotations__首次成功计算,该值将被缓存,并由后续对o.__annotations__的请求返回。

__annotate__ 和 __annotations__

Python支持三种不同类型的注解:函数、类和模块。本PEP以类似的方式修改了这三种类型上的语义。

首先,本PEP添加了一个新的“dunder”属性,__annotate____annotate__必须是一个“数据描述符”,实现所有三个动作:获取、设置和删除。__annotate__属性始终已定义,并且只能设置为None或可调用对象。(__annotate__不能被删除。)如果一个对象没有注解,__annotate__应该初始化为None,而不是返回空字典的函数。

__annotate__数据描述符必须在对象内部有专用存储空间来存储其值的引用。此存储在运行时的位置是实现细节。即使它对Python代码可见,也仍应被视为内部实现细节,Python代码应仅通过__annotate__属性与它交互。

存储在 __annotate__ 中的可调用对象必须接受一个必需的位置参数,名为 format,它将始终是一个 int(或 int 的子类)。它必须返回一个字典(或字典的子类)或引发 NotImplementedError()

以下是__annotate__的正式定义,它将出现在Python语言参考的“魔法方法”部分中

__annotate__(format: int) -> dict

返回一个新的字典对象,将属性/参数名称映射到其注解值。

接受一个format参数,指定注解值应以何种格式提供。必须是以下之一

inspect.VALUE(等同于int常量1

值是评估注解表达式的结果。

inspect.FORWARDREF(等同于int常量2

对于已定义的值,这些值是实际的注解值(根据inspect.VALUE格式),对于未定义的值,则是ForwardRef代理。实际对象可能会暴露给或包含对ForwardRef代理对象的引用。

inspect.SOURCE(等同于int常量3

值是注解在源代码中出现的文本字符串。可能只是近似值;空格可能被规范化,常量值可能被优化。这些字符串的确切值在Python的未来版本中可能会改变。

如果 __annotate__ 函数不支持请求的格式,则必须引发 NotImplementedError()__annotate__ 函数必须始终支持 1 (inspect.VALUE) 格式;当使用 format=1 调用时,它们不得引发 NotImplementedError()

当以format=1调用时,__annotate__函数可能会引发NameError;在请求任何其他格式时,它不得引发NameError

如果一个对象没有任何注解,__annotate__应该优选设置为None(不能删除),而不是设置为返回空字典的函数。

当Python编译器编译带有注解的对象时,它同时编译相应的注解函数。该函数以单个位置参数inspect.VALUE调用,计算并返回在该对象上定义的注解字典。Python编译器和运行时协同工作,以确保该函数绑定到适当的命名空间

  • 对于函数和类,全局字典将是定义对象的模块。如果对象本身是一个模块,其全局字典将是其自身的字典。
  • 对于类上的方法,以及对于类本身,局部字典将是类字典。
  • 如果注解引用了自由变量,则闭包将是包含自由变量单元格的适当闭包元组。

其次,本PEP要求现有的__annotations__必须是一个“数据描述符”,实现所有三个动作:获取、设置和删除。__annotations__还必须有自己的内部存储,用于缓存对注解字典的引用

  • 类和模块对象必须将注解字典缓存在它们的 __dict__ 中,使用键 __annotations__。这是出于向后兼容性原因。
  • 对于函数对象,注解字典缓存的存储是一个实现细节。它最好是函数对象内部的,并且在Python中不可见。

本PEP定义了__annotations____annotate__如何交互的语义,适用于实现它们的这三种类型。在以下示例中,fn表示函数,cls表示类,mod表示模块,o表示这三种类型中的任何一种对象

  • o.__annotations__被求值,并且o.__annotations__的内部存储未设置,并且o.__annotate__被设置为可调用对象时,o.__annotations__的getter会调用o.__annotate__(1),然后将结果缓存到其内部存储中并返回结果。
    • 为了明确一个多次出现的问题:这个o.__annotations__缓存是本PEP中定义的唯一缓存机制。本PEP中没有定义其他缓存机制。Python编译器生成的__annotate__函数明确不缓存它们计算的任何值。
  • o.__annotate__ 设置为可调用对象会使缓存的注解字典无效。
  • o.__annotate__ 设置为 None 不会影响缓存的注解字典。
  • 删除 o.__annotate__ 会引发 TypeError__annotate__ 必须始终设置;这可以防止未注解的子类继承其基类之一的 __annotate__ 方法。
  • o.__annotations__ 设置为合法值会自动将 o.__annotate__ 设置为 None
    • cls.__annotations__mod.__annotations__设置为None,否则就像任何其他属性一样工作;该属性被设置为None
    • fn.__annotations__ 设置为 None 会使缓存的注解字典无效。如果 fn.__annotations__ 没有缓存的注解值,并且 fn.__annotate__None,则 fn.__annotations__ 数据描述符会创建一个新的空字典,进行缓存并返回。(这是为了向后兼容 PEP 3107 语义。)

允许的注解语法更改

__annotate__现在将注解的求值延迟到未来__annotations__被引用时。这也意味着注解在一个新的函数中求值,而不是在定义它们的原始对象被绑定的上下文中求值。有四个在Stock语义中允许,但在from __future__ import annotations活跃时被禁止,并且在本PEP活跃时也将被禁止的具有显著运行时副作用的操作符

  • :=
  • yield
  • yield from
  • await

inspect.get_annotationstyping.get_type_hints 的更改

(本PEP频繁引用这两个函数。未来将它们统称为“辅助函数”,因为它们帮助用户代码处理注解。)

这两个函数提取并返回一个对象的注解。inspect.get_annotations返回未更改的注解;为了方便静态类型用户,typing.get_type_hints在返回注解之前对其进行了一些修改。

本PEP为这两个函数添加了一个新的仅限关键字参数 formatformat 指定注解字典中的值应以何种格式返回。这两个函数上的 format 参数接受与上面定义的 __annotate__ 魔法方法上的 format 参数相同的值;但是,这些 format 参数也具有默认值 inspect.VALUE

当对象上的__annotations____annotate__更新时,这两个属性中的另一个现在已过时,也应该更新或删除(在__annotate__不能删除的情况下,设置为None)。通常,上一节中建立的语义确保了这种情况会自动发生。但是,有一种情况在所有实际目的上都无法自动处理:当o.__annotations__缓存的字典本身被修改,或当该字典中的可变值被修改时。

由于这无法在代码中处理,因此必须在文档中处理。本PEP提议对inspect.get_annotations(以及typing.get_type_hints)的文档进行如下修订

如果您直接修改对象上的 __annotations__ 字典,默认情况下,这些更改可能不会反映在请求该对象的 SOURCEFORWARDREF 格式时由 inspect.get_annotations 返回的字典中。与其直接修改 __annotations__ 字典,不如考虑用一个计算您所需值的注解字典的函数替换该对象的 __annotate__ 方法。否则,最好将对象的 __annotate__ 方法覆盖为 None,以防止 inspect.get_annotationsSOURCEFORWARDREF 格式生成过时的结果。

stringizerfake globals 环境

最初提议时,本PEP支持许多运行时注解用户用例和许多静态类型用户用例。但这还不够——本PEP无法被接受,除非它满足所有现存用例。这成为本PEP的长期阻碍,直到Carl Meyer提出了下面描述的“stringizer”和“fake globals”环境。这些技术使本PEP能够支持FORWARDREFSOURCE格式,从而出色地满足所有剩余用例。

简而言之,这项技术涉及在一个异乎寻常的运行时环境中运行由Python编译器生成的__annotate__函数。它的正常globals字典被替换为一个被称为“fake globals”的字典。“fake globals”字典是一个有一个重要区别的字典:每次你从它“获取”一个未映射的键时,它都会为该键创建、缓存并返回一个新值(根据字典的__missing__回调)。该值是一个被称为“stringizer”的新类型的实例。

“stringizer”是一个行为非常不寻常的Python类。每个stringizer都用其“值”进行初始化,最初是“fake globals”字典中缺失键的名称。然后stringizer实现所有用于实现操作符的Python“dunder”方法,该方法返回的值是一个新的stringizer,其值是该操作的文本表示。

当这些stringizer用于表达式时,表达式的结果是一个新的stringizer,其名称以文本形式表示该表达式。例如,假设您有一个变量f,它是一个引用,指向一个用值'f'初始化的stringizer。以下是一些您可以对f执行的操作以及它们将返回的值的示例

>>> f
Stringizer('f')
>>> f + 3
Stringizer('f + 3')
>> f["key"]
Stringizer('f["key"]')

总而言之:如果我们运行一个Python生成的__annotate__函数,但用一个“fake globals”字典替换它的全局变量,那么它引用的所有未定义符号都将被替换为表示这些符号的stringizer代理对象,并且对这些代理执行的任何操作反过来都会导致表示该表达式的代理。这允许__annotate__完成,并返回一个注解字典,其中stringizer实例代表否则无法评估的名称和整个表达式。

在实践中,“stringizer”功能将由目前在typing模块中定义的ForwardRef对象实现。ForwardRef将被扩展以实现所有stringizer功能;它还将扩展以支持评估其包含的字符串,以产生真实值(假设引用的所有符号都已定义)。这意味着ForwardRef对象将保留对评估表达式所需的适当“全局变量”、“局部变量”甚至“闭包”信息的引用。

此技术是inspect.get_annotations如何支持FORWARDREFSOURCE格式的核心。最初,inspect.get_annotations将调用对象的__annotate__方法请求所需的格式。如果这引发NotImplementedErrorinspect.get_annotations将构建一个“fake globals”环境,然后调用对象的__annotate__方法。

  • inspect.get_annotations通过创建一个新的空“fake globals”字典,将其绑定到对象的__annotate__方法,调用该方法请求VALUE格式,然后从结果字典中的每个ForwardRef对象中提取字符串“值”,从而生成SOURCE格式。
  • inspect.get_annotations通过创建一个新的空“fake globals”字典,用__annotate__方法的globals字典的当前内容预填充它,将“fake globals”字典绑定到对象的__annotate__方法,调用该方法请求VALUE格式,并返回结果,从而生成FORWARDREF格式。

这种技术之所以有效,是因为编译器生成的__annotate__函数由Python本身控制,并且简单且可预测。它们实际上是一个单独的return语句,计算并返回注解字典。由于计算注解所需的大多数操作都是使用dunder方法在Python中实现的,并且stringizer支持所有相关的dunder方法,因此这种方法是一种可靠、实用的解决方案。

然而,尝试对任何__annotate__方法使用此技术是不合理的。本PEP假定第三方库可能实现自己的__annotate__方法,并且这些函数在此“fake globals”环境中运行时几乎肯定会工作不正确。因此,本PEP为代码对象分配了一个标志,即co_flags中一个未使用的位,表示“此代码对象可以在‘fake globals’环境中运行”。这使得“fake globals”环境严格选择性加入,并且预计只有Python编译器生成的__annotate__方法才会设置它。

这种技术的弱点在于处理那些不能直接映射到对象上dunder方法的运算符。这些运算符都实现了某种流控制,无论是分支还是迭代

  • 短路 or
  • 短路 and
  • 三元运算符(if / then 运算符)
  • 生成器表达式
  • 列表/字典/集合推导式
  • 可迭代解包

通常这些技术不会在注解中使用,因此在实践中不会造成问题。但是,最近Python中添加的TypeVarTuple确实使用了可迭代解包。涉及的dunder方法(__iter____next__)不允许区分迭代用例;为了正确检测涉及的用例,仅仅“fake globals”和“stringizer”是不够的;这将需要一个专门为生成SOURCEFORWARDREF格式而设计的自定义字节码解释器。

幸好有一个快捷方式可以很好地工作:stringizer将简单地假设当其迭代dunder方法被调用时,它是为了TypeVarTuple执行的迭代解包。它将硬编码这种行为。这意味着使用迭代的其他技术将不起作用,但在实践中这不会给实际用例带来不便。

最后,请注意“fake globals”环境还需要构建一个匹配的“fake locals”字典,对于FORWARDREF格式,该字典将预填充相关locals字典。“fake globals”环境还将不得不创建一个假的“闭包”,一个由预先创建的ForwardRef对象组成的元组,其中包含__annotate__方法引用的自由变量的名称。

从引用自由变量的 __annotate__ 方法创建的 ForwardRef 代理会将这些自由变量的名称和闭包值映射到局部字典中,以确保 eval 对这些名称使用正确的值。

编译器生成的 __annotate__ 函数

如前一节所述,编译器生成的__annotate__函数很简单。它们主要是一个单独的return语句,计算并返回注解字典。

然而,inspect.get_annotations请求FORWARDREFSOURCE格式的协议要求首先让__annotate__方法来生成它。Python编译器生成的__annotate__方法将不支持这两种格式,并会引发NotImplementedError()

第三方 __annotate__ 函数

第三方类和函数可能需要实现自己的__annotate__方法,以便这些对象的下游用户可以充分利用注解。特别是,包装器可能需要转换包装对象生成的注解字典:以某种方式添加、删除或修改字典。

大多数情况下,第三方代码将通过调用现有上游对象上的inspect.get_annotations来实现它们的__annotate__方法。例如,包装器可能会以请求的格式请求其包装对象的注解字典,然后根据需要修改返回的注解字典并返回。这使得第三方代码可以利用“fake globals”技术,而无需理解或参与其中。

支持PEP 649之前和之后版本的Python的第三方库将不得不创新自己的最佳实践来支持两者。一个明智的方法是让它们的包装器始终支持__annotate__,然后调用它请求VALUE格式,并将结果存储为包装器对象上的__annotations__。这将支持649之前的Python语义,并向前兼容649之后的语义。

伪代码

这是inspect.get_annotations的高级伪代码

def get_annotations(o, format):
    if format == VALUE:
        return dict(o.__annotations__)

    if format == FORWARDREF:
        try:
            return dict(o.__annotations__)
        except NameError:
            pass

    if not hasattr(o.__annotate__):
        return {}

    c_a = o.__annotate__
    try:
        return c_a(format)
    except NotImplementedError:
        if not can_be_called_with_fake_globals(c_a):
            return {}
        c_a_with_fake_globals = make_fake_globals_version(c_a, format)
        return c_a_with_fake_globals(VALUE)

如果Python编译器生成的__annotate__方法用Python编写,它可能看起来像这样

def __annotate__(self, format):
    if format != 1:
        raise NotImplementedError()
    return { ... }

以下是一个第三方包装类如何实现__annotate__的示例。在这个示例中,包装器的工作方式类似于functools.partial,预先绑定被包装可调用对象的一个参数,为了简单起见,该参数必须命名为arg

def __annotate__(self, format):
    ann = inspect.get_annotations(self.wrapped_fn, format)
    if 'arg' in ann:
        del ann['arg']
    return ann

对Python运行时的其他修改

本PEP不明确规定如何实现;这留给语言实现维护者。然而,本PEP的最佳实现可能需要向现有Python对象添加额外信息,这在本PEP被接受后得到了隐含的认可。

例如,可能需要在类对象中添加一个__globals__属性,以便该类的__annotate__函数可以仅在需要时延迟绑定。此外,在类中定义的方法上定义的__annotate__函数可能需要保留对该类的__dict__的引用,以便正确评估在该类中绑定的名称。预计本PEP的CPython实现将包含这两个新属性。

所有添加到现有Python对象中的此类新信息都应使用“dunder”属性,因为它们当然是实现细节。

交互式REPL Shell

本PEP中确立的语义在Python的交互式REPL shell中执行代码时也成立,除了交互式模块(__main__)本身的模块注解。由于该模块从不“完成”,因此没有特定的点可以编译__annotate__函数。

为了简单起见,在这种情况下,我们放弃了延迟求值。REPL shell中的模块级注解将继续完全按照它们在“Stock语义”下的方式工作,立即求值并将结果直接设置在__annotations__字典中。

函数内部局部变量的注解

Python支持函数内部局部变量注解的语法。然而,这些注解没有运行时效果——它们在编译时被丢弃。因此,本PEP不需要做任何事情来支持它们,与Stock语义和PEP 563相同。

原型

本PEP的原始原型实现可以在这里找到

https://github.com/larryhastings/co_annotations/

截至撰写本文时,该实现已严重过时;它基于Python 3.10,并实现了本PEP初稿(2021年初)的语义。它将很快更新。

性能比较

本PEP的性能总体良好。有四种情况需要考虑

  • 未定义注解时的运行时开销,
  • 已定义注解但引用时的运行时开销,以及
  • 已定义注解并作为对象引用时的运行时开销。
  • 已定义注解并作为字符串引用时的运行时开销。

我们将考察所有三种注解语义(Stock、PEP 563 和本PEP)下每种情况。

当没有注解时,所有三种语义的运行时开销都相同:零。不创建注解字典,也不为其生成代码。这不需要运行时处理器时间,也不消耗内存。

当注解已定义但未引用时,使用本PEP的Python运行时开销与PEP 563大致相同,并优于Stock。具体情况取决于被注解的对象

  • 在Stock语义下,注解字典始终被构建,并作为被注解对象的属性设置。
  • PEP 563语义中,对于函数对象,一个预编译的常量(一个特殊构造的元组)被设置为函数的属性。对于类和模块对象,注解字典始终被构建并设置为类或模块的属性。
  • 使用本PEP,一个单一对象被设置为被注解对象的属性。大多数情况下,这个对象是一个常量(一个代码对象),但当注解需要类命名空间或闭包时,这个对象将是一个在绑定时构造的元组。

当注解既已定义又作为对象引用时,使用本PEP的代码应该比PEP 563快得多,并且与Stock一样快或更快。PEP 563语义要求对注解字典中的每个值调用eval(),这会非常慢。而本PEP的实现为类和模块注解生成了明显更高效的字节码;对于函数注解,本PEP和Stock语义的速度应该大致相同。

本PEP比PEP 563明显慢的一个情况是当注解被请求为字符串时;“它们已经是字符串”是很难超越的。但字符串化的注解旨在用于在线文档用例,其中性能不太可能是关键因素。

内存使用在所有三种语义上下文中的所有三种场景下也应该是可比的。在第一和第三种场景中,所有情况下的内存使用量应该大致相等。在第二种场景中,当注解已定义但未引用时,使用本PEP的语义意味着函数/类/模块将存储一个未使用的代码对象(可能绑定到一个未使用的函数对象);在其他两种语义下,它们将存储一个未使用的字典或常量元组。

向后兼容性

与Stock语义的向后兼容性

本PEP保留了Stock语义下注解的几乎所有现有行为

  • 存储在 __annotations__ 属性中的注解字典的格式保持不变。注解字典包含真实值,而不是像 PEP 563 中那样是字符串。
  • 注解字典是可变的,并且对其的任何更改都会保留。
  • __annotations__属性可以显式设置,并且以这种方式设置的任何合法值都将保留。
  • __annotations__属性可以使用del语句删除。

大多数在Stock语义下工作的代码在本PEP活跃时应无需任何修改即可继续工作。但也有例外,如下所述。

首先,有一个访问类注解的众所周知的惯用法,当此 PEP 激活时,它可能无法正常工作。类注解的原始实现有一个只能称之为 bug 的问题:如果一个类没有定义自己的任何注解,但它的一个基类定义了注解,那么该类将“继承”这些注解。这种行为从未是理想的,因此用户代码找到了一个变通方法:不是直接通过 cls.__annotations__ 访问类上的注解,而是通过其字典访问类的注解,例如 cls.__dict__.get("__annotations__", {})。这种惯用法之所以有效,是因为类将其注解存储在它们的 __dict__ 中,并且以这种方式访问它们可以避免在基类中查找。该技术依赖于 CPython 的实现细节,因此它从未是受支持的行为——尽管它是必要的。然而,当此 PEP 激活时,一个类可能定义了注解但尚未调用 __annotate__ 并缓存结果,在这种情况下,这种方法将导致错误地假设该类没有注解。无论如何,该 bug 已在 Python 3.10 中修复,并且不应再使用该惯用法。同样从 Python 3.10 开始,有一个 注解操作指南 (Annotations HOWTO) 定义了使用注解的最佳实践;遵循这些指南的代码即使在此 PEP 激活时也能正常工作,因为它建议根据代码运行的 Python 版本,使用不同的方法从类对象获取注解。

由于将注解的评估延迟到它们被内省时改变了语言的语义,因此它可以在语言内部观察到。因此,可能编写根据注解是在绑定时还是在访问时评估而表现不同的代码,例如:

mytype = str
def foo(a:mytype): pass
mytype = int
print(foo.__annotations__['a'])

这将以常规语义打印 <class 'str'>,当此 PEP 激活时打印 <class 'int'>。因此,这是一个向后不兼容的更改。然而,这个例子是糟糕的编程风格,所以这个改变似乎是可以接受的。

类和模块注解有两种不常见的交互,它们在常规语义下有效,但在此 PEP 激活时将不再有效。这两种交互必须被禁止。好消息是,这两种情况都不常见,也不被认为是好的实践。事实上,它们在 Python 自己的回归测试套件之外很少见。它们是:

  • 在任何流控制语句内部设置模块或类属性注解的代码。 目前,可以在 iftry 语句内部设置带有注解的模块和类属性,并且它按预期工作。当此 PEP 激活时,支持此行为是不可行的。
  • 在模块或类作用域中直接引用或修改本地 __annotations__ 字典的代码。 目前,当在模块或类属性上设置注解时,生成的代码只是创建一个本地 __annotations__ 字典,然后根据需要向其中添加映射。用户代码可以直接修改此字典,尽管这似乎不是一个故意的特性。虽然在此 PEP 激活后在某种程度上支持这是可能的,但语义可能会令人惊讶,并且不会让任何人满意。

请注意,这两者也都是静态类型检查器的痛点,并且这些工具不支持它们。声明这两者至少不受支持,并且它们的使用会导致未定义的行为,这似乎是合理的。投入少量精力通过编译时检查明确禁止它们可能值得。

最后,如果此 PEP 激活,注解值不应使用 if / else 三元运算符。尽管在访问 o.__annotations__ 或从辅助函数请求 inspect.VALUE 时这将正常工作,但当某些名称已定义时,布尔表达式可能无法与 inspect.FORWARDREF 正确计算,并且与 inspect.SOURCE 的正确性会大大降低。

与PEP 563语义的向后兼容性

PEP 563 改变了注解的语义。当其语义激活时,注解必须假定它们将在 模块级类级 作用域中评估。它们可能不再直接引用当前函数或封闭函数中的局部变量。此 PEP 解除了该限制,注解可以引用任何局部变量。

PEP 563 要求使用 eval(或一个辅助函数,如 typing.get_type_hintsinspect.get_annotations,它们会为您使用 eval)将字符串化的注解转换为它们的“实际”值。现有的激活了字符串化注解并直接调用 eval() 将字符串转换回实际值的代码,可以简单地删除 eval() 调用。使用辅助函数的现有代码将继续不变地工作,尽管使用这些函数可能变为可选。

静态类型用户通常有一些模块只包含惰性类型提示定义——但没有实时代码。这些模块仅在运行静态类型检查时才需要;它们在运行时不使用。但在常规语义下,这些模块必须被导入,以便运行时评估和计算注解。同时,这些模块经常导致循环导入问题,这些问题可能难以甚至无法解决。PEP 563 允许用户通过做两件事来解决这些循环导入问题。首先,他们在模块中激活了 PEP 563,这意味着注解是常量字符串,并且不需要定义实际符号即可计算注解。其次,这允许用户只在 if typing.TYPE_CHECKING 块中导入有问题的模块。这使得静态类型检查器可以导入模块和内部的类型定义,但它们在运行时不会被导入。到目前为止,当此 PEP 激活时,这种方法将保持不变;if typing.TYPE_CHECKING 是受支持的行为。

然而,一些代码库实际上 确实 在运行时检查了它们的注解,即使在使用 if typing.TYPE_CHECKING 技术并且不导入其注解中使用的定义时。这些代码库检查注解字符串 而不评估它们, 而是依赖于对字符串的身份检查或简单的词法分析。

此 PEP 也支持这些技术。但用户需要将他们的代码移植到它。首先,用户代码需要使用 inspect.get_annotationstyping.get_type_hints 来访问注解;他们将无法简单地从其对象中获取 __annotations__ 属性。其次,他们需要在调用该函数时为 format 指定 inspect.FORWARDREFinspect.SOURCE。这意味着辅助函数可以成功生成注解字典,即使并非所有符号都已定义。期望字符串化注解的代码应该可以使用 inspect.SOURCE 格式的注解字典而无需修改;但是,用户应该考虑切换到 inspect.FORWARDREF,因为它可能会使他们的分析更容易。

类似地,PEP 563 允许以以前不可能的方式在带有注解的类上使用类装饰器。一些类装饰器(例如 dataclasses)会检查类上的注解。由于使用 @ 装饰器语法的类装饰器在类名绑定之前运行,它们可能导致无法解决的循环定义问题。如果您用对类本身的引用来注解类的属性,或者在多个类中用循环引用彼此来注解属性,您就不能使用检查注解的装饰器以 @ 装饰器语法装饰这些类。PEP 563 允许这样做,只要装饰器对字符串进行词法检查而不使用 eval 来评估它们(或通过进一步的变通方法处理 NameError)。当此 PEP 激活时,装饰器将能够使用辅助函数以 inspect.SOURCEinspect.FORWARDREF 格式计算注解字典。这将允许它们以它们喜欢的格式分析包含未定义符号的注解。

PEP 563 的早期采用者发现“字符串化”注解对于自动生成的文档很有用。用户尝试了这种用例,Python 的 pydoc 对此技术表示了一些兴趣。此 PEP 支持此用例;生成文档的代码将不得不更新以使用辅助函数以 inspect.SOURCE 格式访问注解。

最后,关于在注解中使用 if / else 三元运算符的警告同样适用于 PEP 563 的用户。目前对他们有效,但在从辅助函数请求某些格式时可能会产生不正确的结果。

如果此 PEP 被接受,PEP 563 将被废弃并最终移除。为了方便 PEP 563 早期采用者的过渡,他们现在依赖于其语义,inspect.get_annotationstyping.get_type_hints 将实现一种特殊的便利。

即使此 PEP 被接受,Python 编译器也不会为在 PEP 563 语义激活的模块中定义的对象生成注解代码对象。因此,在正常情况下,从辅助函数请求 inspect.SOURCE 格式将返回一个空字典。作为一种便利,为了方便过渡,如果辅助函数检测到对象是在 PEP 563 激活的模块中定义的,并且用户请求 inspect.SOURCE 格式,它们将返回 __annotations__ 字典的当前值,在这种情况下将是字符串化的注解。这将允许 PEP 563 用户词法分析字符串化注解,立即切换到从辅助函数请求 inspect.SOURCE 格式,这有望平滑他们从 PEP 563 的过渡。

被拒绝的想法

“直接存储字符串”

对于支持 SOURCE 格式的一个建议是,Python 编译器在某处发出注解值的实际源代码,并在用户请求 SOURCE 格式时提供该源代码。

这个想法与其说是被拒绝,不如说是被归类为“尚未”。我们已经知道我们需要支持 FORWARDREF 格式,并且该技术只需几行代码即可适应支持 SOURCE 格式。关于这种方法还有许多未回答的问题:

  • 我们将把字符串存储在哪里?它们总是在注解对象创建时加载,还是按需延迟加载?如果是,延迟加载将如何工作?
  • “源代码”会包含原始文件中的换行符和注释吗?它会保留所有空白,包括纯粹用于格式化的缩进和额外空格吗?

如果判断提高 SOURCE 值对原始源代码的保真度足够重要,我们将来可能会重新讨论这个话题。

致谢

感谢 Carl Meyer、Barry Warsaw、Eric V. Smith、Mark Shannon、Jelle Zijlstra 和 Guido van Rossum 持续的反馈和鼓励。

特别感谢几位贡献了关键思想的个人,这些思想构成了本提案的一些最佳方面:

  • Carl Meyer 提出了“字符串化”技术,使得 FORWARDREFSOURCE 格式成为可能,这使得在看似无法解决的问题导致一年停滞不前后,本 PEP 得以取得进展。他还建议了 PEP 563 用户的便利,即 inspect.SOURCE 将返回字符串化注解,以及许多其他建议。Carl 也是讨论本 PEP 的私下电子邮件线程中的主要通信员,是一位不知疲倦的资源和理性的声音。如果不是 Carl 的贡献,本 PEP 几乎肯定不会被接受。
  • Mark Shannon 建议在一个单独的代码对象中构建整个注解字典,并且只在需要时将其绑定到一个函数。
  • Guido van Rossum 建议 __annotate__ 函数应该复制“常规”语义下注解的名称可见性规则。
  • Jelle Zijlstra 不仅贡献了反馈——还有代码!

参考资料


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

最后修改:2025-10-06 14:23:25 GMT