拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python闭包深度解析:nonlocal、cell机制与循环延迟绑定

Python闭包深度解析:nonlocal、cell机制与循环延迟绑定

1. 先把闭包的边界划清楚:一段"看起来不该工作"的代码

很多人第一次接触闭包,是被一段"反直觉"的代码吓到的:明明外层函数已经执行完毕、局部变量按理说应该随栈帧一起消失,可返回出来的那个内层函数居然还能读到它。这不是玄学,是 Python 的**闭包(closure)**在起作用。如果你写过装饰器、用过functools.wraps、做过回调注入,那你其实早就在用闭包了,只是可能没意识到。

闭包解决的核心问题很朴素:让函数带着"一段私有的、外部看不见的状态"到处跑。不用全局变量,不用定义类,不用把状态塞进参数里层层传递。你在写爬虫的时候给每个站点配一个独立的限速计数器、写数据清洗的时候给一段规则配一个可调阈值、写 Flask/Django 视图的时候给某些接口挂一个鉴权包装,这些场景闭包都能顶上去,而且是几十行以内的轻量方案。

这篇文章不讲教科书式的定义堆砌。我会从"它到底怎么跑起来的"讲到"工程里怎么用、哪里会翻车",包括解释器层面的cell对象、nonlocal为什么必须存在、循环里闭包为什么全部指向最后一个值,以及闭包和类、functools.partial到底该选谁。基础不用太好,会写普通函数和for循环就能跟上;有几年经验的人,第 6、7 章的选择标准和排查手段应该对你更有用。

1.1 从计数器讲起:不用类也不用全局变量

最经典的入门例子是计数器。假设你要做一个"每调一次加一"的函数,最笨的写法是全局变量:

count = 0 def counter(): global count count += 1 return count

问题显而易见:count暴露在模块顶层,谁都能改,多个计数器会互相污染,测试的时候还得手动重置。用类当然可以,但对于"只有一个状态、一个方法"这么简单的需求,写一个类有点重——你要定义__init__、写方法、self.count到处出现。

闭包的写法是这样的:

def make_counter(): count = 0 def counter(): nonlocal count count += 1 return count return counter c1 = make_counter() c2 = make_counter() print(c1(), c1(), c1()) # 1 2 3 print(c2()) # 1,完全独立

关键在于c1和c2各自持有一份属于自己的count。外层函数make_counter调用结束后,count这个名字按理说应该"死掉",但它被内层函数引用着,于是被保留下来了。这个"保留"不是 Python 大发慈悲,而是有明确的机制在托底,第 2 章会拆开看。

1.2 闭包的三个必要条件

判断一段代码算不算闭包,我一般用三个条件去卡:

  • 有嵌套:函数里面定义了函数,所谓"内层函数/嵌套函数"。
  • 有引用:内层函数引用了外层作用域里的变量(这个变量在术语里叫自由变量,free variable)。
  • 有外逃:外层函数把这个内层函数当作返回值(或者通过其他方式传出去,比如塞进列表、注册成回调)。

三个条件缺一个,你就不是在用闭包。比如内层函数引用了外层变量,但内层函数只在外面调一次就丢了,那"外逃"不成立;反过来,内层函数被返回了,但它只用了自己的参数和全局变量,那"引用"不成立,也就没有自由变量可捕获。

这里有个特别容易搞混的点:内层函数引用模块级全局变量,不叫闭包。全局变量的查找走的是LOAD_GLOBAL,在调用时从模块字典里现查,跟闭包机制没关系。你去看这种函数的__closure__,结果是None。

1.3 一个反例:为什么这个不叫闭包

GREET = "hello" def outer(): def inner(): return GREET # 引用的是全局变量 return inner f = outer() print(f.__closure__) # None

很多人会以为这里GREET被"闭包捕获"了,其实没有。这也解释了一个实际工作中常见的困惑:你在测试里用monkeypatch替换了模块级变量,闭包里的值却没变。原因就在于被闭包捕获的只有外层函数的局部变量,全局变量永远是运行时现查的,而真正的自由变量是"焊死"在函数对象上的。

注意:区分"闭包捕获"和"运行时查找",是排查"为什么改了变量值函数里没反应"这类问题的第一把钥匙。

2. 解释器是怎么把外层变量"焊"在函数上的

理解了"是什么",接下来要回答"凭什么"。这一章会稍微进到 CPython 的实现层面,但不会涉及 C 源码,只用 Python 自己提供的自省接口就能看清楚。

2.1 LEGB 与自由变量的产生时机

Python 查找一个名字,遵循LEGB顺序:Local(本函数局部)→ Enclosing(外层函数作用域)→ Global(模块级)→ Builtins(内置)。闭包这条线,正好卡在 E 这一层。

但要注意,名字到底属于哪一层,是编译期就决定了的,不是运行时。CPython 在把源码编译成字节码的时候,会先做一次作用域分析:如果某个名字在本函数里被赋值过(包括for的目标、import的名字、def的函数名、with ... as的名字),它就是本地的;如果没被赋值但被外层嵌套函数赋值过,它就成了自由变量;否则继续往外找。

这就是为什么下面这段会报错:

def make(): x = 10 def inner(): print(x) # 这一行没问题 x = 20 # 这一行让 x 变成了 inner 的局部变量 inner() make() # UnboundLocalError: cannot access local variable 'x' where it is not associated with a value

编译器看到inner里有x = 20,就直接把x登记为inner的局部变量了,于是前面那句print(x)引用的是"尚未赋值"的局部x。这个坑跟闭包的关系是:你以为是闭包,其实赋值语句把变量降级成局部的了。

2.2__closure__、cell 对象和co_freevars的对应关系

每个函数对象上都有几个可以直接查看的元信息,闭包的关键信息就在里面:

def outer(): msg = "hello" def inner(): return msg return inner f = outer() print(f.__code__.co_freevars) # ('msg',) print(f.__closure__) # (<cell at 0x...: str object at 0x...>,) print(f.__closure__[0].cell_contents) # hello print(outer.__code__.co_cellvars) # ('msg',)

几个点值得记牢:

  • co_freevars是自由变量的名字元组,顺序固定;__closure__是对应顺序的 cell 元组。两者按下标一一对应,这就是为什么你可以用f.__closure__[0].cell_contents精确取出msg的值。
  • co_cellvars是站在外层函数视角看的"我有哪些局部变量被别人捕获了"。同一个变量,在外层眼里是 cellvar,在内层眼里是 freevar,这是同一件事的两个视角。
  • 真正承载值的是cell 对象。它本质上是个"带一层间接寻址的容器":栈帧里存的是指向 cell 的指针,变量赋值等于改 cell 里的内容。所以外层函数返回后,栈帧没了,但 cell 还在,被函数对象的__closure__强引用着,值自然不会丢。
  • 如果函数没有自由变量,__closure__就是None;如果__closure__是空元组,说明这个函数来自 C 实现或者构造方式比较特殊。看到None和看到(),含义不一样,排查问题时别混。

2.3 反编译看一眼:dis告诉我们的事

想把这件事看得更实,直接反汇编:

import dis dis.dis(f)

你会看到类似LOAD_DEREF msg的指令。这几个前缀值得记住:

指令含义触发场景
LOAD_FAST读本函数局部变量普通局部变量
LOAD_DEREF通过 cell 读自由变量闭包里的外层变量
LOAD_GLOBAL运行时查模块字典全局变量、内置函数
STORE_DEREF通过 cell 写自由变量nonlocal赋值、co_cellvars赋值

这张表很有用。当你搞不清楚某个变量到底是"被捕获"还是"运行时查找"时,看指令前缀比猜快得多。顺带说一句,Python 3.12 把列表推导式做了内联优化(PEP 709),推导式不再单独开栈帧了,所以你在dis的输出里可能发现少了一层MAKE_FUNCTION。但"推导式不泄漏循环变量"这个语义没变,别因为看到字少了就以为行为变了,最好用sys.version_info加个断言确认清楚。

2.4 cell 是可以被外部改写的(以及为什么别这么干)

既然 cell 只是个容器,那它就是可写的:

f.__closure__[0].cell_contents = "world" print(f()) # world

这个技巧偶尔能在调试或快速原型里救急,比如重置一个闭包计数器而不用重新构造。但我不建议在生产代码里用:一是可读性极差,别人看不懂你在干什么;二是 cell 的顺序依赖co_freevars的顺序,而顺序在解释器版本或代码微调之后可能变化——虽然实践中很稳定,但没有契约保证。真需要"从外部重置状态",老老实实给闭包配一个配套的 setter,或者干脆用类。

提示:cell_contents在某些解释器版本上对未赋值的 cell 访问会抛ValueError,写调试脚本时记得加异常保护。

3.nonlocal存在的意义:改值和改内容完全是两回事

初学者最常见的报错之一就是UnboundLocalError,而且往往出现在已经写了nonlocal又删掉之后。要讲清楚nonlocal,先得拆开"赋值"和"修改内容"这两个在中文里几乎同义、在 Python 里完全不同的操作。

3.1 赋值绑定 vs 原地修改

看两个写法:

# 写法 A:需要 nonlocal def make_a(): n = 0 def inc(): nonlocal n n += 1 return n return inc # 写法 B:不需要 nonlocal def make_b(): box = {"n": 0} def inc(): box["n"] += 1 return box["n"] return inc

n += 1等价于n = n + 1,它包含一次名字绑定,也就是"我要给n这个新名字绑一个对象"。有绑定,编译器就会把n认成内层的局部变量,于是你必须用nonlocal明确告诉编译器:"别登记成本地的,去外层找"。

而box["n"] += 1全程没有给box这个名字重新绑定——它只是调用了box的__setitem__。名字box本身指向的对象没变,所以它天然就是自由变量,不需要nonlocal。

这个区分用一句话记:nonlocal管的是"这个名字指向谁",不管"这个名字指向的东西内部怎么变"。同理,lst.append(x)、d.update(y)、obj.attr = v全都不需要nonlocal。

3.2 没有nonlocal时的三种替代写法及代价

在nonlocal之前的年代(以及现在一些需要跨版本兼容的代码里),大家用别的手段绕。这几种写法我都用过,各自有明确的代价:

方案写法代价
可变容器box = [0],用box[0] += 1语义隐晦,读代码的人要绕一下;类型提示不友好
属性挂载def f(): ...然后f.n += 1状态挂在函数对象上,外部可随意篡改;多实例不好复用
全局变量global污染模块命名空间,多实例互相干扰
改用类定义__call__代码量变大,但对复杂状态更清晰

nonlocal之后,写法 A 才是首选。但要注意一个多数人踩过的坑:nonlocal只能声明"到最近的外层函数作用域",不能跨多级,也不能指到全局。下面这段会报SyntaxError:

def a(): x = 1 def b(): def c(): nonlocal x # SyntaxError: no binding for nonlocal 'x' found x = 2 c() b()

因为x在b里不存在,nonlocal找不到"最近的绑定"。要么在b里也写一句nonlocal x,要么把x挪到b里面。

3.3 多层嵌套时到底改的是哪个

搞清楚"最近的外层",多级嵌套就不容易出错了:

def outer(): x = "outer" def mid(): x = "mid" # 这是 mid 的局部变量,跟 outer 的 x 无关 def inner(): nonlocal x # 找最近的外层,也就是 mid 的 x x = "inner" inner() return x print(mid()) # inner print(x) # outer outer()

输出是inner和outer。这说明了nonlocal的精确语义:它绑定的是最近一层"存在该变量绑定"的外层函数作用域。理解了这一点,多级装饰器、多级工厂函数里的状态改写就不会写错了。

3.4 一个容易忽视的细节:默认参数与外层变量的求值时机

顺便把默认参数也拉进来对比,因为很多人用默认参数"伪造闭包":

def make(x=[]): x.append(1) return x print(make()) # [1] print(make()) # [1, 1] ← 意外共享

默认参数在函数定义时求值一次,之后所有调用共享同一个对象。这跟闭包"每次调用外层函数生成一份新 cell"的行为完全相反。所以你看到def f(x=i)这种写法时,要意识到它是在"冻结当前值",靠的是定义时求值,而不是闭包机制。两者的效果可能一样,原理完全不同,排查问题时的思路也不一样。

4. 循环里的闭包为什么全都指向最后一个值

这是闭包相关被问得最多的问题,也是最容易在生产代码里咬人的地方。面试题里出现的频率高得离谱,但真正理解成因的人不多。

4.1 延迟绑定的真实成因

先看现象:

funcs = [lambda: i for i in range(3)] print([f() for f in funcs]) # [2, 2, 2]

原因一点都不神秘:闭包捕获的是变量,不是变量的值。range(3)三次迭代里,只创建了一个名字i(在推导式的函数作用域里),三个 lambda 的__closure__指向的是同一个 cell。循环结束后这个 cell 里的内容是2,所以你调用任意一个,读到的都是2。

注意这里的关键词是"同一个 cell"。你可以打印验证:

funcs = [lambda: i for i in range(3)] print(funcs[0].__closure__[0] is funcs[1].__closure__[0]) # True

三条 lambda 共享同一个 cell 对象,这就是全部真相。所谓"延迟绑定"(late binding),说的就是"值的读取发生在调用时,而不是定义时"。

4.2 三种修复方案的取舍

方案一:默认参数冻结

funcs = [lambda i=i: i for i in range(3)] print([f() for f in funcs]) # [0, 1, 2]

短小、常见、性能好。缺点是i=i这种写法对新手不友好,而且如果被冻结的对象是可变类型(比如列表),后续修改会影响到所有引用者——冻结的只是引用,不是内容。

方案二:工厂函数

def make_getter(i): def getter(): return i return getter funcs = [make_getter(i) for i in range(3)]

这是我个人最推荐的方式。每次调用make_getter都会新建一个栈帧和一份新的 cell,天然隔离,语义清楚,还方便加类型注解和文档字符串。

方案三:functools.partial

from functools import partial funcs = [partial(lambda i: i, i) for i in range(3)]

如果回调本来就需要参数,partial 往往比闭包更直白。它把"绑定参数"这件事显式化了,不用凭空造一层嵌套。

三种方案怎么选?我的判断标准是:回调只需要零参数调用,且逻辑只有一行,用默认参数;回调逻辑超过两行或者需要类型注解,用工厂函数;本来就在做参数绑定,且绑定的实参来自外部,用 partial。

4.3 同类陷阱:lambda 列表、事件回调、多线程

循环闭包的问题不只在推导式里出现,下面这些场景一模一样:

  • 回调注册:给一批按钮注册点击处理,最后所有按钮都用了最后一个按钮的数据。这是 GUI 编程里最常见的一类 bug。
  • 多线程/多进程提交任务:循环里for url in urls: pool.submit(lambda: fetch(url)),结果所有任务抓同一个地址。这个坑在爬虫并发里几乎人人踩过一次。
  • 定时器:循环里注册多个延时任务,参数却全变成最后一个。

统一的解法就是上面三种之一。我自己的习惯是:只要看到lambda出现在for或推导式里面,就立刻检查它是否引用了循环变量。这个条件反射能省掉很多调试时间。

注意:functools.partial固定的是位置参数和关键字参数,它不参与闭包机制,因此不受延迟绑定影响,也不会有__closure__。用它做循环内的任务提交,比手写闭包少一层心理负担。

5. 闭包在工程里的四个真实落点

概念讲完了,接下来是它到底能用在哪。这四类场景覆盖了我日常遇到的绝大多数闭包使用需求。

5.1 装饰器:闭包最有名的应用

装饰器本质上就是"接收函数、返回新函数"的闭包:

import functools import time def timed(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: cost = time.perf_counter() - start print(f"{func.__name__} 耗时 {cost:.4f}s") return wrapper

这里的func就是自由变量,被wrapper的 cell 捕获。functools.wraps不可省:它把原函数的__name__、__doc__、__module__、__wrapped__等属性复制到wrapper上,否则日志里全是wrapper,排查问题时根本不知道是哪个函数慢。

带参数的装饰器是三层嵌套,对应三层 cell:

def retry(times=3, delay=0.5): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): last = None for attempt in range(times): try: return func(*args, **kwargs) except Exception as exc: last = exc time.sleep(delay) raise last return wrapper return decorator

注意times、delay和func分别来自不同的层级,wrapper的co_freevars会是('delay', 'func', 'times')这样一组名字。写这类装饰器时最大的心得是:把最容易变的参数放到最外层(times、delay),把函数本身作为第二层参数,这样@retry(times=5)这种用法才自然。

5.2 轻量状态机与缓存

需要"记住上次结果"的逻辑,闭包比类轻得多。比如一个简单的滑动窗口限速器:

import time from collections import deque def rate_limiter(max_calls, window): records = deque() def allow(): now = time.monotonic() while records and now - records[0] > window: records.popleft() if len(records) >= max_calls: return False records.append(now) return True return allow

用deque而不是list是因为队首弹出是 O(1),窗口大时差别明显。这类实现完全不需要nonlocal,因为我们只修改容器内容,没有重新绑定名字。给爬虫、给第三方接口调用做限速,二十行就能搞定,比引一个限速库更可控。

5.3 回调注入与依赖替换

测试里经常需要替换某个依赖。闭包可以把依赖"注入"进去:

def make_uploader(send_func): def upload(payload): if not payload: raise ValueError("payload 为空") return send_func(payload) return upload def fake_send(payload): return {"ok": True, "size": len(payload)} upload = make_uploader(fake_send) print(upload("abc"))

这种写法的好处是:send_func被捕获之后,后续即使你替换掉模块里的同名函数,upload用的还是你传进去的那个。这一点在单元测试里既是优点也是陷阱——优点是不会被其他测试的全局替换污染,陷阱是你想通过monkeypatch改行为的时候改不动。遇到"改了 mock 没生效",先检查是不是这个原因。

5.4 和functools.partial的分工

这两个东西经常被拿来互相替代,但业务场景不同:

场景推荐原因
固定几个参数,逻辑不变partial一行搞定,语义直白,还能.func/.args反查
需要内部状态、分支逻辑闭包/工厂函数partial 没法保存中间状态
需要在原函数前后加行为装饰器本质是闭包,但不该用 partial 硬凑
需要被 pickle 序列化顶层类或顶层函数两者都不行,见第 6 章

关于partial有个细节值得知道:它用的是 C 实现,p.__closure__会报AttributeError,因为它根本没有这个属性。想反查绑定内容,用p.func、p.args、p.keywords。

6. 闭包、类、partial该怎么选:一张对比表和判断标准

在同一个需求面前,闭包、类、partial 经常都能实现,这时候选择就变成了风格和可维护性的问题。我的判断依据基本固定。

6.1 能力对比

维度闭包类实例functools.partial
代码量最少最多最少
多实例隔离天然支持天然支持天然支持
可保存中间状态支持(cell)支持(__dict__)不支持
可被 pickle否(局部定义时)顶层类可以顶层函数可以
可被多进程传递受限顶层类可以顶层函数可以
可自省一般(要翻__closure__)强(vars()、dir())中(func/args)
类型注解友好度一般好一般
C 扩展友好不涉及不涉及好

"可被 pickle"和"可被多进程传递"这两行,是实际项目里决定性的。闭包捕获的自由变量和函数本身都是在运行时动态造出来的,pickle靠的是"按限定名导入再重建",找不到路径,直接报AttributeError: Can't pickle local object。如果你要把任务扔给multiprocessing.Pool,那么多进程那边需要能重建这个函数对象——闭包基本告别这条路,除非你改用顶层类、或者用支持序列化闭包的第三方方案。

我在项目里吃过一次亏:单线程写好的任务分发逻辑,改成多进程之后全部报序列化错误,排查了半天才发现是循环里生成的闭包。教训是:一旦代码有并发或多进程的可能,就别用闭包做任务载体。

6.2 选择时的五个判断问题

我一般按顺序问自己五个问题:

  1. 状态有几个?一个状态一个方法,闭包;超过三个状态或五个方法,写类。
  2. 需要序列化吗?需要传给子进程、写进缓存、落盘,那就是类(顶层定义)或者顶层函数。
  3. 需要外部可观测吗?需要打印完整状态、需要遍历属性、需要被调试工具友好展示,用类。
  4. 生命周期多长?只在一个函数体内用几次,闭包;跨模块长期存在,用类,便于定位和替换。
  5. 有多少人维护?团队里新手多,用类;自己写脚本、追求紧凑,用闭包。

这五个问题走一遍,选择基本就唯一了。我的实际经验是:闭包适合"短生命周期 + 内部使用 + 逻辑简单",其他情况一律用类。反过来强调闭包而硬写类的场景也不少,最后得到一堆只有__init__和一个__call__的类,那不如改回闭包。

6.3 一个混合方案:用类管理生命周期,用闭包做热路径

有些场景两边都要。比如一个配置对象需要被大量调用,我通常这样组织:

class Pipeline: def __init__(self, threshold, normalizer): self.threshold = threshold self.normalizer = normalizer def make_step(self): threshold = self.threshold normalizer = self.normalizer def step(record): value = normalizer(record["value"]) return value >= threshold return step

make_step把属性取到局部变量再被闭包捕获,有两个实际好处:一是热循环里少了两层属性查找,二是生成之后step的行为不受后续修改self.threshold的影响,语义更稳定。代价是这两份状态会一直驻留(第 7 章会讲这个)。这个小模式在数据处理流水线里我用了很多次,比每次调用都读self.属性要顺手。

7. 排查闭包问题的实用手段与自测

最后一章讲讲怎么"看"和怎么"查"。闭包最大的问题是它藏得深——函数对象上看不出什么,dir()里也没有状态字段,出问题时容易懵。

7.1 用__closure__现场查看捕获了什么

写一个通用的小工具,遇到可疑函数直接打印:

def inspect_closure(func): freevars = func.__code__.co_freevars cells = func.__closure__ or () for name, cell in zip(freevars, cells): try: print(f"{name} = {cell.cell_contents!r}") except ValueError: print(f"{name} = <未赋值>") inspect_closure(upload)

这个函数在处理"为什么我的闭包里值变了"这类问题时特别有效:一眼就能看出捕获的是哪几个名字、当前值是什么。如果发现两个函数的cell是同一个对象(is判断为True),那必然是循环变量共享的老问题。

7.2 引用计数与内存驻留的观察

闭包会延长对象生命周期,这是它最容易被忽视的成本。__closure__持有的是强引用,只要闭包活着,被捕获的对象就不会被回收。

import sys def make_holder(payload): def holder(): return len(payload) return holder big = [0] * 1_000_000 h = make_holder(big) print(sys.getrefcount(big)) # 注意这里会多算一个 getrefcount 自己的临时引用

判定思路很简单:如果闭包只需要对象的一个小属性,就不要捕获整个大对象。比如回调里只用config["timeout"],那就在外层先把timeout = config["timeout"]取出来再被捕获,让大字典可以被回收。这个技巧在处理大 DataFrame、大字典、大缓冲区时非常实用。

还有一个更隐蔽的情况:两个闭包互相引用、或者闭包被注册到全局容器里,形成环。Python 的循环 GC 最终会收拾,但如果你在__del__里依赖及时回收,行为可能不符合预期。观察办法是gc.get_objects()里按类型筛选函数对象,或者直接在gc.collect()前后比较gc.garbage。

7.3 几道自测题:能全对说明你真的理解了

我整理了几道在面试和自我检查中反复出现的题,你可以先自己心算,再看解析。

第一题

def outer(): xs = [] for i in range(3): xs.append(lambda: i) return xs print([f() for f in outer()])

答案[2, 2, 2]。三个 lambda 共享outer里同一个i的 cell。改法:lambda i=i: i。

第二题

def outer(): n = 0 def inc(): n += 1 return n return inc outer()()

直接UnboundLocalError。n += 1使n成为inc的局部变量,必须加nonlocal n。

第三题

G = "global" def outer(): G = "local" def inner(): return G return inner print(outer()())

答案local。outer里对G的赋值使它成为outer的局部变量,inner引用它形成了闭包,inner.__closure__不为None。这里的G和模块级G是两个完全不同的变量。

第四题

def make(): funcs = [] for i in range(3): def f(n=i): return n * 10 funcs.append(f) return funcs print([x() for x in make()])

答案[0, 10, 20]。默认参数在定义时求值,每次迭代都冻结了当时的i。

第五题

def outer(): data = {"n": 0} def bump(): data["n"] += 1 def show(): return data["n"] bump() return show print(outer()())

答案1。data是可变容器,原地修改不需要nonlocal,而且bump和show共享同一个 cell,所以改动对show可见。这个模式在需要"读方"和"写方"分离时很好用。

如果这五题你都能一眼判断,那么闭包这一块在原理层面基本过关了。剩下的就是把它用在合适的地方——我个人的原则是:能一眼看懂就用,需要想两秒才能说清状态在哪就用类。这个原则帮我省下了不少代码审查的时间。

最后再补一个实践里的小技巧。当你不确定某段用了闭包的代码到底是"捕获值"还是"捕获变量"时,最快的验证方式不是读代码,而是在可疑位置打印func.__closure__[0].cell_contents加上id()。两三次对比之后,规律就刻在脑子里了,以后再看到lambda出现在循环里,手会自动停下来。

返回列表