Go常见数据结构的实现原理——slice

切片

切片是对数组的一个引用,它包含指向数组的指针、长度和容量等信息。在 Go 语言中,切片(Slice)、映射(Map)、通道(Channel)和接口(Interface)都属于引用类型。这些类型的变量存储的是对底层数据的引用,而不是数据本身。

下面是引用类型的一些特点

  1. 内存管理: 引用类型的变量在内存中存储的是数据的地址,而不是实际数据。这使得内存管理更加灵活,允许动态分配和释放内存。
  2. 传递方式: 引用类型通常是通过引用传递的,这意味着当将一个引用类型的变量传递给函数时,实际上传递的是数据的引用,而不是数据的副本。这样的传递方式可以避免不必要的内存复制。
  3. 共享数据: 多个引用可以指向相同的底层数据。当一个引用修改数据时,其他引用也会受到影响,因为它们指向相同的内存地址。
  4. 动态大小: 引用类型的大小通常是动态确定的,可以根据需要动态增长或缩小。

(一)初始化

声明和初始化切片的方式主要有以下几种

  • 变量声明
  • 字面量
  • 使用内置函数make()
  • 从切片和数组中切取

变量声明

	var s []int

字面量

	s1 := []int{}		// 空切片
	s2 := []int{1,2,3}	// 长度为3的切片

内置函数make

	s1 := make([]int,10)		// 指定长度,容量为10
	s2 := make([]int,5,10)		// 长度为5,容量为10

切取

	array := [5]int{1,2,3,4,5}
	s1 := array[0:2]	// 从数组切取 
	s2 := s1[0:1]		// 从切片切取

可能会有一些同学好奇长度和容量有什么区别?

长度是切片中当前存储的元素数量,容量是切片内部底层数组的大小。

是不是还是很疑惑?

比如我们使用make创建了一个切片s := make([]int,3,5),len和cap的大概是这么一个关系
Go常见数据结构的实现原理——slice_第1张图片
此时如果输出s[2]的话会输出0,而输出s[4]的话会出现painc: runtime error: index out of range [4] with length 3
cap覆盖的区域只是预留了地址,而len覆盖的区域 完成了初始化

(二)数据结构

源码位于src/runtime/slice.go 中

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

Go常见数据结构的实现原理——slice_第2张图片

slice依托数组实现,底层数组对用户屏蔽,在底层数组容量不足时,可以实现自动重新分配并生成新的slice。虽然数据结构看着很简单,但是有一些问题需要我们考虑一下
下面程序运行结果是什么?

问题一

func main() {
	// 创建一个整数切片
	x := []int{1, 2, 3}
	z := x[0:]
	z[0] = 10
	fmt.Println(x, z)
}

程序运行结果:[10 2 3] [10 2 3]
这是因为如果我们通过切取方式生成的切片与原数组或切片共享内存。
既然共享地址,那么下面的输出会是什么情况呢?1与2相等吗,3与4相等吗,还是都相等?
切片是引用数据类型,变量

func main() {
	s1 := []int{1, 2, 3, 4, 5}
	s2 := s1[0:3]
	fmt.Printf("%p\n", s1)		// 1
	fmt.Printf("%p\n", s2)		// 2
	fmt.Printf("%p\n", &s1)		// 3
	fmt.Printf("%p\n", &s2)		// 4
}

程序运行结果:
0xc0000be060
0xc0000be060
0xc000094060
0xc000094078

答案是1与2相等,3和4不相等。

  1. fmt.Printf("%p", s):这会打印切片 s的指针,即切片底层数据的地址。

  2. fmt.Printf("%p", &s):这会打印 s 变量本身的地址,即存储切片地址的变量 s的地址。

问题二

func main() {
	// 创建一个整数切片
	s1 := []int{1, 2}
	s2 := s1
	s2 = append(s2, 3)
	sliceRise(s1)
	sliceRise(s2)
	fmt.Println(s1, s2)
}

func sliceRise(s []int) {
	s = append(s, 0)
	for i := range s {
		s[i]++
	}
}

程序运行结果:
[1 2] [2 3 4]

要搞懂这道题首先需要我们知道:

Go语言中所有的传参都是值传递(传值),都是一个副本,一个拷贝。因为拷贝的内容有时候是非引用类型(int、string、struct等这些),这样就在函数中就无法修改原内容数据;有的是引用类型(指针、map、slice、chan等这些),这样就可以修改原内容数据。

我们可以简单验证一下

Go常见数据结构的实现原理——slice_第3张图片

可以看到s、s1已经不是同一个切片了,但是底层数组指向同一个地址。

其次我们需要知道append()是怎样实现添加元素的,使用append()向slice添加一个元素的实现步骤如下:

  1. 假如slice容量够用,则将新元素追加到array[len],slice.len++,返回slice。
  2. 假如原slice容量不够,则将slice先扩容,扩容后得到新slice。
  3. 将新元素追加进新slice,slice.len++,返回新的slice。

扩容容量的选择遵循以下基本准则:

  1. 如果原slice容量小于1024,则新slice容量将扩大为原来的二倍。
  2. 如果原slice容量大于或等于1024,则新slice容量将扩大为原来的1.25倍。
    Go常见数据结构的实现原理——slice_第4张图片

可以看到,切片扩容后底层数组发生改变。接下来我们看上述问题

```go
func main() {
	// 创建一个整数切片
	s1 := []int{1, 2}
	s2 := s1
	s2 = append(s2, 3)
	sliceRise(s1)
	sliceRise(s2)
	fmt.Println(s1, s2)
}

func sliceRise(s []int) {
	s = append(s, 0)
	for i := range s {
		s[i]++
	}
}

首先s2 := s1,此时s2与s1底层数组一样。随后s2 = append(s2, 3),这个时候由于s2需要扩容,所以底层数组发生改变s2=[1,2,3],扩容后的长度为3,容量为4。从这时开始,s2与s1已经没有关系了(我们好像已经渐行渐远了海绵宝宝)。

Go常见数据结构的实现原理——slice_第5张图片

接下来调用sliceRise(s1)方法(值传递),此时s1与s共用同一个底层数组,随后s = append(s, 0),s也发生扩容,底层数组发生了改变,s与s1也没有了联系。因此对s底层数组的修改不会影响到s1。所以s1返回[1,2]
再看sliceRise(s2)方法中调用s = append(s, 0),由于cap(s)=4,而len(s)=3,所以s底层数组没有发生扩容,s2与s还继续共用一个底层数组。后面就对底层数组修改为[2,3,4,1]。那为什么最后s2返回的是[2,3,4]而不是[2,3,4,1]呢?因为s在append(s,0),时,s2切片结构体的len属性并未发生改变。

问题三
下面程序运行结果是什么?


func main() {
	// 创建一个整数切片
	x := make([]int,0,10)
	x = append(x,1,2,3)
	y := append(x,4)
	z := append(x,5)
	fmt.Println(x)
	fmt.Println(y)
	fmt.Println(z)
}

程序运行结果:
[1 2 3]
[1 2 3 5]
[1 2 3 5]

x,y,z指向同一个底层数组,len(x)=3
Go常见数据结构的实现原理——slice_第6张图片

y := append(x,4)时,此时len(x)依旧为3

Go常见数据结构的实现原理——slice_第7张图片

z := append(x,5)时,由于len(x)还等于3,所以会覆盖y

Go常见数据结构的实现原理——slice_第8张图片

ps:有什么问题欢迎大家在评论区讨论

你可能感兴趣的:(golang)