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

深入剖析Go语言数组与切片的底层原理,详解切片的三元组结构、append扩容算法、深拷贝与浅拷贝的区别,以及子切片内存泄漏等常见陷阱,附带性能对比与工程实战案例

数组与切片: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 函数

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]

append 扩容算法深度解析

当你用 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 的扩容规则大致是:

  • 如果新容量小于 256,新容量大约是旧容量的 2 倍(具体算法有优化)
  • 如果新容量大于等于 256,增长率会逐渐降低,最终趋近于 1.25 倍
  • 如果切片的容量小于 1024,新容量通常会翻倍
  • 如果容量大于等于 1024,新容量会增加约 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]  ← 没有变化!

Go 的扩容算法在不同版本中有所调整。Go 1.18 之后采用了一个更平滑的增长曲线,相比早期的翻倍策略,在大量 append 的场景下能更好地平衡性能和内存使用。

扩容的开销分析

每次扩容都涉及三个开销:

  1. 分配新的内存空间
  2. 将原有数据复制到新空间
  3. 垃圾回收旧的底层数组

这就是为什么预分配容量如此重要。如果你知道切片最终的容量,用 make 一次性分配,可以避免多次扩容。

// 不好的做法:多次扩容
var s []int
for i := 0; i < 1000; i++ {
	s = append(s, i)  // 多次分配和复制
}

// 好的做法:预分配
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
	s = append(s, i)  // 只分配一次
}

深拷贝与浅拷贝

copy 函数

如果你想创建一个切片的完全独立的副本(而不是引用),可以用 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]

copy 函数会复制源切片的元素到目标切片,返回实际复制的元素个数(不会超过目标切片的长度)。copy 只能做浅拷贝——如果切片元素本身是指针或包含指针的结构体,底层指向的数据仍然是共享的。

深拷贝

对于简单的值类型切片,copy 就是深拷贝。但对于包含指针或接口的切片,需要手动实现深拷贝:

package main

import "fmt"

type Item struct {
	Name  string
	Value int
}

// 对于值类型切片,copy 相当于深拷贝
func deepCopyItems(src []Item) []Item {
	dst := make([]Item, len(src))
	copy(dst, src)
	return dst
}

func main() {
	original := []Item{
		{Name: "A", Value: 1},
		{Name: "B", Value: 2},
	}

	copied := deepCopyItems(original)
	copied[0].Name = "Z"

	fmt.Println("原始:", original)  // [{A 1} {B 2}]
	fmt.Println("副本:", copied)   // [{Z 1} {B 2}]
}

浅拷贝的场景

浅拷贝在某些场景下正是我们需要的:

// 排序但不改变原始顺序
data := []int{3, 1, 4, 1, 5}
sorted := make([]int, len(data))
copy(sorted, data)
// sort.Ints(sorted)  // 对副本排序
// data 保持不变

切片的常见陷阱与内存泄漏

陷阱 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])
// 现在 bigSlice 可以被垃圾回收了

这种内存泄漏在实践中很常见,尤其是在处理大文件读取、网络数据包缓冲等场景中。

陷阱 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,而空切片会被序列化为 []。

避免切片陷阱的 checklist

  1. append 后旧切片的观察者不会看到新数据
  2. 从一个大切片创建小切片时要考虑内存泄漏
  3. 函数返回子切片时,考虑是否应该 copy
  4. 并发访问切片需要加锁(切片头部不是线程安全的)
  5. 循环中 append 要注意是否触发了扩容

性能对比与分析

在不同场景下,选择合适的数据结构对性能影响很大。

操作数组切片map链表*
随机访问O(1)O(1)O(1) 平均O(n)
尾部追加不可变O(1) 均摊*O(1) 均摊O(1)
插入不可变O(n)O(1) 均摊O(1)
删除不可变O(n)O(1) 均摊O(1)
遍历O(n)O(n)O(n)O(n)
内存占用紧凑紧凑+头部松散指针开销
传递开销复制全部24 字节头部8 字节头部8 字节头部

*链表:基于 container/list 实现
*切片 append 在扩容时可能是 O(n)

内存占用对比

数据结构1000 个 int 的内存占用(64位系统)特点
[1000]int8000 字节紧凑,无额外开销
[]int8000 + 24 字节头部灵活,可扩容
map[int]int约 16000+ 字节哈希表实现,有额外开销
list.List约 24000+ 字节链表节点有指针开销

数组在内存使用上是最紧凑的,但缺乏灵活性。切片在灵活性和内存效率之间取得了很好的平衡,这也是它被广泛使用的原因。

工程实战案例

矩阵运算

使用二维切片实现矩阵操作:

package main

import "fmt"

// 创建矩阵
func createMatrix(rows, cols int) [][]int {
	matrix := make([][]int, rows)
	for i := range matrix {
		matrix[i] = make([]int, cols)
	}
	return matrix
}

// 矩阵转置
func transpose(matrix [][]int) [][]int {
	rows := len(matrix)
	cols := len(matrix[0])
	result := createMatrix(cols, rows)
	for i := 0; i < rows; i++ {
		for j := 0; j < cols; j++ {
			result[j][i] = matrix[i][j]
		}
	}
	return result
}

// 矩阵乘法
func multiply(a, b [][]int) [][]int {
	rows := len(a)
	cols := len(b[0])
	inner := len(b)
	result := createMatrix(rows, cols)
	for i := 0; i < rows; i++ {
		for j := 0; j < cols; j++ {
			sum := 0
			for k := 0; k < inner; k++ {
				sum += a[i][k] * b[k][j]
			}
			result[i][j] = sum
		}
	}
	return result
}

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

	fmt.Println("原矩阵:")
	for _, row := range m1 {
		fmt.Println(row)
	}

	fmt.Println("转置:")
	for _, row := range transpose(m1) {
		fmt.Println(row)
	}
}

缓存池实现

使用切片实现对象复用池:

package main

import "fmt"

// BufferPool 是一个简单的字节切片缓存池
type BufferPool struct {
	pool   [][]byte
	size   int
	maxCap int
}

// NewBufferPool 创建新缓存池
func NewBufferPool(size, maxCap int) *BufferPool {
	return &BufferPool{
		pool:   make([][]byte, 0, maxCap),
		size:   size,
		maxCap: maxCap,
	}
}

// Get 从池中获取一个切片
func (p *BufferPool) Get() []byte {
	if len(p.pool) > 0 {
		// 从尾部弹出(O(1))
		buf := p.pool[len(p.pool)-1]
		p.pool = p.pool[:len(p.pool)-1]
		return buf[:p.size]
	}
	return make([]byte, p.size)
}

// Put 将切片放回池中
func (p *BufferPool) Put(buf []byte) {
	if cap(buf) >= p.size && len(p.pool) < p.maxCap {
		p.pool = append(p.pool, buf[:0])
	}
}

func main() {
	pool := NewBufferPool(1024, 100)

	buf1 := pool.Get()
	fmt.Printf("获取: len=%d, cap=%d\n", len(buf1), cap(buf1))

	// 使用 buf1...
	buf1[0] = 'A'

	pool.Put(buf1)

	buf2 := pool.Get()
	fmt.Printf("复用: len=%d, cap=%d\n", len(buf2), cap(buf2))
}

环形缓冲区

环形缓冲区(Ring Buffer)是一种高效的数据结构,常用于生产者-消费者场景:

package main

import "fmt"

// RingBuffer 环形缓冲区
type RingBuffer struct {
	buffer []int
	head   int // 读位置
	tail   int // 写位置
	size   int // 当前元素数量
	cap    int // 容量
}

// NewRingBuffer 创建环形缓冲区
func NewRingBuffer(capacity int) *RingBuffer {
	return &RingBuffer{
		buffer: make([]int, capacity),
		cap:    capacity,
	}
}

// Push 写入数据
func (r *RingBuffer) Push(val int) bool {
	if r.size == r.cap {
		return false // 满了
	}
	r.buffer[r.tail] = val
	r.tail = (r.tail + 1) % r.cap
	r.size++
	return true
}

// Pop 读取数据
func (r *RingBuffer) Pop() (int, bool) {
	if r.size == 0 {
		return 0, false // 空了
	}
	val := r.buffer[r.head]
	r.head = (r.head + 1) % r.cap
	r.size--
	return val, true
}

// IsEmpty 是否为空
func (r *RingBuffer) IsEmpty() bool {
	return r.size == 0
}

// IsFull 是否已满
func (r *RingBuffer) IsFull() bool {
	return r.size == r.cap
}

func main() {
	rust := NewRingBuffer(5)

	for i := 1; i <= 7; i++ {
		ok := rust.Push(i * 10)
		fmt.Printf("Push %d: %v\n", i*10, ok)
	}

	for !rust.IsEmpty() {
		val, _ := rust.Pop()
		fmt.Printf("Pop: %d\n", val)
	}
}

用切片实现栈

让我们用切片来实现一个整数栈(后进先出的数据结构):

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)  // 栈为空
}

最佳实践

预分配策略

如果你知道切片大概会有多大,用 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)
}

用切片代替数组传参

传递大数组给函数会复制整个数组,用切片就不会:

// 不好:传递数组会复制
func process(arr [10000]int) { /* ... */ }

// 好:传递切片只是传递一个小的头部
func process(s []int) { /* ... */ }

避免子切片陷阱

当函数返回子切片时,考虑是否需要 copy:

// 可能导致内存泄漏:返回的子切片引用了大数组
func getFirstN(data []byte, n int) []byte {
	return data[:n]  // 引用原始大数组
}

// 安全的做法:copy 数据
func getFirstNSafe(data []byte, n int) []byte {
	result := make([]byte, n)
	copy(result, data[:n])
	return result
}

复用底层数组

如果你需要频繁创建切片,可以考虑复用底层数组来减少内存分配:

pool := make([]byte, 1024)

// 从池中取出一块使用
buf := pool[:256]
// 使用 buf...

并发安全

切片头部(ptr, len, cap)不是并发安全的,多个 goroutine 同时读写同一个切片需要同步:

var mu sync.Mutex
var shared []int

func appendSafely(val int) {
	mu.Lock()
	defer mu.Unlock()
	shared = append(shared, val)
}

注意:即使使用 sync.Mutex,也不要在锁保护范围外读取切片,因为 append 可能会改变切片头部。

常见问题 FAQ

Q1:数组和切片到底有什么区别?

A:数组是值类型,长度固定且是类型的一部分;切片是引用类型,长度可变,是对底层数组的视图。数组赋值会复制整个数组,切片赋值只复制头部(24 字节)。在实际开发中,几乎总是使用切片而不是数组。

Q2:为什么 append 要返回新的切片?不能在原地修改吗?

A:append 返回新切片是因为扩容时可能需要分配新的底层数组。如果 append 原地修改,调用者就无法知道底层数组是否已经改变。通过返回新切片,Go 明确告诉调用者:请用这个新切片来引用数据。这是一个精心设计的 API,虽然写起来稍长,但避免了大量隐蔽的 bug。

Q3:切片的容量是怎么计算的?

A:容量是从切片的起始位置到底层数组末尾的元素个数。比如 arr[1:4] 的容量是 len(arr) - 1。这意味着越靠后的子切片容量越小,而前端的子切片即使长度很小也可能拥有很大的容量。

arr := [10]int{}
s1 := arr[0:2]     // len=2, cap=10
s2 := arr[5:7]     // len=2, cap=5

Q4:如何完全切断切片和底层数组的联系?

A:使用 copy 函数创建全新的底层数组,或者使用 append 到一个全新分配的切片:

// 方法 1: copy
newSlice := make([]int, len(oldSlice))
copy(newSlice, oldSlice)

// 方法 2: append 到 nil(Go 1.23 之前需要更复杂的写法)
newSlice := append([]int(nil), oldSlice...)

Q5:nil 切片能调用 append 吗?

A:可以!nil 切片是合法的,len(nilSlice) == 0,cap(nilSlice) == 0。append 到 nil 切片会分配新的底层数组。这是 Go 切片设计的一个优雅之处——nil 切片和空切片在很多场景可以互换使用。

Q6:切片作为函数参数时,函数内修改元素会影响调用者吗?

A:会!因为切片是引用类型,函数内对切片元素的修改会影响底层数组,从而影响所有引用同一数组的切片。但函数内对切片本身(如 append、重新切片)不会修改调用者的切片头部——除非传入的是指向切片的指针。

Q7:二维切片怎么创建?

A:二维切片需要分别分配每一行:

// 正确做法
matrix := make([][]int, rows)
for i := range matrix {
	matrix[i] = make([]int, cols)
}

// 错误做法(所有行共享同一个底层数组)
wrong := make([][]int, rows)
row := make([]int, cols)
for i := range wrong {
	wrong[i] = row  // 所有行指向同一个 row!
}

Q8:make([]int, 5) 和 make([]int, 0, 5) 有什么区别?

A:make([]int, 5) 创建长度为 5、容量为 5 的切片,5 个元素都初始化为 0。make([]int, 0, 5) 创建长度为 0、容量为 5 的空切片,底层数组已分配但当前不包含任何元素。前者适合已知所有初始值的场景,后者适合逐步 append 的场景。

Q9:range 遍历切片时是值拷贝还是引用?

A:range 遍历时会复制元素值。如果是值类型(int、string 等),修改遍历变量不会影响原切片;如果元素是指针或包含指针的结构体,修改指针指向的内容会影响原数据。

for i, v := range slice {
	v = 100  // 这只修改了局部副本,不影响 slice[i]
}

for i := range slice {
	slice[i] = 100  // 这样才能修改原切片
}

Q10:切片有什么排序方法?

A:Go 标准库提供了多种排序方式:

  • sort.Ints、sort.Strings:对基本类型切片原地排序
  • sort.Slice:用自定义比较函数排序
  • sort.SliceStable:稳定排序
  • slices.Sort(Go 1.21+):泛型排序函数

延伸阅读

如果你想更深入地学习 Go 语言的数组和切片,以下是一些推荐的资源:

  • Go 语言规范 - 切片:https://golang.org/ref/spec#Slice_types
  • Go 官方博客 - Slices: usage and internals:https://blog.golang.org/slices-intro
  • Effective Go - 切片:https://golang.org/doc/effective_go.html#slices
  • Go 切片底层实现:https://go.dev/src/runtime/slice.go
  • Go 内存模型:https://golang.org/ref/mem

小结

今天我们深入学习了 Go 语言的数组和切片:

数组:

  • 固定长度,长度是类型的一部分([3]int ≠ [5]int)
  • 值类型,赋值和传参会复制整个数组
  • 在实际开发中很少直接使用

切片:

  • 可变长度,是对底层数组的引用
  • 由指针、长度、容量三部分组成
  • 用 make 创建,用 append 添加元素
  • 扩容时会分配新的底层数组
  • 用 copy 创建独立副本

核心要记住的几点:

  1. 切片是引用类型,多个切片可能共享底层数组
  2. append 可能触发扩容,导致切片分离
  3. 子切片可能导致内存泄漏
  4. 预分配容量可以提升性能
  5. 深拷贝和浅拷贝的行为差异
  6. nil 切片和空切片的微妙区别
  7. 并发访问切片需要同步

切片是 Go 语言中最常用的数据结构,理解它的底层原理能帮你避免很多坑,也能让你写出更高效的代码。从数组到切片,从 make 到 append,从浅拷贝到深拷贝,每一个概念都紧密相连,共同构成了 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. 矩阵转置:用二维切片实现矩阵的转置(行变列,列变行)
  6. 内存泄漏实验:创建一个大切片,只取其中一小部分作为子切片,观察内存占用情况,然后用 copy 解决
  7. 缓存池实现:实现一个可以复用切片的对象池,对比使用池和不使用池的内存分配次数
  8. 环形缓冲区:完善上面的 RingBuffer 实现,支持覆盖写入模式(覆盖最旧的数据)

下一篇预告

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

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

我们下篇见!


参考资料:

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练