Skip to content

Generic

Generic hoặc tên học thuật hơn - đa hình tham số hóa (Parameterized Polymorphism) đề cập đến việc tái sử dụng và linh hoạt mã thông qua tham số hóa loại. Trong nhiều ngôn ngữ lập trình đa hình tham số hóa là một khái niệm quan trọng nó cho phép hàm hoặc cấu trúc dữ liệu xử lý dữ liệu loại khác nhau mà không cần viết mã riêng cho mỗi loại. Go ban đầu không có khái niệm generic nhưng kể từ khi ra đời cộng đồng có tiếng nói cao nhất về Go là hy vọng thêm generic cuối cùng Go vào năm 2022 ở phiên bản 1.18 đã thêm hỗ trợ cho generic.

Thiết kế

Go khi thiết kế generic đã cân nhắc các phương án sau

  • stenciling: đơn hình hóa điển hình như C++ Rust loại này tạo mã template cho mỗi loại được sử dụng hiệu suất này là tốt nhất hoàn toàn không có chi phí runtime nào hiệu suất bằng gọi trực tiếp nhược điểm là làm chậm đáng kể tốc độ biên dịch (so với Go tự thân) đồng thời do tạo mã cho mỗi loại cũng dẫn đến kích thước file nhị phân biên dịch phình to.
  • dictionaries: nó chỉ tạo một bộ mã đồng thời tạo một từ điển loại trong quá trình biên dịch lưu trữ trong đoạn dữ liệu chỉ đọc nó lưu trữ tất cả thông tin loại sẽ được sử dụng khi gọi hàm thì sẽ tra cứu thông tin loại theo từ điển. Cách này sẽ không làm chậm tốc độ biên dịch cũng không gây phình to kích thước nhưng sẽ tạo ra chi phí runtime lớn hiệu suất generic rất kém.

Hai phương pháp trên đại diện cho hai cực đoan Go cuối cùng chọn phương án triển khai là Gcshape stenciling nó là một lựa chọn trung gian đối với các loại có hình dạng bộ nhớ giống nhau (hình dạng xem thế nào do bộ phân phối bộ nhớ quyết định) sẽ sử dụng đơn hình hóa tạo cùng một bộ mã cho chúng ví dụ type Int intint thực tế là cùng một loại nên dùng chung một bộ mã. Nhưng đối với con trỏ thì sao mặc dù tất cả các loại con trỏ đều cùng một hình dạng bộ nhớ ví dụ *int *Person đều là một hình dạng bộ nhớ nhưng chúng không thể dùng chung một bộ mã vì khi giải tham chiếu mục tiêu loại có bố cục bộ nhớ hoàn toàn khác nhau vì vậy Go đồng thời cũng sử dụng từ điển để lấy thông tin loại trong runtime nên generic của Go cũng tồn tại chi phí runtime.

Dẫn nhập

Trước tiên xem một ví dụ đơn giản.

go
func Sum(a, b int) int {
   return a + b
}

Đây là một hàm chức năng rất đơn giản tác dụng là cộng hai số nguyên loại int và trả về kết quả nếu muốn truyền hai số thực loại float64 để tính tổng rõ ràng là không được vì loại không khớp. Một cách giải quyết là định nghĩa thêm một hàm mới như sau

go
func SumFloat64(a, b float64) float64 {
  return a + b
}

Vậy vấn đề là nếu phát triển một bộ công cụ toán học tính tổng hai số của tất cả các loại số lẽ nào mỗi loại đều phải viết một hàm sao rõ ràng là không thể hoặc có thể sử dụng loại any cộng với reflection để判断 như sau

go
func SumAny(a, b any) (any, error) {
  tA, tB := reflect.ValueOf(a), reflect.ValueOf(b)
  if tA.Kind() != tB.Kind() {
    return nil, errors.New("disMatch type")
  }

  switch tA.Kind() {
  case reflect.Int:
  case reflect.Int32:
    ...
  }
}

Nhưng viết như vậy rất rườm rà và hiệu suất thấp. Nhưng logic của hàm Sum đều giống hệt nhau chỉ là cộng hai số mà thôi lúc này cần dùng đến generic vì vậy tại sao cần generic generic là để giải quyết vấn đề không liên quan đến loại thực thi logic loại vấn đề này không quan tâm loại đưa ra là gì chỉ cần hoàn thành thao tác tương ứng là đủ.

Cú pháp

Cách viết generic như sau

go
func Sum[T int | float64](a, b T) T {
   return a + b
}

Tham số hình thức loại: T là một tham số hình thức loại loại cụ thể của tham số hình thức phụ thuộc vào truyền vào loại gì

Ràng buộc loại: int | float64 tạo thành một ràng buộc loại ràng buộc loại này quy định những loại nào được phép ràng buộc quy định phạm vi loại của tham số hình thức loại

Tham số thực tế loại: Sum[int](1,2) chỉ định thủ công loại int int chính là tham số thực tế loại.

Cách sử dụng thứ nhất chỉ định rõ sử dụng loại nào như sau

go
Sum[int](2012, 2022)

Cách sử dụng thứ hai không chỉ định loại để trình biên dịch tự suy luận như sau

go
Sum(3.1415926, 1.114514)

Đây là một slice generic ràng buộc loại là int | int32 | int64

go
type GenericSlice[T int | int32 | int64] []T

Ở đây khi sử dụng không thể bỏ qua tham số thực tế loại

go
GenericSlice[int]{1, 2, 3}

Đây là một bảng băm generic khóa loại phải là có thể so sánh được nên sử dụng interface comparable ràng buộc loại giá trị là V int | string | byte

go
type GenericMap[K comparable, V int | string | byte] map[K]V

Sử dụng

go
gmap1 := GenericMap[int, string]{1: "hello world"}
gmap2 := make(GenericMap[string, byte], 0)

Đây là một struct generic ràng buộc loại là T int | string

go
type GenericStruct[T int | string] struct {
   Name string
   Id   T
}

Sử dụng

go
GenericStruct[int]{
   Name: "jack",
   Id:   1024,
}
GenericStruct[string]{
   Name: "Mike",
   Id:   "1024",
}

Đây là một ví dụ tham số hình thức slice generic

go
type Company[T int | string, S []T] struct {
   Name  string
   Id    T
   Stuff S
}

//Cũng có thể như sau
type Company[T int | string, S []int | []string] struct {
  Name  string
  Id    T
  Stuff S
}

Sử dụng

go
Company[int, []int]{
   Name:  "lili",
   Id:    1,
   Stuff: []int{1},
}

TIP

Trong struct generic khuyến nghị viết như sau

go
type Company[T int | string, S int | string] struct {
  Name  string
  Id    T
  Stuff []S
}

SayAble là một interface generic Person thực hiện interface này.

go
type SayAble[T int | string] interface {
   Say() T
}

type Person[T int | string] struct {
   msg T
}

func (p Person[T]) Say() T {
   return p.msg
}

func main() {
  var s SayAble[string]
  s = Person[string]{"hello world"}
  fmt.Println(s.Say())
}

Interface generic

Interface generic có thể cung cấp khả năng ràng buộc trừu tượng tốt hơn dưới đây là một ví dụ

go
func PrintObj[T fmt.Stringer](s T) {
	fmt.Println(s.String())
}

type Person struct {
	Name string
}

func (p Person) String() string {
	return fmt.Sprintf("Person: %s", p.Name)
}

func main() {
	PrintObj(Person{Name: "Alice"})
}

Cũng có thể sử dụng interface không generic làm tham số hình thức loại của generic

go
func Write[W io.Writer](w W, bs []byte) (int, error) {
	return w.Write(bs)
}

类型断言 generic

Chúng ta có thể sử dụng generic để类型断言 loại any ví dụ như một hàm dưới đây có thể断言 tất cả các loại.

go
func Assert[T any](v any) (bool, T) {
	var av T
	if v == nil {
		return false, av
	}
	av, ok := v.(T)
	return ok, av
}

Tập loại

Sau phiên bản 1.18 định nghĩa của interface trở thành type set interface có tập loại còn được gọi là General interfaces tức interface chung.

An interface type defines a type set

Tập loại chỉ có thể dùng cho ràng buộc loại trong generic không thể dùng cho khai báo loại chuyển đổi loại类型断言. Tập loại là một tập hợp sẽ có tập rỗng tập hợp tập giao tiếp tiếp theo sẽ giải thích ba tình huống này.

Tập hợp

Interface loại SignedInt là một tập loại tập hợp của các loại số nguyên có dấu chính là SignedInt ngược lại SignedInt chính là tập cha của chúng.

go
type SignedInt interface {
   int8 | int16 | int | int32 | int64
}

Loại dữ liệu cơ bản như vậy đối với các interface chung khác cũng như vậy

go
type SignedInt interface {
  int8 | int16 | int | int32 | int64
}

type UnSignedInt interface {
  uint8 | uint16 | uint32 | uint64
}

type Integer interface {
  SignedInt | UnSignedInt
}

Tập giao

Tập loại của interface không rỗng là tập giao của tất cả các phần tử của nó dịch sang tiếng người là: nếu một interface chứa nhiều tập loại không rỗng thì interface đó là tập giao của các tập loại này ví dụ như sau

go
type SignedInt interface {
   int8 | int16 | int | int32 | int64
}

type Integer interface {
   int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64
}

type Number interface {
  SignedInt
  Integer
}

Tập giao trong ví dụ chắc chắn là SignedInt

go
func Do[T Number](n T) T {
   return n
}

Do[int](2)
DO[uint](2) //Không thể biên dịch

Tập rỗng

Tập rỗng là không có tập giao ví dụ như sau Integer trong ví dụ dưới là một tập loại rỗng.

go
type SignedInt interface {
  int8 | int16 | int | int32 | int64
}

type UnsignedInt interface {
  uint8 | uint16 | uint | uint32 | uint64
}

type Integer interface {
  SignedInt
  UnsignedInt
}

Vì số nguyên không dấu và số nguyên có dấu chắc chắn không có tập giao nên tập giao là một tập rỗng bất kể truyền loại gì trong ví dụ dưới đều không thể biên dịch.

go
Do[Integer](1)
Do[Integer](-100)

Interface rỗng

Interface rỗng và tập rỗng không giống nhau interface rỗng là tập hợp của tất cả các tập loại tức chứa tất cả các loại.

go
func Do[T interface{}](n T) T {
   return n
}

func main() {
   Do[struct{}](struct{}{})
   Do[any]("abc")
}

Tuy nhiên chúng ta thường sử dụng any làm tham số hình thức generic vì interface{} không đẹp.

Loại cơ sở

Khi sử dụng từ khóa type khai báo một loại mới cho dù loại cơ sở của nó nằm trong tập loại khi truyền vào cũng vẫn không thể biên dịch.

go
type Int interface {
   int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64
}

type TinyInt int8

func Do[T Int](n T) T {
   return n
}

func main() {
   Do[TinyInt](1) // Không thể biên dịch cho dù loại cơ sở của nó thuộc phạm vi tập loại Int
}

Có hai cách giải quyết cách thứ nhất là thêm loại này vào tập loại nhưng điều này vô nghĩa vì loại cơ sở của TinyIntint8 là nhất quán nên có cách giải quyết thứ hai.

go
type Int interface {
   int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64 | TinyInt
}

Sử dụng ký hiệu ~ để biểu thị loại cơ sở nếu loại cơ sở của một loại thuộc tập loại này thì loại đó thuộc tập loại này như dưới đây

go
type Int interface {
   ~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64
}

Sau khi sửa đổi có thể biên dịch thành công.

go
func main() {
   Do[TinyInt](1) // Có thể biên dịch vì TinyInt nằm trong tập loại Int
}

Điểm cần lưu ý

Generic không thể làm loại cơ bản của một loại

Cách viết sau đây là sai tham số hình thức loại T không thể làm loại cơ bản

go
type GenericType[T int | int32 | int64] T

Mặc dù cách viết sau đây được phép nhưng vô nghĩa và có thể gây ra vấn đề tràn số nên không khuyến nghị

go
type GenericType[T int | int32 | int64] int

Loại generic không thể sử dụng类型断言

Sử dụng类型断言 đối với loại generic sẽ không thể biên dịch generic muốn giải quyết là không liên quan đến loại nếu một vấn đề cần làm logic khác nhau theo loại khác nhau thì根本 không nên sử dụng generic nên sử dụng interface{} hoặc any.

go
func Sum[T int | float64](a, b T) T {
   ints,ok := a.(int) // Không được phép
   switch a.(type) { // Không được phép
   case int:
   case bool:
      ...
   }
   return a + b
}

Struct ẩn danh không hỗ trợ generic

Struct ẩn danh không hỗ trợ generic mã như sau sẽ không thể biên dịch

go
testStruct := struct[T int | string] {
   Name string
   Id T
}[int]{
   Name: "jack",
   Id: 1
}

Hàm ẩn danh không hỗ trợ generic tùy chỉnh

Hai cách viết sau đều không thể biên dịch

go
var sum[T int | string] func (a, b T) T
sum := func[T int | string](a,b T) T{
    ...
}

Nhưng có thể sử dụng loại generic đã có ví dụ trong closure

go
func Sum[T int | float64](a, b T) T {
  sub := func(c, d T) T {
    return c - d
  }
  return sub(a,b) + a + b
}

Không hỗ trợ phương thức generic

Phương thức không thể có tham số hình thức generic nhưng receiver có thể có tham số hình thức generic. Mã như sau sẽ không thể biên dịch

go
type GenericStruct[T int | string] struct {
   Name string
   Id   T
}

func (g GenericStruct[T]) name[S int | float64](a S) S {
   return a
}

Tập loại không thể làm tham số thực tế loại

Bất kỳ interface nào có tập loại đều không thể làm tham số thực tế loại.

go
type SignedInt interface {
  int8 | int16 | int | int32 | int64
}

func Do[T SignedInt](n T) T {
   return n
}

func main() {
   Do[SignedInt](1) // Không thể biên dịch
}

Vấn đề tập giao trong tập loại

Đối với loại không interface trong tập hợp loại không thể có tập giao ví dụ TinyInt~int8 trong ví dụ dưới có tập giao.

go
type Int interface {
   ~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64 | TinyInt // Không thể biên dịch
}

type TinyInt int8

Nhưng đối với interface loại thì cho phép có tập giao như ví dụ dưới

go
type Int interface {
   ~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64 | TinyInt // Có thể biên dịch
}

type TinyInt interface {
  int8
}

Tập loại không thể trực tiếp hoặc gián tiếp thêm chính nó vào tập hợp

Trong ví dụ dưới Floats trực tiếp thêm chính nó vào tập hợp mà Double lại thêm Floats nên lại gián tiếp thêm chính nó vào.

go
type Floats interface {  // Mã không thể biên dịch
   Floats | Double
}

type Double interface {
   Floats
}

Interface comparable không thể thêm vào tập loại

Tương tự cũng không thể thêm vào ràng buộc loại nên cơ bản là sử dụng riêng lẻ.

go
func Do[T comparable | Integer](n T) T { //Không thể biên dịch
   return n
}

type Number interface { // Không thể biên dịch
  Integer | comparable
}

type Comparable interface { // Có thể biên dịch nhưng vô nghĩa
  comparable
}

Tập phương thức không thể thêm vào tập loại

Bất kỳ interface nào chứa phương thức đều không thể thêm vào tập hợp loại

go
type I interface {
    int | fmt.Stringer // cannot use fmt.Stringer in union (fmt.Stringer contains methods)
}

Nhưng chúng có thể làm tập giao nhưng làm như vậy không có ý nghĩa gì

go
type I interface {
    int
    fmt.Stringer
}

Sử dụng

Cấu trúc dữ liệu là tình huống sử dụng phổ biến nhất của generic dưới đây mượn hai cấu trúc dữ liệu để展示 cách sử dụng generic.

Hàng đợi

Dưới đây thực hiện một hàng đợi đơn giản bằng generic trước tiên khai báo loại hàng đợi loại phần tử trong hàng đợi có thể là bất kỳ nên ràng buộc loại là any

go
type Queue[T any] []T

Tổng cộng chỉ có bốn phương thức Pop Peek Push Size mã như sau.

go
type Queue[T any] []T

func (q *Queue[T]) Push(e T) {
  *q = append(*q, e)
}

func (q *Queue[T]) Pop(e T) (_ T) {
  if q.Size() > 0 {
    res := q.Peek()
    *q = (*q)[1:]
    return res
  }
  return
}

func (q *Queue[T]) Peek() (_ T) {
  if q.Size() > 0 {
    return (*q)[0]
  }
  return
}

func (q *Queue[T]) Size() int {
  return len(*q)
}

Trong phương thức PopPeek có thể thấy giá trị trả về là _ T đây là cách sử dụng giá trị trả về có tên nhưng lại sử dụng dấu gạch dưới _ biểu thị đây là ẩn danh điều này không phải là thừa mà là để biểu thị giá trị 0 generic. Do sử dụng generic khi hàng đợi rỗng cần trả về giá trị 0 nhưng do loại không biết không thể trả về loại cụ thể mượn cách trên có thể trả về giá trị 0 generic. Cũng có thể giải quyết vấn đề giá trị 0 bằng cách khai báo biến generic đối với một biến generic giá trị mặc định của nó là giá trị 0 của loại đó như sau

go
func (q *Queue[T]) Pop(e T) T {
    var res T
  if q.Size() > 0 {
    res = q.Peek()
    *q = (*q)[1:]
    return res
  }
  return res
}

Heap

Ví dụ hàng đợi ở trên do không có yêu cầu gì đối với phần tử nên ràng buộc loại là any. Nhưng heap thì khác heap là một loại cấu trúc dữ liệu đặc biệt nó có thể判断 giá trị lớn nhất hoặc nhỏ nhất trong thời gian O(1) nên nó có một yêu cầu đối với phần tử đó là phải là loại có thể sắp xếp được nhưng loại có thể sắp xếp built-in chỉ có số và chuỗi nên khi khởi tạo heap cần truyền vào một bộ so sánh tùy chỉnh bộ so sánh do người gọi cung cấp và bộ so sánh cũng phải sử dụng generic như sau

go
type Comparator[T any] func(a, b T) int

Dưới đây là một triển khai binary heap đơn giản trước tiên khai báo struct generic vẫn sử dụng any để ràng buộc như vậy có thể lưu trữ bất kỳ loại nào

go
type Comparator[T any] func(a, b T) int

type BinaryHeap[T any] struct {
  s []T
  c Comparator[T]
}

Triển khai vài phương thức

go
func (heap *BinaryHeap[T]) Peek() (_ T) {
  if heap.Size() > 0 {
    return heap.s[0]
  }
  return
}

func (heap *BinaryHeap[T]) Pop() (_ T) {
  size := heap.Size()
  if size > 0 {
    res := heap.s[0]
    heap.s[0], heap.s[size-1] = heap.s[size-1], heap.s[0]
    heap.s = heap.s[:size-1]
    heap.down(0)
    return res
  }
  return
}

func (heap *BinaryHeap[T]) Push(e T) {
  heap.s = append(heap.s, e)
  heap.up(heap.Size() - 1)
}

func (heap *BinaryHeap[T]) up(i int) {
  if heap.Size() == 0 || i < 0 || i >= heap.Size() {
    return
  }
  for parentIndex := i>>1 - 1; parentIndex >= 0; parentIndex = i>>1 - 1 {
    // greater than or equal to
    if heap.compare(heap.s[i], heap.s[parentIndex]) >= 0 {
      break
    }
    heap.s[i], heap.s[parentIndex] = heap.s[parentIndex], heap.s[i]
    i = parentIndex
  }
}

func (heap *BinaryHeap[T]) down(i int) {
  if heap.Size() == 0 || i < 0 || i >= heap.Size() {
    return
  }
  size := heap.Size()
  for lsonIndex := i<<1 + 1; lsonIndex < size; lsonIndex = i<<1 + 1 {
    rsonIndex := lsonIndex + 1

    if rsonIndex < size && heap.compare(heap.s[rsonIndex], heap.s[lsonIndex]) < 0 {
      lsonIndex = rsonIndex
    }

    // less than or equal to
    if heap.compare(heap.s[i], heap.s[lsonIndex]) <= 0 {
      break
    }
    heap.s[i], heap.s[lsonIndex] = heap.s[lsonIndex], heap.s[i]
    i = lsonIndex
  }
}

func (heap *BinaryHeap[T]) Size() int {
  return len(heap.s)
}

func NewHeap[T any](n int, c Comparator[T]) BinaryHeap[T] {
	var heap BinaryHeap[T]
	heap.s = make([]T, 0, n)
	heap.Comparator = c
	return heap
}

Sử dụng như sau

go
type Person struct {
  Age  int
  Name string
}

func main() {
  heap := NewHeap[Person](10, func(a, b Person) int {
    return cmp.Compare(a.Age, b.Age)
  })
  heap.Push(Person{Age: 10, Name: "John"})
  heap.Push(Person{Age: 18, Name: "mike"})
  heap.Push(Person{Age: 9, Name: "lili"})
  heap.Push(Person{Age: 32, Name: "miki"})
  fmt.Println(heap.Peek())
  fmt.Println(heap.Pop())
  fmt.Println(heap.Peek())
}

Kết quả xuất

{9 lili}
{9 lili}
{10 John}

Có generic hỗ trợ loại không thể sắp xếp ban đầu sau khi truyền bộ so sánh cũng có thể sử dụng heap làm như vậy chắc chắn thanh lịch và tiện lợi hơn nhiều so với trước đây sử dụng interface{} để thực hiện chuyển đổi và类型断言 loại.

Pool đối tượng

Pool đối tượng phiên bản gốc chỉ có thể dùng loại any mỗi lần lấy ra đều phải类型断言 sau khi cải tạo đơn giản bằng generic có thể省去 công việc này.

go
package main

import (
	"bytes"
	"fmt"
	"sync"
)

func NewPool[T any](newFn func() T) *Pool[T] {
	return &Pool[T]{
		pool: &sync.Pool{
			New: func() interface{} {
				return newFn()
			},
		},
	}
}

type Pool[T any] struct {
	pool *sync.Pool
}

func (p *Pool[T]) Put(v T) {
	p.pool.Put(v)
}

func (p *Pool[T]) Get() T {
	var v T
	get := p.pool.Get()
	if get != nil {
		v, _ = get.(T)
	}
	return v
}

func main() {
	bufferPool := NewPool(func() *bytes.Buffer {
		return bytes.NewBuffer(nil)
	})

	for range 100 {
		buffer := bufferPool.Get()
		buffer.WriteString("Hello, World!")
		fmt.Println(buffer.String())
		buffer.Reset()
		bufferPool.Put(buffer)
	}
}

Tóm tắt

Một đặc điểm lớn của Go là tốc độ biên dịch rất nhanh biên dịch nhanh là vì trình biên dịch làm ít tối ưu trong quá trình biên dịch việc thêm generic sẽ khiến khối lượng công việc của trình biên dịch tăng lên công việc phức tạp hơn điều này tất nhiên sẽ dẫn đến tốc độ biên dịch chậm đi thực tế khi Go1.18 vừa推出 generic quả thực khiến biên dịch chậm hơn nhóm Go vừa muốn thêm generic vừa không muốn làm chậm tốc độ biên dịch quá nhiều nhà phát triển dùng thuận tay trình biên dịch sẽ khó chịu ngược lại trình biên dịch nhẹ nhàng (nhẹ nhàng nhất đương nhiên là đừng có generic) nhà phát triển sẽ khó chịu generic hiện tại là sản phẩm thỏa hiệp giữa hai bên này.

Golang by www.golangdev.cn edit