本文是《Julia 编程基础》开源版本第八章:字典与集合。本书旨在帮助编程爱好者和专业程序员快速地熟悉 Julia 编程语言,并能够在夯实基础的前提下写出优雅、高效的程序。这一系列文章由 郝林 采用 CC BY-NC-ND 4.0(知识共享 署名-非商业性使用-禁止演绎 4.0 国际 许可协议)进行许可,请在转载之前仔细阅读上述许可协议。
在 Julia 的世界里,容器是最典型的参数化类型。类型的参数化也让容器变得更加强大和高效。作为程序开发者,我们最直观的感受就是,它可以让我们用更少的代码完成更多的事情。
我们在上一章已经讨论了最简单且常用的容器——元组。在本章,我们就接着讲一讲另外两个容器——字典和集合。不论是功能、操作方式还是适用场景,它们都与元组有着明显的不同。
8.1 索引与迭代
在接着讲容器之前,我们先来说说索引和迭代。它们都属于比较基础的知识,起码对于容器来说是如此。
8.1.1 索引与可索引对象
你现在应该已经对索引这个词比较熟悉了。所谓的索引就是一种编号机制。这种编号机制可以对一个值中的某种整齐且独立的组成部分(或称索引单元)进行编号。像字符串中的字节以及元组中的元素值都属于索引单元。
在 Julia 中,索引单元的编号通常是从1
开始的,并以值中索引单元的总数作为最后一个编号。由于有着这样的编号风格,这种索引也被称为线性索引(linear indexing),其编号也被叫做线性索引号或简称为索引号。
我们之前也说过,索引表达式通常由一个可索引对象以及一个由中括号包裹的索引号组成。这里所说的可索引对象其实就是我刚刚讲的包含了索引单元的那种值。我们讲过的字符串、元组以及后面将要讲到的数组都是非常典型的可索引对象。字典也是可索引对象,但是它的索引机制并不是依据线性索引建立的。
从根本上讲,一个值是否属于可索引对象,完全依赖于是否存在针对其类型的索引方法。若我们在一个不属于可索引对象的值上应用索引表达式,就立即会引发一个错误。例如:
函数调用Set(1)
会返回一个只包含了元素值1
的集合,而集合并不属于可索引对象。所以,在我对其应用了索引表达式之后,Julia 就立即报错了。
在阅读了错误信息后我们会发现,报错的原因是没有针对Set{Int64}
类型的getindex
方法(即getindex
函数的衍生方法)。而这个getindex
方法恰恰就是那个最重要的索引方法。如果我们想让某个类型的值成为可索引对象就至少要实现对应的getindex
方法。因此,有一种更好的方式可以判断某类值是否属于可索引对象,示例如下:
函数applicable
可以接受若干个参数值。第一个参数值必须是某个函数(以下称目标函数)的名称,而后续的参数值(以下称目标参数值)则应该是需要依次传给目标函数的参数值。applicable
函数的功能是,判断是否已经存在目标函数的某个衍生方法,这个衍生方法恰恰可以接受那些目标参数值。在这里,这些目标参数值具体是什么其实并不重要,重要的是它们的类型都是什么。因此我们也可以讲,applicable
函数可以检查是否存在基于某个函数的、具有特定参数类型的衍生方法。
具体到上面的例子,第一行代码会判断有没有名称为getindex
的衍生方法。具体的要求是,它的第一个参数的类型是Tuple
,并且其第二个参数的类型是Int
。由于判断的结果是true
,所以元组一定属于可索引对象。类似的,第二行代码判断的是有没有针对Set
类型的getindex
方法。由结果可知,集合肯定不属于可索引对象。
幸好applicable
函数返回的结果值不是true
就是false
。所以,我们可以很安全地进行判断,而不用担心像使用索引表达式那样引发错误。
除了getindex
方法之外,如果是可变的可索引对象,那么通常还会实现setindex!
方法。这个方法应该可以被用来改变容器中的与某个或某些索引号对应的元素值。
注意,虽然字符串和元组都属于可索引对象,但是却没有与String
类型或Tuple
类型对应的setindex!
方法。因为这两个类型的值都是不可变的。所以说,判断一个值是否属于可索引对象还是要以是否存在对应的getindex
方法为准。
8.1.2 迭代与可迭代对象
迭代(iteration)这个词我们在之前没有提到过。什么叫迭代呢?简单来说,迭代指的就是根据反馈重复地执行相同操作的过程。如此执行的目的往往是一步一步地逼近并达成某个既定的目标。由于迭代会重复地执行操作,所以它通常都会被放到一个循环当中。在这种情况下,每执行一次操作(即每循环一次)都可以说是一次迭代。除了最后一次迭代,每一次迭代的结束点都会成为下一次迭代的起始点。
在 Julia 中,我们可以使用for
语句来实现循环。for
语句是控制代码的执行流程的一种方式。它可以重复地执行语句中的代码,直到满足完全结束的条件为止。由于在后面会有专门的一章介绍 Julia 代码的流程控制,其中也有对for
语句的阐述,所以我们当下只聚焦于怎样用for
语句迭代容器从而取出其中的元素值。请看下面的示例:
我使用上面的这条for
语句打印出了tuple3
中的每一个元素值,并且每个元素值的展示都独占一行。更具体地讲,其中的每一次迭代都会打印出某一个元素值,并且打印的顺序完全依从于线性索引的顺序。也就是说,第一次迭代会打印出与索引号1
对应的那个元素值,第二次迭代会打印出与索引号2
对应的元素值,以此类推。直到打印出tuple3
中的最后一个元素值,也就是与索引号5
对应的元素值,这个循环才完全结束。
我们可以看到,这条for
语句的代码占用了三行。第一行是以关键字for
开头的,后面跟着迭代变量e
、关键字in
和被迭代的对象tuple3
,它们之间都由空格分隔。与很多其他的代码块一样,for
语句也是以独占一行的end
作为结尾的。
所谓的迭代变量是一种局部变量。它的作用域是当前的for
语句所代表的代码块。换句话讲,它在当前的for
语句之外是不可见的。或者说,该语句之外的代码是无法引用到它的。如果被迭代的对象是一个容器,那么迭代变量在每一次迭代中都会被分别赋予该容器中的某一个元素值。对于元组来说,其中的元素值会被按照线性索引的顺序依次地赋给迭代变量。这也是上述示例能够打印出这般内容的根本原因。
可索引对象基本上都是可迭代对象。因为它们都有索引机制的加持,支持迭代很容易。除此之外,集合也都是可迭代对象,虽然它们并不是可索引对象。
我们如果要判断一个值是否属于可迭代对象,那么可以这样做:
其中的函数iterate
对于可迭代对象来说非常的重要。倘若我们要让某个类型的值成为可迭代对象,那么实现与之对应的衍生方法是必不可少的。因此,iterate
函数以及相应的衍生方法也就成为了辨别可迭代对象的黄金标准。
我们稍后就会讲到怎样对字典或集合做迭代。请接着往下看。
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
是比较灵活的。首先,我们调用它时可以不传给它任何参数值,就像这样:
可以看到,它这时会返回一个键类型和值类型都为Any
的字典实例。当然,我们也可以在构造其实例的同时对键类型和值类型进行指定:
关于标准字典对键类型的要求,我在前面已经说过了。在这里,我要讲的是一种很有趣的现象,它是有些反直觉的。先看下面的代码:
我先定义了一个可变的复合类型,名称为MyKey
。这个复合类型有两个字段,关于它们的类型我们在前面都说明过。之后,我又声明了两个变量,并分别为它们赋予了MyKey
类型的值。注意,这两个结构体中的同名字段的值都是两两相同的。下面,我们再构造一个标准的字典,并指定它的键类型为MyKey
:
现在注意看下面的代码:
是的,我们可以利用索引表达式和一个键在字典中存、取、改与该键对应的值。但这并不是这里的重点。重点是,在我改变了key1
中的sn
字段的值之后,我依然可以从dict1
中获取到10
这个值。这是为什么呢?现在的key1
已经与原来的key1
不同了啊!再来看一行代码:
索引表达式dict1[key2]
竟然让 Julia 报错了。至于它为什么会报错,我们到下一个小节再说。现在注意看错误信息,它表明dict1
中并没有key2
所代表的那个键。还记得吗?key2
的值与key1
原来的值是相同的。但是我们在这里却无法从dict1
中获取到与之对应的值。这是不是很奇怪呢?
这两个结果实际上展示了同一种现象。我们已经知道,字典会利用isequal
方法判断两个键是否相等。因此,这种现象的背后其实就是一个关于结构体判等的问题。我们现在来直接判断一下:
为了复现上述的现象,我先还原了key1
的值。可以看到,即使key1
和key2
中的同名字段的值都两两相同,它们也不是相等的。由于key1
不能在同一时刻代表两个不同的值,所以我无法利用isequal
方法复现另一个结果。但请记住,在这里,即使key1
中的某个字段的值被改变了,它仍然会与其原本的值相等。
这是不是有些反直觉呢?我们的预期是,对于类型相同的两个结构体,只要其中的同名字段的值都两两相同,那么它们就是相等的。并且,字典应该把这样的两个值视为同一个键。可是,上述代码的执行结果却正好相反。
如果你还记得操作符===
的特性的话,那么就应该知道:对于可变的值,这个操作符会比较它们在内存中的存储地址。结构体其实也相当于一个置物架,其中的字段也相当于一个个格子。无论我们向置物架的格子中放置什么物品,这个置物架都还是原来的那一个。同理,无论与key1
绑定的那个结构体中的字段值怎么变,该结构体在内存中的存储地址都不会变。这其实就是dict1[key1]
仍然会返回10
的最深层原因。
但是,新问题又来了。我们明明调用的是isequal
方法,可为什么判等结果却会符合操作符===
的特性呢?
我们已经知道,在大多数情况下,isequal
方法的行为都会依从于操作符==
的判等结果。但你可能还不知道的是,Julia 还内置了如下的定义:
它的含义是,当没有针对某个类型的==
方法时,我们若用操作符==
判断该类型的两个值是否相等,就相当于在用===
做判断。所以,在这种情况下,如果也没有针对这个类型的isequal
方法,那么我们调用isequal
方法也就相当于在用===
。
现在问题终于明朗了。我们既没有为MyKey
类型显式地定义==
方法,也没有为它定义isequal
方法。一旦问题定位清楚了,就差不多等于解决了一半。我们下面就定义针对MyKey
类型的==
方法,因为这样做可以解决得更彻底一些。代码如下:
我们之前说过,编写某个函数的衍生方法的时候必须先导入这个函数。因此,第一行代码是必须的。第二行代码就是==
方法的定义。它的两个参数的类型都是MyKey
,这一点很重要。在赋值符号=
右边的代码就是==
方法的方法体。其中的操作符&&
代表着逻辑与运算。这意味着,只有在它两边的判断表达式的结果都是true
,它们合起来的结果才会是true
,否则就会是false
。因此,这个方法体表达的就是,只要两个参数值的code
字段的值和sn
字段的值都分别相等,这两个参数值就是相等的。
下面,我们重新做一遍之前的操作:
上面的结果是符合我们的预期的。但是,当我们执行如下代码的时候,Julia 仍然会报错:
这又是为什么呢?其原因是,字典会先利用hash
方法确定键值对的存储位置。如果连存储的位置都不同,那就更别提取出相应的值了。上面就属于这种情况。由于key1
和key2
的哈希码不相等:
所以dict1
通过key2
找到的存储位置并不是存储key1
的那个位置。由此它会认为这个键根本就不存在。这时的这个hash
函数会基于值在内存中的存储地址计算哈希码。很显然,key1
和key2
在内存中的存储地址是不同的。因而它们的哈希码也不会相等。
为了解决这一问题,我们还需要为MyKey
类型定义一个hash
方法。因为上面的这个hash
函数在 Julia 中的定义是这样的:
所以我们需要像下面这样来编写针对MyKey
类型的hash
方法:
现在,我们再次运行上面的代码,就会得到如下的结果:
这个结果终于完全符合我们的预期了。并且,一旦key1
中的某个字段的值被改变,它就不再是dict1
中的键了:
说了这么多,我就是想郑重地告诉你,isequal
方法和hash
方法对于一个字典的键类型来说有多么的重要。如果我们想让自己定义的数据类型作为字典的键类型,那么不但应该为它编写isequal
方法,还应该同时为它编写相应的hash
方法。而且,这两个方法在基本的逻辑方面应该保持一致。比如,如果一个isequal
方法不会基于值的存储地址做判断,那么相应的hash
方法就应该同样不基于值的存储地址,反之亦然。
好了,让我们把视线重新放到字典的构造函数上。我们在调用构造函数Dict
的时候,还可以传给它几种参数值。
首先,我们可以传入一个包含了若干同类型元组的数组,就像这样:
在我们给予Dict
函数的数组中有三个元素值。这个数组由一个中括号包裹,并且其中的元素值之间都有英文逗号进行分隔。这就是用字面量表示一个一维数组的一般方式。其中的每一个元素值都是一个元组。这些元组的类型都是Tuple{Int64,String}
,而且它们都只包含了两个元素值。
实际上,Dict
函数不但要求这些元组的类型必须相同,还要求它们都必须包含两个元素值,既不能少也不能多。因为这里的每一个元组都会被视为一个键值对。其中的第一个元素值是键值对中的键,而第二个元素值则是键值对中的值。
其实,我们把这里的元组换成数组也是可以的。例如,我们传给Dict
函数的数组可以是[[1, "a"], [2, "b"], [3, "c"]]
。这个字面量表示的是一个数组的数组,相当于在数组里又嵌套了数组。更宽泛地讲,只要我们给予的那一个参数值是可迭代对象,并且每一次被迭代出来的值都是长度为2
的可索引对象,Dict
函数就可以接受它。
像元组、字典、数组这样的容器基本上都既是可迭代对象也是可索引对象。不过,我不建议把像字典这样的无序容器作为内层的可索引对象。因为如果这样做的话,就会使得被构造的字典中的键值对带有不确定性。
除此之外,在 REPL 环境回显的内容中有一个我们未曾见过的符号=>
。这个符号表示的实际上就是从某个键到某个值的单向映射关系。它很像一个箭头,不是吗?在该箭头的尾部的值(即左侧的那个值)就是键值对中的键,在其头部的值(即右侧的那个值)就是键值对中的值。
如此表示的键值对其实也可以被作为独立的参数值传给Dict
函数。例如:
这次我们向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 存取键值对
我们已经知道,可以用索引表达式存取字典中的键值对。当索引表达式与赋值符号=
联用时,它一般会把键值对添加进指定的字典。例如:
注意,在这个索引表达式的中括号里的不是索引号,而是键。并且,这里的键只能有一个。它既可以由一个字面量表示,也可以由一个变量代表,还可以是一个求值结果为键的表达式,只要其类型符合字典的定义就可以。而在赋值符号右边的就是要与这个键配对的那个值。它们在字典中会组合成为一个键值对。
只要一个字典里已经存在我们要添加的键,那么这样的索引表达式就不会再向该字典添加这个键值对了。它此时只会改变该字典中与这个键对应的值。
另一方面,索引表达式还可以取出字典里与指定的健对应的值。这也是它的默认功能。但是,只要那个字典中不存在这个健,它就会立即报错。示例如下:
如果我们不加以处理,这样的错误就会让程序执行的正常流程中断,甚至最终导致程序的崩溃。用于流程控制的try/catch
语句可以做这样的处理,但是这要到后面的部分才会讲到,我们在这里就不提了。其实还有好几个办法可以应对这种情况。
一个最直接的办法就是使用haskey
函数。haskey
函数可以接受两个参数,第一个参数代表字典,另一个参数代表键。它总是会返回一个Bool
类型的结果值,以表示这个键是否存在于该字典中。例如:
由于haskey
函数不会在找到指定的键时返回对应的值,所以我们为了达成dict2["a"]
的功能还需要多写一些代码:
这行代码是一个代表了三元操作的表达式,其中的英文问号?
和英文冒号:
都是专用的操作符。什么是三元操作呢?我们在讲数值运算的时候介绍了一元运算(如一元减、平方根等)和二元运算(如加、减、乘、除等等)。所谓的一元运算就是只涉及了一个操作数的运算,而二元运算就是有两个操作数的运算。以此类推,三元运算自然就应该包含三个操作数。当然,这里的操作数也可以由字面量、变量或表达式来表示。
上述三元操作的表现形式这样的:
若换成纯文字来表达就是:第一个操作数的求值结果是true
吗?如果是,那么就对第二个操作数求值并将其结果值作为此三元操作的结果,否则就对第三个操作数求值并将其结果值作为此三元操作的结果。因此,上面那行代码的含义即为:键"a"
是否存在于字典dict2
中?如果存在就返回与之对应的值,否则就返回nothing
。
实际上,我们还可以用更少的代码实现此功能。这很简单,只需调用get
函数:
除了字典和键之外,get
函数还会接受一个默认值。当这个键未在该字典中时,此默认值就会被当作结果值返回。
其实我们在这里讲的只是get
函数的一个衍生方法。但由于其他的衍生方法会涉及到流程控制,所以我在这里就不多做介绍了。它们的功能很相似,只不过在表现形式上会有所不同。与之相比,更值得一说的是它的孪生函数get!
。
你可能会疑惑,get!
函数的名称为什么以英文叹号!
结尾呢?该函数与get
函数最重要的区别是,它会在必要时改动字典中的键值对,而get
函数最多只会从字典中获取值。
更宽泛地讲,这其实是 Julia 当中的一种惯用法。名称以!
结尾的函数往往会修改我们传给它的那个最主要的参数值。对于get!
函数来说,这个最主要的参数值就是字典。反过来讲,名称不以!
结尾的函数通常都会保证任何参数值都不会被修改。也可以说这是对原有数据的一种保护。因此,在 Julia 中,“名称以!
结尾”就成为了一种标志。它向我们表明了当前函数在这方面的行为方式。Julia 标准库以及很多第三方库都严格地遵循了这种惯用法。理所应当,我们在编写新的函数的时候也应该遵从它。
我们再来看get!
函数。这个函数的参数列表与get
函数的参数列表一模一样。但是,前者会在发现字典中不存在指定的键时向该字典添加新的键值对。这个新键值对中的键就是我们指定的那个键,而其中的值则是我们给予它的默认值。示例如下:
与此函数在功能上有些相似的一个函数名叫pop!
。对它来说,字典和键是必选的参数,而默认值则是可选的参数。如果它在字典中找到了指定的键,那么也会返回与之对应的值。但是,在返回这个值之前,它还会把相应的键值对从字典中删除掉。请看下面的示例:
另一方面,如果我们传给它了一个默认值,那么在字典中不存在指定的键时,它与get
函数(注意,不是get!
函数)的行为是一致的,如:
请注意,如果指定的键未在字典中,而我们又没有把默认值传给它,那么它就会立即报错:
所以,看起来get
函数才是最安全的。我们其实可以利用haskey
函数和索引表达式来这样模拟get!
函数的功能:
另外,对于pop!
函数,我们可以这样进行防御性编程(defensive programming):
这样做既可以避免pop!
函数的报错,也可以做到功能上的模拟,而且在性能上也不会有明显的损失。
最后,我顺便提一下删除操作。我们可以使用delete!
函数从一个字典中删除掉某个指定的键及其对应的值:
这个函数会返回改动后的字典,也就是我们传给它的那个字典。另外,我们还可以通过调用empty!
函数去清空一个字典,也就是删掉其中的所有键值对,如empty!(dict2)
。它会返回已被清空的字典。
8.2.3.2 迭代
我们对字典的迭代依然可以使用for
语句来做。这很简单,示例如下:
对于字典来说,迭代变量可以只有一个。这时,for
语句迭代出的值的类型就将是Pair
类型。而该类型的两个类型参数值将分别是字典的键类型和值类型。我们已经知道,一个Pair
类型的值就代表着一个键值对,也相当于一个小容器。并且,这类值既属于可索引对象也属于可迭代对象。其中的键的索引号是1
,而值的索引号则是2
。
这里的迭代变量也可以是两个。在这种情况下,我们必须使用圆括号把这两个变量的标识符包裹起来,就像这样:
在这个例子中,迭代变量k
会代表键,而v
会代表与之对应的值。
千万别忘了,字典中的键值对都是无序存储的。所以,字典不会对键值对的迭代顺序做出任何的保证。我们更不要想当然地假设基于字典的迭代的顺序,不论我们迭代的方式是什么。
此外,如果你只想对字典中的键或值做迭代也是可以的。不过,这需要先利用keys
方法或values
方法获取到字典的键列表或值列表。我们马上就会讲到。
8.2.3.3 获取列表
Julia 提供了一些方法,可以让我们分别获取字典中的键列表和值列表。
若要获取键列表,我们就要先调用一个名为keys
的方法。这个方法的用法再简单不过了,代码如下:
keys
方法在被调用之后会返回一个Base.KeySet
类型的值。在这个值中就包含了字典中所有的键。我们可以看到,这个值的类型与源字典类型的关系非常紧密。实际上,这类值仅仅是把源字典又简单地包装了一下而已。
我们可以应用在此类值上的方法很少,恐怕只有length
和isempty
可用。前一个方法可以获取到值的长度,而后一个方法则可以判断值中是否没有任何键。另外,我们还可以通过调用in
方法来判断其中是否存在某个键:
当然,in
函数也有针对字典的衍生方法。不过它需要的第一个参数值是不同的:
另外,Base.KeySet
类型的值都是可迭代对象,但是很可惜它们都不是可索引对象。从该类型的名称上,我们也可以猜到,这类值属于集合。所以我们也可以称之为键集合。那些可以应用在集合上的方法基本上也都适用于键集合。至于都有哪些方法,我们到后面再说。
无论怎样,如果你想得到更多的操控性,那么可以先把键集合转换为标准的集合或者一维的数组,就像这样:
对于字典的值列表来说也是类似的。我们需要先通过调用values
方法获取到包含着所有值的迭代器:
然后,再将这个迭代器转换成集合或者数组:
顺便说一下,我们把一个键集合传给eltype
方法就可以得到源字典的键类型,而把一个值迭代器传给eltype
方法则可以得到源字典的值类型。相应的,对于一个字典,我们可以通过调用keytype
方法获取到它的键类型,或者调用valtype
方法获取到它的值类型。下面是相应的示例:
8.2.3.4 合并
Julia 中有专门的函数可以进行字典的合并,名为merge
。这里说的其实是两个函数,我们先来讲参数较少的那一个。
这个merge
函数可以同时接受多个字典作为其参数值。它会构造一个新的标准字典,并把它接受的所有字典中的键值对全部都添加到这个新字典中,最后返回新字典。由于字典中的键肯定都是唯一的,所以这相当于对多个字典中的键值对取并集。下面是一个简单的示例:
我们在这里需要特别注意传给它的参数值的顺序。因为,如果在多个参数值中存在相等的键,那么在新字典中与此键对应的那个值就将是最右边的那个参数值里的相应键值对中的值。示例如下:
我们可以看到,新字典中的键"a"
、"b"
和"c"
所对应的值都是字典dict5
里的。如果我们把dict5
与dict4
调换一下位置,那么结果肯定就会有所不同。为了描述方便,我们在后面会称此为merge
函数的最右优先规则。
另外,merge
函数还会在必要时对新字典的键类型和值类型做适当的提升。例如:
从 REPL 环境回显的内容可知,新字典的值类型已经被提升为了Float64
,而不是dict5
的值类型Int64
。我们在讲数值与运算的时候讨论过 Julia 的类型提升系统。如果你忘记了相关的规则,那么可以翻回去复习一下。
即使新字典的键类型也需要提升,情况通常也不会变得更加复杂:
字典dict7
的键类型是Int64
,值类型是String
。而字典dict8
的键类型是Float64
,值类型是Char
。我们已经知道,Int64
和Float64
的公共类型就是Float64
。而String
和Char
虽然看起来关系挺近的,但其实它们的公共类型却是顶层类型Any
。
请注意,merge
函数对新字典的键类型和值类型的设定是完全依从于 Julia 的类型提升系统的。而它的最右优先规则却正好相反,完全是它自己制定的。实际上,merge
函数会先利用promote_type
函数获得所有参数值的键类型的公共类型以及它们的值类型的公共类型。然后,该函数会用这两个公共类型去构造一个新的字典。它会先把最左边的参数值中的键值对都放入新字典,然后再利用索引表达式依次地放入更靠右的那些参数值中的键值对。如果有相等的键,那么新的值自然就会覆盖掉旧的值,但是键肯定还是最开始放入的那一个。
我们再来讲另一个merge
函数。这个merge
函数除了可以接受多个字典之外,还有一个名为combine
的必选参数,并且这个参数还处在最左边的参数位置上。
这个参数的作用是确定值的合并策略,即:若出现了相等的键,它们的值怎么整合在一起。我刚才也说了,前一个merge
函数在这种情况下总会用新值完全替换掉旧值。所以这两个函数在细节的处理上显然是不同的。
下面,我们通过一些代码来体会一下它们的区别:
第一个函数调用的结果虽然是经过类型提升之后的新字典,但是在值的合并方面却只有完全的替换而没有真正的整合。第二个函数调用就不同了,它会依从第一个参数的值去整合相等键的多个值。
实际上,第二个merge
函数只是在需要整合多个值的时候调用了第一个参数值所代表的函数(别忘了,数学运算符也是用函数实现的)。因此,新字典中的键"a"
所对应的值最终就是Float64(10) + 1.0
,即11.0
。
除此之外,我们刚刚讲的这两个merge
函数都分别有一个名为merge!
的孪生函数。显然,后两者都会修改我们提供的参数值。更具体地说,它们都不会构造新的字典,而是直接在我们传入的第一个字典上做改动,然后把这个字典作为结果值返回。请看下面的示例:
在这个例子中,第一个函数调用已经改变了dict9
。所以,第二个函数调用其实是在第一次改动的基础上又做了修改。
请注意,dict9
的键类型仍然是Int64
,而值类型也仍然是String
。它们并没有被改变。
其原因是这样的:merge!
函数并不会在意参数值的键类型和值类型,更不会去寻找相应的公共类型。它们只会试图把后续字典中的键值对直接添加进第一个字典。在这种情况下,如果某个后续键值对的键类型与第一个字典的键类型不同,并且前者也不是后者的子类型,那么那个键就会被强行地转换为后者的值。对于那些键值对中的值来说也是如此。倘若不做这样的类型转换,那些类型原本不同的键值对就无法被添加或更新到第一个字典当中。
也正因为如此,当上述的类型转换无法成功完成时,我们就会收到相应的错误:
根据错误信息可知,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
的其他字典(如IdDict
和WeakKeyDict
),那么可以查阅 Julia 的官方文档。同样的,它们都是哈希表的一种实现,而且在功能上也与Dict
差不多,只不过各自具有一些小特点罢了。
8.3 集合
集合也是一种容器。它与字典有些相似,但又有着明显的不同。一个字典可以保证其中的键互不相等,而一个集合也可以保证其中的元素值互不相等。并且,与字典类似,集合中的元素值都是无序存储的。
不过,字典容纳的是一个个同类型的键值对,而集合容纳的却是一个个同类型的元素值。这里的不同在于,字典会区别看待和操作键值对中的键和值,而集合则只会且只能把其中的元素值都视为不可再拆分的整体。当然,我们也可以在集合中存放键值对(即Pair
类型的值),但它依然会把那些键值对都视为元素值。
正因为集合的上述特性,我们可以很轻易地使用字典来模拟集合。比如像这样构造一个字典:
然后像这样判断其中是否存在某个键(即集合中的元素值):
简单地解释一下,Nothing
是一个单例类型,它的唯一实例是nothing
。我把字典mock_set
的值类型设置成了Nothing
,主要是为了避免其中的键值对里的值占用过多的内存空间。因为这样的话这些值就都只会且只能代表同一个对象了,即nothing
。
可是,集合本身的特性并不能完全体现出它的优势。基于集合的操作才是其最大的优势。所以,严格来讲,我们无法用字典替代集合。下面,我们就来一起看一看这是一种什么样的容器。
8.3.1 类型与实例化
在 Julia 中,代表集合的抽象类型是AbstractSet
。Julia 标准库里还有它的子类型有Set
和BitSet
。我们可以称前者为标准集合,称后者为位集合。
标准集合的构造函数Set
可以在不接受任何参数值的情况下构造出一个标准集合。但它也可以接受一个可迭代对象,并以其中的元素值作为新集合最初包含的元素值。示例如下:
可以看到,当我们不给它任何参数值的时候,新集合的元素类型就会是Any
。虽然这样的集合可以容纳任何类型的元素值,但是这通常都不是我们想要的。因为如此一来就基本上失去了参数化类型的好处了。因此,我们一般不这样使用这个函数。
如果我们向它传递了一个可迭代对象,那么新集合的元素类型就会与这个可迭代对象的元素类型或者键值对类型相同。不过,当我们传入的是元组的时候,情况就比较特殊了。因为元组中的各个元素值可以是不同类型的,而且它们的类型还会体现在元组的类型参数中。请看下面的示例:
一旦我们传入的元组包含了不同类型的元素值,就会迫使Set
函数去寻找这些元素类型的共同超类型,并把找到的共同超类型作为新集合的元素类型。在最坏的情况下,这个共同超类型将是Any
。我们在讲数值类型提升的时候提到过共同超类型。它指的是多个类型都有继承的且距离它们最近的那个超类型。
可见,由于元组的特性,它有可能会让Set
函数构造出元素类型迥异的集合,并且这样的元素类型肯定都属于抽象类型。所以在这种情况下,你就需要考虑清楚了,想好怎样应对这种变化。
8.3.2 操作集合
不知道你是否还记得当初学过的集合运算。这应该是高中教过的数学知识。集合运算包括求并集、求交集、求差集和求对称差集。对于这些运算,Julia 都提供了相应的函数。我下面就带着你快速地浏览一下这些函数。
我们先来说求并集。与并集对应的函数叫做union
。它的功能是对多个集合中的元素值进行合并。示例如下:
显然,新集合中的元素值是经过去重(即去除重复)的。对于集合来说理应如此。但去重的操作是在union
函数的内部完成的,而没有让新集合自己去做。另外,union
函数还会在必要的时候提升新集合的元素类型。这与合并字典时的做法是类似的。
再来说求交集。函数intersect
具有此功能。该函数可以提取出多个集合共有的元素值,并把它们都放入新的集合。下面是例子:
注意,只有所有的参数值都共有的元素值,才会被intersect
函数放入到新的集合中。
函数setdiff
能够求多个集合的差集。所谓的差集,就是一个集合包含的但另一些集合都未包含的元素值所组成的集合。因此,与前两个函数不同,setdiff
函数对参数值的位置是很敏感的。它尤其关注哪一个参数值在最左边的位置上。请看下面的示例:
第一个集合中有元素值1
、2
和3
,但只有1
是后续的集合中都没有的。所以新集合只包含了1
。如果我们改变一下这几个参数值的位置,那么结果就会不同,如:
集合Set([2, 3, 4])
中的每一个元素值都存在于后续的某个或某些集合中,所以新集合就是空的。而在集合Set([3, 4, 5])
中,只有5
是后续的集合都没有的,因此新集合就只包含了5
。
函数symdiff
可用于求取多个集合的对称差集,或者说对称差分的集合。我们一定要搞清楚对称差集的定义,即:属于两个集合的并集但不属于它们的交集的元素值所组成的集合。请看示例:
对于上面三个作为参数值的集合,它们的并集是Set([1, 2, 3, 4])
且交集是Set([2, 3])
,所以新集合中才会包含1
和4
。
如果参与的集合超过了两个,那么我们就要特别注意了。因为symdiff
函数并不会去求取所谓的所有集合的对称差集。它会依据参数值的前后位置一步一步地进行计算,并且每一步只让两个集合参与计算。示例如下:
在这里,symdiff
函数会先求前两个集合的对称差集,然后再去求这个对称差集与第三个集合的对称差集。最后得到的这个对称差集才是最终的结果。如果用表达式来描述的话就是这样的:
再来看一个例子,这次会有四个集合参与计算:
你可以先在心里想象一下这个对称差集的计算步骤,然后再看下面的分解操作:
怎么样?这样是不是就很清晰了呢?总之,数学中的对称差集只能有两个参与计算的集合。因此,当symdiff
函数接受到更多的参数值时只会一步一步地进行求解。
我们上面所讲的函数union
、intersect
、setdiff
和symdiff
都有各自的孪生函数,即:union!
、intersect!
、setdiff!
和symdiff!
。我们已经知道,前四个函数都不会修改任何的参数值,而且返回的集合也是新构造出来的。然而,从名称末尾的!
我们就可以猜得出,后四个函数都会直接修改第一个参数值以体现计算的结果,而且返回的集合就是这个被修改的参数值。
我们再延伸一点。前文所述的这些函数可以应用的对象其实都不只是标准集合Set
。或者说,我们一直在讲的实际上都是针对Set
类型的衍生方法。在这些函数之下,还有针对BitSet
、Array
甚至其他的可迭代对象的衍生方法。至于具体都有哪些衍生方法,我们可以通过调用methods
函数查看,比如:
除此之外,还有一些函数可以直接操作集合,比如用于判断一个集合是否是另一个集合的子集的issubset
,又比如用于判断两个集合是否拥有同样的元素值的issetequal
。由于这些函数使用起来都比较简单,所以我就不在这里展开讲了。接下来,我会再简述一下那些可以适用于各种容器的通用操作。
8.4 通用操作
说到适用于各种容器的函数,其实我在前面已经提到过一些。比如,empty!
函数能够清空作为参数值的那个容器。这里的参数值不仅可以是字典,也可以是集合、数组等可变的容器。
empty!
函数还有一个名为empty
的孪生函数。这个函数不会对参数值进行任何的修改,而且会返回一个与参数值一模一样但不包含任何元素值的结果值。另外,还有一个名称很相近的函数isempty
,它可以判断一个容器是否为空。
在很多时候,仅仅判断一个容器是否为空是远远不够的,我们还需要知道一个容器总共容纳了多少个元素值或键值对。这时就需要用到length
函数了。如果想要获得容器的元素类型,那么可以调用eltype
函数。我们在前面用过这个函数。当向它传入字典的时候,它会返回字典中键值对的具体类型。另外,我们若想知道一个值是否是某个容器中的元素值、键或键值对,那么就可以通过调用in
函数得到答案。
此外,还有一些函数或操作可以应用于可索引对象和/或可迭代对象。我在前面讲索引与迭代的时候已经有过说明,因此就不再赘述了。
当然了,我在这里不可能罗列出所有的相关函数和操作。如果你需要的通用操作我没在这里讲到,那么你可以通过某种渠道向我提问,或者去查阅官方的文档,也可以到 Julia 社区的论坛里求助。
8.5 小结
这一章的主题是容器。我们主要讲了两个比较有特点的容器,即:字典和集合。
在正式讲解容器之前,我们阐述了什么是索引和可索引对象,以及什么是迭代和可迭代对象。这是两对很重要的概念。
我们已经说过多次,索引其实就是一种编号机制。它可以对一个值中的索引单元进行编号。这种编号也被称为线性索引号,或简称为索引号。有了索引号,我们就可以利用索引表达式对可索引对象中的索引单元进行存取了。这种机制能够有效的关键在于,已存在对应的getindex
方法。如果可索引对象是可变的,那么还应该有对应的setindex!
方法。
迭代,其实就是按照某种既定的规则、反复地进行同样的操作,直至达到某一个目标的过程。对于容器来说,迭代就是逐一地访问并取出其中的元素值的过程。在 Julia 中,我们可以使用for
语句来实现对容器的迭代。我们可以如此操作的关键在于,已存在与这些容器的类型对应的iterate
方法。
在阐释了上述的重要概念之后,我们详细地讲解了 Julia 中的标准字典Dict
。这个标准字典是一种哈希表的具体实现。所以,它的内部操作基本上都是基于哈希码的。也正因为如此,针对字典的hash
方法和isequal
方法即是关键中的关键。在讨论字典的类型及其实例化的时候,我用了不少的篇幅说明了具体的原因,以及为什么在有些时候对字典的操作结果并不符合我们的直觉和预期。另外,我们还讨论了操作字典的几种常用方式。
我们可以把集合看成是简化的字典。与字典一样,集合也是无序的容器。但它包含的不是键值对,而是单一的元素值。针对集合的操作有一个很显著的特点,即:它们几乎都是基于集合运算的。对于并集、交集、差集和对称差集,Julia 都提供了相应的函数。在这里,我们同样需要注意类型提升的问题。
在通用编程领域,字典和集合都是非常常用的容器。只要你编写 Julia 程序不是仅为了复现数学公式和基本算法,那么就很有必要掌握这两种容器。你应该对它们的类型定义、实例化方式以及各种操作方法都足够的熟悉。这样才能在编程的过程中信手拈来。
系列文章:
Julia编程基础(一):初识Julia,除了性能堪比C语言还有哪些特性?
Julia编程基础(二):开发Julia项目必须掌握的预备知识
Julia 编程基础(四):如何用三个关键词搞懂 Julia 类型系统
评论