大家好,我是朱子润,很高兴为大家带来本次有关类型检查与推导的分享。
本次分享的目标群体是对程序语言理论和类型检查感兴趣的工程师,所以内容以直觉为主,理论为辅。本次分享主要内容分为四个部分:
从日常的编码场景来说一下类型检查和类型推导的作用
概念上如何实现一个简单的类型检查与推导器(我将用 TC 和 TI 作为类型检查与推导的简写)
学界是如何严格定义类型检查与推导的
探讨学界和工业界对其的侧重点
# 类型检查 & 类型推导 #
# 什么是类型检查?
在介绍类型检查 (type check) 前,我们先看一个编译流程图,如下图示,输入的程序会依次进行词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成。类型检查其实就被涵盖在语义分析中(即 semantic analysis)。
图片来源:https://github.com/Elzawawy/compiler-frontend
如果我们看一个函数式语言的架构图,比如 Haskell,它可能会明确地标出在解析 parse 与 rename 之后,会有一个类型检查 type check 的阶段,在此之后还有些其他比较复杂的工作比如后端代码生成。
图片来源:https://github.com/Elzawawy/compiler-frontend 那么,类型检查是怎样的语义分析呢?
顾名思义,类型检查的主要作用就是 找到程序中的类型错误。我们来看两种简单而又典型的类型错误:不兼容的赋值 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
分别以 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 funf(a : X, b : Y) { } // use f (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 funf(a : X, b : Y) where X <: Y { } // use f (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 funsnd(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
对输入的类型参数做模式匹配,假设第一个参数输入的是类型变元 (例如前面例子中的
-- 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 永远会有一个类型,我们用 表示二者的二元关系。
我们使用扩展的 BNF 来定义语法。
我们说,一个项 term 可以是一个整数,布尔值,加法,变量,可以是函数定义 (,lambda abstraction,定义一个函数,其形参是 ,函数体是 ),可以是函数调用 ,还可以是序列 。基于这样的描述,我们就可以规定出如下的简单语法。 (注意:此处的语法定义中,"+" ";" 等这种符号是字面量,而 相当于是占位符,可以展开为定义中的任一种。请读者区分。)
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 。
类型关系用 描述,此处 是 term 和 type 之间的二元关系。
如果类型关系没有横线,则表示该描述恒成立,如下图所示。 表示 永远是 类型, 表示 永远有 类型。
若类型关系如下所示,则表示横线上面 是横线下 的前提 (此处 表示环境, 表示在环境 下 成立),即如果我们的环境 中存放了变量 到类型 的映射关系,那么我们就知道在环境 下 一定有类型 。
下面是函数定义的类型规则。它告诉我们如果在环境 加上 有类型 的前提下,函数体 有 类型,则我们可以说,在环境 下,lambda 表达式 有 的类型。
上述类型规则,还可以用前面介绍过的双向类型检查框架拆分为检查规则 和推导规则 。
对比来看,检查的规则多出了目标类型这一输入:lambda 表达式的目标类型为 ,我们检查其是否成立;而在推导的规则中,“目标类型”是缺失的。不难看出,只有检查的规则 (abs check2) 允许 lambda 表达式省略其形参 x 的类型。通常来说,我们应优先使用检查规则 (如果可用),因为它通常速度更快且可以在更多的场景工作。
子类型关系
在定义了类型关系之后,我们还需要定义子类型关系 (其实 abs check1 已经使用了子类型关系)。子类型关系是两个类型上的二元关系,即 。
以下是一些恒成立的子类型关系规则,比如一个类型永远是它自身类型的子类型 (自反性),一些语言会有底类型 (Bottom、Nothing 等) ,它是所有类型的子类型,一些语言中还有顶类型 (Top、Any 等),它是所有类型的父类型。
以下是在一些条件下会成立的子类型关系 ( 表示类型上的环境,会包含一些类型上下界等相关的信息)。
如下是大多数类型系统都具有的性质:子类型的传递性 transitivity。若存在 是 的子类型,且 是 的子类型,则我们可以得到, 是 的子类型。
如下是函数类型的子类型关系,在此(又)要注意协变和逆变。当我们要证明 时,对于函数参数类型我们要用逆变关系,即 ;对于函数的返回类型我们要用协变关系,即 。
# 类型系统的证明规则
当我们定义了前述的语法和类型规则后,接下来我们就需要去证明我们的类型系统需要满足什么规则。
一般来讲,需要满足两条规则,可靠性 soundness 和 完备性 completeness。
可靠性 Soundness
可靠性指的是,类型正确的表达式在运行的时候是不会出错的 (well-typed terms do not go wrong)。即,如果一个表达式通过了类型检查,或者说它可以被赋予一个类型的话,那它运行的时候一定不会出错。
那么,要如何保证这点呢?这里又可以细分为两个子规则,一个叫 progress,一个叫 preservation。Progress 指,一个 term 或是一个 value,或可以被进一步计算(而最终得到 value),比如 可以被计算到 。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 为主,相比学界来说就没有那么百花齐放。
# 总结 #
总结一下,本次分享简单地介绍了一下类型检查和推导做了什么,然后介绍了如何实现一个类型检查和推导器,并且介绍了如何严格地定义其要满足的定律,以及定律的重要性;在最后,就学界语言和工业界语言做了一个简单的对比。
如果大家对类型检查和推导相关的内容感兴趣,还可以就以下的参考文献进行扩展阅读。
审核编辑:汤梓红
评论
查看更多