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)) # 失败时调用,但一批只发一次
为什么这么写,三个理由:
- 闭包天然拿到「最新状态」:告警可能发生在循环的任何一轮——闭包读到的
alerted始终是这一轮的最新值。如果把这些做成模块级函数,alerted就得当参数一路传,或者搞全局; alerted的作用域刚好:这个「是否已告警」标志只在process_items这一次调用内有意义。放全局会跨批次串(上一批发过告警,这一批就不发了——严重 bug);放 self 上又没有合适的宿主(这是普通函数)。函数级状态,闭包是最小的正确容器;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 有一次查表开销(很小,
sys.modules命中即可,但不是零); - 可读性下降:依赖不再一眼可见(IDE 能提示,但读代码时容易漏)。
生产纪律:只在两类场景用——① import 会触发重资源初始化(重型库、连接客户端);② 剪循环依赖。其余一律顶层导入。
三、可迁移的套路
- 「只属于一次函数调用的临时状态 + 若干辅助函数」→ 闭包内聚,
nonlocal记得声明; - 闭包捕获变量本身——循环里造函数,默认参数固定值;
- import 触发重资源初始化 / 循环依赖 → 延迟导入,并写注释说明为什么(好的项目每处延迟导入都带一行原因注释,这是好习惯);
- 反向规则也要记:不要为了「显得整洁」把顶层 import 全部塞进函数——错误延后的代价会在最尴尬的时刻出现。
↓下一步:04 章 · 声明式鉴权与中间件管道