Go 接口:nil接口为什么不等于nil?

本文主要内容:深入了解接口类型的运行时表示层。

文章目录

    • 一、Go 接口的地位
    • 二、接口的静态特性与动态特性
      • 2.1 接口的静态特性与动态特性介绍
      • 2.2 “动静皆备”的特性的好处
    • 三、nil error 值 != nil
    • 四、接口类型变量的内部表示
      • 第一种:nil 接口变量
      • 第二种:空接口类型变量
      • 第三种:非空接口类型变量
      • 第四种:空接口类型变量与非空接口类型变量的等值比较
    • 五、输出接口类型变量内部表示的详细信息
    • 六、接口类型的装箱(boxing)原理
    • 七、小结

一、Go 接口的地位

Go 语言核心团队的技术负责人 Russ Cox 也曾说过这样一句话:“如果要从 Go 语言中挑选出一个特性放入其他语言,我会选择接口”,这句话足以说明接口这一语法特性在这位 Go 语言大神心目中的地位。

为什么接口在 Go 中有这么高的地位呢?这是因为接口是 Go 这门静态语言中唯一“动静兼备”的语法特性。而且,接口“动静兼备”的特性给 Go 带来了强大的表达能力,但同时也给 Go 语言初学者带来了不少困惑。要想真正解决这些困惑,我们必须深入到 Go 运行时层面,看看 Go 语言在运行时是如何表示接口类型的。

接下来,我们先来看看接口的静态与动态特性,看看“动静皆备”的含义。

二、接口的静态特性与动态特性

2.1 接口的静态特性与动态特性介绍

接口的静态特性体现在接口类型变量具有静态类型。

比如 var err error 中变量 err 的静态类型为 error。拥有静态类型,那就意味着编译器会在编译阶段对所有接口类型变量的赋值操作进行类型检查,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法。如果不满足,就会报错:

var err error = 1 // cannot use 1 (type int) as type error in assignment: int does not implement error (missing Error method)

**而接口的动态特性,就体现在接口类型变量在运行时还存储了右值的真实类型信息,这个右值的真实类型被称为接口类型变量的动态类型。例如,下面示例代码:

var err error
err = errors.New("error1")
fmt.Printf("%T\n", err)  // *errors.errorString

我们可以看到,这个示例通过 errros.New 构造了一个错误值,赋值给了 error 接口类型变量 err,并通过 fmt.Printf 函数输出接口类型变量 err 的动态类型为 *errors.errorString

2.2 “动静皆备”的特性的好处

首先,接口类型变量在程序运行时可以被赋值为不同的动态类型变量,每次赋值后,接口类型变量中存储的动态类型信息都会发生变化,这让 Go 语言可以像动态语言(比如 Python)那样拥有使用 Duck Typing(鸭子类型)的灵活性。所谓鸭子类型,就是指某类型所表现出的特性(比如是否可以作为某接口类型的右值),不是由其基因(比如 C++ 中的父类)决定的,而是由类型所表现出来的行为(比如类型拥有的方法)决定的。

比如下面的例子:

type QuackableAnimal interface {Quack()
}type Duck struct{}func (Duck) Quack() {println("duck quack!")
}type Dog struct{}func (Dog) Quack() {println("dog quack!")
}type Bird struct{}func (Bird) Quack() {println("bird quack!")
}                         func AnimalQuackInForest(a QuackableAnimal) {a.Quack()             
}                         func main() {             animals := []QuackableAnimal{new(Duck), new(Dog), new(Bird)}for _, animal := range animals {AnimalQuackInForest(animal)}  
}

这个例子中,我们用接口类型 QuackableAnimal 来代表具有“会叫”这一特征的动物,而 DuckBirdDog 类型各自都具有这样的特征,于是我们可以将这三个类型的变量赋值给 QuackableAnimal 接口类型变量 a。每次赋值,变量 a 中存储的动态类型信息都不同,Quack 方法的执行结果将根据变量 a 中存储的动态类型信息而定。

这里的 DuckBirdDog 都是“鸭子类型”,但它们之间并没有什么联系,之所以能作为右值赋值给 QuackableAnimal 类型变量,只是因为他们表现出了 QuackableAnimal 所要求的特征罢了。

不过,与动态语言不同的是,Go 接口还可以保证“动态特性”使用时的安全性。比如,编译器在编译期就可以捕捉到将 int 类型变量传给 QuackableAnimal 接口类型变量这样的明显错误,决不会让这样的错误遗漏到运行时才被发现。

接口类型的动静特性展示了其强大的一面,然而在日常使用中,对Gopher常常困惑与“nil 的 error 值不等于 nil”。下面我们来详细看一下。

三、nil error 值 != nil

我们先来看一段改编自GO FAQ 中的例子的代码:

type MyError struct {error
}var ErrBad = MyError{error: errors.New("bad things happened"),
}func bad() bool {return false
}func returnsError() error {var p *MyError = nilif bad() {p = &ErrBad}return p
}func main() {err := returnsError()if err != nil {fmt.Printf("error occur: %+v\n", err)return}fmt.Println("ok")
}

在这个例子中,我们的关注点集中在 returnsError 这个函数上面。这个函数定义了一个 *MyError 类型的变量 p,初值为 nil。如果函数 bad 返回 falsereturnsError 函数就会直接将 p(此时 p = nil)作为返回值返回给调用者,之后调用者会将 returnsError 函数的返回值(error 接口类型)与 nil 进行比较,并根据比较结果做出最终处理。

我们运行这段程序后,输出如下:

error occur: <nil>

按照预期:程序执行应该是pnilreturnsError 返回 p,那么 main 函数中的 err 就等于 nil,于是程序输出 ok 后退出。但是我们看到,示例程序并未按照预期,程序显然是进入了错误处理分支,输出了 err 的值。那这里就有一个问题了:明明 returnsError 函数返回的 p 值为 nil,为什么却满足了 if err != nil 的条件进入错误处理分支呢?

为了弄清楚这个问题,我们来了解接口类型变量的内部表示。

四、接口类型变量的内部表示

接口类型“动静兼备”的特性也决定了它的变量的内部表示绝不像一个静态类型变量(如 intfloat64)那样简单,我们可以在 $GOROOT/src/runtime/runtime2.go 中找到接口类型变量在运行时的表示:

// $GOROOT/src/runtime/runtime2.go
type iface struct {tab  *itabdata unsafe.Pointer
}type eface struct {_type *_typedata  unsafe.Pointer
}

我们看到,在运行时层面,接口类型变量有两种内部表示:ifaceeface,这两种表示分别用于不同的接口类型变量:

  • eface 用于表示没有方法的空接口(empty interface)类型变量,也就是 interface{} 类型的变量;
  • iface 用于表示其余拥有方法的接口 interface 类型变量。

这两个结构的共同点是它们都有两个指针字段,并且第二个指针字段的功能相同,都是指向当前赋值给该接口类型变量的动态类型变量的值。

那它们的不同点在哪呢?就在于 eface 表示的空接口类型并没有方法列表,因此它的第一个指针字段指向一个 _type 类型结构,这个结构为该接口类型变量的动态类型的信息,它的定义是这样的:

// $GOROOT/src/runtime/type.gotype _type struct {size       uintptrptrdata    uintptr // size of memory prefix holding all pointershash       uint32tflag      tflagalign      uint8fieldAlign uint8kind       uint8// function for comparing objects of this type// (ptr to object A, ptr to object B) -> ==?equal func(unsafe.Pointer, unsafe.Pointer) bool// gcdata stores the GC type data for the garbage collector.// If the KindGCProg bit is set in kind, gcdata is a GC program.// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.gcdata    *bytestr       nameOffptrToThis typeOff
}

iface 除了要存储动态类型信息之外,还要存储接口本身的信息(接口的类型信息、方法列表信息等)以及动态类型所实现的方法的信息,因此 iface 的第一个字段指向一个 itab 类型结构。itab 结构的定义如下:

// $GOROOT/src/runtime/runtime2.go
type itab struct {inter *interfacetype_type *_typehash  uint32 // copy of _type.hash. Used for type switches._     [4]bytefun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

这里我们也可以看到,itab 结构中的第一个字段 inter 指向的 interfacetype 结构,存储着这个接口类型自身的信息。你看一下下面这段代码表示的 interfacetype 类型定义,这个 interfacetype 结构由类型信息(typ)、包路径名(pkgpath)和接口方法集合切片(mhdr)组成。

// $GOROOT/src/runtime/type.go
type interfacetype struct {typ     _typepkgpath namemhdr    []imethod
}

itab 结构中的字段 _type 则存储着这个接口类型变量的动态类型的信息,字段 fun 则是动态类型已实现的接口方法的调用地址数组。

下面我们再结合例子用图片来直观展现 efaceiface 的结构。首先我们看一个用 eface 表示的空接口类型变量的例子:

type T struct {n ints string
}func main() {var t = T {n: 17,s: "hello, interface",}var ei interface{} = t // Go运行时使用eface结构表示ei
}

这个例子中的空接口类型变量 ei 在 Go 运行时的表示是这样的:

WechatIMG274

我们看到空接口类型的表示较为简单,图中上半部分 _type 字段指向它的动态类型 T 的类型信息,下半部分的 data 则是指向一个 T 类型的实例值。

我们再来看一个更复杂的用 iface 表示非空接口类型变量的例子:

type T struct {n ints string
}func (T) M1() {}
func (T) M2() {}type NonEmptyInterface interface {M1()M2()
}func main() {var t = T{n: 18,s: "hello, interface",}var i NonEmptyInterface = t
}

eface 比起来,iface 的表示稍微复杂些。我也画了一幅表示上面 NonEmptyInterface 接口类型变量在 Go 运行时表示的示意图:

WechatIMG275

由上面的这两幅图,我们可以看出,每个接口类型变量在运行时的表示都是由两部分组成的,针对不同接口类型我们可以简化记作:eface(_type, data)iface(tab, data)

而且,虽然 efaceiface 的第一个字段有所差别,但 tab_type 可以统一看作是动态类型的类型信息。Go 语言中每种类型都会有唯一的 _type 信息,无论是内置原生类型,还是自定义类型都有。Go 运行时会为程序内的全部类型建立只读的共享 _type 信息表,因此拥有相同动态类型的同类接口类型变量的 _type/tab 信息是相同的。

而接口类型变量的 data 部分则是指向一个动态分配的内存空间,这个内存空间存储的是赋值给接口类型变量的动态类型变量的值。未显式初始化的接口类型变量的值为nil,也就是这个变量的 _type/tab 和 data 都为 nil。

也就是说,我们判断两个接口类型变量是否相等,只需判断 _type/tab 以及 data 是否都相等即可。两个接口变量的 _type/tab 不同时,即两个接口变量的动态类型不相同时,两个接口类型变量一定不等。

当两个接口变量的 _type/tab 相同时,对 data 的相等判断要有区分。当接口变量的动态类型为指针类型时 (*T),Go 不会再额外分配内存存储指针值,而会将动态类型的指针值直接存入 data 字段中,这样 data 值的相等性决定了两个接口类型变量是否相等;当接口变量的动态类型为非指针类型 (T) 时,我们判断的将不是 data 指针的值是否相等,而是判断 data 指针指向的内存空间所存储的数据值是否相等,若相等,则两个接口类型变量相等。

不过,通过肉眼去辨别接口类型变量是否相等总是困难一些,我们可以引入一些 helper 函数。借助这些函数,我们可以清晰地输出接口类型变量的内部表示,这样就可以一目了然地看出两个变量是否相等了。

由于 efaceifaceruntime 包中的非导出结构体定义,我们不能直接在包外使用,所以也就无法直接访问到两个结构体中的数据。不过,Go 语言提供了 println 预定义函数,可以用来输出 efaceiface 的两个指针字段的值。

在编译阶段,编译器会根据要输出的参数的类型将 println 替换为特定的函数,这些函数都定义在 $GOROOT/src/runtime/print.go 文件中,而针对 efaceiface 类型的打印函数实现如下:

// $GOROOT/src/runtime/print.go
func printeface(e eface) {print("(", e._type, ",", e.data, ")")
}func printiface(i iface) {print("(", i.tab, ",", i.data, ")")
}

我们看到,printefaceprintiface 会输出各自的两个指针字段的值。下面我们就来使用 println 函数输出各类接口类型变量的内部表示信息,并结合输出结果,解析接口类型变量的等值比较操作。

第一种:nil 接口变量

我们知道,未赋初值的接口类型变量的值为 nil,这类变量也就是 nil 接口变量,我们来看这类变量的内部表示输出的例子:

func printNilInterface() {// nil接口变量var i interface{} // 空接口类型var err error     // 非空接口类型println(i)println(err)println("i = nil:", i == nil)println("err = nil:", err == nil)println("i = err:", i == err)
}

运行这个函数,输出结果是这样的:

(0x0,0x0)
(0x0,0x0)
i = nil: true
err = nil: true
i = err: true

我们看到,无论是空接口类型还是非空接口类型变量,一旦变量值为 nil,那么它们内部表示均为 (0x0, 0x0),也就是类型信息、数据值信息均为空。因此上面的变量 ierr 等值判断为 true

第二种:空接口类型变量

下面是空接口类型变量的内部表示输出的例子:

  func printEmptyInterface() {var eif1 interface{} // 空接口类型var eif2 interface{} // 空接口类型var n, m int = 17, 18eif1 = neif2 = mprintln("eif1:", eif1)println("eif2:", eif2)println("eif1 = eif2:", eif1 == eif2) // falseeif2 = 17println("eif1:", eif1)println("eif2:", eif2)println("eif1 = eif2:", eif1 == eif2) // trueeif2 = int64(17)println("eif1:", eif1)println("eif2:", eif2)println("eif1 = eif2:", eif1 == eif2) // false}

这个例子的运行输出结果是这样的:

eif1: (0x10ac580,0xc00007ef48)
eif2: (0x10ac580,0xc00007ef40)
eif1 = eif2: false
eif1: (0x10ac580,0xc00007ef48)
eif2: (0x10ac580,0x10eb3d0)
eif1 = eif2: true
eif1: (0x10ac580,0xc00007ef48)
eif2: (0x10ac640,0x10eb3d8)
eif1 = eif2: false

我们按顺序分析一下这个输出结果。

首先,代码执行到第 11 行时,eif1eif2 已经分别被赋值整型值 1718,这样 eif1eif2 的动态类型的类型信息是相同的(都是 0x10ac580),但 data 指针指向的内存块中存储的值不同,一个是 17,一个是 18,于是 eif1 不等于 eif2

接着,代码执行到第 16 行的时候,eif2 已经被重新赋值为 17,这样 eif1eif2 不仅存储的动态类型的类型信息是相同的(都是 0x10ac580),data 指针指向的内存块中存储值也相同了,都是 17,于是 eif1 等于 eif2

然后,代码执行到第 21 行时,eif2 已经被重新赋值了 int64 类型的数值 17。这样,eif1eif2 存储的动态类型的类型信息就变成不同的了,一个是 int,一个是 int64,即便 data 指针指向的内存块中存储值是相同的,最终 eif1eif2 也是不相等的。

第三种:非空接口类型变量

这里,我们也直接来看一个非空接口类型变量的内部表示输出的例子:

type T intfunc (t T) Error() string { return "bad error"
}func printNonEmptyInterface() { var err1 error // 非空接口类型var err2 error // 非空接口类型err1 = (*T)(nil)println("err1:", err1)println("err1 = nil:", err1 == nil)err1 = T(5)err2 = T(6)println("err1:", err1)println("err2:", err2)println("err1 = err2:", err1 == err2)err2 = fmt.Errorf("%d\n", 5)println("err1:", err1)println("err2:", err2)println("err1 = err2:", err1 == err2)
}   

这个例子的运行输出结果如下:

err1: (0x10ed120,0x0)
err1 = nil: false
err1: (0x10ed1a0,0x10eb310)
err2: (0x10ed1a0,0x10eb318)
err1 = err2: false
err1: (0x10ed1a0,0x10eb310)
err2: (0x10ed0c0,0xc000010050)
err1 = err2: false

我们看到上面示例中每一轮通过 println 输出的 err1err2tabdata 值,要么 data 值不同,要么 tabdata 值都不同。

和空接口类型变量一样,只有 tabdata 指的数据内容一致的情况下,两个非空接口类型变量之间才能划等号。这里我们要注意 err1 下面的赋值情况:

err1 = (*T)(nil)

针对这种赋值,println 输出的 err1 是(0x10ed120, 0x0),也就是非空接口类型变量的类型信息并不为空,数据指针为空,因此它与 nil0x0, 0x0)之间不能划等号。

现在我们再回到我们开头的那个问题,你是不是已经豁然开朗了呢?开头的问题中,从 returnsError 返回的 error 接口类型变量 err 的数据指针虽然为空,但它的类型信息(iface.tab)并不为空,而是 *MyError 对应的类型信息,这样 errnil0x0,0x0)相比自然不相等,这就是我们开头那个问题的答案解析,现在你明白了吗?

第四种:空接口类型变量与非空接口类型变量的等值比较

下面是非空接口类型变量和空接口类型变量之间进行比较的例子:

func printEmptyInterfaceAndNonEmptyInterface() {var eif interface{} = T(5)var err error = T(5)println("eif:", eif)println("err:", err)println("eif = err:", eif == err)err = T(6)println("eif:", eif)println("err:", err)println("eif = err:", eif == err)
}

这个示例的输出结果如下:

eif: (0x10b3b00,0x10eb4d0)
err: (0x10ed380,0x10eb4d8)
eif = err: true
eif: (0x10b3b00,0x10eb4d0)
err: (0x10ed380,0x10eb4e0)
eif = err: false

你可以看到,空接口类型变量和非空接口类型变量内部表示的结构有所不同(第一个字段:_type vs. tab),两者似乎一定不能相等。但 Go 在进行等值比较时,类型比较使用的是 eface_typeifacetab._type,因此就像我们在这个例子中看到的那样,当 eiferr 都被赋值为 T(5) 时,两者之间是划等号的。

好了,到这里,我们已经理解了各类接口类型变量在运行时层的表示。我们可以通过 println 可以查看这个表示信息,从中我们也知道了接口变量只有在类型信息与值信息都一致的情况下才能划等号。

五、输出接口类型变量内部表示的详细信息

不过,println 输出的接口类型变量的内部表示信息,在一般情况下都是足够的,但有些时候又显得过于简略,比如在上面最后一个例子中,如果仅凭 eif: (0x10b3b00,0x10eb4d0)err: (0x10ed380,0x10eb4d8) 的输出,我们是无法想到两个变量是相等的。

那这时如果我们能输出接口类型变量内部表示的详细信息(比如:tab._type),那势必可以取得事半功倍的效果。接下来我们就看看这要怎么做。

前面提到过,efaceiface 以及组成它们的 itab_type 都是 runtime 包下的非导出结构体,我们无法在外部直接引用它们。但我们发现,组成 efaceiface 的类型都是基本数据类型,我们完全可以通过“复制代码”的方式将它们拿到 runtime 包外面来。

不过,这里要注意,由于 runtime 中的 efaceiface,或者它们的组成可能会随着 Go 版本的变化发生变化,因此这个方法不具备跨版本兼容性。也就是说,基于 Go 1.17 版本复制的代码,可能仅适用于使用 Go 1.17 版本编译。这里我们就以 Go 1.17 版本为例看看:

// dumpinterface.go 
type eface struct {_type *_typedata  unsafe.Pointer
}type tflag uint8
type nameOff int32
type typeOff int32type _type struct {size       uintptrptrdata    uintptr // size of memory prefix holding all pointershash       uint32tflag      tflagalign      uint8fieldAlign uint8kind       uint8// function for comparing objects of this type// (ptr to object A, ptr to object B) -> ==?equal func(unsafe.Pointer, unsafe.Pointer) bool// gcdata stores the GC type data for the garbage collector.// If the KindGCProg bit is set in kind, gcdata is a GC program.// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.gcdata    *bytestr       nameOffptrToThis typeOff
}type iface struct {tab  *itabdata unsafe.Pointer
}type itab struct {inter *interfacetype_type *_typehash  uint32 // copy of _type.hash. Used for type switches._     [4]bytefun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}... ...const ptrSize = unsafe.Sizeof(uintptr(0))func dumpEface(i interface{}) {ptrToEface := (*eface)(unsafe.Pointer(&i))fmt.Printf("eface: %+v\n", *ptrToEface)if ptrToEface._type != nil {// dump _type infofmt.Printf("\t _type: %+v\n", *(ptrToEface._type))}if ptrToEface.data != nil {// dump dataswitch i.(type) {case int:dumpInt(ptrToEface.data)case float64:dumpFloat64(ptrToEface.data)case T:dumpT(ptrToEface.data)// other cases ... ...default:fmt.Printf("\t unsupported data type\n")}}fmt.Printf("\n")
}func dumpItabOfIface(ptrToIface unsafe.Pointer) {p := (*iface)(ptrToIface)fmt.Printf("iface: %+v\n", *p)if p.tab != nil {// dump itabfmt.Printf("\t itab: %+v\n", *(p.tab))// dump inter in itabfmt.Printf("\t\t inter: %+v\n", *(p.tab.inter))// dump _type in itabfmt.Printf("\t\t _type: %+v\n", *(p.tab._type))// dump fun in tabfunPtr := unsafe.Pointer(&(p.tab.fun))fmt.Printf("\t\t fun: [")for i := 0; i < len((*(p.tab.inter)).mhdr); i++ {tp := (*uintptr)(unsafe.Pointer(uintptr(funPtr) + uintptr(i)*ptrSize))fmt.Printf("0x%x(%d),", *tp, *tp)}fmt.Printf("]\n")}
}func dumpDataOfIface(i interface{}) {// this is a trick as the data part of eface and iface are sameptrToEface := (*eface)(unsafe.Pointer(&i))if ptrToEface.data != nil {// dump dataswitch i.(type) {case int:dumpInt(ptrToEface.data)case float64:dumpFloat64(ptrToEface.data)case T:dumpT(ptrToEface.data)// other cases ... ...default:fmt.Printf("\t unsupported data type\n")}}fmt.Printf("\n")
}func dumpT(dataOfIface unsafe.Pointer) {var p *T = (*T)(dataOfIface)fmt.Printf("\t data: %+v\n", *p)
}
... ...

这里只挑选了关键部分,省略了部分代码。上面这个 dumpinterface.go 中提供了三个主要函数:

  • dumpEface: 用于输出空接口类型变量的内部表示信息;
  • dumpItabOfIface: 用于输出非空接口类型变量的 tab 字段信息;
  • dumpDataOfIface: 用于输出非空接口类型变量的 data 字段信息;

我们利用这三个函数来输出一下前面 printEmptyInterfaceAndNonEmptyInterface 函数中的接口类型变量的信息:

package mainimport "unsafe"type T intfunc (t T) Error() string {return "bad error"
}func main() {var eif interface{} = T(5)var err error = T(5)println("eif:", eif)println("err:", err)println("eif = err:", eif == err)dumpEface(eif)dumpItabOfIface(unsafe.Pointer(&err))dumpDataOfIface(err)
}

运行这个示例代码,我们得到了这个输出结果:

eif: (0x10b38c0,0x10e9b30)
err: (0x10eb690,0x10e9b30)
eif = err: true
eface: {_type:0x10b38c0 data:0x10e9b30}_type: {size:8 ptrdata:0 hash:1156555957 tflag:15 align:8 fieldAlign:8 kind:2 equal:0x10032e0 gcdata:0x10e9a60 str:4946 ptrToThis:58496}data: bad erroriface: {tab:0x10eb690 data:0x10e9b30}itab: {inter:0x10b5e20 _type:0x10b38c0 hash:1156555957 _:[0 0 0 0] fun:[17454976]}inter: {typ:{size:16 ptrdata:16 hash:235953867 tflag:7 align:8 fieldAlign:8 kind:20 equal:0x10034c0 gcdata:0x10d2418 str:3666 ptrToThis:26848} pkgpath:{bytes:<nil>} mhdr:[{name:2592 ityp:43520}]}_type: {size:8 ptrdata:0 hash:1156555957 tflag:15 align:8 fieldAlign:8 kind:2 equal:0x10032e0 gcdata:0x10e9a60 str:4946 ptrToThis:58496}fun: [0x10a5780(17454976),]data: bad error

从输出结果中,我们看到 eif_type0x10b38c0)与 errtab._type0x10b38c0)是一致的,data 指针所指内容(“bad error”)也是一致的,因此 eif == err 表达式的结果为 true

再次强调一遍,上面这个实现可能仅在 Go 1.17 版本上测试通过,并且在输出 ifaceefacedata 部分内容时只列出了 intfloat64T 类型的数据读取实现,没有列出全部类型的实现,你可以根据自己的需要实现其余数据类型。dumpinterface.go 的完整代码你可以在这里找到。

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

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

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

在 Go 语言中,将任意类型赋值给一个接口类型变量也是装箱操作。有了前面对接口类型变量内部表示的学习,我们知道接口类型的装箱实际就是创建一个 efaceiface 的过程。接下来我们就来简要描述一下这个过程,也就是接口类型的装箱原理。

我们基于下面这个例子中的接口装箱操作来说明:

// interface_internal.gotype T struct {n ints 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 = tvar i NonEmptyInterfacei = tfmt.Println(ei)fmt.Println(i)}

这个例子中,对 eii 两个接口类型变量的赋值都会触发装箱操作,要想知道 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), CX0x0036 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), AX0x004b 00075 (interface_internal.go:24) LEAQ    ""..autotmp_15+104(SP), BX0x0050 00080 (interface_internal.go:24) PCDATA  $1, $00x0050 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), CX0x006f 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), AX0x0084 00132 (interface_internal.go:27) LEAQ    ""..autotmp_15+104(SP), BX0x0089 00137 (interface_internal.go:27) PCDATA  $1, $10x0089 00137 (interface_internal.go:27) CALL    runtime.convT2I(SB)

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

// $GOROOT/src/runtime/iface.gofunc 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 = te.data = xreturn
}func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {t := tab._typeif 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 = tabi.data = xreturn
}

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

由此我们也可以看出,经过装箱后,箱内的数据,也就是存放在新分配的内存空间中的数据与原变量便无瓜葛了,比如下面这个例子:

func main() {var n int = 61var ei interface{} = nn = 62  // n的值已经改变fmt.Println("data in box:", ei) // 输出仍是61
}

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

其实这些都依赖 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 操作,增加了零值快速返回等特性。

同时 Go 建立了 staticuint64s 区域,对 255 以内的小整数值进行装箱操作时不再分配新内存,而是利用 staticuint64s 区域的内存空间,下面是 staticuint64s 的定义:

// $GOROOT/src/runtime/iface.go
// staticuint64s is used to avoid allocating in convTx for small integer values.
var staticuint64s = [...]uint64{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,... ...
}

七、小结

接口类型作为参与构建 Go 应用骨架的重要参与者,在 Go 语言中有着很高的地位。它这个地位的取得离不开它拥有的“动静兼备”的语法特性。Go 接口的动态特性让 Go 拥有与动态语言相近的灵活性,而静态特性又在编译阶段保证了这种灵活性的安全。

要更好地理解 Go 接口的这两种特性,我们需要深入到 Go 接口在运行时的表示层面上去。接口类型变量在运行时表示为 efaceifaceeface 用于表示空接口类型变量,iface 用于表示非空接口类型变量。只有两个接口类型变量的类型信息(eface._type/iface.tab._type)相同,且数据指针(eface.data/iface.data)所指数据相同时,两个接口类型变量才是相等的。

我们可以通过 println 输出接口类型变量的两部分指针变量的值。而且,通过拷贝 runtimeefaceiface 相关类型源码,我们还可以自定义输出 eface/iface 详尽信息的函数,不过要注意的是,由于 runtime 层代码的演进,这个函数可能不具备在 Go 版本间的移植性。

最后,接口类型变量的赋值本质上是一种装箱操作,装箱操作是由 Go 编译器和运行时共同完成的,有一定的性能开销,对于性能敏感的系统来说,我们应该尽量避免或减少这类装箱操作。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/186050.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

自动驾驶算法(九):多项式轨迹与Minimun Snap原理与Matab代码详解

目录 1 为什么需要轨迹优化 2 代码解析 3 完整代码 1 为什么需要轨迹优化 我们利用前八篇所学的博客可以利用RRT、A*、遗传算法等设计出一条折线轨迹&#xff0c;轨迹优化就是在路径优化的基础上将折线优化成曲线&#xff0c;这样更加有利于无人机的飞行。 那么什么是多项式轨…

Mybatis(一)

1. Mybatis简介 MyBatis下载地址 1.1 MyBatis历史 MyBatis最初是Apache的一个开源项目iBatis, 2010年6月这个项目由Apache Software Foundation迁移到了Google Code。随着开发团队转投Google Code旗下&#xff0c;iBatis3.x正式更名为MyBatis。代码于2013年11月迁移到Github…

Go语言的Json序列化与反序列化、Goto语法、Tcp Socket通信

目录标题 一、Json序列化与反序列化1. 序列化2. 反序列化 二、Goto语法三、Tcp Socket1. 单客户端发送信息到服务端2. 服务端客户端通信 一、Json序列化与反序列化 1. 序列化 package mainimport ("encoding/json""fmt")type Person struct {Name string…

【c趣编程】输入一个整数,判断其有几位

目录 1题目要求&#xff1a; 2解题思路&#xff1a; 3代码如下所示&#xff1a; 4运行代码如下&#xff1a; 5总结&#xff1a; 1题目要求&#xff1a; 只用一个scanf输出一串数&#xff0c;不可以一个一个的输入并计数&#xff0c;那样太浪费时间了。 C语言是一门面向过…

GoLong的学习之路(二十二)进阶,语法之并发(go最重要的特点)(channel的主要用法)

这一章是接上一章内容继续&#xff0c;上一章说到协程也就是goroutine&#xff0c;如何使用它&#xff0c;这一张是讲一种数据结构。当然这个章节的数据结构非常重要。可以说这个数据结构就是为了方便协程&#xff0c;才制作出来的。 单纯地将函数并发执行是没有意义的。函数与…

MySQL -- 用户管理

MySQL – 用户管理 文章目录 MySQL -- 用户管理一、用户1.用户信息2.创建用户3.删除用户4.远端登录MySQL5.修改用户密码6.数据库的权限 一、用户 1.用户信息 MySQL中的用户&#xff0c;都存储在系统数据库mysql的user表中&#xff1a; host&#xff1a; 表示这个用户可以从…

河南开放大学与电大搜题微信公众号:携手共进,助力学习之路

作为河南省内颇具影响力和声誉的高等教育机构之一&#xff0c;河南开放大学一直致力于提供优质的教育资源和灵活的学习方式&#xff0c;以满足广大学习者的需求。而在这个追求知识的时代&#xff0c;学习者们尤其需要一个便捷、高效的工具来辅助学习。电大搜题微信公众号应运而…

持续集成交付CICD:安装Gitlab Runner(从节点)

目录 一、实验 1.选择Gitlab Runner版本 2.安装Gitlab Runner&#xff08;第一种方式&#xff1a;交互式安装&#xff09; 3.安装Gitlab Runner&#xff08;第二种方式&#xff1a;非交互式安装&#xff09; 二、问题 1.如何查看Gitlab版本 一、实验 1.选择Gitlab Runne…

Web APIs——BOM和延迟函数setTimeout

1、window对象 1.1 BOM&#xff08;浏览器对象模型&#xff09; BOM&#xff08;Browser Object Model&#xff09;是浏览器对象模型 window对象是一个全局对象&#xff0c;也可以说是JavaScript中的顶级对象像document、alert()、console.log()这些都是window的属性&#xf…

windows11使用docker部署安装minio

时间 2023-11-08 windows11使用docker部署安装minio 目录 1.docker 下载镜像2.docker安装镜像3.访问控制台4.安装问题解决5.使用教程 1.docker 下载镜像 调整镜像源到国内&#xff0c;否则会很慢 docker pull minio/minio2.docker安装镜像 设置用户名和密码时需要注意&…

【好书推荐】计算机考研精炼1000题——考研408不可或缺

《计算机考研精炼1000题》简介 本书根据最新《全国硕士研究生招生考试计算机学科专业基础考试大纲》编写。参考过去十多年的真题&#xff0c;本书精心编排了单项选择题和综合应用题&#xff0c;共约1000道&#xff08;分为上下两册&#xff0c;共24章。上册&#xff08;1&#…

Spring基础(1):两个概念

最近看了点Spring的源码&#xff0c;于是来稍微扯一扯&#xff0c;希望能帮一部分培训班出身的朋友撕开一道口子&#xff0c;透透气。 广义上的Spring指的是Spring整个项目&#xff0c;包含SpringBoot、SpringCloud、SpringFramework、SpringData等等&#xff0c; 本系列文章…

sql学习

因为之前sql学的太烂了&#xff0c;想整理一下 一.什么是 SQL&#xff1f; SQL 是用于访问和处理数据库的标准的计算机语言。 SQL 指结构化查询语言SQL 使我们有能力访问数据库SQL 是一种 标准计算机语言 二.SQL 能做什么&#xff1f; SQL 面向数据库执行查询SQL 可从数据库…

17. 机器学习 - 随机森林

Hi&#xff0c;你好。我是茶桁。 我们之前那一节课讲了决策树&#xff0c;说了决策树的优点&#xff0c;也说了其缺点。 决策树实现起来比较简单&#xff0c;解释解释性也比较强。但是它唯一的问题就是不能拟合比较复杂的关系。 后来人们为了解决这个问题&#xff0c;让其能…

华为荣耀软开秋招面经问题整理

一、八股 1.linux常用命令 Linux常用命令&#xff08;面试题&#xff09;_linux常用命令面试题-CSDN博客 常用命令、系统命令、打包命令、vim、开关机命令 2.socket通信调用api过程 TCP UDP 3.进程和线程的区别 进程是系统进行资源分配和调度的基本单元&#xff0c;线程…

初阶JavaEE(15)(Cookie 和 Session、理解会话机制 (Session)、实现用户登录网页、上传文件网页、常用的代码片段)

接上次博客&#xff1a;初阶JavaEE&#xff08;14&#xff09;表白墙程序-CSDN博客 Cookie 和 Session 你还记得我们之前提到的Cookie吗&#xff1f; Cookie是HTTP请求header中的一个属性&#xff0c;是一种用于在浏览器和服务器之间持久存储数据的机制&#xff0c;允许网站…

【Linux】初识进程地址空间

❤️前言 大家好&#xff01;这里是好久没有营业的大懒虫lion&#xff0c;今天要和大家聊的内容是我最近新学习的关于进程地址空间的相关知识。 正文 当我们使用C/C语言进行内存管理时&#xff0c;经常会接触到这样的一张图片&#xff1a; 它常常被我们称作程序地址空间&#…

Netty 是如何利用EventLoop实现千万级并发的

经过前面几篇文章的介绍&#xff0c;我们掌握了 Netty 的 5 个核心组件&#xff0c;但是有了这 5 个核心组件 Netty 这个工厂还是无法很好的运转&#xff0c;因为缺少了一个最核心的组件&#xff1a;EventLoop&#xff0c;它 是 Netty 中最最核心的组件&#xff0c;也是 Netty …

使用C++的QT框架实现五子棋

最近有点无聊正好想玩五子棋&#xff0c;那就实现一下这个游戏吧&#xff0c;网上的五子棋逻辑又长又复杂&#xff0c;我这个逻辑还是蛮简单的&#xff0c;展示如下&#xff08;检测函数在最后&#xff09; 这是一个简单的五子棋&#xff0c;今天就了解一下这个游戏的思路&…

机器学习——回归

目录 一、线性回归 1、回归的概念&#xff08;Regression、Prediction&#xff09; 2、符号约定 3、算法流程 4、最小二乘法&#xff08;LSM&#xff09; 二、梯度下降 梯度下降的三种形式 1、批量梯度下降&#xff08;Batch Gradient Descent,BGD&#xff09;&#xff…