理解问题

要正确理解 Golang 范型编程 的概念,首先需要理解它旨在解决的问题。我们通过一个简单的例子来进行说明。

假设我们有一个简单的 Add() 函数,它接受两个整数并返回它们的和:

func Add(a, b int) int {
  return a + b
}

func main() {
  fmt.Println(Add(1, 2))    // 输出:3
}

这个函数对整数来说非常好用,但如果我们想让它接受浮点数呢?如果我们尝试用我们的 Add() 函数使用浮点数,就会遇到一个错误:

func main() {
  fmt.Println(Add(1.1, 2))    // 错误:无法将 1.1(未类型化的浮点常量)用作参数 Add 的 int 值
}

现在,你可能会建议使用 接口 来解决这个问题,但这里有个问题。我们只希望 Add() 函数接受整数和浮点数,而不接受其他类型。很遗憾,仅使用接口无法实现这种程度的特殊性。

这性编就是 Golang 范程派上用场的地方了。

Go 1.18 中的新功能

备受期待的 Go 1.18 版本,在 Go 1.17 版本发布七个月之后,引入了一项改变游戏规则的功能:泛型。兑现其 兼容性承诺,Go 1.18 确保了与 Go 1 的向后兼容性,确保了开发人员可以平稳过渡。

那么,泛型到底给我们带来了什么?让我们深入研究伴随此版本发布的三项重大改进:

  1. 将接口类型定义为一组类型
  2. 函数和类型的类型参数
  3. 类型推断

听起来是不是很激动?因此,系好安全带并准备好见证 Go 1.18 中泛型的强大功能!

什么是泛型类型?

在编程中,泛型类型是一种可以与其他多种类型一起使用的类型,从而允许代码重用和灵活性。它用作模板,使您能够编写与各种数据类型无缝协作的函数和数据结构,而无需重复代码。

泛型概述

为了更好的理解 Go 语言中的泛型,让我们先了解一下其他编程语言,比如 C++ 和 Java,是如何实现泛型的。通过比较和对比这些实现,我们可以更好地理解 Go 语言在泛型上的独特之处。

在 C++ 中,泛型是通过模板来实现的。模板允许我们创建泛型函数和类。使用模板,我们能够定义可以在不牺牲类型安全的前提下适用于多种类型的算法和数据结构。

例如,以下代码定义了一个 swap 函数,它可以使用任意类型进行调用:

template <typename T>
void swap(T& a, T& b) {
  T temp = a;
  a = b;
  b = temp;
}

在这里,T 表示泛型类型参数。它允许我们编写一个适用于各种数据类型的 swap 函数。

Java 在 Java 5 中引入了泛型。Java 中的泛型是使用类型擦除来实现的。类型擦除是一种在运行时删除类型信息的技巧,这使得泛型成为一种编译时特性。

例如,以下代码定义了一个 Box 类,它可以使用任意类型的值:

public class Box<T> {
    private T value;
    
    public void setValue(T value) {
        this.value = value;
    }
    
    public T getValue() {
        return value;
    }
}

在这里,T 是泛型类型参数。它允许 Box 类处理不同类型的值。

现在,让我们把注意力转移回 Go 语言,看看它是如何实现泛型的。

准备

在开始学习本教程之前,你需要满足以下先决条件:

  1. 安装 Go 1.18 或更高版本:你需要在你的系统中安装 Go,最好是版本 1.18 或更高版本。
  2. 扎实的 Go 基础知识:你需要对 Go 的基本概念有很好的了解,包括 结构类型for 循环切片以及 接口

满足了这些先决条件,你就可以开始学习 Go 语言中的泛型了。现在,让我们开始我们的编码之旅吧!

Go 泛型语法

从 Go 1.18 版本开始,Go 语言支持泛型。泛型的引入为 Go 语言增添了新的灵活性和强大性。现在,让我们来学习 Go 泛型的语法,看看它是如何用于函数和类型的。

Go 泛型函数语法

在 Go 中定义一个泛型函数,我们需要使用以下语法:

  func FunctionName[T Constraint](a, b T) T {
      // Function Body
  }

在这个语法中,FunctionName 是泛型函数的名称。方括号 [T Constraint] 表示使用泛型类型参数 TConstraint 是可选的类型约束。

在函数括号 (a, b T) 中,我们定义了两个类型为 T 的输入参数 ab。函数的返回类型也是 T。你可以用任何有效的标识符替换 T

为了更好地理解 Go 泛型语法的每个组成部分,请参阅下面的图片。

Go 泛型函数语法

Golang 泛型类型语法

同理,我们在定义 Go 中的类型 时可以使用泛型。泛型类型声明的语法如下:

type Number[T Constraint] interface {
    Method(T) T
}

type Student[T Constraint] struct {
    Name T
}

Number[T Constraint]Student[T Constraint] 是两种类型,Number 是一个接口,它指定了一个方法 Method,该方法接受类型 T 的参数并返回相同类型的值。另一方面,Student 结构体类型有一个类型为 T 的单一字段 Name

有关 Golang 泛型类型语法的每个组成部分的详细信息,请参考下图:

Golang 泛型类型语法

使用 Golang 泛型

现在我们开始深入了解泛型的实际使用方法。

假设我们想创建一个既能处理整数又能处理浮点数的 Add() 函数,这个函数会把两个参数相加并作为和返回结果:

func Add[T int | float64](a T, b T) T {
  return a + b
}

func main() {
  fmt.Println(Add(3, 4))       // 输出:7
  fmt.Println(Add(3.1, 4.1))   // 输出:7.199999999999999
}

在上面的示例中,我们使用泛型定义了 Add 函数,函数签名包括一个类型参数 T,它表示操作数和返回值的类型。我们使用约束 [T int | float64] 指定 T 可以是 intfloat64

我们已经检查了 Go 泛型的完整工作示例,现在让我们深入研究每个组件,并深入探索有关此主题的知识。

Golang 泛型如何工作?

使用实际示例很有趣,但了解基本概念更为令人兴奋。在本节中,我们将深入研究 Golang 泛型的内部工作原理。我们将剖析每个组件并探讨它们是如何组合和协同工作的。

在上一个部分中,我们遇到了术语类型参数和类型约束。然而,还有一些概念要掌握,例如类型参数、类型实例化、类型集和类型推断。本节将提供详细的解释。

Go 泛型示例

为了更好地理解 Golang 泛型是如何工作的,我们接着来看上一节的示例:

// 范型函数
func Add[T int | float64](a T, b T) T {
  return a + b
}

func main() {
  add := Add[int]      // 类型实例化
  fmt.Println(reflect.TypeOf(add))  // 输出: func(int, int) int
  fmt.Println(add(1, 2))           // 输出: 3
}

在这个示例中,Add() 函数保持不变,但我们调用函数的方式变了。现在,请特别注意:

当我们写 Add[int] 时,这部分被称为类型实例化,它将一个特定类型指定为类型参数。类型参数告诉编译器为指定类型生成通用函数的具体版本。

接下来,我们用 reflect 包来检查类型实例化函数变量的类型。从输出结果可以看到,类型实例化将通用函数转换为一个普通的 Go 函数。在这种情况下,add 的类型是 func(int, int) int,然后像往常一样用于函数调用。

你可能好奇,在前面的示例中,我们跳过了类型分配和实例化部分,它是如何工作的?答案是类型推断。在那种情况下,编译器自动推断类型参数的类型,允许通用函数在没有显式类型实例化的情况下工作。如果你觉得这个概念有点混淆,不要担心;我们将在后面详细讲解类型推断。

Golang 接口作为类型集

在最新的 Go 1.18 版本中,接口的定义发生了一些变化。在 Go 1.18 之后,Golang 中的接口现在可以定义一组类型以及一组方法。这些更改是为了支持 Golang 泛型中的类型约束。让我们深入了解这些变化以及我们需要掌握哪些内容才能了解 Golang 泛型。

你可能会想知道,为什么做了这些更改?为了理解原因,我们来看一个例子。假设我们想创建一个通用的比较函数。代码可能像这样:

func Compare[T any](a, b T) bool {
  return a == b      // 无效操作:a == b(类型集中无法比较的类型)
}

func main() {
  println(Compare(1, 2))
}

但是,等等!你注意到这里有什么问题吗?类型参数的约束与 any 类型绑定,这意味着 Compare 函数可以接受任何类型作为其参数。然而,如果我们传递一个切片,它可以使用 == 运算符进行比较吗?不行,因为切片不支持 == 运算符。因此,我们需要找到一种方法来创建一组将用作约束的类型,这就是类型集发挥作用的地方。

类型集是包含一个或多个类型集的接口,使用 | (联合)运算符。

让我们举一个例子来说明这个概念:

// 类型集
type Num interface {
  int | float32 | float64 | string | bool
}

func Compare[T Num](a, b T) bool {
  return a == b
}

func main() {
  println(Compare(1, 2))         // false
  println(Compare(1, 1))         // true
  println(Compare(1.1, 1.1))     // true
  println(Compare(1.1, 2.2))     // false
  println(Compare("1", "2"))     // false
}

在这个示例中,我们使用 |(联合)运算符创建了一个名为 Num 的类型集,以组合多个类型。现在,我们的通用函数可以正常工作,因为我们已经确保类型集只包含支持 == 运算符的类型。

基础类型元素

在 Go 1.18 版本中,推出了一个被称为波浪号(**~**)的新操作符。它在 Golang 编程语言中扮演着重要角色,被称为底层类型操作符。下面,我们就来深入了解一下底层类型操作符及其在 Golang 泛型中的关联。

那么,为什么需要底层类型操作符呢?为了回答这个问题,我们不妨先来看一个例子,它是基于我们之前的讨论:

type Num interface {
  int | float32 | float64 | string | bool
}

func Compare[T Num](a, b T) bool {
  return a == b
}

func main() {

  type Integer int

  var a Integer = 1
  var b Integer = 2

  println(Compare(a, b))
}
解释

虽然这个例子的核心逻辑没有改变,但我们传递参数的方式已经发生变化。现在,问题来了:它能正常工作吗?我们得到的输出是:

Integer 不满足 Num可能缺少 Num 中 int 的 ~)

我们为什么要收到这样的错误信息呢?毕竟,Integerint 的某种类型,而且 Num 类型合集包含 int。那么,我们为什么会收到此错误?

答案在于 Compare 函数的类型参数被绑定到了 Num 类型合集。因此,它允许的类型只包括在类型合集中明确定义的那些。但你可能会说,由于 Integer 的底层类型是 int,所以应该允许这么做。这时候,底层操作符 (**~**) 就派上用场了。

为了接受任何底层类型与类型合集所定义类型集相匹配的参数,我们需要使用 **~** 操作符。它允许我们明确查找与类型合集中定义的类型相对应的底层类型。

使用底层类型元素

让我们通过整合底层操作符来改进我们的示例:

// 类型合集
type Num interface {
  ~int | ~float32 | ~float64 | ~string | ~bool
}

func Compare[T Num](a, b T) bool {
  return a == b
}

func main() {
  type Integer int
  type Str string
  type Double float64

  var a Integer = 1
  var b Integer = 2

  var s1 Str = "Hello"
  var s2 Str = "Hello"

  var d1 Double = 1.1
  var d2 Double = 1.2

  println(Compare(a, b))   // false
  println(Compare(s1, s2)) // true
  println(Compare(d1, d2)) // false
}

通过利用底层操作符(~),我们修改了 Num 类型合集,以便考虑指定类型的底层类型。这种改进使我们能够传递那些与类型合集内定义的类型相匹配的底层类型作为参数。

在这个经过改进的示例中,我们可以看到 Compare 函数现在能够正确地比较那些类似于 IntegerStrDouble 等类型的变量了。底层操作符(~)允许灵活地使用底层类型,从而在 Go 语言中扩展了泛型的能力。

类型参数

在前面的章节中,我们接触了 Golang 泛型中的类型参数这个概念。现在,让我们更深入地研究一下,以便更加透彻地理解这个关键方面。

类型参数充当了泛型代码中的抽象数据类型。它允许我们编写可以在不同类型上使用而不会牺牲代码可复用性和灵活性的代码。你可以将类型参数视为占位符,在泛型代码的类型实例化过程中,这些占位符会被实际的类型所替换。

联合体约束元素

在某些情况下,我们可能需要对类型参数施加多个约束。Go 语言允许我们通过使用联合 | 运算符来实现。例如,在定义泛型函数或类型时,我们可以使用 | 运算符来指定多个约束:

func F[T int | float64 | string](a, b T) T {...}

类似地,在使用类型时,我们可以定义具有多个约束的类型接口或结构体:

type Number interface {
    int | float64 | string
}

type Num[T int | float64 | string] interface{}

type Student[T int | float64 | string] struct {
    Name T
}

多重类型参数

如同普通函数可以有多个参数一样,类型参数也可以被定义为多个。这让我们可以同时操作多种类型。

让我们看一个例子:

func Add[T int32 | float32, S int64 | float64](a T, b S) S {
  return S(a) + b
}

func main() {
  fmt.Println(Add[int32, float64](3, 5.5))   // 输出:8.5
}

在这个例子中,Add 函数接受两个类型参数 TS,对于 T 可以是 int32float32,对于 S 可以是 int64float64。该函数执行加法并返回类型为 S 的结果。

类型参数引用

Go 泛型中一个令人着迷的概念是能够引用任何尚未定义的类型参数。此特性增加了灵活性,并让您能够创建可与多种类型一起使用的通用函数,即使它们在同一函数中相互关联。

我们以下面的示例来说明这个概念:

func PrintList[S []T, T any](list S) {
    for _, v := range list {
        println(v)
    }
}

func main() {
    PrintList([]int{1, 2, 3})
    PrintList([]string{"a", "b", "c"})
}

在上面的示例中,我们有一个通用的函数 PrintList。它需要两个类型参数:ST。类型参数 S 代表类型为 T 的元素的切片 ([])。第二个类型参数 T 被定义为 any,这意味着它可以是任何类型。

这里有趣的是,第一个类型参数 S []T 引用了第二个类型参数 T any。此引用允许函数根据提供的类型参数 T 确定切片中元素的类型。

使用类型参数引用,函数 PrintList 可以使用不同类型的切片调用。在 main 函数中,我们通过使用 []int[]string 切片调用 PrintList 来演示其用法。

这种对类型参数的动态引用展现了 Go 泛型的灵活性和通用性。它支持您创建能适应各种类型的通用函数,并提供简洁且可重用的解决方案。

类型约束

在上一个部分中,我们探讨了类型参数,但是如果我们没有深入研究类型约束的世界,那么我们的理解将不会完整。那么,让我们直接开始吧!

什么是类型约束?

类型约束(也称为元类型)是一个定义了一组规则的接口,以及什么类型的可以让作为特定类型参数的参数。

如果这个定义听起来有点复杂,不要担心!有时当我们更深入地了解并理解基本概念时,这些概念就会变得更加清晰。而这正是我们接下来要做的。

为了说明类型约束的概念,我们来看下面的示例:

func Add[T int](a, b T) T {
    return a + b
}

func main() {
    println(Add(1, 2))
}

在此示例中,我们使用 T int 作为类型参数的约束。然而,根据我们刚刚讨论的定义,类型约束实际上是一个接口。因此,也可以将它写成:

func Add[T interface { int }](a, b T) T {}

使用联合运算符:

func Add[T interface { int | float64 }](a, b T) T {}

但是,等等!虽然这些其他的约束表达方式在技术上是有效的,但它们可能会损害您代码的可读性。因此,通常建议为了清晰起见而坚持使用第一种形式。

可比较约束

在“类型集”部分,我们讨论了使用 Go 中的泛型时需要定义一组支持比较运算符的类型。但好消息是,Go 开发者已引入了一个方便的内置类型参数约束,称为“可比较”。

此约束允许开发者利用固有地支持比较运算符的现有 Go 类型,而无需其他类型集。

为了说明怎样使用,我们考虑同一个示例并使用 comparable 类型约束:

func F[T comparable](a, b T) bool {
  return a == b
}

func main() {
  fmt.Println(F(1, 2))            // false
  fmt.Println(F("a", "a"))     // true
  fmt.Println(F([]int{1, 2, 3}, []int{1, 2, 3}))   // Error: []int does not satisfy comparable)
}

在这个示例中,通用函数 F 通过指定 [T comparable] 作为类型参数来使用 comparable 约束。此约束可确保传递给函数的类型必须支持比较运算符,例如 ==, >, < 等。

类型参数和类型实例化

很酷吧,现在我们对类型参数和类型约束有很好的了解,是时候深入研究 类型参数和类型实例化 的精彩世界了。了解类型实例化的工作原理对于为激烈的 Go 泛型之战做好准备至关重要。

什么是类型实例化?

类型实例化是指用类型参数替换泛型函数或类型中的类型参数的过程。它将泛型代码转换成专门针对我们需求的特定非泛型代码。

举个例子来帮助理解:

add = Add[float64]
  fmt.Println(reflect.TypeOf(add)) // Output: func Add
  (a, b float64) float64 {}

在示例中,我们将“类型参数”定义为 float64,它会替换泛型函数中的“类型参数”。因此,泛型函数会变成一个非泛型函数,如同 reflect 包的输出所示。

类型实例化步骤

类型实例化涉及两个关键步骤:

  1. 替换类型参数:在整个泛型声明中,每个类型参数都会被它对应的类型参数替换。这种替换包括类型参数列表本身和与之关联的任何其他类型。
  2. 满足类型约束:替换后,每个类型参数都必须满足相应类型参数的约束。如果类型参数不能满足约束,则实例化就会失败。

类型实例化的步骤

类型实例化涉及两个关键步骤:

  1. 替换类型参数:将每个类型参数替换为其相应的类型参数,替换遍及整个泛型的声明。这种替换包含类型参数列表本身以及与其关联的任何其他类型。
  2. 满足类型约束:在替换之后,每个类型参数都必须满足对应的类型参数的约束。如果类型参数无法满足约束,则实例化失败。

类型实例化的规则

为了让事情更有趣,让我们探讨一下有关 Go 语言泛型中类型实例化的规则:

  1. 类型参数必须:对于一个未被调用的泛型函数,你需要提供类型参数列表来成功实例化。这是解锁泛型力量的必要条件。
  2. 允许部分类型参数:在某些情况下,你可以提供部分类型参数列表,而留一些参数未指定。然而,任何剩余的“类型参数”都必须根据上下文进行推断。
  3. 部分类型参数列表不能是空的:请记住,一个部分类型参数列表不能是空的。你需要至少包括第一个参数来启动这个进程。
  4. 从右到左省略类型参数:你可以自由地从“从右到左”省略类型参数。换句话说,你可以从最右边的参数开始,以相反顺序省略类型参数。

让我们看一个例子来说明这个规则:

func F[T []V, S []T, V int](list T, matrix S, size V) {
    // 函数体在这里
}

func main() {
    // 明确定义类型的参数来实例化类型
    f := F[[]int]       // 部分类型参数
    // 其余代码
}

在这个例子中,我们在“从右到左”的顺序中省略了两个“类型参数”。这只是 Go 语言泛型提供的众多有趣的可能性之一。

类型推断

在上一个部分中,我们简要地讨论了类型推断的概念。现在,让我们对这个话题进行更深入的了解。

什么是类型推断?

类型推断是指在用户没有明确提供类型参数时,确定类型参数的正确类型。它在使用泛型函数或类型中起着关键作用。如果没有正确的类型推断,用户将不得不做额外的工作,为类型实例化明确指定类型参数。

类型推断的关键组件

在 Go 语言中,类型推断涉及几个组件,这些组件协同工作,以简洁的方式推导类型。我们简要探讨一下这些组件:

  1. 类型参数列表:此列表将未知类型定义为推断的占位符。它们表示需要根据上下文确定类型的类型。
  2. 替换映射:替换映射 (M) 跟踪类型参数的已知类型参数。随着类型参数的推断,它会更新。
  3. 普通函数参数:对于函数调用,类型的普通函数参数经过函数参数类型推断,以根据给定值确定它们的类型。
  4. 约束类型推断:此步骤分析泛型类型的约束,以缩小可分配给类型参数的可能类型。
  5. 默认类型推断:对于未键入的普通函数参数,在函数参数类型推断期间使用默认类型来推断它们的类型。

该过程持续到替换映射对每个类型参数都有一个类型参数,或者直到一个推断步骤失败。失败的步骤或不完整的替换映射表示类型推断不成功。

类型推断的类型

有两种类型推断我们将要探讨:函数参数类型推断和约束类型推断。

约束类型推断

另一方面,约束类型推断是指基于类型约束推断类型参数。

我们通过一个示例来展示约束类型推断:

type Num interface {
  int | float64
}

type IdConstraint interface {
  int | ~string
}

type Student[I IdConstraint, T Num] struct {
  ID         I
  TotalMarks T
}

func IncreaseMarks[S []Student[string, T], T Num](studentList S, marksToIncrease T) S {
  for index, _ := range studentList {
    studentList[index].TotalMarks += marksToIncrease
  }

  return studentList
}

func main() {
  student := []Student[string, int]{}
  student = append(student, Student[string, int]{"1", 90})
  student = append(student, Student[string, int]{"2", 80})
  student = append(student, Student[string, int]{"3", 70})

  s := IncreaseMarks(student, 10)
  for _, v := range s {
    println(v.ID, v.TotalMarks)
  }
}

在这个示例中,我们定义了两个约束:NumIdConstraint。这些约束用作 Student 结构类型中的类型参数。我们还定义了 IncreaseMarks 函数,它接收一个 Student 结构的列表、要增加的成绩并返回更新的学生切片。为了使这个函数成为泛型函数,我们引入了类型参数。

main 函数中,我们创建了一个 Student 结构的切片并为三个学生添加了数据。然后,我们使用学生切片和要增加的成绩调用 IncreaseMarks(student, 10) 函数。最后,我们打印返回的切片中学生的 ID 和总成绩。

在这个示例中,我们没有显式地使用类型参数进行类型实例化。然而,类型可以从函数中指定的约束和函数参数的约束中推断出来。这就是所谓的类型约束推断

输出:

1 100
2 90
3 80

总结

总之,我们探讨了 Go 语言的泛型,以增强代码的灵活性并提高代码的重用率。现在你已经掌握了从掌握基本知识到深入了解高级技术和现实世界使用场景的工具,你就可以创建适应性和可扩展性的代码。在你的项目中应用泛型的力量,享受简洁、可维护且具有前瞻性的代码的好处。祝你编码愉快,你的 Go 语言之旅充满无限可能!

常见问题解答 (FAQ)

Go 语言有泛型吗?

是的,Go 语言现在具有泛型!泛型被引入到 Go 1.18 中,并且一直受到 Go 社区的热切期待。这个新特性为该语言带来了更大的灵活性和可重用性,允许开发人员编写更加通用和简洁且适用于各种类型的代码。

Go 语言中的泛型是什么?

Go 语言中的泛型是指编写灵活且适用于各种类型的代码的能力。利用泛型,您可以创建不与特定类型绑定但可与多种类型配合使用的函数和数据结构。

Go 语言中如何实现泛型?

Go 语言中的泛型通过类型参数来实现。类型参数充当占位符,用于在使用泛型代码时确定的特定类型。这些类型参数使您能够编写能够动态适应各种类型的代码。

Go 泛型是否速度很慢?

不,Go 泛型本质上并不慢。在使用泛型时可能存在一些性能方面的考虑因素,例如类型检查和代码专门化开销,但对性能的影响通常很小。

为什么我们需要在 Go 语言中使用泛型?

泛型在 Go 语言中非常有价值,因为它能让代码更加灵活和可重用。通过使用泛型,你可以写出更通用、更简洁、并且可以适用于不同类型的代码。这能消除代码重复,减少针对每种特定类型编写专用函数或数据结构的必要性。

参考链接