Go语言学习笔记 -- 深入接口的内部实现

96 阅读7分钟

一段来自官方FAQ的有趣代码

FAQ: tip.golang.org/doc/faq#nil…

type MyError struct {
	error
}

func main() {
	myError := test()
	if myError != nil {
		fmt.Printf("error: %v\n", myError)
		return
	}
	fmt.Println("OK")
}

func test() error {
	var p *MyError = nil
	if false {
		p = &MyError{
			error: errors.New("bad error"),
		}
	}
	return p
}

对于Go语言的初学者,看到这段代码一定会一拍脑袋告诉我main()会打印 'OK' 然后退出;因为在test()函数中 if 的判断条件为 false,函数中除了 var p *MyError = nil没有再对 p 进行赋值,所以p == nil 是理所当然的,那么main()必定会打印 'OK' 然后退出。

真的是这样的么? 我们来看看这段代码的输出结果:

    $go run chris_go.go 
    error: <nil>

我们可以看到,程序并没有输出 OK 而是进入了错误的分支输出了 myError 的值。这个结果真的非常让人疑惑,p 明明没有被赋值,那它是在什么时候变成非 nil 了呢?

其实这个问题出在test()返回的error身上,它是个接口。所以为了弄清原因,我们必须先搞明白Go语言是如何实现接口的。

源码解读

在 Go 语言中接口类型是具备“动态”特征的。我们可以在runtime包下找到接口变量在运行的实现代码:runtime/runtime2.go

我们先找到它实现的两个关键的结构体

type iface struct {  
    tab *itab  
    data unsafe.Pointer  
}  
  
type eface struct {  
    _type *_type  
    data unsafe.Pointer  
}
  • eface:用于表示没有实现方法的空接口,也就是一般的interface{}类型的变量。
  • iface:用于表示其余拥有方法的接口类型变量。

我们可以看到他们的是有一个共同的字段的 data unsafe.Pointer它的作用是指向赋值给该接口类型变量的动态类型变量的值。

而它们的不同主要来自另一个字段,我们首先来分析一下eface。 这里的 _type *_type 实际就是这指向一个_type类型的结构体,它记录了接口类型变量的动态类型信息。 因为eface没有实现任何的函数,所以它除了需要记录本身的类型之外不需要再维护额外的其他信息了。

另外,在Go语言中每种类型都有唯一的_type,无论是内置类型还是自定义类型。Go语言在运行时会为程序内的全部类型建立只读的共享_type信息表,所以拥有相同动态类型的同类接口类型变量的_type/tab一定是相同的。 它的结构体如下:

// Needs to be in sync with ../cmd/link/internal/ld/decodesym.go:/^func.commonsize,  
// ../cmd/compile/internal/reflectdata/reflect.go:/^func.dcommontype and  
// ../reflect/type.go:/^type.rtype.  
// ../internal/reflectlite/type.go:/^type.rtype.  
type _type struct {  
    size uintptr  
    ptrdata uintptr // size of memory prefix holding all pointers  
    hash uint32  
    tflag tflag  
    align uint8  
    fieldAlign uint8  
    kind 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 *byte  
    str nameOff  
    ptrToThis typeOff  
}
  • size uintptr:表示该类型的大小(字节)。
  • ptrdata uintptr:表示存储所有指针的内存前缀的大小(字节)。
  • hash uint32:哈希值,用于类型的唯一标识。
  • tflag tflag:类型的标志位,用于描述类型的特性和行为。
  • align uint8:对齐方式,表示该类型在内存中的对齐方式。
  • fieldAlign uint8:字段对齐方式,表示该类型的字段在内存中的对齐方式。
  • kind uint8:类型的种类,用于区分不同的类型。
  • equal func(unsafe.Pointer, unsafe.Pointer) bool:用于比较该类型对象的函数。参数是两个指针,返回值是一个布尔值表示是否相等。
  • gcdata *byte:用于垃圾回收器的 GC 类型数据。如果 kind 中设置了 KindGCProg 标志位,则 gcdata 是一个 GC 程序;否则,它是一个 ptrmask 位图。具体细节可参考 mbitmap.go
  • str nameOff:表示类型名称的偏移量。
  • ptrToThis typeOff:指向该类型的指针的类型偏移量。

我们可以看到这些字段主要包含了如:大小、对齐方式、hash等,在运行时对类型进行描述和操作,_type 在GO语言的系统中是核心的组成部分,类型比较和反射等都通过它来实现。

而iface相比eface就复杂了很多,我们看到它除了维护_type之外,还额外维护了很多其他的信息。 如下:

// layout of Itab known to compilers  
// allocated in non-garbage-collected memory  
// Needs to be in sync with  
// ../cmd/compile/internal/reflectdata/reflect.go:/^func.WriteTabs.  
type itab struct {  
    inter *interfacetype  
    _type *_type  
    hash uint32 // copy of _type.hash. Used for type switches.  
    _ [4]byte  
    fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.  
}
  • inter *interfacetype:指向接口类型的指针。表示该 itab 对应的接口类型。
  • _type *_type:指向具体类型的 _type 结构体的指针。表示该 itab 对应的具体类型。
  • hash uint32:用于类型切换(type switch)的哈希值。它是 _type 结构体中 hash 字段的副本,用于快速比较类型。
  • _ [4]byte:占位符,没有实际意义。在 itab 的内存布局中留出 4 个字节的空间。
  • fun [1]uintptr:可变长度的函数指针数组,用于保存具体类型实现接口的方法。fun[0]==0 表示该 _type 结构体不实现对应的接口,否则就按照索引顺序保存实现的方法。

接口的比较

通过对结构的认知,我们了解到了接口运行时的数据结构。但如果要搞懂FAQ中的现象,我们需要继续向下探索。

我们来看看几种情况下的接口比较

func main() {  
    var i interface{} // eface  
    var err error // iface  
  
    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

这里看起来没有任何问题,函数内变量的地址均指向了(0x0,0x0)也就是nil值,他们是完全相等的。

我们再来看一组空实现接口变量的比较

func main() {
	// 空实现接口的比较
	var i1 interface{}
	var i2 interface{}

	var x, y = 1, 2

	i1 = x
	i2 = y

	println("i1", i1)
	println("i2", i2)
	println("i1 == i2", i1 == i2)

	i2 = 1
	println("i1", i1)
	println("i2", i2)
	println("i1 == i2", i1 == i2)

	i2 = int64(1)
	println("i1", i1)
	println("i2", i2)
	println("i1 == i2", i1 == i2)

}

输出结果:

i1 (0x102cd43e0,0x14000044760)
i2 (0x102cd43e0,0x14000044758)
i1 == i2 false
i1 (0x102cd43e0,0x14000044760)
i2 (0x102cd43e0,0x102ccea10)
i1 == i2 true
i1 (0x102cd43e0,0x14000044760)
i2 (0x102cd44a0,0x102ccea10)
i1 == i2 false

从这里我们可以观察到一些现象:

  • 第一组比较中两组eface他们的 _type 相同的,但他们的赋值给接口的变量值不同,同样地址也不同
  • 而在第二组比较中他们的 _type 相同的,并且他们的赋值给接口的变量值相同,只有地址也不同
  • 在第三组中他们的 _type 不同,但他们的赋值给接口的变量值相同,地址也不同

由此我们先得出一个粗浅的结论,及当 _type 相等,并且指针指向的值相等时,两个eface接口及相等。

最后我们再来看一组为空接口变量的比较

func main() {  

    var err1 error  
    var err2 error 
      
    err1 = T(5)  
    err2 = T(6)  
    println("err1:", err1)  
    println("err2:", err2)  
      
    println("1 = 2 :", err1 == err2)  
      
    err2 = T(5)  
    println("err1:", err1)  
    println("err2:", err2)  
      
    println("1 = 2 :", err1 == err2)  
}

输出结果:

err1: (0x102dfd968,0x102deeb48)
err2: (0x102dfd968,0x102deeb50)
1 = 2 : false
err1: (0x102dfd968,0x102deeb48)
err2: (0x102dfd968,0x102deeb48)
1 = 2 : true

从结果上来看iface与eface基本没有区别,对于iface来说itab相等,并且指针指向的值相等,两个iface接口就相等。

对FAQ中出现的现象的解释

至此,我们已经可以知道接口之间比较的规则了,那我们回到上文FAQ的那个问题,并加入一行代码来看看最后myError的内部信息到底是什么。

type MyError struct {
	error
}

func main() {
	myError := test()
   println("我们来看看 myError 的结构:", myError)
	if myError != nil {
		fmt.Printf("error: %v\n", myError)
		return
	}
	fmt.Println("OK")
}

func test() error {
	var p *MyError = nil
	if false {
		p = &MyError{
			error: errors.New("bad error"),
		}
	}
	return p
}

输出结果:

我们来看看 myError 的结构: (0x1048edec8,0x0)
error: <nil>

我们可以看到 test() return 的 error 接口的 itab 并不是一个空值,所以它与真正的 nil(0x0,0x0) 是不相等的。

在 test() 函数中 p 的类型是 MyError 结构体,它的值指向 (0x0),当 p 被 return 时,Go语言的编译器将p 赋值给了 error 接口,而此时 itab 会记录接口信息、MyError的类型以及接口的实现方法等信息,这就是这个问题的答案。