30 秒看懂
在这期 OCaml Unboxed 演示中,讲者展示编译器如何推断值、参数和返回结果的局部性。例子说明了模块签名如何处理不逃逸的承诺、立即数整数为什么可以跨越模式,以及函数转换有哪些限制。
- 1查看带类型的语法树调试标志 -dtypedtree 会显示分配模式,包括尚未确定的模式变量。
- 2优先采用宽松的接口local 参数接受 local 和 global 值;global 返回值给调用者更大的自由。
- 3整数可以跨越模式立即数 int 没有需要保护的指针生命周期,因此可以用于 global 上下文。
- 4安全转换与 eta 展开演示中一种安全的函数转换可能被拒绝,但可以通过 eta 展开来表达。
要点建议
- 编译器引入模式变量,在没有约束要求全局分配时优先选择局部分配。
- local 参数对调用者更宽松,而 global 返回值通常更有用。
- 模块签名可以舍弃 local 参数的承诺,也可以对返回值施加 local 限制。
- 立即数整数可以跨越模式,但有时需要显式类型注解。
- 某些安全的函数类型转换可能需要 eta 展开,而不安全的承诺不能事后添加。
按章节阅读整理后的全文
推断分配模式
编译器引入模式变量并根据约束判断值是 local 还是 global。带类型的语法树输出会显示推断出的分配模式。
此前我们讨论了 local 参数、变量和返回值,以及 local 和 global 是模式这一概念。现在来看推断:OCaml 编译器如何确定值和函数的局部性。
假设我在函数内部绑定一个未使用的 x。普通的编译器输出告诉我 f 的类型是 unit 到 unit,却没有说明 x 是 local 还是 global。虽然在这个小例子中没有区别,但随着程序变大,这一区别可能变得重要。
为了演示,我可以传入 -dtypedtree,打印编译器带类型的中间表示。这种输出供编译器调试使用,而非日常编程,格式也不太友好。在 x 旁边,分配模式显示为 <modevar>:编译器尚不知道答案,因此引入了一个模式变量。

如果我返回 x,它就会逃出函数的区域,打印出的分配模式变为 global。反过来,如果我将 x 标注为 local 并阻止它逃逸,输出就会显示 local。没有这两种约束时,编译器会在类型检查完成后作出选择,并优先采用局部分配。
局部性提供了安全属性,但 Jane Street 开展这项工作的动机也涉及分配:OCaml 程序的某些部分可以采用栈式分配,而不必在需要垃圾回收的堆上分配。从这个意义上说,局部分配可能更好。后续视频中我会进一步讨论它的分配行为。
参数与模块签名
local 参数既接受 local 实参,也接受 global 实参。模块匹配可以舍弃实现所作的不逃逸承诺。
更一般地说,推断会尽可能保持宽松。对于函数参数,local 更宽松:global 是 local 的子模式,因此接受 local 实参的函数也能接受 global 实参。忽略参数的函数是个简单例子,尽管实用函数还可以做更多事情。
普通输出中打印的这个函数类型并不显示 local 参数。为了弄清发生了什么,我将它放入一个模块,并给模块添加签名。普通参数类型和 local_ 参数类型都能匹配同一个实现。
模块签名不会用于检查结构本身的类型。编译器先推断结构,再检查结果是否匹配签名。在这个例子中,它为 f 推断出 local 参数,因此该实现可以满足要求 local 参数的签名。
没有签名时,例如模块没有 mli 文件,一个默认化步骤会从显示的模块类型中移除 local_ 标记。这避免了没有接口文件的项目出现令人意外的结果,尽管实现还有改进空间。推断出的实现仍然可以支持 local 实参。
函数并不一定只能忽略参数。它可以把 x 传给另一个承诺不让它逃逸的函数,或者以其他方式操作它。构造例子比较困难,因为标准库的大部分内容尚不支持 local,调用这些函数可能迫使值成为 global。
为什么推断为 local 的参数也能匹配没有 local_ 的签名?local 参数是实现作出的一项承诺:不让实参逃逸。模块签名匹配允许我们舍弃这一承诺,推断出的模块类型与要求较少的签名兼容。
返回值与逃逸
global 返回值给调用者更大的自由。参数和返回值上的局部性分别对调用的不同一方施加义务。
对于返回值,偏好则相反。global 结果更有用,因为调用者可能需要在 global 上下文中使用它。如果函数返回 local 字符串,我不能直接将结果传给要求 global 字符串的函数;local 值不会自动变成 global。
Jane Street 内部有一个 globalize PPX,可以在需要 global 值时转换 local 值,但这个例子中没有它。返回值上的 local 注解对调用者施加限制:调用者不能让结果逃逸。在签名中加入这一限制是安全的。
对于参数,local 则对函数实现提出要求。如果 h 存储实参或通过其他方式让实参逃逸,推断出的参数就是 global,要求 local 参数的签名就会失败。我们可以从签名的参数中移除 local,或者给返回值添加 local,但一般不能反过来做。
立即数整数与模式跨越
整数是立即数,因此可以跨局部性模式使用。显式类型注解可能影响推断结果。
整数是一个重要例外。考虑一个函数,它将 x 与自身相加,然后将 x 存入引用。local 的 x 通常会因为逃逸而被拒绝,但如果将 x 显式标注为 int,同样的代码就会被接受。

OCaml 的 int 是立即数:它不通过指针表示。local 的设计围绕分配行为展开,因此立即数不需要遵循相同的指针生命周期规则。我们说 int 可以跨越模式:即使是 local int,也可以用在要求 global 值的地方;换句话说,int 始终可以视为 global。
这里的类型注解很重要。如果编译器只是从加法推断出 x 是 int,这个模式跨越的例子不会通过。让信息以这种方式跨表达式流动可能使类型检查变得脆弱,因此程序员必须在某处声明 x 是 int。
这也是我在前面的例子中使用字符串的原因:字符串不能跨越模式。这也解释了为什么 local int 能传给加法,即使标准库通常不了解 local。int 在调用时跨越了模式。
这带来一个令人不满意的限制:如果我希望 int 具有 local 的额外安全属性,模式跨越会阻止我获得它。我们正在开发更细粒度的控制,以决定哪些值可以跨越模式。指针表示的值这样跨越是不安全的,但即使是立即数,有时也可能需要限制。
函数类型之间的转换
有些转换可以安全地增加或舍弃承诺,另一些则会施加无法保证的要求。讲者展示了用 eta 展开绕过编译器拒绝的安全转换。
推断还允许不同局部性函数之间的一些转换。如果 f 返回 global 字符串,我可以在要求返回 local 字符串的函数的位置使用它:后者只是对调用者如何处理结果增加了一项限制。
反方向的承诺则不同。我不能把接受 global 参数的函数拿来,事后声称它接受 local 参数:这会对实现添加一项它并未承诺满足的要求。

有意思的是,演示中编译器也拒绝将一个带 local 参数的函数赋给普通函数类型,尽管舍弃这个承诺是安全的。这个限制到你观看时可能已经修复。通过 eta 展开,也就是用 fun x -> h x 代替 h,可以绕过这次拒绝。
eta 展开无法让不安全的转换成立。如果 f 从未承诺接受 local 实参,用另一个函数包装它也无法创造这一承诺。不同局部性的函数类型之间,有些转换是安全且受支持的,但并非所有转换都安全。
这带来的好处是,你通常不需要在每个项或变量上写 local 注解。和普通类型推断一样,局部性推断通常能算出所需模式,不过也有我们今天探讨的注意事项。
同频道的其他文章
1:48:29EN → 简体中文
1:36:37EN → 简体中文
1:05:45EN → 简体中文
1:25:00EN → 简体中文
1:16:29EN → 简体中文
27:39EN → 简体中文