深入go泛型特性之comparable「附案例」

写作背景

如果你经常遇到一些操作,比如将 map 转换为 slice,判断一个字符串是否出现在 map 中,slice 中是否有重复元素等等,那你对下面这个库肯定不陌生。

github.com/samber/lo

最近抽业余时间在看了源码,底层用了范型封装并且用了预定义类型 comparable 作为约束,平时在开发中也会使用这个关键字封装基础操作,例如:

// 交集
func Intersect[T comparable](slice1, slice2 []T) []T {
	slice1Map := make(map[T]struct{})
	for _, s1 := range slice1 {
		slice1Map[s1] = struct{}{}
	}

	var ret []T
	for _, s2 := range slice2 {
		_, ok := slice1Map[s2]
		if ok {
			ret = append(ret, s2)
		}
	}

	return ret
}

// 求并集
func Union[T comparable](slices ...[]T) []T {
	elementMap := make(map[T]struct{})
	for _, sc := range slices {
		for _, element := range sc {
			elementMap[element] = struct{}{}
		}
	}

	retSlice := make([]T, 0, len(elementMap))
	for element := range elementMap {
		retSlice = append(retSlice, element)
	}
	return retSlice
}

// 去重
func Distinct[T comparable](arr []T) []T {
	if len(arr) == 0 {
		return arr
	}

	srcMap := make(map[T]struct{})
	for k := range arr {
		srcMap[arr[k]] = struct{}{}
	}

	var newArr []T
	for ar := range srcMap {
		newArr = append(newArr, ar)
	}
	return newArr
}

跟 lo 库做了一些类似操作,不过没有它丰富。当时在封装这部分代码时 lo 库还没有进入我们视野,于是造了一些轮子。

刚好看 GO 的发布 blog ,GO 团队对 comparable 进行了一些更新,于是决定研究一番这次升级了啥。

comparable 是在 GO 1.20 版本更新的,如果遇到一些编译问题,可能需要你升级下 SDK 版本(至少是 1.20 版本)。

名词解释

什么是 comparable

comparable 为 Go 中的预定义类型,comparable 是一种类型约束,用于指定某些类型可以进行相等性比较。这意味着我们可以使用 == 和 != 运算符来比较这些类型的值。

下面是官方 sdk 给出的解释。

// comparable is an interface that is implemented by all comparable types
// (booleans, numbers, strings, pointers, channels, arrays of comparable types,
// structs whose fields are all comparable types).
// The comparable interface may only be used as a type parameter constraint,
// not as the type of a variable.
type comparable interface{ comparable }

有几个关键信息

  1. "comparable"是一个接口,所有可比较类型实现这个接口;
  2. 包含了布尔型、数值型、字符串、指针、通道、结构体等,这里注意结构体中的所有成员变量都是可比较类型。
  3. 可比较的接口只能用作行参数约束,不可以作为变量的类型,这个应该比较好理解。

结构体中的所有成员变量都是可比较类型,可能不好理解。下面这段代码定义了 Person,Person 成员变量都是可比较的,所以编译通过并且能得到正确结果。

func Equal[P comparable](params1, params2 P) bool {
	return params1 != params2
}

type Person struct {
	Name string
	Age  int
}

Equal[Person](Person{
		Name: "11",
		Age:  0,
	}, Person{
		Name: "22",
		Age:  0,
	})

下面这段代码同样定义了 Person,但是有一个 Address 是 切片,切片是不可比较的,所以,Person 结构体是不可比较的,下面这段代码编译失败。

func Equal[P comparable](params1, params2 P) bool {
	return params1 != params2
}

type Person struct {
	Name    string
	Age     int
	Address []string
}

Equal[Person](Person{
		Name: "11",
		Age:  0,
	}, Person{
		Name: "22",
		Age:  0,
	})

编译器提示

Person does not satisfy comparable

看到这里,可能有同学要问了哪些类型支持比较呢?参考下面这个链接

https://go.dev/ref/spec#Comparison_operators​go.dev/ref/spec#Comparison_operators

可比较运算符

comparable 作为范型类型约束实参类型,实参类型一定是可比较类型,那可比较是啥意思?简单点说比较运算符比较两个数,并产生一个布尔值。

比较运算符有哪些呢?参考下面链接

The Go Programming Language Specification - The Go Programming Language

下面我贴一些关键信息。

==    equal
!=    not equal
<     less
<=    less or equal
>     greater
>=    greater or equal

比较运算符分为两大类:

1、 相等运算符 == 和 != 适用于可比较类型(comparable types)。

2、 有序类型的操作符 <、<=、> 和 >= 适用于有序类型(ordered types)。

comparable 和 ordered types 是不同的概念,如果类型约束是 comparable ,那么该类型只能使用 == 和 != 运算符,而不能使用排序运算符。

下面这段代码编译不通过的

func min[T comparable](x, y T) {
	if x > y {
		return
	}

	return
}

编译器提示,因为 comparable 不支持大小比较

Invalid operation: x > y (the operator > is not defined on T)

如果要支持大小比较可以改成下面这样

import (
	"fmt"
	"golang.org/x/exp/constraints"
)

func main() {
	fmt.Println(min[int](1, 2))
}

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

	return x
}

constraints.Ordered 约束支持的运算符有 <、<=、> 和 >=。

顺带解释下行参和实参

类型形参(type parameter)

函数的形参(parameter) 只是类似占位符并没有具体的值。

//  T 是形参,在定义函数时它的类型是不确定的,类似占位符
func min[T comparable](a T, b T) T {
	....
}

类型实参(type argument)

T 被称为类型形参(type parameter), 在函数定义时类型并不确定。因为类型不确定性,所以在调用函数的时候再传入具体的类型。被传入的具体类型被称为类型实参(type argument):

//  T 是形参,在定义函数时它的类型是不确定的,类似占位符
func min[T comparable](a T, b T) T {
	....
}

min[int](1,2)

min int,int 就是实参,含义就是把 min 函数定义的行参 T 替换为 int 类型,就如下面这段代码。

func min[T comparable](a int, b int) T {
	....
}

实例化:定义范型类型并不能直接使用,需要被实例化为实参才能使用。

type Map[K comparable, T any] struct {
	m map[K]T
}

func (t *Map[K, T]) Add(k K, val T) {
	if t.m == nil {
		t.m = make(map[K]T)
	}
	t.m[k] = val
}

func main() {
	customMap := new(Map[string, any])
	customMap.Add("123", 90)
	fmt.Println(customMap)
}

范型类型被实例化为 Map[string, any],结果输出:&{map[123:90]}。

comparable 诞生背景

== 和 != 运算符不仅可以支持在一些预定义的类型上,比如:int、 string、bool 等,还应该支持更多的类型,比如结构体、数组、interface。

再加上范型引入,在约束中列举所有这些类型是不可能的。所以需要用一种方式来让行参支持 == 和 != 。

为了解决这个问题,Go 1.18 引入了预定义类型 comparable,comparable 是一个接口类型,其类型集合是可比较类型的集合,并且在实参需要支持 == 或者 != 的情况下用作函数、类型约束。comparable 按照我的理解它是一个语法糖。

如果你尝试封装一些基础库,例如:判断 slice 中是否包含某一个值,通常会定义一些范型类型,为了保证代码的安全性,对传入的实参类型进行了约束( Tmp 为约束)。如下:

type Tmp interface {
	~int | ~string | ~float32 | ~float64 // ....后续可能持续增加
}

// 包含
func IsContain[T Tmp1](src []T, targets ...T) bool {
	if len(src) == 0 {
		return false
	}

	srcMap := make(map[T]struct{})
	for k := range src {
		srcMap[src[k]] = struct{}{}
	}

	for _, target := range targets {
		_, ok := srcMap[target]
		if !ok {
			return false
		}
	}
	return true
}

使用方代码

func main() {
	fmt.Println(IsContain[int]([]int{1, 2}, 2))
}

你可以思考下假设需求驱动,增加了一种类型,你需要在 Tmp 中增加一个约束,这种方式并不优雅。如果是上面类似场景,那你可以放心替换为 comparable了,不用写这么啰嗦的代码了。

说到这里,comparable 跟 interface/any 是有区别的。前者代表仅可比较类型,后者代表任何类型。简单点说就是 interface/any 的类型集是大于 comparable 的。

另外,在没有范型时,你们是否写过下面这样的代码?为了提高代码复用性,map 能兼容更多类型,所以把 map key 定义为 any,但是这种写法是不安全的。虽然编译器不会报错,但是当你运行下面这段代码时,会发生 Panic。

func main() {
	lookupMap := make(map[any]string)
	lookupMap[[]int{}] = "slice"
}
panic: runtime error: hash of unhashable type []int

goroutine 1 [running]:
main.main()

为啥会 Panic 呢?当动态类型存储在接口变量中的实际值的类型是不可比较时,就会在运行时发生 Panic。

相比 comparable,编译器就会提示你类型是否合法。我们把代码微调整下

type CustomMap[k comparable, v any] map[k]v

func main() {
	var lookupMap = make(CustomMap[[]string, string])
	lookupMap[1] = "2"
	fmt.Println(lookupMap)
}

上段代码,comparable 限制了类型是可比较的,当你传入 []int{} 作为 key 时,编辑器提示

Cannot use []string as the type comparable Type does not implement constraint 'comparable' because type is not comparable.

所以,comparable 优势还是非常明显的。

1.20 comparable 升级了什么

好了,下面该讲讲 GO 1.20 升级了啥,在 GO 1.20 版本前,comparable 是不允许你将行参实例化为 any 类型的。any 的类型集合比 comparable 的类型集合更大(不是它的子集),因此并不包含在 comparable 中。

func main() {
	var lookupMap = make(CustomMap[any, string])
	lookupMap[1] = "2"
	fmt.Println(lookupMap)
}

上段代码编译期就会提示

Cannot use any as the type comparable Basic interfaces satisfy 'comparable' type-checking rules starting with Go 1.20

那 Go 1.20 版本是如何解决这个问题的呢?将非严格可比较类型 any 包含在 comparable 类型集合中。

此时,依赖 comparable 的泛型函数不再具备静态类型安全性了,单个非可比较的值可能通过泛型函数或类型,导致 panic。举个例子,下面这段代码在编译期是无法检查异常的,在运行时会 Panic。

func main() {
	var lookupMap = make(CustomMap[any, string])
	lookupMap[[]string{}] = "2"
	fmt.Println(lookupMap)
}

想了解 comparable 升级详细背景可以看看这个:spec: allow basic interface types to instantiate comparable type parameters · Issue #56548 · golang/go · GitHub

comparable 使用场景

推荐大家仔细研读 lo 库,代码非常简单,它会帮助你了解更多使用场景,让你有种恍然大悟感觉。

github.com/samber/lo

总结

  1. 在 GO 1.20 之前,某些可比较类型实际上未满足 comparable 约束,比如 any,所以 comparable 约束的行参是不允许你实例化为 any 的。

  2. GO 1.20 之后更改了 comparable 的行为,使其包含所有可比较类型。另外,1.20 之后 comparable 不再是静态安全的了,如果使用不当也会导致 panic。

参考文献

https://go.dev/blog/comparable​go.dev/blog/comparable

相关推荐
Am心若依旧40919 分钟前
[c++11(二)]Lambda表达式和Function包装器及bind函数
开发语言·c++
明月看潮生21 分钟前
青少年编程与数学 02-004 Go语言Web编程 20课题、单元测试
开发语言·青少年编程·单元测试·编程与数学·goweb
大G哥30 分钟前
java提高正则处理效率
java·开发语言
VBA633741 分钟前
VBA技术资料MF243:利用第三方软件复制PDF数据到EXCEL
开发语言
轩辰~43 分钟前
网络协议入门
linux·服务器·开发语言·网络·arm开发·c++·网络协议
小_太_阳1 小时前
Scala_【1】概述
开发语言·后端·scala·intellij-idea
向宇it1 小时前
【从零开始入门unity游戏开发之——unity篇02】unity6基础入门——软件下载安装、Unity Hub配置、安装unity编辑器、许可证管理
开发语言·unity·c#·编辑器·游戏引擎
古希腊掌管学习的神1 小时前
[LeetCode-Python版]相向双指针——611. 有效三角形的个数
开发语言·python·leetcode
赵钰老师1 小时前
【R语言遥感技术】“R+遥感”的水环境综合评价方法
开发语言·数据分析·r语言
就爱学编程2 小时前
重生之我在异世界学编程之C语言小项目:通讯录
c语言·开发语言·数据结构·算法