很多人第一次看到这样的代码都会懵:

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 中,通常把满足以下三个条件的内部函数称为“闭包”:

  1. 存在嵌套函数:一个函数内部定义了另一个函数;
  2. 内部函数使用了外部函数的局部变量
  3. 外部函数将内部函数作为返回值返回,并在其定义完成后仍被外部调用。

这种情况下,被返回的内部函数本身,连同它所引用的外部变量环境,就共同构成了闭包。
可以将闭包理解为:

函数 + 定义时捕获的一组局部变量的绑定。

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))

运行结果:
在这里插入图片描述
解释过程:

  1. 调用 make_adder(5) 时:
    • 在 make_adder 的局部作用域中产生 x = 5;
    • 定义内部函数 adder(y),其逻辑是 x + y;
    • 返回 adder 函数对象,此时 x = 5 被保留在闭包中。
  2. 调用 make_adder(10) 时,同理得到一个“内部携带 x = 10”的函数。
  3. 因此:
    • 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)

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐