深圳幻海软件技术有限公司 欢迎您!

Go 函数的 Map 型参数,会发生扩容后指向不同底层内存的事儿吗?

2023-02-28

本文转载自微信公众号「网管叨bi叨」,作者KevinYan11。转载本文请联系网管叨bi叨公众号。最近跟同事做项目,由于要在函数里向一个Map中写入不少数据,这个Map是作为参数传到函数里的。他问了我一个问题:“如果把Map作为函数参数传递,会不会像用Slice做参数时一样诡异,是不是一定要把Map

本文转载自微信公众号「网管叨bi叨」,作者KevinYan11。转载本文请联系网管叨bi叨公众号。

最近跟同事做项目,由于要在函数里向一个 Map 中写入不少数据,这个 Map 是作为参数传到函数里的。他问了我一个问题: “如果把 Map 作为函数参数传递,会不会像用 Slice 做参数时一样诡异,是不是一定要把 Map 当成返回值返回才能让函数外部的 Map 变量看到这里添加的数据”?

啥叫会不会像用 Slice 做参数时一样诡异?同事没有明说,其实我已经猜到他说的是什么意思了,说的应该是 Slice 的底层数组如果发生了扩容后会让函数内外原本指向同一个底层数组的两个 Slice 变量,分别指向两个不同的底层数组。

最后就导致了函数内做的数据添加,但是函数外原来的 Slice 变量并没有任何改变的诡异效果。光看字儿解释起来有点难懂,举个例子,有下面这样一个程序。

func main() {
  s := []int{1, 2, 3}
  reverse(s)
  fmt.Println(s)
}

func reverse(s []int) {
  s = append(s, 999, 1000, 1001)
  for i, j := 0, len(s)-1; i < j; i++ {
    j = len(s) - (i + 1)
    s[i], s[j] = s[j], s[i]
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

本来切片只有 3 个元素,分别是 1,2,3。我们把切片赋给了变量 s,然后用变量 s 作为参数传给了函数 reverse 进行处理,函数 reverse 在反转切片元素之前还给原来的切片先追加了几个值,这就导致了切片发生扩容。因为切片实际上并不是一个指针类型,它的运行时类型表示是 SliceHeader。

type SliceHeader struct {
 Data uintptr
 Len  int
 Cap  int
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

因为 Go 里边有一切都是值传递的规则,所以切片作为参数时,会在函数内重新拷贝一个 SliceHeader 结构体,只不过结构体的 Data 指针一开始跟外部切片的指向是一样的,都是同一个底层数据。

这就导致了函数内切片 SliceHeader 里的 Data 指针发生变化后,函数外原来的切片还是指向原来的底层数组。最后结果,打印函数外切片变量输出的是 [1, 2, 3],但函数里边的切片已经是 [1001, 1000, 999, 3, 2, 1] 了。

下面这个图,展示了这个函数内外切片指向的底层数组发生变化的过程。

那么如果用 Map 当函数参数时,有这档子破事儿吗?诶,提到这我就要吐槽下这个一切都是传值的设计了,把一些写 Go 的程序员搞的战战兢兢,用 Map 和结构体指针当参数的时候也老琢磨底层会不会变。

当然我也不是写 Go 的时候都盲目自信,一般书上、别人文章里写的东西我在用的时候,如果不确定他们说的对不对,我都会写个单测试一试。事后再找找解释这些知识点的资料看看,自己解惑一下。

聊远了,下面说下答案哈,如果用 Map 当函数参数,Map发生扩容后,函数内外的Map变量指向的底层内存仍是一致的。这是为什么呢?答案我是在《Go 语言设计与实现》哈希表这一章找到的,有书的可以翻开 75 页看看。

如果没有书的可以看文末的引用链接里贴的在线书籍地址。

关于 Map 的初始化是这么描述的

使用 make 创建哈希,Go 语言编译器都会在类型检查期间将它们转换成 runtime.makemap,使用字面量初始化哈希也只是语言提供的辅助工具,最后调用的都是 runtime.makemap:

func makemap(t *maptype, hint int, h *hmap) *hmap {
 mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
 if overflow || mem > maxAlloc {
  hint = 0
 }
 ......
 return h
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

通过上面的解释和代码我们了解到 Map 这个数据类型,在运行时实际上是一个 hmap类型的指针,只不过在我们写代码阶段被隐藏起来了。

既然是一个 Map 类型的变量实际上是一个指针变量,这跟 Slice 就完全不同了,虽然指针作为函数参数时在 Go 里面也是按照值传递的,但是内外两个指针是指向的同一个 hamp 结构所在的内存,hmap 结构里有很多字段,回答这里的问题,我们只需要知道 buckets 和 oldbuckets 这两个指针类型的字段就行了。

type hmap struct {
 count     int
 flags     uint8
 B         uint8
 noverflow uint16
 hash0     uint32

 buckets    unsafe.Pointer
 oldbuckets unsafe.Pointer
 nevacuate  uintptr

 extra *mapextra
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

Go 的 Map中用于存储键值对数据的结构--桶(bmap),对于bmap 我们不再深挖下去。

buckets 是指向桶数组的。当哈希表增长到需要扩容的时候,Go语言会将bucket数组的数量扩充一倍,产生一个新的bucket数组,老数据存放在 oldbuckets 指向的桶中,并在被访问到时迁移到新桶中去。

这里虽然扩容导致 Map 有了新 bucket 数组的地址,但是这个地址是存在 hmap 的字段 buckets 上的,变更字段的值并不会影响 hmap本身的内存地址。

所以当 Map 由于函数内的操作发生扩容时,不会像上面例子里的 Slice 指向不同底层数组的诡异现象。

不知道大家有没有看明白我这里的分析,这篇文章其实是我自己对思考问题的一个记录,防止时间长了以后忘掉。传值、传引用这些在不同的语言里不一样,对于像我们掌握了至少三门编程语言的男人:)也就只能靠写写笔记防止混淆啦。

(我相信绝大多数人的职业生涯是不能靠一门编程语言吃遍天的)

还有一点我是觉得 Go 的 Slice 使用起来确实要耗费的心智有点高,一不注意就容易踩坑,时间长了,搞的大家用 Map 和 指针当参数时也会先自我怀疑一下,希望这篇文章对解决掉你们的使用疑虑有一定帮助。

引用地址

Go 语言设计与实现 --哈希表 https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-hashmap