反射是指程序检查自身的能力(尤其是通过类型),它是元编程的一种形式。
Go 的类型系统
反射基于类型系统,因此必须先了解 Go 的类型系统。任何情况下,一个 Go 变量都会包含静态类型和值两个部分。
1 | <!-- more --> |
值得关注的是,当变量类型是接口时,值就变得稍微复杂一点:接口只是声明一堆方法集合,甚至是空方法集合(空接口 interface{}),因此值就必须存储 (值, 值的具体类型) 对,以便转换为不同的接口进行调用。比如
1 | var a interface{} |
这个可以在 debug 时看得比较明显。
一个接口变量的值,一定是存储的 (值, 具体类型) 对,而不能是 (值, 接口类型) 对,这样没有意义,也做不到。
几点说明
var i interface{}
,这里i
的静态类型是interface{}
,值是(nil, nil)
对,即值和动态类型都是nil
- 当两个变量值做相等比较时,只有值和动态类型都相等,才判断相等,否则不等
反射三定律
反射一定是从接口获取到反射对象
从根本上讲,反射只是一种检查接口变量中存储的类型和值对的机制。反射一定是从接口变量中获取反射对象的。
1 | a := 12 |
问:上面获取 a
的反射对象值明明是从基本类型变量 a
获取的,和前面一定从接口变量获取发射对象的论述冲突?
答:如下是 reflect.ValueOf()
函数的定义,可以看到,当 a
传入时,实际就传给了静态类型为 interface{}
的变量 i
,此时 i
的静态类型为 interface{}
,值是 (12, int)
。所以 “一定从接口变量获取发射对象” 的论述依然正确。
1 | func ValueOf(i any) Value { |
反射也能从反射对象获取回接口值
反射会生成自己的逆。也就是说,通过 refelct.ValueOf(xxx)
能够获取到 xxx
的反射对象,也能通过反射对象的 xxxValue.interface{}
获取到原本 xxx
所代表的值。
只不过这里丢失了静态类型,这是合理的,静态类型是变量专属,通过
xxxValue.interface{}
获取到的仅仅是一个值,值是不会有静态类型的。
问:通过 xxxValue.interface{}
获取到的值是原本那个值吗?
答:如果原本的值是值类型,比如 int
、string
等类型,那么得到的肯定是一个副本;如果原本的值是一个引用类型,那么得到的就是指向原始数据的引用。
要修改反射对象,其值必须可设置
反射的目的就是为了观察和修改原变量的值,因此会持有原变量的值。如果持有的是原变量的副本,修改就没了意义,此时能看不能改;但如果持有指向原变量的指针,就既能看也能改。一个典型的无法修改的情况如下:
1 | var x float64 = 3.4 |
这是因为 x 是值类型,向 reflect.ValueOf()
传递的是自己的副本,反射对象无法触及到原变量,所以无法修改。要让它能够被修改,得传入其指针
1 | var x float64 = 3.4 |
reflect
包巡礼
反射的第一要义是弄懂
reflect.Type
,reflect.Value
,reflect.Kind
三者的关系。
反射就是使用反射对象描述变量的运行时状态。根据反射第一定律,反射的起始一定是接口,而接口的值以 (值,具体类型)
对存在。Go 使用 reflect.Value
来描述此处的值,reflect.Type
来描述此处的具体类型。
还有一个很重要的概念是 reflect.Kind
,它是一个枚举,描述具体类型的基本类型,下图对比 reflect.Type
和 reflect.Kind
:任何具体类型都是由基础类型定义而来。
reflect.Kind
1 | const ( |
Kind
枚举了所有基础类型:
常规类型:
Bool
、Intx
、Uintx
、Floatxx
、Complexxx
、String
指针相关
Uintptr
对应于uintptr
类型,它可以表示一个无符号的整型值,其大小足够容纳一个指针。主要用于与底层指针操作相关,常见场景是和指针地址值互相转换,平时用不到。UnsafePointer
:对应于 unsafe.Pointer
,允许对内存进行不安全的操作(例如类型强转、规避类型检查等)。Pointer
:就是一个普通的指针。前面两种都不是常规指针类型,不常用,但这个常用。我们通常还会用
reflect.Ptr
,它是reflect.Pointer
的别名。
接口:
Interface
。reflect.Interface
并不能直接得到,但是可以在复合类型中定义元素是interface{}
,比如1
2
3
4
5
6
7
8
9
10
11
12
// 定义一个元素为 interface{} 的切片,那么切片的反射对象的元素的 Kind 就是 reflect.Interface。类似的
var slice []interface{}
slice = append(slice, "Hello", 42, 3.14)
fmt.Println("Slice Element Type Kind:", reflect.TypeOf(slice).Elem().Kind())
// 结构体的字段类型为 interface{},那么通过结构体的反射对象获取到的字段的 Kind 就是 reflect.Inerface
type MyStruct struct {
Field1 interface{}
}
ms := MyStruct{Field1: "World"}
fmt.Println("MyStruct Field1 Type Kind:", reflect.TypeOf(&ms).Field(0).Type.Kind())结构体:
Struct
集合类型:
Slice
Array
Map
通道:
Chan
函数:
Func
每种类型在反射对象上都有对象的操作方法,后面一一说明。
如何理解上面结构体
MyStruct
字段Field1
的反射对象的 Kind 不是具体类型,而是reflect.Interface
?答:构建反射对象时,加载的是目标变量具体类型的静态声明信息,而不是动态信息。这是有道理的:
- 一来,任何时候通过
reflect.TypeOf(ms).Field(0).Type
访问得到的都是声明的接口类型,具有一致性。- 二来,返回静态声明类型直接访问编译后的类型信息就行了,比较高效
我想,真正的疑问是:
reflect.TypeOf(xxx)
时获取的是 xxx 的具体类型;而涉及到复合类型的元素时获取到的却是静态类型,行为似乎不一致。答:
reflect.TypeOf(xxx)
时xxx
的类型已确定,不会再变;但复合类型的元素只能按声明类型操作,因为其元素随时可能发生变化,如果按照string
返回,但它其实还可以设置为 int 类型的值,这样明显不合理。要获取到
string
,可以通过reflect.Value
,比如reflect.ValueOf(&ms).Elem().Field(0).Elem().Kind()
,其中Elem()
函数表示获取接口包含的具体元素或接口指向的元素。
reflect.Type
reflect.Type
是一个接口,
1 | type Type interface { |
对齐
变量对齐的意义
概念:每个变量都有一个对齐值,其作用是知道变量地址分配:分配的地址必须是对齐值的整数倍
为什么分配的地址必须是对齐值的整数倍
首先明白,CPU 从内存读取数据是按照 word 读取的,32 为系统的 word 是 4 字节,64 位是 8 字节
要求分配的地址必须是对齐值的整数倍,是为了确保变量的数据在尽量少的 word 内,减少读取次数,提升效率
举例:假设 word 是 4 字节,变量大小是 2 字节,不要求起始地址是 2 的整数倍时:如果被分配到了 1-2 字节,能够被一次性读出,没有问题;但如果被分配到 3-4 字节,就需要读取两次,浪费一次。
要求起始地址是 2 的倍数时:它肯定被分配到每个 word 内部,一定能够被一次读出。
类型对齐值的计算
对齐值最大只有 8 字节,因为操作系统最大也就是 64 位,该场景下一个 word 就是 8 字节
基本类型的对齐值就是占用字节大小
指针类型和引用类型(如切片、channel 等) 对齐值就是一个 word
结构体的的对齐值是其字段对齐值的最大值
有时候如果分不清楚,就使用
unsafe.Alignof(查看)
。
对齐优化
通常是排列结构体的字段顺序,使得整个结构体占用更少 word
1 | type Struct1 struct { |
解释:我的操作系统是 64 位,Struct1
由于 Field2
的对齐值是 8,因此单独占用一个 word,于是 Field1
和 Field3
不得不自己占用一个 word,总共 3 个 word;Struct2
中,Field1
和 Field3
共同占用一个 word,Field2
占用一个 word,总共 2 个 word。
方法注意事项
- 类型的方法按照字典顺序排
- 对于非接口类型,只能看到导出的方法;对于接口类型,导出方法和非导出方法都能看到。
- 定义在类型指针上的方法,通过类型的
reflect.Type
是获取不到的
可比较
- 基础类型比较的是值
- 指针类型比较的是地址
- 接口类型比较的是 (值,具体类型) 对
- 数组类型如果所有元素可比较,就可比较
- 切片、
map
、函数 不可比较,它们只能和nil
比较,如果硬比较会报编译错误 - 结构体如果所有字段都可比较,就可比较
除 interface{}
外,多数可比较与否都可以在编译阶段检测出来
如果是 interface{}
,其动态类型可以是上面说的任何类型,所以无法直接判断可比较与否,如果强行将两个不可比较的动态类型做比较,会 panic,比如
1 | var slice1 any = []string{"1", "2"} |
可以先判断两者是否可比较,再比较
1 | var slice1 any = []string{"1", "2"} |
补充:如果两个不同类型都可比较,如果直接比较它们会报编译错误,如下
1 | a := 12 |
但如果它们都是动态类型,此时比较不会 panic,只不过结果是 false,因为 interface{}
比较的是 (值,类型)
对。
1 | var a any = 12 |
reflect.Value
reflect.Value
是一个结构体,只能通过 IDE 概览方法,但如果想要复制下来逐个分析就有困难,我们通过如下代码可以打印出所有方法
1 | rValue := reflect.ValueOf(12) |
逐个看
1 | // 获取 Type 对象 |
Addressable Value
什么样的 value 才是可寻址的呢?
所谓可寻址,就是可以获取到原始值的地址信息。可以看一个例子,考虑下面三种情况的寻址情况
1 | a := 12 |
画图分析 reflectValue 的情况
- 对于 a,
reflect.ValueOf(a)
时获得的是一个副本,原始值丢失了,无论如何都无法寻址 - 对于 b,
reflect.ValueOf(b)
时获得了指针 b 的副本,该副本指向 12,因此直接获取的 Value 对象是不可寻址的,但其元素指向了原始值 12,可寻址 - 对于 c,有两层指针,最外层指针
&s
在reflect.ValueOf(c)
时复制过来了,因此它不是原始值,它指向的指针 s 和指针 s 指向的 12 都是原始值,可寻址
Addr() 有什么用?
Addr()
获取的是一个 Value 的指针构成的 Value,有啥用呢🤔?
一个比较典型的用法是:如果我们在一个类型的指针上定义了方法,但获取到的是该类型的反射 Value 对象,此时可以通过 Addr() 获取到该值的指针 Value 对象,从而访问定义在类型指针上的方法。示例代码
1 | type mystr string |
unsafe.Pointer
和普通指针区别
- 普通指针是语言合法暴露给用户的,仅在引用类型时我们可以获取到指针,对指针的所有操作是安全的
- 但其实每个被存储的内容都是有地址的,我们可以通过
unsafe.Pointer
获取到这些地址。如果我们知道变量的内存地址,再知道变量类型的内存结构,我们可以直接通过偏移量等骚操作跳过语法限制直接修改内存中的值来修改变量。如下举例
1 | type MyStruct struct { |
分清几个寻址方法
Addr()
, UnsafeAddr()
, Pointer()
, UnsafePointer()
几个方法的区别。
Addr()
: 返回指向当前值的指针的 Value 对象,只有当前值可寻址时才有效UnsafeAddr()
: 等效于uintptr(Value.Addr().UnsafePointer())
Pointer()
: 将当前值当做指针返回,只有当前值是引用类型才有效。等效于uintptr(Value.UnsafePointer())
UnsafePointer()
: 将当前值当做unsafe.Pointer
返回,同样只有当前值是引用类型才有效
所以其实 Addr()
, UnsafeAddr()
和 Pointer()
, UnsafePointer()
这两个方法没有任何关系,后两个方法和 Int()
、Bool()
这样的方法类似,就是引用类型的值对象本身的值就是指针,通过 Pointer()
或 UnsafePointer()
得到原本的指针值罢了。
而 Addr()
获取指向当前值的地址的 Value 对象,UnsafeAddr()
得到的就是这个地址,即使不是指针或引用类型,只要是可寻址的,就能通过此方式获取到其地址。
Type
和 Value
都有的容易混淆的方法
方法 | reflect.Type |
reflect.Value |
---|---|---|
Elem() |
仅当 slice、array、chan、map、指针 时有效 返回它们包含的元素的类型 |
对 interface 或 pointer 有效 - interface 时返回其包含的元素反射值对象; - 指针时返回其执行的元素的反射值对象 |
Method(i) |
返回第 i 个方法定义; 方法定义是 reflect.Method 对象 ,是对方法的描述 |
返回第 i 个方法本身; 方法本身是一个函数,即一个 Func |
Field(i) |
返回第 i 个字段定义; 字段定义是 reflect.StructField ,tag 就从这里获取 |
返回第 i 个字段值本身; 是一个 reflect.Value 对象。 |
总之掌握一个原则,reflect.Type
代表的类型,类型是静态的,在定义时就已确定,reflect.Type
的所有方法都是在获取形式类型中的描述信息;reflect.Value
代表的是值,复杂不少。
实际使用时候会更多地依赖 reflect.Value
,毕竟它包含更多信息。
操作 reflect.Value
的全局方法
1 | // 创建 reflect.Value |
常用的也就
func ValueOf(i any) Value
和func Indirect(v Value) Value
两个方法了。
三个全局方法
reflect.Copy(Value, Value) n
- 只对切片或数组有效,两个切片的元素类型必须一样
- 浅拷贝,只拷贝一层
- 不会对目标切片扩容:如果目标切片容量不够,复制不会发生
1 | // 目标切片长度为0,不会复制任何东西 |
func DeepEqual(x, y any) bool
递归的深度比较,就是我们想的那个意义上的比较,只有所有都相等时候才会相等。有几个特殊的
slice
比较时会比较容量和长度Func
的比较,只有他们都为 nil 时才会相等,否则不相等。(没错,自己和自己也不相等)
func Swapper(slice any) func(i, j int)
仅切片有效,返回可用于交换切片元素的函数
xxx.(type) 与反射的关系
我们时常会看到这种代码。其实这也只是类型断言,和 writer, ok := xxx.(io.Writer)
是一样的,唯一区别是,xxx.(type)
只能和 switch 配合使用,可以检测多种类型;而 writer, ok := xxx.(io.Writer)
只能检测一种类型。
类型断言与反射的唯一关系就是他们都是构建与 Go 的类型系统之上,初次之外没有半毛钱关系。
1 | func retrieveVarAsString(v any) ([]string, bool) { |
当仅涉及到简单的类型判断是,类型断言是首选:语法更简洁;性能更好(虽然都会在运行时判断动态类型信息,但编译器会对类型断言做优化,不必调用反射包。)
问:反射比起类型断言具体性能消耗在哪里?
答:
构建反射对象:反射需要构建
reflect.Type
对象,需要分配内存、需要初始化;而类型断言直接用读取变量的类型指针来判断就行了通用性逻辑:
reflect.Type
中不止包含类型,还包含每个字段的属性(如可否导出等),无论我们用不用,在创建该对象时都会去遍历这些信息reflect.Type 代表什么
reflect.Value 代表什么
reflect.Kind 代表什么
xxx.(type) 怎么工作的?这个也是类型断言的一种。
Type 和 Value 中那些相同的方法有什么区别?
AI 出一道有难度的反射操作题目
如何获取一个变量的内存地址
研究一下 reflect.Value.Pointer() 和 reflect.Value.Addr() 函数
练习:实现一个通用的 Struct Tag 校验器
题目:实现一个函数
1 | func ValidateStruct(s interface{}) error |
当传入一个结构体时,该函数会检查结构体中被标记为 validate:"required"
的字段是否为“零值”。
- 如果字段是零值(例如
""
、0
、nil
、false
),则认为验证失败,返回一个错误,包含所有未通过验证的字段名称。 - 支持嵌套结构体递归校验。
- 忽略私有字段。
- 只处理结构体类型(否则返回 error)。
- 支持
slice
、map
、array
等 - 也可以附加支持
min
、max
、email
等验证
如下是一个实现了 required
和 min
的版本。实现时主要注意要先对字段做 validate 验证,再根据字段是否是复合类型决定是否验证其元素,因为无论字段是何种类型,都可能需要进行验证(比如 required
也需要验证指针、interface{}
等 )
1 |
|