PEP 558 – locals() 的定义语义
- 作者:
- Alyssa Coghlan <ncoghlan at gmail.com>
- BDFL 代表:
- Nathaniel J. Smith
- 讨论邮件列表:
- Python-Dev 邮件列表
- 状态:
- 已撤回
- 类型:
- 标准跟踪
- 创建日期:
- 2017 年 9 月 8 日
- Python 版本:
- 3.13
- 更新历史:
- 2017 年 9 月 8 日,2019 年 5 月 22 日,2019 年 5 月 30 日,2019 年 12 月 30 日,2021 年 7 月 18 日,2021 年 8 月 26 日
PEP 撤回
2021 年 12 月,本 PEP 和 PEP 667 达成了一致,共同定义了对 locals()
内置函数的 Python 层级语义的提议变更(如以下 PEP 文本中所述),唯一剩余的差异在于提议的 C API 变更以及各种内部实现细节。
在这些剩余的差异中,最显著的一个是,当时 PEP 667 仍然提议一旦 PEP 被接受并实施,就立即对 PyEval_GetLocals()
API 进行向后兼容性中断。
PEP 667 现已更改为提议为 PyEval_GetLocals()
API 提供一个宽限的弃用期,继续支持它,同时与新 PyEval_GetFrameLocals()
API 提供的改进语义并行使用。
任何剩余的 C API 设计问题都与以后可以添加的新信息 API 有关(如果认为必要),并且关于帧 locals 视图实现的确切性能特征的任何潜在问题都被可行的参考实现的可用性所抵消。
因此,本 PEP 已被撤回,转而推进 PEP 667。
注意:在实现 PEP 667 时,很明显,locals()
更新为在 优化作用域 中返回独立快照的基本原理和影响在两个 PEP 中都不完全清楚。本 PEP 中的动机和基本原理部分已相应更新(因为这些方面同样适用于已接受的 PEP 667)。
摘要
locals()
内置函数的语义在历史上未被充分指定,因此依赖于实现。
本 PEP 提议正式将 CPython 3.10 参考实现的大多数执行作用域的行为标准化,并对函数作用域的行为进行一些调整,使其更具可预测性,并且独立于跟踪函数的存在与否。
此外,它提议将以下函数添加到稳定的 Python C API/ABI 中
typedef enum {
PyLocals_UNDEFINED = -1,
PyLocals_DIRECT_REFERENCE = 0,
PyLocals_SHALLOW_COPY = 1,
_PyLocals_ENSURE_32BIT_ENUM = 2147483647
} PyLocals_Kind;
PyLocals_Kind PyLocals_GetKind();
PyObject * PyLocals_Get();
PyObject * PyLocals_GetCopy();
它还提议向 CPython C API 添加几个支持函数和类型定义。
动机
虽然 locals()
内置函数的精确语义在名义上是未定义的,但在实践中,许多 Python 程序依赖于它像在 CPython 中的行为一样(至少在没有安装跟踪函数时)。
其他实现(如 PyPy)目前正在复制该行为,包括复制安装跟踪钩子时可能出现的局部变量变异错误 [1]。
虽然本 PEP 认为 CPython 在没有安装跟踪钩子时的当前行为在很大程度上是可以接受的,但它认为安装跟踪钩子时的当前行为是有问题的,因为它会导致像 [1] 这样的错误即使没有可靠地启用允许调试器(如 pdb
)变异局部变量的所需功能 [3]。
对初始 PEP 和草案实现的审查随后确定了一个机会,可以通过将其更新为在每次调用时返回函数局部变量和闭包变量的独立快照,而不是继续返回它在 CPython 中历史上返回的半动态间歇更新的共享副本,从而简化函数级 locals()
行为的文档和实现。
具体来说,本 PEP 中的提案消除了历史行为,在该行为中,添加新的局部变量会改变使用 exec()
在函数作用域中执行的代码的行为,即使该代码在定义局部变量之前运行。
例如
def f():
exec("x = 1")
print(locals().get("x"))
f()
打印 1
,但
def f():
exec("x = 1")
print(locals().get("x"))
x = 0
f()
打印 None
(来自 .get()
调用的默认值)。
使用本 PEP,这两个示例都将打印 None
,因为对 exec()
的调用和随后对 locals()
的调用将使用局部变量的独立字典快照,而不是使用在帧对象上缓存的相同共享字典。
提案
locals()
内置函数的预期语义根据当前执行作用域而变化。为此,定义的执行作用域为
- 模块作用域:顶层模块代码,以及使用
exec()
或eval()
使用单个命名空间执行的任何其他代码 - 类作用域:
class
语句体中的代码,以及使用exec()
或eval()
使用单独的局部和全局命名空间执行的任何其他代码 - 函数作用域:
def
或async def
语句体中的代码,或任何其他在 CPython 中创建优化代码块的构造(例如,推导式、lambda 函数)
本 PEP 提议将 CPython 参考实现的大多数当前行为提升为语言规范的一部分,除了函数作用域中每次调用 locals()
都将创建一个新的字典对象,而不是在帧对象中缓存一个公共字典实例,每个调用都将更新并返回该实例。
本 PEP 还提议在很大程度上消除 CPython 参考实现中单独的“跟踪”模式的概念。在 Python 3.10 及更早版本中,当通过依赖于实现的机制(如 CPython 的 sys
模块中的 sys.settrace
([4]) 或 CPython 的 C API 中的 PyEval_SetTrace
([5]))在一个或多个线程中注册跟踪钩子时,CPython 解释器的行为会有所不同。如果本 PEP 被接受,那么安装跟踪钩子时唯一剩余的行为差异是,当跟踪逻辑需要在每个操作码之后运行时,解释器 eval 循环中的一些优化将被禁用。
本 PEP 提议更改 CPython 在函数作用域中的行为,使注册跟踪钩子时 locals()
内置函数的语义与未注册跟踪钩子时使用的语义相同,同时还使相关的帧 API 语义更清晰,更容易供交互式调试器依赖。
提议的消除跟踪模式会影响通过其他方式(例如,通过回溯或通过 sys._getframe()
API)获得的帧对象引用的语义,因为跟踪钩子支持所需的直写语义始终由帧对象上的 f_locals
属性提供,而不是依赖于运行时状态。
新的 locals()
文档
本提案的核心是修改 locals()
内置函数的文档,使其如下所示
返回一个映射对象,表示当前的局部符号表,变量名称作为键,它们当前绑定的引用作为值。在模块作用域,以及在使用
exec()
或eval()
且只有一个命名空间的情况下,此函数返回与globals()
相同的命名空间。在类作用域,它返回将传递给元类构造函数的命名空间。
在使用
exec()
或eval()
且分别指定局部和全局命名空间的情况下,它返回传递给函数调用的局部命名空间。在上述所有情况下,在给定执行帧中每次调用
locals()
都将返回**相同**的映射对象。通过从locals()
返回的映射对象进行的更改将显示为绑定、重新绑定或删除的局部变量,并且绑定、重新绑定或删除局部变量将立即影响返回的映射对象的内容。在函数作用域(包括生成器和协程),每次调用
locals()
都会返回一个新的字典,其中包含函数局部变量的当前绑定以及任何非局部单元格引用。在这种情况下,通过返回的字典进行的名称绑定更改**不会**写回相应的局部变量或非局部单元格引用,并且绑定、重新绑定或删除局部变量和非局部单元格引用**不会**影响以前返回的字典的内容。
对于进行此更改的版本,还会有一个versionchanged
注释。
在以前的版本中,修改从locals()
返回的映射对象语义在形式上是未定义的。具体来说,在CPython中,在函数作用域返回的映射可能会被其他操作隐式刷新,例如再次调用locals()
,或者解释器隐式调用Python级别的跟踪函数。现在,要获得旧的CPython行为,需要显式调用将后续调用locals()
的结果更新到最初返回的字典中。
作为参考,此内置函数的当前文档如下所示
更新并返回一个表示当前局部符号表的字典。当在函数块中调用locals()
时,它会返回自由变量,但在类块中不会返回。注意:不应修改此字典的内容;更改可能不会影响解释器使用的局部变量和自由变量的值。
(换句话说:现状是locals()
的语义和行为在形式上是实现定义的,而此PEP之后提出的状态是,唯一实现定义的行为将与实现是否模拟CPython帧API相关,其他所有情况下的行为都由语言和库参考定义)
模块作用域
在模块作用域,以及在使用exec()
或eval()
且只有一个命名空间的情况下,locals()
必须返回与globals()
相同的对象,该对象必须是实际的执行命名空间(在提供对帧对象访问的实现中,可作为inspect.currentframe().f_locals
使用)。
在同一作用域中后续代码执行期间的变量赋值必须动态更改返回映射的内容,并且对返回映射的更改必须更改执行环境中绑定到局部变量名称的值。
为了将此期望作为语言规范的一部分捕获,以下段落将添加到locals()
的文档中
在模块作用域,以及在使用exec()
或eval()
且只有一个命名空间的情况下,此函数返回与globals()
相同的命名空间。
此提案的一部分不需要对参考实现进行任何更改 - 它是对当前行为的标准化。
类作用域
在类作用域,以及在使用exec()
或eval()
且分别指定全局和局部命名空间的情况下,locals()
必须返回指定的局部命名空间(在类的情况下,它可能由元类__prepare__
方法提供)。与模块作用域一样,这必须是对实际执行命名空间的直接引用(在提供对帧对象访问的实现中,可作为inspect.currentframe().f_locals
使用)。
在同一作用域中后续代码执行期间的变量赋值必须更改返回映射的内容,并且对返回映射的更改必须更改执行环境中绑定到局部变量名称的值。
由locals()
返回的映射**不会**用作定义的类的底层实际类命名空间(类创建过程会将内容复制到一个新的字典中,该字典只能通过类机制访问)。
对于在函数内部定义的嵌套类,从类作用域引用的任何非局部单元格都**不**包含在locals()
映射中。
为了将此期望作为语言规范的一部分捕获,以下两段将添加到locals()
的文档中
在使用exec()
或eval()
且分别指定局部和全局命名空间的情况下,[此函数]返回给定的局部命名空间。在类作用域,它返回将传递给元类构造函数的命名空间。
此提案的一部分不需要对参考实现进行任何更改 - 它是对当前行为的标准化。
函数作用域
在函数作用域,解释器实现被赋予很大的自由来优化局部变量访问,因此**不需要**允许通过从locals()
返回的映射任意修改局部变量和非局部变量绑定。
从历史上看,这种宽松性在语言规范中用“不应修改此字典的内容;更改可能不会影响解释器使用的局部变量和自由变量的值”来描述。
此PEP建议将该文本更改为
在函数作用域(包括生成器和协程),每次调用locals()
都会返回一个新的字典,其中包含函数局部变量的当前绑定以及任何非局部单元格引用。在这种情况下,通过返回的字典进行的名称绑定更改**不会**写回相应的局部变量或非局部单元格引用,并且绑定、重新绑定或删除局部变量和非局部单元格引用**不会**影响以前返回的字典的内容。
此提案的一部分**确实**需要对CPython参考实现进行更改,因为CPython目前返回一个共享的映射对象,该对象可能会被对locals()
的额外调用隐式刷新,并且目前用于支持来自跟踪函数的命名空间更改的“写回”策略也不符合它(并导致上面动机中提到的古怪的行为问题)。
CPython 实现变更
提议的实现特定变更摘要
- 根据需要进行更改以提供更新的Python级别语义
- 向稳定ABI添加了两个新函数,以复制更新后的Python
locals()
内置函数的行为。
PyObject * PyLocals_Get();
PyLocals_Kind PyLocals_GetKind();
- 向稳定ABI添加了一个新函数,以有效地获取正在运行的帧中局部命名空间的快照。
PyObject * PyLocals_GetCopy();
- 为这些新的公共API添加了相应的帧访问器函数到CPython帧C API中。
- 在优化的帧上,Python级别的
f_locals
API将返回动态创建的读/写代理对象,这些对象直接访问帧的局部和闭包变量存储。为了与现有的PyEval_GetLocals()
API保持互操作性,代理对象将继续使用C级别帧局部数据存储字段来保存一个值缓存,该缓存还允许存储任意其他键。下面介绍了这些快速局部代理对象的预期行为的更多详细信息。 - 没有添加C API函数来访问局部命名空间的可变映射。而是使用
PyObject_GetAttrString(frame, "f_locals")
,与Python代码中使用的API相同。 PyEval_GetLocals()
仍然受支持,并且不会发出程序警告,但将在文档中弃用,以支持不依赖于返回借用引用的新API。PyFrame_FastToLocals()
和PyFrame_FastToLocalsWithError()
仍然受支持,并且不会发出程序警告,但将在文档中弃用,以支持不需要直接访问帧对象内部数据存储布局的新API。PyFrame_LocalsToFast()
始终引发RuntimeError()
,指示应使用PyObject_GetAttrString(frame, "f_locals")
来获取局部变量的可变读/写映射。- 跟踪钩子实现将不再隐式调用
PyFrame_FastToLocals()
。版本移植指南建议迁移到PyFrame_GetLocals()
以进行只读访问,以及PyObject_GetAttrString(frame, "f_locals")
以进行读/写访问。
提供更新的 Python 层级语义
对locals()
内置函数的实现进行了修改,以便为优化的帧返回局部命名空间的不同副本,而不是对由PyFrame_FastToLocals()
C API更新并由PyEval_GetLocals()
C API返回的内部帧值缓存的直接引用。
解决跟踪模式行为问题
CPython的跟踪模式怪癖(从简单安装跟踪函数开始的副作用,以及将值写回函数局部变量仅适用于正在跟踪的特定函数的事实)的当前原因是目前实现局部变量变异支持跟踪钩子的方式:PyFrame_LocalsToFast
函数。
当安装跟踪函数时,CPython目前对函数帧(代码对象使用“快速局部变量”语义的那些帧)执行以下操作
- 调用
PyFrame_FastToLocals
以更新帧值缓存 - 调用跟踪钩子(禁用钩子本身的跟踪)
- 调用
PyFrame_LocalsToFast
以捕获对帧值缓存所做的任何更改
这种方法存在一些问题
- 即使跟踪函数没有更改值缓存,最后一步也会将任何单元格引用重置回调用跟踪函数之前它们所处的状态(这是[1]中错误报告的根本原因)。
- 如果跟踪函数**确实**更改了值缓存,但随后执行了导致值缓存从帧中刷新的操作,则这些更改将丢失(这是[3]中错误报告的一个方面)。
- 如果跟踪函数试图更改除正在跟踪的帧之外的帧的局部变量(例如
frame.f_back.f_locals
),则这些更改几乎肯定会丢失(这是[3]中错误报告的另一个方面)。 - 如果对帧值缓存的引用(例如,通过
locals()
检索)传递给另一个函数,并且**该**函数更改了值缓存,则这些更改**可能**写回执行帧,**如果**安装了跟踪钩子。
针对这个问题提出的解决方案是利用这样一个事实:虽然函数通常使用语言定义的locals()
内置函数来访问其自身的命名空间,但跟踪函数必须使用实现相关的frame.f_locals
接口,因为框架引用是传递给钩子实现的内容。
Python 级的frame.f_locals
将不再是历史上由locals()
内置函数返回的内部框架值缓存的直接引用,而是更新为返回专用的快速局部变量代理类型的实例,该实例直接向底层框架上的快速局部变量数组读写值。每次访问属性都会产生一个新的代理实例(因此,有意地使创建代理实例成为一项廉价的操作)。
尽管新的代理类型成为访问优化框架上的局部变量的首选方式,但仍然保留存储在框架上的内部值缓存,用于两个关键目的
- 维护与
PyEval_GetLocals()
C API 的向后兼容性和互操作性 - 为快速局部变量数组中没有槽的其他键提供存储空间(例如,
pdb
在跟踪代码执行以进行调试时设置的__return__
和__exception__
键)
通过此 PEP 中的更改,此内部框架值缓存不再可从 Python 代码直接访问(而历史上它既由locals()
内置函数返回,也可作为frame.f_locals
属性使用)。相反,值缓存只能通过PyEval_GetLocals()
C API 和直接访问框架对象的内部存储来访问。
由PyEval_GetLocals()
返回的快速局部变量代理对象和内部框架值缓存提供以下行为保证
- 通过快速局部变量代理进行的更改将立即对框架本身、同一框架的其他快速局部变量代理对象以及存储在框架上的内部值缓存可见(正是这一点提供了
PyEval_GetLocals()
的互操作性) - 直接对内部框架值缓存进行的更改将永远不会对框架本身可见,并且只有在更改与框架的快速局部变量数组中没有槽的额外变量相关时,才能通过同一框架的快速局部变量代理可靠地可见
- 在框架中执行代码所做的更改将立即对该框架的所有快速局部变量代理对象可见(现有代理和新创建的代理)。由
PyEval_GetLocals()
返回的内部框架值缓存中的可见性受下一节中讨论的缓存更新指南约束
由于这些要点,只有使用PyEval_GetLocals()
、PyLocals_Get()
或PyLocals_GetCopy()
的代码需要关注框架值缓存可能变得陈旧的问题。使用新的框架快速局部变量代理 API(无论来自 Python 还是来自 C)的代码将始终看到框架的实时状态。
快速 locals 代理实现细节
每个快速局部变量代理实例都有一个在 Python 运行时 API 中未公开的内部属性
- frame:代理提供访问权限的底层优化框架
此外,代理实例使用并更新存储在底层框架或代码对象上的以下属性
- _name_to_offset_mapping:变量名到快速局部变量存储偏移量的隐藏映射。此映射在第一次通过快速局部变量代理读取或写入框架时延迟初始化,而不是在创建第一个快速局部变量代理时立即填充。由于映射对于运行给定代码对象的所有框架都是相同的,因此单个副本存储在代码对象上,而不是每个框架对象都填充自己的映射
- locals:由
PyEval_GetLocals()
C API 返回并由PyFrame_FastToLocals()
C API 更新的内部框架值缓存。这是 Python 3.10 及更早版本中locals()
内置函数返回的映射。
代理上的__getitem__
操作将填充代码对象上的_name_to_offset_mapping
(如果尚未填充),然后要么返回相关值(如果密钥在_name_to_offset_mapping
映射或内部框架值缓存中找到),要么引发KeyError
。在框架上定义但当前未绑定的变量也会引发KeyError
(就像它们从locals()
的结果中省略一样)。
由于始终直接访问框架存储,因此代理将自动获取在函数执行时发生的名称绑定和解除绑定操作。当从框架状态读取单个变量时(包括用于包含检查,需要检查名称当前是绑定还是未绑定),内部值缓存会隐式更新。
类似地,代理上的__setitem__
和__delitem__
操作将直接影响底层框架上的相应快速局部变量或单元格引用,确保更改立即对正在运行的 Python 代码可见,而不是需要在以后的某个时间写回运行时存储。此类更改也会立即写入内部框架值缓存,以便它们对PyEval_GetLocals()
C API 的用户可见。
在优化框架上,未定义为底层框架上的局部变量或闭包变量的键仍写入内部值缓存。这允许像pdb
(它将__return__
和__exception__
值写入框架的f_locals
映射)这样的实用程序继续像往常一样工作。这些与框架上的局部变量或闭包变量不对应的额外键将在未来的缓存同步操作中被保留。使用框架值缓存存储这些额外键(而不是定义仅保存额外键的新映射)提供了与现有PyEval_GetLocals()
API 的完全互操作性(因为任一 API 的用户都将看到另一 API 添加的额外键,而不是仅使用新的快速局部变量代理 API 的用户只能看到通过该 API 添加的键)。
仅在框架上存储变量值缓存(而不是存储代理类型的实例)的另一个好处是,它避免了从框架到自身的引用循环,因此只有当另一个对象保留对代理实例的引用时,才会使框架保持活动状态。
注意:调用proxy.clear()
方法的影响范围与在早期版本中对空框架值缓存调用PyFrame_LocalsToFast()
类似。不仅会清除框架局部变量,还会清除从框架访问的任何单元格变量(无论这些单元格是由框架本身还是由外部框架拥有)。如果在使用零参数super()
构造(或以其他方式引用__class__
)的方法的框架上调用此方法,则可以清除类的__class__
单元格。这超出了调用frame.clear()
的范围,因为该方法仅删除框架对单元格变量的引用,它不会清除单元格本身。此 PEP 可能是一个潜在的机会,可以通过保留属于外部框架的单元格,仅清除直接属于代理底层框架的局部变量和单元格,来缩小直接清除框架变量尝试的范围(此问题也影响PEP 667,因为问题与单元格变量的处理有关,并且完全独立于内部框架值缓存)。
对稳定 C API/ABI 的变更
与 Python 代码不同,扩展模块函数可以从任何类型的 Python 范围调用到 Python C API。这意味着从上下文中不清楚locals()
是否会返回快照,因为它取决于调用 Python 代码的范围,而不是 C 代码本身。
这意味着希望提供具有可预测的、与范围无关的行为的 C API。但是,也希望允许 C 代码在相同范围内完全模仿 Python 代码的行为。
为了能够模仿 Python 代码的行为,稳定的 C ABI 将获得以下新函数
PyObject * PyLocals_Get();
PyLocals_Kind PyLocals_GetKind();
PyLocals_Get()
与 Python locals()
内置函数完全等效。它返回对模块和类范围内活动 Python 框架的局部命名空间映射的新引用,以及在使用exec()
或eval()
时。它返回函数/协程/生成器范围内活动命名空间的浅拷贝。
PyLocals_GetKind()
返回新定义的PyLocals_Kind
枚举中的一个值,可用的选项如下
PyLocals_DIRECT_REFERENCE
:PyLocals_Get()
返回对正在运行的框架的局部命名空间的直接引用。PyLocals_SHALLOW_COPY
:PyLocals_Get()
返回对正在运行的框架的局部命名空间的浅拷贝。PyLocals_UNDEFINED
:发生错误(例如,没有活动的 Python 线程状态)。如果返回此值,将设置 Python 异常。
由于枚举类型用在稳定的 ABI 中,因此设置了一个额外的 31 位值,以确保将任意有符号 32 位整数转换为PyLocals_Kind
值是安全的。
此查询 API 允许扩展模块代码确定更改 PyLocals_Get()
返回的映射的潜在影响,而无需访问正在运行的帧对象的详细信息。Python 代码可以通过词法作用域以可视化的方式获取等效的信息(如新的 locals()
内置文档中所述)。
为了允许扩展模块代码在任何活动的 Python 作用域中保持一致的行为,稳定的 C ABI 将获得以下新的函数
PyObject * PyLocals_GetCopy();
PyLocals_GetCopy()
返回一个新的字典实例,该实例填充自当前局部命名空间。大致相当于 Python 代码中的 dict(locals())
,但在 locals()
已经返回浅拷贝的情况下避免了双重拷贝。类似于以下代码,但不假设局部变量的结果只有两种
locals = PyLocals_Get();
if (PyLocals_GetKind() == PyLocals_DIRECT_REFERENCE) {
locals = PyDict_Copy(locals);
}
现有的 PyEval_GetLocals()
API 将保留其在 CPython 中的现有行为(类和模块作用域中的可变局部变量,否则共享动态快照)。但是,其文档将更新为指出共享动态快照更新的条件已更改。
还将更新 PyEval_GetLocals()
文档,建议将此 API 的使用替换为针对用例最合适的新的 API 之一。
- 使用
PyLocals_Get()
(可选地与PyDictProxy_New()
结合使用)以只读方式访问当前局部命名空间。这种使用方式需要知道副本可能在优化的帧中失效。 - 使用
PyLocals_GetCopy()
获取一个常规的可变字典,其中包含当前局部命名空间的副本,但与活动帧没有持续的连接。 - 使用
PyLocals_Get()
完全匹配 Python 级别locals()
内置函数的语义。 - 显式查询
PyLocals_GetKind()
以实现自定义处理(例如,为PyLocals_Get()
将返回浅拷贝而不是授予对局部命名空间的读写访问权限的作用域引发有意义的异常)。 - 如果需要对帧进行读写访问,并且
PyLocals_GetKind()
返回的值不为PyLocals_DIRECT_REFERENCE
,则使用特定于实现的 API(例如PyObject_GetAttrString(frame, "f_locals")
)。
对公共 CPython C API 的变更
现有的 PyEval_GetLocals()
API 返回一个借用的引用,这意味着它无法更新为在函数作用域中返回新的浅拷贝。相反,它将继续返回对存储在帧对象上的内部动态快照的借用引用。此共享映射的行为类似于 Python 3.10 及更早版本中现有的共享映射,但刷新它的确切条件将有所不同。具体来说,它只会在以下情况下更新
- 在帧运行期间,任何对
PyEval_GetLocals()
、PyLocals_Get()
、PyLocals_GetCopy()
或 Pythonlocals()
内置函数的调用 - 对帧的任何对
PyFrame_GetLocals()
、PyFrame_GetLocalsCopy()
、_PyFrame_BorrowLocals()
、PyFrame_FastToLocals()
或PyFrame_FastToLocalsWithError()
的调用 - 快速局部变量代理对象上的任何操作,这些操作将其更新作为其实现的一部分更新共享映射。在初始参考实现中,这些操作是本质上为
O(n)
操作(len(flp)
、映射比较、flp.copy()
和呈现为字符串),以及那些刷新单个键的缓存条目的操作。
请求快速局部变量代理 *不会* 隐式更新共享动态快照,并且 CPython 跟踪钩子处理也不会再隐式更新它。
(注意:即使 PyEval_GetLocals()
是稳定的 C API/ABI 的一部分,它返回的命名空间何时刷新的细节仍然是解释器实现的细节)
对公共 CPython C API 的添加是支持稳定的 C API/ABI 更新所需的帧级别增强功能。
PyLocals_Kind PyFrame_GetLocalsKind(frame);
PyObject * PyFrame_GetLocals(frame);
PyObject * PyFrame_GetLocalsCopy(frame);
PyObject * _PyFrame_BorrowLocals(frame);
PyFrame_GetLocalsKind(frame)
是 PyLocals_GetKind()
的底层 API。
PyFrame_GetLocals(frame)
是 PyLocals_Get()
的底层 API。
PyFrame_GetLocalsCopy(frame)
是 PyLocals_GetCopy()
的底层 API。
_PyFrame_BorrowLocals(frame)
是 PyEval_GetLocals()
的底层 API。下划线前缀旨在阻止使用并指示使用它的代码不太可能跨实现移植。但是,它已记录并在链接器中可见,以避免必须从 PyEval_GetLocals()
实现中访问帧结构的内部。
PyFrame_LocalsToFast()
函数将更改为始终发出 RuntimeError
,解释它不再是受支持的操作,并且受影响的代码应更新为使用 PyObject_GetAttrString(frame, "f_locals")
来获取读写代理。
除了上述记录的接口外,草案参考实现还公开了以下未记录的接口。
PyTypeObject _PyFastLocalsProxy_Type;
#define _PyFastLocalsProxy_CheckExact(self) Py_IS_TYPE(op, &_PyFastLocalsProxy_Type)
此类型是参考实现实际从 PyObject_GetAttrString(frame, "f_locals")
返回的内容,用于优化的帧(即,当 PyFrame_GetLocalsKind()
返回 PyLocals_SHALLOW_COPY
时)。
减少跟踪钩子的运行时开销
如 [9] 中所述,对 Python 跟踪钩子支持中的 PyFrame_FastToLocals()
的隐式调用不是免费的,如果帧代理直接从帧读取值而不是从映射中获取值,则可以将其视为不必要。
由于新的帧局部变量代理类型不需要单独的数据刷新步骤,因此此 PEP 结合了 Victor Stinner 的提议,不再在调用用 Python 实现的跟踪钩子之前隐式调用 PyFrame_FastToLocalsWithError()
。
使用新的快速局部变量代理对象的代码将在访问需要它的方法时隐式刷新动态局部变量快照,而使用 PyEval_GetLocals()
API 的代码将在进行此调用时隐式刷新它。
此 PEP 还必须删除从跟踪钩子返回时对 PyFrame_LocalsToFast()
的隐式调用,因为该 API 现在始终引发异常。
基本原理和设计讨论
将 locals()
更改为在函数作用域返回独立快照
locals()
内置函数是语言的必需部分,在参考实现中,它历来返回具有以下特征的可变映射
- 每次调用
locals()
都返回 *相同* 的映射对象。 - 对于
locals()
返回对实际局部执行命名空间以外的内容的引用的命名空间,每次调用locals()
都会使用局部变量和任何引用的非局部单元格的当前状态更新映射对象。 - 对返回的映射的更改 *通常* 不会写回局部变量绑定或非局部单元格引用,但可以通过执行以下操作之一来触发写回
- 安装 Python 级别跟踪钩子(然后在每次调用跟踪钩子时发生写回)
- 运行函数级别通配符导入(需要在 Py3 中注入字节码)
- 在函数的作用域中运行
exec
语句(仅限 Py2,因为exec
在 Python 3 中成为普通的内置函数)
最初,此 PEP 建议保留这两个属性,同时更改第三个属性以解决它可能导致的彻底的行为错误。
在 [7] 中,Nathaniel Smith 令人信服地证明,我们可以通过仅保留第二个属性并在函数作用域中每次调用 locals()
时返回局部变量和闭包引用的 *独立* 快照而不是更新隐式共享快照,从而使函数作用域中 locals()
的行为不那么令人困惑。
由于这种修改后的设计也使实现更容易理解,因此 PEP 已更新为提议此行为更改,而不是保留历史共享快照。
保持 locals()
作为函数作用域的快照
如 [7] 中所述,理论上可以更改 locals()
内置函数的语义,以在函数作用域中返回直通代理,而不是切换为返回独立快照。
此 PEP 没有(也不会)提出此建议,因为这在实践中是不兼容的更改,即使依赖于当前行为的代码在技术上是在语言规范的未定义区域中运行。
考虑以下代码片段
def example():
x = 1
locals()["x"] = 2
print(x)
即使安装了跟踪钩子,该函数在当前参考解释器实现上也会始终打印 1
。
>>> example()
1
>>> import sys
>>> def basic_hook(*args):
... return basic_hook
...
>>> sys.settrace(basic_hook)
>>> example()
1
类似地,locals()
可以传递给函数作用域中的 exec()
和 eval()
内置函数(显式或隐式),而不会冒意外重新绑定局部变量或闭包引用的风险。
诱使引用解释器错误地修改局部变量状态需要更复杂的设置,其中嵌套函数闭包了一个在外部函数中重新绑定的变量,并且由于使用了线程、生成器或协程,跟踪函数有可能在外部函数中的重新绑定操作之前开始运行,但在重新绑定操作完成后才结束运行(在这种情况下,重新绑定将被恢复,这就是在[1]中报告的错误)。
除了保留自PEP 227在 Python 2.1 中引入嵌套作用域以来一直存在的实际语义之外,将写入代理支持限制在实现定义的帧对象 API 的另一个好处是,这意味着只有模拟完整帧 API 的解释器实现才需要提供写入功能,并且 JIT 编译的实现只需要在调用帧内省 API 或安装跟踪钩子时启用它,而无需在函数作用域访问locals()
时启用。
从函数作用域返回locals()
的快照也意味着函数级代码的静态分析将更加可靠,因为只有访问帧机制才能以对静态分析隐藏的方式重新绑定局部和非局部变量引用。
eval()
和 exec()
的默认参数会发生什么?
这些在形式上被定义为默认继承调用作用域的globals()
和locals()
。
PEP 无需更改这些默认值,因此它没有更改,并且当locals()
返回时,exec()
和eval()
将开始在局部命名空间的浅拷贝中运行。
这种行为将对性能产生潜在影响,特别是对于具有大量局部变量的函数(例如,如果这些函数在循环中被调用,则在循环之前调用一次globals()
和locals()
,然后显式地将命名空间传递给函数将提供与现状相同的语义和性能特征,而依赖于隐式默认值将在每次迭代时创建一个新的局部命名空间的浅拷贝)。
(注意:参考实现草案 PR 已更新locals()
和vars()
、eval()
以及exec()
内置函数以使用PyLocals_Get()
。 dir()
内置函数仍然使用PyEval_GetLocals()
,因为它仅使用它来从键创建列表)。
在优化作用域中 eval()
和 exec()
的其他注意事项
注意:在实现PEP 667时,注意到该 PEP 和本 PEP 都没有清楚地解释locals()
更改将对exec()
和eval()
等代码执行 API 产生的影响。此部分已添加到本 PEP 的基本原理中,以更好地描述影响并解释更改的预期好处。
当exec()
在 Python 3.0 中从语句转换为内置函数(PEP 3100中核心语言更改的一部分)时,与之关联的隐式调用PyFrame_LocalsToFast()
被删除,因此通常看起来好像在优化过的帧中使用exec()
写入局部变量的尝试被忽略了。
>>> def f():
... x = 0
... exec("x = 1")
... print(x)
... print(locals()["x"])
...
>>> f()
0
0
实际上,写入并没有被忽略,它们只是没有从字典缓存复制回优化的局部变量数组。然后,在下一次从数组刷新字典缓存时,这些更改将被覆盖。
>>> def f():
... x = 0
... locals_cache = locals()
... exec("x = 1")
... print(x)
... print(locals_cache["x"])
... print(locals()["x"])
...
>>> f()
0
1
0
如果跟踪函数或其他代码在下次刷新缓存之前调用PyFrame_LocalsToFast()
,则行为变得更加奇怪。在这些情况下,更改将被写回优化的局部变量数组。
>>> from sys import _getframe
>>> from ctypes import pythonapi, py_object, c_int
>>> _locals_to_fast = pythonapi.PyFrame_LocalsToFast
>>> _locals_to_fast.argtypes = [py_object, c_int]
>>> def f():
... _frame = _getframe()
... _f_locals = _frame.f_locals
... x = 0
... exec("x = 1")
... _locals_to_fast(_frame, 0)
... print(x)
... print(locals()["x"])
... print(_f_locals["x"])
...
>>> f()
1
1
1
这种情况在 Python 3.10 及更早版本中更为常见,因为仅仅安装跟踪函数就足以触发在每行 Python 代码后隐式调用PyFrame_LocalsToFast()
。但是,它仍然可能在 Python 3.11 及更高版本中发生,具体取决于哪些跟踪函数处于活动状态(例如,交互式调试器有意这样做,以便在代码执行恢复时,在调试提示符处进行的更改可见)。
所有以上关于exec()
的评论都适用于任何尝试在优化过的作用域中修改locals()
结果的操作,并且是locals()
内置函数文档包含此警告的主要原因。
注意:不应修改此字典的内容;更改可能不会影响解释器使用的局部变量和自由变量的值。
虽然库参考中的措辞并不完全明确,但exec()
和eval()
长期以来一直使用调用 Python 帧中调用globals()
和locals()
的结果作为其默认执行命名空间。
从历史上看,这也等同于使用调用帧的frame.f_globals
和frame.f_locals
属性,但此 PEP 将exec()
和eval()
的默认命名空间参数映射到调用帧中的globals()
和locals()
,以保留默认忽略对优化作用域中的局部命名空间的写入尝试的属性。
这对某些代码提出了潜在的兼容性问题,因为在先前实现中,当在函数作用域中多次调用locals()
时返回相同的字典,以下代码通常由于隐式共享的局部变量命名空间而有效。
def f():
exec('a = 0') # equivalent to exec('a = 0', globals(), locals())
exec('print(a)') # equivalent to exec('print(a)', globals(), locals())
print(locals()) # {'a': 0}
# However, print(a) will not work here
f()
当在优化过的作用域中使用locals()
对每次调用返回相同的共享字典时,可以在该字典中存储额外的“伪局部变量”。虽然这些不是编译器已知的真实局部变量(因此不能使用print(a)
之类的代码打印),但它们仍然可以通过locals()
访问并在同一函数作用域中的多个exec()
调用之间共享。此外,因为它们不是真正的局部变量,所以当从局部变量存储数组刷新共享缓存时,它们不会被隐式更新或删除。
当exec()
中的代码尝试写入现有的局部变量时,运行时行为变得难以预测。
def f():
a = None
exec('a = 0') # equivalent to exec('a = 0', globals(), locals())
exec('print(a)') # equivalent to exec('print(a)', globals(), locals())
print(locals()) # {'a': None}
f()
print(a)
将打印None
,因为exec()
中的隐式locals()
调用使用帧上的实际值刷新缓存的字典。这意味着,与通过写回locals()
(包括通过先前对exec()
的调用)创建的“伪”局部变量不同,编译器已知的真实局部变量不容易被exec()
修改(可以这样做,但它需要同时检索frame.f_locals
属性以启用写回帧,然后调用PyFrame_LocalsToFast()
,如上文使用ctypes
所示)。
如“动机”部分所述,即使仅在exec()
调用之后定义了局部变量,也会发生这种令人困惑的副作用。
>>> def f():
... exec("a = 0")
... exec("print('a' in locals())") # Printing 'a' directly won't work
... print(locals())
... a = None
... print(locals())
...
>>> f()
False
{}
{'a': None}
因为a
是一个尚未绑定到值的真实局部变量,所以在a = None
行之前每次调用locals()
时,它都会从locals()
返回的字典中显式删除。此删除是有意的,因为它允许在使用del
语句删除先前绑定的局部变量时,在优化过的作用域中正确更新locals()
的内容。
如ctypes
示例中所述,如果在帧仍在运行时调用了 CPython PyFrame_LocalsToFast()
API,则上述行为描述可能会失效。在这种情况下,对a
的更改可能对正在运行的代码可见,具体取决于该 API 的调用时间(以及帧是否已通过访问frame.f_locals
属性为局部变量修改做好准备)。
如上所述,考虑了两种选项来替换这种令人困惑的行为。
- 使
locals()
返回写入代理实例(类似于frame.f_locals
)。 - 使
locals()
返回真正独立的快照,以便通过exec()
更改局部变量值的尝试将始终被忽略,而没有任何上述警告。
PEP 选择第二个选项,原因如下。
- 在优化过的作用域中返回独立的快照保留了 Python 3.0 对
exec()
的更改,该更改导致在大多数情况下忽略通过exec()
修改局部变量的尝试。 - “
locals()
在优化作用域中提供本地变量的瞬时快照,而在其他作用域中提供读写访问”与“frame.f_locals
在所有作用域(包括优化作用域)中提供对本地变量的读写访问”之间的区别,使得代码的意图更加清晰,即使在不需要或不希望进行写访问时,也避免了两个API都在优化作用域中提供完全读写访问权限的情况。 - 除了提高人类阅读者的清晰度之外,确保优化作用域中的名称重新绑定在代码中仍然以词法可见(只要不访问帧内省API),还可以使编译器和解释器更一致地应用相关的性能优化。
- 只有支持可选帧内省API的Python实现才需要为优化帧提供新的直写代理支持。
通过本PEP中对locals()
语义的更改,解释exec()
和eval()
的行为变得容易得多:在优化作用域中,它们**永远不会**隐式地影响本地变量;在其他作用域中,它们**总是**隐式地影响本地变量。在优化作用域中,当代码执行API返回时,对本地变量的任何隐式赋值都将被丢弃,因为每次调用都会使用本地变量的新副本。
保留内部帧值缓存
保留内部帧值缓存会在帧代理实例保留并在执行过名称绑定和解除绑定操作后重新使用时导致一些可见的怪异行为。
保留帧值缓存的主要原因是保持与PyEval_GetLocals()
API 的向后兼容性。该API返回一个借用的引用,因此它必须引用存储在帧对象上的持久状态。在帧上存储快速本地变量代理对象会创建一个有问题的引用循环,因此最干净的选项是继续返回帧值缓存,就像此函数自首次引入优化帧以来一直所做的那样。
既然帧值缓存无论如何都会保留,那么进一步依赖它来简化快速本地变量代理映射实现就更有意义了。
注意:PEP 667**没有**将内部帧值缓存用作直写代理实现的一部分,这是这两个PEP之间关键的Python级别差异。
更改常规操作中的帧 API 语义
注意:当本PEP首次编写时,它早于Python 3.11中删除隐式回写帧本地变量(无论何时安装跟踪函数)的更改,因此将该更改包含在提案中。
本PEP的早期版本建议让帧f_locals
属性的语义取决于当前是否安装了跟踪钩子——仅在激活跟踪钩子时提供直写代理行为,否则行为与历史locals()
内置函数相同。
最初的设计提案采用这种方式有两个主要原因,一个务实,另一个更具哲学意义。
- 对象分配和方法包装器不是免费的,跟踪函数也不是唯一从函数外部访问帧本地变量的操作。将更改限制在跟踪模式意味着这些更改的额外内存和执行时间开销在常规操作中将尽可能接近于零。
- “不要改变没有损坏的东西”:当前的跟踪模式问题是由跟踪模式特有的需求(支持外部重新绑定函数本地变量引用)引起的,因此将任何相关的修复限制在跟踪模式中也是有意义的。
然而,实际尝试实现和记录这种动态方法突出了这样一个事实:它使得frame.f_locals
的工作方式在运行时状态相关的行为方面变得非常微妙,并在添加和删除跟踪函数时围绕f_locals
的行为创建了几个新的边缘情况。
因此,设计切换到当前的设计,其中frame.f_locals
始终是直写代理,而locals()
始终是快照,这两种方式都更易于实现和解释。
无论CPython参考实现如何处理这个问题,优化编译器和解释器仍然可以自由地对调试器施加额外的限制,例如使通过帧对象进行局部变量变异成为一种可选行为,这可能会禁用某些优化(就像CPython的帧API的仿真在某些Python实现中已经是可选标志一样)。
继续支持在优化帧上存储额外数据
本PEP的一个草案迭代建议通过写入与底层帧上的本地或闭包变量名称不对应的frame.f_locals
键来删除在优化帧上存储额外数据的功能。
虽然这个想法为快速本地变量代理实现提供了一些有吸引力的简化,但pdb
在任意帧上存储__return__
和__exception__
值,因此如果该功能不再起作用,则标准库测试套件将失败。
因此,保留了存储任意键的功能,但代价是代理对象上的某些操作速度会比其他情况慢(因为它们无法假设只有在代码对象上定义的名称才能通过代理访问)。
预计快速本地变量代理与底层帧上的f_locals
值缓存之间的交互的具体细节将随着时间的推移而发展,因为会发现改进的机会。
函数作用域的历史语义
由于历史实现细节,CPython中locals()
和frame.f_locals
的变异语义相当古怪。
- 实际执行使用快速本地变量数组进行本地变量绑定,并使用单元格引用进行非局部变量。
- 有一个
PyFrame_FastToLocals
操作,它根据快速本地变量数组的当前状态和任何引用的单元格填充帧的f_locals
属性。这存在三个原因:- 允许跟踪函数读取本地变量的状态。
- 允许回溯处理器读取本地变量的状态。
- 允许
locals()
读取本地变量的状态。
- 从
locals()
返回对frame.f_locals
的直接引用,因此,如果您发放多个并发引用,则所有这些引用都将指向同一个字典。 - 在迁移到Python 3的过程中,删除了对反向操作
PyFrame_LocalsToFast
的两个常用调用:exec
不再是语句(因此不再能够影响函数本地命名空间),并且编译器现在不允许在函数范围内使用from module import *
操作。 - 但是,仍然存在两个模糊的调用路径:
PyFrame_LocalsToFast
作为从跟踪函数返回的一部分被调用(这允许调试器更改本地变量状态),并且您仍然可以在直接从代码对象而不是通过编译器创建函数时注入IMPORT_STAR
操作码。
本提案故意**没有**将这些语义形式化,因为它们只在语言的历史演变和参考实现方面才有意义,而不是经过精心设计的。
提议对稳定 C API/ABI 进行一些补充
历史上,CPython C API(随后是稳定的ABI)只公开了一个与Python locals
内置函数相关的API函数:PyEval_GetLocals()
。但是,由于它返回一个借用的引用,因此无法直接调整该接口以支持本PEP中提出的新的locals()
语义。
本PEP的早期迭代提出了一种对新语义的极简适应:一个C API函数的行为类似于Python locals()
内置函数,另一个函数的行为类似于frame.f_locals
描述符(如果需要,创建并返回直写代理)。
关于该版本C API的反馈[8]是它过于依赖Python级别语义的实现方式,并且没有考虑C扩展的作者可能**需要**的行为。
现在提出的更广泛的API来自将希望从扩展模块访问Python locals()
命名空间的潜在原因归类为以下几种情况:
- 需要精确复制Python级别
locals()
操作的语义。这是PyLocals_Get()
API。 - 需要根据对
PyLocals_Get()
结果的写入是否对Python代码可见而有所不同。这由PyLocals_GetKind()
查询API处理。 - 始终希望一个从当前Python
locals()
命名空间预先填充的可变命名空间,但**不**希望任何更改对Python代码可见。这是PyLocals_GetCopy()
API。 - 始终希望一个当前本地变量命名空间的只读视图,而无需每次都产生创建完整副本的运行时开销。由于需要检查名称当前是否绑定,因此对于优化帧来说,这不容易实现,因此没有添加特定的API来涵盖它。
历史上,这些类型的检查和操作只有在Python实现模拟完整的CPython帧API时才有可能。使用提议的API,扩展模块可以更明确地请求它们实际需要的语义,从而使Python实现能够更灵活地提供这些功能。
与 PEP 667 的比较
注意:下面的比较针对的是2021年12月的PEP 667。它不反映2024年4月(本PEP被撤回以支持继续进行PEP 667)时PEP 667的状态。
PEP 667为本PEP提供了一个部分竞争的提案,该提案建议完全消除优化帧上的内部帧值缓存是合理的。
这些更改最初作为对PEP 558的修订提出,PEP作者拒绝了它们,主要有三个原因:
- 最初声称
PyEval_GetLocals()
无法修复,因为它返回一个借用的引用,这纯粹是错误的,因为它在PEP 558的参考实现中仍然有效。为了保持其有效性,只需要保留内部帧值缓存,并以一种合理简单的方式设计快速局部变量代理,以便在帧状态发生变化时能够轻松地使缓存保持最新,而不会在不需要缓存时产生明显的运行时开销。鉴于此说法是错误的,要求所有使用PyEval_GetLocals()
API 的代码都重写为使用具有不同引用计数语义的新 API 的提议违反了PEP 387的要求,即 API 兼容性中断应该具有较高的收益/破坏比(因为放弃缓存没有明显的收益,因此无法证明任何代码中断是合理的)。唯一真正无法修复的公共 API 是PyFrame_LocalsToFast()
(这就是为什么这两个 PEP 都提议破坏它的原因)。 - 如果没有某种形式的内部值缓存,快速局部变量代理映射的 API 性能特征就会变得非常不直观。例如,
len(proxy)
在帧上定义的变量数量方面始终为 O(n),因为代理必须遍历整个快速局部变量数组以查看哪些名称当前绑定到值,然后才能确定答案。相比之下,维护内部帧值缓存可能会使代理在算法复杂度方面基本上被视为普通字典,只需要为第一次执行依赖于缓存最新的操作时运行的初始隐式 O(n) 缓存刷新做出让步。 - 声称无缓存实现更简单是高度可疑的,因为PEP 667仅包含可变映射实现子集的纯 Python 草图,而不是与底层数据存储(用于优化帧)集成的全新映射类型的完整 C 实现。PEP 558的快速局部变量代理实现大量委托给帧值缓存来执行完全实现可变映射 API 所需的操作,从而使其能够重用以下操作的现有字典实现
__len__
__str__
__or__
(字典联合)__iter__
(允许重用dict_keyiterator
类型)__reversed__
(允许重用dict_reversekeyiterator
类型)keys()
(允许重用dict_keys
类型)values()
(允许重用dict_values
类型)items()
(允许重用dict_items
类型)copy()
popitem()
- 值比较操作
在这三个原因中,第一个是最重要的(因为我们需要令人信服的理由来破坏 API 向后兼容性,而我们没有)。
但是,在审查了PEP 667提出的 Python 级别语义后,本 PEP 的作者最终同意它们对 Python locals()
API 的用户来说确实会更简单,因此这两个 PEP 之间的这种区别已被消除:无论接受哪个 PEP 和实现,快速局部变量代理对象始终提供局部变量当前状态的一致视图,即使这会导致某些操作变成 O(n),而这些操作在普通字典上是 O(1)(具体来说,len(proxy)
变成 O(n),因为它需要检查哪些名称当前已绑定,并且代理映射比较避免依赖于允许快速检测存储键数量差异的长度检查优化)。
由于在代理实现中采用了这些非标准的性能特征,因此本 PEP 中也删除了PyLocals_GetView()
和PyFrame_GetLocalsView()
C API。
这使得这两个 PEP 之间唯一剩下的区别点与 C API 相关
- PEP 667仍然提出了完全没有必要的 C API 中断(
PyEval_GetLocals()
、PyFrame_FastToLocalsWithError()
和PyFrame_FastToLocals()
的程序化弃用和最终删除),而没有合理的理由,当完全有可能让这些 API 无限期地(并且互操作地)工作时,前提是快速局部变量代理实现的设计合理。 - 本 PEP 中定义了快速局部变量代理处理附加变量的方式,这种方式与现有的
PyEval_GetLocals()
API 完全互操作。在PEP 667中提出的代理实现中,新帧 API 的用户将看不到旧 API 的用户对附加变量所做的更改,并且通过旧 API 对附加变量所做的更改将在随后调用PyEval_GetLocals()
时被覆盖。 - 本 PEP 中的
PyLocals_Get()
API 在PEP 667中称为PyEval_Locals()
。这个函数名称有点奇怪,因为它缺少动词,使其看起来更像是一个类型名称而不是数据访问 API。 - 本 PEP 添加了
PyLocals_GetCopy()
和PyFrame_GetLocalsCopy()
API,以便扩展模块能够轻松避免在PyLocals_Get()
已经进行复制的帧中产生双重复制操作。 - 本 PEP 添加了
PyLocals_Kind
、PyLocals_GetKind()
和PyFrame_GetLocalsKind()
,以便扩展模块能够识别代码何时在函数范围内运行,而无需检查不可移植的帧和代码对象 API(如果没有提议的查询 API,则现有等效于新的PyLocals_GetKind() == PyLocals_SHALLOW_COPY
检查是包含 CPython 内部帧 API 头文件并检查_PyFrame_GetCode(PyEval_GetFrame())->co_flags & CO_OPTIMIZED
是否已设置)。
下面的 Python 伪代码基于PEP 667截至撰写本文时(2021-10-24)提供的实现草图。在注释中指出了提供新快速局部变量代理 API 和现有PyEval_GetLocals()
API 之间改进的互操作性的差异。
与PEP 667一样,所有以下划线开头的属性都是不可见的,无法直接访问。它们仅用于说明提议的设计。
为简单起见(以及与PEP 667一样),省略了模块和类级别帧的处理(它们要简单得多,因为_locals
就是执行命名空间,因此不需要转换)。
NULL: Object # NULL is a singleton representing the absence of a value.
class CodeType:
_name_to_offset_mapping_impl: dict | NULL
...
def __init__(self, ...):
self._name_to_offset_mapping_impl = NULL
self._variable_names = deduplicate(
self.co_varnames + self.co_cellvars + self.co_freevars
)
...
def _is_cell(self, offset):
... # How the interpreter identifies cells is an implementation detail
@property
def _name_to_offset_mapping(self):
"Mapping of names to offsets in local variable array."
if self._name_to_offset_mapping_impl is NULL:
self._name_to_offset_mapping_impl = {
name: index for (index, name) in enumerate(self._variable_names)
}
return self._name_to_offset_mapping_impl
class FrameType:
_fast_locals : array[Object] # The values of the local variables, items may be NULL.
_locals: dict | NULL # Dictionary returned by PyEval_GetLocals()
def __init__(self, ...):
self._locals = NULL
...
@property
def f_locals(self):
return FastLocalsProxy(self)
class FastLocalsProxy:
__slots__ "_frame"
def __init__(self, frame:FrameType):
self._frame = frame
def _set_locals_entry(self, name, val):
f = self._frame
if f._locals is NULL:
f._locals = {}
f._locals[name] = val
def __getitem__(self, name):
f = self._frame
co = f.f_code
if name in co._name_to_offset_mapping:
index = co._name_to_offset_mapping[name]
val = f._fast_locals[index]
if val is NULL:
raise KeyError(name)
if co._is_cell(offset)
val = val.cell_contents
if val is NULL:
raise KeyError(name)
# PyEval_GetLocals() interop: implicit frame cache refresh
self._set_locals_entry(name, val)
return val
# PyEval_GetLocals() interop: frame cache may contain additional names
if f._locals is NULL:
raise KeyError(name)
return f._locals[name]
def __setitem__(self, name, value):
f = self._frame
co = f.f_code
if name in co._name_to_offset_mapping:
index = co._name_to_offset_mapping[name]
kind = co._local_kinds[index]
if co._is_cell(offset)
cell = f._locals[index]
cell.cell_contents = val
else:
f._fast_locals[index] = val
# PyEval_GetLocals() interop: implicit frame cache update
# even for names that are part of the fast locals array
self._set_locals_entry(name, val)
def __delitem__(self, name):
f = self._frame
co = f.f_code
if name in co._name_to_offset_mapping:
index = co._name_to_offset_mapping[name]
kind = co._local_kinds[index]
if co._is_cell(offset)
cell = f._locals[index]
cell.cell_contents = NULL
else:
f._fast_locals[index] = NULL
# PyEval_GetLocals() interop: implicit frame cache update
# even for names that are part of the fast locals array
if f._locals is not NULL:
del f._locals[name]
def __iter__(self):
f = self._frame
co = f.f_code
for index, name in enumerate(co._variable_names):
val = f._fast_locals[index]
if val is NULL:
continue
if co._is_cell(offset):
val = val.cell_contents
if val is NULL:
continue
yield name
for name in f._locals:
# Yield any extra names not defined on the frame
if name in co._name_to_offset_mapping:
continue
yield name
def popitem(self):
f = self._frame
co = f.f_code
for name in self:
val = self[name]
# PyEval_GetLocals() interop: implicit frame cache update
# even for names that are part of the fast locals array
del name
return name, val
def _sync_frame_cache(self):
# This method underpins PyEval_GetLocals, PyFrame_FastToLocals
# PyFrame_GetLocals, PyLocals_Get, mapping comparison, etc
f = self._frame
co = f.f_code
res = 0
if f._locals is NULL:
f._locals = {}
for index, name in enumerate(co._variable_names):
val = f._fast_locals[index]
if val is NULL:
f._locals.pop(name, None)
continue
if co._is_cell(offset):
if val.cell_contents is NULL:
f._locals.pop(name, None)
continue
f._locals[name] = val
def __len__(self):
self._sync_frame_cache()
return len(self._locals)
注意:将PEP 558参考实现的早期迭代转换为现在提议的语义的初步实现的最简单方法是删除受影响操作中的frame_cache_updated
检查,并在这些方法中始终同步帧缓存。采用这种方法会改变以下操作的算法复杂度,如下所示(其中n
是帧上定义的局部变量和单元变量的数量)
__len__
:O(1) -> O(n)- 值比较操作:不再受益于 O(1) 长度检查快捷方式
__iter__
:O(1) -> O(n)__reversed__
:O(1) -> O(n)keys()
:O(1) -> O(n)values()
:O(1) -> O(n)items()
:O(1) -> O(n)popitem()
:O(1) -> O(n)
长度检查和值比较操作的改进机会相对有限:如果不允许使用可能过时的缓存,那么唯一知道当前绑定了多少变量的方法是遍历所有变量并进行检查,如果实现无论如何都要在操作上花费这么多周期,它不妨花时间更新帧值缓存,然后使用结果。在本文档和PEP 667中,这些操作都是 O(n)。可以提供自定义实现,这些实现比更新帧缓存快,但目前尚不清楚为了加快这些操作速度而需要的额外代码复杂性是否值得,因为这只会带来线性性能改进,而不是算法复杂度改进。
其他操作的 O(1) 特性可以通过添加不依赖于值缓存最新的实现代码来恢复。
保持迭代器/可迭代检索方法为 O(1) 将涉及为相应的内置字典帮助程序类型编写自定义替换,就像PEP 667中提议的那样。如上所示,这些实现将类似于PEP 667中提供的伪代码,但并不完全相同(因为本 PEP 提供的改进的PyEval_GetLocals()
互操作性会影响它存储额外变量的方式)。
popitem()
可以通过创建依赖于改进的迭代 API 的自定义实现,将其从“始终为 O(n)”改进为“最坏情况为 O(n)”。
为了确保在 Python 快速局部变量代理 API 中永远不会显示过时的帧信息,在合并之前需要实现参考实现中的这些更改。
截至撰写本文时(2021年10月24日),当前的实现仍然在每个帧上存储一份快速引用映射的副本,而不是在底层代码对象上存储单个实例(因为它仍然直接存储单元格引用,而不是在每次快速局部变量数组访问时检查单元格)。在合并之前,也需要修复此问题。
实现
参考实现更新正在开发中,作为 GitHub 上的一个草稿拉取请求([6])。
致谢
感谢 Nathaniel J. Smith 在 [1] 中提出写入代理的想法,并指出 PEP 早期迭代中一些试图避免引入此类代理的关键设计缺陷。
感谢 Steve Dower 和 Petr Viktorin 要求更多关注提议的 C API 添加的开发者体验 [8] [13]。
感谢 Larry Hastings 提供了如何在稳定 ABI 中使用枚举,同时确保它们可以安全地支持来自任意整数的类型转换的建议。
感谢 Mark Shannon 推动进一步简化 C 级别 API 和语义,以及对 PEP 文本的重大澄清(并在经过一年多的不活跃后于 2021 年初重新启动了对 PEP 的讨论) [10] [11] [12]。最终发布为 PEP 667 的 Mark 的评论也直接导致了一些实现效率的改进,这些改进避免了在不使用相关映射时产生冗余的 O(n) 映射刷新操作的成本,以及确保通过 Python 级别 f_locals
API 报告的状态永远不会过时的更改。
参考文献
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以更具许可性的为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0558.rst
上次修改时间:2024-08-05 03:54:25 GMT