Конструкторы
Конструкторы [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) ## 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) вызывает 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, а затем делегирует построение общему конструктору для случая, когда оба аргумента являются Float64. С этим определением метода, то, что ранее было MethodError, теперь успешно создает точку типа Point{Float64}:
julia> Point(1,2.5)
Point{Float64}(1.0, 2.5)
julia> typeof(ans)
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 at none:1
Для более общего способа сделать все такие вызовы осмысленными, см. Преобразование и продвижение. Рискуя испортить интригу, мы можем раскрыть здесь, что всё, что нужно, это следующее внешнее определение метода, чтобы все вызовы общего конструктора 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, довольно строгие, их поведение можно довольно легко сделать более гибким и осмысленным. Более того, поскольку конструкторы могут использовать всю мощь системы типов, методы и множественную отправку, определение сложного поведения обычно довольно просто.
Случай изучения: 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
g = gcd(den, num)
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. Поскольку gcd возвращает наибольший общий делитель своих аргументов со знаком, совпадающим со знаком первого аргумента (den здесь), после этого деления новое значение den гарантированно будет неотрицательным. Поскольку это единственный внутренний конструктор для OurRational, мы можем быть уверены, что объекты OurRational всегда строятся в этой нормализованной форме.
OurRational также предоставляет несколько внешних методов конструктора для удобства. Первый — это «стандартный» общий конструктор, который выводит параметр типа T из типа числителя и знаменателя, когда они имеют одинаковый тип. Второй применяется, когда заданные значения числителя и знаменателя имеют разные типы: он преобразует их в общий тип и затем делегирует построение внешнему конструктору для аргументов одного типа. Третий внешний конструктор преобразует целые значения в рациональные, передавая значение 1 в качестве знаменателя.
После определений внешних конструкторов, мы определили ряд методов для оператора ⊘, который предоставляет синтаксис для записи рациональных чисел (например, 1 ⊘ 2). Тип Julia Rational использует оператор // для этой цели. До этих определений, ⊘ является полностью неопределенным оператором, имеющим только синтаксис, но не имеющим смысла. После этого он ведет себя так, как описано в Рациональные числа — все его поведение определено в этих нескольких строках. Первое и самое базовое определение просто заставляет 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}
false
Таким образом, хотя оператор ⊘ обычно возвращает экземпляр 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(::Array{Int32,1}, ::Int32)
Closest candidates are:
SummedArray(::Array{T,1}) where T at none:5
Этот конструктор будет вызван синтаксисом SummedArray(a). Синтаксис new{T,S} позволяет указать параметры для конструируемого типа, т. е. этот вызов вернёт SummedArray{T,S}. new{T,S} может использоваться в любом определении конструктора, но для удобства параметры для new{} автоматически выводятся из конструируемого типа, когда это возможно.
- 1Номенклатура: хотя термин «конструктор» обычно относится к всей функции, которая строит объекты типа, принято немного злоупотреблять терминологией и ссылаться на конкретные методы конструктора как на «конструкторы». В таких ситуациях обычно из контекста ясно, что термин используется для обозначения «метода конструктора», а не «функции конструктора», особенно когда он часто используется в значении выделения конкретного метода конструктора из всех остальных.
© 2009–2020 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.4.2/manual/constructors/