Документация Engee

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

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


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