Go 的序列容器:固定长度的数组与动态的切片——切片是 Go 中最常用的数据结构


一、C++ 对照速查表

C++Go关键差异
std::array<T, N>[N]TGo 数组长度是类型的一部分,[3]int 和 [4]int 是不同类型
std::vector<T>[]T(切片)切片是底层数组的引用/视图,不是独立容器
vector::push_backappendappend 返回新切片,必须重新赋值
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 循环 append 10 个元素,观察 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