关于golang指针的有限操作详解
作者:m旧裤子
传统意义上来说,指针是一个指向某个确切的内存地址的值。这个内存地址可以是任何数据或代码的起始地址。在Go语言中有几种东西可以代表"指针"。其中最贴切传统意义的当属uintptr
类型的了。该类型实际上是一个数值类型,也是Go语言内建的数据类型之一。
根据当前计算机的计算架构的不同,它可以存储32位或64位的无符号整数,可以代表任何指针的位(bit)模式,也就是原始的内存地址。
Go语言标准库中的unsafe
包,unsafe
包中有一个类型叫做Pointer
,也代表任何指针的位(bit)模式,也就是原始的内存地址。
unsafe.Pointer
可以表示任何指向可寻址的值的指针,同时它也是前面提到的指针值和uintptr
值之间的桥梁。通过它,我们可以在这两种值之上进行双向的转换。这里有一个很关键的词——可寻址的(addressable)
。
在我们继续说unsafe.Pointer
之前,需要先搞清楚这个词的确切含义。
Go语言中的哪些值是不可寻址的?
- 常量的值。
- 基本类型值的字面量。
- 算术操作的结果值。
- 对各种字面量的索引表达式和切片表达式的结果值。
不过有一个例外,对切片字面量的索引结果值却是可寻址的。
- 对字符串变量的索引表达式和切片表达式的结果值。
- 对字典变量的索引表达式的结果值。
- 函数字面量和方法字面量,以及对它们的调用表达式的结果值。
- 结构体字面量的字段值,也就是对结构体字面量的选择表达式的结果值。
- 类型转换表达式的结果值。
- 类型断言表达式的结果值。
- 接收表达式的结果值。
// 示例1。 const num = 123 //_ = &num // 常量不可寻址。 //_ = &(123) // 基本类型值的字面量不可寻址。 var str = "abc" _ = str //_ = &(str[0]) // 对字符串变量的索引结果值不可寻址。 //_ = &(str[0:2]) // 对字符串变量的切片结果值不可寻址。 str2 := str[0] _ = &str2 // 但这样的寻址就是合法的。 //_ = &(123 + 456) // 算术操作的结果值不可寻址。 num2 := 456 _ = num2 //_ = &(num + num2) // 算术操作的结果值不可寻址。 //_ = &([3]int{1, 2, 3}[0]) // 对数组字面量的索引结果值不可寻址。 //_ = &([3]int{1, 2, 3}[0:2]) // 对数组字面量的切片结果值不可寻址。 _ = &([]int{1, 2, 3}[0]) // 对切片字面量的索引结果值却是可寻址的。 //_ = &([]int{1, 2, 3}[0:2]) // 对切片字面量的切片结果值不可寻址。 //_ = &(map[int]string{1: "a"}[0]) // 对字典字面量的索引结果值不可寻址。 var map1 = map[int]string{1: "a", 2: "b", 3: "c"} _ = map1 //_ = &(map1[2]) // 对字典变量的索引结果值不可寻址。 //_ = &(func(x, y int) int { // return x + y //}) // 字面量代表的函数不可寻址。 //_ = &(fmt.Sprintf) // 标识符代表的函数不可寻址。 //_ = &(fmt.Sprintln("abc")) // 对函数的调用结果值不可寻址。 dog := Dog{"little pig"} _ = dog //_ = &(dog.Name) // 标识符代表的函数不可寻址。 //_ = &(dog.Name()) // 对方法的调用结果值不可寻址。 //_ = &(Dog{"little pig"}.name) // 结构体字面量的字段不可寻址。 //_ = &(interface{}(dog)) // 类型转换表达式的结果值不可寻址。 dogI := interface{}(dog) _ = dogI //_ = &(dogI.(Named)) // 类型断言表达式的结果值不可寻址。 named := dogI.(Named) _ = named //_ = &(named.(Dog)) // 类型断言表达式的结果值不可寻址。 var chan1 = make(chan int, 1) chan1 <- 1 //_ = &(<-chan1) // 接收表达式的结果值不可寻址。
常量的值总是会被存储到一个确切的内存区域中,并且这种值肯定是不可变的
。基本类型值的字面量也是一样,其实它们本就可以被视为常量,只不过没有任何标识符可以代表它们罢了。
第一个关键词:不可变的。由于 Go 语言中的字符串值也是不可变的,所以对于一个字符串类型的变量来说,基于它的索引或切片的结果值也都是不可寻址的,因为即使拿到了这种值的内存地址也改变不了什么。
算术操作的结果值属于一种临时结果
。在我们把这种结果值赋给任何变量或常量之前,即使能拿到它的内存地址也是没有任何意义的。
第二个关键词:临时结果。这个关键词能被用来解释很多现象。我们可以把各种对值字面量施加的表达式的求值结果都看做是临时结果。
我们都知道,Go 语言中的表达式有很多种,其中常用的包括以下几种。
- 用于获得某个元素的索引表达式。
- 用于获得某个切片(片段)的切片表达式。
- 用于访问某个字段的选择表达式。
- 用于调用某个函数或方法的调用表达式。
- 用于转换值的类型的类型转换表达式。
- 用于判断值的类型的类型断言表达式。
- 向通道发送元素值或从通道那里接收元素值的接收表达式。
我们把以上这些表达式施加在某个值字面量上一般都会得到一个临时结果。比如,对数组字面量和字典字面量的索引结果值,又比如,对数组字面量和切片字面量的切片结果值。它们都属于临时结果,都是不可寻址的。
一个需要特别注意的例外是,对切片字面量的索引结果值是可寻址的。因为不论怎样,每个切片值都会持有一个底层数组,而这个底层数组中的每个元素值都是有一个确切的内存地址的。
那么对切片字面量的切片结果值为什么却是不可寻址的?这是因为切片表达式总会返回一个新的切片值,而这个新的切片值在被赋给变量之前属于临时结果。
如果针对的是数组类型或切片类型的变量,那么索引或切片的结果值就都不属于临时结果了,是可寻址的。
这主要因为变量
的值本身就不是“临时的”。对比而言,值字面量
在还没有与任何变量(或者说任何标识符)绑定之前是没有落脚点的,我们无法以任何方式引用到它们。这样的值就是“临时的”。
我们通过对字典类型的变量施加索引表达式,得到的结果值不属于临时结果,可是,这样的值却是不可寻址的。
原因是,字典中的每个键 - 元素对的存储位置都可能会变化,而且这种变化外界是无法感知的。
字典中总会有若干个哈希桶用于均匀地储存键 - 元素对。当满足一定条件时,字典可能会改变哈希桶的数量,并适时地把其中的键 - 元素对搬运到对应的新的哈希桶中。在这种情况下,获取字典中任何元素值的指针都是无意义的,也是不安全的。我们不知道什么时候那个元素值会被搬运到何处,也不知道原先的那个内存地址上还会被存放什么别的东西。所以,这样的值就应该是不可寻址的。
第三个关键词:不安全的。“不安全的”操作很可能会破坏程序的一致性,引发不可预知的错误,从而严重影响程序的功能和稳定性。
函数在 Go 语言中是一等公民,所以我们可以把代表函数或方法的字面量或标识符赋给某个变量、传给某个函数或者从某个函数传出。但是,这样的函数和方法都是不可寻址的。一个原因是函数就是代码,是不可变的。
另一个原因是,拿到指向一段代码的指针是不安全的。此外,对函数或方法的调用结果值也是不可寻址的,这是因为它们都属于临时结果。至于典型回答中最后列出的那几种值,由于都是针对值字面量的某种表达式的结果值,所以都属于临时结果,都不可寻址。
- 不可变的值不可寻址。常量、基本类型的值字面量、字符串变量的值、函数以及方法的字面量都是如此。其实这样规定也有安全性方面的考虑。
- 绝大多数被视为临时结果的值都是不可寻址的。算术操作的结果值属于临时结果,针对值字面量的表达式结果值也属于临时结果。但有一个例外,对切片字面量的索引结果值,虽然也属于临时结果,但却是可寻址的。
- 若拿到某值的指针可能会破坏程序的一致性,那么就是不安全的,该值就不可寻址。由于字典的内部机制,对字典的索引结果值的取址操作都是不安全的。另外,获取由字面量或标识符代表的函数或方法的地址显然也是不安全的。
不可寻址的值在使用上有哪些限制?
首当其冲的当然是无法使用取址操作符&
获取它们的指针了。不过,对不可寻址的值施加取址操作都会使编译器报错,所以倒是不用太担心,你只要记住我在前面讲述的那几条规律,并在编码的时候提前注意一下就好了。
func New(name string) Dog { return Dog{name} }
我们再为它编写一个函数New
。这个函数会接受一个名为name
的string
类型的参数,并会用这个参数初始化一个Dog
类型的值,最后返回该值。我现在要问的是:如果我调用该函数,并直接以链式的手法调用其结果值的指针方法SetName
,那么可以达到预期的效果吗?
New("little pig").SetName("monster")
由于New
函数的调用结果的值是不可寻址的,所以无法对它进行取址操作。因此,上边这行链式调用会让编译器报告两个错误,一个是果,即:不能在New{"little pig"}
的结果值上调用指针方法。一个是因,即:不能取得New{"little pig"}
的地址。
除此之外,我们都知道,Go 语言中的++和–并不属于操作符,而分别是自增语句和自减语句的重要组成部分。
虽然 Go 语言规范中的语法定义是,只要在++或–的左边添加一个表达式,就可以组成一个自增语句或自减语句,但是,它还明确了一个很重要的限制,那就是这个表达式的结果值必须是可寻址的。这就使得针对值字面量的表达式几乎都无法被用在这里。
不过这有一个例外,虽然对字典字面量和字典变量索引表达式的结果值都是不可寻址的,但是这样的表达式却可以被用在自增语句和自减语句中。
与之类似的规则还有两个。一个是,在赋值语句中,赋值操作符左边的表达式的结果值必须可寻址的,但是对字典的索引结果值也是可以的。
另一个是,在带有range子句的for语句中,在range关键字左边的表达式的结果值也都必须是可寻址的,不过对字典的索引结果值同样可以被用在这里。
怎样通过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
变量代表),把它们相加我们就可以得到dogP
的name
字段值的起始存储地址了。这个地址由变量namePtr
代表。
此后,我们可以再通过两次类型转换把namePtr
的值转换成一个* string
类型的值,这样就得到了指向dogP
的name
字段值的指针值。
你可能会问,我直接用取址表达式&(dogP.name)不就能拿到这个指针值了吗?干嘛绕这么大一圈呢?你可以想象一下,如果我们根本就不知道这个结构体类型是什么,也拿不到dogP这个变量,那么还能去访问它的name字段吗?
答案是,只要有namePtr
就可以。它就是一个无符号整数,但同时也是一个指向了程序内部数据的内存地址。它可能会给我们带来一些好处,比如可以直接修改埋藏得很深的内部数据。
但是,一旦我们有意或无意地把这个内存地址泄露出去,那么其他人就能够肆意地改动dogP.name
的值,以及周围的内存地址上存储的任何数据了。
以上就是关于golang指针的有限操作详解的详细内容,更多关于golang指针操作的资料请关注脚本之家其它相关文章!