Spec-Zone.ru › Julia 1.5

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

Конструкторы [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)

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

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

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

  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, ::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, довольно строги, сделать их поведение более гибким и разумным довольно легко. Более того, поскольку конструкторы могут использовать всю мощь системы типов, методов и множественного диспетчера, определение сложного поведения, как правило, довольно просто.

Случайное исследование: Рациональные числа

Возможно, лучший способ связать все эти части воедино — представить реальный пример параметрического составного типа и его методов конструктора. С этой целью мы реализуем свой собственный тип рациональных чисел 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:4

Этот конструктор будет вызываться синтаксисом 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.5.3/manual/constructors/

Spec-Zone.ru

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