Конструкторы
Конструкторы [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
) и неизменяемые структуры, состоящие из других простых типов. Изначально содержимое простого типа данных не определено:
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
, немедленно произойдет ошибка.
Параметрические конструкторы
Параметрические типы добавляют ряд нюансов в схему работы конструкторов. Как вы помните из главы Параметрические типы, по умолчанию экземпляры параметрических составных типов можно создавать либо с явно заданными параметрами типов, либо с параметрами типов, которые выводятся из типов аргументов, переданных в конструктор. Вот ряд примеров.
julia> struct Point{T<:Real}
x::T
y::T
end
julia> Point(1,2) ## неявный тип T ##
Point{Int64}(1, 2)
julia> Point(1.0,2.5) ## неявный тип T ##
Point{Float64}(1.0, 2.5)
julia> Point(1,2.5) ## неявный тип 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) ## явный тип T ##
Point{Int64}(1, 2)
julia> Point{Int64}(1.0,2.5) ## явный тип T ##
ERROR: InexactError: Int64(2.5)
Stacktrace:
[...]
julia> Point{Float64}(1.0, 2.5) ## явный тип T ##
Point{Float64}(1.0, 2.5)
julia> Point{Float64}(1,2) ## явный тип 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}
внутренний конструктор 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
, а затем перелагает дальнейшую работу по созданию экземпляра на общий конструктор, предназначенный для случаев, когда оба аргумента имеют тип 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
использует с этой целью оператор //
. До введения этих определений ⊘
— совершенно неопределенный оператор, имеющий синтаксис, но не имеющий значения. После же их введения он начинает работать так, как описано в разделе Рациональные числа — все его поведение определено в этих нескольких строках. Первое и самое основное определение позволяет создать экземпляр OurRational
с помощью выражения a ⊘ b
. Для этого конструктор 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.
Полностью внешние конструкторы
Как вы уже знаете, стандартный параметрический тип имеет внутренние конструкторы, которые вызываются, когда известны параметры типов, то есть они применяются к 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)
Closest candidates are:
SummedArray(::Vector{T}) where T
@ Main none:4
Stacktrace:
[...]
Этот конструктор будет вызываться при использовании синтаксиса SummedArray(a)
. Синтаксис new{T,S}
позволяет указывать параметры для создаваемого типа, то есть этот вызов вернет SummedArray{T,S}
. new{T,S}
можно использовать в любом определении конструктора, но для удобства параметры new{}
по возможности автоматически наследуются от создаваемого типа.