16-闭包的底层实现
16-闭包的底层实现以及调用
楔子
上一篇我们看了函数是如何调用的,这一次我们看一下函数中局部变量的访问、以及闭包相关的知识。
函数中局部变量的访问
我们说过函数的参数和函数内部定义的变量都属于局部变量,所以它也一样是通过静态的方式进行访问。
1 | x = 123 |
因此我们看到,无论是参数还是内部新创建的变量,本质上都是局部变量。并且我们发现如果函数内部定义的变量如果和函数参数一致,那么参数就没用了,很好理解,因为本质上就相当于重新赋值罢了,此时外面无论给bar2函数的a、b参数传递什么,最终都会变成1和2。所以其实局部变量的实现机制和函数参数的实现机制是一致的。
按照我们的理解,当访问一个全局变量的时候,会去访问 global 名字空间,而这也确实如此。但是当访问函数内的局部变量的时候,是不是访问其内部的 local 名字空间呢? 之前我们说过 Python 变量的访问是有规则的,按照本地
、闭包
、全局
、内置
的顺序去查找,所以首当其冲当然去 local 名字空间去查找啊。但不幸的是,在调用函数期间,Python 通过 _PyFrame_New_NoTrack
创建 PyFrameObject 对象时,这个至关重要的 local 名字空间并没有被创建。
1 | //frameobject.c |
在前面对函数调用时的 global 名字空间的解析中,我们看到,当 Python 虚拟机执行 xxx.py
的时候,f_locals 和 f_globals 指向的是同一个 PyDictObject 对象,然而现在在函数里面 f_locals 则变成了NULL,那么的话,那些重要的符号到底存储在什么地方呢?(显然我们知道是符号表co_varnames中, 但你们就装作不知道配合我一下好吧(#^.^#))
。别急,我们先来看看使用局部变量的函数。
1 | def foo(a, b): |
看一下它的字节码:
1 | 1 0 LOAD_CONST 0 (<code object foo at 0x0000013E31511450, file "local", line 1>) |
我们说 f_localsplus 这段内存虽然是连续的,但它是给四个老铁使用的,分别是:局部变量、cell对象、free对象、运行时栈,而我们看到字节码偏移量为 6 和 10 的两条指令分别是:STORE_FAST 和 LOAD_FAST,所以它和我们之前分析参数的时候是一样的,都是存储在 f_localsplus 中运行时栈前面的那段内存中。
此时我们对局部变量 c 的藏身之处已经了然于心。但是为什么在函数的实现中没有使用 local 名字空间呢?其实函数内部的局部变量有多少,在编译的时候就已经确定了,个数是不会变的。因此编译时就能确定局部变量使用的内存空间位置,也能确定访问局部变量的字节码指令应该如何访问内存。有了这些信息,Python 就能使用静态的方法来实现局部变量的查找,而不需要借助于动态查找 PyDictObject 对象的技术,尽管 PyDictObject 是被高度优化的,但肯定没有静态的方法快啊,而且 Python 里面函数是对象,也是一等公民,并且函数使用的太普遍了。至于在后面的类的剖析中,由于类的特殊性,无论是类的实例对象、还是类对象本身,都是可以在运行时动态修改属性的,那么我们知道显然 Python 就不会再对类使用静态属性查找的方式了。
并且我们还可以从 Python 的层面来验证这个结论:
1 | x = 1 |
我们在函数内部访问了 global 名字空间,而 global 空间显然是全局唯一的,在 Python 层面上就是一个 dict 对象,那么我们修改 x,在外部再打印 x 肯定会变。但是,我要说但是了。
1 | def foo(): |
我们按照相同的套路,却并没有成功,这是为什么?原因就是我们刚才解释的那样,函数内部的局部变量在编译时就已经确定好了,存储在符号表 co_varnames 中,查询的时候是静态查找的,而不是从 locals() 中查找。locals() 不像 globals(),globals() 虽然和 locals() 都是一个 PyDictObject 对象,但是全局变量的访问是从 globals() 这个字典里面访问的,并且全局唯一,我们调用 globals() 就直接访问到了存放全局变量的字典,一旦做了更改,肯定会影响外面的全局变量。但是locals() 则不会,因为局部变量压根就不是从它这里访问的,尽管它和 globals() 类似,在函数中也唯一,也会随着当前的上下文动态改变。
1 | def foo(a, b): |
再看一个例子:
1 | def foo(): |
此时会得到什么结果估计不用我说了,因为内部、外部、builtin都没有变量 x。在编译的时候,没有找到类似于 x = 1
这样的字眼。因此尽管在locals()里面,但是我们说局部变量的值不是从它这里获取的,而是 f_localsplus 前面的那段内存里面,然后那段内存并没有,而且符号表中就没有 ‘x’ 这个符号,所以报错。
1 | x = 123 |
原因不再废话了,一句话:foo函数里面没有 x 这个变量,所以打印的是全局变量,因此输出123。
另外关于局部变量的查找,再来看看最后一个栗子,搭配 exec 可以说明一切:
1 | def foo(): |
尽管 locals() 变了,但是依旧访问不到 x,因为 Python 在将 foo 对应的 block 编译成 PyCodeObject 对象时,并不知道这是创建了一个局部变量,它只知道这是一个函数调用。而 exec(“x = 1”) 相当于创建一个变量 x = 1,但它默认影响的是当前所在的作用域,所以 exec(“x = 1”) 的效果就是改变了局部名字空间,里面多了一个 “x”: 1 键值对。但关键的是,局部变量 x 的访问不是从局部名字空间中查找的,exec 终究还是错付了人。由于函数 foo 对应的 PyCodeObject 对象的符号表中并没有 x 这个符号,所以报错了。
1 | exec("x = 1") |
但是问题又来了:
1 | def foo(): |
这就比较尴尬了,为啥会出现这种效果?解决这个问题首先要明确两点:
1. 函数内的局部变量在编译的时候已经确定, 由语法规则所决定的, 并存储在对应的 PyCodeObject 对象的符号表 (co_varnames) 中;
2. 函数内的局部变量在其整个作用域范围内都是可见的;
举一个常见的错误:
1 | x = 1 |
那么我们的那个问题就很好解释了:
1 | def foo(): |
嵌套函数、闭包与decorator
我们之前一直反复提到了四个字,名字空间。一段代码执行的结果不光取决于代码中的符号,更多取决于代码中符号的语义,而这个运行时的语义正是由名字空间决定的。名字空间是在运行时由Python虚拟机动态维护的,但是有时我们希望将命名空间静态化。换句话说,我们希望有的代码不受命名空间变换带来的影响,始终保持一致的功能该怎么办呢?
比如下面的例子:
1 | def index(name, password, nickname): |
我们注意到每次都需要输入username和password,于是我们可以只设置一次基准值,通过使用嵌套函数来实现:
1 | def wrap(name, password): |
尽管我们调用index的时候,local名字空间(对应那片内存)
里面没有name和password,但是warp里面有。也就是说,index函数作为wrap函数的返回值被传递的时候,有一个名字空间(wrap的local名字空间)
就已经和index紧紧地绑定在一起了,在执行内层函数index的时候,在自己的local空间找不到,就会从和自己绑定的local空间里面去找,这就是一种名字空间静态化的方法。这个名字空间和内层函数捆绑之后的结果我们就称之为闭包(closure)
闭包:外部作用域 + 内层函数。
在前面我们也知道了,PyFunctionObject是Python虚拟机专门为字节码指令准备的大包袱,global名字空间,默认参数都能在PyFunctionObject中与字节码指令捆绑在一起,同样的,PyFunctionObject也是Python中闭包的具体体现。
实现闭包的基石
闭包的创建通常是利用嵌套的函数来完成的,在PyCodeObject中,与嵌套函数相关的属性是co_cellvars和co_freevars,两者的具体含义如下:
co_cellvars:通常是一个tuple,保存了嵌套的作用域中使用的变量名的集合;
co_freevars:通常是一个tuple,保存了使用了的外层作用域中的变量名集合;
光看概念的话比较抽象,实际演示一下:
1 | def foo(): |
我们发现无论是外层函数还是内层函数都有co_cellvars和co_freevars,但是无论是co_cellvars还是co_freevars,得到结果是一样的,都是内层函数使用nonlocal声明的变量、以及内层函数使用的外层函数的变量。只不过外层函数需要使用co_cellvars获取,内层函数需要使用co_freevars获取。如果使用外层函数获取co_freevars的话,那么得到的结果显然就是个空元组的,除非foo也作为某个函数的内层函数,并且内部有nonlocal声明、或者使用外层函数的某个变量,同理内层也是一样的道理。
在PyFrameObject对象中,也有一个属性和闭包的实现相关,这个属性就是f_localsplus,这样一说,是不是有些隐隐约约察觉到了呢?其实在_PyFrame_New_NoTrack
就有一行代码泄漏了天机。
1 | //frameobject.c |
虽然之前我们就见过f_localsplus的结构,但是到现在为止,其面纱才算是真正被揭开。
闭包的实现
在介绍了实现闭包的基石之后,我们可以开始追踪闭包的具体实现过程了,当然还是要先看一下闭包对应的字节码,老规矩嘛。
1 | s = f""" |
首先这个py文件执行之后,肯定会打印出”inner”这个字符串,下面让我们来看看它的字节码:
1 | 2 0 LOAD_CONST 0 (<code object get_func at 0x000001AAB6F4AB30, file "call_function", line 2>) |
相信里面大部分的指令你都认识,我们直接介绍构建闭包对应的指令、以及调用内层函数对应的指令,先来看看前者:
0 LOAD_CONST 1 ('inner'): 把字符串'inner'这个常量load进来;
2 STORE_DEREF 0 (value): 这个STORE_DEREF是什么鬼?从功能来看应该类似于STORE_FAST,具体是啥暂时不用管;
4 LOAD_CLOSURE 0 (value): 又是一条未见过的指令,不过这个我们从名字上可以看出来是load一个闭包;
6 BUILD_TUPLE 1: build一个元组, 为什么? 显然是为了存储内层函数(闭包)的
8 LOAD_CONST 2 (<code object func...: LOAD字节码,显然是内层函数func的字节码;
10 LOAD_CONST 3 ('get_func.<locals>.func'): 又是一个LOAD_CONST,我们按照之前的分析,这次LOAD的应该是外层的local名字空间;
12 MAKE_FUNCTION 8 (closure): MAKE_FUNCTION,构造一个函数, 参数是8; 而且括号里面写着closure, 表示这是个闭包;
14 STORE_FAST 0 (func): 调用STORE_FAST,将符号func和之前的PyFunctionObject组合成entry存储起来, 当然我们知道这里不是存在字典里面的;符号func是在符号表中, PyFunctionObject对象是在常量池中, 并且它们在各自数组中的索引是相等的;
16 LOAD_FAST 0 (func): 因为我们返回了func,所以LOAD_CONST的参数是func;
18 RETURN_VALUE: 返回func;
最后再来看看调用内层函数执行的指令:
0 LOAD_GLOBAL 0 (print): 首先是LOAD_GLOBAL得到print函数,这不需要多说;
2 LOAD_DEREF 0 (value): 关键是这条LOAD_DEREF指令,显然和上面的STORE_DEREF是一组,关系应该是类似于LOAD_FAST和STORE_FAST之间的关系那样, 我们猜测;
4 CALL_FUNCTION 1: 调用函数, 参数个数为1;
虽然我们看到了几个不认识的指令,不过不用慌,我们下面会顺藤摸瓜,沿着那美丽动人的曲线慢慢地、逐一探索。目前只需要知道,在Python虚拟机执行8 LOAD_CONST 2 (<code object func...
指令的时候,就已经开始为closure的实现悄悄地添砖加瓦了。
创建closure
1 | def get_func(): |
我们前面介绍了,虚拟机在执行CALL_FUNCTION指令时,会进入 *_PyFunction_FastCallDict* 中。
1 | //frameobject.c |
而在 *_PyFunction_FastCallDict* 中,由于当前的PyCodeObject为函数get_func对应的PyCodeObject。对于有闭包的函数来说,显然这个条件是不满足的,因此不会进入快速通道,而是会进入 *_PyEval_EvalCodeWithName* 。而且当前的这个PyCodeObject的co_cellvars是有东西的,可能这里有人奇怪了,我们没看到代码里面使用nonlocal声明啊,其实之前说了,除了使用nonlocal声明的变量外,还有内层函数使用的外层作用域中的变量。
1 | def get_func(): |
我们发现了内层函数自己定义了value2,所以它不再co_cellvars中,但是value1在内层函数中没有,而是使用的外层函数内部的value1变量,所以它也在co_cellvars中。因此除了那些被nonlocal关键字声明的变量之外,还有被内层函数使用的外层函数的变量。
因此在 *_PyEval_EvalCodeWithName* 中,Python虚拟机会如同处理默认参数一样,将co_cellvars中的东西拷贝到新创建的PyFrameObject的f_localsplus里面。
1 | PyObject * |
因此在 *_PyEval_EvalCodeWithName* 中,Python虚拟机会如同处理默认参数一样,将co_cellvars中的东西拷贝到新创建的PyFrameObject的f_localsplus里面。
嵌套函数有时候很复杂,如果嵌套的层数比较多的话:
1 | def foo1(): |
但是无论多少层,我们之前说的结论是不会变的。之前我们提到了,Cell 对象在python底层也是一个对象,那它必然也是一个PyObject,我们看一下它的定义:
1 | //cellobject.h |
这个对象似乎出乎意料的简单,仅仅维护了一个PyObject_HEAD,和一个ob_ref(指向某个对象的指针)
1 | //cellobject.c |
但是实际上一开始是不知道这个ob_ref指向的是谁的,什么时候才知道呢?是在我们一开始的闭包代码中,那句value = 'inner'
指令指令的时候,才会真正知道ob_ref指向的是谁。随后这个cell对象被拷贝到了新创建的PyFrameObject对象的f_localsplus中,并且位置是co->co_nlocals+i
,说明在f_localsplus中,cell对象的位置是在局部变量之后的,这完全符合我们之前说的f_localsplus的内存布局。另外图中画错了,指向应该是一个字符串 “inner”,但我不知道为啥画成了整数 10
但是我们发现了一个奇怪的地方,那就是我们发现这个cell对象(value)
好像没有设置名字诶。实际上这个和我们之前提到的Python虚拟机将对局部变量符号的访问方式从PyDictObject的查找变成了对PyTupleObject的索引是一个道理。在get_func这个函数执行的过程中,对value这个cell对象是通过基于索引访问在f_localsplus中完成,因此完全不需要知道cell对象的名字。这个cell对象的名字实际上是在处理被内层函数引用外层函数的默认参数是产生的。我们说参数和内部的创建的变量都是局部变量,在处理默认参数的时候,就把value这个cell对象一并处理了。
在处理了cell对象之后,Python虚拟机将正式进入PyEval_EvalFrameEx,从而正式开始对函数get_func的调用过程。再看一下字节码:
1 | 3 0 LOAD_CONST 1 ('inner') |
我们看到执行0 LOAD_CONST 1 ('inner')
之后,会将PyUnicodeObject对象’inner’压入到运行时栈,紧接着便执行一条我们从未见过的全新的字节码指令–STORE_DEREF
1 | PyObject* _Py_HOT_FUNCTION |
因此我们发现,ob_ref指向的对象似乎就是通过PyCell_SET设置的,没错,这家伙就是干这个勾当的。
1 | //cellobject.h |
如此一来,f_localsplus就发生了变化。
1 | def get_func(): |
现在在get_func的环境中我们知道了value符号对应着一个PyUnicodeObject对象,但是closure是要将这个约束进行冻结,为了在嵌套函数func中被调用的时候还可以使用这个约束。这一次,我们的工具人PyFunctionObject就又登场了,在执行接下来的def func()
表达式对应的字节码时,python虚拟机就会将(value, 'inner')
这个约束塞到PyFunctionObject中。
1 | case TARGET(LOAD_CLOSURE): { |
4 LOAD_CLOSURE
会将刚刚放置好的PyCellObject对象取出,并压入运行时栈,紧接着6 BUILD_TUPLE
指令将PyCellObject对象打包进一个PyTupleObject对象,显然这个PyTupleObject对象中可以存放多个PyCellObject对象,只不过我们的例子中只有一个PyCellObject对象。
随后Python虚拟机通过8 LOAD_CONST
和10 LOAD_CONST
将内层函数func对应PyCodeObject和符号LOAD进来,压入运行时栈,紧接着以一个12 MAKE_FUNCTION 8
指令完成约束和PyCodeObject之间的绑定,注意这里的字节码指令依旧是MAKE_FUNCTION
,但是参数是8,我们再次看看MAKE_FUNCTION
这个指令,还记得这个指令在哪里吗?没错,之前说了只要是字节码指令,都在ceval.c
中
1 | TARGET(MAKE_FUNCTION) { |
此时便将约束(内层函数需要使用的作用域信息)
和内层函数绑定在了一起。然后执行14 STORE_FAST
将新创建的PyFunctionObject对象放置到了f_localsplus当中。这样的话,f_localsplus就又发生了变化。
从图上我们发现内层函数居然在get_func的局部变量里面,是的没有错。其实按照我们之前说的,函数即变量,所以函数和普通变量一样,都是在上一级栈帧的f_localsplus里面的。最后这个新建的PyFunctionObject对象被压入到了上一级栈帧的运行时栈中,并且被作为上一个栈帧的返回值返回了。显然有人就能猜到下一步要介绍什么了,既然拿到了闭包、或者说内层函数对应的PyFunctionObject,那么肯定要使用啊。而且估计有人猜到了,当外面拿到闭包的时候,调用,显然会找到对应的闭包,然后抽出里面的PyCodeObject对象继续创建栈帧。
使用闭包
closure是在get_func函数中被创建的,而对closure的使用,则是在inner_func中。在执行show_value()
对应的CALL_FUNCTION指令时,因为func对应的PyCodeObject对象的co_flags域中包含了CO_NESTED,因此在 *_PyFunction_FastCallDict* 函数中不会进入快速通道function_code_fastcall
,而是会进入 *_PyEval_EvalCodeWithName* 、*PyEval_EvalFrameEx* 、继而进入 *_PyEval_EvalFrameDefault* 。不过问题是,Python是怎么知道co_flags域中包含了CO_NESTED呢?
1 | def get_func(): |
我们看到func函数的字节码的co_flags是19,那么这个值是什么计算出来的呢?还是记得我们在介绍PyCodeObject对象和pyc文件那一章中,当时我们说,co_flags这个域主要用于mask,用来判断参数类型的。
1 | //code.h |
函数没有参数,显然CO_VARARGS和CO_VARKEYWORDS是不存在的:
1 | print(0x0001 | 0x0002 | 0x0010) # 19 |
根据之前说了,对于闭包来说,func对应的PyCodeObject中的co_freevars里面有引用了外层作用域中的符号名,在 *_PyEval_EvalCodeWithName* 中就会对这个co_freevars进行处理。
1 | //ceval.c |
其中的closure变量是作为倒数第三个参数传递进来的,我们可以看看到底传递了什么?
1 | //funcobject.h |
我们看到了,是把PyFunctionObject对象的func_closure拿出来了,这个func_closure是啥还记得吗?之前说得,不记得了再看一下。
1 | TARGET(MAKE_FUNCTION) { |
显然这个func_closure就是PyFunctionObject对象中的、我们之前说得那个与对应PyCodeObject绑定的、装满了PyCellObject对象的PyTupleObject。所以在 *_PyEval_EvalCodeWithName* 中,进行的动作就是将这个PyTupleObject里面的PyCellObject对象一个一个的放到f_localsplus中相应的位置。在处理完之后,func对应的PyFrameObject中f_localsplus就变成了这样。
我们看到闭包使用的变量信息,被设置在了func_closure中,而这个函数是内层函数,那么我们可以通过__closure__进行获取。
1 | def get_func(): |
所以在func调用的过程中,当引用外层作用域的符号时,一定是到f_localsplus里面的free变量区域去获取对应PyCellObject,通过内部的ob_ref进而获取符号对应的值。这正是func函数中’print(value)’表达式对应的第一条字节码指令0 LOAD_DEREF 0
的意义。
1 | case TARGET(LOAD_DEREF): { |
所以在func调用的过程中,当引用外层作用域的符号时,一定是到f_localsplus里面的free变量区域去获取对应PyCellObject,通过内部的ob_ref进而获取符号对应的值。这正是func函数中’print(value)’表达式对应的第一条字节码指令0 LOAD_DEREF 0
的意义。
此外通过闭包,我们还可以玩出一些新花样,但是工作中不要这么做。
1 | def get_func(): |
装饰器
装饰器算是Python中一个亮点,当然其实也不算什么亮点,本质上也是使用了闭包的思想,只不过给我们提供了一个优雅的语法糖。
装饰器的本质就是高阶函数加上闭包,至于为什么要有装饰器,我觉得有句话说的非常好,装饰器存在的最大意义就是可以在不改动原函数的代码和调用方式的情况下,为函数增加一些新的功能。
1 | def deco(func): |
我们可以使用之前的方式:
1 | def deco(func): |
所以这个现象告诉我们,装饰器只是类似于foo = deco(foo)
的一个语法糖罢了
装饰器本质上就是使用了闭包,两者的字节码很类似,这里就不再看了。还是那句话,@
只是个语法糖,它和我们直接调用foo = deco(foo)
是一样的,所以理解装饰器(decorator)的关键就在于理解闭包(closure)。
当然函数在被装饰器装饰之后,整个函数其实就已经变了,为了保留原始信息我们一般会从functools中导入一个wraps函数。当然装饰器的使用方式、以及类装饰器,这些都属于Python层级的东西了,我们就不说了。
当然,我们知道函数可以同时被多个装饰器装饰的。如果有多个装饰器,那么它们是怎么装饰的呢?
1 | def deco1(func): |
请问它的输出结果是什么呢?
可以先分析,解释器还是从上到下解释,但是发现了
@deco1
的时候,肯定要装饰了,但是发现在它下面的哥们不是函数也是一个装饰器,于是说:要不哥们,你先装饰。然后@deco2
发现它下面还是一个装饰器,于是重复了刚才的话,但是当@deco3
的时候,发现下面终于是一个普通的函数了。于是装饰了,当deco3装饰完毕之后,foo = deco3(foo)
,然后deco2发现deco3已经装饰完毕了,然后对deco3装饰的结果再进行装饰,此时foo = deco2(deco3(foo))
,同理再经过deco1的装饰,得到了foo = deco1(deco2(deco3(foo)))
1 | print(foo()) # <deco1><deco2><deco3>hanser</deco3></deco2></deco1> |
关于函数的面试题
1. Python 中有几个名字空间,分别是什么?Python 变量以什么顺序进行查找?
Python总共有4个名字空间:
局部名字空间(local)
闭包名字空间(closure)
全局名字空间(global)
内建名字空间(builtin)
我们之前说过,*Python* 查找变量时,依次检查 局部 、闭包、全局、内建 这几个名字空间,直到变量被找到为止。如果几个空间都遍历完了还没找到,那么会抛出NameError。
2. 如何在一个函数内部修改全局变量?
在函数内部用 *global* 关键字将变量声明为全局,然后再进行修改:
1 | a = 1 |
或者获取global名字空间,然后通过字典进行修改,因为全局变量是通过字典来存储的。
1 | a = 1 |
3. 不使用 def 关键字的话,还有什么办法可以创建函数对象?
根据 *Python* 对象模型,实例对象可以通过调用类型对象来创建。而函数类型对象,虽然没有直接暴露给我们,但我们可以通过函数对象找到:
1 | def f(): |
事实上,*Python* 将函数类型对象暴露在 *types* 模块中,可通过模块属性 *FunctionType* 访问到:
1 | from types import FunctionType |
然而它干的事情和我们本质上是一样的,我们看一下源码怎么实现的:
1 | def _f(): pass |
吱吱吱~~~
而创建函数的时候,可以根据PyCodeObject对象创建,我们之前已经见过了。当时我们传递了3个参数:PyCodeObject、名字空间、函数名。其实可以传递五个参数:
PyCodeObject对象
globals
name
argdef: 默认参数的值
closure: 闭包变量
1 | def f(v): |
是不是奇怪的知识又增加了呢?但还是那句话,这种做法没有什么实际用途,只是让我们能够更好地理解函数的机制。
4. 请介绍装饰器的运行原理,并说说你对 @xxxx 这种写法的理解?
装饰器用于包装函数对象,在不修改函数源码和调用方式的前提下、达到修改函数行为的目的。它的本质是高阶函数加上闭包,而@xxxx只是一个语法糖。
1 | class Deco: |
还是那句话,装饰器本质是高阶函数加上闭包,而很多语言都有闭包,也可以多层函数嵌套。但是对于Python而言,装饰器显得格外的优雅。
flask框架就用到了大量的装饰器,比如:@app.route(“/“),不得不说,flask的作者真的是非常喜欢使用装饰器,还有它们团队开发的、用于处理命令行参数的click模块,也是大量使用了装饰器。
5. Python 中的闭包变量(外层作用域的变量)可以被内部函数修改吗?
显然是可以的,有两种方式:一种是通过nonlocal关键字,另一种是通过获取闭包变量的方式。
1 | def f1(): |
6. 请描述执行以下程序将输出什么内容?并试着解释其中的原因。
1 | def add(n, l=[]): |
出现这种问题的原因就在于,*Python* 函数在创建时便完成了默认参数的初始化,并保存在函数对象的 *defaults* 字段中,并且是不变的,永远是那一个对象:
1 | def add(n, l=[]): |
显然在函数执行的时候,如果我们没有传递参数,那么会从栈帧的f_localsplus中获取对应的默认值,当然这个默认值也在函数的__defaults__中。这个f_localsplus由局部变量、cell对象、free对象、运行时栈组成,运行时栈位于栈顶,*Python* 虚拟机负责从函数对象中取出默认参数并设置相关局部变量:
由于列表是可变对象,因此采用append的方式,那么显然每一次都会有变化的,因为操作的是同一个列表。
所以在设置默认参数的时候,不要设置成可变对象。如果你的IDE比较智能的话,比如pycharm,那么会给你抛出警告的。
我们看到飘黄了,因为默认参数的值是一个可变对象。
小结
到目前为止,我们关于函数的内容就算分析完了,可以好好体会一下函数的底层实现。我们下一篇将来分析Python中类的实现,又是一块难啃的骨头。