闭包可以用几种方式实现,其中一种是实际上捕获环境...换句话说,考虑下面的例子:
def foo(x):
y = 1
z = 2
def bar(a):
return (x, y, a)
return bar
env-capturing解决方案如下:
1.输入foo,建立一个包含x、y、z、bar名称的本地框架。名称x绑定到参数,名称y和z绑定到1和2,名称bar绑定到闭包。
2.分配给bar的闭包实际上捕获了整个父级框架,因此在调用它时,可以在自己的本地框架中查找名称a,在捕获的父级框架中查找x和y。
通过这种方法(不是Python使用的方法),即使闭包没有引用它,变量z也将保持活动状态,只要闭包保持活动状态。
另一个稍微复杂一些的选项,则是按如下方式工作:
1.在编译时,分析代码并发现分配给bar的闭包捕获了当前范围内的名称x和y。
2.因此,将这两个变量分类为“单元格”,并从本地框架单独分配它们。
3.闭包存储这些变量的地址,并且每次访问它们都需要进行双重间接寻址(单元格是指向实际存储值的指针)。
这需要在创建闭包时多花点时间,因为每个捕获的单元格都需要被复制到闭包对象中(而不仅仅是复制父框架的指针)。但是,它的优点是不会捕获整个框架,因此例如z将在foo返回后不会保持活动状态,仅x和y会保持活动状态。
这就是Python的做法……基本上,在发现闭包(命名函数或lambda)时进行子编译时,在编译期间,当查找解析为父函数的查找时,将将变量标记为单元格。
一个小烦恼是,当捕获参数时(如在foo示例中),还需要在序言中执行额外的复制操作,以将传递的值转换为单元格。在Python中,这在字节码中不可见,但直接由调用机制执行。
另一个烦恼是,每次访问捕获的变量都需要在父上下文中进行双重间接寻址。
优点是,闭包仅捕获实际引用的变量,并且当它们不捕获任何变量时,生成的代码与常规函数一样有效率。
要了解Python如何工作,请使用dis模块检查生成的字节码:
>>> dis.dis(foo)
2 0 LOAD_CONST 1 (1)
3 STORE_DEREF 1 (y)
3 6 LOAD_CONST 2 (2)
9 STORE_FAST 1 (z)
4 12 LOAD_CLOSURE 0 (x)
15 LOAD_CLOSURE 1 (y)
18 BUILD_TUPLE 2
21 LOAD_CONST 3 (<code object bar at 0x7f6ff6582270, file "<stdin>", line 4>)
24 LOAD_CONST 4 ('foo.<locals>.bar')
27 MAKE_CLOSURE 0
30 STORE_FAST 2 (bar)
6 33 LOAD_FAST 2 (bar)
36 RETURN_VALUE
>>>
正如您所见,生成的代码使用“STORE_DEREF”(将值写入单元格,因此使用双重间接寻址)将“1”存储到“y”中,并使用“STORE_FAST”将“2”存储到“z”中(“z”未被捕获,只是当前帧中的本地变量)。当“foo”的代码开始执行时,“x”已经被调用机制包装成一个单元格。
“bar”只是一个局部变量,因此使用“STORE_FAST”来写入它,但要构建闭包,则需要逐个复制“x”和“y”(它们在调用“MAKE_CLOSURE”操作码之前放入元组中)。
闭包本身的代码可以通过以下方式查看:
>>> dis.dis(foo(12))
5 0 LOAD_DEREF 0 (x)
3 LOAD_DEREF 1 (y)
6 LOAD_FAST 0 (a)
9 BUILD_TUPLE 3
12 RETURN_VALUE
您可以看到,在返回的闭包内部,x
和y
的访问使用了LOAD_DEREF
。无论一个变量在嵌套函数层次结构中定义多少级别的“向上”,它实际上只是双重间接性,因为构建闭包时需要付出代价。与本地变量相比,封闭的变量访问速度略慢(通过一个常数因子),但在运行时不需要遍历“作用域链”。
甚至更为复杂的编译器(例如生成本地代码的Common Lisp优化编译器SBCL)还执行“逃逸分析”以检测闭包是否确实能够存活于封闭函数中。当这种情况没有发生时(即如果bar
仅在foo
内部使用而不被存储或返回),单元格可以分配到堆栈中而不是堆中,从而降低运行时“consing”的数量(分配在堆上需要进行垃圾回收才能被重新获取的对象)。
这种区别在文献中被称为“向下/向上Funarg”,即捕获的变量是否只能在较低级别(即在闭包或在闭包内创建的更深层次的闭包中)可见,还是在更高级别(即是否我的调用者能够访问我的捕获的本地变量)。
要解决向上funarg问题,需要垃圾收集器,这就是为什么C++闭包不提供此功能的原因。