Golang切片原理

网上很多文章,说Golang切片是引用类型,其实这种说法是不严谨的。下面主要介绍Golang切片底层结构及应用:

切片底层结构

在Golang底层,切片为一个runtime.slice结构体,内部有三个成员,底层数据的指针array、长度len和容量cap,其中arrayunsafe.Pointer类型,lencap均为int类型。

1
2
3
4
5
6
7
// https://github.com/golang/go/blob/master/src/runtime/slice.go#L15

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

我们可以通过unsafe.Size()函数验证一下。在64为系统下,unsafe.Pointerint类型均占8字节,因此切片应该占24字节:

1
2
3
4
s1, s2, s3 := []int(nil), make([]string, 10), make([]byte, 0, 20)
fmt.Println(unsafe.Sizeof(s1)) // 24
fmt.Println(unsafe.Sizeof(s2)) // 24
fmt.Println(unsafe.Sizeof(s3)) // 24

由此看出,在64位系统下,不管哪种类型的切片,不管是否为空,都为24个字节。

我们还可以通过unsafe操作,借助unsafe.Pointer作为桥梁,直接将切片转换为其底层结构体。注意,以下操作仅作为讲述原理,生产环境不建议使用unsafe。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 定义切片结构体
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

// 封装一个函数,打印切片底层结构
func printSlice(s []int) {
    // 对切片取地址并转为unsafe.Pointer
    ptr := unsafe.Pointer(&s)
    // 将ptr转为切片结构体的指针,并取值
    sliceStruct := *(*slice)(ptr)
    // 最后打印sliceStruct
    fmt.Println(sliceStruct)
}

// 创建切片:长度为10,容量为20
s := make([]int, 10, 20)
printSlice(s) // {0xc00002e0a0 10 20}

切片赋值

切片底层为一个结构体,因此切片的赋值规则遵循结构体的赋值规则,即拷贝底层数据的指针、长度和容量,并不会拷贝底层数据。将一个切片赋值给另一个切片,仅修改新切片的长度和容量时,不会对旧切片产生影响;修改底层数据时,会对旧切片产生影响;如果同时对旧切片添加数据和修改底层数据,并触发扩容,也不会对旧切片产生影响。这也解释了为什么内置的append函数,需要用一个切片接收返回值,不能原地操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 创建一个长度为1,容量为2的切片
s := make([]int, 1, 2)
fmt.Println(s) // [0]
printSlice(s)  // {0xc0000940b0 1 2}

// 增加数据,不会对原切片产生影响
s1 := s
s1 = append(s1, 1)
fmt.Println(s)  // [0]
printSlice(s)   // {0xc0000940b0 1 2}
fmt.Println(s1) // [0 1]
printSlice(s1)  // {0xc0000940b0 2 2}

// 删除数据,不会对原切片产生影响
s2 := s
s2 = s2[:len(s2)-1]
fmt.Println(s)  // [0]
printSlice(s)   // {0xc0000940b0 1 2}
fmt.Println(s2) // []
printSlice(s2)  // {0xc0000940b0 0 2}

// 修改底层数据,会对原切片产生影响
s3 := s
s3[0] = 1
fmt.Println(s)  // [1]
printSlice(s)   // {0xc0000940b0 1 2}
fmt.Println(s3) // [1]
printSlice(s3)  // {0xc0000940b0 1 2}

// 触发扩容,会修改底层数据的指针
// 即使修改底层数据,也不会对原切片产生影响
s4 := s
s4 = append(s4, 1, 2)
s4[0] = 2
fmt.Println(s)  // [1]
printSlice(s)   // {0xc0000940b0 1 2}
fmt.Println(s4) // [2 1 2]
printSlice(s4)  // {0xc0000a0060 3 4}

开发建议:对于修改切片长度的函数,无论是增加还是删除数据,无论会不会同时修改底层数据,都需要将修改后切片作为返回值返回,被修改的切片就不要再使用了,参考append函数用法;对于仅修改底层数据的函数,可以原地操作,参考slices.Sort函数用法。

字节切片与字符串的零拷贝转换

在Golang底层,字符串也是一个结构体,包含两个成员,底层数据的指针str和字符串长度len,其中strunsafe.Pointer类型,lenint类型。因此,可以将字符串理解为一个不可变的byte切片。

1
2
3
4
5
6
// https://github.com/golang/go/blob/master/src/runtime/string.go#L282

type stringStruct struct {
	str unsafe.Pointer
	len int
}

Golang内置字符串与字节切片的转换,会发生一次拷贝。因为字节切片是可以修改的,而字符串是不能修改的,所有修改字符串的方法,仅仅是返回一个新的字符串,并不会修改原字符串。如果转换时,不发生拷贝,同时又修改字节切片,则可能会出现内存安全问题。如果我们的代码确信不会修改字节切片,则可以使用unsafe操作实现零拷贝转换。例如,如果我们想对字符串计算crc32值,但是Golang标准库中计算crc32的函数,入参为[]byte类型,需要进行类型转换,而我们确信,该函数不会修改字节切片底层的数据,完全可以使用零拷贝转换。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
s := "Hello, World!"

// 使用Golang内置的方式转换
b1 := []byte(s)
fmt.Println(crc32.ChecksumIEEE(b1)) // 3964322768

// 零拷贝转换
// 获取字符串底层结构
ss := *(*stringStruct)(unsafe.Pointer(&s))
// 创建字节切片底层结构
bs := slice{
    array: ss.str,
    len:   ss.len,
    cap:   ss.len,
}
// 将字节切片底层结构转为字节切片
b2 := *(*[]byte)(unsafe.Pointer(&bs))
fmt.Println(crc32.ChecksumIEEE(b2)) // 3964322768

其实,字节切片与字符串的零拷贝转换没必要这么麻烦。unsafe包中提供了几个函数,我们可以使用内置的lencap函数获取长度和容量,使用unsafe.StringDataunsafe.SliceData获取底层数据的指针,最后通过unsafe.Stringunsafe.Slice直接创建字符串和切片。参考strings.Builder源码的Builder.String()方法:点击查看源码

1
2
3
4
5
6
7
8
9
s := "Hello, World!"

// 将字符串转为字节切片
b := unsafe.Slice(unsafe.StringData(s), len(s))

// 将字节切片转为字符串
s2 := unsafe.String(unsafe.SliceData(b), len(b))

fmt.Println(s2)	// Hello, World!
使用 Hugo 构建
主题 StackJimmy 设计