极客时间已完结课程限时免费阅读

15 | 关于指针的有限操作

15 | 关于指针的有限操作-极客时间

15 | 关于指针的有限操作

讲述:黄洲君

时长17:52大小8.18M

在前面的文章中,我们已经提到过很多次“指针”了,你应该已经比较熟悉了。不过,我们那时大多指的是指针类型及其对应的指针值,今天我们讲的则是更为深入的内容。
让我们先来复习一下。
type Dog struct {
name string
}
func (dog *Dog) SetName(name string) {
dog.name = name
}
对于基本类型Dog来说,*Dog就是它的指针类型。而对于一个Dog类型,值不为nil的变量dog,取址表达式&dog的结果就是该变量的值(也就是基本值)的指针值。
如果一个方法的接收者是*Dog类型的,那么该方法就是基本类型Dog的指针方法。
在这种情况下,这个方法的接收者,实际上就是当前的基本值的指针值。
我们可以通过指针值无缝地访问到基本值包含的任何字段,以及调用与之关联的任何方法。这应该就是我们在编写 Go 程序的过程中,用得最频繁的“指针”了。
从传统意义上说,指针是一个指向某个确切的内存地址的值。这个内存地址可以是任何数据或代码的起始地址,比如,某个变量、某个字段或某个函数。
我们刚刚只提到了其中的一种情况,在 Go 语言中还有其他几样东西可以代表“指针”。其中最贴近传统意义的当属uintptr类型了。该类型实际上是一个数值类型,也是 Go 语言内建的数据类型之一。
根据当前计算机的计算架构的不同,它可以存储 32 位或 64 位的无符号整数,可以代表任何指针的位(bit)模式,也就是原始的内存地址。
再来看 Go 语言标准库中的unsafe包。unsafe包中有一个类型叫做Pointer,也代表了“指针”。
unsafe.Pointer可以表示任何指向可寻址的值的指针,同时它也是前面提到的指针值和uintptr值之间的桥梁。也就是说,通过它,我们可以在这两种值之上进行双向的转换。这里有一个很关键的词——可寻址的(addressable)。在我们继续说unsafe.Pointer之前,需要先要搞清楚这个词的确切含义。
今天的问题是:你能列举出 Go 语言中的哪些值是不可寻址的吗?
这道题的典型回答是以下列表中的值都是不可寻址的。
常量的值。
基本类型值的字面量。
算术操作的结果值。
对各种字面量的索引表达式和切片表达式的结果值。不过有一个例外,对切片字面量的索引结果值却是可寻址的。
对字符串变量的索引表达式和切片表达式的结果值。
对字典变量的索引表达式的结果值。
函数字面量和方法字面量,以及对它们的调用表达式的结果值。
结构体字面量的字段值,也就是对结构体字面量的选择表达式的结果值。
类型转换表达式的结果值。
类型断言表达式的结果值。
接收表达式的结果值。

问题解析

初看答案中的这些不可寻址的值好像并没有什么规律。不过别急,我们一起来梳理一下。你可以对照着 demo35.go 文件中的代码来看,这样应该会让你理解起来更容易一些。
常量的值总是会被存储到一个确切的内存区域中,并且这种值肯定是不可变的。基本类型值的字面量也是一样,其实它们本就可以被视为常量,只不过没有任何标识符可以代表它们罢了。
第一个关键词:不可变的。由于 Go 语言中的字符串值也是不可变的,所以对于一个字符串类型的变量来说,基于它的索引或切片的结果值也都是不可寻址的,因为即使拿到了这种值的内存地址也改变不了什么。
算术操作的结果值属于一种临时结果。在我们把这种结果值赋给任何变量或常量之前,即使能拿到它的内存地址也是没有任何意义的。
第二个关键词:临时结果。这个关键词能被用来解释很多现象。我们可以把各种对值字面量施加的表达式的求值结果都看做是临时结果。
我们都知道,Go 语言中的表达式有很多种,其中常用的包括以下几种。
用于获得某个元素的索引表达式。
用于获得某个切片(片段)的切片表达式。
用于访问某个字段的选择表达式。
用于调用某个函数或方法的调用表达式。
用于转换值的类型的类型转换表达式。
用于判断值的类型的类型断言表达式。
向通道发送元素值或从通道那里接收元素值的接收表达式。
我们把以上这些表达式施加在某个值字面量上一般都会得到一个临时结果。比如,对数组字面量和字典字面量的索引结果值,又比如,对数组字面量和切片字面量的切片结果值。它们都属于临时结果,都是不可寻址的。
一个需要特别注意的例外是,对切片字面量的索引结果值是可寻址的。因为不论怎样,每个切片值都会持有一个底层数组,而这个底层数组中的每个元素值都是有一个确切的内存地址的。
你可能会问,那么对切片字面量的切片结果值为什么却是不可寻址的?这是因为切片表达式总会返回一个新的切片值,而这个新的切片值在被赋给变量之前属于临时结果。
你可能已经注意到了,我一直在说针对数组值、切片值或字典值的字面量的表达式会产生临时结果。如果针对的是数组类型或切片类型的变量,那么索引或切片的结果值就都不属于临时结果了,是可寻址的。
这主要因为变量的值本身就不是“临时的”。对比而言,值字面量在还没有与任何变量(或者说任何标识符)绑定之前是没有落脚点的,我们无法以任何方式引用到它们。这样的值就是“临时的”。
再说一个例外。我们通过对字典类型的变量施加索引表达式,得到的结果值不属于临时结果,可是,这样的值却是不可寻址的。原因是,字典中的每个键 - 元素对的存储位置都可能会变化,而且这种变化外界是无法感知的。
我们都知道,字典中总会有若干个哈希桶用于均匀地储存键 - 元素对。当满足一定条件时,字典可能会改变哈希桶的数量,并适时地把其中的键 - 元素对搬运到对应的新的哈希桶中。
在这种情况下,获取字典中任何元素值的指针都是无意义的,也是不安全的。我们不知道什么时候那个元素值会被搬运到何处,也不知道原先的那个内存地址上还会被存放什么别的东西。所以,这样的值就应该是不可寻址的。
第三个关键词:不安全的。“不安全的”操作很可能会破坏程序的一致性,引发不可预知的错误,从而严重影响程序的功能和稳定性。
再来看函数。函数在 Go 语言中是一等公民,所以我们可以把代表函数或方法的字面量或标识符赋给某个变量、传给某个函数或者从某个函数传出。但是,这样的函数和方法都是不可寻址的。一个原因是函数就是代码,是不可变的。
另一个原因是,拿到指向一段代码的指针是不安全的。此外,对函数或方法的调用结果值也是不可寻址的,这是因为它们都属于临时结果。
至于典型回答中最后列出的那几种值,由于都是针对值字面量的某种表达式的结果值,所以都属于临时结果,都不可寻址。
好了,说了这么多,希望你已经有所领悟了。我来总结一下。
不可变的值不可寻址。常量、基本类型的值字面量、字符串变量的值、函数以及方法的字面量都是如此。其实这样规定也有安全性方面的考虑。
绝大多数被视为临时结果的值都是不可寻址的。算术操作的结果值属于临时结果,针对值字面量的表达式结果值也属于临时结果。但有一个例外,对切片字面量的索引结果值虽然也属于临时结果,但却是可寻址的。
若拿到某值的指针可能会破坏程序的一致性,那么就是不安全的,该值就不可寻址。由于字典的内部机制,对字典的索引结果值的取址操作都是不安全的。另外,获取由字面量或标识符代表的函数或方法的地址显然也是不安全的。
最后说一句,如果我们把临时结果赋给一个变量,那么它就是可寻址的了。如此一来,取得的指针指向的就是这个变量持有的那个值了。

知识扩展

问题 1:不可寻址的值在使用上有哪些限制?
首当其冲的当然是无法使用取址操作符&获取它们的指针了。不过,对不可寻址的值施加取址操作都会使编译器报错,所以倒是不用太担心,你只要记住我在前面讲述的那几条规律,并在编码的时候提前注意一下就好了。
我们来看下面这个小问题。我们依然以那个结构体类型Dog为例。
func New(name string) Dog {
return Dog{name}
}
我们再为它编写一个函数New。这个函数会接受一个名为namestring类型的参数,并会用这个参数初始化一个Dog类型的值,最后返回该值。我现在要问的是:如果我调用该函数,并直接以链式的手法调用其结果值的指针方法SetName,那么可以达到预期的效果吗?
New("little pig").SetName("monster")
如果你还记得我在前面讲述的内容,那么肯定会知道调用New函数所得到的结果值属于临时结果,是不可寻址的。
可是,那又怎样呢?别忘了,我在讲结构体类型及其方法的时候还说过,我们可以在一个基本类型的值上调用它的指针方法,这是因为 Go 语言会自动地帮我们转译。
更具体地说,对于一个Dog类型的变量dog来说,调用表达式dog.SetName("monster")会被自动地转译为(&dog).SetName("monster"),即:先取dog的指针值,再在该指针值上调用SetName方法。
发现问题了吗?由于New函数的调用结果值是不可寻址的,所以无法对它进行取址操作。因此,上边这行链式调用会让编译器报告两个错误,一个是果,即:不能在New("little pig")的结果值上调用指针方法。一个是因,即:不能取得New("little pig")的地址。
除此之外,我们都知道,Go 语言中的++--并不属于操作符,而分别是自增语句和自减语句的重要组成部分。
虽然 Go 语言规范中的语法定义是,只要在++--的左边添加一个表达式,就可以组成一个自增语句或自减语句,但是,它还明确了一个很重要的限制,那就是这个表达式的结果值必须是可寻址的。这就使得针对值字面量的表达式几乎都无法被用在这里。
不过这有一个例外,虽然对字典字面量和字典变量索引表达式的结果值都是不可寻址的,但是这样的表达式却可以被用在自增语句和自减语句中。
与之类似的规则还有两个。一个是,在赋值语句中,赋值操作符左边的表达式的结果值必须可寻址的,但是对字典的索引结果值也是可以的。
另一个是,在带有range子句的for语句中,在range关键字左边的表达式的结果值也都必须是可寻址的,不过对字典的索引结果值同样可以被用在这里。以上这三条规则我们合并起来记忆就可以了。
与这些定死的规则相比,我刚刚讲到的那个与指针方法有关的问题,你需要好好理解一下,它涉及了两个知识点的联合运用。起码在我面试的时候,它是一个可选择的考点。
问题 2:怎样通过unsafe.Pointer操纵可寻址的值?
前边的基础知识很重要。不过现在让我们再次关注指针的用法。我说过,unsafe.Pointer是像*Dog类型的值这样的指针值和uintptr值之间的桥梁,那么我们怎样利用unsafe.Pointer的中转和uintptr的底层操作来操纵像dog这样的值呢?
首先说明,这是一项黑科技。它可以绕过 Go 语言的编译器和其他工具的重重检查,并达到潜入内存修改数据的目的。这并不是一种正常的编程手段,使用它会很危险,很有可能造成安全隐患。
我们总是应该优先使用常规代码包中提供的 API 去编写程序,当然也可以把像reflect以及go/ast这样的代码包作为备选项。作为上层应用的开发者,请谨慎地使用unsafe包中的任何程序实体。
不过既然说到这里了,我们还是要来一探究竟的。请看下面的代码:
dog := Dog{"little pig"}
dogP := &dog
dogPtr := uintptr(unsafe.Pointer(dogP))
我先声明了一个Dog类型的变量dog,然后用取址操作符&,取出了它的指针值,并把它赋给了变量dogP
最后,我使用了两个类型转换,先把dogP转换成了一个unsafe.Pointer类型的值,然后紧接着又把后者转换成了一个uintptr的值,并把它赋给了变量dogPtr。这背后隐藏着一些转换规则,如下:
一个指针值(比如*Dog类型的值)可以被转换为一个unsafe.Pointer类型的值,反之亦然。
一个uintptr类型的值也可以被转换为一个unsafe.Pointer类型的值,反之亦然。
一个指针值无法被直接转换成一个uintptr类型的值,反过来也是如此。
所以,对于指针值和uintptr类型值之间的转换,必须使用unsafe.Pointer类型的值作为中转。那么,我们把指针值转换成uintptr类型的值有什么意义吗?
namePtr := dogPtr + unsafe.Offsetof(dogP.name)
nameP := (*string)(unsafe.Pointer(namePtr))
这里需要与unsafe.Offsetof函数搭配使用才能看出端倪。unsafe.Offsetof函数用于获取两个值在内存中的起始存储地址之间的偏移量,以字节为单位。
这两个值一个是某个字段的值,另一个是该字段值所属的那个结构体值。我们在调用这个函数的时候,需要把针对字段的选择表达式传给它,比如dogP.name
有了这个偏移量,又有了结构体值在内存中的起始存储地址(这里由dogPtr变量代表),把它们相加我们就可以得到dogPname字段值的起始存储地址了。这个地址由变量namePtr代表。
此后,我们可以再通过两次类型转换把namePtr的值转换成一个*string类型的值,这样就得到了指向dogPname字段值的指针值。
你可能会问,我直接用取址表达式&(dogP.name)不就能拿到这个指针值了吗?干嘛绕这么大一圈呢?你可以想象一下,如果我们根本就不知道这个结构体类型是什么,也拿不到dogP这个变量,那么还能去访问它的name字段吗?
答案是,只要有namePtr就可以。它就是一个无符号整数,但同时也是一个指向了程序内部数据的内存地址。它可能会给我们带来一些好处,比如可以直接修改埋藏得很深的内部数据。
但是,一旦我们有意或无意地把这个内存地址泄露出去,那么其他人就能够肆意地改动dogP.name的值,以及周围的内存地址上存储的任何数据了。
即使他们不知道这些数据的结构也无所谓啊,改不好还改不坏吗?不正确地改动一定会给程序带来不可预知的问题,甚至造成程序崩溃。这可能还是最好的灾难性后果;所以我才说,使用这种非正常的编程手段会很危险。
好了,现在你知道了这种手段,也知道了它的危险性,那就谨慎对待,防患于未然吧。

总结

我们今天集中说了说与指针有关的问题。基于基本类型的指针值应该是我们最常用到的,也是我们最需要关注的,比如*Dog类型的值。怎样得到一个这样的指针值呢?这需要用到取址操作和操作符&
不过这里还有个前提,那就是取址操作的操作对象必须是可寻址的。关于这方面你需要记住三个关键词:不可变的、临时结果和不安全的。只要一个值符合了这三个关键词中的任何一个,它就是不可寻址的。
但有一个例外,对切片字面量的索引结果值是可寻址的。那么不可寻址的值在使用上有哪些限制呢?一个最重要的限制是关于指针方法的,即:无法调用一个不可寻址值的指针方法。这涉及了两个知识点的联合运用。
相比于刚说到的这些,unsafe.Pointer类型和uintptr类型的重要性好像就没那么高了。它们的值同样可以代表指针,并且比前面说的指针值更贴近于底层和内存。
虽然我们可以利用它们去访问或修改一些内部数据,而且就灵活性而言,这种要比通用的方式高很多,但是这往往也会带来不容小觑的安全隐患。
因此,在很多时候,使用它们操纵数据是弊大于利的。不过,对于硬币的背面,我们也总是有必要去了解的。

思考题

今天的思考题是:引用类型的值的指针值是有意义的吗?如果没有意义,为什么?如果有意义,意义在哪里?
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 19

提建议

上一篇
14 | 接口类型的合理运用
下一篇
16 | go语句及其执行规则(上)
unpreview
 写留言

精选留言(52)

  • 郝林
    置顶
    2019-06-03
    大家可以具体说说有哪些名词需要用示例解释?
    共 1 条评论
    11
  • 乖,摸摸头
    2019-11-19
    云里雾里,我在这里懵逼等你,我肯定懵逼的不只我一个
    共 1 条评论
    70
  • 孙健波
    2018-09-17
    在描述不可寻址那部分有很多名词,要是能分别有段示例一下就好了,对照起来会更清晰
    共 1 条评论
    49
  • nuczzz
    2018-11-16
    get新技能: unsafe.Pointer+ uintptr突破私有成员访问!
    共 2 条评论
    32
  • 江山如画
    2018-09-17
    引用类型的指针值有意义。 以切片为例:fmt.Printf("%p\n", sli) 和 fmt.Printf("%p\n", &sli[0]) 打印的都是底层数组元素的地址。 而 fmt.Printf("%p\n", &sli) 打印的是切片结构体的内存地址,验证代码如下: arr := [3]int{1, 2, 3} fmt.Printf("%p\n", &arr) //0xc0000161e0 sli := arr[:] fmt.Printf("%p\n", sli) //0xc0000161e0 fmt.Printf("%p\n", &sli[0]) //0xc0000161e0 fmt.Printf("%p\n", &sli) //0xc00000a080 fmt.Println(unsafe.Pointer(&sli)) //0xc00000a080 sliHeader := (*reflect.SliceHeader)(unsafe.Pointer(&sli)) fmt.Printf("0x%10x\n", sliHeader.Data) //0xc0000161e0 可以看到,使用 %p 打印地址的时候:&sli 和 unsafe.Pointer(&sli) 都指向了切片结构体的地址,&arr,sli ,&sli[0],sliHeader.Data 都指向了底层数组。
    展开
    共 5 条评论
    32
  • jacke
    2018-10-13
    字面量有人理解吗?求解释

    作者回复: 说白了就是用于表示值或者类型的代码段。比如:1234就是代表整数值的字面量,int64就是代表类型的字面量。很简单,不用想的太复杂。

    共 4 条评论
    30
  • 静水流深
    2019-07-18
    老师您好,引用类型的值的指针值,是否可以理解为指针的指针?

    作者回复: 你最好不要这么理解,否则会影响其他地方的理解。引用类型的值不是指针那么简单,它本身是有结构的,所以还是要按普通的值来理解,不要理解成指针。

    17
  • 猫王者
    2018-10-09
    go语言的引用类型本质还是结构体,包括切片,字典还有接口,底层都是用结构体来实现,引用类型的地址就是这个底层结构体的地址吧
    17
  • Geek_a8be59
    2019-07-02
    根据您说的 由于 Go 语言中的字符串值也是不可变的 var a = "123" var c = "123" fmt.Printf("a fist is %p\n",&a) a = "456" fmt.Printf("a two is %p \n",&a) b:=a fmt.Printf("a three is %p \n",&a) fmt.Printf("b is %p \n",&b) 我想问的是 ①这里的“123”和“456”值不可变,那是不是他们两个都是在常量区呢?我不清楚是不是有go有常量区这②如果字符串的值是不可变的,那么c和a的字符串的值的地址是一样的呢? ③那为什么&a != & c呢,这里是取的字面量a的地址么?是不是可以理解成*a才是真正字符串“123”的地址呢
    展开

    作者回复: 1. Go 语言没有常量区这个概念。 2. 你要分清楚变量和值啊。变量本身的存储中包含了值,但不限于值。变量可以拥有或者代表一个值,但是它本身并不是值啊。 3. 你无法获取值 "123" 和 "456" 本身的存储地址,因为 Go 语言规范规定这样的值本身是不可取址的。 你可以参考 Go 运行时系统中对 string 结构的定义: type stringStruct struct { str unsafe.Pointer len int } 这个声明所在文件的路径是:/src/runtime/string.go 。

    共 3 条评论
    9
  • 有铭
    2018-09-14
    C里的指针是用来直接操作内存的,因为C里有大量的操作是必须靠操作内存完成的。那么go这种带有GC,不需要手动分配内存的语言,指针存在的意义在哪里呢,Go里的指针在何时有“不得不使用”的意义呢?

    作者回复: 没有非得使用的必要。指针类型及其值最常用,因为可以达到传址的效果。

    共 2 条评论
    8
  • 后端进阶
    2018-09-14
    既然知道dogPtr,那么必然也知道dogP的值,也就是可以通过dogP去那name的值了,不是很理解
    共 1 条评论
    8
  • 高鸿祥
    2018-09-24
    动态语言过来的人表示看不懂😁
    共 1 条评论
    6
  • 2019-07-26
    引用类型的指针意义非常大,前边说过变量的赋值都是拷贝,是副本,对于结构体类型数据也是如此,你对一个结构体数据进行数据修改也是对副本进行修改,不会真的修改成功,但是要有引用就可以实现修改目的了
    5
  • 2019-08-15
    前面分类介绍那里,我感觉很绕,比如说,切片字面量的索引结果值,没看太懂,切片字面量是切片这个变量名称呢还是切片变量指向的值,索引结果值,是切片中索引指向的数据吗?还是这个索引本身的值呢?

    作者回复: 字面量,就是用来表示值本身的东西,比如:[]int32{1,2,3} 、"abc"等。索引结果值说全了是索引表达式的结果值,就是与给定的索引号对应的那个元素值。关于这些概念性名词,我记得都在文章里正面或侧面的解释过。如果你还有哪些不太明白,可以发评论问我。

    4
  • Michael
    2018-09-17
    “引用类型的值得指针值” 同C语言中 “指针的指针” 应该是一个概念; 假设,有一个值类型 T,那么类型T的引用类型就是:*T,引用类型值得指针值类型就是:**T。 var t = T{} // T var pt = &T // *T var ppt = &pt // **T 但是具体的意义与使用场景还没想到,也没有遇到过,若老师不嫌弃,还请老师指出
    展开

    作者回复: 在Go需要中所谓的指针的指针最好不要用,Go也是想避免这种复杂性才搞出来引用类型的。

    4
  • mkii
    2021-02-05
    这节啃了几次了,一直没啃掉。看来还是应用少了
    3
  • ONLY
    2020-03-18
    我的理解:1. 首先go语言中指针无法进行计算,就没法像c语言一样获取一个首地址就可以获取其他成员地址 2. 所以go提供了unsafe.Pointer与uintptr这个口子给大家使用 3. 所有的指针都可以转换为unsafe.Pointer 4.uintptr可以存储指针,并可以计算,但是指针无法直接转换为uintptr 5. 但是uintptr和unsafe.point直接可以相互转换 6. 将dog对象的首地址转换成unsafe.Pointer,然后类型转换成*string,再转换成uintptr,这样指针就可以计算了, 7.通过unsafe.OffSetof获取偏移量,并加上dog的首地址就获取了name的地址,进而进行赋值操作
    展开
    4
  • 传说中的成大大
    2020-03-18
    课程总结 本节内容 主要讲了一些更高级的指针知识 一般的基本类型变量 都能取址 但是针对 临时的 不安全的 不可变的 就不可以取址 因为取址了 也没有多大的意义 特例 字典类型的索引表达式 虽然不是临时结果 但是还是不可取址 因为根字典的底层实现相关有可能是多个hash桶 然后扩容或者缩容操作过后搬移到另外一个hash桶 所以取址没有多大意义 还有可能导致程序出错 然后 uintptr unsafe.Point 和指针 uintptr是一个数值类型也是go语言的内建类型 unsafe.Pointer可以表示任何指向可寻址的值的指针,同时它也是前面提到的指针值和uintptr值之间的桥梁 uintpter不能直接转换为指针 只能通过unsafe.Pointer中转一下 关于思考题 引用类型的值的指针值没有多大意义 因为他们都是作用在同一个对象上面
    展开
    2
  • Alan
    2020-01-02
    New("little pig").SetName("monster") 这个应该看版本的吧,在1.13.5版本中是可以的

    作者回复: 这个是基本的规则,哪个版本都是一样的。

    共 3 条评论
    2
  • Realm
    2018-09-14
    1 “引用类型的值的指针 ”与 “引用类型的值的值指针”是两个概念? 2 值的值指针是类似切片、字典、通道内的元素的指针吗? 如果是,那老师的思考题,引用类型的值的值指针的意义:是不是用此指针来取元素的值?

    作者回复: 我没说过“值指针”这个词。“引用类型的值的指针值”的含义你不理解还是?

    2
新人学习立返 50% 购课币
去订阅《Go语言核心36讲》新人首单¥59