写点什么

Golang 如何进行类型检查

  • 2019-12-04
  • 本文字数:6205 字

    阅读完需:约 20 分钟

Golang 如何进行类型检查

1.3 类型检查

我们在上一节中介绍了 Golang 的第一个编译阶段 — 通过 词法和语法分析器 的解析得到了抽象语法树,在这里就会继续介绍编译器执行的下一个过程 — 类型检查。


提到类型检查和编程语言的类型系统,很多人都会想到几个非常模糊并且难以区分和理解的术语:强类型、弱类型、静态类型和动态类型。这几个术语有的可能在并没有被广泛认同的明确定义,但是我们既然即将谈到 Go 语言编译器的类型检查过程,就不得不讨论一下这些『类型』的含义与异同。

__1. 强弱类型

强类型和弱类型经常会被放在一起进行讨论,然而这两者并没有一个学术上的严格定义,作者以前也尝试对强弱类型这两个概念进行理解,但是查阅了非常多的资料之后发现理解不同编程语言的类型系统反而更加困难。



对于强弱类型来说,我们很多时候也只能根据现象和特性从直觉上进行判断,强类型的编程语言在编译期间会有着更严格的类型限制,也就是编译器会在编译期间发现变量赋值、返回值和函数调用时的类型错误,而弱类型的语言在出现类型错误时可能会在运行时进行隐式的类型转换。


一个接受广泛一些的说法是,强类型在遇到类型不匹配时需要显式类型转换,而弱类型在遇到相同情况时可能会选择偏向于进行隐式类型转换,由于学术界没有明确的定义,这种说法不一定完全正确,放在这里也仅作为参考提供给各位读者。


假如我们从上面的定义出发,我们就可以认为 Java、C# 等大多数需要编译的编程语言往往都是强类型的,同样地按照这个标准,Go 语言因为会在编译期间发现类型错误,所以也应该是强类型的编程语言。


理解强弱类型这两个具有非常明确歧义并且定义不严格的概念是没有太多实际价值的,作为一种抽象的定义,我们使用它更多的时候是为了方便沟通和分类,这对于我们真正使用和理解编程语言可能没有什么帮助,相比没有明确定义的强弱类型,更应该被关注的应该是下面的这些问题:


  1. 类型的转换是显式的还是隐式的?

  2. 编译器会帮助我们推断变量的类型么?


这些具体的问题在这种语境下其实更有价值,也希望各位读者能够减少和避免对强弱类型的争执。

__2. 静态与动态类型

静态类型和动态类型的编程语言其实也是两个不精确的表述,它们其实是应该被称为使用_静态类型检查_和_动态类型检查_的编程语言,这一小节会分别介绍两种类型检查的特点以及它们的区别。

__2.1. 静态类型检查

静态类型检查 是基于对源代码的分析来确定运行程序类型安全的过程,如果我们的代码能够通过静态类型的检查,那么当前程序在一定程度上就满足了类型安全的要求,它可以被看作是一种代码优化的方式,能够减少程序在运行时的类型检查。


作为一个开发者来说,静态类型检查能够帮助我们在编译期间发现程序中出现的类型错误,一些动态类型的编程语言都会有社区提供的工具为这些编程语言加入静态类型检查,例如 Javascript 的 Flow,这些工具能够在编译期间发现代码中的类型错误。


相信很多读者也都听过『动态类型一时爽,代码重构火葬场』,同时使用过动态类型和静态类型编程语言的开发者一定对这句话深有体会,静态类型为代码在编译期间提供了一种约束,如果代码没有满足这种约束就没有办法通过编译器的检查,在重构时这种特性能够帮助我们节省大量的时间并且避免一些遗漏的错误,但是如果使用动态语言,就需要额外写大量的测试用例保证重构不会出现类型错误了。

__2.2. 动态类型检查

动态类型检查 就是在运行时确定程序类型安全的过程,这个过程需要编程语言在编译时为所有的对象加入类型标签和信息,运行时就可以使用这些存储的类型信息来实现动态派发、向下转型、反射以及相似的特性。


这种类型检查的方式能够为工程师提供更多的操作空间,让我们能在运行时获取一些类型相关的上下文并根据对象的类型完成一些动态操作。


只使用动态类型检查的编程语言就叫做动态类型编程语言,常见的动态类型编程语言就包括 Javascript、Ruby 和 PHP,这些编程语言在使用上非常灵活也不需要经过编译器的编译。

__2.3. 小结

静态类型检查和动态类型检查其实并不是两种完全冲突和对立的特点,很多编程语言都会同时允许静态和动态类型,Java 就同时使用了这两种检查的方法,不仅在编译期间对类型提前检查发现类型错误,还为对象添加了类型信息,这样能够在运行时使用反射根据对象的类型动态地执行方法减少了冗余代码。

__3. Go 语言的类型检查

Go 语言的编译器使用静态类型检查来保证程序运行的类型安全,当然它也会在编程期引入类型信息,让工程师能够使用反射来判断参数和变量的类型。在这一节中我们还是会重点介绍编译期间的静态类型检查,回到 Go 语言编译过程概述 一节,我们曾经介绍过 Go 语言编译器主程序中的代码,其中有一段是这样的:


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()
复制代码


这段代码可以分成两个部分,首先通过 typecheck 检查常量、类型、函数声明以及变量赋值语句的类型,然后使用 checkMapKeys 检查哈希中键的类型,我们会分几个部分对上述代码的实现原理进行分析。

__3.1. 执行流程

编译器类型检查的主要逻辑都在 typechecktypecheck1 这两个函数中,其中 typecheck 中逻辑不是特别多,它的主要作用就是判断编译器是否对当前节点执行过类型检查,同时做一些类型检查之前的准备工作:


func typecheck(n *Node, top int) (res *Node) {    if n == nil {        return nil    }
for n.Op == OPAREN { n = n.Left }
n = resolve(n) n = typecheck1(n, top) return n}
复制代码


避免多次类型检查的代码从当前方法中已经被省略掉了,我们可以直接来看核心的类型检查逻辑 typecheck1 函数,这个函数全部的实现总共有将近 2000 行,大部分的代码都是由一个巨型 switch/case 构成的:


func typecheck1(n *Node, top int) (res *Node) {    switch n.Op {    case OLITERAL, ONAME, ONONAME, OTYPE:        if n.Sym == nil {            break        }
typecheckdef(n) if n.Op == ONONAME { n.Type = nil return n } }
switch n.Op { default: Dump("typecheck", n) Fatalf("typecheck %v", n.Op)
case OTARRAY: // ...
case OTMAP: // ...
case OTCHAN: // ... }
// ...
evconst(n)
return n}
复制代码


这个 switch 语句根据传入节点操作的不同,进入不同的 case 执行其中逻辑,所有的操作类型都定义在 syntax.go 这个文件中,由于节点的操作种类确实非常多,所以我们简单节选几个比较重要和有趣的 case 深入分析一下。

__切片 OTARRAY

如果当前节点的操作类型是 OTARRAY,那么这个分支首先会对右节点进行类型检查,也就是切片中元素的类型:


case OTARRAY:        r := typecheck(n.Right, Etype)        if r.Type == nil {            n.Type = nil            return n        }
复制代码


然后该分支会根据当前节点左节点的不同,分三种不同的情况对当前 Node 的类型进行更新,相信对 Go 语言比较熟悉的读者应该已经猜到了是哪三种不同的切片声明形式:[]int[...]int[3]int,第一种相对来说比较简单,这里会直接调用 NewSlice 函数返回一个切片类型:


if n.Left == nil {            // t.Extra = Slice{r.Type}            t = types.NewSlice(r.Type)
复制代码


NewSlice 函数直接返回了一个 TSLICE 类型的结构,它的 Extra 字段保存着结构体 Slice{r.Type},切片中元素的类型信息 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 中的类型,修改了抽象语法树中的内容。由此看来,无论是生成切片还是数组,都在类型检查期间确定了。

__哈希 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,在类型检查开始之前编译器其实没有区分不同的类型创建方法的不同,make 函数的第一个参数是一个类型,所以这里会先对该类型进行检查,类型检查之后根据类型进入不同的分支:


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 并对这两个参数进行校验,切片的长度必须要小于或者等于切片的容量,在这段代码的最后会将当前节点的操作改成 OMAKESLICE,后面 生成中间代码 的过程就不再会处理 OMAKE 类型的节点了,而是会根据这里更加细分的操作类型进行判断:


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 !checkmake(t, "len", l) || r != nil && !checkmake(t, "cap", r) { n.Type = nil return n } 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
复制代码


第二种情况就是 make 的第一个参数是 map 类型,在这种情况下,第二个可选的参数就是 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 的缓冲区大小:


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 类型进行修改并且对传入参数的合法性进行一定的验证。

__4. 总结

类型检查是 Go 语言编译的第二个阶段,在词法和语法分析之后我们得到了每个文件对应的抽象语法树,随后的类型检查会遍历抽象语法树中的节点,对每个节点的类型进行检验,找出其中存在的语法错误,在这个过程中也可能会对抽象语法树进行改写,这不仅能够去除一些不会被执行的代码对编译进行优化提高执行效率,而且也会修改 makenew 等关键字对应节点的操作类型。


makenew 这些内置函数其实并不存在对应的函数实现,它们会在编译期间被转换成真正存在的其他函数,我们在下一节 中间代码生成 中会介绍编译器对它们做了什么。

__5. Reference


**本文转载自 Draveness 技术博客。


原文链接:https://draveness.me/golang/compile/golang-typecheck.html


2019-12-04 08:001060

评论

发布
暂无评论
发现更多内容

牛啊牛啊,这篇Spring Cloud Alibaba笔记一应俱全,几乎涵盖了所有操作

小二,上酒上酒

spring spring cloud alibaba

开源机器学习数据库 OpenMLDB:线上线下一致的生产级特征平台

第四范式开发者社区

人工智能 机器学习 数据库 特征 开源、

活动预告 | AI for Good 2022 峰会

第四范式开发者社区

机器学习 数据库 开源 时序数据库 特征

记录第三天-Vue组件

默默的成长

Vue 前端 10月月更

Vue-监听使用方法和过滤器

默默的成长

Vue 前端 10月月更

Baklib知识分享|制作网站FAQ需要注意哪些内容?

Baklib

耗时182天肝出来1015页分布式全栈手册太香了

程序知音

Java 程序员 架构 分布式 后端技术

六个方法教你如何搭建产品的在线帮助中心

Baklib

产品 帮助中心

Baklib知识分享|企业办公必备利器,在线协作文

Baklib

在线协作文档

【Go】gitlab 内部 Go 组件 Module 私有化

非晓为骁

go语言 包管理 go module go mod

【一Go到底】第十二天---switch

指剑

Go Goalng 10月月更

惊为天人,百度推出的Redis笔记真的太香了

小二,上酒上酒

redis 面试

这份神仙级Spring Security源码手册,真的很强悍

小二,上酒上酒

spring spring security springboot

连接团队知识孤岛,优化团队工作流程

Baklib

知识管理 知识库

五面腾讯,六h灵魂拷问,终拿下 58W offer

程序知音

Java 腾讯 java面试 后端技术 Java面试八股文

多图详解kafka生产者消息发送过程

石臻臻的杂货铺

Kafk Kafka实战 10月月更

全网疯传,SpringBoot笔记(全彩版)顶翻天

程序知音

Java spring 源码 springboot 后端技术

活动预告 | Feature Store Summit 2022

第四范式开发者社区

机器学习 数据库 开源 时序数据库 特征

【转载】Byzer + OpenMLDB 实现端到端的,基于实时特征计算的机器学习流程

第四范式开发者社区

人工智能 机器学习 数据库 开源 特征

浅谈云安全和传统安全

HummerCloud

云计算 云原生 云安全

企业级低代码中“自动化工作流”的5大优势!

优秀

自动化 工作流 企业级低代码

Vector、ArrayList、LinkedList的区别

zarmnosaj

10月月更

澳鹏Appen田小鹏博士:以高质量数据赋能AI驱动的元宇宙时代

澳鹏Appen

人工智能 AR/VR 训练数据 元宇宙 数据训练

成功实践丨基于昇腾,安擎助力天津银行开启加速度

科技热闻

阿里内部整理的Spring boot保姆级笔记,简直太牛了

小二,上酒上酒

spring springboot

来了来了,阿里p9整理的Netty速成笔记,应有尽有

小二,上酒上酒

Netty

阿里出品的这份Java性能调优实战手册,直接涨薪25K,真的香啊

小二,上酒上酒

Java 阿里 阿里面试

P9力鉴!Alibaba9月最新出品776页JDK源码+并发核心原理解析小册

程序知音

Java 架构 jdk 并发编程 后端技术

【融云出海白皮书免费看】出海洞察之美国的「高线市场模型」

融云 RongCloud

互联网 数据 模型

Github星标57.9K!阿里巴巴Java面试突击汇总(全彩版)首次公开

程序员小毕

程序员 程序人生 JVM 多线程 Java 面试

重磅 | 九科加入麒麟生态图谱,携手共建自主可控信创生态

九科Ninetech

Golang 如何进行类型检查_文化 & 方法_Draveness_InfoQ精选文章