10 分钟了解 Golang 泛型
导言
可能有人会觉得 Go 泛型很难,因此想要借鉴其他语言(比如 Java、NodeJS)的泛型实践。事实上 Go 泛型很容易学,本文希望能帮助读者更好的理解 Go 泛型。
👉注:本文不会将 Go 泛型与其他语言的泛型实现进行比较,但会帮助你理解 Go 泛型元素背后的上下文、结构及其原理。
在开始前,推荐一款程序员都应该知道的好物——JNPF 低代码开发
开发语言:Java/.net
这是一个基于 Java Boot/.Net Core 构建的简单、跨平台快速开发框架。前后端封装了上千个常用类,方便扩展;采用微服务、前后端分离架构,集成了代码生成器,支持前后端业务代码生成,满足快速开发;框架集成了表单、报表、图表、大屏等各种常用的 Demo 方便直接使用;后端框架支持 Vue2、Vue3,平台即可私有化部署,也支持 K8S 部署。
选择合适的组件进行集成或二次开发复杂功能,即可自主开发一个属于自己的应用系统。
那么,我们开始吧~
前置条件
要编写本文中的示例代码,需要:
在计算机上安装 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 社区出现反模式和复杂性,风险自担。
评论