PEP 667 – 命名空间的一致视图
- 作者:
- Mark Shannon <mark at hotpy.org>, Tian Gao <gaogaotiantian at hotmail.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2021年7月30日
- Python 版本:
- 3.13
- 发布历史:
- 2021年8月20日,2024年2月22日
- 决议:
- 2024年4月25日
摘要
在早期版本的 Python 中,所有命名空间,无论是在函数、类还是模块中,都以相同的方式实现:作为一个字典。
出于性能原因,函数命名空间的实现发生了变化。不幸的是,这意味着通过 locals() 和 frame.f_locals 访问这些命名空间不再一致,并且随着线程、生成器和协程的添加,这些年来出现了一些奇怪的错误。
本 PEP 提议使这些命名空间再次保持一致。对 frame.f_locals 的修改将始终在底层变量中可见。对局部变量的修改将立即在 frame.f_locals 中可见,并且它们将与线程或协程无关地保持一致。
locals() 函数对类和模块作用域将与现在相同。对于函数作用域,它将返回底层 frame.f_locals 的瞬时快照,而不是隐式刷新帧对象上缓存的单个共享字典。
动机
在 Python 3.12 及更早版本中 locals() 和 frame.f_locals 的实现缓慢、不一致且存在错误。我们希望使其更快、一致,最重要的是修复这些错误。
例如,当尝试通过帧对象操作局部变量时
class C:
x = 1
sys._getframe().f_locals['x'] = 2
print(x)
打印 2,但
def f():
x = 1
sys._getframe().f_locals['x'] = 2
print(x)
f()
打印 1。
这不一致且令人困惑。更糟糕的是,Python 3.12 的行为可能导致奇怪的 错误。
有了这个 PEP,这两个例子都将打印 2,因为函数级别的更改将直接写入帧中优化的局部变量,而不是写入缓存的字典快照。
Python 3.12 的行为没有补偿优势;它不可靠且缓慢。
locals() 内建函数有其自身不理想的行为。有关这些问题的更多详细信息,请参阅 PEP 558。
基本原理
将 frame.f_locals 属性设为直写代理
Python 3.12 中 frame.f_locals 的实现返回一个从局部变量数组动态创建的字典。然后调试器和跟踪函数会调用 PyFrame_LocalsToFast() C API,以便将它们的更改写回数组(在 Python 3.11 之前,此 API 在每次跟踪函数调用后隐式调用,而不是由跟踪函数显式调用)。
这可能导致数组和字典彼此不同步。f_locals 帧属性的写入可能不会显示为对局部变量的修改,如果 PyFrame_LocalsToFast() 从未被调用。如果变量修改前创建的字典快照被写回帧(因为快照中存储的*每个*已知变量都被写回帧,即使帧中存储的值自快照创建以来已更改),则对局部变量的写入可能会丢失。
通过使 frame.f_locals 返回底层帧的视图,这些问题就消失了。frame.f_locals 始终与帧同步,因为它是一个视图,而不是一个副本。
使 locals() 内建函数返回独立的快照
PEP 558 考虑了三种潜在选项,用于标准化 优化作用域 中 locals() 内建函数的行为
- 保留历史行为,即对给定帧的每次
locals()调用都会更新局部变量的单个共享快照 - 使
locals()返回直写代理实例(类似于frame.f_locals) - 使
locals()返回真正独立的快照,以便通过exec()更改局部变量值的尝试将*始终*被忽略,而不是在某些情况下被接受
最后一种选项被选为最容易在语言参考中解释并被用户记住的选项
locals()内建函数在优化作用域中提供局部变量的瞬时快照,并在其他作用域中提供读/写访问;以及frame.f_locals在所有作用域中提供对局部变量的读/写访问,包括优化作用域
这种方法使得代码意图比如果两个 API 都授予优化作用域中的完全读/写访问权(即使不需要或不希望写访问权)更清晰。有关此设计决策的更多详细信息,请参阅 PEP 558,特别是 动机 部分和 在优化作用域中 eval() 和 exec() 的额外考虑。
这种方法并非没有缺点,下文的“向后兼容性”部分将对此进行讨论。
规范
Python API
frame.f_locals 属性
对于模块和类作用域(包括 exec() 和 eval() 调用),frame.f_locals 是对代码执行中使用的局部变量命名空间的直接引用。
对于函数作用域(以及其他 优化作用域),它将是新的直写代理类型的一个实例,可以直接修改底层帧中优化的局部变量存储数组,以及对非局部变量的任何单元格引用的内容。
视图对象完整实现了 collections.abc.Mapping 接口,并且还实现了以下可变映射操作
- 使用赋值添加新的键/值对
- 使用赋值更新与键关联的值
- 通过
setdefault()方法进行条件赋值 - 通过
update()方法进行批量更新
即使内容相同,不同帧的视图也会比较不相等。
对 f_locals 映射的所有写入将立即在底层变量中可见。对底层变量的所有更改将立即在映射中可见。
f_locals 对象将是一个完整的映射,可以向其中添加任意键值对。通过代理添加的新名称将存储在底层帧对象上专门的共享字典中(因此给定帧的所有代理实例将能够访问以这种方式添加的任何名称)。
多余的键(不对应于底层帧上的局部变量)可以像往常一样通过 del 语句或 pop() 方法删除。
不支持使用 del 或 pop() 方法删除对应于底层帧上局部变量的键,尝试这样做将引发 ValueError。局部变量只能通过代理设置为 None(或其他值),不能完全解除绑定。
直写代理上未实现 clear() 方法,因为不清楚它应如何处理无法删除与局部变量对应的条目。
为了保持向后兼容性,需要生成新映射的代理 API(例如 copy())将生成常规的内置 dict 实例,而不是直写代理实例。
为了避免在帧对象和直写代理之间引入循环引用,每次访问 frame.f_locals 都会返回一个*新的*直写代理实例。
locals() 内建函数
locals() 将定义为
def locals():
frame = sys._getframe(1)
f_locals = frame.f_locals
if frame._is_optimized(): # Not an actual frame method
f_locals = dict(f_locals)
return f_locals
对于模块和类作用域(包括 exec() 和 eval() 调用),locals() 继续返回对代码执行中使用的局部变量命名空间的直接引用(该值也与 frame.f_locals 报告的值相同)。
在 优化作用域 中,每次调用 locals() 都将生成一个*独立的*局部变量快照。
eval() 和 exec() 内建函数
因为此 PEP 更改了 locals() 的行为,所以 eval() 和 exec() 的行为也随之改变。
假设有一个函数 _eval() 执行 eval() 的工作,并带有显式命名空间参数,则 eval() 可以定义如下
FrameProxyType = type((lambda: sys._getframe().f_locals)())
def eval(expression, /, globals=None, locals=None):
if globals is None:
# No globals -> use calling frame's globals
_calling_frame = sys._getframe(1)
globals = _calling_frame.f_globals
if locals is None:
# No globals or locals -> use calling frame's locals
locals = _calling_frame.f_locals
if isinstance(locals, FrameProxyType):
# Align with locals() builtin in optimized frame
locals = dict(locals)
elif locals is None:
# Globals but no locals -> use same namespace for both
locals = globals
return _eval(expression, globals, locals)
exec() 的指定参数处理也类似更新。
(在 Python 3.12 及更早版本中,无法向 eval() 或 exec() 提供 locals 而不提供 globals,因为这些以前是仅限位置参数。独立于此 PEP,Python 3.13 更新了这些内置函数以接受关键字参数)
C API
PyEval C API 的补充
将添加三个新的 C-API 函数
PyObject *PyEval_GetFrameLocals(void)
PyObject *PyEval_GetFrameGlobals(void)
PyObject *PyEval_GetFrameBuiltins(void)
PyEval_GetFrameLocals() 等同于: locals()。 PyEval_GetFrameGlobals() 等同于: globals()。
所有这些函数都将返回一个新引用。
PyFrame_GetLocals C API
现有的 PyFrame_GetLocals(f) C API 等同于 f.f_locals。其返回值将如上文访问 f.f_locals 所述。
此函数返回一个新引用,因此它能够适应在优化作用域中每次调用时创建新的直写代理实例。
已弃用的 C API
以下 C API 函数将被弃用,因为它们返回借用引用
PyEval_GetLocals()
PyEval_GetGlobals()
PyEval_GetBuiltins()
应使用以下函数(返回新引用)来代替
PyEval_GetFrameLocals()
PyEval_GetFrameGlobals()
PyEval_GetFrameBuiltins()
以下 C API 函数将变为无操作,并被弃用而无替代
PyFrame_FastToLocalsWithError()
PyFrame_FastToLocals()
PyFrame_LocalsToFast()
所有已弃用的函数都将在 Python 3.13 文档中标记为已弃用。
在这些函数中,只有 PyEval_GetLocals() 带来显著的维护负担。因此,对 PyEval_GetLocals() 的调用将在 Python 3.14 中发出 DeprecationWarning,目标删除日期为 Python 3.16(Python 3.14 之后的两个版本)。建议使用替代方案,如 PyEval_GetLocals 兼容性 中所述。
变更摘要
本节总结了 Python 3.13 及更高版本中指定行为与 Python 3.12 及更早版本中历史行为的不同之处。
Python API 变更
frame.f_locals 变更
考虑以下示例
def l():
"Get the locals of caller"
return sys._getframe(1).f_locals
def test():
if 0: y = 1 # Make 'y' a local variable
x = 1
l()['x'] = 2
l()['y'] = 4
l()['z'] = 5
y
print(locals(), x)
鉴于本 PEP 中的更改,test() 将打印 {'x': 2, 'y': 4, 'z': 5} 2。
在 Python 3.12 中,此示例将因 UnboundLocalError 而失败,因为通过 l()['y'] = 4 对 y 的定义丢失了。
如果倒数第二行从 y 更改为 z,这仍将引发 NameError,就像在 Python 3.12 中一样。添加到 frame.f_locals 中不是词法局部变量的键在 frame.f_locals 中仍然可见,但不会动态地成为局部变量。
locals() 变更
考虑以下示例
def f():
exec("x = 1")
print(locals().get("x"))
f()
鉴于本 PEP 中的更改,这将*始终*打印 None(无论 x 是否是函数中定义的局部变量),因为对 locals() 的显式调用会生成一个与 exec() 调用中隐式使用的快照不同的快照。
在 Python 3.12 中,所示的精确示例将打印 1,但与所涉及函数的定义看似无关的更改可能会使其打印 None(PEP 558 中的 在优化作用域中 eval() 和 exec() 的额外考虑 对该主题进行了更详细的讨论)。
eval() 和 exec() 变更
影响 eval() 和 exec() 的主要变化显示在“locals() 更改”示例中:在优化作用域中重复访问 locals() 将不再隐式共享一个共同的底层命名空间。
C API 变更
PyFrame_GetLocals 变更
PyFrame_GetLocals 在 Python 3.12 中已经可以返回任意映射,因为 exec() 和 eval() 接受任意映射作为其 locals 参数,并且元类可以从其 __prepare__ 方法返回任意映射。
在优化作用域中返回帧局部变量代理只是增加了另一种情况,即会返回内置字典以外的东西。
PyEval_GetLocals 变更
PyEval_GetLocals() 的语义在技术上没有改变,但实际上它们发生了变化,因为优化帧上缓存的字典不再与访问帧局部变量的其他机制(locals() 内建函数、PyFrame_GetLocals 函数、帧 f_locals 属性)共享。
向后兼容性
Python API 兼容性
Python 3.12 及更早版本中使用的实现存在许多边缘情况和奇怪之处。围绕这些问题编写的代码可能需要更改。用于简单模板或打印调试的 locals() 代码将继续正常工作。使用 f_locals 修改局部变量的调试器和其他工具现在将正常工作,即使在存在线程代码、协程和生成器的情况下也是如此。
frame.f_locals 兼容性
尽管 f.f_locals 的行为就像它是函数的命名空间一样,但会存在一些可观察到的差异。例如,对于优化帧,f.f_locals is f.f_locals 将为 False,因为每次访问属性都会生成一个新的直写代理实例。
然而,f.f_locals == f.f_locals 将为 True,并且对底层变量的所有更改,包括添加新的变量名作为映射键,都将始终可见。
locals() 兼容性
对于优化帧,locals() is locals() 将为 False,因此以下代码将引发 KeyError 而不是返回 1
def f():
locals()["x"] = 1
return locals()["x"]
为了继续工作,此类代码需要显式将要修改的命名空间存储在局部变量中,而不是依赖于帧对象上先前的隐式缓存
def f():
ns = {}
ns["x"] = 1
return ns["x"]
虽然这在技术上不是正式的向后兼容性破坏(因为写回 locals() 的行为明确记录为未定义),但确实有一些代码依赖于现有行为。因此,更新后的行为将在文档中明确注明为更改,并将在 Python 3.13 移植指南中涵盖。
为了在所有版本上使用优化作用域中 locals() 的副本,而不在 Python 3.13+ 上进行冗余复制,用户需要定义一个版本相关的辅助函数,该函数仅在 Python 3.13 之前的 Python 版本上进行显式复制
if sys.version_info >= (3, 13):
def _ensure_func_snapshot(d):
return d # 3.13+ locals() already returns a snapshot
else:
def _ensure_func_snapshot(d):
return dict(d) # Create snapshot on older versions
def f():
ns = _ensure_func_snapshot(locals())
ns["x"] = 1
return ns
在其他作用域中,locals().copy() 可以继续无条件调用,而不会引入任何冗余副本。
对 exec() 和 eval() 的影响
尽管此 PEP 未直接修改 exec() 或 eval(),但 locals() 的语义更改会影响 exec() 和 eval() 的行为,因为它们默认在调用命名空间中运行代码。
这给某些代码带来了潜在的兼容性问题,因为在以前的实现中,当 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()
根据本 PEP 对 locals() 的语义更改,exec('print(a)')' 调用将因 NameError 而失败,并且 print(locals()) 将报告一个空字典,因为每一行都将使用其自己独立的局部变量快照,而不是隐式共享存储在帧对象上的单个缓存快照。
通过使用显式命名空间而不是依赖以前隐式共享的帧命名空间,仍然可以在 exec() 调用之间获得共享命名空间
def f():
ns = {}
exec('a = 0', locals=ns)
exec('print(a)', locals=ns) # 0
f()
您甚至可以通过显式使用 frame.f_locals 来可靠地更改局部作用域中的变量,这在以前是不可能实现的(即使使用 ctypes 调用 PyFrame_LocalsToFast 也受本 PEP 其他部分讨论的状态不一致问题的影响)
def f():
a = None
exec('a = 0', locals=sys._getframe().f_locals)
print(a) # 0
f()
exec() 和 eval() 对于模块和类作用域(包括嵌套调用)的行为没有改变,因为 locals() 在这些作用域中的行为没有改变。
对标准库中其他代码执行 API 的影响
pdb 和 bdb 使用 frame.f_locals API,因此即使在优化帧中也能够可靠地更新局部变量。实施此 PEP 将解决这些模块中与线程、生成器、协程以及在调试器活动时允许并发代码执行的其他机制相关的几个长期存在的错误。
标准库中的其他代码执行 API(例如 code 模块)不会隐式访问 locals() *或* frame.f_locals,但显式传递这些命名空间的行为将按本 PEP 其余部分所述进行更改(在优化作用域中传递 locals() 将不再隐式共享跨调用的代码执行命名空间,在优化作用域中传递 frame.f_locals 将允许可靠地修改局部变量和非局部单元格引用)。
C API 兼容性
PyEval_GetLocals 兼容性
PyEval_GetLocals() 历史上从未区分它是在 Python 级别模拟 locals() 还是 sys._getframe().f_locals,因为它们都返回对局部变量绑定的同一共享缓存的引用。
根据本 PEP,locals() 更改为在每次调用优化帧时返回独立的快照,而 frame.f_locals(以及 PyFrame_GetLocals)更改为返回新的直写代理实例。
由于 PyEval_GetLocals() 返回一个借用引用,因此无法更新其语义以与这些替代方案中的任何一个对齐,使其成为唯一需要存储在帧对象上的共享缓存字典的剩余 API。
虽然这在技术上使函数的语义保持不变,但它不再允许将额外的字典条目暴露给其他 API 的用户,因为这些 API 不再访问相同的底层缓存字典。
当 PyEval_GetLocals() 被用作 Python locals() 内建函数的等价物时,应该使用 PyEval_GetFrameLocals() 来代替。
这段代码
locals = PyEval_GetLocals();
if (locals == NULL) {
goto error_handler;
}
Py_INCREF(locals);
应该替换为
// Equivalent to "locals()" in Python code
locals = PyEval_GetFrameLocals();
if (locals == NULL) {
goto error_handler;
}
当 PyEval_GetLocals() 被用作 Python 中调用 sys._getframe().f_locals 的等价物时,它应该替换为在 PyEval_GetFrame() 的结果上调用 PyFrame_GetLocals()。
在这些情况下,原始代码应替换为
// Equivalent to "sys._getframe()" in Python code
frame = PyEval_GetFrame();
if (frame == NULL) {
goto error_handler;
}
// Equivalent to "frame.f_locals" in Python code
locals = PyFrame_GetLocals(frame);
frame = NULL; // Minimise visibility of borrowed reference
if (locals == NULL) {
goto error_handler;
}
对 PEP 709 内联推导式的影响
对于函数内部的内联推导式,locals() 当前在推导式内部或外部的行为相同,并且这不会改变。函数内部 locals() 的行为将普遍按照本 PEP 的其余部分指定进行更改。
对于模块或类作用域中的内联推导式,在内联推导式中调用 locals() 会为每次调用返回一个新的字典。本 PEP 将使函数内部的 locals() 也始终为每次调用返回一个新字典,从而提高一致性;类或模块作用域中的内联推导式将表现得好像内联推导式仍然是一个独立的函数。
实施
每次读取 frame.f_locals 都将创建一个新的代理对象,该对象表现为局部(包括单元格和自由)变量名称到这些局部变量值的映射。
下面概述了可能的实现。所有以下划线开头的属性都是不可见的,不能直接访问。它们仅用于说明提议的设计。
NULL: Object # NULL is a singleton representing the absence of a value.
class CodeType:
_name_to_offset_mapping_impl: dict | NULL
_cells: frozenset # Set of indexes of cell and free variables
...
def __init__(self, ...):
self._name_to_offset_mapping_impl = NULL
self._variable_names = deduplicate(
self.co_varnames + self.co_cellvars + self.co_freevars
)
...
@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:
_locals : array[Object] # The values of the local variables, items may be NULL.
_extra_locals: dict | NULL # Dictionary for storing extra locals not in _locals.
_locals_cache: FrameLocalsProxy | NULL # required to support PyEval_GetLocals()
def __init__(self, ...):
self._extra_locals = NULL
self._locals_cache = NULL
...
@property
def f_locals(self):
return FrameLocalsProxy(self)
class FrameLocalsProxy:
"Implements collections.MutableMapping."
__slots__ = ("_frame", )
def __init__(self, frame:FrameType):
self._frame = frame
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._locals[index]
if val is NULL:
raise KeyError(name)
if index in co._cells
val = val.cell_contents
if val is NULL:
raise KeyError(name)
return val
else:
if f._extra_locals is NULL:
raise KeyError(name)
return f._extra_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 index in co._cells
cell = f._locals[index]
cell.cell_contents = val
else:
f._locals[index] = val
else:
if f._extra_locals is NULL:
f._extra_locals = {}
f._extra_locals[name] = val
def __iter__(self):
f = self._frame
co = f.f_code
yield from iter(f._extra_locals)
for index, name in enumerate(co._variable_names):
val = f._locals[index]
if val is NULL:
continue
if index in co._cells:
val = val.cell_contents
if val is NULL:
continue
yield name
def __contains__(self, item):
f = self._frame
if item in f._extra_locals:
return True
return item in co._variable_names
def __len__(self):
f = self._frame
co = f.f_code
res = 0
for index, _ in enumerate(co._variable_names):
val = f._locals[index]
if val is NULL:
continue
if index in co._cells:
if val.cell_contents is NULL:
continue
res += 1
return len(self._extra_locals) + res
C API
PyEval_GetLocals() 将大致按以下方式实现
PyObject *PyEval_GetLocals(void) {
PyFrameObject * = ...; // Get the current frame.
if (frame->_locals_cache == NULL) {
frame->_locals_cache = PyEval_GetFrameLocals();
} else {
PyDict_Update(frame->_locals_cache, PyFrame_GetLocals(frame));
}
return frame->_locals_cache;
}
与所有返回借用引用的函数一样,必须注意确保引用不会超出对象的生命周期使用。
实现说明
在接受时,PEP 文本建议 PyEval_GetLocals 将开始返回新的直写代理的缓存实例,而实现草图表明它将继续返回帧实例上缓存的字典快照。在实施 PEP 时发现了这种差异,并由 指导委员会 解决,倾向于保留 Python 3.12 返回帧实例上缓存的字典快照的行为。PEP 文本已相应更新。
在 C API 澄清讨论中,也变得明显,优化作用域 中 locals() 更新为返回独立快照的理由不清楚,因为它继承自原始 PEP 558 讨论,而不是在本 PEP 中独立涵盖。PEP 文本已更新以更好地涵盖此更改,并对规范和向后兼容性部分进行了额外更新,以涵盖对默认在 locals() 命名空间中执行代码的代码执行 API 的影响。对 PEP 558 也添加了额外的动机和理由细节。
在 3.13.0 中,直写代理不允许使用 del 和 pop() 删除甚至额外的变量。这随后被报告为 兼容性回归,并已 解决,现在如 frame.f_locals 属性 中所述。
与 PEP 558 的比较
本 PEP 和 PEP 558 有一个共同的目标:使 locals() 和 frame.f_locals() 的语义易于理解,并且操作可靠。
本 PEP 与 PEP 558 的主要区别在于,PEP 558 试图将额外变量存储在局部变量的完整内部字典副本中,以努力提高与旧版 PyEval_GetLocals() API 的向后兼容性,而本 PEP 则不这样做(它将额外局部变量存储在专门的字典中,仅通过新的帧代理对象访问,并且仅在请求时将其复制到 PyEval_GetLocals() 共享字典中)。
PEP 558 没有明确指定内部副本何时更新,这使得在某些情况下无法推断 PEP 558 的行为,而本 PEP 仍然有明确的规范。
PEP 558 还提议在 C API 中引入一些额外的 Python 作用域自省接口,允许扩展模块更容易地确定当前活动的 Python 作用域是否已优化,从而确定 C API 的 locals() 等价物是返回对帧局部执行命名空间的直接引用,还是返回帧局部变量和非局部单元格引用的浅拷贝。是否添加此类自省 API 独立于 locals() 和 frame.f_locals 的提议更改,因此本 PEP 中未包含此类提议。
PEP 558 最终被 撤回,取而代之的是本 PEP。
参考实现
该实现正在 GitHub 上的草稿拉取请求 中开发。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0667.rst