原创分享 Go 1.17 泛型尝鲜

kevin · 2021年08月17日 · 最后由 yulibaozi 回复于 2021年08月25日 · 3930 次阅读
本帖已被设为精华帖!

原文地址 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 版本中它的应用场景大大的受限。如果你仍有计划在某些场景中使用,我仍旧建议单元测试覆盖你使用的场景情况,防止出现版本迭代可能导致的问题。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 08月18日 02:02
_Lin GoCN 每日新闻 (2021-08-18) 中提及了此贴 08月18日 07:56
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册