Go 的序列容器:固定长度的数组与动态的切片——切片是 Go 中最常用的数据结构
一、C++ 对照速查表
| C++ | Go | 关键差异 |
|---|---|---|
std::array<T, N> | [N]T | Go 数组长度是类型的一部分,[3]int 和 [4]int 是不同类型 |
std::vector<T> | []T(切片) | 切片是底层数组的引用/视图,不是独立容器 |
vector::push_back | append | append 返回新切片,必须重新赋值 |
vector::size() / capacity() | len(s) / cap(s) | 完全对应 |
vector<T> 赋值 | 切片赋值 | C++ vector 赋值是深拷贝;Go 切片赋值只是拷贝引用 |
std::vector<T> (值类型) | 切片是引用语义 | 切片共享底层数组,修改会相互影响 |
数组 == 比较 | [N]T 支持 == | 切片不能用 == 比较(只能和 nil 比) |
核心区别:C++ 的
std::vector是一个拥有数据的容器;Go 的切片只是一个"窗口"——包含指针、长度、容量三个字段,指向某个底层数组。
二、数组 [N]T
2.1 基本语法
// 声明:长度在类型中,不可省略
var a [5]int // 零值:[0 0 0 0 0]
b := [3]string{"hello", "go", "world"} // 字面量初始化
c := [...]int{1, 2, 3} // 编译器自动推断长度 = 3
// 长度是类型的一部分:[3]int 和 [4]int 是不同类型!
// var x [3]int = [4]int{...} // 编译错误
2.2 数组是值类型(与 C++ std::array 一致)
func main() {
a := [3]int{1, 2, 3}
b := a // 值拷贝,b 是 a 的副本
b[0] = 999 // 修改 b 不影响 a
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [999 2 3]
}
2.3 数组支持 == 比较
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
fmt.Println(a == b) // true——长度相同、元素逐个相等即可比较
实际使用:Go 中几乎不用数组,绝大多数场景用切片。数组主要用于固定长度的场景(如 RGB 颜色
[3]byte、矩阵维度等)。
三、切片 []T
3.1 切片的内部结构
切片在运行时是一个三元组(reflect.SliceHeader):
┌─────────────┐
│ 指针 (ptr) │ ──→ 指向底层数组的某个位置
├─────────────┤
│ 长度 (len) │ 元素个数
├─────────────┤
│ 容量 (cap) │ 从 ptr 开始到底层数组末尾的可用空间
└─────────────┘
C++ 对照:这类似于 std::span<T>(C++20)或 std::string_view——都是对连续内存的非拥有型视图。但切片还可以通过 append 生长。
3.2 创建切片的四种方式
// 方式一:字面量(最常用)
s1 := []int{10, 20, 30}
fmt.Println(len(s1), cap(s1)) // 3, 3
// 方式二:make(预分配空间)
s2 := make([]int, 5) // len=5, cap=5(全部零值)
s3 := make([]int, 3, 10) // len=3, cap=10(前3个是零值,后7个预留)
fmt.Println(s2) // [0 0 0 0 0]
fmt.Println(s3) // [0 0 0]
// 方式三:从数组/切片切取
arr := [5]int{10, 20, 30, 40, 50}
s4 := arr[1:4] // [20 30 40],len=3, cap=4
s5 := arr[:3] // [10 20 30],len=3, cap=5
s6 := arr[2:] // [30 40 50],len=3, cap=3
// 方式四:声明但不初始化 → nil 切片
var s7 []int // s7 == nil, len(s7)==0, cap(s7)==0
3.3 切片是引用语义
func main() {
arr := [4]int{1, 2, 3, 4}
s := arr[1:3] // s = [2, 3],指向 arr[1] 和 arr[2]
s[0] = 999 // 修改切片元素 → 底层数组也变了
fmt.Println(arr) // [1 999 3 4]
// 两个切片共享底层数组时,修改互相影响
a := arr[0:3] // a = [1, 999, 3]
b := arr[1:4] // b = [999, 3, 4]
b[0] = 777 // b[0] 和 a[1] 是同一个元素
fmt.Println(a) // [1 777 3]
}
3.4 len 与 cap 的关系
s := make([]int, 3, 8)
// 内存布局:
// [0 0 0 . . . . .]
// ←len=3→ ←cap=8 →
// 有效元素 预留空间
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 8
// 重新切取后 cap 可能缩小
sub := s[2:5] // sub = [0 0 0], len=3, cap=6(从 index 2 到底层数组末尾)
四、append — 切片的"增长器"
4.1 基本用法
s := []int{1, 2, 3}
s = append(s, 4) // [1 2 3 4],len=4, cap=4(可能扩容)
s = append(s, 5, 6, 7) // 追加多个元素
s = append(s, []int{8, 9}...) // 追加另一个切片(... 展开)
fmt.Println(s) // [1 2 3 4 5 6 7 8 9]
4.2 扩容机制(关键)
func main() {
s := make([]int, 0, 4)
for i := 0; i < 10; i++ {
oldCap := cap(s)
s = append(s, i)
if cap(s) != oldCap {
fmt.Printf("扩容: len=%d, cap 从 %d → %d\n", len(s), oldCap, cap(s))
}
}
// 典型输出(Go 1.21+):
// 扩容: len=5, cap 从 4 → 8
// 扩容: len=9, cap 从 8 → 16
}
Go 1.21+ 扩容策略:新容量 ≈ 旧容量的 1.5~2 倍(具体由 runtime 内部公式决定),不足时直接取所需最小值。小切片增长快(约 2x),大切片增长慢(约 1.25x)。
C++ 对照:与 std::vector 的扩容策略类似,都是均摊 O(1) 的追加。
4.3 为什么 append 必须重新赋值?
func main() {
s := make([]int, 2, 2)
s[0], s[1] = 1, 2
// 错误写法(如果触发扩容,s 还指向旧数组)
append(s, 3) // 返回值被丢弃!
// 正确写法
s = append(s, 3)
fmt.Println(s) // [1 2 3]
}
原理:append 内部可能分配新的底层数组(当 cap 不够时),返回一个新的切片头(指向新数组)。旧切片 s 的指针没变。如果不接收返回值,追加就丢了。
4.4 高效预分配(C++ reserve 等价)
// 预知最终大小时,一次性分配避免多次扩容
s := make([]int, 0, 1000) // cap=1000,直接预留
for i := 0; i < 1000; i++ {
s = append(s, i) // 每次 append 不触发扩容
}
// 从已知长度的切片预分配
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 0, len(src)) // 预分配与 src 等长
dst = append(dst, src...) // 一次追加
五、copy — 深拷贝切片
func main() {
src := []int{1, 2, 3, 4, 5}
// 方式一:copy(推荐,明确意图)
dst := make([]int, len(src))
n := copy(dst, src) // n = 实际拷贝的元素个数
fmt.Println(dst, n) // [1 2 3 4 5] 5
// 修改 dst 不影响 src(完全独立)
dst[0] = 999
fmt.Println(src) // [1 2 3 4 5] 不受影响
// 方式二:append 复制(惯用法)
dst2 := append([]int(nil), src...) // nil 切片 + append = 深拷贝
dst3 := append(dst2[:0], src...) // 也可以复用已有切片的前 0 位
}
C++ 对照:相当于 std::vector 的拷贝赋值或 std::copy,都是深拷贝。但 Go 中切片赋值只是拷贝引用,必须用 copy 才是深拷贝。
六、切片作为函数参数
6.1 传递的是引用
func modify(s []int) {
s[0] = 999 // 修改会影响调用方
s = append(s, 100) // append 可能分配新数组,不影响调用方的 s
}
func main() {
a := []int{1, 2, 3}
modify(a)
fmt.Println(a) // [999 2 3]——第一个元素被改了
b := make([]int, 2, 4) // len=2, cap=4
b[0], b[1] = 1, 2
modify(b)
fmt.Println(b) // [999 2]——没有 100,因为 append 后的新切片在 modify 内部被丢弃
}
6.2 需要修改切片本身(长度/容量)时的处理
// 错误:函数内 append 不影响调用方
func badAppend(s []int, v int) {
s = append(s, v)
}
// 正确方案一:返回新切片
func goodAppend(s []int, v int) []int {
return append(s, v)
}
// 正确方案二:传指针(罕见,通常不推荐)
func appendPtr(s *[]int, v int) {
*s = append(*s, v)
}
经验法则:切片传参修改元素 → 会影响调用方;切片传参 append → 不会影响调用方(除非底层数组还有余量)。Go 惯用法:用返回值传递变化。
七、for range 遍历切片
s := []string{"a", "b", "c"}
// 经典用法:索引 + 值
for i, v := range s {
fmt.Printf("s[%d] = %s\n", i, v)
}
// 只要值,忽略索引
for _, v := range s {
fmt.Println(v)
}
// 只要索引
for i := range s {
fmt.Println(i)
}
八、nil 切片 vs 空切片
// nil 切片:未分配底层数组
var nilSlice []int
fmt.Println(nilSlice == nil) // true
fmt.Println(len(nilSlice)) // 0
fmt.Println(cap(nilSlice)) // 0
// 空切片:长度为 0 但已分配底层数组
emptySlice := []int{}
fmt.Println(emptySlice == nil) // false
fmt.Println(len(emptySlice)) // 0
fmt.Println(cap(emptySlice)) // 0(或 >0,取决于 make 的 cap)
// 行为差异几乎为零:append 对两者都能正常工作
nilSlice = append(nilSlice, 1) // 正常
emptySlice = append(emptySlice, 1) // 正常
实践建议:JSON 序列化时有区别——nil 切片输出
null,空切片输出[]。如果你需要输出[]而非null,用空切片。其他场景两者等价。
九、常见坑
坑 1:切片是引用——共享底层数组的陷阱
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b = [2, 3],与 a 共享底层数组
b[0] = 999
fmt.Println(a) // [1 999 3, 4, 5]——a 也被改了!
// 解决:用 copy 或重新切取时加三索引限制容量
c := a[1:3:3] // len=2, cap=2——append 时不会越界到 a 的空间
坑 2:append 后底层数组可能变了
a := []int{1, 2, 3, 4}
b := a[:2] // b = [1, 2], len=2, cap=4
b = append(b, 999) // cap 够用,直接写入 a[2] → a 变成 [1, 2, 999, 4]
x := []int{1, 2} // len=2, cap=2
y := x[:1] // y = [1], len=1, cap=2
y = append(y, 888) // cap=2 还够,写入 x[1] → x 变成 [1, 888]
z := []int{1, 2}
w := z[:1:1] // 三索引切取:len=1, cap=1
w = append(w, 777) // cap=1 不够,触发扩容 → 新数组,z 不受影响
最佳实践:如果切片之间需要独立,用三索引切取限制 cap(
s[lo:hi:max]),或者用 copy。
坑 3:for range 中的变量是拷贝
s := []int{1, 2, 3}
for _, v := range s {
v = 0 // 这只是修改局部拷贝,s 不受影响
}
fmt.Println(s) // [1 2 3]——没变
// 如果需要修改原切片,用索引
for i := range s {
s[i] = 0
}
fmt.Println(s) // [0 0 0]
坑 4:切片不能用 == 比较
a := []int{1, 2, 3}
b := []int{1, 2, 3}
// a == b // 编译错误!slice can only be compared to nil
// 唯一的比较方式:和 nil 比
fmt.Println(a == nil) // false
// 判断两个切片是否"内容相同":用 reflect.DeepEqual 或手写循环
坑 5:make 时只指定 len
s := make([]int, 5) // len=5, cap=5,全部是零值
// 如果你要的是"空切片但有预分配容量",必须写三个参数:
s = make([]int, 0, 5) // len=0, cap=5
坑 6:append 一次追加多个元素的性能
// 不好:逐个 append,可能多次扩容
var s []int
for i := 0; i < 10000; i++ {
s = append(s, i)
}
// 好:预分配后 append
s = make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, i)
}
// 更好:如果能一次构造,直接用字面量
s = make([]int, 10000)
for i := range s {
s[i] = i
}
十、练习任务
基础
- 声明一个
[3]int数组并赋值,将其拷贝到另一个变量,修改副本验证值拷贝行为 - 用
make([]int, 0, 5)创建切片,用 for 循环append10 个元素,观察 len 和 cap 的变化 - 写一个函数
func contains(s []int, target int) bool,练习切片传参和遍历
进阶
- 写一个函数
func reverse(s []int)原地反转切片,验证调用方是否受影响 - 写一个函数
func remove(s []int, i int) []int,删除切片中指定索引的元素并返回新切片 - 用三索引切取
s[lo:hi:max]验证 cap 限制如何防止 append 影响原切片
对照实验
- 对比 nil 切片和空切片在 JSON 序列化时的输出差异(
json.Marshal) - 写一段代码验证:函数内 append 后,调用方的切片是否发生变化(分 cap 够用和 cap 不够两种情况)
- 用
go build -gcflags="-m"观察切片和数组的逃逸行为差异
上一篇:03-流程控制-if-for-switch.md 下一篇:05-map.md
豫公网安备41108202000982号