Go语言学习指南:惯例模式与编程实践
上QQ阅读APP看书,第一时间看更新

3.4 映射

当处理一组连续数据时,切片将会很有用。与大多数语言一样,Go提供了一种内置数据类型来处理互相关联的类型。这种映射类型写作map[keyType]valueType。接下来我们看看声明映射的不同方法。第一种是使用var声明来创建一个其值为零值的映射变量:

这种情况下,nilMap被声明为一个键为字符串类型,值为整型的映射类型。映射的零值为nil。值为nil的映射长度为0。尝试读取一个nil映射总是会返回该映射值类型的零值。而如果尝试对nil映射写数据则会导致崩溃。

我们可以使用:=并通过映射字面量来创建映射变量:

这种情况下我们使用了一个空映射字面量。这种方式和nil映射不太一样。虽然它的长度为0,但是可以对分配给空映射字面量的映射进行读写操作。非空映射字面量如下所示:

映射字面量由键、冒号(:)和值构成。映射中每一个键-值对由逗号分隔,并且最后一行后也需要逗号。在这个例子中,值是一个字符串类型的切片。映射的值可以是任意类型,而键的类型有一些限制,我们稍后讨论。

如果已知映射中需要多少键-值对,但是不知道确切的值,可以使用make来创建一个有默认长度的映射:

使用make创建的映射长度依然为0,并且长度可以超出初始定义的长度。

映射和切片有几点很相似:

  • 向映射中添加键-值对会增加映射的长度。
  • 如果已知映射中要插入多少键-值对,可以通过make创建具有特定初始大小的映射。
  • 将映射传入len函数会得到映射中键-值对的数量。
  • 映射的零值为nil
  • 映射不能进行比较。可以判断映射是否等于nil,但是不能通过==或者!=去比较两个映射的键和值是否相同。

映射的值可以是任何可比较的类型。这意味着切片或者映射不能作为映射的值。

如何在映射和切片之间做出正确的选择呢?切片用于处理数据列表,尤其是需要顺序处理的数据。映射则用在不需要关心数据顺序的情况。

 如果不关心元素的顺序,则可以使用映射。如果元素之间的顺序很关键,就使用切片。

什么是哈希映射?

在计算机科学中,映射是一种数据结构,用于将数据关联(绑定)起来。映射的实现方式有多种,每一种都有其优缺点。Go中实现的映射使用哈希映射。为了让读者更加熟悉其概念,这里简单介绍一下。

哈希映射可以快速地通过一个键查找到对应的值,它的内部实现为数组。当添加一个键和值时,键会通过哈希算法转变为一个数。每个键产生的数并不是唯一的。哈希算法可以将不同的键转化为同一个值,然后将这个值作为数组的索引。数组中的每个元素称为桶,键-值对就存储在桶中。如果桶中已经有一个键了,那么新的值会替换原先的值。

每个桶也可视为一个数组,所以桶可以容纳多个值。如果两个键存在于同一个桶中,这种情况称为碰撞,两个键和值就都存储在这个桶中。

从哈希映射中读取数据的方式一样。通过哈希算法将键转化为一个数字,然后找到对应的桶,接着遍历桶中的键找出是否有相同的键。如果找到了,就返回对应的值。

要尽量避免过多的碰撞,因为碰撞越多,从哈希映射中取得结果的时间也会越长,这是因为我们要花更多的时间去遍历同一个桶中的所有键。巧妙的哈希算法会保证碰撞次数最小化,并且如果桶中添加了足够多的元素,哈希映射会重新调整桶的大小以装下更多元素。

哈希映射非常实用,但是自己构建一个功能正确的哈希映射却很难。想了解Go是如何做的,可以查看GopherCon 2016的演讲中映射的内部实现(https://oreil.ly/kIeJM)。

Go不需要(甚至不允许)定义自己的哈希算法或者相等性定义(equality definition)。Go的运行时会对所有允许作为键的类型进行哈希算法处理。

3.4.1 映射的读写操作

我们通过一段简短的代码看看如何声明、写入和读取映射。你可以在The Go Playground(https://oreil.ly/gBMvf)中运行示例3-10。

示例3-10:映射的使用

运行结果如下:

我们通过中括号中的键来读取映射键-值对的值,同样也可以使用=符号给中括号中的键赋值。但要注意的是不能通过:=去赋值映射的键和值。

当我们尝试去读取一个映射中不存在的键时,映射会返回映射的值类型的零值。在示例3-10中,值的类型为整型,所以会返回0。++运算符也可以用来累加映射键的数值。因为映射默认返回零值,所以映射中的键不存在也没问题。

3.4.2 逗号和ok模式

我们已经看到,如果映射中不存在对应的键,就会返回零值。这在实现计数器等功能时很方便。不过,有时我们需要知道某个键在映射中是否存在。Go提供了一个逗号和ok模式(comma ok idiom)来检测返回零值是由于存在与零值关联的键还是由于这个键不存在:

通过逗号和ok模式,我们不是将映射的结果赋值给单个变量,而是将映射的结果赋值给两个变量。第一个变量得到与键对应的值,第二个则为布尔值,通常这个变量名为ok。如果oktrue,就代表映射中存在这个键;如果okfalse,则映射中不存在该键。所以以上代码打印的结果为5 true、0 true和0 false。

 Go中的逗号和ok模式被用于区分是否读取到了真正的值。我们会在第10章和第7章中再次看到这个用法。

3.4.3 从映射中删除

映射中的键-值对可以通过内置的delete函数删除:

delete函数接受一个映射和键作为参数,用来删除指定键的键-值对。如果这个键在映射中不存在或者映射本身为nil,则什么事都不会发生。delete函数不会返回任何结果。

3.4.4 映射模拟集合

许多语言都内置set(集合)。集合是一种数据类型,它的特点是保证最多只有一个值,但是并不保证这些值的顺序。而且不管集合中有多少元素,查找集合中的任何元素都很快(相应地,切片中的元素越多,查找元素的时间就越长)。

Go默认并没有集合,但是通过映射可以模拟它的一部分功能。将需要放入集合的值作为映射的键,然后使用布尔值作为值的类型。示例3-11展示了这个概念,你可以在The Go Playground(https://oreil.ly/wC6XK)中运行示例3-11。

示例3-11:映射模拟集合

我们想要一组整数的集合,因此创建了一个映射,其中键是整型,值是布尔型。我们使用for-range循环(详见4.3.5节)遍历vals中的所有元素并将它们放入intSet,同时将每一个整型键赋值为true

我们为intSet添加了11次值,但是intSet的长度却是8,这是因为映射不可能存在重复的键。如果在intSet中查找5,结果会是true,因为我们确实有一个值为5的键。但是如果在intSet中查找500或者100,结果则是false,这是因为我们并没有将它们放入intSet之中,因此映射会返回零值,而布尔的零值是false

如果想让集合支持并集、交集和差集等功能,我们需要自己实现或者使用其他第三方实现(我们会在第9章讨论如何使用第三方库)。

 一部分人倾向于使用struct{}作为映射值来实现集合(结构体见3.5节)。好处是空的结构体不占用字节,而布尔值占用一个字节。

使用struct{}的缺点是会让代码的实现更加麻烦。这不仅会导致赋值不够清晰,而且也不得不使用逗号和ok模式来检查集合中是否包含某个值:

除非需要很大的集合,否则在内存使用方面的性能提升不太可能抵消其缺点。