从本讲开始,我将用 3 次课程的篇幅为大家带来 Go 语言的反射专题。

我们都知道,Go 语言是基于 C 语言,同时又吸收了其它编程语言(如 Java)的优势设计而成的。传统的 C 甚至 C++ 是没有反射的概念的。反射最早是随 Java 出现后才开始为广大开发者所熟知的。

在程序编译过程中,代码中的变量会被转换为内存地址,变量名称不会被编译器写入可执行的部分。所以在程序运行期间,通常是无法获取变量名、变量类型以及结构体内部构造等等信息的。使用反射则可以让编译器在编译代码时将某些需要访问自身信息的变量写入可执行文件中,并对外开放访问它们的途径。简单地说,借助反射,可以使程序在运行期间可以对其本身进行访问和修改

作为反射专题的第一讲,我们先从简单的示例代码中体验反射的使用,然后再从反射的原理角度介绍反射的三大定律。具体内容如下:

  1. 反射的基本使用
    • 通过反射获取变量的类型与值;
    • 通过反射修改变量的值。
  2. 反射的三大定律
    • 反射可以将“接口类型变量”转换为“反射类型对象”;
    • 反射可以将“反射类型对象”转换为“接口类型变量”;
    • 如果要修改“反射类型对象”,其值必须是“可写的”(settable)。

# 反射的基本使用

使用反射不仅可以获取变量的类型和值,获取值的属性,还可以修改变量的值。

Go 语言中的反射是通过内置的 reflect 包来实现的

# 通过反射获取变量的类型与值

在前面课程的示例中,我们其实已经或多或少地使用过反射了。比如下面这段代码:

go
复制代码func main() {
   var testNum int = 10
   fmt.Println(reflect.TypeOf(testNum))
   fmt.Println(reflect.ValueOf(testNum))
}

在这段代码中,通过调用 reflect.TypeOf() 和 reflect.ValueOf() 函数,并向其中传入 testNum 变量,可以轻松地获取 testNum 的类型和值。本例中,testNum 是 int 类型,值为 10。运行这段代码,可以观察到控制台输出即为:

int

10

好了,就是这么简单,你学会了吗?

类型(Type)与种类(Kind)

接下来我们对上述代码稍加修改如下:

go
复制代码type newInt int
func main() {
   var testNum newInt = 10
   typeOfTestNum := reflect.TypeOf(testNum)
   fmt.Println(typeOfTestNum)
   fmt.Println(typeOfTestNum.Name())
   fmt.Println(typeOfTestNum.Kind())
   fmt.Println(reflect.ValueOf(testNum))
}

可以看到,在这段代码中,一上来基于内置的 int 类型自定义了名为 newInt 类型。testNum 变量的类型改为了 newInt。然后将 reflect.TypeOf() 函数的结果赋值给了 typeOfTestNum 变量,并调用了 Name() 和 Kind() 方法,并输出了运行结果。如此一来,控制台中便会输出:

main.newInt

newInt

int

10

事实上,在调用 reflect.TypeOf() 函数后,将返回 Type 类型的结果,其中包含了类型(Type)和种类(Kind)信息。在编写代码时,我们通常会将注意力放在类型上,即上例中的自定义类型 newInt。当需要对类型进行归类区分时,就要用到种类(Kind)了。在 reflect 包中列举了所有的种类,这里摘抄如下:

go
复制代码const (
   Invalid Kind = iota
   Bool
   Int
   Int8
   Int16
   Int32
   Int64
   Uint
   Uint8
   Uint16
   Uint32
   Uint64
   Uintptr
   Float32
   Float64
   Complex64
   Complex128
   Array
   Chan
   Func
   Interface
   Map
   Ptr
   Slice
   String
   Struct
   UnsafePointer
)

这段常量定义在 type.go 源码中。

值的使用与值的属性判定

我们都知道,使用 reflect.ValueOf() 函数可以获得变量的值。那么,下面这段代码能否直接运行呢?

go
复制代码func main() {
   var testNum int = 10
   valueOfTestNum := reflect.ValueOf(testNum)
   var anotherInt int = valueOfTestNum
   fmt.Println(anotherInt)
}

答案是:编译不通过,程序根本无法运行。发生错误的位置就在于 valueOfTestNum 变量,具体问题是:“Cannot use 'valueOfTestNum' (type Value) as the type int.”意思是:不能将 Value 类型的 valueOfTestNum 当作 int 类型使用。

实际上,在调用 reflect.ValueOf() 函数后,将返回 Value 类型的结果。如果要将其作为 int 类型使用,则需要通过类型转换来实现,具体代码示例如下:

go
复制代码func main() {
   var testNum int = 10
   valueOfTestNum := reflect.ValueOf(testNum)
   var anotherInt int = int(valueOfTestNum.Int())
   fmt.Println(anotherInt)
}

请大家重点关注本例代码的倒数第 3 行(或正数第 4 行)。valueOfTestNum.Int() 方法可将 valueOfTestNum 转为 int64 类型。接着,将 int64 型的值作为参数传入 int() 函数,可将其转为 int 型值,与 anotherInt 的类型一致,代码便可顺利执行了。

❗️ 注意:本例中,若 valueOfTestNum 无法被转为 int64 类型(如当 testNum 是 string 类型时),则会引发宕机。这类宕机在编译期间无法被察觉,因此要格外注意。如果没有 100% 的把握确信变量的类型,最好先判断变量的类型规避宕机的发生。

Value 类型自带了多个较常用的获取属性方法,我们逐个来看:

首先是非空判定,它的方法名是 IsNil(),最终将返回 bool 类型值。当反射值为空时返回 true;反之则返回 false。与此类似的还有有效性判定,方法名是 IsValid()。非法与否的判断标准是无值或值为nil。 它们的使用示例如下:

go
复制代码func main() {
   var testNum int = 10
   ptrOfTestNum := &testNum
   valueOfTestNum := reflect.ValueOf(ptrOfTestNum)
   fmt.Println(valueOfTestNum.IsNil())
   fmt.Println(valueOfTestNum.IsValid())
}

程序运行后,控制台将输出:

false

true

由于 testNum 被赋过了值,所以值不为空(即首先输出 false)且合法(即接着输出 true)。

细心的朋友可能会发现:本例的代码并未直接使用 testNum,而是使用 & 操作符取 testNum 的地址,并赋值给 ptrOfTestNum。接着使用反射获取 valueOfTestNum 的值(即 testNum 的地址值)进行非空和有效性判定的。这是因为进行非空和有效性判定以及后续的修改值等操作,都必须要求操作对象是可寻址的

为什么会有这样的要求呢?我们都知道:Go 语言在函数间传递参数时,都是值传递。若直接传递变量而非地址,那么在另一个函数中无论对其做怎样的修改都不会影响到原始变量的值(相当于修改了原始值的拷贝而非本身)。

所以我们尝试使用反射进行值属性获取或修改值前,一定要确保操作对象是可寻址的。对于修改值得操作,还要确保操作对象是可被修改的。Go 语言提供了相应的方法帮助我们进行判断,分别是 CanAddr() 和 CanSet() 。请大家看下面的示例代码:

go
复制代码func main() {
   var testNum int = 10
   ptrOfTestNum := &testNum
   valueOfPtrTestNum := reflect.ValueOf(ptrOfTestNum)
   fmt.Println(valueOfPtrTestNum.Elem().CanAddr())
   fmt.Println(valueOfPtrTestNum.Elem().CanSet())
}

这段代码中,Elem() 函数的作用是返回指针指向的数据。有点类似 * 操作符,都是从指针中取值

❗️ 注意:使用 Elem() 函数时,若作用于非指针或接口时,将引发宕机。作用于空指针时,将返回 nil。

运行后,控制台将输出两个 true。表示可寻址,可被修改。

# 通过反射修改变量的值

使用反射修改变量的值非常容易,当然前提时要确保操作对象是可寻址、可被修改的。

下面的示例代码演示了如何使用反射重新设定 testInt 变量的值:

go
复制代码func main() {
   var testNum int = 10
   ptrOfTestNum := &testNum
   valueOfPtrTestNum := reflect.ValueOf(ptrOfTestNum)
   valueOfPtrTestNum.Elem().SetInt(20)
   fmt.Println(testNum)
}

# 反射的三大定律

“初尝”反射的使用后,我们从 Go 反射源码和反射的用法来归纳反射的三大定律。

# 反射可以将“接口类型变量”转换为“反射类型对象”

如果我们去查看 reflect.Valueof() 和 reflect.Typeof() 的函数声明,会发现它们的格式是这样的:

go
复制代码func TypeOf(i interface{}) Type
func ValueOf(i interface{}) Value

显然,这两个函数都需要 interface{} (空接口)类型的参数。这也解释了为何我们能像其中传入不同类型的变量,程序都能正常运行的原因(简单地说,interface{} 当作参数时,可以接收任何类型值)。

因此,Go 语言的反射是通过接口来实现的。运行时,首先会将任意类型的变量转换为接口类型,再从接口类型转换为反射类型(即 reflect.Type 和 reflect.Value)。下图更为直观地描述了这一转换过程:

image.png

上图中 ① 表示接口类型转换,② 表示反射类型转换。

以上便是反射的第一定律:将“接口类型变量”转换为“反射类型对象”

# 反射可以将“反射类型对象”转换为“接口类型变量”

第二定律实际上和第一定律刚好相反,在通过 reflect.Value 类型变量调用 Interface() 方法时进行,其作用便是获取值。还是用更直观地示意图来描述,请看下图:

image.png

图中 ① 表示从反射类型转换为接口类型。这也解释了为什么在使用取到的值时需要类型转换了,因为接口类型不能直接当作具体的 int、string 等类型使用。

❗️ 注意:上述转换仅发生于 reflect.Value.interface(),不适用于 reflect.Type。

# 如果要修改“反射类型对象”,其值必须是“可写的”(settable)

第三定律无需多言,前文中已有详细叙述,这里不再展开说明。概括地讲,Go 语言在函数间传递参数时,都是值传递。若要修改原值,就一定要确保操作对象是可写(可通过值的 CanSet() 方法判定)的

# 小结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

  1. 反射的基本使用
    • 通过反射获取变量的类型与值;
    • 通过反射修改变量的值。
  2. 反射的三大定律
    • 反射可以将“接口类型变量”转换为“反射类型对象”;
    • 反射可以将“反射类型对象”转换为“接口类型变量”;
    • 如果要修改“反射类型对象”,其值必须是“可写的”(settable)。

本讲首先介绍了反射的具体应用场景,即程序在运行期间可以对其本身进行访问和修改

接着通过最简单的 int 类型的变量,初尝了反射的基本使用,具体来说包含获取变量的类型和值以及修改值

  • 在获取类型时,阐述了 Go 语言中 类型(Type)种类(Kind) 的概念;
  • 在获取值时,需要进行类型转换才能让值真正地参与后续的运算;
  • 在修改值时,要注意操作对象是可写的。

最后,通过对 Go 源码的阅读和使用规律,总结出 Go 反射的三个定律。牢记这三个定律可以帮助我们更好地理解反射的执行流程,在使用反射时,也可以帮助我们写出正确的代码。

➡️ 在下次课程中,我们会继续 Go 语言中反射的专题,具体内容如下:

  1. 使用反射访问和修改结构体内部数据