QCon北京|3天沉浸式学习,跳出信息茧房。 了解详情
写点什么

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:001136

评论

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

Linux学习方法,《Linux一学就会》教你如何学习Linux

侠盗安全

Linux 运维 linux运维 云计算架构师 linux电子书

大数据开发之如何用Scala进行spark开发

@零度

scala 大数据 spark

HBase 调优详细剖析

五分钟学大数据

11月日更

拿捏这10点,玩转云原生应用

BeeWorks

自动驾驶汽车的安全架构体系 易筋 ARTS 打卡 Week 77

John(易筋)

ARTS 打卡计划

大会回顾丨游戏用户体验优化如何实践,看大咖怎么说(附PPT下载)

WeTest

淘宝客户端安全生产体系建设

阿里巴巴终端技术

ios android 淘宝 客户端 安全生产

爱奇艺智能内容中台|无人值守的应用与实践

爱奇艺技术产品团队

从落地效果看,转转选择TDengine的三个理由

TDengine

tdengine 后端 时序数据库

Hadoop企业级生产调优手册(一)

大数据技术指南

11月日更

前端开发环境搭建在内网是如何搭建的

@零度

大前端

50强诞生!2021 OceanBase 数据库大赛百所高校争霸!

OceanBase 数据库

数据库 开源 开发者 比赛 oceanbase

《新程序员》走进微软亚洲研究院

刘旭东

微软 hololens

3分钟教你如何在github上精确的找开源项目?

热爱java的分享家

Java 架构 程序人生 编程语言 经验分享

鸿蒙轻内核源码分析:虚实映射

华为云开发者联盟

鸿蒙 虚拟内存 物理内存 页表 虚实映射

Remix.run 新手教程

程序员铮铮

JavaScript 大前端 React SSR 教程分享

智能楼宇管理系统开发,智慧楼宇管控系统开发

电微13828808271

这才是Springboot事务创建流程的正确打开方式(附源码分析!)

热爱java的分享家

Java 架构 程序人生 编程语言 经验分享

【Pandas学习笔记02】处理数据实用操作

恒生LIGHT云社区

Python 数据分析 pandas

北京朝阳区有正规等保测评公司吗?联系电话多少?

行云管家

网络安全 等保测评 朝阳区

面试官问:mysql中时间日期类型和字符串类型的选择

华为云开发者联盟

MySQL timestamp 时间日期 字符串类型

Linux一学就会之Linux详细基本命令操作

学神来啦

bash Linux centos 运维 Shell

Java 项目中使用 Resilience4j 框架实现故障隔离

码语者

Java Resilience4j Bulkhead 故障隔离

记一次拿到后台权限的过程

网络安全学海

网络安全 信息安全 渗透测试 WEB安全 安全漏洞

鸿蒙智联生态服务平台——智能硬件伙伴的最佳拍档|HDC2021技术分论坛

HarmonyOS开发者

HarmonyOS

质量基础设施“一站式”服务信息平台建设,NQI一站式线上搭建

电微13828808271

阿里一面灵魂一问:RPC或者HTTP什么时候需要序列化和反序列化?

热爱java的分享家

Java 架构 程序人生 编程语言 经验分享

35岁程序员创业,为何选择云原生赛道

行云创新

云计算 创业 程序员 云原生 CEO

盘点分布式软总线数据传输技术中的黑科技|HDC2021技术分论坛

HarmonyOS开发者

HarmonyOS

连续 14 年!IBM 荣获 2021 年 Gartner 主存储魔力象限领导者

BeeWorks

安全架构|云安全框架及虚拟化技术

明亮安全观

云计算 网络安全 云安全 安全架构

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