在另一个函数内部使用 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
这个例子有三层函数:
retry()接收装饰器配置,捕获attempts和delay;decorate()接收被装饰函数func;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
什么时候值得使用内部函数
可以用下面的清单判断是否应该增加一层嵌套:
- 辅助行为只在一个外层函数中使用;
- 内部行为需要读取外层参数或局部配置;
- 需要返回一个携带少量私有状态的回调;
- 正在实现装饰器或函数工厂;
- 嵌套后仍然容易单独理解和测试。
反过来,如果内部函数很长、被多个位置复用,或者捕获了大量可变状态,应考虑提取为模块级函数或类。闭包能够减少公开接口,但过深的嵌套也会让状态来源变得隐蔽。
一个稳妥的实践原则是:用内部函数表达局部行为,用闭包保存小而明确的状态,用装饰器包装横切逻辑;当状态和生命周期开始变复杂时,及时切换到类或显式对象。