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
- 决议:
- Discourse 消息
摘要
注解是一种 Python 技术,允许表达有关 Python 函数、类和模块的类型信息和其他元数据。但是,Python 最初的注解语义要求它们在绑定带注解的对象时立即进行评估。这导致了使用“类型提示”的静态类型分析用户出现长期问题,因为存在前向引用和循环引用问题。
Python 通过接受 PEP 563 来解决此问题,该提案包含了一种称为“字符串化注解”的新方法,其中注解由 Python 自动转换为字符串。这解决了前向引用和循环引用问题,也促进了注解元数据的一些有趣的新用法。但是,字符串化注解又导致了注解的运行时用户出现长期问题。
本 PEP 提出了一种用于表示和计算注解的新且全面的第三种方法。它添加了一种新的内部机制,可以通过名为 __annotate__
的新对象方法按需延迟计算注解。这种方法与一种将注解值强制转换为备用格式的新技术相结合,解决了上述所有问题,支持所有现有用例,并应促进注解的未来创新。
概述
本 PEP 为支持注解的对象(函数、类和模块)添加了一个新的双下划线属性。这个新的属性称为 __annotate__
,它是对一个函数的引用,该函数计算并返回该对象的注解字典。
在编译时,如果对象的定义包含注解,Python 编译器会将计算注解的表达式写入它自己的函数中。运行时,该函数将返回注解字典。然后,Python 编译器将对该函数的引用存储在对象上的 __annotate__
中。
此外,__annotations__
被重新定义为一个“数据描述符”,它会调用此注解函数一次并缓存结果。
此机制将注解表达式的评估延迟到检查注解时,这解决了許多循环引用问题。
本 PEP 还为 Python 标准库中的两个函数定义了新功能:inspect.get_annotations
和 typing.get_type_hints
。可以通过一个新的关键字参数 format
访问这些功能。 format
允许用户以特定格式从这些函数请求注解。格式标识符始终是预定义的整数值。本 PEP 定义的格式为
inspect.VALUE = 1
默认值。该函数将返回注解的常规 Python 值。此格式与 Python 3.11 中这些函数的返回值相同。
inspect.FORWARDREF = 2
该函数将尝试返回注解的常规 Python 值。但是,如果遇到未定义的名称或尚未与值关联的自由变量,它会动态创建一个代理对象(
ForwardRef
)来代替表达式中的该值,然后继续评估。结果字典可能包含代理和真实值的混合。如果在调用函数时所有真实值都已定义,则inspect.FORWARDREF
和inspect.VALUE
会产生相同的结果。inspect.SOURCE = 3
该函数将生成一个注解字典,其中值已被替换为包含注解表达式的原始源代码的字符串。这些字符串可能只是近似的,因为它们可能是从另一种格式反向生成的,而不是保留原始源代码,但差异将很小。
如果被接受,本 PEP 将取代 PEP 563,并且 PEP 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']
这里的重要细节是,在绑定函数对象时查找值 int
、MyType
和 float
,并将这些值存储在注解字典中。但是此代码无法运行,它在第一行抛出 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
,用户需要使用 eval
、inspect.get_annotations
或 typing.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 表达式”。
当时 Python 的 BDFL(仁慈的独裁者)Guido van Rossum 回复说,这些“隐式 lambda 表达式”行不通,因为它们只能在模块级作用域解析符号。
在我看来,无法从方法上的注解引用类级定义几乎否定了这个想法。
https://mail.python.org/pipermail/python-dev/2017-November/150109.html
这引发了一段简短的讨论,关于扩展方法的 lambda 化注解,使其能够通过维护对类级作用域的引用来引用类级定义。这个想法也被迅速否决了。
本 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 3107和PEP 526的经典语义,即注解表达式的值在对象绑定时计算,并将其值存储在注解字典中,称为“默认”语义,以将其与新的PEP 563“字符串化”注解语义区分开来。)
注解用例的现状
尽管注解有许多具体的用例,但在围绕本 PEP 的讨论中,注解用户倾向于属于以下四类之一。
静态类型用户
静态类型用户使用注解向其代码添加类型信息。但他们基本上不会在运行时检查注解。相反,他们使用静态类型分析工具(mypy、pytype)检查其源代码树,并确定其代码是否一致地使用类型。这几乎可以肯定地说是当今注解最流行的用例。
许多注解使用类型提示,如PEP 484(以及许多后续 PEP)所示。类型提示是被动对象,仅仅是类型信息的表示;它们不执行任何实际工作。类型提示通常使用其他类型或其他类型提示进行参数化。由于它们对这些实际值是什么漠不关心,因此类型提示可以很好地与ForwardRef
代理对象一起使用。静态类型提示的用户发现,在默认语义下进行广泛的类型提示通常会导致大规模的循环引用和循环导入问题,这些问题可能难以解决。PEP 563 就是专门为了解决这个问题而设计的,并且该解决方案对这些用户非常有效。由于这些用户很少在运行时检查注解,因此将字符串化注解呈现为真实值的难度在很大程度上没有给他们带来不便。
静态类型用户通常将PEP 563与if typing.TYPE_CHECKING
习惯用法结合使用,以防止其类型提示在运行时加载。这意味着他们通常无法评估其字符串化注解并在运行时生成真实值。在他们确实在运行时检查注解的罕见情况下,他们通常会放弃eval
,而是直接对字符串化注解进行词法分析。
在本 PEP 下,静态类型用户可能会更喜欢FORWARDREF
或SOURCE
格式。
运行时注解用户
运行时注解用户使用注解作为表达其函数和类的丰富元数据的一种方式,他们将这些元数据用作运行时行为的输入。具体的用例包括运行时类型验证(Pydantic)和将 Python API 公开到另一个域的粘合逻辑(FastAPI、Typer)。注解可能是也可能不是类型提示。
由于运行时注解用户在运行时检查注解,因此传统上他们更适合使用默认语义。此用例与PEP 563在很大程度上不兼容,特别是与if typing.TYPE_CHECKING
习惯用法不兼容。
在本 PEP 下,运行时注解用户最有可能更喜欢VALUE
格式,尽管有些人(例如,如果他们在装饰器中急切地评估注解并希望支持前向引用)也可能会使用FORWARDREF
格式。
包装器
包装器是包装用户函数或类并添加功能的函数或类。这方面的示例包括dataclass()
、functools.partial()
、attrs
和wrapt
。
包装器是运行时注解用户的独特子类别。尽管它们确实在运行时使用注解,但它们可能实际上并不检查其包装的对象的注解——这取决于包装器提供的功能。通常,它们应该将被包装对象的注解传播到其创建的包装器,尽管它们可能会修改这些注解。
包装器通常设计为在默认语义下良好运行。它们在PEP 563语义下是否运行良好取决于它们检查被包装对象的注解的程度。通常,包装器并不关心值本身,只需要有关注解的特定信息。即便如此,PEP 563和if typing.TYPE_CHECKING
习惯用法可能会使包装器难以可靠地确定它们在运行时需要的信息。这是一个持续存在的问题。在本 PEP 下,包装器可能更喜欢FORWARDREF
格式用于其内部逻辑。但被包装的对象需要支持其用户的所有格式。
文档
PEP 563 字符串化注解对于机械构建文档的工具来说是一个福音。
字符串化的类型提示构成了极好的文档;以源代码表示的类型提示通常简洁易读。但是,在运行时,这些相同的类型提示可能会生成在运行时产生值,其 repr 是一个庞大、嵌套、难以阅读的混乱。因此,文档用户从PEP 563中获益良多,但在默认语义下却服务不佳。
在本 PEP 下,预计文档用户将使用SOURCE
格式。
本 PEP 的动机
Python 最初关于注解的语义,由于存在前向引用问题,导致其难以用于静态类型分析。 PEP 563解决了前向引用问题,许多静态类型分析用户成为了其早期欣喜的采用者。但其非传统的解决方案为上述两个用例带来了新的问题:运行时注解用户和包装器。
首先,字符串化的注解不允许引用局部或自由变量,这意味着许多创建注解的有用且合理的方法不再可行。这对于包装现有函数和类的装饰器来说尤其不方便,因为这些装饰器经常使用闭包。
其次,为了使 eval
正确地在字符串化注解中查找全局变量,您必须首先获取对正确模块的引用。但类对象不保留对其全局变量的引用。 PEP 563建议通过名称在 sys.modules
中查找类的模块——对于一项语言级特性来说,这是一个令人惊讶的要求。
此外,复杂但合法的构造可能会使确定要提供给 eval
以正确评估字符串化注解的正确全局变量和局部变量字典变得困难。更糟糕的是,在某些情况下,这可能根本不可行。
例如,一些库(例如 typing.TypedDict
、dataclasses
)会包装用户类,然后将该类所有基类中的所有注解合并到一个累积的注解字典中。如果这些注解被字符串化,稍后对它们调用 eval
可能无法正常工作,因为用于 eval
的全局变量字典将是 用户类 所定义的模块,这可能与 注解 所定义的模块不同。但是,如果由于前向引用问题而将注解字符串化,则早期对它们调用 eval
也可能无法正常工作,因为前向引用尚未解析。这已被证明难以协调;在下面链接的三个错误报告中,只有一个被标记为已修复。
- https://github.com/python/cpython/issues/89687
- https://github.com/python/cpython/issues/85421
- https://github.com/python/cpython/issues/90531
即使有正确的全局变量 和 局部变量,eval
在字符串化注解上也可能不可靠。 eval
只有在注解中引用的所有符号都已定义的情况下才能成功。如果字符串化注解引用了已定义和未定义符号的混合,则该字符串的简单 eval
将失败。对于需要检查注解的库来说,这是一个问题,因为它们无法可靠地将这些字符串化注解转换为真实值。
- 一些库(例如
dataclasses
)通过放弃真实值并执行字符串化注解的词法分析来解决此问题,这需要大量工作才能正确完成。 - 其他库仍然存在此问题,这可能会产生令人惊讶的运行时行为。 https://github.com/python/cpython/issues/97727
此外,eval()
速度很慢,而且并不总是可用;出于某些平台上的空间原因,有时会将其移除。MicroPython 上的 eval()
不支持 locals
参数,这使得在运行时将字符串化注解转换为真实值变得更加困难。
最后, PEP 563 要求 Python 实现将其注解字符串化。这是一种令人惊讶的行为——对于一项语言级特性来说是前所未有的,它具有复杂的实现,并且必须在每次向语言添加新运算符时进行更新。
这些问题促使我们研究寻找新的方法来解决注解用户面临的问题,最终导致了本 PEP 的诞生。
实现
注解表达式的观察到的语义
对于任何支持注解的对象 o
,只要注解表达式中评估的所有名称在 o
定义之前都已绑定,并且随后从未重新绑定,那么 o.__annotations__
在“默认”语义处于活动状态时和本 PEP 处于活动状态时都会生成相同的注解字典。特别是,名称解析在这两种情况下都会以相同的方式执行。
当本 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__
时。这也意味着注解是在一个新函数中评估的,而不是在定义它们的对象绑定到的原始上下文中。在库存语义中允许有四个具有重大运行时副作用的操作符,但在 from __future__ import annotations
处于活动状态时不允许,并且在此 PEP 处于活动状态时将必须不允许。
:=
yield
yield from
await
对 inspect.get_annotations
和 typing.get_type_hints
进行的更改
(此 PEP 经常引用这两个函数。将来它将统称为“辅助函数”,因为它们帮助用户代码处理注解。)
这两个函数从对象中提取并返回注解。 inspect.get_annotations
返回未更改的注解;为了方便静态类型用户,typing.get_type_hints
在返回注解之前对其进行了一些修改。
此 PEP 为这两个函数添加了一个新的仅限关键字的参数 format
。 format
指定应以什么格式返回注解字典中的值。这两个函数上的 format
参数接受与上面定义的 __annotate__
魔术方法上的 format
参数相同的值;但是,这些 format
参数也具有默认值 inspect.VALUE
。
当对象的 __annotations__
或 __annotate__
更新时,这两个属性中的另一个现在已过时,也应该更新或删除(在 __annotate__
的情况下设置为 None
,因为它不能被删除)。一般来说,上一节中建立的语义确保会自动发生这种情况。但是,有一种情况在所有实际目的上都无法自动处理:当 o.__annotations__
缓存的字典本身被修改,或者当该字典内的可变值被修改时。
由于这在代码中无法处理,因此必须在文档中处理。此 PEP 建议修改 inspect.get_annotations
的文档(以及类似地修改 typing.get_type_hints
)如下
如果您直接修改对象上的__annotations__
字典,默认情况下,当请求该对象上的SOURCE
或FORWARDREF
格式时,这些更改可能不会反映在inspect.get_annotations
返回的字典中。与其直接修改__annotations__
字典,不如考虑用计算具有您所需值的注解字典的函数替换该对象的__annotate__
方法。如果失败,最好用None
覆盖对象的__annotate__
方法,以防止inspect.get_annotations
为SOURCE
和FORWARDREF
格式生成过时的结果。
字符串化器和伪全局环境
如最初提议的那样,此 PEP 支持许多运行时注解用户用例,以及许多静态类型用户用例。但这还不够——此 PEP 必须满足*所有*现存的用例才能被接受。这成为此 PEP 的长期障碍,直到 Carl Meyer 提出了如下所述的“字符串化器”和“伪全局”环境。这些技术允许此 PEP 支持 FORWARDREF
和 SOURCE
格式,从而很好地满足了所有剩余的用例。
简而言之,此技术涉及在异域运行时环境中运行 Python 编译器生成的 __annotate__
函数。它的普通 globals
字典被替换为所谓的“伪全局”字典。“伪全局”字典是一个字典,它有一个重要的区别:每次从它“获取”一个未映射的键时,它都会为该键创建、缓存并返回一个新值(根据字典的 __missing__
回调)。该值是称为“字符串化器”的新类型的一个实例。
“字符串化器”是一个具有非常规行为的 Python 类。每个字符串化器都用其“值”初始化,最初是“伪全局”字典中缺少的键的名称。然后,字符串化器实现用于实现操作符的每个 Python“dunder”方法,并且该方法返回的值是一个新的字符串化器,其值是该操作的文本表示形式。
当这些字符串化器用于表达式时,表达式的结果是一个新的字符串化器,其名称以文本方式表示该表达式。例如,假设您有一个变量 f
,它是一个对用值 'f'
初始化的字符串化器的引用。以下是一些您可以对 f
执行的操作以及它们将返回的值的示例。
>>> f
Stringizer('f')
>>> f + 3
Stringizer('f + 3')
>> f["key"]
Stringizer('f["key"]')
将所有内容整合在一起:如果我们运行一个 Python 生成的 __annotate__
函数,但我们将它的全局变量替换为“伪全局”字典,它引用的所有未定义符号将被替换为表示这些符号的字符串化器代理对象,并且对这些代理执行的任何操作反过来都会导致表示该表达式的代理。这允许 __annotate__
完成并返回一个注解字典,其中字符串化器实例代表名称和原本无法评估的整个表达式。
在实践中,“字符串化器”功能将在当前在 typing
模块中定义的 ForwardRef
对象中实现。 ForwardRef
将扩展为实现所有字符串化器功能;它还将扩展为支持评估它包含的字符串,以生成真实值(假设引用了所有定义的符号)。这意味着 ForwardRef
对象将保留对评估表达式所需的相应“全局变量”、“局部变量”甚至“闭包”信息的引用。
此技术是inspect.get_annotations
如何支持FORWARDREF
和SOURCE
格式的核心。最初,inspect.get_annotations
将调用对象的__annotate__
方法,请求所需的格式。如果该方法引发NotImplementedError
,则inspect.get_annotations
将构建一个“伪全局变量”环境,然后调用对象的__annotate__
方法。
inspect.get_annotations
通过创建一个新的空“伪全局变量”字典,将其绑定到对象的__annotate__
方法,调用该方法并请求VALUE
格式,然后从结果字典中每个ForwardRef
对象中提取字符串“值”,从而生成SOURCE
格式。inspect.get_annotations
通过创建一个新的空“伪全局变量”字典,并用__annotate__
方法的全局变量字典的当前内容预填充它,将“伪全局变量”字典绑定到对象的__annotate__
方法,调用该方法并请求VALUE
格式,并返回结果,从而生成FORWARDREF
格式。
此整个技术之所以有效,是因为编译器生成的__annotate__
函数由Python本身控制,并且简单且可预测。它们实际上是一个return
语句,计算并返回注释字典。由于计算注释所需的大多数操作都是使用dunder方法在Python中实现的,并且字符串化器支持所有相关的dunder方法,因此这种方法是一种可靠且实用的解决方案。
但是,尝试对任何__annotate__
方法使用此技术是不合理的。本PEP假设第三方库可能会实现自己的__annotate__
方法,并且这些函数在“伪全局变量”环境中运行时几乎肯定无法正常工作。因此,本PEP在代码对象上分配了一个标志,即co_flags
中未使用的位之一,表示“此代码对象可以在‘伪全局变量’环境中运行”。这使得“伪全局变量”环境严格意义上是选择加入的,并且预计只有Python编译器生成的__annotate__
方法会设置它。
此技术的弱点在于处理不直接映射到对象上的dunder方法的操作符。这些都是以某种方式实现流程控制的操作符,无论是分支还是迭代。
- 短路
or
- 短路
and
- 三元运算符(
if
/then
运算符) - 生成器表达式
- 列表/字典/集合推导式
- 可迭代解包
通常,这些技术不会在注释中使用,因此在实践中不会构成问题。但是,最近将TypeVarTuple
添加到Python中确实使用了可迭代解包。所涉及的dunder方法(__iter__
和__next__
)不允许区分迭代用例;为了正确检测涉及的用例,仅仅使用“伪全局变量”和“字符串化器”是不够的;这需要一个专门围绕生成SOURCE
和FORWARDREF
格式设计的自定义字节码解释器。
谢天谢地,有一个快捷方式可以正常工作:字符串化器将简单地假设当调用其迭代dunder方法时,它是为了TypeVarTuple
执行的迭代器解包服务。它将对这种行为进行硬编码。这意味着使用迭代的其他技术将无法工作,但在实践中这不会给现实世界的用例带来不便。
最后,请注意,“伪全局变量”环境还需要构建一个匹配的“伪局部变量”字典,对于FORWARDREF
格式,它将预填充相关的局部变量字典。“伪全局变量”环境还必须创建一个伪“闭包”,这是一个ForwardRef
对象的元组,这些对象预先创建了__annotate__
方法引用的自由变量的名称。
从引用自由变量的__annotate__
方法创建的ForwardRef
代理将把这些自由变量的名称和闭包值映射到局部变量字典中,以确保eval
对这些名称使用正确的值。
编译器生成的 __annotate__
函数
如上一节所述,编译器生成的__annotate__
函数很简单。它们主要是一个return
语句,计算并返回注释字典。
但是,inspect.get_annotations
请求FORWARDREF
或SOURCE
格式的协议要求首先请求__annotate__
方法生成它。Python编译器生成的__annotate__
方法不支持这两种格式,并将引发NotImplementedError()
。
第三方 __annotate__
函数
第三方类和函数可能需要实现自己的__annotate__
方法,以便这些对象的后续用户能够充分利用注释。特别是,包装器可能需要转换被包装对象生成的注释字典:以某种方式添加、删除或修改字典。
大多数情况下,第三方代码将通过对某个现有的上游对象调用inspect.get_annotations
来实现其__annotate__
方法。例如,包装器可能会请求其被包装对象的注释字典,使用从它们那里请求的格式,然后根据需要修改返回的注释字典并返回它。这允许第三方代码利用“伪全局变量”技术,而无需理解或参与其中。
支持PEP 649之前和之后的Python版本的第三方库将不得不创新他们自己的最佳实践来支持两者。一种明智的方法是让它们的包装器始终支持__annotate__
,然后调用它请求VALUE
格式并将结果存储为其包装器对象上的__annotations__
。这将支持PEP 649之前的Python语义,并与PEP 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编写,Python编译器生成的__annotate__
方法可能如下所示。
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__
的引用,以便正确评估在该类中绑定的名称。预计CPython对本PEP的实现将包含这两个新属性。
添加到现有Python对象的所有此类新信息都应使用“dunder”属性,因为它们当然将是实现细节。
交互式 REPL Shell
本PEP中建立的语义在Python的交互式REPL shell中执行代码时也适用,除了交互式模块(__main__
)本身的模块注释。由于该模块永远不会“完成”,因此没有特定的时间点可以编译__annotate__
函数。
为了简单起见,在这种情况下,我们放弃延迟评估。REPL shell中的模块级注释将继续与“库存语义”完全一样地工作,立即评估并将结果直接设置在__annotations__
字典中。
函数内部局部变量上的注解
Python支持函数内部局部变量注释的语法。但是,这些注释没有任何运行时效果——它们在编译时会被丢弃。因此,本PEP不需要做任何事情来支持它们,与库存语义和PEP 563相同。
原型
本PEP的原始原型实现可以在此处找到。
https://github.com/larryhastings/co_annotations/
在撰写本文时,该实现已经严重过时;它基于Python 3.10,并实现了本PEP第一稿(2021年初)的语义。它将很快更新。
性能比较
使用本PEP的性能通常是有利的。有四种情况需要考虑。
- 未定义注释时的运行时成本,
- 定义了但未引用的注释时的运行时成本,以及
- 定义了并作为对象引用的注释时的运行时成本。
- 定义了并作为字符串引用的注释时的运行时成本。
我们将针对所有三种注释语义(库存、PEP 563和本PEP)检查每种情况。
当没有注解时,所有三种语义的运行时成本都相同:零。不会创建任何注解字典,也不会为其生成任何代码。这不需要运行时处理器时间,也不消耗内存。
当定义了注解但未引用时,使用此 PEP 的 Python 的运行时成本大致与PEP 563相同,并且比原版有所改进。具体情况取决于被注解的对象。
- 在原版语义中,注解字典始终会被构建,并设置为被注解对象的属性。
- 在PEP 563语义中,对于函数对象,一个预编译的常量(一个特殊构造的元组)被设置为函数的属性。对于类和模块对象,注解字典始终会被构建并设置为类或模块的属性。
- 使用此 PEP,一个单独的对象被设置为被注解对象的属性。大多数情况下,此对象是一个常量(一个代码对象),但是当注解需要类命名空间或闭包时,此对象将在绑定时构造的元组。
当注解既被定义又作为对象被引用时,使用此 PEP 的代码应该比PEP 563快得多,并且与原版一样快或更快。PEP 563语义需要为注解字典中的每个值调用eval()
,这非常慢。并且此 PEP 的实现为类和模块注解生成了明显更高效的字节码;对于函数注解,此 PEP 和原版语义的速度应该大致相同。
此 PEP 比PEP 563明显慢的唯一情况是,当注解作为字符串请求时;很难击败“它们已经是字符串”。但是字符串化的注解旨在用于在线文档用例,其中性能不太可能是关键因素。
在所有三种语义上下文中,所有三种场景的内存使用也应该相当。在第一种和第三种场景中,所有情况下的内存使用应该大致相同。在第二种场景中,当定义了注解但未引用时,使用此 PEP 的语义意味着函数/类/模块将存储一个未使用的代码对象(可能绑定到一个未使用的函数对象);使用其他两种语义,它们将存储一个未使用的字典或常量元组。
向后兼容性
与默认语义的向后兼容性
此 PEP 保留了原版语义中几乎所有现有的注解行为。
- 存储在
__annotations__
属性中的注解字典的格式不变。注解字典包含真实值,而不是PEP 563中的字符串。 - 注解字典是可变的,对其进行的任何更改都会被保留。
__annotations__
属性可以显式设置,并且以这种方式设置的任何合法值都将被保留。__annotations__
属性可以使用del
语句删除。
大多数与原版语义一起工作的代码在激活此 PEP 时应该继续工作,无需任何修改。但也有例外,如下所示。
首先,有一种用于访问类注解的众所周知的习惯用法,在激活此 PEP 时可能无法正常工作。类注解的原始实现存在一个只能称为错误的问题:如果一个类没有定义任何自己的注解,但它的一个基类定义了注解,则该类将“继承”这些注解。这种行为从未被期望,因此用户代码找到了解决方法:不是通过cls.__annotations__
直接访问类上的注解,而是通过其字典访问类的注解,如cls.__dict__.get("__annotations__", {})
。这种习惯用法之所以有效,是因为类将其注解存储在其__dict__
中,并且以这种方式访问它们避免了对基类中的查找。该技术依赖于 CPython 的实现细节,因此它从未被支持过——尽管它是必要的。但是,当此 PEP 处于活动状态时,一个类可能已定义了注解,但尚未调用__annotate__
并缓存结果,在这种情况下,这种方法会导致错误地假设该类没有注解。无论如何,从 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 自己的回归测试套件之外看到。它们是
- 在任何类型的流程控制语句内部设置模块或类属性上的注解的代码。目前可以在
if
或try
语句内部设置带有注解的模块和类属性,并且它按预期工作。在激活此 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_hints
或inspect.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_annotations
或typing.get_type_hints
来访问注解;他们将无法简单地从其对象获取__annotations__
属性。其次,他们需要为调用该函数时的format
指定inspect.FORWARDREF
或inspect.SOURCE
。这意味着即使所有符号都未定义,辅助函数也可以成功生成注解字典。期望字符串化注解的代码应使用inspect.SOURCE
格式的注解字典进行修改;但是,用户应考虑切换到inspect.FORWARDREF
,因为它可能会使他们的分析更容易。
类似地,PEP 563 允许以以前不可能的方式在带注解的类上使用类装饰器。一些类装饰器(例如 dataclasses
)会检查类上的注解。由于使用 @
装饰器语法的类装饰器在类名绑定之前运行,因此它们会导致无法解决的循环定义问题。如果使用对类本身的引用来注解类的属性,或者使用彼此的循环引用来注解多个类中的属性,则无法使用检查注解的装饰器,使用 @
装饰器语法来装饰这些类。PEP 563 允许这样做,只要装饰器按字面检查字符串并且不使用 eval
来评估它们(或使用进一步的解决方法处理 NameError
)。当此 PEP 生效时,装饰器将能够使用辅助函数以 inspect.SOURCE
或 inspect.FORWARDREF
格式计算注解字典。这将允许它们以它们喜欢的格式分析包含未定义符号的注解。
PEP 563 的早期采用者发现,“字符串化”的注解对于自动生成的文档很有用。用户尝试了这种用例,并且 Python 的 pydoc
对此技术表示了一些兴趣。此 PEP 支持此用例;生成文档的代码将需要更新为使用辅助函数以 inspect.SOURCE
格式访问注解。
最后,关于在注解中使用 if / else
三元运算符的警告同样适用于 PEP 563 的用户。它目前对他们有效,但在从辅助函数请求某些格式时可能会产生不正确的结果。
如果此 PEP 被接受,PEP 563 将被弃用并最终删除。为了方便 PEP 563 的早期采用者(他们现在依赖于其语义)进行过渡,inspect.get_annotations
和 typing.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 Ziljstra 和 Guido van Rossum 提供持续的反馈和鼓励。
特别感谢几位为该提案的一些最佳方面贡献了关键想法的个人。
- Carl Meyer 提出了“字符串化器”技术,该技术使
FORWARDREF
和SOURCE
格式成为可能,这使得在经过一年的停滞(由于看似无法解决的问题)之后,能够在此 PEP 上取得进展。他还建议为 PEP 563 用户提供支持,其中inspect.SOURCE
将返回字符串化的注解,以及更多其他建议。Carl 也是讨论此 PEP 的私人电子邮件线程中的主要通信者,并且是不懈的资源和理性的声音。如果没有 Carl 的贡献,此 PEP 几乎肯定不会被接受。 - Mark Shannon 建议在单个代码对象内部构建整个注解字典,并且仅根据需要将其绑定到函数。
- Guido van Rossum 建议
__annotate__
函数应该复制“标准”语义下注解的名称可见性规则。 - Jelle Zijlstra 不仅提供了反馈,还提供了代码!
参考文献
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以较宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0649.rst
上次修改时间:2024-05-24 00:46:58 GMT