Go 语言自 1.18 版本开始支持泛型(Generics)。泛型允许我们为函数和数据结构定义类型参数,增强代码的复用性和类型安全性。然而,泛型并不是在所有情况下都适用,正确地使用泛型能带来代码清晰性和性能提升,错误使用则可能使代码复杂或降低性能。
本文旨在帮助你了解在什么情况下应该考虑使用泛型。
泛型的优点
- 代码复用性:泛型允许用一种实现支持多种类型,减少代码重复。
- 类型安全:在编译时检查类型,防止运行时类型错误。
- 抽象能力:抽象地处理数据结构和算法,提升代码表达力。
- 性能:避免因为使用
interface{}
类型带来的类型断言,从而减少运行时开销。
何时使用泛型?
泛型适用于以下场景:
- 你有一组算法或数据结构逻辑,它们在不同的类型上运作,但逻辑完全相同。
- 你想写高度通用的代码来简化代码库,同时保持类型安全。
- 你的代码需要同时处理多种类型,但不想牺牲性能。
- 你希望避免冗余代码,减少维护工作量。
何时不适合使用泛型?
泛型不是万金油,某些情况下反而带来负担:
- 只有单一具体类型需求时,泛型会增加代码复杂度。
- 泛型代码过于复杂,导致可读性变差,增加维护难度。
- 性能占优且简单逻辑可以接受轻微重复代码时,不必强制用泛型。
- 当泛型参数变得过于复杂(过多约束、嵌套)时,可能适得其反。
不要把interface类型替换为类型参数
Go语言有interface类型,interface支持某种意义上的泛型编程。
举个例子,被广泛使用的io.Reader接口提供了一种泛型机制用于读取数据,比如支持从文件和随机数生成器里读取数据。
如果你对某些类型的变量的操作只是调用该类型的方法,那就直接使用interface类型,不要使用类型参数。io.Reader从代码角度易于阅读且高效,没必要使用类型参数。
举个例子,有人可能会把下面第1个基于interface类型的ReadSome版本修改为第2个基于类型参数的版本。
1 | func ReadSome(r io.Reader) ([]byte, error) |
不要做这种修改,使用第1个基于interface的版本会让函数更容易编写和阅读,并且函数执行效率也几乎一样。
注意:尽管可以使用不同的方式来实现泛型,并且泛型的实现可能会随着时间的推移而发生变化,但是Go 1.18中泛型的实现在很多情况下对于类型为interface的变量和类型为类型参数的变量处理非常相似。这意味着使用类型参数通常并不会比使用interface快,所以不要单纯为了程序运行速度而把interface类型修改为类型参数,因为它可能并不会运行更快。
用例示例
例如处理一组不同类型的切片排序、查找操作,可以使用泛型函数避免重复编写针对不同类型的代码。
非泛型版本
1 | func IntContains(slice []int, val int) bool { ... } |
上述重复很多类似逻辑。
泛型版本
1 | func Contains[T comparable](slice []T, val T) bool { ... } |
案例2: Go内置的容器类型
入参是一个map,要返回该map的所有key组成的slice,key的类型可以是map支持的任意key类型。
1 | // MapKeys returns a slice of all the keys in m. |
这段代码没有对map里key的类型做任何限定,并且没有用map里的value,因此这段代码适用于所有的map类型。这就是使用类型参数的一个很好的示例。
这种场景下,也可以使用反射(reflection),但是反射是一种比较别扭的编程模型,在编译期没法做静态类型检查,并且会导致运行期的速度变慢。
结语
泛型是强大的工具,但需用时权衡复杂度与收益。建议代码在泛型和具体类型之间平衡。合适时用泛型,实现代码复用和类型安全;不合适则回归简单可理解方案。
如果你正在考虑是否使用泛型,问自己:
- 这段代码是否适用于多种类型?
- 是否存在类型重复代码?