Python 闭包与装饰器:从原理到实践
文章目录
很多人第一次看到这样的代码都会懵:
def make_adder(x):
def adder(y):
return x + y
return adder
add5 = make_adder(5)
add10 = make_adder(10)
print(add5(3))
print(add10(3))
或者:
@log
def hello():
print("Hello")
“我大概知道它能干什么,但完全说不清楚它是怎么做到的。”
这篇文章就干一件事情:
“用尽量慢的节奏,把“闭包”和“装饰器”的逻辑讲清楚,让你能自己从零写出来。”
一、准备知识:变量的作用域与生命周期
理解闭包之前,需要先明确一件事:变量在哪里“存在”,以及能存在多久。
在一个普通的 Python 脚本中,可以简单地将变量分为两类:
- **全局变量:**定义在函数外部,可以在模块中的任意位置被访问(在不被同名局部变量遮蔽的前提下)。
- **局部变量:**定义在函数内部,只能在该函数的执行过程中访问。
示例:
x = 10 # 全局变量
def foo():
y = 20 # 局部变量
print("在 foo 中看到的 x:", x)
print("在 foo 中看到的 y:", y)
foo()
print("在全局中看到的 x:", x)
print("在全局中看到的 y:", y) # 会抛出 NameError
运行结果如图所示:
解释:
- 函数 foo 内部可以同时访问到全局变量 x 与局部变量 y;
- 当 foo 执行结束后,y 的生命周期随之结束,离开函数后再访问 y 就会报错。
用一句更直白的话概括:局部变量“随函数而生,随函数结束而消亡”。
接下来要介绍的闭包机制,正是为“在函数结束之后,仍然希望保留部分局部变量”这一需求提供了解决方案。
二、闭包:让函数携带一份“记忆”
2.1 闭包的构成条件
在 Python 中,通常把满足以下三个条件的内部函数称为“闭包”:
- 存在嵌套函数:一个函数内部定义了另一个函数;
- 内部函数使用了外部函数的局部变量;
- 外部函数将内部函数作为返回值返回,并在其定义完成后仍被外部调用。
这种情况下,被返回的内部函数本身,连同它所引用的外部变量环境,就共同构成了闭包。
可以将闭包理解为:
函数 + 定义时捕获的一组局部变量的绑定。
2.2 一个基础示例
先看一个简单的嵌套函数:
def outer():
x = 10
def inner():
print("inner 里访问 x:", x)
inner()
这里虽然存在函数嵌套(outer 中定义了 inner),并且 inner 使用了外部函数的变量 x,但 outer 并没有把 inner 返回出去,只是在内部调用了一次。因此,严格来说此时还不能称为闭包。
修改如下:
def outer():
x = 10
def inner():
print("inner 里访问 x:", x)
return inner
f = outer()
f()
运行结果:
此时:
- outer 执行完成,返回了函数对象 inner;
- 变量 x 按道理应该随 outer 的结束而消失;
- 但通过 f() 调用时,inner 依然可以访问 x。
这说明:inner 在被创建时,将外层 outer 的局部变量 x 一并“打包”了起来,使得 x 的生命周期被延长到了闭包存在的整个期间。
2.3 经典案例:加法函数工厂
下面我们来看一下文章开篇时的代码,更能体现出闭包的实用性:
def make_adder(x):
def adder(y):
return x + y
return adder
add5 = make_adder(5)
add10 = make_adder(10)
print(add5(3))
print(add10(3))
运行结果:
解释过程:
- 调用 make_adder(5) 时:
- 在 make_adder 的局部作用域中产生 x = 5;
- 定义内部函数 adder(y),其逻辑是 x + y;
- 返回 adder 函数对象,此时 x = 5 被保留在闭包中。
- 调用 make_adder(10) 时,同理得到一个“内部携带 x = 10”的函数。
- 因此:
- add5(3) 实际上是 5 + 3;
- add10(3) 实际上是 10 + 3。
从使用者的角度看,可以将 make_adder 理解为一个 “加法函数工厂”:给定一个固定的加数 x,它返回一个“专门用来加 x 的函数”。
闭包在这里扮演的角色是:让返回的函数在之后的调用中,仍然能访问到定义时外层环境中的 x。
2.4 带有“可变状态”的闭包:计数器与 nonlocal
前面的闭包只涉及对外部变量的“读取”。如果希望在内部函数中修改外层函数的局部变量,就需要用到关键字 nonlocal。
考虑这样一个“计数器”的例子:
def counter():
n = 0
def inc():
nonlocal n # 指明使用的是外层 counter 中的 n,而不是新建局部变量
n = n + 1
return n
return inc
c = counter()
print(c()) # 1
print(c()) # 2
print(c()) # 3
d = counter()
print(d()) # 1(另一份独立的计数器)
运行结果:
解释说明:
- counter() 每调用一次,就会生成一份新的闭包,其中保存着各自独立的 n;
- inc 通过 nonlocal n 声明,对 n 的赋值操作作用于外层的 n,而非创建新的局部变量;
- 因此同一个闭包对象 c 多次调用时,n 会累积增加;而 d 则是另一份独立的计数器。
这里可以顺便对比 nonlocal 与 global 的作用:
- nonlocal:用于在内部函数中声明“使用最近一层外部函数作用域中的同名变量”;
- global:用于在函数中声明“使用模块级别的全局变量”。
二者都用于修改外层变量,但作用的作用域层级不同。
三、从闭包到装饰器:逐步抽象
装饰器常被介绍为一种“在不修改原函数代码的前提下,为函数增加额外功能的方式”。听起来抽象,其实本质上仍然是闭包,只是把“闭包 + 函数替换”的套路封装成了一个较为固定的模式。
3.1 最初级的“加壳”写法
假设有如下函数:
def hello():
print("Hello")
如果需要在调用 hello 前后打印提示信息,最直接的做法是再写一个新函数:
def hello_with_log():
print("准备调用 hello")
hello()
print("调用结束 hello")
hello_with_log()
这种写法虽然能满足需求,但存在明显缺点:
- 若有多个函数都需要类似的“前后打印”逻辑,就必须为每个函数各写一套“带日志版本”,重复度很高,难以维护。
3.2 将“加壳逻辑”抽象为高阶函数
为了复用这一“加壳逻辑”,可以把它写成一个接收函数作为参数的高阶函数:
def log(func): # func 是“被增强”的原函数
def wrapper(): # wrapper 是“增加功能后的新函数”
print("准备调用")
func()
print("调用结束")
return wrapper
def hello():
print("Hello")
g = log(hello)
g()
这里 log 的行为可以描述为:
- 输入:一个函数 func;
- 输出:一个新函数 wrapper,其中在合适的时机调用 func。
此时 wrapper 使用了外部函数 log 中的变量 func,并由 log 返回,因此 wrapper 本身就是一个闭包。
3.3 用“加壳后的函数”覆盖原来的函数名
进一步,可以直接用返回的闭包替换原有函数的引用:
def log(func): # func 是“被增强”的原函数
def wrapper(): # wrapper 是“增加功能后的新函数”
print("准备调用")
func()
print("调用结束")
return wrapper
def hello():
print("Hello")
hello = log(hello) # hello 现在指向 wrapper
hello()
从这一行开始:
- 名为 hello 的对象不再是最初定义的那个函数;
- 它变成了 log 返回的闭包 wrapper;
- 在 wrapper 内部保存着对原始 hello 函数的引用。
四、通用装饰器:支持任意参数与返回值
前述的 log 只能处理无参数函数。现实场景中,函数常常需要接受各种参数并返回结果,因此装饰器通常写成“通用参数形式”。
4.1 使用 *args 和 **kwargs
可以将 log 重写为如下形式:
def log(func):
def wrapper(*args, **kwargs):
print("准备调用")
result = func(*args, **kwargs) # 将所有参数原样传递给原函数
print("调用结束")
return result # 返回原函数结果
return wrapper
解释:
- *args 捕获所有位置参数;
- **kwargs 捕获所有关键字参数;
- 调用原函数时使用 func(*args, **kwargs) 将捕获到的参数重新“拆开”传入;
- 如此一来,装饰器可以作用于几乎任何函数,而无需关心其具体参数列表。
4.2 引入 @ 语法糖
Python 提供了装饰器语法糖,使上述“函数替换”过程更简洁。以下两段代码是等价的:
@log
def hello():
print("Hello")
等价于
def hello():
print("Hello")
hello = log(hello)
更多推荐

所有评论(0)