Go中泛型和反射比较指南

Go 是一种以简单性为傲的静态类型语言,自诞生以来已经经历了无数的变化。经常引发 Go 开发人员讨论的两个功能是反射和最近的泛型。两者都有相似的目的:为固有的静态语言引入一定程度的活力和灵活性。但是,虽然反射从早期就已经是 Go 的一部分,但泛型却是新事物,提供了不同的工具来解决一些相同的问题。

什么是反射与泛型
Go 中的反射允许您在运行时检查、修改变量的类型和值并与之交互,而无需在编译时知道它们的类型。它非常强大,但很容易出错,并且通常会导致代码可读性较差。

另一方面,泛型引入了类型参数,并允许您编写类型安全且可重用的函数和数据结构,而不会牺牲性能。Go 最近添加的泛型让许多开发人员想知道:“我可以用泛型替换一些基于反射的代码,以获得更好的类型安全性和性能吗?如果是的话,又如何?”

了解反射和泛型之间的权衡可能会对 Go 应用程序的设计和性能产生重大影响。随着 Go 的不断发展,保持最新的最佳实践可以让您在编写高效、可维护和健壮的代码方面获得优势。

让我们尝试简要回顾一下 Go 中反射和泛型的基本概念,并用简单的并行示例进行说明。

1、反射
反射使程序能够在运行时检查和操作变量的类型和值。然而,在这种动态性下,我们通常需要权衡类型安全性和可读性。考虑动态检查值类型的任务:

package main 

import
    "fmt" 
   
"reflect"
 ) 

func  main () { 
    x := 42 
   
// 使用反射来确定 'x' 的类型
    t :=reflect.TypeOf(x) 
    fmt.Println(
"Type of x: " , t)   
   
// 输出:x 类型:int
 }

在上面的示例中,我们使用reflect包来动态确定x.

2、Go 中的泛型
泛型允许您编写灵活且可重用的代码,同时保持类型安全。与我们的反射示例进行类比,让我们使用泛型来编写一个可以接受任何类型的值并返回其类型的函数:

package main 

import  "fmt" 

func  TypeOf [ T  any ] (v T)  string { 
    return fmt.Sprintf(
"%T" , v) 


func  main () { 
    x := 42
     fmt.Println(
"x 的类型:" , TypeOf(x))   
   
// 输出:x 的类型:int
 }

在这里,利用泛型定义的 TypeOf 函数可以接受任何类型的 T,并以字符串形式返回其类型。我们实现了与反射示例类似的功能,但在编译时确保了类型安全。

用泛型取代反射:
本节将研究现实世界中一些常用的反射场景,并探讨如何使用 Go 的新泛型功能重新设计这些场景。

a. 反射
假设你正在使用一个接收 interface{} 类型的函数,而你想根据具体类型执行不同的操作。使用 "反射",您可以这样做

package main

import (
 "reflect"
)

func ReflectTypeCheck(v interface{}) string {

 val := reflect.ValueOf(v)

 switch val.Kind() {
   case reflect.Int:
    return
"It's an integer!"
   case reflect.String:
    return
"It's a string!"
 }

 return
"not implemented"

}

func main() {
   fmt.Println(ReflectTypeCheck(2))
}

b. 泛型
使用泛型可以实现类似的功能,但与使用反射相比,泛型的可读性更强,类型更安全

package main

import (
  "reflect"
 
"fmt"
)

func GenericTypeCheck[T any](v T) string {
 switch any(v).(type) {
 case int:
  return
"It's an integer!"
 case string:
  return
"It's a string!"
 }
 return
"not implemented"
}

func main() {
  fmt.Println(GenericTypeCheck(42))
}


C. 基准测试示例 1
我们将把基准测试添加到同一个主软件包中的 "main_test.go "文件中:

package main

import "testing"

func BenchmarkTypeCheckReflect(b *testing.B) {
 for i := 0; i < b.N; i++ {
  ReflectTypeCheck(42)
  ReflectTypeCheck(
"hello")
 }
}

func BenchmarkTypeCheckGeneric(b *testing.B) {
 for i := 0; i < b.N; i++ {
  GenericTypeCheck(42)
  GenericTypeCheck(
"hello")
 }
}

运行:
go test -bench=. -benchmem

//Output:
// goos: darwin
// goarch: amd64
// pkg: ------
// cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
// BenchmarkTypeCheckReflect-12  260707183  4.564 ns/op  0 B/op 0 allocs/op
// BenchmarkTypeCheckGeneric-12  1000000000 0.2447 ns/op 0 B/op 0 allocs/op

  • BenchmarkTypeCheckReflect-12:该测试使用反射,每次操作耗时约 4.564 纳秒。末尾的 12 表示测试使用 12 个并行线程运行。每次操作的字节分配为零(0 B/op),内存分配为零(0 allocs/op)。260707183 表示测试能够运行的迭代次数
  • BenchmarkTypeCheckGeneric-12:泛型版本的速度明显更快,每次操作只需 0.2447 纳秒,同样使用 12 个并行线程。同样,没有字节或内存分配。我们可以看到,迭代次数(1000000000)增加了。

示例 1 的结论:泛型版本比基于反射的方法快得多
具体来说,快了近 18-20 倍(反射法为 4.564 ns/op,而泛型法为 0.2447 ns/op)。

示例 2:动态切片
a. 使用 反射
创建动态类型的切片通常涉及到反射。例如

package main

import (
 "fmt"
 
"reflect"
)

func CreateSlice(elementType reflect.Type, length, capacity int) reflect.Value {
 return reflect.MakeSlice(reflect.SliceOf(elementType), length, capacity)
}

func main() {
 intSlice := CreateSlice(reflect.TypeOf(1), 5, 5)
 fmt.Println(intSlice.Len())  
// Output: 5
}

b. 使用 "泛型
使用泛型可以创建动态类型片,同时确保类型安全。

package main

import (
 "fmt"
)

func CreateSlice[T any](elementType T, length, capacity int) []T {
 return make([]T, length, capacity)
}

func main() {
 intSlice := CreateSlice(1, 5, 5)
 fmt.Println(len(intSlice))
// Output: 5
}

C. 基准测试示例 2
我们将把基准测试添加到同一个主软件包根目录下的 "main_test.go "文件中:

package main

import (
 "reflect"
 
"testing"
)

func BenchmarkCreateSliceReflect(b *testing.B) {
 b.ReportAllocs()
 for i := 0; i < b.N; i++ {
  _ = CreateSliceReflect(reflect.TypeOf(1), 5, 5)
 }
}

func BenchmarkCreateSliceGeneric(b *testing.B) {
 b.ReportAllocs()
 for i := 0; i < b.N; i++ {
  _ = CreateSliceGeneric(1, 5, 5)
 }
}

测试:

go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: ---
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkCreateSliceReflect-12  11032561  117.3 ns/op  72 B/op  2 allocs/op
BenchmarkCreateSliceGeneric-12  7910863   26.56 ns/op  48 B/op  1 allocs/op

  • BenchmarkCreateSliceReflect-12:该基准运行的迭代次数为 11032561 次,平均每次操作(即使用反射创建片)耗时约 117.3 纳秒,分配了约 72 字节内存。每个操作平均分配两次内存。
  • BenchmarkCreateSliceGeneric-12:基准运行的迭代次数为 37910863。请注意,这比反射法的迭代次数要多得多,表明基于泛型的方法速度更快(因此,在相同时间内执行的迭代次数也更多)。每次操作(即使用泛型创建片)平均耗时约 26.56 纳秒,明显快于基于反射的方法。每次操作分配的内存约为 48 字节,少于基于反射的方法,并且只进行了一次内存分配,是基于反射的方法分配次数的一半。

示例 2 的结论:
基于泛型的方法(BenchmarkCreateSliceGeneric-12)比基于反射的方法(BenchmarkCreateSliceReflect-12)快得多。泛型方法每次操作所需的时间仅为基于反射的方法的 22.6%。

在内存消耗方面,泛型方法的效率也更高,每次操作的内存消耗减少了 33.3%。此外,泛型方法只需要分配一半的内存,这有利于减少垃圾回收开销,提高应用程序性能。

总之,与基于反射的方法相比,泛型方法不仅确保了类型安全和更好的可读性(正如您在文章中所强调的),还具有显著的性能优势。

示例 3:JSON 编码/解码
a. 使用 "反射
反射可用于动态创建未知数据类型的新实例,然后将 JSON 数据解码到其中。该实现使用 jsoniter 进行序列化和反序列化

package main

import (
 "bytes"
 
"fmt"
 
"reflect"

 jsoniter
"github.com/json-iterator/go"
)

var json = jsoniter.ConfigCompatibleWithStandardLibrary
var buf = &bytes.Buffer{}

func EncodeDecodeReflect(data interface{}) interface{} {
 buf.Reset()
// Reset buffer
 dataType := reflect.TypeOf(data)
 newData := reflect.New(dataType).Elem().Interface()

 if err := json.NewEncoder(buf).Encode(data); err != nil {
  return data
 }
 if err := json.NewDecoder(buf).Decode(&newData); err != nil {
  return data
 }
 return newData
}

func main() {
 fmt.Println(EncodeDecodeReflect(42))      
// Output: 42
 fmt.Println(EncodeDecodeReflect(
"hello")) // Output: hello

b.使用 泛型
Go 中增加了泛型后,上述过程变得更加简单。泛型允许在不牺牲数据动态特性的情况下进行类型安全的操作:

package main

import (
 "bytes"
 
"fmt"

 jsoniter
"github.com/json-iterator/go"
)

var json = jsoniter.ConfigCompatibleWithStandardLibrary
var buf = &bytes.Buffer{}

func EncodeDecode[T any](data T) T {
 buf.Reset()
// Reset buffer
 var newData T

 if err := json.NewEncoder(buf).Encode(data); err != nil {
  return data
 }
 if err := json.NewDecoder(buf).Decode(&newData); err != nil {
  return data
 }
 return newData
}

func main() {
 fmt.Println(EncodeDecode(42))      
// Output: 42
 fmt.Println(EncodeDecode(
"hello")) // Output: hello
}

C. 基准测试示例 3
我们将把基准测试添加到同一个主软件包中的 "main_test.go "文件中:

package main

import (
 "testing"
)

func BenchmarkEncodeDecodeReflect(b *testing.B) {
 for i := 0; i < b.N; i++ {
  EncodeDecodeReflect(42)
  EncodeDecodeReflect(
"hello")
 }
}

func BenchmarkEncodeDecodeGeneric(b *testing.B) {
 for i := 0; i < b.N; i++ {
  EncodeDecode(42)
  EncodeDecode(
"hello")
 }
}

运行:
go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: ---
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkEncodeDecodeReflect-12 956721  1252 ns/op  2672 B/op 21 allocs/op
BenchmarkEncodeDecodeGeneric-12 1283476 948.4 ns/op 2608 B/op 16 allocs/op

  • BenchmarkEncodeDecodeReflect-12:在默认基准测试时间段内,使用反射的函数执行了约 956,721 次(956,721 次迭代)。每次调用该函数耗时约 1,252 纳秒(1252 ns/op)。基于反射的函数每次迭代分配 2,672 字节内存(2672 B/op),每次操作执行 21 次内存分配(21 allocs/op)。
  • BenchmarkEncodeDecodeGeneric-12:使用泛型的函数速度更快,在基准测试时间段内执行了约 1,283,476 次(1,283,476 次迭代)。对该函数的每次调用耗时约 948.4 纳秒(948.4 ns/op),快于其对应的反射函数。基于泛型的函数每次迭代分配的内存略少,为 2608 字节(2608 B/op),每次操作执行 16 次内存分配(16 allocs/op),少于反射版本。

示例 3 的结论
就执行时间和内存分配而言,泛型版本更快、更高效。这符合泛型相对于反射的典型优势:由于避免了运行时类型检查和转换,类型安全性更高,性能更好。

使用 jsoniter 也可能有助于提高性能,但正如我们所看到的,所使用的方法(反射与泛型)仍然对整体速度和效率起着重要作用。

结果表明,在可能的情况下,与反射相比,利用泛型可以带来性能更高的代码,尤其是在编码和解码等类型安全和效率都很重要的任务中。

结论:
何时使用反射

  • 对于类型信息在编译时不可用的元编程任务。
  • 当使用非结构化数据格式(例如 JSON、XML 等)时,运行时的类型断言是不可避免的。

何时使用泛型
  • 当类型安全至关重要时。
  • 当性能成为考虑因素时。从我们的基准测试中观察到:

使用泛型进行类型检查比使用反射快近 18 倍。
使用泛型创建切片比反射快约 4.4 倍。

  • 使用泛型的 JSON 编码/解码jsoniter在时间和内存分配方面都更加高效。
  • 当您想要编写可跨不同类型工作但保持类型安全的可重用代码时。

要点
  • 泛型并不是反射的“一刀切”替代品;相反,它们是可以相互补充的工具。
  • 使用适合工作的正确工具;每个都有自己的优点和缺点。
  • 我们的基准测试表明,在某些情况下,泛型可以比反射提供显着的性能优势,而无需牺牲类型安全性。
  • 随着 Go 语言的发展,反射和泛型的功能和用例也会随之发展。