数组与切片:Go 里最常被搞混的一对

彻底搞懂 Go 语言的数组和切片,理解切片的底层原理和常见陷阱

数组与切片: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 因为扩容指向了一个新的底层数组,而 ss1 还指向原来的数组。

陷阱 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 创建独立副本

核心要记住的几点

  1. 切片是引用类型,多个切片可能共享底层数组
  2. append 可能触发扩容,导致切片"分离"
  3. 子切片可能导致内存泄漏
  4. 预分配容量可以提升性能

切片是 Go 语言中最常用的数据结构,理解它的底层原理能帮你避免很多坑,也能让你写出更高效的代码。

练习时间

  1. 切片操作:创建一个切片 [1, 2, 3, 4, 5],删除中间的元素 3,然后添加 6、7、8
  2. 验证扩容:写一个程序,不断 append 元素到切片中,观察 len 和 cap 的变化
  3. 切片陷阱:创建一个切片 s1,然后创建 s2 = s1[:3],修改 s2 的元素,观察 s1 的变化
  4. 用切片实现队列:实现一个先进先出的队列,包含 Enqueue、Dequeue、Size 方法
  5. 矩阵转置:用二维切片实现矩阵的转置(行变列,列变行)

下一篇预告

下一篇文章,我们将学习 Go 语言的 map(字典)。map 是另一种非常重要的数据结构,它存储键值对,查找速度极快。我们会讨论:

  • map 的声明和操作
  • map 的遍历
  • map 的并发安全问题
  • map 的常见陷阱

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页