Golang泛型与类型约束的用法详解

Written by

in

文章目录
  • Go 1.20.2
  • 假设现在我们需要写一个函数,实现: 1)输入一个切片参数,切片类型可以是[]int或[]float64,然后将所有元素相加的“和”返回 2)如果是int切片,返回int类型;如果是float64切片,返回float64类型 当然,最简单的方法是写两个函数SumSliceInt(s []int)、SumSliceFloat64(s []float64)来分别支持不同类型的切片,但是这样会导致大部分代码重复冗余,不是很优雅。那么有没有办法只写一个函数呢? 我们知道,在Go中所有的类型都实现了interface{}接口,所以如果想让一个变量支持多种数据类型,我们可以将这个变量声明为interface{}类型,例如var slice interface{},然后使用类型断言(.(type))来判断这个变量的类型。 interface{} + 类型断言: // any是inerface{}的别名,两者是完全相同的:type any = interface{} func SumSlice(slice any) (any, error) { switch s := slice.(type) { case []int: sum := 0 for _, v := range s { sum += v } return sum, nil case []float64: sum := float64(0) for _, v := range s { sum += v } return sum, nil default: return nil, fmt.Errorf(“unsupported slice type: %T”, slice) } } 从上述代码可见,虽然使用interface{}类型可以实现在同一个函数内支持两种不同切片类型,但是每个case块内的代码仍然是高度相似和重复的,代码冗余的问题没有得到根本的解决。
  • 幸运的是,在Go 1.18之后开始支持了泛型(Generics),我们可以使用泛型来解决这个问题: func SumSlice[T interface{ int | float64 }](slice []T) T { var sum T = 0 for _, v := range slice { sum += v } return sum } 是不是简洁了很多?而且,泛型相比interface{}还有以下优势: 可复用性:提高了代码的可复用性,减少代码冗余。 类型安全性:泛型在编译时就会进行类型安全检查,可以确保编译出来的代码就是类型安全的;而interface{}是在运行时才进行类型判断,如果编写的代码在类型判断上有bug或缺漏,就会导致Go在运行过程中报错。 性能:不同类型的数据在赋值给interface{}变量时,会有一个隐式的装箱操作,从interface{}取数据时也会有一个隐式的拆箱操作,而泛型就不存在装箱拆箱过程,没有额外的性能开销。
  • 以上为个人经验,希望能给大家一个参考,也希望大家多多支持风君子博客。 您可能感兴趣的文章: Golang使用泛型对数组进行去重的实现 golang使用泛型结构体实现封装切片 golang1.21泛型函数全面讲解 一文带你了解Golang中的泛型 一文教你如何在Golang中用好泛型 Golang中的泛型你真的了解吗
  • 目录
    • 一、环境
    • 二、没有泛型的Go
    • 三、泛型的优点
    • 四、理解泛型
      • (一)泛型函数(Generic function)
        • 1)定义
        • 2)调用
      • (二)类型约束(Type constraint)
        • 1)接口与约束
        • 2)结构体类型约束
        • 3)类型近似(Type approximations)
      • (三)泛型类型(Generic type)
        • 1)泛型切片
        • 2)泛型结构体
        • 3)泛型接口
      • (五)一些错误示例
        • 1)联合约束中的类型元素限制
        • 2)一般接口只能用于泛型的类型约束
    • 总结

      Go 1.20.2

      假设现在我们需要写一个函数,实现:

      1)输入一个切片参数,切片类型可以是[]int[]float64,然后将所有元素相加的“和”返回

      2)如果是int切片,返回int类型;如果是float64切片,返回float64类型

      当然,最简单的方法是写两个函数SumSliceInt(s []int)SumSliceFloat64(s []float64)来分别支持不同类型的切片,但是这样会导致大部分代码重复冗余,不是很优雅。那么有没有办法只写一个函数呢?

      我们知道,在Go中所有的类型都实现了interface{}接口,所以如果想让一个变量支持多种数据类型,我们可以将这个变量声明为interface{}类型,例如var slice interface{},然后使用类型断言(.(type))来判断这个变量的类型。

      interface{} + 类型断言:

      // any是inerface{}的别名,两者是完全相同的:type any = interface{}
      func SumSlice(slice any) (any, error) {
      	switch s := slice.(type) {
      	case []int:
      		sum := 0
      		for _, v := range s {
      			sum += v
      		}
      		return sum, nil
      	case []float64:
      		sum := float64(0)
      		for _, v := range s {
      			sum += v
      		}
      		return sum, nil
      	default:
      		return nil, fmt.Errorf("unsupported slice type: %T", slice)
      	}
      }

      从上述代码可见,虽然使用interface{}类型可以实现在同一个函数内支持两种不同切片类型,但是每个case块内的代码仍然是高度相似和重复的,代码冗余的问题没有得到根本的解决。

      幸运的是,在Go 1.18之后开始支持了泛型(Generics),我们可以使用泛型来解决这个问题:

      func SumSlice[T interface{ int | float64 }](slice []T) T {
      	var sum T = 0
      	for _, v := range slice {
      		sum += v
      	}
      	return sum
      }

      是不是简洁了很多?而且,泛型相比interface{}还有以下优势:

      • 可复用性:提高了代码的可复用性,减少代码冗余。
      • 类型安全性:泛型在编译时就会进行类型安全检查,可以确保编译出来的代码就是类型安全的;而interface{}是在运行时才进行类型判断,如果编写的代码在类型判断上有bug或缺漏,就会导致Go在运行过程中报错。
      • 性能:不同类型的数据在赋值给interface{}变量时,会有一个隐式的装箱操作,从interface{}取数据时也会有一个隐式的拆箱操作,而泛型就不存在装箱拆箱过程,没有额外的性能开销。

      编写一个函数,输入ab两个泛型参数,返回它们的和:

      // T的名字可以更改,改成K、V、MM之类的都可以,只是一般比较常用的是T
      // 这是一个不完整的错误例子
      func Sum(a, b T) T {
      	return a + b
      }

      大写字母T的名字叫类型形参(Type parameter),代表ab参数是泛型,可以接受多种类型,但具体可以接受哪些类型呢?在上面的定义中并没有给出这部分信息,要知道,并不是所有的类型都可以相加的,因此这里就引出了约束的概念,我们需要对T可以接受的类型范围作出约束:

      // 正确例子
      func Sum[T interface{ int | float64 }](a, b T) T {
      	return a + b
      }

      中括号[]之间的空间用于定义类型形参,支持定义一个或多个

      • T:类型形参的名字
      • interface{ int | float64 }:对T的类型约束(Type Constraint),必须是一个接口,约束T只可以是intfloat64

      为了简化写法,类型约束中的interface{}某些情况下是可以省略的,所以可以简写成:

      func Sum[T int | float64](a, b T) T {
      	return a + b
      }

      interface{}不能省略的一些情况:

      // 当接口中包含方法时,不能省略
      func Contains[T interface{ Equal() bool }](num T) {
      }

      可以定义多个类型形参:

      func Add[T int, E float64](a T, b E) E {
      	return E(a) + b
      }

      以上面的Sum泛型函数为例,完整的调用写法为:

      Sum[int](1, 2)
      Sum[float64](1.1, 2.2)

      []之间的内容称为类型实参(Type argument),是函数定义中的类型形参T的实际值,例如传int过去,那么T的实际值就是int

      类型形参确定为具体类型的过程称为实例化(Instantiations),可以简单理解为将函数定义中的T替换为具体类型:

      泛型函数实例化后,就可以像普通函数那样调用了。

      大多数时候,编译器都可以自动推导出该具体类型,无需我们主动告知,这个功能叫函数实参类型推导(Function argument type inference)。所以可以简写成:

      // 简写,跟调用普通函数一样的写法
      Sum(1, 2)
      Sum(1.1, 2.2)

      需要注意的是,在调用这个函数时,ab两个参数的类型必须一致,要么两个都是int,要么都是float64,不能一个是int一个是float64

      Sum(1, 2.3) // 编译会报错

      什么时候不能简写?

      // 当类型形参T仅用在返回值,没有用在函数参数列表时
      func Foo[T int | float64]() T {
      	return 1
      }
      Foo() // 报错:cannot infer T
      Foo[int]() // OK
      Foo[float64]() // OK

      Go 使用interface定义类型约束。我们知道,在引入泛型之前,interface中只可以声明一组未实现的方法,或者内嵌其它interface,例如:

      // 普通接口
      type Driver interface {
      	SetName(name string) (int, error)
      	GetName() string
      }
      
      // 内嵌接口
      type ReaderStringer interface {
          io.Reader
          fmt.Stringer
      }

      接口里的所有方法称之为方法集(Method set)

      引入泛型之后,interface里面可以声明的元素丰富了很多,可以是任何 Go 类型,除了方法、接口以外,还可以是基本类型,甚至struct结构体都可以,接口里的这些元素称为类型集(Type set)

      // 基本类型约束
      type MyInt interface {
      	int
      }
      
      // 结构体类型约束
      type Point interface {
      	struct{ X, Y int }
      }
      
      // 内嵌其它约束
      type MyNumber interface {
      	MyInt
      }
      
      // 联合(Unions)类型约束,不同类型元素之间是“或”的关系
      // 如果元素是一个接口,这个接口不能包含任何方法!
      type MyFloat interface {
      	float32 | float64
      }

      有了丰富的类型集支持,我们就可以更加方便的使用接口对类型形参T的类型作出约束,既可以约束为基本类型(intfloat32string…),也可以约束它必须实现一组方法,灵活性大大增加。

      因此前面的Sum函数还可以改写成:

      // 原始例子:
      // func Sum[T int | float64](a, b T) T {
      //	 return a + b
      // }
      
      type MyNumber interface {
      	int | float64
      }
      
      func Sum[T MyNumber](a, b T) T {
      	return a + b
      }

      Go 还允许我们使用复合类型字面量来定义约束。例如,我们可以定义一个约束,类型元素是一个具有特定结构的struct

      type Point interface {
      	struct{ X, Y int }
      }

      然而,需要注意的是,虽然我们可以编写受此类结构体类型约束的泛型函数,但在当前版本的 Go 中,函数无法访问结构体的字段,例如:

      func GetX[T Point](p T) int {
      	return p.X  // p.X undefined (type T has no field or method X)
      }

      我们知道,在Go中可以创建新的类型,例如:

      type MyString string

      MyString是一个新的类型,底层类型是string

      在类型约束中,有时候我们可能并不关心上层类型,只要底层类型符合要求就可以,这时候就可以使用类型近似符号:~

      // 创建新类型
      type MyString string
      
      // 定义类型约束
      type AnyStr interface {
      	~string
      }
      
      // 定义泛型函数
      func Foo[T AnyStr](param T) T {
      	return param
      }
      
      func main() {
      	var p1 string = "aaa"
      	var p2 MyString = "bbb"
      	Foo(p1)
      	Foo(p2) // 虽然p2是MyString类型,但也可以通过泛型函数的类型约束检查
      }

      需要注意的是,类型近似中的类型,必须是底层类型,而且不能是接口类型:

      type MyInt int
      
      type I0 interface {
      	~MyInt // 错误! MyInt不是底层类型, int才是
      	~error // 错误! error是接口
      }

      假设现在有一个IntSlice类型:

      type IntSlice []int
      
      var s1 IntSlice = []int{1, 2, 3} // 正常
      var s2 IntSlice = []string{"a", "b", "c"} // 报错,因为IntSlice底层类型是[]int,字符串无法赋值

      很显然,因为类型不一致,s2是无法赋值的,如果想要支持其它类型,需要定义新类型:

      type StringSlice []string
      type Float32Slice []float32
      type Float64Slice []float64
      // ...

      但是这样做的问题也显而易见,它们结构都是一样的,只是元素类型不同就需要重新定义这么多新类型,导致代码复杂度增加。

      这时候就可以用泛型类型来解决这个问题:

      // 只需定义一种新类型,就可以同时支持[]int/[]string/[]float32多种切片类型
      // 新类型的名字叫 MySlice[T]
      type MySlice[T int|string|float32] []T

      类型定义中带 类型形参 的类型,称之为泛型类型(Generic type)

      泛型切片的初始化:

      var s1 MySlice[int] = MySlice[int]{1, 2, 3}
      var s2 MySlice[string] = MySlice[string]{"a", "b", "c"}
      s3 := MySlice[string]{"a", "b", "c"} // 简写

      其它一些例子:

      // 泛型Map
      type MyMap[K int | string, V any] map[K]V
      
      var m1 MyMap[string, int] = MyMap[string, int]{"a": 1, "b": 2} // 完整写法
      m2 := MyMap[int, string]{1: "a", 2: "b"} // 简写
      
      // 泛型通道
      type MyChan[T int | float32] chan T
      
      var c1 MyChan[int] = make(MyChan[int]) // 完整写法
      c2 := make(MyChan[float32]) // 简写

      假设现在要创建一个struct结构体,里面含有一个data泛型属性,类型是一个intfloat64的切片:

      type List[T int | float64] struct {
      	data []T
      }

      给这个结构体增加一个Sum方法,用于对切片求和:

      func (l *List[T]) Sum() T {
      	var sum T
      	for _, v := range l.data {
      		sum += v
      	}
      	return sum
      }

      实例化结构体,并调用Sum方法:

      // var list *List[int] = &List[int]{data: []int{1, 2, 3}} // 完整写法
      list := &List[int]{data: []int{1, 2, 3}}
      sum := list.Sum()
      fmt.Println(sum) // 输出:6

      泛型也可以用在接口上:

      type Human[T float32] interface {
      	GetWeight() T
      }

      假设现在有两个结构体,它们都有GetWeight()方法,哪个结构体实现了上面Human[T]接口?

      // 结构体1
      type Person1 struct {
      	Name string
      }
      func (p Person1) GetWeight() float32 {
      	return 66.6
      }
      
      // 结构体2
      type Person2 struct {
      	Name string
      }
      func (p Person2) GetWeight() int {
      	return 66
      }

      注意观察两个GetWeight()方法的返回值类型,因为我们在Human[T]接口中约束了T的类型只能是float32,而只有Person1结构体的返回值类型符合约束,所以实际上只有Person1结构体实现了Human[T]接口。

      p1 := Person1{Name: "Tim"}
      var iface1 Human[float32] = p1 // 正常,因为Person1实现了接口,所以可以赋值成功
      
      p2 := Person2{Name: "Tim"}
      var iface2 Human[float32] = p2 // 报错,因为Person2没有实现接口

      下面列出一些错误使用泛型的例子。

      联合约束中的类型元素不能是包含方法的接口:

      // 错误
      type ReaderStringer interface {
      	io.Reader | fmt.Stringer // 错误,io.Reader和fmt.Stringer是包含方法的接口
      }
      
      // 正确
      type MyInt interface {
      	int
      }
      type MyFloat interface {
      	float32
      }
      type MyNumber interface {
      	MyInt | MyFloat // 正确,MyInt和MyFloat接口里面没有包含方法
      }

      联合约束中的类型元素不能含有comparable接口:

      type Number interface {
      	comparable | int // 含有comparable,报错
      }

      先解释下相关概念,引入泛型后,Go的接口分为两种类型:

      • 基本接口(Basic interface)
      • 只包含方法的接口,称为基本接口,其实就是引入泛型之前的那种传统接口。
      • 一般接口(General interface)
      • 由于引入泛型后,接口可以定义的元素大大丰富,如果一个接口里含有除了方法以外的元素,那么这个接口就称为一般接口

      一般接口只能用于泛型的类型约束,不能用于变量、函数参数、返回值的类型声明,而基本接口则没有此限制:

      type NoMethods interface {
      	int
      }
      
      // 错误,不能用于函数参数列表、返回值
      func Foo(param NoMethods) NoMethods {
      	return param
      }
      
      // 错误,不能用来声明变量的类型
      var param NoMethods
      
      // 正确
      func Foo[T NoMethods](param T) T {
      	return param
      }

      以上为个人经验,希望能给大家一个参考,也希望大家多多支持风君子博客。

      您可能感兴趣的文章:

      • Golang使用泛型对数组进行去重的实现
      • golang使用泛型结构体实现封装切片
      • golang1.21泛型函数全面讲解
      • 一文带你了解Golang中的泛型
      • 一文教你如何在Golang中用好泛型
      • Golang中的泛型你真的了解吗

      站内搜索