开篇:切片和 JS 数组,看着一样其实不一样

你在 JS 里天天用数组,const arr = [1,2,3]arr.push(4)arr.slice(0,2),闭着眼都能写。学 Go 时你看到 s := []int{1,2,3}s = append(s, 4),第一反应一定是:「这不就是数组吗?我会啊!」

停!这正是最大的陷阱。 Go 的切片看着像 JS 数组,底层却完全是另一回事。Go 切片隐藏了三个概念:指针、长度、容量,并且多个切片可能「共享」同一块内存。如果你带着 JS 的思维直接写,一定会撞上这种诡异现象:

a := []int{1, 2, 3, 4, 5}
b := a[1:4]          // b = [2 3 4]
b[0] = 99            // 改 b 的第一个元素
fmt.Println(a)       // [1 99 3 4 5]  ← 为什么 a 也变了?!

在 JS 里 arr.slice() 是深拷贝,改新数组不影响原数组。Go 不一样。这章我就带你把「为什么 a 也变了」彻底搞懂,搞懂了你就真正入门 Go 了。


4.1 数组(Array)—— 固定长度,几乎不用

先快速过一遍数组。Go 里 99% 用切片,数组几乎不用,但你得知道它存在,才能理解切片。

var arr1 [3]int              // [0 0 0],长度 3 的 int 数组
arr2 := [3]int{1, 2, 3}      // 字面量
arr3 := [...]int{1, 2, 3}    // ... 让编译器数长度(还是固定 3)

数组的两个「反直觉」特性

特性一:长度是类型的一部分

var a [3]int
var b [5]int
// a = b    // ❌ 编译错误![3]int 和 [5]int 是不同类型

特性二:数组是值类型(赋值会复制!)

arr1 := [3]int{1, 2, 3}
arr2 := arr1          // 整份复制,不是引用
arr2[0] = 99
fmt.Println(arr1)     // [1 2 3],没变

💡 为什么要告诉你这个? 因为「数组是值类型、会复制」这件事,正是切片要解决的问题——切片就是为了避免数组复制而设计的。下一节你就明白为什么。


4.2 切片(Slice)—— Go 的「数组」,最常用最重要

切片是你日常 99% 会用的类型,对应 JS 的 Array。

4.2.1 创建切片的几种方式

// 1. 字面量(最常用)
s := []int{1, 2, 3}

// 2. make(指定长度和容量)
s := make([]int, 5)        // len=5, cap=5
s := make([]int, 3, 10)    // len=3, cap=10

// 3. 从数组/切片切割
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4]              // [2 3 4],左闭右开

// 4. nil 切片
var s []int                // s == nil,len=0

4.2.2 切片的底层结构(灵魂所在)

切片本身不存数据,它只是「底层数组的一个视图」,由三个字段组成:

切片 s
┌─────────────────────────────────────────┐
│  ptr  → 指针,指向底层数组的某元素        │
│  len  → 长度,当前可见的元素个数          │
│  cap  → 容量,底层数组从 ptr 起的可用空间 │
└─────────────────────────────────────────┘

举个例子,s := make([]int, 3, 5) 在内存里长这样:

底层数组(cap=5,共 5 个槽位):
┌────┬────┬────┬────┬────┐
│ 0  │ 0  │ 0  │ _  │ _  │   ← 前三个可见(len=3),后两个预留(cap=5)
└────┴────┴────┴────┴────┘
切片 s: { ptr 指向这里, len=3, cap=5 }

记住这张图,切片的所有行为都由它解释。

s := make([]int, 3, 5)
fmt.Println(len(s), cap(s))   // 3 5

4.2.3 append 追加元素(必须重新赋值!)

s := []int{1, 2, 3}
s = append(s, 4)              // [1 2 3 4]
s = append(s, 5, 6, 7)        // [1 2 3 4 5 6 7]
s = append(s, []int{8, 9}...) // 追加另一个切片(注意三个点)

⚠️ 最大陷阱:append 必须重新赋值

append(s, 4)        // ❌ 编译能过,但 s 没变!
s = append(s, 4)    // ✅ 必须接收返回值

对比 JS:JS 的 arr.push(4) 直接改原数组。Go 的 append 可能触发扩容(生成新底层数组),所以必须用返回值覆盖原切片。

4.2.4 扩容机制(一步步演示)

len > cap 时 append 会扩容。我们演示 s = append(s, x)容量够容量不够时的差别:

情况 A:容量够,不扩容

初始:s = {ptr, len=3, cap=5}
底层数组:[1, 2, 3, _, _]

s = append(s, 4)   →  把 4 放进第 4 个槽位
底层数组:[1, 2, 3, 4, _]
切片 s: {ptr, len=4, cap=5}   ← len 变 4,cap 不变

情况 B:容量不够,扩容

初始:s = {ptr, len=5, cap=5}
底层数组:[1, 2, 3, 4, 5]   ← 满了

s = append(s, 6)
1. 申请一个更大的新数组(通常 2 倍:cap=10)
2. 把旧数组 [1,2,3,4,5] 复制过去
3. 把 6 放进去
4. s 指向新数组

旧数组:[1, 2, 3, 4, 5]     ← 等 GC 回收
新数组:[1, 2, 3, 4, 5, 6, _, _, _, _]
切片 s: {新ptr, len=6, cap=10}

这就是为什么 append 必须重新赋值——扩容后 s 指向了全新的内存,老 s 还指着旧数组。

4.2.5 共享底层数组(开篇那个诡异现象的解释)

现在回到开篇的例子:

a := []int{1, 2, 3, 4, 5}
b := a[1:4]          // b = [2 3 4]
b[0] = 99            // 改 b 的第一个元素
fmt.Println(a)       // [1 99 3 4 5]  ← a 也变了

为什么 a 变了?因为 a 和 b 共享同一个底层数组b := a[1:4] 不是复制,而是让 b 这个切片「指向 a 的底层数组从索引 1 开始的 3 个元素」:

底层数组(a 和 b 共享这一份):
索引:    0    1    2    3    4
值:     [1]  [2]  [3]  [4]  [5]
              ↑         ↑
              │         │
a: {ptr→0, len=5, cap=5}
b: {ptr→1, len=3, cap=4}   ← b[0] 就是底层的索引 1

b[0] = 99  改的是底层数组索引 1:
值:     [1]  [99] [3]  [4]  [5]

所以 a 看到索引 1 也变成了 99!

怎么避免?copy 创建独立副本:

s1 := []int{1, 2, 3}
s2 := make([]int, len(s1))
copy(s2, s1)        // s2 是 s1 的深拷贝
s2[0] = 99
fmt.Println(s1)     // [1 2 3],不受影响

4.2.6 切片的常用操作

// 长度
len(s)

// 追加
s = append(s, 4)

// 切片(左闭右开)
sub := s[1:4]

// 删除索引 i(Go 没有原生 delete,用 append 模拟)
i := 2
s = append(s[:i], s[i+1:]...)

4.2.7 遍历切片

s := []string{"a", "b", "c"}
for i, v := range s {
    fmt.Printf("%d: %s\n", i, v)
}

⚠️ range 的 v 是拷贝,改 v 不影响原切片,要改用索引:

for i := range users {
    users[i].Name = "X"   // ✅ 通过索引改
}

4.3 切片作为函数参数

切片传给函数,底层数组是共享的(这和数组相反)。所以函数内直接改元素外部能看到,但 append 可能导致「改了但外面看不到」

func modify(s []int) {
    s[0] = 999          // ✅ 改底层数组,外部可见
}

func appendFail(s []int) {
    s = append(s, 1)    // ❌ 可能扩容,外部 s 不变!
}

// 涉及 append 的函数要返回新切片
func appendOK(s []int) []int {
    return append(s, 1)
}

规则:要改元素用 s[i]=x;要追加元素,函数必须 return append(...),调用方接收返回值。


4.4 Map —— Go 的「对象」

Map 是键值对集合,对应 JS 的 Object / Map,PHP 的关联数组。

4.4.1 创建 Map

// 1. make
m := make(map[string]int)
m["apple"] = 5

// 2. 字面量
m := map[string]int{
    "apple":  5,
    "banana": 3,
}

// 3. nil map(只读,不能写!)
var m map[string]int
// m["x"] = 1    // ❌ 运行时 panic

4.4.2 基本操作

// 读取(不存在的 key 返回零值,不报错)
price := m["apple"]          // 5
pear := m["pear"]            // 0(不存在,返回零值)

// 判断 key 是否存在(重要!)
// 检验 pear 这个key是否存在
price, ok := m["pear"]       // ok=false 表示不存在
if ok {
    fmt.Println("有梨:", price)
}

// 删除
delete(m, "apple")

// 长度
len(m)

// 遍历(顺序随机!)
for k, v := range m {
    fmt.Println(k, v)
}

⚠️ 三个 Map 关键陷阱

  1. 遍历顺序随机——Go 故意打乱,防止依赖顺序。要有序先取 key 排序。
  2. nil map 不能写——会 panic。
  3. 读取不存在的 key 返回零值——要区分「值为 0」和「不存在」必须用 v, ok := m[k]

4.4.2 断言处理

有些时候,我们在处理一些map的各个值类型不太一样的时候可能使用any定义map的类型。

var m1 = map[string]any{
    "name": "如花",
    "age":  19,
}

这都没啥问题,不过当我们使用某个值做运算的时候,就会出现类型问题,比如我们 m1["age"] ,对程序来说,其实是不知道它是什么类型的。如果我们用它做运算,就会报错,比如:m1["age"] + 100,要解决这个问题就需要在运算之前通过断言指定他的类型:

// 通过断言指定类型为int
m1["age"].(int) + 100

4.5 报错诊断小节

学这章你一定会撞上这些错,对着查:

报错 1:append 后切片没变

s := []int{1,2,3}
append(s, 4)
fmt.Println(s)   // [1 2 3],没加进去!

原因:append 返回新切片,没接收。
修复s = append(s, 4)

报错 2:改了切片 b,切片 a 也变了(开篇现象)

原因:a 和 b 共享底层数组。
修复:用 copy 做独立副本。

报错 3:panic: assignment to entry in nil map

var m map[string]int
m["a"] = 1    // ← 报错

原因:nil map 不能写。
修复m := make(map[string]int) 先 make。

报错 4:panic: runtime error: index out of range

s := []int{1,2,3}
fmt.Println(s[5])   // ← 报错

原因:越界访问。Go 切片不像 JS 返回 undefined,直接 panic。
修复:先检查 if i < len(s)

报错 5:Map 并发读写 panic

// 多个 goroutine 同时读写同一个 map → fatal error: concurrent map writes

原因:map 非线程安全。
修复:用 sync.Mutex 加锁,或用 sync.Map(第 08 章详讲)。


4.6 Node.js ↔ Go 对照表

概念 JS Go 备注
动态数组 [] []T 切片 Go 用切片代替
追加 arr.push(x) s = append(s, x) 必须重新赋值
切片 arr.slice(1,3) s[1:3] 左闭右开
深拷贝 [...arr] copy(dst, src)
关联数组 obj / Map map[K]V
检查 key "k" in obj v, ok := m["k"]
删除 key delete obj.k delete(m, "k")
遍历对象 for k in obj for k, v := range m 顺序随机
越界访问 返回 undefined panic Go 严格