Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

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 定义准确指定了三个命名空间来检查每个名称——局部命名空间、全局命名空间和内置命名空间。根据此定义,如果函数 A 在函数 B 内定义,则 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 语句必须在对名称的所有使用之前。

如果在代码块内使用了一个名称,但它没有在那里绑定并且没有声明为全局,则该使用被视为对最近的封闭函数区域的引用。(注意:如果一个区域包含在一个类定义中,则类块中发生的名称绑定对封闭函数不可见。)

类定义是一个可执行语句,它可能包含名称的使用和定义。这些引用遵循名称解析的正常规则。类定义的命名空间成为类的属性字典。

以下操作是名称绑定操作。如果它们出现在块中,除非还有全局声明,否则它们会在当前块中引入新的局部名称。

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 *),并且该函数包含一个带有自由变量的嵌套块,则编译器将引发 SyntaxError

如果在函数中使用 exec,并且该函数包含一个带有自由变量的嵌套块,除非 exec 显式指定 exec 的局部命名空间,否则编译器将引发 SyntaxError。(换句话说,“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。

向后兼容性

嵌套作用域会导致两种兼容性问题。在一种情况下,代码在早期版本中的行为方式有所不同,因为嵌套作用域。在另一种情况下,某些构造与嵌套作用域的交互不佳,并且将在编译时触发 SyntaxError。

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 的讨论中,人们对两种可能的解释都提出了论证。一方面,有些人认为,如果存在本地 y,则g()中的引用应该绑定到本地 y。这种解释的一个问题是,代码的阅读者无法通过局部检查来确定 y 的绑定。这似乎很可能引入细微的错误。另一种解释是将 exec 和 import * 视为不影响静态作用域的动态特性。在这种解释下,exec 和 import * 将引入局部名称,但这些名称永远不会对嵌套作用域可见。在上面的具体示例中,代码的行为将与 Python 的早期版本完全相同。

由于每种解释都存在问题,并且确切的含义不明确,因此编译器会引发异常。当嵌套作用域未启用时,Python 2.1 编译器会发出警告。

简要审查了三个 Python 项目(标准库、Zope 和 PyXPCOM 的 beta 版本),发现大约 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未来语句(在本节中统称为“未来语义”),则编译器在某些情况下会发出 SyntaxErrors。

警告通常适用于包含具有自由变量的嵌套函数的函数。例如,如果函数 F 包含函数 G 且 G 使用内置函数len(),则 F 就是一个包含具有自由变量(len)的嵌套函数(G)的函数。“自由嵌套”标签将用于描述这些函数。

在函数作用域中使用 import *

语言参考指定import *只能出现在模块作用域中。(第 6.11 节)C Python 的实现支持在函数作用域中使用import *

如果在自由嵌套函数的函数体中使用import *,编译器将发出警告。在未来语义下,编译器将引发SyntaxError

在函数作用域中使用裸 exec

exec 语句允许在关键字“in”之后跟随两个可选表达式,用于指定用于局部变量和全局变量的命名空间。省略这两个命名空间的 exec 语句是裸 exec。

如果在自由嵌套函数的函数体中使用裸 exec,编译器将发出警告。在未来语义下,编译器将引发SyntaxError

局部变量遮蔽全局变量

如果自由嵌套函数具有局部变量的绑定,该变量(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 表达式,如果函数体或任何包含的函数具有自由变量,则将创建一个闭包。使用扁平闭包,闭包的创建成本较高,但查找成本较低。

该实现添加了几个新的操作码和代码对象中两种新的名称类型。对于特定代码对象,变量可以是单元变量或自由变量。单元变量由包含的作用域引用;因此,定义它的函数必须在每次调用时为其分配单独的存储空间。自由变量通过函数的闭包引用。

自由闭包的选择基于三个因素。首先,假设嵌套函数的使用频率较低,深度嵌套(多级嵌套)的频率更低。其次,嵌套作用域中名称的查找应该很快。第三,嵌套作用域的使用,特别是当返回访问封闭作用域的函数时,不应阻止垃圾回收器回收未引用的对象。

参考文献


来源:https://github.com/python/peps/blob/main/peps/pep-0227.rst

上次修改时间:2023-09-09 17:39:29 GMT