golang爬坑笔记之自问自答系列(6)——深度解析Golang中的切片Slice

每一门语言都有其独特的数据结构,和Python语言中的list一样,Slice在Golang的数据结构中有举足轻重的地位。作为一门语言的最重要的基本数据结构,理解其内在机理和设计思想至关重要。

定义

Slice(切片)表示一个拥有相同类型元素的可变长度的序列。它通常写成[]T,其中元素的类型都是T;它看起来像没有长度的Array(数组)类型。

切片与数组

Golang中的数组和切片是紧密关联的。它有三个属性:指针、长度和容量。以下是其结构定义:

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

其中指针指向数组的第一个可以从slice中访问的元素,这个元素并不一定是数组的第一个元素。这句话怎么理解呢?看下面代码。

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var weekday = [...]string{1:"Monday",2:"Tuesday",3:"Wednesday",4:"Thursday",5:"Friday",6:"Saturday",7:"Sunday"}
	slice1 := weekday[1:3]
	slice2 := weekday [2:4]
	slice3 :=weekday[1:5]
	fmt.Printf("weekday:%v, address:%p,len():%d,cap:%d\n",weekday,&weekday,len(weekday),cap(weekday))
	fmt.Printf("slice1:%v, address:%p,array ptr:%v,len():%d,cap:%d\n",slice1,&slice1,*(*unsafe.Pointer)(unsafe.Pointer(&slice1)),len(slice1),cap(slice1))
	fmt.Printf("slice2:%v, address:%p,array ptr:%v,len():%d,cap:%d\n",slice2,&slice2,*(*unsafe.Pointer)(unsafe.Pointer(&slice2)),len(slice2),cap(slice2))
	fmt.Printf("slice3:%v, address:%p,array ptr:%v,len():%d,cap:%d\n\n",slice3,&slice3,*(*unsafe.Pointer)(unsafe.Pointer(&slice3)),len(slice3),cap(slice3))

	weekday[2]="holiday"
	fmt.Printf("weekday:%v, address:%p,len():%d,cap:%d\n",weekday,&weekday,len(weekday),cap(weekday))
	fmt.Printf("slice1:%v, address:%p,array ptr:%v,len():%d,cap:%d\n",slice1,&slice1,*(*unsafe.Pointer)(unsafe.Pointer(&slice1)),len(slice1),cap(slice1))
	fmt.Printf("slice2:%v, address:%p,array ptr:%v,len():%d,cap:%d\n",slice2,&slice2,*(*unsafe.Pointer)(unsafe.Pointer(&slice2)),len(slice2),cap(slice2))
	fmt.Printf("slice3:%v, address:%p,array ptr:%v,len():%d,cap:%d\n\n",slice3,&slice3,*(*unsafe.Pointer)(unsafe.Pointer(&slice3)),len(slice3),cap(slice3))
}

输出:

weekday:[ Monday Tuesday Wednesday Thursday Friday Saturday Sunday], address:0xc000090000,len():8,cap:8
slice1:[Monday Tuesday], address:0xc000092000,array ptr:0xc000090010,len():2,cap:7
slice2:[Tuesday Wednesday], address:0xc000092020,array ptr:0xc000090020,len():2,cap:6
slice3:[Monday Tuesday Wednesday Thursday], address:0xc000092040,array ptr:0xc000090010,len():4,cap:7

weekday:[ holiday Tuesday Wednesday Thursday Friday Saturday Sunday], address:0xc000090000,len():8,cap:8
slice1:[holiday Tuesday], address:0xc000092000,array ptr:0xc000090010,len():2,cap:7
slice2:[Tuesday Wednesday], address:0xc000092020,array ptr:0xc000090020,len():2,cap:6
slice3:[holiday Tuesday Wednesday Thursday], address:0xc000092040,array ptr:0xc000090010,len():4,cap:7

其中weekday数组就是slice1、slice2和slice3共同的底层数组。我们可以看到每个切片本身的内存地址是不同的,需要强调的是,切片本身的内存地址和它的指针属性不是一回事,它们指向不同的地址。但是它们共用了weeday数组,因此当weekday[2]的值改变之后,它们对应的值也发生了改变。

另外,需要注意slice其实是一种通过结构体定义的数据结构,再次强调,它的指针指向数组的第一个可以从slice中访问的元素。那么我们可以看到slice1与slice3第一个可访问的元素都是week[1],因此它们中的指针是应该相同的,运行结果也表明了这一点(*(*unsafe.Pointer)(unsafe.Pointer(&slice1))代表获取结构体slice1中的第一个成员变量-array unsafe.Pointer的内存地址)。

为什么切片在函数中是引用传递

函数的值传递:当调用一个函数的时候,每个传入的参数都会创建一个副本,然后赋值给对应的函数变量,所以函数接受的是一个副本,而不是原始的参数。使用这种方式传递大的数组会变得很低效,并且在函数内部对数组的任何修改都仅影响副本,而不是原属数组。

在其他语言中,例如Python,在函数调用时,数组是隐式地使用引用传递,而在Golang中数组被设计为值传递。我们知道,在Go中,为了让数组实现引用传递,我们可以显式地传递一个数组的指针给函数,这样在函数内部对数组的任何修改都会反映到原始数组上面。如以下例子:

package main

import "fmt"

func doubleArray(arr *[10]int)  {
	for i,v := range *arr{
		arr[i] = v*2
	}
}

func main() {
	var a  = [10]int{0,1,2,3,4,5,6,7,8,9}
	fmt.Println(a)
	doubleArray(&a)
	fmt.Println(a)
}

//输出:
//[0 1 2 3 4 5 6 7 8 9]
//[0 2 4 6 8 10 12 14 16 18]

如果理解了上文中关于切片与数组的关系,那么就能想到为何切片的函数调用是引用传递了。因为slice包含了指向数组元素的指针,所以将一个slice传递给函数的时候,可以在函数内部修改底层数组的元素。底层数组改变,那么相应的slice也就跟着改变了。

例:

package main

import "fmt"

func setValue(slice []int)  {
	for i:=range slice{
		slice[i]=i
	}
}

func main() {
	var a = make([]int,10)
	fmt.Println(a)
	setValue(a)
	fmt.Println(a)
}
//输出
//[0 0 0 0 0 0 0 0 0 0]
//[0 1 2 3 4 5 6 7 8 9]

切片的深浅拷贝

浅拷贝

先上代码:

package main

import (
	"fmt"
)

func main() {
	a := []int{1,2,3}
	b :=a
	var c  []int
	c = a
	fmt.Printf("%p,%p,%p\n",&a,&b,&c)

	a[1]= a[1]*100
	fmt.Printf("%v,%v,%v",a,b,c)
}

输出:

0xc000094000,0xc000094020,0xc000094040
[1 200 3],[1 200 3],[1 200 3]

让我们回想一下什么是浅拷贝。浅拷贝对于值类型的话是完全拷贝一份,对拷贝后的对象进行值修改不会影响原对象的值,而对于引用类型的浅拷贝是复制其地址,也就是对拷贝的对象修改引用类型的变量同样会影响到原对象。下面我们先看看我们熟悉的python中的例子。

a = 4
b = a
print(id(a),id(b))
a = 5
print(a,b)

arr = [1,2,3]
arr2 = arr
print(id(arr),id(arr2))
arr[1] = arr[1]*100
print(arr,arr2)

输出:

4304841856 4304841856
5 4
4340876424 4340876424
[1, 200, 3] [1, 200, 3]

可见,在Python中完美的阐述了浅拷贝。回到Golang中上面的例子,为啥a、b、c的地址不是相同的,但是改变a的值,却会影响到b、c。那么Golang中切片的=操作是不是传统意义上的浅拷贝呢?

我的理解:你既可以把它当作是浅拷贝,也可认为它不是。因为它的=操作,并非满足浅拷贝的定义——复制原切片的地址。但是它们的值修改却是互相影响。这是为何呢?其实很简单,因为它们的底层数组是同一个。看代码

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	a := []int{1,2,3}
	b :=a
	var c  []int
	c = a
	fmt.Printf("%p,%p,%p,%v,%v,%v\n",&a,&b,&c,*(*unsafe.Pointer)(unsafe.Pointer(&a)),*(*unsafe.Pointer)(unsafe.Pointer(&b)),*(*unsafe.Pointer)(unsafe.Pointer(&c)))
}
//输出
//0xc000084000,0xc000084020,0xc000084040,0xc000086000,0xc000086000,0xc000086000

深拷贝

了解完浅拷贝,对于深拷贝就比较容易理解了。任何对象都会被完完整整的拷贝一份,拷贝对象与被拷贝对象不存在如何联系,也就不会互相影响。

那么问题来了,在Golang中如何实现切片的深拷贝。答案是使用内置的copy函数,以下为copy的官方源代码注释。

// The copy built-in function copies elements from a source slice into a
// destination slice. (As a special case, it also will copy bytes from a
// string to a slice of bytes.) The source and destination may overlap. Copy
// returns the number of elements copied, which will be the minimum of
// len(src) and len(dst).
func copy(dst, src []Type) int

使用copy()

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	a := []int{1,2,3}
	var b  []int
	copy(b,a)
	var c  = make([]int,3)
	copy(c,a)
	fmt.Printf("%p,%p,%p,%v,%v,%v\n",&a,&b,&c,*(*unsafe.Pointer)(unsafe.Pointer(&a)),*(*unsafe.Pointer)(unsafe.Pointer(&b)),*(*unsafe.Pointer)(unsafe.Pointer(&c)))
	a[1] = 200
	fmt.Println(a,b,c)
}
//输出
//0xc00008a000,0xc00008a020,0xc00008a040,0xc00008c000,,0xc00008c020
//[1 200 3] [] [1 2 3]

细心的你有没有注意到b指向的底层数组为nil,这是因为通过var b []int这样的定义方式,这样定义的slice是零值nil,而值为nil的slice是没有对应的底层数组的,因此也会打印出nil。

通过copy函数,将a值复制给c,a改变之后,c的值并不会跟随改变。这是因为a和c的底层数组不一样。这样的复制方式,也就完成了golang切片的深拷贝。

切片的扩容

切片的扩容大多数情况是伴随着append函数进行的。

在上一篇文章中有讨论到append函数,但是讲的不好,不够清晰,在这里再讲一次。

在slice每次通过append函数新增一个item时,其每一次的调用都必须检查slice是否仍有足够容量来存储数组中的新元素。情况一:如果slice容量足够,那么它就会定义一个新的slice(仍然引用原始底层数组),然后将新增元素item复制到新的位置,并返回这个新的slice。新slice和旧slice引用相同的底层数组。情况二:如果slice的容量不够容纳增长的元素,必须创建一个拥有足够容量的新的底层数组来存储新元素,然后将旧slice上的元素复制到这个数组,再将新元素item追加到数组后面。新slice和旧slice引用不同的底层数组。

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var a []int
	for i:=0;i<2000;i++ {
		a = append(a,i)
		fmt.Printf("len:%d,cap:%d,address:%p,ptr Adress:%v\n",len(a),cap(a),&a,*(*unsafe.Pointer)(unsafe.Pointer(&a)))
	}
}

输出: 

len:1,cap:1,address:0xc00008a000,ptr Adress:0xc00008c000
len:2,cap:2,address:0xc00008a000,ptr Adress:0xc00008c030
len:3,cap:4,address:0xc00008a000,ptr Adress:0xc000082020
len:4,cap:4,address:0xc00008a000,ptr Adress:0xc000082020
len:5,cap:8,address:0xc00008a000,ptr Adress:0xc00009a000
len:6,cap:8,address:0xc00008a000,ptr Adress:0xc00009a000
len:7,cap:8,address:0xc00008a000,ptr Adress:0xc00009a000
len:8,cap:8,address:0xc00008a000,ptr Adress:0xc00009a000
len:9,cap:16,address:0xc00008a000,ptr Adress:0xc00009c000
len:10,cap:16,address:0xc00008a000,ptr Adress:0xc00009c000
len:11,cap:16,address:0xc00008a000,ptr Adress:0xc00009c000
len:12,cap:16,address:0xc00008a000,ptr Adress:0xc00009c000
len:13,cap:16,address:0xc00008a000,ptr Adress:0xc00009c000
               ......
len:1024,cap:1024,address:0xc00008a000,ptr Adress:0xc0000a6000
len:1025,cap:1280,address:0xc00008a000,ptr Adress:0xc0000ac000
               ......
len:1280,cap:1280,address:0xc00008a000,ptr Adress:0xc0000ac000
len:1281,cap:1696,address:0xc00008a000,ptr Adress:0xc0000b6000
               ......
len:1696,cap:1696,address:0xc00008a000,ptr Adress:0xc0000b6000
len:1697,cap:2304,address:0xc00008a000,ptr Adress:0xc0000c0000
               ......

可见,从始至终切片的地址没有发生改变,但是当容量不够新增item时,会开辟新的底层数组,切片的指针也会指向新的底层数组,旧的数组被GC。同时,当cap小于1024时候,其扩容比是按照2倍的比例进行的,当大于1024时候,按照1.25倍方式扩容,大于1280时,按照1.0125倍,大于1696,按照1.35倍。。。这样的动态调整扩容策略,能够在面对大数据量slice时候,兼容内存和效率。

总结

理解Golang切片的关键在于搞懂它与底层数组的联系。

  • 一个底层数组可以对应多个切片
  • 底层数组数据的改变将会直接影响到引用它的切片
  • 切片的隐式引用传递,原因是传递给了函数底层数组的指针
  • 要想完成切片的深拷贝,请使用copy函数
  • append操作会有额外性能上的开销,如果能确定处理数据量的大小,可通过make构造出一个指定len的初始切片,进行数据处理

你可能感兴趣的:(Golang)