深入 Go 语言核心:map 和 slice 的传参有什么不同

在 Go 开发中,经常会遇到需要在函数中修改 map 或 slice 的场景。虽然它们都支持动态扩容,但在函数传参时的行为却大不相同。今天,让我们通过实例深入理解这个问题。

一个困惑的开始

看这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
// Map 示例
m := map[string]int{"old": 1}
modifyMap(m)
fmt.Println(m) // 输出: map[new:1]

// Slice 示例
s := []int{1, 2, 3}
modifySlice(s)
fmt.Println(s) // 输出: [100 2 3],而不是 [100 2 3 200]
}

func modifyMap(m map[string]int) {
m["new"] = 1 // 会影响原始 map
delete(m, "old") // 也会影响原始 map
}

func modifySlice(s []int) {
s[0] = 100 // 会影响原始 slice
s = append(s, 200) // 不会影响原始 slice
}

有趣的是:

  1. map 的所有操作都会影响原始数据
  2. slice 的简单索引修改会影响原始数据,但 append 可能不会

为什么会这样?让我们从内部结构开始分析。

内部结构解析

Map 的内部结构

1
2
3
4
5
6
7
type hmap struct {
count int // 元素个数
flags uint8 // 状态标志
B uint8 // 桶的对数 B
buckets unsafe.Pointer // 指向桶数组的指针
// ... 其他字段
}

当我们声明一个 map 变量时:

1
2
m := make(map[string]int)
// 实际上 m 是 *hmap,即指向 hmap 结构的指针

Slice 的内部结构

1
2
3
4
5
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 当前长度
cap int // 当前容量
}

当我们声明一个 slice 变量时:

1
2
s := make([]int, 0, 10)
// s 是一个完整的 slice 结构体,而不是指针

深入理解传参行为

场景一:简单修改(不涉及扩容)

1
2
3
4
func modifyBoth(m map[string]int, s []int) {
m["key"] = 1 // 通过指针修改原始 map
s[0] = 100 // 通过指向相同底层数组的指针修改
}

图解:

1
2
3
4
5
6
7
8
9
10
11
Map:
main()中的 m -----> hmap{...} <----- modifyBoth()中的 m
(同一个底层结构)

Slice:
main()中的 s = slice{array: 指向数组1, len: 3, cap: 3}
|
v
[1 2 3]
^
modifyBoth()中的 s = slice{array: 指向数组1, len: 3, cap: 3}

场景二:涉及扩容的操作

1
2
3
4
5
6
7
8
9
func expandBoth(m map[string]int, s []int) {
// map 扩容
for i := 0; i < 100; i++ {
m[fmt.Sprintf("key%d", i)] = i
}

// slice 扩容
s = append(s, 200)
}

图解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Map 扩容过程:
Before:
main()中的 m -----> hmap{buckets: 指向存储A}
^
expandBoth()中的 m ---------|

After:
main()中的 m -----> hmap{buckets: 指向更大的存储B} // 同一个 hmap,只是更新了内部指针
^
expandBoth()中的 m ---------|


Slice 扩容过程:
Before:
main()中的 s = slice{array: 指向数组A, len: 3, cap: 3}
|
v
[1 2 3]
^
expandBoth()中的 s = slice{array: 指向数组A, len: 3, cap: 3}

After append:
main()中的 s = slice{array: 指向数组A, len: 3, cap: 3} // 保持不变
|
v
[1 2 3]

expandBoth()中的 s = slice{array: 指向数组B, len: 4, cap: 6} // 新的结构体,指向新数组
|
v
[1 2 3 200]

关键区别解析

  1. 传递方式不同

    • map 传递的是指针,函数内外使用的是同一个 hmap 结构
    • slice 传递的是结构体副本,函数内的修改发生在副本上
  2. 扩容行为不同

    • map 扩容时,原有的 hmap 结构保持不变,只更新内部的 buckets 指针
    • slice 扩容时,会创建新的底层数组,并返回一个指向新数组的新 slice 结构体
  3. 修改效果不同

    • map 的所有操作(包括扩容)都会反映到原始数据
    • slice 的行为分两种情况:
      • 不涉及扩容的修改会影响原始数据(因为指向同一个底层数组)
      • 涉及扩容的操作(如 append)会创建新的底层数组,修改不会影响原始数据

最佳实践

基于以上原理,在编码时应注意:

  1. 对于 map:
1
2
3
func modifyMap(m map[string]int) {
m["key"] = 1 // 直接修改即可,不需要返回
}
  1. 对于 slice:
1
2
3
4
5
6
7
func modifySlice(s []int) []int {
// 如果需要 append 或其他可能导致扩容的操作
return append(s, 1)
}

// 使用时
s = modifySlice(s)

总结

理解 map 和 slice 的这些差异,关键在于:

  1. map 是指针类型,始终指向同一个 hmap 结构
  2. slice 是结构体,包含了指向底层数组的指针
  3. 扩容时 map 只更新内部指针,而 slice 需要创建新的底层数组

这种设计各有优势:

  • map 的行为更加统一和直观
  • slice 的设计提供了更多的灵活性和控制权

在实际编程中,正确理解和处理这些差异,是写出健壮 Go 代码的关键。


深入 Go 语言核心:map 和 slice 的传参有什么不同
https://hedon.top/2025/02/14/go-slice-vs-map/
Author
Hedon Wang
Posted on
2025-02-14
Licensed under