看着屏幕上的数字一直没动,写代码的人愣了好半天,才反应过来问题不是算法,是“作用域”这玩意儿。
现场就是这样:一个函数套着另一个函数,外层有个变量,内层想去改它,结果改的好像是自己口袋里的零钱。程序跑出来不是预期的值,打印的还是老一套。把代码倒着推,最后在那句赋值语句上定住了:赋值会在当前函数里新建一个名字,外面的同名名字被掩盖了。话说白了,就是名字一样,可不是指向同一个内存位,这点常常被忽视,常常把人坑得一脸懵。
要把这事儿弄清楚,先把“四个圈”讲清楚。这四个圈是变量可见性的层级,从里往外依次是:局部、嵌套(也叫闭包、外层函数的局部)、模块级的全局、最外面的内置。查名字的时候,Python是按这顺序找的:先在最里层找,没找到就往外一层一层找,最后去内置表里碰碰运气。许多人就是靠这套顺序来判断一个名字到底指哪儿。
局部圈就是函数体里那块小天地。函数运行的时候会给自己挖个沙箱,一般所有没有特殊声明的赋值都会在这里新建名字。这个名字的生命周期和函数调用绑在一起,函数一跑完,这些名字就没了。
再往外是嵌套函数的圈。要是一个函数在另一个函数里定义,内层可以看到外层的名字。问题就在这儿:内层如果对外层的名字做赋值,默认情况下它会新建自己的局部名字,这就造成了“我能读但不能写”的局面。许多人就是被这条规则绊倒的——以为能改,结果只是改了自己的影子。
再外一层是模块级的全局名字。你在文件顶层定义的变量,整个模块都能看到。在函数里想改它,不特别说明,赋值会造出一个新的局部名字,和全局那个不是一回事。
最外面那圈是内置作用域,放着Python自带的名字,列如 print、len、int、Exception 之类。如果前面几圈都没这个名字,才会去内置表里找。内置函数大致分几类:数学运算、类型转换、输入输出以及常见的异常名。像 print、len、int、str、float,还有 ValueError、TypeError 这种异常名字,都在这儿。
用实例来讲会更直观。看这段伪代码,是常见的坑:
def outer():
count = 0
def inner():
count = count + 1
inner()
print(count)
这段代码会报错(UnboundLocalError),或者你以为应该打印 1,结果不是。缘由就在 inner 里那句赋值:由于出现了赋值语句,Python 在编译 inner 的时候就把 count 认定为 inner 的局部变量,但在执行 count = count + 1 时,右边的 count 还没被赋值,所以会出错。把思路倒过来想一下:进入 inner 的时候,名字的作用域早就在编译阶段被确定了,赋值语句决定了名字的身份,这比你运行时的想法更先一步。
解决办法有两把钥匙。一个是 global,用来告知函数“你操作的是模块级的那个名字”。另一个是 nonlocal,说明“你操作的是外层函数的那个名字,而不是新造一个”。写法像这样:
count = 0
def outer():
def inner():
global count
count += 1
inner()
或者当你想修改紧邻外层函数的局部变量,写成:
def outer():
count = 0
def inner():
nonlocal count
count += 1
inner()
print(count)
注意这两招有各自的适用场景,用错了会报语法错误或者不起作用。global 只能指向模块级变量,nonlocal 只在嵌套函数里指向外层局部变量。
还有一点老生常谈但容易忘:可变对象和不可变对象的区别。像列表、字典是可变的,内层函数如果只是修改对象内容(比方说 append),一般不需要 nonlocal,由于你并没有把名字重新绑定到另一个对象上,你只是改了对象内部。例如:
def outer():
lst = []
def inner():
lst.append(1)
inner()
print(lst) # 会打印 [1]
但如果你用直接赋值 lst = […] 的方式,那就是重绑定名字,会触发局部创建机制,这种情况就需要 nonlocal 来声明。
再说说名字遮蔽的问题。外层有个 len 变量,内层又定义了 len,那么内层的 len 会把全局或内置的 len 挡住。结果你在内层想用内置 len 计算长度,反而出错。用同名变量是可以的,但要小心,一不注意就把方便的内置函数挡住了。遇到奇怪问题,优先检查最近的作用域里有没有和常用名字同名的变量。
把时间线拉长一点,看程序员常犯的其他错误。许多人习惯在函数里乱用赋值,或者随手复用了常见名字。如果某个赋值不小心用了外层同名的变量,就会悄悄把外层变量替换成局部变量。调试时常见画面是:你在内层打印到处都正常,外层一点动静都没有。定位到名字遮蔽后,一般有两种修法:换个名字,或者用 nonlocal/global 明确说明。
调试时的好习惯是把变量声明写清楚,不要随意复用常见名字。写模块级变量时尽量避免和内置函数同名。写嵌套函数的时候,先想想你到底是要读外层变量,还是要修改它。如果只是读,直接用就行;如果想改,思考把修改逻辑放到外层,或者用 nonlocal。如果是模块级的状态,频繁改全局变量会让程序状态难以追踪,尽量避免滥用 global。
现场那个人最后把逻辑拆开了,要么把修改放回外层函数,要么在内层加上 nonlocal 声明,问题就解决了。调试过程有点像剥洋葱,一层层往外看,最后发现不是算法的锅,是名字在不同圈里同名但指向不同东西。遇到变量莫名其妙不变,先别急着怀疑逻辑,先问一句最近的作用域里有没有玩“同名遮蔽”的把戏。



















- 最新
- 最热
只看作者