轉換和提升

Julia 有個系統,用於將數學運算子的參數提升至共用類型,這已在其他各種章節中提到,包括 整數和浮點數數學運算和基本函式類型方法。在本節中,我們將說明此提升系統如何運作,以及如何將其擴充至新的類型,並將其套用至內建數學運算子以外的函式。傳統上,程式語言在算術參數的提升方面分為兩派

  • 內建算術類型和運算子的自動提升。在大部分語言中,內建數字類型在用作具有中綴語法的算術運算子(例如 +-*/)的運算元時,會自動提升至共同類型以產生預期的結果。C、Java、Perl 和 Python 等語言都會正確地計算總和 1 + 1.5 為浮點值 2.5,即使 + 的運算元之一是整數。這些系統很方便,而且設計得夠精良,通常對程式設計員來說幾乎是看不見的:幾乎沒有人在撰寫此類表達式時會意識到這個提升,但編譯器和直譯器必須在加法之前執行轉換,因為整數和浮點值不能原樣相加。因此,此類自動轉換的複雜規則不可避免地成為此類語言的規範和實作的一部分。
  • 沒有自動提升。此陣營包括 Ada 和 ML,這些是非常「嚴格」的靜態型別語言。在這些語言中,每個轉換都必須由程式設計員明確指定。因此,範例表達式 1 + 1.5 在 Ada 和 ML 中都會是編譯錯誤。相反地,必須撰寫 real(1) + 1.5,在執行加法之前將整數 1 明確轉換為浮點值。不過,到處進行明確轉換非常不方便,因此即使 Ada 也有一些程度的自動轉換:整數文字會自動提升至預期的整數類型,而浮點文字也會類似地提升至適當的浮點類型。

在某種意義上,Julia 屬於「無自動提升」的範疇:數學運算子只是具有特殊語法的函數,函數的參數絕不會自動轉換。不過,我們可以觀察到,將數學運算套用於各種混合參數類型,只不過是多型多重調用的極端情況,而 Julia 的調用和類型系統特別適合處理這種情況。數學運算元的「自動」提升只不過是一個特殊應用:Julia 附帶預先定義的數學運算元萬用調用規則,當某些運算元類型組合沒有特定實作時,就會呼叫這些規則。這些萬用規則會先使用使用者可定義的提升規則,將所有運算元提升至共用類型,然後針對現在同類型的結果值,呼叫運算元在特定實作中的版本。使用者定義的類型可以輕鬆參與這個提升系統,方法是定義轉換至其他類型和從其他類型轉換的方法,並提供一些提升規則,定義與其他類型混合時應提升至哪些類型。

轉換

取得特定類型 T 值的標準方法是呼叫該類型的建構函數 T(x)。不過,在某些情況下,將值從一種類型轉換為另一種類型,而不需要程式設計師明確要求,會比較方便。一個範例是將值指定給陣列:如果 AVector{Float64},則表達式 A[1] = 2 應該會自動將 2Int 轉換為 Float64,並將結果儲存在陣列中。這是透過 convert 函數來完成的。

convert 函數通常採用兩個參數:第一個是類型物件,第二個是要轉換為該類型的值。傳回值是已轉換為指定類型實例的值。了解這個函數最簡單的方法是觀察它的實際運作

julia> x = 12
12

julia> typeof(x)
Int64

julia> xu = convert(UInt8, x)
0x0c

julia> typeof(xu)
UInt8

julia> xf = convert(AbstractFloat, x)
12.0

julia> typeof(xf)
Float64

julia> a = Any[1 2 3; 4 5 6]
2×3 Matrix{Any}:
 1  2  3
 4  5  6

julia> convert(Array{Float64}, a)
2×3 Matrix{Float64}:
 1.0  2.0  3.0
 4.0  5.0  6.0

轉換並不總是可行的,這種情況下會擲出 MethodError,表示 convert 不知道如何執行要求的轉換

julia> convert(AbstractFloat, "foo")
ERROR: MethodError: Cannot `convert` an object of type String to an object of type AbstractFloat
[...]

有些語言會將字串解析為數字或將數字格式化為字串視為轉換(許多動態語言甚至會自動執行轉換)。這在 Julia 中並非如此。即使有些字串可以解析為數字,但大多數字串都不是數字的有效表示,而且只有非常有限的子集是。因此在 Julia 中,必須使用專用的 parse 函數來執行此操作,使其更為明確。

何時呼叫 convert

下列語言建構會呼叫 convert

  • 指定給陣列會轉換為陣列的元素類型。
  • 指定給物件的欄位會轉換為欄位宣告的類型。
  • 使用 new 建構物件會轉換為物件宣告的欄位類型。
  • 指定給已宣告類型的變數(例如 local x::T)會轉換為該類型。
  • 具有宣告回傳類型的函數會將其回傳值轉換為該類型。
  • 將值傳遞給 ccall 會將其轉換為對應的引數類型。

轉換相對於建構

請注意,convert(T, x) 的行為似乎與 T(x) 幾乎相同。確實,通常是如此。然而,有一個關鍵的語意差異:由於 convert 可以隱式呼叫,因此其方法僅限於被認為是「安全」或「不令人意外」的情況。convert 僅會在表示相同基本類型的類型之間進行轉換(例如數字的不同表示形式或不同的字串編碼)。它通常也是無損的;將值轉換為不同類型再轉換回來,應該會得到完全相同的值。

建構函數與 convert 不同的情況有四種類型

與其引數無關的類型建構函式

有些建構函式不實作「轉換」的概念。例如,Timer(2) 會建立一個 2 秒計時器,這並非從整數到計時器的「轉換」。

可變集合

如果 x 已為 T 類型,則預期 convert(T, x) 會傳回原始的 x。相反地,如果 T 是可變集合類型,則 T(x) 應始終建立一個新的集合(從 x 複製元素)。

包裝器類型

對於某些「包裝」其他值的類型,即使建構函式的引數已為請求的類型,建構函式仍可能將其引數包裝在新的物件中。例如,Some(x) 會包裝 x 以指出有值存在(在結果可能是 Somenothing 的情況下)。然而,x 本身可能是物件 Some(y),這種情況下,結果會是 Some(Some(y)),有兩層包裝。另一方面,convert(Some, x) 會直接傳回 x,因為它已為 Some

不會傳回其自身類型實例的建構函式

極少數情況下,建構函式 T(x) 傳回非 T 類型的物件可能是有意義的。如果包裝器類型為其自身的反函數(例如 Flip(Flip(x)) === x),或者為了支援舊的呼叫語法以在重新建構函式庫時維持向後相容性,這種情況可能會發生。但 convert(T, x) 應始終傳回 T 類型的值。

定義新的轉換

在定義新類型時,一開始所有建立它的方式都應定義為建構函式。如果很明顯隱式轉換會很有用,而且某些建構函式符合上述「安全性」準則,那麼就可以新增 convert 方法。這些方法通常相當簡單,因為它們只需要呼叫適當的建構函式即可。此類定義可能如下所示

convert(::Type{MyType}, x) = MyType(x)

此方法的第一個引數類型為 Type{MyType},其唯一的實例為 MyType。因此,此方法僅在第一個引數為類型值 MyType 時才會呼叫。請注意第一個引數所使用的語法:在 :: 符號之前省略引數名稱,只給出類型。這是 Julia 中函式引數的語法,其類型已指定,但不需要按名稱參照其值。

預設情況下,某些抽象類型的所有實例都被視為「足夠相似」,因此在 Julia Base 中提供了通用的 convert 定義。例如,此定義指出,透過呼叫 1 個引數的建構函式,將任何 Number 類型轉換為任何其他類型都是有效的

convert(::Type{T}, x::Number) where {T<:Number} = T(x)::T

這表示新的 Number 類型只需要定義建構函式,因為此定義會為它們處理 convert。還提供了身分轉換來處理引數已為所要求類型的案例

convert(::Type{T}, x::T) where {T<:Number} = x

AbstractStringAbstractArrayAbstractDict 也有類似的定義。

提升

促成是指將混合型別的值轉換為單一共用型別。雖然這並非絕對必要,但通常暗示值轉換成的共用型別可以忠實呈現所有原始值。在此意義上,「促成」一詞很恰當,因為值會轉換為「更大」的型別,也就是說,可以將所有輸入值呈現為單一共用型別。然而,重要的是不要將此與物件導向(結構)超類別或 Julia 的抽象超類別概念混淆:促成與型別階層無關,而與在備用表示之間轉換有關。例如,雖然每個 Int32 值也可以表示為 Float64 值,但 Int32 並非 Float64 的子類別。

促成到共用「更大」型別是由 Julia 中的 promote 函式執行,它會接收任意數量的引數,並傳回相同數量的值組成的元組,轉換為共用型別,或是在無法促成時擲回例外。促成的最常見使用案例是將數值引數轉換為共用型別

julia> promote(1, 2.5)
(1.0, 2.5)

julia> promote(1, 2.5, 3)
(1.0, 2.5, 3.0)

julia> promote(2, 3//4)
(2//1, 3//4)

julia> promote(1, 2.5, 3, 3//4)
(1.0, 2.5, 3.0, 0.75)

julia> promote(1.5, im)
(1.5 + 0.0im, 0.0 + 1.0im)

julia> promote(1 + 2im, 3//4)
(1//1 + 2//1*im, 3//4 + 0//1*im)

浮點值會提升為浮點引數類型中最大的類型。整數值會提升為整數引數類型中最大的類型。如果類型大小相同但符號不同,則會選擇無符號類型。整數和浮點值的混合會提升為足夠容納所有值的浮點類型。與有理數混合的整數會提升為有理數。與浮點數混合的有理數會提升為浮點數。與實數混合的複數值會提升為適當類型的複數值。

這就是使用提升的所有內容。其餘的只是靈活應用的問題,最典型的「靈活」應用是為數值運算(例如算術運算子 +-*/)定義萬用方法。以下是 promotion.jl 中提供的一些萬用方法定義

+(x::Number, y::Number) = +(promote(x,y)...)
-(x::Number, y::Number) = -(promote(x,y)...)
*(x::Number, y::Number) = *(promote(x,y)...)
/(x::Number, y::Number) = /(promote(x,y)...)

這些方法定義表示在沒有更具體的規則來加、減、乘和除成對數值時,將值提升為常見類型,然後再試一次。這就是全部內容:在其他任何地方都不必擔心將數值提升為算術運算的常見數值類型,因為它會自動發生。在 promotion.jl 中為許多其他算術和數學函數定義了萬用提升方法,但除此之外,Julia Base 中幾乎不需要呼叫 promotepromote 最常見的用法出現在外部建構函數方法中,為方便起見提供,允許使用混合類型進行建構函數呼叫,以委派給欄位提升為適當常見類型的內部類型。例如,請回想 rational.jl 提供以下外部建構函數方法

Rational(n::Integer, d::Integer) = Rational(promote(n,d)...)

這允許像下列呼叫運作

julia> x = Rational(Int8(15),Int32(-5))
-3//1

julia> typeof(x)
Rational{Int32}

對於大多數使用者定義的類型,建議要求程式設計人員明確提供預期的類型給建構函數,但有時,特別是對於數值問題,自動執行提升會很方便。

定義促銷規則

雖然原則上,你可以直接為 promote 函數定義方法,但這會需要為所有可能的參數類型排列組合定義許多重複的定義。因此,promote 的行為是根據一個稱為 promote_rule 的輔助函數定義的,你可以為其提供方法。promote_rule 函數會接收一對類型物件,並傳回另一個類型物件,讓參數類型的執行個體可以提升至傳回的類型。因此,透過定義規則

promote_rule(::Type{Float64}, ::Type{Float32}) = Float64

你宣告當 64 位元和 32 位元浮點值一起提升時,它們應該提升至 64 位元浮點值。提升類型不需要是其中一個參數類型。例如,下列提升規則都出現在 Julia Base 中

promote_rule(::Type{BigInt}, ::Type{Float64}) = BigFloat
promote_rule(::Type{BigInt}, ::Type{Int8}) = BigInt

在後面的情況中,結果類型是 BigInt,因為 BigInt 是唯一夠大的類型,可以容納任意精確度整數運算的整數。另外請注意,你不需要定義 promote_rule(::Type{A}, ::Type{B})promote_rule(::Type{B}, ::Type{A}) – 對稱性是由 promote_rule 在提升過程中使用的途徑暗示的。

promote_rule 函數用作定義稱為 promote_type 的第二個函數的建構區塊,該函數會在給定任意數量的類型物件時,傳回那些值(作為 promote 的參數)應該提升至的共用類型。因此,如果你想在沒有實際值的情況下,知道特定類型值的集合會提升至哪種類型,你可以使用 promote_type

julia> promote_type(Int8, Int64)
Int64

請注意,我們不會直接重載 promote_type:我們改為重載 promote_rulepromote_type 使用 promote_rule,並新增對稱性。直接重載它可能會導致混淆錯誤。我們重載 promote_rule 以定義如何提升項目,並使用 promote_type 來查詢。

在內部,promote_type 會在 promote 內部使用,以決定提升時應將哪些型別引數值轉換為。好奇的讀者可以閱讀 promotion.jl 中的程式碼,它在約 35 行中定義了完整的提升機制。

個案研究:有理數提升

最後,我們完成 Julia 有理數型別的持續個案研究,它透過下列提升規則,相對複雜地使用提升機制

promote_rule(::Type{Rational{T}}, ::Type{S}) where {T<:Integer,S<:Integer} = Rational{promote_type(T,S)}
promote_rule(::Type{Rational{T}}, ::Type{Rational{S}}) where {T<:Integer,S<:Integer} = Rational{promote_type(T,S)}
promote_rule(::Type{Rational{T}}, ::Type{S}) where {T<:Integer,S<:AbstractFloat} = promote_type(T,S)

第一個規則表示,將有理數與任何其他整數型別提升,會提升為有理數型別,其分子/分母型別為其分子/分母型別與其他整數型別提升的結果。第二個規則將相同的邏輯套用於兩種不同的有理數型別,產生分子/分母型別各自提升後的有理數。第三個也是最後一個規則規定,將有理數與浮點數提升,會產生與將分子/分母型別與浮點數提升相同的型別。

這少數幾個提升規則,加上型別建構函式和數字的預設 convert 方法,就足以讓有理數與 Julia 的所有其他數字型別(整數、浮點數和複數)完全自然地交互作用。透過以相同的方式提供適當的轉換方法和提升規則,任何使用者定義的數字型別都可以與 Julia 的預定義數字一樣自然地交互作用。