细聊一下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,简单就是好,学习成本低,易用性高!
相关推荐
煎鱼eddycjy3 小时前
新提案:由迭代器启发的 Go 错误函数处理
go
煎鱼eddycjy3 小时前
Go 语言十五周年!权力交接、回顾与展望
go
不爱说话郭德纲20 小时前
聚焦 Go 语言框架,探索创新实践过程
go·编程语言
0x派大星2 天前
【Golang】——Gin 框架中的 API 请求处理与 JSON 数据绑定
开发语言·后端·golang·go·json·gin
IT书架2 天前
golang高频面试真题
面试·go
郝同学的测开笔记2 天前
云原生探索系列(十四):Go 语言panic、defer以及recover函数
后端·云原生·go
秋落风声3 天前
【滑动窗口入门篇】
java·算法·leetcode·go·哈希表
0x派大星5 天前
【Golang】——Gin 框架中的模板渲染详解
开发语言·后端·golang·go·gin
0x派大星5 天前
【Golang】——Gin 框架中的表单处理与数据绑定
开发语言·后端·golang·go·gin
三里清风_6 天前
如何使用Casbin设计后台权限管理系统
golang·go·casbin