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

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

在第 4 章,我們介紹了 Julia 的類型系統,探討了類型與類型以及類型與值之間的關係,還講解了兩個特殊的類型和三種主要的類型。接下來,我們會講述更多的類型和相關知識。你將學習到那些定義更加複雜、功能更加強大的類型,以及操縱這些類型及其值的方法。

7.1 類型的參數化

參數化(parametric)是 Julia 類型系統中的一個非常重要且強大的特性。它允許類型自身包含參數,並使得一個這樣的類型就可以代表整個類型族羣。像Ref{T}這樣的參數化類型,可以代表的類型的數量是無限的,因爲我們可以用任何一個類型的名稱替換掉T,從而表示一種確定的(或者說具體的)類型,如Ref{String}。進一步講,隨着類型中參數值的不同,這個類型的字面量就可以表示該類型族羣中的某一個特定的類型。順便說一下,我有時只會寫出參數化類型的名稱,而省略掉後面的花括號。這主要是爲了簡化描述和節約篇幅。到了後面我們會看到,這種表示方式依然是合法的。

Julia 已經預定義了不少的參數化類型。我們在前面已經見過幾個,包括RefUnionComplexSubString等。對它們的進一步說明如下:

  • Ref{T}:它是專門用來做引用的類型。要想讓它成爲某一個類型的引用類型,我們就需要在其花括號中填入那個類型的名稱。例如,Ref{UInt32}就表示針對UInt32類型的引用類型。
  • Union{Types...}:這個類型的花括號中可以有多個類型名稱。這使它可以表示爲針對那些類型的聯合類型,從而讓那些類型的值都成爲這個聯合類型的實例。例如,Union{Integer, AbstractString}就聯合了Integer類型和AbstractString類型,從而使整數值和字符串值都變成了它的實例。
  • Complex{T<:Real}:代表複數的的類型。因爲複數的實部和虛部都必須是實數,所以Complex類型的參數一定要是Real類型的子類型。
  • SubString{T<:AbstractString}:代表子字符串的類型。由於子字符串值只能基於字符串值創建,因此SubString類型的參數必須繼承自AbstractString

可以看到,前兩個參數化類型對其參數都沒有做顯式的約束。也就是說,它們的參數值可以是任意的類型。當然,我們是可以對類型的參數做出約束的。

我們之前已經講過操作符<:。在類型定義中,它用於表示當前類型直接繼承自哪一個抽象類型。它也可以與兩個類型字面量構成一個表達式,以判斷這兩個類型之間是否存在直接或間接的繼承關係。而在類型的參數定義中,<:則用來表明參數值的有效範圍,或者說參數值必須是哪一個類型的(直接或間接的)子類型。由於一個類型也是它自己的子類型,所以這裏的有效範圍也會包含處於<:右側的那個類型。

後兩個參數化類型都在它們的花括號中對其參數進行了約束。更確切地說,它們都對其類型參數(type parameter)的上限進行了定義。

我們在這裏回顧這幾個參數化類型,是爲了幫助你重溫對這種類型的宏觀認識。這算是一個熱身。接下來,我們將要說明怎樣定義參數化類型。

7.1.1 基本特徵

我們之前說過,參數化類型就相當於一種對數據結構的泛化定義。因此,它也常被稱爲泛化類型,簡稱泛型。此種類型的奧祕就藏在緊隨類型名稱之後的那對花括號當中。

對於一個參數化類型,比如Ref{T},我們稱其花括號中的英文字母T(Type 的縮寫)爲類型參數。然而,這個字母只是一個佔位符而已,用於表示這個位置上需要一個具體的參數值(別忘了,類型也是一種值)。原則上,這個佔位符的名稱可以是任何一個或多個可打印的 Unicode 字符。不過,按照慣例,英文字母T仍然是這裏的首選。

Julia 並沒有對一個類型可以擁有多少個參數做出限制。不過,類型一旦定義完成,其類型參數的個數就會固定下來,並且不可再被更改。而Union{Types...}類型着實是一個特例,因爲 Julia 並沒有限制我們使用它聯合多少個類型。它甚至還可以不聯合任何類型,即Union{}。同樣特殊的還有代表元組類型的Tuple{Types...}。有些可惜,作爲 Julia 程序開發者的我們是無法編寫這樣的參數化類型的。

那麼我們可以編寫什麼樣的參數化類型呢?請接着往下看。

7.1.2 參數化複合類型

參數化的複合類型應該是我們最常定義的一種參數化類型。如果我們想爲抽屜這樣的物件建立程序模型,那麼可以這樣來做:

julia> mutable struct Drawer{T}
           content::T
       end 

理想狀況下,一個足夠大的抽屜可以容納任何物品。所以我並沒有對類型參數T進行約束。此外,我只爲這個複合類型編寫了一個字段content,其類型同樣是T

通常,一個複合類型的類型參數總是要被用在這個類型的內部的,否則也就沒有必要爲它定義類型參數了。對於Drawer類型,什麼種類的物品可以被放進抽屜,恰恰取決於其類型參數的值是什麼。比如,Drawer{String}類型的類型參數已經確定,那麼它的字段content的類型肯定也是String。所以,我們只能把String類型的“物品”放到這類抽屜裏:

julia> drawer1 = Drawer{String}("a kind of goods")
Drawer{String}("a kind of goods")

julia> drawer1.content = 'G'
ERROR: MethodError: Cannot `convert` an object of type Char to an object of type String
# 省略了一些回顯的內容。

julia> 

這裏有一個特別之處,像Drawer{T}這樣的表示方式只能被用在它的定義當中。如果我們想在其他地方指代這個參數化類型,那麼只寫出它的名稱Drawer就好了。或者說,在其定義之外的任何地方,Drawer{T}都只能用於表示該參數化類型的某個確定類型(如Drawer{String})。所以,這時的T必須被替換爲一個已聲明的類型名稱。對比如下:

julia> Drawer{T} 
ERROR: UndefVarError: T not defined
Stacktrace:
 [1] top-level scope at none:0

julia> Drawer
Drawer

julia> 

另外,由於參數化類型可以代表整個類型族羣,而它的每一個確定類型都是這個類型族羣中的一員。因此,參數化類型本身是它的所有確定類型的超類型。例如:

julia> Drawer{String} <: Drawer
true

julia> Drawer{Char} <: Drawer
true

julia> Drawer{Int} <: Drawer
true

julia> 

注意,這是除了使用操作符<:以外的另一種可以形成繼承關係的聲明方式。

讓我們再回到抽屜的話題上來。我們都知道,很多傢俱都有抽屜。無論是家用的還是商用的都是如此。如果這裏指的是商用展櫃中的抽屜,那我們還可以接着構建模型:

julia> mutable struct Showcase{T}
           drawer1::Drawer{T}
           drawer2::Drawer{T}
       end

julia> 

上面這個展櫃有兩個抽屜。顯然,如果這是一個首飾的展櫃,那麼它的抽屜裏就只能放置首飾。但如果這是一個玩具展櫃,那這兩個抽屜裏就只會放置一些玩具。所以,在確定的參數化類型Showcase{String}中,drawer1drawer2的類型都只會是Drawer{String}。示例如下:

julia> showcase1 = Showcase{String}(Drawer("goods1"), Drawer("goods2"))
Showcase{String}(Drawer{String}("goods1"), Drawer{String}("goods2"))

julia> 

可以看到,我在實例化Showcase{String}類型的時候並沒有在類型名稱Drawer之後編寫花括號。但是,Julia 依然知道我們是在構建Drawer{String}類型的值。這要感謝 Julia 的類型推斷。實際上,在這種情況下,我們連Showcase後面的花括號都可以省略掉:

julia> showcase1 = Showcase(Drawer("goods1"), Drawer("goods2"))
Showcase{String}(Drawer{String}("goods1"), Drawer{String}("goods2"))

julia> 

Julia 可以根據我們給予的"goods1""goods2"推斷出這裏的DrawerShowcase的類型參數爲String

現在,假設這就是一個首飾的展櫃,那麼我們需要先對首飾進行一些定義:

julia> abstract type Jewelry end

julia> struct Necklace <: Jewelry end

julia> struct Ring <: Jewelry end

julia> 

我定義了代表首飾的抽象類型Jewelry,還定義了該類型的子類型Necklace(項鍊)和Ring(戒指)。爲了儘量簡單,我們不去關心這些首飾的具體特徵以及它們的定價。所以,我沒有爲NecklaceRing添加任何字段。

有了前面這些定義,我們就可以開始爲首飾店建模了:

julia> mutable struct JewelryShop{T<:Jewelry}
           showcase1::Showcase{Necklace}
           showcase2::Showcase{Ring}
           showcase3::Showcase{Jewelry}
           showcase4::Showcase{T}
       end

julia> 

在這個店鋪中,第 1 個展櫃專用於放置項鍊,第 2 個展櫃專用於放置戒指。而第 3 個展櫃和第 4 個展櫃都是機動的展櫃。我們可以根據實際需要確定它們的用途。

不過要注意,雖然我爲JewelryShop的類型參數做了約束,使該參數的值必須是Jewelry的子類型,但showcase3showcase4這兩個字段的類型仍然是不同的。對於showcase3,無論JewelryShop的具體參數值是什麼,它都代表可以放置任何首飾的展櫃。而showcase4就不同了,它可以放置哪種首飾完全取決於JewelryShop的具體參數值。

另外還要注意,雖然複合類型NecklaceRing都是抽象類型Jewelry的子類型,但是基於它們的參數化類型之間卻不存在這樣的繼承關係。比如,Drawer{Necklace}Drawer{Ring}都肯定不是Drawer{Jewelry}的子類型。同理,Showcase{Necklace}Showcase{Ring}也都不是Showcase{Jewelry}的子類型。代碼演示如下:

julia> Drawer{Necklace} <: Drawer{Jewelry}, Drawer{Ring} <: Drawer{Jewelry}
(false, false)

julia> Showcase{Necklace} <: Showcase{Jewelry}, Showcase{Ring} <: Showcase{Jewelry}
(false, false)

julia> 

這種特性被稱爲非轉化(invariant)。也就是說,對於這些確定的參數化類型,不會由於其參數值之間存在繼承關係,就形成繼承關係。與之相對的特性有協變(covariance)和逆變(contravariance)。

我們在實例化Showcase{Jewelry}的時候就可以明顯地感知到這一特性。像下面這樣構建它的值是不行的:

julia> Showcase{Jewelry}(Drawer(Necklace()), Drawer(Ring()))
ERROR: MethodError: Cannot `convert` an object of type Drawer{Necklace} to an object of type Drawer{Jewelry}
# 省略了一些回顯的內容。

julia> 

依據提示可知,報錯的原因是Drawer{Necklace}類型的值無法被轉換成Drawer{Jewelry}類型的值。對於像Showcase{Jewelry}這樣的確定的參數化類型,Julia 會爲它自動生成一個全名(即攜帶花括號的名稱)相同的構造函數。這個構造函數接受的參數與該類型的字段一一對應,但參數的類型並沒有被約束。

也就是說,我們在使用這樣的構造函數時,必須提供數量與該類型的字段數相同的參數值,但參數值的類型可以是任意的。Julia 一旦發現參數值的類型與對應字段的類型不符,就會試圖通過調用convert函數進行參數類型轉換。如果轉換不成功,那麼就會直接報錯。

現在我們知道了,Showcase{Jewelry}類型的兩個字段都是Drawer{Jewelry}類型的。但是,我們傳給它的構造函數的參數值Drawer(Necklace())Drawer(Ring())卻分別是Drawer{Necklace}類型和Drawer{Ring}類型的。在這種情況下,Julia 會試圖進行參數類型轉換。可是,轉換失敗了,因爲Drawer{Necklace}Drawer{Ring}都不是Drawer{Jewelry}的子類型。錯誤由此產生。

不過,我們只要稍加改動就可以使這段代碼合法化:

julia> Showcase{Jewelry}(Drawer{Jewelry}(Necklace()), Drawer{Jewelry}(Ring()))
Showcase{Jewelry}(Drawer{Jewelry}(Necklace()), Drawer{Jewelry}(Ring()))

julia> 

注意,我們這次傳給Showcase{Jewelry}函數的是兩個Drawer{Jewelry}類型的值。因爲Jewelry是一個抽象類型,所以它本身不能被實例化。但由於NecklaceRing都是它的子類型,因此把這兩個類型(之一)的值傳給Drawer{Jewelry}的構造函數是完全沒有問題的。這與上述參數化類型之間的關係形成了鮮明的對比。

參數化類型的非轉化特性不僅會體現在它們的構造函數上,也會同樣體現在普通的函數上。比如,我們要定義用來描述上述類型值的函數describe,那麼對於以普通的複合類型Jewelry爲首的類型族羣來說,定義一個函數就足夠了:

describe(jewelry::Jewelry) = "A $(typeof(jewelry))"

但對於以參數化的複合類型Drawer爲首的類型族羣而言,我們如果只定義下面這個函數:

describe(drawer::Drawer{Jewelry}) = "$(describe(drawer.content))"

那麼就無法讓類型爲Drawer{Necklace}Drawer{Ring}的參數值傳進去。不過,這裏有兩種解決辦法。第一種辦法,指定參數化類型但不指定其類型參數:

describe(drawer::Drawer) = "$(describe(drawer.content))"

這就是在告訴 Julia,參數值只要是Drawer類型的,不論它的類型參數值是什麼,全都符合這個函數的定義。這樣做固然是可以的。但在很多時候,適用範圍太廣通常不是一件好事。

第二種辦法是,指定參數化類型及其類型參數,但只約束後者的有效範圍。例如:

describe(drawer::Drawer{<:Jewelry}) = "$(describe(drawer.content))"

我們把參數drawer的類型聲明爲了Drawer{<:Jewelry}。注意,在<:的左側並沒有T。在這種情況下,只要參數值的類型是Drawer,且它的類型參數值是Jewelry的子類型,就符合這個describe函數的定義。如此一來,我們向該函數傳入Drawer{Necklace}Drawer{Ring}類型的參數值就都沒有問題了。

第二種解決辦法是更好的。因爲爲了程序的穩定性和運行效率,我們總是需要給予恰當的類型約束。

最後,順便說一下,我們可以把Drawer{<:Jewelry}看做是對協變類型的模擬。而<:在這裏可以被視爲轉化標註(variance annotation)。所謂的協變是指,同一個參數化類型的多個確定類型之間可以存在繼承關係,並且這種繼承關係完全取決於它們的類型參數值之間的繼承關係。例如:

julia> Drawer{<:Necklace} <: Drawer{<:Jewelry}, Drawer{<:Ring} <: Drawer{<:Jewelry}
(true, true)

julia> Drawer{Necklace} <: Drawer{<:Jewelry}, Drawer{Ring} <: Drawer{<:Jewelry}
(true, true)

julia> 

不過,再次強調一下,參數化類型本身具有的是非轉化特性。我們雖然可以通過上述方式對協變類型進行模擬,但對此要持有謹慎的態度,並要關注運用的合理性。因爲這在爲我們提供便利的同時,還可能會讓程序變得更加複雜。

7.1.2 參數化抽象類型

參數化的抽象類型與參數化的複合類型有着很多的共同點。比如,參數化的抽象類型定義相當於聲明瞭一個抽象類型的族羣。又比如,參數化的抽象類型本身是它的所有確定類型的超類型。還比如,對於確定的參數化抽象類型,不會由於其參數值之間存在繼承關係,就形成繼承關係(即非轉化特性)。

那麼,參數化的抽象類型有什麼特殊的功用嗎?顯然,與普通的抽象類型一樣,參數化抽象類型可以幫助我們搭建自己的類型層次結構。並且,它還可以構建出更加豐富的類型體系。

如果我們有如下的類型定義:

# 代表儲物空間的抽象類型。
abstract type StorageSpace{T} end

# 代表抽屜的類型。
mutable struct Drawer{T} <: StorageSpace{T}
    content::T
end

# 代表展櫃的類型。
mutable struct Showcase{T<:Goods} <: StorageSpace{T}
    drawer1::Drawer{T}
    drawer2::Drawer{T}
end

那麼,對於每一個StorageSpace類型的確定類型,都會有一個Drawer類型的確定類型和一個Showcase類型的確定類型與之相對應。並且,後兩者總是前者的子類型。例如:

julia> Drawer{Jewelry} <: StorageSpace{Jewelry}, Showcase{Jewelry} <: StorageSpace{Jewelry}
(true, true)

julia> Drawer{Necklace} <: StorageSpace{Necklace}, Showcase{Necklace} <: StorageSpace{Necklace}
(true, true)

julia> Drawer{Ring} <: StorageSpace{Ring}, Showcase{Ring} <: StorageSpace{Ring}
(true, true)

julia> 

我們可以看到,這個類型體系是立體的,而不是平面的。更重要的是,如果我們定義更多的StorageSpace類型的子類型,那麼這個體系的規模就將呈現指數級的增長。

與參數化的複合類型一樣,我們也可以對參數化抽象類型的類型參數做出範圍約束。不過,對於以超類型的身份出現在其他類型定義當中的參數化抽象類型,我們就不能這麼做了。這是什麼意思呢?舉個例子,我們在前面是這樣再次定義Showcase類型的:

mutable struct Showcase{T<:Goods} <: StorageSpace{T}
    drawer1::Drawer{T}
    drawer2::Drawer{T}
end

在這個定義當中,以超類型的身份出現的參數化抽象類型StorageSpace不能被寫成StorageSpace{T<:Goods}或者StorageSpace{<:Goods}。因爲這不符合 Julia 的語法,會使它報錯。即使這個參數化抽象類型本身聲明的類型參數就是{T<:Goods}也是如此。這是合乎情理的,因爲參數化類型一旦定義完成,我們就不能再去修改其類型參數的聲明瞭。在這裏,我們可以把它寫成StorageSpace{T},也可以寫成像StorageSpace{Goods}這樣的確定類型。

你可能已經注意到了,我對Showcase類型的類型參數做了範圍約束,其值必須是Goods的子類型。我在前面沒有給出Goods類型的定義。它其實就是一個代表了商品的普通的抽象類型而已。

沒錯,我們可以在參數化類型的定義當中對其超類型的類型參數做出進一步的約束。不過,對於進一步約束的方向,Julia 並沒有嚴格的規定。我們既可以收緊先前的約束,也可以放寬先前的約束。我又定義瞭如下類型:

# 代表玩具的類型。
abstract type Toy <: Goods end

# 代表毛絨玩具的類型。
struct StuffedToy <: Toy end

# 代表電動玩具的類型。
struct ElectricToy <: Toy end

# 代表玩具箱的抽象類型。
abstract type ToyBox{T<:Toy} <: StorageSpace{T} end

# 代表紙板箱的類型。
mutable struct Carton{T<:Goods} <: ToyBox{T}
    content::T
end

抽象類型ToyBoxStorageSpace類型的又一個子類型,並且它對後者的類型參數T做了進一步的範圍約束,使它的值必須是Toy的子類型。類似的,複合類型CartonToyBox類型的子類型,同時它也對後者的類型參數做出了自己的約束。但是,CartonT的約束比ToyBoxT的約束更加寬鬆,因爲GoodsToy的超類型。這在 Julia 中是允許的。

即便如此,我依然建議你在做進一步約束時要收緊而不要放寬。這起碼有 3 個好處:

  1. 這樣做是對超類型的延續,而不是破壞。從類型層次設計的角度講,子類型的適用範圍總是應該比超類型的適用範圍更小。或者說,超類型的應用場景起碼應該涵蓋子類型的應用場景。
  2. 這樣做更容易使人理解。順應當前的類型繼承紋理,可以讓代碼的閱讀者更快速地領會到類型定義者的意圖。雖然“在紙板箱裏放置商品”從邏輯上講是沒有問題的,但這會讓人對“Carton繼承ToyBox”產生疑惑。難道這樣的紙板箱只是玩具箱的一種嗎?這顯然有些自相矛盾了。
  3. 這樣做可以避免類型的使用者犯錯。使用者一旦看到了當前類型的定義,就可以完全瞭解到關於其類型參數的約束。因爲當前類型對其參數的約束是最嚴格的。否則,如果像前面那樣,那麼Carton{Goods}(StuffedToy())就一定會使 Julia 報錯。因爲它不符合ToyBoxT的約束。

總之,雖然參數化的抽象類型可以構建出更加豐富的類型體系,但它對類型體系的設計者也提出了更高的要求。這關乎類型體系的質量和使用者的心智負擔,值得我們仔細思考。

7.1.3 參數化原語類型

我們也可以定義參數化的原語類型。不過,與前面兩種參數化類型相比,參數化原語類型的意義就不太大了。

我們都知道,原語類型的結構僅僅是一個扁平的比特序列。在定義這種類型的時候,我們只需要指定其比特序列的長度,也就是其值需要佔據的存儲空間的大小。因此,即使我們在這種類型的名稱後面添加了類型參數,也無法在它的定義體中引用這個參數。比如,Julia 預定義的原語類型Ptr是這樣的:

# 32-bit system:
primitive type Ptr{T} 32 end

# 64-bit system:
primitive type Ptr{T} 64 end

在這種情況下,類型參數已經失去了泛化數據結構的作用,而僅能作爲特定類型的一種標籤。例如,Ptr{Char}代表了可以指向字符值的那種指針的類型,而Ptr{Int64}則代表可以指向Int64類型值的那種指針的類型。

由於上述的特定類型都是Ptr類型的子類型,所以我們說原語類型依然可以因參數化而成爲當下的類型族羣之首。並且,參數化的原語類型依然具有非轉化特性。

7.2 參數化的更多知識

我們現在已經知曉了定義參數化類型的基本知識。但這還不夠,我們還需要了解更多,以便做到遊刃有餘。

7.2.4 類型參數的值域

到目前爲止,我們一直在說,參數化類型的參數值可以是任何一個類型。或者說,我們可以用任意一個類型的名稱替換掉類型參數的佔位符(比如T),從而表示一種確定的參數化類型。

但實際上,類型參數的值域中還包含了所有位類型的值。所謂的位類型,指的就是那些傳統的數據類型。這種類型的值不可變,並且其中不包含任何對其他值的引用。位類型的值總是可以由若干個連續的比特(位)承載。並且,存儲同一個位類型的任何值所用的比特個數總是相同的。你可能已經猜到了,所有的原語類型都屬於位類型。

我們對此不用死記硬背。如果你不確定一個類型是否屬於位類型的話,那麼可以使用isbitstype函數來判斷。例如:

julia> isbitstype(Bool), isbitstype(Float64)
(true, true)

julia> isbitstype(Complex), isbitstype(Complex{Int64})
(false, true)

julia> isbitstype(Char), isbitstype(String)
(true, false)

julia> isbitstype(Union), isbitstype(Union{String})
(false, false)

julia> isbitstype(Ptr), isbitstype(Ptr{Char})
(false, true)

julia> 

此外,Julia 還有提供了isbits函數。該函數用於判斷一個值是否是位類型的實例。

如果要解釋位類型的值是怎樣應用在類型參數中的話,我覺得最好的案例莫過於我們在前面見過很多次的類型ArrayArray是一個參數化類型,它的全名是Array{T,N}。其中的類型參數T用於確定數組的元素類型,而類型參數N則用於確定數組的維度。也就是說,數組的維度與它的元素類型一樣都會被寫入到它的類型字面量中。原則上,N的取值可以是任何Int64類型的值(如果在 32 位的計算機系統中,那麼就是任何Int32類型的值)。但在實際應用中,N的值肯定不能是負數。

我們在自定義參數化類型的時候,如果允許類型參數的取值包含位類型的值,那麼就需要仔細地考量。比如,允許哪個或哪些位類型的值、這些值是否都能夠被正確地接受和處理,等等。若有必要,你可以根據實際情況使用isbitstype函數、isbits函數以及其他的方式幫助抉擇。

7.2.5 類型的類型

我們在前面說過,Julia 中的類型的類型是DataType。包括之前講過的特殊類型AnyUnion在內,所有的類型都是DataType類型的實例。就連DataType類型本身的類型也是DataType

不過,對於參數化類型來說,情況就不太一樣了。類型參數已確定的參數化類型(如Drawer{Jewelry})依然是DataType類型的實例。但是,未確定的參數化類型(如Drawer)的類型就不是DataType了,而是UnionAll。演示代碼如下:

julia> typeof(Drawer{Jewelry}), typeof(Drawer{Ring})
(DataType, DataType)

julia> typeof(Drawer), typeof(Drawer{})
(UnionAll, UnionAll)

julia> 

注意,Drawer{}等同於Drawer,因爲前者同樣沒有明確類型參數的值。它們代表的都是還未完全確定的參數化類型。

另外,我們需要謹記“某個類型的類型”與“某個類型的超類型”這兩個概念之間的不同。雖然它們要解答的都是類型的歸類問題,但不同的是:“某個類型的類型”說的是這個類型的先天歸屬,就像在說一個人的性別或者血統;而“某個類型的超類型”說的是一個類型的後天分類,就像在說一個人的職業或者國籍。一個值(別忘了類型也是一種值)從被編寫或被構造的那時起就會有一個類型,而一個類型繼承自哪一個超類型卻需要我們通過編寫代碼來指定。顯然,這是兩個不同維度的歸類問題。我們可以說這兩者是正交的。一個類型既會有它隸屬的類型,也會有它從屬的超類型(最起碼有Any),並且各自獨立、互不干擾。

上面這兩個概念很容易被混淆,尤其對於初學現代編程語言的人來說。既然講清楚了它們的區別,那麼我們再回到“類型的類型”這個問題上來。

我們已經瞭解了DataType類型,但對UnionAll這個類型還很陌生。這個類型用於描述所有未確定的參數化類型。也就是說,在這個類型下的每一個參數化類型中,至少還有一個類型參數沒有明確的取值。單從字面上看,我們也可以感受到,它可以代表一個參數化類型能夠包含的所有確定類型的聯合。

由於像Drawer這樣的參數化類型中還有一些東西沒有被確定下來,所以它們不能算是正常的數據類型(從其類型不是DataType就可以印證這一點)。因此,它們也無法被實例化。

7.2.6 值化的表示法

既然說到了UnionAll類型,那麼我們就不得不提及針對此類型的實例(也就是參數化類型)的表示法,也可以稱之爲參數化類型的值化表示法。這種表示法與參數化類型定義中的表現手法很相似,但前者還需要用到where關鍵字。如果用這種表示法來表示Drawer類型的話,那麼就是:

julia> Drawer{T} where T
Drawer

julia> 

Drawer{T} where T代表了Drawer類型所能包含的所有確定類型的聯合。而且,這個類型還對它的類型參數做出了範圍約束,如跟在where後面的內容所示。只不過,這裏在where後面的只有T,所以相當於沒有約束。如果我們想爲這裏的T添加約束,那麼可以像下面這樣寫:

julia> Drawer{T} where T<:Jewelry
Drawer{T} where T<:Jewelry

julia> 

顯然,值化表示法讓我們可以在參數化類型的定義之外爲其類型參數制定範圍約束。我們之前運用過的Drawer{<:Jewelry}其實就是Drawer{T} where T<:Jewelry的一種簡寫形式。但對於擁有多個類型參數的參數化類型來說,這種簡寫形式就顯得不夠靈活了。比如,對於Array{T,N}類型,如果我們使用簡寫形式的話,就只能同時約束或確定它的所有類型參數。代碼如下:

julia> Array{<:Jewelry, 1}
Array{#s11,1} where #s11<:Jewelry

julia> Array{<:Jewelry, <:UInt32}
Array{#s9,#s10} where #s10<:UInt32 where #s9<:Jewelry

julia> 

從 REPL 環境回顯的內容可知,第一行代碼就相當於Array{T,1} where T<:Jewelry,而第二行代碼則相當於Array{T,N} where N<:UInt32 where T<:Jewelry

沒錯,我們可以在參數化類型的全名後面追加多個where,但是每一個where都只能針對單個類型參數做出約束。跟在where後邊的那些類型參數也常被稱爲類型變量(type variable),因爲它們就像變量那樣可以在某個類型的定義之上進行取值。只不過,類型變量取的不是確切的值,而是值域。所以我們也可以說,where是專門用來劃定類型參數的值域的。請注意,如果基於某個類型定義的多個where劃定了同一個類型參數的值域,那麼 Julia 只會認可最左邊的那一個。

如果我們想用前述的簡寫形式只對一部分類型參數劃定值域,那麼就會收到如下的報錯:

julia> Array{<:Jewelry, N}
ERROR: UndefVarError: N not defined
Stacktrace:
 [1] top-level scope at none:0

julia> Array{T, <:UInt32}
ERROR: UndefVarError: T not defined
Stacktrace:
 [1] top-level scope at none:0

julia> 

不過這解決起來相當容易,不簡寫就可以了:

julia> Array{T,N} where T<:Jewelry where N
Array{T,N} where T<:Jewelry where N

julia> Array{T,N} where N<:UInt32 where T
Array{T,N} where N<:UInt32 where T

julia> 

還記得嗎?如果在where後面的只有類型參數的佔位符,那麼就相當於對該類型參數沒做任何約束。

另外還有一種等價的解法,那就是爲它創建一個帶有類型參數的別名(alias),如:

julia> JewelryArray{N} = Array{<:Jewelry, N}
Array{#s19,N} where #s19<:Jewelry where N

julia> Vector{T} = Array{T, 1}
Array{T,1} where T

julia> 

總之,值化表示法使我們可以對參數化類型所能代表的確定類型的範圍進行相當靈活的再定製。並且,這種再定製絲毫不會影響到參數化類型的原有定義。此外,由於這種表示法把參數化類型表達成了一種值,所以它能讓參數化類型賦給某個變量或常量、在函數之間傳來傳去、成爲其他參數化類型的參數值,等等。不過別忘了,如此表示的參數化類型仍然是一種類型,所以它依舊能夠作爲變量、複合類型的字段、函數的參數等等的類型。

7.3 容器:元組

容器在 Julia 中也被稱爲集合。但由於集合一詞與有着廣泛應用的數據結構 Set 的中文譯名重複,因而容易導致歧義和誤解,所以我們在本書中會統一稱之爲容器,而集合這個詞將特指像 Set 那樣的容器。

容器的類型通常都是參數化類型。在很多編程語言中,這也是泛型最經典的運用場景。Julia 中的容器類型就像一種模具,用來製造含有若干格子的置物架。模具不同,製造出來的置物架也不同,並且每一個模具都只能製造一類置物架。每一類置物架都有自己獨特的內部結構和存取物品的方式(或者說操作規則),而且同一類置物架在這些方面一定是相同的。

通過實例化容器類型構造出來的值就是容器,而存放在容器中的值則被統稱爲元素值。有的容器類型允許同一個容器接納不同類型的元素值,但有的容器類型卻只讓一個容器接受相同類型的元素值。有的容器可以容納的元素的數量是固定的,而有的容器卻可以自行擴展甚至收縮。

我們下面就來一起討論 Julia 中最簡單且常用的容器——元組。

7.3.1 元組概述

元組(tuple)是一種很簡單的容器。它可以包含若干個任意類型的元素值。我們在前面其實已經見過這類值很多次了。看一個例子你就應該能明白了:

julia> Drawer{Necklace} <: Drawer{Jewelry}, Drawer{Ring} <: Drawer{Jewelry}
(false, false)

julia> typeof(ans)
Tuple{Bool,Bool}

julia> 

我在這裏輸入的第一行代碼是我們之前展示過的一個例子。這行代碼包含了兩個表達式,並以英文逗號分隔。REPL 環境回顯給我們的求值結果是(false, false)。這個結果值實際上就是一個元組。第二行代碼的求值結果Tuple{Bool,Bool}就是它的類型。

當我們像上面這樣讓 REPL 環境同時對多個表達式求值時,該環境就會把求值結果都塞入到一個元組值中並回顯給我們。這種元組值總是由圓括號包裹,並以英文逗號分隔其中的多個元素值。

此外,我們還可以看到,元組類型Tuple{Bool,Bool}中有兩個參數值。它們依次反映了其實例中的每一個元素值的類型。不過由於(false, false)中的兩個元素值類型相同,所以在視覺上沒有顯現出來。但我們要記住,元組類型不但會確定其所有元素的類型,還會體現元素的順序。

7.3.2 普通的元組

普通元組的表示形式與我們調用函數時傳入參數值的方式很相似。下面來看一個之前展示過的示例:

julia> function sum1(a::Real, b::Real)
           a + b
       end
sum1 (generic function with 1 method)

julia> sum1(1.2, 5)
6.2

julia> 

函數sum1擁有一個參數列表。這個參數列表由圓括號包裹,其中定義了兩個參數。在調用sum1函數的時候,我們需要傳給它兩個符合定義的參數值。在它下面的調用表達式中,我給出的參數值是用(1.2, 5)來呈現的。這其實就是一種元組。

元組類型與一般的參數化類型有着一個很明顯的不同——它具有協變的特性。我們在前面解釋過什麼是協變。舉個例子,有兩個確定的元組類型Tuple{Real}Tuple{Integer}。由於它們的類型參數值Real‌和Integer之間存在繼承關係,所以Tuple{Real}Tuple{Integer}之間也有着相同的繼承關係。驗證的代碼如下:

julia> Tuple{Real} >: Tuple{Integer}
true

julia> Tuple{Real, Char} >: Tuple{Integer, Char}
true

julia> Tuple{Real, AbstractChar} >: Tuple{Integer, Char}
true

julia> Tuple{Real, Char} >: Tuple{Integer, AbstractChar}
false

julia> Tuple{Real, AbstractChar} >: Tuple{Integer, String}
false

julia> Tuple{Real, Char} >: Tuple{Integer}
false

julia> Tuple{Real} >: Tuple{Integer, Char}
false

julia> 

可以看到,僅當兩個元組類型擁有相同數量的參數值,並且所有對應位置上的參數值都存在方向一致的繼承關係,這種繼承關係纔會在這兩個元組類型上延續。

在值的操作方面,元組值與字符串值有着很多相同之處。比如,我們可以使用索引號訪問到一個元組值中的某個元素值。我們現在有這樣一個元組值:

julia> tuple1 = (125, 3.1, '中', "編程")
(125, 3.1, '中', "編程")

julia> typeof(tuple1)
Tuple{Int64,Float64,Char,String}

julia> 

那麼,索引表達式tuple1[1]的求值結果就是Int64類型的125,而表達式tuple1[2]的求值結果則是Float64類型的3.1,以此類推。注意,這裏的索引號依然是從1開始的。與字符串值類似,我們不能通過索引表達式替換元組中的任何元素值。因爲 Julia 中的元組也都是不可變的!

我們還可以用範圍索引表達式截取元組中的某一段:

julia> tuple1[1:3]
(125, 3.1, '中')

julia> typeof(ans)
Tuple{Int64,Float64,Char}

julia> 

這種表達式的求值結果也會是一個元組,而且那些被截取到的元素值的類型也都不會改變。

我們之前講過的那 4 個用於搜索的函數,即:findfirstfindlastfindprevfindnext,都可以被用來搜索元組中的元素值。只不過,對於元組,我們傳給它們的第一個參數值必須是一個用來做條件判斷的函數。也就是說,這個函數的結果值必須是Bool類型的。下面是一些示例:

julia> findfirst(isequal('中'), tuple1)
3

julia> findlast(isequal('中'), tuple1)
3

julia> findprev(isequal('中'), tuple1, 4)
3

julia> findnext(isequal('中'), tuple1, 2)
3

julia> findnext(isequal('中'), tuple1, 4) == nothing
true

julia>  

另外,比較操作符也可以直接用於元組之間的比較。在這種情況下,Julia 會依次比較兩個元組中的每一個元素值,直到足以做出判斷爲止。

對於元組的拼接,操作符+*都是無能爲力的。這時我們可以使用tuple函數和符號...。它們的用法如下:

julia> tuple(tuple1..., tuple1...)
(125, 3.1, '中', "編程", 125, 3.1, '中', "編程")

julia> 

我們在前面說過,符號...的作用就是,把緊挨在它左邊的那個值中的所有元素值都平鋪開來,並讓它們都成爲獨立的參數值。所以,上面的這個表達式與如下的表達式等價:

julia> tuple(tuple1[1], tuple1[2], tuple1[3], tuple1[4], tuple1[1], tuple1[2], tuple1[3], tuple1[4])
(125, 3.1, '中', "編程", 125, 3.1, '中', "編程")

julia> 

除此之外,我們還可以僅用圓括號來拼接元組:

julia> (tuple1..., tuple1...)
(125, 3.1, '中', "編程", 125, 3.1, '中', "編程")

julia> 

元組的拼接總會產生新的元組。但這樣的元組不一定是全新的,因爲其中的元素值不一定都是位類型的值。還記得嗎?位類型的值不會包含任何對其他值的引用。更進一步地說,如果原有元組中的元素值引用了其他值,那麼在由拼接產生的新元組中,對應的元素值仍然會引用同一個值。例如,我們有如下的兩個元組:

julia> tuple2 = ([1,2,3], [4,5,6,7])
([1, 2, 3], [4, 5, 6, 7])

julia> tuple2_2 = (tuple2..., tuple2...)
([1, 2, 3], [40, 5, 6, 7], [1, 2, 3], [40, 5, 6, 7])

julia> 

元組tuple2包含了兩個元素值。這兩個元素值都是數組(由方括號包裹,並以英文逗號分隔其包含的多個元素值)。而元組tuple2_2則是兩個tuple2的拼接。

對於一個確定的元組類型來說,只要它的參數值都屬於位類型,那麼這個元組類型就一定屬於位類型,如:

julia> isbitstype(Tuple{Int64,Float64,Char})
true

julia> isbitstype(Tuple{Float64,String})
false

julia> isbitstype(Tuple{Real})
false

julia> 

但數組類型與之不同,它的任何確定類型都不屬於位類型。並且,它的值都是可變的。所以,如果我們改變了元組tuple2包含的某個數組中的元素值,那麼這種改變就會立即反映到元組tuple2_2中。例如:

julia> tuple2[2][1] = tuple2[2][1] * 10
40

julia> tuple2
([1, 2, 3], [40, 5, 6, 7])

julia> tuple2_2
([1, 2, 3], [40, 5, 6, 7], [1, 2, 3], [40, 5, 6, 7])

julia> 

我用鏈式的索引表達式tuple2[2][1]改變了tuple2所包含的數組[4, 5, 6, 7]中的第 1 個元素值。可以看到,tuple2_2中的兩個對應的元素值都有了同樣的改變。

7.3.3 有名的元組

有名元組中的“有名”並不是說元組有名字,而是說其中的每一個元素值都擁有自己的名字。例如:

julia> named_tuple1 = (name="Robert", reg_year=2020, extra="something")
(name = "Robert", reg_year = 2020, extra = "something")

julia> 

可以看到,有名元組同樣由圓括號包裹,也同樣以英文逗號分隔其中的多個元素值。但與普通的元組不同的是,在有名元組中的每一個元素值的左側,都有一個代表了元素名稱的標識符和一個等號。這種表示形式與對變量的賦值極其相似。而且這兩者的含義也基本相同,即:把一個值與一個標識符綁定在一起。但是,它們的作用域是不同的。雖然有名元組中的元素值也可以通過其名稱來訪問,但這些名稱僅在其所屬元組的上下文中可用。例如:

julia> named_tuple1[:reg_year]
2020

julia> typeof(:reg_year)
Symbol

julia> reg_year
ERROR: UndefVarError: reg_year not defined

julia> 

表達式named_tuple1[:reg_year]是普通的索引表達式的一種變體。在它的中括號裏的不是一個索引號,而是一個Symbol類型的值。Symbol的值必須要以英文冒號:開頭,並後跟一個符合變量命名規則的標識符。

Symbol本來是元編程中的一個概念,它的值用於表示對變量的訪問。在有名元組的上下文中,其值的含義就是指代某個元素值的名稱,而在:後面的就是那個名稱。又由於這裏的Symbol類型值與索引號的作用是相同的,因此前述表達式的求值結果就是與reg_year對應的那個元素值。

有名元組的類型是NamedTuple。該類型也是一個參數化類型,但它只有固定個數的類型參數。元組named_tuple1的類型如下:

julia> typeof(named_tuple1)
NamedTuple{(:name, :reg_year, :extra),Tuple{String,Int64,String}}

julia> 

可以看到,這個類型的第一個參數值是一個普通的元組。在這個元組裏,包含了一些Symbol類型的值,這些值與named_tuple1中的元素名稱逐一對應。該類型的第二個參數值是一個確定的元組類型。它精確地體現了named_tuple1中的各個元素值的類型。或者說,如果named_tuple1中只有元素值而沒有元素名,那麼它的類型就會如上述示例中的第二個類型參數值。總之,一個有名元組的類型幾乎確定了其實例的方方面面,除了元素的值。

還記得嗎?對於確定的參數化類型,Julia 會爲它自動生成一個全名(即攜帶花括號的名稱)相同的構造函數。這就意味着,NamedTuple類型的構造函數名往往很長,如NamedTuple{(:name, :reg_year, :extra),Tuple{String,Int64,String}}。幸好,Julia 允許我們在這裏走一個小捷徑,不必寫出那麼長的構造函數名,就像這樣:

julia> NamedTuple{(:name, :reg_year, :extra)}(("Robert", 2020, "something"))
(name = "Robert", reg_year = 2020, extra = "something")

julia> 

我在這裏使用的構造函數名爲NamedTuple{(:name, :reg_year, :extra)}。雖然也不算短,但是比前面的那個全名要好多了。這個函數名只體現了有名元組中的各個元素值的名稱,而沒有體現它們的類型。不過不用擔心,Julia 會根據我們給予的參數值推斷出元素值的類型。不知道你注意到沒有,我們傳給上述構造函數的參數值就是一個普通的元組。

由此可見,有名元組實際上是對普通元組的一種再封裝。這從有名元組的類型字面量上也可以看出端倪。這種再封裝讓元組中的每一個元素值都有了自己的名字,就像我們傳給函數的參數值都有對應的參數名那樣。另外,順便說一句,有名元組的類型是非轉化的。

7.3.4 可變參數的元組

可變參數(vararg)的意思是參數的數量可多可少,並不固定。單詞 vararg 有時也被寫成 varargs,是一個出自計算機編程領域的合成詞,由 variable 和 argument 合成而來。其含義是數量可變的參數,所以它在中文裏常常被簡稱爲可變參數。

由此延伸,可變參數的元組就是指元素數量並不固定的元組。這種元組其實就是普通的元組,只不過在其類型中會有一個特殊的類型參數值,使它的所有實例都可以接納更多的元素值。

這種元組的類型可以是這樣的:

julia> Tuple{Vararg{String}}
Tuple{Vararg{String,N} where N}

julia> 

其中的Vararg{String}就是那個特殊的類型參數值。它是Vararg{String,N} where N的簡寫形式。而Vararg是一個直接繼承自Any的抽象類型,同時也是一個參數化類型。它擁有兩個類型參數,其佔位符分別是TN。因此,類型Vararg{T,N}表達的就是NT類型的參數。若放到元組類型的上下文中,它則表示該元組類型的所有實例都要有NT類型的元素值。因爲元組中元素值的數量總是與其類型中類型參數的數量保持一致。

我們可以用一個確切的整數替換掉這裏的N,也可以放任不管。如果放任不管,那麼就表示參數的數量是任意的。比如Vararg{String}就表示可以有任意個String類型的參數。所以,元組類型Tuple{Vararg{String}}代表的就是那些包含了任意個字符串值的元組。驗證的代碼如下:

julia> isa((), Tuple{Vararg{String}})
true

julia> isa(("Julia",), Tuple{Vararg{String}})
true

julia> isa(("Julia", "Python", "Golang"), Tuple{Vararg{String}})
true

julia> 

可以看到,不論這些元組中的字符串值有多少個,它們都是Tuple{Vararg{String}}類型的實例。請注意,上述示例中的()表示的是空元組,也就是不包含任何元素值的元組。而("Julia",)表示的則是隻包含了一個元素值(即"Julia")的元組。爲了避免歧義,我們若要表示只有一個元素值的元組,就需要在該元素值的後面添加一個英文逗號。否則,Julia 就可能會把圓括號識別爲包裹高優先級操作的符號,從而將其忽略掉。示例如下:

julia> ("Julia",)
("Julia",)

julia> typeof(ans)
Tuple{String}

julia> ("Julia")
"Julia"

julia> typeof(ans)
String

julia> 

回到可變參數的話題。如果我們把Vararg{T,N}中的N也確定下來,比如Vararg{String,2},那麼它表達的參數數量就是固定的了。這種類型字面量肯定不能用於表示可變參數的元組。不過它們仍然是很有用處的。請思考一下,如果我們要寫出一個類型字面量,它需要代表包含了 10 個整數值的元組,那麼應該怎樣寫呢?

實際上,我們不必寫出包含 10 個類型參數值的元組類型,只需要像下面這樣利用Vararg類型來編寫就可以了:

julia> Tuple{Vararg{Int,10}}
NTuple{10,Int64}

julia> Tuple{Vararg{Int,10}} == Tuple{Int,Int,Int,Int,Int,Int,Int,Int,Int,Int}
true

julia> isa((1,2,3,4,5,6,7,8,9,0), Tuple{Vararg{Int,10}})
true

julia> 

示例中的Tuple{Vararg{Int,10}}就是答案。它等價於長長的擁有 10 個Int的元組類型。另外,NTuple{10,Int64}Tuple{Vararg{Int,10}}類型的別名。更寬泛地講,NTuple{N,T}總是Tuple{Vararg{T,N}}類型的別名。這顯然可以讓我們少寫一些代碼。

最後,關於在元組類型中使用Vararg,我們還有兩點需要注意。第一,在編寫元組類型時,Vararg類型的字面量只能作爲它的最後一個類型參數值,否則 Julia 就會直接報錯。第二,雖然Vararg類型在一些時候可以爲我們提供便利,但由於它只能表示N個同類型的參數,所以它的實際應用場景還是相對有限的。要知道,元組類型中的每一個類型參數值都可以是任意的類型。因此,我們應該在考慮使用它的時候認真地權衡一下利弊,不要濫用。

無論是普通的元組還是有名的元組,又或是我們剛剛講的可變參數的元組,都是非常靈活的容器。原則上,它們都可以用於保存任意數量、任意類型的值。而且,由於它們都是不可修改的,所以我們既不用擔心它們保存的值被篡改,也不用擔心併發訪問的問題。這也是不可變對象的最大優勢,可以顯著地減少對象創建者和使用者的心智負擔。但要注意,元組中的元素值不一定都是不可變的,所以一個元組可能無法做到完全的不可變。

7.4 小結

我們在這一章主要討論了參數化類型。參數化是 Julia 類型系統中的一個非常重要且強大的特性。它允許一個類型自身擁有參數,以使其可以代表一個完整的類型族羣。在很多時候,參數化類型就相當於一種對數據結構的泛化定義,因此我們也可以稱之爲泛型類型。我們之前講過的抽象類型、原語類型和複合類型都可以被參數化。其中,參數化複合類型最常用,但抽象類型的參數化意義更大。

除了這些核心的概念和編程方式之外,我們還講述了與參數化有關的更多知識。這包括類型參數的值域、類型的類型,以及值化的表示法。其中,值化的表示法尤爲重要。因爲它可以使參數化類型的具體化變得非常的靈活,而且還可以讓我們對參數化類型的認識更加深刻。

我們還用一定的篇幅介紹了 Julia 中的元組。元組是一種比較簡單的參數化類型,同時它還是一種非常常用的容器。我們介紹了三種元組,即:普通元組、有名元組和可變參數元組。

在認真閱讀這一章之後,我相信你會對 Julia 的參數化類型有一個正確且比較深入的認知。容器是廣泛應用類型參數化的典型案例。我們在後面還會講解更多、更復雜的容器。不過別擔心,一旦你熟悉了元組,那麼理解其他容器就會容易很多。

系列文章:

Julia編程基礎(一):初識Julia,除了性能堪比C語言還有哪些特性?

Julia編程基礎(二):開發Julia項目必須掌握的預備知識

Julia編程基礎(三):一文掌握Julia的變量與常量

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

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章