Spec-Zone.ru › Julia 1.11

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

Конструкторы [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) и неизменяемых структур других простых типов данных (см. также: 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 до его инициализации, немедленно будет выброшена ошибка.

Параметрические конструкторы

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

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

Для более общего способа обработки всех таких вызовов разумным образом, см. Преобразование и продвижение. Рискуя испортить интригу, мы можем раскрыть здесь, что всё, что нужно, — это следующее определение внешнего метода, чтобы все вызовы общего конструктора 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 использует оператор // для этой цели. До этих определений, ⊘ — это полностью неопределённый оператор с только синтаксисом и без смысла. После этого он ведёт себя так, как описано в Рациональные числа — всё его поведение определено в этих нескольких строках. Обратите внимание, что инфиксное использование ⊘ работает, потому что в 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.

Только внешние конструкторы

Как мы видели, типичный параметризованный тип имеет внутренние конструкторы, которые вызываются, когда параметры типа известны; например, они применяются к 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{} автоматически вычисляются из создаваемого типа, когда это возможно.

Конструкторы — это просто вызываемые объекты

Объект любого типа можно сделать вызываемым путём определения метода. Это относится и к типам, то есть объектам типа 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)
  • 1Номенклатура: хотя термин «конструктор» обычно относится к всей функции, которая создаёт объекты типа, часто допускается небольшое злоупотребление терминологией, и конкретные методы конструктора могут называться «конструкторами». В таких ситуациях из контекста обычно ясно, что этот термин используется для обозначения «метода конструктора», а не «функции конструктора», особенно поскольку он часто используется в том смысле, чтобы выделить конкретный метод конструктора из всех остальных.

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

Spec-Zone.ru

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