14. 集合:Map的使用

在Scala中,Map(映射)是基于键值对(key/value)的集合,并且键是唯一的,就像Java的Map,Ruby的Hash,或者Python的字典。14.1小节介绍了Map的创建、不可变Map以及可变Map的基本使用。

介绍完Map的基本使用后,14.2小节将会选择不同的Map实现来满足特殊的场景和功能。接着,14.3和14.4小节将分别涵盖不可变和可变Map的添加、更新、删除元素的介绍。

Scala中的Map相比较于Java,最大的不同在于Scala中的Map默认是不可变的,如果以前没有用过不可变集合,在Map中尝试添加、删除、修改元素,将会感受到非常大的惊喜。

除了添加、删除、替换元素之外,还涵盖其他常用的Map功能,比如使用键和值(14.5至14.8小节)、以及遍历(14.9小节)、排序(14.10小节)和过滤(14.11小节)Map。

14.1 创建和使用Map

问题

你想要在Scala应用中创建Map并使用它,即一个包含键值对的数据结构,就像Java的Map,Python的字典,或者Ruby的Hash。

解决方案

有时候,你需要用到键值对的数据结构,在Scala中可以直接创建不可变和可变的Map类型。

不可变Map

使用不可变的Map,不需要使用任何import语句,直接创建一个Map实例:

    val a = Map(
        "AL" -> "Alabama",
        "AK" -> "Alaska"
    )

这个例子创建了一个类型为 [String, String] 的不可变Map,表示keyvalue都是String类型。在第一个元素中,字符串AL是key(键),Alabama是value(值)。

一旦创建了一个不可变的Map,就可以指定要添加、更新和删除的元素,同时将生成的结果Map赋值给一个新的变量,下面的例子展示了这些:

当使用不可变Map,可以创建一个var变量,这样就可以不用创建不同名字的变量为结果Map赋值:

可变Map

要创建一个可变 Map,要么使用import语句将其带入作用域中,要么就在创建实例时指定scala.collection.mutable.Map类的完整路径。当作用域中有了可变的Map,接下来就可以使用其中的功能。下面的例子将展示一些基本功能:

讨论

和其他语言中的Map一样,Scala中的Map是键值对的集合。如果熟悉Java中的Map,Python的字典,或者Ruby的Hash,那么Scala中的Map就很容易理解了。只需要学习一些新的东西,包括Map类中可用的方法,Map适用的场景,比如创建一个有序的Map

注意在Map的括号内创建一个元组所使用的语法:

由于元组也能被声明为 ("AL", "Alabama"),所以也可以像这样创建Map

为了清楚地表明正在使用可变的Map,有个技巧是可以在导入可变Map时给它指定一个别名,然后在使用时引用这个别名,如下所示:

9.3小节对这个技巧进行了更多的介绍。

14.2 选择一种Map实现

问题

你需要选择合适的Map类来解决特定的问题。

解决方案

在Scala中,可用的Map类型很多,甚至还能使用Java的Map类。讨论中提供了大多数类的全部列表,但其中一些最受欢迎的类包括:

不可变 Map

一个基础的不可变map,不能保证其键返回的顺序

可变 Map

一个基础的可变map,不能保证其键返回的顺序

SortedMap

按照键的顺序返回其元素

LinkedHashMap

按元素插入的顺序返回元素

VectorMap

通过基于 vector/map 的数据结构中存储元素来保持插入顺序

WeakHashMap

根据Scaladoc,“如果键不再被强引用,则会删除键值对”

其他几个章节中详细介绍了基本的不可变和可变Map,因此这里不多做介绍。SortedMapLinkedHashMap包含在小节14.10中,因此这里只简单接触一下。

基本的不可变和可变Map

如果不关心排序或者插入顺序,可以使用默认的基本的不可变Map或者14.1小节所示的可变Map

SortedMap

如果想要按照键的顺序返回Map中的元素,可以使用SortedMap

将代码粘贴到REPL中,可以得到以下输出:

使用LinkedHashMap、VectorMap或者ListMap记住插入顺序

如果想要Map记住元素的插入顺序,可以使用LinkedHashMapVectorMap或者ListMap。Scala中只有可变的LinkedHashMap,所以使用前先导入:

当创建一个LinkedHashMap实例,它会按照元素的插入顺序存储元素:

根据Scaladoc,VectorMap “使用基于vector/map的数据结构实现不可变的Map,从而保留元素的插入顺序...VectorMap的查找是常数时间复杂度,但代价是使用额外的内存,其他操作的性能通常较低。” 它与基本的不可变Map类似,但保留了元素的插入顺序:

也可以使用不可变的ListMap,但这是一个有点特别的结构。在内部,元素像List一样存储,最新的元素存储在头部位置。根据ListMap的Scaladoc( https://oreil.ly/OrZpT ),“ListMap迭代器和遍历方法会按照键值对被首次插入的顺序访问,” 也就是从尾部到头部的访问顺序。因此,ListMap的性能可能很差,只适用于比较小的数据量。有关更多性能的详细信息,请参阅不可变的ListMap的Scaladoc。

讨论

表14-1展示了Scala基本的Map类和特质,并提供了相应的简单描述。括号中的文本摘自每个类的Scaladoc。

表14-1:基本的Map类和特质

类或特质
描述

collection.immutable.Map

如果不引入任何东西,这就是默认的,通常意义上的不可变Map。

collection.mutable.Map

基本Map的可变版本。

collection.immutable.VectorMap

“使用基于vector/map的数据结构实现不可变的Map,从而保留元素的插入顺序;查找是常数时间复杂度,但代价是使用额外的内存,其他操作的性能通常较低。”

collection.mutable.LinkedHashMap

所有遍历元素的方法都以插入的顺序访问元素。

collection.immutable.SortedMap

Map的键按照顺序返回。“键值对根据键的scala.math.Ordering定义排序规则。”

虽然这些都是最常见的Map,Scala还提供了更多的Map类型。详细见表14-2,括号中的文本摘自每个类的Scaladoc。

表14-2:更多的Map类和特质

类或特质
描述

collection.immutable.HashMap

“使用压缩的哈希数组映射的前缀树实现的不可变Map。”

collection.immutable.TreeMap

“当使用范围查询或需要按顺序遍历时,此类是最佳的。”

collection.mutable.WeakHashMap

java.util.WeakHashMap的包装类,“如果键不再被强引用,Map会移除这个键值对。”

collection.immutable.ListMap

“使用基于List的数据结构实现不可变Map”;一些操作的时间复杂度是O(n),“所以只适用于比较小的数据量。”

还有一些其他的Map类参考Scala集合库( https://oreil.ly/EB8AD ),“提供了Scala 2.13标准集合的各种新增内容。”

比如MultiDict类替代了旧的MultiMap类,“可以将键和一组值进行关联。”

还有SortedMultiDict类,是MultiDict的有序版本。

并行Map类

从Scala 2.13开始,并行的集合库( https://oreil.ly/WdrEV )被迁移到了一个新的JAR中,并且在Github上进行维护。库中内置了并行/并发的Map实现,比如collection.parallel.immutable.ParMapcollection.parallel.immutable.ParHashMapcollection.parallel.mutable.ParHashMap等等。详情可以参考链接中的Github项目。

另见

  • 如果看重Map的性能,请参阅11.2小节。

14.3 为不可变Map添加、更新或删除元素

问题

你想要为不可变的Map添加、更新或者删除元素。

解决方案

无法在不可变的Map上原地更新元素,所以:

  • 对于不同的目的使用正确的函数方法。

  • 记得将结果赋值给一个新的变量。

为了更好地理解这种方式,下面的例子使用了一系列val变量定义的不可变Map。首先,创建一个赋值为val的不可变Map

添加元素

使用 + 方法添加一个元素,在这个过程中将结果赋值给一个新的变量:

使用 ++ 方法添加两个或者更多的元素:

更新元素

更新一个不可变Map的键值对,需要用 + 方法对键和值重新赋值,新值会替代旧值:

更新多个键值对,可以将新的元组值放在map或者sequence进行操作:

删除元素

使用 - 方法删除一个元素:

使用 -- 方法删除多个元素:

讨论

可以将不可变Map声明为var变量,然后将结果重新赋值给这个相同的变量。使用var,前面的例子如下所示:

上面例子中,由于a变量被定义成了var,所以在代码的每一步都可以被重新赋值。

14.4 为可变Map添加、更新或删除元素

问题

你想在可变的Map中添加、删除或者更新元素。

解决方案

可以这样对可变的Map添加元素:

  • 使用赋值语法:map(key) = value

  • 使用 += 方法

  • 使用 ++= 方法

删除元素:

  • 使用 -= 方法

  • 使用 --= 方法

可以将键重新进行赋值来更新元素。讨论中展示了可以使用的其他方法,包括putfilterInPlaceremoveclear方法。

假设有如下的可变Map

可以通过给键指定值的方式添加一个元素:

也可以使用 += 方法添加元素:

使用 ++= 从另一个集合添加多个元素:

使用 -= 方法,通过指定元素的键从Map中删除一个元素:

使用 --= 方法,根据键删除多个元素:

通过赋新值给元素的键来更新元素:

讨论中展示了修改可变Map的更多方法。

讨论

解决方案中展示的是最常用的方法。也可以这些方法:

  • 使用put方法添加一个键值对,或者替换已有的元素。

  • 使用filterInPlace方法保留满足条件的元素。

  • 使用remove方法删除对应的元素。

  • 使用clear方法删除所有元素。

比如,给定一个可变Map

下面的例子展示了如何使用这些方法:

例子中的注释解释了像putremove方法会返回SomeNone值。

14.5 访问Map的值(避免异常)

问题

你希望访问存储在Map中的单个值时,避免抛出异常。比如,给定一个Map

可以像访问数组元素的方式一样来访问一个Map中键所关联的值。

然而,如果Map没有包含所请求的键,会抛出java.util.NoSuchElementException异常:

解决方案

为了避免异常,使用:

  • 创建一个Map时使用withDefaultValue方法。

  • 访问元素时使用getOrElse方法。

  • 使用get方法返回一个Some或者None

例如,给定一个Map

避免这个问题的一种方式是在创建Map时使用withDefaultValue方法:

顾名思义,该方法会创建一个默认值,如果键不存在,就会返回这个默认值。

另一种方法是在尝试寻找值时使用getOrElse方法。当指定的键不存在时会返回默认值:

也可以使用get方法返回Option对象:

这三种方式都很不错,因为它们提供了不同的工作方式,可以根据喜欢的编程风格进行使用。

14.6 测试Map中键或值的存在

问题

你想判断Map中是否包含给定的键或者值。

解决方案

可以使用contains或者get方法测试Map中是否包含一个键。使用valuesIterator方法测试Map中是否包含一个值。

测试键

为了测试一个Map中是否包含一个,使用contains方法是最直接的方式:

根据需要,也可以使用get方法返回一个None或者Some来判断:

比如,可以在match表达式中使用这种方式:

注意,前面小节已经展示了尝试直接访问一个不存在的键可能会抛出异常:

测试值

为了测试一个Map中是否包含一个,使用valuesIterator方法搜索该值,结合contains方法测试:

这是可行的,因为(a)valuesIterator方法返回一个迭代器:

然后(b)如果Map中存在这个值,contians方法会返回true。如果需要对Map的值执行更多搜索操作,也可以使用以下方法:

因为exists方法需要一个函数,所以可以将此技术用于更复杂的键值和自定义的算法。

讨论

像这样将方法连起来,一定要注意中间结果,尤其是比较大的集合。在上面的例子中,我原本使用了values方法去获取Map中所有的值,但是该方法返回的结果是一个新的集合。如果是一个大的Map,这将会消耗很多内存。相反,valuesIterator方法则返回一个轻量级的迭代器。

另见

  • 14.5小节展示了访问Map的键时如何避免产生异常。

  • 14.7小节介绍了valuesvaluesIterator方法。

14.7 从Map中获取所有的键和值

问题

你想要获取Map中所有的键或者值。

解决方案

为了获取键,可以使用这些方法:

  • 使用keySet方法返回以Set的方式获取键。

  • 使用keys方法获取一个Iterable对象。

  • 使用keysIterator方法返回以迭代器的方式获取键。

这些方法的使用如下所示:

为了获取值,可以使用这些方法:

  • 使用values方法获取一个Iterable对象。

  • 使用valuesIterator方法获取一个Iterator对象。

这些方法的使用如下所示:

讨论

如这些例子所示,keysIteratorvaluesIterator方法都会从Map数据中返回一个迭代器。当操作一个大Map时,通常需要使用这些方法,因为它们不会创建一个新的集合;它们只提供一个迭代器来遍历现有元素。

还需注意,如果想要转换Map中值,可以使用view.mapValues或者transform方法,参阅14.9小节。

14.8 找到Map中最大(或最小)的键或者值

问题

你想在Map中查找最大或者最小的键或者值。

解决方案

使用Mapmaxmin方法,或者使用keysIterator或者valuesIterator的方式,取决于具体的需求。

两种查找最大最小的方式将会在解决方案中进行展示,查找最大最小的方式将会在讨论中进行展示。

为了展示使用Map键的方法,首先创建一个简单的Map

在这个Map中,键的类型是String,哪个键是“最大的”取决于如何定义。通过调用Mapmaxmin方法找到String自然排序的最大或最小的键:

因为 Teri 中的 T 是名字中字母排序最大的,所以作为最大值被返回了。Al 作为最小值被返回也是同样的道理。

也可以调用keysIterator方法返回一个scala.collection.Iterator对象,然后调用maxmin方法:

此外,调用keysIterator结合reduceLeft方法,同样能找到相同的最大和最小值:

这种方式很灵活,因为一旦最大的定义变成最长字符串,可以直接调整为比较字符串的长度:

讨论

想要从一个Map中获取最大和最小的值,可以使用valuesIterator方法返回一个迭代器,然后调用max或者min方法:

这是可以工作的,因为例子中Map的值是Int类型,含有一个隐式的Ordering。根据Scala的官方文档( https://oreil.ly/cYPQe ),“隐式的方法 Int => Ordered[Int] 会通过scala.Predef.intWrapper被自动的引入。”

相反,如果Map中的值类型没有提供一个Ordering,这个方式将会行不通。参阅13.14,“排序一个集合”,和参阅12.11,“排序数组”,获取很多关于scala.math.Ordered或者scala.math.Ordering特质的实现。

同样的,也可以使用reduceLeft的方式获取最大和最小值:

使用reduceLeft的好处是可以使用自定义的算法比较任意的值类型,这代表了可能需要对更复杂的数据类型执行操作。下面的例子展示了如何将reduceLeft方法与复杂的算法结合使用:

现在已经知道了如何访问x和y的值,然后就可以创建任何自定义的算法进行比较。

另见

  • 参阅13.10小节 “用reduce和fold方法遍历集合”

  • 参阅14.10小节

14.9 遍历Map

问题

你想要遍历Map中的每个元素。

解决方案

遍历Map中的元素有几种不同的方法。假设有如下一个Map

一个不错的方式是使用for循环语句遍历所有的元素:

使用foreach配合匿名函数,可读性也不错:

下面的例子展示了如何使用元组语法来访问键和值字段:

注意:例子中的电影评分数据来自Programming Collective Intelligence by Toby Segaran(O’Reilly)一书。

讨论

注意在Scala 3中,可以用三种不同的方式编写带有匿名函数的foreach方法:

在每个例子中,花括号内的代码都充当匿名函数。

根据不同遍历Map的方式,你可以只访问Map中的键或者值。

如果只想访问Map中的键,可以使用keysIterator方法得到一个轻量级的迭代器来获得所有的键:

keys方法返回一个Iterable对象,因此它会创建一个新的中间集合(如果是个大Map,可能会有性能问题),但是简单易用:

如果想要遍历Map操作其中的值,可以使用定义在MapView特质中的mapValues方法。首先,在Map上调用view方法创建一个MapView,然后调用mapValues方法。它可以在遍历的每个值上应用一个函数,然后返回修改后的Map

由于视图以非严格或懒惰的方式实现转换,因此在调用mapValues之前创建视图只会创建一个轻量级迭代器:

当一个Map特别大时,这种方式特别有效。可以参阅11.4小节“在集合上创建惰性视图”获取更多信息。

相反,可以使用valuesmap方法来解决这个问题:

但请注意,此过程的第一步将创建一个中间的Iterable对象:

因此,对于大的Map来说,这种方式将会产生额外的中间结果,导致性能或者消耗内存的问题。

转换方法

如果想要遍历Map的时候对值进行转换,另一个方式是使用transform方法可以从一个已知的Map中创建一个新的Map。和mapValues方法不同,transform方法可以同时使用键和值实现转换:

对于更复杂的情况,还可以在初始的Map上创建一个视图,然后在该MapView上调用map方法,这样就可以访问Map的键和值:

14.10 根据键或值对Map排序

问题

你想要按照键或者值对未排序的Map元素进行排序。

解决方案

假设有一个基本的不可变Map

可以使用sortBy方法按照键从低到高对Map进行排序,然后将结果存储在一个可变的LinkedHashMap或者不可变的VectorMap。两种方案如下所示:

也可以使用sortWith方法传入自定义的算法,按照升序或者降序对键进行排序,然后将结果存储在一个LinkedHashMap中:

这种语法将会在讨论中进行解释。

使用sortBy方法按照值对Map排序:

也可以使用sortWith方法按照升序或者降序对值进行排序:

除了使用LinkedHashMap,也可以将结果存在一个不可变的VectorMap中。当影响到性能时,一定要测试一下这两种Map类型,看看哪种最适合实际的应用场景。

讨论

将解决方案分解成几个小的步骤有助于理解。首先,创建一个基本的不可变Map

接下来是grades.toSeq创建一个双元素元组的序列,也就是Seq[(String, Int)]

转换成Seq的原因是它有现成的排序方法:

在上面的例子中,_._1 语法表示元组的第一个元素,也就是。同样的,_._2 表示

一旦Map的数据按预想的方式完成排序,将其保存到一个LinkedHashMapVectorMap或者ListMap中,保证排序的顺序:

LinkedHashMap只有可变的版本,VectorMap是一个不可变的Map。还有一个不可变的ListMap,但只建议用于小的Map。可以按照不同的实际情况进行使用。

关于_*

代码中的 _* 部分需要花点时间去理解。它的作用是将数据转换,然后将其作为多个参数传给LinkedHashMap(或者VectorMapListMap)。如果曾经在Unix系统上使用过xargs命令,它工作方式与之类似,将一系列元素作为输入,每次将一个元素传递给下一个命令。

将代码分解成多行,可以更容易理解。sortBy方法返回一个Seq[(String, Int)],即一个元组序列:

不幸的是,不能直接使用元组序列来构造VectorMapLinkedHashMap或者ListMap

但是由于VectorMap伴生对象的apply方法接受一个Tuple2的变长参数,可以调整seqOfTuples使用 _* 将Tuple2元素序列转换为一系列单独的Tuple2值。为VectorMapapply方法提供了所需的输入:

另一个了解 _* 如何工作的方式是自定义接受变长参数的方法。下面例子中的printAll方法接受一个参数,String类型的变长字段:

然后像这样创建一个List

可以发现List不能被传入printAll方法;和前面的例子一样会引起错误:

但是使用 _* 可以调整List,让它可以作用于printAll方法,像下面这样:

如果有Unix背景,可以将 _* 看做是一个splat操作符。该操作符将序列的每个元素作为单独的参数传递给printAll方法,而不是将fruits当做单个的List参数传入。

14.11 过滤Map

问题

你想要过滤Map中包含的元素,要么直接修改可变的Map,要么是在不可变的Map上应用过滤算法生成一个新的Map

解决方案

在可变的Map上使用filterInPlace方法选择需要保留的元素,使用filterKeys或者filterKeys或者filter方法过滤可变或者不可变Map的元素,不要忘记将结果赋值给一个新的变量。

可变Map

通过filterInPlace方法指定要保留的元素,过滤可变Map的元素:

如上所见,filterInPlace原地修改了可变Map。如例子中隐含的匿名函数签名:

算法可以测试每个元的键和值,决定哪些元素可以保留在Map中。

取决于“filter”的定义,也可以像removeclear方法那样删除Map中的元素,可以参阅14.3小节。

可变和不可变Map

使用可变或者不可变Map时,用filterKeys方法配合断言选择保留Map中的哪些元素。使用这个方法时,首先调用Map中的view方法创建一个MapView,然后记得将过滤后的结果赋值给一个新的变量:

断言对于要在新集合中保留的元素返回true,对于不想保留的元素返回false

注意使用这种方式,如果没有在结尾调用toMap方法,将会在REPL中看到如下输出:

这里 MapView() 是因为view方法返回的是一个惰性视图,只有通过调用toMap等方法强制执行时,才能真正执行计算。(参阅11.4小节获取更多关于视图的信息。)

如果算法较长,可以定义一个匿名函数(或者方法),然后在调用filterKeys时使用,而不是用匿名函数的方式。比如,首先定义如下例子中的方法,当输入参数为1时返回true

然后将该函数传给filterKeys方法:

也可以让filterKeys配合一个Set指定需要保留的元素:

讨论中展示了其他过滤方法,例如如何根据Map的值进行过滤。

讨论

可以像13.7小节那样使用所有的过滤方法。例如,Map上的filter方法允许按键、值或者两者一起过滤Map中的元素。filter方法给断言提供了一个Tuple2对象,这样就可以像下面例子中一样访问键和值了:

过滤的算法也可以使用一个tuple,像这样:

take方法可以“提取”(保留)Map中前N个元素:

参阅13.7小节使用其他方法的例子,包括takeWhiledropslice等。

最后更新于

这有帮助吗?