系列总索引


正文

新的语言特性,尤其是像泛型这样比较重要的,难免有需要注意的地方,这里讨论两个问题:

场景1: 这个代码可以编译么?

我们先来看下面的代码,这个代码可以编译么?如果不能,你认为原因是什么呢?

type Sizer[T any] interface {
    Size() uintptr
}

type Producer[T any] struct {
    val T
}

// Producer implements the Sizer interface

func (p Producer[T]) Size() uintptr {
    return unsafe.Sizeof(p.val)
}

func (p Producer[T]) Produce() T {
    return p.val
}

func Execute[T any](s Sizer[T]) {
    switch p := s.(type) {
    case Producer[T]:
        fmt.Println(p.Produce())
    default:
        panic("This should not happen")
    }
}

func main() {
    Execute[string](Producer[string]{"23"})
    Execute[string](Producer[int64]{27})
}

注意 Execute() 的签名,以及 main() 里的两个 Execute() 调用。当 Execute 使用 string 类型实例化时,参数 s 的类型应该是 Sizer[string],应该与下面的伪代码一致:

func Execute[string](s Sizer[string])

Sizer[T] 是一个有方法 Size() 的接口, 类型 Producer[T] 实现了这个接口。传递一个 Producer[string] 给 Execute[string],就像 main() 里的第一个 Execute 调用,应该像预期那样的工作。

使用 Go Playground。(这个代码需要用 Go 1.18 来执行。)

执行以后,你会发现:

  1. 代码编译没有错误
  2. 第一个代码执行成功。
  3. 第二个 Execute 也执行成功,但是在 Execute 里面,type 的选择分支是 default。

为什么会发生这个情况呢?为什么不对的类型参数没有触发编译错误呢?

interface 有类型参数,但是...

可能你已经发现了,Sizer 接口声明了类型参数 T,但是并没有在任何地方使用它。这里在 Sizer 增加类型参数是比较自然的选择,因为,毕竟 Producer 有一个参数,并且 Producer 接收 Sizer 作为入参,所以 Sizer 看起来也需要一个。

然而,实际上,因为 Sizer 的类型参数在 Sizer 的定义内部并没有使用,我们可以安全的忽略它。在 interface 的定义处,和执行的地方。

更简洁的版本

这里是简化的版本。运行的结果也和之前一样,但是更容易阅读。

type Sizer interface {
    Size() uintptr
}

func Execute[T any](s Sizer) {
    switch p := s.(type) {
    case Producer[T]:
        fmt.Println(p.Produce())
    default:
        fmt.Printf("s implements Sizer. That's all we know about s. Size is: %d bytes.\n", s.Size())
    }
}

注意 Execute 仍然有类型参数。类型 switch 需要匹配 s 与 Producer[T] 的关系。但是 Sizer 的类型参数不再影响结果。

这个的机制的原因是,下面一个接口的两种声明方式在语义上是等价的。类型参数并没有起效。

type Doer interface {
    Do()
}

type Doer[T any] interface {
    Do()
}

场景2: 一个关于泛型参数的类型 switch 失败...为什么呢?

和上面的场景类似的一个场景是下面这个。一个关于联合 string 和 int 的类型的例子如下

func PrintStringOrInt[T string | int](v T) {
    switch v.(type) {
    case string:
        fmt.Printf("String: %s\n", v)
    case int:
        fmt.Printf("Int: %d\n", v)
    default:
        panic("Impossible")
    }
}

func main() {
    PrintStringOrInt("hello")
    PrintStringOrInt(42)
}

运行结果可能有点意外。这个代码并不能通过编译。switch 语句触发了报错

./prog.go:10:9: cannot use type switch on type parameter value v (variable of type T constrained by StringOrInt)

Go build failed.

看起来,类型 switch 并不想去对类型参数进行 switch。

为什么类型 switch 不能通过编译?

这个是 Go 团队故意设计的行为。因为对类型参数提供类型 switch 会造成困惑。

在早期的设计,我们允许对类型参数(或者基于类型参数的类型)进行类型断言和类型 switch。我们最终去掉了这个特性,因为这样做可能导致把一个任意类型的值转换成空接口类型,然后对这个值使用类型断言或者类型 switch。并且,在使用近似元素的类型集合的约束中,类型断言或类型开关会使用实际的类型参数,而不是类型参数的基础类型,这有时会造成困惑。(解释)

让我们把相关代码转换成代码。如果类型限制使用近似类型(注意波浪线)

func PrintStringOrInt[T ~string | ~int](v T)

如果有一个 int 的自定义类型

type Seconds int

如果 PrintOrString() 使用 Seconds 作为参数

PrintStringOrInt(Seconds(42))

类型 switch 不会进入 int 的分支,而是 default 的分支。因为 Seconds 不是 int。也许开发者希望 case int 和 case Seconds 是等价的。

如果期望一个条件分支即满足 int,也满足 Seconds,需要一个新的语法

case ~int:

在这篇文章编写时,关于这个提议还在讨论。也许会有更新的语法,例如 (switch type T)

如果对这个提案感兴趣,可以关注 issue。

技巧:把类型转换成 any

幸运的是,我们不需要等待这个特性落地。这里有一个特别简单的绕过方法,使用 any(v).(type) 替换 v.(type)

switch any(v).(type) {
    ...

这个技巧把 v 转换为空 interface{}(any),条件 switch 可以正常工作了。

感谢

这个文行是基于 golang-nuts 邮件列表的讨论编写而成。感谢 T Benschar 带来这个话题,Ian Lance Taylor 提供了答案,Brian Candler 解释了更多更与使用 any(v) 的技巧。