暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

接口传值传址傻傻分不清?——分析golang函数遇到接口入参的行为

非典型后端码农 2021-03-08
786

简介

本文将对接口作为函数入参时遇到的XXX method has a pointer receiver的编译错误进行分析解释,预计耗时6~8分钟。


背景

C语言支持结构体与结构体指针,当利用结构体指针访问结构体的变量时,需要使用t->field的方式进行访问。golang对这类语法做了优化,使得可以利用t.field的方式进行访问。

定义一个结构体上绑定的方法时,同样可以不用理会方法的接收者是T还是*T。这样会让开发者产生一种“任何地方都不用关心传地址还是传值”的错觉。

然而当使用接口作为函数入参时,部分开发者会发现编译报错XXX method has a pointer receiver,改成传地址又能通过编译了。

    type Iface interface {
    SayHi()
    }
    type User struct {}
    func (u *User) SayHi() {}
    func Introduce(u Iface) {}
    func main() {
    user := User{}
    // 编译错误:'SayHi' method has a pointer receiver
    Introduce(user) // 当改成&user后即可通过编译
    }


    结构体方法调用与方法接收者

    golang中的结构体方法定义为func (receiver Type) functionName(inputs...) outputs,其中receiver表示方法的接收者,可以是值类型也可以是值类型,相当于python中的self或java中的this

    但在计算机实现函数调用的视角里,其实receiver也是函数的入参之一。golang之所以能够实现t.function,是因为在编译期就根据方法的receiver的类型对t进行解引用(*)或取地址(&)来获取合适的函数入参比如下面的一段实现里,Name方法的receiver是一个结构体。

      // main.go
      type User struct {
      name string
      }
      func (u User) Name() string {
      return u.name
      }




      func main() {
      user := User{
      name: "Tenz",
        }
      user.Name()
      }


      利用GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go查看其伪汇编,抽出函数调用相关的几行:

        0x003a 00058 (main.go:16)       PCDATA  $0, $0
        0x003a 00058 (main.go:16) MOVQ AX, (SP)
        0x003e 00062 (main.go:16) MOVQ $4, 8(SP)
        0x0047 00071 (main.go:16) CALL "".User.Name(SB)


        在这之前AX寄存器已经赋予了user值,随后在CALL之前,通过MOVQ AX, (SP)对值进行压栈,作为新栈帧的入参(PS:函数调用入参其实是新栈帧的帧指针向栈底即内存中的高地址方向获取的)。可以发现,Name方法的入参其实是一个结构体。

        接下来尝试将Name方法的receiver改成结构体指针,即func (u *User) Name() string,再分析其伪汇编代码。

          0x003a 00058 (main.go:16)       PCDATA  $0, $1
          0x003a 00058 (main.go:16) PCDATA $1, $0
          0x003a 00058 (main.go:16) LEAQ "".user+24(SP), AX
          0x003f 00063 (main.go:16) PCDATA $0, $0
          0x003f 00063 (main.go:16) MOVQ AX, (SP)
          0x0043 00067 (main.go:16) CALL "".(*User).Name(SB)


          LEAQ "".user+24(SP), AX指令将user的地址传入AX寄存器,随后再进行压栈,即传入Name方法的是一个指针。

          同样可以对结构体方法做(&t).method调用作校验,会看到在压栈的是结构体而不是指针。我们可以得出一个结论,之所以能够无顾忌地使用t.method来调用函数,因为编译器会帮忙解引用或取地址。


          接口与方法集

          在理解receiver的概念后,就很好理解T*T的区别了,现在来解释为什么会出现XXX method has a pointer receiver的编译错误。

          当给一个receiver绑定了多个方法后,这些方法形成了这个receiver的方法集。而接口则是对方法集的描述,对一个接口赋值的时候,会拷贝类型信息和该类型的方法集,只有接口方法集属于receiver的方法集的子集时,才说明该receiver实现了该接口,才能成功编译,不过这也会引发两个思考。


          1. 为什么编译器无法对这种情况自动解引用或取址呢?

              答:因为以接口为入参的函数在被调用前无法预知原来的入参是值语义还是引用语义,而解引用/取址是在编译期就得完成的,所以无法做自动推导。

          2. 是不是receiver一定要完全一样才能通过检查呢?

              答:不是,golang是只有值传递的,即使传指针,在被调用函数里也是先将入参拷贝一份到局部变量再继续运算的。可以分两种情况

          1. 如果传的是一个结构体,无法获知原来的结构体是什么样的,即使取址改变的也是局部变量的值,无意义。

          2. 如果传的是一个指针,遇到结构体方法时可以解引用得到原来的结构体实例再调用,则是可以通过编译的。这也解释了为什么只有在对结构体指针方法传入结构体时会报编译错误,而对结构体方法传指针时不会。

          简单归纳成一个表格则为

          method receiverparameter
          pointerpointer
          valuevalue, pointer


          # 验证想法

          最后通过一个小测试验证结论。

            type User struct {}
            func (u *User) sayHi(){}
            type Hier interface {
            sayHi()
            }
            func Introduce(h Hier) {}


            func main() {
            u1 := User{}
              u2 := &User{}  
            Introduce(u1) // 编译失败,因为u1这个值类型没有实现sayHi
            Introduce(&u1) // 成功,因为&u1是一个指针,拥有方法sayHi
            Introduce(u2) // 成功,理由同上
            }


            # 总结

            1. 当直接调用结构体或结构体指针的方法时,不用关心要不要解引用或取址,编译器会自动推导。

            2. 对于函数入参为接口的情况,如果结构体方法的接收者为指针,则只能传址,否则不进行限制。



            文章转载自非典型后端码农,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

            评论