Go 从 0 到精通 · 第 09 课:切片 slice

学习定位:这是整套 Go 教程的第 9 课。
前置要求:已经完成第 8 课,理解数组的声明、初始化、遍历,知道数组是值类型、长度不可变。
本课目标:掌握切片的创建、长度与容量、append、切片截取、底层共享问题,理解切片为什么是 Go 日常开发中最常用的数据结构。


1. 本课你要解决的核心问题

第 8 课学的数组有两个硬伤:

  1. 长度固定,不能增减元素
  2. 长度是类型的一部分,函数签名绑死了长度

切片(slice)就是为了解决这些问题而设计的。它是 Go 中使用频率最高的数据结构。

你需要搞明白以下问题:

  • 切片和数组的区别到底是什么
  • 切片的长度和容量分别指什么
  • append 怎么给切片加元素
  • 切片截取后,修改一个会影响另一个吗
  • 切片的底层机制是什么

学完这一课,切片将替代数组成为你处理数据集合的默认选择。


2. 切片 vs 数组:一眼看懂区别

特性 数组 切片
长度 固定 动态可变
声明 [5]int []int
类型 长度是类型的一部分 长度不是类型的一部分
添加元素 不支持 append
函数传参 每次绑死长度 灵活,任意长度

一句话总结:

数组是固定大小的容器,切片是动态大小的视图。


3. 切片的创建

3.1 用 []T{} 字面量创建

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {
s := []int{1, 2, 3, 4, 5}

fmt.Println(s) // [1 2 3 4 5]
fmt.Println(len(s)) // 5
fmt.Printf("类型:%T\n", s) // []int
}

注意声明方式的区别:

1
2
a := [5]int{1, 2, 3, 4, 5}  // 数组:有长度 [5]
s := []int{1, 2, 3, 4, 5} // 切片:没长度 []

[]int 没有指定长度,所以是切片。

3.2 用 make 创建

make 是 Go 的内置函数,专门用于创建切片、map 和 channel:

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
// make([]类型, 长度, 容量)
s := make([]int, 3, 10)

fmt.Println(s) // [0 0 0]
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 10
}
  • 第二个参数 3:初始长度,元素都是零值
  • 第三个参数 10:容量(可省略,省略时容量等于长度)

3.3 声明但不初始化

1
2
3
4
var s []int  // s 是 nil 切片

fmt.Println(s == nil) // true
fmt.Println(len(s)) // 0

nil 切片长度为 0,可以安全地传给 appendappend 会自动创建新底层数组)。


4. 长度 len 与容量 cap

这是切片最重要也最容易混淆的概念。

4.1 概念解释

  • 长度 len:切片当前包含的元素个数
  • 容量 cap:从切片的第一个元素开始,到底层数组末尾的元素个数

打个比方:

1
2
3
4
5
底层数组: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

切片起点
├──── len=3 ────┤
├────────── cap=10 ──────────┤

切片看到 3 个元素(长度),但底层数组还有 7 个位置没用(容量 = 3 + 7 = 10)。

4.2 实际例子

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
// 从数组创建切片
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s := arr[2:5]

fmt.Println("s =", s) // [2 3 4]
fmt.Println("len =", len(s)) // 3
fmt.Println("cap =", cap(s)) // 8(从下标 2 到数组末尾,共 8 个位置)
}

容量的实际意义:容量决定了在不重新分配内存的情况下,append 最多能加多少元素。


5. append:给切片添加元素

5.1 基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func main() {
s := []int{1, 2, 3}
fmt.Println("原始:", s)

s = append(s, 4)
fmt.Println("加一个:", s)

s = append(s, 5, 6, 7)
fmt.Println("加多个:", s)
}

输出:

1
2
3
原始: [1 2 3]
加一个: [1 2 3 4]
加多个: [1 2 3 4 5 6 7]

重要append 返回一个新切片,你必须用返回值重新赋给变量。

1
2
3
4
5
// 错误写法:丢弃了返回值
append(s, 4) // s 没有变化

// 正确写法
s = append(s, 4)

5.2 追加另一个切片

... 展开一个切片,追加到另一个切片:

1
2
3
4
a := []int{1, 2}
b := []int{3, 4, 5}
a = append(a, b...)
fmt.Println(a) // [1 2 3 4 5]

这和第 6 课学的可变参数 ... 是同一个道理。

5.3 append 何时扩容

append 发现容量不够时,它会:

  1. 分配一块更大的内存
  2. 把旧数据复制过去
  3. 添加新元素
  4. 返回指向新内存的切片

扩容策略大致是:

  • 容量 < 1024:翻倍
  • 容量 >= 1024:增长约 25%
1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
s := make([]int, 0, 2)

for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Printf("i=%d len=%d cap=%d\n", i, len(s), cap(s))
}
}

输出(具体值可能因 Go 版本略有不同):

1
2
3
4
5
6
7
8
9
10
i=0 len=1 cap=2
i=1 len=2 cap=2
i=2 len=3 cap=4
i=3 len=4 cap=4
i=4 len=5 cap=8
i=5 len=6 cap=8
i=6 len=7 cap=8
i=7 len=8 cap=8
i=8 len=9 cap=16
i=9 len=10 cap=16

每次容量不够就翻倍。


6. 切片截取(Slicing)

6.1 基本语法

1
s[low:high]

从索引 low(包含)到 high(不包含)截取。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

fmt.Println(s[2:5]) // [2 3 4]
fmt.Println(s[:3]) // [0 1 2]
fmt.Println(s[7:]) // [7 8 9]
fmt.Println(s[:]) // [0 1 2 3 4 5 6 7 8 9]
}

省略规则:

  • s[:n] 等价于 s[0:n]
  • s[n:] 等价于 s[n:len(s)]
  • s[:] 等价于 s[0:len(s)]

6.2 截取会共享底层数组

这是切片最需要小心的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
original := []int{0, 1, 2, 3, 4}
sub := original[1:4]

sub[0] = 999

fmt.Println("original:", original) // [0 999 2 3 4]
fmt.Println("sub:", sub) // [999 2 3]
}

suboriginal 共享同一个底层数组。修改 sub 会影响 original

截取不会复制数据,它只是创建了一个新的"窗口",指向同一块内存的不同位置。

6.3 用 copy 真正复制

如果你不想要共享,用 copy 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

func main() {
original := []int{0, 1, 2, 3, 4}

// 先创建一个新切片
dup := make([]int, len(original))
copy(dup, original)

dup[0] = 999

fmt.Println("original:", original) // [0 1 2 3 4]
fmt.Println("dup:", dup) // [999 1 2 3 4]
}

copy(dst, src)src 的元素复制到 dst。两个切片不再共享底层数组。


7. 切片作为函数参数

7.1 切片传参是"引用语义"的近似

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func modify(s []int) {
s[0] = 999
}

func main() {
arr := []int{1, 2, 3}
modify(arr)
fmt.Println(arr) // [999 2 3]
}

函数内部修改了切片的元素,外部也变了。

但这并不是真正的"引用传递"——Go 仍然是值传递。传给函数的是切片头(header)的副本,但副本和原切片共享同一个底层数组,所以通过下标修改元素会影响原数据。

7.2 append 在函数内不会影响外部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func addElement(s []int) {
s = append(s, 999)
fmt.Println("函数内:", s)
}

func main() {
arr := []int{1, 2, 3}
addElement(arr)
fmt.Println("函数外:", arr)
}

输出:

1
2
函数内: [1 2 3 999]
函数外: [1 2 3]

原因:append 可能返回一个新的切片(扩容时),但这个新切片只赋给了函数内的局部变量 s,不影响外部。

如果想让函数通过 append 修改外部切片,需要传指针 *[]int,或者让函数返回新切片。

7.3 推荐做法:返回新切片

1
2
3
4
5
6
7
8
9
func addElement(s []int) []int {
return append(s, 999)
}

func main() {
arr := []int{1, 2, 3}
arr = addElement(arr)
fmt.Println(arr) // [1 2 3 999]
}

这也是 Go 标准库和社区的常见做法。


8. 删除切片中的元素

Go 没有内置的删除操作,但可以通过截取 + append 实现。

8.1 删除指定索引的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
s := []int{0, 1, 2, 3, 4}

// 删除下标 2 的元素
i := 2
s = append(s[:i], s[i+1:]...)

fmt.Println(s) // [0 1 3 4]
}

原理:把下标 2 前面的部分 s[:2] 和下标 2 后面的部分 s[3:] 拼接起来。

8.2 注意:这会修改底层数组

上面的操作会改变底层数组的元素。如果你不希望影响原数据,应该先 copy 一份。

8.3 删除第一个和最后一个元素

1
2
3
4
5
6
7
s := []int{0, 1, 2, 3, 4}

// 删除第一个
s = s[1:] // [1 2 3 4]

// 删除最后一个
s = s[:len(s)-1] // [0 1 2 3]

9. nil 切片 vs 空切片

1
2
3
4
5
6
7
8
9
var a []int        // nil 切片
b := []int{} // 空切片
c := make([]int, 0) // 空切片

fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(c == nil) // false
fmt.Println(len(a)) // 0
fmt.Println(len(b)) // 0
  • nil 切片:没有底层数组,len 为 0,== niltrue
  • 空切片:有底层数组(长度为 0),len 为 0,== nilfalse

实际使用中,两者的行为几乎一样:都能 append、都能 for range(不执行循环体)。但 nil 切片更节省内存。

实践建议:不要区分它们的语义差异。大多数场景下,nil 切片和空切片可以互换使用。


10. 一段综合示例

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
35
36
37
38
package main

import "fmt"

func main() {
// 创建切片
nums := []int{10, 20, 30}
fmt.Println("初始:", nums, "len=", len(nums), "cap=", cap(nums))

// append 添加元素
nums = append(nums, 40, 50)
fmt.Println("append 后:", nums, "len=", len(nums), "cap=", cap(nums))

// 截取
sub := nums[1:4]
fmt.Println("截取 [1:4]:", sub)

// 截取会共享底层数组
sub[0] = 999
fmt.Println("修改 sub 后 nums:", nums)

// 真正复制
dup := make([]int, len(nums))
copy(dup, nums)
dup[0] = 111
fmt.Println("copy 后修改 dup,nums 不变:", nums)

// 删除元素
nums = append(nums[:2], nums[3:]...)
fmt.Println("删除下标 2:", nums)

// 用 make 创建
scores := make([]int, 0, 5)
for i := 0; i < 5; i++ {
scores = append(scores, 80+i*2)
}
fmt.Println("成绩:", scores)
}

11. 常见坑总结

11.1 忘记接收 append 的返回值

1
2
3
s := []int{1, 2}
append(s, 3) // s 没变!
s = append(s, 3) // 正确

append 返回新切片,不赋值就等于白做。

11.2 截取后修改影响原切片

1
2
3
4
a := []int{1, 2, 3, 4, 5}
b := a[1:3]
b[0] = 999
// a 变成 [1, 999, 3, 4, 5]

如果不想共享,用 copy 创建独立副本。

11.3 在循环中 append 可能导致意外

1
2
3
4
5
6
7
8
slices := [][]int{}
data := []int{1, 2, 3}

for i := 0; i < 3; i++ {
data[0] = i
slices = append(slices, data[:]) // 每次都共享底层数组!
}
// slices 里的三个切片其实都指向同一块内存

解决:每次 appendcopy 一份。

11.4 切片传给函数后 append 不影响外部

1
2
3
func add(s []int) {
s = append(s, 999) // 只改了局部变量 s
}

要影响外部,要么返回新切片,要么传 *[]int

11.5 把切片和数组搞混

1
2
3
4
5
a := [3]int{1, 2, 3}   // 数组
s := []int{1, 2, 3} // 切片

fmt.Printf("%T\n", a) // [3]int
fmt.Printf("%T\n", s) // []int

看中括号里有没有数字:有数字是数组,没数字是切片。


12. 本课练习

练习 1:动态构建切片

要求:

  • 从一个空切片开始
  • for 循环和 append 添加 1 到 10
  • 打印结果

练习 2:切片截取与共享

要求:

  • 创建切片 a := []int{0, 1, 2, 3, 4, 5}
  • 截取 b := a[2:4]
  • 修改 b[0],打印 ab
  • 解释为什么 a 也变了

练习 3:删除切片元素

要求:

  • 实现函数 removeAt(s []int, index int) []int,删除指定下标的元素
  • 不要越界,越界时返回原切片
  • 测试删除第一个、中间、最后一个元素

练习 4:切片去重

要求:

  • 实现函数 unique(s []int) []int
  • 返回去重后的新切片(保持顺序)
  • 例如 [1, 2, 2, 3, 1] 返回 [1, 2, 3]

练习 5:观察 append 扩容

要求:

  • 从容量为 1 的切片开始
  • 循环 append 20 个元素
  • 每次打印 lencap
  • 总结容量变化规律

练习 6:用 copy 实现独立副本

要求:

  • 创建切片 original
  • copy 创建独立副本 dup
  • 修改 dup,验证 original 不受影响

13. 自测题

13.1 概念题

  1. 切片和数组的核心区别是什么?
  2. 切片的 lencap 分别指什么?
  3. append 返回什么?为什么必须接收返回值?
  4. 切片截取后,新切片和原切片共享什么?
  5. 怎么创建不共享底层数组的切片副本?
  6. nil 切片和空切片有什么区别?
  7. 切片传给函数后,在函数里 append 会影响外部吗?为什么?
  8. make([]int, 3, 10) 创建的切片,lencap 分别是多少?
  9. []int{1, 2, 3} 是数组还是切片?怎么判断?
  10. 怎么用 append 删除切片中指定下标的元素?

14. 本课总结

这一课你学到了 Go 中使用最广泛的数据结构——切片。

你现在应该已经理解:

  • 切片是动态大小的序列,底层基于数组
  • []T{} 字面量或 make([]T, len, cap) 创建
  • len 是当前元素个数,cap 是底层数组的可用空间
  • append 添加元素,容量不够时自动扩容(近似翻倍)
  • 截取 s[low:high] 不复制数据,新切片和原切片共享底层数组
  • copy(dst, src) 创建独立副本
  • 切片传参是值传递(切片头的副本),但共享底层数组
  • append 在函数内不影响外部切片(可能返回新切片)

15. 下一课预告

下一课我们学习:映射 map

会重点讲:

  • map 是什么,和切片有什么区别
  • map 的创建、增删改查
  • 怎么判断键是否存在
  • map 的遍历
  • map 是引用类型——函数内外共享

学完下一课,你就能用键值对来组织和查找数据了。