AI 中文社区(简称 AI 中文社),是国内学习交流AI人工智能技术的中文社区网站,这里可获取及贡献任何AI人工智能技术,我们追求自由、简洁、纯粹、分享的多元化人工智能社区。
Go 1.27.0(新增)的泛型方法
泛型对 Go 而言是一次深刻的变革——它为语言添加了类型参数,从根本上扩展了 Gopher 能够编写的程序种类。
现在,我们可以在 Go 中表达泛型类型和泛型函数。这些构造减少了对专用数据类型和函数的需求,降低了程序的冗余度,并在这些场景中改善了语言的易用性。
举例来说,不同类型的链表现在可以合并为单一类型定义:
// Go 1.18 之前
type ListOfInts struct {
elem int
next *ListOfInts
}
type ListOfStrings struct {
elem string
next *ListOfStrings
}
// ...
// Go 1.18 之后
type List[E any] struct {
elem E
next *List[E]
}同样,对不同类型的有序数据进行排序也可以表达为单个函数:
// Go 1.18 之前
func SortInts(s []int) { /* ... */ }
func SortStrings(s []string) { /* ... */ }
// ...
// Go 1.18 之后
func Sort[E cmp.Ordered](s []E) { /* ... */ }然而,这样的能力并未降临到方法(method)上。
泛型提案认为,由于泛型接口方法难以高效实现(后文会讨论),因此也不应该为非接口(即“具体”)方法添加类型参数。该观点认为,方法主要是实现接口的手段。
Go 1.27 则采取了不同的视角,因此为 Go 增加了泛型方法。在本文中,我们将解释这一视角的转变,并展示这一新特性的若干用法。
方法用于组织代码
方法使得围绕类型来组织功能成为可能。为了说明这一点,让我们重新审视上面的链表(增加一些便利方法):
type List[E any] struct {
elem E
next *List[E]
}
func NewList[E any](elems ...E) List[E] { /* ... */ }
func (List[E]) String() string { /* ... */ }假设我们想要将这个结构映射为持有其他类型(例如字符串)的值。像 ToString 这样的方法就可以实现:
func (List[E]) ToString(f func(E) string) List[string] { /* ... */ }通过提供一个“转换”函数 f,ToString 可以用“现成”的例程进行定制。
例如,strconv.Itoa 可用于 List[int]:
func main() {
fmt.Println(NewList(1, 2, 3).ToString(strconv.Itoa)) // [1 2 3]
}对于 List[[]byte],则有更多选择(取决于具体用例):
func main() {
l := NewList([]byte("Hallo Welt"), []byte("Helló világ"))
fmt.Println(l.ToString(hex.EncodeToString)) // [48616c6c6f2057656c74 48656c6cc3b32076696cc3a167]
fmt.Println(l.ToString(base64.StdEncoding.EncodeToString)) // [SGFsbG8gV2VsdA== SGVsbMOzIHZpbMOhZw==]
}注意,对 List 进行参数化使得源类型得以泛化,但目标类型却无法泛化,因为这取决于所应用的转换。如果只有一个目标类型,这或许还算合理,但如果目标类型有很多呢?
在 Go 1.18 中,可以使用泛型函数来解决:
// Go 1.18 之后
func MapList[E, R any](l List[E], f func(E) R) List[R] { /* ... */ }但使用函数有一个缺点:MapList 会被放到包作用域中——如果许多数据类型都支持映射操作,包作用域就会变得拥挤。此外,链式调用必须写成“由内向外”的形式:
func main() {
fmt.Println(MapList(MapList(NewList(0, 2, 4), add(2)), divideBy(2))) // [1 2 3]
}由于 Go 1.18 不支持泛型方法,我们不得不使用泛型函数作为变通方案。这一问题在 Go 1.27 中得到了修正:
// Go 1.27 之后
func (List[E]) Map[R any](f func(E) R) List[R] { /* ... */ }这样写简洁、富有表现力,且作用域仅限于类型内部。此外,链式调用更加可读,因为可以自然地从左到右书写:
func main() {
fmt.Println(NewList(0, 2, 4).Map(add(2)).Map(divideBy(2))) // [1 2 3]
}更喜欢“由内向外”的形式?方法表达式可以将任何方法(包括泛型方法)转换为等价的函数形式。因此,如果需要,可以恢复函数调用的结构:
func main() {
f := List[int].Map[int]
fmt.Println(f(f(NewList(0, 2, 4), add(2)), divideBy(2))) // [1 2 3]
}泛型方法和其他 Go 泛型一样,在使用(调用或转换为函数)之前必须先被实例化(显式或隐式)。
解耦问题
如果将方法也视为一种组织工具,那么 Go 1.18 的推理就显得过于严格了。虽然泛型具体方法无法帮助实现接口(在没有泛型接口方法的情况下),但它仍然对代码组织有用。换句话说,这些关注点可以解耦。
详细说明一下,让我们考虑一个简单的接口:
type I interface {
M()
}
type T struct{} // 一个结构体,而非接口
func (T) M[P any]() { /* ... */ } // 一个泛型 *具体* 方法这里,T.M 由类型参数 P 参数化。T.M 的任何实例化都会产生与 I.M 相同的方法签名:
func main() {
T{}.M[int]()
}T.M[int] 和 I.M 都具有签名 func M()。然而,这并不意味着 T 实现了 I。接口实现是(已实例化的)类型的属性,而不是某个特定方法的属性。重要的是,T 声明的是 T.M 的泛型形式(即未实例化的形式),而不是 T.M[int]。
要让 T.M 参与接口实现,就需要存在一个合适的泛型接口方法 I.M:
type I interface {
M[P any]() // 一个泛型 *接口* 方法
}
type T struct{}
func (T) M[P any]() { /* ... */ }由于 Go 1.27 的语法不允许接口方法声明类型参数,因此无法写出这样的代码。
但为什么 Go 不能有泛型接口方法呢?为此,需要稍作展开。
泛型接口方法的麻烦之处
接口值是一个可以容纳其他值的盒子——被装入的值可以是任何类型,只要该类型实现了该接口。这意味着该类型上声明的方法必须是接口中声明方法的超集。
举例说明,下面 T 实现了 I:
type I interface {
M()
}
type T struct{}
func (T) M() { /* ... */ }我们可以在接口值上调用方法:
func main() {
F(T{})
}
func F(i I) {
i.M() // 由 T.M 支撑
}上面可以很容易地看出,对 i.M 的调用会路由到 T.M。但在更复杂的程序中,接口值方法调用与其可能的目标之间的关系就远没有那么明显了。这种关系通常跨越多个包,在一般情况下很难甚至不可能推断出来。
为了说明,我们引入一个包边界:
// -- 包 main --
import "p"
func main() {
p.F(T{})
}
type T struct{}
func (T) M() { /* ... */ }
// -- 包 p --
func F(i I) {
i.M()
}
type I interface {
M()
}因为两个包是分开编译的,main 包不知道 p.F 会如何使用 T{}。为了确保 p.F 可能调用的任何方法都存在,编译器会在类型声明(或实例化)时为该类型的所有非泛型方法生成代码。这样,无论 p.F 对 T{} 做什么,运行时都具备必要的代码。
现在,假设 I.M 是泛型的:
// -- 包 main --
import "p"
func main() {
p.F(T{})
}
type T struct{}
func (T) M[P any]() { /* ... */ }
// -- 包 p --
func F(i I) {
i.M[int]()
}
type I interface {
M[P any]()
}同样,main 包不知道 p.F 会如何使用 T{}——包括它会如何实例化 T.M。为了处理 p.F 内部对 T{} 的所有可能使用方式,编译器需要用每一种可能的类型实参来实例化 T.M。
这与 Go 的实例化方法(编译器根据类型实参为每个方法生成特定代码)是不切实际的。如果方法参数采用“装箱”方式(即作为其约束接口的值来传递),那么每个实例化都会共享相同的代码。这将避免产生过多的实例化,代价是间接调用的开销——即使是对已实例化方法的直接调用也是如此。
结论
Go 1.27 引入了具体方法上的类型参数——这是 Go 社区长久以来强烈期望的特性,因为它允许编写更加符合人体工程学和可读性更高的代码。尽管我们无法支持泛型接口方法,但我们认为允许具体方法使用类型参数仍然值得。
我们希望你享受使用 Go 泛型方法的过程,并在你的项目中找到有益的使用方式!
游客
- 一字一句需斟酌,一言一语显风范。
- 评论消耗5积分,点赞、收藏消耗3积分。
AI 中文社