8.2 标准字典

字典(dictionary)也属于一种容器。不过,与元组不同的是,它容纳的是一个个键值对(key-value pair),而不是一个个元素值。本节将要讲述的是 Julia 中的标准字典。

8.2.1 规则与约束

我们先来看看 Julia 中的标准字典(以下简称字典)会遵循哪些规则,以及有着什么样的约束。

这里需要先解释一下什么叫做键值对。简单来说,一个键值对就是两个值的组合,同时它也是一个存储单元。在很多地方也把它称为映射。这很形象,因为它表示的正是从某个键到某个值的映射关系。在字典中,我们可以通过一个键保存、获得或者改变与之对应的值,但是反过来却不行。也就是说,这种映射关系是单向的。

不要误会,所谓的键并不是什么特殊的东西。它指的其实就是某种数据类型的值。对于一个数据类型,只要程序中存在针对它的hash方法(即hash函数的衍生方法)和isequal方法(即isequal函数的衍生方法),那么该类型的所有实例就都可以作为字典中的键。一个字典总会通过调用对应的hash方法和isequal方法来判断一个键是否存在于其中,以及确定这个键和对应的值在其中的存储位置。因此,这两个方法通常都是必不可少的。

Julia 中的所有原语类型、复合类型以及预定义的容器类型都有相应的hash方法和isequal方法可用。如果你要定义自己的数据类型,并且有可能会让它成为字典的键类型,那么我强烈建议你去显式地定义针对该类型的hash方法和isequal方法。至于具体原因,我在后面会讲到。

这里所说的hash函数及其衍生方法也常被统称为哈希函数。它的功能是计算并输出某个输入值的哈希码(hash code)。一个哈希码其实就是一个整数,在大多数情况下它都足以代表作为输入的那个值。一个优秀的哈希函数几乎可以保证任何两个不相等的值的哈希码也都是不相等的。在这里,我们不去讨论各种哈希函数所采用的算法的优劣。你暂时可以大胆地假设不相等的值总会有不同的哈希码。不要担心,即使两个不等值的哈希码相等(也称哈希冲突),字典也有应对的方案。

原则上一个字典里可以存储任意个键值对,但同一个键只会出现一次。也就是说,一个字典中的任意两个键都肯定不会相等。当我们想把一个键值对放入一个字典里的时候,只有该字典中不存在这个键才会使该键值对被添加进去,否则就只会改变其中与此键对应的值。这种约束也正是由上述两个方法辅助实现的。

另外,字典并不会按照固定的次序存储其中的键值对。更具体地说,它们既不会按照我们添加键值对的顺序进行存储,也不会依从某种排序规则去安排这些键值对的存储位置。其根本原因是,标准的字典是一种哈希表(hash table)的具体实现。这样的实现只会依据键的哈希码和键的值本身通过取模等运算选择键值对的存储位置,而丝毫不会关心键值对被添加的时间点。不但如此,字典还会择机对其存储的键值对进行整理。在每一次整理之后,这些键值对的具体存储位置都可能会有所不同。所以,从使用的角度看,我们可以说字典中的键值对都是无序存储的。更宽泛地讲,字典不会对其中键值对的存储位置和存取顺序做出任何的保证。

在 Julia 中,标准字典的行为都会基于以上描述。下面,我们就来讲讲这个标准字典到底是什么。

8.2.2 类型与实例化

标准字典的类型名为Dict,是一个参数化类型,直接继承自抽象类型AbstractDict

Dict类型有两个类型参数,分别是代表键类型的K和代表值类型的V。这个类型的构造函数Dict是比较灵活的。首先,我们调用它时可以不传给它任何参数值,就像这样:

  1. julia> Dict()
  2. Dict{Any,Any} with 0 entries
  3. julia>

可以看到,它这时会返回一个键类型和值类型都为Any的字典实例。当然,我们也可以在构造其实例的同时对键类型和值类型进行指定:

  1. julia> Dict{Int64, String}()
  2. Dict{Int64,String} with 0 entries
  3. julia>

关于标准字典对键类型的要求,我在前面已经说过了。在这里,我要讲的是一种很有趣的现象,它是有些反直觉的。先看下面的代码:

  1. julia> mutable struct MyKey
  2. code::String
  3. sn::Int128
  4. end
  5. julia> key1 = MyKey("mykey", 1); key2 = MyKey("mykey", 1);
  6. julia>

我先定义了一个可变的复合类型,名称为MyKey。这个复合类型有两个字段,关于它们的类型我们在前面都说明过。之后,我又声明了两个变量,并分别为它们赋予了MyKey类型的值。注意,这两个结构体中的同名字段的值都是两两相同的。下面,我们再构造一个标准的字典,并指定它的键类型为MyKey

  1. julia> dict1 = Dict{MyKey, Int64}()
  2. Dict{MyKey,Int64} with 0 entries
  3. julia>

现在注意看下面的代码:

  1. julia> dict1[key1] = 10
  2. 10
  3. julia> key1.sn = -1; dict1[key1]
  4. 10
  5. julia>

是的,我们可以利用索引表达式和一个键在字典中存、取、改与该键对应的值。但这并不是这里的重点。重点是,在我改变了key1中的sn字段的值之后,我依然可以从dict1中获取到10这个值。这是为什么呢?现在的key1已经与原来的key1不同了啊!再来看一行代码:

  1. julia> dict1[key2]
  2. ERROR: KeyError: key MyKey("mykey", 1) not found
  3. # 省略了一些回显的内容。
  4. julia>

索引表达式dict1[key2]竟然让 Julia 报错了。至于它为什么会报错,我们到下一个小节再说。现在注意看错误信息,它表明dict1中并没有key2所代表的那个键。还记得吗?key2的值与key1原来的值是相同的。但是我们在这里却无法从dict1中获取到与这个键对应的值。这是不是很奇怪呢?

这两个结果实际上展示了同一种现象。我们已经知道,字典会利用isequal方法判断两个键是否相等。因此,这种现象的背后其实就是一个关于结构体判等的问题。我们现在来直接判断一下:

  1. julia> key1 = MyKey("mykey", 1); isequal(key1, key2)
  2. false
  3. julia>

为了复现上述的现象,我先还原了key1的值。可以看到,即使key1key2中的同名字段的值都两两相同,它们也不是相等的。由于key1不能在同一时刻代表两个不同的值,所以我无法利用isequal方法复现另一个结果。但请记住,在这里,即使key1中的某个字段的值被改变了,它仍然会与其原本的值相等。

这是不是有些反直觉呢?我们的预期是,对于类型相同的两个结构体,只要其中的同名字段的值都两两相同,那么它们就是相等的。并且,字典应该把这样的两个值视为同一个键。可是,上述代码的执行结果却正好相反。

如果你还记得操作符===的特性的话,那么就应该知道:对于可变的值,这个操作符会比较它们在内存中的存储地址。一个结构体其实也相当于一个置物架,其中的字段也相当于一个个格子。无论我们向置物架的格子中放置什么物品,这个置物架都还是原来的那一个。同理,无论与key1绑定的那个结构体中的字段值怎么变,该结构体在内存中的存储地址都不会变。这其实就是dict1[key1]仍然会返回10的最深层原因。

但是,新问题又来了。我们明明调用的是isequal方法,可为什么判等结果却会符合操作符===的特性呢?

我们已经知道,在大多数情况下,isequal方法的行为都会依从于操作符==的判等结果。但你可能还不知道的是,Julia 还内置了如下的定义:

  1. ==(x, y) = x === y

它的含义是,当没有针对某个类型的==方法时,我们若用操作符==判断该类型的两个值是否相等,就相当于在用===做判断。所以,在这种情况下,如果也没有针对这个类型的isequal方法,那么我们调用isequal方法也就相当于在用===

现在问题终于明朗了。我们既没有为MyKey类型显式地定义==方法,也没有为它定义isequal方法。一旦问题定位清楚了,就差不多等于解决了一半。我们下面就定义针对MyKey类型的==方法,因为这样做可以解决得更彻底一些。代码如下:

  1. julia> import Base.==
  2. julia> ==(x::MyKey, y::MyKey) = x.code == y.code && x.sn == y.sn
  3. == (generic function with 156 methods)
  4. julia>

我们之前说过,编写某个函数的衍生方法的时候必须先导入这个函数。因此,第一行代码是必须的。第二行代码就是==方法的定义。它的两个参数的类型都是MyKey,这一点很重要。在赋值符号=右边的代码就是==方法的方法体。其中的操作符&&代表着逻辑与运算。这意味着,只有在它两边的判等表达式的结果都是true的情况下,它们合起来的结果才会是true,否则就会是false。因此,这个方法体表达的就是,只要两个参数值的code字段的值和sn字段的值都分别相等,这两个参数值就是相等的。

下面,我们重新做一遍之前的操作:

  1. julia> key1 = MyKey("mykey", 1); key2 = MyKey("mykey", 1);
  2. julia> key1 == key2, isequal(key1, key2)
  3. (true, true)
  4. julia>

上面的结果是符合我们的预期的。但是,当我们执行如下代码的时候,Julia 仍然会报错:

  1. julia> dict1 = Dict{MyKey, Int64}();
  2. julia> dict1[key1] = 10; dict1[key2]
  3. ERROR: KeyError: key MyKey("mykey", 1) not found
  4. # 省略了一些回显的内容。
  5. julia>

这又是为什么呢?其原因是,字典会先利用hash方法确定键值对的存储位置。如果连存储的位置都不同,那就更别提取出相应的值了。上面就属于这种情况。由于key1key2的哈希码不相等:

  1. julia> hash(key1) == hash(key2)
  2. false
  3. julia>

所以dict1通过key2找到的存储位置并不是存储key1的那个位置。由此它会认为这个键根本就不存在。这时的这个hash函数会基于值在内存中的存储地址计算哈希码。很显然,key1key2在内存中的存储地址是不同的。因而它们的哈希码也不会相等。

为了解决这一问题,我们还需要为MyKey类型定义一个hash方法。因为上面的这个hash函数在 Julia 中的定义是这样的:

  1. hash(x::Any) = hash(x, zero(UInt))

所以我们需要像下面这样来编写针对MyKey类型的hash方法:

  1. julia> import Base.hash
  2. julia> hash(k::MyKey, h::UInt) = hash(k.sn, hash(k.code, h))
  3. hash (generic function with 56 methods)
  4. julia>

现在,我们再次运行上面的代码,就会得到如下的结果:

  1. julia> key1 = MyKey("mykey", 1); key2 = MyKey("mykey", 1);
  2. julia> dict1 = Dict{MyKey, Int64}();
  3. julia> dict1[key1] = 10; dict1[key2]
  4. 10
  5. julia>

这个结果终于完全符合我们的预期了。并且,一旦key1中的某个字段的值被改变,它就不再是dict1中的键了:

  1. julia> key1.sn = -1; dict1[key1]
  2. ERROR: KeyError: key MyKey("mykey", -1) not found
  3. # 省略了一些回显的内容。
  4. julia>

说了这么多,我就是想郑重地告诉你,isequal方法和hash方法对于一个字典的键类型来说有多么的重要。如果我们想让自己定义的数据类型作为字典的键类型,那么不但应该为它编写isequal方法,还应该同时为它编写相应的hash方法。而且,这两个方法在基本的逻辑方面应该保持一致。比如,如果一个isequal方法不会基于值的存储地址做判断,那么相应的hash方法就应该同样不基于值的存储地址,反之亦然。

好了,让我们把视线重新放到字典的构造函数上。我们在调用构造函数Dict的时候,还可以传给它几种参数值。

首先,我们可以传入一个包含了若干同类型元组的数组,就像这样:

  1. julia> Dict([(1, "a"), (2, "b"), (3, "c")])
  2. Dict{Int64,String} with 3 entries:
  3. 2 => "b"
  4. 3 => "c"
  5. 1 => "a"
  6. julia>

在我们给予Dict函数的数组中有三个元素值。这个数组由一个中括号包裹,并且其中的元素值之间都有英文逗号进行分隔。这就是用字面量表示一个一维数组的一般方式。其中的每一个元素值都是一个元组。这些元组的类型都是Tuple{Int64,String},而且它们都只包含了两个元素值。

实际上,Dict函数不但要求这些元组的类型必须相同,还要求它们都必须包含两个元素值,既不能少也不能多。因为这里的每一个元组都会被视为一个键值对。其中的第一个元素值是键值对中的键,而第二个元素值则是键值对中的值。

其实,我们把这里的元组换成数组也是可以的。例如,我们传给Dict函数的数组可以是[[1, "a"], [2, "b"], [3, "c"]]。这个字面量表示的是一个数组的数组,相当于在数组里又嵌套了数组。更宽泛地讲,只要我们给予的那一个参数值是可迭代对象,并且每一次被迭代出来的值都是长度为2的可索引对象,Dict函数就可以接受它。

像元组、字典、数组这样的容器基本上都既是可迭代对象也是可索引对象。不过,我不建议把像字典这样的无序容器作为内层的可索引对象。因为如果这样做的话,就会使得被构造的字典中的键值对带有不确定性。

除此之外,在 REPL 环境回显的内容中有一个我们未曾见过的符号=>。这个符号表示的实际上就是从某个键到某个值的单向映射关系。它很像一个箭头,不是吗?在该箭头的尾部的值(即左侧的那个值)就是键值对中的键,在其头部的值(即右侧的那个值)就是键值对中的值。

如此表示的键值对其实也可以被作为独立的参数值传给Dict函数。例如:

  1. julia> Dict(1=>"a", 2=>"b", 3=>"c")
  2. Dict{Int64,String} with 3 entries:
  3. 2 => "b"
  4. 3 => "c"
  5. 1 => "a"
  6. julia>

这次我们向Dict函数传入了三个参数值,即:1=>"a"2=>"b"3=>"c"。这与前面的那行向Dict函数传入数组的代码是等价的。

之所以像1=>"a"这样的字面量可以独立的存在,是因为它们表示的也是一个类型的值。这个类型叫做Pair。它也是一个参数化类型,全名是Pair{A, B}。其中的类型参数A代表键的类型,而B则代表值的类型。因此,1=>"a"的类型就是Pair{Int64,String}

Pair类型的值都是可索引对象。其中的键的索引号总是1,而值的索引号总是2。同时,Pair类型的值也都是可迭代对象。但是这个意义就不大了。因为这类值最多只能包含两个元素值,基本上用不着迭代。

最后,提示一下,当我们把一个字典传入Dict函数的时候,就相当于在构造一个前者的复本。但要注意,这个复本只是原字典的浅拷贝。其中包含的所有键值对都依然是原字典中的键值对。

8.2.3 操作字典

8.2.3.1 存取键值对

我们已经知道,可以用索引表达式存取字典中的键值对。当索引表达式与赋值符号=联用时,它一般会把键值对添加进指定的字典。例如:

  1. julia> dict2 = Dict{String, Int64}()
  2. Dict{String,Int64} with 0 entries
  3. julia> dict2["a"] = 1
  4. 1
  5. julia>

注意,在这个索引表达式的中括号里的不是索引号,而是键。并且,这里的键只能有一个。它既可以由一个字面量表示,也可以由一个变量代表,还可以是一个求值结果为键的表达式,只要其类型符合字典的定义就可以。而在赋值符号右边的就是要与这个键配对的那个值。它们在字典中会组合成为一个键值对。

只要一个字典里已经存在我们要添加的键,那么这样的索引表达式就不会再向该字典添加这个键值对了。它此时只会改变该字典中与这个键对应的值。

另一方面,索引表达式还可以取出字典里与指定的健对应的值。这也是它的默认功能。但是,只要那个字典中不存在这个健,它就会立即报错。示例如下:

  1. julia> dict2["a"]
  2. 1
  3. julia> dict2["b"]
  4. ERROR: KeyError: key "b" not found
  5. # 省略了一些回显的内容。
  6. julia>

如果我们不加以处理,这样的错误就会让程序执行的正常流程中断,甚至最终导致程序的崩溃。用于流程控制的try-catch语句可以做这样的处理,但是这要到后面的部分才会讲到,我们在这里就不提了。其实还有好几个办法可以应对这种情况。

一个最直接的办法就是使用haskey函数。haskey函数可以接受两个参数,第一个参数代表字典,另一个参数代表键。它总是会返回一个Bool类型的结果值,以表示这个键是否存在于该字典中。例如:

  1. julia> haskey(dict2, "a")
  2. true
  3. julia> haskey(dict2, "b")
  4. false
  5. julia>

由于haskey函数不会在找到指定的键时返回对应的值,所以我们为了达成dict2["a"]的功能还需要多写一些代码:

  1. julia> haskey(dict2, "a") ? dict2["a"] : nothing
  2. 1
  3. julia>

这行代码是一个代表了三元操作的表达式,其中的英文问号?和英文冒号:都是专用的操作符。什么是三元操作呢?我们在讲数值运算的时候介绍了一元运算(如一元减、平方根等)和二元运算(如加、减、乘、除等等)。所谓的一元运算就是只涉及了一个操作数的运算,而二元运算就是有两个操作数的运算。以此类推,三元运算自然就应该包含三个操作数。当然,这里的操作数也可以由字面量、变量或表达式来表示。

上述三元操作的表现形式这样的:

  1. <第一个操作数> ? <第二个操作数> : <第三个操作数>

若换成纯文字来表达就是:第一个操作数的求值结果是true吗?如果是,那么就对第二个操作数求值并将其结果值作为此三元操作的结果,否则就对第三个操作数求值并将其结果值作为此三元操作的结果。因此,上面那行代码的含义即为:键"a"是否存在于字典dict2中?如果存在就返回与之对应的值,否则就返回nothing

实际上,我们还可以用更少的代码实现此功能。这很简单,只需调用get函数:

  1. julia> get(dict2, "a", nothing), get(dict2, "b", nothing)
  2. (1, nothing)
  3. julia>

除了字典和键之外,get函数还会接受一个默认值。当这个键未在该字典中时,此默认值就会被当作结果值返回。

其实我们在这里讲的只是get函数的一个衍生方法。但由于其他的衍生方法会涉及到流程控制,所以我在这里就不多做介绍了。它们的功能很相似,只不过在表现形式上会有所不同。与之相比,更值得一说的是它的孪生函数get!

你可能会疑惑,get!函数的名称为什么以英文叹号!结尾呢?该函数与get函数最重要的区别是,它会在必要时改动字典中的键值对,而get函数最多只会从字典中获取值。

更宽泛地讲,这其实是 Julia 当中的一种惯用法。名称以!结尾的函数往往会修改我们传给它的那个最主要的参数值。对于get!函数来说,这个最主要的参数值就是字典。反过来讲,名称不以!结尾的函数通常都会保证任何参数值都不会被修改。也可以说这是对原有数据的一种保护。因此,在 Julia 中,“名称以!结尾”就成为了一种标志。它向我们表明了当前函数在这方面的行为方式。Julia 标准库以及很多第三方库都严格地遵循了这种惯用法。理所应当,我们在编写新的函数的时候也应该遵从它。

我们再来看get!函数。这个函数的参数列表与get函数的参数列表一模一样。但是,前者会在发现字典中不存在指定的键时向该字典添加新的键值对。这个新键值对中的键就是我们指定的那个键,而其中的值则是我们给予它的默认值。示例如下:

  1. julia> get!(dict2, "b", 2)
  2. 2
  3. julia> dict2
  4. Dict{String,Int64} with 2 entries:
  5. "b" => 2
  6. "a" => 1
  7. julia>

与此函数在功能上有些相似的一个函数名叫pop!。对它来说,字典和键是必选的参数,而默认值则是可选的参数。如果它在字典中找到了指定的键,那么也会返回与之对应的值。但是,在返回这个值之前,它还会把相应的键值对从字典中删除掉。请看下面的示例:

  1. julia> pop!(dict2, "b")
  2. 2
  3. julia> dict2
  4. Dict{String,Int64} with 1 entry:
  5. "a" => 1
  6. julia>

另一方面,如果我们传给它了一个默认值,那么在字典中不存在指定的键时,它与get函数(注意,不是get!函数)的行为是一致的,如:

  1. julia> pop!(dict2, "b", 2)
  2. 2
  3. julia> dict2
  4. Dict{String,Int64} with 1 entry:
  5. "a" => 1
  6. julia>

请注意,如果指定的键未在字典中,而我们又没有把默认值传给它,那么它就会立即报错:

  1. julia> pop!(dict2, "b")
  2. ERROR: KeyError: key "b" not found
  3. # 省略了一些回显的内容。
  4. julia>

所以,看起来get函数才是最安全的。我们其实可以利用haskey函数和索引表达式来这样模拟get!函数的功能:

  1. julia> haskey(dict2, "b") ? dict2["b"] : dict2["b"]=2
  2. 2
  3. julia> dict2
  4. Dict{String,Int64} with 2 entries:
  5. "b" => 2
  6. "a" => 1
  7. julia>

另外,对于pop!函数,我们可以这样进行防御性编程(defensive programming):

  1. julia> haskey(dict2, "b") ? pop!(dict2, "b") : 2
  2. 2
  3. julia> dict2
  4. Dict{String,Int64} with 1 entry:
  5. "a" => 1
  6. julia>

这样做既可以避免pop!函数的报错,也可以做到功能上的模拟,而且在性能上也不会有明显的损失。

最后,我顺便提一下删除操作。我们可以使用delete!函数从一个字典中删除掉某个指定的键及其对应的值:

  1. julia> delete!(dict2, "a")
  2. Dict{String,Int64} with 0 entries
  3. julia> dict2
  4. Dict{String,Int64} with 0 entries
  5. julia>

这个函数会返回改动后的字典,也就是我们传给它的那个字典。另外,我们还可以通过调用empty!函数去清空一个字典,也就是删掉其中的所有键值对。例如,empty!(dict2)。它会返回已被清空的字典。

8.2.3.2 迭代

我们对字典的迭代依然可以使用for语句来做。这很简单,示例如下:

  1. julia> dict3 = Dict("a"=>1, "b"=>2, "c"=>3, "d"=>4, "e"=>5);
  2. julia> for p in dict3
  3. println("$(p[1]) => $(p[2]) [$(typeof(p))]")
  4. end
  5. c => 3 [Pair{String,Int64}]
  6. e => 5 [Pair{String,Int64}]
  7. b => 2 [Pair{String,Int64}]
  8. a => 1 [Pair{String,Int64}]
  9. d => 4 [Pair{String,Int64}]
  10. julia>

对于字典来说,迭代变量可以只有一个。这时,for语句迭代出的值的类型就将是Pair类型。而该类型的两个类型参数值将分别是字典的键类型和值类型。我们已经知道,一个Pair类型的值就代表着一个键值对,也相当于一个小容器。并且,这类值既属于可索引对象也属于可迭代对象。其中的键的索引号是1,而值的索引号则是2

这里的迭代变量也可以是两个。在这种情况下,我们必须使用圆括号把这两个变量的标识符包裹起来,就像这样:

  1. julia> for (k,v) in dict3
  2. println("($k, $v) [$(typeof(k)), $(typeof(v))]")
  3. end
  4. (c, 3) [String, Int64]
  5. (e, 5) [String, Int64]
  6. (b, 2) [String, Int64]
  7. (a, 1) [String, Int64]
  8. (d, 4) [String, Int64]
  9. julia>

在这个例子中,迭代变量k会代表键,而v会代表与之对应的值。

千万别忘了,字典中的键值对都是无序存储的。所以,字典不会对键值对的迭代顺序做出任何的保证。我们更不要想当然地假设基于字典的迭代的顺序,不论我们迭代的方式是什么。

此外,如果你只想对字典中的键或值做迭代也是可以的。不过,这需要先利用keys方法或values方法获取到字典的键列表或值列表。我们马上就会讲到。

8.2.3.3 获取列表

Julia 提供了一些方法,可以让我们分别获取字典中的键列表和值列表。

若要获取键列表,我们就要先调用一个名为keys的方法。这个方法的用法再简单不过了,代码如下:

  1. julia> dict4 = Dict("a"=>1, "b"=>2, "c"=>3);
  2. julia> keys_dict4 = keys(dict4)
  3. Base.KeySet for a Dict{String,Int64} with 3 entries. Keys:
  4. "c"
  5. "b"
  6. "a"
  7. julia> typeof(keys_dict4)
  8. Base.KeySet{String,Dict{String,Int64}}
  9. julia>

keys方法在被调用之后会返回一个Base.KeySet类型的值。在这个值中就包含了字典中所有的键。我们可以看到,这个值的类型与源字典类型的关系非常紧密。实际上,这类值仅仅是把源字典又简单地包装了一下而已。

我们可以应用在此类值上的方法很少,恐怕只有lengthisempty可用。前一个方法可以获取到值的长度,而后一个方法则可以判断值中是否没有任何键。另外,我们还可以通过调用in方法来判断其中是否存在某个键:

  1. julia> in("a", keys_dict4)
  2. true
  3. julia>

当然,in函数也有针对字典的衍生方法。不过它需要的第一个参数值是不同的:

  1. julia> in(Pair("a", 1), dict4)
  2. true
  3. julia>

另外,Base.KeySet类型的值都是可迭代对象,但是很可惜它们都不是可索引对象。从该类型的名称上,我们也可以猜到,这类值属于集合。所以我们也可以称之为键集合。那些可以应用在集合上的方法基本上也都适用于键集合。至于都有哪些方法,我们到后面再说。

无论怎样,如果你想得到更多的操控性,那么可以先把键集合转换为标准的集合或者一维的数组,就像这样:

  1. julia> Set(keys_dict4)
  2. Set(["c", "b", "a"])
  3. julia> collect(keys_dict4)
  4. 3-element Array{String,1}:
  5. "c"
  6. "b"
  7. "a"
  8. julia>

对于字典的值列表来说也是类似的。我们需要先通过调用values方法获取到包含着所有值的迭代器:

  1. julia> values_dict4 = values(dict4)
  2. Base.ValueIterator for a Dict{String,Int64} with 3 entries. Values:
  3. 3
  4. 2
  5. 1
  6. julia> typeof(values_dict4)
  7. Base.ValueIterator{Dict{String,Int64}}
  8. julia>

然后,再将这个迭代器转换成集合或者数组:

  1. julia> Set(values_dict4)
  2. Set([2, 3, 1])
  3. julia> collect(values_dict4)
  4. 3-element Array{Int64,1}:
  5. 3
  6. 2
  7. 1
  8. julia>

顺便说一下,我们把一个键集合传给eltype方法就可以得到源字典的键类型,而把一个值迭代器传给eltype方法则可以得到源字典的值类型。相应的,对于一个字典,我们可以通过调用keytype方法获取到它的键类型,或者调用valtype方法获取到它的值类型。下面是相应的示例:

  1. julia> keytype(dict4) == eltype(keys_dict4) == String
  2. true
  3. julia> valtype(dict4) == eltype(values_dict4) == Int64
  4. true
  5. julia>

8.2.3.4 合并

Julia 中有专门的函数可以进行字典的合并,名为merge。这里说的其实是两个函数(更确切地说,是衍生方法),我们先来讲参数较少的那一个。

这个merge函数可以同时接受多个字典作为其参数值。它会构造一个新的标准字典,并把它接受的所有字典中的键值对全部都添加到这个新字典中,最后返回新字典。下面是一个简单的示例:

  1. julia> merge(dict3, dict4)
  2. Dict{String,Int64} with 5 entries:
  3. "c" => 3
  4. "e" => 5
  5. "b" => 2
  6. "a" => 1
  7. "d" => 4
  8. julia>

我们在这里需要特别注意传给它的参数值的顺序。因为,如果在多个参数值中存在相等的键,那么在新字典中与此键对应的那个值就将是最右边的那个参数值里的相应键值对中的值。示例如下:

  1. julia> dict3
  2. Dict{String,Int64} with 5 entries:
  3. "c" => 3
  4. "e" => 5
  5. "b" => 2
  6. "a" => 1
  7. "d" => 4
  8. julia> dict4
  9. Dict{String,Int64} with 3 entries:
  10. "c" => 3
  11. "b" => 2
  12. "a" => 1
  13. julia> dict5 = Dict("a"=>10, "b"=>20, "c"=>30);
  14. julia> merge(dict3, dict4, dict5)
  15. Dict{String,Int64} with 5 entries:
  16. "c" => 30
  17. "e" => 5
  18. "b" => 20
  19. "a" => 10
  20. "d" => 4
  21. julia>

我们可以看到,新字典中的键"a""b""c"所对应的值都是字典dict5里的。如果我们把dict5dict4调换一下位置,那么结果肯定就会有所不同。为了描述方便,我们在后面会称此为merge函数的最右优先规则。

另外,merge函数还会在必要时对新字典的键类型和值类型做适当的提升。例如:

  1. julia> dict6 = Dict("a"=>1.0, "d"=>4.0, "e"=>5.0);
  2. julia> merge(dict5, dict6)
  3. Dict{String,Float64} with 5 entries:
  4. "c" => 30.0
  5. "e" => 5.0
  6. "b" => 20.0
  7. "a" => 1.0
  8. "d" => 4.0
  9. julia>

从 REPL 环境回显的内容可知,新字典的值类型已经被提升为了Float64,而不是dict5的值类型Int64。我们在讲数值与运算的时候讨论过 Julia 的类型提升系统。如果你忘记了相关的规则,那么可以翻回去复习一下。

别担心,即使新字典的键类型也需要提升,情况通常也不会变得更加复杂:

  1. julia> dict7 = Dict(1=>"a", 2=>"b", 3=>"c"); dict8 = Dict(1.0=>'a', 3.0=>'c');
  2. julia> merge(dict7, dict8)
  3. Dict{Float64,Any} with 3 entries:
  4. 2.0 => "b"
  5. 3.0 => 'c'
  6. 1.0 => 'a'
  7. julia>

字典dict7的键类型是Int64,值类型是String。而字典dict8的键类型是Float64,值类型是Char。我们已经知道,Int64Float64的公共类型就是Float64。而StringChar虽然看起来关系挺近的,但其实它们的公共类型却是顶层类型Any

请注意,merge函数对新字典的键类型和值类型的设定是完全依从于 Julia 的类型提升系统的。而它的最右优先规则却正好相反,完全是它自己制定的。实际上,merge函数会先利用promote_type函数获得所有参数值的键类型的公共类型以及它们的值类型的公共类型。然后,该函数会用这两个公共类型去构造一个新的字典。它会先把最左边的参数值中的键值对都放入新字典,然后再利用索引表达式依次地放入更靠右的那些参数值中的键值对。如果有相等的键,那么新的值自然就会覆盖掉旧的值,但是键肯定还是最开始放入的那一个。

我们再来讲另一个merge函数。这个merge函数除了可以接受多个字典之外,还有一个名为combine的必选参数,并且这个参数还处在最左边的参数位置上。

这个参数的作用是确定值的合并策略,即:若出现了相等的键,它们的值怎么整合在一起。我刚才也说了,前一个merge函数在这种情况下总会用新值完全替换掉旧值。所以这两个函数在细节的处理上显然是不同的。

下面,我们通过一些代码来体会一下它们的区别:

  1. julia> merge(dict5, dict6)
  2. Dict{String,Float64} with 5 entries:
  3. "c" => 30.0
  4. "e" => 5.0
  5. "b" => 20.0
  6. "a" => 1.0
  7. "d" => 4.0
  8. julia> merge(+, dict5, dict6)
  9. Dict{String,Float64} with 5 entries:
  10. "c" => 30.0
  11. "e" => 5.0
  12. "b" => 20.0
  13. "a" => 11.0
  14. "d" => 4.0
  15. julia>

第一个函数调用的结果虽然是经过类型提升之后的新字典,但是在值的合并方面却只有完全的替换而没有真正的整合。第二个函数调用就不同了,它会依从第一个参数的值去整合相等键的多个值。

实际上,第二个merge函数只是在需要整合多个值的时候调用了第一个参数值所代表的函数(别忘了,数学运算符也是用函数实现的)。因此,新字典中的键"a"所对应的值最终就是Float64(10) + 1.0,即11.0

除此之外,我们刚刚讲的这两个merge函数都分别有一个名为merge!的孪生函数。显然,后两者都会修改我们提供的参数值。更具体地说,它们都不会构造新的字典,而是直接在我们传入的第一个字典上做改动,然后把这个字典作为结果值返回。请看下面的示例:

  1. julia> dict9 = Dict(1=>"x", 2=>"y", 4=>"z");
  2. julia> merge!(dict9, dict7)
  3. Dict{Int64,String} with 4 entries:
  4. 4 => "z"
  5. 2 => "b"
  6. 3 => "c"
  7. 1 => "a"
  8. julia> merge!(*, dict9, dict8)
  9. Dict{Int64,String} with 4 entries:
  10. 4 => "z"
  11. 2 => "b"
  12. 3 => "cc"
  13. 1 => "aa"
  14. julia> dict9
  15. Dict{Int64,String} with 4 entries:
  16. 4 => "z"
  17. 2 => "b"
  18. 3 => "cc"
  19. 1 => "aa"
  20. julia>

在这个例子中,第一个函数调用已经改变了dict9。所以,第二个函数调用其实是在第一次改动的基础上又做了修改。

请注意,dict9的键类型仍然是Int64,而值类型也仍然是String。它们并没有被改变。

其原因是这样的:merge!函数并不会在意参数值的键类型和值类型,更不会去寻找相应的公共类型。它们只会试图把后续字典中的键值对直接添加进第一个字典。在这种情况下,如果某个后续键值对的键类型与第一个字典的键类型不同,并且前者也不是后者的子类型,那么那个键就会被强行地转换为后者的值。对于那些键值对中的值来说也是如此。倘若不做这样的类型转换,那些类型原本不同的键值对就无法被添加或更新到第一个字典当中。

也正因为如此,当上述的类型转换无法成功完成时,我们就会收到相应的错误:

  1. julia> merge!(dict9, dict8)
  2. ERROR: MethodError: Cannot `convert` an object of type Char to an object of type String
  3. # 省略了一些回显的内容。
  4. julia>

根据错误信息可知,Char类型的对象无法被转换为String类型的对象。这样的转换是在底层由convert函数自动执行的。

到这里,你可能会有个疑问,那为什么表达式merge!(*, dict9, dict8)却可以被成功求值呢?这其实只是侥幸而已。不过,第二个merge!函数(即带有combine参数的merge!函数)确实也帮了忙。

当一个后续键值对中的键与第一个字典中的某个键相等时,第二个merge!函数就会先调用combine所代表的函数去整合两个值,然后才会把整合后的结果值更新到第一个字典里的相应键值对当中。

在我们让 Julia 执行表达式merge!(*, dict9, dict8)的时候,字典dict8中的所有键恰恰都存在于dict9当中。又由于combine所代表的操作符*可以把一个字符串和一个字符拼接在一起并返回一个新的字符串,这才避免了前面那样的错误。

综上所述,为了保险起见,我们尽量不要让merge!函数去合并在类型上不兼容的多个字典。反观merge函数,它们虽然可以通过寻找公共类型解决掉上述类型不兼容的问题,但是它们返回的字典在类型方面却很可能会与我们传入的字典大相径庭。在你使用merge函数的时候一定要考虑清楚,你的程序是否可以接受这种变化。

不可否认,这四个函数都可以在很多时候为我们提供便利。只不过这种便利并不是完全没有代价的。如果确实有必要,你可以实现自己的合并函数,以满足特殊的需求。

到这里,我们已经讲述了与标准字典有关的许多知识。这包括了特性、结构、规则、约束、类型、实例化、操作等几个方面。我们首先特别地强调了hash方法和isequal方法对于字典及其键的重要性。在字典之上的所有操作几乎都会直接或间接地用到它们。

另一方面,标准字典的构造函数Dict是很灵活的。只要我们向它传入的可迭代对象包含了若干个长度为2的可索引对象,就可以成功地构造出一个字典。当然,我们不传入任何参数值或者直接传入键值对也是可以的。

在字典的操作方面,索引表达式无疑起到了非常重要的作用。但是,它一次只能存取一个键值对。若我们想访问字典中的所有键值对则可以使用迭代。此外,我们还可以通过一些方法只获取字典的键列表和值列表。最后,我们可以把多个字典合并在一起,具体的细节就如上面刚刚讲过的那样。

有了这些知识,我想你在常规情况下使用标准字典就不会再有问题了。如果你想学习和使用继承自AbstractDict的其他字典(如IdDictWeakKeyDict),那么可以查阅 Julia 的官方文档。同样的,它们都是哈希表的一种实现,而且在功能上也与Dict差不多,只不过各自具有一些小特点罢了。