Go 反射定律


1. 序言

计算机中的反射是程序检查其自身结构的能力,尤其是通过类型;它是元编程注释 1的一种形式,也是主要的难点。

在这篇文章中,我们试图通过阐明 Go 语言中反射的工作原理的方式,讲清楚反射。每门编程语言的反射模型都不同(而且很多语言不支持反射),这篇文章是关于 Go 的,因此在本文的剩余部分中,“反射”一词等价于“Go 中的反射”。


2. 类型和接口

因为反射构建在类型系统之上,让我们从复习 Go 中的类型开始。

Go 是静态类型的。每个变量都有静态的类型,也就是说,在编译时每个变量都有已知并且确定的类型:intfloat32*MyType[]byte 等。如果声明

那么 i 的类型是 intj 的类型是 MyInt。变量 ij 拥有不同的静态类型。即使它们拥有相同的底层类型,但是在不转换的情况下,不能互相赋值。

接口类型是重要的类型种类,它表示固定的方法集合。接口变量可以存储任何实现接口方法的具体(非接口)值。io 包中定义的 ReaderWriter 类型,io.Readerio.Writer 是一对很好的示例:

可以说,任何实现具有该签名的 Read(或 Write)方法的类型都实现 io.Reader(或 io.Writer)。这意味着类型 io.Reader 的变量能持有类型拥有 Read 方法的任何值:

必须讲清楚的是不管 r 持有的具体值是什么,r 的类型永远是 io.Reader:Go 是静态类型的,r 的静态类型是 io.Reader

空接口是接口类型的非常重要示例:

它表示空方法集合,并且被任何值满足,因为任何值都拥有 0 或多个方法。

有人说 Go 的接口是动态类型的,这是误导。接口是静态类型的:接口类型的变量永远拥有相同的静态类型,即便在运行时接口变量中存储的值可能改变类型,但是该值将始终满足接口。

因为反射与接口紧密相关,所以我们需要弄明白这些。(补充:反射其实就是将接口的实现暴露一部分给应用代码。


3. 接口的表示

Russ Cox 写过一篇关于 Go 中接口值的表示的详细博文。本文没必要重复所有叙述,但是简短的总结很合时宜。

接口类型的变量存储一个 (value, type) 对:被赋值给变量的具体值,以及该值的类型描述。更准确地说,value 是实现接口的底层具体数据项,type 描述该数据项的完整类型(full type)。比如:

r 包含 (value, type)(tty, *os.File)。注意类型 *os.File 也实现 Read 之外的方法;虽然接口值只提供对 Read 方法的访问,但是接口值的内部拥有关于具体值的全部类型信息。所以可以这样做:

此赋值中的表达式是类型断言(type assertion);它断言的是 r 里面的数据项也实现 io.Writer,因此可以将其赋值给 w。在赋值之后,w 将包含 (tty, *os.File) 对,它就是 r 中持有的 (value, type) 对。虽然接口变量里面的具体值可能拥有更大的方法集合,但是接口的静态类型决定可以调用接口变量的哪些方法。

继续,可以这样做:

空接口值 empty 将包含相同的 (value, type)(tty, *os.File)。空接口可以持有任何值,并且包含关于该值的全部信息。

(此处无需类型断言,因为通过静态分析可知 w 满足空接口。在将来自 Reader 的值赋给 Writer 的示例中,需要显式地使用类型断言,因为 Writer 的方法不是 Reader 的方法的子集。)

接口里面的 (value, type) 对的形式永远是 (value, 具体类型),不可能是 (value, 接口类型)。接口不能持有接口值。


4. 第一反射定律

1,反射将接口值转换为反射对象(1. Reflection goes from interface value to reflection object.)

在底层,反射只是一种检查类型和存储在接口变量中的 (value, type) 对的机制。在开始之前,需要先了解 reflect 包中的两种类型:TypeValue。这两种类型提供访问接口变量内容的途径,另外 reflect 包还提供两个简单的函数:

这两个函数分别获取接口值的 reflect.Typereflect.Value 部分。(另外,从 reflect.Value 可以很容易地获取 reflect.Type,但是现在将 ValueType 这两个概念分开。)

TypeOf 开始:

该程序打印:

接口在哪里呢?因为该程序看起来是将 float64 类型的变量 x,而不是接口值,传递给 reflect.TypeOf。但 reflect.TypeOf 的签名包含空接口:

当调用 reflect.TypeOf(x) 时,x 首先被存储到空接口中,然后作为参数被传递给 reflect.TypeOf;之后 reflect.TypeOf “拆箱”(unpack),重获类型信息。

当然,reflect.ValueOf 函数可以重获该值(从此处起,省略模版,仅聚焦于可执行代码):

打印:

(这里显式地调用 String 方法是因为在默认情况下,fmt 包将“钻进” reflect.Value,展示里面的具体值。而 String 方法则不然。)

reflect.Typereflect.Value 都有很多供我们查看和使用的方法。比如:

打印:

Value 上还有像 SetIntSetFloat 之类的方法,但是使用它们之前,需要理解可设置性(settability),这是第三反射定律的主题,将在下面讨论。

反射库有两个特性值得单独指出。首先,为保持 API 简单,Value 上的“getter”和“setter”方法操作的是能够持有该值的最大类型:比如,int64 用于所有有符号整数。也就是说,ValueInt 方法返回 Int64SetInt 接收 int64;在必要时可以转换回实际类型:

第二个特性是反射对象的 Kind 方法描述底层类型,而非静态类型。假如反射对象包含用户自定义的整型值:

虽然 x 的静态类型是 MyInt,而不是 int,但是 vKind 仍然是 reflect.Int。换句话说,使用 Kind 无法区分 intMyInt,但通过 Type 可以:


5. 第二反射定律

2,反射将反射对象转换为接口值(2. Reflection goes from reflection object to interface value.)

就像物理学上的反射,Go 中的反射也能产生自己的逆反射。

给定一个 reflect.Value,使用 Interface 方法可以重新获取接口值;事实上,该方法将类型和值信息“装箱”(pack)进接口的表示,然后返回结果:

因此可以通过如下操作:

打印反射对象 v 表示的 float64 值。

可以做得更好一些。fmt.Printlnfmt.Printf 等方法的参数是空接口值。正如在前面的示例中所做的那样,fmt 包在内部将参数“拆箱”(unpack)。因此为正确地打印 reflect.Value,只需将 Interface 方法的结果传递给格式化打印程序:

(自首次编写本文以来,已经对 fmt 包做出变更,以便其可以自动地“拆箱“ reflect.Value,因此可以使用

获取相同的结果,但为清晰起见,此处继续使用 .Interface() 调用)

因为值的类型是 float64,所以可以使用浮点格式化:

在本例中将得到:

无需将 v.Interface() 的结果类型断言(type-assert)为 float64,空接口值内部包含具体值的类型信息,Printf 将重新获取它。

简而言之,Interface 方法是 ValueOf 函数的逆过程,除其结果永远属于静态类型 interface{} 外。

重申:反射就是将接口值转换为反射对象,再反过来。


6. 第三反射定律

3,如果想要修改反射对象,那么值必须是可设置的(settable)(3. To modify a reflection object, the value must be settable.)

第三定律是最难理解的,但是如果从第一法则开始,就可以很容易地理解它。

下面是一些无法运行,但值得学习的代码。

运行这段代码将引发带有晦涩难懂的信息的 panic

问题的根源不是值 7.1 不可寻址;而是 v 不可设置。可设置性(Settability)是 Value 的属性,并且不是所有 Value 都拥有该属性。

ValueCanSet 方法用于报告 Value 的可设置性;在上述示例中,

打印:

在不可设置的 Value 上调用 Set 方法是错误的。那么什么是可设置性呢?

与可寻址(addressability)类似,可设置性是一个位(bit),但是很严格。它是一种反射对象能修改用于创建反射对象的实际存储的特性。可设置性由反射对象是否持有原始数据项决定。当我们执行

我们把 x 的副本传递给 reflect.ValueOf。所以作为 reflect.ValueOf 的参数的接口值是从 x 的副本创建的,而不是 x 本身。因此,下面的语句

如果被允许成功,即使 v 看起来像是从 x 创建的,但是它不更新 x。它将更新存储在反射值内部的 x 的副本,但是 x 本身不受影响。那将令人难以理解,并且无用,因此它是非法的,可设置性是用于避免该问题的特性。

这看起来很奇怪,但是其实不奇怪。这其实是一种披着不同寻常的外衣的常见情况。假设将 x 传递给函数:

我们不希望 f 能修改 x,因为我们传递的是 x 的副本,而非 x 本身。如果我们想让 f 直接修改 x,那么必须将 x 的地址(即指向 x 的指针)传递给函数:

这很易懂且常见,反射也是如此。如果想通过反射修改 x,那么我们必须将想要修改的值的指针传递给反射库。

接下来我们就这样做。首先我们像平时一样初始化 x,然后创建指向它的反射值,称之为 p

当前输出是

反射对象 p 是不可设置的,但是我们想设置的不是 p,而是 *p。我们调用 ValueElem 方法获取 p 指向的内容,称之为 v,然后间接地通过指针,将结果存储到 Value 中:

正如输出所示,v 是可设置的反射对象

因为它表示 x,我们最终可以使用 v.SetFloat 修改 x 的值:

果不其然,输出是

理解反射很困难,尽管它通过 TypeValue 掩饰所做的事情,但是它的功能和编程语言完全一样。请记住如果想要修改 Value 表示的数据项,需要向 Value 提供数据项的地址。


7. 结构体

在前面的示例中,v 本身不是指针,它只是派生于指针。当使用反射修改结构体的字段时,这是一种常用方式。只要我们有结构体的地址,就能修改其字段。

下面是分析结构体值 t 的简单示例。我们使用结构体的地址创建反射对象是因为想要修改它。然后将 typeOfT 设置为其类型,接着使用简单的方法调用迭代结构体的字段(详见 reflect 包)。注意我们从结构体类型提取字段的名称,但是字段本身是常规的 reflect.Value 对象。

这段程序的输出是

在这里顺便介绍另一个关于可设置性的知识点:T 的字段名的首字母是大写字母(可导出的),因为只有可导出的结构体字段是可设置的。

因为 s 包含可设置的反射对象,所以可以修改该结构体的字段。

结果如下:

如果将程序改成从 t,而不是 &t 创建 s,那么对 SetIntSetString 的调用将失败,因为 t 的字段不是可设置的。


8. 总结

反射定律如下:

只要理解这些定律,即便反射仍然很微妙,但是在 Go 中使用反射将更容易。反射是很强大的工具,但是使用时应该多加小心,并且除非十分需要,否则应该避免使用反射。

还有许多我们没有讲解的反射 - 在通道上发送和接收,申请内存,使用切片和映射,调用方法和函数 - 但本文已足够长。在以后的文章中,我们将讲解上面提到的某些主题。


注释

注释 1 - 元编程

元编程(Metaprogramming)是指编写能够操作其它程序的程序的技术。它允许程序在运行时创建、修改和操作代码结构,以及在编译时生成代码。元编程是一种高级编程技巧,它可以增强代码的灵活性和可重用性,以及实现复杂的编程任务。

元编程有多种形式,包括宏展开、反射、模板元编程和装饰器等。下面简要介绍这些形式:

  1. 宏展开:宏是一种在编译时进行代码替换的机制。宏展开允许程序员定义一些代码模板,并且在编译时将其展开为实际的代码。这样可以根据需要生成重复的代码,提高代码的可读性和维护性。

  2. 反射:反射是一种在程序运行时获取和操作类型信息的能力。通过反射,程序可以动态地查询和修改对象的属性和方法,以及在运行时创建新对象。反射使得程序能够在不事先知道类型的情况下,对其进行操作和扩展。

  3. 模板元编程:模板元编程是一种使用模板来生成代码的技术。在编译时,模板元编程通过特定的语法和规则,生成代码的模板实例。这种技术常用于实现通用算法和数据结构,以及在编译时进行优化。

  4. 装饰器:装饰器是一种在运行时动态修改函数或类行为的技术。通过装饰器,可以在不修改原始代码的情况下,添加额外的功能或修改现有功能。装饰器广泛应用于 Python 等动态类型语言中。

元编程的应用范围广泛,可以用于自动生成代码、增加代码的灵活性和可扩展性、实现领域特定语言(DSL)等。然而,元编程技术通常较为复杂,需要谨慎使用,以避免引入过多的复杂性和难以维护的代码。