形式陈述
未知类型与一致性
渐进类型允许同一程序中的不同部分提供不同程度的静态类型信息,并用运行时检查连接信息不足的边界。考虑类型语法
T ::= Int ∣ Bool ∣ ? ∣ T → T . ? 是未知类型,表示此处暂不提供具体的静态类型信息。它使检查可以推迟,却不承诺某个运行值实际具有任意期望类型。[1]
一致性 S ∼ T 表示两者已经给出的类型信息不冲突。定义规则为
Int ∼ Int , Bool ∼ Bool , ? ∼ T , T ∼ ? , S 1 ∼ T 1 S 2 ∼ T 2 S 1 → S 2 ∼ T 1 → T 2 . 这是一组生成规则,不再加传递闭包。一致性自反、对称,但不传递:
且 Int ∼ ? 且 ? ∼ Bool , Int ≁ Bool . 最后一项可检查规则的结论得出:基础规则只连接同名基础类型,未知规则至少一侧是 ? ,箭头规则两侧都是函数类型,没有规则能推出 Int ∼ Bool 。若错误地补上传递规则,这两个明确不兼容的类型就会被混在一起。
精度与子类型问的是不同问题
约定 S ⊑ T 表示 S 比 T 更精确 ,即把 S 的一些信息忘掉可以得到 T 。规则为
Int ⊑ Int , Bool ⊑ Bool , T ⊑ ? , S 1 ⊑ T 1 S 2 ⊑ T 2 S 1 → S 2 ⊑ T 1 → T 2 . 于是 Int ⊑ ? ,而 ? ⊑ Int 不成立;函数类型满足
Int → Int ⊑ ? → ? ⊑ ? . 精度沿函数的两个分量使用相同方向,因为无论参数还是结果,操作都是忘掉静态信息。子类型 公理库 子类型 Subtyping 允许某类型值在期望其上界类型的位置使用的可替代关系。 的函数参数方向则相反:它根据可替代性使用参数逆变、结果协变。
关系
判断的问题
关键结构
一致性 S ∼ T
已知信息是否冲突?
对称;通常不传递
精度 S ⊑ T
忘掉 S 的信息能否得到 T ?
有方向;函数两分量同向
子类型 S <: T
S 的值能否安全用于要求 T 的位置?
可替代性;函数参数逆变
未知类型也不同于通常的顶类型 Top。顶类型允许其他类型的值向上使用,但不能自动把 Top 值当作整数;渐进类型允许从 ? 走向 Int,条件是插入可能失败的检查。? 也不是整数与布尔的联合类型,它表达的是信息缺失,而不是列举已知备选类型。
直觉
静态信息不够时,把义务放在边界上
对于函数 f : A → B 与实参 u : C ,普通静态应用要求实参符合参数类型。渐进类型可以在 C ∼ A 时接受应用,并插入从 C 到 A 的转换。若已知实参是 Bool、参数是 Int,一致性仍会立即拒绝;若一侧是 ? ,则由运行时值决定检查能否通过。
转换称为 cast 。它不是把布尔值计算成整数,而是检查或记录值的类型。把整数送入未知类型区域时,附上 Int 标签;把未知值送回整数区域时,检查标签是否为 Int。这样,动态区域仍能保存值的真实类别,静态区域也不会被迫接受一个伪装成整数的布尔值。
先把源程序翻译清楚
为了完整推演两个例子,取源项
s ::= x ∣ n ∣ true ∣ false ∣ λ x : T . s ∣ s s ∣ inc s . n 是整数常量,inc 是整数加一原语。沿用类型判断 公理库 类型判断 Typing judgment 由类型规则推导的上下文判断,记录项在给定变量假设下具有的静态类型。 ,把带翻译结果的判断写成 Γ ⊢ s : T ⇝ e ,表示源项有类型 T ,并翻译成显式含 cast 的目标项 e 。
对任何 S ∼ T ,定义转换构造器
K S , T p ( e ) = { e , S = T , ⟨ S ⇒ T ⟩ p e , S ≠ T . p 记录源代码边界及责任方向。同类型转换在翻译时省略,其他转换全部交给下文的目标核心。定义函数匹配 fun ( A → B ) = A → B 、fun ( ? ) = ? → ? ;它在基础类型上未定义,因而不会静态接受已知整数的应用。
变量从环境取类型并原样翻译,整数与布尔常量也原样翻译为各自基础类型。抽象、应用和 inc 的规则是
Γ , x : A ⊢ s : B ⇝ e Γ ⊢ λ x : A . s : A → B ⇝ λ x : A . e , Γ ⊢ s 1 : D ⇝ e 1 fun ( D ) = A → B Γ ⊢ s 2 : C ⇝ e 2 C ∼ A Γ ⊢ s 1 s 2 : B ⇝ K D , A → B a ( e 1 ) K C , A a ( e 2 ) , Γ ⊢ s : C ⇝ e C ∼ Int Γ ⊢ inc s : Int ⇝ inc ( K C , Int b ( e ) ) . 应用边界的位置记为 a ,inc 参数边界的位置记为 b 。未知类型出现在函数位置时,翻译先插入到 ? → ? 的检查;已知箭头的同类型转换由 K 省略。类型推导归纳可知所有插入转换均满足一致性前提。
在环境 x : ? 下,? ∼ Int 允许检查 inc x,翻译时插入投影。因此
λ x : ? . inc x ⇝ λ x : ? . inc ( ⟨ ? ⇒ Int ⟩ b x ) , 且该函数具有 ? → Int 类型。把整数 7 或布尔 true 传给它时,应用规则分别插入 Int 或 Bool 到 ? 的注入。静态接受不是跳过检查,而是生成明确的检查位置。
例子与边界
函数 cast 核心的值与全部求值规则
使用小步语义 公理库 小步操作语义 Small-step operational semantics · Structural operational semantics 用配置间的一步转移及有限或无限路径精确描述执行顺序、终止、卡住与发散。 ,按值、从左到右求值。记 F = ? → ? ,ground 标签 只有
G ::= Int ∣ Bool ∣ F . F 表示“可调用的函数”,并不编码它所有未来输入输出的类型。边界标签带极性 p , p ¯ ,满足 p ¯ ― = p ;它们标识源边界的两侧,和三个运行时 ground 标签不同。沿用 a , b 作为正标签。目标项与值为
e ::= x ∣ n ∣ true ∣ false ∣ λ x : T . e ∣ e e ∣ inc e ∣ ⟨ S ⇒ T ⟩ p e ∣ blame G ( p ) , v ::= n ∣ true ∣ false ∣ λ x : T . e ∣ ⟨ G ⇒ ? ⟩ p v ∣ ⟨ S 1 → S 2 ⇒ T 1 → T 2 ⟩ p v . 后两类分别是 ground 注入和函数包装;内部项先成为值,整体才是值。包装先保存契约,等调用时检查。目标应用要求实参类型与参数类型精确相等,inc 要求 Int,其余常量、变量与 λ 使用通常规则。cast 与受控失败的规则是
( 任 意 ) Γ ⊢ e : S S ∼ T Γ ⊢ ⟨ S ⇒ T ⟩ p e : T , Γ ⊢ blame G ( p ) : T (任意 T ) . G 只是失败时所期望的标签诊断,不是 blame 的唯一静态类型;否则将失败传播出上下文时会破坏保持。类型规则保证每个注入的内容确实有其 ground 类型。
普通计算与基础身份转换为
( λ x : T . e ) v ⟶ e [ x := v ] , inc n ⟶ n + 1 , ⟨ B ⇒ B ⟩ p v ⟶ v ( B ∈ { Int , Bool } ) , ⟨ ? ⇒ ? ⟩ p v ⟶ v . 替换避免捕获。箭头到相同箭头的显式 cast 仍是包装值;源翻译的 K T , T 可以直接省略它,因而不会出现“既是值又必须执行身份归约”的冲突。对 ground 投影,全部三种标签统一使用
⟨ ? ⇒ G ⟩ p ( ⟨ G ⇒ ? ⟩ q v ) ⟶ v , ⟨ ? ⇒ H ⟩ p ( ⟨ G ⇒ ? ⟩ q v ) ⟶ blame H ( p ) ( G ≠ H ) . 非 ground 箭头 A = S 1 → S 2 ≠ F 与未知类型之间必须经过 F :
⟨ A ⇒ ? ⟩ p v ⟶ ⟨ F ⇒ ? ⟩ p ( ⟨ A ⇒ F ⟩ p v ) , ⟨ ? ⇒ A ⟩ p v ⟶ ⟨ F ⇒ A ⟩ p ( ⟨ ? ⇒ F ⟩ p v ) . A ∼ F 总成立,所以这些中间项良型。最后,函数包装的应用展开为
( ⟨ S 1 → S 2 ⇒ T 1 → T 2 ⟩ p f ) v ⟶ ⟨ S 2 ⇒ T 2 ⟩ p ( f ( ⟨ T 1 ⇒ S 1 ⟩ p ¯ v ) ) . 参数沿 T 1 ⇒ S 1 反向 进入被包装函数,并翻转责任标签;结果沿 S 2 ⇒ T 2 出来,保留标签。运行时没有遍历所有函数输入来判断整个箭头契约,而是每次调用履行这两个义务。[3, §3.2]
求值上下文和失败传播为
E ::= [ ] ∣ E e ∣ v E ∣ inc E ∣ ⟨ S ⇒ T ⟩ p E , e → e ′ ⇒ E [ e ] → E [ e ′ ] , E [ blame G ( p ) ] → blame G ( p ) ( E ≠ [ ] ) . 传播保持原标签及其极性。允许从嵌套上下文直接传播或逐层传播时,步数可以不同,最终错误一致;下面的安全性不依赖选择哪一种传播步。
从两个源程序走到值与 blame
成功程序是 ( λ x : ? . inc x ) 7 。翻译后,入口注入和函数体投影都显式出现:
( λ x : ? . inc ( ⟨ ? ⇒ Int ⟩ b x ) ) ( ⟨ Int ⇒ ? ⟩ a 7 ) ⟶ inc ( ⟨ ? ⇒ Int ⟩ b ( ⟨ Int ⇒ ? ⟩ a 7 ) ) ⟶ inc 7 ⟶ 8. 第一步是 β 归约:带标签的实参已经是值。第二步比较标签后解包,第三步执行整数原语。
把源实参换成 true,静态阶段仍接受,因为 Bool ∼ ? 。目标程序则保留布尔标签:
( λ x : ? . inc ( ⟨ ? ⇒ Int ⟩ b x ) ) ( ⟨ Bool ⇒ ? ⟩ a true ) ⟶ inc ( ⟨ ? ⇒ Int ⟩ b ( ⟨ Bool ⇒ ? ⟩ a true ) ) ⟶ inc ( blame Int ( b ) ) ⟶ blame Int ( b ) . 错误属于要求整数的投影位置 b 。它发生在布尔值进入整数原语之前,因而不会形成没有归约规则的 inc true。
若将源函数注解改成 λ x : Int . inc x ,调用 7 仍能通过,调用 true 则在源程序的应用规则处被拒绝:现在需要 Bool ∼ Int ,而该关系不成立。增加注解把这次错误从运行时边界提前到了静态检查。
函数边界:参数与结果在不同一侧负责
令 f = λ x : Int . inc x ,把它交给允许未知参数的调用者:
w = ⟨ Int → Int ⇒ ? → Int ⟩ p f . w 已是值,但没有检验任一未来实参。写 d B ( v ) = ⟨ B ⇒ ? ⟩ a v 。调用整数成功:
w d Int ( 7 ) → ⟨ Int ⇒ Int ⟩ p ( f ( ⟨ ? ⇒ Int ⟩ p ¯ d Int ( 7 ) ) ) → ∗ 8. 调用布尔则在进入 f 之前得到 blame Int ( p ¯ ) 。这里是边界外的调用者给了不合要求的参数,故标签反转。
反过来,令 g = λ x : ? . d Bool ( true ) ,并要求它返回整数:
( ⟨ ? → ? ⇒ Int → Int ⟩ p g ) 7 → ⟨ ? ⇒ Int ⟩ p ( g ( ⟨ Int ⇒ ? ⟩ p ¯ 7 ) ) → ∗ ⟨ ? ⇒ Int ⟩ p d Bool ( true ) → blame Int ( p ) . 输入注入成功,失败在提供者返回的布尔值,因此保留正标签。这两条推导说明当前边界的责任方向;它们没有证明含任意多个共享标签的程序都满足某个全局 blame 定理。
图片加载失败 把 f 存入未知类型也不能只写一个任意箭头标签:
⟨ Int → Int ⇒ ? ⟩ p f → ⟨ F ⇒ ? ⟩ p ( ⟨ Int → Int ⇒ F ⟩ p f ) . 外部只有统一的函数标签 F ,内部包装保留 Int 输入输出义务。投影回箭头时先检查 F ,随后创建所需的箭头包装;若动态值装的是整数,则在 ? 到 F 的投影处立即 blame,根本不会执行整数应用。
推论与应用
完整函数 cast 核心的安全性
沿用进展与保持 公理库 进展与保持定理 Progress and preservation · Type safety 对按值调用的简单类型 lambda 演算逐层解释规范形、替换、进展与保持,以及类型安全的准确结论。 的结构,先证明翻译保类型:若 Γ ⊢ s : T ⇝ e ,则目标判断 Γ ⊢ e : T 。对翻译推导归纳,变量、常量和 λ 直接成立;应用的两个 K 分别把函数变为 A → B 、实参变为 A ,于是目标应用得到 B ;inc 的参数转换到 Int。恒等 K 保留类型,其余由一致性和 cast 规则成立。
规范形。 对良型闭值反演其最后规则可得:Int 值是整数,Bool 值是布尔常量,? 值是且只能是某个 ⟨ G ⇒ ? ⟩ p v ,箭头值是 λ 或函数包装。包装内部仍为箭头值,所以它可以形成有限栈;不能遗漏这一类而只为 λ 写应用进展。
保持。 替换引理按类型推导归纳,cast 分支重用内部归纳假设及同一一致性前提,blame 分支可在任何环境赋予所需类型。于是 β 归约保持结果类型。整数加一与身份规则直接保持;成功 ground 投影返回其类型前提已保证的 G 值,失败则用 blame 的任意类型规则。两种经 F 分解的规则中,内外转换的中间类型恰好相接。
函数应用是关键分支。原包装有目标类型 T 1 → T 2 ,实参 v : T 1 ;由于一致性对称且箭头逐分量一致,⟨ T 1 ⇒ S 1 ⟩ p ¯ v : S 1 。内部 f : S 1 → S 2 接受该实参并产生 S 2 ,外部 ⟨ S 2 ⇒ T 2 ⟩ p 最后得到 T 2 。上下文内归约由反演重建外层推导,失败传播则把同一 blame 赋予整个上下文的结果类型。因此 Γ ⊢ e : T 且 e → e ′ 蕴含 Γ ⊢ e ′ : T 。
进展。 对良型闭项的推导归纳,先沿求值上下文求出子项或传播 blame。应用两侧均为值时,规范形保证函数是 λ 或包装,分别由 β 或包装规则接住;inc 的值参数必为整数。对 cast 的值参数,枚举一致性允许的外形:相同基础类型与 ? 使用身份规则;箭头到箭头是包装值;ground 到 ? 是注入值;非 ground 箭头与 ? 之间使用分解规则;? 到 ground 时,规范形提供唯一标签,以相等或不等两类穷尽检查。除此以外的基础类型冲突或基础/箭头直接转换没有类型推导。故结果是值、blame,或者存在下一步。
把两条定理沿运行组合,得到:源程序的目标执行若有限结束,就以正确类型的值或规定的 blame 结束,绝不会到达 inc true 或 7 8 这样的未定义状态。还允许无限运行;例如在完整源语言中
( λ x : ? . x x ) ( λ x : ? . x x ) 可以类型检查,未知函数规则使自应用继续展开。没有显式 fix 不意味着这个核心终止。
一个可完整证明渐进保证的片段
现在固定较小的源语言,独立证明擦除绑定注解 的保证。基本类型记为 D ::= Int ∣ Bool ∣ ? ,语法为
s ::= x ∣ n ∣ true ∣ false ∣ inc s ∣ ( λ x : D . s ) s . λ 只出现在立即调用位置,不能作为值存储、传参或返回;环境只含 D 类型。仍可嵌套任意多次调用、重复使用基本变量,并让一个调用的输出进入下一调用。使用前述翻译规则,其源项结果总是基本类型或 ? ,所有插入的非身份转换都是基础注入/投影。此处没有函数包装,下面的渐进保证仅对这个片段证明 ;完整高阶语言的比较定理需要额外的项精度与模拟关系。[2, §4.3]
记 s ⊑ s ′ 表示只把一些绑定注解 B 换成 ? ,其余语法、常量与边界位置保持不变;环境 Γ ⊑ Γ ′ 逐变量取相同方向。对整数或布尔原始常量 k ,记 tag ( k ) 为它的基础类型,并定义与某个 D 相容的运行表示
repr B ( k ) = k ( tag ( k ) = B ) , repr ? ( k ) = ⟨ tag ( k ) ⇒ ? ⟩ a k . 比较表示时忽略注入标签 a ,不忽略常量或其 ground 标签。s ⇓ D k 表示翻译执行成功,并返回 k 的 D 表示;s ⇓ blame 只记录受控失败,不要求两个程序在同一标签失败。
定理。 若闭项 s ⊑ s ′ 且 s : D ,则存在 D ′ 满足 s ′ : D ′ 与 D ⊑ D ′ ,并且
或 s ⇓ D k ⟹ s ′ ⇓ D ′ k , s ′ ⇓ D ′ k ⟹ s ⇓ D k 或 s ⇓ blame , s ′ ⇓ blame ⟹ s ⇓ blame . 这个片段的每个良型闭项都结束于值或 blame,故无需另列发散条款。证明分为以下相互依赖的步骤。
静态单调性与 cast 成功的单调性
若 C ∼ D 、C ⊑ C ′ 、D ⊑ D ′ ,则 C ′ ∼ D ′ :若两边都未擦除,复用旧判断;只要某一边成为 ? ,一致性直接成立。对源语法归纳,变量的结果类型随环境削弱,常量不变,inc 仍检查一个与 Int 一致的类型并返回 Int。立即调用中,实参和参数注解同时变得不更精确,刚才的事实保留边界一致性;函数体在削弱后的扩展环境中应用归纳假设,给出不更精确的结果类型。这证明定理的静态部分,不能把它误写成一致性本身有传递性。
基础边界处理一个已用 C 表示的 k 时,成功条件恰好是
成 功 或 K C , D ( repr C ( k ) ) 成功 ⟺ D = ? 或 tag ( k ) = D . 前提是 C ∼ D 且输入表示存在;C = D 、向 ? 注入和从 ? 投影三种情况分别给出这条等价式。成功结果就是 repr D ( k ) ,标签选择不影响原始常量。于是当输入和目标均削弱为 C ′ , D ′ ,且两次输入表示同一 k ,原边界成功就蕴含新边界成功:若 D ′ = ? 自动成功;若 D ′ 是基础类型,则精度迫使 D = D ′ ,旧成功已经保证对应标签。这个引理比较任意允许的两条基础边界,并不限于注入后立刻投影的抵消。
环境求值与成功模拟
用环境 ρ 保存基本值表示。按原语法定义求值:变量查表,常量返回自己;inc 先求子项,再执行其类型到 Int 的 K ,成功后加一;( λ x : D . t ) u 先求 u ,执行实参类型到 D 的 K ,然后在 ρ [ x ↦ v ] 中求 t 。任何子计算或边界失败就传播 blame。
这是对原始语法的结构递归 :u , t 都是真子项,环境只保存基本值,不保存可再调用的函数体。每个基础 K 有限步结束,故该求值器总终止。对语法归纳,并在立即调用处使用避免捕获的值代换引理,可知环境求值与上述目标CBV求值返回相同常量或失败;目标的一次替换不会把环境中的值变成新的源调用。因此片段内的目标执行也终止。
同时归纳建立成功模拟:假设 ρ , ρ ′ 给同名自由变量提供同一原始常量的相应类型表示。变量、常量直接对应;inc 的成功前提使旧投影得到某整数 k ,子项归纳假设和 cast 单调性使新投影得到同一 k ,两边都返回 k + 1 。立即调用先由实参归纳假设得到同一常量,再用 cast 单调性使新参数边界成功;扩展环境仍逐变量表示相同常量,于是函数体归纳假设给出相同结果。这得到定理第一条。
总终止性与安全性说明精确程序只有成功或 blame 两种结局。若较弱程序成功而精确程序也成功,第一条与求值结果唯一性迫使常量相同,得到第二条;若较弱程序失败但精确程序成功,第一条会推出较弱程序成功,矛盾,得到第三条。结论允许增加信息引入 blame,也不声称失败位置保持不变。
擦除注解可以移除一个实际失败的边界
取两个语法对应的良型程序
s = ( λ x : Int .7 ) ( ( λ y : ? . y ) true ) , s ′ = ( λ x : ? .7 ) ( ( λ y : ? . y ) true ) . 二者内层都把布尔值注入 ? 后返回;外层静态实参类型为 ? ,所以两者都可类型检查。s 的外层必须投影到 Int,运行得到 blame,即使函数体没有用到 x 也不能跳过CBV实参检查。s ′ 擦除了这个注解,外层身份边界直接接受原未知值,返回整数 7 。
这是定理允许的变化:更多信息导致的失败可在擦除后消失,精确程序本来成功的计算则不能因擦除而新增失败。前面 λ x : Int . inc x 调用整数的成功例,在擦除后仍返回相同整数;调用布尔而直接被静态拒绝的版本,不满足定理要求的“精确程序已经良型”前提。
渐进类型由此把三种问题分开:一致性决定边界是否可延后检查,运行时转换保护每次实际调用,精度与模拟则说明调整静态信息会怎样改变程序行为。安全性只排除未定义卡住,不能单独推出渐进保证。
参考资料
[1] Jeremy G. Siek and Walid Taha, “Gradual Typing for Functional Languages,” Scheme and Functional Programming Workshop , 2006, pp. 81–92,原始论文 ,§2 定义一致性和渐进函数应用,§3 讨论与子类型方案的差别。
[2] Jeremy G. Siek, Michael M. Vitousek, Matteo Cimini and John Tang Boyland, “Refined Criteria for Gradual Typing,” SNAPL 2015 , pp. 274–293,作者全文 ,§3 给出翻译与 cast 动态语义,§4.2 证明安全性,§4.3 定义精度并讨论渐进保证。其Theorem 5还比较高阶与发散运行;本页另在明确的一阶片段给出完整证明,不把该片段结论外推到所有高阶程序。
[3] Philip Wadler、Robert Bruce Findler,Well-Typed Programs Can’t Be Blamed ,2009年技术报告,对应ESOP 2009;§2.1定义极性,§3.2 Figure 4给出函数参数方向和标签反转,§3.5给出类型安全。本文只用基本/未知/箭头部分,并显式给出自己的核心规则;没有纳入报告中的精化谓词类型。