Generics for Go

Ben Hoyt

Go 泛型

原文由 Ben Hoyt 发布,订阅该博客

Go 编程语言于 2009 年首次发布,1.0 版本于 2012 年 3 月发布。早在 1.0 发布之前,就有开发者批评该语言过于简单,部分原因在于它不支持用户定义的泛型类型和按类型参数化的函数。尽管存在这一缺憾,Go 依然得到了广泛使用,据估计全球有 100 万至 200 万开发者在使用。多年来,社区提出了多个为语言加入某种形式泛型的方案,而由核心开发者 Ian Lance Taylor 和 Robert Griesemer 撰写的最新提案看起来很有可能被纳入未来的 Go 版本。

背景

Go 是一门静态类型语言,因此类型需要在源代码中显式指定(或由编译器推断),并由编译器进行检查。编译器会生成经过优化的机器码,因此在执行 CPU 密集型代码时,其效率要显著高于 Python 或 Ruby 这类使用字节码编译器并通过虚拟机执行的语言。

泛型,也被称为“参数化类型”或“参数多态”,是一种编写可适用于任意数据类型的代码或构建数据结构的方式;同一份代码或数据结构可以被实例化来处理各种不同的数据类型,而无需重复编写代码。它在编写排序、搜索等通用算法,以及树、线程安全的映射等与类型无关的数据结构时非常有用。例如,开发者可以编写一个适用于所有整数和浮点类型的通用 min() 函数,或创建一个能将键类型关联到值类型的二叉树(可用于字符串、整数或用户自定义类型)。有了泛型,就可以无需重复代码地编写这类程序,同时编译器仍会对类型进行静态检查。

与早期的 Java 版本一样,Go 并未提供用户自定义的泛型。正如 Go FAQ 指出的,泛型“很可能会在某个时候加入”;FAQ 还解释了为何不加入泛型是一项有意为之的权衡:

泛型虽然方便,但会给类型系统和运行时带来复杂性。我们尚未找到一种设计,其带来的价值能与其复杂性相称,尽管我们仍在持续思考。与此同时,Go 内置的 map 和 slice,加上使用空接口来构建容器(需要显式拆箱)的能力,意味着在许多情况下,即便不够流畅,也依然可以写出泛型所能实现的功能。

该语言的实际使用者之所以没有强烈抱怨缺少泛型,部分原因在于 Go 已经为内置的容器类型提供了泛型,具体包括 slice(Go 的可增长数组类型)、map(哈希表)和 channel(线程安全的通信队列)。例如,编写博客软件的开发者可能会写一个函数来获取文章列表,或获取作者 ID 到作者信息的映射:

    // takes ID, returns "slice of Article" (compiler checks types)
    func GetLatestArticles(num int) []Article {
        ...
    }

    // takes "slice of int" of IDs, returns "map of int IDs to Author"
    func GetAuthors(authorIDs []int) map[int]Author {
        ...
    }

len()append() 这样的内置函数可以作用于这些容器类型,但开发者无法自行定义与这些泛型内置函数等效的函数。正如许多 Go 开发者所证实的,即便没有用户自定义的泛型类型,仅凭内置的、按类型参数化的可增长数组和映射,就已经能解决很大一部分问题。

此外,Go 还支持另外两项常被用来替代泛型或弥补其缺失的特性:接口和闭包。例如,Go 中的排序是通过 sort.Interface 类型实现的,这是一个要求实现三个方法的接口:

    type Interface interface {
        Len() int           // length of this collection
        Less(i, j int) bool // true if i'th element < j'th element
        Swap(i, j int)      // swap i'th and j'th elements
    }

如果用户自定义的集合实现了该接口,就可以使用标准库的 sort.Sort() 函数进行排序。自 Go 1.8 加入 sort.Slice() 以来,开发者可以使用该函数并传入一个“小于比较闭包”,而不必实现完整的排序接口;例如:

    // declare a struct for names and ages and a slice of those structs with four entries
    people := []struct {
        Name string
        Age  int
    }{
        {"Gopher", 7},
        {"Alice", 55},
        {"Vera", 24},
        {"Bob", 75},
    }

    // sort people using the "less-than closure" specified in the call
    sort.Slice(
        people,
        func(i, j int) bool { // i and j are the two slice indices
            return people[i].Name < people[j].Name
        },
    )

还有其他办法可以绕过 Go 缺少泛型的问题,例如创建使用 interface{}(即“空接口”)的容器类型。这种方式实际上会对插入集合的每个值进行装箱,并需要在运行时进行类型断言,因此既不够高效,也不具备类型安全性。不过,它确实可行,甚至标准库中的一些类型如 sync.Map 也采用了这种方式。

一些开发者甚至认为根本不应该给 Go 加入泛型,因为这会带来过多的复杂性。例如,Greg Hall 希望“Go 永远不要有泛型,或者如果一定要有,设计者能找到某种方式来避免我在 Java 泛型和 C++ 模板中看到的复杂性和困难”。

Go 团队非常重视复杂性问题。正如核心开发者 Russ Cox 在其 2009 年的文章“The Generic Dilemma”中所说:

泛型似乎有三种基本的实现方式:

  1. (C 语言的方式。)完全不提供泛型。这会拖慢程序员。但不会给语言增加任何复杂性。
  2. (C++ 的方式。)编译期特化或宏展开。这会拖慢编译。它会生成大量代码,其中很多是冗余的,并且需要一个优秀的链接器来消除重复的副本。[...]
  3. (Java 的方式。)隐式地对所有内容进行装箱。这会拖慢执行速度。[...]

泛型的两难在于:你想要慢的程序员、慢的编译器和臃肿的二进制文件,还是慢的执行速度?

尽管如此,许多 Go 开发者仍在呼吁加入泛型,多年来关于如何以符合 Go 风格的方式加入泛型也产生了大量讨论。一些开发者在基于自身 Go 使用经验撰写的“经验报告”中提供了颇有见地的理由。Taylor 在 Go 官方博客上发表的“Why Generics?”一文详细说明了为 Go 加入泛型将带来什么,并列出了 Go 团队在加入泛型时所遵循的指导原则:

最重要的是,如今的 Go 是一门简单的语言。Go 程序通常清晰易懂。在长期探索这一领域的过程中,很大一部分工作就是试图弄清楚如何在保留这种清晰性和简洁性的同时加入泛型。我们需要找到能够很好融入现有语言的机制,而不是将其变成一门截然不同的语言。

这些指导原则应该适用于 Go 中任何泛型的实现。这是我今天想传达的最重要的信息:泛型能为这门语言带来显著的好处,但只有在 Go 依然保持 Go 的感觉时,才值得去做。

最新提案

Taylor 尤其在为 Go 加入泛型这一话题上著述颇丰,至少撰写了六份提案。前四份写于 2010 年至 2013 年间,列在他题为“Go should have generics”的文档底部。关于这些提案,他指出:“都以各种方式存在缺陷”。2019 年 7 月,他发表了上文提到的“Why Generics?”博客文章,其中链接到他与 Griesemer 共同撰写的基于“契约(contracts)”的泛型版本的长篇 2019 年提案。近一年后,在 2020 年 6 月,Taylor 和 Griesemer 发布了当前的提案,该提案不再引入契约。用 Taylor 的来说:

泛型的早期草案设计使用一种名为契约的新语言结构来实现约束。类型列表只出现在契约中,而不是接口类型上。然而,许多人很难理解契约与接口类型之间的区别。后来发现,契约可以表示为一组对应的接口;因此,去掉契约并不会损失表达能力。我们决定简化方案,仅使用接口类型。

移除契约在一定程度上基于 Philip Wadler 及其合作者在 2020 年 5 月发表的论文“Featherweight Go [PDF]”(视频演示)中的工作。Wadler 是一位类型理论家,曾参与 Haskell 的设计,并于 2004 年参与了为 Java 加入泛型的工作。Go 的创始人之一 Rob Pike 曾询问 Wadler 是否“有兴趣帮助我们为 Go 的某个未来版本正确实现多态(和/或弄清楚‘正确’意味着什么)”;这篇论文正是对 Pike 请求的回应。

2020 年的提案建议为函数和类型添加可选的类型参数,从而分别支持泛型算法和泛型容器类型。下面是在该提案下泛型函数的一个示例:

    // Stringify calls the String method on each element of s,
    // and returns the results.
    func Stringify(type T Stringer)(s []T) []string {
        var ret []string
        for _, v := range s {
            ret = append(ret, v.String())
        }
        return ret
    }

    // Stringer is a type constraint that requires the type argument to have
    // a String method and permits the generic function to call String.
    // The String method should return a string representation of the value.
    type Stringer interface {
        String() string
    }

类型参数是 T(任意名称),在函数名后额外的一对圆括号中指定,并附带 Stringer 约束:type T Stringer。函数的实际参数在第二对圆括号中,即 s []T。像这样编写函数在当前的 Go 中是不可能的;它不允许将具体类型的切片传递给接受接口类型切片(例如 Stringer)的函数。

除了泛型函数,新提案还支持类型的参数化,以支持类型安全的集合,如二叉树、图数据结构等。泛型 Vector 类型可能如下所示:

    // Vector is a name for a slice of any element type.
    type Vector(type T) []T

    // Push adds a value to the end of a vector.
    func (v *Vector(T)) Push(x T) {
        *v = append(*v, x)
    }

    // v is a Vector of Authors
    var v Vector(Author)
    v.Push(Author{Name: "Ben Hoyt"})

由于 Go 不支持运算符重载,也没有通过方法来定义运算符,因此无法使用接口约束来指定某个类型必须支持 < 运算符(举例来说)。在该提案中,这一需求通过一项名为“类型列表”的新特性来实现,示例如下:

    // Ordered is a type constraint that matches any ordered type.
    // An ordered type is one that supports the <, <=, >, and >= operators.
    type Ordered interface {
        type int, int8, int16, int32, int64,
            uint, uint8, uint16, uint32, uint64, uintptr,
            float32, float64,
            string
    }

在实践中,标准库可能会新增一个 constraints 包,其中预定义了像 Ordered 这样的常用约束。类型列表使开发者能够编写使用内置运算符的泛型函数:

    // Smallest returns the smallest element in a slice of "Ordered" values.
    func Smallest(type T Ordered)(s []T) T {
        r := s[0]
        for _, v := range s[1:] {
            if v < r { // works due to the "Ordered" constraint
                r = v
            }
        }
        return r
    }

唯一无法用类型列表表达的约束是针对 ==!= 运算符的约束,因为 Go 允许对结构体、数组和接口类型进行相等性比较。为了解决这个问题,提案建议新增一个内置的 comparable 约束来支持判等运算符。例如,这在一个用于在切片或数组中查找值索引的函数中会很有用:

    // Index returns the index of x in s, or -1 if not found.
    func Index(type T comparable)(s []T, x T) int {
        for i, v := range s {
            // v and x are type T, which has the comparable
            // constraint, so we can use == here.
            if v == x {
                return i
            }
        }
        return -1
    }

Taylor 和 Griesemer 开发了一个用于试验的工具(位于 go2go 分支),它能将按此提案编写的 Go 代码转换为普通的 Go 代码,使开发者今天就能编译和运行泛型代码。甚至还有一个 Go Playground 的版本,可以让人们在线分享和运行按此提案编写的代码——例如,这里有一个上述 Stringify() 函数的可运行示例

Go 团队正邀请开发者尝试使用泛型试验工具来解决自身的问题,并针对以下问题提供详细的反馈:

第一,泛型代码是否合理?它是否仍有 Go 的感觉?人们会遇到哪些意外情况?错误信息是否有用?

第二,我们知道许多人都说 Go 需要泛型,但我们不一定确切知道这意味着什么。这份草案设计是否以有用的方式解决了问题?如果有一个让你觉得“我要是 Go 有泛型就能解决这个问题”的问题,那么在使用这个工具时,你能否解决它?

讨论

自最新提案发布以来,在主要的 golang-nuts 邮件列表上,以及在 Hacker Newsreddit.com/r/golang 的帖子中,关于泛型已经有了大量公开讨论。

正如 Pike 去年在所说 [YouTube]中表示的,“语法不是问题,至少现在还不是”,然而,邮件列表上的许多帖子却立刻对语法提出了批评。诚然,该语法有些不寻常,它为 Go 又增加了一对圆括号,而 Go 本就以括号众多而著称(例如,Go 的方法定义就用一对括号表示方法的接收者类型,另一对表示方法的参数)。提案试图通过解释为何选择圆括号而非尖括号来预先避免围绕语法的无谓争论:

在解析函数内的代码时,例如 v := F<T>,在看到 < 的那一刻,无法判断我们看到的是类型实例化还是使用了 < 运算符的表达式。要解决这种歧义,需要近乎无界的前瞻。一般来说,我们力求保持 Go 解析器的高效。

邮件列表上的大多数回复者都提议使用像 C++、Java 和 C# 那样的尖括号,例如使用 List<T> 而不是 List(T)。Taylor 更关心新提案的语义是否合理,但他仍耐心地在每个这类语法帖子中回复了类似下面的内容:

让我们先看看使用所建议的语法编写的实际代码是什么样子,然后再去考虑替代方案。谢谢。

这种情况发生了太多次,以至于一位邮件列表贡献者 Tyler Compton 整理了一份关于所有语法相关帖子的实用清单

泛型将有助于消除为多种类型重复定义的类型和函数,例如 sort 包中的 sort.Intssort.Float64ssort.Strings。在 Hacker News 上的一条评论中,Kyle Conroy 展示了“对标准库中各种 sql.Null* 类型的四行替代方案”:

    type Null(type T) struct {
        Val   T
        Valid bool // Valid is true if Val is not NULL
    }

邮件列表贡献者 Pee Jai 询问是否有办法将类型约束为仅允许结构体,但 Taylor 表示这无法实现;他指出“泛型并不能解决所有问题”。Robert Engels 表示无论如何这种情况下仍需要使用 reflect 包

在一个帖子中,“i3dmaster”提出了关于自定义 map 类型的一些问题,Taylor 澄清说“自定义容器类型将不支持 len()range”。集合类型的创建者将无法使用这种特殊语法,而是需要自行定义 Len() 方法,以及自行实现遍历集合的方式。

Go 核心贡献者 Bryan Mills 在多个帖子中发表了颇有见地的回复。他还创建了自己的仓库,其中包含他试验泛型时的各种笔记和代码示例,包括一篇解释他为何认为类型列表并不理想的说明。该仓库还包含了多种尝试,试图用所提议的泛型来重新实现 append() 内置函数。

时间线

在他们最近的博客文章中,Taylor 和 Griesemer 明确表示,为语言加入泛型不会是一个快速的过程——他们希望把它做好,并充分考虑社区的反馈:

我们将利用从 Go 社区收集到的反馈来决定下一步如何推进。如果草案设计受到好评且无需重大修改,下一步将是提交正式的语言变更提案。为了设定预期,如果大家对设计草案完全满意且无需进一步调整,那么泛型最早可能在定于 2021 年 8 月发布的 Go 1.17 版本中加入。当然,现实中可能会出现不可预见的问题,因此这是一个乐观的时间表;我们无法做出任何确切的预测。

我个人猜测,对于如此规模的特性而言,2021 年 8 月(仅一年多以后)是乐观的。要征求反馈、迭代设计,并以可用于生产的方式实现泛型,而不是使用当前的 Go-to-Go 转换器,将需要相当长的时间。但鉴于迄今为止提案的数量和反馈的体量,无论何时到来,泛型都必将成为一个被广泛使用(并希望被审慎使用)的特性。

本文章由 muse-spark-1.2-contributor 进行翻译

评论