标准库 reflect 为 Go 语言提供了运行时动态获取对象的类型和值以及动态创建对象的能力。反射可以帮助抽象和简化代码,提高开发效率。

Go 语言标准库以及很多开源软件中都使用了 Go 语言的反射能力,例如用于序列化和反序列化的 json、ORM 框架 gorm、xorm 等。

1.使用 strconv 而不是 fmt

基本数据类型与字符串之间的转换,优先使用 strconv 而非 fmt,因为前者性能更佳。

// Bad
func BenchmarkFmtSprint(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = fmt.Sprint(rand.Int())
    }
}

BenchmarkFmtSprint-4    143 ns/op    2 allocs/op

// Good
func BenchmarkStrconv(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = strconv.Itoa(rand.Int())
    }
}

BenchmarkStrconv-4    64.2 ns/op    1 allocs/op

运行如下基准测试命令,将看到上面的基准测试结果。

# -bench=. 表示执行所有基准测试函数
# -benchmem 打印基准测试的内存分配统计信息
# main/perf 表示执行基准测试的包
go test -bench=. -benchmem main/perf

为什么性能上会有两倍多的差距,因为 fmt 实现上利用反射来达到范型的效果,在运行时进行类型的动态判断,所以带来了一定的性能损耗。

2.少量的重复不比反射差

有时,我们需要一些工具函数。比如从 uint64 切片过滤掉指定的元素。

利用反射,我们可以实现一个类型泛化支持扩展的切片过滤函数。

// DeleteSliceElms 从切片中过滤指定元素。注意:不修改原切片。
func DeleteSliceElms(i interface{}, elms ...interface{}) interface{} {
    // 构建 map set。
    m := make(map[interface{}]struct{}, len(elms))
    for _, v := range elms {
        m[v] = struct{}{}
    }
    // 创建新切片,过滤掉指定元素。
    v := reflect.ValueOf(i)
    t := reflect.MakeSlice(reflect.TypeOf(i), 0, v.Len())
    for i := 0; i < v.Len(); i++ {
        if _, ok := m[v.Index(i).Interface()]; !ok {
            t = reflect.Append(t, v.Index(i))
        }
    }
    return t.Interface()
}

很多时候,我们可能只需要操作一个类型的切片,利用反射实现的类型泛化扩展的能力压根没用上。退一步说,如果我们真地需要对 uint64 以外类型的切片进行过滤,拷贝一次代码又何妨呢?可以肯定的是,绝大部分场景,根本不会对所有类型的切片进行过滤,那么反射带来好处我们并没有充分享受,但却要为其带来的性能成本买单。

// DeleteU64liceElms 从 []uint64 过滤指定元素。注意:不修改原切片。
func DeleteU64liceElms(i []uint64, elms ...uint64) []uint64 {
    // 构建 map set。
    m := make(map[uint64]struct{}, len(elms))
    for _, v := range elms {
        m[v] = struct{}{}
    }
    // 创建新切片,过滤掉指定元素。
    t := make([]uint64, 0, len(i))
    for _, v := range i {
        if _, ok := m[v]; !ok {
            t = append(t, v)
        }
    }
    return t
}

下面看一下二者的性能对比。

func BenchmarkDeleteSliceElms(b *testing.B) {
    slice := []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9}
    elms := []interface{}{uint64(1), uint64(3), uint64(5), uint64(7), uint64(9)}
    for i := 0; i < b.N; i++ {
        _ = DeleteSliceElms(slice, elms...)
    }
}

func BenchmarkDeleteU64liceElms(b *testing.B) {
    slice := []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9}
    elms := []uint64{1, 3, 5, 7, 9}
    for i := 0; i < b.N; i++ {
        _ = DeleteU64liceElms(slice, elms...)
    }
}

运行上面的基准测试。

go test -bench=. -benchmem main/reflect 
goos: darwin
goarch: amd64
pkg: main/reflect
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkDeleteSliceElms-12              1226868               978.2 ns/op           296 B/op         16 allocs/op
BenchmarkDeleteU64liceElms-12            8249469               145.3 ns/op            80 B/op          1 allocs/op
PASS
ok      main/reflect    3.809s

可以看到,反射涉及了额外的类型判断和大量的内存分配,导致其对性能的影响非常明显。随着切片元素的递增,每一次判断元素是否在 map 中,因为 map 的 key 是不确定的类型,会发生变量逃逸,触发堆内存的分配。所以,可预见的是当元素数量增加时,性能差异会越来大。

当使用反射时,请问一下自己,我真地需要它吗?

3.慎用 binary.Read 和 binary.Write

binary.Read 和 binary.Write 使用反射并且很慢。如果有需要用到这两个函数的地方,我们应该手动实现这两个函数的相关功能,不推荐直接去使用它们。

encoding/binary 包实现了数字和字节序列之间的简单转换以及 varints 的编码和解码。varints 是一种使用可变字节表示整数的方法。其中数值本身越小,其所占用的字节数越少。Protocol Buffers 对整数采用的便是这种编码方式。

其中数字与字节序列的转换可以用如下三个函数:

// Read 从结构化二进制数据 r 读取到 data。data 必须是指向固定大小值的指针或固定大小值的切片。
func Read(r io.Reader, order ByteOrder, data interface{}) error
// Write 将 data 的二进制表示形式写入 w。data 必须是固定大小的值或固定大小值的切片,或指向此类数据的指针。
func Write(w io.Writer, order ByteOrder, data interface{}) error
// Size 返回 Wirte 函数将 v 写入到 w 中的字节数。
func Size(v interface{}) int

下面以我们熟知的 C 标准库函数 ntohl() 函数为例,看看 Go 利用 binary 包如何实现。

// Ntohl 将网络字节序的 uint32 转为主机字节序。
func Ntohl(bys []byte) (num uint32, err error) {
    r := bytes.NewReader(bys)
    err = binary.Read(r, binary.BigEndian, &num)
    return
}

// 如将 IP 127.0.0.1 网络字节序解析到 uint32
fmt.Println(Ntohl([]byte{0x7f, 0, 0, 0x1})) // 2130706433 <nil>

如果我们针对 uint32 类型手动实现一个 ntohl() 呢?

func NtohlNotUseBinary(bys []byte) uint32 {
    return uint32(bys[3]) | uint32(bys[2])<<8 | uint32(bys[1])<<16 | uint32(bys[0])<<24
}

// 如将 IP 127.0.0.1 网络字节序解析到 uint32
fmt.Println(NtohlNotUseBinary([]byte{0x7f, 0, 0, 0x1})) // 2130706433

该函数也是参考了 encoding/binary 包针对大端字节序将字节序列转为 uint32 类型时的实现。

下面看下剥去反射前后二者的性能差异。

func BenchmarkNtohl(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _, _ = Ntohl([]byte{0x7f, 0, 0, 0x1})
    }
}

func BenchmarkNtohlNotUseBinary(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = NtohlNotUseBinary([]byte{0x7f, 0, 0, 0x1})
    }
}

运行上面的基准测试,结果如下:

go test -bench=BenchmarkNtohl.* -benchmem main/reflect
goos: darwin
goarch: amd64
pkg: main/reflect
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkNtohl-12                       13026195                81.96 ns/op           60 B/op          4 allocs/op
BenchmarkNtohlNotUseBinary-12           1000000000               0.2511 ns/op          0 B/op          0 allocs/op
PASS
ok      main/reflect    1.841s

可见使用反射实现的 encoding/binary 包的性能相较于针对具体类型实现的版本,性能差异非常大。

powered by Gitbook该文章修订时间: 2024-08-04 07:27:05

results matching ""

    No results matching ""