反射是 Go 语言中较为难以理解的一个特性,网上讲解反射的文章有很多,但是要么讲解细致但是篇幅过长,要么讲解稍微模糊。但其实如果掌握了反射所使用到了一些技术,反射本身是很好理解的,其原理就是一个向上和向下转换的过程。本文会从反射所基于的语言特性出发,简要地分析反射这一特性是如何实现的。
unsafe.Pointer
在讨论unsafe.Pointer
之前,我们先来讨论一下 C 语言中的原始指针类型以及指针之间的相互转换。在 C 语言中,所有指针都是可以相互转换的,而对指针取值其实就相当于取当前指针所声明类型长度的一段内存。我们可以利用 C 语言指针的这种特性在不同长度的结构体实现变换,最为经典的例子是 linux 链表。Go 中同样也有指针,但由于 Go 是一种 GC 语言,如果保留 C 指针的这样灵活性,将会对 GC 扫描带来很大的挑战,如果一个指针向上转换了,那么很有可能会造成内存泄露的问题。
为了实现 C 语言中这种灵活转换的特性,Go 中引入了unsafe.Pointer
这一类型。unsafe.Pointer
是 go 中用于实现类型转换的一种中间类,我们可以将一个长度为 n 的unsafe.Pointer
视作是一个长度为 n 的数组(即内存中的某一段数据),但是这一段数组中的数据对用户来说是不可见的。在某种意义上来说,unsafe.Pointer
是用来告诉 GC 扫描器这段内存已经被分配,如果需要进行垃圾清理,必须释放这段内存,防止内存泄露。在 C++ 中,这一特性是通过将析构函数作为虚函数来实现的。
为了更直观地表示这一功能,以一个代码实例来演示:
1 | type A struct { |
这段代码是可以编译通过并运行的,通过调试器来观察 a 中的值。
可以看到,b 中 header 字段的值被拷贝给了 a,而 hidden 字段则成为了一个 8 字节长度(int 长度)的unsafe.Pointer
用于占位,当 a 声明周期结束时,将会释放 header + hidden 长度的内存。要实现这一功能,必须要保证两个结构体的几个起始字段类型和顺序是相同的,并且“基类”需要以unsafe.Pointer
字段结尾。
在 go 语言中,为了实现反射大量使用了这一特性,如果对这一特性不了解,建议先学习一下 linux 中的链表实现,这将有助于理解 go 的反射原理。
eface 和 iface
eface
和iface
是 go 中非空接口与空接口的底层实现,其原始定义代码出现在 runtime2.go:202:
1 | type iface struct { |
我们将代码展开,来比较两个结构体之间的差异:
1 | type iface struct { |
注意这两个类型展开后的区别,iface
在起始位置比eface
多了一个 inter 字段,该字段用于存储接口信息,这是为了比较不同的非空接口是否相等;而iface
和eface
的最后一个字段都是unsafe.Pointer
类型。暂时不考虑其中存储的数据类型,将iface.hash
字段至iface.data
字段也都视为字节(这些字段是为了实现与 XXtype 之间的相互转换),那么可以发现,iface
实际上可以表示为如下的形式:
1 | type iface struct { |
实际上,去掉inter
字段后,iface
就成为了eface
,因此只需要在该字段上进行相关操作,就能够实现两者之间的相互转换。用一张图来表示两者之间的关系:
XXtype 类型
在 runtime/type.go 文件中,有如下结构体的定义:interfacetype
, methodtype
, maptype
, arraytype
, chantype
, slicetype
, functype
, ptrtype
, structtype
。这些结构体表示了在接口中,go 语言中各种类型的存储形式。值得注意的是,这些结构体全部以_type
类型的字段作为起始,这一类型同样被接口用于记录类型。以 structtype
为例:
1 | type structtype struct { |
以同一字段作为起始位置,这种做法在 C 中比较常见,其实就是通过统一字段加类型转换的方式实现了零成本的多态,例如 linux 链表,Lua LValue 的实现,都是使用了这种思想。go 中的指针虽然带有类型检查,不能强制转换,但是同样可以使用unsafe.Pointer
来实现类似的“向上转换”。事实上,如果将structtype
中 Value 信息部分视作一个unsafe.Pointer
,那么就可以将其视作是一个eface
类型。在 go 中,空接口就是使用这种方式来存储类型信息的,因为空接口没有任何方法,只需要进行类型转换和比较,因此只需要保留 Type 信息字段即可。另外注意到在 typ 字段后的字节(不同 struct 中字段名不同)都是一个指针类型,长度是四个字节,这个长度与iface
中的 hash 字段是相互对应的。
用一张图像来表示更为直观,iface
结构体的第二和第三个字段与 XXtype 中的第一个和第二个字段是对齐的,两个结构体的长度也是相同的;这也意味着 XXtype 与eface
结构体的第一个字段是对齐的,二者的结构体也是相同的。三者之间可以通过一定的方式相互转换,这是接口赋值与反射的基础原理。
struct 与 interface 的转换
读到这里,应该可以隐约明白 Relect 是如何实现的了,但是其中还有比较关键的一步,那就是structtype
类型究竟是如何生成并且被存储到一个空接口或非空接口中的。许多博客文章起始都没有提到这一点,或者是对这一点介绍地较为简略。
在零成本抽样的 C++ 中以虚函数表的形式来实现了多态,然而这一功能并非 Zero Cost;同样地,在 go 中一个 struct 被赋值给一个它所实现的 interface,这个过程并不是零成本的。go 编译器隐藏了一些必要的工作:在编译时,编译器会增加一些代码来完成这些额外的工作。
在如下的代码片段中:
1 | // B 是一个接口 |
当 struct A 被赋值给 interface B 时,经过 go 编译器编译后的代码,实际上会实现如下的逻辑:
- 若生成一个非空接口,将接口所需的
interfacetype
类型信息拷贝到栈上; - 在符号表中寻找 A 类型及其实现的方法,以及 A 实例 data,将这些拷贝到栈上;
- 调用
convT2E64(t *_type, elem unsafe.Pointer) (e eface)
生成空接口,或调用convT2I(tab *itab, elem unsafe.Pointer) (i iface)
生成非空接口。
这个过程实际上是在搜寻所需要的类型信息,并将类型信息与类型实例中各字段的值打包在一起,生成一个 XXtype 类型实例,最终使用“向上转换”生成接口实例。这个过程中发生了值的拷贝,因为利用unsafe.Pointer
实现“向上转换”的前提是数据是连续的。无法确定 A 实例中值在内存中的地址前有足够的空间来分配生成接口所需的字段,因为需要将 A 实例的值拷贝到内存中的其他区域。可以看到,struct 到 interface 的转换其实是一个代价较大的操作。
Reflect 的实现
既然已经知道,struct 赋值给 interface 之后会发生什么,那么理解 Reflect 的实现就是一个非常简单的事情。Reflect 主要实现了取值和取类型这两类的操作。
取类型
首先分析一下取类型的源码:
1 | func TypeOf(i any) Type { |
这段源码虽然很短,但是理解起来还是稍微困难的。感觉这里理解困难的点主要在于 go 是自举,这里用了一些 go 语言编译中的一些特性,如果用 C 的眼光去看这段代码,其实会更好理解。这段代码其实主要做了以下工作:
- 在传参阶段,将 struct 转换为 any 类型,这一步发生了上一节中所讲内容;
- 对 any 类型 i 进行原地转换,这一步是为了取接口中存储的原始值;
- 将原始值中的 type 字段传递给一个接口 Type,实现封装。
可以注意到,这个过程中发生了两次接口的赋值,由于接口赋值需要拷贝数据,因此在反射中只取类型也是一个代价高昂的操作。最好不要重复获取一个对象的 Type。
取值
对一个对象进行反射取值操作,最终会得到一个Value
结构体,结构体的实现如下:
1 | type Value struct { |
可以看到Value
其实就是一个eface
加上一个flag
标识位,该标识位用于表示可以被采取什么样的操作。事实上,它就是由eface
拼接得到的:
1 | func ValueOf(i any) Value { |
这个过程中仍然是发生了两次拷贝,一次是 struct 传入 any 接口,一次是创建 Value 结构体。注意到,Value 这里使用的是复制后的数据,因此如果想使用反射来修改原数据,一定要传入指针。
取字段
反射中的取字段是将Value
类型向下转换来实现的。在Value.ptr
字段中存储了 XXtype 除类型头外的所有信息,在获取 Value 的基础上再使用“向下转换”可以获取类型具体,我们以获取 struct 中字段个数的方法为例:
1 | func (v Value) NumField() int { |
代码中比较关键的过程就是使用unsafe.Pointer
来向下转换,这里向下转换能够成为是因为Value
,eface
,XXtype
的同源性,这三者本身就是相同的。
在反射中需要如果使用字段的名称来获取字段,需要经过如下代码,该代码最终是:
1 | func (v Value) FieldByName(name string) Value { |
可以看到,FieldByName
函数其实是使用遍历加比较字符串的方式来确认字段是否匹配的,当结构体的字段数量较大,并且字段名较长时,性能就会比较差。
总结
Reflect 的实现其实并不是很难,阻碍理解的地方主要在于 struct 与 interface 的转换、unsafe.Pointer 的使用这两点。由于 interface 和 Reflect 中需要全量拷贝值,因此使用引用类型是一个非常明智的抉择。但与之相对的,大量使用引用类型会导致 GC 问题。可以考虑将类型取指针后再赋值给接口。