Golang指针与nil浅析
曾经听说过一句话,编程的本质就是指针和递归。那会刚开始编码,只是这两个的概念有个感性粗浅的认识。最早接触指针,莫过于C语言了,能否理解用好指针也成为一个合格C语言的基本标志。
Golang也提供了指针,但是go不能进行指针运算,因此相对于C也少了很多复杂度。私以为,go之所以提供指针,并不是为了让你更多和内存打交道,而是提供操作数据的基本桥梁。因为go很多调用,往往复制一份对象,例如函数的参数,如果没有指针,有些情况不得不存在很多副本。
内存和变量
编程语言中一般都会有变量。变量存储一些值。通常我们会对变量声明,赋值,和销毁等操作。
想象一下,内存好比一个长长的桌子,桌子有很多连续的抽屉(内存块)。我们可以按照顺序给每一个抽屉从0开始编号(内存地址),这个编号就是抽屉的地址。当我们需要使用抽屉存放东西的时候,就通过编号找到对应的抽屉,放好东西。这个东西就是我们存的数据。
addr 1 2 3 4 5 +----------+---------+---------+---------+---------+ | | | | | | | | | | | | | a book | none | none | none | a pen | | | | | | | | | | | | | +----------+---------+---------+---------+---------+
通过编号找东西固然不错,可是有时候我们想直观的知道抽屉里放了什么内容,就给抽屉外面贴上(声明)一个标签(变量名),比如编号5的抽屉式水果,编号7的抽屉式书啦。下次要找书,就直接找到贴有书标签的抽屉即可。
addr 1 2 3 4 5 +----------+---------+---------+---------+---------+ | | | | | | | | | | | | | a book | none | none | none | a pen | | | | | | | | | | | | | +----------+---------+---------+---------+---------+ tag book pen
内存,内存地址,内存存储的数据,变量名,这些概念几乎是计算机通过编程语言执行程序的基本套路。只不过高级语言往往帮我们隐藏了内存地址和变量名的映射。像C这样的可以声明一个变量,然后赋值,而像Python,声明和赋值甚至可以写成一起。
指针
了解了内存地址和变量的关系,我们再看看指针。可以把指针看成是一种“类型”,这种类型的值是一个内存地址。例如有一个编号3抽屉,里面存放了一个指针,而这个指针的值是一个编号5,通过操作指针,我们可以直接操作编号5的内存数据。
addr 1 2 3 4 5 +----------+---------+---------+---------+---------+ | | | | | | | | | | | | | a book | none | 5 --> | none | a pen | | | | | | | | | | | | | +----------+---------+---------+---------+---------+ tag book pointer pen
记住,指针的是内存地址,但是指针本身也是有内存地址的。正如指向别的抽屉,也有一个抽屉来存储它自己。
golang指针的地址和值
高级语言提供完美声明变量和值之间的绑定关系。帮我们隐藏了变量内存地址。想要获取内存地址,需要在变量前加上一个符号&
,&
即为取址符。例如变量a
的内存地址为&a
。
对于一个指针,它的值是一个别处的地址,想要获取这个地址的值,可以使用*
符号。*
即为取值符。例如上面的&a
是一个地址,那么这个地址里存储的值为*&a
。
由此可见,&
和*
是是一对相爱相杀的兄弟,他们做着相反的事情。
初学指针的同学,往往混淆指针的值和指针地址的差别,指针的值是一个地址,是别的内存地址,指针的地址则是存储指针内存块的地址。例如你家里装着公司的钥匙,这个钥匙可以打开公司的大门,而你家的大门需要你自己的钥匙。
零值与nil
talk is cheaper,下面来看看golang中的指针相关操作
package main import "fmt" func main() { // 声明一个变量 aVar 类型为 string var aVar string fmt.Printf("aVar: %p %#v\n", &aVar, aVar) // 输出 aVar: 0xc42000e240 "" }
我们声明了一个字串类似的变量,尚未赋值,go就会自动赋予一个零值。字符的零值就是空子串。同时通过&
符号读取了变量的内存地址。
fmt.Printf 函数可以通过格式化字串打印出变量,
p
表示可以打印指针,v
可以打印变量的值,#
v可以打印变量的结构。
上面的过程可以用下面的简图来表示:
addr 0xc42000e240 +---------+ | | | "" | | | | | +---------+ aVar
下面再声明一个指针变量,使用*
符号声明一个指针变量。
// 声明一个指针变量 aPot 其类型也是 string var aPot *string fmt.Printf("aPot: %p %#v\n", &aPot, aPot) // 输出 aPot: 0xc42000c030 (*string)(nil)
指针变量的零值不是空子串,而是nil
。aPot的值是指针类型,由于尚未该指针尚未指向另外一个地址。因此初始化为nil。
这个过程可以用下面的图表示:
addr 0xc42000c030 +---------+ | | | | | nil | | | +---------+ aPot
正常的变量初始化之后,可以使用=
赋值:
func main() { // 声明一个变量 aVar 类型为 string var aVar string fmt.Printf("aVar: %p %#v\n", &aVar, aVar) // 输出 aVar: 0xc42000e240 "" aVar = "This is a aVar" fmt.Printf("aVar: %p %#v\n", &aVar, aVar) // 输出 aVar: 0xc42000e240 "This is a aVar" }
普通变量赋值十分简单,无非就是抽屉换一个值啦。
addr 0xc42000e240 +---------+ | This is | | | | a aVar | | | +---------+ aVar
可是如果一个值为nil的指针变量,直接赋值会出问题。
func main(){ // 声明一个指针变量 aPot 其类型也是 string var aPot *string fmt.Printf("aPot: %p %#v\n", &aPot, aPot) // 输出 aPot: 0xc42000c030 (*string)(nil) *aPot = "This is a Pointer" // 报错: panic: runtime error: invalid memory address or nil pointer dereference }
出错也很正常,*aPot = "This is a Pointer"
的含义可以理解为,将aPot的指针地址的值赋予"This is a Pointer"
。可是aPot的值是nil,但还没有赋值成地址,因此不能把一个子串赋值给一个nil值。此外,即使不是赋值,对nil的指针通过*
读取也会报错,毕竟读取不到任何地址。
解决问题方式就是初始化一个内存,并把该内存地址赋予指针变量。
// 声明一个指针变量 aPot 其类型也是 string var aPot *string fmt.Printf("aPot: %p %#v\n", &aPot, aPot) // 输出 aPot: 0xc42000c030 (*string)(nil) aPot = &aVar *aPot = "This is a Pointer" fmt.Printf("aVar: %p %#v \n", &aVar, aVar) // 输出 aVar: 0xc42000e240 "This is a Pointer" fmt.Printf("aPot: %p %#v %#v \n", &aPot, aPot, *aPot) // 输出 aPot: 0xc42000c030 (*string)(0xc42000e240) "This is a Pointer"
我们把aVar的内存地址赋值给aPot,也可以看到aPot的值也就是aVar的地址,同时也可以通过*
读取aPot指针地址所指向的值,即aVar的值。
addr 0xc42000c030 0xc42000c240 +---------------+ +----------+ | | | | | 0oxc42000x240 |+-----> | This is | | | | | | | | a aVar | +---------------+ +----------+ aPot aVar
new 关键字
通过已经存在的aVar,我们可以给aPot指针赋值。可以如果没有已存在都变量,go提供了new来初始化一个地址。
var aNewPot *int aNewPot = new(int) *aNewPot = 217 fmt.Printf("aNewPot: %p %#v %#v \n", &aNewPot, aNewPot, *aNewPot) // 输出 aNewPot: 0xc42007a028 (*int)(0xc42006e1f0) 217
new 可以开辟一个内存,然后返回这个内存的地址。因为int指针是简单类型,因此new(int)的操作,除了可以开辟一个内存,还能为这个内存初始化零值,即0。
new 不仅可以为简单类型开辟内存,也可以为复合引用类型开辟,不过后者初始化的零值还是nil,如果需要赋值,还会有别的问题,下面我们再讨论。
复合类型与nil
int,string等是基础类型,array则是基于这些基础类型的复合类型。复合类型的指针初始化也需要注意:
var arr [5]int fmt.Printf("arr: %p %#v \n", &arr, arr) // arr: 0xc420014180 [5]int{0, 0, 0, 0, 0} arr[0], arr[1] = 1, 2 fmt.Printf("arr: %p %#v \n", &arr, arr) // arr: 0xc420014180 [5]int{1, 2, 0, 0, 0}
声明一个大小为5的数组,go会自动为数组的item初始化为零值,数组可以通过索引读取和赋值。
如果声明的是一个数组指针,即一个指针的类型是数组,这个指针如何初始化和赋值呢?
var arrPot *[5]int fmt.Printf("arrPot: %p %#v \n", &arrPot, arrPot) // arrPot: 0xc42000c040 (*[5]int)(nil)
从输出可以看到,arrPot初始化的值是nil
。我们已经了解,nil的值是不能直接赋值的,因此(*arrPot)[0] = 11
直接赋值会抛错。
new 关键之函数
既然如此,我们可以使用new创建一块内存,并把内存地址给arrPot指针变量。然后赋值就正常啦。
var arrPot *[5]int fmt.Printf("arrPot: %p %#v \n", &arrPot, arrPot) // arrPot: 0xc42000c040 (*[5]int)(nil) arrPot = new([5]int) fmt.Printf("arrPot: %p %#v \n", &arrPot, arrPot) // arrPot: 0xc42000c040 &[5]int{0, 0, 0, 0, 0} (*arrPot)[0] = 11 fmt.Printf("arrPot: %p %#v \n", &arrPot, arrPot) // arrPot: 0xc42000c040 &[5]int{11, 0, 0, 0, 0}
上面的内存图如下:
addr 0xc42000c040 +------------------+ 0xc42000c099 (new创建的内存) | | +------+------+------+------+------+ | | | | | | | | | 0xc42000c099 +-----> | 11 | 0 | 0 | 0 | 0 | | | | | | | | | | | +------+------+------+------+------+ +------------------+ arrPot
引用类型与nil
Go的array是虽然是复合类型,但不是引用类型。go中的引用类似是slice,map等。下面我们就看看map类型如何初始化已经对nil的处理。
var aMap map[string]string fmt.Printf("aMap: %p %#v \n", &aMap, aMap) // aMap: 0xc42000c048 map[string]string(nil)
声明一个map类型的变量,map不像array那样声明之后可以初始化成零值。go会给引用类型初始化为nil,nil是不能直接赋值的。并且,map和数组指针还不一样,不能使用new开辟一个内存,然后再赋值。aMap本身就是值类型,声明就已经初始化内存了,只不过其值是nil而已,我们不能修改地址。&aMap = new(map[string]string)
这样的操作会报错。
make 关键字
既然无法使用new,那么go提供了另外一个函数make。make不仅可以开辟一个内存,还能给这个内存的类型初始化其零值,同时返回这个内存实例。
aMap = make(map[string]string) aMap["name"] = "Golang" fmt.Printf("aMap: %p %#v \n", &aMap, aMap) // aMap: 0xc420078038 map[string]string{"name":"Golang"}
new 和 make
New和make都是golang用来初始化变量的内存的关键字函数。new返回的是内存的地址,make则返回时类型的示例。比如new一个数组,则返回一个数组的内存地址,make一个数组,则返回一个初始化的数组。
经过上面的case,相信再面对map类型的指针,也一样可以通过new和make配合完成初始化工作。
var mapPot *map[string]int fmt.Printf("mapPot: %p %#v \n", &mapPot, mapPot) // mapPot: 0xc42000c050 (*map[string]int)(nil) // 初始化map指针的地址 mapPot = new(map[string]int) fmt.Printf("mapPot: %p %#v \n", &mapPot, mapPot) // mapPot: 0xc42000c050 &map[string]int(nil) //(*mapPot)["age"] = 21 // 报错 // 初始化map指针指向的map (*mapPot) = make(map[string]int) (*mapPot)["age"] = 21 fmt.Printf("mapPot: %p %#v \n", &mapPot, mapPot) // mapPot: 0xc42000c050 &map[string]int{"age":21}
上面的代码声明了一个指针变量mapPot,这个指针变量的类型是一个map。通过new给指针变量开辟了一个内存,并赋予其内存地址。
Map是引用类型,其零值为nil,因此使用make初始化map,然后变量就能使用*
给指针变量mapPot赋值了。
Make除了可以初始化map,还可以初始化slice和channel,以及基于这三种类型的自定义类型。
type User map[string]string var user User fmt.Printf("user: %p %#v \n", &user, user) // user: 0xc42000c060 main.User(nil) user = make(User) user["name"] = "Golang" fmt.Printf("user: %p %#v \n", &user, user) // user: 0xc42007a050 main.User{"name":"Golang"} var userPot *User fmt.Printf("userPot: %p %#v \n", &userPot, userPot) // userPot: 0xc42000c068 (*main.User)(nil) userPot = new(User) fmt.Printf("userPot: %p %#v \n", &userPot, userPot) // userPot: 0xc42000c068 &main.User(nil) (*userPot) = make(User) fmt.Printf("userPot: %p %#v \n", &userPot, userPot) // userPot: 0xc42000c068 &main.User{} (*userPot)["name"] = "Golang" fmt.Printf("userPot: %p %#v \n", &userPot, userPot) // userPot: 0xc42000c068 &main.User{"name":"Golang"}
可见,再复杂的类型,只要弄清楚了指针与nil的关系,配合new和make就能轻松的给golang的数据类型进行初始化。
方法中的指针
Go可以让我自定义类型,而类型又可以创建方法。与OOP类似,方法接受一个类型的实例对象,称之为接受者,接受者既可以是类型的实例变量,也可以是类型的实例指针变量。
func main(){ person := Person{"vanyar", 21} fmt.Printf("person<%s:%d>\n", person.name, person.age) person.sayHi() person.ModifyAge(210) person.sayHi() } type Person struct { name string age int } func (p Person) sayHi() { fmt.Printf("SayHi -- This is %s, my age is %d\n",p.name, p.age) } func (p Person) ModifyAge(age int) { fmt.Printf("ModifyAge") p.age = age }
输出如下:
person<vanyar:21> SayHi -- This is vanyar, my age is 21 ModifyAgeSayHi -- This is vanyar, my age is 21
尽管 ModifyAge 方法修改了其age字段,可是方法里的p是person变量的一个副本,修改的只是副本的值。下一次调用sayHi方法的时候,还是person的副本,因此修改方法并不会生效。
也许有人会想,方法会拷贝实例变量,如果实例变量是一个指针,不就轻而易举的修改了么?
personPot := &Person{"noldor", 27} fmt.Printf("personPot<%s:%d>\n", personPot.name, personPot.age) personPot.sayHi() personPot.ModifyAge(270) personPot.sayHi()
输出如下:
personPot<noldor:27> SayHi -- This is noldor, my age is 27 ModifyAgeSayHi -- This is noldor, my age is 27
可见并没有效果,实际上,go的确实copy里personPot,只不过会根据接受者是值还是指针类型做一个自动转换,然后再拷贝转换后的对象。即personPot.ModifyAge(270)
实际上等同于(*personPot).ModifyAge(270)
,也就是拷贝的是(*personPot)。与personPot本身是值还是指针没有关系。
真正能修改对象的方式是设置指针类型的接受者。指针类型的接受者,如果实例对象是值,那么go会转换成指针,然后再拷贝,如果本身就是指针对象,那么就直接拷贝指针实例。因为指针都指向一处值,自然就能修改对象了。代码如下:
func (p *Person) ChangeAge(age int) { fmt.Printf("ModifyAge") p.age = age }
Go会根据Person的示例类型,转换成指针类型再拷贝,即 person.ChangeAge会变成 (&person).ChangeAge。
总结
Golang是一门简洁的语言,提供了指针用于操作数据内存,并通过引用来修改变量。
只声明未赋值的变量,golang都会自动为其初始化为零值,基础数据类型的零值比较简单,引用类型和指针的零值都为nil,nil类型不能直接赋值,因此需要通过new开辟一个内存,或者通过make初始化数据类型,或者两者配合,然后才能赋值。
指针也是一种类型,不同于一般类似,指针的值是地址,这个地址指向其他的内存,通过指针可以读取其所指向的地址所存储的值。
函数方法的接受者,也可以是指针变量。无论普通接受者还是指针接受者都会被拷贝传入方法中,不同在于拷贝的指针,其指向的地方都一样,只是其自身的地址不一样。
文字输出的内存地址因编译环境和运行有所不同。参考代码gist