Golang - 接口 3

188 阅读7分钟

值接收者和指针接收者

值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。

值接收者指针接收者操作
值类型调用者方法会使用调用者的一个副本,相当于传值使用值的引用来调用方法
指针类型调用者指针类型调用者会被解引用为值类型类似于指针传参,复制了一份指针

这实际上是 Go 的语法糖在起作用,直接先给出结论:实现了值接收者的方法相当于自动实现了指针接收者方法;但是实现了指针接收者方法不会自动生成对应值接收者方法。

验证一下结论:

package main

import "fmt"

type coder interface{
    code()
    debug()
}

type Gopher struct {
    language string
}

func (p Gopher) code() {
    fmt.Printf("i'm coding %s language", p.language)
}

func (p *Gopher) code() {
        fmt.Printf("i'm debug %s language", p.language)
}

func main() {
    var c coder = &Gopher{"Go"}
    c.code()
    c.debug()
}

main 函数正常运行,并按逻辑输出结果;如果把第一行代码改为:

var c code = Gopher{"Go"}

运行就会报错:"cannot use Gopher literal (type Gopher) as type coder in assignment: Gopher does not implement coder(debug method has pointer receiver".

接收者是指针类型的方法,很可能在方法中会对接收者的属性进行修改,从而影响原接收者;而对于接受者是值类型的方法,在方法中不会对接收者本身产生影响;所以,当实现了一个接收者是值类型的方法时,就可以自动生成一个接收者是对应指针类型的方法,因为两者都不会影响接收者;而当实现了一个接收者是指针类型的方法,如果此时自动生成一个接收者是值类型的方法,原本期望对接收者的改变,现在无法实现,因为值类型只会产生一个副本调用者去调用,不会影响到原调用者。

func (p Gopher) code() {

}

/*
func (p *Gopher) code() {

}
*/

例如,当我们为值接收者实现了 code 方法时,同时也为指针接收者实现了该方法,最终调用时,无论是指针接收者还是值接收者,都是以值接收者副本形式调用,不会修改原调用者,这也是为什么不能为单独声明为指针接收者方法实现值接收者方法的原因,一旦自动实现,原本期望能够对原调用者的修改就失效了。

另一种解释

调用接口方法的组合实际有四种情况:

  1. 值类型结构体 -> 赋值给接口 -> 调用接受者类型为值类型的结构体方法
  2. 指针类型结构体 -> 赋值给接口 -> 调用接受者类型为指针类型的结构体方法
  3. 值类型结构体 -> 赋值给接口 -> 调用接受者类型为指针类型的结构体方法
  4. 指针类型结构体 -> 赋值给接口 -> 调用接受者类型为值类型的结构体方法

这四种不同情况只有一种会发生编译不通过的问题,即:结构体类型为值类型、调用了接收者为指针的方法(上面第 3 种)。

接收者是方法的一个额外参数,而 Go 在调用函数的时候,参数都是值传递。而将一个指针拷贝,它们还是指向同一个地址,指向一个确定的结构体;而将一个值拷贝,它们变成了两个不同的结构体,有着不同的地址,这会导致下面的两种情况:

  1. 当在一个结构体指针上,通过接口调用一个接收者为值类型的方法时,Go 首先会创建这个指针的副本,然后将这个指针解引用,再作为接收者参数传递给该方法,这两个指针指向相同的地址,所以它们传递的接收者参数都是相同的:
type Inter interface {
    foo()
}

type S struct{}

func (s S) foo() {} // 接收者为值类型的方法

var a Inter = &S{}

a.foo() // 调用 foo 方法

// 实际上底层是这样的:首先拷贝 a 的底层值(&S{}),是一个结构体指针;
// 虽然 a、b 是不同的变量,但是指向同一个结构体
var b *S = a.inner_value

// 将 b 解引用,传递给 foo: *b 和 *(a.inner_value) 其实表示的是同一个结构体
foo(*b) 

使用结构体指针调用一个接收者为值类型的方法时,首先会拷贝底层结构体的指针,然后对这个指针解引用后再作为接收者参数传递给该方法。

但是,当一个值类型的结构体通过接口调用一个接收者为指针类型的方法时,假设能够通过编译,就会出现以下情况:

type Inter interface {
    foo()
    bar()
}

type S struct {}

// 接收者为指针类型的方法
func (s *S) foo() {
    s.bar = 100
} 

var a Inter = S{} // 声明一个值类型的结构体
a.foo() 

// 如果允许通过编译:首先拷贝 a 的底层值即一个结构体,存到一个临时变量 b 中
// 此时 a、b 是不同的变量,指向不同的结构体
var b S = a.inner_value
// 然后将 b 的地址传递给 foo
// foo 方法实际上修改的是临时变量 b 的字段
foo(&b)
b.bar == 100 // true
a.bar == 100 // false

我们在通过值类型调用 foo 方法时,明明代码里修改了指针接收者的某个字段的值,但实际上并没有生效,修改的不是原指针接收者的值,而是副本的值。这显然与我们的与其不符,因此在值类型上调用指针接收者方法不会编译成功。

即当使用值类型结构体调用接收者为指针类型的方法时,即使可以成功通过编译,那么修改的是也是另一个副本的值,不会影响到原结构体,与传递指针修改值的初衷不符,因此 go 的编译器不会让这种情况通过编译。

何时使用?

如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;

如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身,会影响到原调用者。

使用指针作为方法接收者的场景:

  1. 方法能够修改接收者指向的值时
  2. 避免在每次调用方法时复制该调用者的值(比如在值的类型为大型结构体时,使用指针接收者更加高效,内存消耗更小)

使用值接收者还是指针接收者,不是由该方法是否修改了调用者(也就是接收者)决定,而应该基于该类型的本质。

如果类型具备原始的本质,即它的成员都是由 Go 语言的内置的原始类型组成,比如字符串、整型值等,那就定义值接收者类型的方法。而内置的引用类型,如 slice、map、interface、channel ,这些类型比较特殊,声明他们的时候实际上是创建了一个 header 类型,比如反射时使用的 sliceheader,对于这些类型也是直接定义值接收者类型的方法。这样在进行函数调用时,实际上是复制了这些类型的 header,而 header 本身就是为复制设计的,因此这些类型也是应该声明值类型接收者的方法。

如果类型具备非原始的本质,不能被安全的复制,这种类型总是应该被共享,那就定义指针接收者的方法。比如 Go 源码里的文件结构体(struct File)就不应该被复制,应该只有一份实体,就要定义指针接收者类型的方法。

根据类型中包含的字段信息,判断他们是否能够被安全的复制,如果类型中包含的都是 go 语言的内置原始类型,那么直接声明值接收者方法即可。但是如果类型中包含了哪些不能被安全复制、且应该被共享、又或者说应该只有一份实体的类型,就需要定义指针接收者方法。