Go 1.18 支持泛型了。现在抽空就看了一下泛型提案。英文地址
简单翻译如下:
翻译名词对照
- type => 类型
- constrain => 结束
- interface => interface
摘要
我们建议扩展Go语言,对类型和函数声明添加可选的类型参数。类型参数被 interface 类型约束。当 interface 类型被用作类型限制的时候,可以支持添加额外的元素,用来限制满足 interface 的类型的集合。参数化的类型和函数可能会使用参数的操作,但这种情况只有在所有满足参数约束的类型都允许才行。类型 interface 通过一个统一的算法进行类型推断,以允许在函数调用的时候去掉类型参数。这个设计对 Go1 完全后向兼容。
怎么读这个提案
这个文章非常长,这是如何阅读的指导:
- 我们从一个高级的概述开始,非常简单的描述概念
- 然后我们从零开始解释全部的设计,介绍详情,并有简单的例子
- 在设计全部描述完之后,我们讨论实现,一些设计的问题,然后跟其它的泛型方法比较。
- 然后我用几个完整的例子,以展示这个实现在实践中是如何使用的。
- 例子之后,在附录中谈论了一些小的细节。
高级别的概述
这部分非常简要的描述了这个设计建议的修改。这部分是为那些已经熟悉一个语言中, 泛型如何工作的人准备的。这些概念在后面的章节中会有详细的解释。
- 函数可以有一个使用方括号的额外的类型参数列表,但是其它方面像一个普通的参数列表。
func F[T any](p T) {...}
- 这些类型参数可以被普通参数使用,也可以在函数体内使用。
- 类型也可以有类型参数列表:
type M[T any] []T
. - 每个类型参数都有一个类型约束,就像每个普通参数都有一个类型一样:
func F[T Constraint](p T) {...}
- 类型约束是 interface 类型。
- 新的预定义的约束
any
是一个允许所有类型的类型约束。 - 当 interface 类型作为类型约束的时候,可以另外嵌入元素以限制满足约束的类型集合:
- 一个任意的类型
T
限制只能用那个类型。 - 近似元素
~T
限制只能使用那些底层类型是 T 的类型。 - 联合元素
T1 | T2 | ...
限制只能使用列出来的类型。
- 一个任意的类型
- 泛型函数使用的操作,必须被所有符合约束的类型都支持。
- 使用泛型函数或类型,必须传一个类型参数。
- 在常见情况下,类型推断允许去掉一个函数的类型参数。
在接下来的部分中,我们将会对这些语言的修改详细的过一遍,你可能更想跳过开头,到例子部分,看一下实践中的泛型代码是怎么写的。
背景
在 Go 中,已经有很多要求支持泛型的请求的了。在 issue traker 上也有大量的讨论。
这个设计建议,通过添加一种参数多态的形式,对 Go 语言进行扩展,这里的类型参数不是被声明的子类型关系限制的(就像别的面向对象语言一样),而是被明确定义的结构化约束限制。
这个版本的设计跟2019年7月31号的设计草稿有很多相似的地方,不过 contracts 已经去掉了,替换成了 interface 类型,并且语法也有改变。
针对增加类型参数,已经有几个提案了,可以通过以前的链接找到。这里呈现的很多想法之前也出现过。这里描述的主要新特性是语法和 interface 类型作为约束的仔细检查。
这个设计不支持模板元编程或其它任何形式的编译时编程。
因为术语 generic
在 Go 社区广泛使用,我们下面将使用它来代表一个带着类型参数的函数或类型。不要将本设计中使用的术语 generic 跟其它语言像 C++, C#, Java 或 Rust 中使用的同样的术语搞混淆。它们有相似的地方但是不一样。
设计
我们将分阶段基于例子来描述完整的设计。
类型参数
泛型代码是使用抽象的数据类型来写的,这种抽象数据类型,我们称作类型参数。当运行泛型代码的时候,类型参数将被实际参数替换。
这是一个函数,它输出 slice 中的每个元素,这里 slice 中元素的类型 T
是未知的。这是为了支持泛型编程我们想要允许的函数中,一个微不足道的例子。(稍后我们将讨论泛型类型)。
1 | // Print 输出 slice 中的元素。 |
在这个方法中,第一个需要做的决定是:类型参数 T 怎么被声明?在像 Go 这样的语言中,我们希望每一个标识符都以某种方式被声明。
这里我做一个设计决定:类型参数跟普通的非类型函数参数相似,并且跟其它参数一起列出来。然而,类型参数跟非类型参数不一样,所以虽然它们都出现在参数列表中,但是我们想要区分它们。这会导致我们下一个设计决定:我们定义一个另外的可选的参数列表来描述类型参数。
类型参数列表出现在普通参数前面。为了区分类型参数列表和普通参数列表,类型参数列表使用方括号而不是小圆括号。就像普通参数拥有类型,类型参数也有元类型,就是约束。我们稍后将讨论约束的细节。现在我们只需要知道 any
是一个有效的约束,意思是任意类型都可以。
1 | // Print 输出任意 slice 的元素。 |
这就是说在 Print 函数中,标识符 T 是一个类型参数,这个类型现在还不知道,但是当函数被调用时就知道了。any
的意思是T
可以是任何类型。就像上面看到的,当描述普通的非类型参数时,类型参数可以被当作类型使用。在函数体中,它也可以当作类型使用。
跟普通参数列表不一样的是,在类型参数列表中名字是必须的。这可以避免语法歧义,并且没有任何理由去省略类型参数的名字。
由于 Print 有一个类型参数,所有对 Print 的调用必须提供一个类型参数。稍后我们将看到这个类型参数怎么通过非类型参数推断出来。现在我们将明确的传入类型参数。类型参数被传入,就相当于类型参数被声明了:作为一个分享的参数列表。当有类型参数列表时,使用方括号。
1 | // 使用 []int 调用 Print. |
约束
让我们的例子稍微复杂点。比如有一个函数,它将为了把一个任意类型的 slice 转换成 []string, 将通过调用每个元素的 String 方法来实现。
1 | // 这个方法是非法的。只是演示 |
第一眼看上去好像可以,不过这个例子中 v 的类型是 T, 而 T 可以是任何类型。这意味着 T 不需要有 String 方法。所以 v.String() 的调用是非法的。
当然了,同样的问题在其它支持泛型的语言中也存在。例如在 C++ 中,一个泛型函数(用C++的术语,一个函数模板)可以调用一个泛型类型的值的任意方法。就是在 C++ 中,调用 v.String() 是可以的。如果函数调用使用了一个类型参数,它没有 String 方法,编译的时候会报错。这些报错可能很长,比如在报错发生前有好几层的泛型函数调用时,为了明白哪里出错了,所有这些都需要报出来。
C++ 的方案对 Go 来说是个糟糕的选择。一个原因是语言的风格。在 Go 中我们不引用名字,比如,在这个例子中,String, 并且希望它们存在。当它们被看见的时候,Go 解析所有的名字到它们声明的地方。
另一个原因是 Go 被设计用来支持大规模编程的。我们必须考虑这个例子中泛型函数的定义(上面的 Stringify)以及对泛型函数的调用(没有显示,不过可能在其它包中)相距甚远的情况。一般来讲,所有的泛型代码期望类型参数符合某种确定的要求。我们当这种要求称作约束(其它语言有类似的概念,比如类型限制或trait限制或概念)。在这个情况,约束非常明显:类型必须有 String() string
方法。在别的情况中,可能没有那么明显。
我们不想从 Stringify 发生的地方衍生约束(在这个情况中,调用 String 方法的地方)。如果我们做了,对 Stringify 的一个小的改动可能会改变约束。那就意味着一个的改动可能影响很远的代码,调用这个函数的代码意外退出。对 Stringify 故意改变它的约束,并强制调用方改变是没有问题的。我们想要避免的是 Stringify 意外的改变了它的约束。
这意味着约束必须同时在调用者传入的类型参数和泛型函数中的代码中设置限制。调用者只能传满足约束的类型参数。泛型函数只能以约束允许的方式使用这些值。我们相信任何尝试在 Go 中定义泛型编程,这都是一条重要的规则:泛型代码只能使用它的类型参数知道实现了的操作。
任意类型允许的操作
在我们讨论约束之前,我们简单的记住 any
约束是什么。如果一个泛型函数使用 any
约束,就像上面的 Print 函数一样,任意类型都允许。泛型函数中类型参数的值可以使用的操作就是那些被任意类型都允许的操作。在上面的例子中,Print 函数声明了一个变量 v 它的类型是 T,并且它把这个变量传给函数。
任意类型允许的操作是:
- 声明这些类型的变量
- 把同类型的其它值你分配给这些值
- 把这些变量传给函数或者在函数返回它们
- 取这些变量的地址
- 转换或者分配这些类型的值给 interface{}
- 转换 T 类型的值到类型 T (允许但是没有用)
- 使用类型断言把一个 interace 类型的值转到这些类型
- 用在 type switch 中的 case
- 定义和使用这些类型的组合类型,比如这些类型的 slice
- 把这些类型传一些预定义的函数,比如
new
可能随着未来语言变化,可能增加其它的操作,但这不是现在可以预料的。
定义约束
Go 中已经有一种合约非常接近我们约束的需要:interface 类型。一个 interface 类型是一组方法。只有那些类型实现了同样的方法,它们的值才能分配给 interface 类型的值。interface 类型的值可以的事情,不是类型允许的操作,只是调用这些方法。
使用类型参数调用一个泛型函数跟赋值给 interface 类型非常相似:传入的类型参数必须实现类型参数的约束。写一个泛型函数就像使用 interface 类型的值:泛型代码只能使用被约束允许的操作(或者被any
类型允许的操作)。
所以,在这个设计中,约束就是简单的 interface 类型。满足约束就意味着实现 interface 类型。(为了定义非方法的操作约束,比如二进制操作,后面我们再重述这个)。
对于 Stringify 例子,我们需要一个 interface 类型:它有一个 String 方法,没有入参,返回一个 String 的值。
1 | // Stringer 是一个类型约束,它要求类型参数有一个 String 方法并允许泛型函数调用 String. |
(跟这个讨论来没关系,但是这里定义了跟标准库 fmt.Stringer
一样的 interface 类型,真实的代码应该直接使用 fmt.Stringer
)。
any 约束
现在我们知道约束就是简单的 interface 类型,我们可以解释 any 约束是什么意思。就像上面显示的那样,any 约束允许任何类型作为类型参数,并且只允许函数使用任意类型允许的操作。它的 interface 类型就是 interface{}
(空接口)。所以我们像这样写 Print 的例子
1 | func Print[T interface{}](s []T) { |
然而,每次当你要写一个泛型函数,它不强制类型参数的约束的时候,必须每次都写 interface{}
,这太无聊了。所以在这个设计中,我们建议约束 any
等价于 interface{}
。这将是一个预定义的名字,在 universe block 中隐式声明。使用 any 用作类型参数约束以外的地方是无效的。
(注意:显然我们可以将 any 作为 interface{} 的别名,或者作为定义为 interface{} 的新类型。但是我们不希望这种关于泛型的设计导致非泛型代码的重大修改。添加 any 作为 interface{} 的通用名称可以而且应该单独讨论)
使用约束
对于泛型函数,约束中可以认为是类型参数的类型:元类型。就像上面显示的那样,约束出现在类型参数列表中,作为类型参数的元类型。1
2
3
4
5
6// Stringify 调用 s 中每个元素的 String 方法, 并且返回结果
func Stringify[T Stringer](s []T) (ret []string) {
for _, v := range s {
ret = append(ret, v.String())
}
}
在这个情形中,类型参数 T 后面跟着对它的约束。
多个类型参数
虽然 Stringify 的例子只用了一个类型参数,函数也可以有多个类型参数。1
2// Print2 有两个类型参数,和两个非类型参数
func Print2[T1, T2 any](s1 []T1, s2 []T2) {...}
跟这个相比1
2// Print2Same 有一个类型参数和两个非类型参数
func Print2Same[T any](s1 []T, s2 []T) {...}
Print2 中,s1 和 s2 可能是不同类型的 slice。在 Print2Same 中,s1 和 s2 必须是同一个类型的 slice。
就像每个普通参数都有自己的类型,每个类型参数也有自己的约束。
1 | // Stringer 是一个需要 String 方法的约束。 |
一个约束可以用到多个类型参数,就像一个类型参数可以用到多个非类型参数上。约束单独应用到每个类型参数上。
1 | // Stringify2 将两个不类型的 slice 转成 string, 并且返回所有 string 连接的结果。 |
泛型类型
除了泛型函数,我们还想要泛型类型。我们建议类型可以被扩展到接受类型参数。1
2// Vector 是一个元素可以为任意类型的 slice.
type Vector[T any] []T
类型的类型参数就像函数的类型参数一样。
在类型定义中,类型参数可以像别的类型一样使用。
为了使用泛型类型,你必须提供类型参数。这叫做实例化。类型参数就像以前一样出现在方括号中。当我们通过为类型参数提供类型参数来实例化一个类型时,我们会产生一个类型,其中在类型定义中的类型参数的每次使用,都会被相应类型实参替换。
1 | // v 是一个整形值的 Vector |
泛型类型也可以有方法。方法的接收者类型必须声明同样数量的类型参数, 跟声明在接收者类型定义一样。它们的声明没有约束。
1 | // Push 增加一个值到一个 Vector 的末尾 |
在方法声明中列出的类型参数,不需要拥有跟在类型定义中的类型参数一样的名字。特别地,如果它们没有被方法使用,它们可以是 _
。
在一个类型通常可以引用自身的情况下,泛型类型可以引用自身。但是当它这样做时,类型参数必须是类型参数,以相同的顺序列出。此限制可以防止类型参数实例化的无限递归。
1 | // List 是一个链表,其值是类型 T |
这个限制对直接引用和间接引用都有效。
1 | // ListHead 是一个链表的头 |
(注意:随着对人首怎么写代码越来越了解,可能会放松这个规则以允许一些使用不同类型参数的case)
泛型类型的类型参数可能拥有不是any 的约束。
1 | // StringableVector 是一个一些类型的 slice,这里的类型必须有 String 方法。 |
方法可能不带另外的类型参数
尽管泛型类型的方法可能使用类型参数,方法可能没有另外的类型参数。这里给方法增加类型参数可能是有用的,人们不得不写一个适当参数化的顶级函数。
操作符
就像我们看到的,我们使用 interface 类型数作约束。interface 类型除了提供了一个方法集,没有提供别的。这意味着迄今为止我们看到的,泛型函数可以做的唯一事情就是使用类型参数的值,除了任何类型允许的操作外,就是调用方法。
然而,方法调用不能满足我们想表达的所有。思考这个简单的函数:它返回slice 中最小的元素,假定slice不为空。
1 | // 这个函数是无效的。 |
任何合理的泛型实现都该允许你编写这样的函数。问题是表达式 v < r
。这假定T
支持<
操作符,T
上的约束是any
。拥有any
约束的函数Smallest
只能使用所有类型都允许的操作,但是不是所有类型都支持<
。不幸地,因为<
不是一个方法,所以没有一个明显的方法来写一个约束 —- 一个interface 类型 —- 让它允许使用<
。
我们需要一种方法来写一个约束,让它只接受支持<
的类型。为了做到这个,我们观察,除了后面将这个请求的两个例外,语言定义的所有的算术运算,比较,和逻辑运算符可能只能用到语言预定义的类型上,或者是定义的类型,但是它的底层类型是预定义类型。那就是<
操作符只能被用在预定义的类型像int
或者float64
,或者定义的类型它的底层是这些类型。Go不允许组合类型或任意自定义类型使用<
。
这意味着与其尝试编写<
的约束,我们可以反过来处理:与其说一个约束应该支持哪些运算符,我们可以说一个约束应该接受哪些类型。我们通过为约束定义一个类型集来做到这一点。
类型集
虽然我们的主要兴趣在于定义约束的类型集合,最直观的方法就是定义所有类型的集合。约束的类型集就从这些集合中挑。这似乎与将运算符与参数化类型一起使用的主题偏离了主题,但我们最终会到达那里。
每一个类型有相关的类型集。一个非接口类型 T 的类型集只是简单的集合{T}
:一个只包含 T 的集合。一个普通接口类型的类型集就是所有声明了接口中方法的类型的集合。
注意普通接口类型的类型集是一个无限集合。对于任何给定的类型 T 和一个接口类型 IT 非常容易判断 T 是否在类型集 IT 中(通过判断 T 是否声明了 IT 所有的方法),但是没有合理的方法去枚举 IT 类型集中所有的类型。IT 是它自己的类型集中的一员,因为一个接口内存地声明了它自己所有的方法。空接口的类型集interface{}
是所有类型的集合。
通过检查接口中的元素,对于构建接口类型的类型集合,将会很有用。这跟另一个不同的方式将产生同样的结果。接口的元素可以是方法签名或嵌入式接口类型。虽然一个方法签名不是一个类型,但是它定义一个类型集很方便:所有声明了那个方法的类型集合。一个嵌入式的接口类型 E 的类型集就是 E: 所有声明了 E 中所有方法的类型集合。
对于任意方法签名 M,interface{ M }
的类型集是 M 的类型:所有声明 M 的类型的集合。对于任意方法签名 M1 和 M2,interface{ M1; M2}
的类型集合就是所有同时声明了 M1 和 M2 的类型的集合。这是 M1 类型集和 M2 的类型集的交集。观察这个发现,M1 的类型集就是所有拥有方法 M1 的类型,M2 也类似。如果我们取它们的交集,结果就是同时声明了 M1 和 M2 的类型集。那就是确实的 interface{ M1; M2}
的类型集。
这同样可以应用到嵌入式接口类型。对于任何两个接口类型 E1 和 E2,interface{ E1; E2}
的类型集就是 E1 和 E2 的类型集的交集。
所以,一个接口类型的类型集就是接口元素的类型集的交集。
约束的类型集
现在我们描述了接口类型的类型集,我们将重新定义满足约束的意思。前面我们说过一个类型参数满足一个约束就是它实现了约束。现在我们将说一个类型参数满足约束就是它是约束的类型集的一员。
对于一个普通的接口类型,它的唯一元素就是方法签名和嵌入式普通接口类型,这意味着:实现接口的类型集正是其类型集中的类型集。
我们现在将继续定义那可能出现在接口类型中用于约束时的元素,并且定义这些额外的元素用作约束时,怎么控制约束的类型集。
约束元素
普通接口类型的元素是方法签名和嵌入式接口类型。我们建议当接口类型用作约束时,允许三个额外的元素。如果这三个元素被使用了,这个接口类型不能被用作普通的类型,但只可以用作约束。
任意类型约束元素
第一个新元素是允许列出任意类型,不只是接口类型。例如:整数类型type Integer interface{ int }
。当一个非接口类型 T 被列出来当作一个约束的元素时,它的类型集就是{T}
。int 的类型集就是{int}``。因为约束的类型集是所有元素的类型集的交集,Integer 的类型集是
{int}。这个约束 Integer 可以被任何
{int}`
中的一员满足。有一个确切的类型:int。
类型可以是引用类型参数(或更多)的类型文字,不过它不能是普通的类型参数。
1 | // EmbeddedParameter is 是非法的。 |
近似约束元素
列出一个单个类型本身是没有用的。对于约束满足,我们想要说不只是 int,还包括 “底层类型是int 的所有类型”。思考一下上面的 Smallest 的例子。我们想要它不只对预定义的顺序类型的slice 有效,也要对程序定义的类型有效。如果一个程序使用type MyString string
,程序就可以对 MyString 使用<
操作符。可以使用类型 MyString 来实例化 Smallest。
为了支持这个,我们提议的在约束中使用的第二个新元素是一个新语法构造:近似元素,写做~T
。~T 的类型集就是所有底层类型是 T 的所有类型的集合。
例如:type AnyString interface{ ~string }
。~string 的类型集,因此AnyString的类型集,是底层类型是 string 的所有类型的集合。这包含了 MyString;MyString 用作类型参数将满足约束 AnyString。
这个新的 ~T 的语法将是在 Go 中第一个使用 ~ 符号的。
因为 ~T 意味着所有底层类型是 T 的类型集合,因此如果 T 的底层类型不是它自己,将 ~ 与 T 一起使用将是错误的。其底层类型是本身的类型是:
- 类型文字,比如 []byte 或者 struct{ f int }。
- 大多数的预定义类型,比如 int 或 string (error不是)。
- 如果 T 是类型参数或者 T 是一个接口类型, ~T 是不允许被使用的。
1 | type MyString string |
联合约束元素
在约束中我们提议增加的第三个新元素也是一个新语法构造:一个联合元素,写作一系列约束元素被竖线分隔。例如:int | float32
或者~int8 | ~int16 | ~int32 | ~int64
。一个联合元素的类型集合是序列中每个元素的类型集合的并集。列在并集中的元素必须不同。比如:
1 | // PredeclaredSignedInteger 是一个匹配五个预定义的有符号整数的约束 |
联合元素的类型集合是{int, int8, int16, int32, int64}
。由于联合是 PredeclaredSignedInteger 中的唯一元素,这也是 PredeclaredSignedInteger 的类型集。这个约束可以被这五个类型的任意一个满足。
这是一个使用近似元素的例子
1 | // SignedInteger 是一个匹配任意有符号整数的约束。 |
这个约束的类型集是所有底层类型是 int, int8, int16, int32, 或者int64 其中一个的类型集合。这些类型将满足这个约束。
新约束元素的语法是
1 | InterfaceType = "interface" "{" {(MethodSpec | InterfaceTypeName | ConstraintElem) ";" } "}" . |
基于类型集的操作
类型集合的目的是允许泛型函数使用操作符,比如:<,与类型是类型参数的值一起使用。
无则是泛型函数可能使用一个值,它的类型是一个类型参数以某种方式被类型参数的约束的类型集中的每个成员都允许的。这应用到像‘<’ 或 ‘+’ 或者其它泛型操作符。对于特殊目的的操作符,像range循环,如果类型参数有一个 structural 约束,我们也允许使用。这里的约束定义是基本的,只有一个单个的底层类型。如果函数使用约束的类型集中的每个类型都能编译通过,或者当使用 structural 类型应用,然后使用是允许的。
对于之前的 Smallest 例子,我们像这样使用约束:
1 | package constraints |
在实践中,这个约束将会在一个新的标准库包中被定义并且可导出,constraints,所以这可以被函数和类型定义使用。
给定 constraint,我们可以这样写这个函数,现在是合法的:
1 | // Smallest 返回 slice 中最小的元素 |
约束中的可比较类型
之前我们提到过,对于操作符只可以被语言预定义的类型使用的规则,有两个例外。这两个例外是 == 和 !=,它们也可以用到 struct, array, interface 上。这对于我们想要写一个接受任何可比较类型的约束,已经够用了。
为了做到这个,我们介绍一个新的预定义的约束:comparable。comparable 约束的类型集是所有可比较类型的集合。这个类型参数允许使用 == 和 != 。
例如:这个函数可以使用任何可比较类型实例化:
1 | // Index 返回 x 在 s 中的索引,如果不存在就返回 -1 |
由于 comparable 是约束,它可以作为约束嵌入到别的接口类型中。
1 | // ComparableHasher 是一个匹配所有可比较类型且有一个 Hash 方法的约束。 |
约束 ComparableHasher 可以被任何一个是可比较的且有一个 Hash() uintptr 方法的类型实现。一个使用 ComparableHasher 作为约束的泛型函数,可以比较类型的值,也可以调用 Hash 方法。
可以使用可比较来产生任何类型都无法满足的约束。另请参阅下面对空类型集的讨论。
1 | // ImpossibleConstraint 是一个没有类型可以满足的约束, |
互相引用的类型参数
在一个单类型参数列表中,约束可以引用另外的类型参数,甚至它定义在同一个列表的后面也可以。(类型参数的范围在类型参数列表的开头开始,一直延伸到类型封装函数或声明的末尾)
例如,思考一个泛型的图形包,它包含关于图形的泛型算法。算法使用两个类型,Node 和 Edge。Node 有一个方法Edges() []Edge
。Edge 有一个方法Nodes() (Node, Node)
。一个图形可以表示为 []Node。
这个简单的表示足够实现像寻找最短路径的图形算法。
1 | package graph |
这里有很多类型参数和实例。在图形 Node 的约束中,传给类型约束 NodeConstraint 的 Edge,是图形的第二个类型参数。这样使用类型参数 Edge 实例化 NodeConstraint,我们看到 Node 必须有一个Edges方法,它返回 Edge 的 slice,这就是我们要的。同样应用到 Edge 的约束,同样的类型参数和约束在 New 函数重复了一次。我们不是想说明这样很简单,只是说明这样是可能的。
修复注意的是,乍一看这可能看起来像是接口类型的典型用法,Node 和 Edge 是拥有特殊方法的非接口类型。为了使用 graph.Graph,用于 Node 和 Edge 的类型参数必须定义遵循特定模式的方法,但是它们不能使用接口类型来这样做。特别是,方法不是返回接口类型。
例如,思考在别的包里的类型定义:
1 | // Vertex 是一个图形的 Node |
这里没有接口类型,介理我们使用*Vertex
和*FromTo
来实例化graph.Graph
。
1 | var g = graph.New[*Vertex, *FromTo]([]*Vertex{ ... }) |
*Vertex 和 *FromTo 不是接口类型,但是当一起使用的时候,它们定义的方法实现了 graph.Graph 的约束。iyuj我们不能直接把 Vertex 或 FromTo 传给 graph.New,因为 Vertext 和 FromTo 没有实现约束。Edges 和 Nodes 方法是定义在指针类型 *Vertex 和 *FromTo;类型 Vertex 和 FromTo 没有任何方法。
当我们使用一个泛型接口类型作为约束时,我们第一个实例化的类型,是在类型参数列表中提供了类型参数,然后比较相应的类型参数与实例化的约束。在这个例子中,graph.New 的 Node 类型参数有一个约束 NodeConstraint[Edge]。当我们使用 Node 类型参数 *Vertex 和 Edge 类型参数 *FromTo 来调用 graph.New 的时候,为了检查 Node 上的约束,编译器使用类型参数 *FromTo 实例化 NodeConstraint。这产生一个实例化的约束,在这个情况下,需要 Node 有一个方法Edges() []*FromTo
,并且编译器验证 *Vertex 满足这个约束。
虽然 Node 和 Edge 不是必须使用接口类型来实例化,如果你喜欢,使用接口类型也可以。
1 | type NodeInterface interface { Edges() []EdgeInterface } |
我们可以使用 NodeInterface 和 EdgeInterface 来实例化 graph.Graph,因为它们实现了类型约束。没有足够的理由来这么实例化,但是这是允许的。
这种类型参数引用另一个类型参数的能力说明了一个重要的点:任何在 Go 中增加泛型的尝试,都应该要求能够实例化具有多个类型参数的泛型代码,这些类型参数以编译器可以检查的方式相互引用。
类型推断
在许多情况下,我们可以使用类型推断来避免明确的写一些或全部的类型参数。我们可以使用函数参数类型推断,对于函数调用从非类型参数的类型推断类型参数。我们可以使用约束类型推断,从已知类型参数来推断未知类型参数。
在上面的例子中,当实例化一下泛型函数或者类型时,我们总是为所有的类型参数都指定参数。我们也允许只指定一部分类型参数,或者当没有类型参数可以被推断出来时,完全去掉类型参数,它们是列表中的第一个类型参数的入参。
例如,一个函数像这样:
1 | func Map[F, T any](s []F, f func(F) T) []T { ... } |
可以被以三种方式调用。(我们下面将解释类型推断工作的细节;这个例子只是展示类型参数处理的不完全列表)
1 | var s []int |
如果一个泛型函数或类型使用的时候没有指定所有的类型参数,如果未指定的参数不能被推断出来,就会报错。
(注意:类型推断是一个很方便的特性。虽然我们认为它是一个重要的特性,它不添加任何功能到设计中,只是使用上方便。从初始实现中去掉是完全可能的,然后看一下它是否需要。这就是说,这个特性不需要另外增加语法,就可以增加更多的可读性。)
类型统一
类型推断是基于类型统一的。类型统一应用到两个类型,它们中的一个或全部可能是或包含类型参数。
类型统一通过比较类型的结构来生效。它们的结构不考虑类型参数必须相同,并且类型参数以外的类型必须等价。一种类型的类型参数可能匹配任何一个另一种类型的子类型。如果结构不同,或者类型参数之外的类型不等价,那么类型统一就会失败。成功的类型统一提供了类型参数与其它类型(它们本身可能是或包含类型参数)的关联列表。
对于类型统一,如果两个不包含任何类型参数的类型相同,或者它们是相同的忽略通道方向的通道类型,或者底层类型相同,则它们是等价的。在类型推断期间允许类型不相同是可以的,因为如果推断成功,我们仍将检查约束,并且我们仍将检查函数参数是否可分配给推断的类型。
例如,如果 T1 和 T2 是类型参数,[]map[int]bool 可以被统一成以下任一形式:
1 | []map[int]bool |
(这不是一个专有列表,也有其它可能成功的统一形式。)
另一方面,[]map[int]bool 不能被统一成以下任何形式
1 | int |
(这个列表当然也不是专有的;有一个无限的数字类型不能被成功统一)
一般我们可以在两边都有类型参数,所以在某些情况下,我们可能会将 T1 与例如 T2 或 []T2 相联。
函数参数类型推断
函数参数类型推断是与函数调用一起使用的,从非类型参数推断类型参数。函数参数类型推断不是用在类型实例化的时候,并且当函数实例化且没有调用的时候也不使用。
去看一下它怎么工作,让我们回去看一下调用 Print 函数的例子:
1 | Print[int]([]int{1, 2, 3}) |
在这个函数调用中的类型参数 int 可以从非类型参数的类型中推断出来。
唯一可以被推断的类型参数是那些用于函数(非类型)输入参数类型的参数。如果有一些类型参数只用作函数的结果参数类型,或者只在函数体中,那么这些类型参数不能使用函数参数类型推断出来。
推断函数类型参数,我们将函数调用的参数与函数的非类型参数统一起来。在调用者这边,我们有真实的(非类型)参数的类型列表,对于 Print 例子来说就是 []int。在函数这边是函数的非类型参数的类型列表,对于 Print 来说就是 []T。在列表中,我们丢弃函数端不使用类型参数的各个参数。然后我们必须对剩下的参数类型应用类型统一。
函数参数类型推断是一个两阶段的算法。在第一阶段,我们在调用端忽略没有类型的常量和它们在函数定义中对应的类型。我们使用两阶段以便在一些情况下,后面的参数可以被没有类型的常量的类型来决定。
我们统一列表中的相应类型。这将使我们将函数侧的类型参数与调用方的类型联系起来。如果同样的类型参数在函数侧出现了多次,它将在调用侧匹配多个参数类型。如果这些调用侧的类型不等价,我们报一个错误。
第一个阶段之后,我们检查在调用侧的任意一个没有类型的常量。如果没有无类型常量,或者如果对应的函数类型中类型参数已经匹配了其它输入类型,那类型统一就完成了。
否则,对第二阶段来说,对任意一个对应函数类型还没有设置的无类型常量,我们按常规方法来决定无类型常量的默认类型。然后我们再次统一剩下的类型,这次没有无类型常量。
当约束类型推断是可能的时候,像下面描述的,它在两个阶段中应用。
在这个例子中
1 | s1 := []int{1, 2, 3} |
我们比较 []int 和 []T,匹配到 T 和 int,那么我们就完成了。单个类型参数 T 是 int,所以我们推断 Print 的调用实际上是 Print[int]。
一个更复杂的例子,思考
1 | // Map 在 slice s 中的每个元素上调用函数 f, 返回一个新的 slice |
这两个类型参数 F 和 T 都用作输入参数,所以函数参数类型推断是可能的。在调用中
1 | strs := Map([]int{1, 2, 3}, strconv.Itoa) |
我们统一 []int 和 []F,匹配到 F 和 int。我们统一 strconv.Itoa 的类型,它是 func(int) string,和 func(F) T,匹配到 F 和 int,T 和 string。类型参数 F 匹配了两次,每次都是 int。统一成功,所以 Map 的调用就是 Map[int, string] 的调用。
看一下无类型常量规则的效率,思考:
1 | // NewPair 返回同样类型值的 Pair |
在 NewPair(1, 2) 调用中,所以参数都是无类型常量,所以在第一阶段都忽略了。没有任何东西被统一。第一阶段后,我们仍然有两个无类型常量。都设置成了它们的默认类型,int。第二次类型统一阶段统一 F 和 int,所以最终的调用是 NewPair[int](1, 2)
。
在NewPair(1, int64(2))
的调用中,第一个参数是无类型参数,所以我们在第一阶段忽略它。我们然后统一 int64 和 F。在这点,无类型常量对应的类型参数是全确定的,所以最终调用是NewPair[int64](1, int64(2))
。
在NewPair(1, 2.5)
的调用中,所有参数都是无类型常量,所以我继续第二阶段。这次我们设置第一个参数为int, 第二个参数为float64。然后我们尝试统一 F 与 int 和 float64,所以统一失败,然后我们报一个错误。
像之前提到的,函数参数类型推断是不管约束的。首先我们使用函数参数类型推断来确定用于函数的类型参数,然后,如果成功,我们检查这些类型参数是否实现了约束(如果有)。
注意函数参数类型成功推断之后,编译器仍然必须检查实参赋值给形参,就像任何函数调用一样。
约束类型推断
约束类型推断允许基于类型参数约束,从另一个类型参数推断一个类型参数。当函数想要为某个其他类型参数的元素指定类型名称时,或者当函数想要将约束应用于基于某个其他类型参数的类型时,约束类型推断很有用。
约束类型推断只可以推断这些类型,如果一些类型参数有约束且类型集中只有一个类型,或者类型集中的类型底层类型都是一种类型。这两种情况略有不同,因为在第一种情况中,类型集中只有一个类型,单个类型不需要它的底层类型。另一种,单个类型叫做结构类型,并且约束叫做结构约束。结构类型描述了类型参数需要的类型。结构约束可能也定义了方法,不过方法会被约束类型推断忽略。对于约束类型推断是有用的,结构类型将用一个或更多的类型参数来正常定义。
约束类型推断只有在至少有一个类型参数且类型参数未知时才会尝试。
我们这里描述的算法可能比较复杂,典型的具体例子可以直观看到约束类型推断出什么。算法的描述后面有一些例子。
我们通过创建一个类型形参到实参的映射来开始。我们用所有类型参数且它的类型参数是已知的来初始化映射,如果存在的话。
对于每个有结构化约束的类型参数,我们用结构化类型统一类型参数。将类型参数与它的约束联系起来,将会有用。我们把结果添加到我们维护的映射中。如果统一发现了任何类型参数的关联,我们也将它添加到映射中。当我们发任何一个类型参数的多个关联,我们统一每个这样的关联以产生一个单个的映射记录。如果一个类型参数直接与另一个类型参数关联,意味着它们必须都匹配到同一个类型,我们一起统一每个参数的关联。如果这些统一中任意一个失败了,那么约束类型推断就会失败。
将所有类型参数与结构化约束合并之后,我们将有一个各种类型参数到类型(也可能含有其它的类型参数)的映射。我们继续寻找一个类型参数 T,它映射到一个完全知道的类型参数A,它不包含任何类型参数。在映射的类型参数中出现T的任何地方,我们将T替换成A。我们重复这些过程,直到我们替换了每个类型参数。
当约束类型推断可能的时候,类型推断继续像下面这样执行:
- 用已知类型参数组建一个映射。
- 应用约束类型推断。
- 用类型参数应用函数类型推断。
- 再次应用约束类型推断。
- 使用任何剩余无类型参数的默认类型,应用函数类型推断
- 再次应用约束类型推断
元素约束例子
一个约束类型推断有用的例子,让我们考虑一个函数,它接受一个定义类型的数字切片,并且返回一个相同定义类型的实例,其中每个数字都加倍。
如果我们忽略定义类型的要求,写一个类似的函数是非常容易的。
1 | // Double 返回一个新的切片,其中包含s中所有的元素,并双倍。 |
然而,根据这个定义,如果我们用定义好的切片类型调用函数,结果将不是那个定义的类型。
1 | // MySlice 是一个 int 的切片 |
现在如果我们使用明确的类型参数,我们可以得到正确的类型。
1 | // V2 的类型将是 MySlice. |
这里函数参数推断通过自己是不够推断出类型参数的,因为类型参数 E 没有被任何输入参数使用。但是结合函数参数类型推断和约束类型推断就可以推断出。
1 | // V3 的类型将是 MySlice. |
首先我们应用函数参数类型推断。我们看到参数的类型是 MySlice。函数参数类型推断匹配到类型参数 S 和 MySlice。
然后我们继续到约束类型推断。我们知道一个类型参数,S。我们看到类型参数 S 有一个结构类型约束。
我们用知道的类型参数创建一个映射:
{S -> MySlice}
我们然后使用带有约束类型集中只有单个类型的约束来统一类型参数。在这个例子中,结构约束是 ~[]E
,它有结构类型[]E
,所以我们使用[]E
统一 S。因为我们已经有一个对 S 的映射,我们然后用 MySlice 统一[]E
。因为 MySlice 是定义成[]int
,这关联 E 和 int。我们现在有:
{S -> MySlice, E -> int}
我们然后用 int 替换 E,这什么也不改变,然后我们完成了。这次对 DoubleDefined 的调用类型参数是 [MySlice, int]
。
这个例子显示了我们怎么使用约束类型推断为其它类型参数设置类型名字。在这个例子中,我们可以把 S 的元素类型命名为 E,并且我们之后应用约束到 E,在这个例子中,需要它是个数字。
指针方法例子
思考这个例子:函数希望一个类型 T 有一个 Set(string) 方法,它使用 string 来初始化值
1 | // Setter 是一个类型约束, 它需要类型实现 Set 方法从 string 设置值。 |
现在让我们看看一些调用代码(这个例子是无效的)。
1 | // Settable 是一个整数类型,可以设置字符串。 |
目标是使用 FromString 得到一个 []Settable 类型的切片。不幸的是,这个例子是无效,并且无法编译。
问题是 FromStrings 需要拥有 Set(string) 方法的类型。这个函数 F 尝试使用 Settable 去实例化 FromStrings,不过 Settable 没有 Set 方法。拥有 Set 方法的类型是 *Settable。
所以让我们使用 *Settable 来重写 F。
1 | func F() { |
这能编译通过,不过不幸的是,它在运行时会 panic。问题是 FromStrings 创建了一个切片类型[]T
。当使用 *Settable 初始化的时候,这意味着切片类型是[]*Settable
。当 FromStrings 调用 result[i].Set(v)
时,这将在存储在result[i]
中的指针上,调用 Set 方法。这个指针是 nil。Settable.Set 将在一个 nil 接受者上被调用,然后因为 nil 解引用失败,将会引起 panic。
指针类型 *Settable 实现了约束,不过代码真的想要使用非指针类型 Settable。我们需要的是一种方法来写 FromStrings 以便于它可以接受类型 Settable 作为参数,但是调用一个指针方法。重复一下,我们不能用 Settable,因为它没有 Set 方法,并且我们也不能使用 *Settable 因为我们不能创建类型 Settable 的切片。
我们可以做的是两个类型都传递。
1 | // Setter2 是一个类型约束,它要求类型实现 Set 方法用 String 设置值。 |
我们可以像这样调用 FromStrings2:
1 | func F2() { |
这种像期待的那样有效,不过它必须笨拙的在类型参数中重复 Settable。幸运的是,约束类型推断可以减少重复。使用约束类型推断我们可以这样写
1 | func F3() { |
没有办法不传类型参数 Settable。但是给定类型参数,约束类型推断可以为类型参数 PT 推断出 *Settable。
像之前一样,我们创建一个已知类型参数的映射:
{T -> Settable}
我们用结构约束统一每个类型参数。在这个例子中,我们使用单个类型 Setter2[T] 来统一 PT,它是 *T。现在映射是
{T -> Settable, PT -> *T}
我们把所有的 T 替换成 Settable,得到:
{T -> Settable, PT -> *Settable}
这之后没有什么改变,并且我们完成了。所有类型参数都是已知的。
这个例子显示了我们怎么使用约束类型推断来将约束应用到基于其它类型参数的类型上。在这个例子中我们说的 PT,是个 *T 类型,必须有 Set 方法。不需要调用者明确提到 *T 我们就可以做到这一点。
即使在约束类型推断之后也应用约束
即使当约束类型推断已经基于约束来推断类型参数,类型参数确定后,我们仍然必须检查约束。
在上面的 FromStrings2 的例子中,我们能基于 Setter2 来推断出 PT 的类型参数。但是这期间我们只寻找了类型集,我们不看方法。我们仍然必须检查方法也有,满足约束,即使约束类型推断成功了。
例如,思考这个无效的代码:
1 | // Unsettable 是一个没有 Set 方法的类型。 |
当这个调用发生时,我们在调用前应用约束类型推断。这可能成功,并且推断出类型参数是[Unsettable, *Unsettable]
。只有当约束类型推断完成之后,我们检查 *Unsettable 是否实现了约束 Setter2[Unsettable]
。因为 *Unsettable 没有 Set 方法,约束检查将会失败,这个代码将不会编译。
使用在约束中引用自己的类型
对于泛型函数来说,需要一个类型参数和一个参数是类型本身的方法是很有用的。例如,这将自然引起方法比较。(注意我们这里讨论的是方法,不是操作符)假设我们想要写一个 Index 方法,它使用 Equal 方法来检查是否发现期望的值。我们这么写:
1 | // Index 返回 e 在 s 中的索引,如果没有找到,返回-1 |
为了写 Equaler 约束,我们必须写一个约束,它能引用传入的类型参数。最容易做到的方法是利用约束不必定义类型,它可以只是简单的接口类型文字。接口类型的文字然后引用类型参数。
1 | // Index 返回 e 在 s 中的索引,如果没有找到就返回 -1. |
这个版本的 Index 可能与像这种定义的类型 equalInt 一样使用:
1 | // equalInt 一个实现了 Equaler 的 int. |
在这个例子中,当我们把 equalInt 传给 Index,我们检查 equalInt 是否实现了约束interface { Equal(T) bool }
。约束有一个类型参数,所以我们用传入的实参(就是 equalInt)替换类型参数。这给我们一个interface { Equal(equalInt) bool}
。equalInt 类型有一个 Equal 方法有这样的签名,所以它是符合的,编译可以成功。
类型参数的值是没有装箱的
在当前的 Go 实现中,接口类型的值总是持有指针。把一个非指针的值赋给一个接口类型变量,会引用这个值被装箱。这意味着真实的值被放在别的某个地方了,堆或栈中,并且接口类型持有一个指向那个位置的指针。
在这种设计中,泛型类型的值是不装箱的。例如,我们回过头去看一下之前的例子 FromStrings2。当它用类型 Settable 来初始化的时候,它返回一个[]Settable
类型的值。例如,我们可以写
1 | // Settable 是一个整数类型,可以用字符串设置值。 |
当我们用 Settable 实例化来调用 FromStrings2 的时候,我们得到一个 []Settable
类型。切片的元素将是 Settable 的值,那就是说,它们将是整数。他们没有被装箱,即使它们被泛型函数合建和设置。
相似的,当一个泛型函数被实例化,它将像组合一样会有期望的类型。
1 | type Pair[F1, F2 any] struct { |
当这个被实例化,字段将不会被装箱,没有期望外的内存被分配。类型Pair[int, string]
被转换成struct { first int; second string }
。
更多关于类型集合的内容
现在让我们回到类型集来涵盖一些仍然值得注意的不太重要的细节。
元素和方法都在约束中
就像在前面看到的 Setter2 一样,约束可能同时使用约束元素和方法。
1 | // StringableSignedInteger 是类型约束,它匹配任何满足下面两个条件的类型: |
类型集的规则定义了这意味着什么。union 元素的类型集是其底层类型是预先声明的有符号整数类型之一的所有类型的集合。String() string
的类型集是所有定义了这个方法的类型。StringableSignedInteger 的类型集是这两个类型的交集。结果是所有底层类型是预定义的有符号整数且定义了 String() string 方法的集合。使用 StringSignedInteger 作为约束的参数化类型 P 的函数可以对类型 P 的值使用任何整数类型(+,*, 等)所允许的操作。它也可以对值调用 String 方法以获取字符串。
值得注意的是这里的 ~。StringableSignedInteger 约束使用 ~int,不是 int。类型 int 本身不允许作为类型参数,因为它没有 String 方法。允许类型参数的一个示例是 MyInt,定义如下:
1 | // MyInt 是可字符串化的 int. |
约束中的组合类型
像我们在之前的一些例子中看到的一样,约束元素可以是类型字面量。
1 | type byteseq interface { |
通常的规则应用:这个约束的类型参数可能是 string 或者 []byte
;使用这个约束的泛型函数可以使用任何被 string 和 []byte
同时允许的操作。
byteseq 约束允许写对 string 和 []byte
都有用的泛型函数。
1 | // Join 连接它第一个参数的元素以创造一个单个的值。seq 是结果中放在元素中间的。 |
对于组合类型(string, pointer, array, slice, struct, function, map, channel)我们强加一个额外的限制:仅当运算符接受相同的输入类型(如果有)并为类型集中的所有类型生成相同的结果类型时,才可以使用操作。需要明确的是,仅当组合类型出现在类型集中时才施加此附加限制。当组合类型由类型集之外的类型参数形成时,它不适用,例如某些类型参数 T 的 var v []T。
1 | // structField 是一个由一些带有字段名 x 的 struct 类型组成的类型约束。 |
添加这个限制使用在泛型函数中理解一些操作的类型更加容易了。它避免了基于对类型集的每个元素应用一些操作来引入具有构造类型集的值的概念。
(注意:对人们想要怎么写代码理解的越多,在将士为可以会放松这个限制)
类型集合中的类型参数
在约束元素中的类型字面量可以引用约束中的类型参数。在这个例子中,泛型函数 Map 接受两个类型参数。第一个类型参数要求有一个底层类型是第二个类型参数的切片。第二个类型参数没有约束限制。
1 | // SliceConstraint 是一个匹配类型参数切片的约束。 |
在之前讨论约束类型推断的时候,我们展示过类型这种的例子。
类型转换
在一个有两个类型参数 From 和 To 的函数中,如果在 From 约束类型集中的所有类型都可以转换成所有在 To 约束类型集中的类型,From 类型的值可以转成 To 类型的值。
这是个普遍规则的结果:泛型函数可以使用被类型集中所有类型都允许的任何操作。
例如:
1 | type integer interface { |
在Convert 中的转换是允许的,因为 Go 允许整数被转换成任何其它整数类型。
没有类型的常量
一些函数使用无类型常量。如果类型参数约束的类型集中的每个类型都允许使用类型参数的值,则允许使用无类型常量的值。
与类型转换一样,普遍规则的结果:泛型函数可以使用任何被类型集所有每个都允许的操作。
1 | type integer interface { |
嵌入的约束的类型集合
当约束嵌入其它约束时,外面约束的类型集是所有涉及的类型集的交集。如果有多个嵌入类型,则交集保留任何类型参数必须满足所有约束元素的要求的属性。
1 | // Addable 是支持 + 操作符的类型 |
一个嵌入约束可能出现在联合元素中。联合的类型集是,像平常一样,列在联合中的元素的类型集的并集。
1 | // Signed 是拥有所有有符号整数类型的约束。 |
联合元素中的 interface 类型
我们说过联合元素的类型集是联合中所有类型的类型集的并集。对于大多数类型 T 它的类型集就是它自己。对于接口类型(和近似元素),不是这样的。
没有嵌入非接口元素的接口类型的类型集是,像我们之前说过的,所有声明了接口方法的类型,包含接口类型自己。在联合元素中使用这样的接口类型将把它的类型集添加到并集中。
1 | type Stringish interface { |
Stringish 的类型集是 string 类型和所有实现了 fmt.Stringer 的类型。这些类型(包括 fmt.Stringer 自己)将被允许作为这个约束的类型参数。使用 Stringish 作为约束的类型参数中的值,什么操作都不允许(而不是所有都支持的操作)。这是因为 fmt.Stringer 在 Stringish 的类型集中,并且 fmt.Stringer,一个接口类型,不支持任何类型特定的操作。被 Stringish 允许的操作就是那些被类型集中所有类型都支持的操作,包括 fmt.Stringer,所以在这种情况下没有操作被允许,除非被全部类型都支持的。使用这个约束的参数化的函数必须使用类型断言或反射来使用值。仍然,在一些场景下对于强静态类型检查会非常有用。最主要的点是它直接来自约束满足和类型集的定义。
空类型集合
写一个空类型集的约束是可能的。那将没有任何类型参数能满足这个约束,所以任何尝试去实例化一个拥有空类型集约束的函数将会失败。一般来讲让编译器去检查所有这种情况是不可能的。可能 vet 工具如果能检查出这种情况应该报错。
1 | // Unsatisfiable 是一个拥有空类型集的约束。 |
类型集合注意事项
明确的在约束列出类型可能显得很笨拙,但是很清楚在调用方允许哪些类型参数,以及泛型函数允许哪些操作。
如果语言之后修改了,变得支持操作符方法(现在还没有这个计划),那时约束像任何其它方法一样算是它们。
总是有一定数量的预定义类型,以及这些类型支持的一些操作。将来语言改变也不会从根本上改变这些事实,所以这种方式将继续有用。
这种方法不会尝试去处理每一个可能的操作。期望是在泛型函数和类型定义中的组合类型将被正常的当作组合类型处理,而不是将组合类型放入类型集中。例如,我们希望想在切片元素类型T上参数化索引切片的函数,使用[]T
类型的参数或变量。
就像上面 DoubleMySlice 的例子中看到的,这种方法将使得声明一个接受与返回组合类型,并且想返回跟参数一样类型的泛型函数更麻烦。定义组合类型不常见,但是确实可能出现。这个难点是这种方法的弱点。在调用方的约束类型推断可以提供帮助。
反射
我不会提出以任何方式更改反射包的提案。当一个类型或函数被实例化时,所有的类型参数将会变成普通的非泛型类型。一个实例化类型的 reflect.Type 的 String 方法将会返回以类型参数在方括号中的方式的名字。例如,List[int]。
非泛型的代码不能引用一个没有实例化的泛型代码,所以未实例化的泛型类型或函数就没有反射信息。
实现
Russ Cox 著名地观察到泛型需要在慢程序员,慢编译器,或慢的执行之间进行选择。
我们相信这个设计允许不同的实现选择。代码可能对于类型参数的每个集合分别编译,或者可以编译为每个类型参数的处理方式类似于带有方法调用的接口类型,或者可能是两者的某种组合。
换句话说,这个设计允许人们停止选择慢程序员,并且允许实现在慢编译器(单独编译每组类型参数)或慢执行时间之前决定(对于类型参数值上的每种操作使用方法调用)。
小结
虽然本文档冗长而详细,但实际设计减少了几个要点。
- 函数和类型可以有类型参数,这些参数是使用约束来定义的,它们是接口类型。
- 约束描述了类型参数所需的方法和允许的类型。
- 约束描述了类型参数允许的方法和操作。
- 当调用带有类型参数的函数时,类型推断经常允许省略类型参数。
- 这个设计是完全后向兼容的。
我们相信这个设计满足了人们对Go通用编程的需求,而不会使语言变得过于复杂。
如果没有多年的设计经验,我们无法真正了解对语言的影响。也就是说,这里有一些猜测。
复杂性
Go 很重要的一个特点是简单性。这设计无疑会让语言变得更复杂。
我们相信对于阅读编写良好的泛型代码而不是编写它的人来说,增加的复杂性是很小的。自然,人们必须学习声明类型参数的新语法。这个新语法,以及对于接口中类型集的新支持,都只是这个设计中的新的语法构造。泛型函数中的代码读起来像普通的 Go 代码,就像下面的例子中看到的一样。从 []int 到 []T 是个很容易的迁移。类型参数约束很有效率的作为文档,来描述类型。
我们希望大多数的包不会定义泛型类型或函数,但是很多包可能会使用在别外定义的泛型类型或函数。在常见情况下,泛型函数的工作方式与非泛型函数完全相同:你简单的调用它们。类型推断意味着你不需要必须明确地写出类型参数。类型推断规则的设计并不令人惊讶:类型参数被正确推断,或者调用失败并需要显式类型参数。类型推断使用类型等价,不尝试解析两种相似但不等价的类型,从而消除了显著的复杂性。
使用泛型类型的包必须明确伟类型参数。这个语法是直白的。唯一的改变是传参数给类型而不是只给函数。
一般来说,我们在设计尝试避免惊讶。只有时间将证明我们是否成功。
无处不在的
我们希望在标准库中增加一些新的包。一个 slices 包,类似于现在的 bytes 包和 strings 包,操作任何元素类型的切片。新的 maps 和 chans 包,将提供现在为了每种元素类型重复的算法。会增加 sets 包。
一个新的 constraints 包,提供标准的约束,例如允许所有整数或所有数字类型的约束。
像 container/list 和 container/ring 的包,和像 sync.Map 和 sync/atomic.Value 的类型,将被更新到编译时的类型安全,这些包将使用新的名字或新的版本。
math 包将会扩展以提供针对所有数字类型的标准算法,例如很流利的 Min 和 Max 函数。
我可能在 sort 包中增加泛型变量。
好像新特殊目的的编译时类型安全容器类型将被开发。
我不希望像 C++ STL 迭代器类型这样的方法被广泛使用。在 Go 中各种想法可以更自然的使用接口类型来表达。在 C++ 术语中,为迭代器使用接口类型可以被视为带有抽象损失,因为运行时效率低于实际上内联所有代码的 C++ 方法;我们相信 Go 程序员会继续发现这种损失是可以接受的。
当我们得到更多的容器类型,我们将开发一个标准的迭代器接口。这可能反过来导致修改语言以添加一些机制来使用带有 range 子句的迭代器压力。不过,这是非常把投机的。
效率
目前尚不清楚人们期望从泛型代码中获取什么样的效率。
泛型函数,而不是泛型类型,可能使用基于接口的方法来编译。这将优化编译时间,函数将只编译一次,但是将有一些运行时间成本。
泛型类型可以很自然的为每组参数编译多次。这将肯定带来编译时间消耗,但是没有运行时间消耗。编译可能选择像接口类型一样实现泛型类型,使用特殊的方法来访问每个依赖于类型参数的元素。
只有经验才会显示出人们在这一块希望什么。
疏漏
我们相信这个设计覆盖了泛型编程的基本需求。然而,还有一些编译构造是不支持的。
- 没有泛型特化。没有办法去写一个多版本的泛型函数让它只对几个特殊的类型参数起作用。
- 没有无编程。没有办法去写在编译时运行,以产生运行时代码的代码。
- 没有高级别的抽象。一个带有类型参数的函数,如果不去调用它或实例化它,没有办法使用。一个泛型类型如果不实例化它,就没有办法使用。
- 没有泛型类型描述。为了在泛型函数中使用操作符,约束列出特定的类型,而不是描述类型必须的特性。这很容易理解,不过有时间是个限制。
- 函数参数没有协变和逆变。
- 没有操作符方法。你可以写一个编译时类型安全的泛型容器,但是你只能通过普通方法访问它,而不通使用像 c[k] 这样的语法。
- 没有柯里化。没有办法部分实例化一个泛型函数或类型,而不是通过一个帮助函数或者一个包装类型。所有的类型参数必须明确的传入或者在实例化时被推断出来。
- 没有可变类型参数。不支持可变类型参数,这是允许写一个单个的泛型函数可以接受不同数量的类型参数和普通参数。
- 没有适配器。没有办法为约束定义一个适配器,让它支持那些没有实现这个约束的类型,比如,根据 Equal 方法定义 == 操作符,反之亦然。
- 无类型值无法参数化,比如常量。这在数组中最为明显,有进可以方便的编写类型 Matrix[n int][n][n]float64。有时为容器类型指定值也可能很有用,例如元素的默认值。
问题
这个设计有一些问题应受更详细的讨论。我们认为这些问题与整体设计相比较小,但仍然值得完整的聆听和讨论。
零值
这个设计对于类型参数的零值没有简单的表达式。例如,思考这样一个例子,使用指针的可选值的实现:
1 | type Optional[T any] struct { |
在这个情况下,o.p == nil,我们想要返回 T 的零值,但是我们没有办法去这么写。返回一个 nil 会不错,但是如果 T 是,就不能正常工作,说,int;在这个情况下,我们必须返回 0。而且,当然,没有办法去写一个约束来支持,返回 nil 或者 0.
针对这种情形的一些办法:
- 使用
var zero T
,像上面一样,这在当前的设计下可以正常工作,不过需要一条额外的语句。 - 使用
*new(T)
,这是隐秘的,不过在当前设计下有效。 - 只对结果来说, 命名结果参数,并且使用一个不带任何值的返回语句来返回一个零值。
- 扩展设计以允许使用 nil 作为任何泛型类型的零值(但是看问题 22729).
- 扩展设计以允许使用 T{},这里 T 是类型参数,来表示这个类型的零值。
- 改变语言以允许在赋值语句的左边使用 _ (包括返回或函数调用)作为提案。
- 改变语言以允许返回 … 来返回结果类型的零值,在问题 21182 中提出。
我们感觉决定在这里做(如果有的话)需要更多的设计经验。
识别匹配的预声明类型
这个设计不提供任何方法来测试由 ~T 约束元素匹配的底层类型。代码可以通过转换为空接口类型并使用类型断言或类型切换的有点尴尬的方法来来测试实际的类型参数。但是这让代码可以测试实际的类型参数,它与底层类型不同。
这是一个例子,显示了不同之处。
1 | type Float interface { |
当实例化G的时候,这个代码将会 panic,因为在 NewtonSqrt 函数中的 v 的类型是 MyFloat, 不是 float32 或 float64。这个函数真正想测试的不是 v 的类型,而是在约束类型集中 v 匹配的近似类型。
一种解决这种情况的办法是允许在 type switch 中写近似类型,像 case ~float32:
。这样这个case 将匹配任何底层类型是 float32 的类型。这是有意义的,并且有用,即使在泛型函数这外的 type switch 中也一样。
无法表达可转换性
该设计无法表达两个不同类型的转换性。例如,无法写这样的函数:
1 | // Copy 从 src 拷贝值到 dst,随时转换它们。 |
从类型 T2 转换到类型 T1 是无效的,因为对于允许转换的类型都没有约束。更糟的是,在泛型中没有办法写出这样的约束。在特殊情况下, T1 和 T2 都有有限的类型集,这个函数可以写成抑是之前讨论使用类型集来转换的那样。但是,例如,没有办法为这种情况写一个约束,让T1是接口类型并且 T2 是实现了该接口的类型。
值得注意的是如果 T1 是一个接口类型然后这可以写成使用转换成空接口类型并且类型断言的方式,但是当然,这不是编译时类型安全的。
1 | // Copy 从 src 到 dst 复制值,随时转换它们。 |
没有参数化的方法
该设计不允许方法声明对方法特定的类型参数。接受者可能有类型参数,但是方法不能添加任何的类型参数。
在 Go 中,方法的主要角色之一就是允许类型实现接口。目前尚不清楚是否可以合理地允许参数化方法实现接口。例如,思考这个代码,对参数化方法使用了明显的语法。这个代码使用了多个包,使用问题更清楚。
1 | package p1 |
在这个例子中,我们有一个带有参数化方法的类型 p1.S 和一个也有参数化方法的类型 p2.HasIdentity。p1.S 实现了 p2.HasIdentity。所以,函数 p3.CheckIdentity 可以用 int 参数调用 vi.Identity,在 p4.CheckSIdentity 的调用中将会调用 p1.S.Identity[int]。但是包 p3 不知道关于 p1.S 类型的任何事。可能在程序的别的地方没有其它调用 p1.S.Identity 的地方。我们需要在别的地方实例化 p1.S.Identity[int], 但是怎么做到呢?
我们可以在链接时实例化它,但是一般情况下这需要链接器遍历完整的程序调用图来决定可能传给 CheckIdentity 的类型集。并且当反射类型被调用的时候,遍历也不能满足一般情况,因为径向可能基于用户输入的字符串来录找方法。所以通常在链接器中实例化参数化方法可能震怒为每个可能的类型参数实例化每个参数化方法,这似乎是站不住脚的。
或者,我们可以在运行时实例化它。一般来说这意味着使用某种 JIT,或编译代码以使用某种反射方法。这里每种方法非常复杂难以实现,并且在运行时会非常慢。
或者,我们可以决定参数化方法实际上不实现接口,但是这样对于我们为什么需要这个方法就不够清楚。如果我们不考虑接口,任何参数化方法可以被当作一个参数化函数实现。
因此,虽然参数化方法乍一看很有用,但是我们必须决定它们的含义以及如何实现它。
没有办法要求指针方法
在一此情况下,参数化函数是自然编写的,因为它总是可寻址值的方法。例如,当在切片中每个元素上调用方法,这就会发生。在这种情况下,该函数只要求方法在切片元素类型指针的方法集中。这个设计中描述的类型约束无法编写这样的要求。
例如,思考一个我之前展示过的例子中的 Stringify 的变量。
1 | // Stringify2 在 s 的每个元素上调用 String 方法,并返回结果。 |
假设我们有一个 []bytes.Buffer 并且我们想要把它转换成 []string。这里的 Stringify2 函数并不能帮我们做到。我们想要写一个Stringify2[bytes.Buffer],但是我们不能,因为 bytes.Buffer 没有 String 方法。有 String 方法的类型是 *bytes.Buffer
,但是我们只有 []bytes.Buffer
。
在上面指针方法例子中我们讨论过类似的情况。在那个例子中我们使用约束类型推断来简化问题。这里这样行不通,因为 Stringify2 实际并不关心调用一个指针方法。它只是想要一个有 String 方法的类型,并且如果方法只在指针方法集中,不在值方法集中也可以。但是我们也想要接受方法在值方法集中的情况,例如,如果我们真的有一个 []*bytes.Buffer
。
我们需要一种方式来说明类型约束适用于指针方法集还是值方法集。函数主体只需要调用该类型的可寻址值方法。
这个问题在实践中多久被解决还不清楚。
浮点数和复数之间没有关联
约束类型推断让我们给切片类型的元素一个名字,并且应用其它类似类型分解。然而,没有方法关联浮点类型和复合类型。例如,使用这个设计没有办法与一个预定义的实数,虚数,或复合函数。没有办法说“如果参数类型是 complex64,那么结构类型是 float32。”
一个可能的方法是允许real(T)作为类型约束,意思是“与复合类型T关联的浮点类型”。类似地,complex(T)将表示“与浮点类型T关联的复合类型”。约束类型推断将简化调用站点。但是,这与其他类型约束不同。
放弃的想法
这个设计不是完善的,可能有改进的方法。这就是说,我们已经详细考虑了许多想法。本节列出了其中的一些想法,希望有助于减少重复讨论。这些想法以常见问题解答的形式呈现。
contract 发生了什么
较早的泛型设计草案使用称为合同的新语言结构实现了约束。类型集只出现在全同中,而不出现在接口类型中。然而很多人很难理解合约和接口类型之间的区别。事实证明,合约可以表示为一组相应的接口;没有合同就没有表达能力的损失。我们决定简化只使用接口类型的方法。
为什么不用方法集而使用类型集?
类型集是神秘的。为什么不为所有操作符写方法呢?
允许操作符作为方法名字是可能的,导致方法像+(T) T
。不幸地,这是不满足的。我们需要一些机制来描述匹配任何整数类型的类型,对于像移位<<(integer) T
和索引[](integer) T
的操作,它们不只限制于单独的 int 类型。对于像 ==(T)
的操作,我们也需要未知类型的布尔类型。对于像转换的操作,我们需要引入新的记号,或者表示它可能跨域一个类型,这可能需要一些新的语法。我们需要一些机制来描述无类型常量的有效值。我们必须考虑是否支持<(T) bool
是否意味着泛型函数也可以使用<=
,同样是否支持+(T) T
意味洋函数可以使用++
。使用这种方式生效也是可能的,但是它不直观。使用这个设计的方式好像更简单,并且只依赖一个新语法构造(类型集) 和一个新名字(comparable)。
为什么不在包上加类型参数?
我们广泛地调查这个。当你想要写一个 list 包的时候,并且你想这个包包含一个 Transform 函数,它可以把一个类型的 List 转换成另一个类型的 List,这个时候会有问题。在一个包的实例中的一个函数,返回一个类型,它需要同一个包的不同的实例,这会很奇怪。
包边界与类型定义也会让人很迷惑。没有特别的理由认为泛型类型的使用会整齐的分解包。有时它们会,有时间它们不会。
为什么不像 C++ 和 Java 一样使用 F<T>
这样的语法?
当解析函数中的代码时,比如 v:= F<T>
,在找到<
的点,我们是寻找一个类型实例还是寻找一个使用<
操作符的表达工呢?就会模糊不清。在没有类荆信息的时候,这很难解。
例如,思考一个像这样的语句
1 | a, b = w < x, y > (z) |
没有类型信息,无法决定等号右边是一对表达式(w < x and y > (z)
),还是泛型函数实例化并且调用它返回两个结果值((w<x, y>)(z)
)。
Go 的一个关键设计就是没有类型信息解析是可能的,当泛型使用尖括号时,这好像是不可能的。
为什么不使用 F(T) 语法?
这个设计的早期版本使用这个语法。这是可以的,不过它引入几个解析歧义。比如,当写var f func(x(T))
,不清楚该类型是具有实例化类型x(T) 的单个未命名的参数的函数,还是具有名为 x 的参数的函数类型(T)(通常写为 func(x T),但是在这种情况下使用带括号的类型)。
也有其它的歧义。对于[]T(v1)
和[]T(v2){}
,在开括号的位置,我们不知道这是一个类型转换(v1的值转换成[]T
类型)还是一个类型字面量(它的类型是实例化的T(v2)
)。对于interface { M(T) }
,我们不知道这是一个拥有方法M的接口还是一个拥有一个内嵌实例化接口M(T)的接口。这些歧义可以解决,添加更多的括号,但是很笨拙。
也有一些人对像func F(T any)(v T)(r1, r2 T)
的声明或像F(int)(1)
调用中涉及的括号数量所困扰。
为什么不使用F«T»
?
我们考虑过它但是我们不能让自己需要非ASCII字符。
为什么不把 constraints 定义在 builtin 包中?
不是写出类型集,而是使用像 constraints.Arithmetic 和 constraints.Comparable 的名字。
列出所有可能组合的类型集非常长。它还引入一组新名称,不仅是通用代码的编写者,更重要的是,读者,必须记住。这个设计致力的一个目标就是尽可能的少引入新的名字。在这个设计中我们只引入两个新的预定义名字,comparable 和 any。
我们希望如果人们发现这样有用的名字,我们可以引入一个 constraints 包来定义这些名字,这些名称可以被其他类型和函数使用并嵌入到其他约束中。这将在标准库中定义最有用的名字,同时让程序员可以灵活地在适当的情况下使用其他类型组合。
为什么不允许对类型是类型参数的值进行类型断言?
在这个设计的早期版本中,我们允许对类型是类型参数或它的类型是基于类型参数的变量使用类型断言和类型 switch。我们移除这个功能是因为把任何类型转换成空接口类型总是可能的,然后对它使用类型断言或类型 switch。此外,有时令人困惑的是,在具有使用近似元素的类型集的约束中,类型断言或类型switch 将使用实际的类型参数,而不是类型参数的底层类型(差异在关于识别匹配的预声明类型的部分中)。
跟Java相比
大多数关于Java泛型的抱怨都是围绕类型擦除的。这个设计没有类型擦除。泛型类型的反射信息包含完全的编译时类型信息。
在Java中的类型通配符(List<? extends Number>, List<? super Number>
) 实现了协变和逆变。Go缺少这些概念,这使得泛型更简单。
跟C++相比
C++模板不对类型参数强加任何限制(除非概念提案被采纳)。这意味着更改模板代码可能会意外地破坏遥远的实例化。也意味着错误信息只会在实例化时才会报,并且可能被尝试嵌套且难以明白。这个设计通过强制和显式约束避免了这些问题。
C++ 支持模板元编程,可以将其视为在编译时使用与非模板C++完全不同的语法完成的普通编程。这个设计没有类似的特性。这节省了相当多的复杂性,同时损失了一些能力和运行时效率。
C++ 使用两阶段名字查找,一些名字是在模板定义期间被找到的,一些名字是在模板实例化时找到的。这个设计中所有名字都是在它们被写的地方查找的。
在实践中,所有C++ 编译器在它实例化的地方编译每个模板。这可能拖慢编译时间。这个设计提供了如何处理泛型函数编译的灵活性。
跟 Rust 相比
在这个设计中描述的泛型跟 Rust 的泛型很像。
一个不同是在Rust中 trait bound 和类型的关系必须被明确定义,在定义 trait bound 的 crate 中或在定义类型的 crate 中。在 Go 术语中,这意味着我们必须在某个地方声明一个类型是否满足约束。就像Go 类型可以不需要明确的声明就满足 Go 接口,在这个设计中 Go 类型参数可以不用显式声明就可以满足约束。
这个设计使用类型集的地方,Rust 标准库为 comparison 等操作定义了标准 traits。这些标准 traits 由 Rust 的原始类型自动实现,也可以由用户定义的类型实现。Rust 提供了一个相当广泛的特征列表,至少 34 个,涵盖了所有的操作符。
Rust 支持方法上的类型参数,这个设计不支持。
例子
下面是这个设计可以怎么使用的例子。这旨在解决人们创建与 Go 缺少泛型有关的用户体验报告的特定领域。
Map/Reduce/Filter
这是一个如何写 map, reduce, 和 filter 的例子。这些函数旨在对应于 Lisp, Python, Java 等中类似的函数。
1 | // Package slices 实现了各种 slice 算法。 |
这是这些函数的一些调用例子。类型推断被用于根据非类型参数的类型确实类型参数。
1 | s := []int{1, 2, 3} |
Map keys
这是如何得到任意一个 map 的 keys 切片
1 | // Package maps 提供了对任何 map 类型都有效的泛型函数。 |
典型使用情况下,map 键值类型都将被推断出来。
1 | k := maps.Keys(map[int]int{1:2, 2:4}) |
Sets
很多人要求扩展或缩减Go的内置地图类型以支持集合类型。这是一个集合类型的类型安全袜,尽管它使用方法而不是像[]这样的运行符。
1 | // Package sets 实现了任何可比较类型的集合 |
使用的例子:
1 | // 创建一个int集合 |
这个例子展示了如何使用这个设计来为一个存在的API提供一下编译时类型安全的包装
Sort
在引入 sort.Slice 之前,一个常见的报怨是需要样板定义才能使用 sort.Sort。通过这种设计,我们可以在 sort 包中添加如下内容。
1 | // Ordered 是匹配所有排序类型的类型约束。 |
现在我们可以这样写:
1 | s1 := []int32{3, 5, 2} |
使用一样的行数,我们可以添加一个使用 comparsion 函数排序的函数,跟 sort.Slice 类似,但是写函数接收值而不是切片索引。
1 | // sliceFn 是一个实现了 sort.Interface 的内部类型。 |
调用这个的例子是:
1 | var s []*Person |
Channels
许多简单一般目标的 channel 函数是从来不写的,因为它们必须使用反射并且调用者必须对结果使用类型断言。用这个设计,它们可以直白的这么写:
1 | // Package chans 实现了各位 channel 算法。 |
在下一个部分,有一个使用这个函数的例子。
Containers
一个对Go泛型的频繁的请求是写编译时类型安全容器的能力。这个设计使对现有的容器写编译时类型安全的包装器非常容易。我们不写这样的例子。这个设计也使编写不使用装箱的编译时类型安全的容器更容易。
这是一个以二叉树实现的排序map的例子。它的实现细节并不太重要。重要的是:
- 代码是用纯正的go风格编写的,使用需要的键值类型。
- 键和值是直接以树的结点存储的,不是使用指针,没有装条成interface 值。
1 | // Package orderedmaps 提供了一个排序的 map, 二叉树实现。 |
这是这个看起来怎么用
1 | import "container/orderedmaps" |
Append
存在预先声明的 append 函数以替换样板文件,否则需要增长切片。在将 append 添加到语言之前,在bytes 包有一个 Add 函数:
1 | // Add 追加 t 的内容到 s 的结尾,并返回结果。 |
Add 追加两个[]byte
值,返回一个新的切片。这样对于[]byte
很好,但是如果你有一个其它类型的切片,你必须写本质上同样的代码来追加更多的值。如果这个设计当时可用,可能我们不会在语言中添加 append。相反,我们可以这样写:
1 | // Package slices 实现了各种的切片算法。 |
这个例子使用了预先声明的copy 函数,但是这是没问题的,我们也可以写一个:
1 | // Copy 从t复制值到s,当切片满了就停止,返回复制的值的数量。 |
这些函数可以像下面这样使用:
1 | s := slices.Append([]int{1, 2, 3}, 4, 5, 6) |
这个代码没有实现特殊的追加或复制 string 到[]byte
的情形,它不太可能像预先定义的函数那样高效。仍然,这个例子表明,使用这种设计将允许一次通用地编写追加和复制,而不需要任何额外的特殊语言特性。
Metrics
在 Go 的体验报告中,Sammer Ajmani 描述了一个指标实现。每个指标都有一个值和一个或多个字段。字段有不同的类型。定义指标 需要指定字段的类型。Add 方法将字段类型作为参数,并记录该字段集的一个实例。C++实现使用可变参数模板。Java实现包括类型名字中的字段数。C++和Java实现都提供了编译时类型安全的 Add 方法。
以下是如何使用此设计通过编译时类型安全的 Add 方法在 Go 中提供类似的功能。 因为不支持可变数量的类型参数,所以我们必须为不同数量的参数使用不同的名称,就像在 Java 中一样。 此实现仅适用于可比较的类型。 更复杂的实现可以接受比较函数来处理任意类型。
1 | // Package metrics 提供了泛型的机制来计算不同值的指标 |
像这样使用这个包:
1 | import "metrics" |
由于缺乏对可变参数类型参数的支持,这个实现有一定的重复。但是,使用该软件包很容易且是类型安全的。
List transform
虽然切片是高效且容易使用,但在某些情况下链表是合适的。此示例主要展示了将一种类型的链表转换为另一种类型,作为使用相同泛型类型的不同实例化的示例。
1 | // Package lists 提供了一任何类型的链表 |
点乘
泛型实现了对任何数值类型都有效的点乘
1 | // Numeric 是一个匹配任何数值类型的约束。 |
(注意:泛型实现方法可能会影响DotProduct 是否使用 FMA,从而影响使用浮点类型时的确切结果。目前尚不清楚这是一个多大的问题,或者是否有任何方法可以解决它)
绝对差
通过Abs 方法计算两个数值的绝对差。这使用了定义在上个例子中同样的 Numeric 约束。
这个例子使用了比计算绝对差的简单情况更多的机器。它旨在展示如何将算法的公共部分分解为使用方法的代码,其中方法的确切定义可能会根据所使用的类型的种类而有所不同。
注意:这个例子中的代码在 Go 1.18 中不能工作。我们希望解决这个问题以使它在将来的版本中可以工作。
1 | // NumericAbs 将数值类型与Abs方法匹配。 |
我们可以为不同的数值类型定义 Abs 方法
1 | // OrderedNumeric 匹配支持< 操作符的数据类型 |
然后,我们可以定义为调用者完成工作的函数,方法是与我们刚刚定义的类型相互转换。
1 | // OrderedAbsDifference 返回 a 和 b 差的绝对值,这里 a 和 b 是有序类型。 |
值得注意的是,这种设计还不够强大,无法编写如下代码:
1 | // 这个函数是无效的 |
对 OrderedAbsDifference 和 ComplexAbsDifference 的调用无效,因为并非所有实现 Numeric 约束的类型都可以实现 OrderedNumeric 或 Complex 约束。 尽管类型切换意味着此代码在概念上将在运行时工作,但不支持在编译时编写此代码。 这是表达上面列出的遗漏之一的另一种方式:这种设计不提供特殊化。
Acknowledgements
我们要感谢Go团队中的许多人、Go问题跟踪器的许多贡献者,以及所有分享他们的想法和对早期设计草案的反馈的人。我们阅读了所有内容,我们很感激。
特别是对于这个版本的提案,我们收到了来自 Josh Bleecher-Snyder、Jon Bodner、Dave Cheney、Jaana Dogan、Kevin Gillette、Mitchell Hashimoto、Chris Hines、Bill Kennedy、Ayke van Laethem、Daniel Martí、Elena Morozova、Roger 的详细反馈 佩佩和罗娜·斯坦伯格。
附录
本附录涵盖了设计的各种细节,这些细节似乎不足以在前面的部分中涵盖。
泛型类型别名
类型别名可以引用泛型类型,但类型别名可能没有自己的参数。 存在此限制是因为不清楚如何处理具有约束的类型参数的类型别名。
1 | type VectorAlias = Vector |
在这种情况下,类型别名的使用必须提供适合被别名的泛型类型的类型参数。
1 | var v VectorAlias[int] |
类型别名可能也指向实例化的类型。
1 | type VectorInt = Vector[int] |
实例化函数
Go 通常允许您在不传递任何参数的情况下引用函数,从而生成函数类型的值。 您不能对具有类型参数的函数执行此操作; 所有类型参数必须在编译时已知。 也就是说,您可以通过传递类型参数来实例化函数,但您不必调用实例化。 这将产生一个没有类型参数的函数值。
1 | // PrintInts 是 func([]int) 类型 |
内嵌类型参数
当一个泛型类型是结构体,并且类型参数是内嵌作为结构体一个字段,字段名字是类型参数的名字。
1 | // Lockable 是一个可以安全地同时从多个goroutines 通过 Get 和 Set 方法访问的值。 |
内嵌类型参数方法
当泛型类型是结构体时,并且类型参数是内嵌的结构体字段,类型参数的约束的任何方法被提升到结构体的方法。(出于选择器解析的目的,这些方法被视为位于类型参数的深度0,即使在实际类型参数中这些方法本身是从内嵌类型中提升的)
1 | // NamedInt 一个有名字的 int. 名字可以是有 String 方法的任何类型。 |
内嵌的实例化类型
当内嵌一个实例化的类型,字段的名字是没有类型参数的名字。
1 | type S struct { |
泛型类型作为类型switch case 时
泛型类型可以被用作类型断言或类型 switch 中case 的类型。
这是一些琐碎的例子:
1 | func Assertion[T any](v interface{}) (T, bool) { |
在类型 switch 中,如果泛型类型结果证明跟 type switch 中其它case 是重复也没有问题,第一个匹配的 case 会被选中。
1 | func Switch2[T any](v interface{}) int { |
约束元素的类型集
就像接口类型的类型集是接口元素的类型集的交集一样,接口类型的方法集可以定义为接口元素的方法集的并集。在大多数情况下,内嵌元素没有方法,这样不会贡献任何方法给接口类型。这就是说,为了完整性起见,我们将注意到,~T的方法集是T的方法集。联合元素的方法集是联合元素的方法集的交集。这些规则隐含在类型集的定义中,但它们不是理解约束行为所必需的。
允许约束作为普通接口类型
这是我们现在不建议的特性,但是该语言的以后的更高版本可以考虑。
我们建议约束可以嵌入一些额外的元素。有了这个提议,任何嵌入接口类型以外的的任何内容的接口类型只能用作约束,或作为另一个约束中的嵌入元素。下一步自然是允许使用嵌入任何类型或嵌入这些新元素的接口类型作为普通类型,而不仅仅是作为约束。
我们现在不建议我这样做。但是上面的类型集和方法集的规则描述了它们的行为方式。作为类型集元素的任何类型都可以分配给这样的接口类型。这种接口类型的值将允许调用方法集的任何成员。
这将允许其他语言称为sum类型或union类型的版本。这将是一个GO接口类型,只能分配特定类型。当然,这样的接口类型仍然可以取值nil,因此它与其他语言中的典型sum类型不太一样。
另一个自然的下一步是在类型 switch case 中允许近似元素和联合元素。这将更容易确定使用这些元素的接口类型的内容。也就是说,近似元素和联合元素不是类型,因为不能在类型断言中使用。
组合字面量的类型推断
这是一个现在我不建议的特性,但是语言将来的版本可以考虑。
我们可以考虑泛型的组合字面量支持类型推断。
1 | type Pair[T any] struct { f1, f2 T } |
目前尚不清楚这在实际代码中出现的频率。
泛型函数参数的类型推断
这是一个我们现在不建议的特性,但是语言将来的版本可以考虑。
在下面的例子中,思考在 FindClose 中对 Find 的调用。类型推断可以确定 Find 的类型参数是 T4,并且从这里我们可以知道最终的参数必须是func(T4, T4) bool
,并且从这里我们可以推论出IsClose 的类型参数必须是 T4。然而,之前描述的类型推断算法做不到这些,所以我们必须明确的写IsClose[T4]
。
起初这可能看很深奥,但在将泛型函数传递给泛型Map和 Filter 函数时就会出现。
1 | // Differ 有一个 Diff 方法,它返回值的差 |
类型参数的反射
尽管我们不建议更改 reflect 包,但未来考虑的一种可能性为 reflect.Type 添加两个新方法:NumTypeArgument() int
将会返回类型参数的数量,TypeArgument(i) Type
将返回第i个类型参数。对于实例化的泛型类型,NumTypeArguemt 将返回非零值。可以为reflect.Value 定义类似的方法,对于实例化的泛型函数,NumTypeArgument 将返回非零值。可能有一些程序关心这些信息。