Go Generics - 泛型复习

AI 摘要: Go语言在1.18版本引入了泛型,带来了三大功能。首先,可以将函数和自定义类型定义为范型类型参数。其次,可以将接口类型定义为类型集,可以包含方法或空集合。最后,泛型能够自动推断,大多数情况下不需要明确指定范型类型。虽然Go语言在没有泛型的情况下已经足够简单,但是通过泛型可以减少大量重复处理逻辑的代码冗余度。在使用泛型时,需要注意可读性和简易性的平衡。

1. Go 泛型资料参考

  1. 官网: https://go.dev/blog/intro-generics
  2. 欧长坤: https://changkun.de/s/generics118

2. 1.18 带来泛型的 3 大功能

  1. 作为函数 func 和自定义类型 type 的范型类型参数
  2. 将接口类型(可以是包含方法或者空集合)定义成类型集
  3. 泛型能够自动推断,大多数情况无需强制指定范型类型 T,在使用时候上下文的明确的类型

2.1. 类型参数 - Type Parameters

普通函数的每个值参数都有一个类型,泛型类型定义了一组值,这组值可以由类型参数约束定义。

2.1.1. 函数泛型参数,约定泛型类型 - func GMin[T constraints.Ordered](x, y T) T

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 取两个数的较小值,同样逻辑可以扩展到其他float32、int、int32、int64等类型
func Min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

// 使用范型可以拥有一套处理流程,不关注数据类型
// 泛型示例
import "golang.org/x/exp/constraints"

func GMin[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

// 泛型调用
x := GMin[int](2, 3)        // 泛型类型指定: 向 GMin 提供类型参数(在本例中为 int )称为实例化,实例化分两步进行: 1.编译器替换类型参数(比如替换成int) 2. 编译器验证类型参数是否满足约束
y := GMin[float32](2.0, 3)  // 泛型类型指定
z := GMin(2.0,3)            // 泛型参数类型推断

// z := GMin(2.0,3),泛型参数类型推断
fmin := GMin[float64]
m := fmin(2.71, 3.14)

2.1.2. 自定义类型变量,约定泛型类型 - type Tree[T interface{}] struct

场景: 我们想定义一棵通用的二叉树,二叉树的节点可能为各种类型;同时二叉树支持 LookUp 节点查询方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 泛型类型 Tree 存储类型参数 T 的值
type Tree[T interface{}] struct {
    left, right *Tree[T]
    value       T
}

// 泛型类型可以有方法
func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

// 定义一棵字符串节点的二叉树
// 为了使用泛型类型,必须对其进行实例化
var stringTree Tree[string]

注意: 为了使用泛型类型,必须对其进行实例化; Tree[string] 是使用**类型参数 string **实例化后的 Tree 的实例

2.2. 类型集 - Type sets

普通函数的每个值参数都有一个类型,该类型定义了一组值,比如 float64 已经定义了一组 float64 值类型。

类似地,类型参数列表中的每个类型参数都有一个类型。由于类型参数本身就是一种类型,因此类型参数的类型定义了类型集。这种元类型称为类型约束。

1
2
3
4
5
6
7
8
// 类型约束是从约束包中导入的
// Ordered 约束描述了具有可排序值的所有类型的集合,或者换句话说,支持<、<= 、> 等运算符比较操作,Orderd约束确保只有具有可排序值的类型才能传递给 GMin
func GMin[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

注意: 在 Go 中,类型约束必须是接口。也就是说,接口类型可以用作值类型,也可以用作元类型。接口定义方法,因此显然我们可以表达需要存在某些方法的类型约束。但是 constraints.Ordered也是一个接口类型,并且 < 运算符不是一个方法。

2.2.1. 回想之前我们如何定义和使用接口?

同一类型的集合,比如一个编解码接口,要求至少满足Encode()和Decode()方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 编解码接口
type Encoder interface{
    func Encode(v interface{}) (str string, err error)
    func Decode(data string) (v interface{}, err error)
}

// 一个Json编解码的接口的实现
type JsonEncoder struct{}
func (js *JsonEncoder) Encode(v interface{}) (str string, err error)
func (js *JsonEncoder) Decode(data string) (v interface{}, err error)

// 将接口作为参数值传入另外一个处理流程(类似依赖注入,实现控制反转IoC)
func Transfer(orgData v interface{}, enc Encoder) error {
    // 可以根据Encoder的接口实现对原始数据的编码后传输
    encStr,_ := enc.Encode(orgData)

    // 通过网络传输将encStr传给下游处理程序
}

// 编码调用&传输
enc := &JsonEncoder{}
_ = Transfer(orgData, enc)

从上面的接口定义我们发现,常规接口值会包含:

  1. 新的接口值类型定义(type JsonEncoder struct{})以及接口方法实现(Encode()和Decode())
  2. 在通用处理流程Transfer()中,支持传入接口值类型实现类型依赖注入的设计模式

简单总结就是: 接口定义了一组类型,即实现这些方法的类型

2.2.2. 泛型 - 对接口集合扩展

类型集视图比方法集视图有一个优势:我们可以显式地将类型添加到集合中,从而以新的方式控制类型集。

比如: interface{ int|string|bool } 定义包含类型 int 、 string 和 bool 的类型集。

再看下constraints.Ordered接口类型

1
2
3
4
5
// 声明表示 Ordered 接口是所有整数、浮点和字符串类型的集合
// 竖线表示类型的联合、以及所有字符串类型(包含自定义类型但基础类型为string的,比如type MyString string)
type Ordered interface {
    Integer|Float|~string
}

这里针对|和~补充说明:

  1. 竖线表示类型的联合(或本例中的类型集)。 Integer 和 Float 是在 constraints 包中类似定义的接口类型。请注意, Ordered 接口没有定义任何方法。
  2. 对于类型约束,我们通常不关心特定类型,例如 string,或者是 type MyString string ,这就是 ~ 符号的用途。表达式 ~string 表示基础类型为 string 的所有类型的集合。这包括类型 string 本身以及使用 type MyString string 等定义声明的所有类型。

2.2.3. 内联参数类型 - [S interface{~[]E}, E interface{}]

通常内联参数类型使用不多,当用作类型约束时,接口定义的类型集准确指定允许作为相应类型形参的类型实参的类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 这里 S 必须是一个切片类型,其元素类型可以是任何类型。
func Join[S interface{~[]E}, E interface{}] (v S) string {
    ...
}

// 上述 [S interface{~[]E}, E interface{}] ,所以对于约束位置的接口,可以省略封闭的 interface{} ,可以等价为下面形式
=> [S ~[]E, E interface{}]

// 1.18引入了 标识符 any 作为空接口类型的别名,因此上述泛型类型参数等价于下面形式
=> [S ~[]E, E any]
func Join[S ~[]E, E any](v S) string {
    ...
}


// 拼接成SQL中的IN (1,2..)或者("a","b",..)形式
func Join[S ~[]E, E any](sdata S) string {
	str := "("
	for i, v := range sdata {
		if i == 0 {
			str += fmt.Sprintf(`"%v"`, v)
			continue
		}
		str += fmt.Sprintf(`,"%v"`, v)
	}
	str += ")"

	return str
}

//  generics_test.go:45: ("1","2","3")
//  generics_test.go:46: ("a","b","c")
func TestJoin(t *testing.T) {
	nums := []int{1, 2, 3}
	chars := []string{"a", "b", "c"}

	t.Log(Join(nums))
	t.Log(Join(chars))
}

2.3. Type inference - 类型推断

2.3.1. 函数参数类型推断

让编译器进行自动参数类型推断。

注意: 函数参数类型推断仅适用于在函数参数中使用的类型参数,不适用于仅在函数结果或仅在函数体中使用的类型参数。例如,它不适用于 MakeT[T any]() T 等仅使用 T 获取结果的函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 定义泛型参数函数
func GMin[T constraints.Ordered](x, y T) T { ... }

// 使用明确类型float64定义
var a, b, m float64
m = GMin[float64](a, b) // explicit type argument

// 让编译器通过普通参数进行断言,推断T为float64类型
var a, b, m float64
m = GMin(a, b) // no type argument

2.3.2. Constraint type inference 约束类型推断

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 这是一个将E元素内的参数进行倍数处理的函数,E传参需要时整型
// Scale returns a copy of s with each element multiplied by c.
// This implementation has a problem, as we will see.
func Scale[E constraints.Integer](s []E, c E) []E {
    r := make([]E, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

// 但如果我们自定义了一个Point类型,基础类型为[]int
type Point []int32
func (p Point) String() string {
    // Details not important.
}

// 因为Point类型是自定义类型,而Scale()函数有了类型约束,无法支持自定义类型(即便基础类型也是整型),所以调用上述函数会报错
// ScaleAndPrint doubles a Point and prints it.
func ScaleAndPrint(p Point) {
    r := Scale(p, 2)
    fmt.Println(r.String()) // DOES NOT COMPILE
}

// 修改方式也很简单,使用~符号表明基础类型符合要求即可使用该函数: [S ~[]E, E constraints.Integer],同时内部的make方法改成make(S, len(s))即可
// Scale returns a copy of s with each element multiplied by c.
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
    r := make(S, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

为什么可以在不传递显式类型参数的情况下编写对 Scale 的调用?

也就是说,为什么我们可以编写没有类型参数的 Scale(p, 2) ,而不是必须编写 Scale[Point, int32](p, 2) ?

我们的新 Scale 函数有两个类型参数: S 和 E , 在对不传递任何类型参数的 Scale 的调用中,如上所述的函数参数类型推断使编译器推断 S 的类型参数是 Point 。但该函数还有一个类型参数 E ,它是乘法因子 c 的类型,对应的函数参数是 2 ,并且由于 2 是无类型常量,函数参数类型推断无法推断出 E 的正确类型(最多可能推断出 2 的默认类型是 int ,这是不正确的)。相反,编译器推断 E 的类型参数是切片的元素类型的过程称为约束类型推断 (通过类型约束推断出结果)

约束类型推断从类型参数约束中推导出类型实参,当一个类型参数具有根据另一类型参数定义的约束时使用它。当这些类型参数之一的类型参数已知时,约束用于推断另一个类型参数的类型参数。

在 Scale 示例中看到了这一点。 S 是 ~[]E ,它是 ``~ 后跟根据另一个类型参数编写的类型 []E 。如果我们知道 S 的类型参数,我们就可以推断出 E 的类型参数。 S 是切片类型, E 是该切片的元素类型。

结论:类型推断要么成功,要么失败。如果成功,则可以省略类型参数,并且调用泛型函数看起来与调用普通函数没有什么不同。如果类型推断失败,编译器将给出错误消息,在这种情况下我们只需提供必要的类型参数即可。

3. 在工作中真实的示例 Case

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
package shim

import (
	"fmt"
	"strings"
)

// Negative 确保金额类型为负数
func Negative[T int | int32 | int64 | float32 | float64](money T) T {
	if money > 0 {
		return -money
	}
	return money
}

// Positive 确保金额类型为正数
func Positive[T int | int32 | int64 | float32 | float64](money T) T {
	if money < 0 {
		return -money
	}
	return money
}

// InSlice 尝试用范型比较
func InSlice[T comparable](id T, ids []T) bool {
	for _, v := range ids {
		if id == v {
			return true
		}
	}
	return false
}

// Uniq 切片去重
func Uniq[T comparable](ids []T) []T {
	m := make(map[T]struct{})
	var retIDs []T
	for _, id := range ids {
		if _, ok := m[id]; !ok {
			retIDs = append(retIDs, id)
			m[id] = struct{}{}
		}
	}
	return retIDs
}

// PageData 获取分页数据
func PageData[T interface{}](data []T, page int, pageSize int) []T {
	if len(data) == 0 {
		return data
	}
	if page <= 0 {
		page = 1
	}

	total := len(data)
	start := (page - 1) * pageSize
	if start > total {
		start = total
	}
	end := start + pageSize

	if end > total {
		end = total
	}

	return data[start:end]
}

// ShardingNumbers 分片函数,按指定大小分成多片数据
func ShardingNumbers[T comparable](ids []T, batchSize int) (batches [][]T) {
	slen := len(ids)
	if batchSize <= 0 {
		batchSize = slen
	}

	batchCount := slen / batchSize
	for i := 0; i <= batchCount; i++ {
		start := i * batchSize
		end := start + batchSize
		if end > slen {
			end = slen
		}
		if len(ids[start:end]) == 0 {
			continue
		}
		batches = append(batches, ids[start:end])
	}

	return
}

// JoinNumbers 拼接数值成字符串
func JoinNumbers[T comparable](ids []T, sep string) string {
	var ss []string
	for _, id := range ids {
		ss = append(ss, fmt.Sprintf("%v", id))
	}
	return strings.Join(ss, sep)
}

4. 最后

虽然 Go 语言在没有泛型的情况下也足够简单,但在编写一些具备同样重复逻辑,但仅参数类型不一致的方法、函数、类型时候,通过泛型可以缩减大量重复处理逻辑。

Go 在 1.18 版本引入泛型后,通过函数参数泛型支持、类型约束定义,可以让同样处理逻辑的代码冗余度得到了降低。

泛型是在编译器编译环节会对类型变量进行替换,通过类型推断识别函数或方法的参数类型。

在使用泛型过程中,也应该规避过于复杂给程序带来的可读性问题,应该在代码的可读性和简易性进行TradeOff考量。