Constructors
Конструкторы [1] — это функции, которые создают новые объекты — конкретно, экземпляры Composite Types. В Julia объекты типов также служат в качестве функций-конструкторов: они создают новые экземпляры самих себя, когда применяются к кортежу аргументов в качестве функции. Это уже было упомянуто кратко, когда были введены составные типы. Например:
julia> struct Foo
bar
baz
end
julia> foo = Foo(1, 2)
Foo(1, 2)
julia> foo.bar
1
julia> foo.baz
2
Для многих типов создание новых объектов путем связывания их значений полей вместе — это все, что когда-либо нужно для создания экземпляров. Однако в некоторых случаях требуется больше функциональности при создании составных объектов. Иногда необходимо обеспечить инварианты, либо проверяя аргументы, либо преобразуя их. Recursive data structures, особенно те, которые могут быть самоссылочными, часто не могут быть сконструированы чисто без предварительного создания в неполном состоянии, а затем программно изменены, чтобы стать целыми, как отдельный шаг от создания объекта. Иногда просто удобно иметь возможность конструировать объекты с меньшим или другим количеством параметров, чем у них есть поля. Система Julia для создания объектов охватывает все эти случаи и многое другое.
Outer Constructor Methods
Конструктор похож на любую другую функцию в 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)
Здесь метод конструктора без аргументов вызывает метод конструктора с одним аргументом, который, в свою очередь, вызывает автоматически предоставленный метод конструктора с двумя аргументами. По причинам, которые станут ясны очень скоро, дополнительные методы конструктора, объявленные как обычные методы, называются внешними методами конструктора. Внешние методы конструктора могут создавать новый экземпляр только путем вызова другого метода конструктора, такого как автоматически предоставленные методы по умолчанию.
Inner Constructor Methods
Хотя внешние методы конструктора успешно решают проблему предоставления дополнительных удобных методов для создания объектов, они не решают другие два случая использования, упомянутые во введении к этой главе: обеспечение инвариантов и возможность создания самоссылочных объектов. Для этих проблем нужны внутренние методы конструктора. Внутренний метод конструктора похож на внешний метод конструктора, за исключением двух отличий:
- Он объявляется внутри блока объявления типа, а не снаружи, как обычные методы.
- У него есть доступ к специальной локально существующей функции под названием
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)
Хорошей практикой является предоставление как можно меньшего количества внутренних конструкторов: только тех, которые принимают все аргументы явно и обеспечивают необходимую проверку ошибок и преобразования. Дополнительные конструкторы удобства, предоставляющие значения по умолчанию или вспомогательные преобразования, должны быть предоставлены в качестве внешних конструкторов, которые вызывают внутренние конструкторы для выполнения основной работы. Это разделение обычно довольно естественно.
Incomplete Initialization
Последняя проблема, которая все еще не была решена, - это создание самоссылочных объектов или, более общим образом, рекурсивных структур данных. Поскольку основная трудность может быть не сразу очевидна, давайте кратко объясним это. Рассмотрим следующее объявление рекурсивного типа:
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
или любой из его вызываемых функций попытается получить доступ к полю data
объекта Lazy
до его инициализации, ошибка будет выброшена немедленно.
Parametric Constructors
Parametric types add a few wrinkles to the constructor story. Recall from Parametric Types that, by default, instances of parametric composite types can be constructed either with explicitly given type parameters or with type parameters implied by the types of the arguments given to the constructor. Here are some examples:
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)
The type `Point` exists, but no method is defined for this combination of argument types when trying to construct it.
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)
вызывает InexactError
при преобразовании 2.5
в Int64
. Когда тип подразумевается аргументами вызова конструктора, как в Point(1,2)
, тогда типы аргументов должны совпадать – в противном случае T
не может быть определен – но любая пара действительных аргументов с совпадающим типом может быть передана обобщенному конструктору Point
.
Что на самом деле происходит здесь, так это то, что Point
, Point{Float64}
и Point{Int64}
— это все разные функции-конструкторы. На самом деле, Point{T}
— это отдельная функция-конструктор для каждого типа T
. Без каких-либо явно предоставленных внутренних конструкторов, объявление составного типа Point{T<:Real}
автоматически предоставляет внутренний конструктор Point{T}
для каждого возможного типа T<:Real
, который ведет себя так же, как и непараметризованные стандартные внутренние конструкторы. Он также предоставляет один общий внешний конструктор 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)
вызовет определение Point{T}(x,y)
внутри блока struct
. Объявление внешнего конструктора, с другой стороны, определяет метод для общего конструктора 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
, а затем делегирует создание общему конструктору для случая, когда оба аргумента равны 4d61726b646f776e2e436f64652822222c2022466c6f617436342229_40726566
. С этим определением метода то, что ранее было 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)
The type `Point` exists, but no method is defined for this combination of argument types when trying to construct it.
Closest candidates are:
Point(::T, !Matched::T) where T<:Real
@ Main none:1
Point(!Matched::Int64, !Matched::Float64)
@ Main none:1
Stacktrace:
[...]
Для более общего способа сделать все такие вызовы работающими разумно, смотрите Conversion and Promotion. Рискуя испортить интригу, мы можем раскрыть здесь, что все, что нужно, это следующее определение внешнего метода, чтобы все вызовы общего конструктора 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, довольно строгие, вполне возможно заставить их вести себя более расслабленно, но разумно, довольно легко. Более того, поскольку конструкторы могут использовать всю мощь системы типов, методов и множественной диспетчеризации, определение сложного поведения обычно довольно просто.
Case Study: Rational
Возможно, лучший способ связать все эти элементы вместе — представить реальный пример параметрического составного типа и его методов конструктора. С этой целью мы реализуем свой собственный тип рационального числа 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
). Тип Rational
в Julia использует оператор //
для этой цели. До этих определений ⊘
является полностью неопределенным оператором с только синтаксисом и без смысла. После этого он ведет себя так, как описано в Rational Numbers – его все поведение определяется в этих нескольких строках. Обратите внимание, что инфиксное использование ⊘
работает, потому что в Julia есть набор символов, которые признаются инфиксными операторами. Первое и самое основное определение просто делает так, что a ⊘ b
создает OurRational
, применяя конструктор OurRational
к a
и b
, когда они являются целыми числами. Когда один из операндов ⊘
уже является рациональным числом, мы создаем новое рациональное число для полученной дроби немного иначе; это поведение на самом деле идентично делению рационального числа на целое. Наконец, применение ⊘
к комплексным целым значениям создает экземпляр 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.
Outer-only constructors
Как мы видели, типичный параметрический тип имеет внутренние конструкторы, которые вызываются, когда известны параметры типа; например, они применимы к Point{Int}
, но не к Point
. Опционально можно добавить внешние конструкторы, которые автоматически определяют параметры типа, например, создавая Point{Int}
из вызова Point(1,2)
. Внешние конструкторы вызывают внутренние конструкторы, чтобы фактически создавать экземпляры. Однако в некоторых случаях было бы предпочтительнее не предоставлять внутренние конструкторы, чтобы конкретные параметры типа не могли быть запрошены вручную.
Например, скажем, что мы определяем тип, который хранит вектор вместе с точным представлением его суммы:
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)
The type `SummedArray` exists, but no method is defined for this combination of argument types when trying to construct it.
Closest candidates are:
SummedArray(::Vector{T}) where T
@ Main none:4
Stacktrace:
[...]
Этот конструктор будет вызван с помощью синтаксиса SummedArray(a)
. Синтаксис new{T,S}
позволяет указывать параметры для создаваемого типа, т.е. этот вызов вернет SummedArray{T,S}
. new{T,S}
может использоваться в любом определении конструктора, но для удобства параметры для new{}
автоматически выводятся из создаваемого типа, когда это возможно.
Constructors are just callable objects
Объект любого типа может быть made callable, определив метод. Это включает типы, т.е. объекты типа Type
; и конструкторы могут, на самом деле, рассматриваться как просто вызываемые объекты типа. Например, существует множество методов, определенных для Bool
и различных суперт типов:
julia> methods(Bool)
# 10 methods for type constructor:
[1] Bool(x::BigFloat)
@ Base.MPFR mpfr.jl:393
[2] Bool(x::Float16)
@ Base float.jl:338
[3] Bool(x::Rational)
@ Base rational.jl:138
[4] Bool(x::Real)
@ Base float.jl:233
[5] (dt::Type{<:Integer})(ip::Sockets.IPAddr)
@ Sockets ~/tmp/jl/jl/julia-nightly-assert/share/julia/stdlib/v1.11/Sockets/src/IPAddr.jl:11
[6] (::Type{T})(x::Enum{T2}) where {T<:Integer, T2<:Integer}
@ Base.Enums Enums.jl:19
[7] (::Type{T})(z::Complex) where T<:Real
@ Base complex.jl:44
[8] (::Type{T})(x::Base.TwicePrecision) where T<:Number
@ Base twiceprecision.jl:265
[9] (::Type{T})(x::T) where T<:Number
@ boot.jl:894
[10] (::Type{T})(x::AbstractChar) where T<:Union{AbstractChar, Number}
@ char.jl:50
Обычный синтаксис конструктора точно эквивалентен синтаксису объектоподобной функции, поэтому попытка определить метод с каждым из синтаксисов приведет к тому, что первый метод будет перезаписан следующим:
julia> struct S
f::Int
end
julia> S() = S(7)
S
julia> (::Type{S})() = S(8) # overwrites the previous constructor method
julia> S()
S(8)
- 1Nomenclature: while the term "constructor" generally refers to the entire function which constructs objects of a type, it is common to abuse terminology slightly and refer to specific constructor methods as "constructors". In such situations, it is generally clear from the context that the term is used to mean "constructor method" rather than "constructor function", especially as it is often used in the sense of singling out a particular method of the constructor from all of the others.