Interfaces

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

Iteration

Существует два метода, которые всегда необходимы:

Required methodBrief description
iterate(iter)Returns either a tuple of the first item and initial state or nothing if empty
iterate(iter, state)Returns either a tuple of the next item and next state or nothing if no items remain

Существует несколько дополнительных методов, которые следует определить в некоторых обстоятельствах. Обратите внимание, что вы всегда должны определить хотя бы один из Base.IteratorSize(IterType) и length(iter), потому что стандартное определение Base.IteratorSize(IterType) — это Base.HasLength().

MethodWhen should this method be defined?Default definitionBrief description
Base.IteratorSize(IterType)If default is not appropriateBase.HasLength()One of Base.HasLength(), Base.HasShape{N}(), Base.IsInfinite(), or Base.SizeUnknown() as appropriate
length(iter)If Base.IteratorSize() returns Base.HasLength() or Base.HasShape{N}()(undefined)The number of items, if known
size(iter, [dim])If Base.IteratorSize() returns Base.HasShape{N}()(undefined)The number of items in each dimension, if known
Base.IteratorEltype(IterType)If default is not appropriateBase.HasEltype()Either Base.EltypeUnknown() or Base.HasEltype() as appropriate
eltype(IterType)If default is not appropriateAnyThe type of the first entry of the tuple returned by iterate()
Base.isdone(iter, [state])Must be defined if iterator is statefulmissingFast-path hint for iterator completion. If not defined for a stateful iterator then functions that check for done-ness, like isempty() and zip(), may mutate the iterator and cause buggy behaviour!

Последовательная итерация реализована функцией iterate. Вместо изменения объектов во время их итерации, итераторы Julia могут отслеживать состояние итерации вне объекта. Возвращаемое значение из функции iterate всегда является либо кортежем значения и состояния, либо nothing, если элементы больше не остались. Объект состояния будет передан обратно в функцию iterate на следующей итерации и, как правило, считается частной деталью реализации, относящейся к итерируемому объекту.

Любой объект, который определяет эту функцию, является итерируемым и может быть использован в many functions that rely upon iteration. Он также может быть использован напрямую в for цикле, поскольку синтаксис:

for item in iter   # or  "for item = iter"
    # body
end

переведено на:

next = iterate(iter)
while next !== nothing
    (item, state) = next
    # body
    next = iterate(iter, state)
end

Простой пример - это итерируемая последовательность квадратов чисел с заданной длиной:

julia> struct Squares
           count::Int
       end

julia> Base.iterate(S::Squares, state=1) = state > S.count ? nothing : (state*state, state+1)

С помощью только iterate определения тип Squares уже довольно мощный. Мы можем перебирать все элементы:

julia> for item in Squares(7)
           println(item)
       end
1
4
9
16
25
36
49

Мы можем использовать многие встроенные методы, которые работают с итерируемыми объектами, такие как in или sum:

julia> 25 in Squares(10)
true

julia> sum(Squares(100))
338350

Есть еще несколько методов, которые мы можем расширить, чтобы дать Джулии больше информации об этой итерируемой коллекции. Мы знаем, что элементы в последовательности Squares всегда будут Int. Расширив метод eltype, мы можем передать эту информацию Джулии и помочь ей создать более специализированный код в более сложных методах. Мы также знаем количество элементов в нашей последовательности, поэтому мы можем расширить length, тоже:

julia> Base.eltype(::Type{Squares}) = Int # Note that this is defined for the type

julia> Base.length(S::Squares) = S.count

Теперь, когда мы просим Джулию collect все элементы в массив, она может предварительно выделить Vector{Int} нужного размера вместо того, чтобы наивно push! каждый элемент в Vector{Any}:

julia> collect(Squares(4))
4-element Vector{Int64}:
  1
  4
  9
 16

Хотя мы можем полагаться на общие реализации, мы также можем расширить конкретные методы, когда знаем, что существует более простой алгоритм. Например, есть формула для вычисления суммы квадратов, поэтому мы можем переопределить общую итеративную версию более производительным решением:

julia> Base.sum(S::Squares) = (n = S.count; return n*(n+1)*(2n+1)÷6)

julia> sum(Squares(1803))
1955361914

Это очень распространенный шаблон в Julia Base: небольшой набор обязательных методов определяет неформальный интерфейс, который позволяет реализовать множество более сложных поведений. В некоторых случаях типы захотят дополнительно специализировать эти дополнительные поведения, когда они знают, что в их конкретном случае можно использовать более эффективный алгоритм.

Также часто полезно позволить итерацию по коллекции в обратном порядке, итерируя по Iterators.reverse(iterator). Однако для фактической поддержки итерации в обратном порядке тип итератора T должен реализовать iterate для Iterators.Reverse{T}. (Учитывая r::Iterators.Reverse{T}, подлежащий итератор типа T — это r.itr.) В нашем примере Squares мы реализуем методы Iterators.Reverse{Squares}:

julia> Base.iterate(rS::Iterators.Reverse{Squares}, state=rS.itr.count) = state < 1 ? nothing : (state*state, state-1)

julia> collect(Iterators.reverse(Squares(4)))
4-element Vector{Int64}:
 16
  9
  4
  1

Indexing

Methods to implementBrief description
getindex(X, i)X[i], indexed access, non-scalar i should allocate a copy
setindex!(X, v, i)X[i] = v, indexed assignment
firstindex(X)The first index, used in X[begin]
lastindex(X)The last index, used in X[end]

Для итерируемого объекта Squares выше мы можем легко вычислить i-й элемент последовательности, возведя его в квадрат. Мы можем представить это в виде выражения индексации S[i]. Чтобы включить это поведение, Squares просто нужно определить getindex:

julia> function Base.getindex(S::Squares, i::Int)
           1 <= i <= S.count || throw(BoundsError(S, i))
           return i*i
       end

julia> Squares(100)[23]
529

Кроме того, чтобы поддержать синтаксис S[begin] и S[end], мы должны определить firstindex и lastindex, чтобы указать соответственно первые и последние допустимые индексы:

julia> Base.firstindex(S::Squares) = 1

julia> Base.lastindex(S::Squares) = length(S)

julia> Squares(23)[end]
529

Для многомерной индексации begin/end, как в a[3, begin, 7], например, вы должны определить firstindex(a, dim) и lastindex(a, dim) (которые по умолчанию вызывают first и last на axes(a, dim), соответственно).

Обратите внимание, что выше только определяется getindex с одним целочисленным индексом. Индексация с использованием чего-либо, кроме Int, вызовет MethodError, сообщая о том, что не было соответствующего метода. Для поддержки индексации с использованием диапазонов или векторов Int необходимо написать отдельные методы:

julia> Base.getindex(S::Squares, i::Number) = S[convert(Int, i)]

julia> Base.getindex(S::Squares, I) = [S[i] for i in I]

julia> Squares(10)[[3,4.,5]]
3-element Vector{Int64}:
  9
 16
 25

Хотя это начинает поддерживать больше indexing operations supported by some of the builtin types, все еще остается довольно много отсутствующих поведений. Эта последовательность Squares начинает выглядеть все больше и больше как вектор, поскольку мы добавили к ней поведения. Вместо того чтобы определять все эти поведения самостоятельно, мы можем официально определить это как подтип AbstractArray.

Abstract Arrays

Methods to implementBrief description
size(A)Returns a tuple containing the dimensions of A
getindex(A, i::Int)(if IndexLinear) Linear scalar indexing
getindex(A, I::Vararg{Int, N})(if IndexCartesian, where N = ndims(A)) N-dimensional scalar indexing
Optional methodsDefault definitionBrief description
IndexStyle(::Type)IndexCartesian()Returns either IndexLinear() or IndexCartesian(). See the description below.
setindex!(A, v, i::Int)(if IndexLinear) Scalar indexed assignment
setindex!(A, v, I::Vararg{Int, N})(if IndexCartesian, where N = ndims(A)) N-dimensional scalar indexed assignment
getindex(A, I...)defined in terms of scalar getindexMultidimensional and nonscalar indexing
setindex!(A, X, I...)defined in terms of scalar setindex!Multidimensional and nonscalar indexed assignment
iteratedefined in terms of scalar getindexIteration
length(A)prod(size(A))Number of elements
similar(A)similar(A, eltype(A), size(A))Return a mutable array with the same shape and element type
similar(A, ::Type{S})similar(A, S, size(A))Return a mutable array with the same shape and the specified element type
similar(A, dims::Dims)similar(A, eltype(A), dims)Return a mutable array with the same element type and size dims
similar(A, ::Type{S}, dims::Dims)Array{S}(undef, dims)Return a mutable array with the specified element type and size
Non-traditional indicesDefault definitionBrief description
axes(A)map(OneTo, size(A))Return a tuple of AbstractUnitRange{<:Integer} of valid indices. The axes should be their own axes, that is axes.(axes(A),1) == axes(A) should be satisfied.
similar(A, ::Type{S}, inds)similar(A, S, Base.to_shape(inds))Return a mutable array with the specified indices inds (see below)
similar(T::Union{Type,Function}, inds)T(Base.to_shape(inds))Return an array similar to T with the specified indices inds (see below)

Если тип определен как подтип AbstractArray, он наследует очень большой набор богатых поведений, включая итерацию и многомерную индексацию, построенные на основе доступа к отдельным элементам. См. arrays manual page и Julia Base section для получения дополнительной информации о поддерживаемых методах.

Ключевой частью определения подтипа AbstractArray является IndexStyle. Поскольку индексация является такой важной частью массива и часто происходит в горячих циклах, важно сделать как индексацию, так и присвоение по индексу как можно более эффективными. Структуры данных массива обычно определяются одним из двух способов: либо они наиболее эффективно получают доступ к своим элементам, используя только один индекс (линейная индексация), либо они по своей сути получают доступ к элементам с индексами, указанными для каждого измерения. Эти два режима идентифицируются Julia как IndexLinear() и IndexCartesian(). Преобразование линейного индекса в несколько индексов подиндексации обычно очень дорого, поэтому это предоставляет механизм на основе свойств для обеспечения эффективного универсального кода для всех типов массивов.

Это различие определяет, какие методы скалярной индексации тип должен определить. IndexLinear() массивы просты: просто определите getindex(A::ArrayType, i::Int). Когда массив затем индексируется с помощью многомерного набора индексов, резервный getindex(A::AbstractArray, I...) эффективно преобразует индексы в один линейный индекс и затем вызывает вышеуказанный метод. Массивы IndexCartesian(), с другой стороны, требуют определения методов для каждой поддерживаемой размерности с ndims(A) Int индексами. Например, SparseMatrixCSC из стандартной библиотеки SparseArrays поддерживает только две размерности, поэтому он просто определяет getindex(A::SparseMatrixCSC, i::Int, j::Int). То же самое касается setindex!.

Возвращаясь к последовательности квадратов, мы могли бы вместо этого определить её как подтип AbstractArray{Int, 1}:

julia> struct SquaresVector <: AbstractArray{Int, 1}
           count::Int
       end

julia> Base.size(S::SquaresVector) = (S.count,)

julia> Base.IndexStyle(::Type{<:SquaresVector}) = IndexLinear()

julia> Base.getindex(S::SquaresVector, i::Int) = i*i

Обратите внимание, что очень важно указать два параметра AbstractArray; первый определяет eltype, а второй определяет ndims. Этот суперкласс и эти три метода — всё, что нужно, чтобы SquaresVector был итерируемым, индексируемым и полностью функциональным массивом:

julia> s = SquaresVector(4)
4-element SquaresVector:
  1
  4
  9
 16

julia> s[s .> 8]
2-element Vector{Int64}:
  9
 16

julia> s + s
4-element Vector{Int64}:
  2
  8
 18
 32

julia> sin.(s)
4-element Vector{Float64}:
  0.8414709848078965
 -0.7568024953079282
  0.4121184852417566
 -0.2879033166650653

В качестве более сложного примера давайте определим наш собственный игрушечный N-мерный разреженный массив, построенный на основе Dict:

julia> struct SparseArray{T,N} <: AbstractArray{T,N}
           data::Dict{NTuple{N,Int}, T}
           dims::NTuple{N,Int}
       end

julia> SparseArray(::Type{T}, dims::Int...) where {T} = SparseArray(T, dims);

julia> SparseArray(::Type{T}, dims::NTuple{N,Int}) where {T,N} = SparseArray{T,N}(Dict{NTuple{N,Int}, T}(), dims);

julia> Base.size(A::SparseArray) = A.dims

julia> Base.similar(A::SparseArray, ::Type{T}, dims::Dims) where {T} = SparseArray(T, dims)

julia> Base.getindex(A::SparseArray{T,N}, I::Vararg{Int,N}) where {T,N} = get(A.data, I, zero(T))

julia> Base.setindex!(A::SparseArray{T,N}, v, I::Vararg{Int,N}) where {T,N} = (A.data[I] = v)

Обратите внимание, что это массив IndexCartesian, поэтому мы должны вручную определить getindex и setindex! на размерности массива. В отличие от SquaresVector, мы можем определить 4d61726b646f776e2e436f64652822222c2022736574696e646578212229_40726566, и таким образом мы можем изменять массив:

julia> A = SparseArray(Float64, 3, 3)
3×3 SparseArray{Float64, 2}:
 0.0  0.0  0.0
 0.0  0.0  0.0
 0.0  0.0  0.0

julia> fill!(A, 2)
3×3 SparseArray{Float64, 2}:
 2.0  2.0  2.0
 2.0  2.0  2.0
 2.0  2.0  2.0

julia> A[:] = 1:length(A); A
3×3 SparseArray{Float64, 2}:
 1.0  4.0  7.0
 2.0  5.0  8.0
 3.0  6.0  9.0

Результат индексирования AbstractArray может сам по себе быть массивом (например, при индексировании с помощью AbstractRange). Методы резервного копирования AbstractArray используют similar для выделения Array соответствующего размера и типа элемента, который заполняется с использованием основного метода индексирования, описанного выше. Однако при реализации обертки массива вы часто хотите, чтобы результат также был обернут:

julia> A[1:2,:]
2×3 SparseArray{Float64, 2}:
 1.0  4.0  7.0
 2.0  5.0  8.0

В этом примере это достигается путем определения Base.similar(A::SparseArray, ::Type{T}, dims::Dims) where T, чтобы создать соответствующий обернутый массив. (Обратите внимание, что хотя similar поддерживает формы с 1 и 2 аргументами, в большинстве случаев вам нужно только специализированное определение для формы с 3 аргументами.) Для того чтобы это работало, важно, чтобы SparseArray был изменяемым (поддерживал setindex!). Определение similar, getindex и setindex! для SparseArray также делает возможным copy массива:

julia> copy(A)
3×3 SparseArray{Float64, 2}:
 1.0  4.0  7.0
 2.0  5.0  8.0
 3.0  6.0  9.0

В дополнение ко всем перечисленным выше итерируемым и индексируемым методам, эти типы также могут взаимодействовать друг с другом и использовать большинство методов, определенных в Julia Base для AbstractArrays:

julia> A[SquaresVector(3)]
3-element SparseArray{Float64, 1}:
 1.0
 4.0
 9.0

julia> sum(A)
45.0

Если вы определяете тип массива, который позволяет нетрадиционную индексацию (индексы, начинающиеся не с 1), вы должны специализированно использовать axes. Вы также должны специализированно использовать similar, чтобы аргумент dims (обычно кортеж размера Dims) мог принимать объекты AbstractUnitRange, возможно, типы диапазонов Ind вашего собственного дизайна. Для получения дополнительной информации смотрите Arrays with custom indices.

Strided Arrays

Methods to implementBrief description
strides(A)Return the distance in memory (in number of elements) between adjacent elements in each dimension as a tuple. If A is an AbstractArray{T,0}, this should return an empty tuple.
Base.unsafe_convert(::Type{Ptr{T}}, A)Return the native address of an array.
Base.elsize(::Type{<:A})Return the stride between consecutive elements in the array.
Optional methodsDefault definitionBrief description
stride(A, i::Int)strides(A)[i]Return the distance in memory (in number of elements) between adjacent elements in dimension k.

Строчный массив является подтипом AbstractArray, элементы которого хранятся в памяти с фиксированными шагами. При условии, что тип элемента массива совместим с BLAS, строчный массив может использовать процедуры BLAS и LAPACK для более эффективных линейных алгебраических операций. Типичным примером пользовательского строчного массива является тот, который оборачивает стандартный Array с дополнительной структурой.

Предупреждение: не реализуйте эти методы, если базовое хранилище на самом деле не является страйдированным, так как это может привести к неправильным результатам или ошибкам сегментации.

Вот несколько примеров, чтобы продемонстрировать, какие типы массивов являются шаговыми, а какие нет:

1:5   # not strided (there is no storage associated with this array.)
Vector(1:5)  # is strided with strides (1,)
A = [1 5; 2 6; 3 7; 4 8]  # is strided with strides (1,4)
V = view(A, 1:2, :)   # is strided with strides (1,4)
V = view(A, 1:2:3, 1:2)   # is strided with strides (2,4)
V = view(A, [1,2,4], :)   # is not strided, as the spacing between rows is not fixed.

Customizing broadcasting

Methods to implementBrief description
Base.BroadcastStyle(::Type{SrcType}) = SrcStyle()Broadcasting behavior of SrcType
Base.similar(bc::Broadcasted{DestStyle}, ::Type{ElType})Allocation of output container
Optional methods
Base.BroadcastStyle(::Style1, ::Style2) = Style12()Precedence rules for mixing styles
Base.axes(x)Declaration of the indices of x, as per axes(x).
Base.broadcastable(x)Convert x to an object that has axes and supports indexing
Bypassing default machinery
Base.copy(bc::Broadcasted{DestStyle})Custom implementation of broadcast
Base.copyto!(dest, bc::Broadcasted{DestStyle})Custom implementation of broadcast!, specializing on DestStyle
Base.copyto!(dest::DestType, bc::Broadcasted{Nothing})Custom implementation of broadcast!, specializing on DestType
Base.Broadcast.broadcasted(f, args...)Override the default lazy behavior within a fused expression
Base.Broadcast.instantiate(bc::Broadcasted{DestStyle})Override the computation of the lazy broadcast's axes

Broadcasting вызывается явным вызовом broadcast или broadcast!, или неявно через операции "точка", такие как A .+ b или f.(x, y). Любой объект, который имеет axes и поддерживает индексацию, может участвовать в качестве аргумента в широковещательной передаче, и по умолчанию результат сохраняется в Array. Эта базовая структура может быть расширена тремя основными способами:

  • Обеспечение поддержки широковещательной передачи для всех аргументов
  • Выбор подходящего выходного массива для данного набора аргументов
  • Выбор эффективной реализации для данного набора аргументов

Не все типы поддерживают axes и индексацию, но многие из них удобно использовать в широковещательной передаче. Функция Base.broadcastable вызывается для каждого аргумента для широковещательной передачи, позволяя ей возвращать что-то другое, что поддерживает axes и индексацию. По умолчанию это функция тождества для всех AbstractArray и Number — они уже поддерживают axes и индексацию.

Если тип предназначен для работы как "0-мерный скаляр" (один объект), а не как контейнер для широковещательной передачи, то следует определить следующий метод:

Base.broadcastable(o::MyType) = Ref(o)

это возвращает аргумент, обернутый в 0-мерный Ref контейнер. Например, такой метод обертки определен для самих типов, функций, специальных синглтонов, таких как missing и nothing, и дат.

Пользовательские типы, похожие на массивы, могут специализировать Base.broadcastable, чтобы определить свою форму, но они должны следовать соглашению, что collect(Base.broadcastable(x)) == collect(x). Замечательное исключение — AbstractString; строки обрабатываются как скаляры для целей широковещательной передачи, хотя они являются итерируемыми коллекциями своих символов (см. Strings для получения дополнительной информации).

Следующие два шага (выбор выходного массива и реализация) зависят от определения единственного ответа для данного набора аргументов. Broadcast должен взять все разнообразные типы своих аргументов и свести их к одному выходному массиву и одной реализации. Broadcast называет этот единственный ответ "стилем". Каждый объект, который можно передавать, имеет свой собственный предпочтительный стиль, и используется система, подобная повышению, для объединения этих стилей в единственный ответ — "стиль назначения".

Broadcast Styles

Base.BroadcastStyle — это абстрактный тип, от которого производятся все стили широковещательной передачи. При использовании в качестве функции он имеет две возможные формы: унарную (с одним аргументом) и бинарную. Унарный вариант указывает на то, что вы намерены реализовать конкретное поведение широковещательной передачи и/или тип вывода и не хотите полагаться на значение по умолчанию Broadcast.DefaultArrayStyle.

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

struct MyStyle <: Broadcast.BroadcastStyle end
Base.BroadcastStyle(::Type{<:MyType}) = MyStyle()

В некоторых случаях может быть удобно не определять MyStyle, в этом случае вы можете воспользоваться одним из общих оберток для широковещательной передачи:

  • Base.BroadcastStyle(::Type{<:MyType}) = Broadcast.Style{MyType}() может быть использован для произвольных типов.
  • Base.BroadcastStyle(::Type{<:MyType}) = Broadcast.ArrayStyle{MyType}() предпочтительнее, если MyType является AbstractArray.
  • Для AbstractArrays, которые поддерживают только определенную размерность, создайте подтип Broadcast.AbstractArrayStyle{N} (см. ниже).

Когда ваша операция трансляции включает несколько аргументов, индивидуальные стили аргументов комбинируются для определения единого DestStyle, который контролирует тип выходного контейнера. Для получения дополнительной информации смотрите below.

Selecting an appropriate output array

Стиль трансляции вычисляется для каждой операции трансляции, чтобы позволить диспетчеризацию и специализацию. Фактическое выделение массива результата обрабатывается с помощью similar, используя объект Broadcasted в качестве первого аргумента.

Base.similar(bc::Broadcasted{DestStyle}, ::Type{ElType})

Определение резервного варианта — это

similar(bc::Broadcasted{DefaultArrayStyle{N}}, ::Type{ElType}) where {N,ElType} =
    similar(Array{ElType}, axes(bc))

Однако, если необходимо, вы можете специализироваться на любом или всех этих аргументах. Последний аргумент bc является ленивым представлением (возможно, объединенной) операции широковещательной передачи, объектом Broadcasted. Для этих целей наиболее важными полями обертки являются f и args, описывающие функцию и список аргументов соответственно. Обратите внимание, что список аргументов может — и часто включает — другие вложенные обертки Broadcasted.

Для полного примера, предположим, что вы создали тип ArrayAndChar, который хранит массив и один символ:

struct ArrayAndChar{T,N} <: AbstractArray{T,N}
    data::Array{T,N}
    char::Char
end
Base.size(A::ArrayAndChar) = size(A.data)
Base.getindex(A::ArrayAndChar{T,N}, inds::Vararg{Int,N}) where {T,N} = A.data[inds...]
Base.setindex!(A::ArrayAndChar{T,N}, val, inds::Vararg{Int,N}) where {T,N} = A.data[inds...] = val
Base.showarg(io::IO, A::ArrayAndChar, toplevel) = print(io, typeof(A), " with char '", A.char, "'")

Вы можете захотеть, чтобы трансляция сохранила "метаданные" char. Сначала мы определяем

Base.BroadcastStyle(::Type{<:ArrayAndChar}) = Broadcast.ArrayStyle{ArrayAndChar}()

Это означает, что мы также должны определить соответствующий метод similar:

function Base.similar(bc::Broadcast.Broadcasted{Broadcast.ArrayStyle{ArrayAndChar}}, ::Type{ElType}) where ElType
    # Scan the inputs for the ArrayAndChar:
    A = find_aac(bc)
    # Use the char field of A to create the output
    ArrayAndChar(similar(Array{ElType}, axes(bc)), A.char)
end

"`A = find_aac(As)` returns the first ArrayAndChar among the arguments."
find_aac(bc::Base.Broadcast.Broadcasted) = find_aac(bc.args)
find_aac(args::Tuple) = find_aac(find_aac(args[1]), Base.tail(args))
find_aac(x) = x
find_aac(::Tuple{}) = nothing
find_aac(a::ArrayAndChar, rest) = a
find_aac(::Any, rest) = find_aac(rest)

Из этих определений получается следующее поведение:

julia> a = ArrayAndChar([1 2; 3 4], 'x')
2×2 ArrayAndChar{Int64, 2} with char 'x':
 1  2
 3  4

julia> a .+ 1
2×2 ArrayAndChar{Int64, 2} with char 'x':
 2  3
 4  5

julia> a .+ [5,10]
2×2 ArrayAndChar{Int64, 2} with char 'x':
  6   7
 13  14

Extending broadcast with custom implementations

В общем, операция широковещательной передачи представлена ленивым контейнером Broadcasted, который хранит функцию, которая будет применена, вместе с её аргументами. Эти аргументы могут сами по себе быть более вложенными контейнерами Broadcasted, формируя большое дерево выражений для оценки. Вложенное дерево контейнеров Broadcasted непосредственно строится с помощью неявного синтаксиса точек; 5 .+ 2.*x временно представляется как Broadcasted(+, 5, Broadcasted(*, 2, x)), например. Это невидимо для пользователей, так как сразу реализуется через вызов copy, но именно этот контейнер обеспечивает основу для расширяемости широковещательной передачи для авторов пользовательских типов. Встроенный механизм широковещательной передачи затем определяет тип и размер результата на основе аргументов, выделяет его, а затем, наконец, копирует реализацию объекта Broadcasted в него с помощью метода по умолчанию copyto!(::AbstractArray, ::Broadcasted). Встроенные методы резервного копирования broadcast и broadcast! аналогично строят временное представление Broadcasted операции, чтобы они могли следовать тому же коду. Это позволяет пользовательским реализациям массивов предоставлять свою собственную специализацию copyto!, чтобы настроить и оптимизировать широковещательную передачу. Это снова определяется вычисленным стилем широковещательной передачи. Это такая важная часть операции, что она хранится как первый параметр типа Broadcasted, что позволяет осуществлять диспетчеризацию и специализацию.

Для некоторых типов механизм "слияния" операций через вложенные уровни широковещательной передачи недоступен или может быть выполнен более эффективно поэтапно. В таких случаях вам может потребоваться или захотеть оценить x .* (x .+ 1) так, как если бы это было написано broadcast(*, x, broadcast(+, x, 1)), где внутренняя операция оценивается перед тем, как приступить к внешней операции. Этот вид жадной операции напрямую поддерживается с помощью небольшого косвенного обращения; вместо того чтобы напрямую создавать объекты Broadcasted, Julia преобразует слитое выражение x .* (x .+ 1) в Broadcast.broadcasted(*, x, Broadcast.broadcasted(+, x, 1)). Теперь по умолчанию broadcasted просто вызывает конструктор Broadcasted, чтобы создать ленивое представление слитого дерева выражений, но вы можете выбрать переопределить его для конкретной комбинации функции и аргументов.

В качестве примера встроенные объекты AbstractRange используют эту механику для оптимизации частей широковещательных выражений, которые могут быть оценены заранее исключительно на основе начала, шага и длины (или остановки), вместо вычисления каждого отдельного элемента. Как и вся другая механика, broadcasted также вычисляет и предоставляет комбинированный стиль широковещательной передачи своих аргументов, так что вместо специализации на broadcasted(f, args...) вы можете специализироваться на broadcasted(::DestStyle, f, args...) для любой комбинации стиля, функции и аргументов.

Например, следующее определение поддерживает отрицание диапазонов:

broadcasted(::DefaultArrayStyle{1}, ::typeof(-), r::OrdinalRange) = range(-first(r), step=-step(r), length=length(r))

Extending in-place broadcasting

Поддержка прямого широковещания может быть обеспечена путем определения соответствующего метода copyto!(dest, bc::Broadcasted). Поскольку вы можете захотеть специализироваться либо на dest, либо на конкретном подтипе bc, чтобы избежать неоднозначностей между пакетами, мы рекомендуем следующую конвенцию.

Если вы хотите специализироваться на определенном стиле DestStyle, определите метод для

copyto!(dest, bc::Broadcasted{DestStyle})

При желании с помощью этой формы вы также можете специализироваться на типе dest.

Если вместо этого вы хотите специализироваться на типе назначения DestType, не специализируясь на DestStyle, то вам следует определить метод со следующей сигнатурой:

copyto!(dest::DestType, bc::Broadcasted{Nothing})

Это использует резервную реализацию copyto!, которая преобразует обертку в Broadcasted{Nothing}. Следовательно, специализация на DestType имеет более низкий приоритет, чем методы, которые специализируются на DestStyle.

Аналично, вы можете полностью переопределить неуместное широковещательное сообщение с помощью метода copy(::Broadcasted).

Working with Broadcasted objects

Чтобы реализовать такой метод copy или copyto!, конечно, вам нужно работать с оберткой Broadcasted, чтобы вычислить каждый элемент. Существует два основных способа сделать это:

  • Broadcast.flatten пересчитывает потенциально вложенную операцию в одну функцию и плоский список аргументов. Вы несете ответственность за реализацию правил формы широковещательной передачи самостоятельно, но это может быть полезно в ограниченных ситуациях.
  • Итерация по CartesianIndices осей axes(::Broadcasted) и использование индексации с полученным объектом CartesianIndex для вычисления результата.

Writing binary broadcasting rules

Правила приоритета определяются бинарными вызовами BroadcastStyle:

Base.BroadcastStyle(::Style1, ::Style2) = Style12()

где Style12 — это BroadcastStyle, который вы хотите выбрать для выходных данных, связанных с аргументами Style1 и Style2. Например,

Base.BroadcastStyle(::Broadcast.Style{Tuple}, ::Broadcast.AbstractArrayStyle{0}) = Broadcast.Style{Tuple}()

указывает на то, что Tuple "выигрывает" у нульмерных массивов (выходной контейнер будет кортежем). Стоит отметить, что вам не нужно (и не следует) определять оба порядка аргументов этого вызова; определение одного достаточно, независимо от того, в каком порядке пользователь предоставляет аргументы.

Для типов AbstractArray определение BroadcastStyle заменяет выбор по умолчанию, Broadcast.DefaultArrayStyle. DefaultArrayStyle и абстрактный суперкласс AbstractArrayStyle хранят размерность в качестве параметра типа, чтобы поддерживать специализированные типы массивов, которые имеют фиксированные требования к размерности.

DefaultArrayStyle "теряет" перед любым другим AbstractArrayStyle, который был определен, из-за следующих методов:

BroadcastStyle(a::AbstractArrayStyle{Any}, ::DefaultArrayStyle) = a
BroadcastStyle(a::AbstractArrayStyle{N}, ::DefaultArrayStyle{N}) where N = a
BroadcastStyle(a::AbstractArrayStyle{M}, ::DefaultArrayStyle{N}) where {M,N} =
    typeof(a)(Val(max(M, N)))

Вы не обязаны писать бинарные правила BroadcastStyle, если только не хотите установить приоритет для двух или более типов, не являющихся DefaultArrayStyle.

Если ваш тип массива действительно имеет фиксированные требования к размерности, то вам следует создать подкласс AbstractArrayStyle. Например, код разреженного массива имеет следующие определения:

struct SparseVecStyle <: Broadcast.AbstractArrayStyle{1} end
struct SparseMatStyle <: Broadcast.AbstractArrayStyle{2} end
Base.BroadcastStyle(::Type{<:SparseVector}) = SparseVecStyle()
Base.BroadcastStyle(::Type{<:SparseMatrixCSC}) = SparseMatStyle()

Каждый раз, когда вы создаете подкласс AbstractArrayStyle, вам также необходимо определить правила для комбинирования размерностей, создав конструктор для вашего стиля, который принимает аргумент Val(N). Например:

SparseVecStyle(::Val{0}) = SparseVecStyle()
SparseVecStyle(::Val{1}) = SparseVecStyle()
SparseVecStyle(::Val{2}) = SparseMatStyle()
SparseVecStyle(::Val{N}) where N = Broadcast.DefaultArrayStyle{N}()

Эти правила указывают на то, что комбинация SparseVecStyle с 0- или 1-мерными массивами дает другой SparseVecStyle, что его комбинация с 2-мерным массивом дает SparseMatStyle, а все, что имеет более высокую размерность, возвращается к плотной произвольной размерной структуре. Эти правила позволяют широковещательной передаче сохранять разреженное представление для операций, которые приводят к одномерным или двумерным выходам, но производят Array для любой другой размерности.

Instance Properties

Methods to implementDefault definitionBrief description
propertynames(x::ObjType, private::Bool=false)fieldnames(typeof(x))Return a tuple of the properties (x.property) of an object x. If private=true, also return property names intended to be kept as private
getproperty(x::ObjType, s::Symbol)getfield(x, s)Return property s of x. x.s calls getproperty(x, :s).
setproperty!(x::ObjType, s::Symbol, v)setfield!(x, s, v)Set property s of x to v. x.s = v calls setproperty!(x, :s, v). Should return v.

Иногда желательно изменить способ взаимодействия конечного пользователя с полями объекта. Вместо того чтобы предоставлять прямой доступ к полям типа, можно предоставить дополнительный уровень абстракции между пользователем и кодом, перегрузив object.field. Свойства — это то, что пользователь видит в объекте, поля — это то, чем объект на самом деле является.

По умолчанию свойства и поля одинаковы. Однако это поведение можно изменить. Например, возьмите это представление точки на плоскости в polar coordinates:

julia> mutable struct Point
           r::Float64
           ϕ::Float64
       end

julia> p = Point(7.0, pi/4)
Point(7.0, 0.7853981633974483)

Как описано в таблице выше, доступ через точку p.r такой же, как getproperty(p, :r), который по умолчанию такой же, как getfield(p, :r):

julia> propertynames(p)
(:r, :ϕ)

julia> getproperty(p, :r), getproperty(p, :ϕ)
(7.0, 0.7853981633974483)

julia> p.r, p.ϕ
(7.0, 0.7853981633974483)

julia> getfield(p, :r), getproperty(p, :ϕ)
(7.0, 0.7853981633974483)

Однако мы можем захотеть, чтобы пользователи не знали, что Point хранит координаты как r и ϕ (поля), а вместо этого взаимодействовали с x и y (свойства). Методы в первом столбце могут быть определены для добавления новой функциональности:

julia> Base.propertynames(::Point, private::Bool=false) = private ? (:x, :y, :r, :ϕ) : (:x, :y)

julia> function Base.getproperty(p::Point, s::Symbol)
           if s === :x
               return getfield(p, :r) * cos(getfield(p, :ϕ))
           elseif s === :y
               return getfield(p, :r) * sin(getfield(p, :ϕ))
           else
               # This allows accessing fields with p.r and p.ϕ
               return getfield(p, s)
           end
       end

julia> function Base.setproperty!(p::Point, s::Symbol, f)
           if s === :x
               y = p.y
               setfield!(p, :r, sqrt(f^2 + y^2))
               setfield!(p, :ϕ, atan(y, f))
               return f
           elseif s === :y
               x = p.x
               setfield!(p, :r, sqrt(x^2 + f^2))
               setfield!(p, :ϕ, atan(f, x))
               return f
           else
               # This allow modifying fields with p.r and p.ϕ
               return setfield!(p, s, f)
           end
       end

Важно, чтобы getfield и setfield использовались внутри getproperty и setproperty!, а не синтаксисом с точкой, так как синтаксис с точкой сделает функции рекурсивными, что может привести к проблемам с выводом типов. Теперь мы можем попробовать новую функциональность:

julia> propertynames(p)
(:x, :y)

julia> p.x
4.949747468305833

julia> p.y = 4.0
4.0

julia> p.r
6.363961030678928

Наконец, стоит отметить, что добавление свойств экземпляра таким образом в Julia делается довольно редко и, как правило, должно осуществляться только в том случае, если для этого есть веская причина.

Rounding

Methods to implementDefault definitionBrief description
round(x::ObjType, r::RoundingMode)noneRound x and return the result. If possible, round should return an object of the same type as x
round(T::Type, x::ObjType, r::RoundingMode)convert(T, round(x, r))Round x, returning the result as a T

Чтобы поддерживать округление для нового типа, обычно достаточно определить единственный метод round(x::ObjType, r::RoundingMode). Переданный режим округления определяет, в каком направлении должно быть округлено значение. Наиболее часто используемые режимы округления — это RoundNearest, RoundToZero, RoundDown и RoundUp, так как эти режимы округления используются в определениях метода с одним аргументом round, а также trunc, floor и ceil, соответственно.

В некоторых случаях возможно определить метод round с тремя аргументами, который более точен или производителен, чем метод с двумя аргументами, за которым следует преобразование. В этом случае допустимо определить метод с тремя аргументами в дополнение к методу с двумя аргументами. Если невозможно представить округленный результат в виде объекта типа T, то метод с тремя аргументами должен вызывать InexactError.

Например, если у нас есть тип Interval, который представляет диапазон возможных значений, аналогичный https://github.com/JuliaPhysics/Measurements.jl, мы можем определить округление для этого типа следующим образом

julia> struct Interval{T}
           min::T
           max::T
       end

julia> Base.round(x::Interval, r::RoundingMode) = Interval(round(x.min, r), round(x.max, r))

julia> x = Interval(1.7, 2.2)
Interval{Float64}(1.7, 2.2)

julia> round(x)
Interval{Float64}(2.0, 2.0)

julia> floor(x)
Interval{Float64}(1.0, 2.0)

julia> ceil(x)
Interval{Float64}(2.0, 3.0)

julia> trunc(x)
Interval{Float64}(1.0, 2.0)