这篇文章主要记录了一些我在学习 Go 语言实现原理过程中的笔记。
本人使用的 go version go1.22.4 darwin/amd64
。
Go interface
interface 是 Go 语言中很有意思的一个特性,它能让你像纯动态语言一样使用duck typing
,而且编译器还能像静态语言一样做类型检查。
如果一个东西叫起来像鸭子,走路也像鸭子,那么它就是鸭子。
也就是说,interface 允许你定义一组方法的集合,任何实现了这组方法的类型都可以被看作是这个 interface 的实现。
有了 interface,可以做很多有意思的事情。
1、可以通过 interface 来抽象,然后再通过组合与继承,让程序足够有表达力。
2、如果不用 interface,一个函数就只能接受具体的类型作为参数,而且也只能返回具体的类型作为返回值,这个函数的适用范围就比较局限。有了 interface,就能让这个函数接受和返回更抽象的类型的参数(某个 interface),相当于让这个函数的适用范围更广了。
3、interface 让程序可以很方便地实现各种编程模式,提升程序的抽象性和解耦度,让程序更容易维护和扩展。
下面我们主要关注 interface 的底层实现原理,也就是要理解这段代码的原理(直接用 AI 生成几段代码给我们):
类型断言
1 | var i interface{} = 42 |
类型转化:把一种类型转化为
1 |
|
类型 switch:尝试把某个变量转化为其他类型,是类型断言和类型转化的组合。
1 | switch v := i.(type) { |
动态派发:在运行的时候确定应该调用哪个函数。
1 | type Speaker interface { |
Go interface 的实现原理
interface 在 Go 语言的实现其实就靠一个叫做 iface
的数据结构。
1 |
|
把所有子数据结构也展开,就是如下:
1 | type iface struct { // `iface` |
用图来表示就是这样:
理解 iface
的运作原理的最好方式就是直接查看其汇编代码。
写一段简单的代码:
1 | package main |
然后直接一个命令把代码编译为 Plan 9 汇编代码:
GOOS=linux GOARCH=amd64 go build -gcflags="-S" ./main.go 2> ./main.S
先来看 JustSpeak
函数的汇编代码:
1 | main.JustSpeak STEXT size=67 args=0x10 locals=0x10 funcid=0x0 align=0x0 |
别被这一大段汇编代码吓到了,其实很好读懂。
最关键的是这一行:
1 | 0x0020 00032 (/main.go:25) CALL CX |
这就是动态调用的实现。
在调用这个函数之前,它会先构造一个 iface 来表示传入的 Speaker 类型的变量,然后它会把传入这个的参数的偏移 24 字节作为函数进行调用,实际上就是调用的 Speak() 函数。
那么 iface 是如何被构造出来的?继续看这段代码。
1 | go:itab.*main.Dog,main.Speaker SRODATA dupok size=32 |
还记得上面的 iface
数据结构吗?
1 | type iface struct { |
你不用很懂 Plan 9 Assembly 也可以看出来,这段代码的核心就是在把 iface
给构造出来并通过指针传递给下一个函数,而构造 iface
的核心步骤之一就是把 go:itab.*main.Dog,main.Speaker
复制到了 AX 上,而这是一个 rodata 只读数据,我也把它列出来了。
go:itab.*main.Dog,main.Speaker
看名字我们也知道,实际上就是 Dog 这个 struct 实现的 Speaker 的函数表(当然 itab 里也包含了变量类型和接口类型的相关信息)。
因此,我们可以大胆地做两个推测:
- 所有的 interface 实现都有一个对应的 itab,如果 3 个 struct 分别实现了 5 个 interface,那么就会有 15 个这样的 itab rodata。
- 每个变量在底层都有一个对应的 iface,iface 包含了该变量所需的类型信息和函数表信息,底层是通过把变量组装为 iface,然后以此作为“介质”进行更高级的类型推断或类型转化的实现的。类型转换无非就是从这个样的 iface 转换为另外一个 iface 而已,类型推断无非就是对 itab 做一些相应的判断。
runtime 代码分析
在那个汇编文件里,我们会看到很多 runtime 函数,这些 runtime 函数在类型推断和转化的过程中扮演了关键角色。
- runtime.typeAssert
- runtime.interfaceSwitch
我们直接来看函数的实现:
当进行 type assert 时,会先把把 abi.TypeAssert 和 _type 这俩给构造出来,然后把它们的指针放到寄存器里,然后再调用 runtime.typeAssert 函数。
1 |
|
源代码虽然有点长,但是稍微耐心点是很容看懂的。
最关键的是要理解 func getitab(inter *interfacetype, typ *_type, canfail bool) *itab
这个函数在做什么。
它尝试从 interface type 和 struct type 里构造出一个 itab,如果能构造成功,那么就意味着这个 struct 实现了该 interface。
而且你可以看到,它还用了缓存机制,第一次构造成功之后就会缓存起来,后续再进行这样的构造时就直接从缓存里拿了,空间换时间,提高性能。
最终核心的函数是func (m *itab) init() string
。它会尝试从可执行文件里寻找所有 interface/struct pair(主要是 rodata 和 text 段的数据),也就是看这个 struct 是否都实现了 interface 所规定的所有函数。
type switch 呢?一样的,最终都会落到 func getitab(inter *interfacetype, typ *_type, canfail bool) *itab
这个函数上。
自此,我们基本上理解了 interface 在 Go 中是如何实现的了,当然其中还有很多细节,但是对于我们理解整体的实现原理并无影响。
总结一下
- 查看汇编代码可以很直观地理解 go 语言运行的过程。
- 程序的底层是以 iface/eface/interfacetype/_type 等各种 abi 数据结构为“介质”来进行交互的。如果说调用一个函数的参数是某种 interface 类型,那么从汇编程序中就可以看出,程序现在堆栈上构造出 iface/eface,然后再调用函数,之后就可以实现 interface 的各种功能了。
- go 会为每一种
type x interface
记录一条itab
,这个非常重要。比如说有三种 interface,而且分别有三个具体类型都实现这三个接口,那么汇编代码里就有 9 个 itab 的 rodata。有了这些,就可以实现 interface 那些神奇的功能了。 - 很多 interface 最终都是调用了 go 写的函数而不是汇编级代码,比如说类型 switch 就对应了 runtime.interfaceSwitch,类型推断对应了 runtime.typeAssert,等等。
- 编译器和 runtime 之间需要有一个规约(abi),才能相互无缝地相互配合,让程序运行起来。
- 真正理解了 itab iface eface 等数据结构,那么 interface 的各种特性的实现则是不言自明的。
反射的实现
聊完 interface 的实现后再来聊 reflection 就顺理成章了。
具体实现是怎么样的呢?
interface 实现源码中的 eface 和 iface 会和反射实现源码中的 emptyInterface 和 nonEmptyInterface 是一样的数据结构,它们保持同步。
反射中提供了两个核心的数据结构,Type 和 Value,在此基础上实现了各种方法,这两个都叫做反射对象。
Type 和 Value 提供了非常多的方法:例如获取对象的属性列表、获取和修改某个属性的值、对象所属结构体的名字、对象的底层类型(underlying type)等等。
Go 中的反射,在使用中最核心的就两个函数:
reflect.TypeOf(x)
reflect.ValueOf(x)
这两个函数可以分别将给定的数据对象转化为以上的 Type 和 Value,这两个都叫做反射对象。
这两个函数的实现源码如下:
1 | // TypeOf returns the reflection Type that represents the dynamic type of i. |
1 | // ValueOf returns a new Value initialized to the concrete value |
实际上也是一个 pack 和 unpack 出 emptyInterface/nonEmptyInterface 的过程。
在 Go 中,反射是在类型系统的基础上包装了一套更高级的类型系统的用法。上面说那些类型推断、类型转换只不过是这套类型系统的一个应用而已,而且这个应用直接集成到了代码语法上。
反射无非就是把 itab 这样的静态数据的构造从编译阶段放到了运行阶段。或者从另外一个角度来说,就是把静态类型检查从编译阶段放到了运行阶段。
Go 中反射机制的本质是,Go 会把函数和类型的元数据(尤其是 itab,比如:go.itab.*“”.MyStruct,””.MyInterface SRODATA dupok size=48)存储在 rodata 里,在运行时,通过读取这些元数据,来动态构造出 iface,然后在此基础上进行一些数据修改或函数的调用。
反射三定律
根据 Go 官方关于反射的博客,反射有三大定律:
- Reflection goes from interface value to reflection object.
- Reflection goes from reflection object to interface value.
- To modify a reflection object, the value must be settable.
关于第三条,记住一句话:如果想要操作原变量,反射变量 Value 必须要 hold 住原变量的地址才行。
反射的优劣
使用反射的优势:
- 程序抽象性提升
- 程序表达力提升
适用反射的坏处:
- 程序维护性降低
- 程序性能降低
- 程序安全性降低