Go 1.17 泛型嚐鮮

kevin發表於2021-08-17

原文地址 https://www.4async.com/2021/08/golang-117-generics/

今天,Go 的 1.17 版本終於正式釋出,除了帶來各種優化和新功能外,1.17 正式在程式中提供了嚐鮮的泛型支援,這一功能也是為 1.18 版本泛型正式實裝做鋪墊。意味著在 6 個月後,我們就可以正式使用泛型開發了。那在 Go 1.18 正式實裝之前,我們在 1.17 版本中先嚐鮮一下泛型的支援吧。

泛型有什麼作用?

在使用 Go 沒有泛型之前我們怎麼實現針對多型別的邏輯實現的呢?有很多方法,比如說使用interface{}作為變數型別引數,在內部通過型別判斷進入對應的處理邏輯;將型別轉化為特定表現的鴨子型別,通過介面定義的方法實現邏輯整合;還有人專門編寫了 Go 的函式程式碼生成工具,通過批量生成不同型別的相同實現函式代替手工實現等等。這些方法多多少少存在一些問題:使用了interface{}作為引數意味著放棄了編譯時檢查,作為強型別語言的一個優勢就被抹掉了。同樣,無論使用程式碼生成還是手工書寫,一旦出現問題,意味著這些方法都需要重複生成或者進行批量修改,工作量反而變得更多了。

在 Go 中引入泛型會給程式開發帶來很多好處:通過泛型,可以針對多種型別編寫一次程式碼,大大節省了編碼時間。你可以充分應用編譯器的編譯檢查,保證程式變數型別的可靠性。藉助泛型,你可以減少程式碼的重複度,也不會出現一處出現問題需要修改多處地方的尷尬問題。這也讓很多測試工作變得更簡單,藉助型別安全,你甚至可以少考慮很多的邊緣情況。

Go 語言官方有詳細的泛型提案文件可以在這裡這裡檢視詳情。

如何使用泛型

前面理論我們僅僅只做介紹,這次嚐鮮還是以實踐為主。讓我們先從一個小例子開始。

從簡單的例子開始

讓我們先從一個最簡單的例子開始:

package main

import (
    "fmt"
)

type Addable interface {
    type int, int8, int16, int32, int64,
        uint, uint8, uint16, uint32, uint64, uintptr,
        float32, float64, complex64, complex128,
        string
}

func add[T Addable](a, b T) T {
    return a + b
}

func main() {
    fmt.Println(add(1,2))
    fmt.Println(add("1", "2"))
}

這個函式可以實現任何需要使用+符號進行運算的型別,我們通過定義Addable型別,列舉了所有可能可以使用add方法的所有的型別。比如我們在main函式中就使用了intstring兩種不同型別。

但是如果這時我們使用簡單的go run命令執行,會發現提示語法錯誤:

$ go version
go version go1.17 darwin/arm64
$ go run ~/main.go
# command-line-arguments
../main.go:8:2: syntax error: unexpected type, expecting method or interface name
../main.go:15:6: missing function body
../main.go:15:9: syntax error: unexpected [, expecting (

因為在 Go 1.17 中,泛型並未預設開啟,你需要定義gcflags方式啟用泛型:

$ go run -gcflags=-G=3 ~/main.go
3
12

如果你覺得這種方式太過於複雜,每次都需要新增,也可以通過定義環境變數形式讓每次都帶此引數(不推薦,尤其是多版本環境時低版本 Go 中會報錯):

$ export GOFLAGS="-gcflags=-G=3"
$ go run ~/main.go
3
12

在 Go 中,泛型可以做什麼更多更復雜的事情嗎?當然可以。除了最基礎的演算法實現以外,我們可以通過後面的幾個場景看一下泛型可用的場景。

實現型別安全的 Map

在現實開發過程中,我們往往需要對 slice 中資料的每個值進行單獨的處理,比如說需要對其中數值轉換為平方值,在泛型中,我們可以抽取部分重複邏輯作為 map 函式:

package main

import (
    "fmt"
)

func mapFunc[T any, M any](a []T, f func(T) M) []M {
    n := make([]M, len(a), cap(a))
    for i, e := range a {
        n[i] = f(e)
    }
    return n
}

func main() {
    vi := []int{1,2,3,4,5,6}
    vs := mapFunc(vi, func(v int) int {
        return v*v
    })
    fmt.Println(vs)
}
$ go run -gcflags=-G=3 main.go
[1 4 9 16 25 36]

在這個例子中,我們定義了一個 M 型別,因此除了進行同樣型別的轉換外,也可以做不同型別的轉換:

-     vs := mapFunc(vi, func(v int) int {
-        return v*v
+     vs := mapFunc(vi, func(v int) string {
+        return "<"+fmt.Sprint(v)+">"
$ go run -gcflags=-G=3 main.go
[<1> <2> <3> <4> <5> <6>]

實現型別安全的 Map/Filter

除了運算元據以外,我們通常還需要對資料進行篩選。在前面的例子上,我們可以通過實現filterFunc實現更好的通用邏輯:

package main

import (
    "crypto/rand"
    "fmt"
    "math/big"
    "strings"
)

func mapFunc[T any, M any](a []T, f func(T) M) []M {
    n := make([]M, len(a), cap(a))
    for i, e := range a {
        n[i] = f(e)
    }
    return n
}


func filterFunc[T any](a []T, f func(T) bool) []T {
    var n []T
    for _, e := range a {
        if f(e) {
            n = append(n, e)
        }
    }
    return n
}


func main() {
    vi := filterFunc(
        mapFunc([]int{1,2,3,4,5,6},
            func(v int) int {
                return v*v
            },
        ), 
        func(v int) bool {
            return v < 40
        })
    fmt.Println(vi)

    vs := filterFunc(
        mapFunc([]string{"a", "b", "c", "d", "e"},
            func(v string) string {
                // 需要使用crypto/rand增加隨機性
                n, _ :=rand.Int(rand.Reader, big.NewInt(5))

                i := int(n.Int64())+1
                return strings.Repeat(v, i)
            },
        ), 
        func(v string) bool {
            return len(v)>3
        })
    fmt.Println(vs)
}
$ go run -gcflags=-G=3 main.go
[1 4 9 16 25 36]
[aaaa dddd eeeee]

實現型別可靠的 Worker Pool

除了上面這個例子,我們還可以通過泛型實現一個型別可靠的通用批量型別轉換函式:

package main

import (
    "fmt"
    "strconv"
    "sync"
)

type T1 interface{}
type T2 interface{}

func ParallelMap(parallelism int, in []T1, f func(T1) (T2, error)) ([]T2, error) {
    var wg sync.WaitGroup
    defer wg.Wait()

    inc, outc, errc := make(chan T1), make(chan T2), make(chan error)

    donec := make(chan struct{})
    defer close(donec)

    wg.Add(parallelism)
    for i := 0; i < parallelism; i++ {
        go func() {
            defer wg.Done()
            for x := range inc {
                y, err := f(x)
                if err != nil {
                    select {
                    case errc <- err:
                    case <-donec:
                    }
                    return
                }
                select {
                case outc <- y:
                case <-donec:
                    return
                }
            }
            select {
            case errc <- nil:
            case <-donec:
            }
        }()
    }

    go func() {
        for _, x := range in {
            inc <- x
        }
        close(inc)
    }()

    out := make([]T2, 0, len(in))
    for rem := parallelism; rem > 0; {
        select {
        case err := <-errc:
            if err != nil {
                return nil, err
            }
            rem--
        case y := <-outc:
            out = append(out, y)
        }
    }
    return out, nil
}

func main() {
    in := []T1{"1", "2", "3", "4", "5"}
    out, err := ParallelMap(4, in, func(x T1) (T2, error) {
        return strconv.Atoi(x.(string))
    })
    if err != nil {
        fmt.Println("error: ", err)
        return
    }
    fmt.Println(out)

    in2 := []T1{1, 2, 3, 4, 5}
    out2, err := ParallelMap(4, in2, func(x T1) (T2, error) {
        return fmt.Sprintf("<%d>", x), nil
    })
    if err != nil {
        fmt.Println("error: ", err)
        return
    }
    fmt.Println(out2)
}
$ go run -gcflags=-G=3 main.go
[3 5 2 4 1]
[<1> <4> <5> <3> <2>]

其他應用

我們可以預見在 Go 1.18 版本中,多個標準庫會被新增或者擴充套件,包括:型別定義庫constraints,通用 slice 操作庫slices,通用型別安全 mapmaps等等。因為這些會進入標準庫,大家可以先自行實現試用,真正線上使用建議等待標準庫新增內容即可。

Go 泛型的實現原理

我們迴歸到最原始的例子快速看一下 Go 中是如何實現泛型的。為了方便分析,我們在所有func上新增go:noinline防止內聯,然後編譯程式進行分析。這裡可能 Go 1.17 實現問題未能支援如go toolgo build -gcflags=all=-S之類的命令傳遞-G=3引數,因此這裡我們選擇第三方的反彙編工具看一下具體的實現:

ASM

可以看到目前 Go 會根據型別將泛型展開成對應型別函式,這樣也會小小的增加編譯時間和編譯後檔案大小。因為我測試使用 Apple Silicon 平臺,考慮大家可能不熟悉相關彙編,具體執行邏輯不再具體展示。

其他注意事項

目前 Go 的泛型仍在開發過程中,即便在 1.17beta 到正式版過程中,很多泛型的 corner case 也正在完善過程中,比如在之前測試中我發現某些程式碼在 beta 版本無法正確編譯,但是在 RC 中已可以正確編譯。目前的泛型實現未必代表 1.18 版本中是相同的實現細節,甚至可能在 1.18 中提供更多的功能。同時,目前 1.17 泛型型別是無法在 package 中匯出的,這導致在 1.17 版本中它的應用場景大大的受限。如果你仍有計劃在某些場景中使用,我仍舊建議單元測試覆蓋你使用的場景情況,防止出現版本迭代可能導致的問題。

更多原創文章乾貨分享,請關注公眾號
  • Go 1.17 泛型嚐鮮
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章