Reflect
反射 reflection 是指计算机程序在运行时可以访问、检测和修改它本身状态或者行为的一种能力,是元编程的一种形式。反射将对要调用的对象的检查工作从编译期间推迟到运行期间,可以使得程序动态地适应不同的运行情况。 Go 语言的语法元素很少、设计简单,所以它没有特别强的表达能力,但是其提供 reflect 包能够弥补它在语法上的一些劣势。本文将介绍 Go 语言中的反射机制的实现原理,并介绍其使用方法。
类型系统
Go 语言中的 reflection 基于其类型系统实现,在
reflect 实现了运行时的反射能力,能够让程序操作不同类型的对象1。反射包中有两对非常重要的函数和类型,reflect.TypeOf 能获取类型信息,reflect.ValueOf 能获取数据的运行时表示,另外两个类型是 Type 和 Value,它们与函数是一一对应的关系:
类型 Type 是反射包定义的一个接口,我们可以使用 reflect.TypeOf 函数获取任意变量的的类型,Type 接口中定义了一些有趣的方法,MethodByName 可以获取当前类型对应方法的引用、Implements 可以判断当前类型是否实现了某个接口:
|
|
反射包中 Value 的类型与 Type 不同,它被声明成了结构体。这个结构体没有对外暴露的字段,但是提供了获取或者写入数据的方法:
|
|
反射包中的所有方法基本都是围绕着 Type 和 Value 这两个类型设计的。我们通过 reflect.TypeOf、reflect.ValueOf 可以将一个普通的变量转换成『反射』包中提供的 Type 和 Value,随后就可以使用反射包中的方法对它们进行复杂的操作。
三大法则
运行时反射是程序在运行期间检查其自身结构的一种方式。反射带来的灵活性是一把双刃剑,反射作为一种元编程方式可以减少重复代码,但是过量的使用反射会使我们的程序逻辑变得难以理解并且运行缓慢。我们在这一节中会介绍 Go 语言反射的三大法则3,其中包括:
- 从
interface{}变量可以反射出反射对象; - 从反射对象可以获取
interface{}变量; - 要修改反射对象,其值必须可设置;
第一法则
反射的第一法则是我们能将 Go 语言的 interface{} 变量转换成反射对象。很多读者可能会对这以法则产生困惑 —— 为什么是从 interface{} 变量到反射对象?当我们执行 reflect.ValueOf(1) 时,虽然看起来是获取了基本类型 int 对应的反射类型,但是由于 reflect.TypeOf、reflect.ValueOf 两个方法的入参都是 interface{} 类型,所以在方法执行的过程中发生了类型转换。
在函数调用一节中曾经介绍过,Go 语言的函数调用都是值传递的,变量会在函数调用时进行类型转换。基本类型 int 会转换成 interface{} 类型,这也就是为什么第一条法则是『从接口到反射对象』。
上面提到的 reflect.TypeOf 和 reflect.ValueOf 函数就能完成这里的转换,如果我们认为 Go 语言的类型和反射类型处于两个不同的『世界』,那么这两个函数就是连接这两个世界的桥梁。
我们通过以下例子简单介绍这两个函数的作用,reflect.TypeOf 获取了变量 author 的类型,reflect.ValueOf 获取了变量的值 draven。如果我们知道了一个变量的类型和值,那么就意味着知道了这个变量的全部信息。
|
|
有了变量的类型之后,我们可以通过 Method 方法获得类型实现的方法,通过 Field 获取类型包含的全部字段。对于不同的类型,我们也可以调用不同的方法获取相关信息:
- 结构体:获取字段的数量并通过下标和字段名获取字段
StructField; - 哈希表:获取哈希表的
Key类型; - 函数或方法:获取入参和返回值的类型;
- …
总而言之,使用 reflect.TypeOf 和 reflect.ValueOf 能够获取 Go 语言中的变量对应的反射对象。一旦获取了反射对象,我们就能得到跟当前类型相关数据和操作,并可以使用这些运行时获取的结构执行方法。
第二法则
反射的第二法则是我们可以从反射对象可以获取 interface{} 变量。既然能够将接口类型的变量转换成反射对象,那么一定需要其他方法将反射对象还原成接口类型的变量,reflect 中的 reflect.Value.Interface 方法就能完成这项工作:
不过调用 reflect.Value.Interface 方法只能获得 interface{} 类型的变量,如果想要将其还原成最原始的状态还需要经过如下所示的显式类型转换:
|
|
从反射对象到接口值的过程就是从接口值到反射对象的镜面过程,两个过程都需要经历两次转换:
- 从接口值到反射对象:
- 从基本类型到接口类型的类型转换;
- 从接口类型到反射对象的转换;
- 从反射对象到接口值:
- 反射对象转换成接口类型;
- 通过显式类型转换变成原始类型;
当然不是所有的变量都需要类型转换这一过程。如果变量本身就是 interface{} 类型,那么它不需要类型转换,因为类型转换这一过程一般都是隐式的,所以我不太需要关心它,只有在我们需要将反射对象转换回基本类型时才需要显式的转换操作。
第三法则
Go 语言反射的最后一条法则是与值是否可以被更改有关,如果我们想要更新一个 reflect.Value,那么它持有的值一定是可以被更新的,假设我们有以下代码:
|
|
运行上述代码会导致程序崩溃并报出 reflect: reflect.flag.mustBeAssignable using unaddressable value 错误,仔细思考一下就能够发现出错的原因,Go 语言的函数调用都是传值的,所以我们得到的反射对象跟最开始的变量没有任何关系,所以直接对它修改会导致崩溃。
想要修改原有的变量只能通过如下的方法:
|
|
- 调用
reflect.ValueOf函数获取变量指针; - 调用
reflect.Value.Elem方法获取指针指向的变量; - 调用
reflect.Value.SetInt方法更新变量的值:
由于 Go 语言的函数调用都是值传递的,所以我们只能先获取指针对应的 reflect.Value,再通过 reflect.Value.Elem 方法迂回的方式得到可以被设置的变量,我们通过如下所示的代码理解这个过程:
|
|
如果不能直接操作 i 变量修改其持有的值,我们就只能获取 i 变量所在地址并使用 *v 修改所在地址中存储的整数。
类型和值
Go 语言的 interface{} 类型在语言内部是通过 emptyInterface 这个结体来表示的,其中的 rtype 字段用于表示变量的类型,另一个 word 字段指向内部封装的数据:
|
|
用于获取变量类型的 reflect.TypeOf 函数将传入的变量隐式转换成 emptyInterface 类型并获取其中存储的类型信息 rtype:
|
|
rtype 就是一个实现了 Type 接口的结构体,我们能在 reflect 包中找到如下所示的 reflect.rtype.String 方法帮助我们获取当前类型的名称等信息:
|
|
reflect.TypeOf 函数的实现原理其实并不复杂,它只是将一个 interface{} 变量转换成了内部的 emptyInterface 表示,然后从中获取相应的类型信息。
用于获取接口值 Value 的函数 reflect.ValueOf 实现也非常简单,在该函数中我们先调用了 reflect.escapes 函数保证当前值逃逸到堆上,然后通过 reflect.unpackEface 方法从接口中获取 Value 结构体:
|
|
reflect.unpackEface 函数会将传入的接口转换成 emptyInterface 结构体,然后将具体类型和指针包装成 Value 结构体并返回。
reflect.TypeOf 和 reflect.ValueOf 函数的实现都很简单。我们已经分析了这两个函数的实现,现在需要了解编译器在调用函数之前做了哪些工作:
|
|
从上面这段截取的汇编语言,我们发现在函数调用之前已经发生了类型转换,上述指令将 int 类型的变量转换成了占用 16 字节 autotmp_19+280(SP) ~ autotmp_19+288(SP) 的接口,两个 LEAQ 指令分别获取了类型的指针 type.int(SB) 以及变量 i 所在的地址。
当我们想要将一个变量转换成反射对象时,Go 语言会在编译期间完成类型转换的工作,将变量的类型和值转换成了 interface{} 并等待运行期间使用 reflect 包获取接口中存储的信息。
更新变量
当我们想要更新一个 reflect.Value,就需要调用 reflect.Value.Set 方法更新反射对象,该方法会调用 reflect.flag.mustBeAssignable 和 reflect.flag.mustBeExported 分别检查当前反射对象是否是可以被设置的以及字段是否是对外公开的:
|
|
reflect.Value.Set 方法会调用 reflect.Value.assignTo 并返回一个新的反射对象,这个返回的反射对象指针就会直接覆盖原始的反射变量。
|
|
reflect.Value.assignTo 会根据当前和被设置的反射对象类型创建一个新的 Value 结构体:
- 如果两个反射对象的类型是可以被直接替换,就会直接将目标反射对象返回;
- 如果当前反射对象是接口并且目标对象实现了接口,就会将目标对象简单包装成接口值;
在变量更新的过程中,reflect.Value.assignTo 返回的 reflect.Value 中的指针会覆盖当前反射对象中的指针实现变量的更新。
实现协议
reflect 包还为我们提供了 reflect.rtypes.Implements 方法可以用于判断某些类型是否遵循特定的接口。在 Go 语言中获取结构体的反射类型 reflect.Type 还是比较容易的,但是想要获得接口的类型就需要通过以下方式:
|
|
我们通过一个例子在介绍如何判断一个类型是否实现了某个接口。假设我们需要判断如下代码中的 CustomError 是否实现了 Go 语言标准库中的 error 接口:
|
|
上述代码的运行结果正如我们在接口一节中介绍的:
CustomError类型并没有实现error接口;*CustomError指针类型实现了error接口;
抛开上述的执行结果不谈,我们来分析一下 reflect.rtypes.Implements 方法的工作原理:
|
|
reflect.rtypes.Implements 方法会检查传入的类型是不是接口,如果不是接口或者是空值就会直接 panic 中止当前程序。在参数没有问题的情况下,上述方法会调用私有函数 reflect.implements 判断类型之间是否有实现关系:
|
|
如果接口中不包含任何方法,就意味着这是一个空的接口,任意类型都自动实现该接口,这时就会直接返回 true。
在其他情况下,由于方法都是按照字母序存储的,reflect.implements 会维护两个用于遍历接口和类型方法的索引 i 和 j 判断类型是否实现了接口,因为最多只会进行 n 次比较(类型的方法数量),所以整个过程的时间复杂度是 O(n)。
方法调用
作为一门静态语言,如果我们想要通过 reflect 包利用反射在运行期间执行方法不是一件容易的事情,下面的十几行代码就使用反射来执行 Add(0, 1) 函数:
|
|
- 通过
reflect.ValueOf获取函数Add对应的反射对象; - 根据反射对象
reflect.rtype.NumIn方法返回的参数个数创建argv数组; - 多次调用
reflect.ValueOf函数逐一设置argv数组中的各个参数; - 调用反射对象
Add的reflect.Value.Call方法并传入参数列表; - 获取返回值数组、验证数组的长度以及类型并打印其中的数据;
使用反射来调用方法非常复杂,原本只需要一行代码就能完成的工作,现在需要十几行代码才能完成,但这也是在静态语言中使用动态特性需要付出的成本。
|
|
reflect.Value.Call 方法是运行时调用方法的入口,它通过两个 MustBe 开头的方法确定了当前反射对象的类型是函数以及可见性,随后调用 reflect.Value.call 完成方法调用,这个私有方法的执行过程会分成以下的几个部分:
- 检查输入参数以及类型的合法性;
- 将传入的
reflect.Value参数数组设置到栈上; - 通过函数指针和输入参数调用函数;
- 从栈上获取函数的返回值;
我们将按照上面的顺序分析使用 reflect 进行函数调用的几个过程。
参数检查
参数检查是通过反射调用方法的第一步,在参数检查期间我们会从反射对象中取出当前的函数指针 unsafe.Pointer,如果该函数指针是方法,那么我们就会通过 reflect.methodReceiver 函数获取方法的接受者和函数指针。
|
|
在上述方法中,上述方法还会检查传入参数的个数以及参数的类型与函数签名中的类型是否可以匹配,任何参数的不匹配都会导致整个程序的崩溃中止。
准备参数
当我们已经对当前方法的参数完成验证之后,就会进入函数调用的下一个阶段,为函数调用准备参数,在前面的章节函数调用中我们已经介绍过 Go 语言的函数调用惯例,函数或者方法在调用时,所有的参数都会被依次放置到栈上。
|
|
-
通过
reflect.funcLayout函数计算当前函数需要的参数和返回值的栈布局,也就是每一个参数和返回值所占的空间大小; -
如果当前函数有返回值,需要为当前函数的参数和返回值分配一片内存空间
args; -
如果当前函数是方法,需要向将方法的接受者拷贝到
args内存中; -
将所有函数的参数按照顺序依次拷贝到对应
args内存中
- 使用
reflect.funcLayout返回的参数计算参数在内存中的位置;
- 使用
-
将参数拷贝到内存空间中;
准备参数的过程是计算各个参数和返回值占用的内存空间并将所有的参数都拷贝内存空间对应的位置的过程,该过程会考虑函数和方法、返回值数量以及参数类型带来的差异。
调用函数
准备好调用函数需要的全部参数之后,就会通过以下的代码执行函数指针了。我们会向该函数传入栈类型、函数指针、参数和返回值的内存空间、栈的大小以及返回值的偏移量:
|
|
上述函数实际上并不存在,它会在编译期间被链接到 runtime.reflectcall 这个用汇编实现的函数上,我们在这里不会分析该函数的具体实现,感兴趣的读者可以自行了解其实现原理。
处理返回值
当函数调用结束之后,就会开始处理函数的返回值:
- 如果函数没有任何返回值,会直接清空
args中的全部内容来释放内存空间; - 如果当前函数有返回值;
- 将
args中与输入参数有关的内存空间清空; - 创建一个
nout长度的切片用于保存由反射对象构成的返回值数组; - 从函数对象中获取返回值的类型和内存大小,将
args内存中的数据转换成reflect.Value类型并存储到切片中;
- 将
|
|
由 reflect.Value 构成的 ret 数组会被返回到上层,到这里为止使用反射实现函数调用的过程就结束了。
小结
Go 语言的 reflect 包为我们提供的多种能力,包括如何使用反射来动态修改变量、判断类型是否实现了某些接口以及动态调用方法等功能,通过对反射包中方法原理的分析能帮助我们理解之前看起来比较怪异、令人困惑的现象。
参考资料
-
No backlinks found.