数组与切片: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 的场景下能更好地平衡性能和内存使用。
扩容的开销分析
每次扩容都涉及三个开销:
- 分配新的内存空间
- 将原有数据复制到新空间
- 垃圾回收旧的底层数组
这就是为什么预分配容量如此重要。如果你知道切片最终的容量,用 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
- append 后旧切片的观察者不会看到新数据
- 从一个大切片创建小切片时要考虑内存泄漏
- 函数返回子切片时,考虑是否应该 copy
- 并发访问切片需要加锁(切片头部不是线程安全的)
- 循环中 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]int | 8000 字节 | 紧凑,无额外开销 |
| []int | 8000 + 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 创建独立副本
核心要记住的几点:
- 切片是引用类型,多个切片可能共享底层数组
- append 可能触发扩容,导致切片分离
- 子切片可能导致内存泄漏
- 预分配容量可以提升性能
- 深拷贝和浅拷贝的行为差异
- nil 切片和空切片的微妙区别
- 并发访问切片需要同步
切片是 Go 语言中最常用的数据结构,理解它的底层原理能帮你避免很多坑,也能让你写出更高效的代码。从数组到切片,从 make 到 append,从浅拷贝到深拷贝,每一个概念都紧密相连,共同构成了 Go 灵活而高效的序列类型系统。
练习时间
- 切片操作:创建一个切片 [1, 2, 3, 4, 5],删除中间的元素 3,然后添加 6、7、8
- 验证扩容:写一个程序,不断 append 元素到切片中,观察 len 和 cap 的变化
- 切片陷阱:创建一个切片 s1,然后创建 s2 = s1[:3],修改 s2 的元素,观察 s1 的变化
- 用切片实现队列:实现一个先进先出的队列,包含 Enqueue、Dequeue、Size 方法
- 矩阵转置:用二维切片实现矩阵的转置(行变列,列变行)
- 内存泄漏实验:创建一个大切片,只取其中一小部分作为子切片,观察内存占用情况,然后用 copy 解决
- 缓存池实现:实现一个可以复用切片的对象池,对比使用池和不使用池的内存分配次数
- 环形缓冲区:完善上面的 RingBuffer 实现,支持覆盖写入模式(覆盖最旧的数据)
下一篇预告
下一篇文章,我们将学习 Go 语言的 map(字典)。map 是另一种非常重要的数据结构,它存储键值对,查找速度极快。我们会讨论:
- map 的声明和操作
- map 的遍历
- map 的并发安全问题
- map 的常见陷阱
我们下篇见!
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。