ข้ามไปยังเนื้อหา

Generics

ก่อน Go 1.18 การเขียน function ที่ทำงานกับหลาย type ต้องการ code ซ้ำหรือใช้ interface{} + type assertion ที่ runtime ซึ่งสูญเสีย type safety Generics แก้ปัญหานี้: เขียนครั้งเดียว compiler สร้าง code เฉพาะ type และตรวจสอบการใช้งานที่ compile time

ตัวอย่างเช่น function Max ที่ทำงานได้ทั้ง int และ float64 ก่อนหน้านี้ต้องเขียนสอง function ที่เหมือนกัน หรือใช้ interface{} parameter ที่ยอมรับ type ผิดอย่างเงียบ ๆ ด้วย generics function เดียวครอบคลุมทั้งสอง — และ compiler ปฏิเสธทุก call ที่ใช้ type ที่ไม่รองรับ

syntax ของ generic function คือ func FuncName[T Constraint](args) returnType โดย [T Constraint] เรียกว่า type parameter list T เป็น placeholder ที่ compiler แทนด้วย concrete type ที่แต่ละ call site

func Max[T Number](a, b T) T {
if a > b {
return a
}
return b
}

ที่ call site Go มักจะ อนุมาน T จาก argument types ได้ เขียน Max(3, 7) แล้ว compiler อนุมานว่า T เป็น int — ไม่ต้องเขียน Max[int](3, 7) อย่างชัดเจน ต้องระบุ type argument อย่างชัดเจนเฉพาะเมื่อการอนุมานคลุมเครือหรือเมื่อ call โดยไม่มี argument

constraint คือ interface ที่จำกัดว่า type ใดสามารถแทน type parameter ได้ constraints จะปรากฏหลังชื่อ type parameter ใน list [T Constraint]

  • any — constraint ที่กว้างที่สุด; เทียบเท่ากับ interface{} อนุญาต type ใดก็ได้ แต่จำกัด operations ที่ทำได้กับค่าของ type T (เฉพาะ operations ที่ valid กับทุก type: การ assign, การส่งไปยัง function เป็นต้น)
  • comparable — type ใดก็ตามที่รองรับ == และ != ใช้เมื่อ generic function ต้องเปรียบเทียบค่าเพื่อความเท่าเทียม

เขียน custom constraints โดยใช้ interface union syntax ได้:

type Number interface {
~int | ~float64
}

prefix ~ หมายถึง “type ใดก็ตามที่มี underlying type เป็น int หรือ float64” นี่คือ tilde operator: หากไม่มี ~ เฉพาะ named type นั้นเท่านั้นที่ตรงกัน; เมื่อมี ~ custom types เช่น type Celsius float64 ก็ satisfy ~float64 ได้เช่นกัน — นี่คือ defined (named) type ทำให้ constraints ทำงานได้ตามธรรมชาติกับ type แบบนี้ ส่วน true alias อย่าง type C = float64 เป็น type เดียวกับ float64 อยู่แล้ว จึงไม่ต้องใช้ ~ เลย

constraint interface ที่มี type term คั่นด้วย | (หรือมี ~) ใช้ได้แค่ข้างใน list [T Constraint] เท่านั้น — ห้ามใช้เป็น type ของตัวแปรธรรมดา, field, หรือ parameter เลย แม้จะเขียนด้วย syntax interface { ... } ก็ตาม:

type Number interface {
~int | ~float64
}
var x Number // BUG: constraint interfaces aren't usable this way

ตรวจสอบแล้วบน 1.26.5 นี่คือ compile error:

cannot use type Number outside a type constraint: interface contains type constraints

interface ที่ไม่มี type term เลย (มีแค่ method อย่าง error หรือ io.Reader) ใช้เป็น type ธรรมดาได้เสมอ — ข้อจำกัดนี้ใช้เฉพาะกับ interface ที่มี type term คั่นด้วย | เพราะมันมีไว้เพื่ออธิบาย constraint ตอน compile time เท่านั้น ไม่ใช่ interface value ตอน runtime

comparable ดูเหมือนจะรับประกันว่า == ไม่มีวันล้มเหลว แต่ไม่ใช่ทั้งหมด ตั้งแต่ Go 1.20 any (และ interface type ใด ๆ) satisfy comparable ได้ เพราะ static type ของ interface value รองรับ == ทาง syntax เสมอ ส่วนการเปรียบเทียบจะสำเร็จจริงหรือไม่ขึ้นอยู่กับ dynamic value ที่เก็บอยู่ข้างในตอน runtime:

func Equal[T comparable](a, b T) bool {
return a == b
}
var a, b any = []int{1, 2}, []int{1, 2}
Equal(a, b) // compiles fine -- any satisfies comparable

ตรวจสอบแล้วบน 1.26.5 การเรียกนี้ panic เพราะ slice ไม่ comparable แม้ interface ที่ถือมันจะ comparable ก็ตาม:

panic: runtime error: comparing uncomparable type []int

type ที่ comparable ตามปกติ (int, string, struct ที่มี field เป็น comparable ทั้งหมด) ไม่มีวันเจอปัญหานี้ — ความเสี่ยงจะปรากฏเฉพาะเมื่อ T ถูก instantiate ด้วย any หรือ interface type อื่น และ caller ควบคุมไม่ได้ว่าข้างในจะมีอะไร

Go 1.21 นำเสนอ package cmp ซึ่ง export cmp.Ordered — constraint แบบ idiomatic สำหรับ type ที่รองรับ <, <=, >, >=, == ครอบคลุม integer, float, และ string ทุกชนิด การใช้ cmp.Ordered สะอาดกว่าการเขียน union เต็มรูปแบบ ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ... | ~float64 | ~string ด้วยตัวเอง

import "cmp"
func Min[T cmp.Ordered](a, b T) T {
if a < b {
return a
}
return b
}

playground snippet ด้านล่างใช้ local Number interface แทน cmp.Ordered เพื่อให้เห็น constraint อยู่ในไฟล์เดียวกัน

ก่อนที่ cmp.Ordered จะเข้ามาอยู่ใน standard library (Go 1.21) package ของ community อย่าง golang.org/x/exp/constraints ทำหน้าที่นี้อยู่ก่อน — constraints.Ordered มีรูปแบบเดียวกันและมีมาก่อน stdlib version โค้ดใหม่ควรใช้ cmp.Ordered; constraints.Ordered ยังพบได้ในโค้ดที่เขียนสำหรับ Go 1.18–1.20 หรือ package ที่ยังไม่ migrate และทั้งสองใช้แทนกันได้เมื่อคุณควบคุมทั้งสองฝั่ง

struct ก็สามารถ parameterize ได้เช่นกันเหมือนกับ function ประกาศ type parameter หลังชื่อ type:

type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}

methods บน generic type ใช้ type parameter ซ้ำใน receiver — (s *Stack[T]) — แต่ไม่เพิ่ม constraint ใหม่ constraint ถูกกำหนดไว้แล้วครั้งเดียวตอนประกาศ type

Pop ด้านบนใช้ var zero T เพื่อสร้าง zero value ของ T ไม่ว่ามันจะเป็นอะไร — 0 สำหรับตัวเลข, "" สำหรับ string, nil สำหรับ slice และ pointer *new(T) ทำสิ่งเดียวกัน: new(T) allocate T ที่เป็น zero แล้วคืน *T และการ dereference มันก็ได้ค่านั้นมา ทั้งสองแบบเทียบเท่ากันและพบได้ในโค้ดจริง var zero T เป็น idiom ที่พบบ่อยกว่าเพราะอ่านตรง ๆ ว่า “zero value ของ T” โดยไม่ต้องผ่านขั้นตอน allocate-แล้ว-dereference เพิ่ม

type แบบ generic ไม่เคย infer type argument ให้เลย — ทุกการใช้งานต้อง instantiate อย่างชัดเจน รวมถึงการประกาศตัวแปรเปล่า ๆ ด้วย:

type Stack[T any] struct{ items []T }
var s Stack // BUG: missing a type argument

ตรวจสอบแล้วบน 1.26.5:

cannot use generic type Stack[T any] without instantiation

ทางแก้คือ var s Stack[int] (หรือ element type ที่ต้องการ) นี่ต่างจาก function แบบ generic ซึ่งมักไม่ต้องระบุ type argument อย่างชัดเจนเพราะ Go อนุมาน T จาก arguments ที่ call site ได้ — แต่การอนุมานนั้นดูแค่ arguments เท่านั้น ไม่เคยดูจาก return type ที่คาดหวัง:

func Zero[T any]() T {
var zero T
return zero
}
var x int = Zero() // BUG: nothing to infer T from

ตรวจสอบแล้วบน 1.26.5:

in call to Zero, cannot infer T (declared at ./main.go:5:11)

แม้ผลลัพธ์จะถูก assign ให้ int Go ก็ไม่ทำ return-type inference ให้ — ต้องเขียน Zero[int]()

ตั้งแต่ Go 1.24 type alias รับ type parameter ได้ เหมาะกับการย่อ instantiation ยาว ๆ โดยไม่ต้องสร้าง named type ใหม่:

type Set[T comparable] = map[T]struct{}
var seen Set[string] = map[string]struct{}{} // same type, just a shorter name

generic type อ้างถึงตัวเองใน type parameter list ของตัวเองได้ ใช้แสดงความหมายว่า “type ที่รวมกับค่าของ type เดียวกันได้” — รูปแบบ Adder แบบคลาสสิก:

package main
import "fmt"
type Adder[A Adder[A]] interface {
Add(A) A
}
func Sum[A Adder[A]](xs ...A) A {
var acc A
for _, x := range xs {
acc = acc.Add(x)
}
return acc
}
type Money int
func (m Money) Add(o Money) Money { return m + o }
func main() {
fmt.Println(Sum(Money(5), Money(10), Money(20)))
}

ก่อน Go 1.27 มีแค่ function และ type เท่านั้นที่ประกาศ type parameter ได้ method ใช้ได้เฉพาะ parameter ของ receiver Go 1.27 ยกข้อจำกัดนี้: method ประกาศ type parameter ของตัวเองได้ standard library ใช้มันใน math/rand/v2 ที่ (*Rand).N ตอนนี้ทำงานเหมือน rand.N ระดับบนสุด:

// math/rand/v2, Go 1.27
func (r *Rand) N[Int intType](n Int) Int
r := rand.New(rand.NewPCG(1, 2))
d := r.N(10 * time.Second) // Int inferred as time.Duration
i := r.N[int64](100) // or explicit

type ของคุณเองก็ทำได้เหมือนกัน:

package main
import (
"fmt"
"math/rand/v2"
"time"
)
type Registry struct{ items map[string]any }
// Get declares its own type parameter T — allowed since Go 1.27.
func (r *Registry) Get[T any](key string) (T, bool) {
v, ok := r.items[key].(T)
return v, ok
}
func main() {
reg := &Registry{items: map[string]any{"port": 8080, "host": "localhost"}}
port, ok := reg.Get[int]("port")
fmt.Println(port, ok)
host, ok := reg.Get[string]("host")
fmt.Println(host, ok)
_, ok = reg.Get[int]("host") // wrong type: zero value, false
fmt.Println(ok)
r := rand.New(rand.NewPCG(1, 2))
d := r.N(10 * time.Second) // Int inferred as time.Duration
i := r.N[int64](100) // or explicit
fmt.Println(d < 10*time.Second, i < 100)
}

สองกฎที่ต้องจำ: method ใน interface ประกาศ type parameter ไม่ได้ และ generic method จึงใช้ satisfy interface ไม่ได้ generic method ต้องใช้ Go 1.27+ (go 1.27 ใน go.mod)

เดิมการอนุมาน type argument ของ generic function จำกัดอยู่แค่ตอน call เท่านั้น ตอนนี้มีอีกสองบริบทที่อนุมานได้เช่นกัน แต่มาจากคนละ release:

  • assign ให้ตัวแปรที่เป็น function type ตรงกัน — ทำงานได้ตั้งแต่ Go 1.21 แล้ว
  • convert แบบชัดเจนเป็น function type ที่ตรงกัน — นี่คือส่วนที่เพิ่มใน Go 1.27
func Max[T cmp.Ordered](a, b T) T { /* ... */ }
var maxInt func(int, int) int = Max // T inferred as int (Go 1.21+)
sortBy := (func(string, string) string)(Max) // T inferred as string (Go 1.27)

รูปแบบ assignment compile ผ่านบน toolchain Go 1.26.5 ของคอร์สนี้ (เมื่อ go.mod ระบุ go 1.21 ขึ้นไป) แต่รูปแบบ conversion ไม่ผ่าน: บน 1.26.5 compiler ฟ้อง cannot use generic function Max without instantiation ยืนยันแล้วว่าทำงานได้จริงบน Go 1.27 ผ่าน go.dev playground — โค้ดด้านบนรันได้เฉพาะที่นั่น

package main
import "fmt"
type Number interface {
~int | ~float64
}
func Max[T Number](a, b T) T {
if a > b {
return a
}
return b
}
func Map[T, U any](slice []T, f func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = f(v)
}
return result
}
func Filter[T any](slice []T, pred func(T) bool) []T {
var result []T
for _, v := range slice {
if pred(v) {
result = append(result, v)
}
}
return result
}
func main() {
fmt.Println(Max(3, 7))
fmt.Println(Max[float64](3.14, 2.71))
nums := []int{1, 2, 3, 4, 5}
doubled := Map(nums, func(n int) int { return n * 2 })
fmt.Println(doubled)
words := []string{"go", "generics", "fun", "code"}
long := Filter(words, func(s string) bool { return len(s) > 3 })
fmt.Println(long)
}

generics แก้ปัญหาเฉพาะเจาะจง: algorithm เดียวกัน เขียนครั้งเดียว ใช้กับหลาย concrete type โดยตรวจสอบตอน compile time มันเป็นเครื่องมือที่ผิดเมื่อ:

  • มี concrete type เดียวที่ต้องการใช้จริง ๆ Stack ที่ใช้แค่กับ int ใน package เดียวไม่จำเป็นต้องมี type parameter — ค่อยเพิ่มทีหลังถ้ามี use case ที่สองเกิดขึ้นจริง
  • สิ่งที่ต้องการจริง ๆ คือ runtime polymorphism ถ้า caller ต้องเลือกพฤติกรรมต่างกันตอน runtime (Reader, Handler, plugin) นั่นคือ interface ไม่ใช่ type parameter — generics ถูก resolve ตอน compile time จึงไม่สามารถแสดงออกว่า “เลือก implementation ไหนตอน runtime” แบบที่ interface value ทำได้
  • body ของ function แทบไม่ได้ใช้ T เลย function ที่ส่งผ่าน T โดยไม่แตะต้องมันเลย (ไม่มี operation เฉพาะ T) บางครั้งอ่านง่ายกว่าถ้าใช้ any กับ type assertion แค่จุดเดียวที่จำเป็น แทนที่จะร้อย type parameter ผ่านทั้ง call chain เพื่อ use case แคบ ๆ อันเดียว
  • constraint ต้องใหญ่โตหรือขัดแย้งกันเองมาก แค่เพื่อทำให้ function เดียวเป็น generic — ปกติแล้วเป็นสัญญาณว่ามี algorithm ที่ต่างกันจริง ๆ สองตัวถูกยัดเข้ารูปแบบเดียว
flowchart LR
  subgraph Distinct["different GC shapes -- separate compiled code"]
    IntCall["Max[int](3, 7)"] --> IntCode["compiled Max for int"]
    F64Call["Max[float64](3, 7)"] --> F64Code["compiled Max for float64"]
  end
  subgraph Shared["pointer-shaped types -- one shared stencil"]
    PtrACall["Max[*A](...)"] --> SharedCode["compiled Max for pointer shape"]
    PtrBCall["Max[*B](...)"] --> SharedCode
    SharedCode --> Dict["runtime dictionary picks *A vs *B behavior"]
  end
GC-shape stenciling: shape ที่ต่างกันได้ code ของตัวเอง; type ที่ shape เดียวกันแชร์ตัวเดียวบวก dictionary

เมื่อโค้ดที่ AI generate ใช้ type parameter ให้มองหาสิ่งเหล่านี้ก่อนเชื่อมัน:

  • constraint interface (ที่มี type term คั่นด้วย | หรือ ~) ถูกใช้เป็น type ของตัวแปร, field, หรือ parameter แทนที่จะใช้แค่ข้างใน [T Constraint] หรือเปล่า? นั่นคือ compile error ไม่ใช่ runtime surprise
  • generic type ถูกใช้โดยไม่มี type argument (var s Stack แทนที่จะเป็น var s Stack[int]) หรือเปล่า? type ไม่เคย infer ให้เลย — มีแค่ function call เท่านั้นที่ infer ได้ และ infer จาก argument เท่านั้น
  • โค้ดสมมติว่า == จาก Equal[T comparable] หรือคล้ายกัน panic ไม่ได้เลยหรือเปล่า? ถ้า T เป็น any หรือ interface type อื่นได้ ค่าที่ไม่ comparable ข้างในจะ panic ตอน runtime
  • มี generic function ที่ concrete type เดียว หรือ interface ธรรมดา จะสื่อความหมายเดียวกันได้โดยใช้กลไกน้อยกว่าหรือเปล่า?
  • var zero T (หรือ *new(T)) ถูกใช้สร้างกรณี “ไม่มีผลลัพธ์” แต่ caller กลับปฏิบัติกับ zero value เหมือนมันมีความหมายต่างจาก “ไม่มี” หรือเปล่า — เช่น 0 หมายถึงทั้ง “ไม่มีค่า” และ “ค่าคือศูนย์” พร้อมกัน?

🐛 หา bug ในโค้ดนี้

func Equal[T comparable](a, b T) bool {
return a == b
}
func main() {
var a, b any = []int{1, 2}, []int{1, 2}
fmt.Println(Equal(a, b))
}
เฉลย

any satisfy comparable ได้ตั้งแต่ Go 1.20 — การตรวจ constraint ผ่านตอน compile time แต่ == ระหว่างค่า any สองตัวเทียบ concrete value ที่เก็บอยู่ข้างในตอน runtime และ []int ไม่ comparable ตรวจสอบแล้วบน 1.26.5: โค้ดนี้ panic ด้วย panic: runtime error: comparing uncomparable type []int comparable รับประกันแค่ว่าโค้ด compile ผ่าน ไม่ได้รับประกันว่าทุก instantiation รันได้ปลอดภัย — ปัญหานี้จะเกิดเฉพาะเมื่อ T ลงเอยเป็น any หรือ interface type อื่นที่ถือ slice, map, หรือ func

package main
import "fmt"
// Zero returns the zero value of T -- var zero T is the idiomatic form.
func Zero[T any]() T {
var zero T
return zero
}
// ZeroAlt is equivalent using *new(T): new(T) allocates a T and zeroes it,
// then *new(T) dereferences to get the value. var zero T is more common.
func ZeroAlt[T any]() T {
return *new(T)
}
// Equal works for any comparable type -- including plain values, where == never panics.
func Equal[T comparable](a, b T) bool {
return a == b
}
func main() {
fmt.Println(Zero[int](), Zero[string](), Zero[[]int]() == nil)
fmt.Println(ZeroAlt[int](), ZeroAlt[string]() == "")
fmt.Println(Equal(3, 3))
fmt.Println(Equal("go", "rust"))
// any satisfies comparable since Go 1.20 -- fine as long as the
// dynamic value inside it is itself comparable.
var a, b any = 3, 3
fmt.Println(Equal(a, b))
}
สิ่งที่ได้ประโยชน์ต้นทุน
type parameterscode reuse โดยไม่ต้องใช้ interface{}compile time เพิ่มขึ้น, syntax ซับซ้อน
type constraintscompile-time type safetyต้อง define constraint interface ก่อนใช้
comparable constraintใช้ == กับ generic type ได้ไม่รองรับ deep equality (slice, map)
~T (tilde/approximation)constraint ครอบคลุม named types เช่น type MyID intเข้าใจยากสำหรับผู้เริ่มต้น
  • generics ทำให้ binary ใหญ่ขึ้นมาก — Go ใช้ GC-shape stenciling: type ที่มี pointer/interface shape เดียวกันแชร์ instantiation เดียวกันและใช้ dictionary ส่ง type info ตอน runtime ไม่ขยายแบบ C++ templates
  • generic function เร็วกว่า interface เสมอ — เพราะ pointer/interface shape แชร์ instantiation ผ่าน dictionary กรณีนี้ generics อาจช้ากว่า interface ด้วยซ้ำ; ส่วน value type (int, struct) ได้ code เฉพาะของตัวเองต่อ type จึงเร็วและ avoid boxing ได้จริง
  • generics เหมือน C++ templates — Go generics ตรวจสอบ constraint ตอน definition ไม่ใช่ instantiation — error messages ดีกว่ามาก
  • ควรใช้ generics แทน interface เสมอ — interface เหมาะกว่าสำหรับ runtime polymorphism, generics เหมาะกว่าสำหรับ compile-time type-safe algorithms

💡 ตัวอย่างจากของจริง

slices: generic slices.Sort[S ~[]E, E cmp.Ordered](s S) — type-safe sort ที่ทำงานกับทุก ordered type โดยไม่ต้อง cast

ใน func Max[T Number](a, b T) T ส่วน [T Number] หมายความว่าอะไร?
โค้ดนี้ compile ผ่านไหม?
type Number interface {
    ~int | ~float64
}

func main() {
    var x Number
}
จะเกิดอะไรขึ้น?
type Stack[T any] struct{ items []T }

func main() {
    var s Stack
}
constraint ใดที่อนุญาต type ที่รองรับ == และ !=?
ที่ call site Max(3, 7) จำเป็นต้องเขียน Max[int](3, 7) หรือไม่?
Stack[T] แบบ generic มี method Pop คืนค่า (T, bool) var zero T ข้างใน Pop จะได้ค่าอะไรเมื่อ T เป็น []int?