Categories
程式開發

Julia編程基礎(八):如何在最合適的場景使用字典與集合?


本文是《Julia 編程基礎》開源版本第八章:字典與集合。本書旨在幫助編程愛好者和專業程序員快速地熟悉 Julia 編程語言,並能夠在夯實基礎的前提下寫出優雅、高效的程序。這一系列文章由 郝林 採用 CC BY-NC-ND 4.0知識共享 署名-非商業性使用-禁止演繹 4.0 國際 許可協議)進行許可,請在轉載之前仔細閱讀上述許可協議。

在 Julia 的世界裡,容器是最典型的參數化類型。類型的參數化也讓容器變得更加強大和高效。作為程序開發者,我們最直觀的感受就是,它可以讓我們用更少的代碼完成更多的事情。

我們在上一章已經討論了最簡單且常用的容器——元組。在本章,我們就接著講一講另外兩個容器——字典和集合。不論是功能、操作方式還是適用場景,它們都與元組有著明顯的不同。

8.1 索引與迭代

在接著講容器之前,我們先來說說索引和迭代。它們都屬於比較基礎的知識,起碼對於容器來說是如此。

8.1.1 索引與可索引對象

你現在應該已經對索引這個詞比較熟悉了。所謂的索引就是一種編號機制。這種編號機制可以對一個值中的某種整齊且獨立的組成部分(或稱索引單元)進行編號。像字符串中的字節以及元組中的元素值都屬於索引單元。

在 Julia 中,索引單元的編號通常是從1開始的,並以值中索引單元的總數作為最後一個編號。由於有著這樣的編號風格,這種索引也被稱為線性索引(linear indexing),其編號也被叫做線性索引號或簡稱為索引號。

我們之前也說過,索引表達式通常由一個可索引對像以及一個由中括號包裹的索引號組成。這裡所說的可索引對像其實就是我剛剛講的包含了索引單元的那種值。我們講過的字符串、元組以及後面將要講到的數組都是非常典型的可索引對象。字典也是可索引對象,但是它的索引機制並不是依據線性索引建立的。

從根本上講,一個值是否屬於可索引對象,完全依賴於是否存在針對其類型的索引方法。若我們在一個不屬於可索引對象的值上應用索引表達式,就立即會引發一個錯誤。例如:

julia> Set(1)(1)
ERROR: MethodError: no method matching getindex(::Set{Int64}, ::Int64)
# 省略了一些回显的内容。

julia> 

函數調用Set(1)會返回一個只包含了元素值1的集合,而集合併不屬於可索引對象。所以,在我對其應用了索引表達式之後,Julia 就立即報錯了。

在閱讀了錯誤信息後我們會發現,報錯的原因是沒有針對Set{Int64}類型的getindex方法(即getindex函數的衍生方法)。而這個getindex方法恰恰就是那個最重要的索引方法。如果我們想讓某個類型的值成為可索引對象就至少要實現對應的getindex方法。因此,有一種更好的方式可以判斷某類值是否屬於可索引對象,示例如下:

julia> applicable(getindex, (1,), 1)
true

julia> applicable(getindex, Set(1), 1)
false

julia> 

函數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語句迭代容器從而取出其中的元素值。請看下面的示例:

julia> tuple3 = (10, 20, 30, 40, 50);

julia> for e in tuple3
           println(e)
       end
10
20
30
40
50

julia> 

我使用上面的這條for語句打印出了tuple3中的每一個元素值,並且每個元素值的展示都獨占一行。更具體地講,其中的每一次迭代都會打印出某一個元素值,並且打印的順序完全依從於線性索引的順序。也就是說,第一次迭代會打印出與索引號1對應的那個元素值,第二次迭代會打印出與索引號2對應的元素值,以此類推。直到打印出tuple3中的最後一個元素值,也就是與索引號5對應的元素值,這個循環才完全結束。

我們可以看到,這條for語句的代碼佔用了三行。第一行是以關鍵字for開頭的,後面跟著迭代變量e、關鍵字in和被迭代的對象tuple3,它們之間都由空格分隔。與很多其他的代碼塊一樣,for語句也是以獨占一行的end作為結尾的。

所謂的迭代變量是一種局部變量。它的作用域是當前的for語句所代表的代碼塊。換句話講,它在當前的for語句之外是不可見的。或者說,該語句之外的代碼是無法引用到它的。如果被迭代的對像是一個容器,那麼迭代變量在每一次迭代中都會被分別賦予該容器中的某一個元素值。對於元組來說,其中的元素值會被按照線性索引的順序依次地賦給迭代變量。這也是上述示例能夠打印出這般內容的根本原因。

可索引對象基本上都是可迭代對象。因為它們都有索引機制的加持,支持迭代很容易。除此之外,集合也都是可迭代對象,雖然它們並不是可索引對象。

我們如果要判斷一個值是否屬於可迭代對象,那麼可以這樣做:

julia> applicable(iterate, (1,))
true

julia> applicable(iterate, Set(1))
true

julia> 

其中的函數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是比較靈活的。首先,我們調用它時可以不傳給它任何參數值,就像這樣:

julia> Dict()
Dict{Any,Any} with 0 entries

julia> 

可以看到,它這時會返回一個鍵類型和值類型都為Any的字典實例。當然,我們也可以在構造其實例的同時對鍵類型和值類型進行指定:

julia> Dict{Int64, String}()
Dict{Int64,String} with 0 entries

julia> 

關於標準字典對鍵類型的要求,我在前面已經說過了。在這裡,我要講的是一種很有趣的現象,它是有些反直覺的。先看下面的代碼:

julia> mutable struct MyKey
           code::String
           sn::Int128
       end

julia> key1 = MyKey("mykey", 1); key2 = MyKey("mykey", 1);

julia> 

我先定義了一個可變的複合類型,名稱為MyKey。這個複合類型有兩個字段,關於它們的類型我們在前面都說明過。之後,我又聲明了兩個變量,並分別為它們賦予了MyKey類型的值。注意,這兩個結構體中的同名字段的值都是兩兩相同的。下面,我們再構造一個標準的字典,並指定它的鍵類型為MyKey

julia> dict1 = Dict{MyKey, Int64}()
Dict{MyKey,Int64} with 0 entries

julia> 

現在註意看下面的代碼:

julia> dict1(key1) = 10
10

julia> key1.sn = -1; dict1(key1)
10

julia>

是的,我們可以利用索引表達式和一個鍵在字典中存、取、改與該鍵對應的值。但這並不是這裡的重點。重點是,在我改變了key1中的sn字段的值之後,我依然可以從dict1中獲取到10這個值。這是為什麼呢?現在的key1已經與原來的key1不同了啊!再來看一行代碼:

julia> dict1(key2)
ERROR: KeyError: key MyKey("mykey", 1) not found
Stacktrace:
 (1) getindex(::Dict{MyKey,Int64}, ::MyKey) at ./dict.jl:477
 (2) top-level scope at REPL(6):1

julia> 

索引表達式dict1(key2)竟然讓 Julia 報錯了。至於它為什麼會報錯,我們到下一個小節再說。現在註意看錯誤信息,它表明dict1中並沒有key2所代表的那個鍵。還記得嗎?key2的值與key1原來的值是相同的。但是我們在這裡卻無法從dict1中獲取到與之對應的值。這是不是很奇怪呢?

這兩個結果實際上展示了同一種現象。我們已經知道,字典會利用isequal方法判斷兩個鍵是否相等。因此,這種現象的背後其實就是一個關於結構體判等的問題。我們現在來直接判斷一下:

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

julia> 

為了復現上述的現象,我先還原了key1的值。可以看到,即使key1key2中的同名字段的值都兩兩相同,它們也不是相等的。由於key1不能在同一時刻代表兩個不同的值,所以我無法利用isequal方法復現另一個結果。但請記住,在這裡,即使key1中的某個字段的值被改變了,它仍然會與其原本的值相等。

這是不是有些反直覺呢?我們的預期是,對於類型相同的兩個結構體,只要其中的同名字段的值都兩兩相同,那麼它們就是相等的。並且,字典應該把這樣的兩個值視為同一個鍵。可是,上述代碼的執行結果卻正好相反。

如果你還記得操作符===的特性的話,那麼就應該知道:對於可變的值,這個操作符會比較它們在內存中的存儲地址。結構體其實也相當於一個置物架,其中的字段也相當於一個個格子。無論我們向置物架的格子中放置什麼物品,這個置物架都還是原來的那一個。同理,無論與key1綁定的那個結構體中的字段值怎麼變,該結構體在內存中的存儲地址都不會變。這其實就是dict1(key1)仍然會返回10的最深層原因。

但是,新問題又來了。我們明明調用的是isequal方法,可為什麼判等結果卻會符合操作符===的特性呢?

我們已經知道,在大多數情況下,isequal方法的行為都會依從於操作符==的判等結果。但你可能還不知道的是,Julia 還內置瞭如下的定義:

==(x, y) = x === y

它的含義是,當沒有針對某個類型的==方法時,我們若用操作符==判斷該類型的兩個值是否相等,就相當於在用===做判斷。所以,在這種情況下,如果也沒有針對這個類型的isequal方法,那麼我們調用isequal方法也就相當於在用===

現在問題終於明朗了。我們既沒有為MyKey類型顯式地定義==方法,也沒有為它定義isequal方法。一旦問題定位清楚了,就差不多等於解決了一半。我們下面就定義針對MyKey類型的==方法,因為這樣做可以解決得更徹底一些。代碼如下:

julia> import Base.==

julia> ==(x::MyKey, y::MyKey) = x.code == y.code && x.sn == y.sn
== (generic function with 156 methods)

julia> 

我們之前說過,編寫某個函數的衍生方法的時候必須先導入這個函數。因此,第一行代碼是必須的。第二行代碼就是==方法的定義。它的兩個參數的類型都是MyKey,這一點很重要。在賦值符號=右邊的代碼就是==方法的方法體。其中的操作符&&代表著邏輯與運算。這意味著,只有在它兩邊的判斷表達式的結果都是true,它們合起來的結果才會是true,否則就會是false。因此,這個方法體表達的就是,只要兩個參數值的code字段的值和sn字段的值都分別相等,這兩個參數值就是相等的。

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

julia> key1 = MyKey("mykey", 1); key2 = MyKey("mykey", 1);

julia> key1 == key2, isequal(key1, key2)
(true, true)

julia>

上面的結果是符合我們的預期的。但是,當我們執行如下代碼的時候,Julia 仍然會報錯:

julia> dict1 = Dict{MyKey, Int64}(); 

julia> dict1(key1) = 10; dict1(key2)
ERROR: KeyError: key MyKey("mykey", 1) not found
Stacktrace:
 (1) getindex(::Dict{MyKey,Int64}, ::MyKey) at ./dict.jl:477
 (2) top-level scope at REPL(12):1

julia> 

這又是為什麼呢?其原因是,字典會先利用hash方法確定鍵值對的存儲位置。如果連存儲的位置都不同,那就更別提取出相應的值了。上面就屬於這種情況。由於key1key2的哈希碼不相等:

julia> hash(key1) == hash(key2)
false

julia> 

所以dict1通過key2找到的存儲位置並不是存儲key1的那個位置。由此它會認為這個鍵根本就不存在。這時的這個hash函數會基於值在內存中的存儲地址計算哈希碼。很顯然,key1key2在內存中的存儲地址是不同的。因而它們的哈希碼也不會相等。

為了解決這一問題,我們還需要為MyKey類型定義一個hash方法。因為上面的這個hash函數在 Julia 中的定義是這樣的:

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

所以我們需要像下面這樣來編寫針對MyKey類型的hash方法:

julia> import Base.hash

julia> hash(k::MyKey, h::UInt) = hash(k.sn, hash(k.code, h))
hash (generic function with 56 methods)

julia> 

現在,我們再次運行上面的代碼,就會得到如下的結果:

julia> key1 = MyKey("mykey", 1); key2 = MyKey("mykey", 1);

julia> dict1 = Dict{MyKey, Int64}(); 

julia> dict1(key1) = 10; dict1(key2)
10

julia> 

這個結果終於完全符合我們的預期了。並且,一旦key1中的某個字段的值被改變,它就不再是dict1中的鍵了:

julia> key1.sn = -1; dict1(key1)
ERROR: KeyError: key MyKey("mykey", -1) not found
Stacktrace:
 (1) getindex(::Dict{MyKey,Int64}, ::MyKey) at ./dict.jl:477
 (2) top-level scope at REPL(16):1

julia> 

說了這麼多,我就是想鄭重地告訴你,isequal方法和hash方法對於一個字典的鍵類型來說有多麼的重要。如果我們想讓自己定義的數據類型作為字典的鍵類型,那麼不但應該為它編寫isequal方法,還應該同時為它編寫相應的hash方法。而且,這兩個方法在基本的邏輯方面應該保持一致。比如,如果一個isequal方法不會基於值的存儲地址做判斷,那麼相應的hash方法就應該同樣不基於值的存儲地址,反之亦然。

好了,讓我們把視線重新放到字典的構造函數上。我們在調用構造函數Dict的時候,還可以傳給它幾種參數值。

首先,我們可以傳入一個包含了若干同類型元組的數組,就像這樣:

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

julia> 

在我們給予Dict函數的數組中有三個元素值。這個數組由一個中括號包裹,並且其中的元素值之間都有英文逗號進行分隔。這就是用字面量表示一個一維數組的一般方式。其中的每一個元素值都是一個元組。這些元組的類型都是Tuple{Int64,String},而且它們都只包含了兩個元素值。

實際上,Dict函數不但要求這些元組的類型必須相同,還要求它們都必須包含兩個元素值,既不能少也不能多。因為這裡的每一個元組都會被視為一個鍵值對。其中的第一個元素值是鍵值對中的鍵,而第二個元素值則是鍵值對中的值。

其實,我們把這裡的元組換成數組也是可以的。例如,我們傳給Dict函數的數組可以是((1, "a"), (2, "b"), (3, "c"))。這個字面量表示的是一個數組的數組,相當於在數組裡又嵌套了數組。更寬泛地講,只要我們給予的那一個參數值是可迭代對象,並且每一次被迭代出來的值都是長度為2的可索引對象,Dict函數就可以接受它。

像元組、字典、數組這樣的容器基本上都既是可迭代對像也是可索引對象。不過,我不建議把像字典這樣的無序容器作為內層的可索引對象。因為如果這樣做的話,就會使得被構造的字典中的鍵值對帶有不確定性。

除此之外,在 REPL 環境回顯的內容中有一個我們未曾見過的符號=>。這個符號表示的實際上就是從某個鍵到某個值的單向映射關係。它很像一個箭頭,不是嗎?在該箭頭的尾部的值(即左側的那個值)就是鍵值對中的鍵,在其頭部的值(即右側的那個值)就是鍵值對中的值。

如此表示的鍵值對其實也可以被作為獨立的參數值傳給Dict函數。例如:

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

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 存取鍵值對

我們已經知道,可以用索引表達式存取字典中的鍵值對。當索引表達式與賦值符號=聯用時,它一般會把鍵值對添加進指定的字典。例如:

julia> dict2 = Dict{String, Int64}()
Dict{String,Int64} with 0 entries

julia> dict2("a") = 1
1

julia> 

注意,在這個索引表達式的中括號裡的不是索引號,而是鍵。並且,這裡的鍵只能有一個。它既可以由一個字面量表示,也可以由一個變量代表,還可以是一個求值結果為鍵的表達式,只要其類型符合字典的定義就可以。而在賦值符號右邊的就是要與這個鍵配對的那個值。它們在字典中會組合成為一個鍵值對。

只要一個字典裡已經存在我們要添加的鍵,那麼這樣的索引表達式就不會再向該字典添加這個鍵值對了。它此時只會改變該字典中與這個鍵對應的值。

另一方面,索引表達式還可以取出字典裡與指定的健對應的值。這也是它的默認功能。但是,只要那個字典中不存在這個健,它就會立即報錯。示例如下:

julia> dict2("a")
1

julia> dict2("b")
ERROR: KeyError: key "b" not found
# 省略了一些回显的内容。

julia> 

如果我們不加以處理,這樣的錯誤就會讓程序執行的正常流程中斷,甚至最終導致程序的崩潰。用於流程控制的try/catch語句可以做這樣的處理,但是這要到後面的部分才會講到,我們在這裡就不提了。其實還有好幾個辦法可以應對這種情況。

一個最直接的辦法就是使用haskey函數。haskey函數可以接受兩個參數,第一個參數代表字典,另一個參數代表鍵。它總是會返回一個Bool類型的結果值,以表示這個鍵是否存在於該字典中。例如:

julia> haskey(dict2, "a")
true

julia> haskey(dict2, "b")
false

julia>

由於haskey函數不會在找到指定的鍵時返回對應的值,所以我們為了達成dict2("a")的功能還需要多寫一些代碼:

julia> haskey(dict2, "a") ? dict2("a") : nothing
1

julia> 

這行代碼是一個代表了三元操作的表達式,其中的英文問號?和英文冒號:都是專用的操作符。什麼是三元操作呢?我們在講數值運算的時候介紹了一元運算(如一元減、平方根等)和二元運算(如加、減、乘、除等等)。所謂的一元運算就是只涉及了一個操作數的運算,而二元運算就是有兩個操作數的運算。以此類推,三元運算自然就應該包含三個操作數。當然,這裡的操作數也可以由字面量、變量或表達式來表示。

上述三元操作的表現形式這樣的:

 ?  : 

若換成純文字來表達就是:第一個操作數的求值結果是true嗎?如果是,那麼就對第二個操作數求值並將其結果值作為此三元操作的結果,否則就對第三個操作數求值並將其結果值作為此三元操作的結果。因此,上面那行代碼的含義即為:鍵"a"是否存在於字典dict2中?如果存在就返回與之對應的值,否則就返回nothing

實際上,我們還可以用更少的代碼實現此功能。這很簡單,只需調用get函數:

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

julia> 

除了字典和鍵之外,get函數還會接受一個默認值。當這個鍵未在該字典中時,此默認值就會被當作結果值返回。

其實我們在這裡講的只是get函數的一個衍生方法。但由於其他的衍生方法會涉及到流程控制,所以我在這裡就不多做介紹了。它們的功能很相似,只不過在表現形式上會有所不同。與之相比,更值得一說的是它的孿生函數get!

你可能會疑惑,get!函數的名稱為什麼以英文嘆號!結尾呢?該函數與get函數最重要的區別是,它會在必要時改動字典中的鍵值對,而get函數最多只會從字典中獲取值。

更寬泛地講,這其實是 Julia 當中的一種慣用法。名稱以!結尾的函數往往會修改我們傳給它的那個最主要的參數值。對於get!函數來說,這個最主要的參數值就是字典。反過來講,名稱不以!結尾的函數通常都會保證任何參數值都不會被修改。也可以說這是對原有數據的一種保護。因此,在 Julia 中,“名稱以!結尾”就成為了一種標誌。它向我們表明了當前函數在這方面的行為方式。Julia 標準庫以及很多第三方庫都嚴格地遵循了這種慣用法。理所應當,我們在編寫新的函數的時候也應該遵從它。

我們再來看get!函數。這個函數的參數列表與get函數的參數列表一模一樣。但是,前者會在發現字典中不存在指定的鍵時向該字典添加新的鍵值對。這個新鍵值對中的鍵就是我們指定的那個鍵,而其中的值則是我們給予它的默認值。示例如下:

julia> get!(dict2, "b", 2)
2

julia> dict2
Dict{String,Int64} with 2 entries:
  "b" => 2
  "a" => 1

julia> 

與此函數在功能上有些相似的一個函數名叫pop!。對它來說,字典和鍵是必選的參數,而默認值則是可選的參數。如果它在字典中找到了指定的鍵,那麼也會返回與之對應的值。但是,在返回這個值之前,它還會把相應的鍵值對從字典中刪除掉。請看下面的示例:

julia> pop!(dict2, "b")
2

julia> dict2
Dict{String,Int64} with 1 entry:
  "a" => 1

julia> 

另一方面,如果我們傳給它了一個默認值,那麼在字典中不存在指定的鍵時,它與get函數(注意,不是get!函數)的行為是一致的,如:

julia> pop!(dict2, "b", 2)
2

julia> dict2
Dict{String,Int64} with 1 entry:
  "a" => 1

julia> 

請注意,如果指定的鍵未在字典中,而我們又沒有把默認值傳給它,那麼它就會立即報錯:

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

julia> 

所以,看起來get函數才是最安全的。我們其實可以利用haskey函數和索引表達式來這樣模擬get!函數的功能:

julia> haskey(dict2, "b") ? dict2("b") : dict2("b")=2
2

julia> dict2
Dict{String,Int64} with 2 entries:
  "b" => 2
  "a" => 1

julia> 

另外,對於pop!函數,我們可以這樣進行防禦性編程(defensive programming):

julia> haskey(dict2, "b") ? pop!(dict2, "b") : 2
2

julia> dict2
Dict{String,Int64} with 1 entry:
  "a" => 1

julia> 

這樣做既可以避免pop!函數的報錯,也可以做到功能上的模擬,而且在性能上也不會有明顯的損失。

最後,我順便提一下刪除操作。我們可以使用delete!函數從一個字典中刪除掉某個指定的鍵及其對應的值:

julia> delete!(dict2, "a")
Dict{String,Int64} with 0 entries

julia> dict2
Dict{String,Int64} with 0 entries

julia> 

這個函數會返回改動後的字典,也就是我們傳給它的那個字典。另外,我們還可以通過調用empty!函數去清空一個字典,也就是刪掉其中的所有鍵值對,如empty!(dict2)。它會返回已被清空的字典。

8.2.3.2 迭代

我們對字典的迭代依然可以使用for語句來做。這很簡單,示例如下:

julia> dict3 = Dict("a"=>1, "b"=>2, "c"=>3, "d"=>4, "e"=>5);

julia> for p in dict3
           println("$(p(1)) => $(p(2)) ($(typeof(p)))")
       end
c => 3 (Pair{String,Int64})
e => 5 (Pair{String,Int64})
b => 2 (Pair{String,Int64})
a => 1 (Pair{String,Int64})
d => 4 (Pair{String,Int64})

julia> 

對於字典來說,迭代變量可以只有一個。這時,for語句迭代出的值的類型就將是Pair類型。而該類型的兩個類型參數值將分別是字典的鍵類型和值類型。我們已經知道,一個Pair類型的值就代表著一個鍵值對,也相當於一個小容器。並且,這類值既屬於可索引對像也屬於可迭代對象。其中的鍵的索引號是1,而值的索引號則是2

這裡的迭代變量也可以是兩個。在這種情況下,我們必須使用圓括號把這兩個變量的標識符包裹起來,就像這樣:

julia> for (k,v) in dict3
           println("($k, $v) ($(typeof(k)), $(typeof(v)))")
       end
(c, 3) (String, Int64)
(e, 5) (String, Int64)
(b, 2) (String, Int64)
(a, 1) (String, Int64)
(d, 4) (String, Int64)

julia> 

在這個例子中,迭代變量k會代表鍵,而v會代表與之對應的值。

千萬別忘了,字典中的鍵值對都是無序存儲的。所以,字典不會對鍵值對的迭代順序做出任何的保證。我們更不要想當然地假設基於字典的迭代的順序,不論我們迭代的方式是什麼。

此外,如果你只想對字典中的鍵或值做迭代也是可以的。不過,這需要先利用keys方法或values方法獲取到字典的鍵列表或值列表。我們馬上就會講到。

8.2.3.3 獲取列表

Julia 提供了一些方法,可以讓我們分別獲取字典中的鍵列表和值列表。

若要獲取鍵列表,我們就要先調用一個名為keys的方法。這個方法的用法再簡單不過了,代碼如下:

julia> dict4 = Dict("a"=>1, "b"=>2, "c"=>3);

julia> keys_dict4 = keys(dict4)
Base.KeySet for a Dict{String,Int64} with 3 entries. Keys:
  "c"
  "b"
  "a"

julia> typeof(keys_dict4)
Base.KeySet{String,Dict{String,Int64}}

julia> 

keys方法在被調用之後會返回一個Base.KeySet類型的值。在這個值中就包含了字典中所有的鍵。我們可以看到,這個值的類型與源字典類型的關係非常緊密。實際上,這類值僅僅是把源字典又簡單地包裝了一下而已。

我們可以應用在此類值上的方法很少,恐怕只有lengthisempty可用。前一個方法可以獲取到值的長度,而後一個方法則可以判斷值中是否沒有任何鍵。另外,我們還可以通過調用in方法來判斷其中是否存在某個鍵:

julia> in("a", keys_dict4)
true

julia> 

當然,in函數也有針對字典的衍生方法。不過它需要的第一個參數值是不同的:

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

julia> 

另外,Base.KeySet類型的值都是可迭代對象,但是很可惜它們都不是可索引對象。從該類型的名稱上,我們也可以猜到,這類值屬於集合。所以我們也可以稱之為鍵集合。那些可以應用在集合上的方法基本上也都適用於鍵集合。至於都有哪些方法,我們到後面再說。

無論怎樣,如果你想得到更多的操控性,那麼可以先把鍵集合轉換為標準的集合或者一維的數組,就像這樣:

julia> Set(keys_dict4)
Set(("c", "b", "a"))

julia> collect(keys_dict4)
3-element Array{String,1}:
 "c"
 "b"
 "a"

julia> 

對於字典的值列表來說也是類似的。我們需要先通過調用values方法獲取到包含著所有值的迭代器:

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

julia> typeof(values_dict4)
Base.ValueIterator{Dict{String,Int64}}

julia> 

然後,再將這個迭代器轉換成集合或者數組:

julia> Set(values_dict4)
Set((2, 3, 1))

julia> collect(values_dict4)
3-element Array{Int64,1}:
 3
 2
 1

julia>

順便說一下,我們把一個鍵集合傳給eltype方法就可以得到源字典的鍵類型,而把一個值迭代器傳給eltype方法則可以得到源字典的值類型。相應的,對於一個字典,我們可以通過調用keytype方法獲取到它的鍵類型,或者調用valtype方法獲取到它的值類型。下面是相應的示例:

julia> keytype(dict4) == eltype(keys_dict4) == String
true

julia> valtype(dict4) == eltype(values_dict4) == Int64
true

julia> 

8.2.3.4 合併

Julia 中有專門的函數可以進行字典的合併,名為merge。這裡說的其實是兩個函數,我們先來講參數較少的那一個。

這個merge函數可以同時接受多個字典作為其參數值。它會構造一個新的標準字典,並把它接受的所有字典中的鍵值對全部都添加到這個新字典中,最後返回新字典。由於字典中的鍵肯定都是唯一的,所以這相當於對多個字典中的鍵值對取並集。下面是一個簡單的示例:

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

julia> 

我們在這裡需要特別注意傳給它的參數值的順序。因為,如果在多個參數值中存在相等的鍵,那麼在新字典中與此鍵對應的那個值就將是最右邊的那個參數值裡的相應鍵值對中的值。示例如下:

julia> dict3
Dict{String,Int64} with 5 entries:
  "c" => 3
  "e" => 5
  "b" => 2
  "a" => 1
  "d" => 4

julia> dict4
Dict{String,Int64} with 3 entries:
  "c" => 3
  "b" => 2
  "a" => 1

julia> dict5 = Dict("a"=>10, "b"=>20, "c"=>30);

julia> merge(dict3, dict4, dict5)
Dict{String,Int64} with 5 entries:
  "c" => 30
  "e" => 5
  "b" => 20
  "a" => 10
  "d" => 4

julia> 

我們可以看到,新字典中的鍵"a""b""c"所對應的值都是字典dict5裡的。如果我們把dict5dict4調換一下位置,那麼結果肯定就會有所不同。為了描述方便,我們在後面會稱此為merge函數的最右優先規則。

另外,merge函數還會在必要時對新字典的鍵類型和值類型做適當的提升。例如:

julia> dict6 = Dict("a"=>1.0, "d"=>4.0, "e"=>5.0);

julia> merge(dict5, dict6)
Dict{String,Float64} with 5 entries:
  "c" => 30.0
  "e" => 5.0
  "b" => 20.0
  "a" => 1.0
  "d" => 4.0

julia> 

從 REPL 環境回顯的內容可知,新字典的值類型已經被提升為了Float64,而不是dict5的值類型Int64。我們在講數值與運算的時候討論過 Julia 的類型提升系統。如果你忘記了相關的規則,那麼可以翻回去複習一下。

即使新字典的鍵類型也需要提升,情況通常也不會變得更加複雜:

julia> dict7 = Dict(1=>"a", 2=>"b", 3=>"c"); dict8 = Dict(1.0=>'a', 3.0=>'c');

julia> merge(dict7, dict8)
Dict{Float64,Any} with 3 entries:
  2.0 => "b"
  3.0 => 'c'
  1.0 => 'a'

julia> 

字典dict7的鍵類型是Int64,值類型是String。而字典dict8的鍵類型是Float64,值類型是Char。我們已經知道,Int64Float64的公共類型就是Float64。而StringChar雖然看起來關係挺近的,但其實它們的公共類型卻是頂層類型Any

請注意,merge函數對新字典的鍵類型和值類型的設定是完全依從於 Julia 的類型提升系統的。而它的最右優先規則卻正好相反,完全是它自己制定的。實際上,merge函數會先利用promote_type函數獲得所有參數值的鍵類型的公共類型以及它們的值類型的公共類型。然後,該函數會用這兩個公共類型去構造一個新的字典。它會先把最左邊的參數值中的鍵值對都放入新字典,然後再利用索引表達式依次地放入更靠右的那些參數值中的鍵值對。如果有相等的鍵,那麼新的值自然就會覆蓋掉舊的值,但是鍵肯定還是最開始放入的那一個。

我們再來講另一個merge函數。這個merge函數除了可以接受多個字典之外,還有一個名為combine的必選參數,並且這個參數還處在最左邊的參數位置上。

這個參數的作用是確定值的合併策略,即:若出現了相等的鍵,它們的值怎麼整合在一起。我剛才也說了,前一個merge函數在這種情況下總會用新值完全替換掉舊值。所以這兩個函數在細節的處理上顯然是不同的。

下面,我們通過一些代碼來體會一下它們的區別:

julia> merge(dict5, dict6)
Dict{String,Float64} with 5 entries:
  "c" => 30.0
  "e" => 5.0
  "b" => 20.0
  "a" => 1.0
  "d" => 4.0

julia> merge(+, dict5, dict6)
Dict{String,Float64} with 5 entries:
  "c" => 30.0
  "e" => 5.0
  "b" => 20.0
  "a" => 11.0
  "d" => 4.0

julia> 

第一個函數調用的結果雖然是經過類型提升之後的新字典,但是在值的合併方面卻只有完全的替換而沒有真正的整合。第二個函數調用就不同了,它會依從第一個參數的值去整合相等鍵的多個值。

實際上,第二個merge函數只是在需要整合多個值的時候調用了第一個參數值所代表的函數(別忘了,數學運算符也是用函數實現的)。因此,新字典中的鍵"a"所對應的值最終就是Float64(10) + 1.0,即11.0

除此之外,我們剛剛講的這兩個merge函數都分別有一個名為merge!的孿生函數。顯然,後兩者都會修改我們提供的參數值。更具體地說,它們都不會構造新的字典,而是直接在我們傳入的第一個字典上做改動,然後把這個字典作為結果值返回。請看下面的示例:

julia> dict9 = Dict(1=>"x", 2=>"y", 4=>"z");

julia> merge!(dict9, dict7)
Dict{Int64,String} with 4 entries:
  4 => "z"
  2 => "b"
  3 => "c"
  1 => "a"

julia> merge!(*, dict9, dict8)
Dict{Int64,String} with 4 entries:
  4 => "z"
  2 => "b"
  3 => "cc"
  1 => "aa"

julia> dict9
Dict{Int64,String} with 4 entries:
  4 => "z"
  2 => "b"
  3 => "cc"
  1 => "aa"

julia> 

在這個例子中,第一個函數調用已經改變了dict9。所以,第二個函數調用其實是在第一次改動的基礎上又做了修改。

請注意,dict9的鍵類型仍然是Int64,而值類型也仍然是String。它們並沒有被改變。

其原因是這樣的:merge!函數並不會在意參數值的鍵類型和值類型,更不會去尋找相應的公共類型。它們只會試圖把後續字典中的鍵值對直接添加進第一個字典。在這種情況下,如果某個後續鍵值對的鍵類型與第一個字典的鍵類型不同,並且前者也不是後者的子類型,那麼那個鍵就會被強行地轉換為後者的值。對於那些鍵值對中的值來說也是如此。倘若不做這樣的類型轉換,那些類型原本不同的鍵值對就無法被添加或更新到第一個字典當中。

也正因為如此,當上述的類型轉換無法成功完成時,我們就會收到相應的錯誤:

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

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差不多,只不過各自具有一些小特點罷了。

8.3 集合

集合也是一種容器。它與字典有些相似,但又有著明顯的不同。一個字典可以保證其中的鍵互不相等,而一個集合也可以保證其中的元素值互不相等。並且,與字典類似,集合中的元素值都是無序存儲的。

不過,字典容納的是一個個同類型的鍵值對,而集合容納的卻是一個個同類型的元素值。這裡的不同在於,字典會區別看待和操作鍵值對中的鍵和值,而集合則只會且只能把其中的元素值都視為不可再拆分的整體。當然,我們也可以在集合中存放鍵值對(即Pair類型的值),但它依然會把那些鍵值對都視為元素值。

正因為集合的上述特性,我們可以很輕易地使用字典來模擬集合。比如像這樣構造一個字典:

julia> mock_set = Dict{String,Nothing}()
Dict{String,Nothing} with 0 entries

然後像這樣判斷其中是否存在某個鍵(即集合中的元素值):

julia> haskey(mock_set, "d") ? true : false
false

julia> 

簡單地解釋一下,Nothing是一個單例類型,它的唯一實例是nothing。我把字典mock_set的值類型設置成了Nothing,主要是為了避免其中的鍵值對裡的值佔用過多的內存空間。因為這樣的話這些值就都只會且只能代表同一個對象了,即nothing

可是,集合本身的特性並不能完全體現出它的優勢。基於集合的操作才是其最大的優勢。所以,嚴格來講,我們無法用字典替代集合。下面,我們就來一起看一看這是一種什麼樣的容器。

8.3.1 類型與實例化

在 Julia 中,代表集合的抽像類型是AbstractSet。 Julia 標準庫裡還有它的子類型有SetBitSet。我們可以稱前者為標準集合,稱後者為位集合。

標準集合的構造函數Set可以在不接受任何參數值的情況下構造出一個標準集合。但它也可以接受一個可迭代對象,並以其中的元素值作為新集合最初包含的元素值。示例如下:

julia> Set()
Set(Any())

julia> typeof(ans)
Set{Any}

julia> Set((1,2,4))
Set((4, 2, 1))

julia> typeof(ans)
Set{Int64}

julia> Set(Dict(1=>"x", 2=>"y", 4=>"z"))
Set(Pair{Int64,String}(2 => "y", 4 => "z", 1 => "x"))

julia> typeof(ans)
Set{Pair{Int64,String}}

julia> 

可以看到,當我們不給它任何參數值的時候,新集合的元素類型就會是Any。雖然這樣的集合可以容納任何類型的元素值,但是這通常都不是我們想要的。因為如此一來就基本上失去了參數化類型的好處了。因此,我們一般不這樣使用這個函數。

如果我們向它傳遞了一個可迭代對象,那麼新集合的元素類型就會與這個可迭代對象的元素類型或者鍵值對類型相同。不過,當我們傳入的是元組的時候,情況就比較特殊了。因為元組中的各個元素值可以是不同類型的,而且它們的類型還會體現在元組的類型參數中。請看下面的示例:

julia> Set((1,2,4))
Set((4, 2, 1))

julia> typeof(ans)
Set{Int64}

julia> Set((1,2.0,"4"))
Set(Any("4", 2.0, 1))

julia> typeof(ans)
Set{Any}

julia> 

一旦我們傳入的元組包含了不同類型的元素值,就會迫使Set函數去尋找這些元素類型的共同超類型,並把找到的共同超類型作為新集合的元素類型。在最壞的情況下,這個共同超類型將是Any。我們在講數值類型提升的時候提到過共同超類型。它指的是多個類型都有繼承的且距離它們最近的那個超類型。

可見,由於元組的特性,它有可能會讓Set函數構造出元素類型迥異的集合,並且這樣的元素類型肯定都屬於抽像類型。所以在這種情況下,你就需要考慮清楚了,想好怎樣應對這種變化。

8.3.2 操作集合

不知道你是否還記得當初學過的集合運算。這應該是高中教過的數學知識。集合運算包括求並集、求交集、求差集和求對稱差集。對於這些運算,Julia 都提供了相應的函數。我下面就帶著你快速地瀏覽一下這些函數。

我們先來說求並集。與並集對應的函數叫做union。它的功能是對多個集合中的元素值進行合併。示例如下:

julia> union(Set((1,2)), Set((2,3)), Set((3,4,5)))
Set((4, 2, 3, 5, 1))

julia> 

顯然,新集合中的元素值是經過去重(即去除重複)的。對於集合來說理應如此。但去重的操作是在union函數的內部完成的,而沒有讓新集合自己去做。另外,union函數還會在必要的時候提升新集合的元素類型。這與合併字典時的做法是類似的。

再來說求交集。函數intersect具有此功能。該函數可以提取出多個集合共有的元素值,並把它們都放入新的集合。下面是例子:

julia> intersect(Set((1,2)), Set((2,3)), Set((3,4,5)))
Set(Int64())

julia> intersect(Set((1,2,3)), Set((2,3,4)), Set((3,4,5)))
Set((3))

julia> 

注意,只有所有的參數值都共有的元素值,才會被intersect函數放入到新的集合中。

函數setdiff能夠求多個集合的差集。所謂的差集,就是一個集合包含的但另一些集合都未包含的元素值所組成的集合。因此,與前兩個函數不同,setdiff函數對參數值的位置是很敏感的。它尤其關注哪一個參數值在最左邊的位置上。請看下面的示例:

julia> setdiff(Set((1,2,3)), Set((2,3,4)), Set((3,4,5)))
Set((1))

julia> 

第一個集合中有元素值123,但只有1是後續的集合中都沒有的。所以新集合只包含了1。如果我們改變一下這幾個參數值的位置,那麼結果就會不同,如:

julia> setdiff(Set((2,3,4)), Set((1,2,3)), Set((3,4,5)))
Set(Int64())

julia> setdiff(Set((3,4,5)), Set((2,3,4)), Set((1,2,3)))
Set((5))

julia> 

集合Set((2, 3, 4))中的每一個元素值都存在於後續的某個或某些集合中,所以新集合就是空的。而在集合Set((3, 4, 5))中,只有5是後續的集合都沒有的,因此新集合就只包含了5

函數symdiff可用於求取多個集合的對稱差集,或者說對稱差分的集合。我們一定要搞清楚對稱差集的定義,即:屬於兩個集合的並集但不屬於它們的交集的元素值所組成的集合。請看示例:

julia> symdiff(Set((1,2,3)), Set((2,3,4)))
Set((4, 1))

julia> 

對於上面三個作為參數值的集合,它們的並集是Set((1, 2, 3, 4))且交集是Set((2, 3)),所以新集合中才會包含14

如果參與的集合超過了兩個,那麼我們就要特別注意了。因為symdiff函數並不會去求取所謂的所有集合的對稱差集。它會依據參數值的前後位置一步一步地進行計算,並且每一步只讓兩個集合參與計算。示例如下:

julia> symdiff(Set((1,2,3)), Set((2,3,4)), Set((3,4,5)))
Set((3, 5, 1))

julia> 

在這裡,symdiff函數會先求前兩個集合的對稱差集,然後再去求這個對稱差集與第三個集合的對稱差集。最後得到的這個對稱差集才是最終的結果。如果用表達式來描述的話就是這樣的:

julia> symdiff(symdiff(Set((1,2,3)), Set((2,3,4))), Set((3,4,5)))
Set((3, 5, 1))

julia> 

再來看一個例子,這次會有四個集合參與計算:

julia> symdiff(Set((1,2,3)), Set((2,3,4)), Set((3,4,5)), Set((4,5,6)))
Set((4, 3, 6, 1))

julia> 

你可以先在心裡想像一下這個對稱差集的計算步驟,然後再看下面的分解操作:

julia> symdiff(Set((1,2,3)), Set((2,3,4)))
Set((4, 1))

julia> symdiff(ans, Set((3,4,5)))
Set((3, 5, 1))

julia> symdiff(ans, Set((4,5,6)))
Set((4, 3, 6, 1))

julia> 

怎麼樣?這樣是不是就很清晰了呢?總之,數學中的對稱差集只能有兩個參與計算的集合。因此,當symdiff函數接受到更多的參數值時只會一步一步地進行求解。

我們上面所講的函數unionintersectsetdiffsymdiff都有各自的孿生函數,即:union!intersect!setdiff!symdiff!。我們已經知道,前四個函數都不會修改任何的參數值,而且返回的集合也是新構造出來的。然而,從名稱末尾的!我們就可以猜得出,後四個函數都會直接修改第一個參數值以體現計算的結果,而且返回的集合就是這個被修改的參數值。

我們再延伸一點。前文所述的這些函數可以應用的對像其實都不只是標準集合Set。或者說,我們一直在講的實際上都是針對Set類型的衍生方法。在這些函數之下,還有針對BitSetArray甚至其他的可迭代對象的衍生方法。至於具體都有哪些衍生方法,我們可以通過調用methods函數查看,比如:

julia> methods(setdiff!)
# 5 methods for generic function "setdiff!":
(1) setdiff!(s1::BitSet, s2::BitSet) in Base at bitset.jl:320
(2) setdiff!(v::AbstractArray{T,1} where T, itrs...) in Base at array.jl:2406
(3) setdiff!(s::Set, t::Set) in Base at set.jl:78
(4) setdiff!(s::AbstractSet, itr) in Base at abstractset.jl:171
(5) setdiff!(s::AbstractSet, itrs...) in Base at abstractset.jl:165

julia> 

除此之外,還有一些函數可以直接操作集合,比如用於判斷一個集合是否是另一個集合的子集的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的變量與常量

Julia 編程基礎(四):如何用三個關鍵詞搞懂 Julia 類型系統

Julia編程基礎(五):數值與運算

Julia編程基礎(六):玩轉字符和字符串

Julia編程基礎(七):由淺入深了解參數化類型