构造函数
构造函数[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
字段的有效值提供时,如何构造第一个实例?唯一的解决方案是允许创建 obj
字段未赋值的不完整初始化的 SelfReferential
实例,并将该不完整实例用作另一个实例(例如自身)的 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}
都是不同的构造函数。事实上,对于每个类型T
,Point{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)
这样的调用(参数类型不同)会导致“无方法”错误。
假设我们希望通过将整数值1
提升为浮点值1.0
来使构造函数调用Point(1,2.5)
能够正常工作。实现此目的最简单的方法是定义以下附加的外部构造函数方法:
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术语:虽然术语“构造函数”通常指的是构造类型对象的整个函数,但略微滥用术语并将其特定构造函数方法称为“构造函数”是很常见的。在这种情况下,从上下文中通常可以清楚地看出该术语指的是“构造函数方法”而不是“构造函数函数”,尤其是在将其用于从所有其他方法中挑选出特定构造函数方法的意义上时。