泛型第 01 部分:基本语法

变化

该草案是一份变化文件,这意味着这些提案需要随着时间的推移而改变。本节记录此提案何时发生更改。

2020/08/21 :推进泛型设计草案

介绍

在这一系列关于Go中的泛型的文章中,我将介绍代码并教授当前泛型草案的不同方面。我将提供go2go Playground的代码链接,以便您可以尝试不同的示例。代码示例也将在 Go 培训存储库中提供,但随着我了解更多信息或草稿的更改,这些示例可能会发生变化。

在第一篇文章中,我将分享一个泛型函数的基本示例,分解新语法,并解释为什么需要新语法。这篇文章的代码可以在这个playground链接中找到。

具体示例

如果您想编写一个可以输出整数切片的打印函数怎么办?

清单 1.

13 func printNumbers(numbers []int) {
14     fmt.Print("Numbers: ")
15     for _, num := range numbers {
16         fmt.Print(num, " ")
17     }
18     fmt.Print("\n")
19 }

Main:
numbers := []int{1, 2, 3}
printNumbers(numbers)

Output:
Numbers: 1 2 3

清单 1 显示了一个 print 函数的实现,它可以使用 5 行代码输出一个整数切片。我会争辩说,所有有经验的大多数 Go 开发人员都可以毫不费力地快速阅读和维护这段代码。

如果你想编写一个可以输出字符串切片的打印函数怎么办?

清单 2.

21 func printStrings(strings []string) {
22     fmt.Print("Strings: ")
23     for _, str := range strings {
24         fmt.Print(str, " ")
25     }
26     fmt.Print("\n")
27 }

Main:
strings := []string{"A", "B", "C"}
printStrings(strings)

Output:
Strings: A B C

清单 2 显示了一个 print 函数的实现,它可以使用与整数版本基本相同的 5 行代码来输出一段字符串。这两个函数之间的唯一区别是该printStrings函数接受一段字符串(不是整数),并且在第 22 行,print 语句显示单词Strings而不是Numbers. 因为我想接受一段字符串切片,所以我需要一个新函数。

空接口和类型断言

如果您想编写一个可以同时输出整数和字符串切片的打印函数怎么办?一种选择是使用空接口和类型断言。

清单 3.

35 func printAssert(v interface{}) {
36     fmt.Print("Assert: ")
37     switch list := v.(type) {
38     case []int:
39         for _, num := range list {
40             fmt.Print(num, " ")
41         }
42     case []string:
43         for _, str := range list {
44             fmt.Print(str, " ")
45         }
46     }
47     fmt.Print("\n")
48 }

Main:
numbers := []int{1, 2, 3}
strings := []string{"A", "B", "C"}
printAssert(numbers)
printAssert(strings)

Output:
Assert: 1 2 3
Assert: A B C

清单 3 显示了单个打印函数的实现,该函数可以使用空接口和类型断言同时输出整数切片和字符串。该函数可以接受整数切片或字符串切片(或任何类型的值),因为空接口不对传入的数据施加约束。在第 37 行,语句中使用了类型switch断言应用条件逻辑并测试是否将整数或字符串切片传递到函数中。每个case语句根据类型断言的结果提供逻辑。

因为给定类型的每个切片都需要通过case语句来实现,所以这个函数实际上不是一个泛型函数。它目前仅限于打印整数和字符串的切片。这个函数所做的一切本质上是替换了 case 语句的两个具体函数。如果要打印float64的切片,则需要为该类型的切片编写额外的 case 语句。

反射

如果您想编写一个可以输出任何给定类型的切片的打印函数怎么办?您今天的选择是使用reflect包。

清单 4.

56 func printReflect(v interface{}) {
57     fmt.Print("Reflect: ")
58     val := reflect.ValueOf(v)
59     if val.Kind() != reflect.Slice {
60         return
61     }
62     for i := 0; i < val.Len(); i++ {
63         fmt.Print(val.Index(i).Interface(), " ")
64     }
65     fmt.Print("\n")
66 }

Main:
numbers := []int{1, 2, 3}
strings := []string{"A", "B", "C"}
floats := []float64{1.5, 2.9, 3.1}
printReflect(numbers)
printReflect(strings)
printReflect(floats)

Output:
Reflect: 1 2 3
Reflect: A B C
Reflect: 1.5 2.9 3.1

清单 4 显示了单个打印函数的实现,该函数可以使用空接口和reflect包输出任何类型的切片。此函数再次可以接受任何具体类型的切片,因为空接口不会对传入的数据创建约束。多亏了reflect这个包,我可以编写代码来对切片执行线性遍历,而不管其类型如何,如第 62 行和第 63 行所示。

但是,此功能并不完美。如果调用方未传递切片,则第 60 行上的return语句应返回错误值,这会破坏原始 API。代码也不像具体版本那样简单直观。需要了解reflect包及其 API。最后,这是一个泛型函数,可以打印任何切片的单个值,所以我可以说Go已经有了泛型。

我问自己的一个问题是:

拥有一个可以与泛型类型一起使用的单个打印函数是否有价值,这样可以重用具体的实现并避免反射代码?

我认为这将是有价值的,因为像这样的函数比反射版本更简单,更直观地阅读和维护,同时提供相同的功能。

泛 型

如果您想编写一个打印函数,该函数可以输出任何给定类型的切片,并且不像我们在前面的示例中那样使用反射,该怎么办?这就是新的泛型支持发挥作用的地方。

清单 5.

75 func print[T any](slice []T) {   |   13 func printNumbers(numbers []int) {
76     fmt.Print("Generic: ")       |   14     fmt.Print("Numbers: ")
77     for _, v := range slice {    |   15     for _, num := range numbers {
78         fmt.Print(v, " ")        |   16         fmt.Print(num, " ")
79     }                            |   17     }
80     fmt.Print("\n")              |   18     fmt.Print("\n")
81 }                                |   19 }

在清单 5 的左侧,您可以看到单个打印函数的实现,该函数可以使用当前草稿提出的新泛型语法输出任何给定类型的切片。我还在右侧包含了具体的print函数,该函数接受整数切片,用于代码的并排比较。如果仔细观察,这两个函数本质上是相同的,减去不同的变量名称以及第 76 行和第 14 行上初始打印调用的标签。

我又回到了5行代码,我相信大多数有经验的Go开发人员都可以快速阅读和维护。

了解新语法

若要理解新语法,请务必了解编译器的部分问题空间。

若要了解新语法,了解编译器问题空间的一部分很重要。

清单 6.

75 func print(slice []T) {
76     fmt.Print("Generic: ")
77     for _, v := range slice {
78         fmt.Print(v, " ")
79     }
80     fmt.Print("\n")
81 }

在清单 6 中,我从清单 5 中删除了函数名称和参数列表之间的方括号。删除方括号后,该函数告诉编译器该函数将接受将在包中的某个位置显式声明的T类型切片。我想重申一下,编译器期望在包中的某个位置声明为T类型。

如果我尝试编译这段代码会发生什么?

清单 7.

undeclared name: T

这里重要的部分是语言需要一个语法选择,以告诉编译器类型是程序员在编译程序之前不会声明的T类型。由编译器在编译时确定什么T类型。

清单 7 显示了当编译器没有显式定义T类型的声明时的编译器消息。因此,问题来了。如果您要编写一个泛型函数,您需要一种方法告诉编译器您不会显式声明(在这种情况下)T类型,但它必须由编译器在编译时确定。

这里重要的部分是语言需要一个语法选择来告诉编译器类型是程序员在编译程序之前不会声明的T类型。由编译器决定在编译时是什么类型T

泛型类型列表

在当前草案中,这是使用一组方括号来完成的,这些方括号定义了泛型类型标识符的列表。

清单 8.

75 func printGeneric[T any](slice []T) {
76     fmt.Print("Generic: ")
77     for _, v := range slice {
78         fmt.Print(v, " ")
79     }
80     fmt.Print("\n")
81 }

清单 8 再次显示了泛型 print 函数,其中放回了一组方括号。这些方括号用于定义泛型类型标识符列表,这些标识符表示需要在编译时确定的特定于该函数的类型。这是您告诉编译器在程序编译之前不会声明具有这些名称的类型的方式。这些类型需要在编译时计算出来。

注意:您可以在括号内定义多个类型标识符,尽管当前示例仅使用一个。前任。[T, S, R any]

您可以将这些类型标识符命名为任何您认为有助于函数可读性的名称。在这种情况下,我使用大写字母T来描述将传入某种类型的切片T(在编译时确定)。我喜欢在集合中使用单个大写字母,这也是一种约定这可以追溯到较早的编程语言,如 C++ 和 Java。

使用any

自 2020 年 8 月 21 日起草案新增,必须为每个类型标识符提供约束。

为了避免数组声明的歧义,我们将要求所有类型参数都提供一个约束。这样做的好处是为类型参数列表提供与普通参数列表完全相同的语法(除了使用方括号)。为了简化没有约束的类型参数的常见情况,我们将引入一个新的预声明标识符“any”作为“interface{}”的别名。

我将在稍后的文章中讨论约束,但现在声明T在编译器时没有任何约束any

调用泛型函数

用户如何调用此通用打印函数?

清单 9.

numbers := []int{1, 2, 3}
print[int](numbers)

strings := []string{"A", "B", "C"}
print[string](strings)

floats := []float64{1.7, 2.2, 3.14}
print[float64](floats)

清单 9 显示了如何调用泛型 print 函数,其中 在调用上显式提供了类型信息。func name[T any](slice []T) {该语法模拟了函数声明定义两组参数的想法。第一组是映射到相应类型标识符的类型,第二组是映射到相应输入变量的数据。

幸运的是,编译器可以推断类型,并且无需在调用上显式传递类型信息。

清单 10.

numbers := []int{1, 2, 3}
print(numbers)

strings := []string{"A", "B", "C"}
print(strings)

floats := []float64{1.7, 2.2, 3.14}
print(floats)

清单 10 显示了如何在不需要显式传递类型信息的情况下调用泛型 print 函数。在函数调用上,编译器能够识别要使用的T类型,并构造函数的具体版本以支持该类型的切片。编译器能够根据它在调用上从传入的数据中获得的信息来推断类型。

尖括号

Go邮件列表上的一个重要讨论点是为什么尖括号不像在C++和Java中使用。在Go中使用尖括号是不可行的,我会解释原因。

清单 12.

func print(list []T) {

print(numbers)
print(strings)
print(floats)

清单 12 显示了如果使用尖括号,泛型打印函数和显式函数调用将是什么样子。最大的问题是,为什么这作为运营商的选择是不可行的?它与保持与当前语言规范的向后兼容性有关。

以下是您今天可以在 Go 中编写的代码。

清单 12 显示了使用尖括号时泛型打印函数和显式函数调用的样子。最大的问题是为什么这不能作为可用选择?它与保持与当前语言规范的向后兼容性有关。

这是您今天可以用 Go 编写的代码。

清单 13.

w, x, y, z := 10, 20, 30, 40
a, b := w < x, y > (z)
fmt.Println(a, b)

Output:
true false

清单 13 显示了两个变量的声明,ab从短变量声明运算符右侧的两个表达式中分配了值。编译器将该行代码分解为这两个语句。

清单 14

a := w < x
b := y > (z)

清单 14 展示了ab如何分配布尔表达式的结果。如果尖括号是声明泛型类型列表的选择会发生什么?鉴于清单 13 中的表达式,编译器现在有歧义。

清单 15.

a, b := w < x, y > (z)

// Is it this like before?
a := w < x
b := y > (z)

// Or is it this?
a, b := w(z)

清单 15 显示如果尖括号表示小于和大于运算符,或者表示对泛型函数的调用,它是如何变得不明显了。由于编译器在编译时无法获得类型信息,因此这些标识符中的任何一个都可以在另一个尚未解析的源代码文件中声明。如果不破坏语言的向后兼容性承诺,就无法解决这种歧义,因此尖括号在 Go 中不是一个好的选项。

结论

阅读这篇文章后,您应该对 Go 中泛型函数的基本语法以及在编译时声明类型与显式定义类型的解决方案有更好的理解。在声明泛型函数时,您应该看到需要方括号来形成泛型类型列表。借助编译器在函数调用点推断泛型类型的能力,您应该看到调用泛型函数与调用任何其他函数没有什么不同。

我向您展示了如何使用新语法编写泛型函数可以降低使用reflect包编写泛型函数的复杂性。我向您展示了在这些示例中删除空接口和反射代码的使用如何有助于提高代码的可读性和可维护性。

在下一篇文章中,我将探讨如何使用泛型来定义用户定义的类型。如果您迫不及待,我建议您查看这些博客文章所基于的代码仓库并自己进行实验。如果您有任何问题,请通过电子邮件、Slack 或 Twitter 与我联系。

原文链接:https://www.ardanlabs.com/blog/2020/07/generics-01-basic-syntax.html

你可能感兴趣的:(泛型第 01 部分:基本语法)