Go语言反射特性(reflect)提供了运行时动态获取对象的类型和值以及动态创建对象的能力。反射可以帮助抽象和简化代码,提高开发效率,但通常降低运行效率。

Go语言中的反射

Go语言提供了一种机制在运行时更新和检查变量的值、调用变量的方法和变量支持的内在操作,但是在编译时并不知道这些变量的具体类型,这种机制被称为反射。反射也可以让我们将类型本身作为第一类的值类型处理。

很多语言都有反射特性,在ORM相关框架中用的最多;也有些现代高级语言不支持反射遭到吐槽,比如Flutter开发中用的Dart。Go语言中反射的用法有其特点,下面我们看看例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type Config struct {
	Name    string `json:"server-name"`
	IP      string `json:"server-ip"`
	URL     string `json:"server-url"`
	Timeout string `json:"timeout"`
}

func getConfig() *Config {
	config := Config{}
	typ := reflect.TypeOf(config)
	value := reflect.Indirect(reflect.ValueOf(&config))

	for i := 0; i < typ.NumField(); i++ {
		f := typ.Field(i)
		if v, ok := f.Tag.Lookup("json"); ok {
			tmp := strings.ReplaceAll(strings.ToUpper(v), "-", "_")
			key := fmt.Sprintf("CONFIG_%s", tmp)
			if env, exist := os.LookupEnv(key); exist {
				value.FieldByName(f.Name).Set(reflect.ValueOf(env))
			}
		}
	}
	return &config
}

func ReflectDemo() {
	os.Setenv("CONFIG_SERVER_NAME", "Go-Server")
	os.Setenv("CONFIG_SERVER_IP", "10.10.10.11")
	os.Setenv("CONFIG_SERVER_URL", "chende.com")
	cfg := getConfig()
	fmt.Printf("%+v", cfg)
}

运行结果如下:

&{Name:Go-Server IP:10.10.10.11 URL:chende.com Timeout:}

环境变量中设置的三个配置项已经生效。之后无论结构体 Config 内部的字段发生任何改变,这部分代码无需任何修改即可完美的适配,出错概率也极大地降低。

反射的类型(Type)与种类(Kind)

在使用反射时,需要首先理解类型(Type)和种类(Kind)的区别。编程中,使用最多的是类型,但在反射中,当需要区分一个大品种的类型时,就会用到种类(Kind)。例如需要统一判断类型中的指针时,使用种类(Kind)信息就较为方便。

1) 反射种类(Kind)的定义

Go语言程序中的类型(Type)指的是系统原生数据类型,如 int、string、bool、float32 等类型,以及使用 type 关键字定义的类型,这些类型的名称就是其类型本身的名称。例如使用 type A struct{} 定义结构体时,A 就是 struct{} 的类型。

种类(Kind)指的是对象归属的品种,在 reflect 包中有如下定义:

type Kind uint
const (
    Invalid Kind = iota  // 非法类型
    Bool                 // 布尔型
    Int                  // 有符号整型
    Int8                 // 有符号8位整型
    Int16                // 有符号16位整型
    Int32                // 有符号32位整型
    Int64                // 有符号64位整型
    Uint                 // 无符号整型
    Uint8                // 无符号8位整型
    Uint16               // 无符号16位整型
    Uint32               // 无符号32位整型
    Uint64               // 无符号64位整型
    Uintptr              // 指针
    Float32              // 单精度浮点数
    Float64              // 双精度浮点数
    Complex64            // 64位复数类型
    Complex128           // 128位复数类型
    Array                // 数组
    Chan                 // 通道
    Func                 // 函数
    Interface            // 接口
    Map                  // 映射
    Ptr                  // 指针
    Slice                // 切片
    String               // 字符串
    Struct               // 结构体
    UnsafePointer        // 底层指针
)

Map、Slice、Chan 属于引用类型,使用起来类似于指针,但是在种类常量定义中仍然属于独立的种类,不属于 Ptr。type A struct{} 定义的结构体属于 Struct 种类,*A 属于 Ptr。

2) 从类型对象中获取类型名称和种类

Go语言中的类型名称对应的反射获取方法是 reflect.Type 中的 Name() 方法,返回表示类型名称的字符串;类型归属的种类(Kind)使用的是 reflect.Type 中的 Kind() 方法,返回 reflect.Kind 类型的常量。

反射的性能

毫无疑问的是,反射会增加额外的代码指令,对性能肯定会产生影响的。具体影响有多大,看看分情况测试。

创建对象

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func init() {
	runtime.GOMAXPROCS(1)
}

func BenchmarkNew(b *testing.B) {
	var config *Config
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		config = new(Config)
	}
	_ = config
}

func BenchmarkReflectNew(b *testing.B) {
	var config *Config
	typ := reflect.TypeOf(Config{})
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		config, _ = reflect.New(typ).Interface().(*Config)
	}
	_ = config
}

运行结果如下go test -bench=.

> go test -bench=.
goos: windows
goarch: amd64
pkg: yufa/demo/creflect
BenchmarkNew            37554218                28.8 ns/op
BenchmarkReflectNew     28631622                41.8 ns/op
PASS
ok      yufa/demo/creflect      2.488s

看出来,反射的处理方式明显慢不少。

修改字段的值

通过反射获取结构体的字段有两种方式,一种是 FieldByName,另一种是 Field(index)。前面的例子中,我们使用的是 FieldByName

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func BenchmarkSet(b *testing.B) {
	config := new(Config)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		config.Name = "name"
		config.IP = "ip"
		config.URL = "url"
		config.Timeout = "timeout"
	}
}

func BenchmarkReflect_FieldSet(b *testing.B) {
	typ := reflect.TypeOf(Config{})
	ins := reflect.New(typ).Elem()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		ins.Field(0).SetString("name")
		ins.Field(1).SetString("ip")
		ins.Field(2).SetString("url")
		ins.Field(3).SetString("timeout")
	}
}

func BenchmarkReflect_FieldByNameSet(b *testing.B) {
	typ := reflect.TypeOf(Config{})
	ins := reflect.New(typ).Elem()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		ins.FieldByName("Name").SetString("name")
		ins.FieldByName("IP").SetString("ip")
		ins.FieldByName("URL").SetString("url")
		ins.FieldByName("Timeout").SetString("timeout")
	}
}
>go test -bench=.
goos: windows
goarch: amd64
pkg: yufa/demo/creflect
BenchmarkSet                            1000000000            0.229 ns/op
BenchmarkReflect_FieldSet               56716939              21.5 ns/op
BenchmarkReflect_FieldByNameSet         5559864               214 ns/op
PASS
ok      yufa/demo/creflect      3.040s

这里使用反射给每个字段赋值,相比直接赋值,性能差了约 100 - 1000 倍。其中,FieldByName 的性能相比 Field 差10 倍。

FieldByNameField 为什么会有这10倍的性能差异呢?看看源代码就知道了:FieldByName中使用 for 循环,逐个字段查找,字段名匹配时返回。也就是说,在反射的内部,字段是按顺序存储的,因此按照下标访问查询效率为 O(1),而按照 Name 访问,则需要遍历所有字段,查询效率为 O(N)。结构体所包含的字段(包括方法)越多,那么两者之间的效率差距则越大。

怎样提高性能

避免使用反射

使用反射赋值,效率非常低下,如果有替代方案,尽可能避免使用反射,特别是会被反复调用的热点代码。例如 RPC 协议中,需要对结构体进行序列化和反序列化,这个时候避免使用 Go 语言自带的 jsonMarshalUnmarshal 方法,因为标准库中的 json 序列化和反序列化是利用反射实现的。可选的替代方案有 easyjson,在大部分场景下,相比标准库,有 5 倍左右的性能提升。或者采用Gin框架中推荐使用的jsoniter ,据说比easyjson还要快一些!

缓存

在上面的例子中可以看到,FieldByName 相比于 Field 有一个数量级的性能劣化。那在实际的应用中,就要避免直接调用 FieldByName。我们可以利用字典将 NameIndex 的映射缓存起来。避免每次反复查找,耗费大量的时间。

我们利用缓存,再看看刚才的测试用例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func BenchmarkReflect_FieldByNameCacheSet(b *testing.B) {
	typ := reflect.TypeOf(Config{})
	cache := make(map[string]int)
	for i := 0; i < typ.NumField(); i++ {
		cache[typ.Field(i).Name] = i
	}
	ins := reflect.New(typ).Elem()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		ins.Field(cache["Name"]).SetString("name")
		ins.Field(cache["IP"]).SetString("ip")
		ins.Field(cache["URL"]).SetString("url")
		ins.Field(cache["Timeout"]).SetString("timeout")
	}
}

测试结果如下,不是10倍的差距,而是2倍:

>go test -bench=.
goos: windows
goarch: amd64
pkg: yufa/demo/creflect
BenchmarkSet                            1000000000            0.225 ns/op
BenchmarkReflect_FieldSet               54694621              21.0 ns/op
BenchmarkReflect_FieldByNameSet         5419586               227 ns/op
BenchmarkReflect_FieldByNameCacheSet    25519588              45.1 ns/op
PASS
ok      yufa/demo/creflect      4.213s

Golang标准库中对json的解析代码就是采用这里介绍的算法(缓存属性索引值):

1
2
3
4
5
// encoding/json/encode.go
type structFields struct {
	list      []field
	nameIndex map[string]int
}

总结

Go 作为一门静态语言,相比javascript等动态语言,在编写过程中灵活性会受到一定的限制。但是通过接口加反射实现了类似于动态语言的能力,可以在程序运行时动态地捕获甚至改变类型的信息和值。

Go 语言的反射实现的基础是类型,或者说是 interface,当我们使用反射特性时,实际上用到的就是存储在 interface 变量中的和类型相关的信息,也就是常说的 <type, value> 对;只有 interface 才有反射的说法。

反射在 reflect 包中实现,涉及到两个相关函数:

1
2
func TypeOf ( i interface{} ) Type
func ValueOf ( i interface{} ) Value

Type 是一个接口,定义了很多相关方法,用于获取类型信息。Value 则持有类型的具体值。Type、Value、Interface 三者间通过函数 TypeOf,ValueOf,Interface 进行相互转换。

温习一下反射三大定律:

  1. 反射将接口变量转换成反射对象 Type 和 Value;
  2. 反射可以通过反射对象 Value 还原成原先的接口变量;
  3. 反射可以用来修改一个变量的值,前提是这个值可以被修改。

参考:

http://c.biancheng.net/view/4407.html

https://studygolang.com/articles/2157

(完)