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 的工作 “冲突”,所以需要我们谨慎使用。