类型检查:时常被忽略的编译器组件

原文来自微信公众号“编程语言Lab”:类型检查:时常被忽略的编译器组件
搜索关注“编程语言Lab”公众号(HW-PLLab)获取更多技术内容!
欢迎加入 编程语言社区 SIG-类型系统 参与交流讨论(加入方式:添加小助手微信 pl_lab_001,备注“加入SIG-类型系统”)。

分享嘉宾 | 朱子润

回顾整理 | Hana

视频回顾

编程语言技术沙龙|第 17 期: 类型检查:时常被忽略的编译器组件

大家好,我是朱子润,很高兴为大家带来一个比较简单的类型检查与推导的分享。

这个分享的目标群体是对程序语言理论和类型检查感兴趣的工程师,所以内容以直觉为主,理论为辅。本次分享主要内容分为四个部分:

  • 从日常的编码场景来说一下类型检查和类型推导的作用
  • 概念上如何实现一个简单的类型检查与推导器(我将用 TC 和 TI 作为类型检查与推导的简写)
  • 学界是如何严格定义类型检查与推导的
  • 探讨学界和工业界对其的侧重点

相关阅读

类型系统综述(一)
类型系统综述(二)
浅谈 Intersection Types
编程语言设计原理书籍推荐

# 类型检查 & 类型推导 #

# 什么是类型检查?

在介绍类型检查 (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 的代码示例中,我们声明了两个类 CD,然后声明一个变量 x 并用一个三元表达式来初始化,三元表达式的两个分支分别创建了 CD 的实例。这里编译器也会报错,说 CD 类型不匹配,mismatching。这是因为编译器无法找到 CD 的公共父类型,即编译器尝试将 CD 的所有父类找出来,发现它们没有交集,所以说没有公共父类型。(不过这样一类问题其实是通过类型推导发现的。)
在这里插入图片描述
而如果 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:即 1Int 类型,那么这个返回结果就可以向上传递直至顶层。那么我们对于左子树的检查就完成了,它返回 true
在这里插入图片描述
接下来,同样按照递归下降的方式检查右子树,直到最后检查 false 是否是 Int 类型,(鉴于一般语言是不允许的,因此) 结果返回 false。我们将这个返回结果向上传递至顶层。
在这里插入图片描述
在这里插入图片描述
此时左子树的返回结果 true 与右子树的返回结果 false 做一个逻辑与的操作,得到最终的检查结果为 false,即对这棵语法树的类型检查会失败。
在这里插入图片描述

# 伪代码示例

接下来我们用伪代码来解释一下如何实现 (这部分代码会比较像 Haskell)。说明:a <: b 表示 ab 的一个子类型 subtype。

首先,编写 Check 的类型。Check 有两个参数:语法树 ast 和目标类型 ty。我们给 ast 做模式匹配。

Check : (AST, Type) -> Bool
Check (ast, ty) = case ast of pattern match

加法类型检查

我们以上一节的加法场景为例,将加法的左右子树分别绑定到新的变量 lr,分别递归检查左子树和右子树,检查的目标类型仍然是 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,该函数有两个泛型类型参数 XY;给定一个如下的函数调用 (因为是检查模式,所以这里是用 f<> 的形式调用的),应该如何检查函数调用是否合法呢?

// define
fun <X, Y>f(a : X, b : Y) { }

// use
f<Bool, Int>(true, 0)

如下所示,先建立一个类型上的映射 (或者叫代换 substitution) m,具体来说,我们需要根据位置关系,将 X 映射到 Bool 类型,Y 映射到 Int 类型;第二步,我们需要找到函数 f 的定义,并且将类型中所有的 XY 用映射 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 是被调用的函数,xf 的入参)。

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 内查到 xf 的类型。当然,更好的做法是,如前文所说,先做一次全局扫描,将所有函数的定义全部添加到环境 env 中,这样就可以处理互递归函数的情况了。

# 泛型函数类型推导

接下来我们一起看一下,对于一般初学者来讲比较难的部分,即泛型函数中泛型形参的类型推导 (本次分享我们只提供一个大的框架,有兴趣的小伙伴可以查阅文末的参考文献)。

依旧以 Kotlin 语法为例讲解一下如何做泛型函数的类型求解。定义一个 snd() 函数,有两个泛型形参类型 XY,接受两个参数 xy,并返回第二个参数的类型 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 的语法定义如下。值是不可被化简(规约)的部分,比如整数 0123 等,但 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 T1T2 的类型。

Γ , 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:T1T2Γ,x:T1t: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.tT1T2T1<:TxΓ,x:TxtT2Γλx.tT1T2Γ,x:T1tT2Γλx:Tx.tTxT2Γ,x:TxtT2abs check1abs check2abs infer

对比来看,检查的规则多出了目标类型这一输入:lambda 表达式的目标类型为 T 1 → T 2 T_1 \rightarrow T_2 T1T2,我们检查其是否成立;而在推导的规则中,“目标类型”是缺失的。不难看出,只有检查的规则 (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 S1T1<:S2T2 时,对于函数参数类型我们要用逆变关系,即 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} ΔS1T1<:S2T2Δ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 3Preservation 指,term 在经过计算后仍然拥有一个合法的类型。下面这个例子就验证了上述两条规则。
在这里插入图片描述
完备性 Completeness

对于类型检查器来说,完备性即 每一个正确的表达式都可以通过检查;对于类型推导器来说,完备性指 要为每一个正确的表达式分配一个类型。不过在实际中,我们不一定能完全做到后者。那怎么办呢?很简单,我们只需要将类型标注出来,然后“退回到”类型检查即可。毕竟检查一般来说会比推导兼容更多的场景。

# 举个例子

下面我们用一些反例来说明为什么前述两条规则是重要的。

如果我们的程序不满足可靠性,那么很可能的是,程序可以编译通过,但运行行为是未定义的 undefined。

而如果我们的类型检查器是很不完备的,那么可能一些明显正确的代码,比如 1 + 2 : Int,也无法编译。如果类型推导器是十分不完备的,那么编译器很有可能会很烦人,要求我们在每个很明显的地方显式指定类型,比如我们不能写 var a = true,而需要明确写出 bool a = true

我们来看一个真实的例子。以 C++ 为例,程序中定义了公共父类 class CC 有两个子类 DE。我们声明一个类型为 C 的变量 c,并且用一个简单的二元表达式来初始化它。C++ 的编译器会在这里报错:它说它无法找到 DE 的公共父类——即便这里只能是 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 为主,相比学界来说就没有那么百花齐放。

# 总结 #

总结一下,本次分享简单地介绍了一下类型检查和推导做了什么,然后介绍了如何实现一个类型检查和推导器,并且介绍了如何严格地定义其要满足的定律,以及定律的重要性;在最后,就学界语言和工业界语言做了一个简单的对比。

如果大家对类型检查和推导相关的内容感兴趣,还可以就以下的参考文献进行扩展阅读。

参考

本文相关

  1. Types and Programming Languages
  2. Local Type Inference

扩展阅读

  1. Type Systems
  2. The Simple Essence of Algebraic Subtyping
  3. Intersection Types and Bounded Polymorphism
  4. A Core Calculus for Scala Type Checking
  5. Type Theory and Formal Proof An Introduction

  1. Hindley-Milner Type System —— Wikipedia https://en.wikipedia.org/wiki/Hindley–Milner_type_system ↩︎

  2. Algorithm W Step by Step. https://raw.githubusercontent.com/wh5a/Algorithm-W-Step-By-Step/master/AlgorithmW.pdf ↩︎

  3. Understanding Algorithm W. https://jeremymikkola.com/posts/2018_03_25_understanding_algorithm_w.html ↩︎

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值