Go接口实现中的的值接收者和指针接收者

值接收者和指针接收者

所谓指针接收者和值接收者这两个概念,用GO写了一阵子代码的人都了解了,这里只做简要说明一下,也就是对于一个给定结构,咱们对结构进行方法包装 的时候,固定必传的参数,用来指向这个对象结构自身的一个参数,在go中也就是形式如下:

type testStruct struct{
	a  int
}

func (a testStruct)sum(x,y int)int  {
    return a.a + x + y
}

func (a *testStruct)modify(v int)  {
    a.a = v
}

我们对结构体testStruct进行了包装,提供了两个方法,sum和modify,其中sum的方法接收者为a testStruct,这个就是值接收者,而 modify的接收者为a *testStruct就是指针接收者,也就是说固定对象指针,一个传递的是指针地址,而另外一个直接传递的是结构值拷贝了 对指针有一定了解的,都可以知道,指针传递过去的,可以直接修改结构内部内容,而值传递过去的,无论如何修改这个接收者的数据,不会对原 对象结构产生影响。而对于咱们包装结构对象的时候,到底是使用指针还是使用值接收者,这个实际上没有太大的定论,就我个人的观点来说,如 果结构体占有的内存空间不大(<KB级别),而又不需要修改内部的,同时结构对象内部没有同步对象比如(sync包中的mutex,rwlock,waitGroup 等之类的结构的话,可以直接值传递,实际上值copy也没有咱们想象的那么慢,很多时候,都用指针,最后的GC回收扫描可能都比咱们这个传递copy 的消耗大)

实现接口的值接收者和指针接收者有啥区别

也就是比如定义如下:

type ITest interface {
  sum1(int, int) int
}

type ITest2 interface {
  sum2(int, int) int
}

type testStruct struct {
  a int
}

func (t testStruct) sum1(x, y int) int {
  return t.a + x + y
}

func (t *testStruct) sum2(x, y int) int {
  return t.a + x + y
}

这里面的值接收者和指针接收者有什么区别,这里咱来写一个测试

func main() {
    t := testStruct{
        a: 3,
    }
    var test1 ITest
    var test2 ITest2
    test2 = &t
    if v, ok := test2.(ITest); ok {
        fmt.Println("指针接收者接口->值接收者接口", v.sum1(5, 5))
    } else {
        fmt.Println("指针接收者接口 无法转到 值接收者接口")
    }
    test1 = t
    if v, ok := test1.(ITest2); ok {
        fmt.Println("值接收者接口->指针接收者接口", v.sum2(3, 3))
    } else {
    fmt.Println("值接收者接口 无法转到 指针接收者接口")
    }
}	

通过这个测试用例可以发现,指针接收者实现的接口可以同时支持转移到值接收者接口和指针接收者接口,而用值接收者实现的接口,则无法转移到 使用指针接收者实现的接口,为啥子呢?目前网上或者各类资料上都是给的一个很官方很官方,而且很书面话难以理解的说明,大致意思如下:

  • 当方法的接收者定义为值类型时, Go 语言编译器会自动做转换,所以值类型接收者和指针类型接收者是等价的,编译不会报错,运行也都可以调用相应方法
  • 在实现接口时,应保持接收者定义、结构体定义、断言类型一致

这是目前网络或者各种资料上都是差不多是这样说的,看似讲了,实际上就说了一个结果,根本就没说出来一个为什么。这样的总结出来,一个初学者的角度来看, 是很不好理解的,初学者要么就是死记硬背,要么就是生搬硬套,甚至直到写了好多好多代码了,都还没有搞明白一个为啥子,只是会用了而已,从长远来说这是 不利于自身提高的。

说了这么多,那么这到底是个什么原因呢,其实很简单,我们关注其本质就行了:

  • 值接收者是传递的时候,实际上是执行了一个值拷贝传递进去了,这个值拷贝和原数据已经没有任何关系了
  • 指针接收者传递的是地址,此时和原数据还有关联,通过解指针操作可以到原数据的数据空间

有这两个本质点,咱们自己来思考一下,如果你来实现这个编译器的时候,用指针接收的时候,指针接收者,默认就能直接获取支持,而值接收者实现接口的 咱们可以直接来一个解指针就变成了值,就能匹配上值接收者实现的接口了,反过来说,如果值接收者,此时要匹配指针接收者,如何匹配呢,取一个地址就 变成了指针了,此时数据类型确实是匹配了,但是,地址指向的数据区不对了,因为我们刚刚说了值接收者拷贝了一个新值之后是完全的一个新的对象,这个新 对象和原始对象一点关系都没有,咱们取地址,取的也是这个新对象地址,对这个地址进行操作,也是这个新对象的内部数据,和原始数据内部没有任何关系, 所以由此就能推断出,这个是为啥子值接收者不能匹配上指针接收者,而指针接收者却可以匹配上值接收者了。

Go的字符串小计

在某个作用域内部,所有定义的字符串的数据区相同

func main(){
    tstr := "test1"
    data := (*reflect.StringHeader)(unsafe.Pointer(&tstr))
    fmt.Println("地址:", data.Data)
    tstr2 := "test1"
    data = (*reflect.StringHeader)(unsafe.Pointer(&tstr2))
    fmt.Println("地址:", data.Data)
}

这个很好验证

字符串相加会产生一个新串

这个也很好验证

字符串真的是不可变的吗

实际上从字符串的结构

type stringStruct struct{
	Data  uintptr
	len   int
} 

从这个结构,就能大致的推断出来,字符串设计成这样就不具备直接扩容+来增加新数据,而如果咱们直接使用

  string[index] = 'a'

用这种方式,就不能编译通过,官方也确定说字符串是不可变的。那么真的是不可变的吗?通过上面的结构,在加上go的slice切片的数据结构

type sliceStruct struct{
	Data uintptr
	len  int
	cap  int
}

由此可见,咱们可以将字符串通过指针方式强转为一个byte数组指针,然后通过byte切片来修改,试试

func main() {
    tstr := "test1"
    data := (*reflect.StringHeader)(unsafe.Pointer(&tstr))
    btHeader := reflect.SliceHeader{
        Data: data.Data,
        Len:  data.Len,
        Cap:  data.Len,
    }
    bt := *(*[]byte)(unsafe.Pointer(&btHeader))
    bt[0] = 'a'
}	

编译通过,运行报错

unexpected fault address 0xae2e27
fatal error: fault

这个错误,基本上就是一个内存的保护错误,是写异常,所以说明了,这个肯定做了内存写保护,那么直接修改一下内存区的属性,去掉他的写保护,就能写了 以下代码都是在Win平台,Go1.18,Win上修改内存权限属性,使用VirtualProtect,代码如下

func main() {
    tstr := "test1"
    data := (*reflect.StringHeader)(unsafe.Pointer(&tstr))
    btHeader := reflect.SliceHeader{
        Data: data.Data,
        Len:  data.Len,
        Cap:  data.Len,
    }
    bt := *(*[]byte)(unsafe.Pointer(&btHeader))
    kernelDell := syscall.NewLazyDLL("kernel32.dll")
    kernelDell.Load()
    VirtualProtect := kernelDell.NewProc("VirtualProtect")
    var old1 uint32
    VirtualProtect.Call(uintptr(unsafe.Pointer(data.Data)), uintptr(data.Len), 0x40, uintptr(unsafe.Pointer(&old1)))
    bt[0] = 'a'
    bt[1] = 'a'
    fmt.Println(tstr)
}

此时运行,就能发现tstr的内容被咱们变了,这种情况实际上在实际开发中不具有实际意义,因为本身在语言层面,已经做了层层限制,咱们这是属于非法强制 的操作方式,是流氓行为,那么是否有比较温和一点的操作方式呢?答案是有的,且往下看。

通过上面,我们已经用到了字符串结构,切片结构,要想字符串内容可变,那么咱们自己构造字符串的数据内容区域,且让这个数据区木有内存写保护不就行了, 内容区可变,GO原生态的byte数组不就行嘛,所以咱们自己构造一下

func main() {
    buffer := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0}
    stringData := reflect.StringHeader{
        Data: uintptr(unsafe.Pointer(&buffer[0])),
        Len:  len(buffer),
	}
    buffer[0] = 'h'
    buffer[1] = 'e'
    buffer[2] = 'l'
    buffer[3] = 'l'
    buffer[4] = 'o'
    buffer[5] = ' '
    buffer[6] = 'w'
    buffer[7] = 'o'
    buffer[8] = 'r'
    str := *(*string)(unsafe.Pointer(&stringData))
    fmt.Println(str)
}	

此时我们直接修改buffer的内容,就是直接修改了str的数据内容了。而又不会像前面的一样遇到内存写保护

字符串转换优化时可能碰到的坑

通过前面讨论的字符串的可变性的方法,咱们可以知道,很多时候,[]byte到字符串的转变,可以直接构造其结构,而共享数据,从而达到减少数据内存copy 的方式来进行优化,再使用这些优化的时候,一定需要注意,字符串或者数组的生命周期,是否会存在被改写的情况,从而导致前后不一致的问题。 比如下面这段代码:

func main() {
    buffer := []byte("test")
    stringData := reflect.StringHeader{
        Data: uintptr(unsafe.Pointer(&buffer[0])),
        Len:  len(buffer),
    }
    str := *(*string)(unsafe.Pointer(&stringData))
    mmp := make(map[string]int, 32)
    mmp[str] = 3
    mmp["abcd"] = 4

    fmt.Println(mmp[str])
    buffer[0] = 'a'
    buffer[1] = 'b'
    buffer[2] = 'c'
    buffer[3] = 'd'
    fmt.Println(mmp[str])
    fmt.Println(mmp["test"])
    fmt.Println(mmp["abcd"])
    for k, v := range mmp {
        fmt.Println(k, v)
    }
}	

大家可以猜想一下,这个最后里面的数据mmp中,"test"的value是多少,"abcd"的value是多少,然后想想为什么,且等下回再来分解