【AICon】探索八个行业创新案例,教你在教育、金融、医疗、法律等领域实践大模型技术! >>> 了解详情
写点什么

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

评论

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

MT7915 with IPQ4019/IPQ4029 5G Radio Achieve 843 Mbps Throughput|DBDC Network Card

wallyslilly

IPQ4019 ipq4029 MT7915

GeaFlow任务能力增强:通过API定制流图计算逻辑

TuGraphAnalytics

分布式计算 java编程 API 图计算 tugraph

读《芯片浪潮》,学习台积电张忠谋的管理之道

博文视点Broadview

为什么要做Redis分区?

百度搜索:蓝易云

redis 云计算 Linux 运维 云服务器

分布式事务的华丽进化 | 京东物流技术团队

京东科技开发者

分布式事务 数据库事务 企业号 8 月 PK 榜 柔性分布式事务

【第1讲】Python3.11的下载、安装和卸载,有手就能学会

程序员晚枫

Python 安装 入门

可行性研究报告编制方案

金陵老街

“数智未来 因你而来”,鲲鹏&昇腾创新大赛2023湖北赛区决赛正式启动

彭飞

CCF C³ 走进百度:大模型与可持续生态发展

飞桨PaddlePaddle

人工智能 百度飞桨 文心大模型 重磅活动

MySQL 执行计划详解 | 京东物流技术团队

京东科技开发者

MySQL 数据库 explain关键字 企业号 8 月 PK 榜

SAM适配下游任务的探究:SAM Adapter

华为云开发者联盟

人工智能 华为云 华为云开发者联盟 企业号 8 月 PK 榜

C++11时间日期库chrono的使用

智趣匠

CPU 缓存行

红袖添香

Java 多线程 MESI 缓存行 伪共享

低代码平台轻松玩转业务规则

鲸品堂

低代码 低代码开发 企业号 8 月 PK 榜

18. Python中的模块与包

茶桁

Python

Maxon IP68 wifi6 4x4 Industrial Access Point

wifi6module

聊聊JDK1.0到JDK20的那些事儿 | 京东云技术团队

京东科技开发者

Java jdk jdk8 jdk17 企业号 8 月 PK 榜

浅谈统一权限管理服务的设计与开发

百度Geek说

百度 数据中心 企业号 8 月 PK 榜 权限服务

Go 常量

小万哥

Go 程序员 云原生 后端 开发

使用Logstash同步Mysql到Easysearch

极限实验室

MySQL Logstash ELK easysearch

华为云API Explorer:自动化运维的得力助手

华为云开发者联盟

云计算 后端 华为云 华为云开发者联盟 企业号 8 月 PK 榜

分布式那些事儿-浅谈高并发分布式架构演进路径

HelloGeek

分布式 微服务

SpringBoot3集成Redis

Java redis 架构 springboot SpringBoot3

Ubuntu 20.04 安装VNC Server的教程。

百度搜索:蓝易云

云计算 Linux ubuntu 运维 vnc

如何精准预测天气?火山引擎ByteHouse与大地量子这么做

字节跳动数据平台

数据库 大数据 云原生 数仓 企业号 8 月 PK 榜

火山引擎DataLeap的Data Catalog系统公有云实践

字节跳动数据平台

大数据 数据中台 企业号 8 月 PK 榜

质效提升 | QA不做业务需求测试,你怎么看?

laofo

DevOps cicd 研发效能 持续交付 质量赋能

ARTS 打卡第 5 天

自由

ARTS 打卡计划

深度开发者故事 | 华为云API Explorer 自动化运维的得力助手

华为云PaaS服务小智

华为 API 华为云

实战:工作中对并发问题的处理 | 京东物流技术团队

京东科技开发者

数据库 并发 数据库锁 更新丢失 企业号 8 月 PK 榜

华为云GaussDB(for Influx)单机版上线,企业降本增效利器来了

华为云开发者联盟

数据库 后端 华为云 华为云开发者联盟 企业号 8 月 PK 榜

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