文章

Go 指针与接口那些事

Go 是传值的

在正式开始讨论指针与接口之前,我们有必要达成共识:Go 中函数传参是传值。

普通参数

普通参数很容易理解,传值就是把原来的值拷贝一份,再传给函数。那么函数拿到的是副本,对齐的修改自然也不会影响原来的值。playground

func foo(b int) {
  b = 007
}
func main() {
  a := 996
  foo(a)
  println(a) // 996
}

传参发生时,系统开辟了新的内存空间(位于 0x65),把原先的值拷贝一份进去。foo() 修改的是 0x65 里的值。而打印的依然是 a,是 0x11 里的值。

指针参数

指针参数传的还是值!还是有拷贝!但是拷贝的不是值本身,而是一个指针。所以在 64 位电脑上,一个指针传递要拷贝 8B 内存,无论它指向的值有多大。playground

import "fmt"

func foo(c *int) {
  fmt.Printf("c 的地址是 %p\n", &c) // 0xc00000e028
  *c = 007
}
func main() {
  a := 996
  b := &a
  fmt.Printf("b 的地址是 %p\n", &b) // 0xc00000e038
  foo(b)
  println(a) // 7
}

传指针

b 传过去变成 c 的过程中依然发生了拷贝。bc 对应内存中完全不同的地址,只不过它们都指向 a 而已。就像公路和铁路都连接杭州,但它们是完全不同的东西,公路堵车不会影响铁路。

此时 foo() 修改的不是 c 本身,而是 c 指向的那个东西。因此变化可以反映到 a*b。就像通过火车把苹果运到了杭州,你此时开车去杭州也可以看见这箱苹果。

切片参数

切片也是传值!不是引用!

切片是一个结构体:

type slice struct {
    array unsafe.Pointer // 指向底层数组
    len   int
    cap   int
}

所以切片传递时和传递一个普通结构体没有什么区别——需要把其 3 个字段完整拷贝。特殊的是,切片是一个引用类型。

所谓引用类型,指它实际的值存放在一片单独的内存中,与自身分离。自己通过保存一个内存指针来访问数据。

我们完全可以自己写一个引用类型,实现和切片类似的,看起来像引用存传递,实际是值传递的效果:playground

import "fmt"

type RefType struct {
  RealData *int
}

func foo(r RefType) {
  *r.RealData = 007
}

func main() {
  data := 996
  a := RefType{RealData: &data}
  fmt.Printf("before: %d\n", *a.RealData) // 996
  foo(a)
  fmt.Printf("after: %d\n", *a.RealData) //7
}

看,虽然参数 r 不是指针,但它内部的改动依然可以反映到 main 里。

切片同理:playground

import "fmt"

func foo(b []int) {
  fmt.Printf("b 的地址是 %p\n", &b) // 0xc0000a4048
  b[0] = 7
}

func main() {
  a := make([]int, 4)
  a[0] = 996
  fmt.Printf("a 的地址是 %p\n", &a) // 0xc0000a4030
  fmt.Printf("before: %d\n", a[0]) // 996
  foo(a)
  fmt.Printf("after: %d\n", a[0]) // 7
}

传切片

很明显,ba 的一个拷贝,由此证明 slice 也是传值。


为了进一步证明 slice 是引用类型,可以做一个实验:playground

import (
  "fmt"
  "unsafe"
)

func main() {
  a := make([]int64, 1, 2)
  _ = append(a, 6)
  // fmt.Println(a[1]) // 越界!
  // 强制访问 a[1]
  baseAddr := unsafe.Pointer(&a[0])
  offset := unsafe.Sizeof(a[0])
  fmt.Println(*(*int64)(unsafe.Pointer(uintptr(baseAddr) + offset))) // 6
}

虽然追加元素后没有重新赋值,其实底层数组已经更新了。由于 slice 的 len 字段不是指针,才未能更新正确的长度导致报错。

虽然大部分情况下我们只需要传切片值就好,但因为其本质依然是值传递,所以如果可能修改切片的自己的字段(例如发生重分配/扩容),还是需要以指针的形式传递切片,否则修改无法回带。

接口的本质

原型与方法集

聊接口之前,先熟悉几个名词。

  • 函数原型 (fuction prototype):由函数名与函数签名组成。字面形式还包括一个 func 关键字。例如:

    func Double(n int) (result int)
    
  • 方法原型 (method prototype):形式上是不带 func 关键字的函数原型。方法 = func + 属主参数声明(接收器)+ 方法原型 + 方法体。方法原型例如:

    Pages() int
    SetPages(pages int)
    
  • 方法集 (method set):一个类型的方法集由它所声明的所有方法原型(不包括名字为空白标识符 _ 的方法)组成。接口类型除外,这是下面要重点讨论的。 方法集是无序的。例如对于下面的声明:

    func (b Book) Pages() int {
      return Book.Pages(b)
    }
    
    func (b *Book) SetPages(pages int) {
      (*Book).SetPages(b, pages)
    }
    

    Book 的方法集为:

    Pages() int
    

    *Book 的方法集为:

    Pages() int
    SetPages(pages int)
    

    注意,这里隐含了两个关键点:

    1. Book*Book 是两个完全不同的类型。
    2. 值接收器的方法隐式地同时被声明为指针类型的方法。反之成立。

接口类型与实现

「接口类型」定义了一个方法集。前面说过,每个类型都有一个方法集:

  • 对于非接口类型:方法集由它所声明的,所有的,名称不为空的方法原型组成。

  • 对于接口类型:方法集就是它所指定的方法集。 简单粗暴对不对?

因此接口可以说定义了一个类型「能做什么」。


如果任意一个类型的方法集是接口类型的方法集的超集,那么这个类型就实现了此接口。这句看似简单的话也隐含了几个易混淆的点:

  1. 接口的实现不一定是结构体,而可能是任意类型。即:基本类型的定义类型、结构体,甚至接口都可以实现接口(而不叫继承)例如:

    type Sayer interface{ Say(text string) }
    
    type MyInt int // 定义一个新类型,源类型是基本类型 int
    
    func (m MyInt) Say(s string) {
      logger.Info(strconv.Itoa(int(m)) + " say: " + s)
    }
    // 现在可以说,类型 MyInt 实现了接口 Sayer
    var sayer Sayer = MyInt(1) // ok
    
    
    type AnotherSayer interface { Say(text string) }
    // 现在可以说 AnotherSayer 与 Sayer 互相实现
    var a AnotherSayer = Sayer(MyInt(1)) // ok
    var s Sayer = AnotherSayer(MyInt(1)) // ok
    
  2. 方法集由方法原型组成,方法原型=函数名+函数签名。所以只有一个类型的方法名称、参数、返回值都与接口中定义的相同时,才可以实现接口。只有函数签名相同是不行的。

    type Sayer interface{ Say(text string) }
    type MyInt int
    func (m MyInt) SayText(text string) {
      logger.Info(strconv.Itoa(int(m)) + " say: " + s)
    }
    // MyInt *没有* 实现 Sayer
    var a Sayer = MyInt(1) // error!
    

根据上面的定义,任何类型都实现了任何空接口类型。 <- 这一点很重要,下面的例子要用。令一个事实是,任何接口都实现了自己。

接口的值

(也许)不少人认为接口是没有值的,尤其是 jvm 系的同学(比如我),因为都被一句话洗过脑:(java 中) 接口不可以实例化。

Go 中,接口是否可以实例化不好回答,因为它就不是个面向对象的语言。但确定的是,Go 中的接口可以有值,而且接口的值既不是实现它的对象(类型上不是,地址上更不是),也不是接口类型本身。 从便于理解的角度出发,可以认为接口的值相当于接口的一个实例。

把一个接口的实现赋值给接口变量,这时候接口的值是什么?再次强调,绝不是这个值本身: (playground)

type Sayer interface{ Say(text string) }
type Fool struct{ Name string}
func (f Fool) Say(text string) {
  fmt.Println("A fool called ", f.Name, " say: ", text)
}
func main() {
  var f = Fool{ "Tom" }
  var s Sayer = f
  fmt.Printf("f: %p\ns: %p\n", &f, &s)
  // f: 0x551048
  // s: 0xc000108050
}

可以看到,fs 不是一个东西。

接口的值其实是一个结构体实例,其大小为 2 字(在 64 位设备上是 16B),含有 2 个字段,定义如下^ 1

type iface struct {
  tab  *itab          // iTable
  data unsafe.Pointer // 动态值(底层值)的指针
}

其中 itab 是另一个结构体,它储存了这个接口值的动态值的类型和方法集

由于空接口没有方法集,所以它不需要 itab 结构了,而是只存动态值的类型信息:

type eface struct {
  _type *_type        // 直接存类型信息
  data unsafe.Pointer
}

动态值 (底层值)是这个接口值所包含的实际的值。

例如我们把 f Fool 赋值给了接口值 s Sayer,那么此时,s 的动态值就是 f(准确说是 f 的副本),s 动态值的类型就是 Fool,方法集就是 Fool 的方法集。

可以发现,接口值的本质和切片很像:它们都是一个包装,通过指针引用着实际(底层)的值。

当一个值 v 被赋值给接口变量 i(前提是 v 的类型实现了 i 的接口),可能发生两件事情:

  • <u>若 v 不是接口类型</u>。那么创建一个接口结构体并赋值给 iv 会被拷贝一份包装到 i 里。除此之外,i 也会记录 v 的类型信息(不用拷贝)。
  • <u>若 v 也是一个接口</u>。那么 v 里包装的动态值会被再复制一份放到 i 里。然后 v 里包装的类型信息也会同步给 i

在编译的时候,Go 编译器会构建一个全局表(不是 itab),存储用到的所有类型的信息。这些信息包括:类型的种类 (kind)、字段、方法等。因此构建 itab 时类型信息只需指向全局表就行,不需要拷贝了。

这么说有点抽象,上面 "Fool" 的例子,背后发生的事如图:

非接口值赋值给接口变量

没错,把值转成接口会复制一份,这两份的数据各自独立,这与 java 等很不一样。所以大部分情况下应该把指针转成接口:

var f = Fool{ "Tom" }
var s Sayer = &f // <-- 用地址转换成接口

另外,接口值的动态值的「直接部分」是不允许修改的。只能整体替换动态值。

所谓「直接部分」,假设接口动态值是一个容器类型,这个容器的字段就是直接部分(例如 slice.data / slice.len / slice.cap),这些字段所指向的数据(如果是指针字段的话)就是间接部分,例如切片底层数组里的数据,可以修改。

和结构体类似,接口的零值也是 nil但是注意区分下面三个说法:

  • 零(nil)接口值
  • 空接口值
  • 包装了 nil 值的接口(值)

零接口值

零接口值 = nil 接口值 = 接口的零值

不要想的太复杂,就是那个最简单,最基础的零值概念。

var s0 Sayer
var s1 Sayer = nil
// s0, s1 都是零接口值

零接口值中什么也没有包装——是是个空箱子。

空接口值

空接口值 = 空接口类型的接口值

上面说过,任何类型都实现了空接口,因此可以把空接口值视为其他语言中的 any 类型的值。

type Empty interface {} // 这是一个空接口
var e1 Empty = 1
var e2 Empty = "abc"
var e3 Empty = Fool{ "Tom" }
// e1, e2, e3 都是空接口值,但不是 nil

空接口值可以是零接口值。

type Empty interface {} // 这是一个空接口
var e1 Empty // e1 是空接口的值,也是 nil

包装了 nil 值的接口值

type Empty interface {} // 这是一个空接口
func main() {
  var n []int = nil
  var e1 Empty = n
  var e2 Empty = nil
  // e1 是包装了 nil 值的接口值
  // e1 不是零值(零接口值/nil接口值)!
  // e2 才是零接口值
  fmt.Printf("%v\n", e1 == e2) // false
} 

接口指针?

奇怪的错误

首先,你觉得下面的程序有问题吗 🤨 ?

type People interface { Rename(name string) }
type Teacher struct { Name string }

func (t *Teacher) Rename(name string) {
  t.Name = name
}

func changeName(p *People) {
  fmt.Printf("p 的地址:%p\n", p)
  p.Rename("Bob")
}

答案是有!L10 p.Rename("Bob") 无法编译通过,错误是 p.Rename undefined (type *People is pointer to interface, not interface)。看看多人性,知道你可能一头雾水还特地解释了一下。

来捋一下,为什么函数 changeName() 的参数要定义为指针?因为

  1. 不想发生无谓的内存拷贝。
  2. 希望对结构体的更改可以带回。

那么事实上,我们想要的效果是这样:

func changeName(p *Teacher) {
  fmt.Printf("p 的地址:%p\n", p)
  p.Rename("Bob")
}

同时又要求灵活性(除了 Teacher,其他包含 People 的结构体应该也可以 changeName)。想当然地,把一个具体的类型换成了接口。然后就陷入了一个误区 ❗️

结构体指针和接口指针是两码事,不可以混淆或类比使用。

经过上面毫无逻辑的长篇大论,我们已经知道接口的值类似于切片,是一个容器。所以通常根本不需要使用接口指针。为此 Go 专门取消了自动解引用接口指针,以减少错误的使用。^ 2

接口方法的接收者

讲到这,我们可以试着理解一个很容易记混的规定的来源:

指针接收者的接口实现,只能用指针调用。
值接收者的接口实现,既可以用值调用,也可以用指针调用。

只能用指针调用指针

从原理上,只定义了指针接收器,值的方法集就不包含此函数,所以值不可以调用指针接收器的函数。

那为什么指针接收器的方法不自动隐式声明值类型接收器的方法呢?根据 《Go 语言实战》5.4.3 方法集 ,有地址就一定能取到值,然而不是每个值都可以寻址的,比如:playground

type duration int

func main() {
  // cannot take address of duration(40) (constant 40 of type duration)
  fmt.Printf("%p", &duration(40))
}

显然,duration(40) 是个合法的值,但是取不到地址,自然也就不能用值去调用指针接收器方法。Go FAQ - Why do T and *T have different method sets? 中也提到了类似的解释。

But if an interface value contains a value T, there is no safe way for a method call to obtain a pointer. (Doing so would allow a method to modify the contents of the value inside the interface, which is not permitted by the language specification.)

除此之外,FAQ 也从设计角度给出了答案:这个限制是为了避免非预期的结果。当我们把一个方法的接收者定义为指针,就期望对其内部状态的更改可带到外面。即使传入的参数可以寻址,接口值内部保存的只是实现它的对象的一个拷贝,对拷贝的修改不能反映到外面。比如:

var buf bytes.Buffer
io.Copy(buf, os.Stdin) // 实际上不允许,假设可以

Buffer 实现了 io.Writer,这个 buf 也可以寻址。假设编译器接受了这个写法,那么 Copy() 拿到的 dst io.Writer 内部包装的是 buf一个拷贝Buffer.Write() 的接收器也只能拿到这个拷贝的地址。尽管理论上跑得通,但如此一来,外面的 buf 内容不会改变,显然,这和我们预期的不一样。


🎉 彩蛋

有时可以定义一个匿名的包级变量:

var _ Interface = &Struct{}

它有两个作用^ 3

  1. 确保 Struct 实现了 Interface
  2. 预热 Interface 的 itable。(非主要作用)

参考