“边界首先来自效果。值限制用同一个引用先写整数列表、再误读成布尔列表的执行,说明为什么一次分配结果不能任意泛化;λ工厂则通过每次新建单元避免错误共享。”
形式陈述
值限制控制带副作用语言中let绑定的泛化。纯Hindley–Milner可以把不在环境中自由出现的类型变量量化;加入可变引用后,这条规则不能不加条件地套在每个右侧表达式上。
固定按值调用的ML核心,含整数、布尔、不可变列表及引用。ref e分配新单元,!r读取,r:=v写入。本页的基础版本只对句法上非扩张的右侧泛化:常量、变量、λ抽象,以及仅由非扩张成分组成的不可变构造值。函数应用、引用分配和赋值不在其中。具体语言对构造器、惰性项等还有细则,不能只按“求值最终返回一个值”判断。
若已推得
第二行保留未量化的共享类型变量。它们以后可被具体使用约束成Int等类型,但不能每次出现时重新实例化。实现常把这类尚未确定的单态变量显示为weak type variables。
直觉
一个多态恒等函数的不同使用可以选择不同类型,因为函数不保存一份会被后来的使用改写的“当前实参”。一个引用则是真实共享的存储单元:第一次把整数写进去,第二次仍读同一块内容。若两次使用可以各自任意选择该单元的元素类型,就会给一块内存同时签发互相冲突的读写许可。
关键不是“值里绝对不能含引用”。函数值可以捕获引用,但捕获环境中已有的类型变量不会被泛化;函数体内部也可以分配引用,只要每次调用的分配与类型实例相匹配。值限制与“只量化环境外的变量”要一起使用。
例子与边界
一次不安全泛化怎样真的卡住
暂时错误地允许
let r = ref [] in
r := [8];
if head(!r) then 0 else 1
在分配处把r泛化成
但运行只有一个地址ℓ:
| 步骤 | 运行时存储 | 错误静态视图 |
|---|---|---|
ref [] |
r被授予任意列表引用类型 | |
r := [8] |
当作Ref(List Int)写入 | |
!r |
读出同一个[8] | 当作List Bool读取 |
head(!r) |
得到整数8 | 被错误地认为是Bool |
if 8 then ... |
不是值,也没有布尔分支规则可用 | 程序卡住 |
此例没有空列表head错误,因为真正读出的列表非空。失败恰在整数被冒充布尔值,破坏进展与保持希望保证的运行时安全。
正确规则在 ref [] 处保留一个共享α,令
把共享单元改成工厂
下面的接口安全:
let fresh = fun () -> ref [] in
let ri = fresh() in
let rb = fresh() in
ri := [8];
rb := [true]
fresh的右侧是λ值,可取得类型方案
两次调用各自产生新地址
反过来,若外层已有 let read = fun () -> !r,α仍自由出现在Γ中。即使右侧是λ,read也不能取得
安全程序也可能被保守限制
let id2 = (fun f -> f) (fun x -> x)没有副作用,结果确实是恒等函数。基础值限制仍把右侧看作应用,不泛化其
可以把它改成 fun x -> ((fun f -> f) (fun y -> y)) x,使外层成为λ值。不过一般程序的η展开可能把副作用从定义时移到调用时,甚至改变执行次数;不能把它当作无条件保持语义的修复按钮。
推论与应用
放宽的值限制允许对扩张表达式结果类型中只出现在协变位置的候选变量泛化。直观上,不可变List只把元素交给使用者,不允许使用者写入一个不同类型的元素,所以 (fun () -> [])()的结果可获得
协变性与子类型中的方向相同:若A可替代B,List A可替代List B。函数域反向、函数余域同向,因此
对抽象类型构造器,客户端还须知道接口声明的variance。实现私下使用不可变列表,而接口没有暴露协变性,并不让客户端自动获得这个证明。
值限制解决的是“何时安全地产生类型方案”。它不处理多态函数参数的量词位置,也不允许递归体自动在不同类型上调用自己;这些分别进入高秩多态与多态递归。
参考资料
- Andrew M. Pitts, Lecture Notes on Types, 2015,§3:多态引用与安全泛化
- OCaml manual, “Polymorphism and its limitations”, §§5.1.1–5.1.5:weak variables、值限制、放宽规则与variance
- Andrew K. Wright, “Simple Imperative Polymorphism,” Lisp and Symbolic Computation 8, 1995, 343–355