Go 语言切片与数组

Go 语言中,数组和切片是处理序列数据的核心结构。它们看似相似,但在行为、性能和底层实现上有显著差异。把两者的关系和使用场景理清楚,是写好 Go 程序的重要一步。

本文将带你从基础语法出发,深入到内存布局,理解切片扩容机制,并归纳常见陷阱与最佳实践。

一、数组:固定长度的值类型

1.1 声明与初始化

数组的长度是其类型的一部分,[3]int 和 [5]int 是完全不同的类型。

go 复制代码
var a [3]int           // 声明并初始化为零值 [0, 0, 0]
b := [3]int{1, 2, 3}   // 字面量初始化
c := [...]int{4, 5, 6} // 编译器自动推断长度为 3

1.2 数组是值类型

当你把数组赋值给另一个变量,或将数组传递给函数时,会发生完整的拷贝。这在数组较大时会带来性能开销。

go 复制代码
a := [3]int{1, 2, 3}
b := a         // 完整拷贝
b[0] = 100     // 修改 b 不会影响 a
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [100 2 3]

1.3 数组的适用场景

正是因为值拷贝的特性,数组在以下场景比较合适:

长度固定且很小(如 [3]float64 表示三维坐标)

需要值语义,确保数据不被意外修改

作为底层存储的载体(例如切片底层依赖数组)

大多数字面量使用场景中,我们并不会直接声明数组,而是用切片。

二、切片:灵活的动态视图

切片是对底层数组的一个"窗口",它不存储数据,只是描述底层数组的一段连续片段。

2.1 切片的底层结构

切片在运行时的结构体定义如下(reflect.SliceHeader):

go 复制代码
type SliceHeader struct {
    Data uintptr // 指向底层数组的指针
    Len  int     // 切片当前长度
    Cap  int     // 切片容量(从 Data 开始到底层数组末尾的元素个数)
}

正因为切片只持有指针和两个长度信息,所以切片本身的赋值和传参非常廉价(只拷贝这三个字段,24 字节)。数据本身仍然在原底层数组中。

2.2 创建切片

go 复制代码
// 方式 1:字面量
s1 := []int{1, 2, 3} // 底层自动创建数组

// 方式 2:make 函数
s2 := make([]int, 3, 5) // len=3, cap=5, 元素初始化为零值

// 方式 3:从数组或切片切割
arr := [5]int{1, 2, 3, 4, 5}
s3 := arr[1:4] // 元素 [2,3,4], len=3, cap=4 (从索引1开始到底)

2.3 切割与底层数组共享

这是最容易踩坑的地方:多个切片可能共享同一个底层数组,修改一个切片的数据会影响其他切片。

go 复制代码
arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[1:4] // [2 3 4], len=3, cap=4
s2 := s1[0:2]  // [2 3],     len=2, cap=4 (仍是同一个底层数组)
s2[0] = 100
fmt.Println(s1) // [100 3 4]
fmt.Println(arr) // [1 100 3 4 5]

这个例子中,s2 并没有开辟新数组,它仍然指向 arr 的底层空间。因此修改 s2[0] 就会修改共享数据。理解切片操作仅改变 Len 和 Data 偏移,但不复制数据非常重要。

三、append 与切片扩容

append 用于向切片末尾追加元素。当容量足够时,直接在底层数组上追加并返回同一个底层数组的切片;当容量不足时,Go 会分配一个更大的数组,将原数据拷贝过去,再追加新元素。

3.1 扩容规则(简要)

扩容策略在不同 Go 版本中略有调整,大致思路如下(以 Go 1.18+ 为例):

如果新需要的容量 newCap > 2 * oldCap,则直接扩容到 newCap;

否则,如果旧容量小于 256,新容量变为旧容量的两倍;

如果旧容量大于等于 256,则采用更平滑的增长公式 (oldCap + 3*threshold)/4,逐步过渡,避免大切片浪费过多内存。

你不需要死记规则,但要记住:扩容通常会生成新的底层数组,原有切片不再与之共享。

3.2 append 的返回值陷阱

append 返回一个新的切片,你必须接收返回值,否则原切片可能已经"过时"。

go 复制代码
s := make([]int, 0, 2)
s = append(s, 1) // 正确
_ = append(s, 2) // 危险!忘记赋值,s 仍然为 [1]

更要命的是:当多个切片引用同一底层数组时,一个切片 append 触发扩容,其他切片还指向旧数组,数据不再同步。

go 复制代码
s1 := make([]int, 0, 2)
s1 = append(s1, 1, 2)
s2 := s1
s1 = append(s1, 3, 4, 5) // 触发扩容,s1 底层数组与 s2 不同
s1[0] = 100
fmt.Println(s1) // [100 2 3 4 5]
fmt.Println(s2) // [1 2]  完全不受影响

如果你的逻辑依赖共享底层数组,请避免在不清楚容量边界的情况下直接 append。

四、切片作为函数参数

切片传入函数时,拷贝的是切片头部结构(指针、长度、容量)。函数内部修改切片元素,会直接影响原底层数组:

go 复制代码
func modify(s []int) {
    s[0] = 999 // 修改底层数组
}

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

但如果函数内发生了 append 并扩容,则对原切片无影响(因指向了新数组),同时若未返回新切片,原切片的长度和容量也不会变。

因此,当函数需要改变切片的长度或容量,通常需要返回新的切片,就像 append 那样。

go 复制代码
func appendOne(s []int) []int {
    return append(s, 1)
}

五、nil 切片与空切片

go 复制代码
var s1 []int         // nil 切片,len=0, cap=0, Data=nil
s2 := make([]int, 0) // 非 nil 空切片,len=0, cap=0, Data 指向一个空数组
s3 := []int{}        // 同上

大多数场景下它们可以互换(len、append、range 表现一致)。

与 nil 比较时会有差异:s1 == nil 为 true,s2 == nil 为 false。

JSON 序列化结果不同:nil 切片序列化为 null,空切片序列化为 []。这可能会影响某些 API 的合约,建议根据业务区分使用。

六、总结

优先使用切片,除非你明确需要固定长度的值类型。

创建切片时预分配容量,如果长度已知:make([]T, 0, expectedLen) 可减少扩容带来的内存分配和拷贝。

不要假设切片间独立,注意切割操作导致的数据共享,如有必要可以使用 copy 深拷贝数据。

append 后务必接收返回值,并小心扩容引起的底层分离。

函数修改切片长度/容量时,返回新切片,保持风格与标准库一致。

区分 nil 和空切片,尤其在返回切片给调用方时,想清楚期望的序列化行为。

避免长时间持有大数组的微小切片,因为切片会阻止整个底层数组被垃圾回收。如需长期保留部分数据,用 copy 取出所需元素。

数组是 Go 世界里的一块朴实基石,而切片则是在这块基石上构建出的强大、灵活的工具。理解它们的内存模型和底层共享关系,能让你避开大多数性能陷阱和并发 Bug。

相关推荐
geovindu2 小时前
go: Monitor Pattern
开发语言·后端·设计模式·golang·监控模式
郭龙_Jack2 小时前
Java的虚拟线程 VS Go语言的goroutine
java·golang
喵了几个咪2 小时前
Kratos WebRTC 传输中间件:H5游戏P2P实时音视频与数据通信实战
游戏·微服务·中间件·golang·webrtc·实时音视频·kratos
jieyucx17 小时前
Go 语言进阶:构造函数、父子结构体与组合复用详解
服务器·算法·golang·继承·结构体·构造函数
jieyucx20 小时前
Go语言通透教程:结构体定义与方法
服务器·数据库·golang·结构体
念何架构之路20 小时前
GoFrame类型转换详解
golang
m0_502724951 天前
golang 、java、c++、javascript 语言switch case异同
java·javascript·c++·golang
jieyucx1 天前
Go 语言进阶:结构体指针、new 关键字与匿名结构体/成员详解
开发语言·后端·golang·结构体
赛特·亮1 天前
利用WTAPI(WeChatapi)-robot-go 项目解析与实战指南
微信·面试·golang