十年磨一劍 go 1-18 泛型

泛型引入了抽象,無用的抽象帶來複雜性。

什麼是泛型

泛型程序設計(generic programming)是程序設計語言的一種風格或範式。泛型允許程序員在強類型程序設計語言中編寫代碼時使用一些以後才指定的類型,在實例化時作爲參數指明這些類型。各種程序設計語言和其編譯器、運行環境對泛型的支持均不一樣。

Java 和 C# 稱之爲泛型(generics)ML、Scala 和 Haskell 稱之爲參數多態(parametric polymorphism);C++ 和 D 稱之爲模板(template)。具有廣泛影響的 1994 年版的《Design Patterns》一書稱之爲參數化類型(parameterized type)。

爲什麼需要泛型

考慮這麼一個需求,實現一個函數,這個函數接受 2 個 int 的入參,返回兩者中數值較小的。需求是非常簡單的,我們可以不假思索的寫下如下的代碼:

func Min(a,b int) int {
    if a < b {
        return a
    }
    return b
}

看起來很美好,但是這個函數有侷限性,入參只能用 int 類型,如果需求做了拓展,需要支持對兩個 float64 的入參做判斷,返回兩者中較小的。

衆所周知,go 是一個強類型的語言,且不像 c 那樣在算術表達式裏有隱式的類型轉換(例如隱式的 int 轉 bool,float 轉 int),所以上述這個函數就不能滿足需求場景的,不過要支持這個拓展的需求也是很簡單的,改成如下的代碼然後使用 MinFloat64 即可:

func Min(a,b int) int {
    if a < b {
        return a
    }
    return b
}
func MinFloat64(a,b float64) float64 {
    if a < b {
        return a
    }
    return b
}

但是如果需求又做了拓展,需要支持對兩個 int64 類型的。同理也很簡單,如下:

func Min(a,b int) int {
    if a < b {
        return a
    }
    return b
}
func MinFloat64(a,b float64) float64 {
    if a < b {
        return a
    }
    return b
}
func MinInt64(a,b int64) int64 {
    if a < b {
        return a
    }
    return b
}

但是如果需求又做了拓展...... 然後我們就一直加哇加哇,然後最終就變的像下圖一樣了(ps:go 離泛型就差一個 sublime...)

不知道大家有沒有發現,一旦需求做了拓展,我們都需要也跟着做一些變更,一直做着重複事情,而且通過看函數原型,我們發現只有類型聲明這裏不一致,當然函數名也是不一致,因爲 golang 也是不支持函數重載(function overloading) 的,如果 golang 支持了函數重載,我們這裏不一致的也就只剩下類型了(ps:函數重載其實也是泛型的一種實現,在編譯時通過將類型參數信息加入函數符號裏,就實現了編碼時的調用同名函數,但是在運行時因爲類型信息也不會有二義性)。

那麼有沒有一種手段可以減少我們重複的工作量呢?在需求做了拓展後,也能在不改動原有代碼的基礎上做支持,也就是提高代碼的可複用性,而這就是泛型的使命。

before go1.18 泛型

在沒有泛型前,開發者們是如何實現 "泛型的"。

  1. copy & paste

這是我們最容易想到的方式,也是我們在前文介紹的方式,看起來是一種很笨的方式,但是結合實際情況,大多數情況下你可能只需要兩三個類型的實現,過早的去優化,可能會帶來更多的問題,go proverbs 裏有一句就很符合這個場景。

“A little copying is better than a little dependency.[1]”(一點複製好過一點依賴)

優點:無需額外的依賴,代碼邏輯簡單。

缺點:代碼會有一些臃腫,且靈活性有缺失。

  1. interface

比較符合 OOP 的思路,面向接口編程則容易想到這種途徑,不過像我們上述的取兩數 min 場景就不能用 interface 去滿足了,可應用的場景比較單一,考慮有下邊這樣一個接口。

type Inputer interface {
    Input() string
}

對於 Inputer 接口,我們可以定義有多種實現,比如

type MouseInput struct{}

func (MouseInput) Input() string {
    return "MouseInput"
}

type KeyboardInput struct{}
func (KeyboardInput) Input() string {
    return "KeyboardInput"
}

這樣我們在調用時,也就可以用不同的類型定義相同的接口,通過 interface 來調用相同的函數了。不過本質上 interface 和 generic 是兩種設計思路,應用的場景也不太一樣,這裏只是舉了一個共通的例子。

優點:無需額外的依賴,代碼邏輯簡單。

缺點:代碼會有一些臃腫,且應用的場景較單一。

  1. reflect

reflect(反射)在運行時動態獲取類型,golang runtime 將使用到的類型都做了存儲,對於用戶層 golang 則提供了非常強大的反射包,犧牲了性能,但是提供更多的便捷性,幫助程序員在可以在靜態語言裏使用一些動態的特性,本質上 reflect 和 generic 是兩種截然不同的設計思路,反射在運行時發揮作用,而泛型則在編譯時發揮作用,runtime 無須感知到泛型的存在,像 gorm 框架就大量用到了反射。

reflect 包就內置了 DeepEqual 的實現,用來判斷了兩個入參是否相等。

func DeepEqual(x, y any) bool {
   if x == nil || y == nil {
      return x == y
   }
   v1 := ValueOf(x)
   v2 := ValueOf(y)
   if v1.Type() != v2.Type() {
      return false
   }
   return deepValueEqual(v1, v2, make(map[visit]bool))
}

優點:代碼簡單,使用方便。

缺點:運行時開銷大,不安全,沒有編譯時的類型保障。

(ps: 用過反射的基本都遇到過 panic,運行時的類型保障,reflect 包裏就存在着大量的類型檢查,不符合的直接 panic,對這裏存疑,reflect 包和 map/slice 這些不太一樣,比較偏用戶場景,爲什麼不用 error,要用 panic,猜測是 go team 認爲在靜態語言裏類型不 match 是非常嚴重的場景?)

  1. code generator

代碼生成,大家接觸比較多的可能就是 thrift/grpc 的代碼生成,將 idl 轉換成對應的語言源代碼。

在這裏的 code generator 概念上會不太一樣,概念上可能會類似之前的 php/jsp,寫一份通用的模板,在模板內預置一些變量,然後使用工具將預置的變量做填充,生成最終的語言代碼(ps:好像和泛型也比較像,哈哈哈),go 在 1.5 時也引入了go generator工具,一般會結合text/template包來使用,在 go code generator 裏有比較火第三方工具:github.com/cheekybits/…[2] generator 來寫兩數之 Min,會是下邊這樣的風格:

package main

import "github.com/cheekybits/genny/generic"

//go:generate genny -in=$GOFILE -out=gen-$GOFILE gen "T=int,float32,float64"
type T generic.Type

func MinT(a, b T) T {
   if a < b {
      return a
   }
   return b
}

執行go generator會生成如下代碼:

// This file was automatically generated by genny.
// Any changes will be lost if this file is regenerated.
// see https://github.com/cheekybits/genny

package main

func MinInt(a, b int) int {
   if a < b {
      return a
   }
   return b
}

func MinFloat32(a, b float32) float32 {
   if a < b {
      return a
   }
   return b
}

func MinFloat64(a, b float64) float64 {
   if a < b {
      return a
   }
   return b
}

優點:代碼比較乾淨,因爲是使用前去生成,也可以利用到靜態檢查的能力,安全且無運行時開銷。

缺點:需要針對性的寫模板代碼,然後使用工具生成最終代碼後才能在工程中使用,且依賴第三方的構建工具,因爲涉及多份類型的源代碼生成,工程裏的代碼裏會變多,導致最終構建出的二進制也會較大。

go 1.18 泛型

go 泛型的路程也是非常曲折的...

yUbGHW

從 2010 年開始設計,其中在發展過程中提出的Contracts(合約) 的方案,一度被認爲會是泛型的實現,不過在 2019 年,也因爲設計過於複雜做了廢棄,直到 2021 年才確定了最終的基本方案開始實現,並在 2021 年 8 月的 golang 1.17 裏做了 beta 版的實現,在 2022 年 1 月的 golang 1.18 裏做了實裝,真正意義上的十年磨一劍(ps:Ian Lance Taylor 太牛了)。

泛型類型

在 json 裏有 number 類型,在 golang 的encoding/json庫遇到 interface{} 類型裏默認就會用 float64 去解析 json 的 number 類型,這就會導致在面對大整數時會丟失精度,而實際上的 Number 類型應該對應到 golang 裏的多個類型,包括 int32、int64、float32 和 float64 等,如果按照 golang 的語法,在泛型裏我們可以這麼標識 Number 類型。

type Number[T int32|int64|float32|float64] T

但是很遺憾。。。目前 golang 還不支持這種寫法,在編譯時會有如下報錯:

 cannot use a type parameter as RHS in type declaration
 //RHS:right hand side(在操作符的右側)

報錯的意思就是還不支持單獨使用類型形參作爲泛型類型,需要結合 struct、slice 和 map 等類型來使用,關於這個問題的討論可以詳見:github.com/golang/go/i…[3] Lance Taylor 大佬做個回覆:意思就是這是目前 go1.18 泛型已知的一個問題,具體大概會在 go 1.19 進行嘗試。

我們嘗試定義一個泛型 Number 切片類型,並實例化使用:

package main

type Numbers[T int32 | int64 | float32 | float64] []T

func main() {
   var a = Numbers[int32]{1, 2, 3}
   println(a)
}

這裏實際上是實例化了一個長度爲 3,元素依次是 1,2,3 的 int32 的切片,同樣的,我們也可以按如下這種方式定義,float32 也在我們的類型形參列表內。

var b = Numbers[float32]{1.1, 2.1, 3.1}

上述是隻有一個形參的泛型類型,我們來看幾個複雜的泛型類型。

  1. 多個類型形參
type KV[K int32 | float32,V int8|bool] map[K]V//(多個類型形參的定義用逗號分隔)
var b = KV[int32, bool]{10: true}

上述我們定義了KV[K,V]這個泛型類型,KV是類型形參,K的類型約束是int32|float32V的類型約束是 int8|boolK int32 | float32,V int8|bool則是KV類型的類型形參列表,KV[int32, bool]則是泛型類型的實例化,其中int32K的實參,boolV的實參。

  1. 嵌套的形參
type User[T int32 | string, TS []T | []string] struct {
   Id     T
   Emails TS
}
var c = User[int32, []string]{
   Id:     10,
   Emails: []string{"123@qq.com", "456@gmail.com"},
}

這段個類型看起來會比較複雜,但是 golang 有一條限制:任何定義的形參,在使用時都需要有按順序一一對應的實參。上述我們定義了 struct{Id T Email TS} 這個泛型類型,TTS是類型形參,T的類型約束是int32|stringTS的類型約束是 []T|[]string,也就是說,我們在這裏定義的 TS 形參的類型約束裏使用了前置定義的 T 形參,這種語法 golang 也是支持的。

  1. 形參傳導的嵌套
type Ints[T int32|int64] []T
type Int32s[T int32] Ints[T]

這裏我們定義了 Ints 類型,形參是 int32|int64,又基於 Ints 類型,定義了 Int32s 類型,就是我們第二行的這個代碼,初看起來可能會比較懵,但是拆開來看:

Int32s[T] 這個泛型類型,T 是類型形參,T 的類型約束是int32,Ints[T] 則是這裏的定義類型,這裏的定義類型又是一個泛型類型,而實例化這個泛型類型的方式就是使用實參 T 來進行實例化,注意 T 在這裏是 Int32s 的形參,確是 Ints 的實參。

泛型函數

僅有泛型類型並不能發揮泛型真正的作用,泛型最強大的作用是結合函數來使用,回到我們最開始的那個例子,取兩數之 min,在有泛型的情況下,我們可以寫出這樣的代碼:

package main


func main() {
   println(Min[int32](10, 20 "int32"))
   println(Min[float32](10, 20 "float32"))
}

func Min[T int | int32 | int64 | float32 | float64](a, b T "T int | int32 | int64 | float32 | float64") T {
   if a < b {
      return a
   }
   return b
}

上述我們定義了 Min 泛型函數,包含泛型 T 類型,有對應的類型約束,在實際調用時,我們分別用 int32/float32 去做了形參實例化,來調用不同類型的泛型函數。

上述在使用起來也會有不方便的地方,我們在調用時還需要顯示的去指定類型,才能使用泛型函數,golang 對這種情況支持了自動類型推導(auto type inference) , 可以簡化我們的寫法 我們可以像下述這種方式去調用 Min 函數。

Min(10, 20)//golang裏會把整數字面量推導爲int,所以這裏實際實例化的函數爲Min[int]
Min(10.0, 20.0)//浮點數字面量推導爲float64,所以這裏調用的實例化函數爲Min[float64]

有了泛型函數,一些常見的操作,比如集合操作取交 / 並 / 補 / 差集合也可以很簡單的寫出來了,在之前第三方的 lib 一般都是用反射來實現的,比如:github.com/thoas/go-fu…[4]

結合泛型類型和泛型函數,就是使用泛型 receiver,可以構造高級一點的集合數據結構了,比如在其他語言裏比較常見的棧 (stack)

package main

import (
   "fmt"
)

type Stack[T interface{}] struct {
   Elems []T
}

func (s *Stack[T]) Push(elem T) {
   s.Elems = append(s.Elems, elem)
}

func (s *Stack[T]) Pop() (T, bool) {
   var elem T
   if len(s.Elems) == 0 {
      return elem, false
   }
   elem = s.Elems[len(s.Elems)-1]
   s.Elems = s.Elems[:len(s.Elems)-1]
   return elem, true
}

func main() {
   s := Stack[int]{}
   s.Push(10)
   s.Push(20)
   s.Push(30)
   fmt.Println(s)
   fmt.Println(s.Pop())
   fmt.Println(s)
}
//輸出:
//{[10 20 30]}
//30 true
//{[10 20]}

上述我們定義了 Stack[T] 這個泛型類型,我們使用空接口:interface{} 做泛型約束,空接口的含義是不限制具體的類型,也就是可以用所有的類型進行實例化。實現了 Pop 和 Push 操作,有了泛型,像其他語言裏常見的隊列、優先隊列、Set 等高級數據結構也可以比較簡單的實現(像之前一些第三方的 lib 一般都是用反射來實現的)。

這裏指的一提的是泛型並不支持直接使用我們之前常用的類型斷言 (type assert)。

func (s *Stack[T]) Push(elem T) {
   switch elem.(type) {
   case int:
      fmt.Println("int push")
   case bool:
      fmt.Println("bool push")
   }
   s.Elems = append(s.Elems, elem)
}

//cannot use type switch on type parameter value elem (variable of type T constrained by any)

如果想獲取一個泛型類型的實際類型,可以通過轉換到 interface{} 來實現(當然也可以用反射來實現)。

func (s *Stack[T]) Push(elem T) {
   var a interface{}
   a = elem
   switch a.(type) {
   case int:
      fmt.Println("int push")
   case bool:
      fmt.Println("bool push")
   }
   s.Elems = append(s.Elems, elem)
}

interface

golang 裏有基礎類型和複合類型這兩類內置類型。

基礎數據類型包括:布爾型、整型、浮點型、複數型、字符型、字符串型、錯誤類型。

複合數據類型包括:指針、數組、切片、字典、通道、結構體、接口。

通過將基礎類型和複合類型做組合,我們可以定義出非常多的泛型,但是大量的類型會導致類型約束寫的非常長,拿 number 來舉例:

type Numbers[T int|int8|int16|int32|int64|float32|float64] []T

定義類型約束

golang 支持用 interface 來預定義類型約束,這樣我們在使用時就可以複用已有的類型約束,如下:

type Number interface {
   int | int8 | int16 | int32 | int64 | float32 | float64
}

type Numbers[T Number] []T

內置類型可以自由組合形成泛型,同理,接口也可以跟接口組合,接口也可以跟內置類型組合來形成泛型。

type Int interface {
   int | int8 | int16 | int32 | int64
}

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

type IntAndUInt interface {
   Int | UInt
}

type IntAndString interface {
   Int | string
}

同樣的 golang 爲了方便我們使用也內置了兩個接口,分別是 any 和 comparable。

any

// any is an alias for interface{} and is equivalent to interface{} in all ways.
type any = interface{}

any 其實是非常簡單的,其實就是空接口(interface{})的別名,空接口我們在上邊也用到過,空接口是可以用作任意類型,用 any 可以更方便我們的使用,而且從語義上看,any 的語義也會比 interface{} 的語義更加清晰。

comparable

// comparable is an interface that is implemented by all comparable types
// (booleans, numbers, strings, pointers, channels, arrays of comparable types,
// structs whose fields are all comparable types).
// The comparable interface may only be used as a type parameter constraint,
// not as the type of a variable.
type comparable interface{ comparable }

golang 內置了比較類型,是上述註釋中提到的這些內置類型的組合,也是爲了方便使用的,值得一提的是 comparable 是支持 == 和!= 操作,但是像比較大小的 > 和 < 是不支持的,需要我們自己實現這種 ordered 類型。

func Min[T comparable](a, b T "T comparable") T {
   if a < b {
      return b
   }
   return a
}
//invalid operation: a < b (type parameter T is not comparable with <)

當然我們可以自己實現一份比較類型:

type Signed interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64
}

type Unsigned interface {
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

type Integer interface {
        Signed | Unsigned
}

type Float interface {
        ~float32 | ~float64
}

// Ordered is a constraint that permits any ordered type: any type
// that supports the operators < <= >= >.
// If future releases of Go add new ordered types,
// this constraint will be modified to include them.
type Ordered interface {
        Integer | Float | ~string
}

而這正是 golang 官方拓展包的實現:pkg.go.dev/golang.org/…[5]

interface 集合操作

  1. 並集

我們上邊在用的一直都是並集操作,也就是用豎線分隔的多個類型:

type Float interface {
        float32 | float64
}

上述的 Float 類型約束就支持 float32/float64 的實例化。

  1. 交集

同樣的 interface 也支持交集操作,將類型分別寫到多行,最終 interface 定義的類型約束就是這幾行約束的交集:

type Float interface {
        float32 | float64
}
type Float32 interface {
        Float
        float64
}

這裏我們定義的 Float32 就 Float 和 float64 的交集,而 Float 是 float32|float64,所以 Float32 最終其實只定義了 float32 這一個泛型約束(屬於是)。

  1. 空集

通過空的交集我們可以定義出空的 interface 約束,比如

type Null interface {
    float32
    int32
}

上述我們定義的 Null 就是 float32 和 int32 的交集,這兩個類型的交集爲空,所以最終定義出的這個 Null 就是一個空的類型約束,編譯器不會阻止我們這樣使用,但是實際上並沒有什麼意義。

~ 符號

在上邊的 Ordered 類型約束的實現裏,我們看到了~ 這個操作符,這個操作符的意思是,在實例化泛型時,不僅可以直接使用對應的實參類型,如果實參的底層類型在類型約束中,也可以使用,說起來可能比較抽象,來一段代碼看一下

package main

type MyInt int

type Ints[T int | int32] []T

func main() {
   a := Ints[int]{10, 20} //正確
   b := Ints[MyInt]{10, 20}//錯誤
   println(a)
   println(b)
}
//MyInt does not implement int|int32 (possibly missing ~ for int in constraint int|int32)

所以爲了支持這種新定義的類型但是底層類型符合的方便使用,golang 增加了新的~字符,意思是如果底層類型 match,就可以正常進行泛型的實例化。所以可以改成如下的寫法:

type Ints[T ~int | ~int32] []T

interface 的變化

go 複用了 interface 關鍵字來定義泛型約束,那麼對 interface 的定義自然也就有了變化,在 go1.18 之前,interface 的定義是:go.dev/doc/go1.17_…[6]

An interface type specifies a method set called its interface

對 interface 的定義是 method set(方法集) ,也確實是這樣的,在 go1.18 前,interface 就是方法的集合。

type ReadWriter interface {
   Read(p []byte) (n int, err error)
   Write(p []byte) (n int, err error)
}

上述 ReadWriter 這個類型就是定義了 Read 和 Write 這兩個方法,但是我們不妨反過來看待問題,有多個類型都實現了 ReadWrite 接口,那我們就可以把 ReadWrite 看成是多個類型的集合,而這個類型集合裏的每一個類型都實現了 ReadWrite 定義的這兩個方法,

這裏拿我們上邊的空接口 interface{} 來舉例,因爲每個類型都實現了空接口,所以空接口就可以用來標識全部類型的集合,也就是我們前文介紹的 any 關鍵字。

所以結合上述我們介紹的用 interface 來定義泛型約束的類型集合,go1.18 中,interface 的定義換成了:go.dev/ref/spec#In…[7]

An interface type defines a type set.

對 interface 是 type set(類型集) ,對 interface 的定義從方法集變成了類型集。接口類型的變量可以存儲接口類型集中的任何類型的值。而爲了 golang 承諾的兼容性,又將 interface 分成了兩種,分別是

  1. 基本接口(basic interface)

  2. 一般接口(general interface)

兩種 interface

基本接口

如果接口定義裏只有方法沒有類型(也是在 go1.18 之前接口的定義,用法也是基本一致的),那麼這種接口就是基本接口(basic interface)

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
   Error() string
}

var err error
package main

import (
   "bytes"
   "io"
   "strings"
)

type ReadOrWriters[T io.Reader | io.Writer] []T

func main() {
   rs := ReadOrWriters[io.Reader]{bytes.NewReader([]byte{}), bytes.NewReader([]byte{})}
   ws := ReadOrWriters[io.Writer]{&strings.Builder{}, &strings.Builder{}}
}

一般接口

只要接口裏包含類型約束(無論是否包含方法),這種接口被稱爲 一般接口 (General interface) ,如下例子都是一般接口

package main

type Int interface {
   int | int8 | int16 | int32 | int64
}

func main() {
   var i Int
}
//interface contains type constraints

一些有意思的設計

  1. 爲什麼選用了方括號[]而不是其他語言裏常見的尖括號<>

是爲了和 map,slice 這些「內置泛型」保持一致,這樣用起來會更協調。golang 官方也回答了他們爲什麼選擇了 [],而不是 <>,因爲尖括號會導致歧義:

When parsing code within a function, such as v := F<T>, at the point of seeing the < it's ambiguous whether we are seeing a type instantiation or an expression using the < operator. Resolving that requires effectively unbounded lookahead. In general we strive to keep the Go parser simple.

當解析一個函數塊中的代碼時,類似v := F<T> 這樣的代碼,當編譯器看到< 符號時,它搞不清楚這到底是一個泛型的實例化,還是一個使用了小於號的表達式。解決這個問題需要有效的無界 lookahead。但我們現在更希望讓 Go 的語法解析保持足夠的簡單。

總結

以上我們介紹了泛型的基本概念以及爲什麼需要泛型,在 go1.18 以前大家也都有各自的 “泛型” 實現方式,下一篇文章我們會解析 golang 泛型的實現原理。go 對泛型的支持還是非常謹慎的,目前的功能也不是很豐富,回到最開始的那句話,泛型引入了抽象,無用的抽象帶來複雜性,所以在泛型的使用上也要非常慎重。

引用

  1. go.dev/ref/spec[8]

  2. go.googlesource.com/proposal/+/…[9]

  3. go.dev/doc/go1.17_…[10]

  4. go.googlesource.com/proposal/+/…[11]

  5. golang3.eddycjy.com/posts/gener…[12]

  6. segmentfault.com/a/119000004…[13]

參考資料

[1] “A little copying is better than a little dependency.: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=9m28s

[2] github.com/cheekybits/…: https://github.com/cheekybits/genny,這裏如果用 go

[3] github.com/golang/go/i…: https://github.com/golang/go/issues/45639,Ian

[4] github.com/thoas/go-fu…: https://github.com/thoas/go-funk

[5] pkg.go.dev/golang.org/…: https://pkg.go.dev/golang.org/x/exp/constraints

[6] go.dev/doc/go1.17_…: https://go.dev/doc/go1.17_spec#Interface_types

[7] go.dev/ref/spec#In…: https://go.dev/ref/spec#Interface_types

[8] go.dev/ref/spec: https://go.dev/ref/spec

[9] go.googlesource.com/proposal/+/…: https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md

[10] go.dev/doc/go1.17_…: https://go.dev/doc/go1.17_spec

[11] go.googlesource.com/proposal/+/…: https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md

[12] golang3.eddycjy.com/posts/gener…: https://golang3.eddycjy.com/posts/generics-history/

[13] segmentfault.com/a/119000004…: https://segmentfault.com/a/1190000041634906

轉自: https://juejin.cn/post/7106393821943955463

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/rPNXS9FRRoUMYWNJoWOgHA