2.3 类型检查
我们在上一节中介绍了 Golang 的第一个编译阶段 — 通过词法和语法分析器的解析得到了抽象语法树,在这里就会继续介绍编译器执行的下一个过程 — 类型检查。
提到类型检查和编程语言的类型系统,很多人都会想到几个非常模糊并且难以区分和理解的术语:强类型、弱类型、静态类型和动态类型。这几个术语有的可能在并没有被广泛认同的明确定义,但是我们既然即将谈到 Go 语言编译器的类型检查过程,就不得不讨论一下这些『类型』的含义与异同。
2.3.1 强弱类型
强类型和弱类型1经常会被放在一起讨论,然而这两者并没有一个学术上的严格定义,作者以前也尝试对强弱类型这两个概念进行理解,但是查阅了很多资料之后却发现理解编程语言的类型系统反而更加困难,很多资料都是相互矛盾的、用词含糊不清,也没有足够权威的资料。
图 2-10 强类型和弱类型
由于权威的定义的缺失,对于强弱类型来说,我们很多时候也只能根据现象和特性从直觉上进行判断 —— 强类型的编程语言在编译期间会有着更严格的类型限制,也就是编译器会在编译期间发现变量赋值、返回值和函数调用时的类型错误,而弱类型的语言在出现类型错误时可能会在运行时进行隐式的类型转换,在类型转换时可能会造成运行错误2。
假如我们从上面的定义出发,我们就可以认为 Java、C# 等在编译期间进行类型检查的编程语言往往都是强类型的,同样地按照这个标准,Go 语言因为会在编译期间发现类型错误,所以也应该是强类型的编程语言。
理解强弱类型这两个具有非常明确歧义并且定义不严格的概念是没有太多实际价值的,作为一种抽象的定义,我们使用它更多的时候是为了方便沟通和分类,这对于我们真正使用和理解编程语言可能没有什么帮助,相比没有明确定义的强弱类型,更应该被关注的应该是下面的这些问题:
- 类型的转换是显式的还是隐式的?
- 编译器会帮助我们推断变量的类型么?这些具体的问题在这种语境下其实更有价值,也希望各位读者能够减少和避免对强弱类型的争执。
2.3.2 静态与动态类型
静态类型和动态类型的编程语言其实也是两个不精确的表述,它们应该被称为使用静态类型检查和动态类型检查的编程语言,这一小节会分别介绍两种类型检查的特点以及它们的区别。
静态类型检查
静态类型检查是基于对源代码的分析来确定运行程序类型安全的过程3,如果我们的代码能够通过静态类型检查,那么当前程序在一定程度上就满足了类型安全的要求,它也可以被看作是一种代码优化的方式,能够减少程序在运行时的类型检查。
作为一个开发者来说,静态类型检查能够帮助我们在编译期间发现程序中出现的类型错误,一些动态类型的编程语言都会有社区提供的工具为这些编程语言加入静态类型检查,例如 JavaScript 的 Flow4,这些工具能够在编译期间发现代码中的类型错误。
相信很多读者也都听过『动态类型一时爽,代码重构火葬场』5,使用很多编程语言的开发者一定对这句话深有体会,静态类型为代码在编译期间提供了一种约束,如果代码没有满足这种约束就没有办法通过编译器的检查。
在重构时这种静态的类型检查能够帮助我们节省大量的时间并且避免一些遗漏的错误,但是对于仅使用动态类型检查的语言,就需要额外写大量的测试用例保证重构不会出现类型错误了,当然在这里并不是说测试不重要,我们写的任何代码都应该有良好的测试,这与语言没有太多的关系。
动态类型检查
动态类型检查就是在运行时确定程序类型安全的过程,这个过程需要编程语言在编译时为所有的对象加入类型标签和信息,运行时就可以使用这些存储的类型信息来实现动态派发、向下转型、反射以及其他特性6。
这种类型检查的方式能够为工程师提供更多的操作空间,让我们能在运行时获取一些类型相关的上下文并根据对象的类型完成一些动态操作。
只使用动态类型检查的编程语言就叫做动态类型编程语言,常见的动态类型编程语言就包括 JavaScript、Ruby 和 PHP,这些编程语言在使用上非常灵活也不需要经过编译器的编译,但是有问题的代码该出错还是出错并不会因为更加灵活就会减少错误。
小结
静态类型检查和动态类型检查其实并不是两种完全冲突和对立的特点,很多编程语言都会同时使用两种类型检查,Java 就同时使用了这两种检查的方法,不仅在编译期间对类型提前检查发现类型错误,还为对象添加了类型信息,这样能够在运行时使用反射根据对象的类型动态地执行方法减少了冗余代码。
2.3.3 执行过程
Go 语言的编译器不仅使用静态类型检查来保证程序运行的类型安全,还会在编程期引入类型信息,让工程师能够使用反射来判断参数和变量的类型,当我们想要将 interface
转换成具体类型时就会进行动态类型检查,如果无法发生转换就可能会造成程序崩溃。
我们在这里还是会重点介绍编译期间的静态类型检查,在 2.1 概述中,我们曾经介绍过 Go 语言编译器主程序中的 Main
函数,其中有一段是这样的:
for i := 0; i < len(xtop); i++ {
n := xtop[i]
if op := n.Op; op != ODCL && op != OAS && op != OAS2 && (op != ODCLTYPE || !n.Left.Name.Param.Alias) {
xtop[i] = typecheck(n, ctxStmt)
}
}
for i := 0; i < len(xtop); i++ {
n := xtop[i]
if op := n.Op; op == ODCL || op == OAS || op == OAS2 || op == ODCLTYPE && n.Left.Name.Param.Alias {
xtop[i] = typecheck(n, ctxStmt)
}
}
...
checkMapKeys()
这段代码的执行过程可以分成两个部分,首先通过 src/cmd/compile/internal/gc/typecheck.go
文件中的 typecheck
函数检查常量、类型、函数声明以及变量赋值语句的类型,然后使用 checkMapKeys
检查哈希中键的类型,我们会分几个部分对上述代码的实现原理进行分析。
编译器类型检查的主要逻辑都在 typecheck
和 typecheck1
这两个函数中,其中 typecheck
中逻辑不是特别多,它的主要作用就做一些类型检查之前的准备工作。而核心的类型检查逻辑都在 typecheck1 函数中,这是由一个巨大的 switch/case 构成的 2000 行函数:
func typecheck1(n *Node, top int) (res *Node) {
switch n.Op {
case OTARRAY:
...
case OTMAP:
...
case OTCHAN:
...
}
...
return n
}
typecheck1
根据传入节点操作 Op
的不同,进入不同的分支,其中 Op
包括加减乘数等操作符、函数调用、方法调用等 150 多种,所有的节点操作 Op
都定义在 src/cmd/compile/internal/gc/syntax.go
这个文件中,不过由于它的种类确实非常多,所以我们在这里只节选几个比较重要的案例深入分析一下。
切片 OTARRAY
如果当前节点的操作类型是 OTARRAY
,那么这个分支首先会对右节点,也就是切片或者数组中元素的类型进行类型检查:
case OTARRAY:
r := typecheck(n.Right, Etype)
if r.Type == nil {
n.Type = nil
return n
}
然后该分支会根据当前节点的左节点不同,分三种不同的情况更新当前 Node
的类型,也就是三种不同的声明方式 []int
、[…]int
和 [3]int
,第一种相对来说比较简单,这里会直接调用 NewSlice
函数:
if n.Left == nil {
t = types.NewSlice(r.Type)
NewSlice
函数直接返回了一个 TSLICE
类型的结构体,中元素的类型信息 r.Type
也会存储在结构体中。当遇到 […]int
这种形式的数组类型时,就会使用 NewDDDArray
函数创建一个存储着 &Array{Elem: elem, Bound: -1}
结构的 TARRAY
类型,-1
就代表当前的数组类型的大小需要进行推导:
} else if n.Left.Op == ODDD {
if top&ctxCompLit == 0 {
if !n.Diag() {
n.SetDiag(true)
yyerror("use of [...] array outside of array literal")
}
n.Type = nil
return n
}
// t.Extra = &Array{Elem: r.Type, Bound: -1}
t = types.NewDDDArray(r.Type)
在最后,如果源代码中直接包含了数组的大小,就会调用 NewArray
函数初始化一个 TARRAY
类型的结构体,结构体存储着数组中元素的类型和数组的大小:
} else {
n.Left = indexlit(typecheck(n.Left, ctxExpr))
l := n.Left
v := l.Val()
bound := v.U.(*Mpint).Int64()
// t.Extra = &Array{Elem: r.Type, Bound: bound}
t = types.NewArray(r.Type, bound) }
n.Op = OTYPE
n.Type = t
n.Left = nil
n.Right = nil
三个不同的分支会分别处理数组和切片声明的不同形式,每一个分支都会更新 Node
结构体中存储的类型并修改抽象语法树中的内容。通过对这个片段的分析,我们发现数组的长度是类型检查期间确定的,而 […]int
这种声明形式也只是 Go 语言为我们提供的语法糖。
哈希 OTMAP
对于哈希或者映射来说,编译器会对它的键值类型分别进行检查以验证它们类型的合法性:
case OTMAP:
n.Left = typecheck(n.Left, Etype)
n.Right = typecheck(n.Right, Etype)
l := n.Left
r := n.Right
n.Op = OTYPE
n.Type = types.NewMap(l.Type, r.Type)
mapqueue = append(mapqueue, n)
n.Left = nil
n.Right = nil
与处理切片时几乎完全相同,这里会通过 NewMap
创建一个新的 TMAP
类型并将哈希的键值类型都存储到该结构体中:
func NewMap(k, v *Type) *Type {
t := New(TMAP)
mt := t.MapType()
mt.Key = k
mt.Elem = v
return t
}
代表当前哈希的节点最终也会被加入 mapqueue
队列,编译器会在后面的阶段对哈希键的类型进行再次检查,而检查键类型调用的其实就是上面提到的 checkMapKeys
函数:
func checkMapKeys() {
for _, n := range mapqueue {
k := n.Type.MapType().Key
if !k.Broke() && !IsComparable(k) {
yyerrorl(n.Pos, "invalid map key type %v", k)
}
}
mapqueue = nil
}
该函数会遍历 mapqueue
队列中等待检查的节点,判断这些类型能否作为哈希的键,如果当前类型不合法就会在类型检查的阶段直接报错中止整个检查的过程。
关键字 OMAKE
最后要介绍的其实就是 Go 语言中很常见的内置函数 make
,在类型检查阶段之前,无论是创建切片、哈希还是 Channel 用的都是 make
关键字,但是在类型检查阶段就会根据创建的类型将 make
替换成本的函数,后面生成中间代码的过程就不再会处理 OMAKE
类型的节点了,而是会根据这里生成的更加细分的操作类型进行处理:
图 2-4 类型检查阶段对 make 进行改写
这里会先对关键字 make
的第一个参数,也就是类型进行检查并类型的不同进入不同的分支,切片分支 TSLICE
、哈希分支 TMAP
和 Channel 分支 TCHAN
:
case OMAKE:
args := n.List.Slice()
n.List.Set(nil)
l := args[0]
l = typecheck(l, Etype)
t := l.Type
i := 1
switch t.Etype {
case TSLICE:
// ...
case TMAP:
// ...
case TCHAN:
// ...
}
n.Type = t
如果 make
的第一个参数是切片类型,那么就会从参数中获取切片的长度 len
和容量 cap
并对这两个参数进行校验,其中包括:
- 切片的长度参数是否被传入;
- 切片的长度必须要小于或者等于切片的容量;
case TSLICE:
if i >= len(args) {
yyerror("missing len argument to make(%v)", t)
n.Type = nil
return n
}
l = args[i]
i++
l = typecheck(l, ctxExpr)
var r *Node
if i < len(args) {
r = args[i]
i++
r = typecheck(r, ctxExpr)
}
if Isconst(l, CTINT) && r != nil && Isconst(r, CTINT) && l.Val().U.(*Mpint).Cmp(r.Val().U.(*Mpint)) > 0 {
yyerror("len larger than cap in make(%v)", t)
n.Type = nil
return n
}
n.Left = l
n.Right = r
n.Op = OMAKESLICE
除了对参数的数量和合法性进行校验,这段代码最后会将当前节点的操作 Op
改成 OMAKESLICE
,方便后面编译阶段的处理。
第二种情况就是 make
的第一个参数是 map
类型,在这种情况下,第二个可选的参数就是哈希的初始大小,在默认情况下它的大小是 0,当前分支最后也会改变当前节点的 Op
属性:
case TMAP:
if i < len(args) {
l = args[i]
i++
l = typecheck(l, ctxExpr)
l = defaultlit(l, types.Types[TINT])
if !checkmake(t, "size", l) {
n.Type = nil
return n
}
n.Left = l
} else {
n.Left = nodintconst(0)
}
n.Op = OMAKEMAP
make
内置函数能够初始化的最后一种结构就是 Channel 了,从下面的代码我们可以发现第二个参数表示的就是该 Channel 的缓冲区大小,如果不存在第二个参数,那么就会创建缓冲区大小为 0 的 Channel:
case TCHAN:
l = nil
if i < len(args) {
l = args[i]
i++
l = typecheck(l, ctxExpr)
l = defaultlit(l, types.Types[TINT])
if !checkmake(t, "buffer", l) {
n.Type = nil
return n
}
n.Left = l
} else {
n.Left = nodintconst(0)
}
n.Op = OMAKECHAN
在类型检查的过程中,无论 make
的第一个参数是什么类型,都会对当前节点的 Op
类型进行修改并且对传入参数的合法性进行一定的验证。
2.3.4 小结
类型检查是 Go 语言编译的第二个阶段,在词法和语法分析之后我们得到了每个文件对应的抽象语法树,随后的类型检查会遍历抽象语法树中的节点,对每个节点的类型进行检验,找出其中存在的语法错误,在这个过程中也可能会对抽象语法树进行改写,这不仅能够去除一些不会被执行的代码、对代码进行优化以提高执行效率,而且也会修改 make
、new
等关键字对应节点的操作类型。
make
和 new
这些内置函数其实并不直接对应某些函数的实现,它们会在编译期间被转换成真正存在的其他函数,我们在下一节中间代码生成中会介绍编译器对它们做了什么。
Strong and weak typing https://en.wikipedia.org/wiki/Strong_and_weak_typing↩︎
Weak And Strong Typing https://wiki.c2.com/?WeakAndStrongTyping↩︎
https://en.wikipedia.org/wiki/Type_system#Static_type_checking↩︎
JavaScript 静态检查工具 https://flow.org/↩︎
为什么说“动态类型一时爽,代码重构火葬场”? https://www.zhihu.com/question/30072490↩︎
https://en.wikipedia.org/wiki/Type_system#Dynamic_type_checking_and_runtime_type_information↩︎
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。