Back

[Golang] Go 语言切片(Slice)数据类型的三种拷贝方式

Golang中切片数据类型常用的三种拷贝方式区别以及底层原理分析

0x00 Go语言切片结构体

切片(Slice)可以看成可变长的数组,主要具有append, copy, len, cap等方法,这是其结构体定义:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
  • 第一个字段 array 是一个指向一段连续内存的指针,数据在这段内存上连续保存
  • 第二个字段 len 表示当前切片的有效长度,即元素的个数,可用 len() 方法取得。尝试访问超出这个范围的元素会提示下标越界。
  • 第三个字段 cap 表示当前切片的最大容量,当切片使用 append() 方法添加元素且剩余容量不足时,goroutine 会自动帮你重新分配内存来扩容,并将原来的值拷贝到新内存区域,扩容策略类似 C++ 的 vector 。注意,扩容之后 array 指针会发生变化,指向新的内存

0x01 样例分析

  1. 代码
package main

import (
    "fmt"
)

func printSlice(arr []int) {
    fmt.Printf("arr: %v &arr: %p &arr[0]: %p len: %d cap: %d\n", arr, &arr, &arr[0], len(arr), cap(arr))
}

func main() {
    var arr1 []int
    arr1 = append(arr1, 1, 2, 3, 4, 5)
    arr2 := arr1 // way1
    arr3 := make([]int, 5, 6)
    copy(arr3, arr1)  // way2
    arr4 := arr1[0:3] // way3

    printSlice(arr1)
    printSlice(arr2)
    printSlice(arr3)
    printSlice(arr4)
    fmt.Println()

    arr1[0] = -1 // assignment

    printSlice(arr1)
    printSlice(arr2)
    printSlice(arr3)
    printSlice(arr4)
    fmt.Println()

    arr1 = append(arr1, 6, 7, 8, 9, 10) // expand
    arr1[1] = -2                        // assignment

    printSlice(arr1)
    printSlice(arr2)
    printSlice(arr3)
    printSlice(arr4)
    fmt.Println()
}
  1. 输出
arr: [1 2 3 4 5] &arr: 0xc000004078 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc0000040a8 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc0000040d8 &arr[0]: 0xc00000c420 len: 5 cap: 6
arr: [1 2 3] &arr: 0xc000004108 &arr[0]: 0xc00000c3f0 len: 3 cap: 6

arr: [-1 2 3 4 5] &arr: 0xc000004138 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [-1 2 3 4 5] &arr: 0xc000004168 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc000004198 &arr[0]: 0xc00000c420 len: 5 cap: 6
arr: [-1 2 3] &arr: 0xc0000041c8 &arr[0]: 0xc00000c3f0 len: 3 cap: 6

arr: [-1 -2 3 4 5 6 7 8 9 10] &arr: 0xc0000041f8 &arr[0]: 0xc00001e180 len: 10 cap: 12
arr: [-1 2 3 4 5] &arr: 0xc000004228 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc000004258 &arr[0]: 0xc00000c420 len: 5 cap: 6
arr: [-1 2 3] &arr: 0xc000004288 &arr[0]: 0xc00000c3f0 len: 3 cap: 6

代码中声明了四个int类型的切片,其中arr1为源切片,其它三个切片由不同的拷贝手段从arr1拷贝而来。arr2使用了最常用的:=符号来赋值;arr3使用了copy方法赋值;arr4则使用arr1中的部分范围来赋值。

分析

在四个切片初始化完成后,进行了一次 printSlice() 分别输出切片当前所有元素,切片结构体地址,切片首元素的地址(可以视为 array 指针的值),切片长度以及切片容量。首次 printSlice 的结果可以看到两个很明显的结果:

a. arr1 和 arr2 的结构体地址不同,但是使用了同一个 `array` 指针,也就是说不同切片引用了同一片连续内存;

b. arr1 和 arr4 的结构体地址不同,但是 `array` 指针也相同,说明 `[start:end]` 这种拷贝方式其实还是引用到了 arr1 原来的连续内存,只是 `len` 和 `cap` 字段进行了修改。可以预见,如果用的是 `[1:5]` 来拷贝那么 `&arr4[0]` 的值应该会变为 `0xc00000c3f0+4` ——增加了一个 int 的大小;

修改 arr1[0] 值为-1后再次输出,没有意外的——arr2[0] 以及 arr4[0] 都发生了改变变成了-1,产生了一种类似浅拷贝的效果。到此为止 arr3 并没有发生一点变化,因为从输出的结果来看, arr3 底层的 array 指针从一开始就指向单独分配的内存,换言之显式的使用 copy() 去拷贝切片会真实的在内存中开辟一块新区域存放源切片的副本,对新切片的读写也完全不会影响原切片;

尝试使用 append 触发 arr1 的自动扩展,然后修改 arr1[1] 的值为-2,再输出一次。可以看到结果中 &arr1[0] 的地址已经发生了大变化——array 引用的内存地址为 go 线程新申请的内存。并且这次对 arr1[1] 的修改没有再影响到 arr2 和 arr4。

0x02 总结

  • :=拷贝方式:没有直接创建数据的副本,而且引用了同一个array
  • [start:end]方式:同样没有创建副本,但是会根据起始和结束的位置修改array指针的偏移以及lencap的大小;
  • copy()方式:最无忧的方式,会直接在内存中创建一个新的副本,免去不必要的干扰。但在实际工程中,并不是copy()一定是最好的,因为需要考虑到执行效率,尽量降低申请内存的次数。题外话:使用make预先说明好可能的最大容量也可以避免频繁扩容申请内容造成性能损耗。

对于有良好C/C++编程基础的人,看了样例很快就能猜出go语言切片的一些底层原理。但是对于以go入门的人而言弄明白这个可以避免创造一些神奇的bug。

Submit