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

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

本書的示例項目名爲Programs.jl,地址在這裏。其中會包含本書所講的大部分代碼,但並不是那些代碼的完全拷貝。這個示例項目中的代碼旨在幫助本書讀者更好地記憶和理解書中的要點。它們算是對書中代碼的再整理和補充。
在 Julia 中,任何值都是有類型的。可見,與值一樣,類型也散佈在 Julia 程序的每一個角落。

我們都知道,計算機編程語言大體上可以分爲兩類。一類是以 C、Java、Golang 爲代表的靜態類型語言,另一個類是以 Python、Ruby、PHP 爲代表的動態類型語言。

所謂的靜態類型語言是指,在通常情況下,程序中的每一個變量或表達式的類型在編寫時就要有所說明,最遲到編譯時也要被確定下來。另外,變量的類型是不可以被改變的。或者說,一個變量只能被賦予某一種類型的值。雖然在有的編程語言(如 Golang)中,變量的類型可以被聲明爲某個接口類型,從而使其值的類型可以不唯一(只要是該接口類型的實現類型即可),但這終歸是有一個非常明確的範圍的。

動態類型語言與之有着明顯的不同。這類語言中的變量的類型是可變的。或者說,變量的類型會隨着我們賦予它的值的類型而變化。這種變化可以說是隨心所欲的。它給程序帶來了極大的靈活性,但同時也帶來了很多不穩定的因素。這主要是由於某些操作只能施加在某個或某種類型的值之上。比如,對於數值類型的值纔有求和一說。又比如,只有字符類型和字符串類型的值才能進行所謂的拼接。一旦變量所代表的值與將要施加的操作不匹配,那麼程序就會出現異常,甚至崩潰。爲了謹慎對待此類錯誤,我們往往不得不在程序中添加很多額外的錯誤檢測和處理代碼。這無疑會加重我們的心智負擔。

那些靜態類型語言的編譯器可以幫助我們檢查程序中絕大多部分的類型錯誤。同時,固化類型的變量也可以讓程序跑得更快。不過,這肯定會讓我們在寫程序時多敲入一些代碼,包括對變量類型進行聲明的代碼,以及對不同類型的值實現同一種操作的代碼。後一種代碼可以被稱爲多態性代碼。

如果多態性代碼可以由編程語言提供的一些工具簡化,而不用我們完全手動編寫,那麼就可以說這種編程語言是支持多態的。絕大多數動態類型語言都是支持多態的。其代碼幾乎都可以自動地成爲多態性代碼。這也主要得益於其變量類型的可變性。而一些靜態類型語言也可以通過一些手段(比如方法重載和泛型)在一定程度上支持多態。

4.1 概述

嚴格來說,Julia 屬於動態類型語言。或者說,Julia 的類型系統是動態的。但是,我們卻可以爲變量附加類型標註,以使它的類型固化。雖然有些傳統的動態類型語言(比如 Python)也可以爲變量添加類型信息,但那最多也只能算是一種註釋,並不屬於其類型系統的一部分。相比之下,一旦我們爲 Julia 程序中的變量添加了類型標註,那麼julia命令就可以在程序真正運行之前檢測出並及時報告類型不兼容的賦值。

4.1.1 三個要點

如果只用三個詞來概括 Julia 的類型系統的話,那麼就應該是動態的(dynamic)、記名的(nominative)和參數化的(parametric)。

我們已經解釋過什麼叫做“動態的”。簡單來說就是,變量的類型是可以被改變的。如果我們不爲變量添加類型標註,那麼只有到了程序運行的時候,Julia 才能知道該變量的類型是什麼。

所謂的“記名的”是指,Julia 中的每一個類型都是有名稱的。並且,即使兩個類型的含義和結構都是相同的,只要它們的名稱不同,那麼它們就是兩個不同的類型。另外,類型之間的層次關係一定是有顯式的聲明的。例如,Int64類型的定義是這樣的:

primitive type Int64 <: Signed 64 end

這裏應該重點關注的是Int64 <: Signed。操作符<:的含義是,其左側的類型是其右側類型的直接子類型。因此,Int64類型是Signed類型的直接子類型,或者說Int64類型直接繼承了Signed類型。當然,兩個類型之間的關係也可以是間接的。例如,Signed類型的定義如下:

abstract type Signed <: Integer end

因此我們可以說Int64類型是Integer類型的間接子類型。

對於類型的參數化,我們也多次提到過。還記得我們在上一章定義過的那個Ref{UInt32}類型的常量嗎?Julia 中的參數化類型(如Ref{T})類似於其他一些編程語言(比如 Haskell、Java 等)中的泛型。不過,各種編程語言實現泛型的方式都會有所不同,最起碼在實現細節上都會有自己的特點。對於 Julia 來說更是如此,別忘了它可是動態類型的編程語言。

我們會在後面專門講類型的參數化。你現在只需要知道,參數化類型相當於一種對數據結構的泛化定義。更具體地說,我們可以藉此在不指定具體類型的情況下用代碼去描繪泛化的(或者說更加通用的)數據結構和算法。

4.1.2 一個特點

Julia 類型系統的最大特點當屬它的多重分派機制。正因爲有了多重分派機制,Julia 才能夠對多態提供強大的支持。

當我們沒有爲變量或參數添加類型標註的時候,原則上它們可以被賦予任何類型的值。至於後續的操作是不是支持這樣的值,那就需要以多重分派的結果爲準了。例如,有這樣一個函數sum1

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

julia> 

注意,在functionend之間的代碼就是我們對sum1函數的定義。該函數的功能顯而易見。它有兩個參數ab,並且都沒有類型標註。在這種情況下,我們使用兩個整數值、兩個浮點數值或者一個整數值及一個浮點數值作爲參數值調用它都是可以的:

julia> sum1(1, 2)
3

julia> sum1(1.2, 2.3)
3.5

julia> sum1(1.2, 4)
5.2

julia> 

這是由於 Julia 的多重分派機制根據在操作符+兩側的值的類型,把相加的操作委派給了不同的內部代碼(操作符+實際上也代表着一個函數,且針對其參數類型的不同還有着很多衍生方法)。這就自動地讓我們的代碼成爲了多態性代碼,即:對不同類型的值實現同一種操作的代碼。

即使我們爲sum1函數的參數添加了類型標註,情況也是類似的。我們可以對這個函數稍加改造:

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

julia> 

這裏的Real代表了實數類型,同時它也屬於抽象類型。簡單來說,抽象類型代表着一個類型範圍。比如,我們之前講過的Int64UInt32以及未曾碰到過的Float32Float64都在Real這個範圍之內。這與數學中的概念是一樣的,即:整數和浮點數都屬於實數。

因此,即便是在這樣的類型約束之下,我們在前面寫的那幾種調用方式也依然是有效的:

julia> println("$(sum1(1, 2)), $(sum1(1.2, 2.3)), $(sum1(1.2, 4))")
3, 3.5, 5.2

julia> 

也即是說,sum1函數在如此的類型約束下仍然是多態的。

注意,我們到這裏已經擁有了兩個名爲sum1的函數。第二個sum1函數也可以被稱爲第一個sum1函數的衍生方法。第一個sum1函數的參數類型都是Any(我們稍後會講到這個類型),而第二個sum1函數的參數類型都是Real。相比於前者,後者對參數的類型有了一定的約束。Julia 會根據我們調用這個函數時給予的參數值的類型來選擇具體使用哪一個函數。這依然是多重分派機制在起作用。別擔心,你現在對此不理解並沒有關係。我們會在後面用一章專門講解函數、方法以及 Julia 的多重分派機制。對於抽象類型,我們在後面也會詳細論述。

你現在只需要知道,類型標註和多重分派機制都已經被內置在了 Julia 的類型系統中,並且它們都是這個系統的核心功能。它們能夠幫助我們產出富有表現力且可高效運行的代碼。由於它們的共同作用,我們的代碼纔可以在各種約束之下靈活地實現多態。

4.2 類型與值

我們在前面提到了子類型(subtype)這個概念。與之相對的概念是超類型(supertype)。比如說,Integer類型是Signed類型的直接超類型,並且還是Int64類型的間接超類型。如果用操作符<:來表示的話,那就是:Int64 <: Signed <: Integer

實際上,Julia 中預定義的所有類型共同構成了一幅具有層次的類型圖。這幅類型圖中的類型之間都是有關係的。更具體地說,它們要麼存在着直接或間接的繼承關係,要麼有着共同的超類型。

每一個 Julia 程序都會使用甚至定義一些類型。正因爲如此,我們的程序才與 Julia 的類型系統關聯在了一起。可以說,我們在編寫程序時總會使用到 Julia 的類型圖,並且有時候(即在自定義類型時)還會對這幅圖進行擴展。我們定義的所有類型都會有一個超類型,即使我們沒有顯式地指定它。如此一來,我們的類型就與 Julia 原有的類型圖聯繫在一起了。

我們之前說過,Julia 代碼中的任何值都是有類型的。或者說,Julia 程序中的每一個值都分別是其所屬類型的一個實例。不僅如此,每一個值也都分別是其所屬類型的所有超類型的一個實例。例如:

julia> 10::Int64, 10::Signed, 10::Integer
(10, 10, 10)

julia> 

可以看到,上例中的 3 個類型斷言都成功了。也就是說,10這個值既是Int64類型的一個實例,也是Signed類型和Integer類型的一個實例。

此外,Julia 代碼中所有的值都是對象(object)。但與那些傳統的支持面向對象編程的語言不同,Julia 中的對象(或者說這些對象所屬的類型)並不會包含或關聯任何方法。恰恰相反,一個函數會用它的衍生方法去儘量適應被操作的對象。這正是由 Julia 的多重分派機制來控制的。

再次強調,Julia 中只有值纔有類型,而變量本身是沒有類型的。一個變量代表的只是一個標識符與某個值之間的綁定關係。

4.3 兩個特殊類型

4.3.1 Any 類型

在 Julia 的類型圖中,Any是一個唯一的頂層類型。如果說超類型在上、子類型在下的話,那麼它就處在類型圖的最頂端。Any類型是所有類型的直接或間接的超類型。也就是說,對於任意類型的變量x,類型斷言x::Any都必定是成功的。

還記得嗎?我們在前面定義第一個sum1函數的時候,並沒有爲它的兩個參數指定類型。然而,在這種情況下,這兩個參數實際上都會有一個缺省的類型,即:Any類型。這也是爲什麼我們可以用任何類型的值作爲參數值調用這個sum1函數的原因。

再比如,我們可以定義如下的原語類型(我們稍後會講到這種類型):

julia> primitive type MyWord 64 end

julia> 

注意,我們沒有顯式地指定它的超類型。然而,在這種情況下,MyWord類型會有一個缺省的超類型,同樣是Any類型。也就是說,這個MyWord類型是Any類型的直接子類型。

更寬泛地講,Any類型會在很多情況下擔當默認類型併發揮其作用。我們在後面還會遇到類似的情形。另外,Any類型是一個抽象類型。因此它本身是不能被實例化的。但所有的值卻都是它的實例。

4.3.2 Union{} 類型

在 Julia 的類型圖中,還有一個與Any完全相對的類型。它就是Union{}類型。由於這個類型是所有類型的子類型,所以它是一個底層類型,並且也是唯一的一個。它處在類型圖的最底端。也就是說,對於任意類型的變量x,類型斷言x::Union{}都必定是失敗的。另外,與Any一樣,Union{}也是一個抽象類型。

從字面上我們就可以看出,Union{}是一個被參數化的類型。它的源類型是Union{Types...}類型,其中的Types...代表任意個類型參數。如果這裏有多個類型參數,那麼它們之間需要用英文逗號分隔開。

這個Union{Types...}類型有着一種很特殊的用途。我們可以利用它,讓一個單一的類型字面量代表多個類型。換句話說,把多個類型聯合在一起形成一個類型,並讓後者作爲前者的統一代表。因此,我們也可以把這個類型稱爲聯合類型。而每一個類型參數的組合都可以代表一種聯合類型。示例如下:

julia> IntOrString = Union{Integer, AbstractString} 
Union{AbstractString, Integer}

julia> 2020::IntOrString
2020

julia> "2020"::IntOrString
"2020"

julia> 

類型Union{Integer, AbstractString}表示的是Integer類型和AbstractString類型的聯合。因此,任何Integer類型或AbstractString類型的實例都可以被視爲這個聯合類型的實例。這就是類型斷言2020::IntOrString"2020"::IntOrString可以成功的原因。

另外,由於 Julia 中的類型屬於一類特殊的值(DataType類型的值),所以上述的聯合類型自然也就可以與標識符IntOrString綁定在一起。這時,我們可以說IntOrString是那個聯合類型的別名(alias)。

搞清楚了聯合類型以及它的用途,我們就很容易理解“Union{}類型處在類型圖的最底端”的原因了。由於它的花括號中沒有任何類型參數,所以這種聯合類型也就代表不了任何類型,相當於一個“虛無”的類型。而任何類型都比“虛無”包含了更多的東西,所以它們都是這種聯合類型的超類型。如果我們使用操作符<:在這些類型之間做判斷的話,就可以很形象地看到這種關係:

julia> Union{} <: Integer
true

julia> Union{} <: Union{Integer}
true

julia> 

此示例中的兩個表達式的結果值都是true。這說明整數類型Integer和聯合類型Union{Integer}都是“虛無”類型Union{}的超類型。

至此,我們已經較爲充分地瞭解了 Julia 類型圖中的兩端,即:最頂端的Any和最底端的Union{}。下面,我們一起來看看在它們之間的類型都有哪些。

4.4 三種主要類型

如果以是否可以被實例化來劃分的話,Julia 中的類型可以被分爲兩大類:抽象類型和具體類型。而具體類型還可以再細分。我們先從抽象類型說起。

4.4.1 抽象類型

抽象類型不能被實例化。正因爲如此,抽象類型只能作爲類型圖中的節點,而不能作爲終端。如果把類型圖比喻成一棵樹的話,那麼抽象類型只能是這棵樹的樹幹或枝條,而不可能是樹上的葉子。即使是Union{}這個特殊的底層類型,也無法成爲葉子並與一般的值和變量扯上關係。

有了抽象類型,我們就可以去構造自己的類型層次結構。比如,由AbstractString類型延伸出一些特殊的字符串類型,以便適配一些具體的情況。又比如,從直接繼承自Any的某個類型開始,一步步構建和擴展我們自己的類型(子)圖,從而描繪出一個面向某個領域的數據類型體系。

另外,抽象類型讓我們在編寫數據結構和算法時不必指定具體的類型。在很多時候,具體類型就意味着嚴格的限制。這可能會讓程序更加穩定,但也可能會使程序失去必要的靈活性。當有了抽象類型和類型層次結構,我們就可以根據自己的需要去權衡穩定性與靈活性之間的關係了。我們在前面編寫的那個sum1函數就是一個很好的例子。

下面,我們一起來看看怎樣定義一個抽象類型。這種定義的一般語法是這樣的:

abstract type <類型名> end 

abstract type <name> <: <超類型名> end

注意,其中的成對尖括號及其包含的內容是需要我們替換掉的。

這裏有兩種形式。它們都以多詞關鍵字abstract type開頭,並後接類型的名稱。不同的是,第一種形式沒有顯式地指定它的超類型,而直接以end結尾了。在這種情況下,這個被定義的類型的超類型就是Any。而第二種形式在類型名和end之間插入了操作符<:和超類型名。我們之前說過,這個操作符在這裏表示“A 直接繼承自 B”,或者說“A 是 B 的直接子類型”。其中 A 代表該操作符左側的類型,而 B 則代表操作符右側的類型。

我們在前面展示過的抽象類型Signed的定義使用的就是第二種形式。Signed類型直接繼承了Integer類型:

abstract type Signed <: Integer end

如果我們把焦點擴散開來,就會發現這只是數值類型層次結構中的一小段。下面是數值類型子圖的示意。


圖 4-1 數值類型的層次結構

圖中由圓角矩形包裹的類型都是抽象類型,而由直角矩形包裹的類型都是具體類型。再次強調,只有具體類型(如Float32BoolInt64等)纔可能被實例化,而抽象類型(如RealIntegerSigned等)一定不能被實例化。不過一個值卻可以是某個抽象類型的實例。比如,10這個值就是SignedIntegerReal類型的實例。原因是這幾個抽象類型都是具體類型Int64的超類型。

我們可以說,抽象類型就是 Julia 類型圖中的支柱。沒有它們,整個類型層次結構就不復存在。其根本原因是,具體類型不能像抽象類型那樣被繼承。也就是說,具體類型只能是類型圖中的終端或樹上的葉子。

順便提一句,我們可以使用isabstracttype函數來判斷一個類型是否屬於抽象類型,還可以用isconcretetype函數判斷一個類型是否屬於具體類型。顯然,對於同一個類型,這兩個函數總會給出相反的結果。

4.4.2 原語類型

原語類型是一種具體類型。它的結構相當簡單,僅僅是一個扁平的比特(bit)序列。我們在前面提到的數值類型中有很多都屬於原語類型,具體如下:

  • 浮點數類型:Float16Float32Float64
  • 布爾類型:Bool
  • 有符號整數類型:Int8Int16Int32Int64Int128
  • 無符號整數類型:UInt8UInt16UInt32UInt64UInt128

除此之外,Char類型也屬於原語類型。所以,Julia 預定義的原語類型一共有 15 個。

原語類型的定義方式與抽象類型的很相似。只不過它以多詞關鍵字primitive type開頭,而不是以abstract type開頭。我們之前提到過Int64類型的定義,它是這樣的:

primitive type Int64 <: Signed 64 end

注意,在這個定義的超類型名Signed和關鍵字end之間有一個數字64。這個數字代表的就是該類型的比特序列的長度。或者說,它代表的是該類型的值需要佔據的存儲空間的大小,單位是比特。爲了與值的顯示長度區分開,我們通常把這個數字稱爲類型的寬度。實際上,這裏的寬度已經體現在Int64類型的名稱中了。

由此,我們可以得知,Int8UInt8的寬度是相同的,Int16UInt16的寬度也是相同的。以此類推。雖然寬度相同,但由於它們的名稱不同,所以還是不同的類型。更何況,它們的含義也是不一樣的。

Bool類型和Char類型的寬度都沒有體現在名稱上。但通過其含義,我們可以倒推出它們的寬度。Bool是用於存儲布爾值的類型。布爾值總共纔有兩個,即:truefalse。因此按理說使用一個比特來存儲就足夠了。但由於計算機內存的最小尋址單位是字節(即 8 個比特),更小的存儲空間既不利於內存尋址也無益於性能優化,所以布爾值最少也要用 8 個比特來存儲。至於Char,它的值代表單個 Unicode 字符。由於一個 Unicode 字符最多也只會佔用 4 個字節,所以把Char類型的寬度設定爲 32 個比特就足夠了。

順便說一下,如果我們想在程序中獲得一個類型的寬度,那麼可以使用sizeof函數,就像這樣:

julia> sizeof(Bool)
1

julia> sizeof(Char)
4

julia> 

該函數會返回一個Int64類型的結果值。但要注意,從這裏得到的類型寬度的單位是字節,而不是比特。

與很多其他的編程語言都不同,Julia 允許我們定義自己的原語類型。如此一來,我們就可以在一個固定大小的空間中存放自己的比特級數據了。例如,我們可以定義這樣一個原語類型:

primitive type MyUInt64 <: Unsigned 64 end

原語類型MyUInt64直接繼承自Unsigned類型,所以它的值可以被用來存儲無符號整數。又因爲它的寬度是64,所以其值需要佔據的存儲空間是 8 個字節。

不過,要想讓這個類型真正實用,我們還需要編寫更多的代碼。對於這些代碼,你目前閱讀起來可能會有些難。所以我把它們存放到了相對路徑爲src/ch04/primitive/main.jl的源碼文件中。如果你現在就對此感興趣,可以打開這個文件看一看。其中的註釋會幫助你更好地理解代碼。

4.4.3 複合類型

複合類型也是一種具體類型。它的結構可以很簡單,也可以相對複雜。這完全取決於我們的具體定義。我們可以在定義一個複合類型的時候爲它添加若干個有名稱、有類型的字段,以滿足我們對數據結構的要求。這裏的字段也是由一個標識符代表的。它與變量很類似,只不過它只能存在於複合類型的內部。對於一個複合類型的字段,我們只能通過其實例才能訪問到。

在 Julia 中,複合類型是唯一一個可以由我們完全掌控的類型,同時也是最常用的一個類型。很多編程語言也都有類似的類型。有的語言把它的實例稱爲對象,而有的語言把它的實例稱爲結構體。在這裏,爲了體現這種類型的特點,我們把 Julia 中的複合類型的實例也稱爲結構體(但它們也都是對象)。在一些編程語言中(比如 Java 和 Golang),每個複合類型都可以關聯一些方法。而在 Julia 中,複合類型是不可以關聯任何方法的。對此,我們已經在前面有所提及。這種設計可以讓程序變得更加靈活。

4.4.3.1 定義

複合類型的定義需要由關鍵字struct開頭,並且再加上一個類型的名稱作爲第一行。與其他的很多定義一樣,複合類型的定義也需要以獨佔一行的end作爲結尾。下面是一個簡單的例子:

julia> struct User
           name::String
           reg_year::UInt16
           extra
       end

julia> 

我定義了一個名爲User的類型。它包含了 3 個字段,分別是namereg_yearextra。每個字段的定義都獨佔一行。其中,name代表姓名,是String類型的,而reg_year代表註冊年份,是UInt16類型的。至於extra,我打算用它來存儲一些額外的信息,具體是什麼我現在還不能確定。所以我沒有爲這個字段添加類型標註。這樣的話,這個字段的類型將會是Any類型。也就是說,我可以賦給它任何類型的值。

4.4.3.2 實例化

複合類型的實例化需要用到構造函數(constructor)。不過,我們並不用自己手動編寫,這與原語類型一樣。一個使用示例如下:

julia> u1 = User("Robert", 2020, "something")
User("Robert", 0x07d0, "something")

julia> 

可以看到,構造函數是與對應的類型同名的,並且我是按照User類型中字段定義的順序來爲構造函數傳入參數值的。

Julia 會爲每一個複合類型都自動生成兩個構造函數。它們也被稱爲默認的構造函數。第一個構造函數的所有參數都一定是Any類型的,我們稱之爲泛化的構造函數。這種構造函數可以讓我們用任何類型的參數值去嘗試構造User類型的實例。不過,這不一定會成功。原因是,當參數類型不匹配時,Julia 會嘗試使用convert函數把參數值轉換成對應字段的那個類型的值。如果這種轉換失敗,那麼對複合類型的實例化也將失敗。例如:

julia> u2 = User("Robert", 2020.1, "something")
ERROR: InexactError: UInt16(2020.1)
Stacktrace:
 [1] Type at ./float.jl:682 [inlined]
 [2] convert at ./number.jl:7 [inlined]
 [3] User(::String, ::Float64, ::String) at ./REPL[0]:2
 [4] top-level scope at none:0

julia> 

在複合類型的第二個默認的構造函數中,每一個參數的類型都一定會與對應字段的類型一致。這個函數中不會有任何的參數類型轉換。這種構造函數其實也叫做前面那個泛化函數的衍生方法。如果像下面這樣調用,就一定會用到這個衍生方法:

julia> u2 = User("Robert", UInt16(2020), "something")
User("Robert", 0x07d0, "something")

julia> 

真的是這樣嗎?空口無憑,我們怎麼驗證這種規則呢?這就需要用到一個名叫@which的宏了。至於什麼是宏,你現在可以簡單地把它理解爲一種特殊的函數。它可以像操縱數據那樣去操縱代碼。我們在使用宏的時候需要在其名稱之前插入一個專用的符號@。拿@which來說,這個宏的本名其實是which

怎樣使用@which宏呢?很簡單,我們可以把前面示例中的等號右邊的代碼作爲參數值傳給它,就像這樣:

julia> @which User("Robert", UInt16(2020), "something")
User(name::String, reg_year::UInt16, extra) in Main at REPL[0]:2

julia> 

可以看到,我只用空格分隔了宏名稱和它的參數值。我們當然也可以像調用普通函數那樣用圓括號包裹住參數值。但由於這裏傳入的是一段代碼,所以爲了清晰我選用了第一種方式。

我們現在來看 REPL 環境回顯的內容。顯然,User(name::String, age::UInt8, extra)正是我們在前面描述的第二個默認的構造函數。它的每個參數的類型都與對應字段的類型相一致。前面那句話由此得到了證實。

相對應的,當我們給u1賦值時,等號右邊的代碼實際上調用的是第一個默認的構造函數。證據如下:

julia> @which User("Robert", 2020, "something")
User(name, reg_year, extra) in Main at REPL[0]:2

julia> 

關於複合類型的構造函數,我們就先介紹到這裏。在後面講函數和方法的時候,我們還會說到它。另外,在本教程的最後一部分,我們還會專門討論宏和元編程。

4.4.3.3 字段的訪問

我們再來說一下字段的訪問方式。我在剛開始講複合類型的時候說過:只有通過其實例才能訪問到其中的字段。那具體應該怎樣做呢?

其實很簡單,通過一個英文點號就可以做到。這個英文點號在這裏被稱爲選擇符。我們可以用它來選擇複合類型實例中的某個字段。示例如下:

julia>  u2.name
"Robert"

julia> Int16(u2.reg_year)
2020

julia> 

注意,雖然我們可以這樣訪問到u2的字段,但是卻不能用這種方式修改它們的值:

julia> u2.name = "Eric"
ERROR: setfield! immutable struct of type User cannot be changed
Stacktrace:
 [1] setproperty!(::User, ::Symbol, ::String) at ./sysimg.jl:19
 [2] top-level scope at none:0

julia> 

依據錯誤信息,我們得知這個User類型的結構體竟然是不可變的!沒錯,我們定義的所有複合類型的實例都是不可變的。你會覺得這很不合常理嗎?雖然不同於其他的一些編程語言的做法,但這樣做是非常有好處的。具體如下:

  1. 提高了程序的安全性。我們完全不用擔心因操作失誤而造成的數據更改。尤其是在與其他的代碼共享數據的時候。在其他的編程語言中,我們往往需要通過控制訪問權限來做到這一點。
  2. 提高了程序的性能。Julia 對不可變的實例會有很多優化的手段。比如,當它們在函數間傳遞的時候並不會造成多餘的內存分配。因爲對於不可變的值,通常只存一份就足夠了。
  3. 可以減少我們的心智負擔。這很好理解,我們肯定不用擔心一個不可變的值會在某個時刻突然發生變化。而且,我們可以完全確定,一個複合類型的所有實例都是不可變的。這可以省去不少用於檢查的代碼。

不過要注意,雖然複合類型的實例本身是不可變的,但如果它包含了某種可變類型(比如數組)的字段,那麼它還是可以被改變的。複合類型的實例本身只能保證,可變類型的字段始終只會引用同一個可變值。關於這一點,我們需要在定義複合類型的時候就考慮清楚。

4.4.3.4 可變的複合類型

Julia 當然允許我們定義可變的複合類型。定義的方式與通常的複合類型定義非常相似。只需要把單詞關鍵字struct替換爲多詞關鍵字mutable struct就可以了。顯然,其中的“mutable”是唯一的關鍵。

例如,我們可以定義一個可變的複合類型Person,然後構造一個此類型的實例,並隨意地修改其中的字段值。代碼如下:

julia> mutable struct Person
           name::String
           age::UInt8
           extra
       end

julia> p1 = Person("Robert", 30, "something")
Person("Robert", 0x1e, "something")

julia> p1.age = 37
37

julia> Int8(p1.age)
37

julia> 

此外,可變的複合類型與不可變的複合類型還有一個很重要的不同,那就是:

  • 對於可變的複合類型,即使兩個實例中對應字段的值都相同,這兩個實例也是不同的。例如:
 julia> p2 = Person("Robert", 37, "something"); p1 === p2
 false
 
 julia> 
  • 對於不可變的複合類型,只要兩個實例中對應字段的值都相同,這兩個實例就是相同的。例如:
 julia> u1 === u2
 true
 
 julia> 

操作符===用於比較兩個值是否完全相同。對於可變的值,這個操作符會比較它們在內存中的存儲地址。對於不可變的值,該操作符會逐個比特地比較它們。此類操作的結果總會是一個Bool類型的值。

因此,p1p2不相同的原因是,它們是被分別構造的,它們的值被存儲在了不同的內存地址上。而u1u2顯然是同一個值,因爲它們的內容完全一樣。

到這裏,我們已經介紹了 Julia 中 3 種主要的類型,即:抽象類型、原語類型和複合類型。它們可以構建起一幅擁有層次結構的類型圖。抽象類型不能被實例化,但可以被繼承。它們是類型圖的支柱。沒有它們,整個類型層次結構就不復存在。這是由於具體類型雖然可以被實例化,但卻不能被繼承。

原語類型和複合類型都屬於具體類型。它們都可以由我們自己定義。但用得最多的還是複合類型。注意,複合類型在默認情況下是不可變的。我們只有使用多詞關鍵字mutable struct進行定義,才能讓複合類型成爲可變類型。

最後,再提示一點,Julia 中的類型繼承只支持單繼承。也就是說,一個類型的超類型只可能有一個。

4.5 小結

在本章,我們主要討論了 Julia 的類型系統。雖然 Julia 屬於動態類型的編程語言,但我們卻可以爲程序中的變量(以及函數中的參數和結果)添加類型標註,並以此讓它們的類型固定下來。

如果只用三個詞來概括 Julia 的類型系統的話,那麼就應該是:動態的、記名的和參數化的。動態指的是變量的類型也可以被改變。記名是指,Julia 會以類型的名稱來區分它們。參數化的意思是,Julia 的類型可以被參數化。或者說,Julia 對泛型提供了完整的支持。

我們講解了超類型、子類型以及繼承的概念。Julia 中的類型共同組成了一幅類型圖。它們要麼存在着直接或間接的繼承關係,要麼有着共同的超類型。Julia 代碼中的任何值都是有類型的。也就是說,Julia 程序中的每一個值都分別是其所屬類型的一個實例。並且,某一個類型的實例必然也是該類型的所有超類型的實例。

關於抽象類型以及兩種具體類型,我已經在上一節的最後總結過了,在這裏就不再重複了。不過,我們需要特別關注兩個 Julia 預定義的抽象類型,即:所有類型的超類型Any和所有類型的子類型Union{}。它們都在類型圖中有着特殊的地位。

本章的內容可以讓你對 Julia 的類型系統有一個正確的認知。雖然沒有涉及到太多的細節,但對於類型相關的概念你應該已經比較熟悉了。我們會在後面頻繁地提及和運用這裏所講述的基礎知識。

原文鏈接:

https://github.com/hyper0x/JuliaBasics/blob/master/book/ch04.md

系列文章:

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

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

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

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