一段带 return 的 block 在方法内部调用时可以正常返回,保存后等方法结束再调用,却可能抛 LocalJumpError。原因不是代码内容改变,而是退出目标已经不在当前执行路径里。闭包同时保存环境,控制语句还携带特定的退出语义,两者必须分开分析。

文章卡

项目 内容
先修 第 06 篇的 Proc、lambda 和 yield
实验 bundle exec ruby labs/07/run.rb
核心问题 return、break、next 分别终止哪次执行?
验收 有效与失效的 return/break、lambda 局部返回、共享闭包绑定
工程边界 长期保存回调优先使用明确的 call 返回契约

闭包捕获绑定,不是输入快照

两个闭包可以访问同一个外层局部变量:

1
2
3
4
5
6
count = 0
increment = -> { count += 1 }
observe = -> { count }
increment.call
increment.call
raise unless observe.call == 2

observe 看到的是已经修改后的绑定。闭包不是把创建时的 0 自动复制到一个私有字段里。捕获的变量还可以指向一个可变对象,其他引用修改它时,闭包也可能看到变化。

这项能力适合局部计数和配置工厂,也会延长被捕获对象的可达时间。把大型任务数组捕获进长期缓存回调,可能令数组无法回收;第 34 篇将可达性与分配实验连接起来。本篇不通过一个计数器就宣称闭包没有内存成本或线程风险。

保存回调时,必须说明它读取实时配置还是创建时配置。若需要快照,显式复制相应结构;若需要共享状态,则建立更新与同步契约。第 30 篇会验证跨 Thread 访问,单线程闭包计数并不能证明并发正确。

非 lambda Proc 的 return 指向定义方法

实验方法在自己的调用仍有效时执行 Proc:

1
2
3
4
5
6
def live_return
callback = proc { return :method_result }
callback.call
:unreachable
end
raise unless live_return == :method_result

Proc 中的 return 结束 live_return,最后一行不执行。这不是 callback.call 返回一个值后方法自然继续,而是一次非局部控制转移。Proc 控制流

将回调从即时执行改成队列存储,不只改变时间,还可能改变目标是否有效。定义方法返回以后,该 Proc 再执行 return,不能重新返回一个已经结束的方法调用。

1
2
3
4
5
6
7
8
9
10
11
def escaped_return
proc { return :left }
end

callback = escaped_return
begin
callback.call
raise 'dead return target accepted'
rescue LocalJumpError => error
raise unless error.reason == :return
end

这里的闭包仍存在,捕获的环境并没有因此消失;失效的是 return 的退出目标。捕获局部变量与恢复已结束调用是不同能力。错误原因记录为 :return,使反例能检查具体语义,而不是碰到任意异常都通过。

lambda 的 return 结束自身调用

同样把代码放进 lambda:

1
2
3
4
5
def local_return
callback = -> { return :lambda_result }
[callback.call, :method_continued]
end
raise unless local_return == [:lambda_result, :method_continued]

lambda 返回后,外层方法继续执行,第二个值出现在结果里。长期存储的转换函数通常需要这样的局部返回语义,因为调用时不应突然退出定义它的业务方法。

但“使用 lambda 就完全安全”也不成立。lambda 仍可捕获共享数据、抛异常、执行 I/O 和产生副作用。它解决的是特定参数与返回规则,不能代替资源管理、输入校验和并发协议。

如果只是产生最终值,不一定需要显式 return。让最后表达式产生回调结果,可以减少退出目标的歧义;需要提前结束时,再明确选择 lambda 或普通方法中的 return。代码形式与公开契约要一致。

break 结束接收 block 的调用

本篇使用一个简单方法:

1
2
3
4
5
6
7
def invoke
yield
:after_yield
end

raise unless invoke { break :caller_result } == :caller_result
raise unless invoke { next :block_result } == :after_yield

break 使 invoke 这次调用结束,并把给定值作为调用结果;方法末尾未执行。next 结束本次 block 执行,将值返回给 yield,外层方法随后继续,最终得到 :after_yield。两个语句的位置相同,目标却不同。

遍历回调中使用 next,可以略过当前项的处理;break 可以提早结束遍历。把这段 block 改成独立 Proc 后,不能默认仍存在同一个接收调用目标。第 08 篇中 Lazy 的终止是枚举协议自己的需求控制,也不等于任意回调中的 break 都可以被延后执行。

保存 break 也可能保存失效目标

实验方法将收到的 block 返回,而不当场调用:

1
2
3
4
5
6
7
8
9
10
11
def escaped_break(&block)
block
end

late = escaped_break { break :late }
begin
late.call
raise 'dead break target accepted'
rescue LocalJumpError => error
raise unless error.reason == :break
end

escaped_break 的那次调用已经结束,later call 不能再结束它。lab 断言 LocalJumpError 的原因是 :break,与前面的 :return 分开。只有测试“方法返回后执行”的路径,才会暴露这个问题;在定义方法内立即调用并通过,不能证明保存后的语义。

API 因此应明确即时回调与长期回调。配置 block 若在构造阶段立刻求值,可以明确限定控制范围。保存事件回调、任务队列或重试函数时,应约定普通 call 结果与异常传播,避免依赖已经退出的栈帧。

退出仍需要经过资源清理

本篇将正常返回和局部错误分开验证。第 18 篇的 ensure 将说明退出过程中的清理顺序,包括 return 与异常路径;不要为了保住清理而在 ensure 中再写 return,它可能覆盖原有结果或异常。

回调抛出 LocalJumpError 时,调用者通常应让这个编程错误被诊断出来。把所有错误捕获后返回空数组,会使 Taskbook 像是没有符合条件的任务,真正的回调错误却消失。错误分类是数据管道契约的一部分。

实验与练习

labs/07/run.rb 实际输出有效 return 的结果、lambda 外层继续结果、break 与 next 的目标,以及 closure=2,最后 PASS 07。两个失效目标的反例都有类型和 reason 检查。

练习是写一个保存回调的容器:先放入带 return 的非 lambda Proc,在创建方法结束后调用并断言失败;随后改成 lambda,检查正常结果。另一个练习是用两个闭包共享配置 Hash,分别实现实时读取和显式快照,修改外部配置后比较返回值。不要通过猜测“闭包应该复制”决定结果。

参考资料

系列导航

导读 · 上一篇:06:block、Proc 与 lambda · 下一篇:08:Enumerable、Enumerator 与 Lazy · 完整源码包