本文主要介绍切片(slice)和它的基本使用。

因为数组的长度是固定的并且数组长度属于类型的一部分,所以数组有很多的局限性。在实际应用中切片更加多。

切片(Slice)是一个拥有相同类型元素的可变长度的序列。它是基于数组类型做的一层封装。它非常灵活,支持自动扩容。

切片是一个引用类型,它的内部结构包含地址长度容量。切片一般用于快速地操作一块数据集合。

1
2
3
4
5
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

指针指向切片元素对应的底层数组元素的地址。长度对应切片中元素的数目,长度不能超过容量。容量一般是从切片的开始位置到底层数据的结尾位置的长度。

切片的初始化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
	// 声明切片类型
	var a []string              //声明一个字符串切片
	var b = []int{}             //声明一个整型切片并初始化
	var c = []bool{false, true} //声明一个布尔切片并初始化
	var d = []bool{false, true} //声明一个布尔切片并初始化
	fmt.Println(a)              //[]
	fmt.Println(b)              //[]
	fmt.Println(c)              //[false true]
	fmt.Println(a == nil)       //true
	fmt.Println(b == nil)       //false
	fmt.Println(c == nil)       //false
	// fmt.Println(c == d)   //切片是引用类型,不支持直接比较,只能和nil比较
}

切片拥有自己的长度和容量,我们可以通过使用内置的len()函数求长度,使用内置的cap()函数求切片的容量。

切片表达式

切片表达式从字符串数组指向数组或切片的指针中构造子字符串或切片。

它有两种变体:一种指定low和high两个索引界限值的简单的形式,另一种是除了low和high索引界限值外还指定容量的完整的形式。

简单切片表达式

切片的底层就是一个数组,所以我们可以基于数组通过切片表达式得到切片。 切片表达式中的lowhigh表示一个索引范围(左包含,右不包含)。

1
2
3
4
5
6
7
nums := []int{1,2,3,4,5,6,7,8}
// 从下标 2 一直到下标 4,但是不包括 4
nums1 := nums[2:4]
// 从下标 0 一直到下标 3,但是不包括 3
nums2 := nums[:3]
// 从下标 3 一直到结尾
nums3 := nums[3:]

用这种切片表达式要考虑索引合法的问题,否则会出现索引越界(out of range)的问题。

索引的上限是cap(s)而不是len(s)

完整切片表达式

对于数组,指向数组的指针,或切片(注意不能是字符串)支持完整切片表达式:

1
a[low : high : max]

它会将得到的结果切片的容量设置为max-low。在完整切片表达式中只有第一个索引值(low)可以省略;它默认为0。

1
2
3
4
5
func main() {
	a := [5]int{1, 2, 3, 4, 5}
	t := a[1:3:5]
	fmt.Printf("t:%v len(t):%v cap(t):%v\n", t, len(t), cap(t))
}

输出结果:

1
t:[2 3] len(t):2 cap(t):4

使用 make() 构造切片

如果需要动态的创建一个切片,我们就需要使用内置的make()函数。

1
make([]T, size, cap)

其中:

  • T:切片的元素类型
  • size:切片中元素的数量
  • cap:切片的容量
1
2
3
4
5
6
func main() {
	a := make([]int, 2, 10)
	fmt.Println(a)      //[0 0]
	fmt.Println(len(a)) //2
	fmt.Println(cap(a)) //10
}

切片值复制与数据引用

数组的复制是值复制,如下例中,对于数组a的副本c的修改不会影响到数组a。然而,对于切片b的副本d的修改会影响到原来的切片b。这说明切片的副本与原始切片共用一个内存空间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
a := [4]int{1,2,3,4}
b := []int{100,200,300}
c := a
d := b
c[1] = 200
d[0] = 1

结果a={1,2,3,4} c={1, 200, 3, 4}
b={1,200,300} d{1,200,300}

在Go语言中,切片的复制其实也是值复制,但这里的值复制指对于运行时SliceHeader结构的复制。

底层指针仍然指向相同的底层数据的数组地址,因此可以理解为数据进行了引用传递。切片的这一特性使得即便切片中有大量数据,在复制时的成本也比较小,这与数组有显著的不同。

扩容

Go语言的内建函数append()可以为切片动态添加元素。 可以一次添加一个元素,可以添加多个元素,也可以添加另一个切片中的元素(后面加…)。

1
2
3
4
5
6
7
func main(){
	var s []int
	s = append(s, 1)        // [1]
	s = append(s, 2, 3, 4)  // [1 2 3 4]
	s2 := []int{5, 6, 7}  
	s = append(s, s2...)    // [1 2 3 4 5 6 7]
}

注意:通过var声明的零值切片可以在append()函数直接使用,无需初始化。

1
2
var s []int
s = append(s, 1, 2, 3)

没有必要像下面的代码一样初始化一个切片再传入append()函数使用,

1
2
3
4
5
s := []int{}  // 没有必要初始化
s = append(s, 1, 2, 3)

var s = make([]int)  // 没有必要初始化
s = append(s, 1, 2, 3)

每个切片会指向一个底层数组,这个数组的容量够用就添加新增元素。当底层数组不能容纳新增的元素时,切片就会自动按照一定的策略进行“扩容”,此时该切片指向的底层数组就会更换。“扩容”操作往往发生在append()函数调用时,所以我们通常都需要用原变量接收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
// nextslicecap computes the next appropriate slice length.
func nextslicecap(newLen, oldCap int) int {
	newcap := oldCap
	doublecap := newcap + newcap
	if newLen > doublecap {
		return newLen
	}

	const threshold = 256
	if oldCap < threshold {
		return doublecap
	}
	for {
		// Transition from growing 2x for small slices
		// to growing 1.25x for large slices. This formula
		// gives a smooth-ish transition between the two.
		newcap += (newcap + 3*threshold) >> 2

		// We need to check `newcap >= newLen` and whether `newcap` overflowed.
		// newLen is guaranteed to be larger than zero, hence
		// when newcap overflows then `uint(newcap) > uint(newLen)`.
		// This allows to check for both with the same comparison.
		if uint(newcap) >= uint(newLen) {
			break
		}
	}

	// Set newcap to the requested cap when
	// the newcap calculation overflowed.
	if newcap <= 0 {
		return newLen
	}
	return newcap
}

初始检查:

  • 如果新长度大于当前容量的两倍,直接返回新长度。

    这是为了处理需要大幅增长的情况,避免多次小幅增长导致的性能损失。

小切片的处理:

  • 定义一个阈值 threshold=256
  • 如果当前容量小于这个阈值,新容量直接翻倍。

大切片的处理:

  • 新容量增长公式:newcap += (newcap + 3*threshold) >> 2
  • 实现了从翻倍增长到增长 1.25 倍的平滑过渡。

使用copy() 复制切片

Go语言内建的copy()函数可以迅速地将一个切片的数据复制到另外一个切片空间中,copy()函数的使用格式如下:

1
copy(destSlice, srcSlice []T)
  • srcSlice: 数据来源切片
  • destSlice: 目标切片
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
	// copy()复制切片
	a := []int{1, 2, 3, 4, 5}
	c := make([]int, 5, 5)
	copy(c, a)     //使用copy()函数将切片a中的元素复制到切片c
	fmt.Println(a) //[1 2 3 4 5]
	fmt.Println(c) //[1 2 3 4 5]
	c[0] = 1000
	fmt.Println(a) //[1 2 3 4 5]
	fmt.Println(c) //[1000 2 3 4 5]
}

从切片上删除元素

如果要删除切片中间的某一段或某一个元素,可以借助切片的截取特性,通过截取删除元素前后的切片数组,再使用append函数拼接的方式实现。这种处理方式比较优雅,并且效率很高,因为它不会申请额外的内存空间。

1
2
3
4
5
6
7
func main() {
	// 从切片中删除元素
	a := []int{30, 31, 32, 33, 34, 35, 36, 37}
	// 要删除索引为2的元素
	a = append(a[:2], a[3:]...)
	fmt.Println(a) //[30 31 33 34 35 36 37]
}

小结

切片是Go语言提供的重要数据类型,也是Gopher日常编码中最常使用的类型之一。切片是数组的描述符,在大多数场合替代了数组,并减少了数组指针作为函数参数的使用。

append在切片上的运用让切片类型部分支持了“零值可用”的理念,并且append对切片的动态扩容将Gopher从手工管理底层存储的工作中解放了出来。

在可以预估出元素容量的前提下,使用cap参数创建切片可以提升append的平均操作性能,减少或消除因动态扩容带来的性能损耗。


零值可用理念:

零值可用"(Zero Value Usable)是Go语言设计中的一个重要理念。这个概念意味着一个类型的零值(即变量被声明但未显式初始化时的默认值)应该是有意义且可以直接使用的。让我解释一下这个理念的核心内容和优点:

  1. 零值的定义:
    • 在Go中,当你声明一个变量但不赋初值时,它会被自动设置为该类型的"零值"。
    • 例如,int 的零值是 0,string 的零值是空字符串,指针的零值是 nil。
  2. 零值可用的含义:
    • 这个理念要求类型的设计者考虑使零值具有实际的、合理的意义。
    • 零值应该代表该类型的一个有效状态,可以安全地使用,而不会导致错误或异常行为。
  3. 常见的零值可用例子:
    • sync.Mutex:零值就是一个未锁定的互斥锁,可以直接使用。
    • bytes.Buffer:零值是一个空的缓冲区,可以直接开始写入。
    • 切片(slice):零值是 nil,可以直接用于 append 操作。
  4. 优点:
    • 简化初始化:减少了显式初始化的需求,使代码更简洁。
    • 提高安全性:降低了因忘记初始化而导致的错误风险。
    • 提升性能:避免了不必要的内存分配和初始化开销。
    • 增强可读性:代码更加清晰,减少了样板代码。
  5. 实现零值可用的策略:
    • 谨慎选择数据结构和算法,使得零值状态有意义。
    • 在类型方法中正确处理零值情况。
    • 如果零值不适用,考虑提供便捷的构造函数或工厂方法。