前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Golang指针与nil浅析

Golang指针与nil浅析

作者头像
李海彬
发布2018-03-27 12:00:23
1.1K0
发布2018-03-27 12:00:23
举报
文章被收录于专栏:Golang语言社区Golang语言社区
曾经听说过一句话,编程的本质就是指针和递归。那会刚开始编码,只是这两个的概念有个感性粗浅的认识。最早接触指针,莫过于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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2017-07-23,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Golang语言社区 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档