PEP 558 – 定义 locals() 的语义
- 作者:
- Alyssa Coghlan <ncoghlan at gmail.com>
- BDFL 委托:
- 纳撒尼尔·J·史密斯
- 讨论至:
- 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 相关,如果认为有必要,这些 API 可以在以后添加,而对于帧局部变量视图实现的精确性能特征的任何潜在担忧,都将被可用的可行参考实现所抵消。
因此,本 PEP 已被撤回,以支持继续进行 PEP 667。
注意:在实现 PEP 667 时,人们发现,无论是本 PEP 还是 PEP 667,都没有完全清楚 locals() 更新为在 优化作用域 中返回独立快照的原理和影响。本 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 和草案实现的审查然后发现,通过更新函数级别 locals() 的行为以返回函数局部变量和闭包变量的独立快照,而不是继续返回 CPython 中历史上返回的半动态间歇性更新的共享副本,从而有机会简化文档和实现。
具体而言,本 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() 的每次调用都将创建一个新的字典对象,而不是缓存帧对象上每个调用都会更新和返回的通用 dict 实例。
本 PEP 还提议在很大程度上消除 CPython 参考实现中独立“跟踪”模式的概念。在 Python 3.10 及之前的版本中,CPython 解释器在通过 sys 模块中的 sys.settrace ([4]) 或 CPython 的 C API 中的 PyEval_SetTrace ([5]) 等依赖于实现的机制在其中一个或多个线程中注册了跟踪钩子时,行为会有所不同。如果本 PEP 被接受,那么在安装了跟踪钩子时唯一剩余的行为差异是,当跟踪逻辑需要在每个操作码之后运行时,解释器 eval 循环中的一些优化会被禁用。
本 PEP 提议更改 CPython 在函数作用域中的行为,使在注册了跟踪钩子时 locals() 内建函数的语义与未注册跟踪钩子时使用的语义相同,同时使相关的帧 API 语义更清晰,并且更容易供交互式调试器依赖。
拟议的跟踪模式的消除会影响通过其他方式(例如,通过 traceback 或 sys._getframe() API)获得的帧对象引用的语义,因为跟踪钩子支持所需的写穿透语义始终由帧对象上的 f_locals 属性提供,而不是依赖于运行时状态。
新的 locals() 文档
此提议的核心是修改内建函数 locals() 的文档,使其阅读如下:
返回一个表示当前局部符号表的映射对象,其中变量名为键,它们当前绑定的引用为值。在模块作用域,以及在使用带单个命名空间的
exec()或eval()时,此函数返回与globals()相同的命名空间。在类作用域,它返回将传递给元类构造函数的命名空间。
在使用带独立局部和全局命名空间的
exec()或eval()时,它返回传递给函数调用的局部命名空间。在所有上述情况下,在给定执行帧中对
locals()的每次调用都将返回相同的映射对象。通过从locals()返回的映射对象所做的更改将作为绑定的、重新绑定的或删除的局部变量可见,并且绑定、重新绑定或删除局部变量将立即影响返回映射对象的内容。在函数作用域(包括生成器和协程)中,对
locals()的每次调用将返回一个包含函数局部变量和任何非局部单元格引用的当前绑定的新字典。在这种情况下,通过返回的 dict 所做的名称绑定更改不会写回相应的局部变量或非局部单元格引用,并且绑定、重新绑定或删除局部变量和非局部单元格引用不会影响先前返回的字典的内容。
还将为进行此更改的版本添加一个 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()的每次调用将返回一个包含函数局部变量和任何非局部单元格引用的当前绑定的新字典。在这种情况下,通过返回的 dict 所做的名称绑定更改不会写回相应的局部变量或非局部单元格引用,并且绑定、重新绑定或删除局部变量和非局部单元格引用不会影响先前返回的字典的内容。
此提议部分确实需要对 CPython 参考实现进行更改,因为 CPython 目前返回一个共享映射对象,该对象可能会被对 locals() 的其他调用隐式刷新,并且当前用于支持从跟踪函数进行命名空间更改的“写回”策略也不符合此要求(并且会导致上述动机中提到的奇怪行为问题)。
CPython 实现更改
拟议的特定于实现的更改摘要
- 进行必要的更改以提供更新后的 Python 级别语义
- 向稳定的 ABI 添加了两个新函数,以复制 Python
locals()内建函数的更新行为
PyObject * PyLocals_Get();
PyLocals_Kind PyLocals_GetKind();
- 向稳定的 ABI 添加了一个新函数,用于高效地获取运行帧中局部命名空间的快照
PyObject * PyLocals_GetCopy();
- 在 CPython C API 中添加了与这些新公共 API 对应的帧访问器函数
- 在优化帧上,Python 级别的
f_localsAPI 将返回动态创建的读/写代理对象,这些对象直接访问帧的局部和闭包变量存储。为了与现有的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] 中的 bug 报告的根本原因)。
- 如果跟踪函数确实突变了值缓存,但然后执行了导致值缓存从帧中刷新的操作,则这些更改将丢失(这是 [3] 中的 bug 报告的一个方面)。
- 如果跟踪函数尝试突变正在跟踪的帧以外的帧的局部变量(例如
frame.f_back.f_locals),则几乎肯定会丢失这些更改(这是 [3] 中的 bug 报告的另一个方面)。 - 如果帧值缓存的引用(例如通过
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()、PyLocals_Get() 或 PyLocals_GetCopy() 的代码才需要担心帧值缓存可能过时。使用新的帧快速局部变量代理 API(无论是从 Python 还是从 C)的代码将始终看到帧的实时状态。
快速局部变量代理实现细节
每个快速局部变量代理实例都有一个内部属性,该属性未作为 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 C API 的扩展模块函数可以从任何类型的 Python 作用域调用。这意味着从上下文中不清楚 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()返回浅拷贝而不是授予对局部命名空间读/写访问的作用域引发有意义的异常)。 - 使用特定于实现的 API(例如
PyObject_GetAttrString(frame, "f_locals")),如果需要对帧的读/写访问并且PyLocals_GetKind()返回的内容不是PyLocals_DIRECT_REFERENCE。
公共 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() 实现中访问帧结构(struct)的内部细节。
函数 PyFrame_LocalsToFast() 将被更改为始终引发 RuntimeError,解释它不再是受支持的操作,受影响的代码应更新为使用 PyObject_GetAttrString(frame, "f_locals") 来获取读/写代理。
除了上述记录的接口外,草案参考实现还公开了以下未记录的接口:
PyTypeObject _PyFastLocalsProxy_Type;
#define _PyFastLocalsProxy_CheckExact(self) Py_IS_TYPE(op, &_PyFastLocalsProxy_Type)
这是参考实现为优化帧(即,当 PyFrame_GetLocalsKind() 返回 PyLocals_SHALLOW_COPY 时)实际从 PyObject_GetAttrString(frame, "f_locals") 返回的类型。
减少跟踪钩子的运行时开销
正如 [9] 中所述,Python 跟踪钩子支持中的隐式调用 PyFrame_FastToLocals() 并非免费的,如果帧代理直接从帧读取值而不是从映射中获取,则可以避免。
由于新的帧局部变量代理类型不需要单独的数据刷新步骤,因此本 PEP 采纳了 Victor Stinner 的提议,即在调用 Python 中实现的跟踪钩子之前,不再隐式调用 PyFrame_FastToLocalsWithError()。
使用新的快速局部变量代理对象进行代码访问时,动态局部变量快照将在访问需要它的方法时隐式刷新,而使用 PyEval_GetLocals() API 进行代码访问时,将在调用该 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] 中报告的 bug)。
除了保留自 PEP 227 引入 Python 2.1 中的嵌套作用域以来一直存在的实际语义外,将写穿透代理支持限制为依赖于实现的帧对象 API 的另一个好处是,这意味着只有模拟完整帧 API 的解释器实现才需要提供写穿透功能,而 JIT 编译的实现则仅在调用帧内省 API 或安装跟踪钩子时才需要启用它,而不是每当访问函数作用域中的 locals() 时。
在函数作用域中从 locals() 返回快照也意味着函数级别的代码的静态分析将更可靠,因为只有访问帧机制才能在隐藏于静态分析的范围内重新绑定局部变量和非局部变量引用。
默认参数对于 eval() 和 exec() 会发生什么?
这些默认被正式定义为从调用作用域继承 globals() 和 locals()。
PEP 不需要更改这些默认设置,因此它不更改,并且 exec() 和 eval() 将在局部命名空间的浅拷贝中开始运行,当 locals() 返回该命名空间时。
此行为可能具有潜在的性能影响,尤其是对于具有大量局部变量的函数(例如,如果这些函数在循环中调用,在循环之前调用一次 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() 会返回相同 dict)不同,由于隐式共享的局部变量命名空间,以下代码通常有效:
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() 为每次调用返回相同的共享 dict 时,就可以在该 dict 中存储额外的“假局部变量”。虽然这些不是编译器已知的真实局部变量(因此不能用 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() 会用帧上的实际值刷新缓存的 dict。这意味着,与通过写入 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 语句删除先前绑定的局部变量时。
如ctypes 示例中所述,如果在框架仍在运行时调用 CPython 的 PyFrame_LocalsToFast() API,则上述行为描述可能会失效。在这种情况下,对 a 的更改可能会对正在运行的代码可见,具体取决于该 API 被调用的确切时间(以及框架是否已通过访问 frame.f_locals 属性而被设置为允许 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 返回一个借用引用,因此它必须引用存储在帧对象上的持久状态。将一个快速 locals 代理对象存储在帧上会创建一个有问题的引用循环,因此最干净的选项是继续返回一个帧值缓存,就像自优化帧首次引入以来该函数所做的那样。
由于帧值缓存无论如何都会被保留,因此依赖它来简化快速 locals 代理映射的实现更有意义。
注意:PEP 667不将内部帧值缓存用作直通代理实现的一部分,这是这两个 PEP 之间在 Python 级别上的关键区别。
更改常规操作中的帧 API 语义
注意:当此 PEP 最初编写时,它早于 Python 3.11 中在安装跟踪函数时删除帧局部变量的隐式回写更改,因此该更改作为提案的一部分被包含进来。
此 PEP 的早期版本曾提议让帧 f_locals 属性的语义取决于当前是否安装了跟踪钩子——仅在跟踪钩子处于活动状态时提供直通代理行为,否则表现与历史上的 locals() 内建函数相同。
出于两个主要原因(一个实际原因和一个哲学原因),这被采纳为最初的设计提案:
- 对象分配和方法包装器不是免费的,跟踪函数不是唯一会从函数外部访问帧 locals 的操作。将更改限制在跟踪模式意味着这些更改的额外内存和执行时间开销在常规操作中将尽可能接近于零。
- “不要改变未损坏的东西”:当前的跟踪模式问题是由跟踪模式特有的要求(支持对函数局部变量引用的外部重绑定)引起的,因此将任何相关的修复也限制在跟踪模式是有意义的。
然而,实际尝试实现和记录这种动态方法突出了这样一个事实:它会在运行时状态依赖行为上产生一个非常微妙的区分,即 frame.f_locals 的工作方式,并围绕跟踪函数被添加和移除时 f_locals 的行为产生几个新的边缘情况。
因此,设计被切换到当前版本,其中 frame.f_locals 始终是直通代理,而 locals() 始终是快照,这既易于实现,也易于解释。
无论 CPython 参考实现如何选择处理此问题,优化编译器和解释器也仍然可以对调试器施加额外的限制,例如将通过帧对象进行的局部变量修改视为选择加入的行为,这可能会禁用某些优化(就像在某些 Python 实现中模拟 CPython 的帧 API 已经是选择加入标志一样)。
继续支持在优化帧上存储额外数据
此 PEP 的一个草稿版本曾提议移除通过写入帧 f_locals 属性的键(这些键不对应于底层帧上的局部变量或闭包变量名)来存储附加数据到优化帧上的能力。
虽然这个想法为快速 locals 代理的实现提供了一些有吸引力的简化,但 pdb 会将 __return__ 和 __exception__ 值存储在任意帧上,因此如果该功能不再起作用,标准库测试套件将失败。
因此,保留了存储任意键的能力,但代价是代理对象的某些操作比其他操作更慢(因为它们不能假定只有在代码对象上定义的名称才能通过代理访问)。
预计快速 locals 代理和底层帧上的 f_locals 值缓存之间的交互的精确细节将随着识别到改进机会而随时间演变。
函数作用域的历史语义
由于历史实现细节,CPython 中当前修改 locals() 和 frame.f_locals 的语义相当奇怪。
- 实际执行使用快速 locals 数组进行局部变量绑定,使用单元格引用进行非局部变量绑定。
- 存在一个
PyFrame_FastToLocals操作,它根据快速 locals 数组和任何引用的单元格的当前状态填充帧的f_locals属性。这存在的原因有三个:- 允许跟踪函数读取局部变量的状态。
- 允许回溯处理器读取局部变量的状态。
- 允许
locals()读取局部变量的状态。
- 从
locals()返回的frame.f_locals的直接引用,因此如果您分发多个并发引用,那么所有这些引用都将指向同一个字典。 - 逆向操作的两个常见调用
PyFrame_LocalsToFast在迁移到 Python 3 时已被移除:exec不再是语句(因此不能再影响函数局部命名空间),并且编译器现在禁止在函数作用域中使用from module import *操作。 - 但是,仍然存在两个晦涩的调用路径:
PyFrame_LocalsToFast作为从跟踪函数返回的一部分被调用(这允许调试器更改局部变量状态),并且您仍然可以通过直接从代码对象创建函数而不是通过编译器来注入IMPORT_STARopcode。
此提案故意不将这些语义标准化,因为它们仅在语言和参考实现的历史演变方面才有意义,而不是经过故意设计。
提议对稳定的 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。 - 始终需要当前 locals 命名空间的只读视图,而无需每次都承担完整复制的运行时开销。这对于优化帧来说不容易提供,因为需要检查名称当前是否绑定,因此没有添加特定的 API 来覆盖它。
历史上,这些类型的检查和操作只有在 Python 实现模拟完整的 CPython 帧 API 时才可能。通过建议的 API,扩展模块可以更清晰地请求它们实际需要的语义,从而为 Python 实现提供更多关于如何提供这些功能的灵活性。
与 PEP 667 的比较
注意:下面的比较是针对 2021 年 12 月的 PEP 667。它不反映 2024 年 4 月(此 PEP 已撤销以支持 PEP 667)的 PEP 667 状态。
PEP 667 提出了一个部分竞争性的提案,建议完全消除优化帧上的内部帧值缓存是合理的。
这些更改最初作为对 PEP 558 的修订提供,但 PEP 作者因三个主要原因拒绝了它们:
- 关于
PyEval_GetLocals()无法修复的初始说法(因为它返回一个借用引用)是错误的,因为它在 PEP 558 的参考实现中仍然有效。要使其继续工作,只需要保留内部帧值缓存,并以一种合理直接的方式设计快速 locals 代理,使其能够轻松地使缓存与帧状态的变化保持同步,而不会在不需要缓存时产生显着的运行时开销。鉴于此说法是错误的,要求所有使用PyEval_GetLocals()API 的代码重写为使用具有不同引用计数语义的新 API 的提案,未能满足 PEP 387 关于 API 兼容性中断应具有较大的收益/中断比的要求(由于删除缓存没有显着的收益,因此不能证明任何代码中断是合理的)。唯一真正无法修复的公共 API 是PyFrame_LocalsToFast()(这就是为什么两个 PEP 都提议中断它)。 - 没有某种形式的内部值缓存,快速 locals 代理映射的 API 性能特征将变得相当不直观。例如,
len(proxy)将始终是帧上定义的变量数量的 O(n),因为代理必须迭代整个快速 locals 数组以查看当前哪些名称绑定了值,然后才能确定答案。相比之下,维护一个内部帧值缓存可能会使代理在算法复杂性方面在很大程度上被视为普通字典,只需要为第一次执行依赖于缓存是最新的操作时运行的隐式 O(n) 缓存刷新做出一些调整。 - 声称无缓存实现会更简单是高度可疑的,因为 PEP 667 只包含了一个可变映射实现的子集的一个纯 Python 草图,而不是一个与优化帧的底层数据存储集成的、完整的功能 C 实现。 PEP 558 的快速 locals 代理实现大量委托给帧值缓存来执行实现可变映射 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 的作者最终同意,它们对于 locals() API 的用户来说会更简单,因此这两个 PEP 之间的区别已被消除:无论接受哪个 PEP 和实现,快速 locals 代理对象始终提供对局部变量当前状态的一致视图,即使这会导致某些操作变成 O(n)(而普通字典的 O(1) 操作会变得如此)(具体来说,len(proxy) 变成 O(n),因为它需要检查当前绑定了哪些名称,而代理映射比较避免依赖长度检查优化,该优化允许快速检测存储键数量的差异,而对于常规映射则可以)。
由于代理实现中采用了这些非标准性能特征,因此 PyLocals_GetView() 和 PyFrame_GetLocalsView() C API 也从此 PEP 的提案中被移除。
这样,两个 PEP 之间唯一剩下的区别点就只与 C API 相关了:
- PEP 667 仍然提出了完全不必要的 C API 破坏(对
PyEval_GetLocals()、PyFrame_FastToLocalsWithError()和PyFrame_FastToLocals()的程序化弃用和最终移除),但没有理由,因为在具有适当设计的快速 locals 代理实现的情况下,可以无限期地(并且可互操作地)保持这些功能正常工作。 - 此 PEP 中定义的快速 locals 代理对额外变量的处理方式与现有的
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)提供的实现草图。提供了改进的新快速 locals 代理 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 和 PEP 667 中都是 O(n) 的。可以提供自定义实现,这些实现比更新帧缓存更快,但尚不清楚加速这些操作所需的额外代码复杂性是否值得,因为它们只提供线性性能改进而不是算法复杂度改进。
通过添加不依赖于帧信息是最新的实现代码,可以恢复其他操作的 O(1) 性质。
将迭代器/可迭代对象检索方法保持为 O(1) 将涉及编写相应内置字典辅助类型的自定义替换,正如 PEP 667 中所建议的那样。如上所示,实现将类似于 PEP 667 中提供的伪代码,但并不完全相同(因为此 PEP 提供的改进的 PyEval_GetLocals() 互操作性会影响其存储额外变量的方式)。
可以通过创建依赖于改进的迭代 API 的自定义实现,将 popitem() 从“始终 O(n)”改进为“最坏情况 O(n)”。
为确保 Python 快速 locals 代理 API 中永不呈现过时的帧信息,在合并之前需要实现参考实现中的这些更改。
在撰写本文时(2021-10-24),当前实现还会在每个帧上存储快速引用映射的副本,而不是将单个实例存储在底层代码对象上(因为它仍然直接存储单元引用,而不是在每次访问快速 locals 数组时检查单元)。在合并之前,也需要修复这一点。
实施
参考实现更新正在 GitHub 上以草稿拉取请求的形式进行开发([6])。
致谢
感谢 Nathaniel J. Smith 提出[1] 中的直通代理想法,并指出了早期 PEP 版本中试图避免引入此类代理的一些关键设计缺陷。
感谢 Steve Dower 和 Petr Viktorin 要求对建议的 C API 扩展的开发者体验给予更多关注[8] [13]。
感谢 Larry Hastings 提出如何在使用枚举的同时确保它们安全地支持从任意整数进行类型转换。
感谢 Mark Shannon 推动进一步简化 C 级别 API 和语义,以及对 PEP 文本的重大澄清(以及在 2021 年初经过一年的不活跃后重启 PEP 讨论)[10] [11] [12]。Mark 发表的最终作为 PEP 667 发表的评论也直接导致了几项实现效率的提高,这些提高避免了在相关映射未被使用时产生冗余 O(n) 映射刷新操作的成本,以及确保 Python 级别 f_locals API 报告的状态永远不会过时的更改。
参考资料
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0558.rst