Spec-Zone.ru › Julia 1.1

Конструкторы

Конструкторы [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 для создания объектов охватывает все эти случаи и многое другое.

[1]

Номенклатура: хотя термин «конструктор» обычно относится ко всей функции, которая создаёт объекты типа, принято немного злоупотреблять терминологией и ссылаться на конкретные методы-конструкторы как «конструкторы». В таких ситуациях из контекста обычно ясно, что этот термин означает «метод-конструктор», а не «функция-конструктор», особенно когда он используется в значении выделения определенного метода конструктора из всех остальных.

Методы внешних конструкторов

Конструктор — это такая же функция в 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)

Здесь метод-конструктор без аргументов вызывает метод-конструктор с одним аргументом, который в свою очередь вызывает автоматически предоставляемый метод-конструктор с двумя аргументами. По причинам, которые станут ясными очень скоро, дополнительные методы-конструкторы, объявленные как обычные методы, называются методами внешних конструкторов. Методы внешних конструкторов могут создать новый экземпляр только путём вызова другого метода-конструктора, например, автоматически предоставленных стандартных.

Методы внутренних конструкторов

Хотя методы внешних конструкторов успешно решают проблему предоставления дополнительных удобных методов для создания объектов, они не решают другие две проблемы, упомянутые во введении к этой главе: обеспечение инвариантов и возможность создания самоссылочных объектов. Для этих задач нужны методы внутренних конструкторов. Метод внутреннего конструктора похож на метод внешнего конструктора, за исключением двух различий:

  1. Он объявляется внутри блока объявления типа, а не вне него, как обычные методы.
  2. Он имеет доступ к специальной локально существующей функции под названием 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<:Real, ::T<:Real) 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<:Real, !Matched::T<:Real) 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{} автоматически выводятся из строящегося типа, когда это возможно.

© 2009–2019 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.1.1/manual/constructors/

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API