KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 闭包与延迟导入:两个「藏在函数里」的写法 — keel 龙骨

两个不起眼但频繁出现的写法:函数体里再定义函数(闭包)、函数体里再 import(延迟导入)。真实代码都用在刀刃上——闭包做「本次调用内」的去重状态,延迟导入防重资源提前初始化、破循环依赖。

两个不起眼但频繁出现的写法:函数体里再定义函数(闭包)、函数体里再 import(延迟导入)。真实代码都用在刀刃上——闭包做「本次调用内」的去重状态,延迟导入防重资源提前初始化、破循环依赖。


一、闭包:为什么把告警函数定义在函数体里

def process_items(items):
    alerted = False              # ← 外层函数的局部变量

    def _maybe_alert(error_msg):
        nonlocal alerted         # ② 要重新赋值外层变量,必须 nonlocal 声明
        if alerted:
            return
        print(f"alert: {error_msg}")   # 整个批次只告警一次
        alerted = True           # ③ 修改外层变量

    for it in items:
        try:
            handle(it)
        except Exception as exc:
            _maybe_alert(str(exc))     # 失败时调用,但一批只发一次

为什么这么写,三个理由:

  1. 闭包天然拿到「最新状态」:告警可能发生在循环的任何一轮——闭包读到的 alerted 始终是这一轮的最新值。如果把这些做成模块级函数,alerted 就得当参数一路传,或者搞全局;
  2. alerted 的作用域刚好:这个「是否已告警」标志只在 process_items 这一次调用内有意义。放全局会跨批次串(上一批发过告警,这一批就不发了——严重 bug);放 self 上又没有合适的宿主(这是普通函数)。函数级状态,闭包是最小的正确容器;
  3. nonlocal 是关键:只读外层变量直接用;要赋值必须声明 nonlocal,否则 Python 会把它当「本地新变量」(赋值那行变成定义新局部变量,静默失效——一个经典的隐蔽 bug)。读不需要声明,这是闭包规则里最容易记反的点。

1.1 闭包的迟到绑定坑

funcs = []
for i in range(3):
    funcs.append(lambda: i)      # 三个 lambda 共享同一个 i
print([f() for f in funcs])      # [2, 2, 2] —— 不是 [0, 1, 2]!

闭包捕获的是变量本身,不是求值时的快照。循环结束后 i=2,三个 lambda 都读它。修法:lambda i=i: i(默认参数在定义时求值)。

二、延迟导入:把 import 挪进函数体

2.1 防重资源提前初始化

# report.py
def export_excel(rows):
    # 延迟导入:避免模块加载时就初始化 openpyxl(重依赖)
    import openpyxl
    wb = openpyxl.Workbook()
    ...

问题链条:Python 的 import 是执行模块顶层代码。如果 report.py 在模块加载期就 import openpyxl,任何 import 这个文件的进程(哪怕只是想用个工具函数)都会连带初始化 openpyxl。延迟导入把初始化时机从「import 时」推迟到「真正要用时」。

2.2 打破循环导入

延迟导入的另一个高频用途:A 模块顶层 import B,B 顶层又 import A → 循环 → ImportError。两个互相引用的模块:

# formatter.py
def format_row(row):
    from exporter import default_charset     # 把 import 挪进函数体内
    return row.encode(default_charset)

# exporter.py
default_charset = "utf-8"

def export(rows):
    for row in rows:
        print(format_row(row))

exporter 顶层 import formatter 会触发 formatter 又 import exporter 的循环;把 formatter 里那行挪进函数体,循环就被剪断了。判断标准:import 报错提示「most likely due to a circular import」时,找环上最边缘的那条依赖线,剪掉。

2.3 代价与纪律

延迟导入不是免费的:

生产纪律:只在两类场景用——① import 会触发重资源初始化(重型库、连接客户端);② 剪循环依赖。其余一律顶层导入。

三、可迁移的套路

  1. 「只属于一次函数调用的临时状态 + 若干辅助函数」→ 闭包内聚,nonlocal 记得声明;
  2. 闭包捕获变量本身——循环里造函数,默认参数固定值;
  3. import 触发重资源初始化 / 循环依赖 → 延迟导入,并写注释说明为什么(好的项目每处延迟导入都带一行原因注释,这是好习惯);
  4. 反向规则也要记:不要为了「显得整洁」把顶层 import 全部塞进函数——错误延后的代价会在最尴尬的时刻出现。

↓下一步:04 章 · 声明式鉴权与中间件管道

进入 keel 阅读