ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

python函数闭包概念

python函数闭包概念

前言


闭包(closure)是函数式编程里最常被提起、也最容易被含糊带过的概念。很多人第一次接触时的理解是「函数里套函数」,这个说法只描述了长相,没有说明它为什么特殊。真正关键的一点是:内层函数在离开外层函数之后,仍然能读到外层函数的局部变量。没有这一点,「函数里套函数」和普通的嵌套调用没有区别。


另一个典型误解是认为闭包「复制」了外层变量的值。事实相反:闭包捕获的是变量本身(更准确说是变量所在的单元 cell),而不是变量当时的值。这个区别直接导致了后面要讲的延迟绑定坑——循环里创建的一批 lambda,最后全都读到同一个变量的最终值。


本文要讲清三件事:闭包在 Python 中到底捕获了什么、它靠什么机制活过外层函数的返回、以及什么时候该用它、什么时候不该用。文中示例以 Python 3 为基准,闭包机制本身从 Python 3.0 起就没有变过,nonlocal也是 3.0 引入的;Python 2 在 2020 年 1 月 1 日已停止维护,本文不再使用print语句等旧写法。


一、闭包的最小定义


一个闭包由三部分构成:外层函数、在函数内部定义的内层函数、以及内层函数引用的外层函数局部变量。


# 适用于 Python 3.0+
def make_greeter(prefix):
# prefix 是外层函数的局部变量(也是形参)

def greet(name):
# greet 引用了 prefix,prefix 对 greet 而言是“自由变量”
return f"{prefix}, {name}!"

return greet

hello = make_greeter("Hello")
print(hello("World")) # Hello, World!
print(hello("Python")) # Hello, Python!

make_greeter("Hello")已经返回了,它的栈帧按理说应该消失,局部变量prefix也应该随之销毁。但hello("World")依然能取到"Hello"。这就是闭包存在的意义:把数据和行为一起打包,而不是把数据塞进全局变量或类的属性里。


注意区分两种「函数里套函数」:




写法内层函数是否引用外层局部变量是否形成闭包



内层函数只用自己的参数和全局名否否,只是嵌套函数

内层函数读取外层局部变量是是

内层函数nonlocal修改外层局部变量是(且可写)是



二、闭包在 CPython 里靠什么活着


CPython 的实现里,被闭包引用的局部变量不再存放在普通的栈帧插槽中,而是被搬进一个「单元对象」(cell)。内层函数的函数对象通过__closure__属性持有一串 cell,访问自由变量时就是去 cell 里取值。


# 适用于 Python 3.0+
def make_greeter(prefix):
def greet(name):
return f"{prefix}, {name}!"
return greet

g = make_greeter("Hi")
print(g.__closure__) # (<cell at 0x...: str object at 0x...>,)
print(g.__closure__[0].cell_contents) # Hi
print(g.__code__.co_freevars) # ('prefix',)


  • co_freevars是函数代码对象上的属性,列出该函数用到的所有自由变量名,返回一个元组。

  • __closure__是对应的 cell 元组,顺序与co_freevars一致;若函数不是闭包,这个属性的值是None。

  • cell_contents是 cell 里当前保存的对象。


这几项是 CPython 的实现细节,用来观察和调试很好用,但不要依赖它们写业务逻辑——其他实现(如 PyPy)不一定保证同样形态。


三、读和写是两件事:nonlocal


只读自由变量不需要任何声明。但只要在内层函数里对该名字赋值,Python 就会把它当成内层函数的局部变量,于是出现著名的UnboundLocalError。这时必须用nonlocal显式声明。


# 适用于 Python 3.0+
def make_counter():
count = 0

def counter():
nonlocal count # 声明 count 来自最近的外层函数作用域
count += 1
return count

return counter

c = make_counter()
print(c(), c(), c()) # 1 2 3

nonlocal的规则是:在最近的外层函数作用域中找到该名字并绑定过去。如果外层根本没有这个名字,编译期就会直接抛SyntaxError,而不是等到运行时。相比之下global指向的是模块级命名空间,两者不能混用。


实战:一个带状态的限流器


下面这个例子把闭包用在真实场景里——记录调用时间,判断是否超频。它不需要定义类,也不需要全局变量。


# 适用于 Python 3.0+
import time

def make_limiter(max_calls, period):
"""返回一个函数:在 period 秒内最多允许 max_calls 次调用。"""
history = []

def allow():
now = time.monotonic()
# 丢掉窗口之外的时间戳
while history and now - history[0] >= period:
history.pop(0)
if len(history) >= max_calls:
return False
history.append(now)
return True

return allow

limiter = make_limiter(3, 1.0)
print([limiter() for _ in range(5)]) # [True, True, True, False, False]

history是allow的自由变量,每个make_limiter调用都会生成一份独立的 cell,因此两个限流器互不干扰。这正是闭包相对全局变量的优势:状态是私有的、可多次实例化的。


再补一个配置工厂的写法,说明闭包怎样做「参数预置」:


# 适用于 Python 3.0+
def make_formatter(uppercase=False, sep="-"):
def fmt(*parts):
text = sep.join(str(p) for p in parts)
return text.upper() if uppercase else text
return fmt

plain = make_formatter()
loud = make_formatter(uppercase=True, sep=" | ")
print(plain("a", "b", "c")) # a-b-c
print(loud("a", "b", "c")) # A | B | C

常见坑点


1. 循环里创建闭包,全都读到最后一个值


❌ 错误写法:


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

✅ 正确写法(用默认参数把当前值绑定进函数对象):


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

要理解这不是 bug,而是「捕获变量而非值」的必然结果。另一种写法是用工厂函数包一层,或改用functools.partial。


2. 在内层函数里赋值却忘了nonlocal


❌ 错误写法:


def make_counter():
count = 0
def counter():
count += 1 # UnboundLocalError: cannot access local variable
return count # 'count' where it is not associated with a value
return counter

✅ 正确写法:


def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter

3. 把可变对象当闭包状态,外部还能改到


❌ 错误写法:


def make_adder():
data = [] # 若外层还有个引用,就会被外部改掉
...

✅ 正确写法:状态用不可变绑定或私有容器,只通过返回的函数操作:


def make_adder():
state = {"data": []} # 容器本身不对外暴露
def add(x):
state["data"].append(x)
return len(state["data"])
return add

4. 以为闭包会「冻结」变量当时的值


❌ 错误理解:


x = 1
def f():
return x # 以为这里永远返回 1
x = 2
print(f()) # 实际是 2

✅ 正确写法(确实需要快照,就显式取默认参数或做一次赋值):


x = 1
def f(_snapshot=x): # 默认值在函数定义时求值,等于快照
return _snapshot
x = 2
print(f()) # 1

5. 在类体里写闭包,误以为能拿到实例


❌ 错误写法:


class C:
def make(self):
return lambda: self.x # 这样写没问题,但下面这样写就不行
f = lambda: x # 类体里的 lambda 看不到实例属性

✅ 正确写法:方法里返回闭包,把self绑进去:


class C:
def __init__(self, x):
self.x = x
def make(self):
return lambda: self.x

6. 依赖__closure__/co_freevars写业务逻辑


❌ 错误写法:用f.__closure__[0].cell_contents = 5去改状态。


✅ 正确写法:把状态变化做成闭包主动暴露的接口(返回一个 setter),或者干脆用类。__closure__、co_freevars、cell_contents都是 CPython 实现细节。


7. 闭包持有大对象导致内存不释放


❌ 错误写法:


def load():
big = open("data.txt").read() # 几 MB 的字符串
def size():
return len(big)
return size # big 一直被 cell 引用,无法回收

✅ 正确写法:只捕获真正需要的那部分数据,或者显式del:


def load():
big = open("data.txt", encoding="utf-8").read()
n = len(big)
def size():
return n
return size

8. 用闭包实现「只读一次」的初始化却忘了线程安全


❌ 错误写法:多个线程同时首次调用,初始化会跑多次。


✅ 正确写法:改用functools.lru_cache(3.2+)或functools.cache(3.9+)这类已经处理好并发的缓存装饰器:


# 适用于 Python 3.9+
from functools import cache

@cache
def get_config():
return {"debug": False}

总结




概念要点



闭包三要素外层函数、内层函数、内层函数引用的外层局部变量

捕获的是变量所在的 cell,不是值的副本

co_freevars函数用到的自由变量名元组

__closure__对应的 cell 元组,非闭包时为None

只读直接读,不需要声明

要写必须nonlocal;找不到外层绑定会编译期SyntaxError

最大风险循环中的延迟绑定,所有闭包读到同一个最终值

版本nonlocal自 Python 3.0 起可用



闭包的本质是把「一段行为」和「它需要的那点私有状态」绑在一起返回,用好了可以省掉大量只有一两个方法的类;但也正因为它共享变量而非常量,循环里的延迟绑定才成了新手最难排查的一类问题。记住一句话就够了:闭包看的是变量,不是值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进