【GoLang笔记】实例分析GoLang built-in数据结构map的赋值引用行为

备注1:本文旨在介绍Go语言中map这个内置数据结构的引用行为,并用实例来说明如何避免这种引用行为带来的“副作用”。

备注2:文末列出的参考资料均来自GoLang.org官方文档,需翻墙访问。

1. map internals

map是go中内置的数据结构,关于其语法规则,可以查看language specification中这里的说明,或者查看Effective Go中关于Maps的说明,此处略过。

map的底层是用hashmap实现的(底层hashmap源码路径为src/pkg/runtime/hashmap.c),部分注释摘出如下:

// This file contains the implementation of Go's map type.
//
// The map is just a hash table.  The data is arranged
// into an array of buckets.  Each bucket contains up to
// 8 key/value pairs.  The low-order bits of the hash are
// used to select a bucket.  Each bucket contains a few
// high-order bits of each hash to distinguish the entries
// within a single bucket.
//
// If more than 8 keys hash to a bucket, we chain on
// extra buckets.
//
// When the hashtable grows, we allocate a new array
// of buckets twice as big.  Buckets are incrementally
// copied from the old bucket array to the new bucket array.

这段注释除表明map底层确实是hashmap实现的外,还解释了hashmap部分实现细节。此外,源码中还包含遍历map的处理细节以及一个map性能的小实验,可以查看源码文件了解。

目前已经清楚,map在内部维护了一个hashmap,那么语法层面的map数据结构是如何与底层的hashmap关联起来的呢?

在Effective Go关于Maps的说明文档中,有这样一句话:

Like slices, maps hold references to an underlying data structure. If you pass a map to a function that changes the contents of the map, the changes will be visible in
the caller.

具体而言,map这个数据结构在内部维护了一个指针,该指针指向一个真正存放数据的hashmap。参考Go官网博客的文章Go Slices: usageand
internals
关于slice内部结构的说明,再结合map底层hashmap.c源码片段(注意下面摘出的Hmap结构体定义中的count和buckets字段,而oldbuckets只在map rehash时有用),可以看出map内部确实维护着map元素的count和指向hashmap的指针。

struct Hmap
{
    // Note: the format of the Hmap is encoded in ../../cmd/gc/reflect.c and
    // ../reflect/type.go.  Don't change this structure without also changing that code!
    uintgo  count;        // # live cells == size of map.  Must be first (used by len() builtin)
    uint32  flags;
    uint32  hash0;        // hash seed
    uint8   B;            // log_2 of # of buckets (can hold up to LOAD * 2^B items)
    uint8   keysize;      // key size in bytes
    uint8   valuesize;    // value size in bytes
    uint16  bucketsize;   // bucket size in bytes

    byte    *buckets;     // array of 2^B Buckets. may be nil if count==0.
    byte    *oldbuckets;  // previous bucket array of half the size, non-nil only when growing
    uintptr nevacuate;    // progress counter for evacuation (buckets less than this have been evacuated)
};

2. map type is reference type

先看下面一段简单代码:

// demo.go
package main

import "fmt"

func main() {
    foo := make(map[string]string)
    foo["foo"] = "foo_v"
    bar := foo
    bar["bar"] = "bar_v"

    fmt.Printf("foo=%v, ptr_foo=%v\n", foo, &foo)
    fmt.Printf("bar=%v, ptr_bar=%v\n", bar, &bar)
}

编译并执行:

$ go build demo.go
$ ./demo

输出结果如下:

foo=map[foo:foo_v bar:bar_v], ptr_foo=0xc210000018
bar=map[foo:foo_v bar:bar_v], ptr_bar=0xc210000020

看到了吧?当我们执行bar := foo时,bar被自动声明为map[string][string]类型并进行赋值,而这个赋值行为并没有为bar申请一个新的hashmap并把foo底层的hashmap内容copy过去,它只是把foo指向底层hashmap的指针copy给了bar,赋值后,它们指向同一个底层hashmap。这个行为类似于C++中的“浅拷贝”行为。

可见,正如Go maps in action https://blog.golang.org/go-maps-in-action一文中提到的,Go的map类型是引用类型(Map types are reference types)。关于Go语言的设计者们为何要把map设计成reference type,可以参考Go FAQ在这里的解释。新手需要特别注意这种引用行为,下面开始用实例来说明。

3. handel "deep copy" manually if necessary

有时候,业务场景并不希望两个map变量指向同一个底层hashmap,但若Go新手恰好对map的引用行为理解不深的话,很有可能踩到坑,我们来段有Bug的代码感受下。

// bug version: mapref.go
package main

import (
    "fmt"
)

func main() {
    foo := make(map[string]map[string]map[string]float32)
    foo_s12 := map[string]float32{"s2": 0.1}
    foo_s1 := map[string]map[string]float32{"s1": foo_s12}
    foo["x1"] = foo_s1
    foo_s22 := map[string]float32{"s2": 0.5}
    foo_s2 := map[string]map[string]float32{"s1": foo_s22}
    foo["x2"] = foo_s2

    x3 := make(map[string]map[string]float32)
    for _, v := range foo {
        for sk, sv := range v {
            if _, ok := x3[sk]; ok {
                for tmpk, tmpv := range sv {
                    if _, ok := x3[sk][tmpk]; ok {
                        x3[sk][tmpk] += tmpv
                    } else {
                        x3[sk][tmpk] = tmpv
                    }
                }
            } else {
                x3[sk] = sv  ## 注意这里,map的赋值是个引用行为!
            }
        }
    }
    fmt.Printf("foo=%v\n", foo)
    fmt.Printf("x3=%v\n", x3)
}

上述代码的目的是对一个3层map根据第2层key做merge(本例中是值累加),最终结果存入x3。比如,foo的一级key是"x1"和"x2",其对应的value都是个两级map结构,我们要对1级key的value这个两级map根据其key做merge,具体在上述代码中,一级key对应的value分别是map[s1:map[s2:0.1]]和map[s1:map[s2:0.5]],由于它们有公共的key "s1",所以需要merge s1的value,而由于s1 value也是个map(分别是map[s2:0.1]和map[s2:0.5])且它们仍有公共key
"s2",所以需要对两个s2的value做累加。总之,我们预期的结果是x3 = map[s1:map[s2:0.6]],同时不改变原来的那个3层map的值。

下面是上述代码的执行结果:

foo=map[x1:map[s1:map[s2:0.6]] x2:map[s1:map[s2:0.5]]]
x3=map[s1:map[s2:0.6]]

可以看到,x3确实得到了预期的结果,但是,foo的值却被修改了(注意foo["x1"]["s1"]["s2"]的值由原来的0.1变成了0.6),如果应用程序后面要用到foo,那这个坑肯定是踩定了。

bug是哪里引入的呢?

请看代码中x3[sk] = sv那句(第29行),由于前面提到的map的reference特性,s3[sk]的值与sv指向的是同一个hashmap,而代码在第30-34行对x3[sk]的值做进一步merge时,修改了这个hashmap!这会导致foo["x1"]["s1"]["s2"]的值也被修改(因为它们共用底层存储区)。

所以,这种情况下,我们必须手动对目的map做“深拷贝”,避免源map也被修改,下面是bug fix后的代码。

// bug fix version: mapref.go
package main

import (
    "fmt"
)

func main() {
    foo := make(map[string]map[string]map[string]float32)
    foo_s12 := map[string]float32{"s2": 0.1}
    foo_s1 := map[string]map[string]float32{"s1": foo_s12}
    foo["x1"] = foo_s1
    foo_s22 := map[string]float32{"s2": 0.5}
    foo_s2 := map[string]map[string]float32{"s1": foo_s22}
    foo["x2"] = foo_s2

    x3 := make(map[string]map[string]float32)
    for _, v := range foo {
        for sk, sv := range v {
            if _, ok := x3[sk]; ok {
                for tmpk, tmpv := range sv {
                    if _, ok := x3[sk][tmpk]; ok {
                        x3[sk][tmpk] += tmpv
                    } else {
                        x3[sk][tmpk] = tmpv
                    }
                }
            } else {
                // handel "deep copy" manually if necessary
                tmp := make(map[string]float32)
                for k, v := range sv {
                    tmp[k] = v
                }
                x3[sk] = tmp
            }
        }
    }
    fmt.Printf("foo=%v\n", foo)
    fmt.Printf("x3=%v\n", x3)
}

执行结果符合预期:

foo=map[x1:map[s1:map[s2:0.1]] x2:map[s1:map[s2:0.5]]]
x3=map[s1:map[s2:0.6]]

以上就是本文要说明的问题及避免写出相关bug代码的方法。

其实Go map的这个行为与Python中的dict行为非常类似,引入bug的原因都是由于它们的赋值行为是by reference的。关于Python的类似问题,之前的一篇笔记有过说明,感兴趣的话可以去查看。

【参考资料】

1. Go source code - src/pkg/runtime/hashmap.c

2. The Go Programming Language Specification - Map types

3. Effective Go - Maps

4. Go Slices: usage and internals

5. Go maps in action

6. Go FAQ - Why are maps, slices, and channels references while arrays are values?

============================ EOF ========================

时间: 2024-08-14 21:14:43

【GoLang笔记】实例分析GoLang built-in数据结构map的赋值引用行为的相关文章

【GoLang笔记】遍历map时的key随机化问题及解决方法

之前的一篇笔记曾分析过,Go的map在底层是用hashmap实现的.由于高效的hash函数肯定不是对key做顺序散列的,所以,与其它语言实现的hashmap类似,在使用Go语言map过程中,key-value的插入顺序与遍历map时key的访问顺序是不相同的.熟悉hashmap的同学对这个情况应该非常清楚. 所以,本文要提到的肯定不是这个,而是一个比较让人惊奇的情况,下面开始说明. 1. 通过range遍历map时,key的顺序被随机化 在golang 1.4版本中,借助关键字range对Go语

第十七篇:实例分析(4)--初探WDDM驱动学习笔记(十一)

感觉有必要把 KMDDOD_INITIALIZATION_DATA 中的这些函数指针的意思解释一下, 以便进一步的深入代码. DxgkDdiAddDevice 前面已经说过, 这个函数的主要内容是,将BASIC_DISPLAY_DRIVER实例指针存在context中, 以便后期使用, 支持多实例. DxgkDdiStartDevice 取得设备信息, 往注册表中加入内容, 从POST设备中获取FRAME BUFFER以及相关信息(DxgkCbAcquirePostDisplayOwnershi

第十七篇:实例分析(3)--初探WDDM驱动学习笔记(十)

续: 还是记录一下, BltFuncs.cpp中的函数作用: CONVERT_32BPP_TO_16BPP 是将32bit的pixel转换成16bit的形式. 输入是DWORD 32位中, BYTE 0,1,2分别是RGB分量, 而BYTE3则是不用的 为了不减少color的范围, 所以,都是取RGB8,8,8的高RGB5, 6, 5位, 然后将这16位构成一个pixel. CONVERT_16BPP_TO_32BPP是将16bit的pixel转换成32bit的形式 输入是WORD 16BIT中

golang slice性能分析

golang在gc这块的做得比较弱,频繁地申请和释放内存会消耗很多的资源.另外slice使用数组实现,有一个容量和长度的问题,当slice的容量用完再继续添加元素时需要扩容,而这个扩容会把申请新的空间,把老的内容复制到新的空间,这是一个非常耗时的操作.有两种方式可以减少这个问题带来的性能开销: 在slice初始化的时候设置capacity(但更多的时候我们可能并不知道capacity的大小) 复用slice 下面就针对这两个优化设计了如下的benchmark,代码在: https://githu

golang pprof 性能分析工具

性能优化是个永恒的话题,而很多时候我们在作性能优化的时候,往往基于代码上面的直觉,把所有能想到的优化都优化了一遍,不错过任何小的优化点,结果整个代码的逻辑变得极其复杂,而性能上面并没有太大的提升.事实上,性能问题往往集中在某些小点,有时候很小的改动就能有巨大的提升,所以问题的关键是是怎么去找出这些优化点,幸运的是 golang 在设计的时候就考虑了这个问题,原生提供了性能分析的工具,可以很方便地帮我们找到性能瓶颈 pprof 简介 golang 的性能分析库在 runtime/pprof 里,主

GO语言文件的创建与打开实例分析

本文实例分析了GO语言文件的创建与打开用法.分享给大家供大家参考.具体分析如下: 文件操作是个很重要的话题,使用也非常频繁,熟悉如何操作文件是必不可少的.Golang 对文件的支持是在 os package 里,具体操作都封装在 type File struct {} 结构体中. 一.func Open(name string) (file *File, err error)再简单不过了,给一个路径给它,返回文件描述符,如果出现错误就会返回一个 *PathError.这是一个只读打开模式,实际上

实例分析Java Class的文件结构

实例分析Java Class的文件结构 博客分类: Java SE 今天把之前在Evernote中的笔记重新整理了一下,发上来供对java class 文件结构的有兴趣的同学参考一下. 学习Java的朋友应该都知道Java从刚开始的时候就打着平台无关性的旗号,说“一次编写,到处运行”,其实说到无关性,Java平台还有另外一个无关性那就是语言无关性,要实现语言无关性,那么Java体系中的class的文件结构或者说是字节码就显得相当重要了,其实Java从刚开始的时候就有两套规范,一个是Java语言规

【转】Linux I2C设备驱动编写(三)-实例分析AM3359

原文网址:http://www.cnblogs.com/biglucky/p/4059586.html TI-AM3359 I2C适配器实例分析 I2C Spec简述 特性: 兼容飞利浦I2C 2.1版本规格 支持标准模式(100K bits/s)和快速模式(400K bits/s) 多路接收.发送模式 支持7bit.10bit设备地址模式 32字节FIFO缓冲区 可编程时钟发生器 双DMA通道,一条中断线 三个I2C模块实例I2C0\I2C1\I2C2 时钟信号能够达到最高48MHz,来自PR

Linux I2C设备驱动编写(三)-实例分析AM3359

TI-AM3359 I2C适配器实例分析 I2C Spec简述 特性: 兼容飞利浦I2C 2.1版本规格 支持标准模式(100K bits/s)和快速模式(400K bits/s) 多路接收.发送模式 支持7bit.10bit设备地址模式 32字节FIFO缓冲区 可编程时钟发生器 双DMA通道,一条中断线 三个I2C模块实例I2C0\I2C1\I2C2 时钟信号能够达到最高48MHz,来自PRCM 不支持 SCCB协议 高速模式(3.4MBPS) 管脚 管脚 类型 描述 I2Cx_SCL I/O