Go 中的泛型编程

介绍

与其他编译语言相比,Go 中的泛型编程一直很尴尬。在 Go 中实现它的最流行的方法是使用接口、类型转换和代码生成。但每种方法都有其明显的局限性。例如,使用接口需要为每种数据类型实现一个接口。类型转换会导致潜在的运行时错误。而在代码生成的情况下,我们必须编写生成器,这需要花费大量时间。

本文的主要目标是了解泛型在 Go 中的工作原理,并将其性能与 Go 中以前的泛型编程方法进行比较。我将实现一个流行的函数“ Map ”,它遍历数据数组并使用回调函数转换每个元素。

文章代码示例存储在Github 存储库中。

去泛型函数

在 1.18 版本的 Go 中添加了通用函数。

泛型函数是指编译时多态性的机制,特别是参数多态性。这些是使用类型参数定义的函数,旨在使用编译时类型信息进行解析。编译器使用这些类型来实例化合适的版本,适当地解决任何函数重载。

Go 中的通用函数允许:

  • 定义函数和类型的类型参数。
  • 将接口类型定义为类型集,包括没有方法的类型。
  • 定义类型推断,允许在调用函数时在许多情况下省略类型参数

在实践中,它具有以下特点:

  • 代码编写对开发人员来说变得很舒服,因为不需要为每种新数据类型实现或生成代码
  • 编译时类型安全
  • 运行时类型安全

Go 泛型函数 – 映射示例

让我们看看通用函数在 Go 中是如何工作的。对于现实世界的示例,我们将使用Map函数。该函数接受一个切片和一个回调,该回调修改每个项目并返回一个新切片。

整数值的Map函数实现如下所示:

// Map modifies every item of list and returns a new modified slice.
func Map[T any](list []T, modify func(item T) T) []T {
	if list == nil {
		return nil
	}

	if modify == nil {
		return list
	}

	mapped := make([]T, len(list))
	for i, item := range list {
		mapped[i] = modify(item)
	}

	return mapped
}

nil为了安全起见,对列表的值和回调函数进行了两次检查。之后,有一个与列表长度相同的新切片。然后,该函数遍历一个列表,修改每个项目,并将该项目写入一个新的切片mapped中。该函数如何工作的示例:

package main

func main() {
    // prints 2, 4, 6
    fmt.Println(Map([]int{1,2,3}, func(item int) int {
		return item * 2
	}))
}

但是,如果需要将Map函数与其他类型一起使用,则需要实现一个新函数。这种架构不可扩展且难以维护。但是使用新的泛型函数,我们可以创建一个函数来处理我们需要的所有类型:

// Map modifies every item of list and returns a new modified slice.
func Map[V any](list []V, modify func(item V) V) []V {
	if list == nil {
		return nil
	}

	if modify == nil {
		return list
	}

	mapped := make([]V, len(list))
	for i, item := range list {
		mapped[i] = modify(item)
	}

	return mapped
}

Typeany是 的新别名interface{}

函数实现看起来类似于整数映射实现。唯一的区别是函数签名。现在有一个类型参数定义[V any],这意味着该函数可以处理任何类型,但在回调函数中应该是相同的类型modify func(item V) V) []V。让我们看看Map函数如何适用于不同的类型:

package main

import (
	"fmt"
	"strings"
)

type person struct {
	name string
	age  int
}

func main() {
	// prints [2 4 6]
	fmt.Println(Map([]int{1, 2, 3}, func(item int) int {
		return item * 2
	}))

	// prints [HELLO WORLD]
	fmt.Println(Map([]string{"hello", "world"}, func(item string) string {
		return strings.ToUpper(item)
	}))

	// prints [{Linda 19} {John 23}]
	fmt.Println(Map([]person{{name: "linda", age: 18}, {name: "john", age: 22}}, func(p person) person {
		p.name = strings.Title(p.name)
		p.age += 1

		return p
	}))
}

Go 泛型函数内部

本节将研究泛型函数在编译和程序运行时的行为。

运行时类型安全

对于研究,我将使用以下 Go 程序:

package main

import (
	"fmt"
	"runtime"
	"strings"
)

type person struct {
	name string
	age  int
}

func main() {
	ints := []int{1, 2, 3}
	doubledInts := Map(ints, func(item int) int {
		return item * 2
	})

	// prints [2 4 6]
	fmt.Println(doubledInts)

	words := []string{"hello", "world"}
	capitalizedWords := Map(words, func(item string) string {
		return strings.ToUpper(item)
	})

	// prints [HELLO WORLD]
	fmt.Println(capitalizedWords)

	people := []person{{name: "linda", age: 18}, {name: "john", age: 22}}
	modifiedPeople := Map(people, func(p person) person {
		p.name = strings.Title(p.name)
		p.age += 1

		return p
	})

	// prints [{Linda 19} {John 23}]
	fmt.Println(modifiedPeople)

	runtime.Breakpoint()
}

// Map modifies every item of list and returns a new modified slice.
func Map[T any](list []T, modify func(item T) T) []T {
	if list == nil {
		return nil
	}

	if modify == nil {
		return list
	}

	mapped := make([]T, len(list))
	for i, item := range list {
		mapped[i] = modify(item)
	}

	runtime.Breakpoint()

	return mapped
}

有两个运行时断点用于调试目的。为方便起见,我使用 JetBrains Goland IDEA 来调试代码。

在调试模式下运行程序后,我们可以看到Map函数的第一次调用包含listwith []int

但是,以下两个函数调用在运行时具有不同的类型:

因此,特定的、严格定义的类型是从Map函数内部的运行时调用函数传递的。

此外,在main函数中还定义了所有类型:

我们可以得出结论,Go 中的泛型在 运行时确实 保留了它们的类型信息,事实上,Go 在运行时不知道泛型“模板”——只知道它是如何实例化的。

为了确保在编译期间保留该类型,我们可以尝试分配capitalizedWords []stringdoubledInts []int

	ints := []int{1, 2, 3}
	doubledInts := Map(ints, func(item int) int {
		return item * 2
	})

	words := []string{"hello", "world"}
	capitalizedWords := Map(words, func(item string) string {
		return strings.ToUpper(item)
	})

	doubledInts = capitalizedWords 

这里发生编译错误:

./main.go:24:16: cannot use capitalizedWords (variable of type []string) as type []int in assignment

因此,Go 确实在运行时强制泛型类型的类型安全。

运行时实例化

让我们看看如果我们尝试在运行时使用反射检查泛型函数会发生什么:

package main

import (
	"fmt"
	"reflect"
)

func main() {
	fmt.Println(reflect.TypeOf(Map))
}

// Map modifies every item of list and returns a new modified slice.
func Map[T any](list []T, modify func(item T) T) []T {
	if list == nil {
		return nil
	}

	if modify == nil {
		return list
	}

	mapped := make([]T, len(list))
	for i, item := range list {
		mapped[i] = modify(item)
	}

	return mapped
}

尝试编译程序会返回错误:

./main.go:9:29: cannot use generic function Map without instantiation

因此,泛型在实例化之前对 Go 没有帮助。无法使用反射来引用泛型“模板”,这意味着无法在运行时使用泛型实例化新类型。就好像编译的 Golang 二进制文件中不存在泛型类型一样。

泛型函数性能

我们研究了泛型函数在编译和运行时是如何工作的。最后一个重要的问题是:它们的执行速度有多快?

Map函数有三种不同的实现方式:

// Map modifies every item of list and returns a new modified slice.
func Map[T any](list []T, modify func(item T) T) []T {
   if list == nil {
      return nil
   }

   if modify == nil {
      return list
   }

   mapped := make([]T, len(list))
   for i, item := range list {
      mapped[i] = modify(item)
   }

   return mapped
}

// MapTyped modifies every item of list and returns a new modified slice. It works only with Integer values.
func MapTyped(list []int, modify func(item int) int) []int {
   if list == nil {
      return nil
   }

   if modify == nil {
      return list
   }

   mapped := make([]int, len(list))
   for i, item := range list {
      mapped[i] = modify(item)
   }

   return mapped
}

// MapAny modifies every item of list and returns a new modified slice. It works with Any type, so you should cast types by yourself.
func MapAny(list []any, modify func(item any) any) []any {
   if list == nil {
      return nil
   }

   if modify == nil {
      return list
   }

   mapped := make([]any, len(list))
   for i, item := range list {
      mapped[i] = modify(item)
   }

   return mapped
}

对于基准测试,我们使用整数列表[]int{1,2,3}和将每个整数值加倍的回调函数:

func BenchmarkGenericMap(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Map([]int{1, 2, 3}, func(item int) int {
			return item * 2
		})
	}
}

func BenchmarkTypedMap(b *testing.B) {
	for i := 0; i < b.N; i++ {
		MapTyped([]int{1, 2, 3}, func(item int) int {
			return item * 2
		})
	}
}

func BenchmarkAnyMap(b *testing.B) {
	for i := 0; i < b.N; i++ {
		MapAny([]any{1, 2, 3}, func(item any) any {
			return item.(int) * 2
		})
	}
}

调用该go test -bench=. -benchmem -v ./...命令后,我们得到下表中描述的基准测试结果:

地图功能类型操作计数ns/op字节/操作分配/操作
通用的4203370528.90241
打字4131702229.16241
任何(使用类型转换)1756397568.61481

基准测试要点:

  • Generic Function 与特定类型的函数实现具有相同的性能
  • 平均而言,通用函数优于 Any(使用类型转换)实现:
    • 运算速度快约 2.4 倍
    • 消耗了一半的内存
  • 性能改进是消除了使用类型转换的需要的结果

结论

我们研究了泛型函数是什么以及它们在 Go 中是如何工作的。此外,我们将泛型函数的性能与以前的泛型编程方法进行了比较。最后,有主要结论:

  • 泛型允许只编写一次函数并将其用于不同的数据类型,而无需任何额外代码。因此,它更容易扩展和维护。
  • Go 泛型函数在编译期间被实例化,它们不会在程序运行时出现。这可以防止程序中出现意外行为并保证类型安全。
  • Go Generic Functions 与特定类型的函数实现具有相同的性能,但它更可取,因为不需要为每种数据类型编写新函数。

参考

  1. 通用函数 – 维基百科 ( https://en.wikipedia.org/wiki/Generic_function )
  2. 泛型简介 – Go 编程语言 ( https://go.dev/blog/intro-generics )
  3. 艰难地去泛型(https://github.com/akutz/go-generics-the-hard-way
分类: Go