Go Generic Function

Go 泛型使用筆記

沒有泛型的日子

假設要寫一個加總的函式,int 一份、float64 一份:

func SumInts(nums []int) int {
var total int
for _, n := range nums {
total += n
}
return total
}
func SumFloats(nums []float64) float64 {
var total float64
for _, n := range nums {
total += n
}
return total
}

兩個函式除了型別外一樣,想避開重複通常只有 any

// 用 any(也就是 interface{})接,代價是每次都要型別斷言,且錯誤要等到執行期才會炸
func SumAny(nums []any) any {
var total int
for _, n := range nums {
total += n.(int) // 傳進 float64 就 panic
}
return total
}

第一個泛型函式

泛型就像是型別的參數,寫在函式名稱後面的方括號裡:

func Sum[T int | float64](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
  • [T int | float64]:宣告一個型別參數 T,後面那串是約束(Constraint),意思是 T 只能是 intfloat64
  • nums []T、回傳值 T:函式簽章裡就可以拿 T 當成一般型別用。
  • var total TT 的零值。int0float640.0

呼叫時通常什麼都不用多寫,編譯器會從參數推斷出 T

Sum([]int{1, 2, 3}) // T 推斷成 int,得到 6
Sum([]float64{1.5, 2.5}) // T 推斷成 float64,得到 4
Sum[int]([]int{1, 2, 3}) // 也可以手動指定
Sum([]string{"a", "b"}) // 編譯錯誤:string 不在約束裡

錯誤發生在編譯期,不是執行期。這是泛型跟 any 搭配型別斷言最大的差別。

約束怎麼寫

約束本質上是一個 interface,只是它描述的不只是方法,還可以是「哪些型別可以代入」。

any:什麼型別都行

any 當約束時代表不限型別,但也因此不能對值做任何假設,只能搬移、比較指標、放進容器。經典的例子是 Map

func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, 0, len(s))
for _, v := range s {
result = append(result, f(v))
}
return result
}
names := Map([]int{1, 2, 3}, func(n int) string {
return fmt.Sprintf("no.%d", n)
})
// [no.1 no.2 no.3]

一個函式可以有多個型別參數,這裡 T 是輸入元素型別、U 是輸出元素型別,兩者由 f 的簽章串起來。

comparable:可以用 == 比較

要在函式裡寫 ==!=,約束就得是 comparable

func Contains[T comparable](s []T, target T) bool {
for _, v := range s {
if v == target {
return true
}
}
return false
}
Contains([]string{"a", "b"}, "b") // true

comparable 涵蓋數字、字串、布林、指標、channel,以及欄位全都可比較的 struct 和 array;slice、map、func 不在其中,因為它們本來就不能用 == 比較。

cmp.Ordered:可以用 < > 比較

comparable 只保證 ==,要比大小得用標準函式庫 cmp 提供的 cmp.Ordered

import "cmp"
func Max[T cmp.Ordered](s []T) (T, bool) {
var zero T
if len(s) == 0 {
return zero, false
}
m := s[0]
for _, v := range s[1:] {
if v > m {
m = v
}
}
return m, true
}
Max([]int{3, 1, 4}) // 4, true
Max([]string{"b", "a", "c"}) // "c", true

自訂約束與 ~ 符號

約束多了就把它抽成具名的 interface:

type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~float32 | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}

~int 的波浪號讀作「底層型別是 int 的所有型別」。差別在這裡:

type Celsius float64
Sum([]Celsius{36.5, 37.2}) // 約束寫 ~float64 才過,寫 float64 會被擋下

沒有 ~ 的話,只有 float64 本人算數,Celsius 這種自訂型別會被排除。實務上寫約束預設加 ~ 就對了。

約束裡也可以放方法,這時它就跟一般 interface 沒兩樣:

type Stringer interface {
String() string
}
func JoinAll[T Stringer](items []T) string {
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, item.String())
}
return strings.Join(parts, ", ")
}

泛型型別

除了函式,struct 也可以有型別參數,例如一個型別安全的 Stack:

type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{}
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}
func (s *Stack[T]) Len() int {
return len(s.items)
}
// Go 1.27 起,方法也可以宣告自己的型別參數。
func (s *Stack[T]) MapTo[U any](f func(T) U) *Stack[U] {
result := NewStack[U]()
for _, item := range s.items {
result.Push(f(item))
}
return result
}

用起來:

s := NewStack[string]()
s.Push("a")
s.Push("b")
v, ok := s.Pop() // "b", true
s.Push(42) // 編譯錯誤:型別已經被鎖成 string
lengths := s.MapTo(func(v string) int { return len(v) })
lengths.Push(42) // OK,lengths 是 *Stack[int]
  • 建立型別實例時不能省略型別參數,&Stack{} 不合法,要寫 &Stack[string]{}(所以才常搭一個 NewStack[T]() 建構函式讓推斷生效)。
  • 方法的 receiver 要帶上 [T]Go 1.27 起,方法本身也能宣告新的型別參數,如上例的 MapTo[U any];在舊版 Go,這種需求只能寫成獨立的函式。
  • interface 的方法仍然不能宣告型別參數,而且 generic method 不能用來實作 interface method。

標準函式庫已經幫你寫好了

上面示範的 ContainsMax 其實不用自己實作,slicesmaps 這兩個泛型套件已經涵蓋大部分日常需求:

import (
"maps"
"slices"
)
nums := []int{3, 1, 4, 1, 5}
slices.Contains(nums, 4) // true
slices.Index(nums, 4) // 2
slices.Max(nums) // 5
slices.Sort(nums) // 就地排序成 [1 1 3 4 5]
slices.Reverse(nums)
people := []Person{{Name: "b"}, {Name: "a"}}
slices.SortFunc(people, func(x, y Person) int {
return cmp.Compare(x.Name, y.Name)
})
m := map[string]int{"a": 1, "b": 2}
keys := slices.Collect(maps.Keys(m)) // maps.Keys 回傳的是 iterator
slices.Sort(keys) // map 走訪順序隨機,要排序自己來

什麼時候不要用泛型

泛型解決的是「同一段邏輯要套在多種型別上」,不是「製造多餘的抽象」:

  • 只有一種型別會用到就別寫泛型,等真的出現第二種再改,Go 的慣例一向是先重複再抽象。
  • 只需要呼叫方法就用普通 interface。像 func Print(s fmt.Stringer) 這種,寫成 func Print[T fmt.Stringer](s T) 沒有得到任何好處,只是多打字。差別在於泛型保留了具體型別(可以回傳 T、放進 []T),一般 interface 則會抹掉。
  • 邏輯依型別而異就別硬塞。如果函式裡開始出現對型別分支的判斷,那代表這其實是兩個函式。

總結

概念寫法用途
型別參數func F[T any](...)把型別變成參數
型別推斷F(v) 而非 F[int](v)多數情況免手動指定
any[T any]不限型別,但不能對值做假設
comparable[T comparable]允許 ==!=
cmp.Ordered[T cmp.Ordered]允許 <>
~~int | ~float64涵蓋底層型別相同的自訂型別
泛型型別type Stack[T any] struct型別安全的容器
型別參數寫在方括號裡、約束決定你能對值做什麼

延伸閱讀