简单易懂的 Go 泛型使用和实现原理介绍
原文:A gentle introduction to generics in Go by Dominik Braun
万俊峰Kevin:我看了觉得文章非常简单易懂,就征求了作者同意,翻译出来给大家分享一下。
本文是对泛型的基本思想及其在 Go 中的实现的一个比较容易理解的介绍,同时也是对围绕泛型的各种性能讨论的简单总结。首先,我们来看看泛型所解决的核心问题。
问题
假设我们想实现一个简单的 tree
数据结构。每个节点持有一个值。在 Go 1.18 之前,实现这种结构的典型方法如下。
type Node struct {
value interface{}
}
这在大多数情况下都很好用,但它也有一些缺点。
首先,interface{}
可以是任何东西。如果我们想限制 value
可能持有的类型,例如整数和浮点数,我们只能在运行时检查这个限制。
func (n Node) IsValid() bool {
switch n.value.(type) {
case int, float32, float64:
return true
default:
return false
}
}
这样并不可能在编译时限制类型,像上面这样的类型判断在许多 Go 库中都是很常见的做法。这里有 go-zero 项目中的例子。
第二,对 Node 中的值进行处理是非常繁琐和容易出错的。对值做任何事情都会涉及到某种类型的断言,即使你可以安全地假设值持有一个 int
值。
number, ok := node.value.(int)
if !ok {
// ...
}
double := number * 2
这些只是使用 interface{}
的一些不便之处,它没有提供类型安全,并有可能导致难以恢复的运行时错误。
解决方法
我们不打算接受任意数据类型或具体类型,而是定义一个叫做 T
的 占位符类型
作为值的类型。请注意,这段代码还不会通过编译。
type Node[T] struct {
value T
}
首先需要声明泛型类型 T
,这是在结构或函数名称后面方括号里面使用的。
T
可以是任何类型,只有在实例化一个具有明确类型的 Node
时,T
才会被推导为该类型。
n := Node[int]{
value: 5,
}
泛型 Node
被实例化为 Node[int]
(整数节点),所以 T
是一个 int
。
类型约束
上面的实现里,T
的声明缺少一个必要的信息:类型约束。
类型约束用于进一步限制可以作为 T
的可能类型。Go 本身提供了一些预定义的类型约束,但也可以使用自定义的类型约束。
type Node[T any] struct {
value T
}
任意类型(any)约束允许 T
实际上是任何类型。如果节点值需要进行比较,有一个 comparable
类型约束,满足这个预定义约束的类型可以使用 ==
进行比较。
type Node[T comparable] struct {
value T
}
任何类型都可以作为一个类型约束。Go 1.18 引入了一种新的 interface
语法,可以嵌入其他数据类型。
type Numeric interface {
int | float32 | float64
}
这意味着一个接口不仅可以定义一组方法,还可以定义一组类型。使用 Numeric
接口作为类型约束,意味着值可以是整数或浮点数。
type Node[T Numeric] struct {
value T
}
重获类型安全
相对于使用 interface{}
,泛型类型参数的巨大优势在于,T
的最终类型在编译时就会被推导出来。为 T
定义一个类型约束,完全消除了运行时检查。如果用作 T
的类型不满足类型约束,代码就不会编译通过。
在编写泛型代码时,你可以像已经知道 T
的最终类型一样写代码。
func (n Node[T]) Value() T {
return n.value
}
上面的函数返回 n.Value
,它的类型是 T
。因此,返回值是 T
,如果 T
是一个整数,那么返回类型就已知是 int
。因此,返回值可以直接作为一个整数使用,不需要任何类型断言。
n := Node[int]{
value: 5,
}
double := n.Value() * 2
在编译时恢复类型安全使 Go 代码更可靠,更不容易出错。
泛型使用场景
在 Ian Lance Taylor
的 When To Use Generics 中列出了泛型的典型使用场景,归结为三种主要情况:
- 使用内置的容器类型,如
slices
、maps
和channels
- 实现通用的数据结构,如
linked list
或tree
- 编写一个函数,其实现对许多类型来说都是一样的,比如一个排序函数
一般来说,当你不想对你所操作的值的内容做出假设时,可以考虑使用泛型。我们例子中的 Node
并不太关心它持有的值。
当不同的类型有不同的实现时,泛型就不是一个好的选择。另外,不要把 Read(r io.Reader)
这样的接口函数签名改为 Read[T io.Reader](r T)
这样的通用签名。
性能
要了解泛型的性能及其在 Go 中的实现,首先需要了解一般情况下实现泛型的两种最常见方式。
这是对各种性能的深入研究和围绕它们进行的讨论的简要介绍。你大概率不太需要关心 Go 中泛型的性能。
虚拟方法表
在编译器中实现泛型的一种方法是使用 Virtual Method Table
。泛型函数被修改成只接受指针作为参数的方式。然后,这些值被分配到堆上,这些值的指针被传递给泛型函数。这样做是因为指针看起来总是一样的,不管它指向的是什么类型。
如果这些值是对象,而泛型函数需要调用这些对象的方法,它就不能再这样做了。该函数只有一个指向对象的指针,不知道它们的方法在哪里。因此,它需要一个可以查询方法的内存地址的表格:Virtual Method Table
。这种所谓的动态调度已经被 Go 和 Java 等语言中的接口所使用。
Virtual Method Table
不仅可以用来实现泛型,还可以用来实现其他类型的多态性。然而,推导这些指针和调用虚拟函数要比直接调用函数慢,而且使用 Virtual Method Table
会阻止编译器进行优化。
单态化
一个更简单的方法是单态化(Monomorphization
),编译器为每个被调用的数据类型生成一个泛型函数的副本。
func max[T Numeric](a, b T) T {
// ...
}
larger := max(3, 5)
由于上面显示的max函数是用两个整数调用的,编译器在对代码进行单态化时将为 int
生成一个 max
的副本。
func maxInt(a, b int) int {
// ...
}
larger := maxInt(3, 5)
最大的优势是,Monomorphization
带来的运行时性能明显好于使用 Virtual Method Table
。直接方法调用不仅更有效率,而且还能适用整个编译器的优化链。不过,这样做的代价是编译时长,为所有相关类型生成泛型函数的副本是非常耗时的。
Go 的实现
这两种方法中哪一种最适合 Go?快速编译很重要,但运行时性能也很重要。为了满足这些要求,Go 团队决定在实现泛型时混合两种方法。
Go 使用 Monomorphization
,但试图减少需要生成的函数副本的数量。它不是为每个类型创建一个副本,而是为内存中的每个布局生成一个副本:int
、float64
、Node
和其他所谓的 "值类型"
在内存中看起来都不一样,因此泛型函数将为所有这些类型复制副本。
与值类型相反,指针和接口在内存中总是有相同的布局。编译器将为指针和接口的调用生成一个泛型函数的副本。就像 Virtual Method Table
一样,泛型函数接收指针,因此需要一个表来动态地查找方法地址。在 Go 实现中的字典与虚拟方法表的性能特点相同。
结论
这种混合方法的好处是,你在使用值类型的调用中获得了 Monomorphization
的性能优势,而只在使用指针或接口的调用中付出了 Virtual Method Table
的成本。
在性能讨论中经常被忽略的是,所有这些好处和成本只涉及到函数的调用。通常情况下,大部分的执行时间是在函数内部使用的。调用方法的性能开销可能不会成为性能瓶颈,即使是这样,也要考虑先优化函数实现,再考虑调用开销。
更多阅读
Vicent Marti: Generics can make your Go code slower (PlanetScale)
Andy Arthur: Generics and Value Types in Golang (Dolthub)
对标准库的影响
作为 Go 1.18 的一部分,不改变标准库
是一个谨慎的决定。目前的计划是收集泛型的经验,学习如何适当地使用它们,并在标准库中找出合理的用例。
Go 有一些关于通用包、函数和数据结构的提议:
constraints
, providing type constraints (#47319)maps
, providing generic map functions (#47330)slices
, providing generic slice functions (#47203)sort.SliceOf
, a generic sort implementation (#47619)sync.PoolOf
and other generic concurrent data structures (#47657)
关于 go-zero 泛型的计划
对 go-zero 支持用泛型改写,我们持谨慎态度,因为一旦使用泛型,那么 Go 版本必须从 1.15 升级到 1.18,很多用户的线上服务现在还未升级到最新版,所以 go-zero 的泛型改写会延后 Go 两三个版本,确保用户线上服务大部分已经升级到 Go 1.18
go-zero
也在对泛型做充分的调研和尝试。
其中的 mr
包已经新开仓库支持了泛型:
https://github.com/kevwan/mapreduce
其中的 fx
包也已新开仓库尝试支持泛型,但是由于缺少 template method
,未能完成,期待后续 Go 泛型的完善
https://github.com/kevwan/stream
当后续 go-zero
支持泛型的时候,我们就会合入这些已经充分测试的泛型实现。
项目地址
https://github.com/zeromicro/go-zero
https://gitee.com/kevwan/go-zero
欢迎使用 go-zero
并 star 支持我们!

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
-
上一篇
Tails 5.0 发布,隐私性极高的 Linux 发行版
Tails 开发团队发布了 Tails 5.0 的正式版本,它基于最新的 Debian GNU/Linux 11 "Bullseye" 操作系统,带来了很多 Tails 中包含的软件的新版本以及新的 OpenPGP 工具。 Tails 5.0 正式发布,更新内容如下: 新功能 新版本增加了 Kleopatra,以取代 OpenPGP Applet 和 Password and Keys 工具。OpenPGP Applet 已经不再被积极开发,因此在 Tails 中继续保留它是很复杂的。Password and Keys 工具也没有得到很好的维护,Tails 用户还面临着它所带来的太多问题。 Kleopatra 在一个单一的工具中提供了同等的功能,而且开发得更加积极。 变化和更新 持久性存储的附加软件功能被默认启用,以使其更快、更好地配置你的第一个附加软件包。 你现在可以使用活动概览来访问你的窗口和应用程序。 包含的软件 大多数包含的软件在 Debian 11 中已经升级了,例如: 将 Tor Browser 升级到 11.0.11。 将 GNOME 从 3.30 升级到 3.38,对桌...
-
下一篇
声明式 HTTP 框架 Forest v1.5.20 发布,支持Java 17!
Forest介绍 Forest 是一个开源的 Java HTTP 客户端框架,它能够将 HTTP 的所有请求信息(包括 URL、Header 以及 Body 等信息)绑定到您自定义的 Interface 方法上,能够通过调用本地接口方法的方式发送 HTTP 请求 在Gitee上现已超过 2.3k star Forest 如何使用 Forest 不需要您编写具体的 HTTP 调用过程,只需要您定义一个接口,然后通过 Forest 注解将 HTTP 请求的信息添加到接口的方法上即可。请求发送方通过调用您定义的接口便能自动发送请求和接受请求的响应 Forest 的工作原理 Forest 会将您定义好的接口通过动态代理的方式生成一个具体的实现类,然后组织、验证 HTTP 请求信息,绑定动态数据,转换数据形式,SSL 验证签名,调用后端 HTTP API(httpclient 等 API)执行实际请求,等待响应,失败重试,转换响应数据到 Java 类型等脏活累活都由这动态代理的实现类给包了。 请求发送方调用这个接口时,实际上就是在调用这个干脏活累活的实现类 支持JDK 17 JDK 17这个坑已...
相关文章
文章评论
共有0条评论来说两句吧...