Python 内部函数实战:闭包、状态与装饰器

2026-09-26 32 预计阅读时间: 1 分钟
来源: realpython.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:8 分钟

在另一个函数内部使用 def 定义函数,看起来只是缩进多了一层,但它解决了三个很实际的问题:复用外层作用域中的名字、把状态封装进闭包,以及构建可配置的装饰器。内部函数不是为了隐藏复杂度而随意嵌套代码;它更适合表达“这个行为只属于当前函数,并且需要共享当前上下文”。

内部函数能访问哪些名字

Python 查找变量时会沿着 LEGB 作用域链进行:局部作用域(Local)、外层函数作用域(Enclosing)、全局作用域(Global)和内置作用域(Built-in)。因此,内部函数可以直接读取外层函数中的变量。

下面这个例子可以直接运行:

def format_user(name: str, uppercase: bool = False) -> str:
    prefix = "USER"

    def normalize(value: str) -> str:
        value = value.strip()
        return value.upper() if uppercase else value

    return f"{prefix}: {normalize(name)}"


print(format_user("  Alice  "))
print(format_user("  Alice  ", uppercase=True))

输出为:

USER: Alice
USER: ALICE

这里的 normalize() 可以读取参数 uppercase。因为它只服务于 format_user(),放在内部比暴露成模块级函数更能体现其使用边界。

不过,“读取外层变量”和“修改外层变量”是两件事。只要内部函数对一个名字赋值,Python 默认就会把它视为内部函数的局部变量。若要修改外层函数中的绑定,需要显式使用 nonlocal。

用 nonlocal 构建带状态的闭包

外层函数返回内部函数后,内部函数仍然可以保留对外层变量的引用。这种函数与其捕获环境的组合就是闭包。

下面实现一个简单计数器:

from collections.abc import Callable


def make_counter(start: int = 0, step: int = 1) -> Callable[[], int]:
    value = start

    def counter() -> int:
        nonlocal value
        value += step
        return value

    return counter


by_one = make_counter(start=10)
by_five = make_counter(start=0, step=5)

print(by_one())   # 11
print(by_one())   # 12
print(by_five())  # 5
print(by_five())  # 10

每次调用 make_counter() 都会创建一套独立状态。nonlocal value 表示赋值目标是最近一层外部函数中的 value,而不是创建新的局部变量。

如果去掉 nonlocal,执行 value += step 时会出现 UnboundLocalError:Python 已经把 value 判定为局部变量,但在读取它进行加法运算时,它尚未在当前局部作用域中赋值。

闭包适合保存少量、边界清楚的状态,例如:

  • 生成递增序号;
  • 记录轻量级调用统计;
  • 根据初始化参数创建校验器或格式化函数;
  • 预先绑定配置,生成专用回调函数。

如果状态字段很多、需要继承、需要公开检查,或者存在复杂生命周期,类通常比闭包更清晰。

装饰器本质上是函数包装器

装饰器接收一个函数,再返回一个新函数。内部函数可以捕获被装饰函数和装饰器参数,因此非常适合完成这项工作。

下面是一个可配置的重试装饰器。示例不依赖第三方库,可以保存为 retry_demo.py 后直接运行:

from collections.abc import Callable
from functools import wraps
from time import sleep
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def retry(attempts: int = 3, delay: float = 0.0):
    if attempts < 1:
        raise ValueError("attempts must be at least 1")

    def decorate(func: Callable[P, R]) -> Callable[P, R]:
        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last_error: Exception | None = None

            for attempt in range(1, attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_error = exc
                    print(f"attempt {attempt} failed: {exc}")
                    if attempt < attempts and delay > 0:
                        sleep(delay)

            assert last_error is not None
            raise last_error

        return wrapper

    return decorate


failures_remaining = 2


@retry(attempts=3, delay=0.1)
def fetch_value() -> str:
    global failures_remaining
    if failures_remaining > 0:
        failures_remaining -= 1
        raise RuntimeError("temporary failure")
    return "ok"


print(fetch_value())
print(fetch_value.__name__)  # fetch_value

这个例子有三层函数:

  1. retry() 接收装饰器配置,捕获 attempts 和 delay;
  2. decorate() 接收被装饰函数 func;
  3. wrapper() 接收实际调用参数,并执行重试逻辑。

functools.wraps() 很重要。它会保留原函数的名称、文档字符串等元数据,否则日志、调试工具和 API 文档可能只看到一个名为 wrapper 的函数。

实际项目中不要不加区分地重试所有 Exception。更稳妥的做法是只捕获明确的瞬时错误,并确认操作是否具备幂等性;支付、创建订单等操作如果盲目重试,可能产生重复副作用。

一个常见陷阱:循环变量的延迟绑定

闭包捕获的是名字,而不是在定义瞬间自动复制变量值。因此,在循环中批量创建内部函数时,所有函数可能最终读取同一个循环变量。

def build_bad_multipliers():
    return [lambda value: value * factor for factor in range(1, 4)]


print([func(10) for func in build_bad_multipliers()])
# [30, 30, 30]

调用这些函数时,循环已经结束,factor 的值是 3。可以用默认参数在创建函数时绑定当前值:

def build_multipliers():
    return [
        lambda value, factor=factor: value * factor
        for factor in range(1, 4)
    ]


print([func(10) for func in build_multipliers()])
# [10, 20, 30]

也可以改用单独的工厂函数,让绑定过程更直观:

def make_multiplier(factor: int):
    def multiply(value: int) -> int:
        return value * factor

    return multiply

什么时候值得使用内部函数

可以用下面的清单判断是否应该增加一层嵌套:

  • 辅助行为只在一个外层函数中使用;
  • 内部行为需要读取外层参数或局部配置;
  • 需要返回一个携带少量私有状态的回调;
  • 正在实现装饰器或函数工厂;
  • 嵌套后仍然容易单独理解和测试。

反过来,如果内部函数很长、被多个位置复用,或者捕获了大量可变状态,应考虑提取为模块级函数或类。闭包能够减少公开接口,但过深的嵌套也会让状态来源变得隐蔽。

一个稳妥的实践原则是:用内部函数表达局部行为,用闭包保存小而明确的状态,用装饰器包装横切逻辑;当状态和生命周期开始变复杂时,及时切换到类或显式对象。


相关推荐