一个函数在资源有效期内创建闭包,再把闭包返回到外部,闭包就可能在资源关闭后继续使用该资源。普通返回类型 () => Int 只描述参数与结果,没有显示函数保存了哪些能力。Capture checking 研究把这部分引用关系放入类型检查,使某些能力逃逸在编译时被拒绝。

这是一项实验性语言能力。本篇固定 Scala 3.7.4、-experimental 与 language.experimental.captureChecking,只研究这一组合下的通过与拒绝样例,不把它作为主线稳定语义,也不据此建议生产系统立即采用。后续文档与实现可能继续变化。

独立工程位于 examples/scala-lab/electives/E08,成功记录为 evidence/20261002-b/。样例使用一个逻辑能力对象,不打开真实文件;它验证捕获约束,不假装已经完成文件资源管理或完整效果系统的安全证明。

返回值里可能隐藏引用

假设 File 对象提供 write 方法,使用函数把它交给调用方回调。普通 Scala 代码允许回调返回任意结果,因此也可能返回一个捕获 File 的函数。

1
2
3
4
5
class File:
def write(): Int = 42

def withFile[T](op: File^ => T): T =
op(new File)

本例的 File 只是最小能力模型,write 返回常数,便于把争议限制到类型关系。File^ 表示带捕获能力的类型。调用方可以在回调执行期间使用参数,但如果返回结果保留了这个参数,检查器需要判断该引用是否超出允许范围。

假设真实实现会在回调返回后关闭句柄,逃逸闭包就可能造成延迟使用已关闭资源。这个运行时问题与捕获关系有关,但修复仍需要正确实现关闭动作。加入捕获检查不会自动生成 try/finally,也不会替文件系统执行 close。

因此存在两个互补条件:类型边界限制能力如何被保留,运行时结构管理何时释放。只做其中一个,仍可能出错。一个从不关闭文件的函数,即使禁止返回捕获闭包,也仍然会泄漏文件描述符。

正例返回普通结果

Good.scala 在回调中立即调用 write,返回普通整数。离开回调后,结果不再需要 File 引用。

1
2
3
4
5
6
7
8
9
10
11
import language.experimental.captureChecking

class File:
def write(): Int = 42

def withFile[T](op: File^ => T): T = op(new File)

@main def good(): Unit =
val result = withFile(f => f.write())
assert(result == 42)
println(s"scoped-result=$result")

这段程序使用实验开关编译并在 JDK 21 上运行,输出 scoped-result=42,退出码为 0。它说明所选能力在回调期间被使用,并且返回的 Int 通过类型检查。

正例的重要性在于排除“所有实验代码都因开关配置错误而失败”的假象。如果只有一个拒绝用例,编译器不识别 ^、实验开关不生效或依赖未下载,都可能造成非零退出。先运行同一版本的正例,再观察反例,才能把失败定位到目标规则。

这个正例也不表示计算没有副作用。write 在真实实现中可以执行 IO,捕获检查讨论的是返回值保留哪些能力,而不是通过方法名或返回 Int 就断言没有外部效果。效果执行与引用保存相关,但不是同一个命题。

反例返回捕获闭包

Bad.scala 保持 File 与 withFile 定义不变,只把返回表达式改成闭包:

1
val escaped = withFile(f => () => f.write())

闭包执行时需要调用 f.write,因此闭包必须保留 f。反例在 Scala 3.7.4 下被拒绝,诊断包含返回函数对 f 的捕获关系,以及该引用无法进入外层捕获集合的说明。记录器要求非零退出,同时匹配 capture 或 cap 以及类型错误诊断。

拒绝发生在编译期,没有执行 escaped。它与“先运行闭包、等待文件关闭异常”的测试不同:后者只能说明某条运行路径出错,前者展示接口在运行之前对程序形状施加约束。

将函数写成局部变量再返回,不会改变它需要保存 f 的事实。把引用放进另一个对象字段,同样可能需要跟踪对象的捕获能力。捕获检查旨在沿类型关系传播这些依赖,而不是只搜索源代码中有没有 lambda。

不过,一个最小闭包反例不能证明所有间接保存路径都已被覆盖。泛型容器、继承、可变字段、类型推断与库边界都可能引入更复杂规则,必须在所选版本上单独测试。实验性实现的错误接受或错误拒绝,也不能通过这一例完全排除。

[PATTERN] 能力逃逸检查必须同时提供不保留能力的正例和保留能力的反例。诊断应指向捕获关系,不能只依据编译失败。

捕获集合描述依赖关系

普通函数类型主要说明“输入什么、输出什么”,捕获集合补充“该值还依赖哪些能力”。在相关语法中,可以显式表示一个函数允许保存某些能力引用;没有允许关系的外层结果,就不能随意携带局部能力离开。

这与局部变量的词法作用域不同。源代码中的 f 名称在回调外已经不可见,运行时闭包却仍然可能保存对应对象。词法名字消失不会自动销毁引用,捕获检查正是要让这类保留关系进入类型推导。

也不能把捕获集合等同于堆中所有可达对象。它关注语言模型中的能力依赖,具体何种参数、字段和类型被跟踪,要按版本规则解释。一个对象内部存在普通数据引用,不意味着它们都被当作可限制的资源能力。

在本文的诊断中,编译器展示了 fresh root capability、universal root capability 与待推断捕获集合。这些文字属于冻结版本的诊断细节,读者不需要记住内部占位符;需要识别的是返回函数捕获了 f,而目标结果类型不允许把该引用带出去。

如果错误信息过长,最有效的定位方式是先去掉容器和辅助函数,只留下 withFile 与一个返回表达式。最小化后再逐步加入原来的泛型包装,可以确定是哪一个边界失去或要求更多捕获信息。不要为了消除诊断直接增加宽泛能力标注,否则可能撤销原本希望建立的限制。

与资源管理、类型状态的关系

Resource 或 try/finally 负责在退出范围时执行释放。普通类型系统通常允许引用逃出该范围,因此需要 API 设计和使用约定配合。Capture checking 尝试在类型层收紧某些逃逸路径,二者可以互补,但不是互相替代。

类型状态 DSL 则主要约束对象当前暴露的操作。例如 Draft 可以 add,Confirmed 可以 submit。它并不自动限制一个对象被多少闭包保存,也不保证引用只使用一次。捕获关系、状态转换与线性使用分别回答不同问题,不应因为都出现类型参数就混成一种机制。

同样,能力约束不是访问控制系统的全部。真实权限还涉及谁可以创建能力、是否有不受限制的全局入口、外部数据能否伪造令牌,以及底层接口是否绕过检查。如果同时公开了一个无约束的全局文件操作函数,某个局部接口的捕获限制并不能封住整个系统。

外部库也是可信边界。未按相同模型描述的 Java 接口或第三方库,可能无法向检查器提供所需信息。给已有库补类型包装时,需要核查其实际保留行为:方法是立即调用回调,还是把回调存入后台任务,二者的捕获与生命周期要求不同。

实验版本需要独立冻结

本篇不使用“最新 Scala”作为运行条件。case.json 明确写入 3.7.4,开启实验功能,源码哈希与命令保存进证据目录。这样即使官方网站日后展示新语法,仍能恢复本文的编译器行为。

官方当前文档会继续演进。例如后续页面可能拆分为基础、类、作用域能力等章节,例子与术语也会变化。不能把新文档中的代码直接放入旧编译器,再把不兼容诊断解释成理论反例;同样不能把旧版成功样例当作新版保证。

反向核查还应查看编译器问题记录。捕获集合推断、能力成员与实验模式都可能出现正在修复的问题。公开 issue 能说明哪些形状值得追加回归测试,却不能在未复现时证明本文冻结版本也有同一问题。版本、最小源码和实际诊断需要一起比较。

本文仅将经过本机编译的两个最小程序标记为 LAB 证据。更广的能力模型说明属于官方资料层面的解释,不宣称本机实验已经证明实现完备或类型系统健全。尤其不能把一个拒绝样例写成“所有资源逃逸都不可能发生”。

设计接口时保留失败信息

实验性特性进入研究项目时,可以将它放进独立模块,使普通主线代码不被迫开启相同开关。输出边界尽量使用稳定的数据值,减少实验类型传播到其他模块。这样升级编译器时,能够识别失败范围,而不是让全部业务工程同时承担迁移成本。

拒绝测试应像正常测试一样保留版本。某次升级后反例突然编译成功,可能是规则改变、推断变强,也可能是回归;必须检查预期性质是否仍然存在。不能自动把“构建更绿”视为改进,因为这里有些文件本来就应该编译失败。

相反,正例突然失败也可能是新规则要求更精确的声明。应该先阅读具体捕获诊断,再决定修改接口或保留旧实验。通过删掉正例、扩大类型或关闭检查来消除失败,会失去原来要验证的保证。

一个有价值的研究工程应记录正例、反例、编译器版本与匹配诊断四项。理论模型提供预期,编译器输出提供观察,两者有差异时明确保留差异,不把运行结果修饰成已经符合预期。

结果与练习

场景 编译与运行结果 结论范围
回调立即返回 Int 编译通过,输出 42 此结果未保留局部能力
回调返回捕获 f 的函数 编译拒绝 此逃逸路径不被允许
真实文件获得与关闭 本例未执行 File 是逻辑能力模型
任意第三方库与并发逃逸 本例未覆盖 需要独立模型与用例

手算题:把反例中的函数立即调用,改成 withFile(f => (() => f.write())()),返回值是否仍必须保存 f?调用发生在回调内,返回结果是 Int;应作为新的正例编译核验,而不是只凭外表仍出现 lambda 就判断逃逸。

修改练习:增加一个持有回调的包装类,将返回闭包替换为包装实例,观察冻结编译器怎样显示捕获关系。保留直接返回 Int 的基线;若新增反例失败,确认诊断来自捕获规则,而不是构造器声明或普通类型错误。

可迁移规则 对应边界
返回值可能保存能力 词法作用域结束不等于引用消失
静态限制与动态释放互补 捕获检查不执行 close
实验语义必须冻结 编译器版本、开关、源码与诊断
通过和拒绝都要回归 编译成功不总是期望结果

完整运行命令见实验说明。

参考资料

顺序导航:系列入口:00 · 上一篇:E07。