网上很多文章,说Golang切片是引用类型,其实这种说法是不严谨的。下面主要介绍Golang切片底层结构及应用:
切片底层结构
在Golang底层,切片为一个runtime.slice结构体,内部有三个成员,底层数据的指针array、长度len和容量cap,其中array为unsafe.Pointer类型,len和cap均为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.Pointer和int类型均占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,其中str为unsafe.Pointer类型,len为int类型。因此,可以将字符串理解为一个不可变的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包中提供了几个函数,我们可以使用内置的len和cap函数获取长度和容量,使用unsafe.StringData和unsafe.SliceData获取底层数据的指针,最后通过unsafe.String和unsafe.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!
|