类的范围和列表、集合或字典推导式以及生成器表达式不能混合使用。
为什么会这样呢?或者说,官方对此的解释是什么?
在Python 3中,列表推导式被赋予了自己的适当作用域(局部命名空间),以防止它们的局部变量泄漏到周围的作用域中(参见
列表推导式在作用域之后重新绑定名称。这样做对吗?)。当在模块或函数中使用这样的列表推导式时,这非常好,但在类中,作用域有点,嗯,奇怪。
这在
pep 227中有记录:
类作用域中的名称不可访问。名称在最内层的封闭函数作用域中解析。如果类定义出现在一系列嵌套作用域中,则解析过程会跳过类定义。
还有在
class
复合语句文档中也有相关说明:
类的套件然后在一个新的执行框架中执行(参见
命名和绑定),使用新创建的本地命名空间和原始全局命名空间。(通常,套件只包含函数定义。)当类的套件执行完成时,
它的执行框架被丢弃,但其本地命名空间被保存。
[4] 然后使用继承列表为基类创建一个类对象,并使用保存的本地命名空间作为属性字典。
重点是,执行框架是临时作用域。
因为作用域被重新用作类对象上的属性,允许它作为非局部作用域使用会导致未定义的行为;例如,如果一个类方法引用了
x
作为嵌套作用域变量,然后还操作了
Foo.x
,那会发生什么?更重要的是,这对于
Foo
的子类意味着什么?Python必须将类作用域与函数作用域区别对待,因为它们之间非常不同。
最后但绝对不是最不重要的,执行模型文档中链接的
命名和绑定部分明确提到了类作用域。
The scope of names defined in a class block is limited to the class block; it does not extend to the code blocks of methods – this includes comprehensions and generator expressions since they are implemented using a function scope. This means that the following will fail:
class A:
a = 42
b = list(a + i for i in range(10))
所以,总结一下:你无法从函数、列表推导式或生成器表达式中访问类作用域;它们的行为就像该作用域不存在一样。在Python 2中,列表推导式使用了一个快捷方式,但在Python 3中,它们有了自己的函数作用域(正如它们一直应该有的),因此你的示例会出错。其他推导类型不管Python版本如何,都有自己的作用域,所以在Python 2中,使用集合推导式或字典推导式的类似示例也会出错。
y = {x: x for i in range(1)}
(小)例外情况;或者,为什么一个部分可能仍然有效
无论Python版本如何,理解或生成器表达式中的一部分都会在周围的作用域中执行。这将是最外层可迭代对象的表达式。在您的示例中,它是range(1)
:
y = [x for i in range(1)]
因此,在该表达式中使用
x
不会引发错误。
y = [i for i in range(x)]
这仅适用于最外层的可迭代对象;如果一个推导式有多个
for
子句,那么内部
for
子句的可迭代对象将在推导式的作用域中进行评估。
y = [i for i in range(1) for j in range(x)]
这个设计决策是为了在生成器表达式的最外层可迭代对象抛出错误时,在生成器表达式创建时而不是迭代时抛出错误,或者当最外层可迭代对象实际上不可迭代时抛出错误。为了保持一致性,推导式也共享这种行为。
深入了解;或者说,比你想要的更详细
你可以使用
dis
模块来观察这一切的运作。我在下面的示例中使用的是Python 3.3,因为它添加了可以清晰地标识我们想要检查的代码对象的
限定名称。生成的字节码在功能上与Python 3.2完全相同。
要
创建一个类,Python基本上会将构成类体的整个套件(即比
class <name>:
行缩进一级的所有内容)作为一个函数执行:
>>> import dis
>>> def foo():
... class Foo:
... x = 5
... y = [x for i in range(1)]
... return Foo
...
>>> dis.dis(foo)
2 0 LOAD_BUILD_CLASS
1 LOAD_CONST 1 (<code object Foo at 0x10a436030, file "<stdin>", line 2>)
4 LOAD_CONST 2 ('Foo')
7 MAKE_FUNCTION 0
10 LOAD_CONST 2 ('Foo')
13 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
16 STORE_FAST 0 (Foo)
5 19 LOAD_FAST 0 (Foo)
22 RETURN_VALUE
第一个
LOAD_CONST
加载了一个
Foo
类体的代码对象,然后将其转换为函数并调用它。该调用的结果被用来创建类的命名空间,即
__dict__
。到目前为止一切顺利。
需要注意的是字节码中包含了一个嵌套的代码对象;在Python中,类定义、函数、推导式和生成器都被表示为包含字节码以及表示局部变量、常量、全局变量和嵌套作用域变量的结构的代码对象。编译后的字节码引用了这些结构,Python解释器知道如何根据给定的字节码访问这些结构。
重要的是要记住Python在编译时创建这些结构;
class
套件是一个已经编译的代码对象(
<code object Foo at 0x10a436030, file "<stdin>", line 2>
)。
让我们检查一下创建类体本身的代码对象;代码对象具有一个
co_consts
结构。
>>> foo.__code__.co_consts
(None, <code object Foo at 0x10a436030, file "<stdin>", line 2>, 'Foo')
>>> dis.dis(foo.__code__.co_consts[1])
2 0 LOAD_FAST 0 (__locals__)
3 STORE_LOCALS
4 LOAD_NAME 0 (__name__)
7 STORE_NAME 1 (__module__)
10 LOAD_CONST 0 ('foo.<locals>.Foo')
13 STORE_NAME 2 (__qualname__)
3 16 LOAD_CONST 1 (5)
19 STORE_NAME 3 (x)
4 22 LOAD_CONST 2 (<code object <listcomp> at 0x10a385420, file "<stdin>", line 4>)
25 LOAD_CONST 3 ('foo.<locals>.Foo.<listcomp>')
28 MAKE_FUNCTION 0
31 LOAD_NAME 4 (range)
34 LOAD_CONST 4 (1)
37 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
40 GET_ITER
41 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
44 STORE_NAME 5 (y)
47 LOAD_CONST 5 (None)
50 RETURN_VALUE
上述的字节码创建了类体。函数被执行,产生的locals()命名空间包含x和y,用于创建类(但由于x没有定义为全局变量,所以无法工作)。请注意,在将5存储在x中之后,它加载另一个代码对象;那就是列表推导式;它被包装在一个函数对象中,就像类体一样;创建的函数接受一个位置参数,即用于循环代码的range(1)可迭代对象,转换为迭代器。如字节码所示,range(1)在类作用域中进行评估。
从这里可以看出,函数或生成器的代码对象与推导式的代码对象之间唯一的区别是,当父代码对象执行时,后者会立即执行;字节码只是在几个小步骤中动态创建并执行函数。
Python 2.x在此处使用内联字节码,以下是Python 2.7的输出:
2 0 LOAD_NAME 0 (__name__)
3 STORE_NAME 1 (__module__)
3 6 LOAD_CONST 0 (5)
9 STORE_NAME 2 (x)
4 12 BUILD_LIST 0
15 LOAD_NAME 3 (range)
18 LOAD_CONST 1 (1)
21 CALL_FUNCTION 1
24 GET_ITER
>> 25 FOR_ITER 12 (to 40)
28 STORE_NAME 4 (i)
31 LOAD_NAME 2 (x)
34 LIST_APPEND 2
37 JUMP_ABSOLUTE 25
>> 40 STORE_NAME 5 (y)
43 LOAD_LOCALS
44 RETURN_VALUE
没有加载任何代码对象,而是在内联中运行一个FOR_ITER循环。因此,在Python 3.x中,列表生成器被赋予了自己的适当的代码对象,这意味着它有自己的作用域。
然而,当模块或脚本首次被解释器加载时,推导式会与其余的Python源代码一起编译,并且编译器不认为类套件是一个有效的作用域。列表推导式中的任何引用变量必须在类定义周围的作用域中进行递归查找。如果编译器没有找到该变量,它将将其标记为全局变量。对列表推导式代码对象的反汇编显示,确实将x作为全局变量加载:
>>> foo.__code__.co_consts[1].co_consts
('foo.<locals>.Foo', 5, <code object <listcomp> at 0x10a385420, file "<stdin>", line 4>, 'foo.<locals>.Foo.<listcomp>', 1, None)
>>> dis.dis(foo.__code__.co_consts[1].co_consts[2])
4 0 BUILD_LIST 0
3 LOAD_FAST 0 (.0)
>> 6 FOR_ITER 12 (to 21)
9 STORE_FAST 1 (i)
12 LOAD_GLOBAL 0 (x)
15 LIST_APPEND 2
18 JUMP_ABSOLUTE 6
>> 21 RETURN_VALUE
这段字节码加载传入的第一个参数(
range(1)
迭代器),就像Python 2.x版本一样使用
FOR_ITER
循环遍历它并创建输出。
如果我们在
foo
函数中定义了
x
,那么
x
将是一个单元变量(单元变量指的是嵌套作用域)。
>>> def foo():
... x = 2
... class Foo:
... x = 5
... y = [x for i in range(1)]
... return Foo
...
>>> dis.dis(foo.__code__.co_consts[2].co_consts[2])
5 0 BUILD_LIST 0
3 LOAD_FAST 0 (.0)
>> 6 FOR_ITER 12 (to 21)
9 STORE_FAST 1 (i)
12 LOAD_DEREF 0 (x)
15 LIST_APPEND 2
18 JUMP_ABSOLUTE 6
>> 21 RETURN_VALUE
LOAD_DEREF
会间接地从代码对象的单元对象中加载
x
。
>>> foo.__code__.co_cellvars
('x',)
>>> foo.__code__.co_consts[2].co_cellvars
()
>>> foo.__code__.co_consts[2].co_consts[2].co_freevars
('x',)
>>> foo().y
[2]
实际的引用从当前帧数据结构中查找该值,这些数据结构是从函数对象的
.__closure__
属性初始化的。由于为推导式代码对象创建的函数会被丢弃,我们无法检查该函数的闭包。要查看闭包的运行情况,我们需要检查嵌套函数。
>>> def spam(x):
... def eggs():
... return x
... return eggs
...
>>> spam(1).__code__.co_freevars
('x',)
>>> spam(1)()
1
>>> spam(1).__closure__
>>> spam(1).__closure__[0].cell_contents
1
>>> spam(5).__closure__[0].cell_contents
5
所以,总结一下:
- 在Python 3(直到Python 3.11)中,列表推导式有自己的代码对象,函数、生成器和推导式之间没有区别;推导式代码对象被包装在临时函数对象中并立即调用。
- 代码对象在编译时创建,并根据代码的嵌套作用域将任何非局部变量标记为全局变量或自由变量。类体不被视为查找这些变量的作用域。
- 在执行代码时,Python 只需要查看全局变量或当前执行对象的闭包。由于编译器没有将类体包括在作用域中,因此临时函数命名空间不会被考虑。
解决方法;或者如何处理它
如果您为x
变量创建一个显式作用域,例如在一个函数中,您可以在列表推导式中使用类作用域变量:
>>> class Foo:
... x = 5
... def y(x):
... return [x for i in range(1)]
... y = y(x)
...
>>> Foo.y
[5]
“临时”的
y
函数可以直接调用;当我们使用它的返回值时,我们会替换它。在解析
x
时,会考虑它的作用域。
>>> foo.__code__.co_consts[1].co_consts[2]
<code object y at 0x10a5df5d0, file "<stdin>", line 4>
>>> foo.__code__.co_consts[1].co_consts[2].co_cellvars
('x',)
当然,阅读你的代码的人可能会对此感到困惑;你可能希望在那里加上一个大而明显的注释,解释为什么要这样做。
最好的解决方法是使用
__init__
来创建一个实例变量:
def __init__(self):
self.y = [self.x for i in range(1)]
避免所有的困惑和解释自己的问题。举个具体的例子,我甚至不会将`namedtuple`存储在类上;要么直接使用输出(根本不存储生成的类),要么使用全局变量。
from collections import namedtuple
State = namedtuple('State', ['name', 'capital'])
class StateDatabase:
db = [State(*args) for args in [
('Alabama', 'Montgomery'),
('Alaska', 'Juneau'),
]]
PEP 709,作为Python 3.12的一部分,再次改变了一些内容。
在Python 3.12中,通过移除嵌套函数并内联循环,大大提高了推导式的效率,同时仍保持独立的作用域。具体实现细节可以参考
PEP 709 - Inlined comprehensions,但简单来说,不再创建新的函数对象并调用它,而是通过
LOAD_CONST
、
MAKE_FUNCTION
和
CALL
字节码,将循环中使用的冲突名称先移到堆栈上,然后内联执行推导式字节码。
需要注意的是,
这个改变只影响性能,与类作用域的交互没有改变。你仍然无法访问在类作用域中创建的名称,原因如上所述。
使用Python 3.12.0b4,
Foo
类的字节码现在如下所示:
Disassembly of <code object Foo at 0x104e97000, file "<stdin>", line 2>:
2 0 RESUME 0
2 LOAD_NAME 0 (__name__)
4 STORE_NAME 1 (__module__)
6 LOAD_CONST 0 ('foo.<locals>.Foo')
8 STORE_NAME 2 (__qualname__)
3 10 LOAD_CONST 1 (5)
12 STORE_NAME 3 (x)
4 14 PUSH_NULL
16 LOAD_NAME 4 (range)
18 LOAD_CONST 2 (1)
20 CALL 1
28 GET_ITER
30 LOAD_FAST_AND_CLEAR 0 (.0)
32 LOAD_FAST_AND_CLEAR 1 (i)
34 LOAD_FAST_AND_CLEAR 2 (x)
36 SWAP 4
38 BUILD_LIST 0
40 SWAP 2
>> 42 FOR_ITER 8 (to 62)
46 STORE_FAST 1 (i)
48 LOAD_GLOBAL 6 (x)
58 LIST_APPEND 2
60 JUMP_BACKWARD 10 (to 42)
>> 62 END_FOR
64 SWAP 4
66 STORE_FAST 2 (x)
68 STORE_FAST 1 (i)
70 STORE_FAST 0 (.0)
72 STORE_NAME 5 (y)
74 RETURN_CONST 3 (None)
这里,最重要的字节码是偏移量为34的那个。
34 LOAD_FAST_AND_CLEAR 2 (x)
这将在局部作用域中获取变量x的值并将其推送到堆栈上,然后清除该名称。如果当前作用域中没有变量x,则在堆栈上存储一个C的NULL值。现在,该名称已经从局部作用域中消失,直到达到偏移量为66的字节码位置。
66 STORE_FAST 2 (x)
这将恢复
x
到列表推导之前的状态;如果在堆栈上存储了一个
NULL
来表示没有名为
x
的变量,那么在执行完这段字节码后仍然不会有变量
x
。
在
LOAD_FAST_AND_CLEAR
和
STORE_FAST
调用之间的其余字节码与之前几乎相同,只是使用
SWAP
字节码来访问
range(1)
对象的迭代器,而不是在早期Python 3.x版本中的函数字节码中使用
LOAD_FAST (.0)
。