10 分钟了解 Golang 泛型
泛型是 Golang 在 1.18 版本引入的强大工具,能够帮助我们在合适的场合实现简洁、可读、可维护的代码。原文: Go Generics: Everything You Need To Know
导言
可能有人会觉得 Go 泛型很难,因此想要借鉴其他语言(比如 Java、NodeJS)的泛型实践。事实上 Go 泛型很容易学,本文希望能帮助读者更好的理解 Go 泛型。
👉注:本文不会将 Go 泛型与其他语言的泛型实现进行比较,但会帮助你理解 Go 泛型元素背后的上下文、结构及其原理。
前置条件
要编写本文中的示例代码,需要:
在计算机上安装
Go 1.18+
对 Golang 结构、类型、函数和方法有最低限度的了解
概述
在 2020 年之前,Go 泛型既是风险也是机遇。
当 Go 泛型在 2009 年左右被首次提出时(当时该编程语言已经公开),该特性是 Go 语言的主要弱点之一(Go 团队调查发现)。
此后,Go 团队在 Go 草案设计中接受了许多泛型实现,并在 Go 1.18 版本中首次引入了泛型。
Go 2020 调查显示,自 Go 语言诞生以来,Go 社区一直要求引入泛型功能。
Go 开发人员(以及 Go 团队成员)看到这一缺陷阻碍了 Go 语言的发展,同时,如果得到修复,Go 将具有更大的灵活性和性能。
什么是程序设计中的泛型?
根据维基百科的解释,泛型编程是一种计算机编程风格,在这种编程风格中,算法的具体类型可以在以后指定。
简单解释一下:泛型是一种可以与多种类型结合使用的类型,泛型函数是一种可以与多种类型结合使用的函数。
☝️ 简单提一下:尽管"泛型"在过去和现在都可以通过
interface{}
、反射包或代码生成器在 Go 中实现,但还是要提一下在使用这三种方法之前需要仔细考虑。
为了帮助我们以实用的方式理解和学习 Go 泛型,我们将在本文稍后部分提供示例代码。
但要知道,既然 Go 泛型已经可用,就可以消除模板代码,不必担心向后兼容问题,同时还能编写可重用、类型安全和可维护的代码。
那么......为什么需要 Go 泛型?
简而言之,最多可提高 20% 性能。
根据 Go 博客的描述,Go 泛型为 Go 语言增加了三个主要组件:
函数和类型的类型参数。
将接口类型定义为类型集,包括没有方法的类型。
类型推导,允许在调用函数时省略类型参数。
在 Go 1.18 之前没有这种功能吗?
从技术上讲,早在 Go 泛型发布之前,Go 就有一些处理"泛型"的方法:
使用"泛型"代码生成器生成 Go 软件包,如 https://github.com/cheekybits/genny
使用带有
switch
语句和类型转换的接口使用带有参数验证的反射软件包
然而,与正式的 Go 泛型相比,这些方法还远远不够,有如下缺点:
使用类型
switch
和转换时性能较低类型安全损耗:接口和反射不是类型安全的,这意味着代码可能会传递任何类型,而这些类型在编译过程中会被忽略,从而在运行时引起
panic
。Go 项目构建更复杂,编译时间更长
可能需要对调用代码和函数代码进行类型断言
缺乏对自定义派生类型的支持
代码可读性差(使用反射时更明显)
👉注:上述观点并不意味着在 Go 编程中使用接口或反射包不好;它们还有其他用途,应该在合适的场景下应用。
巧合的是,上述几点 ☝️ 使 Go 泛型适合处理目前在 Go 中的泛型实现,因为:
类型安全 (运行时不会丢失类型,也不需要类型验证、切换或转换)
高性能
Go IDE 的支持
向后兼容 (使用 Go 1.18+ 重构后,旧版代码仍可运行)
对自定义数据类型的高度支持
入门:使用 Go 泛型
在开始重构之前,我们借助一个迷你 Go 程序来了解 Go 泛型使用的一些术语和逻辑。
作为实操案例,我们将首先在不使用 Go 泛型的情况下解决 Leetcode 问题。然后,随着我们对这一主题的了解加深,我们将使用 Go 泛型对其进行重构。
Leetcode 问题
有几家公司在技术面试时都问过这个问题,我们对措辞稍作改动,但逻辑不变。Leetcode 链接为:https://leetcode.com/problems/contains-duplicate。
📌问题:给定一个整型(int 或 in32 或 int64)数组
nums
,如果任何值在数组中至少出现两次,则返回true
;如果每个元素都不同,则返回false
。
现在,我们在不使用 Go 泛型的情况下解决这个问题。
进入开发目录,创建一个新的 Go 项目目录,名称不限。我将其命名为 leetcode1
。然后将目录更改为新创建的项目目录。
按照惯例,我们在终端的项目根目录下运行 go mod init github.com/username/leetcode1
,为项目创建一个 Go 模块。
❗️ 记住:不要忘记将 username 替换为你自己的 Github 用户名
接下来,创建 leetcode.go
文件并将下面的代码复制进去:
再看一下 Leetcode 的问题,程序应该检查输入的数组(可以是 INT、INT32 或 INT64),并找出是否有重复数据,如果有则返回 true
,否则返回 false
,上面这段代码就是完成这个任务的。
在第 10、11 和 12 行,分别提供了 int
、int32
和 int64
类型数据的示例数组。
在第 5、6 和 7 行,分别创建了关键字类型为 int
、int32
和 int64
的 map
类型 FilterInt
、FilterInt32
和 FilterInt64
。
所有类型 map
的值都是布尔值,所有类型都有相同的 has
和 add
方法。从本质上讲,add
方法将接受 datum
参数,并在 map
中创建值为 true
的键。根据 map
是否包含作为 datum
传入的键,has
方法将返回 true
或 false
。
现在,第 18 行的函数 FindDuplicateInt
、第 29 行的函数 FindDuplicateInt32
和第 40 行的函数 FindDuplicateInt64
实现了相同的逻辑,即验证所提供的数据中是否存在重复数据,如果发现重复数据,则返回 true
,否则返回 false
。
看看这些重复代码。
有没有让你感到恶心🤕?
总之,如果我们在终端运行项目根目录下的 go run leetcode.go
,就会编译成功并运行。输出结果应该与此类似:
如果我们要查找 float32
、float64
或字符串的重复内容,该怎么办?
我们可以为每种类型编写一个实现,为不同类型明确编写多个函数,或者使用接口,或者通过包生成"泛型"代码。这就是"泛型"诞生的过程。
通过泛型,我们可以编写泛型函数来替代多个函数,或使用带有类型转换的接口。
接下来我们用泛型来重构代码,但首先需要熟悉一些术语和概念。
泛型基础知识
1.类型参数
上图描述的是泛型函数 FindDuplicate
,T
是类型参数,any
是类型参数的约束条件(接下来将讨论约束条件)。
类型参数就像一个抽象的数据层,通常用紧跟函数或类型名称的方括号中的大写字母(多为字母 T)来表示。下面是一些例子:
2.类型推导
泛型函数必须了解其支持的数据类型,才能正常运行。
🎯要点:泛型类型参数的约束条件是在编译时由调用代码确定的代表单一类型的一组类型。
进一步来说,类型参数的约束代表了一系列可允许的类型,但在编译时,类型参数只代表一种类型,因为 Go 是一种强类型的静态检查语言。
❗️提醒:由于 Go 是一种强类型的静态语言,因此会在应用程序编译期间而非运行时检查类型。Go 泛型解决了这个问题。
类型由调用代码类型推导提供,如果泛型类型参数的约束条件不允许使用该类型,代码将无法编译。
由于类型是通过约束知道的,因此在大多数情况下,编译器可以在编译时推断出参数类型。
通过类型推导,可以避免从调用代码中为泛型函数或泛型类型实例化进行人工类型推导。
👉注意:如果编译器无法推断类型(即类型推导失败),可以在实例化时或在调用代码中手动指定类型。
下面是 FindDuplicate
泛型函数的一个很好的示例:
我们可以忽略调用代码中的 [int]
,因为编译器会推断出[int]
,但我更倾向于加入[int]
以提高代码的可读性。
3.约束
在引入泛型之前,Go 接口用于定义方法集。然而,随着泛型约束的引入,接口现在既可以定义类型集,也可以定义方法集。
约束是用于指定允许使用的泛型的接口,在上述 FindDuplicate
函数中使用了 any
约束。
❗️Pro 提示:除非必要,否则避免使用
any
接口约束。
在底层实现上,any
关键字只是一个空接口,这意味着可以用 interface{}
替换,编译时不会出现任何错误。
上述接口约束允许使用 int
、int16
、int32
和 int64
类型。这些类型是约束联合体,用管道符 |
分隔类型。
约束在以下几个方面有好处:
通过类型参数定义了一组允许的类型
明确发现泛型函数的误用
提高代码可读性
有助于编写更具可维护性、可重用性和可测试性的代码
☝️ 简单提一下:使用约束时有一个小问题
请看下面的代码:
在上面的代码中,第 5 行定义了一个名为 CustomType
的自定义类型,其基础类型为 int16
。
在第 8 行,声明了一个以 CustomType
为类型的变量,并在第 9 行为其赋值。
然后,在第 10 行调用带有值的 printValue
泛型函数。
...🤔
...🤔
你认为代码可以编译运行吗?
如果我们在终端执行 go run custom-generics.go
,就会出现这样的错误。
尽管自定义类型 CustomType
是 int16
类型,但 printValue
泛型函数的类型参数约束无法识别。
鉴于函数约束不允许使用该类型,这也是合理的。不过,可以修改 printValue
函数,使其接受我们的自定义类型。
现在,更新 printValue
函数如下:
使用管道操作符,我们将自定义类型 CustomType
添加到 printValue
泛型函数类型参数的约束中,现在有了一个联合约束。
如果我们再次运行该程序,编译和运行都不会出现任何错误。
但是,等等!为什么需要 int16
类型和"int16
"类型的约束联合?
我们将在下一节介绍波浪线 ~
运算符。
4.波浪线(Tilde)运算符和基础类型
幸运的是,Go 1.18 通过波浪线运算符引入了底层类型,波浪线运算符允许约束支持底层类型。
在上一步代码示例中,CustomType
类型的底层类型是 int16
。现在,我们使用 ~
波浪线更新 printValue
泛型函数类型参数的约束,如下所示:
新代码应该是这样的:
再次运行程序,应该可以成功编译和运行。我们删除了约束联合,并在约束中的 int16
类型前用 ~
波浪线运算符替换了 CustomType
。
编译器现在可以理解,CustomType
类型之所以可以使用,仅仅是因为它的底层类型是 int16
。
💡 简单来说,
~
告诉约束接受任何int16
类型以及任何以int16
作为底层类型的类型。
下面是一个泛型约束接口示例,它也允许函数声明:
不过,下一步还有更多东西要学。
5.预定义约束
Go 团队非常慷慨的为我们提供了一个常用约束的预定义包,可在 golang.org/x/exp/constraints 找到。
以下是预定义约束包中包含的约束示例:
因此,我们可以更新之前示例中的 printValue
泛型函数,使其接受所有整数,具体方法如下。
❗️ 记住:不要忘记导入预定义约束包 golang.org/x/exp/constraints。
重构 Leetcode 示例
现在我们对泛型有了一些了解,接下来重构 FindDuplicate
程序,通过泛型在整数、浮点数和字符串类型的切片及其底层类型中查找是否有重复数据。
具体修改为:
创建允许使用整数、浮点和字符串及其底层类型的接口约束
使用
go get
将约束包下载到项目中,在终端的 Leetcode 根目录中执行如下指令:
添加到项目中后,在主函数上方创建名为
AllowedData
的约束,如下所示:
constraints.Ordered
是一种约束,允许任何使用支持比较运算符(如 ≤=≥===)的有序类型。
👉注:可以在泛型函数中使用
constraint.Ordered
,而无需创建新的接口约束。不过,为了便于学习,我们还是创建了自己的约束AllowData
。
接下来,删除类型 map 中的所有 FilterIntX 类型,创建一个名为
Filter
的新类型,如下所示,该类型以T
为类型参数,以AllowedData
为约束条件:
在泛型类型 Filter
前面,声明了 T
类型参数,并指定 map
键只接受类型参数的约束 AllowedData
作为键类型。
现在,删除所有 FindDuplicateIntX 函数。然后使用 Go 泛型创建一个新的
FindDuplicate
函数,代码如下:
FindDuplicate
函数是一个泛型函数,添加了类型参数 T
,并在函数名后面的方括号中指定了 AllowedData
约束,然后用类型参数 T
定义了切片类型的函数参数,并用类型参数 T
初始化了 inArray
。
👉注:在函数中声明泛型参数时使用方括号。
接下来,更新
has
以及add
方法,如下所示。
因为我们在定义类型 Filter
时已经声明了约束,因此方法中只包含类型参数。
最后,更新调用 FindDuplicateIntX 的调用代码,使用新的泛型函数 FindDuplicate
,最终代码如下:
现在执行 go run main.go
,程序成功编译并运行,预期输出为:
我们成功重构了代码,却没有犯复制粘贴的错误。
6.可比较(comparable)约束
可比较约束与相等运算符(即 == 和≠)相关联。
这是在 Go 1.18 中引入的一个接口,由结构体、指针、接口、管道等类似类型实现。
👉注:Comparable 不用作任何变量的类型。
7.约束类型链和类型推导
类型链
允许一个已定义的类型参数与另一个类型参数复合的做法被称为类型链。当在泛型结构或函数中定义辅助类型时,这种方法就派上用场了。
示例:
约束类型推导
前面我们详细介绍了类型推导,但与类型链无关,可以如下调用上图中的函数:
由于 ~T
是类型参数 T
与任意约束条件的复合体,因此在调用 Example
函数时可以推断出类型参数 U
。
👉注:2 是整数,是 T 的底层类型。
8.多类型参数和约束
Go 泛型支持多类型参数,但有一个问题,我们看下面的另一个例子:
如果编译并成功运行,预期输出结果将是:
在函数方括号[]中,我们添加了多个类型参数。类型参数 A
和 B
共享同一个约束条件。在函数括号中,参数 a
和 a1
共享同一个类型参数 any
约束条件。
现在更新主函数,如下所示。
发生了什么?
我们将 2 的值从 2 改为 2.1,如你所知,这会将 2 的数据类型从 int
改为 float
。当我们再次运行程序时,编译失败:
等等!我们到底有没有声明 int 类型?
原因就在这里--在编译过程中,编译器会根据函数括号中的类型参数约束进行推断。可以看到,a
和 a1
共享同一个类型参数 A
,约束条件是 any
(允许所有类型)。
编译器会根据调用代码的变量类型进行推断,并在编译过程中使用函数括号中的类型参数约束来检查类型。
可以看到,a
和 a1
具有相同的类型参数 A
,并带有 any
约束。因此,a
和 a1
必须具有相同的类型,因为它们在用于类型推导的函数括号中共享相同的类型参数。
尽管类型参数 A
和 B
共享同一个约束条件,但 b
在函数括号中是独立的。
何时使用(或不使用)泛型
总之,请记住一点--大多数用例并不需要 Go 泛型。不过,知道什么时候需要也很有帮助,因为这样可以大大提高工作效率。
这里有一些指导原则:
何时使用 Go 泛型
替换多个类型执行相同逻辑的重复代码,或者替换处理切片、映射和管道等多个类型的重复代码
在处理容器型数据结构(如链表、树和堆)时
当代码逻辑需要对多种类型进行排序、比较和/或打印时
何时不使用 Go 泛型
当 Go 泛型会让代码变得更复杂时
当指定函数参数类型时
当有可能滥用 Go 泛型时。避免使用 Go 泛型/类型参数,除非确定有使用多种类型的重复逻辑
当不同类型的实现不同时
使用 io.Reader 等读取器时
局限性
目前,匿名函数和闭包不支持类型参数。
Go 泛型的测试
由于 Go 泛型支持编写多种类型的泛型代码,测试用例将与函数支持的类型数量成正比增长。
结论
本文介绍了 Go 中的泛型、与之相关的新术语,以及如何在类型、函数、方法和结构体中使用泛型。
希望能对大家的学习 Go 有所帮助,但请不要滥用 Go 泛型。
收获
如果使用得当,Go 泛型的功能会非常强大;但要谨慎,因为能力越大,责任越大。
Go 泛型将提高代码的灵活性和可重用性,同时保持向后兼容,从而为 Go 语言增添价值。
它简单易用,直接明了,学习周期短,练习有助于更好的理解 Go 泛型及其局限性。
过度使用、借用其他语言的泛型实现以及误解会导致 Go 社区出现反模式和复杂性,风险自担。
你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!
版权声明: 本文为 InfoQ 作者【俞凡】的原创文章。
原文链接:【http://xie.infoq.cn/article/4aa886a7e62783c7e3c99caf3】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论