Go 是传值的
在正式开始讨论指针与接口之前,我们有必要达成共识:Go 中函数传参是传值。
普通参数
普通参数很容易理解,传值就是把原来的值拷贝一份,再传给函数。那么函数拿到的是副本,对齐的修改自然也不会影响原来的值。playground
|
|
传参发生时,系统开辟了新的内存空间(位于 0x65
),把原先的值拷贝一份进去。foo()
修改的是 0x65
里的值。而打印的依然是 a
,是 0x11
里的值。
指针参数
指针参数传的还是值!还是有拷贝!但是拷贝的不是值本身,而是一个指针。所以在 64 位电脑上,一个指针传递要拷贝 8B 内存,无论它指向的值有多大。playground
|
|
把 b
传过去变成 c
的过程中依然发生了拷贝。b
和 c
对应内存中完全不同的地址,只不过它们都指向 a
而已。就像公路和铁路都连接杭州,但它们是完全不同的东西,公路堵车不会影响铁路。
此时 foo()
修改的不是 c
本身,而是 c
指向的那个东西。因此变化可以反映到 a
或 *b
。就像通过火车把苹果运到了杭州,你此时开车去杭州也可以看见这箱苹果。
切片参数
切片也是传值!不是引用!
切片是一个结构体:
|
|
所以切片传递时和传递一个普通结构体没有什么区别——需要把其 3 个字段完整拷贝。特殊的是,切片是一个引用类型。
所谓引用类型,指它实际的值存放在一片单独的内存中,与自身分离。自己通过保存一个内存指针来访问数据。
我们完全可以自己写一个引用类型,实现和切片类似的,看起来像引用存传递,实际是值传递的效果:playground
|
|
看,虽然参数 r
不是指针,但它内部的改动依然可以反映到 main
里。
切片同理:playground
|
|
很明显,b
是 a
的一个拷贝,由此证明 slice 也是传值。
为了进一步证明 slice 是引用类型,可以做一个实验:playground
|
|
虽然追加元素后没有重新赋值,其实底层数组已经更新了。由于 slice 的 len
字段不是指针,才未能更新正确的长度导致报错。
虽然大部分情况下我们只需要传切片值就好,但因为其本质依然是值传递,所以如果可能修改切片的自己的字段(例如发生重分配/扩容),还是需要以指针的形式传递切片,否则修改无法回带。
接口的本质
原型与方法集
聊接口之前,先熟悉几个名词。
函数原型 (fuction prototype):由函数名与函数签名组成。字面形式还包括一个
func
关键字。例如:1
func Double(n int) (result int)
方法原型 (method prototype):形式上是不带
func
关键字的函数原型。方法 =func
+ 属主参数声明(接收器)+ 方法原型 + 方法体。方法原型例如:1 2
Pages() int SetPages(pages int)
方法集 (method set):一个类型的方法集由它所声明的所有方法原型(不包括名字为空白标识符
_
的方法)组成。接口类型除外,这是下面要重点讨论的。 方法集是无序的。例如对于下面的声明:1 2 3 4 5 6 7
func (b Book) Pages() int { return Book.Pages(b) } func (b *Book) SetPages(pages int) { (*Book).SetPages(b, pages) }
Book
的方法集为:1
Pages() int
*Book
的方法集为:1 2
Pages() int SetPages(pages int)
注意,这里隐含了两个关键点:
Book
与*Book
是两个完全不同的类型。- 值接收器的方法隐式地同时被声明为指针类型的方法。反之不成立。
接口类型与实现
「接口类型」定义了一个方法集。前面说过,每个类型都有一个方法集:
对于非接口类型:方法集由它所声明的,所有的,名称不为空的方法原型组成。
对于接口类型:方法集就是它所指定的方法集。 简单粗暴对不对?
因此接口可以说定义了一个类型「能做什么」。
如果任意一个类型的方法集是接口类型的方法集的超集,那么这个类型就实现了此接口。这句看似简单的话也隐含了几个易混淆的点:
接口的实现不一定是结构体,而可能是任意类型。即:基本类型的定义类型、结构体,甚至接口都可以实现接口(而不叫继承)例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
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
方法集由方法原型组成,方法原型=函数名+函数签名。所以只有一个类型的方法名称、参数、返回值都与接口中定义的相同时,才可以实现接口。只有函数签名相同是不行的。
1 2 3 4 5 6 7
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)
|
|
可以看到,f
与 s
不是一个东西。
接口的值其实是一个结构体实例,其大小为 2 字(在 64 位设备上是 16B),含有 2 个字段,定义如下1:
|
|
其中 itab
是另一个结构体,它储存了这个接口值的动态值的类型和方法集。
由于空接口没有方法集,所以它不需要 itab
结构了,而是只存动态值的类型信息:
|
|
动态值 (底层值)是这个接口值所包含的实际的值。
例如我们把
f Fool
赋值给了接口值s Sayer
,那么此时,s
的动态值就是f
(准确说是f
的副本),s
动态值的类型就是Fool
,方法集就是Fool
的方法集。
可以发现,接口值的本质和切片很像:它们都是一个包装,通过指针引用着实际(底层)的值。
当一个值 v
被赋值给接口变量 i
(前提是 v 的类型实现了 i 的接口),可能发生两件事情:
- 若
v
不是接口类型。那么创建一个接口结构体并赋值给i
,v
会被拷贝一份包装到i
里。除此之外,i
也会记录v
的类型信息(不用拷贝)。 - 若
v
也是一个接口。那么v
里包装的动态值会被再复制一份放到i
里。然后v
里包装的类型信息也会同步给i
。
在编译的时候,Go 编译器会构建一个全局表(不是 itab),存储用到的所有类型的信息。这些信息包括:类型的种类 (kind)、字段、方法等。因此构建 itab 时类型信息只需指向全局表就行,不需要拷贝了。
这么说有点抽象,上面 “Fool” 的例子,背后发生的事如图:
没错,把值转成接口会复制一份,这两份的数据各自独立,这与 java 等很不一样。所以大部分情况下应该把指针转成接口:
|
|
另外,接口值的动态值的「直接部分」是不允许修改的。只能整体替换动态值。
所谓「直接部分」,假设接口动态值是一个容器类型,这个容器的字段就是直接部分(例如
slice.data
/slice.len
/slice.cap
),这些字段所指向的数据(如果是指针字段的话)就是间接部分,例如切片底层数组里的数据,可以修改。
和结构体类似,接口的零值也是 nil
。但是注意区分下面三个说法:
- 零(nil)接口值
- 空接口值
- 包装了 nil 值的接口(值)
零接口值
零接口值 = nil 接口值 = 接口的零值
不要想的太复杂,就是那个最简单,最基础的零值概念。
|
|
零接口值中什么也没有包装——是是个空箱子。
空接口值
空接口值 = 空接口类型的接口值
上面说过,任何类型都实现了空接口,因此可以把空接口值视为其他语言中的 any
类型的值。
|
|
空接口值可以是零接口值。
|
|
包装了 nil 值的接口值
|
|
接口指针?
奇怪的错误
首先,你觉得下面的程序有问题吗 🤨 ?
|
|
答案是有!L10 p.Rename("Bob")
无法编译通过,错误是 p.Rename undefined (type *People is pointer to interface, not interface)
。看看多人性,知道你可能一头雾水还特地解释了一下。
来捋一下,为什么函数 changeName()
的参数要定义为指针?因为
- 不想发生无谓的内存拷贝。
- 希望对结构体的更改可以带回。
那么事实上,我们想要的效果是这样:
|
|
同时又要求灵活性(除了 Teacher,其他包含 People 的结构体应该也可以 changeName)。想当然地,把一个具体的类型换成了接口。然后就陷入了一个误区 ❗️
结构体指针和接口指针是两码事,不可以混淆或类比使用。
经过上面毫无逻辑的长篇大论,我们已经知道接口的值类似于切片,是一个容器。所以通常根本不需要使用接口指针。为此 Go 专门取消了自动解引用接口指针,以减少错误的使用。2
接口方法的接收者
讲到这,我们可以试着理解一个很容易记混的规定的来源:
指针接收者的接口实现,只能用指针调用。
值接收者的接口实现,既可以用值调用,也可以用指针调用。
只能用指针调用指针
从原理上,只定义了指针接收器,值的方法集就不包含此函数,所以值不可以调用指针接收器的函数。
那为什么指针接收器的方法不自动隐式声明值类型接收器的方法呢?根据 《Go 语言实战》5.4.3 方法集 ,有地址就一定能取到值,然而不是每个值都可以寻址的,比如:playground
|
|
显然,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 也从设计角度给出了答案:这个限制是为了避免非预期的结果。当我们把一个方法的接收者定义为指针,就期望对其内部状态的更改可带到外面。即使传入的参数可以寻址,接口值内部保存的只是实现它的对象的一个拷贝,对拷贝的修改不能反映到外面。比如:
|
|
Buffer
实现了 io.Writer
,这个 buf
也可以寻址。假设编译器接受了这个写法,那么 Copy()
拿到的 dst io.Writer
内部包装的是 buf
的一个拷贝,Buffer.Write()
的接收器也只能拿到这个拷贝的地址。尽管理论上跑得通,但如此一来,外面的 buf
内容不会改变,显然,这和我们预期的不一样。
🎉 彩蛋
有时可以定义一个匿名的包级变量:
|
|
它有两个作用3:
- 确保
Struct
实现了Interface
。 - 预热 Interface 的 itable。(非主要作用)