与数组最大的不同,切片支持动态扩容

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

切片的内部结构包含地址、长度和容量。切片一般用于快速地操作一块数据集合。

定义

语法

var name []type
var sl []int

与数组的区别就是不需要指定元素的数量

例子:

var sl0 []int     // 定义
var sl1 = []int{} // 定义并初始化
var sl2 = []int{1, 2}
var sl3 = []int{1, 2}


fmt.Printf("sl0: %v\n", sl0)
fmt.Printf("sl1: %v\n", sl1)
fmt.Printf("sl2: %v\n", sl2)
fmt.Printf("sl3: %v\n", sl3)

fmt.Printf("(sl0 == nil): %v\n", (sl0 == nil)) // true
fmt.Printf("(sl1 == nil): %v\n", (sl1 == nil)) // false
// (sl2 == sl3) 错误,切片之间不能比较,只能和 nil 比较

注意:切片之间不能比较,只能和 nil 比较

长度和容量

与数组不同,切片的长度和数量往往不一致,就像 std::vector 一样,这是切片的自动扩容机制决定的

可以使用 cap() 函数获取切片的容量:

var sl = []int{1, 2}
c := cap(sl)

切片表达式

切片表达式从字符串、数组、指向数组或切片的指针构造子字符串或切片。它有两种变体:

  • 指定 low 和 high 两个索引界限值的简单的形式,a[low:high]
  • 除了 low 和 high 索引界限值外还指定容量的完整的形式,a[low,high,cap]

切片的 底层就是一个数组,所以我们可以基于数组通过切片表达式得到切片:

var arr = [5]int{1, 2, 3, 4, 5}

sl := arr[1:3] // [low, high)
fmt.Printf("sl: %v\n", sl)           // 2 3
fmt.Printf("cap(sl): %v\n", cap(sl)) // 4

注意:不包含右边界

为什么容量是 4?这个会在后面讲「切片的本质」时提到

此外,还可以对一个切片再切片:

var arr = [5]int{1, 2, 3, 4, 5}

sl := arr[1:3] // [low, high), [2 3]
sl1 := sl[0:4] // 对 sl 切片,[2 3 4 5]

fmt.Printf("sl: %v\n", sl)
fmt.Printf("sl1: %v\n", sl1)
fmt.Printf("cap(sl): %v\n", cap(sl)) // 4
fmt.Printf("cap(sl1): %v\n", cap(sl1)) // 4

注意,再次分片时:high 的上限是切片的容量,对于上面的例子,即 cap(sl) == 4

完整的切片表达式

数组、指向数组的指针、切片支持完整切片表达式:

var arr = [4]int{1, 2, 3, 4} // 数组
sl := arr[0:4]               // 切片

sl1 := arr[0:1:4] // [1], cap = 4
sl2 := sl[0:1:4]  // [1], cap = 4

fmt.Printf("sl1: %v\n", sl1)
fmt.Printf("cap(sl1): %v\n", cap(sl1))
fmt.Printf("sl2: %v\n", sl2)
fmt.Printf("cap(sl2): %v\n", cap(sl2))

而 string 不支持:

var s string = "123"
sl3 := s[0:1]   // 正确
sl4 := s[0:1:2] // 错误,字符串不支持完整切片表达式

完整切片表达式必须满足 0 <= low <= high <= max <= cap(a),a 为底层数组或切片

使用 make 函数构造切片

除了基于数组构造切片外,还可以使用 make 函数动态构造切片:

make([]T, size, cap)
sl1 := make([]int, 4, 16) // size = 4,cap = 16

切片的本质

切片的本质实际上就是把底层数组做了一层封装,包含三个属性:

  • 底层数组的指针
  • size
  • cap

例如,假设有一个数组 a := [8]int{0, 1, 2, 3, 4, 5, 6, 7},切片 s1 := a[0:5]

image

切片 s2 := a[3:6]

image

这样应该可以理解为什么容量分别为 8、5 了

判断切片是否为空

判断切片是否为空的唯一方式:len(s) == 0

不能 使用 s == nil 判断:

sl := []int{}
fmt.Printf("(sl == nil): %v\n", (sl == nil))       // false
fmt.Printf("(len(sl) == 0): %v\n", (len(sl) == 0)) // true
// 但是 sl 实际上确实为空

此外,切片之间也 不能 相互比较:

image

如果要比较两个切片,可以遍历两个切片来比较

切片的拷贝

切片的拷贝可以理解为「浅拷贝」,也就是说:拷贝后的切片与源切片 共享同一个底层数组

这也许会产生意想不到的结果:

sl0 := []int{1, 2, 3, 4}
sl1 := sl0 // 引用赋值

fmt.Printf("sl1[0]: %v\n", sl1[0]) // 1
sl0[0] = 666
fmt.Printf("sl1[0]: %v\n", sl1[0]) // 666

因此,需要考虑业务需求,来选择是否需要深拷贝

深拷贝切片需要用到 copy 函数:

copy(destSlice, srcSlice []T)

对于上面的示例,可以修改成:

sl0 := []int{1, 2, 3, 4}
sl1 := make([]int, 4) // size == cap == 4,可以访问 sl1[0]
copy(sl1, sl0)

fmt.Printf("sl1[0]: %v\n", sl1[0]) // 1
sl0[0] = 666
fmt.Printf("sl1[0]: %v\n", sl1[0]) // 1

注意,当目的切片没有足够空间(这里是 size,不是 cap)时,copy 不会拷贝后续的部分,并不会抛异常,这与 C++ 是不同的

append 方法

append 方法可以用于向切片添加元素:

func append(slice []Type, elems ...Type) []Type

可以一次添加一个元素或多个元素,也可以直接添加一个切片:

sl0 := []int{5, 6, 7, 8}
// sl1 := []int{} // 没有必要初始化
var sl1 []int // 直接声明即可
sl1 = append(sl1, 1)
sl1 = append(sl1, 2, 3, 4)
sl1 = append(sl1, sl0...) // 注意 ...

fmt.Printf("sl1: %v\n", sl1)

为啥每次都要重新赋值,直接写 append(sl1, 1) 不行吗?

主要原因还是因为切片的扩容机制决定的

append 时,若底层数组的 cap 不足以容纳新的元素时,切片会按照一定「策略」扩容,这会改变切片指向的底层数组

由于切片的扩容机制,append 实际上每次返回的是一个新的切片(底层数组可能相同)

因此,每次 append 都要用原变量来接受 append 的返回值

为什么 nil 切片可以直接 append?

因为 nil 切片可以通过 append 来扩容(申请一块内存),最终变成一个正常的 slice

扩容策略

来看一下 Go 切片扩容的源码:

newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
    newcap = newLen
} else {
    const threshold = 256
    if oldCap < threshold {
        newcap = doublecap
    } else {
        // Check 0 < newcap to detect overflow
        // and prevent an infinite loop.
        for 0 < newcap && newcap < newLen {
            // 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) / 4
        }
        // Set newcap to the requested cap when
        // the newcap calculation overflowed.
        if newcap <= 0 {
            newcap = newLen
        }
    }
}
  • 先假设扩容后的 newcap 为 doublecap,即原 cap 的 2 倍
  • 如果 append 后,切片的长度比 doublecap 还大,说明扩容 2 倍不能满足需求,直接使用 append 后的切片长度作为 newcap
    sl0 := []int{1, 2, 3}
    sl0 = append(sl0, 4, 5, 6, 7, 8, 9, 10)
    fmt.Printf("len(sl0): %v\n", len(sl0)) // 10
    fmt.Printf("cap(sl0): %v\n", cap(sl0)) // 10
    
  • 否则,说明扩容 2 倍能满足需求,但不能直接使用 doublecap,因为要减少无效内存占用(如果本身的容量已经很大,扩容 2 倍很有可能浪费内存),判断原容量与 threshold 的大小关系:
    • 如果比 threshold 小,说明原来的 cap 还比较小,可以接受扩容 2 倍带来的潜在内存浪费
    • 否则,计算 newcap,以平滑地过渡从 2 倍增长到 1.25 倍增长的情况。循环将 newcap 增加到一个新值,该值介于两者之间。
    • 如果计算过程中,newcap <= 0,说明计算溢出了,为了安全起见,将 newcap 设置为新的长度(newLen)。

此外,切片扩容策略还有可能与切片元素类型有关,例如,int 和 string 类型的处理方式就不一样

2024.3.13 补充:

切片扩容还涉及到了 内存对齐,主要是为了保证 CPU 的访问性能

  // Specialize for common values of et.Size.
	// For 1 we don't need any division/multiplication.
	// For goarch.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
	// For powers of 2, use a variable shift.
	switch {
	case et.Size_ == 1:

	case et.Size_ == goarch.PtrSize:

	case isPowerOfTwo(et.Size_):

	default:

	}

因此,扩容后的实际容量还与与切片元素类型有关

测试

示例 0

func test0() {
	a1 := [7]int{1, 2, 3, 4, 5, 6, 7}
	fmt.Printf("a1: %v (len: %d, cap: %d)\n",
		a1, len(a1), cap(a1))
	s9 := a1[1:4]
	fmt.Printf("s9: %v (len: %d, cap: %d)\n",
		s9, len(s9), cap(s9))
	for i := 1; i <= 5; i++ {
		s9 = append(s9, i)
		fmt.Printf("s9(%d): %v (len: %d, cap: %d)\n",
			i, s9, len(s9), cap(s9))
	}
	fmt.Printf("a1: %v (len: %d, cap: %d)\n",
		a1, len(a1), cap(a1)) // 想清楚这一行的输出
	fmt.Println()
}

输出:

a1: [1 2 3 4 5 6 7] (len: 7, cap: 7)
s9: [2 3 4] (len: 3, cap: 6)
s9(1): [2 3 4 1] (len: 4, cap: 6) # 在这里对 a1 修改
s9(2): [2 3 4 1 2] (len: 5, cap: 6) # 在这里对 a1 修改
s9(3): [2 3 4 1 2 3] (len: 6, cap: 6) # 在这里对 a1 修改
s9(4): [2 3 4 1 2 3 4] (len: 7, cap: 12)
s9(5): [2 3 4 1 2 3 4 5] (len: 8, cap: 12)
a1: [1 2 3 4 1 2 3] (len: 7, cap: 7) # s9 的底层数组就是 a1,对 s9 的修改会反映到 a1 上

因此,在必要的时候使用深拷贝

示例 1

func test2()  {
	// 示例1。
	s6 := make([]int, 0)
	fmt.Printf("The capacity of s6: %d\n", cap(s6))
	for i := 1; i <= 5; i++ {
		s6 = append(s6, i)
		fmt.Printf("s6(%d): len: %d, cap: %d\n", i, len(s6), cap(s6))
	}
	fmt.Println()

	// 示例2。
	s7 := make([]int, 1024)
	fmt.Printf("The capacity of s7: %d\n", cap(s7))
	s7e1 := append(s7, make([]int, 200)...)
	fmt.Printf("s7e1: len: %d, cap: %d\n", len(s7e1), cap(s7e1))
	s7e2 := append(s7, make([]int, 400)...)
	fmt.Printf("s7e2: len: %d, cap: %d\n", len(s7e2), cap(s7e2))
	s7e3 := append(s7, make([]int, 600)...)
	fmt.Printf("s7e3: len: %d, cap: %d\n", len(s7e3), cap(s7e3))
	fmt.Println()

	// 示例3。
	s8 := make([]int, 10)
	fmt.Printf("The capacity of s8: %d\n", cap(s8))
	s8a := append(s8, make([]int, 11)...)
	fmt.Printf("s8a: len: %d, cap: %d\n", len(s8a), cap(s8a))
	s8b := append(s8a, make([]int, 23)...)
	fmt.Printf("s8b: len: %d, cap: %d\n", len(s8b), cap(s8b))
	s8c := append(s8b, make([]int, 45)...)
	fmt.Printf("s8c: len: %d, cap: %d\n", len(s8c), cap(s8c))
}

输出:

The capacity of s6: 0
s6(1): len: 1, cap: 1
s6(2): len: 2, cap: 2
s6(3): len: 3, cap: 4
s6(4): len: 4, cap: 4
s6(5): len: 5, cap: 8

The capacity of s7: 1024
s7e1: len: 1224, cap: 1536 # 理论计算结果为 1024 + (1024 + 256 * 3) / 4 = 1472,但却是 1024 的 1.5 倍,这也是内存对齐的结果
s7e2: len: 1424, cap: 1536
s7e3: len: 1624, cap: 2048

The capacity of s8: 10
s8a: len: 21, cap: 22
s8b: len: 44, cap: 44
s8c: len: 89, cap: 96 # 很好的体现了扩容策略中的「内存对齐」

删除切片的元素

Go 没有直接提供删除切片元素的方法,但是可以使用 append 来达到相同效果:

sl0 := []int{1, 2, 3, 4, 5, 6}

sl0 = append(sl0[0:1], sl0[2:]...) // 删除 idx = 1 的元素

fmt.Printf("sl0: %v\n", sl0) // 1 3 4 5 6

传 slice 和 slice 指针有什么区别

Go 所有的函数传递,都是 值传递,没有引用传递

因此,在 Go 中:

  • 传一个对象本身,在函数调用者看来,是不希望对象被函数改变的
  • 传一个对象的指针,在函数调用者看来,是允许对象被函数改变的

对于 slice,也是适用这个规则,因为 slice 实际上是一个复合类型

示例 0

func myAppend(s []int) {
	s = append(s, 100)
}

func main() {
	s := []int{1, 1, 1}
	myAppend(s)
	fmt.Printf("s: %v\n", s)
}

输出:

s: [1 1 1]

可以看到,myAppend 对 slice 的改变,并没有反映到原 slice

正确的写法有两种:

// 返回修改后的对象
func myAppend(s []int) []int {
	s = append(s, 100)
	return s
}

// 直接传递指针
func myAppendPtr(s *[]int) {
	*s = append(*s, 100)
}

func main() {
	s := []int{1, 1, 1}
	s = myAppend(s)

	fmt.Println(s)

	myAppendPtr(&s)
	fmt.Println(s)
}

当然,你可能对接下来的示例有些疑惑:

示例 1

// 传值
func foo(s []int)  {
	s[len(s) - 1] = 114514
}

func main()  {
	s := []int{1, 2, 3}
	foo(s)
	fmt.Printf("s: %v\n", s)
}

输出:

s: [1 2 114514]

可以发现,即使传递的是 slice 副本,还是被修改了,似乎违背了 Go 只有值传递的事实,这是为什么?

实际上,虽然传递的是 slice 的副本,但是 底层数组还是同一个,通过 [] 运算符修改,实际上操作的还是底层数组

例如:

type Foo struct {
	A *int
}

func Bar(f Foo) {
    *f.A = 114514
}

func main()  {
	f := Foo{A: new(int)}
	*f.A = 1

	Bar(f) // 传的是 f 的副本

	fmt.Printf("f.A: %v\n", *f.A) // 114514
}

这个示例的本质与示例 1 是一致的

因此,当直接用切片作为函数参数时,可以改变切片的元素,不能改变切片本身

如果想要在一个函数中修改 slice 本身,那么建议 传递 slice 的指针