前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go 何时使用泛型

Go 何时使用泛型

作者头像
恋喵大鲤鱼
发布2023-01-07 09:44:09
5780
发布2023-01-07 09:44:09
举报
文章被收录于专栏:C/C++基础C/C++基础

文章目录

0.前言

Go 泛型设计者 Ian Lance Taylor 在官方博客发表了一篇文章 When To Use Generics,详细说明了在什么场景下应该使用泛型,什么场景下不要使用泛型。这对于我们编写 Go 泛型代码非常有指导意义。

本人在原文的基础上做了一些表述上的优化和内容扩充,帮助大家理解。

1.简介

这篇博客汇总了我在2021年Google开源活动日和GopherCon会议上关于泛型的分享。

Go 1.18版本新增了一个重大功能:支持泛型编程。本文不会介绍什么是泛型以及如何使用泛型,而是把重点放在讲解Go编程实践中,什么时候应该使用泛型,什么时候不要使用泛型。

需要明确的是,我将会提供一些通用的指引,这并不是硬性规定,大家可以根据自己的判断来决定,但是如果你不确定如何使用泛型,那建议参考本文介绍的指引。

2.写代码

Go 编程有一条通用准则:

write Go programs by writing code, not by defining types.

具体到泛型,如果你写代码的时候从定义类型参数约束(type parameter constraints)开始,那你可能搞错了方向。从编写函数开始,如果写的过程中发现使用类型参数更好,那再使用类型参数。

3.类型参数何时有用?

接下来我们看看在什么情况下,使用类型参数对我们写代码更有用。

使用内置容器类型

如果函数使用了语言内置的容器类型(包括 slice, map 和 channel)作为函数参数,并且函数代码对容器的处理逻辑并没有预设容器里的元素类型,那使用类型参数(type parameter)可能就会有用。

举个例子,我们要实现一个函数,该函数的入参是一个 map,要返回该 map 的所有key组成的slice,key的类型可以是map支持的任意key类型。

代码语言:javascript
复制
// MapKeys returns a slice of all the keys in m.
// The keys are not returned in any particular order.func 
MapKeys[Key comparable, Val any](m map[Key]Val) []Key {
    s := make([]Key, 0, len(m))
    for k := range m {
        s = append(s, k)
    }
    return s
}

这段代码没有对 map 里 key 的类型做任何限定,并且没有用 map 里的 value,因此这段代码适用于所有的 map 类型。这就是使用类型参数的一个很好的例子。

这种场景下,也可以使用反射(reflection),但是反射是一种比较别扭的编程模型,在编译期没法做静态类型检查,并且会导致运行期的速度变慢。

实现通用的数据结构

对于通用的数据结构,类型参数也会有用。通用的数据结构类似于 slice 和 map,但是并不是语言内置的数据结构,比如链表或者二叉树。

在没有泛型的时候,如果要实现通用的数据结构,有 2 种方案:

  • 方案 1: 针对每个元素类型分别实现一个数据结构。
  • 方案 2: 使用 interface 类型。

泛型相对方案1的优点是代码更精简,也更方便给其它模块调用。

泛型相对方案2的优点是数据存储更高效,节约内存资源,并且可以在编译期做静态类型检查,避免代码里使用类型断言。

下面的例子就是使用类型参数实现的通用二叉树数据结构:

代码语言:javascript
复制
// Tree is a binary tree.
type Tree[T any] struct {
    cmp  func(T, T) int
    root *node[T]
}

// A node in a Tree.
type node[T any] struct {
    left, right  *node[T]
    val          T
}

// find returns a pointer to the node containing val,
// or, if val is not present, a pointer to where it
// would be placed if added.
func (bt *Tree[T]) find(val T) **node[T] {
    pl := &bt.root
    for *pl != nil {
        switch cmp := bt.cmp(val, (*pl).val); {
        case cmp < 0:
            pl = &(*pl).left
        case cmp > 0:
            pl = &(*pl).right
        default:
            return pl
        }
    }
    return pl
}

// Insert inserts val into bt if not already there,
// and reports whether it was inserted.
func (bt *Tree[T]) Insert(val T) bool {
    pl := bt.find(val)
    if *pl != nil {
        return false
    }
    *pl = &node[T]{val: val}
    return true
}

二叉树的每个结点包含一个类型为T的变量 val。当二叉树实例化的时候,需要传入类型实参,这个时候 val 的类型已经确定下来了,不会被存为 interface 类型。

这种场景使用类型参数是合理的,因为 Tree 是个通用的数据结构,包括方法里的代码实现都和 T 的类型无关。

Tree 数据结构本身不需要知道如何比较二叉树节点上类型为T的变量 val 的大小,它有一个成员变量 cmp 来实现val大小的比较,cmp 是一个函数类型变量,在二叉树初始化的时候被指定。因此二叉树上结点值的大小比较是 Tree 外部的一个函数来实现的,你可以在 find 方法的第 4 行看到对 cmp 的使用。

类型参数优先使用在函数而不是方法上

上面的 Tree 数据结构示例阐述了另外一个通用准则:当你需要类似 cmp 的比较函数时,优先考虑使用函数而不是方法。

对于上面 Tree 类型,除了使用函数类型的成员变量 cmp 来比较 val 的大小之外,还有另外一种方案是要求类型 T 必须有一个 Compare 或者 Less 方法来做大小比较。要做到这一点,就需要定义一个类型约束(type constraint)用于限定类型 T 必须实现这个方法。

这造成的结果是即使 T 只是一个普通的 int 类型,那使用者也必须定义一个自己的 int 类型,实现类型约束里的方法(method),然后把这个自定义的 int 类型作为类型实参传给类型参数 T。但是如果我们参照上面 Tree 的代码实现,定义一个函数类型的成员变量 cmp 用来做 T 类型的大小比较,代码实现就比较简洁。

如果 Tree 元素类型恰好已经有一个 Compare 方法,那么我们可以直接使用 ElementType.Compare 作为比较函数。

换句话说,把方法转为函数比给一个类型增加方法容易得多。因此对于通用的数据类型,优先考虑使用函数,而不是写一个必须有方法的类型限制。

不同类型需要实现公用方法

类型参数另一个有用的场景是不同的类型要实现一些公用方法,并且对于这些方法,不同类型的实现逻辑是一样的。

下面举个例子,Go 标准库里有一个 sort 包,可以对存储不同数据类型的 slice 做排序,比如 Float64s(x) 可以对 []float64 做排序,Ints(x) 可以对 []int 做排序。

同时 sort 包还可以对用户自定义的数据类型(比如结构体、自定义的int类型等)调用 sort.Sort() 做排序,只要该类型实现了 sort.Interface 这个接口类型里 Len()、Less() 和 Swap() 这3个方法即可。

下面我们对 sort 包可以使用泛型来做一些改造,就可以对存储不同数据类型的 slice 统一调用 sort.Sort() 来做排序,而不用专门为 []int 调用 Ints(x),为 []float64 调用 Float64s(x)做差异化处理了,可以简化代码逻辑。

下面的代码实现了一个泛型的结构体类型 SliceFn,这个结构体类型实现了 sort.Interface。

代码语言:javascript
复制
// SliceFn implements sort.Interface for a slice of T.
type SliceFn[T any] struct {
    s    []T
    less func(T, T) bool
}

func (s SliceFn[T]) Len() int {
    return len(s.s)
}
func (s SliceFn[T]) Swap(i, j int) {
    s.s[i], s.s[j] = s.s[j], s.s[i]
}
func (s SliceFn[T]) Less(i, j int) bool {
    return s.less(s.s[i], s.s[j])
}

对于不同的 slice 类型, Len 和 Swap 方法的实现是一样的。Less 方法需要对 slice 里的 2 个元素做比较,比较逻辑实现在 SliceFn 里的成员变量 less 里头,less 是一个函数类型的变量,在结构体初始化的时候进行传参赋值。这点和上面 Tree 这个二叉树通用数据结构的处理类似。

我们再将 sort.Sort 按照泛型风格封装为 SortFn 泛型函数,这样对于所有 slice 类型,我们都可以统一调用 SortFn 做排序。

代码语言:javascript
复制
// SortFn sorts s in place using a comparison function.
func SortFn[T any](s []T, less func(T, T) bool) {
    sort.Sort(SliceFn[T]{s, less})
}

这和标准库里的 sort.Slice 很类似,只不过这里的less比较函数的参数是具体的值,而 sort.Slice 里比较函数 less 的参数是 slice 的下标索引。

这种场景使用类型参数比较合适,因为不同类型的 SliceFn 的方法实现逻辑都是一样的,只是 slice 里存储的元素的类型不一样而已。

4.类型参数何时不要用

现在我们谈谈类型参数不建议使用的场景。

不要把interface类型替换为类型参数

我们大家都知道Go语言有interface类型,interface支持某种意义上的泛型编程。

举个例子,被广泛使用的io.Reader接口提供了一种泛型机制用于读取数据,比如支持从文件和随机数生成器里读取数据。

如果你对某些类型的变量的操作只是调用该类型的方法,那就直接使用interface类型,不要使用类型参数。io.Reader从代码角度易于阅读且高效,没必要使用类型参数。 举个例子,有人可能会把下面第1个基于interface类型的ReadSome版本修改为第2个基于类型参数的版本。

代码语言:javascript
复制
func ReadSome(r io.Reader) ([]byte, error)

func ReadSome[T io.Reader](r T) ([]byte, error)

不要做这种修改,使用第1个基于interface的版本会让函数更容易编写和阅读,并且函数执行效率也几乎一样。

注意:尽管可以使用不同的方式来实现泛型,并且泛型的实现可能会随着时间的推移而发生变化,但是Go 1.18中泛型的实现在很多情况下对于类型为interface的变量和类型为类型参数的变量处理非常相似。这意味着使用类型参数通常并不会比使用interface快,所以不要单纯为了程序运行速度而把interface类型修改为类型参数,因为它可能并不会运行更快。

如果方法的实现不同,不要使用类型参数

当决定要用类型参数还是interface时,要考虑方法的逻辑实现。正如我们前面说的,如果方法的实现对于所有类型都一样,那就是用类型参数。相反,如果每个类型的方法实现是不同的,那就是用interface类型,不要用类型参数。

举个例子,从文件里 Read 的实现和从随机数生成器里 Read 的实现完全不一样,在这种场景下,可以定义一个 io.Reader 的 interface 类型,该类型包含有一个 Read 方法。文件和随机数生成器实现各自的 Read 方法。

在适当的时候可以使用反射

Go 有运行期反射。反射机制支持某种意义上的泛型编程,因为它允许你编写适用于任何类型的代码。如果某些操作需要支持以下场景,就可以考虑使用反射。

  • 操作没有方法的类型,interface 类型不适用。
  • 每个类型的操作逻辑不一样,泛型不适用。

一个例子是 encoding/json 包的实现。我们并不希望要求我们编码的每个类型都实现 MarshalJson 方法,因此我们不能使用 interface 类型。而且不同类型编码的逻辑不一样,因此我们不应该用泛型。 因此对于这种情况,encoding/json 使用了反射来实现。具体实现细节可以参考源码

5.一个简单原则

总结一下,何时使用泛型可以简化为如下一个简单原则。

如果你发现重复在写几乎完全一样的代码,唯一的区别是代码里使用的类型不一样,那就要考虑是否可以使用泛型来实现。

另一种说法是,应该避免使用类型参数,直到您注意到要多次编写除类型不同外完全相同的代码。


参考文献

An Introduction To Generics - The Go Programming Language When To Use Generics - The Go Programming Language Go泛型设计介绍 - 掘金

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2023-01-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 文章目录
  • 0.前言
  • 1.简介
  • 2.写代码
  • 3.类型参数何时有用?
    • 使用内置容器类型
      • 实现通用的数据结构
        • 类型参数优先使用在函数而不是方法上
          • 不同类型需要实现公用方法
          • 4.类型参数何时不要用
            • 不要把interface类型替换为类型参数
              • 如果方法的实现不同,不要使用类型参数
                • 在适当的时候可以使用反射
                • 5.一个简单原则
                • 参考文献
                相关产品与服务
                容器服务
                腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档