PEP 227 – 静态嵌套作用域
- 作者:
- Jeremy Hylton <jeremy at alum.mit.edu>
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2000 年 11 月 1 日
- Python 版本:
- 2.1
- 发布历史:
摘要
本 PEP 描述了为 Python 2.2 添加静态嵌套作用域(词法作用域),并作为 Python 2.1 的源级别选项。此外,Python 2.1 将对启用此功能后其含义可能发生变化的构造发出警告。
旧的语言定义(2.0 及以前版本)精确定义了用于解析名称的三个命名空间——局部、全局和内置命名空间。嵌套作用域的添加允许在封闭函数的命名空间中解析未绑定的局部名称。
此更改最明显的结果是,lambda(以及其他嵌套函数)可以引用周围命名空间中定义的变量。目前,lambda 必须经常使用默认参数来显式地在 lambda 的命名空间中创建绑定。
引言
此提议更改了 Python 函数中自由变量的解析规则。新的名称解析语义将在 Python 2.2 中生效。通过在模块顶部添加“from __future__ import nested_scopes”,这些语义也将适用于 Python 2.1。(参见PEP 236)。
Python 2.0 定义精确指定了要检查每个名称的三个命名空间——局部命名空间、全局命名空间和内置命名空间。根据此定义,如果在函数 B 内定义了函数 A,则 B 中绑定的名称在 A 中不可见。该提议更改了规则,使得 B 中绑定的名称在 A 中可见(除非 A 包含隐藏 B 中绑定的名称绑定)。
本规范引入了 Algol 类语言中常见的词法作用域规则。词法作用域与现有的一等函数支持相结合,让人联想到 Scheme。
更改的作用域规则解决了两个问题——lambda 表达式(以及一般嵌套函数)的有限实用性,以及熟悉支持嵌套词法作用域的其他语言的新用户经常出现的混淆,例如除了模块级别之外无法定义递归函数。
lambda 表达式产生一个未命名的函数,它评估单个表达式。它通常用于回调函数。在下面的示例(使用 Python 2.0 规则编写)中,lambda 主体中使用的任何名称都必须显式作为默认参数传递给 lambda。
from Tkinter import *
root = Tk()
Button(root, text="Click here",
command=lambda root=root: root.test.configure(text="..."))
这种方法很麻烦,尤其是当 lambda 主体中使用了多个名称时。冗长的默认参数列表掩盖了代码的目的。提出的解决方案,粗略地说,自动实现了默认参数方法。“root=root”参数可以省略。
新的名称解析语义将导致某些程序的行为与 Python 2.0 下的行为不同。在某些情况下,程序将无法编译。在其他情况下,以前使用全局命名空间解析的名称将使用封闭函数的局部命名空间解析。在 Python 2.1 中,将对所有行为不同的语句发出警告。
规范
Python 是一种具有块结构的静态作用域语言,遵循 Algol 的传统。代码块或区域,例如模块、类定义或函数体,是程序的基本单元。
名称引用对象。名称通过名称绑定操作引入。程序文本中名称的每次出现都引用包含该名称使用的最内层函数块中建立的该名称的绑定。
名称绑定操作包括参数声明、赋值、类和函数定义、导入语句、for 语句和 except 子句。每个名称绑定都发生在由类或函数定义或模块级别(顶级代码块)定义的块内。
如果名称在代码块中的任何位置被绑定,则该块内所有对该名称的使用都被视为对当前块的引用。(注意:当名称在块内使用而未绑定时,这可能导致错误。)
如果 global 语句出现在块内,则语句中指定的名称的所有使用都引用该名称在顶级命名空间中的绑定。名称在顶级命名空间中通过搜索全局命名空间(即包含代码块的模块的命名空间)和内置命名空间(即 __builtin__ 模块的命名空间)来解析。首先搜索全局命名空间。如果未找到名称,则搜索内置命名空间。global 语句必须在所有名称使用之前。
如果名称在代码块内使用,但未在该块中绑定且未声明为 global,则该使用被视为引用最近的封闭函数区域。(注意:如果一个区域包含在类定义中,则在类块中发生的名称绑定对封闭函数不可见。)
类定义是可执行语句,可能包含名称的使用和定义。这些引用遵循名称解析的正常规则。类定义的命名空间成为类的属性字典。
以下操作是名称绑定操作。如果它们发生在块内,则它们会在当前块中引入新的局部名称,除非还有 global 声明。
Function definition: def name ...
Argument declaration: def f(...name...), lambda ...name...
Class definition: class name ...
Assignment statement: name = ...
Import statement: import name, import module as name,
from module import name
Implicit assignment: names are bound by for statements and except
clauses
在某些情况下,当 Python 语句与包含自由变量的嵌套作用域一起使用时,它们是非法的。
如果在封闭作用域中引用了变量,则删除该名称是错误的。编译器将为“del name”引发 SyntaxError。
如果函数中使用了通配符形式的 import(import *),并且该函数包含一个带有自由变量的嵌套块,则编译器将引发 SyntaxError。
如果在函数中使用了 exec,并且该函数包含一个带有自由变量的嵌套块,则编译器将引发 SyntaxError,除非 exec 显式指定了 exec 的局部命名空间。(换句话说,“exec obj”是非法的,但“exec obj in ns”是合法的。)
如果在函数作用域中绑定的名称也是模块全局名称或标准内置名称,并且该函数包含一个引用该名称的嵌套函数作用域,则编译器将发出警告。名称解析规则在 Python 2.0 下和 Python 2.2 下将导致不同的绑定。警告表明程序可能无法在所有版本的 Python 中正确运行。
讨论
指定的规则允许在一个函数中定义的名称在该函数中定义的任何嵌套函数中被引用。名称解析规则对于静态作用域语言来说是典型的,但有三个主要例外:
- 类作用域中的名称不可访问。
- global 语句短路了正常规则。
- 变量未声明。
类作用域中的名称不可访问。名称在最内层的封闭函数作用域中解析。如果类定义发生在嵌套作用域链中,则解析过程会跳过类定义。此规则可防止类属性和局部变量访问之间出现奇怪的交互。如果名称绑定操作发生在类定义中,它会在结果类对象上创建一个属性。要访问方法中或方法中嵌套函数中的此变量,必须使用属性引用,无论是通过 self 还是通过类名。
另一种选择是允许类作用域中的名称绑定行为与函数作用域中的名称绑定完全相同。此规则将允许通过属性引用或简单名称引用类属性。此选项被排除,因为它与所有其他形式的类和实例属性访问不一致,这些访问始终使用属性引用。使用简单名称的代码将变得模糊不清。
global 语句短路了正常规则。根据该提案,global 语句的效果与 Python 2.0 中的效果完全相同。它也值得注意,因为它允许在一个块中执行的名称绑定操作更改另一个块(模块)中的绑定。
变量未声明。如果名称绑定操作在函数中的任何位置发生,则该名称被视为该函数的局部变量,并且所有引用都指向局部绑定。如果在名称绑定之前发生引用,则会引发 NameError。唯一的声明类型是 global 语句,它允许使用可变全局变量编写程序。因此,无法重新绑定在封闭作用域中定义的名称。赋值操作只能在当前作用域或全局作用域中绑定名称。缺少声明以及无法在封闭作用域中重新绑定名称对于词法作用域语言来说是不寻常的;通常有一个机制来创建名称绑定(例如 Scheme 中的 lambda 和 let)以及一个机制来更改绑定(Scheme 中的 set!)。
示例
包含一些示例以说明规则的工作方式。
>>> def make_adder(base):
... def adder(x):
... return base + x
... return adder
>>> add5 = make_adder(5)
>>> add5(6)
11
>>> def make_fact():
... def fact(n):
... if n == 1:
... return 1L
... else:
... return n * fact(n - 1)
... return fact
>>> fact = make_fact()
>>> fact(7)
5040L
>>> def make_wrapper(obj):
... class Wrapper:
... def __getattr__(self, attr):
... if attr[0] != '_':
... return getattr(obj, attr)
... else:
... raise AttributeError, attr
... return Wrapper()
>>> class Test:
... public = 2
... _private = 3
>>> w = make_wrapper(Test())
>>> w.public
2
>>> w._private
Traceback (most recent call last):
File "<stdin>", line 1, in ?
AttributeError: _private
Tim Peters 的一个示例演示了在没有声明的情况下嵌套作用域的潜在陷阱。
i = 6
def f(x):
def g():
print i
# ...
# skip to the next page
# ...
for i in x: # ah, i *is* local to f, so this is what g sees
pass
g()
g() 的调用将引用由 for 循环在 f() 中绑定的变量 i。如果在循环执行之前调用 g(),将引发 NameError。
向后兼容性
嵌套作用域导致两种兼容性问题。在一种情况下,早期版本中以某种方式运行的代码因为嵌套作用域而行为不同。在其他情况下,某些构造与嵌套作用域交互不良,并在编译时触发 SyntaxErrors。
Skip Montanaro 的以下示例说明了第一类问题:
x = 1
def f1():
x = 2
def inner():
print x
inner()
根据 Python 2.0 规则,inner() 内部的 print 语句引用全局变量 x,如果调用 f1(),将打印 1。根据新规则,它引用 f1() 的命名空间,即具有绑定的最近封闭作用域。
该问题仅在全局变量和局部变量共享相同名称且嵌套函数使用该名称引用全局变量时发生。这是一种糟糕的编程习惯,因为读者很容易混淆这两个不同的变量。在实现嵌套作用域期间,在 Python 标准库中发现了一个此类问题的示例。
为了解决这个问题,它不太可能经常发生,Python 2.1 编译器(当未启用嵌套作用域时)会发出警告。
另一个兼容性问题是由在函数体中使用 import * 和 'exec' 引起的,当该函数包含嵌套作用域且包含作用域具有自由变量时。例如:
y = 1
def f():
exec "y = 'gotcha'" # or from module import *
def g():
return y
...
在编译时,编译器无法判断对局部命名空间进行操作的 exec 或 import * 是否会引入遮蔽全局 y 的名称绑定。因此,无法判断 g() 中对 y 的引用应该引用全局变量还是 f() 中的局部名称。
在 python-list 的讨论中,人们对两种可能的解释都进行了争论。一方面,一些人认为 g() 中的引用应该绑定到局部 y(如果存在)。这种解释的一个问题是,代码的人类读者无法通过局部检查确定 y 的绑定。这似乎很可能引入细微的错误。另一种解释是将 exec 和 import * 视为不影响静态作用域的动态特性。在这种解释下,exec 和 import * 将引入局部名称,但这些名称永远不会对嵌套作用域可见。在上面的特定示例中,代码的行为将与早期版本的 Python 中的行为完全相同。
由于每种解释都有问题且确切含义模糊,编译器会引发异常。当未启用嵌套作用域时,Python 2.1 编译器会发出警告。
对三个 Python 项目(标准库、Zope 和 PyXPCOM 的测试版)的简要审查发现,在大约 200,000 行代码中存在四个向后兼容性问题。标准库中有一个案例 #1(细微行为变化)的示例和两个 import * 问题的示例。
(在 Python 2.1a2 中实现的 import * 和 exec 限制的解释要严格得多,基于参考手册中从未强制执行的语言。发布后放宽了这些限制。)
C API 的兼容性
此实现导致几个 Python C API 函数发生更改,包括 PyCode_New()。因此,C 扩展可能需要更新才能与 Python 2.1 正常工作。
locals() / vars()
这些函数返回一个包含当前作用域局部变量的字典。对字典的修改不影响变量的值。根据当前规则,使用 locals() 和 globals() 允许程序访问所有名称解析的命名空间。
不会为嵌套作用域提供类似的函数。根据此提案,将无法以字典样式访问所有可见作用域。
警告和错误
Python 2.1 编译器将发出警告,以帮助识别在未来版本的 Python 下可能无法编译或正确运行的程序。在 Python 2.2 或 Python 2.1 中,如果使用 nested_scopes future 语句(在本节中统称为“未来语义”),编译器在某些情况下将发出 SyntaxErrors。
警告通常适用于包含具有自由变量的嵌套函数的函数。例如,如果函数 F 包含函数 G 且 G 使用内置的 len(),则 F 是一个包含嵌套函数 (G) 且具有自由变量 (len) 的函数。标签“free-in-nested”将用于描述这些函数。
函数作用域中使用的 import *
语言参考规定 import * 只能出现在模块作用域中。(第 6.11 节)C Python 的实现支持函数作用域中的 import *。
如果在 free-in-nested 函数体中使用 import *,编译器将发出警告。在未来语义下,编译器将引发 SyntaxError。
函数作用域中的裸 exec
exec 语句允许在关键字“in”后面跟两个可选表达式,用于指定局部变量和全局变量的命名空间。省略这两个命名空间的 exec 语句是裸 exec。
如果在 free-in-nested 函数体中使用裸 exec,编译器将发出警告。在未来语义下,编译器将引发 SyntaxError。
局部变量遮蔽全局变量
如果 free-in-nested 函数具有局部变量绑定,且该变量 (1) 在嵌套函数中使用,并且 (2) 与全局变量同名,则编译器将发出警告。
重新绑定封闭作用域中的名称
支持在封闭作用域中重新绑定名称存在技术困难,但当前提案不允许这样做的主要原因是 Guido 反对此。他的动机是:它难以支持,因为它需要一种新机制,允许程序员指定块中的赋值应该重新绑定封闭块中的名称;大概一个关键字或特殊语法 (x := 3) 可以实现这一点。鉴于这将鼓励使用局部变量来保存最好存储在类实例中的状态,因此不值得添加新语法来实现这一点(Guido 认为)。
拟议的规则允许程序员实现重新绑定的效果,尽管有些笨拙。将被封闭函数有效重新绑定的名称绑定到一个容器对象。程序不使用赋值,而是通过修改容器来实现所需的效果。
def bank_account(initial_balance):
balance = [initial_balance]
def deposit(amount):
balance[0] = balance[0] + amount
return balance
def withdraw(amount):
balance[0] = balance[0] - amount
return balance
return deposit, withdraw
支持嵌套作用域中的重新绑定将使此代码更清晰。一个定义了 deposit() 和 withdraw() 方法并将余额作为实例变量的类会更清晰。由于类似乎以更直接的方式实现相同的效果,因此它们是首选。
实施
C Python 的实现使用扁平闭包[1]。每个被执行的 def 或 lambda 表达式,如果函数体或任何包含的函数有自由变量,都会创建一个闭包。使用扁平闭包,创建闭包的成本相对较高,但查找成本较低。
该实现添加了几个新的操作码和代码对象中的两种新名称。变量可以是特定代码对象的 cell 变量或自由变量。cell 变量由包含作用域引用;因此,定义它的函数必须在每次调用时为其分配单独的存储空间。自由变量通过函数的闭包引用。
扁平闭包的选择基于三个因素。首先,假定嵌套函数不经常使用,深度嵌套(多层嵌套)使用频率更低。其次,嵌套作用域中名称的查找应该很快。第三,嵌套作用域的使用,特别是当返回一个访问封闭作用域的函数时,不应阻止垃圾回收器回收未引用的对象。
参考资料
版权
来源:https://github.com/python/peps/blob/main/peps/pep-0227.rst