Go中泛型和反射比較指南

banq發表於2024-02-14

Go 是一種以簡單性為傲的靜態型別語言,自誕生以來已經經歷了無數的變化。經常引發 Go 開發人員討論的兩個功能是反射和最近的泛型。兩者都有相似的目的:為固有的靜態語言引入一定程度的活力和靈活性。但是,雖然反射從早期就已經是 Go 的一部分,但泛型卻是新事物,提供了不同的工具來解決一些相同的問題。

什麼是反射與泛型
Go 中的反射允許您在執行時檢查、修改變數的型別和值並與之互動,而無需在編譯時知道它們的型別。它非常強大,但很容易出錯,並且通常會導致程式碼可讀性較差。

另一方面,泛型引入了型別引數,並允許您編寫型別安全且可重用的函式和資料結構,而不會犧牲效能。Go 最近新增的泛型讓許多開發人員想知道:“我可以用泛型替換一些基於反射的程式碼,以獲得更好的型別安全性和效能嗎?如果是的話,又如何?”

瞭解反射和泛型之間的權衡可能會對 Go 應用程式的設計和效能產生重大影響。隨著 Go 的不斷髮展,保持最新的最佳實踐可以讓您在編寫高效、可維護和健壯的程式碼方面獲得優勢。

讓我們嘗試簡要回顧一下 Go 中反射和泛型的基本概念,並用簡單的並行示例進行說明。

1、反射
反射使程式能夠在執行時檢查和操作變數的型別和值。然而,在這種動態性下,我們通常需要權衡型別安全性和可讀性。考慮動態檢查值型別的任務:

package main 

import
    <font>"fmt" 
   
"reflect"
 ) 

func  main () { 
    x := 42 
   
// 使用反射來確定 'x' 的型別<i>
    t :=reflect.TypeOf(x) 
    fmt.Println(
"Type of x: " , t)   
   
// 輸出:x 型別:int<i>
 }

在上面的示例中,我們使用reflect包來動態確定x.

2、Go 中的泛型
泛型允許您編寫靈活且可重用的程式碼,同時保持型別安全。與我們的反射示例進行類比,讓我們使用泛型來編寫一個可以接受任何型別的值並返回其型別的函式:

package main 

import  <font>"fmt" 

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


func  main () { 
    x := 42
     fmt.Println(
"x 的型別:" , TypeOf(x))   
   
// 輸出:x 的型別:int<i>
 }

在這裡,利用泛型定義的 TypeOf 函式可以接受任何型別的 T,並以字串形式返回其型別。我們實現了與反射示例類似的功能,但在編譯時確保了型別安全。

用泛型取代反射:
本節將研究現實世界中一些常用的反射場景,並探討如何使用 Go 的新泛型功能重新設計這些場景。

a. 反射
假設你正在使用一個接收 interface{} 型別的函式,而你想根據具體型別執行不同的操作。使用 "反射",您可以這樣做

package main

import (
 <font>"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 (
  <font>"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 <font>"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:<i>
// goos: darwin<i>
// goarch: amd64<i>
// pkg: ------<i>
// cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz<i>
// BenchmarkTypeCheckReflect-12  260707183  4.564 ns/op  0 B/op 0 allocs/op<i>
// BenchmarkTypeCheckGeneric-12  1000000000 0.2447 ns/op 0 B/op 0 allocs/op<i>

  • 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 (
 <font>"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<i>
}

b. 使用 "泛型
使用泛型可以建立動態型別片,同時確保型別安全。

package main

import (
 <font>"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<i>
}

C. 基準測試示例 2
我們將把基準測試新增到同一個主軟體包根目錄下的 "main_test.go "檔案中:

package main

import (
 <font>"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 (
 <font>"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<i>
 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<i>
 fmt.Println(EncodeDecodeReflect(
"hello")) // Output: hello<i>

b.使用 泛型
Go 中增加了泛型後,上述過程變得更加簡單。泛型允許在不犧牲資料動態特性的情況下進行型別安全的操作:

package main

import (
 <font>"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<i>
 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<i>
 fmt.Println(EncodeDecode(
"hello")) // Output: hello<i>
}

C. 基準測試示例 3
我們將把基準測試新增到同一個主軟體包中的 "main_test.go "檔案中:

package main

import (
 <font>"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 語言的發展,反射和泛型的功能和用例也會隨之發展。

 

相關文章