golang中的接口包装方法和panicwrap

66 阅读4分钟

1、写在前面

运行下面一段代码:

package main

type Cat int

type Animal interface {
   Eat()
}

//go:noinline
func (cat Cat) Eat() {}

var (
   c *Cat
   animal Animal = c
)

func main() {
   animal.Eat()
}

预计得到一个空指针异常,却得到另一个报错:

panic: value method main.Cat.Eat called using nil *Cat pointer

本篇文章浅浅总结一下关于这个报错的原理和关于接口包装方法的学习心得。

2、接口的包装方法

包装方法是啥?在上面的场景中,编译器会为接收者为值类型的方法生成接收者为指针类型的方法,也就是所谓的“包装方法”。

下面来验证一下,执行命令:

go tool compile main.go         // 生成 main.o 文件
go tool nm main.o | grep T      // 查看文件中实现的函数 

执行结果:

1ecf T "".(*Cat).Eat
1e5f T "".Animal.Eat
1981 T "".Cat.Eat

可以看到一共实现了三个Eat()方法,第二个是Animal接口声明的Eat()方法,第三个是Cat的Eat()方法,接收者类型是Cat,而第一个接收者类型为*Cat的Eat()方法在源码中并没有定义,就是编译器生成的包装方法。

至于生成包装方法的原因,可以参考这篇博客:GoLang之包装方法系列一,这里大概总结一下就是:

对于接口来讲,在调用指针接收者方法时,传递地址是非常方便的,也不用关心数据的具体类型,地址的大小总是一致的。
假如通过接口调用值接收者方法,就需要通过接口中的data指针把数据的值拷贝到栈上,由于编译阶段不能确定接口背后的具体类型,所以编译器不能生成相关的指令来完成拷贝,所以说,接口是不能直接使用值接收者方法的,这就是编译器生成包装方法的根本原因。

3、寻找真相

根据推测,例子调用的animal.Eat(),不是接收者为值类型的Cat.Eat(),应该是包装方法(*Cat).Eat(),通过执行go tool compile -S main.go看下main()方法汇编:

"".main STEXT size=57 args=0x0 locals=0x10 funcid=0x0
        0x0000 00000 (main.go:17)       TEXT    "".main(SB), ABIInternal, $16-0
        0x0000 00000 (main.go:17)       CMPQ    SP, 16(R14)
        0x0004 00004 (main.go:17)       PCDATA  $0, $-2
        0x0004 00004 (main.go:17)       JLS     50
        0x0006 00006 (main.go:17)       PCDATA  $0, $-1
        0x0006 00006 (main.go:17)       SUBQ    $16, SP
        0x000a 00010 (main.go:17)       MOVQ    BP, 8(SP)
        0x000f 00015 (main.go:17)       LEAQ    8(SP), BP
        0x0014 00020 (main.go:17)       FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (main.go:17)       FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (main.go:18)       MOVQ    "".animal(SB), CX
        0x001b 00027 (main.go:18)       MOVQ    "".animal+8(SB), AX
        0x0022 00034 (main.go:18)       MOVQ    24(CX), CX
        0x0026 00038 (main.go:18)       PCDATA  $1, $0
        0x0026 00038 (main.go:18)       CALL    CX
        0x0028 00040 (main.go:19)       MOVQ    8(SP), BP
        0x002d 00045 (main.go:19)       ADDQ    $16, SP
        0x0031 00049 (main.go:19)       RET
        0x0032 00050 (main.go:19)       NOP
        0x0032 00050 (main.go:17)       PCDATA  $1, $-1
        0x0032 00050 (main.go:17)       PCDATA  $0, $-2
        0x0032 00050 (main.go:17)       CALL    runtime.morestack_noctxt(SB)
        0x0037 00055 (main.go:17)       PCDATA  $0, $-1
        0x0037 00055 (main.go:17)       JMP     0

奇怪,在汇编中并没有看到有调用(*Cat).Eat()方法,只有这句call调用:

0x0026 00038 (main.go:18) CALL CX

在这之上做了这些操作:

0x0014 00020 (main.go:18) MOVQ "".animal(SB), CX 
0x001b 00027 (main.go:18) MOVQ "".animal+8(SB), AX 
0x0022 00034 (main.go:18) MOVQ 24(CX), CX

首先是把animal和animal+8的位置分别存在CX和AX寄存器上,然后把24(CX)存到CX寄存器上。animal是iface接口类型,iface的底层结构如下:

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

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.
}

24(CX)其实就是itab中的fun数组开头位置,上面对CX的call调用其实就是调用func数组中的第一个方法,在上面场景中就是(*Cat).Eat()。在把对象赋值给接口的时候,itab会保存对象的实际类型,该类型下和接口方法对应的指针类型的方法(包括生成的包装方法),会以字典序排列在itab.func数组中。 注意itab.func数组大小只有1,这其实没有关系,多出来的方法会紧紧靠在func[1]的后面。

下面用例子进行验证,把这个func数组中所有方法都打印出来:

package main

import (
   "runtime"
   "unsafe"
)

type Cat int

type Animal interface {
   Eat()
   Run()
}

//go:noinline
func (cat Cat) Eat() {}

//go:noinline
func (cat Cat) Run() {}

//go:noinline
func (cat Cat) Sleep() {}

var (
   c Cat = 1
   animal Animal = c
)

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

type itab struct {
   inter unsafe.Pointer
   _type unsafe.Pointer
   hash  uint32
   _     [4]byte
   fun   [3]uintptr
}

func main() {
   inter :=(*iface)(unsafe.Pointer(&animal))
   t := inter.tab
   println(runtime.FuncForPC(t.fun[0]).Name())
   println(runtime.FuncForPC(t.fun[1]).Name())
   println(runtime.FuncForPC(t.fun[2]).Name())
   c.Sleep()
   animal.Eat()
   animal.Run()
}

打印结果:

main.(*Cat).Eat
main.(*Cat).Run

例子中Animal接口有Eat()和Run()方法,Cat类型实现了值接收者的Eat(),Run()和Sleep()方法,打印结果显示func数组保存了(*Cat).Eat和(*Cat).Run,没有(*Cat).Sleep。

既然知道CALL CX其实是调用(*Cat).Eat这个包装方法,让我们看看汇编:

"".(*Cat).Eat STEXT dupok size=98 args=0x8 locals=0x10 funcid=0x16
        0x0000 00000 (<autogenerated>:1)        TEXT    "".(*Cat).Eat(SB), DUPOK|WRAPPER|ABIInternal, $16-8
        0x0000 00000 (<autogenerated>:1)        CMPQ    SP, 16(R14)
        0x0004 00004 (<autogenerated>:1)        PCDATA  $0, $-2
        0x0004 00004 (<autogenerated>:1)        JLS     63
        0x0006 00006 (<autogenerated>:1)        PCDATA  $0, $-1
        0x0006 00006 (<autogenerated>:1)        SUBQ    $16, SP
        0x000a 00010 (<autogenerated>:1)        MOVQ    BP, 8(SP)
        0x000f 00015 (<autogenerated>:1)        LEAQ    8(SP), BP
        0x0014 00020 (<autogenerated>:1)        MOVQ    32(R14), R12
        0x0018 00024 (<autogenerated>:1)        TESTQ   R12, R12
        0x001b 00027 (<autogenerated>:1)        JNE     80
        0x001d 00029 (<autogenerated>:1)        NOP
        0x001d 00029 (<autogenerated>:1)        FUNCDATA        $0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB)
        0x001d 00029 (<autogenerated>:1)        FUNCDATA        $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
        0x001d 00029 (<autogenerated>:1)        FUNCDATA        $5, "".(*Cat).Eat.arginfo1(SB)
        0x001d 00029 (<autogenerated>:1)        MOVQ    AX, ""..this+24(SP)
        0x0022 00034 (<autogenerated>:1)        TESTQ   AX, AX
        0x0025 00037 (<autogenerated>:1)        JEQ     57
        0x0027 00039 (<autogenerated>:1)        MOVQ    (AX), AX
        0x002a 00042 (<autogenerated>:1)        PCDATA  $1, $1
        0x002a 00042 (<autogenerated>:1)        CALL    "".Cat.Eat(SB)
        0x002f 00047 (<autogenerated>:1)        MOVQ    8(SP), BP
        0x0034 00052 (<autogenerated>:1)        ADDQ    $16, SP
        0x0038 00056 (<autogenerated>:1)        RET
        0x0039 00057 (<autogenerated>:1)        CALL    runtime.panicwrap(SB)
        0x003e 00062 (<autogenerated>:1)        XCHGL   AX, AX
        0x003f 00063 (<autogenerated>:1)        NOP
        0x003f 00063 (<autogenerated>:1)        PCDATA  $1, $-1
        0x003f 00063 (<autogenerated>:1)        PCDATA  $0, $-2
        0x003f 00063 (<autogenerated>:1)        MOVQ    AX, 8(SP)
        0x0044 00068 (<autogenerated>:1)        CALL    runtime.morestack_noctxt(SB)
        0x0049 00073 (<autogenerated>:1)        MOVQ    8(SP), AX
        0x004e 00078 (<autogenerated>:1)        PCDATA  $0, $-1
        0x004e 00078 (<autogenerated>:1)        JMP     0
        0x0050 00080 (<autogenerated>:1)        LEAQ    24(SP), R13
        0x0055 00085 (<autogenerated>:1)        CMPQ    (R12), R13
        0x0059 00089 (<autogenerated>:1)        JNE     29
        0x005b 00091 (<autogenerated>:1)        MOVQ    SP, (R12)
        0x005f 00095 (<autogenerated>:1)        NOP
        0x0060 00096 (<autogenerated>:1)        JMP     29

原来包装方法内部实现其实还是调用原本值类型的方法的:

0x002a 00042 (<autogenerated>:1) CALL "".Cat.Eat(SB)

上面例子中的报错应该就是这个panicwrap引起的:

0x0039 00057 (<autogenerated>:1) CALL runtime.panicwrap(SB)

panicwrap实现:

// panicwrap generates a panic for a call to a wrapped value method
// with a nil pointer receiver.
//
// It is called from the generated wrapper code.
func panicwrap() {
   // 省略...
   panic(plainError("value method " + pkg + "." + typ + "." + meth + " called using nil *" + typ + " pointer"))
}

这个panicwrap的检测是在生成包装方法的时候构造了个if语句节点塞进方法体的:

func methodWrapper(rcvr *types.Type, method *types.Field) *obj.LSym {
    // ...
    // generate nil pointer check for better error
    if rcvr.IsPtr() && rcvr.Elem() == methodrcvr {
       // generating wrapper from *T to T.
       n := ir.NewIfStmt(base.Pos, nil, nil, nil)
       n.Cond = ir.NewBinaryExpr(base.Pos, ir.OEQ, nthis, typecheck.NodNil())
       call := ir.NewCallExpr(base.Pos, ir.OCALL, typecheck.LookupRuntime("panicwrap"), nil)
       n.Body = []ir.Node{call}
       fn.Body.Append(n)
    // ...
}

参考:

深入研究 Go interface 底层实现 (halfrost.com)