为三个任务分别准备 red、blue、green 后缀,调用三个回调时却都得到 "hello green"。循环没有跳过前两项,任务也没有覆盖已经成功的结果;三个函数在调用时查找的是同一个已经走到末尾的循环变量。

05 的可变默认参数实验区分了“函数定义时得到什么”和“函数调用时再查找什么”。本章沿用同一个 tasklab,由可信的本地回调先转换合成文本,再以原来的 submit → run → query 路径执行词频;任务输入不能携带代码或模块名。

三个函数,一次晚到的名称查找

1
2
3
4
def late_callbacks(suffixes):
return [lambda text: f"{text} {suffix}" for suffix in suffixes]

callbacks = late_callbacks(["red", "blue", "green"])

推导时要画时间轴,而不是把 lambda 视作立即运行。循环迭代会创建三个函数对象;它们引用外层函数的 suffix 名称,但此时还没有用文本调用函数。late_callbacks 返回以后,外层循环留下的 suffix 值是 "green";逐个调用时才读取它,三个结果因此一致。官方编程常见问题专门解释了这一现象,执行模型则给出名称解析的语义背景。这个现象不特属于 lambda,换成引用同一外层变量的普通嵌套函数也会发生。

针对当前任务,所有错误回调都提交 "hello green",应用分别保存六次提交中的前三项;三个词频结果均为 {"hello": 1, "green": 1}。这不是任务 ID 冲突,每个任务都按收到的文本正确执行。输入在进入应用之前已经错了。

创建回调时固定对应的后缀

tasklab.transforms.suffix_callbacks 将本轮循环值绑定到每个回调自身的默认形参:

1
2
def suffix_callbacks(suffixes):
return [lambda text, suffix=suffix: f"{text} {suffix}" for suffix in suffixes]

默认表达式在各个函数对象创建时求值,各回调以后使用自己持有的字符串值。实测三项分别生成包含 red、blue、green 的文本,词频结果也分别含对应键;累计任务数为 6。这里默认值是字符串,05 的危险可变列表不在场。若后缀本身改成可变容器,并且回调内部会修改它,还必须重新设计所有权边界,不能仅凭“用了默认形参”宣称隔离。

在 examples/python-language/ 目录执行:

1
2
PYTHONPATH=src /usr/bin/python3.12 experiments/06_closures.py
PYTHONPATH=src /usr/bin/python3.12 -m unittest discover -s tests -v

两条命令均以 0 退出。第一条输出同时保留 late 的三份错误结果和 bound 的三份正确结果;第二条累计运行 20 条测试,验证错误查找时机与修复后的每份任务输入。examples/python-language/evidence/06/20261007-closures/ 保存完整环境、命令、原始输出和断言。原业务层仍只接受白名单的 word_count;它并未提供从上传数据执行任意回调的功能。

预测题

先创建三项 late_callbacks,随后执行 suffixes = ["yellow"],原来的三个回调会改为输出 "hello yellow" 吗?

答案:不会。这次赋值给调用方的名称换了一份新列表,不会替换已经返回的回调所引用的外层 suffix 名称;后者仍是创建时循环结束后的 "green"。要判断更复杂的情况,必须继续区分“改变名称所指的列表”和“修改被共同引用的列表”。这个变式是推导题,原始实验未包含重绑动作。

变更题

若将 suffix_callbacks 的 suffix=suffix 删除,保留其他实现,哪条断言应先失败?只断言任务数为 3 为什么不够?

答案:test_early_binding_keeps_each_task_input_distinct 应检查结果序列中依次出现 red、blue、green,并核对第一项的已保存文本;删掉默认参数后,回调到调用时才找循环变量,第一项不再是 "hello red"。即使三个任务都有唯一 ID,它们仍可能提交完全相同的输入。此错误改动未应用到受信任的生产回调。

下一章把合法状态和操作分派放在一起,检查条件短路与模式匹配是否掩盖了未知请求。系列导航只连接实际文章。

参考资料