Go unsafe.Pointer、uintptr、Pointer 之间的区别与联系
前几天见到了一个问题:
uintptr与 unsafe.Pointer 有什么区别?
你可能曾经看到过类似这样的代码:
type T struct {
a uint8
b bool
c string
}
t := T{a: 1, b: true, c: "goooo"}
Tc := *(*string)(unsafe.Pointer(uintptr(unsafe.Pointer(&a)) + unsafe.Offsetof(T{}.c)))
fmt.Println(Tc)
这一行代码看似实现的功能非常复杂,其实它就是等价于:
fmt.Println(t.c)
从某种意义上来说,这好像没什么用(确实没啥作用…),那你再看一段代码:
//go:nosplit
func Bytes2String(bs []byte) string {
return *(*string)(unsafe.Pointer(&bs))
}
//go:nosplit
func String2Bytes(s string) []byte {
ss := *[2]uintptr(unsafe.Pointer(&s))
b := [3]uintptr{ss[0], ss[1], ss[1]}
return *(*[]byte)(unsafe.Pointer(&b))
}
这两个函数实现了 []Byte 与 string 的互转,并且不需要申请任何内存,因为他们共享底层数据空间。
函数前的类似注释的一行是一些指令,用来指示编译器进行一些用户的行为,Go 中的栈帧是可以根据需要动态增长的,一开始只有 2048Byte,是固定在源码中的。而 go:nosplit
就是表明函数栈空间无需动态增长,这在一定程度上增加了效率,但是如果你申请了过量的空间,就会导致程序因为爆栈而 panic。
这段代码通过 unsafe.Pointer 类型实现了从 float64 强转 uint64 的效果,出自标准库。
func Float64bits(f float64) uint64 {
return *(*uint64)(unsafe.Pointer(&f))
}
unsafe包
Go
语言本身由于类型系统的原因,不支持指针的运算操作,对于指针的使用进行了极大的限制,不像 C/C++
语言那样对指针 “为所欲为”,也不像 Java
语言中那样根本不提供指针,采取了一种折中方案,只支持常见的取址(&),取值(*)操作。但是为了有些场合的需要,还是提供了一个用来直接无视类型系统而直接操作的特性,这便是 unsafe
包提供的能力。
unsafe
包开放了一个类型,三个函数,分别是:
- Pointer ,任意指针类型,类似于 C/C++ 语言中的 void* 类型
- Alignof(x ArbitraryType) uintptr 函数,计算变量内存对齐边界大小
- Sizeof(x ArbitraryType) uintptr 函数,计算该类型所占用内存大小,不包含底层数据大小
- Offsetof(x ArbitraryType) uintptr 函数,计算该变量相对于结构体首地址的地址偏移
看源码过程中可能会看到
ArbitraryType
类型,而Pointer
就是ArbitraryType
类型,不必疑惑,这个类型没有任何实际意义,仅仅代表了它可以是任意Go
语言类型,不是unsafe
包的组成部分。
Go 语言类型系统的限制,不允许进行指针运算,比如:
a := 1
pa := &a
pa++
编译器无法通过这种写法,会报不支持这种运算的错误:
# command-line-arguments
.\notify.go:17:5: invalid operation: pa++ (non-numeric type *int)
这 “一类型,三函数” 所提供的能力就是直接无视这个限制,直接进行地址运算,下面详细介绍这些能力。
一类型Pointer
Pointer
类型提供了 uintptr
与类型指针之间的转换中介,要想将任意指针类型 *p 转为 uintptr 来进行指针运算,需要先转换为 Pointer
类型,再转换为 uintptr
类型。
类型介绍
unsafe.Pointer 类型的定义非常简单,就是一个任意类型的指针表示:
type ArbitraryType int
type Pointer *ArbitraryType
自定义了一个命名类型 ArbitraryType
,这个类型并没有什么实际含义,只是表示任意类型,在代码文档中使用。如果没有这个类型,那么 Sizeof 等函数的参数就是 int 类型,会让人费解。所以正如字面意思,Pointer
的含义就是 “任意类型的指针”。
使用注意
除了上图描述的这个类型间转换规则外,还需要遵循几个固定的规则。
uintptr变量无法保持对变量的引用
你不能这么做:
var i int = 1
up := uintptr(unsafe.Pointer(&i))
// after 1000 years...
Println(i) // maybe panic!
uintptr
可以用来进行指针运算,实际上就是一个非常大的无符号整数类型,可以表示所有地址空间。上述代码可能会 panic 的原因是 i 变量可能会被 Garbage Collector(gc) 回收。一般这个 uintptr 类型只作为中间值参与运算。
使用案例
go slice 的创建是通过 makeslice 创建,返回值是一个结构体:
func makeslice(et *_type, len, cap int) slice
我们可以通过以下两种方式获取切片的长度:
s := make([]int, 8, 8)
Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(8)))) // 8
Println(len(s)) // 8
把 uintptr(8)
改为 uintptr(16)
就可以获取到容量了。
如果我们想获取 map 的大小,需要有一点小小的改变,因为 map 的创建是通过 makemap 函数:
func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap
该返回的是一个指针,由于再次取地址,是二级指针,所以我们稍稍修改一下代码:
mm := make(map[int]int, 8)
Println(**(**int)(unsafe.Pointer(&mm))) // 8
三函数Alignof、Sizeof和Offsetof
看了上面的内容,可能会有一个疑问,这有什么用呢?不是有更优的 “常规” 解法吗?
我想说的是,除了 zero-copy
来转换 slice 与 string 这个例子之外,还有一个场景。
如果结构体成员私有,包外如何访问?你不能通过常规的运算符 ‘.’ 来访问到私有字段,就要借助 unsafe 包了。通过 unsafe.Sizeof
函数计算偏移字段的大小和,直接获取到目标字段的地址偏移:
package a
type T struct {
a int
b bool
c byte
}
package b
t := a.T{ a: 1, b: true, c: 2 }
tc := *(*byte)(unsafe.Pointer)(uintptr(unsafe.Pointer(&t)) + unsafe.Sizeof(0) + unsafe.Sizeof(true))
Println(tc) // 2
总结
Go 语言中和了 C/C++ 和 Java 语言对指针提供的能力,有效地解决了指针滥用导致内存泄露、非法访问等安全问题。通过静态语言的严格类型系统限制了一些内存操作,也提供了一个不安全但 “炫酷” 的 unsafe 包,标准库的源码许多都使用了 unsafe 包所提供了能力,高效简洁,让我们仿佛在看 C 系语言源码。这也不难理解,Go 语言的创始人之一正是 C 语言的创造者之一,许多设计思想优秀且独树一帜。
不过之所以命名为 unsafe,也是因为可以直接绕过类型系统直接操作底层内存,会带来一定的安全问题,与 GC 的工作 “冲突”,所以需要我们谨慎使用。