数组与切片:Go 里最常被搞混的一对
如果你问我 Go 语言里最容易让新手困惑的概念是什么,我一定会提到切片(slice)。
很多从 Python、Java 或其他语言转过来的开发者,看到 Go 的切片都会觉得似曾相识——它看起来像 Python 的 list,又像 Java 的 ArrayList。但当你真正开始使用它的时候,会发现它的行为和你想的总是不太一样。
切片之所以让人困惑,是因为它和数组(array)长得很像,但底层的行为完全不同。很多人写了好几个月的 Go 代码,都没真正搞清楚这两者的区别。
今天,我们就来把数组和切片彻底搞明白。我会从最基础的数组开始讲起,然后深入切片的底层原理,让你不仅知道怎么用,还知道为什么。
数组:固定长度的数据容器
什么是数组?
数组是一块连续的内存空间,用来存储固定数量的相同类型的元素。
在 Go 语言中,数组的长度是它类型的一部分。也就是说,[3]int 和 [5]int 是两种不同的类型,不能互相赋值。
var a [3]int // 一个能存 3 个整数的数组
var b [5]int // 一个能存 5 个整数的数组
// a = b // ❌ 编译错误!类型不匹配
数组的声明和初始化
// 方式 1:声明后默认是零值
var a [3]int
fmt.Println(a) // [0 0 0]
// 方式 2:声明时初始化
b := [3]int{1, 2, 3}
fmt.Println(b) // [1 2 3]
// 方式 3:部分初始化,未指定的元素使用零值
c := [5]int{1, 2, 3}
fmt.Println(c) // [1 2 3 0 0]
// 方式 4:用 ... 让编译器自动推断长度
d := [...]int{1, 2, 3, 4, 5}
fmt.Println(d) // [1 2 3 4 5]
// 方式 5:指定索引初始化
e := [5]int{0: 10, 2: 30, 4: 50}
fmt.Println(e) // [10 0 30 0 50]
访问和修改数组元素
fruits := [3]string{"苹果", "香蕉", "橙子"}
// 访问元素(索引从 0 开始)
fmt.Println(fruits[0]) // 苹果
fmt.Println(fruits[2]) // 橙子
// 修改元素
fruits[1] = "葡萄"
fmt.Println(fruits) // [苹果 葡萄 橙子]
// 获取数组长度
fmt.Println(len(fruits)) // 3
⚠️ 踩坑提示:访问越界的索引会导致程序崩溃(panic):
// fmt.Println(fruits[3]) // ❌ panic: runtime error: index out of range
数组是值类型
这是数组最重要的特性之一:数组是值类型。当你把数组赋值给另一个变量,或者作为参数传给函数时,会复制整个数组。
package main
import "fmt"
func modify(arr [3]int) {
arr[0] = 999
fmt.Println("函数内部:", arr)
}
func main() {
a := [3]int{1, 2, 3}
modify(a)
fmt.Println("函数外部:", a)
}
// 输出:
// 函数内部: [999 2 3]
// 函数外部: [1 2 3]
看到了吗?函数内部修改了数组,但外部的数组没有任何变化。这是因为传给函数的是数组的副本。
这个特性在数组很大的时候会产生性能问题——每次传递都要复制一大堆数据。这也是为什么在实际开发中,我们很少直接使用数组,而是使用切片。
多维数组
Go 支持多维数组:
// 3x3 的二维数组(矩阵)
matrix := [3][3]int{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9},
}
fmt.Println(matrix[1][2]) // 6
// 遍历二维数组
for i := 0; i < len(matrix); i++ {
for j := 0; j < len(matrix[i]); j++ {
fmt.Printf("%d ", matrix[i][j])
}
fmt.Println()
}
切片:灵活的数据视图
什么是切片?
切片是对数组的一个连续片段的引用。你可以把切片理解为一个"窗口",通过这个窗口你可以看到数组的一部分(或全部)。
和数组不同,切片的长度是可变的,你可以随时往里面添加元素。这是切片最大的优势。
// 创建一个切片
s := []int{1, 2, 3}
fmt.Println(s) // [1 2 3]
fmt.Println(len(s)) // 3
注意切片和数组的声明语法的细微区别:数组声明要指定长度 [3]int,切片不需要 []int。
切片的底层结构
要真正理解切片,你需要知道它的底层结构。一个切片在内存中由三个部分组成:
┌──────────┬──────┬──────────┐
│ 指针 │ 长度 │ 容量 │
│ (pointer)│(len) │ (cap) │
└──────────┴──────┴──────────┘
│
│ 指向底层数组
▼
┌──────────────────────────┐
│ 底层数组 │
│ [1] [2] [3] [4] [5] │
└──────────────────────────┘
- 指针:指向底层数组中切片的起始位置
- 长度(len):切片当前包含的元素个数
- 容量(cap):从切片的起始位置到底层数组末尾的元素个数
Go 的运行时(runtime)用这样一个结构体来表示切片:
type SliceHeader struct {
Data uintptr // 指向底层数组的指针
Len int // 切片的长度
Cap int // 切片的容量
}
理解这三个字段是理解切片所有行为的关键。
从数组创建切片
你可以用切片语法从数组中"截取"一段作为切片:
arr := [5]int{10, 20, 30, 40, 50}
// 语法:arr[low:high]
// 包含 low,不包含 high
s1 := arr[1:4] // [20 30 40],len=3, cap=4
s2 := arr[0:5] // [10 20 30 40 50],len=5, cap=5
s3 := arr[:3] // [10 20 30],len=3, cap=5
s4 := arr[2:] // [30 40 50],len=3, cap=3
⚠️ 重要:切片是对底层数组的引用。修改切片中的元素会影响底层数组,也会影响其他引用同一数组的切片:
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4] // [20 30 40]
s[0] = 999
fmt.Println(s) // [999 30 40]
fmt.Println(arr) // [10 999 30 40 50] ← 底层数组也被修改了!
用 make 创建切片
如果你不需要一个现成的数组,可以用 make 函数直接创建切片:
// make([]T, len, cap)
s := make([]int, 5, 10) // 长度为 5,容量为 10
fmt.Println(len(s)) // 5
fmt.Println(cap(s)) // 10
fmt.Println(s) // [0 0 0 0 0]
如果你省略容量,容量会等于长度:
s := make([]int, 5) // len=5, cap=5
切片的扩容机制
当你用 append 往切片中添加元素时,如果容量不够用,Go 会自动分配一个更大的底层数组,把原来的数据复制过去。
s := make([]int, 0, 5) // len=0, cap=5
// 添加元素
for i := 1; i <= 10; i++ {
s = append(s, i)
fmt.Printf("添加 %2d 后: len=%2d, cap=%2d, %v\n", i, len(s), cap(s), s)
}
输出:
添加 1 后: len= 1, cap= 5, [1]
添加 2 后: len= 2, cap= 5, [1 2]
添加 3 后: len= 3, cap= 5, [1 2 3]
添加 4 后: len= 4, cap= 5, [1 2 3 4]
添加 5 后: len= 5, cap= 5, [1 2 3 4 5]
添加 6 后: len= 6, cap=10, [1 2 3 4 5 6] ← 容量翻倍
添加 7 后: len= 7, cap=10, [1 2 3 4 5 6 7]
...
添加 10 后: len=10, cap=10, [1 2 3 4 5 6 7 8 9 10]
注意第 6 次添加时,容量从 5 变成了 10——底层数组被重新分配了。Go 的扩容规则大致是:
- 如果新容量小于 1024,新容量 = 旧容量 × 2
- 如果新容量大于等于 1024,新容量 = 旧容量 × 1.25
⚠️ 踩坑提示:扩容后,切片指向了一个新的底层数组。这时候,之前引用旧数组的切片就不会看到新的变化了:
s1 := make([]int, 3, 5)
s2 := s1 // s2 和 s1 共享底层数组
s1 = append(s1, 1, 2, 3) // 触发扩容,s1 指向新数组
s1[0] = 999
fmt.Println(s1) // [999 0 0 1 2 3]
fmt.Println(s2) // [0 0 0] ← 没有变化!
append 函数
append 是操作切片最常用的函数。它的基本用法:
s := []int{1, 2, 3}
// 追加单个元素
s = append(s, 4)
fmt.Println(s) // [1 2 3 4]
// 追加多个元素
s = append(s, 5, 6, 7)
fmt.Println(s) // [1 2 3 4 5 6 7]
// 追加另一个切片(用 ... 展开)
more := []int{8, 9, 10}
s = append(s, more...)
fmt.Println(s) // [1 2 3 4 5 6 7 8 9 10]
💡 小贴士:append 的返回值是一个新的切片。你必须把返回值赋值回原来的变量(或者一个新变量)。不要像 append(s, 4) 这样调用后就忽略了返回值——这是很多新手会犯的错。
删除切片元素
Go 没有内置的删除函数,但你可以用 append 配合切片操作来实现:
s := []int{1, 2, 3, 4, 5}
// 删除索引为 2 的元素(值为 3)
i := 2
s = append(s[:i], s[i+1:]...)
fmt.Println(s) // [1 2 4 5]
这个方法会移动后面的元素,保持切片的顺序。如果你不在乎顺序,有一个更快的方法:
s := []int{1, 2, 3, 4, 5}
// 删除索引为 1 的元素(不保持顺序)
i := 1
s[i] = s[len(s)-1] // 把最后一个元素放到要删除的位置
s = s[:len(s)-1] // 截断最后一个元素
fmt.Println(s) // [1 5 3 4]
切片的复制
如果你想创建一个切片的完全独立的副本(而不是引用),可以用 copy 函数:
src := []int{1, 2, 3, 4, 5}
dst := make([]int, len(src))
n := copy(dst, src)
fmt.Println(n) // 5(复制的元素个数)
fmt.Println(dst) // [1 2 3 4 5]
// 修改 dst 不会影响 src
dst[0] = 999
fmt.Println(src) // [1 2 3 4 5]
fmt.Println(dst) // [999 2 3 4 5]
切片的常见陷阱
陷阱 1:append 可能导致切片分离
s := make([]int, 0, 3)
s = append(s, 1, 2, 3)
s1 := s // s1 和 s 共享底层数组
s2 := append(s, 4) // 触发扩容!
fmt.Printf("s = %v (cap=%d)\n", s, cap(s)) // s = [1 2 3] (cap=3)
fmt.Printf("s1 = %v (cap=%d)\n", s1, cap(s1)) // s1 = [1 2 3] (cap=3)
fmt.Printf("s2 = %v (cap=%d)\n", s2, cap(s2)) // s2 = [1 2 3 4] (cap=6)
s2 因为扩容指向了一个新的底层数组,而 s 和 s1 还指向原来的数组。
陷阱 2:子切片的内存泄漏
如果你有一个很大的切片,只取了其中一小部分作为子切片,整个底层数组都不会被垃圾回收:
// 假设这是一个很大的切片
bigSlice := make([]byte, 1024*1024) // 1MB
// 只取前 10 个字节
smallSlice := bigSlice[:10]
// 即使 bigSlice 不再被引用,1MB 的底层数组也不会被回收
// 因为 smallSlice 还在引用它!
解决方案是创建一个新的小切片,然后复制数据:
smallSlice := make([]byte, 10)
copy(smallSlice, bigSlice[:10])
陷阱 3:nil 切片 vs 空切片
var s1 []int // nil 切片
s2 := []int{} // 空切片
s3 := make([]int, 0) // 也是空切片
fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false
fmt.Println(s3 == nil) // false
// 但是它们的行为基本一致
fmt.Println(len(s1), cap(s1)) // 0 0
fmt.Println(len(s2), cap(s2)) // 0 0
// 都可以正常 append
s1 = append(s1, 1)
fmt.Println(s1) // [1]
在大多数情况下,nil 切片和空切片可以互换使用。但在某些场景(比如 JSON 序列化)中会有区别:nil 切片会被序列化为 null,而空切片会被序列化为 []。
切片的性能优化建议
1. 预分配容量
如果你知道切片大概会有多大,用 make 预分配容量可以避免多次扩容:
// ❌ 不好:每次 append 可能触发扩容
var s []int
for i := 0; i < 10000; i++ {
s = append(s, i)
}
// ✅ 好:预分配容量
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, i)
}
2. 用切片代替数组传参
传递大数组给函数会复制整个数组,用切片就不会:
// ❌ 不好:传递数组会复制
func process(arr [10000]int) { ... }
// ✅ 好:传递切片只是传递一个小的头部
func process(s []int) { ... }
3. 复用底层数组
如果你需要频繁创建切片,可以考虑复用底层数组来减少内存分配:
pool := make([]byte, 1024)
// 从池中取出一块使用
buf := pool[:256]
// 使用 buf...
实战:用切片实现一个简单的栈
让我们用切片来实现一个整数栈(后进先出的数据结构):
package main
import (
"errors"
"fmt"
)
// Stack 整数栈
type Stack struct {
items []int
}
// NewStack 创建新栈
func NewStack() *Stack {
return &Stack{
items: make([]int, 0),
}
}
// Push 入栈
func (s *Stack) Push(item int) {
s.items = append(s.items, item)
}
// Pop 出栈
func (s *Stack) Pop() (int, error) {
if len(s.items) == 0 {
return 0, errors.New("栈为空")
}
lastIndex := len(s.items) - 1
item := s.items[lastIndex]
s.items = s.items[:lastIndex]
return item, nil
}
// Peek 查看栈顶元素
func (s *Stack) Peek() (int, error) {
if len(s.items) == 0 {
return 0, errors.New("栈为空")
}
return s.items[len(s.items)-1], nil
}
// Size 栈的大小
func (s *Stack) Size() int {
return len(s.items)
}
// IsEmpty 是否为空
func (s *Stack) IsEmpty() bool {
return len(s.items) == 0
}
func main() {
stack := NewStack()
// 入栈
stack.Push(1)
stack.Push(2)
stack.Push(3)
fmt.Println("栈的大小:", stack.Size()) // 3
// 查看栈顶
top, _ := stack.Peek()
fmt.Println("栈顶元素:", top) // 3
// 出栈
for !stack.IsEmpty() {
item, _ := stack.Pop()
fmt.Println("出栈:", item)
}
// 输出:
// 出栈: 3
// 出栈: 2
// 出栈: 1
// 空栈出栈
_, err := stack.Pop()
fmt.Println("错误:", err) // 栈为空
}
小结
今天我们深入学习了 Go 语言的数组和切片:
数组:
- 固定长度,长度是类型的一部分(
[3]int≠[5]int) - 值类型,赋值和传参会复制整个数组
- 在实际开发中很少直接使用
切片:
- 可变长度,是对底层数组的引用
- 由指针、长度、容量三部分组成
- 用
make创建,用append添加元素 - 扩容时会分配新的底层数组
- 用
copy创建独立副本
核心要记住的几点:
- 切片是引用类型,多个切片可能共享底层数组
append可能触发扩容,导致切片"分离"- 子切片可能导致内存泄漏
- 预分配容量可以提升性能
切片是 Go 语言中最常用的数据结构,理解它的底层原理能帮你避免很多坑,也能让你写出更高效的代码。
练习时间
- 切片操作:创建一个切片
[1, 2, 3, 4, 5],删除中间的元素 3,然后添加 6、7、8 - 验证扩容:写一个程序,不断 append 元素到切片中,观察 len 和 cap 的变化
- 切片陷阱:创建一个切片 s1,然后创建 s2 = s1[:3],修改 s2 的元素,观察 s1 的变化
- 用切片实现队列:实现一个先进先出的队列,包含 Enqueue、Dequeue、Size 方法
- 矩阵转置:用二维切片实现矩阵的转置(行变列,列变行)
下一篇预告
下一篇文章,我们将学习 Go 语言的 map(字典)。map 是另一种非常重要的数据结构,它存储键值对,查找速度极快。我们会讨论:
- map 的声明和操作
- map 的遍历
- map 的并发安全问题
- map 的常见陷阱
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。