Go语言 非类型安全指针

2023-02-16 17:38 更新

我们已经从Go中的指针一文中学习到关于指针的各种概念和规则。 从那篇文章中,我们得知,相对于C指针,Go指针有很多限制。 比如,Go指针不支持算术运算,并且对于任意两个指针值,很可能它们不能转换到对方的类型。

事实上,在那篇文章中解释的指针的完整称呼应该为类型安全指针。 虽然类型安全指针有助于我们轻松写出安全的代码,但是有时候施加在类型安全指针上的限制也确实导致我们不能写出最高效的代码。

实际上,Go也支持限制较少的非类型安全指针。 非类型安全指针和C指针类似,它们都很强大,但同时也都很危险。 在某些情形下,通过非类型安全指针的帮助,我们可以写出效率更高的代码; 但另一方面,使用非类型安全指针也导致我们可能轻易地写出潜在的不安全的代码,这些潜在的不安全点很难在它们产生危害之前被及时发现。

使用非类型安全指针的另外一个较大的风险是Go中目前提供的非类型安全指针机制并不受到Go 1 兼容性保证的保护。 使用了非类型安全指针的代码可能从今后的某个Go版本开始将不再能编译通过,或者运行行为发生了变化。

如果出于种种原因,你确实希望在你的代码中使用非类型安全指针,你不仅需要提防上述风险,你还需遵守Go官方文档中列出的非类型安全指针使用模式,并清楚地知晓使用非类型安全指针带来的效果。否则,你很难使用非类型安全指针写出安全的代码。

关于unsafe标准库包

非类型安全指针在Go中为一种特别的类型。 我们必须引入unsafe标准库包来使用非类型安全指针。 非类型安全指针unsafe.Pointer被声明定义为:

type Pointer *ArbitraryType

当然,这不是一个普通的类型定义。这里的ArbitraryType仅仅是暗示unsafe.Pointer类型值可以被转换为任意类型安全指针(反之亦然)。换句话说,unsafe.Pointer类似于C语言中的void*

非类型安全指针是指底层类型为unsafe.Pointer的类型。

非类型安全指针的零值也使用预声明的nil标识符来表示。

在Go 1.17之前,unsafe标准库包只提供了三个函数:

  • func Alignof(variable ArbitraryType) uintptr。 此函数用来取得一个值在内存中的地址对齐保证(address alignment guarantee)。 注意,同一个类型的值做为结构体字段和非结构体字段时地址对齐保证可能是不同的。 当然,这和具体编译器的实现有关。对于目前的标准编译器,同一个类型的值做为结构体字段和非结构体字段时的地址对齐保证总是相同的。 gccgo编译器对这两种情形是区别对待的。
  • func Offsetof(selector ArbitraryType) uintptr。 此函数用来取得一个结构体值的某个字段的地址相对于此结构体值的地址的偏移。 在一个程序中,对于同一个结构体类型的不同值的对应相同字段,此函数的返回值总是相同的。
  • func Sizeof(variable ArbitraryType) uintptr。 此函数用来取得一个值的尺寸(亦即此值的类型的尺寸)。 在一个程序中,对于同一个类型的不同值,此函数的返回值总是相同的。

注意:

  • 这三个函数的返回值的类型均为内置类型uintptr。下面我们将了解到uintptr类型的值可以转换为非类型安全指针(反之亦然)。
  • 尽管这三个函数之一的任何调用的返回结果在同一个编译好的程序中总是一致的,但是这样的一个调用在不同架构的操作系统中(或者使用不同的编译器编译时)的返回值可能是不一样的。
  • 这三个函数的调用总是在编译时刻被估值,估值结果为类型为uintptr的常量。
  • 传递给Offsetof函数的实参必须为一个字段选择器形式value.field。 此选择器可以表示一个内嵌字段,但此选择器的路径中不能包含指针类型的隐式字段

一个使用了这三个函数的例子:

package main

import "fmt"
import "unsafe"

func main() {
	var x struct {
		a int64
		b bool
		c string
	}
	const M, N = unsafe.Sizeof(x.c), unsafe.Sizeof(x)
	fmt.Println(M, N) // 16 32

	fmt.Println(unsafe.Alignof(x.a)) // 8
	fmt.Println(unsafe.Alignof(x.b)) // 1
	fmt.Println(unsafe.Alignof(x.c)) // 8

	fmt.Println(unsafe.Offsetof(x.a)) // 0
	fmt.Println(unsafe.Offsetof(x.b)) // 8
	fmt.Println(unsafe.Offsetof(x.c)) // 16
}

下面是一个展示了上面提到的最后一个注意点的例子:

package main

import "fmt"
import "unsafe"

func main() {
	type T struct {
		c string
	}
	type S struct {
		b bool
	}
	var x struct {
		a int64
		*S
		T
	}

	fmt.Println(unsafe.Offsetof(x.a)) // 0
	
	fmt.Println(unsafe.Offsetof(x.S)) // 8
	fmt.Println(unsafe.Offsetof(x.T)) // 16
	
	// 此行可以编译过,因为选择器x.c中的隐含字段T为非指针。
	fmt.Println(unsafe.Offsetof(x.c)) // 16
	
	// 此行编译不过,因为选择器x.b中的隐含字段S为指针。
	//fmt.Println(unsafe.Offsetof(x.b)) // error
	
	// 此行可以编译过,但是它将打印出字段b在x.S中的偏移量.
	fmt.Println(unsafe.Offsetof(x.S.b)) // 0
}

注意,上面程序中的注释所暗示的输出结果是此程序在AMD64架构上使用标准编译器1.19版本编译时的结果。

unsafe包提供的这三个函数看上去并不怎么危险。 它们的原型在以后的Go 1版本中几乎不可能会发生改变。 Rob Pike甚至曾经将这几个函数挪到其它包中。 unsafe包的危险性基本上来自于非类型安全指针。它们和C指针一样危险,这是Go安全指针千方百计设法去避免的。

Go 1.17引入了一个新类型和两个新函数。 此新类型为IntegerType。它的定义如下。 此类型不代表着一个具体类型,它只是表示任意整数类型(有点泛型的意思)。

type IntegerType int

Go 1.17引入的两个函数为:

  • func Add(ptr Pointer, len IntegerType) Pointer。 此函数在一个(非安全)指针表示的地址上添加一个偏移量,然后返回表示新地址的一个指针。 此函数以一种更正规的形式部分地覆盖了下面将要介绍的使用模式3中展示的合法用法。
  • func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType。 此函数用来从一个任意(安全)指针派生出一个指定长度的切片。

Go 1.20进一步引入了三个函数:

  • func String(ptr *byte, len IntegerType) string。 此函数用来从一个任意(安全)byte指针派生出一个指定长度的字符串。
  • func StringData(str string) *byte。 此函数用来获取一个字符串底层字节序列中的第一个byte的指针。
  • func SliceData(slice []ArbitraryType) *ArbitraryType。 此函数用来获取一个切片底层元素序列中的第一个元素的指针。

Go 1.17之后引入的这些函数具有一定的危险性,需谨慎使用。 下面是使用了Go 1.17引入的两个函数的一个例子。

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	a := [16]int{3: 3, 9: 9, 11: 11}
	fmt.Println(a)
	eleSize := int(unsafe.Sizeof(a[0]))
	p9 := &a[9]
	up9 := unsafe.Pointer(p9)
	p3 := (*int)(unsafe.Add(up9, -6 * eleSize))
	fmt.Println(*p3) // 3
	s := unsafe.Slice(p9, 5)[:3]
	fmt.Println(s) // [9 0 11]
	fmt.Println(len(s), cap(s)) // 3 5

	t := unsafe.Slice((*int)(nil), 0)
	fmt.Println(t == nil) // true

	// 下面是两个不正确的调用。因为它们
	// 的返回结果引用了未知的内存块。
	_ = unsafe.Add(up9, 7 * eleSize)
	_ = unsafe.Slice(p9, 8)
}

下面这两个函数使用了非类型安全的方式实现了字符串和字节切片之间的类型转换。 和类型安全方式相比,它们不用复制字符串和字节切片的底层字节序列,因此效率更高。

import "unsafe"

func String2ByteSlice(str string) []byte {
	if str == "" {
		return nil
	}
	return unsafe.Slice(unsafe.StringData(str), len(str))
}

func ByteSlice2String(bs []byte) string {
	if len(bs) == 0 {
		return ""
	}
	return unsafe.String(unsafe.SliceData(bs), len(bs))
}

非类型安全指针相关的类型转换

目前(Go 1.19),Go支持下列和非类型安全指针相关的类型转换:

  • 一个类型安全指针值可以被显式转换为一个非类型安全指针类型,反之亦然。
  • 一个uintptr值可以被显式转换为一个非类型安全指针类型,反之亦然。 但是,注意,一个nil非类型安全指针类型不应该被转换为uintptr并进行算术运算后再转换回来。

通过使用这些转换规则,我们可以将任意两个类型安全指针转换为对方的类型,我们也可以将一个安全指针值和一个uintptr值转换为对方的类型。

然而,尽管这些转换在编译时刻是合法的,但是它们中一些在运行时刻并非是合法和安全的。 这些转换摧毁了Go的类型系统(不包括非类型安全指针部分)精心设立的内存安全屏障。 我们必须遵循本文后面要介绍的一些用法指示来使用非类型安全指针才能写出合法并安全的代码。

我们需要知道的一些事实

在开始介绍合法的非类型安全指针使用模式之前,我们需要知道一些事实。

事实一:非类型安全指针值是指针但uintptr值是整数

每一个非零安全或者不安全指针值均引用着另一个值。但是一个uintptr值并不引用任何值,它被看作是一个整数,尽管常常它存储的是一个地址的数字表示。

Go是一门支持垃圾回收的语言。 当一个Go程序在运行中,Go运行时(runtime)将不时地检查哪些内存块将不再被程序中的任何仍在使用中的值所引用并且回收这些内存块。 指针在这一过程中扮演着重要的角色。值与值之间和内存块与值之间的引用关系是通过指针来表征的。

既然一个uintptr值是一个整数,那么它可以参与算术运算。

下一节中的例子将展示指针和uintptr值的不同。

事实二:不再被使用的内存块的回收时间点是不确定的

在运行时刻,一次新的垃圾回收过程可能在一个不确定的时间启动,并且此过程可能需要一段不确定的时长才能完成。 所以一个不再被使用的内存块的回收时间点是不确定的。

一个例子:

import "unsafe"

// 假设此函数不会被内联(inline)。
//go:noinline
func createInt() *int {
	return new(int)
}

func foo() {
	p0, y, z := createInt(), createInt(), createInt()
	var p1 = unsafe.Pointer(y) // 和y一样引用着同一个值
	var p2 = uintptr(unsafe.Pointer(z))

	// 此时,即使z指针值所引用的int值的地址仍旧存储
	// 在p2值中,但是此int值已经不再被使用了,所以垃圾
	// 回收器认为可以回收它所占据的内存块了。另一方面,
	// p0和p1各自所引用的int值仍旧将在下面被使用。

	// uintptr值可以参与算术运算。
	p2 += 2; p2--; p2--

	*p0 = 1                         // okay
	*(*int)(p1) = 2                 // okay
	*(*int)(unsafe.Pointer(p2)) = 3 // 危险操作!
}

在上面这个例子中,值p2仍旧在使用这个事实并不能保证曾经被z指针值所引用的int值所占的内存块一定还没有被回收。 换句话说,当*(*int)(unsafe.Pointer(p2)) = 3被执行的时候,此内存块有可能已经被回收了。 所以,继续通过解引用值p2中存储的地址是非常危险的,因为此内存块可能已经被重新分配给其它值使用了。

事实三:一个值的地址在程序运行中可能改变

详情请阅读内存块一文(见链接所指一节的尾部)。 这里我们只需要知道当一个协程的栈的大小改变时,开辟在此栈上的内存块需要移动,从而相应的值的地址将改变。

事实四:一个值的生命范围可能并没有代码中看上去的大

比如中下面这个例子,值t仍旧在使用中并不能保证被值t.y所引用的值仍在被使用。

type T struct {
	x int
	y *[1<<23]byte
}

func bar() {
	t := T{y: new([1<<23]byte)}
	p := uintptr(unsafe.Pointer(&t.y[0]))

	... // 使用t.x和t.y

	// 一个聪明的编译器能够觉察到值t.y将不会再被用到,
	// 所以认为t.y值所占的内存块可以被回收了。

	*(*byte)(unsafe.Pointer(p)) = 1 // 危险操作!

	println(t.x) // ok。继续使用值t,但只使用t.x字段。
}

事实五:*unsafe.Pointer是一个类型安全指针类型

是的,类型*unsafe.Pointer是一个类型安全指针类型。 它的基类型为unsafe.Pointer。 既然它是一个类型安全指针类型,根据上面列出的类型转换规则,它的值可以转换为类型unsafe.Pointer,反之亦然。

一个例子:

package main

import "unsafe"

func main() {
	x := 123                // 类型为int
	p := unsafe.Pointer(&x) // 类型为unsafe.Pointer
	pp := &p                // 类型为*unsafe.Pointer
	p = unsafe.Pointer(pp)
	pp = (*unsafe.Pointer)(p)
}

如何正确地使用非类型安全指针?

unsafe标准库包的文档中列出了六种非类型安全指针的使用模式。 下面将对它们逐一进行讲解。

使用模式一:将类型*T1的一个值转换为非类型安全指针值,然后将此非类型安全指针值转换为类型*T2。

利用前面列出的非类型安全指针相关的转换规则,我们可以将一个*T1值转换为类型*T2,其中T1T2为两个任意类型。 然而,我们只有在T1的尺寸不小于T2并且此转换具有实际意义的时候才应该实施这样的转换。

通过将一个*T1值转换为类型*T2,我们也可以将一个T1值转换为类型T2

一个这样的例子是math标准库包中的Float64bits函数。 此函数将一个float64值转换为一个uint64值。 在此转换过程中,此float64值在内存中的每个位(bit)都保持不变。 函数math.Float64frombits为此转换的逆转换。

func Float64bits(f float64) uint64 {
	return *(*uint64)(unsafe.Pointer(&f))
}

func Float64frombits(b uint64) float64 {
	return *(*float64)(unsafe.Pointer(&b))
}

请注意,函数调用math.Float64bits(aFloat64)的结果和显式转换uint64(aFloat64)的结果不同。

在下面这个例子中,我们使用此模式将一个[]MyString值和一个[]string值转换为对方的类型。 结果切片和被转换的切片将共享底层元素。(这样的转换是不可能通过安全的方式来实现的。)

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	type MyString string
	ms := []MyString{"C", "C++", "Go"}
	fmt.Printf("%s\n", ms)  // [C C++ Go]
	// ss := ([]string)(ms) // 编译错误
	ss := *(*[]string)(unsafe.Pointer(&ms))
	ss[1] = "Zig"
	fmt.Printf("%s\n", ms) // [C Zig Go]
	// ms = []MyString(ss) // 编译错误
	ms = *(*[]MyString)(unsafe.Pointer(&ss))
}

顺便说一句,从Go 1.17开始,我们也可以使用unsafe.Slice函数来实现这样的转换:

func main() {
	...
	
	ss = unsafe.Slice((*string)(&ms[0]), len(ms))
	ms = unsafe.Slice((*MyString)(&ss[0]), len(ss))
}

此模式在实践中的另一个应用是将一个不再使用的字节切片转换为一个字符串(从而避免对底层字节序列的一次开辟和复制)。如下例所示:

func ByteSlice2String(bs []byte) string {
	return *(*string)(unsafe.Pointer(&bs))
}

此实现借鉴于strings标准库包中的Builder类型的String方法的实现。 字节切片的尺寸比字符串的尺寸要大,并且它们的底层结构类似,所以此转换(对于当前的主流Go编译器来说)是安全的。 即使这样,此实现也只推荐在标准库中使用,而不推荐在用户代码中使用。 从Go 1.20开始,在用户代码中,最好尽量使用本文前面介绍使用unsafe.String函数的实现。

反过来,下面这个例子中的转换是非法的,因为字符串的尺寸比字节切片的尺寸小。

func String2ByteSlice(s string) []byte {
	return *(*[]byte)(unsafe.Pointer(&s)) // 危险
}

在后面的模式六中展示了一种合法的(无需复制底层字节序列即可)将一个字符串转换为字节切片的实现。

注意:当运用上面展示的使用非类型安全指针将一个字节切片转换为字符串的技巧时,请确保结果字符串在使用过程中绝对不修改此字节切片中的字节值。

使用模式二:将一个非类型安全指针值转换为一个uintptr值,然后使用此uintptr值。

此模式不是很有用。一般我们将最终的转换结果uintptr值输出到日志中用来调试,但是有很多其它安全并且简洁的途径也可以实现此目的。

一个例子:

package main

import "fmt"
import "unsafe"

func main() {
	type T struct{a int}
	var t T
	fmt.Printf("%p\n", &t)                          // 0xc6233120a8
	println(&t)                                     // 0xc6233120a8
	fmt.Printf("%x\n", uintptr(unsafe.Pointer(&t))) // c6233120a8
}

输出地址在每次运行中可能都会不同。

使用模式三:将一个非类型安全指针转换为一个uintptr值,然后此uintptr值参与各种算术运算,再将算术运算的结果uintptr值转回非类型安全指针。

转换前后的非类型安全指针必须指向同一个内存块。一个例子:

package main

import "fmt"
import "unsafe"

type T struct {
	x bool
	y [3]int16
}

const N = unsafe.Offsetof(T{}.y)
const M = unsafe.Sizeof(T{}.y[0])

func main() {
	t := T{y: [3]int16{123, 456, 789}}
	p := unsafe.Pointer(&t)
	// "uintptr(p) + N + M + M"为t.y[2]的内存地址。
	ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M+M))
	fmt.Println(*ty2) // 789
}

其实,对于这样地址加减运算,更推荐使用上面介绍的Go 1.17中引入的unsafe.Add函数来完成。

注意:在上面这个例子中,转换unsafe.Pointer(uintptr(p) + N + M + M)不应该像下面这样被拆成两行。 请阅读下面的代码中的注释以获取原因。

func main() {
	t := T{y: [3]int16{123, 456, 789}}
	p := unsafe.Pointer(&t)
	// ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M+M))
	addr := uintptr(p) + N + M + M
	
	// ...(一些其它操作)
	
	// 从这里到下一行代码执行之前,t值将不再被任何值
	// 引用,所以垃圾回收器认为它可以被回收了。一旦
	// 它真地被回收了,下面继续使用t.y[2]值的曾经
	// 的地址是非法和危险的!另一个危险的原因是
	// t的地址在执行下一行之前可能改变(见事实三)。
	// 另一个潜在的危险是:如果在此期间发生了一些
	// 操作导致协程堆栈大小改变的情况,则记录在addr
	// 中的地址将失效。
	ty2 := (*int16)(unsafe.Pointer(addr))
	fmt.Println(*ty2)
}

这样的bug是非常微妙和很难被觉察到的,并且爆发出来的几率是相当得低。 一旦这样的bug爆发出来,将很让人摸不到头脑。这也是使用非类型安全指针被认为是危险操作的原因之一。

中间uintptr值可以参与&^清位运算来进行内存对齐计算,只要保证转换前后的非类型安全指针同时指向同一个内存块,整个转换就是合法安全的。

另一个需要注意的细节是最好不要将一个内存块的结尾边界地址存储在一个(安全或非安全)指针中。 这样做将导致紧随着此内存块的另一个内存块因为被引用而不会被垃圾回收掉,或者因为形成非法指针而导致程序崩溃(取决于具体编译器实现)。 请阅读这个问答以获取更多解释。

使用模式四:将非类型安全指针值转换为uintptr值并传递给syscall.Syscall函数调用。

通过对上一个使用模式的解释,我们知道像下面这样含有uintptr类型的参数的函数定义是危险的。

// 假设此函数不会被内联。
func DoSomething(addr uintptr) {
	// 对处于传递进来的地址处的值进行读写...
}

上面这个函数是危险的原因在于此函数本身不能保证传递进来的地址处的内存块一定没有被回收。 如果此内存块已经被回收了或者被重新分配给了其它值,那么此函数内部的操作将是非法和危险的。

然而,syscall标准库包中的Syscall函数的原型为:

func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)

那么此函数是如何保证处于传递给它的地址参数值a1a2a3处的内存块在此函数执行过程中一定没有被回收和被移动呢? 此函数无法做出这样的保证。事实上,是编译器做出了这样的保证。 这是syscall.Syscall这样的函数的特权。其它自定义函数无法享受到这样的待遇。

我们可以认为编译器针对每个syscall.Syscall函数调用中的每个被转换为uintptr类型的非类型安全指针实参添加了一些指令,从而保证此非类型安全指针所引用着的内存块在此调用返回之前不会被垃圾回收和移动。

注意:在Go 1.15之前,类型转换表达式uintptr(anUnsafePointer)可以呈现为相关实参的子表达式。 但是,从Go 1.15开始,使用此模式的要求变得略加严格:相关实参必须呈现为uintptr(anUnsafePointer)这种形式。

下面这个调用是安全的:

syscall.Syscall(syscall.SYS_READ, uintptr(fd),
			uintptr(unsafe.Pointer(p)), uintptr(n))

但下面这个调用则是危险的:

u := uintptr(unsafe.Pointer(p))
// 被p所引用着的值在此时有可能会被回收掉,
// 或者它的地址已经发生了改变。
syscall.Syscall(SYS_READ, uintptr(fd), u, uintptr(n))

// 相关实参必须呈现为"uintptr(anUnsafePointer)"
// 这种形式。事实上,Go 1.15之前,此调用是合法的;
// 但是Go 1.15略改了一点规则。
syscall.Syscall(SYS_XXX, uintptr(uintptr(fd)),
			uint(uintptr(unsafe.Pointer(p))), uintptr(n))

注意:此使用模式也适用于Windows系统中的syscall.Proc.Callsyscall.LazyProc.Call系统调用。

再提醒一次,此使用模式不适用于其它自定义函数。

使用模式五:将reflect.Value.Pointer或者reflect.Value.UnsafeAddr方法的uintptr返回值立即转换为非类型安全指针。

reflect标准库包中的Value类型的PointerUnsafeAddr方法都返回一个uintptr值,而不是一个unsafe.Pointer值。 这样设计的目的是避免用户不引用unsafe标准库包就可以将这两个方法的返回值(如果是unsafe.Pointer类型)转换为任何类型安全指针类型。

这样的设计需要我们将这两个方法的调用的uintptr结果立即转换为非类型安全指针。 否则,将出现一个短暂的可能导致处于返回的地址处的内存块被回收掉的时间窗。 此时间窗是如此短暂以至于此内存块被回收掉的几率非常之低,因而这样的编程错误造成的bug的重现几率亦十分得低。

比如,下面这个调用是安全的:

p := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer()))

而下面这个调用是危险的:

u := reflect.ValueOf(new(int)).Pointer()
// 在这个时刻,处于存储在u中的地址处的内存块
// 可能会被回收掉。
p := (*int)(unsafe.Pointer(u))

注意:Go 1.19引入了一个新的方法:reflect.Value.UnsafePointer()。 官方推荐以后使用此方法来替换上述两个方法。也就说,承认了原来的设计思路并不太对路。 UnsafePointer()放回一个unsafe.Pointer值。

使用模式六:将一个reflect.SliceHeader或者reflect.StringHeader值的Data字段转换为非类型安全指针,以及其逆转换。

和上一小节中提到的同样的原因,reflect标准库包中的SliceHeaderStringHeader类型的Data字段的类型被指定为uintptr,而不是unsafe.Pointer

我们可以将一个字符串的指针值转换为一个*reflect.StringHeader指针值,从而可以对此字符串的内部进行修改。 类似地,我们可以将一个切片的指针值转换为一个*reflect.SliceHeader指针值,从而可以对此切片的内部进行修改。

一个使用reflect.StringHeader的例子:

package main

import "fmt"
import "unsafe"
import "reflect"

func main() {
	a := [...]byte{'G', 'o', 'l', 'a', 'n', 'g'}
	s := "Java"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
	hdr.Data = uintptr(unsafe.Pointer(&a))
	hdr.Len = len(a)
	fmt.Println(s) // Golang
	// 现在,字符串s和切片a共享着底层的byte字节序列,
	// 从而使得此字符串中的字节变得可以修改。
	a[2], a[3], a[4], a[5] = 'o', 'g', 'l', 'e'
	fmt.Println(s) // Google
}

一个使用了reflect.SliceHeader的例子:

package main

import (
	"fmt"
	"unsafe"
	"reflect"
)

func main() {
	a := [6]byte{'G', 'o', '1', '0', '1'}
	bs := []byte("Golang")
	hdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	hdr.Data = uintptr(unsafe.Pointer(&a))

	hdr.Len = 2
	hdr.Cap = len(a)
	fmt.Printf("%s\n", bs) // Go
	bs = bs[:cap(bs)]
	fmt.Printf("%s\n", bs) // Go101
}

一般说来,我们只应该从一个已经存在的字符串值得到一个*reflect.StringHeader指针, 或者从一个已经存在的切片值得到一个*reflect.SliceHeader指针, 而不应该从一个全新的StringHeader值生成一个字符串,或者从一个全新的SliceHeader值生成一个切片。 比如,下面的代码是不安全的:

var hdr reflect.StringHeader
hdr.Data = uintptr(unsafe.Pointer(new([5]byte)))
// 在此时刻,上一行代码中刚开辟的数组内存块已经不再被任何值
// 所引用,所以它可以被回收了。
hdr.Len = 5
s := *(*string)(unsafe.Pointer(&hdr)) // 危险!

下面是一个展示了如何通过使用非类型安全途径将一个字符串转换为字节切片的例子。 和使用类型安全途径进行转换不同,使用非类型安全途径避免了复制一份底层字节序列。

package main

import (
	"fmt"
	"reflect"
	"strings"
	"unsafe"
)

func String2ByteSlice(str string) (bs []byte) {
	strHdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	sliceHdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	sliceHdr.Data = strHdr.Data
	sliceHdr.Cap = strHdr.Len
	sliceHdr.Len = strHdr.Len
	return
}

func main() {
	// str := "Golang"
	// 对于官方标准编译器来说,上面这行将使str中的字节
	// 开辟在不可修改内存区。所以这里我们使用下面这行。
	str := strings.Join([]string{"Go", "land"}, "")
	s := String2ByteSlice(str)
	fmt.Printf("%s\n", s) // Goland
	s[5] = 'g'
	fmt.Println(str) // Golang
}

注意:当使用上面展示的使用非类型安全指针将一个字符串转换为字节切片时,请确保结果此源字符串的生命期内务必不要修改结果字节切片中的字节值(上面的例子违背了此原则)。 事实上,更为推荐的是最好永远不要修改结果字节切片中的字节值。此非类型安全方式的目的主要是为了在局部感知范围内避免一次内存开辟,而不是一种通用的方式。

我们可以使用类似的实现(如下所示)来将一个字节切片转换为字符串。 此实现被模式一中展示的方法略为安全一些(但是也更慢一些)。

func ByteSlice2String(bs []byte) (str string) {
	sliceHdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	strHdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	strHdr.Data = sliceHdr.Data
	strHdr.Len = sliceHdr.Len
	return
}

同样地,请确保结果此结果字符串的生命期内务必不要修改实参字节切片中的字节值。

最后,顺便举一个违背了模式三的使用原则的例子:

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func Example_Bad() *byte {
	var str = "godoc"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	pbyte := (*byte)(unsafe.Pointer(hdr.Data + 2))
	return pbyte // *pbyte == 'd'
}

func main() {
	fmt.Println(string(*Example_Bad()))
}

下面是两个正确的实现:

func Example_Good1() *byte {
	var str = "godoc"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	pbyte := (*byte)(unsafe.Pointer(
		uintptr(unsafe.Pointer(hdr.Data)) + 2))
	return pbyte
}

// 从Go 1.17开始也可以使用此实现。
func Example_Good2() *byte {
	var str = "godoc"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	pbyte := (*byte)(unsafe.Add(unsafe.Pointer(hdr.Data), 2))
	return pbyte
}

上面这几个例子借鉴自Bryan C. Mills在slack中发表的一个留言。

reflect标准库包中SliceHeaderStringHeader类型的文档提到这两个结构体类型的定义不保证在以后的版本中不发生改变。 这也可以看作是使用非类型安全指针的另一个(较低的)潜在风险。 好在目前的两个主流Go编译器(标准编译器和gccgo编译器)都认可当前版本中的定义。

Go核心开发团队也意识到了这两个类型的使用不方便并且容易出错,因此,这两个类型在Go 1.20中被废弃了。 取而代之,我们应该尽量使用本文前面介绍的unsafe.Stringunsafe.StringDataunsafe.Sliceunsafe.SliceData这几个函数。

总结一下

从上面解释中,我们得知,对于某些情形,非类型安全机制可以帮助我们写出运行效率更高的代码。 但是,使用非类型安全指针也使得我们可能轻易地写出一些重现几率非常低的微妙的bug。 一个含有这样的bug的程序很可能在很长一段时间内都运行正常,但是突然变得不正常甚至崩溃。 这样的bug很难发现和调试。

我们只应该在不得不使用非类型安全机制的时候才使用它们。 特别地,当我们使用非类型安全机制时,请务必遵循上面列出的使用模式。

重申一次,我们应该知晓当前的非类型安全机制规则和使用模式可能在以后的Go版本中完全失效。 当然,目前没有任何迹象表明这种变化将很快会来到。 但是,一旦发生这种变化,本文中列出的当前是正确的代码将变得不再安全甚至编译不通过。 所以,在实践中,请尽量保证能够将使用了非类型安全机制的代码轻松改为使用安全途径实现。

最后值得提一下的是,Go官方工具链1.14中加入了一个-gcflags=all=-d=checkptr编译器动态分析选项(在Windows平台上推荐使用工具链1.15+)。 当此选项被使用的时候,编译出的程序在运行时会监测到很多(但并非所有)非类型安全指针的错误使用。一旦错误的使用被监测到,恐慌将产生。 感谢Matthew Dempsky实现了此特性


以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号