Go 100 常见错误 9: 对何时使用泛型感到困惑

181 阅读8分钟

Go 1.18 引入了泛型到语言中。简而言之,这允许我们编写具有可以稍后指定并在需要时实例化的类型的代码。然而,何时使用泛型以及何时不使用可能会让人感到困惑。在本节中,我们将描述 Go 中泛型的概念,然后探讨常见的使用和误用情况。

2.9.1 概念

考虑以下函数,它从 map[string]int 类型中提取所有键:

func getKeys(m map[string]int) []string {
    var keys []string
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

如果我们想对其他类型的映射,如 map[int]string 使用类似的功能,会怎么样?在泛型出现之前,Go 开发者有几个选择:使用代码生成、反射或复制代码。例如,我们可以为每种映射类型编写两个函数,或者甚至尝试扩展 getKeys 以接受不同的映射类型:

func getKeys(m any) ([]any, error) {
    switch t := m.(type) {
    default:
        // 接受并返回任何参数
        return nil, fmt.Errorf("unknown type: %T", t)
    case map[string]int:
        var keys []any
        for k := range t {
            keys = append(keys, k)
        }
        return keys, nil
    case map[int]string:
        // 复制提取逻辑
    }
}

处理尚未实现的类型的运行时错误

通过这个例子,我们开始注意到一些问题。首先,它增加了样板代码。实际上,当我们想要添加一个 case 时,它需要复制范围循环。同时,该函数现在接受一个 any 类型,这意味着我们失去了 Go 作为类型语言的一些好处。实际上,检查一个类型是否被支持是在运行时而不是编译时进行的。因此,如果提供的类型未知,我们还需要返回一个错误。最后,由于键类型可以是 intstring,我们不得不返回一个 any 类型的切片来提取键类型。这种方法增加了调用者方面的努力,因为客户端可能还需要对键执行类型检查或额外的转换。多亏了泛型,我们现在可以使用类型参数重构这段代码。

类型参数是我们可以与函数和类型一起使用的泛型类型。例如,以下函数接受一个类型参数:

func foo[T any](t T) {
    // ...
}

T 是一个类型参数。

当我们调用 foo 时,我们传递一个任何类型的类型参数。提供类型参数称为实例化,工作在编译时完成。这保持了类型安全作为核心语言特性的一部分,并避免了运行时开销。

让我们回到 getKeys 函数,并使用类型参数编写一个泛型版本,该版本将接受任何类型的映射:

func getKeys[K comparable, V any](m map[K]V) []K {
    // 键是可比较的,而值是任何类型。
    var keys []K
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

创建键的切片

为了处理映射,我们定义了两种类型的类型参数。首先,值可以是任何类型:V any。然而,在 Go 中,映射的键不能是任何类型。例如,我们不能使用切片:

var m map[[]byte]int

这段代码会导致编译错误:无效的映射键类型 []byte。因此,我们不是接受任何键类型,而是不得不限制类型参数,以便键类型满足特定要求。在这里,要求是键类型必须是可比较的(我们可以使用 ==!=)。因此,我们定义 K 为可比较的而不是任何类型。

将类型参数限制为匹配特定要求称为约束。约束是一个接口类型,可以包含:

  • 一组行为(方法)
  • 任意类型

让我们检查后者的一个具体示例。想象一下我们不想为映射键类型接受任何可比较类型。例如,我们想将其限制为 intstring 类型。我们可以通过这种方式定义自定义约束:

type customConstraint interface {
    ~int | ~string
}
func getKeys[K customConstraint, V any](m map[K]V) []K {
    // 相同的实现
}

定义一个自定义类型,限制类型为 intstring

将类型参数 K 更改为 customConstraint 类型

首先,我们定义一个 customConstraint 接口,使用联合运算符 |(我们将稍后讨论 ~ 的使用)将类型限制为 intstring。现在 KcustomConstraint 而不是以前的可比较类型。

getKeys 的签名强制我们可以使用任何值类型和键类型为 intstring 的映射调用它,例如,在调用者端:

m := map[string]int{
    "one":   1,
    "two":   2,
    "three": 3,
}
keys := getKeys(m)

注意,Go 可以推断 getKeys 是用字符串类型参数调用的。前面的调用等同于:

keys := getKeys[string](m)

~intint

使用 ~int 的约束与使用 int 的约束有什么区别?使用 int 将其限制为该类型,而 ~int 限制所有底层类型为 int 的类型。为了说明这一点,让我们想象一个约束,我们将限制类型为实现 String() string 方法的任何 int 类型:

type customConstraint interface {
    ~int
    String() string
}

使用这个约束限制类型参数为自定义类型。例如:

type customInt int
func (i customInt) String() string {
    return strconv.Itoa(int(i))
}

因为 customIntint 并实现了 String() string 方法,所以 customInt 类型满足定义的约束。然而,如果我们将约束改为包含 int 而不是 ~int,使用 customInt 会导致编译错误,因为 int 类型没有实现 String() string

到目前为止,我们已经讨论了使用泛型函数的示例。然而,我们也可以将泛型用于数据结构。例如,我们可以创建一个包含任意类型值的链表。为此,我们将编写一个 Add 方法来附加一个节点:

type Node[T any] struct {
    Val  T
    next *Node[T]
}
func (n *Node[T]) Add(next *Node[T]) {
    n.next = next
}

在这个例子中,我们使用类型参数来定义 T,并在 Node 中使用两个字段。关于方法,接收器被实例化了。确实,因为 Node 是泛型的,所以它也必须遵循定义的类型参数。

关于类型参数的最后一点需要注意的是,它们不能与方法参数一起使用,只能与函数参数或方法接收器一起使用。例如,以下方法将无法编译:

type Foo struct {}
func (Foo) bar[T any](t T) {}
./main.go:29:15: methods cannot have type parameters

如果我们想要在方法中使用泛型,接收器就需要成为一个类型参数。

2.9.2 常见的使用和误用

泛型在何时有用?让我们讨论一些推荐使用泛型的常见用途:

  • 数据结构 - 例如,如果我们实现二叉树、链表或堆,我们可以使用泛型来提取元素类型。

  • 与任何类型的切片、映射和通道一起工作的函数 - 例如,一个合并两个通道的函数将与任何通道类型一起工作。因此,我们可以使用类型参数来提取通道类型:

    func merge[T any](ch1, ch2 <-chan T) <-chan T {
        // ...
    }
    
  • 而不是类型的提取行为 - 例如,sort 包包含一个 sort.Interface 接口,它有三种方法:

    type Interface interface {
        Len() int
        Less(i, j int) bool
        Swap(i, j int)
    }
    

    这个接口被不同的函数使用,如 sort.Intssort.Float64s。使用类型参数,我们可以提取排序行为(例如,通过定义一个持有切片和比较函数的结构体):

    type SliceFn[T any] struct {
        S       []T
        Compare func(T, T) bool
    }
    func (s SliceFn[T]) Len() int { return len(s.S) }
    func (s SliceFn[T]) Less(i, j int) bool { return s.Compare(s.S[i], s.S[j]) }
    func (s SliceFn[T]) Swap(i, j int) { s.S[i], s.S[j] = s.S[j], s.S[i] }
    ​
    

    然后,由于 SliceFn 结构体实现了 sort.Interface,我们可以使用 sort.Sort(sort.Interface) 函数对提供的切片进行排序:

    s := SliceFn[int]{
        S: []int{3, 2, 1},
        Compare: func(a, b int) bool {
            return a < b
        },
    }
    sort.Sort(s)
    fmt.Println(s.S) // 输出: [1 2 3]
    

    在这个例子中,提取行为允许我们避免为每种类型创建一个函数。

    相反,何时建议我们不使用泛型呢?

  • 当调用类型参数的方法时 - 考虑一个接收 io.Writer 并调用 Write 方法的函数,例如:

    func foo[T io.Writer](w T) {
        b := getBytes()
        _, _ = w.Write(b)
    }
    

    在这种情况下,使用泛型对我们的代码没有任何价值。我们应该直接将 w 参数作为 io.Writer

  • 当它使我们的代码更复杂时 - 泛型从不是必须的,作为 Go 开发者,我们在没有它们的情况下生活了十多年。如果我们正在编写泛型函数或结构体,并且发现它并没有使我们的代码更清晰,我们可能应该重新考虑我们对特定用例的决定。

尽管泛型在特定条件下可能是有帮助的,我们应该谨慎决定何时使用它们以及何时不使用它们。一般来说,如果我们想要回答何时不使用泛型,我们可以找到与何时不使用接口的相似之处。实际上,泛型引入了一种形式的抽象,我们必须记住,不必要的抽象会引入复杂性。