Go指南4-数组与切片

545 阅读6分钟

前言

本文讲述的主要内容有:

  • 1.数组与切片的区别
  • 1.1 为什么说切片是数组的引用?
  • 1.2 切片的长度属性和容量属性有何不同?
  • 2.切片的扩容
  • 3.Go和C语言的数组有何不同
  • 4.两个面试题

数组与切片的区别

slice

首先,Golang的数组和切片之间的关系可以用上图表示。

从上图可以得到以下几点内容:
1.数组和切片是两个不同的概念,数组有点像C语言的数组,是存储数据的容器;而切片本身并不存储数据,它只是数组的一个引用

2.一个切片有三部分组成:数组引用、长度属性容量属性

长度属性和容量属性非常容易使人混乱,其中,长度属性指的是切片的元素数量,决定了可读取数据的上限;而容量属性指的切片的最大元素数量,决定切片扩展的上限,下面我会通过几个例子来说明它们的区别。

为什么说切片是数组的引用?

func main() {
	// 底层数组声明
	names := [4]string{
		"John",
		"Paul",
		"George",
		"Ringo",
	}
	fmt.Println(names)
	
	// 获取切片,a,b共享一个数组
	a := names[0:2]
	b := names[1:3]
	fmt.Println(a, b)
	
	// 改变切片b,会改变底层数组,所以a也变了
	b[0] = "XXX"
	fmt.Println(a, b)
	fmt.Println(names)
}

总结:通过以上例子可以看到,a, b虽然是不同切片,但共享的是同一个数组,所以b的修改会导致a也修改

切片的长度属性和容量属性有何不同?

切片长度属性指的是切片的元素数量,决定了可读取数据的上限;而容量属性指的切片的最大元素数量,决定切片了扩展的上限

长度的计算: 即切片的元素数量,这个比较好理解。
容量的计算: 从切片的第一个元素起,到底层数组的最后一个元素,即为切片的容量。

栗子一:

func sliceLenAndCap() {
	names := [6]string{
		"John",
		"Paul",
		"George",
		"Ringo",
		"Richer",
		"Amy",
	}

	a := names[2:4]
  fmt.Println(a)
  // 切片a包含George和Ringo元素,所以长度为2
  fmt.Println(len(a))  
  // 从切片的第一个元素算起,即George,底层包含的元素是George到Amy,有4个,所以cap为4
	fmt.Println(cap(a))  
}

栗子二:长度和容量在声明切片时的作用

// 声明了一个长度和容量都为0的切片
// 与 num1 := []int{} 等价
var num1 []int

// 声明了一个长度为0,容量为3的切片,其底层数组包含三个元素,值都是0
num1 := make([]int, 0, 3)
fmt.Println(num1)  // 结果为[],因为长度为0

// 声明一个长度为1,容量为3的切片,可以看到其元素值是0
num2 := make([]int, 1, 3)
fmt.Println(num2)  // 结果为[0],因为长度为1

// 声明一个长度和容量都为3的切片
num3 := make([]int, 3)
fmt.Println(num3) // 结果为[0 0 0]

切片的容量属性:单凭一个长度属性,Go编译器无法知道是否需要对底层数组进行扩容,所以还需要一个容量属性辅助判断。
栗子三:

func sliceAddElem() {
  // 一开始容量为2,即底层数组只有两个元素
	num1 := make([]int, 0, 2)
	fmt.Printf("num1 %p", num1)  //  0xc000020090

  // 添加元素时,发现添加的元素数量超过底层数组的长度,所以创建了一个新的数组,并修改切片的引用,让它指向新数组
	num1 = append(num1, 1, 2, 3, 4)  // 0xc000018140
	fmt.Printf("num1 %p", num1)
}

切片的扩容

切片在Golang中表现为一个动态数组,而动态数组势必会经历一个扩容的过程。

根据这篇文章,Go slice扩容深度分析,切片的扩容流程为:

1.如果进行append操作的时候,添加的元素是少量的,这里的少量指切片的容量翻倍(double)后仍然能容纳,则扩容流程为:当容量不足1024,双倍扩容;当容量超过1024,1.25倍扩容。

2.如果进行append操作的时候,添加的元素比较多,超过了切片翻倍(double)后的容量,直接使用预估的容量(旧容量 + 新添加的元素数量)

最后,以上两个方式得到新容量后,还要根据切片的类型大小(int、uint32),算出新的容量所需的内存情况(capmem),然后再进行capmem向上取整,得到新的所需内存,除上类型size,得到真正的最终容量,作为新的slice的容量(这句话我看不懂,是直接引用原文的,反正就是上面两种情况得到的容量还会根据类型大小作最后的调整)。

Go和C语言数组的区别

1.C语言的数组名是一个元素的地址,代表指针型常量,所以不能将一个数组赋值给另一个数组;而Go的数组是一个值,所以可以赋给另外一个数组,且赋值时是拷贝所有元素到新数组中。

func arrayCopy() {
	arr1 := [3]int{1, 2, 3}
	arr2 := arr1
	fmt.Println(arr2)  // [1 2 3]
	arr1[0] = -1
	fmt.Println(arr1)  // [-1 2 3]
	fmt.Println(arr2)  // [1 2 3]
}

2.在Golang中,如果将一个数组传入某个函数,在函数内部接收到的是数组的副本而非指针。如果想传入指针,有两种做法,一是显式加上指针,第二就是传递切片(推荐做法)

func elemAdd(arr [3]int) {
	for idx, _ := range arr{
		arr[idx] += 1
	}
	fmt.Println("new", arr) // [2, 3, 4]
}

func elemAddTest() {
	num1 := [3]int{1, 2, 3}
	elemAdd(num1)
	fmt.Println(num1) // [1, 2, 3]
}

// 1.显式加上指针
func elemAddByPoint(arr *[3]int) {
	for idx, _ := range arr{
		arr[idx] += 1
	}
	fmt.Println("new", arr) // &[2, 3, 4]
}

func elemAddByPointTest() {
	num1 := [3]int{1, 2, 3}
	elemAddByPoint(&num1)
	fmt.Println(num1) // [2, 3, 4]
}

// 2.传递切片
func elemAddBySlice(arr []int) {
	for idx, _ := range arr{
		arr[idx] += 1
	}
	fmt.Println("new", arr) // [2, 3, 4]
}

func elemAddBySliceTest() {
	num1 := []int{1, 2, 3}
	elemAddBySlice(num1)
	fmt.Println(num1) // [2, 3, 4]
}

面试题

1.有如下程序,请问两次打印各输出什么内容?以及切片是如何扩容的?

func main() {
	s := make([]int, 5)
	s = append(s, 1, 2, 3)
	// 第一次打印
	fmt.Println(s)

	var fb = func(arr []int) {
		arr[0] = 10;
		arr = append(arr, 4)
	}
	fb(s)
	// 第二次打印
	fmt.Println(s)
}

2.有如下程序,为什么经过t = append(s, 1, 2, 3, 4)后,t和s的地址一样,但是内容不同?

func sliceTest() {
	// 声明两个切片,一开始两个切片的地址是不一样的
	var t = make([]int, 0, 10)
	var s = make([]int, 0, 10)

	fmt.Printf("addr:%p len:%v content:%v\n", t, len(t), t);  // addr:0xc00009a000       len:0 content:[]
	fmt.Printf("addr:%p len:%v content:%v\n", s, len(s), s);  // addr:0xc00009a050       len:0 content:[]

	t = append(s, 1, 2, 3, 4)

	fmt.Printf("addr:%p len:%v content:%v\n", t, len(t), t); // addr:0xc00009a050       len:4 content:[1 2 3 4]
	fmt.Printf("addr:%p len:%v content:%v\n", s, len(s), s); // addr:0xc00009a050       len:0 content:[]
}

答案:因为此时s的长度是0,所以它读取的数据为空,即使它指向的底层数据是不为空的。你可以在 t = append(s, 1, 2, 3, 4)后面加上 s = s[: 3],并看看效果就清楚了。

参考

Go 切片:用法和本质
实效Go编程(Slice)
深入解析 Go 中 Slice 底层实现