建構函式
建構函式 [1] 是建立新物件的函式,特別是 複合類型 的實例。在 Julia 中,類型物件也用作建構函式:當將其作為函式套用至引數元組時,它們會建立自己的新實例。當介紹複合類型時,已經簡短地提到過這一點。例如
julia> struct Foo
bar
baz
end
julia> foo = Foo(1, 2)
Foo(1, 2)
julia> foo.bar
1
julia> foo.baz
2
對於許多類型,透過繫結其欄位值來形成新物件,是建立實例所需要的一切。然而,在某些情況下,在建立複合物件時需要更多功能。有時必須強制不變異數,透過檢查引數或轉換它們。 遞迴資料結構,特別是那些可能是自參照的,通常無法在未先建立不完整狀態,然後透過程式設計加以變更以使其完整,作為物件建立的獨立步驟,而乾淨地建立。有時,僅能建立具有比其欄位更少或不同的參數類型的物件,這很方便。Julia 的物件建立系統可處理所有這些情況,甚至更多。
外部建構函式方法
建構函式就像 Julia 中的任何其他函式,其整體行為由其方法的綜合行為定義。因此,您可以透過定義新方法,將功能新增至建構函式。例如,假設您想要新增一個建構函式方法,用於只接受一個引數的 Foo
物件,並將給定的值用於 bar
和 baz
欄位。這很簡單
julia> Foo(x) = Foo(x,x)
Foo
julia> Foo(1)
Foo(1, 1)
您也可以新增一個零引數 Foo
建構函式方法,為 bar
和 baz
欄位提供預設值
julia> Foo() = Foo(0)
Foo
julia> Foo()
Foo(0, 0)
在這裡,零參數建構函式方法會呼叫單參數建構函式方法,而單參數建構函式方法則會呼叫自動提供的兩個參數建構函式方法。由於原因將在非常短的時間內變得清楚,因此像這樣宣告為一般方法的其他建構函式方法稱為外部建構函式方法。外部建構函式方法只能透過呼叫另一個建構函式方法(例如自動提供的預設方法)來建立新的執行個體。
內部建構函式方法
雖然外部建構函式方法成功解決了提供建立物件的其他便利方法的問題,但它們無法解決本章引言中提到的其他兩個用例:強制不變式和允許建立自參照物件。對於這些問題,需要內部建構函式方法。內部建構函式方法就像外部建構函式方法,除了兩個差異
- 它宣告在類型宣告區塊內,而不是像一般方法一樣宣告在區塊外。
- 它可以存取稱為
new
的特殊局部存在函式,該函式會建立區塊類型的物件。
例如,假設有人想要宣告一個包含一對實數的類型,但受限於第一個數字不大於第二個數字。可以像這樣宣告
julia> struct OrderedPair
x::Real
y::Real
OrderedPair(x,y) = x > y ? error("out of order") : new(x,y)
end
現在,OrderedPair
物件只能建構為 x <= y
julia> OrderedPair(1, 2)
OrderedPair(1, 2)
julia> OrderedPair(2,1)
ERROR: out of order
Stacktrace:
[1] error at ./error.jl:33 [inlined]
[2] OrderedPair(::Int64, ::Int64) at ./none:4
[3] top-level scope
如果類型宣告為 mutable
,你可以直接變更欄位值來違反此不變性。當然,未經邀請就變更物件的內部是不好的做法。你(或其他人)也可以在稍後提供其他外部建構函式方法,但一旦類型宣告後,就無法再新增更多內部建構函式方法。由於外部建構函式方法只能透過呼叫其他建構函式方法來建立物件,因此最終必須呼叫一些內部建構函式才能建立物件。這可確保宣告類型的所有物件都必須透過呼叫類型提供的其中一個內部建構函式方法才能建立,從而強制執行類型不變性。
如果定義任何內部建構函式方法,則不會提供預設建構函式方法:假設你已提供所有需要的內部建構函式。預設建構函式等於撰寫你自己的內部建構函式方法,將物件的所有欄位當作參數(如果對應欄位有類型,則受限於正確的類型),並將它們傳遞給 new
,傳回結果物件
julia> struct Foo
bar
baz
Foo(bar,baz) = new(bar,baz)
end
此宣告的效果與先前定義的 Foo
類型相同,但沒有明確的內部建構函式方法。下列兩個類型相同,一個有預設建構函式,另一個有明確的建構函式
julia> struct T1
x::Int64
end
julia> struct T2
x::Int64
T2(x) = new(x)
end
julia> T1(1)
T1(1)
julia> T2(1)
T2(1)
julia> T1(1.0)
T1(1)
julia> T2(1.0)
T2(1)
建議提供盡可能少的內部建構函式方法:只有那些明確採用所有引數並強制執行必要的錯誤檢查和轉換的方法。提供預設值或輔助轉換的額外便利建構函式方法應提供為呼叫內部建構函式來執行繁重工作的外部建構函式。此區分通常很自然。
不完整初始化
尚未解決的最後一個問題是建立自參考物件,或更一般地說,遞迴資料結構。由於根本的困難可能並不明顯,讓我們簡要地說明一下。考慮以下遞迴類型宣告
julia> mutable struct SelfReferential
obj::SelfReferential
end
此類型可能看起來相當無害,直到有人考慮如何建立它的執行個體。如果 a
是 SelfReferential
的執行個體,則可以透過呼叫建立第二個執行個體
julia> b = SelfReferential(a)
但是,當沒有執行個體可提供作為其 obj
欄位的有效值時,如何建立第一個執行個體?唯一的解決方案是允許建立未完全初始化的 SelfReferential
執行個體,其 obj
欄位未指定,並將該未完成的執行個體用作另一個執行個體的 obj
欄位的有效值,例如本身。
為了允許建立未完全初始化的物件,Julia 允許呼叫 new
函數,其引數少於類型所擁有的欄位數,傳回未指定欄位未初始化的物件。然後,內部建構函數方法可以使用未完成的物件,在傳回之前完成其初始化。例如,以下是定義 SelfReferential
類型的另一種嘗試,這次使用零引數內部建構函數,傳回 obj
欄位指向自身的執行個體
julia> mutable struct SelfReferential
obj::SelfReferential
SelfReferential() = (x = new(); x.obj = x)
end
我們可以驗證此建構函數是否運作,以及是否建立實際上是自參考的物件
julia> x = SelfReferential();
julia> x === x
true
julia> x === x.obj
true
julia> x === x.obj.obj
true
雖然從內部建構函數傳回完全初始化的物件通常是個好主意,但可以傳回未完全初始化的物件
julia> mutable struct Incomplete
data
Incomplete() = new()
end
julia> z = Incomplete();
雖然允許建立具有未初始化欄位的物件,但任何存取未初始化參考的行為都是立即錯誤
julia> z.data
ERROR: UndefRefError: access to undefined reference
這避免了持續檢查 null
值的需要。但是,並非所有物件欄位都是參考。Julia 將某些類型視為「純資料」,表示其所有資料都是自含的,且不會參考其他物件。純資料類型包含原生的類型(例如 Int
)和由其他純資料類型組成的不可變結構(另請參閱:isbits
、isbitstype
)。純資料類型的初始內容未定義
julia> struct HasPlain
n::Int
HasPlain() = new()
end
julia> HasPlain()
HasPlain(438103441441)
純資料類型的陣列表現出相同的行為。
您可以從內部建構函式傳遞不完整的物件給其他函式,以委派其完成。
julia> mutable struct Lazy
data
Lazy(v) = complete_me(new(), v)
end
與建構函式傳回的不完整物件相同,如果 complete_me
或其任何被呼叫者在 Lazy
物件初始化之前嘗試存取其 data
欄位,將立即擲回錯誤。
參數化建構函式
參數化類型為建構函式故事增添了一些變化。請回想 參數化類型,預設上,參數化複合類型的執行個體可以使用明確給定的類型參數或由傳給建構函式的引數類型暗示的類型參數來建構。以下是一些範例
julia> struct Point{T<:Real}
x::T
y::T
end
julia> Point(1,2) ## implicit T ##
Point{Int64}(1, 2)
julia> Point(1.0,2.5) ## implicit T ##
Point{Float64}(1.0, 2.5)
julia> Point(1,2.5) ## implicit T ##
ERROR: MethodError: no method matching Point(::Int64, ::Float64)
Closest candidates are:
Point(::T, ::T) where T<:Real at none:2
julia> Point{Int64}(1, 2) ## explicit T ##
Point{Int64}(1, 2)
julia> Point{Int64}(1.0,2.5) ## explicit T ##
ERROR: InexactError: Int64(2.5)
Stacktrace:
[...]
julia> Point{Float64}(1.0, 2.5) ## explicit T ##
Point{Float64}(1.0, 2.5)
julia> Point{Float64}(1,2) ## explicit T ##
Point{Float64}(1.0, 2.0)
正如您所見,對於具有明確類型參數的建構函數呼叫,參數會轉換為隱含的欄位類型:Point{Int64}(1,2)
可行,但 Point{Int64}(1.0,2.5)
在將 2.5
轉換為 Int64
時會引發 InexactError
。當類型是由建構函數呼叫的參數隱含,例如 Point(1,2)
,則參數的類型必須一致,否則無法確定 T
,但任何一對具有匹配類型的實參數都可以傳遞給泛型 Point
建構函數。
這裡真正發生的是 Point
、Point{Float64}
和 Point{Int64}
都是不同的建構函數。事實上,Point{T}
是每個類型 T
的不同建構函數。在沒有明確提供的內部建構函數的情況下,複合類型 Point{T<:Real}
的宣告會自動為每個可能的類型 T<:Real
提供一個內部建構函數 Point{T}
,其行為就像非參數預設內部建構函數一樣。它還提供一個單一的通用外部 Point
建構函數,它接受成對的實參數,這些參數必須是同類型的。這種自動提供建構函數的方式等同於以下明確宣告
julia> struct Point{T<:Real}
x::T
y::T
Point{T}(x,y) where {T<:Real} = new(x,y)
end
julia> Point(x::T, y::T) where {T<:Real} = Point{T}(x,y);
請注意每個定義看起來都像是它所處理的建構函式呼叫的形式。呼叫 Point{Int64}(1,2)
將會在 struct
區塊內呼叫定義 Point{T}(x,y)
。另一方面,外部建構函式宣告定義一般 Point
建構函式的方法,它只適用於相同實數類型的一對值。這個宣告讓建構函式呼叫沒有明確的類型參數,例如 Point(1,2)
和 Point(1.0,2.5)
,可以運作。由於方法宣告限制引數必須是相同類型,因此呼叫例如 Point(1,2.5)
,其引數為不同類型,會產生「沒有方法」的錯誤。
假設我們想讓建構函式呼叫 Point(1,2.5)
透過「提升」整數值 1
至浮點值 1.0
來運作。達成此目的最簡單的方式是定義下列額外的外部建構函式方法
julia> Point(x::Int64, y::Float64) = Point(convert(Float64,x),y);
這個方法使用 convert
函式將 x
明確轉換為 Float64
,然後委派建構至一般建構函式,其中兩個引數都是 Float64
。透過這個方法定義,先前是 MethodError
的現在成功建立類型為 Point{Float64}
的點
julia> p = Point(1,2.5)
Point{Float64}(1.0, 2.5)
julia> typeof(p)
Point{Float64}
然而,其他類似的呼叫仍然無法運作
julia> Point(1.5,2)
ERROR: MethodError: no method matching Point(::Float64, ::Int64)
Closest candidates are:
Point(::T, !Matched::T) where T<:Real
@ Main none:1
Stacktrace:
[...]
如需更通用的方式讓所有此類呼叫都能合理運作,請參閱 轉換與提升。冒著破壞懸念的風險,我們可以在此揭露,只要下列外部方法定義,就能讓所有呼叫至一般 Point
建構函式都能如預期般運作
julia> Point(x::Real, y::Real) = Point(promote(x,y)...);
promote
函式將其所有引數轉換為共用類型,在此情況下為 Float64
。透過這個方法定義,Point
建構函式提升其引數的方式與數值運算子(例如 +
)相同,而且對所有類型的實數都有效
julia> Point(1.5,2)
Point{Float64}(1.5, 2.0)
julia> Point(1,1//2)
Point{Rational{Int64}}(1//1, 1//2)
julia> Point(1.0,1//2)
Point{Float64}(1.0, 0.5)
因此,儘管 Julia 中預設提供的隱式類型參數建構函式相當嚴格,但可以很輕鬆地讓它們以更寬鬆但合理的方式運作。此外,由於建構函式可以利用類型系統、方法和多重調用的所有功能,定義精密的行為通常相當簡單。
案例研究:有理數
或許將所有這些部分串聯在一起的最佳方式,就是提供一個參數化複合類型及其建構函式方法的實際範例。為此,我們實作自己的有理數類型 OurRational
,類似於 Julia 內建的 Rational
類型,定義於 rational.jl
julia> struct OurRational{T<:Integer} <: Real
num::T
den::T
function OurRational{T}(num::T, den::T) where T<:Integer
if num == 0 && den == 0
error("invalid rational: 0//0")
end
num = flipsign(num, den)
den = flipsign(den, den)
g = gcd(num, den)
num = div(num, g)
den = div(den, g)
new(num, den)
end
end
julia> OurRational(n::T, d::T) where {T<:Integer} = OurRational{T}(n,d)
OurRational
julia> OurRational(n::Integer, d::Integer) = OurRational(promote(n,d)...)
OurRational
julia> OurRational(n::Integer) = OurRational(n,one(n))
OurRational
julia> ⊘(n::Integer, d::Integer) = OurRational(n,d)
⊘ (generic function with 1 method)
julia> ⊘(x::OurRational, y::Integer) = x.num ⊘ (x.den*y)
⊘ (generic function with 2 methods)
julia> ⊘(x::Integer, y::OurRational) = (x*y.den) ⊘ y.num
⊘ (generic function with 3 methods)
julia> ⊘(x::Complex, y::Real) = complex(real(x) ⊘ y, imag(x) ⊘ y)
⊘ (generic function with 4 methods)
julia> ⊘(x::Real, y::Complex) = (x*y') ⊘ real(y*y')
⊘ (generic function with 5 methods)
julia> function ⊘(x::Complex, y::Complex)
xy = x*y'
yy = real(y*y')
complex(real(xy) ⊘ yy, imag(xy) ⊘ yy)
end
⊘ (generic function with 6 methods)
第一行 – struct OurRational{T<:Integer} <: Real
– 宣告 OurRational
採用一個整數類型的類型參數,本身則是一個實數類型。欄位宣告 num::T
和 den::T
表示儲存在 OurRational{T}
物件中的資料是一對 T
型別的整數,一個代表有理數值的分子,另一個代表其分母。
現在事情變得有趣了。OurRational
有單一的內部建構函式方法,用於檢查 num
和 den
都不為零,並確保每個有理數都以「最簡分數」建構,且分母為非負數。這可藉由在分母為負數時先將分子和分母的符號互換來達成。接著,兩者都除以它們的最大公因數(gcd
始終傳回非負數,不論其引數的符號為何)。由於這是 OurRational
唯一的內部建構函式,我們可以確定 OurRational
物件總是採用這種正規化形式建構。
OurRational
也提供多個外部建構函式方法以供方便使用。第一個是「標準」一般建構函式,它會從分子和分母的類型推斷類型參數 T
,前提是它們具有相同的類型。第二個適用於指定的分子和分母值具有不同類型的情況:它會將它們提升為共同類型,然後委派建構給具有相符類型的引數的外部建構函式。第三個外部建構函式會將整數值轉換為有理數,方法是提供 1
的值作為分母。
在外部建構函式定義後,我們定義了許多用於運算子 ⊘
的方法,它提供了一個語法來撰寫有理數(例如 1 ⊘ 2
)。Julia 的 Rational
類型使用運算子 //
來達成此目的。在這些定義之前,⊘
是完全未定義的運算子,只有語法而沒有意義。之後,它的行為就像 有理數 中所描述的一樣——它的所有行為都在這幾行中定義。第一個也是最基本的定義只是讓 a ⊘ b
透過將 OurRational
建構函式套用到 a
和 b
(當它們是整數時)來建構一個 OurRational
。當 ⊘
的其中一個運算元已經是有理數時,我們會以略微不同的方式為結果的比率建構一個新的有理數;此行為實際上與有理數除以整數相同。最後,將 ⊘
套用到複數整數值會建立一個 Complex{<:OurRational}
的實例——一個實部和虛部是有理數的複數
julia> z = (1 + 2im) ⊘ (1 - 2im);
julia> typeof(z)
Complex{OurRational{Int64}}
julia> typeof(z) <: Complex{<:OurRational}
true
因此,儘管運算子 ⊘
通常會傳回 OurRational
的實例,但如果它的任何一個引數都是複數整數,它將會傳回 Complex{<:OurRational}
的實例。有興趣的讀者應該考慮閱讀 rational.jl
的其餘部分:它很短、獨立,並實作了一個完整的 Julia 基本類型。
僅限外部的建構函式
正如我們所見,典型的參數化類型具有在已知類型參數時呼叫的內部建構函式;例如,它們套用於 Point{Int}
但不套用於 Point
。另外,可以新增自動決定類型參數的外部建構函式,例如從呼叫 Point(1,2)
建構一個 Point{Int}
。外部建構函式呼叫內部建構函式來實際建立實例。然而,在某些情況下,你可能不希望提供內部建構函式,以便無法手動要求特定的類型參數。
例如,假設我們定義一個類型來儲存一個向量,以及其總和的準確表示
julia> struct SummedArray{T<:Number,S<:Number}
data::Vector{T}
sum::S
end
julia> SummedArray(Int32[1; 2; 3], Int32(6))
SummedArray{Int32, Int32}(Int32[1, 2, 3], 6)
問題在於我們希望 S
是比 T
還要大的類型,以便我們可以用較少的資訊遺失來對許多元素求和。例如,當 T
是 Int32
時,我們希望 S
是 Int64
。因此,我們想要避免允許使用者建構 SummedArray{Int32,Int32}
類型的實例的介面。執行此操作的方法之一是僅為 SummedArray
提供建構函式,但在 struct
定義區塊內來抑制預設建構函式的產生
julia> struct SummedArray{T<:Number,S<:Number}
data::Vector{T}
sum::S
function SummedArray(a::Vector{T}) where T
S = widen(T)
new{T,S}(a, sum(S, a))
end
end
julia> SummedArray(Int32[1; 2; 3], Int32(6))
ERROR: MethodError: no method matching SummedArray(::Vector{Int32}, ::Int32)
Closest candidates are:
SummedArray(::Vector{T}) where T
@ Main none:4
Stacktrace:
[...]
此建構函式將由語法 SummedArray(a)
呼叫。語法 new{T,S}
允許指定要建構的類型的參數,亦即此呼叫將傳回 SummedArray{T,S}
。new{T,S}
可用於任何建構函式定義,但為方便起見,new{}
的參數會在可能的情況下自動從正在建構的類型中衍生。
- 1術語:雖然「建構函式」一詞通常是指建構某種類型的物件的整個函式,但略微濫用術語並將特定的建構函式方法稱為「建構函式」是很常見的。在這種情況下,通常從上下文中可以清楚看出,此術語用於表示「建構函式方法」而非「建構函式函式」,特別是因為它通常用於從所有其他方法中挑選建構函式的特定方法。