开篇:切片和 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 关键陷阱:
- 遍历顺序随机——Go 故意打乱,防止依赖顺序。要有序先取 key 排序。
- nil map 不能写——会 panic。
- 读取不存在的 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 严格 |
💬 评论 0
还没有评论,快来抢沙发吧~