细聊一下GO的泛型

快速开始

官方文档: go.dev/doc/tutoria... , Go的泛型是1.18版本开始支持的,所以使用的话最低版本要求是1.18

一、快速开始

写法的话可以看下面这个例子,代码有点长,主要是看看长啥样子就行了,现在编辑器很智能,写错了也会帮你改,不行问问GPT!

go 复制代码
package main

import (
	"fmt"
	"reflect"
)

// Ptr 泛型函数
func Ptr[T any](input T) *T {
	return &input
}

func FromPtr[T any](input *T) (_ T) {
	if input == nil {
		return
	}
	return *input
}

// KV 泛型结构体
type KV[K comparable, V any] struct {
	Key   K
	Value V
}

func (k KV[K, V]) String() string {
	// 反射API是没啥变更的
	f0 := reflect.TypeOf(k).Field(0)
	f1 := reflect.TypeOf(k).Field(1)
	return fmt.Sprintf("KV[%s, %s]{Key: %v, Value: %v}", f0.Type, f1.Type, k.Key, k.Value)
}

func (kv *KV[K, V]) SetValue(value V) {
	kv.Value = value
}

// IndexFunc 泛型参数是不区分先后定义顺序的,即不需要把类型参数E申明到前面
func IndexFunc[S ~[]E, E any](s S, f func(E) bool) int {
	for index, elem := range s {
		if f(elem) {
			return index
		}
	}
	return -1
}

func MapFromSlice[Input, Output any](inputs []Input, handle func(Input) Output) []Output {
	r := make([]Output, 0, len(inputs))
	for _, elem := range inputs {
		r = append(r, handle(elem))
	}
	return r
}

// Number 类型约束
type Number interface {
	~int | ~float64 | ~int64
}

type IntNumber int

func ToString[T Number](t T) string {
	return fmt.Sprintf("%v", t)
}

func NewStringKV[V any]() KV[string, V] {
	return KV[string, V]{}
}

// Min 支持可变参数
func Min[T Number](x T, y ...T) T {
	min := x
	for _, elem := range y {
		if elem < min {
			min = elem
		}
	}
	return min
}

func main() {
	fmt.Println(Min(2, 1, 3))

	numbers := []int{0, 42, -10, 8}
	fmt.Println(IndexFunc(numbers, func(n int) bool {
		return n < 0
	}))

	// 支持参数类型推导
	var intp *int
	fmt.Println(FromPtr(intp))
	fmt.Println(*Ptr(1))

	// 类型约束, 也就是说 ~ 符号类型推导,是不会丢失原始类型的!
	fmt.Println(ToString(1)) // ToString[int](int)
  fmt.Println(ToString(IntNumber(1))) // 注意这里实际上是 ToString[IntNumber](IntNumber)

	fmt.Println(MapFromSlice([]KV[string, int]{{Key: "1", Value: 1}, {Key: "2", Value: 2}}, func(input KV[string, int]) string {
		return input.Key
	}))

	fmt.Println(KV[string, int]{Key: "1", Value: 1})
	fmt.Println(KV[string, string]{Key: "1", Value: "1"})

	// 支持type=interface{}/any等任何类型
	kv := NewStringKV[interface{}]()
	kv.Value = "1"
	fmt.Println(kv.Value.(string))
}

二、兼容性问题上,目前和旧的类型系统还是保持兼容的

go 复制代码
package main

import "net/http"

type Cache[V any] interface {
	Get(key string) V
	Set(key string, v V)
}

func main() {
	var cache Cache[string] = http.Header{} // 兼容普通类型
	cache.Get("1")

	var cache2 Cache[interface{}] // 支持interface{}为泛型参数类型
	cache2.Get("1")
}

三、目前GO1.21已经正式发布了,我们可以看一下1.18-1.21带来的新特性

  1. 1.19
  • 由于和Go1.18发布仅间隔5个月未引入大量的特性
  1. 1.20
  • 优化了泛型的编译速度,大概提升15%左右
  • 优化了comparable 类型的推断逻辑
go 复制代码
package main

func doSth[T comparable](t T) T {
    return t
}

func main() {
	n := 2
	var i interface{} = n
	doSth(i) // 在go1.18编译不通
}
  1. 1.21
  • 引入了 maps / slices / cmp 库,都是是一些泛型工具库,方便使用!
  • 引入了 min/max/clear 内置函数
  • 更智能的类型推导,下面这个代码需要在Go1.21中才能编译通过 godbolt.org/z/vEPW8T6Kr
go 复制代码
package main

import (
	"fmt"
)

type Ordered interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
		~float32 | ~float64 |
		~string
}

// Min 支持可变参数
func Min[T Ordered](x T, y ...T) T {
	min := x
	for index, elem := range y {
		if elem < min {
			min = elem
		}
	}
	return min
}

func main() {
	fmt.Println(Min(1, 1.1, 1.2, 1.3, -1, -1.1)) // 在Go1.21中才能编译通过,其他版本会报错 default type float64 of 1.2 does not match inferred type int for T, Go低版本的做法是把它推导成了 Min[int](...),1.21是Min(float64)
	fmt.Println(min(1, 1, 1.2, 1.3, -1, -1.1))
}

底层实现

一、底层实现

底层实现和C++差不多,都是要进行泛型(模版)函数/类型的实例化的,也就是说会为不同类型生成不同的函数/类型信息,所以会就变相的导致二进制的庞大,但是带来的好处就是性能没有劣化!

具体我们可以通过查看Go的汇编代码可以发现: godbolt.org/z/8M7qeTxYn ,实际上调用泛型函数的时候调用的是不同的函数名。

其次Go的实现区别于Java语言,Java会在生成字节码的泛型会被擦除掉,具体可以看 godbolt.org/z/bhGKhdcco ,但实际上在runtime阶段是可以拿到泛型信息的!

二、可能存在的一些坑

GO1.18版本之前大家喜欢用 swith type 来进行类型判断,这里实际上在泛型中是行不通的!

go 复制代码
package main

func ToString[T int64 | float64](num T) string {
	switch num.(type) { // 这里编译失败,原因是实例化后的代码,num的类型是int64/float64而不是interface{}
	case int64:
	case float64:
	}
	return ""
}

三、如何编译Go输出汇编

shell 复制代码
go build -v -o main -gcflags="-N -l" main.go
# -gnu 会携带输出at&t,方便查看
go tool objdump -gnu -S main > main.s
# 等价于上面图片的输出,坏处就是输出的是plan9汇编格式
go tool compile -N -l -S  main.go

一些缺陷

没函数重载

通常我们需要用到泛型做数据类型转换,但是对于一些复杂类型的参数可能会出现以下的代码,因为Go不支持函数重载最终导致还是会出现case by case 的定义一些函数,例如下面函数是一个 map 函数,可以实现a->b 集合的转换.

go 复制代码
package utils

func MapFromSlice[Input, Output any](input []Input, handler func(Input) Output) []Output {
	ret := make([]Output, 0, len(input))
	for _, elem := range input {
		ret = append(ret, handler(elem))
	}
	return ret
}

func MapFromMap[IK comparable, IV any, Output any](input map[IK]IV, handler func(key IK, value IV) Output) []Output {
	ret := make([]Output, 0, len(input))
	for k, v := range input {
		ret = append(ret, handler(k, v))
	}
	return ret
}

说实话Go有妥协,例如 go 在 1.21 中引入的 clear 函数就是妥协,但是目前没有把这个妥协的能力开放给开发者

go 复制代码
package main

// 编译条件: go1.21
// func clear[T ~[]Type | ~map[Type]Type1](t T)

func main() {
	arr := []int{3, 2, 4, 6}
  clear(arr) // 最终会调用 runtime.memclrNoHeapPointers的函数,实现整个数组元素都清零

	_map := map[string]int{"1": 1}
	clear(_map) // 最终会调用runtime.mapclear的函数,清空整个map
}

然而如果支持了函数重载,那么开发者也可以很轻松的实现相似的代码!

类型自动推导的坑

上面讲了go1.21引入了更加智能的类型推导,但是实际上他会造成业务逻辑的BUG,如下面例子,这个就很难避免了需要个人去避免了!!

go 复制代码
package main

import (
	"fmt"
	"math"
)

// func min[T cmp.Ordered](x T, y ...T) T

func main() {
	fmt.Println(min(math.MaxInt64, math.MaxFloat64)) // 推导成了 min[float64](float64, float64)float64 导致输出 int64->float64自动转型  // 9.223372036854776e+18
	fmt.Println(math.MaxInt64) // 9223372036854775807
	fmt.Println(float64(math.MaxInt64)) // 9.223372036854776e+18
}

不支持类型特化

泛型中大量使用了 any/comparable 这种 generic 类型,就会导致一个问题,这种类型范围太大了,如果我们想针对一种类型进行优化的时候就显得很麻烦了!!因此特化就显得非常重要!

go 复制代码
package main

import "fmt"

func ToString[T any](input T) string {
	return fmt.Sprintf("%v", input)
}

func ToString[T fmt.Stringer](input T) string { //编译失败
	return input.String()
}

func ToString[T ~string](input T) string { //编译失败
	return string(input)
}

C++中是支持特化的,其次也支持部分参数特化和全特化!下面代码不是通过特化实现的,主要是类型推导的原因,采用的 SFINAE + 重载实现的,但是最终也实现了特化的效果(C++的模版编程特别复杂,所以现代语言基本没有参考C++的做法)!代码示例: godbolt.org/z/dz37T8xYj

cpp 复制代码
#include <iostream>
#include <string>

template <typename T>
concept is_std_to_string_v = requires(T t) {
    { std::to_string(t) } -> std::same_as<std::string>;
};

template <typename T>
    requires is_std_to_string_v<T>
inline std::string to_string(T t) { // f1
    return std::to_string(t);
}

template <typename T>
    requires std::is_convertible_v<T, std::string>
inline std::string to_string(T t) { // f2
    return t;
}

inline std::string to_string(bool b) { // f3
    return b ? "true" : "false";
}

int main() {
    std::cout << to_string(1) << std::endl; // 调用的f1
    std::cout << to_string("1") << std::endl; // 调用的f2
    std::cout << to_string(true) << std::endl; // 调用的f3
}

不支持lambda(类型简化)

上面讲到的MapFromSlice 和 MapFromMap 函数我们发现

go 复制代码
type KV[K, V any] struct {
	Key   K
	Value V
}

type Item[T any] struct {
	Data []T
}

func main() {
	input := make([]KV[string, Item[string]], 0)
	arr := MapFromSlice(input, func(input KV[string, Item[string]]) int {
		return len(input.Value.Data)
	})
  
	// 首先思考下为什么需要 lambda,你会发现我们的参数类型 KV[string, Item[string]] 这么长,需要手动写,如果更复杂的呢?
	// 而有了lambda或者有更加优秀的类型推断系统(类似于c++的auto/decltype关键字),可以避免这种一大串的类型申明
	// MapFromSlice(input, (input)- > len(input.Value.Data)) 

	fmt.Println(arr)
}

这里Go不支持lambda的原因很简单因为lambda本质上就是匿名函数(GO是支持的),但是有一点是lambda可以简化类型申明!

IDE支持不友好

类型推导不行 (goland 2023.3.2),需要手动写类型申明!

go 复制代码
package main

import "fmt"

type List[T any] []T

func Map[Input, Output any](params List[Input], handler func(Input) Output) List[Output] {
	ret := make(List[Output], 0, len(params))
	for _, elem := range params {
		ret = append(ret, handler(elem))
	}
	return ret
}

type KV[K comparable, V any] struct {
	Key   K
	Value V
}

func main() {
	input := make([]KV[string, int], 0)
	input = append(input, KV[string, int]{}) // 推导不出来
	array := Map(input, func(input KV[string, int]) int { // 推导不出来
		return input.Value
	})
	for _, elem := range array {
		fmt.Println(elem)
	}
}
  1. 简单类型推导不出来
  1. 函数的参数也推导不出来

旧的API不太容易适配泛型

例如 sync.Map 类型,实际上它的泛型参数就一个 key/value ,如果Go官方如果把 sync.Map 改成了泛型实现,就会导致旧的API无法编译通过了!如果支持泛型默认值就好了!但是几百年了Go的函数参数都不支持默认值!想想也不太可能了!!

cpp 复制代码
template <typename K = int, typename V = int>
struct Map {
    // ...
};

int main() {
    Map arr{};
    Map<int, std::string> arr2{};
}

总结

  1. 可以减少代码量,提高代码的健壮性!
  2. 降低代码中使用 interface{} 类型,从而避免Go的逃逸带来的额外性能开销,进而提升代码的性能!
  3. 泛型可以使得类型变得更加安全,即编译器可以帮我们做类型检查!
  4. Go的泛型整体设计比较简单遵循 less is more,简单就是好,学习成本低,易用性高!
相关推荐
幼儿园老大*1 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
架构师那点事儿6 小时前
golang 用unsafe 无所畏惧,但使用不得到会panic
架构·go·掘金技术征文
于顾而言1 天前
【笔记】Go Coding In Go Way
后端·go
qq_172805591 天前
GIN 反向代理功能
后端·golang·go
follycat1 天前
2024强网杯Proxy
网络·学习·网络安全·go
OT.Ter1 天前
【力扣打卡系列】单调栈
算法·leetcode·职场和发展·go·单调栈
探索云原生1 天前
GPU 环境搭建指南:如何在裸机、Docker、K8s 等环境中使用 GPU
ai·云原生·kubernetes·go·gpu
OT.Ter1 天前
【力扣打卡系列】移动零(双指针)
算法·leetcode·职场和发展·go
码财小子2 天前
k8s 集群中 Golang pprof 工具的使用
后端·kubernetes·go
明月看潮生5 天前
青少年编程与数学 02-003 Go语言网络编程 04课题、TCP/IP协议
青少年编程·go·网络编程·编程与数学