Golang 泛型初识

15,733 阅读20分钟

本文主要是介绍Golang泛型的基本要素,泛型的一些通用代码的实践。

分为以下四个部分

  1. 简介
  2. 泛型的基本元素
  3. 泛型实践
  4. 总结

简介

泛型是什么?

泛型编程是一种计算机编程风格,编程范式,其中算法是根据稍后指定的类型编写的,然后在需要时为作为参数提供的特定类型实例化。常用的的编程语言也基本都支持泛型这一特性,例如C++、C#、Java、Python、Rust、Swift、TypeScript、kotlin等。泛型有以下特点:

  • 类型的参数化(parameterized type):把类型当做函数参数传递。
  • 更强的类型检查:泛型使编译器可以在编译期间对类型进行检查以提高类型安全,减少运行时由于对象类型不匹配引发的异常。
  • 代码节省与抽象呈现

Golang 中的泛型

research.swtch.com/generic

普遍的困境是这样的:你想要慢的程序员、慢的编译器和臃肿的二进制文件,还是慢的执行时间?——拉斯考克斯(2009 年)

f005b77e-979d-4085-9dd8-4d1ad8102ffc.gif

网络梗图:手动泛型

很长一段时间以来,Go 都没有泛型功能,参考为什么 Go 语言没有泛型 - 面向信仰编程一文,文中讨论到Golang没有支持泛型的原因有两个:

  • 泛型困境使开发者必须在开发效率、编译速度和运行速度三者中选择两个
  • 目前社区中的 Go 语言方案都是有缺陷的,而 Go 团队认为泛型的支持不够紧急

本文认为还有一点原因是:

  • 泛型会使得Golang变得更加复杂,影响深远。在Go 1.18中只是提供了泛型特性,很多系统库实现并没有转换为泛型风格,泛型相关均在 Golang.org/x/exp库。

然而在 2020 年年度 Go 开发者调查中,26% 的受访者表示 Go 缺乏他们需要的语言特性,88% 的受访者选择泛型作为关键缺失的特性。

结果来自: Go.dev/blog/survey…

image.png

然后在 2021 年 1 月 13 日,Ian Lance Taylor 和 Robert Greseimer(均为 Go 团队成员)提议使用类型参数将泛型添加到 Go 中。不过,它并不是突然出现的,之前提出的很多设计都经过讨论,最终都进入了这个提案。

github.com/Golang/Go/i…

有关泛型的提案 spec: add generic programming using type parameters #43651 已经被 Go 团队接受,并在 2022 年 3 月 15 日发布Go 1.18 ,此版本Go语言发生了重大变化,包括泛型。我们可以基于此特性实现一些有趣的功能,也能扩展代码的抽象能力、减少部分重复的代码。

Go官网博客有两篇关于泛型的文章、介绍泛型以及何时使用泛型,可作为初步了解Golang泛型材料

与其他语言泛型对比

Go泛型没有太多包袱,更多是偏向设计,而Java、C++ 等老牌语言因兼容或其他原因,慢步迭代过来,这里对比下Go的泛型和老牌语言泛型在语法、类型约束、实现原理等方面的差异:

语法

  • Go
// Go 泛型泛型语法为中括号
func Print[T any](t T) {
    fmt.Printf("printing type: %T\n", t)
}
  • Java
public static <T> void print(T t) {
    System.out.println("printing type: " + t.getClass().getName());
}

类型约束

类型约束在Java中支持Bounds(有界与多重有界)即:

  • :是指 “上界通配符(Upper Bounds Wildcards)”
  • :是指 “下界通配符(Lower Bounds Wildcards)”

在Go中只包含了对类型或方法的限制。

  • Go
// 方法限制
type Stringer interface {
   String() string
}
// 类型集合
type Types interface {
   ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 |
      ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64
}

// 限定为 Types
func  Sub[T Types](t1, t2 T) T {
   return t1 - t2
}
  • Java

public class Main{
    // 泛型限定 必须是Collection 类型的子类才可以被接收
    public static <T extends Collection> void print(T t){
        System.out.println(t);
    }

    public static void main(String[] args){
        print(Arrays.asList(1,2,3));
    }
}

实现原理

Tips:

单态化是针对我们要处理的不同类型的数据,多次复制代码。单态化通常比基于继承的多态代码更快,但代价是编译时间和二进制大小。事实上,单态确保零开销调用,而基于继承的多态需要通过虚拟调度表使用间接指针。此外,编译器可以专门优化和/或内联单态化代码。

  • Go

参考泛型实现,Go泛型实现的一个关键特征是仅部分使用单态化,在 Go 中,单态化只是部分应用了一种称为“GCShape stenciling with Dictionaries”的技术。这样做的主要效果是指针类型或接口的所有参数都被视为属于相同的底层类型,这意味着只生成该函数的一个单态版本。将此与采用算术参数的函数进行对比,例如int32and float64,每个函数都有自己的专用函数版本。

  • Java

大多数关于 Java 泛型的抱怨都集中在类型擦除上。此设计没有类型擦除。泛型类型的反射信息将包括完整的编译时类型信息。

在 Java 类型通配符 ( List<? extends Number>, List<? super Number>) 中实现协变和逆变。Go 中缺少这些概念,这使得泛型类型变得更加简单。

  • C++

C++ 模板不对类型参数实施任何约束(除非采用概念提案)。这意味着更改模板代码可能会意外破坏遥远的实例化。这也意味着错误消息仅在实例化时报告,并且可能嵌套很深,难以理解。这种设计通过强制和显式约束避免了这些问题。

C++ 支持模板元编程,可以将其视为在编译时使用与非模板 C++ 完全不同的语法完成的普通编程。这种设计没有类似的特点。这节省了相当多的复杂性,同时损失了一些功率和运行时间效率。

C++ 使用两阶段名称查找,其中有些名称在模板定义的上下文中查找,有些名称在模板实例化的上下文中查找。在这个设计中,所有的名字都是在他们被写的地方查找的。

实际上,所有 C++ 编译器都会在实例化每个模板时对其进行编译。这会减慢编译时间。这种设计为如何处理泛型函数的编译提供了灵活性。

基本元素

类型参数(Type Parameters)

通用代码是使用开发者称为类型参数的抽象数据类型编写的。调用泛型方法时,类型参数将替换为类型参数。

  • 定义了额外的可选参数列表来描述类型参数
  • 类型参数和非类型参数应当一起列出

看一个简单的例子:

UML 图.jpg

类型参数列表出现在常规参数之前。为了区分类型参数列表和常规参数列表,类型参数列表使用方括号而不是圆括号。正如常规参数具有类型一样,类型参数也具有元类型,也称为约束。

func Print[T any](s []T) {
   for _, v := range s {
      fmt.Println(v)
   }
}

调用泛型方法时:

Print [ int ]([] int { 1 , 2 , 3 })
// 将会输出
// 1
// 2
// 3

这里有一个小细节,Go中类型参数列表使用的是中括号的语法进行标识,而Java、C++、Rust等大多数语言中,使用F<T> 来标识泛型,Go为什么这样设计呢?

来看这个场景:

a, b = w < x, y > (z)

这段代码中,如果没有类型信息,则不能区分右值是一对表达式w < x , y > (z),还是返回两个结果值的函数调用,而在此种情况下,Go希望在没有类型信息的情况下也能进行正确的解析,那尖括号则满足不了这个条件。

Go官方也考虑过F(T)的语法来标识泛型,且早期使用该语法,是可行的,但是此语法引入了一些解析歧义。例如:

var f func(x(T))

此场景有两种理解:

  1. 单个未命名参数的函数
  2. 以类型x(T)命名参数的函数

这也会给理解上带来一定的困难,也被否定掉了。

官方还考虑过F<<T>> 但是由于此符号不在ASCII中,否定。

Tips:

在当前Go版本的实现中,接口值持有实例的指针,将非指针的值传递给一个声明为interface{}类型的形参,会有一个装箱的操作,即在内存中,实例的内容在堆栈上,而接口值则是指向实例位置的指针。

但是需要注意的是,在泛型中,泛型类型的值不会被装箱。

约束(Constraints)

通常,所有泛型代码都希望类型参数满足某些要求。这些要求被称为约束

看一个例子:

 // 这个方法是无效的
// any 约束并没有任何可实现的操作(方法)
func Stringify[T any](s []T) (ret []string) {
   for _, v := range s {
      ret = append(ret, v.String()) // 编译错误
}
   return ret
}

在此例中:

any约束允许任何类型作为类型参数,并且只允许函数使用任何类型所允许的操作。其接口类型是空接口:interface{}s切片元素类型为T ,并且Tany类型的,意味着T类型的实例并没有强制要求实现String()方法,即上面的代码将编译失败。

所以需要开发者使用合适的约束作用于Stringify,对于调用者传递类型参数和泛型函数中的代码设置限制。调用者只能传递满足约束的类型参数。通用函数只能以约束允许的方式使用这些值。这是一条重要的规则,即:泛型代码只能使用其类型参数已知可实现的操作。

Go 已经有一个接近于我们需要的约束的构造:接口类型。接口类型是一组方法。唯一可以分配给接口类型变量的值是那些已经实现了全部接口所定义方法的实例。除了对任何类型允许的操作之外,接口变量唯一能做的操作是调用接口定义的方法。使用类型参数调用泛型函数类似于分配给接口类型的变量:类型参数必须实现类型参数的约束。编写泛型函数就像使用接口类型的值:泛型代码只能使用约束允许的操作(或任何类型允许的操作)。

对于上述编译会失败的代码,现在定义一个约束,使得Stringify方法能够正常编译通过,并且能够正常调用。

  • 约束定义
type Stringer interface {
   String() string
}
  • 约束使用
func Stringify[T Stringer](s []T) (ret []string) {
   for _, v := range s {
      ret = append(ret, v.String())
   }
   return ret
}
  • 支持多个类型参数和约束

type Stringer interface {
   String() string
}

type Plusser interface {
   Plus(string) string
}

func ConcatTo[S Stringer, P Plusser](s []S, p []P) []string {
   r := make([]string, len(s))
   for i, v := range s {
      r[i] = p[i].Plus(v.String())
   }
   return r
}

类型集(Type Sets)

类型集是在Go1.18 扩展的一个概念,不仅仅应用于泛型。在之前版本的Go中interface{}可以定义了一组方法。

图片来自:go.dev/blog/intro-…

在Go1.18中 可以将接口看做接口定义了一组类型,即实现这些方法的类型。从这个角度来看,作为接口类型集元素的任何类型都实现了该接口。

图片来自:go.dev/blog/intro-…

也可以理解为Go在接口定义中增加了一层抽象去管理不同的方法集,这样可以更加容易组合不同的类型,使得抽象的操作更加简便。

约束元素

任意类型约束元素

允许列出任何类型,而不仅仅是接口类型。例:

// 其中 int 为基础类型
type Integer  interface { int } 

近似约束元素

在日常coding中,可能会有很多的类型别名,例如:

type Phone string
type Email string
type Address string
...

此时想对这些类型提供一些通用的处理函数,比如脱敏,这是需要每个类型都去实现一遍方法吗?并不需要,Go1.18 中扩展了近似约束元素(Approximation constraint element)这个概念,以上述例子来说,即:基础类型为string的类型。语法表现为:

type AnyString interface{ ~string }

此处的AnyString类型即可表示上述的 Phone | Email | Address,对于基础类型为string提供一个通用的脱敏函数code如下:

func Desensitization[T AnyString] (str T) string{
   var newStr string
   // Desensitization logic
 // newStr = desensitizationFunc(str)
 return newStr
}

联合约束元素

联合元素,写成一系列由竖线 ( |) 分隔的约束元素。例如:int | float32~int8 | ~int16 | ~int32 | ~int64。并集元素的类型集是序列中每个元素的类型集的并集。联合中列出的元素必须全部不同。这里给所有有符号的数字类型添加一个通用的求和方法coding如下:


type SignedInteger interface {
   ~int | ~int8 | ~int16 | ~int32 | ~int64
}

func SumOfSignedInteger[T SignedInteger](integers []SignedInteger) SignedInteger {
   sum := 0
   for i := range integers {
      sum += i
   }
   return sum
}

只能使用确定的类型进行联合类型约束

 // GOOD
func PrintInt64OrFloat64[T int64|float64](t T) {
   fmt.Printf( "%v\n" , t)
}

type someStruct struct {}

// GOOD
func PrintInt64OrSomeStruct[T int64|*someStruct](t T) {
   fmt.Printf( "t: %v\n" , t)
}

// BAD,不能在联合类型中使用 ,且不能通过编译
func handle[T io.Closer | Flusher](t T) {
   err := t.Flush()
   if err != nil {
      fmt.Println( "failed to flush: " , err.Error())
   }

   err = t.Close()
   if err != nil {
      fmt.Println( "failed to close: " , err.Error())
   }
}

type Flusher interface {
   Flush() error
}

约束中的可比类型

Go1.18 中内置了一个类型约束 comparable约束,comparable约束的类型集是所有可比较类型的集合。这允许使用该类型参数==!=值。

func Index[T comparable](s []T, x T) int {
   for i, v := range s {
      if v == x {
         return i
      }
   }
   return -1
}

也可以comparable 内嵌到其他接口类型中使用:

type ComparableHasher interface {
   comparable
   Hash() uintptr
}

类型推断

在许多情况下,可以使用类型推断来避免必须显式写出部分或全部类型参数。可以对函数调用使用的参数类型推断从非类型参数的类型中推断出类型参数。开发者可以使用约束类型推断从已知类型参数中推断出未知类型参数。

类型参数小节中调用Print函数时声明了泛型函数调用的实际类型参数为int,但因为有类型推断这一特性,开发者可以更加简洁的使用泛型。例:

func Map[F, T any](s []F, f func(F) T) []T { ... }

func E (){
   var s []int
   f := func(i int) int64 { return int64(i) }
   var r []int64
   // 标注两个类型
r = Map[int, int64](s, f)
   // 只指定第一个类型参数
r = Map[int](s, f)
   // 不指定任何类型参数,并让两者都被推断。
r = Map(s, f)
}

Tips:

如果在没有指定所有类型参数的情况下使用泛型函数或类型,则如果无法推断出任何未指定的类型参数,则会出现错误。

(注意:类型推断是一个方便的特性。虽然它是一个重要特性,但它并没有给设计增加任何功能,只是方便使用它。在最初的实现中可以省略它,看看是否它似乎是必需的。也就是说,此功能不需要额外的语法,并且生成更具可读性的代码。)

泛型实践

泛型函数式应用

Tips:

在函数式编程语言中,📖高阶函数 (Higher-order function)是一个重要的特性。高阶函数是至少满足下列一个条件的函数:

  • 接受一个或多个函数作为输入
  • 输出一个函数

在Go中支持闭包的特性,所以很容易实现高阶函数:

func foo(bar func() string) func() string {
   return  func() string {
      return  "foo" + " " + bar()
   }
}

func main() {
   bar := func() string {
      return  "bar"
}
   foobar := foo(bar)
   fmt.Println(foobar())
   // foo bar
}

Tips:

Go有泛型这一特性,结合函数在Go中是一等公民,可以写出一些常见的、类型间通用的函数,能应对一些类型组合操作,提高代码的可读性,增加可维护性。以下是几个常用的高阶函数:

filter 操作是高阶函数的经典应用,它接受一个函数 f(func (T) bool)和一个线性表 l([]T),对 l 中的每个元素应用函数f,如结果为 true,则将该元素加入新的线性表里,否则丢弃该元素,最后返回新的线性表。(借用下Java中Steam的图示,类似的道理)

而Go的泛型语法为这种通用代码提供了很好的抽象,可以很容易写出一个简单的filter函数

func Filter[T any](f func(T) bool, src []T) []T {
   var dst []T
   for _, v := range src {
      if f(v) {
         dst = append(dst, v)
      }
   }
   return dst
}

// 使用如下
func main() {
   src := []int{-2, -1, -0, 1, 2}
   // 过滤出大于等于0的元素
   dst := Filter(func(v int) bool { return v >= 0 }, src)
   fmt.Println(dst)
}
// Output:
// [0 1 2]

同为高阶函数的还有MapMap接受一个函数 f(func (T1) T2)和一个线性表 l1([]T1),对 l1 中的每个元素应用函数 f,返回的结果组成新的线性表 l2([]T2)。Map

一般长用于类型转换或者属性选择,可用作常见Obj的转换(DO、VO、PO、DTO等):

func Map[S, T any](src []S, f func(S) T) []T {
   dst := make([]T, len(src))
   for i, v := range src {
      dst[i] = f(v)
   }
   return dst
}

func  main () { type User struct {
      Name string
      Age  int
   }
   users := []User{
      {Name: "John", Age: 20},
      {Name: "Mary", Age: 30},
      {Name: "Bob", Age: 40},
      {Name: "Alice", Age: 50},
      {Name: "Tom", Age: 60},
      {Name: "Jack", Age: 70},
   }
   // 属性选择
   names := Map(users, func(u User) string { return u.Name })
   fmt.Println(names)
   // [John Mary Bob Alice Tom Jack]
   ages := Map(users, func(u User) int { return u.Age })
   fmt.Println(ages)
   // [20 30 40 50 60 70]

    // 类型转换
   ints := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
   strs := Map(ints, func(i int) string { return strconv.FormatInt(int64(i), 10) })
   fmt.Println(strs)
   // [1 2 3 4 5 6 7 8 9 10]
}

最后一个比较常见的是Reduce 接受一个函数f func(T, T) T和一个线性表 l1([]T1),将线性表中的每个元素执行函数f,并将先前元素的计算结果作为参数传入,最后将其结果汇总为单个返回值:

func Reduce[T any](f func(T, T) T, src []T) T {
   if len(src) == 1 {
      return src[0]
   }
   return f(src[0], Reduce(f, src[1:]))
}

func main() {
   ints := []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
   res := Reduce(func(a, b int64) int64 {
      return a + b
   }, ints)
   fmt.Println(res)
   // 55
}

指针方法

Tips:

在一些项目的实体定义中,某些实体可能带有一些修改自身属性的方法,这种方法带有写的语义,在Go中,这种方法会声明接受者为接收类型的指针。

那如何对这类实体应用泛型的相关特性呢?,这里有一个简单的小例子:

 // Setter 是一个类型约束
// 实现一个从字符串中设置值的 Set 方法。
type Setter interface {
   Set(string)
}

// FromStrings 接受一个字符串切片并返回一个 T 切片,
// 调用 Set 方法来设置每个返回值。
//
// 注意因为 T 只用于结果参数,
// 调用时函数参数类型推断不起作用
func FromStrings[T Setter](s []string) []T {
   result := make([]T, len(s))
   for i, v := range s {
      result[i].Set(v)
   }
   return result
}

构建一个调用的例子

 // 定义一个可设置的 int
type Settable int

// 从字符串中设置 *p 的值
func (p *Settable) Set(s string) {
   // 生产场景代码不应该忽略错误
i, _ := strconv.Atoi(s)
   *p = Settable(i)
}

func F() {
   nums := FromStrings[Settable]([]string{ "1" , "2" })
}

这里的目标是使用FromStrings函数获得一个切片,但是此处会编译错误,问题是FromStrings需要一个有Set(string)方法的类型。函数F试图用转换返回类型为Settable,但Settable没有Set方法。有Set方法的类型是*Settable,那在调用时将返回类型改变为*Settable

func F() {
   nums := FromStrings[*Settable]([]string{ "1" , "2" })
}

当前可编译,但是运行时会panic,问题是FromStrings创建了一个 type 切片[]T。当用 实例化时*Settable,这意味着一个类型的切片[]*SettableFromStrings调用时result[i].Set(v),即调用Set存储在result[i]. 那个指针是nil。该Settable.Set方法将由nil接收者调用,并由于nil取消引用错误而引发恐慌。

指针类型*Settable实现了约束,但代码确实想使用非指针类型Settable。我们需要的是一种编写方法FromStrings,它可以将类型Settable作为参数但调用指针方法。重复一遍,我们不能使用Settable,因为它没有Set方法,我们不能使用*Settable,因为不能创建 type 的切片Settable

这里可以传递这个两种类型实现如下:

type Setter2[B any] interface {
   Set(string)
   *B // non-interface type constraint element
}

func FromStrings2[T any, PT Setter2[T]](s []string) []T {
   result := make([]T, len(s))
   for i, v := range s {
      p := PT(&result[i])
      p.Set(v)
   }
   return result
}
func F() {
   nums := FromStrings2[Settable, *Settable]([]string{ "1" , "2" })
   // 现在 nums 是 []Settable{1, 2}。
   // 也可以使用类型推断  会简单点
   nums =  FromStrings2[Settable]([]string{"1", "2"})
}

即可编译,运行成功。

泛型零值

Tips:

Go中现有泛型设计对于类型参数的零值并不好表达,Go官方目前没有更好的办法,但是提供了一些目前可行的一些方案:

  • 对于目前泛型的设计:

    • 可用 var zero T,但是这里需要额外去声明下。
    • 使用*new(T)
    • 对于返回结果可命名结果参数,并使用裸return返回零值。
  • 扩展设计:

    • 设计以允许nil用作任何泛型类型的零值(但请参阅issue 22729)。
    • 设计以允许使用T{}(其中T是类型参数)来指示类型的零值。
    • 更改语言以允许return ...返回结果类型的零值,如issue 21182中所建议的那样。

但目前来说一般使用 var zero T 的方式。

以下我们有一个队列,使用泛型的chan实现,对这个结构体有些方法,最简单的出队入队方法,但是对于泛型类型的变量,需要考虑零值的问题:


// 有一个对象,包含一个管道属性,可以调用此对象方法压入或弹出数据
type Queue[T any] struct {
   data chan T
}

// 构建新的队列
func NewQueue[T any](size int) Queue[T] {
   return Queue[T]{
      data: make(chan T, size),
   }
}

// 压入数据
func (q Queue[T]) Push(val T) {
   q.data <- val
}

// 弹出数据 ,如果没有数据会被阻塞
func (q Queue[T]) Pop() T {
   d := <-q.data
   return d
}

func (q Queue[T]) TryPop() (T, bool) {
   select {
   case val := <-q.data:
      return val, true
 default:
   // 编译报错
      return nil, false
}
}

// 在该代码中,T可以是任何值,包括可能不为nil的值。
// 我们可以利用var语句来解决这个问题,它生成一个新变量,并将其初始化为该类型的零值:
func Zero[T any]() T {
  var zero T
  return zero
}

// 根据这一特性,可以改写TryPop方法
func (q Queue[T]) TryPop() (T, bool) {
  select {
  case val := <-q.data:
    return val, true
  default:
  // 可编译通过
    var zero T
    return zero, false
  }
}

总结

泛型是一个很大的语言特性,但目前在Go中还没有太多的实践,官方也没有提供太多示例,但是可以通过加深对泛型中的基本元素的认知,了解其设计思想,结合编程范式、设计模式,相信会在工程实践中真正的提高编码效率。

参考文献

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

官网投递:job.toutiao.com/s/FyL7DRg