interface

Written by with ♥ on in Go

接口定义一组行为(方法集),实现这组行为的对象默认实现该接口(Duck Typing),使用接口能更好地组织代码,并写出易于测试的代码。

接口是计算机系统中多个组件共享的边间,通过定义接口,具体的实现可以和调用完全分离解耦,实现多态,上层的模块只需要依赖某个接口

POSIX(可移植操作系统接口)就是一个典型的例子,它定义了应用程序接口和命令行等标准接口,为计算机软件带来了可移植性,根据它实现的计算机软件无需修改就能在不同操作系统,不同架构的 CPU 上运行。

在具体的开发中,可以先实现类型后抽象出所需的接口,因为在项目前期设计出合理的接口并不容易,而在代码重构,模块分拆时再分离出接口解耦逻辑,就很自然。在使用第三方包时,抽象出所需接口,可以屏蔽太多不需要关注的细节,便于日后替换,例如:封装不同的缓存中间件。

定义

io 包中的 Writer 接口举例,该接口定义 Write(p []byte) (n int, err error) 方法,实现抽象的写入 p []byte 字节到某处,返回写入成功的字节数和错误。

type Writer interface {
    Write(p []byte) (n int, err error)
}

比较

接口值可以使用 ==!= 进行比较,规则是:

  1. 先进行动态类型比较,如果动态类型相同,再进行动态值比较;
  2. 动态值根据动态类型的 ==!= 比较规则进行比较。

不可比较的动态类型进行运算会产生 panic 错误,如 slice 动态类型:

var x interface{}
x = []int{1, 2}
fmt.Println(x == x) // panic: runtime error: comparing uncomparable type []int

// interfaceEqual protects against panics from doing equality tests on
// two interfaces with non-comparable underlying types.
// 比较 interface 可能会 panic,需要 recover 防止进程崩溃
// 坐标 os.exec 包
func interfaceEqual(a, b interface{}) bool {
	defer func() {
		recover()
	}()
	return a == b
}

嵌套

接口可以嵌套接口,继承嵌套接口的方法集。

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type ReadWriter interface {
    Reader
    Writer
}

赋值

Go 中的变量总是有一个定义明确的值,接口也不例外。在概念上,接口值分为两个部分:动态类型和动态值。在赋值时,接口存储当前值对应的类型和值的拷贝。

分为两种情况:

  1. 对象赋值给接口,又分为两种情况:
    • 对象本身赋值
    • 对象指针赋值
  2. 接口赋值给接口(A = B,B 赋值给 A,B 的方法集是 A 的超集)

对象方法和指针方法

对象方法和指针方法是最容易迷惑的概念,go 语言中存在指针类型,当指针和接口同时出现就会有一些令人困惑的问题。

接口在定义的时候并没有指定实现者是指针还是对象,只有方法在定义时指定了指针还是对象。核心就是:对象不同值的方法集覆盖接口定义的方法集。

在赋值给接口的时候有 4 种情况:

  • 方法集定义为对象,初始化指针值:初始化为指针能够调用是因为:通过指针可以隐式获取到对应的底层结构体。类似于 C 语言中的 s->Writer(),先获取底层结构体间接取址,再执行对应的方法;
  • 方法集定义为对象,初始化对象值;
  • 方法集定义为指针,初始化指针值;
  • 方法集定义为指针,初始化对象值(只有这种情况无法通过编译):对象值没有指针方法集!

第 4 种情况其实比较困惑,理论上我们有对象,自然也能取到对象的指针对不对?

编译器会提醒我们『T 类型并没有实现 Server 接口,Stop 方法的接受者是指针』

要想理解这个问题,需要知道 go 语言在进行参数传递的时候都是值传递的。

调用函数会对值进行复制,产生一个新的值,编译器自然找不到不到原值的指针。如果是第一种情况方法集是对象,初始化指针,通过复制的指针可以找到原始对象,自然可以调用

下面介绍,接口类型如何初始化和传递,以及实现接口时使用指针类型和结构体类型的区别。

这两种不同类型的接口实现方式其实会导致 go 语言编译器底层生成的汇编代码不同,在具体的执行过程上也会有一些差异,接下来就会介绍接口常见操作的基本原理。

使用指针类型实现接口

package main

type Server interface {
	Start()
	Stop()
}

type T struct {
	F string
}

//go:noinline
func (t *T) Start() {
	println(t.F, " Start.")
}

//go:noinline
func (t *T) Stop() {
	println(t.F, " Stop.")
}

func main() {
	var s Server = &T{F: "point"}
	s.Start()
	s.Stop()
}

下面研究汇编形式的内部过程:

SUBQ	$56, SP
LEAQ	type."".T(SB), AX // 取出 T 的类型指针放入 AX
MOVQ	AX, (SP) // 类型指针作为 runtime.newobject 参数
CALL	runtime.newobject(SB) // 返回的 T 对象指针地址会放在 8(SP)
MOVQ	8(SP), DI // 指针地址存入 DI
MOVQ	DI, ""..autotmp_2+16(SP) // 暂存指针地址到 16(SP)
MOVQ	$5, 8(DI) // 5 写入 8(DI),在 DI 指针地址指向的位置偏移 8 byte,写入常量 5,也是字符串的长度
LEAQ	go.string."point"(SB), AX // ”pint“ 字符串地址写入 AX
MOVQ	AX, (DI) // 将字符串地址写入 DI 指向的地址

此时 &T{} 指针和值都初始化好了。

runtime.newobject 函数原型,根据对象的类型指针在堆上开辟所需的内存空间,返回空间的指针地址。

func newobject(typ *_type) unsafe.Pointer {
    return mallocgc(typ.size, typ, true)
}

之后进入 *TServer 接口的过程:

LEAQ	go.itab.*"".T,"".Server(SB), CX // 取出 &go.itab.*T 类型指针存储 CX
MOVQ	CX, "".s+32(SP) // itab 放入 32(SP)
MOVQ	AX, "".s+40(SP) // AX 中的指针地址写入 40(SP)
MOVQ	"".s+32(SP), AX // Ax 存储 itab 地址
TESTB	AL, (AX)
MOVQ	24(AX), AX // 取出 Start 方法指针地址
MOVQ	"".s+40(SP), CX  // 取出 *T 指针
MOVQ	CX, (SP) // *T 指针,放入栈顶作为参数
CALL    AX

回顾一下整个调用过程的汇编代码和伪代码,其中的大部分内容都是对 T 指针和 iface 的初始化,调用 Start 方法时也只是取出指针放到栈顶作为参数,调用的过程也没有经过动态派发的过程,这其实就是 go 语言编译器帮我们做的优化了,我们会在后面详细介绍动态派发的过程。

使用结构体类型实现接口

package main

type Server interface {
	Start()
	Stop()
}

type T struct {
	F string
}

//go:noinline
func (t T) Start() {
	println(t.F, " Start.")
}

//go:noinline
func (t T) Stop() {
	println(t.F, " Stop.")
}

func main() {
	var s Server = T{F: "object"}
	s.Start()
	s.Stop()
}

如果使用 &T{Name: "object"} 也能通过编译,不过生成的汇编代码和上一节几乎完全相同,都会通过 runtime.newobject 创建新的 T 结构体指针并设置它的变量,在最后也会使用同样的方式调用 Start 方法。

初始化 T 结构体:

SUBQ	$72, SP // 增加栈空间
XORPS	X0, X0 // X0 = 0 清空内存?
MOVUPS	X0, ""..autotmp_1+48(SP) // 内存重排
LEAQ	go.string."object"(SB), AX // AX = &"object" 字符串的地址
MOVQ	AX, ""..autotmp_1+48(SP) // StringHeader(SP+48).Data = AX // 字符串值
MOVQ	$6, ""..autotmp_1+56(SP) // StringHeader(SP+56).Len = 6 // 字符串长度

这段汇编指令的工作其实与上一节中的差不多,这里会在栈上占用 16 字节初始化 T 结构体,不过上一节中的代码在堆上申请了 16 字节的内存空间,栈上只是一个指向 T 结构体的指针。

LEAQ    go.itab."".T,"".Server(SB), AX     ;; AX = &(go.itab."".T,"".Server) // 取出 Server 接口的 itab 地址
MOVQ    AX, (SP)                           ;; SP = AX // 放入 SP 栈顶
LEAQ    ""..autotmp_1+48(SP), AX           ;; AX = &(SP+48) = &Cat{Name: "object"} // 加载 48(SP) 的地址到 AX,也就是栈上的 T{} 对象地址
MOVQ    AX, 8(SP)                          ;; SP + 8 = AX
CALL    runtime.convT2I(SB)                ;; runtime.convT2I(SP, SP+8)

结构体初始化后就进入了类型转换的阶段,编译器会将 go.itab.T 类型的地址和指向栈上 T 值的指针传入 runtime.convT2I 函数:

convT2I 函数会获取 itab 中存储的类型,根据类型的大小申请一片内存空间并将 elem 指针中的内容拷贝到目标的内存空间中:

func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
    t := tab._type
    x := mallocgc(t.size, t, true) // 开辟内存空间
    typedmemmove(t, x, elem) // 复制 ele 指向的值,也就是复制对象值
    i.tab = tab
    i.data = x
    return
}

convT2I 在函数的最后会返回一个 iface 结构体,其中包含 itab 指针和拷贝的 T 结构体值,在当前函数返回值之后,main 函数的栈上就会包含以下的数据:

runtime.convT2I 会写入到 SP + 16 的位置,是一个占用 16 字节内存空间的 iface 结构体。

SP + 48 存储在栈上的 T 结构体值,会在 runtime.convT2I 执行过程中拷贝到堆上。

做好一切准备之后,取出函数指针,调用 CALL

此时的栈布局:

     +-----------------+
+--->+                 |
|    |    "object"     |"object"         heap
|    +-----------------+          +----------------+
|    |                 |          |                |
|    |       6      T{}|          |    "object"    |
|    +-----------------+48(SP)    |                |
|    |                 |          |                |
|    |  unsafe.Pointer +--------->+       6        |
|    |                 |          +----------------+
|    |  &go.itab.T     |iface(coverT2I return value)
|    +-----------------+16(SP)
|    |                 |
+----+        *T       |
     +-----------------+8(SP)
     |                 |
     |   &go.itab.T    |
     +-----------------+0(SP)

接口初始化好以后,调用 Start() 方法:

MOVQ	16(SP), AX // AX 存储 &go.itab.T 类型指针
MOVQ	24(SP), CX // CX 存储 unsafe.Pointer 数据指针
MOVQ	AX, "".s+32(SP) // 类型指针放入 32(SP)
MOVQ	CX, "".s+40(SP) // 数据指针放入 40(SP)
MOVQ	"".s+32(SP), AX // 又放回来?

TESTB	AL, (AX)
MOVQ	24(AX), AX // 24(AX) 是第一个方法的指针地址 AX = AX.fun[0] = T.Start (可以通过 itab 的结果算出来)
MOVQ	"".s+40(SP), CX // 40(SP) 数据指针的值写入 CX
MOVQ	CX, (SP) // 把 this 参数放到栈顶(其实还是传指针,只不过是拷贝对象的指针)
CALL	AX // s.Start()

何时检测对象是否实现接口

go 会在参数传递、返回值和变量赋值时检测某个对象是否实现对应的接口。

func main() {
    var rpcErr error = NewRPCError(400, "unknown err") // 变量赋值 typecheck
    err := AsErr(rpcErr) // 参数传递 typecheck
    println(err) 
}

func NewRPCError(code int64, msg string) error {
    return &RPCError{ // 返回值 typecheck
        Code:    code,
        Message: msg,
    }
}

func AsErr(err error) error {
    return err
}

go 会编译期间对上述代码进行类型检查,这里总共触发了三次类型检查:

  1. *RPCError 类型的变量赋值给 error 类型的变量 rpcErr
  2. *RPCError 类型的变量 rpcErr 传递给签名中参数类型为 errorAsErr 函数;
  3. *RPCError 类型的变量从函数签名的返回值类型为 errorNewRPCError 函数中返回;

interface{} 类型

有方法定义的接口和最特殊的 interface{} 无方法定义接口类型,在源码中有方法接口为 iface 结构体,无方法接口为 eface 结构体(因为无方法接口在语言中特别常见,在实现的时候实现为一种特殊类型)

与 C 语言的 void* 不同,interface{} 不表示任意类型,只表示 interface{} 类型。

函数参数为 interface{} 是会发生类型转换,将参数转换成 interface{} 类型。

数据结构

eface 对应空接口 interface{}

interface{} 不包含任何方法,结构相对更简单,只包含底层数据和类型两个指针。eface 就是 empty face 的缩写。

type eface struct { // 16 bytes
    _type *_type
    data  unsafe.Pointer
}

iface 对应有方法定义的接口

type iface struct { // 16 bytes
    tab  *itab
    data unsafe.Pointer
}

iface 包含底层数据指针和 itab 类型字段 tab 指针

itab 结构体

itab 是接口类型的核心组成部分,每一个 itab 占 32 字节。其中 _type 是 go 语言运行时(runtime)的内部结构,每一个 _type 结构都包含了类型的大小、对齐、哈希等信息。可以看到 eface 中也有 _type 结构,可以推断,有方法的接口和容易转换成 interca() 类型接口。

type itab struct { // 32 bytes
    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.
}

还有一个表示接口类型的 interfacetype 结构,是对 _type 类型的简单封装。

hash 字段其实是对 _type.hash 的拷贝,会在从 interface 到具体类型断言时快速判断目标类型与接口中的类型是否一致

最后的 fun 数组其实是一个动态大小的数组,如果如果当前数组中内容为空就表示 _type 没有实现 inter 接口,虽然这是一个大小固定的数组,但是在使用时会直接通过指针获取其中的数据并不会检查数组的边界,所以该数组中保存的元素数量是不确定的。

_type 结构体

_type 类型表示的就是 Go 语言中类型的运行时表示,下面其实就是类型在运行期间的结构,我们可以看到其中包含了非常多的原信息 — 类型的大小、哈希、对齐以及种类等字段。

type _type struct {
    size       uintptr // type size
    ptrdata    uintptr // size of memory prefix holding all pointers
    hash       uint32  // hash of type; avoids computation in hash tables
    tflag      tflag   // extra type information flags
    align      uint8   // alignment of variable with this type
    fieldalign uint8   // alignment of struct field with this type
    kind       uint8   // enumeration for C 反射的基本类型,C 中的枚举
    alg        *typeAlg  // algorithm table
    // 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    // garbage collection data
    str       nameOff  // string form
    ptrToThis typeOff  // type for pointer to this type, may be zero
}

断言

上面介绍如何从类型转换到接口类型,也就是协变的过程。

下面介绍从接口类型转换到具体类型或其他接口类型,也就是逆变的过程。

  • i.(struct) 接⼝断言类型;
  • i.(interface) 接口断言接口。

接口断言类型

var s Server = &T{F: "point"}
t := s.(*T)
println(t)

初始化

MOVQ	"".s+56(SP), AX // iface
MOVQ	"".s+48(SP), CX // iface 中的 itab 指针存入 CX
LEAQ	go.itab.*"".T,"".Server(SB), CX // 取出 &go.itab.*T 地址指针存入 DX
CMPQ	CX, DX // 比较两个 itab 指针地址是否相等(如果类型相同,必然相等)
JEQ	120 // 断言成功,在 CX,DX 相等时跳转 120
JMP	164 // panic 跳转 164

120:
MOVQ	AX, "".t+24(SP) // 把 iface 中的数据指针 copy 过来就初始化 t 了

164:
// 取出类型 T 和 Server 调用 panicdottypeI 函数报错
MOVQ	CX, (SP)
LEAQ	type.*"".T(SB), AX
MOVQ	AX, 8(SP)
LEAQ	type."".Server(SB), AX
MOVQ	AX, 16(SP)
CALL	runtime.panicdottypeI(SB)

接口断言判断接口是不是某个类型,返回对应的类型和判断条件(ok-idom)。

v, ok := interface.(type)

如果不使用 ok-idom,直接断言,类型不匹配时会中断程序,抛出 runtime 异常。

var x interface{}
x = &T{Name: "ok-idiom"}
// type error ok == false
s2, ok := x.(T)
if ok {
	fmt.Printf("%+v\n", s2)
} else {
	fmt.Println("type error.")
}

// ok == true
// 返回临时复制对象,即 S 类型值的指针
s1, ok := x.(*T)
if ok {
	fmt.Printf("%+v\n", s1)
} else {
	fmt.Println("type error.")
}

接口断言接口

var s Server = &T{F: "point"}
st := s.(Starter)
println(st)

汇编逻辑:

LEAQ	type."".Starter(SB), DX
MOVQ	DX, (SP)
MOVQ	CX, 8(SP)
MOVQ	AX, 16(SP)
CALL	runtime.assertI2I(SB)

上面的逻辑很简单,就是构造 assertI2I 函数的参数,然后拿到新的接口值。

// inter 为需要断言为的接口
// i 为当前接口值
func assertI2I(inter *interfacetype, i iface) (r iface) {
	tab := i.tab
	if tab == nil {
		// explicit conversions require non-nil interface value.
		panic(&TypeAssertionError{nil, nil, &inter.typ, ""})
    }
    // 如果 interface 类型相等,原样返回 i
	if tab.inter == inter {
		r.tab = tab
		r.data = i.data
		return
    }
    // 获取新的 itab
	r.tab = getitab(inter, tab._type, false)
	r.data = i.data
	return
}

// 传入需要断言到的接口类型 inter 与 当前的类型 typ
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
	if len(inter.mhdr) == 0 {
		throw("internal error - misuse of itab")
	}

	// easy case
	if typ.tflag&tflagUncommon == 0 {
		if canfail {
			return nil
		}
		name := inter.typ.nameOff(inter.mhdr[0].name)
		panic(&TypeAssertionError{nil, typ, &inter.typ, name.name()})
	}

    // 初始化新的 itab,下面的逻辑就是判断方法是不是匹配,能不能断言成功
	var m *itab

	// First, look in the existing table to see if we can find the itab we need.
	// This is by far the most common case, so do it without locks.
	// Use atomic to ensure we see any previous writes done by the thread
	// that updates the itabTable field (with atomic.Storep in itabAdd).
	t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable)))
	if m = t.find(inter, typ); m != nil {
		goto finish
	}

	// Not found.  Grab the lock and try again.
	lock(&itabLock)
	if m = itabTable.find(inter, typ); m != nil {
		unlock(&itabLock)
		goto finish
	}

	// Entry doesn't exist yet. Make a new entry & add it.
	m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys))
	m.inter = inter
	m._type = typ
	m.init()
	itabAdd(m)
	unlock(&itabLock)
finish:
	if m.fun[0] != 0 {
		return m
	}
	if canfail {
		return nil
	}
	// this can only happen if the conversion
	// was already done once using the , ok form
	// and we have a cached negative result.
	// The cached result doesn't record which
	// interface function was missing, so initialize
	// the itab again to get the missing function name.
	panic(&TypeAssertionError{concrete: typ, asserted: &inter.typ, missingMethod: m.init()})
}

Type Switch

我们可能需要判断一个接口是多个类型中的哪一个,如果使用断言一个一个判断也是可以实现的,但是有更好的方式 Type Switch

func main() {
	var t interface{}
	t = 1
	switch t.(type) {
	case int:
		println("int")
	case string:
		println("string")
	case bool:
		println("bool")
	}
}

汇编逻辑就是比较接口中的 hash,如对应类型的 hash 是否相等:

LEAQ	type.int(SB), AX
MOVQ	AX, "".t+24(SP)
LEAQ	"".statictmp_0(SB), CX
MOVQ	CX, "".t+32(SP)
MOVQ	AX, ""..autotmp_1+40(SP)
MOVQ	CX, ""..autotmp_1+48(SP)
TESTB	AL, (AX)
MOVL	type.int+16(SB), AX // type.int 移 16 位就是 hash 值
MOVL	AX, ""..autotmp_3+20(SP)
CMPL	AX, $335480517 // case int 比较 hash 值
...
LEAQ	type.bool(SB), AX
CMPQ	""..autotmp_1+40(SP), AX // 比较 hash
JEQ 120 // 相等则跳转
...

CMPL	""..autotmp_3+20(SP), $-520135500
...

动态派发

动态派发是在运行期间选择具体的多态操作执行的过程,它其实是一种在面向对象语言中非常常见的特性,但是 go 语言中接口的引入其实也为它带来了动态派发这一特性,也就是对于一个接口类型的方法调用,我们会在运行期间决定具体调用该方法的哪个实现。

假如我们有以下的代码,主函数中调用了两次 Start 方法,其中第一次调用是以 Server 接口类型的方式进行调用的,这个调用的过程需要经过运行时的动态派发,而第二次调用是以 *T 类型的身份调用该方法的,最终调用的函数在编译期间就已经确认了:

在这里我们需要使用 -N 的编译参数指定编译器不要优化生成的汇编指令,如果不指定这个参数,编译器会对很多能够推测出来的结果进行优化,与我们理解的执行过程会有一些偏差,例如:

  • 由于接口类型中的 tab 参数并没有被使用,所以优化从 *T 转换到 Server 接口类型的一些编译指令;
  • 由于变量的类型是确定的,所以删除从 Server 接口类型转换到 *T 具体类型时可能会发生 panic 的分支;

通过接口调用需要从 tabfunc 数组取出方法地址:

MOVQ	AX, "".s+56(SP)
MOVQ	"".s+48(SP), AX
TESTB	AL, (AX)
MOVQ	24(AX), AX // 取出函数地址
MOVQ	"".s+56(SP), CX
MOVQ	CX, (SP)
CALL    AX

通过指针调用不需要动态的查找 Start 方法,在编译器已经确认方法地址,直接调用,没有查找的开销

CALL	"".(*T).Start(SB)

参考资料