接口类型的装箱(boxing)原理

接口类型的装箱(boxing)原理

Content #

接口类型有着复杂的内部结构,所以我们将一个类型变量值赋值给一个接口类型变量值的过程肯定不会像var i int = 5那么简单,那么接口类型变量赋值的过程是怎样的呢?其实接口类型变量赋值是一个“装箱”的过程。

装箱(boxing)是编程语言领域的一个基础概念,一般是指把一个值类型转换成引用类型,比如在支持装箱概念的 Java 语言中,将一个 int 变量转换成 Integer 对象就是一个装箱操作。

在 Go 语言中,将任意类型赋值给一个接口类型变量也是装箱操作。接口类型的装箱实际就是创建一个 eface 或 iface 的过程。

// interface_internal.go
    type T struct {
        n int
        s string
    }
    func (T) M1() {}
    func (T) M2() {}
    type NonEmptyInterface interface {
        M1()
        M2()
    }

    func main() {
        var t = T{
            n: 17,
            s: "hello, interface",
        }
        var ei interface{}
        ei = t

        var i NonEmptyInterface
        i = t
        fmt.Println(ei)
        fmt.Println(i)
    }

对 ei 和 i 两个接口类型变量的赋值都会触发装箱操作,要想知道 Go 在背后做了些什么,我们需要“下沉”一层,也就是要输出上面 Go 代码对应的汇编代码:

$go tool compile -S interface_internal.go > interface_internal.s

对应ei = t一行的汇编如下:

0x0026 00038 (interface_internal.go:24) MOVQ    $17, ""..autotmp_15+104(SP)
0x002f 00047 (interface_internal.go:24) LEAQ    go.string."hello, interface"(SB), CX
0x0036 00054 (interface_internal.go:24) MOVQ    CX, ""..autotmp_15+112(SP)
0x003b 00059 (interface_internal.go:24) MOVQ    $16, ""..autotmp_15+120(SP)
0x0044 00068 (interface_internal.go:24) LEAQ    type."".T(SB), AX
0x004b 00075 (interface_internal.go:24) LEAQ    ""..autotmp_15+104(SP), BX
0x0050 00080 (interface_internal.go:24) PCDATA  $1, $0
0x0050 00080 (interface_internal.go:24) CALL    runtime.convT2E(SB)

对应 i = t 一行的汇编如下:

0x005f 00095 (interface_internal.go:27) MOVQ    $17, ""..autotmp_15+104(SP)
0x0068 00104 (interface_internal.go:27) LEAQ    go.string."hello, interface"(SB), CX
0x006f 00111 (interface_internal.go:27) MOVQ    CX, ""..autotmp_15+112(SP)
0x0074 00116 (interface_internal.go:27) MOVQ    $16, ""..autotmp_15+120(SP)
0x007d 00125 (interface_internal.go:27) LEAQ    go.itab."".T,"".NonEmptyInterface(SB), AX
0x0084 00132 (interface_internal.go:27) LEAQ    ""..autotmp_15+104(SP), BX
0x0089 00137 (interface_internal.go:27) PCDATA  $1, $1
0x0089 00137 (interface_internal.go:27) CALL    runtime.convT2I(SB)

在将动态类型变量赋值给接口类型变量语句对应的汇编代码中,我们看到了 convT2E和convT2I两个 runtime 包的函数。这两个函数的实现位于 $GOROOT/src/runtime/iface.go中:

// $GOROOT/src/runtime/iface.go
func convT2E(t *_type, elem unsafe.Pointer) (e eface) {
    if raceenabled {
        raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2E))
    }
    if msanenabled {
        msanread(elem, t.size)
    }
    x := mallocgc(t.size, t, true)
    typedmemmove(t, x, elem)
    e._type = t
    e.data = x
    return
}

func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
    t := tab._type
    if raceenabled {
        raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2I))
    }
    if msanenabled {
        msanread(elem, t.size)
    }
    x := mallocgc(t.size, t, true)
    typedmemmove(t, x, elem)
    i.tab = tab
    i.data = x
    return
}

convT2E 用于将任意类型转换为一个 eface,convT2I 用于将任意类型转换为一个 iface。两个函数的实现逻辑相似,主要思路就是根据传入的类型信息(convT2E 的 _type 和 convT2I 的 tab._type)分配一块内存空间,并将 elem 指向的数据拷贝到这块内存空间中,最后传入的类型信息作为返回值结构中的类型信息,返回值结构中的数据指针(data)指向新分配的那块内存空间。

那么 convT2E 和 convT2I 函数的类型信息是从何而来的呢?

其实这些都依赖 Go 编译器的工作。编译器知道每个要转换为接口类型变量(toType)和动态类型变量的类型(fromType),它会根据这一对类型选择适当的 convT2X 函数,并在生成代码时使用选出的 convT2X 函数参与装箱操作。

不过,装箱是一个有性能损耗的操作,因此 Go 也在不断对装箱操作进行优化,包括对常见类型如整型、字符串、切片等提供系列快速转换函数:

// $GOROOT/src/runtime/iface.go
func convT16(val any) unsafe.Pointer     // val must be uint16-like
func convT32(val any) unsafe.Pointer     // val must be uint32-like
func convT64(val any) unsafe.Pointer     // val must be uint64-like
func convTstring(val any) unsafe.Pointer // val must be a string
func convTslice(val any) unsafe.Pointer  // val must be a slice

这些函数去除了 typedmemmove 操作,增加了零值快速返回等特性。

对255以下小整数的装箱操作,参看 staticuint64s区域

Viewpoint #

From #

29|接口:为什么nil接口不等于nil?