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
的过程中依然发生了拷贝。b
和 c
对应内存中完全不同的地址,只不过它们都指向 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
}
很明显,b
是 a
的一个拷贝,由此证明 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)
注意,这里隐含了两个关键点:
Book
与*Book
是两个完全不同的类型。- 值接收器的方法隐式地同时被声明为指针类型的方法。反之不成立。
接口类型与实现
「接口类型」定义了一个方法集。前面说过,每个类型都有一个方法集:
-
对于非接口类型:方法集由它所声明的,所有的,名称不为空的方法原型组成。
-
对于接口类型:方法集就是它所指定的方法集。 简单粗暴对不对?
因此接口可以说定义了一个类型「能做什么」。
如果任意一个类型的方法集是接口类型的方法集的超集,那么这个类型就实现了此接口。这句看似简单的话也隐含了几个易混淆的点:
-
接口的实现不一定是结构体,而可能是任意类型。即:基本类型的定义类型、结构体,甚至接口都可以实现接口(而不叫继承)例如:
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
-
方法集由方法原型组成,方法原型=函数名+函数签名。所以只有一个类型的方法名称、参数、返回值都与接口中定义的相同时,才可以实现接口。只有函数签名相同是不行的。
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
}
可以看到,f
与 s
不是一个东西。
接口的值其实是一个结构体实例,其大小为 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>。那么创建一个接口结构体并赋值给i
,v
会被拷贝一份包装到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()
的参数要定义为指针?因为
- 不想发生无谓的内存拷贝。
- 希望对结构体的更改可以带回。
那么事实上,我们想要的效果是这样:
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:
- 确保
Struct
实现了Interface
。 - 预热 Interface 的 itable。(非主要作用)