Golang指针与nil浅析

人世间 · · 6648 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

曾经听说过一句话,编程的本质就是指针和递归。那会刚开始编码,只是这两个的概念有个感性粗浅的认识。最早接触指针,莫过于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


有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:人世间

查看原文:Golang指针与nil浅析

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

6648 次点击  ∙  1 赞  
加入收藏 微博
1 回复  |  直到 2021-01-13 14:55:07
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传