原文来自微信公众号“编程语言Lab”:类型检查:时常被忽略的编译器组件
搜索关注“编程语言Lab”公众号(HW-PLLab)获取更多技术内容!
欢迎加入 编程语言社区 SIG-类型系统 参与交流讨论(加入方式:添加小助手微信 pl_lab_001,备注“加入SIG-类型系统”)。
分享嘉宾 | 朱子润
回顾整理 | Hana
视频回顾
编程语言技术沙龙|第 17 期: 类型检查:时常被忽略的编译器组件
大家好,我是朱子润,很高兴为大家带来一个比较简单的类型检查与推导的分享。
这个分享的目标群体是对程序语言理论和类型检查感兴趣的工程师,所以内容以直觉为主,理论为辅。本次分享主要内容分为四个部分:
- 从日常的编码场景来说一下类型检查和类型推导的作用
- 概念上如何实现一个简单的类型检查与推导器(我将用 TC 和 TI 作为类型检查与推导的简写)
- 学界是如何严格定义类型检查与推导的
- 探讨学界和工业界对其的侧重点
相关阅读
类型系统综述(一)
类型系统综述(二)
浅谈 Intersection Types
编程语言设计原理书籍推荐
# 类型检查 & 类型推导 #
# 什么是类型检查?
在介绍类型检查 (type check) 前,我们先看一个编译流程图,如下图示,输入的程序会依次进行词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成。类型检查其实就被涵盖在语义分析中(即 semantic analysis)。
如果我们看一个函数式语言的架构图,比如 Haskell,它可能会明确地标出在解析 parse 与 rename 之后,会有一个类型检查 type check 的阶段,在此之后还有些其他比较复杂的工作比如后端代码生成。
那么,类型检查是怎样的语义分析呢?
顾名思义,类型检查的主要作用就是 找到程序中的类型错误。我们来看两种简单而又典型的类型错误:不兼容的赋值 incompatible assignment 与 不兼容的函数调用 incompatible function calls。
- 不兼容的赋值:比如当我们在声明一个变量后,给其赋一个类型不兼容的值(一个声明为布尔类型的变量被赋一个整数的值)。
- 不兼容的函数调用:比如在函数调用时,形参与实参的类型不匹配。
如下是一个 C++ 中比较典型的例子。变量 x
的类型是 Char
,给 x
赋值了一个字符串 "abc"
,就会出现一个 warning,原因就是赋值时类型不兼容。
下图 Java 的例子中,我们 switch
了一个布尔值 true
,编译器会报错:switch
接受一个整数值,不能用布尔值,即类型不兼容。
还有一类情况,如下 Swift 的代码示例中,我们声明了两个类 C
和 D
,然后声明一个变量 x
并用一个三元表达式来初始化,三元表达式的两个分支分别创建了 C
和 D
的实例。这里编译器也会报错,说 C
和 D
类型不匹配,mismatching。这是因为编译器无法找到 C
和 D
的公共父类型,即编译器尝试将 C
和 D
的所有父类找出来,发现它们没有交集,所以说没有公共父类型。(不过这样一类问题其实是通过类型推导发现的。)
而如果 D
继承 C
,那它们的公共父类型就变成 C
了,这个错误也就消除了。
前三个例子的对比如下。
# 什么是类型推导?
在第一部分,我们简单介绍了类型检查,最后引出了一点儿类型推导 (type inference):类型推导找类型错误的方法是去看程序片段能否有一个类型,这个类型不是事先标注好的。
除此之外,类型推导更大的好处是可以让程序编码更加地高效。比如类型推导可以省掉大量无聊的类型注解 type annotations,让程序员可以聚焦在代码的业务逻辑上;比如类型推导还可以友好地掩盖掉像泛型 generics 或者多态 polymorphisms 这样的类型复杂性。
如下所示的函数调用中,尖括号里很长的部分是一个泛型参数,如果有类型推导的话,那我们可以省略掉这一长串内容,直接写成 f(m)
,程序员无需理解背后的理论,可以轻松地使用多态函数,掩盖一些复杂度。
f<HashMap<User, List<String>>>(m)
|
↓
f(m)
分别以 C++,Java,Swift 为例,对比一下在没有类型推导和有类型推导的情况下,我们写的代码。
C++:
Java:
Swift:
借住类型推导,三个例子的对比如下。
接下来,我们看一下如何简单地实现类型检查与推导 TC & TI,这部分也是本次分享的干货。
# 编写一个类型检查器 #
# 递归下降
类型检查器 type checker 一般是递归下降 (recursive and descent) 的,(个人认为只有骨骼清奇的人才才能写出自底向上的版本),我们定义一个函数 Check : (AST, Type) -> Bool
。这个函数接受两个参数,第一个参数是抽象语法树 AST,即要检查的语法树节点,第二个参数是目标类型。函数返回一个布尔值,用于告诉我们检查的节点是否有给定的目标类型。
用简单的例子来说明,假设我要检查这样一棵语法树(如下图所示),看它是不是 Int
类型。这棵树很简单,是一个加法,有左右两个分支,左边是 1
,右边是 false
。按照递归下降,我们先递归检查左子树,往下走一步,看 Lit 节点有没有 Int
类型,然后再递归往下走,看 1
有没有 Int
类型。
此时,类型检查返回的结果是 true
:即 1
有 Int
类型,那么这个返回结果就可以向上传递直至顶层。那么我们对于左子树的检查就完成了,它返回 true
。
接下来,同样按照递归下降的方式检查右子树,直到最后检查 false
是否是 Int
类型,(鉴于一般语言是不允许的,因此) 结果返回 false
。我们将这个返回结果向上传递至顶层。
此时左子树的返回结果 true
与右子树的返回结果 false
做一个逻辑与的操作,得到最终的检查结果为 false
,即对这棵语法树的类型检查会失败。
# 伪代码示例
接下来我们用伪代码来解释一下如何实现 (这部分代码会比较像 Haskell)。说明:a <: b
表示 a
是 b
的一个子类型 subtype。
首先,编写 Check
的类型。Check
有两个参数:语法树 ast
和目标类型 ty
。我们给 ast
做模式匹配。
Check : (AST, Type) -> Bool
Check (ast, ty) = case ast of pattern match
加法类型检查
我们以上一节的加法场景为例,将加法的左右子树分别绑定到新的变量 l
和 r
,分别递归检查左子树和右子树,检查的目标类型仍然是 ty
。最后将两边子树的检查结果用逻辑与 &&
连接起来,
Check (ast, ty) =
Add(l,r) ---> Check(l, ty) && Check (r, ty)
字面值类型检查
在刚才的例子中,我们遇到了字面量,那么我们可以检查字面量的类型是否是目标类型的子类型,
Check (ast, ty) = case ast of pattern match
Lit(n) ---> typeOf(n) <: ty
函数定义类型检查
接下来,我们介绍两类比较有意思的情况,即函数定义和函数调用的类型检查。先来看函数定义。如果我们的语法树是一个函数定义的节点,那么我们可以分别将函数名、函数参数、函数体分别绑定到 f
, param
, body
上,
Check (ast, ty) =
-- function definitions
FunAbs(f, param, body) --->
接下来我们来做检查。因为我们在做函数定义时,形参名 pName
后会跟形参类型 pTy
,即 fun(pName : pTy)
。因此,我们可以进一步用模式匹配将形参节点解构为声明处的名字和类型。
这里我们使用 let
来做模式匹配,将 param
解构成 Param(pName, pTy)
(即程序片段 fun(pName : pTy
对应的 AST),
Check (ast, ty) =
-- function definitions
FunAbs(f, param, body) --->
-- pattern match fun(pName : pTy)
let Param(pName, pTy) = param
第二步,我们将输入的目标类型也通过模式匹配来解构:这个目标类型一定要是一个函数类型才可以,否则报错 (这里的伪代码仅做示意,因此省略掉报错的环节了哈)。那么,函数类型可以解构为参数类型和返回类型。
因此,使用 let
来做模式匹配,将目标类型 ty
中解构出来的参数类型绑定到 parTy
,返回类型绑定到 retTy
,
Check (ast, ty) =
-- function definitions
FunAbs(f, param, body) --->
-- pattern match fun(pName : pTy)
let Param(pName, pTy) = param
let FunTy(parTy, retTy) = ty
基于前述步骤解构出来的数据,我们可以用目标类型解构出的返回类型 retTy
来与函数体的类型 body
做检查,递归地检查函数体是否是 retTy
的子类型。此外,我们还需要检查函数声明处的形参类型 pTy
,与目标类型解构出来的形参类型 parTy
是否形成了有效的子类型关系 (后面会展开解释什么是有效的子类型关系)。
Check (ast, ty) =
-- function definitions
FunAbs(f, param, body) --->
-- pattern match fun(pName : pTy)
let Param(pName, pTy) = param
let FunTy(parTy, retTy) = ty
parTy <: pTy && Check(body, retTy) -- allow subtyping
函数调用类型检查
接下来我们来看函数调用的检查。我们需要传入被调用的函数 f
和传给函数的参数 arg
。在此之前该函数已经被定义了,因此可以用 typeOf(f)
获得定义的函数类型,包括形参类型 parTy
和返回类型 retTy
。
Check (ast, ty) = case ast of pattern match
-- function invocations
FunApp(f, arg) --->
let FuncTy(parTy, retTy) = typeOf(f)
我们要检查的是函数调用时的实参 arg
是否满足定义处的形参类型 parTy
。以及函数定义处的返回类型 retTy
是否满足传入的目标返回类型 ty
。
Check (ast, ty) = case ast of pattern match
-- function invocations
FunApp(f, arg) --->
let FuncTy(parTy, retTy) = typeOf(f)
Check(arg, parTy) && retTy <: ty
以上就是几个简单常见的类型检查例子,伪代码示意如下。要进一步说明的是,“是否满足子类型关系”在函数相关的类型检查中是比较特殊的。在检查返回类型时,我们需要判断是否满足协变 co-variance,即 retTy <: ty
;而在检查入参类型时,需要判断是否满足逆变 contra-variance,即 parTy <: pTy
。
Check : (AST, Type) -> Bool
Check (ast, ty) = case ast of pattern match
Add(l,r) ---> Check(l, ty) && Check (r, ty)
Lit(n) ---> typeOf(n) <: ty
-- function definitions
FunAbs(f, param, body) --->
-- pattern match fun(pName : pTy)
let Param(pName, pTy) = param
let FunTy(parTy, retTy) = ty
parTy <: pTy && Check(body, retTy) -- allow subtyping
-- function invocations
FunApp(f, arg) --->
let FuncTy(parTy, retTy) = typeOf(f)
Check(arg, parTy) && retTy <: ty
类型检查的环境
为了简化伪代码,我们有几处做了忽略。事实上,Check
函数还有一个额外的参数 —— 环境,用于存放变量名/函数名到其类型的映射,即 Check : (Env, AST, Type) -> Bool
。因此,一般情况下,我们会先做全局扫描,抓到所有的函数名和其对应类型并创建 Env
,这样便能应付(互)递归的函数定义了。在检查的过程中,我们也会(根据局部变量的生命周期)动态地往 Env
中加入、删除局部变量。
# 泛型函数类型检查
接下来,我们来看一下如何做泛型 generics(或多态 polymorphic)函数的类型检查。
我们以 Kotlin 的语法为例,如下所示。定义一个函数 f
,该函数有两个泛型类型参数 X
和 Y
;给定一个如下的函数调用 (因为是检查模式,所以这里是用 f<>
的形式调用的),应该如何检查函数调用是否合法呢?
// define
fun <X, Y>f(a : X, b : Y) { }
// use
f<Bool, Int>(true, 0)
如下所示,先建立一个类型上的映射 (或者叫代换 substitution) m
,具体来说,我们需要根据位置关系,将 X
映射到 Bool
类型,Y
映射到 Int
类型;第二步,我们需要找到函数 f
的定义,并且将类型中所有的 X
和 Y
用映射 m
代换掉,这样一来,我们就成功地将泛型函数类型检查替换为非泛型函数的类型检查了。
// Step 1: 建立映射
m = [X --> Bool, Y --> Int]
// Step 2: 用 m 代换泛型版本 f 的类型,得到
f : (a : Bool, b : Int) { }
// Step 3: 对代换后的函数进行类型检查
......
进一步地,现在很多语言支持在泛型上定义上界 upper bounds。对带泛型上界的函数类型检查其理念其实也很简单。
// define
fun <X, Y>f(a : X, b : Y) where X <: Y { }
// use
f<Bool, Int>(true, 0)
和前面一样,我们先建立类型映射 m
并且做代换。需要注意的是,我们需要优先检查代换后的上界是否还是正确的。(例子中的上界是 where Bool <: Int
。)
// Step 1: 建立映射
m = [X --> Bool, Y --> Int]
// Step 2: 做代换
f : (a : Bool, b : Int) where Bool <: Int { }
// Step 3: 优先验证 `where Bool <: Int` 是否满足子类型关系
// Step 4: 若验证成功,则对代换后的函数进行类型检查;
// 若验证失败,则可以提前终止类型检查
......
以上是一些简单的示例,我在参考部分补充了复杂的例子,感兴趣的同学可以进一步阅读。
# 编写一个类型推导器 #
现在我们来看一下如何实现一个类型推导器。
类型推导 type inference 在文献里常被叫做 类型重构造 type reconstruction。相比类型检查而言,类型推导一般会更困难 (当然,这个困难在本次分享中不会体现得很明显)。
类型推导一个很关键问题就是,什么类型可以被推导出来。一般来说,我们会有局部 local推导和全局 all / global 推导两种情况。
局部一般使用 局部类型推导 local type inference (或者 双向类型检查 bidirectional type checking) 的推导方法,通常来讲,它只推导局部的变量定义、初始化、表达式、泛型函数调用等的类型,分界明显。
全局,会扫描代码里所有的定义和表达式,根据它们的声明和使用收集约束,最后,使用合一的方法 purely unification-based approaches 把类型解出来。(熟悉的朋友应该了解 Hindley-Milner 1 的类型推导算法 Algorithm W 2 3,该算法在实践中被证明是很有效的,已成功应用于很多的大型项目中。)
# 局部 —— 双向类型检查
我们先从局部的部分开始,即使用双向类型检查。
一般来讲,程序可以被切割成 非泛型的部分 和 泛型的部分。对于非泛型部分 (后面会有具体的例子解释说明),我们会将定义处的类型信息传递到使用处,或者将使用处的类型信息传递到定义处,从而完成一个简单的类型推导;对于泛型部分,我们会选用合一的方法来做推导(这是比较困难的部分 😦 )。
以变量声明为例,若开发者标注了变量的类型,我们便可以将该类型用作目标类型,来检查初始化表达式 expr
的类型;我们也允许开发者在定义变量的时候不指定类型,那么此时,我们需要先找到初始化表达式 expr
的类型,再将该类型当做变量的类型。这两种情况下类型的传播方向是双向的,即双向类型检查。
下图是函数返回类型的双向类型检查例子。如果开发者显式标注了函数的返回类型,那么我们可以用该类型去检查函数体中每一个 return
表达式的类型,类型传播如下图左边所示;当然,我们也允许开发者省略掉函数返回类型的标注,此时我们需要找出函数体中所有可能的 return
表达式的类型,将这些类型的公共父类型作为该函数的返回类型。省略返回类型一般在 lambda 表达式里比较有用。
如下是函数调用的例子。若在函数定义时,开发者声明了形参的类型,那在函数调用处,我们可以省略标注实参的类型,直接用形参的类型作为目标类型来检查实参类型即可。
类型推导在高阶函数的场景中是有好处的。如下所示例子中,函数调用的实参是一个 lambda 表达式,我们可以省略标注 lambda 表达式中形参 x
的类型,用函数定义处的形参类型 ParTy
作为 x
的类型即可;也可以使用定义处的返回类型 RetTy
来检查 lambda 体的类型。
以上就是四个简单的例子,用来说明如何使用双向类型检查的框架去做局部的类型推导,其实质就是将类型信息从已知的部分传递到未知的部分。
# 伪代码示例
接下来,我们来看如何实现一个类型推导器(双向类型检查器)。
首先,定义一个 Infer
函数,接受环境 Env
和要推导类型的节点 AST
为参数,返回该节点的类型。如下,
-- recall that `Check:(AST, Type) -> Bool`
Infer : (Env, AST) -> Type
字面值类型推导
和类型检查类似,我们需要对正在被类型推导的树做模式匹配。字面值的类型推导比较简单,它是一种原子节点,这种情况下,我们可以直接用 typeOf()
获取字面值的类型。
Infer (env, Lit(n)) = typeOf(n) -- pattern match
加法类型推导
还是按照刚才的加法例子,我们输入加法的语法树,如下。分别递归地推导左右子树的类型,如果推导出的左右子树类型相等,那么返回该类型作为加法的类型;否则,返回一个错误类型 ErrTy
。
Infer (env, Add(1, r)) =
let tyL = Infer(env, 1)
let tyR = Infer(env, r)
in if (tyL == tyR) then tyL else ErrTy
函数调用类型推导
我们再看一个函数调用的例子。给函数 Infer
输入参数 env
和函数调用节点 FunApp(f, x)
(f
是被调用的函数,x
是 f
的入参)。
Infer (env, FunApp(f, x))
首先在环境 env
中找到 f
在定义处的类型,将查找到的函数类型使用模式匹配解构到形参类型 parTy
和返回类型 retTy
。
Infer (env, FunApp(f, x)) =
let FunTy(parTy, retTy) = LookUp(env, f)
然后我们就到了类型检查的阶段。我们需要检查,函数的实参 x
是否可以有形参类型 parTy
,或者说实参类型与形参类型是否匹配。如果匹配,则我们可以将查找到的函数返回类型 retTy
当做实际函数调用表达式的返回类型;否则,返回错误类型 ErrTy
。
Infer (env, FunApp(f, x)) =
let FunTy(parTy, retTy) = LookUp(env, f)
in if (Check(env, x, parTy)) then retTy else ErrTy
函数定义类型推导
再来看函数定义的例子。我们将函数定义的名字绑定到变量 f
,函数形参绑定到 par
,函数体绑定到 body
,函数定义的返回类型绑定到 retTy
,如下。
Infer (env, FunAbs(f, par, body), retTy)
将形参解构为形参名 x
与形参类型 tyX
。
Infer (env, FunAbs(f, par, body), retTy) =
let (x, tyX) = par
在检查函数体类型之前,我们需要先做一步扩展环境的动作,将变量 x
有类型 tyX
这个事实加到环境中 (这是因为,函数体 body
里会有对变量 x
的引用)。
Infer (env, FunAbs(f, par, body), retTy) =
let (x, tyX) = par
-- the new env has mapping x : tyX
let env' = extendEnv(env, x, tyX)
如果我们想支持递归 (即在函数体内引用函数 f
自己),那么我们也需要将函数 f
本身的定义类型 FunTy(tyX, retTy)
加到环境中,如下。
Infer (env, FunAbs(f, par, body), retTy) =
let (x, tyX) = par
-- the new env has mapping x : tyX
let env' = extendEnv(env, x, tyX)
-- support recursion
let env'' = extendEnv(env', f, FunTy(tyX, retTy))
接下来,我们就可以直接检查函数体有没有返回类型 retTy
了。如果有,则返回 FunTy(tyX, retTy)
作为函数定义的类型,否则返回错误类型 ErrTy
。
Infer (env, FunAbs(f, par, body), retTy) =
let (x, tyX) = par
-- the new env has mapping x : tyX
let env' = extendEnv(env, x, tyX)
-- support recursion
let env'' = extendEnv(env', f, FunTy(tyX, retTy))
-- use the new env to check
in if (Check(env'', body, retTy))
then FunTy(tyX, retTy)
else ErrTy
环境扩展的作用是为了让我们可以在函数体 body
内查到 x
和 f
的类型。当然,更好的做法是,如前文所说,先做一次全局扫描,将所有函数的定义全部添加到环境 env
中,这样就可以处理互递归函数的情况了。
# 泛型函数类型推导
接下来我们一起看一下,对于一般初学者来讲比较难的部分,即泛型函数中泛型形参的类型推导 (本次分享我们只提供一个大的框架,有兴趣的小伙伴可以查阅文末的参考文献)。
依旧以 Kotlin 语法为例讲解一下如何做泛型函数的类型求解。定义一个 snd()
函数,有两个泛型形参类型 X
和 Y
,接受两个参数 x
和 y
,并返回第二个参数的类型 Y
。调用函数 snd(1, true)
,并将结果返回给声明为 Bool
类型的变量 res
。那么,我们应该怎样去解泛型函数参数的类型呢?
// define
fun <X, Y> snd(x : X, y : Y) : Y
// use
val res : Bool = snd(1, true)
我们先收集实参 args 的类型,1 : Int, true : Bool
,以及函数定义处形参 params 的类型,即 x : X, y : Y
。
这里有一个规则 (或者事实),要求实参类型必须是形参类型的子类型,即 args <: params
。据此我们可以得到这样两条规则,
Int <: X
Bool <: Y
鉴于我们的环境表示函数的返回类型必须是 Bool
,我们又会有一个规则需要遵循,要求函数声明处的返回类型,必须是我们环境/上下文中要求的类型的子类型,即 return <: context-type
,因此我们可以得到,
Y <: Bool
根据前述步骤,我们将已获得的子类型关系按照变量整理收集,可以得到如下两条关系。我们可以看到,变量 X
只有下界 Int
,而变量 Y
有上下界,都是 Bool
。
Int <: X
Bool <: Y <: Bool
据此,我们得到了可能的解,即 X
只要是 Int
的父类型即可,而 Y
只能是 Bool
。
Int <: X
Y = Bool
最后,结合考虑子类型关系、协变、逆变等因素,尽可能地找到一个最优解,如下。
X = Int
Y = Bool
# 如何实现合一
前面介绍的方法就是合一 (即 unifications),接下来我们简单介绍一下如何实现。
第一步,定义合一函数 unify
,输入两个类型,输出一组约束。
unify : (Type, Type) -> Constraints
对输入的类型参数做模式匹配,假设第一个参数输入的是类型变元 (例如前面例子中的 <X, Y>
),那么我们需要生成一条约束,表示该类型变元应该小于等于上界类型 sup
。
-- a singleton set
unify(TyVar(tv), sup) = { tv <: sup }
假设我们给第二个参数输入类型变元,那么对应地,我们可以生成如下这样的约束,表示该类型变元有下界 sub
。
unify(sub, TyVar(tv)) = { sub <: tv }
还有一些其他的类型,比较有趣的是如下示例的函数类型。我们需要分别对两个函数的参数类型和返回类型递归地生成约束,再取并集,得到最终的约束。这里需要注意的是,入参的部分是逆变的,返回类型部分是协变的。
unify(FunTy(S1, S2), FunTy(U1, U2)) =
unify(U1, S1) `union` unify(S2, U2)
其它情况下,我们只需要检查左边的类型 sub
是否是右边类型 sup
的子类型。
unify(sub, sup) = checkSubTy(sub, sup)
现实中的语言会很复杂,这边就不展开介绍了。
局部 vs 全局
上一小节,我们简单介绍了如何对程序中非泛型部分和泛型部分做类型推导。那么,局部和全局的方法应该如何选用呢?
对比来看,局部的类型推导使用合一的方法来寻找泛型(多态)函数调用的类型变元的代换 (需要单独处理每个函数调用);全局的类型推导算法 (比如 Hindley-Milner-liked / Algorithm W) 使用合一的算法来推导所有表达式、定义的类型 (先将新的类型变元赋给表达式,然后再找到它们的代换)。
除了一些基于技术难度的考量外,局部和全局的选择也是一种设计理念上的哲学,比如,认为类型是文档的一部分 (为了代码的可读性,一些语言可能会倾向于不允许省略类型标注,比如函数定义部分的形参类型和返回类型);认为使用处不应该影响定义处等等。基于这些设计理念的考虑,一些语言,特别是工业语言,也可能会选择一种相对局部的类型推导技术。
# 如何严格地定义类型检查和类型推导 #
接下来,我们来看一下,应该如何严格地定义类型检查和推导,以及它们要满足的一些规则有哪些。
# 严格的类型检查
要严格地定义类型检查,我们需要做这些事情,
语法定义
首先,语法上需要定义程序里哪些是项 terms,哪些是项运行后的结果 values,哪些是类型 types。
- 项 Terms:即我们常说的表达式和语句。
- 值 Values:不可化简(规约)的项,包括字面量,函数和 lambda 等。
- 类型 Types:项的抽象。一个正确的 term 永远会有一个类型,我们用 t e r m : T y p e term : Type term:Type 表示二者的二元关系。
我们使用扩展的 BNF 来定义语法。
我们说,一个项 term 可以是一个整数,布尔值,加法,变量,可以是函数定义 ( λ x . t \lambda x.t λx.t,lambda abstraction,定义一个函数,其形参是 x x x,函数体是 t t t),可以是函数调用 t ( t ) t(t) t(t),还可以是序列 t ; t t;t t;t。基于这样的描述,我们就可以规定出如下的简单语法。(注意:此处的语法定义中,“+” “;” 等这种符号是字面量,而 t t t 相当于是占位符,可以展开为定义中的任一种。请读者区分。)
t ::= 0 | 1 | 2... ; integers
| true | false ; booleans
| t "+" t ; additions
| x, y... ; variables
| λx "." t ; abstraction
| t "(" t ")" ; application
| t ";" t ; sequence
| ...
值 values 的语法定义如下。值是不可被化简(规约)的部分,比如整数 0
、1
、2
、3
等,但 1+2
不是值,因为它可以被化简(规约)为 3
。
v ::= 0 | 1 | 2... ; integers
| true | false ; booleans
| λx.t ; application
|...
我们还需要定义类型 types,语法规则如下。类型可以是类型变元,可以是多态类型,可以是函数类型,可以是布尔类型、整数类型等,如果我们还需要处理子类型关系的话,大概率系统里还需要一个顶类型和底类型 (顶类型 top type,即所有类型的公共父类;底类型 bottom type,即所有类型的公共子类)。
T ::= X ; type variables
| ∀X <: T.T ; polymorphic types
| T → T ; function types
| Bool ; booleans
| Int ; integers
| ⊤ ; the Top type
| ⊥ ; the Bottom type
| Unit ; the Unit type
| ...
类型规则
在有了语法之后,我们需要定义一组 类型关系 type relations ( t : T ) (t : T) (t:T)。
类型关系用 t : T t:T t:T 描述,此处 : : : 是 term 和 type 之间的二元关系。
如果类型关系没有横线,则表示该描述恒成立,如下图所示。 1 : I n t 1:Int 1:Int 表示 1 1 1 永远是 I n t Int Int 类型, t r u e : B o o l true:Bool true:Bool 表示 t r u e true true 永远有 B o o l Bool Bool 类型。
1 : I n t integers t r u e : B o o l booleans \begin{aligned} &1 : Int &\qquad \text{integers}\\ &true : Bool &\qquad \text{booleans}\\ \end{aligned} 1:Inttrue:Boolintegersbooleans
若类型关系如下所示,则表示横线上面 x : T ∈ Γ x:T\in\Gamma x:T∈Γ 是横线下 Γ ⊢ x : T \Gamma \vdash x:T Γ⊢x:T 的前提 (此处 Γ \Gamma Γ 表示环境, Γ ⊢ A \Gamma \vdash A Γ⊢A 表示在环境 Γ \Gamma Γ 下 A A A 成立),即如果我们的环境 Γ \Gamma Γ 中存放了变量 x x x 到类型 T T T 的映射关系,那么我们就知道在环境 Γ \Gamma Γ 下 x x x 一定有类型 T T T。
x : T ∈ Γ Γ ⊢ x : T variable \frac{x:T\in\Gamma}{\Gamma\vdash x:T}\qquad \text{variable} Γ⊢x:Tx:T∈Γvariable
下面是函数定义的类型规则。它告诉我们如果在环境 Γ \Gamma Γ 加上 x x x 有类型 T 1 T_1 T1 的前提下,函数体 t t t 有 T 2 T_2 T2 类型,则我们可以说,在环境 Γ \Gamma Γ 下,lambda 表达式 λ x : T 1 . t \lambda x : T_1 . t λx:T1.t 有 T 1 → T 2 T_1 \rightarrow T_2 T1→T2 的类型。
Γ , x : T 1 ⊢ t : T 2 Γ ⊢ λ x : T 1 . t : T 1 → T 2 abstraction \frac{\Gamma, x:T_1\vdash t : T_2}{\Gamma\vdash\lambda x:T_1.t:T_1\rightarrow T_2}\qquad \text{abstraction} Γ⊢λx:T1.t:T1→T2Γ,x:T1⊢t:T2abstraction
上述类型规则,还可以用前面介绍过的双向类型检查框架拆分为检查规则 ⇐ \Leftarrow ⇐ 和推导规则 ⇒ \Rightarrow ⇒。
T 1 < : T x Γ , x : T x ⊢ t ⇐ T 2 Γ ⊢ λ x : T x . t ⇐ T 1 → T 2 abs check1 Γ , x : T 1 ⊢ t ⇐ T 2 Γ ⊢ λ x . t ⇐ T 1 → T 2 abs check2 Γ , x : T x ⊢ t ⇒ T 2 Γ ⊢ λ x : T x . t ⇒ T x → T 2 abs infer \begin{aligned} &\frac{T_1<:T_x \qquad \Gamma, x:T_x \vdash t \Leftarrow T_2}{\Gamma \vdash \lambda x:T_x.t \Leftarrow T_1 \rightarrow T_2} &\qquad \text{abs check1} \\ \\ &\frac{\Gamma, x:T_1 \vdash t \Leftarrow T_2}{\Gamma \vdash \lambda x.t \Leftarrow T_1 \rightarrow T_2} &\qquad \text{abs check2} \\ \\ &\frac{\Gamma,x:T_x\vdash t \Rightarrow T_2}{\Gamma \vdash \lambda x:T_x.t \Rightarrow T_x \rightarrow T_2} &\qquad \text{abs infer}\\ &\qquad\qquad\qquad\qquad\qquad \end{aligned} Γ⊢λx:Tx.t⇐T1→T2T1<:TxΓ,x:Tx⊢t⇐T2Γ⊢λx.t⇐T1→T2Γ,x:T1⊢t⇐T2Γ⊢λx:Tx.t⇒Tx→T2Γ,x:Tx⊢t⇒T2abs check1abs check2abs infer
对比来看,检查的规则多出了目标类型这一输入:lambda 表达式的目标类型为 T 1 → T 2 T_1 \rightarrow T_2 T1→T2,我们检查其是否成立;而在推导的规则中,“目标类型”是缺失的。不难看出,只有检查的规则 (abs check2) 允许 lambda 表达式省略其形参 x 的类型。通常来说,我们应优先使用检查规则 (如果可用),因为它通常速度更快且可以在更多的场景工作。
子类型关系
在定义了类型关系之后,我们还需要定义子类型关系 (其实 abs check1 已经使用了子类型关系)。子类型关系是两个类型上的二元关系,即 T 1 < : T 2 T_1 <: T_2 T1<:T2。
以下是一些恒成立的子类型关系规则,比如一个类型永远是它自身类型的子类型 (自反性),一些语言会有底类型 ⊥ \bot ⊥ (Bottom、Nothing 等) ,它是所有类型的子类型,一些语言中还有顶类型 ⊤ \top ⊤ (Top、Any 等),它是所有类型的父类型。
T < : T ⊥ < : T T < : ⊤ T <: T \qquad \bot <: T \qquad T<: ⊤ T<:T⊥<:TT<:⊤
以下是在一些条件下会成立的子类型关系 ( Δ \Delta Δ 表示类型上的环境,会包含一些类型上下界等相关的信息)。
X < : T ∈ Δ Δ ⊢ X < : T \frac{X <: T\in\Delta}{\Delta\vdash X <: T} Δ⊢X<:TX<:T∈Δ
如下是大多数类型系统都具有的性质:子类型的传递性 transitivity。若存在 T 1 T_1 T1 是 T 2 T_2 T2 的子类型,且 T 2 T_2 T2 是 T 3 T_3 T3 的子类型,则我们可以得到, T 1 T_1 T1 是 T 3 T_3 T3 的子类型。
Δ ⊢ T 1 < : T 2 Δ ⊢ T 2 < : T 3 Δ ⊢ T 1 < : T 3 transitivity \frac{\Delta\vdash T_1 <: T_2 \quad \Delta\vdash T_2 <: T_3}{\Delta\vdash T_1 <: T_3}\qquad\text{transitivity} Δ⊢T1<:T3Δ⊢T1<:T2Δ⊢T2<:T3transitivity
如下是函数类型的子类型关系,在此(又)要注意协变和逆变。当我们要证明 S 1 → T 1 < : S 2 → T 2 S_1 \rightarrow T_1 <: S_2 \rightarrow T_2 S1→T1<:S2→T2 时,对于函数参数类型我们要用逆变关系,即 S 2 < : S 1 S_2 <: S_1 S2<:S1;对于函数的返回类型我们要用协变关系,即 T 1 < : T 2 T_1 <: T_2 T1<:T2。
Δ ⊢ S 2 < : S 1 Δ ⊢ T 1 < : T 2 Δ ⊢ S 1 → T 1 < : S 2 → T 2 arrow \frac{\Delta\vdash S_2 <: S_1 \quad \Delta \vdash T_1 <: T_2}{\Delta \vdash S_1\rightarrow T_1 <: S_2 \rightarrow T_2}\qquad\text{arrow} Δ⊢S1→T1<:S2→T2Δ⊢S2<:S1Δ⊢T1<:T2arrow
# 类型系统的证明规则
当我们定义了前述的语法和类型规则后,接下来我们就需要去证明我们的类型系统需要满足什么规则。
一般来讲,需要满足两条规则,可靠性 soundness 和 完备性 completeness。
可靠性 Soundness
可靠性指的是,类型正确的表达式在运行的时候是不会出错的 (well-typed terms do not go wrong)。即,如果一个表达式通过了类型检查,或者说它可以被赋予一个类型的话,那它运行的时候一定不会出错。
那么,要如何保证这点呢?这里又可以细分为两个子规则,一个叫 progress,一个叫 preservation。Progress 指,一个 term 或是一个 value,或可以被进一步计算(而最终得到 value),比如
1
+
2
1+2
1+2 可以被计算到
3
3
3。Preservation 指,term 在经过计算后仍然拥有一个合法的类型。下面这个例子就验证了上述两条规则。
完备性 Completeness
对于类型检查器来说,完备性即 每一个正确的表达式都可以通过检查;对于类型推导器来说,完备性指 要为每一个正确的表达式分配一个类型。不过在实际中,我们不一定能完全做到后者。那怎么办呢?很简单,我们只需要将类型标注出来,然后“退回到”类型检查即可。毕竟检查一般来说会比推导兼容更多的场景。
# 举个例子
下面我们用一些反例来说明为什么前述两条规则是重要的。
如果我们的程序不满足可靠性,那么很可能的是,程序可以编译通过,但运行行为是未定义的 undefined。
而如果我们的类型检查器是很不完备的,那么可能一些明显正确的代码,比如 1 + 2 : Int
,也无法编译。如果类型推导器是十分不完备的,那么编译器很有可能会很烦人,要求我们在每个很明显的地方显式指定类型,比如我们不能写 var a = true
,而需要明确写出 bool a = true
。
我们来看一个真实的例子。以 C++ 为例,程序中定义了公共父类 class C
,C
有两个子类 D
和 E
。我们声明一个类型为 C
的变量 c
,并且用一个简单的二元表达式来初始化它。C++ 的编译器会在这里报错:它说它无法找到 D
和 E
的公共父类——即便这里只能是 C。
class C { };
class D : public C { };
class E : public C { };
C c = true ? D() : E();
// error: incompatible operand
// types ('D' and 'E')
我们尝试将上面的代码改成推导模式,让编译器推导 c
的类型,程序仍然无法通过编译。
auto c = true ? D() : E();
// error: imcompatible operand
// types ('D' and 'E')
我们只能将某个分支的类型 (比如 D
) 强转到父类型 C
,来达到目的。
auto c = true ? (C)D() : E();
// this works
当然,这只是 C++ 编译器的行为。但如果我们用 Kotlin 或者 Swift 来写上面的代码,都是可以编译通过的。
# 学界 vs 工业界 #
最后,我们简单讨论一下,学界和工业界关心什么?
# 学界 Academic
学界最关心的就是前面介绍的两条定律 可靠性 soundness 和 完备性 completeness,这是第一要位;学界通常是在保证类型系统、类型检查和推导在满足这两条定律的情况下,再向语言中增加新的特性,并且保证扩展后的类型检查和推导器仍然满足这两条定律,同时提供 (经过编译器验证的) 证明。
学界还十分关注抽象的核心语言 core languages (核心语言中语言的特性越少越好)。学界希望能找到一组最简单的特性,能够通过组合来表达更多的特性。因为这样一来,需要做的证明是最少的。
此外,相比性能,学界更关注语言的表达能力。
当然,学界对现代的语言特性也很感兴趣,比如 effect handlers,gradual typing,refinement types,dependent types,linear types,session types 等。
学界语言还拥抱百花齐放的编程范式,比如 OO,FP,logic,relational,probabilistic,concurrent (parallel)…
# 工业界 Industrial
于工业界来说,人们对语言最首要的需求就是语言要有很多方便易用的语法、特性,从而语言的使用者能用更短的代码去解决实际问题。比起证明,人们往往使用测试用例来提高软件质量。
工业界对语言的表达能力和性能都很关注,也会在一些场景中,为了性能引入一些让用户比较头疼的语言特性。
工业语言对前沿语言特性的引入一般持保守态度。它们更倾向于引入一些偏向实际且已经较为成熟稳定的语言特性 (大概会和学界有十年到二十年的差距)。比如 C++ 这样的工业语言还会关注零代价抽象,后向兼容等。
就编程范式而言,一般来说,工业界语言还是以面向对象 OO 为主,相比学界来说就没有那么百花齐放。
# 总结 #
总结一下,本次分享简单地介绍了一下类型检查和推导做了什么,然后介绍了如何实现一个类型检查和推导器,并且介绍了如何严格地定义其要满足的定律,以及定律的重要性;在最后,就学界语言和工业界语言做了一个简单的对比。
如果大家对类型检查和推导相关的内容感兴趣,还可以就以下的参考文献进行扩展阅读。
参考
本文相关
- Types and Programming Languages
- Local Type Inference
扩展阅读
- Type Systems
- The Simple Essence of Algebraic Subtyping
- Intersection Types and Bounded Polymorphism
- A Core Calculus for Scala Type Checking
- Type Theory and Formal Proof An Introduction
Hindley-Milner Type System —— Wikipedia https://en.wikipedia.org/wiki/Hindley–Milner_type_system ↩︎
Algorithm W Step by Step. https://raw.githubusercontent.com/wh5a/Algorithm-W-Step-By-Step/master/AlgorithmW.pdf ↩︎
Understanding Algorithm W. https://jeremymikkola.com/posts/2018_03_25_understanding_algorithm_w.html ↩︎