Spec-Zone.ru › Julia 1.3

Методы

Напомним из Функций, что функция — это объект, который сопоставляет кортеж аргументов со значением возврата или генерирует исключение, если соответствующее значение вернуть невозможно. Часто одна и та же концептуальная функция или операция реализуется совершенно по-разному для различных типов аргументов: сложение двух целых чисел сильно отличается от сложения двух чисел с плавающей запятой, и оба отличаются от сложения целого числа с числом с плавающей запятой. Несмотря на различия в реализации, эти операции все подпадают под общее понятие «сложение». Соответственно, в Julia эти поведения принадлежат одному объекту: функции +.

Чтобы обеспечить плавное использование многих различных реализаций одного и того же понятия, функции не обязательно определять сразу, но можно определять по частям, предоставляя определенное поведение для определенных комбинаций типов и количества аргументов. Определение одного возможного поведения функции называется методом. До сих пор мы представляли только примеры функций, определенных с одним методом, применимым ко всем типам аргументов. Однако подписи определений методов можно аннотировать, чтобы указать типы аргументов в дополнение к их количеству, и можно предоставить более одного определения метода. Когда функция применяется к определенному кортежу аргументов, применяется наиболее специфичный метод, применимый к этим аргументам. Таким образом, общее поведение функции — это мозаика из поведений ее различных определений методов. Если мозаика спроектирована правильно, даже если реализации методов могут быть совершенно разными, внешнее поведение функции будет казаться плавным и согласованным.

Выбор метода, который нужно выполнить при применении функции, называется диспетчеризацией. Julia позволяет процессу диспетчеризации выбирать, какой из методов функции вызвать, в зависимости от количества заданных аргументов и типов всех аргументов функции. Это отличается от традиционных языков объектно-ориентированного программирования, где диспетчеризация происходит только на основе первого аргумента, который часто имеет специальный синтаксис аргумента, и иногда подразумевается, а не явно записан как аргумент. [1] Использование всех аргументов функции для выбора метода, который нужно вызвать, а не только первого, известно как множественная диспетчеризация. Множественная диспетчеризация особенно полезна для математического кода, где не имеет смысла искусственно считать операции «принадлежащими» одному аргументу больше, чем любому другому: принадлежит ли операция сложения в x + y к x больше, чем к y? Реализация математического оператора обычно зависит от типов всех его аргументов. Однако даже за пределами математических операций множественная диспетчеризация оказывается мощной и удобной парадигмой для структурирования и организации программ.

[1]

В C++ или Java, например, в вызове метода, таком как obj.meth(arg1,arg2), объект obj «получает» вызов метода и неявно передается методу через ключевое слово this, а не как явный аргумент метода. Когда текущий объект this является получателем вызова метода, его можно опустить совсем, написав просто meth(arg1,arg2), при этом this подразумевается как объект-получатель.

Определение методов

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

При определении функции можно дополнительно ограничить типы параметров, к которым она применима, с помощью оператора утверждения типа ::, представленного в разделе о Составных типах:

julia> f(x::Float64, y::Float64) = 2x + y
f (generic function with 1 method)

Это определение функции применяется только к вызовам, где x и y являются значениями типа Float64:

julia> f(2.0, 3.0)
7.0

При применении к другим типам аргументов будет получено значение MethodError:

julia> f(2.0, 3)
ERROR: MethodError: no method matching f(::Float64, ::Int64)
Closest candidates are:
  f(::Float64, !Matched::Float64) at none:1

julia> f(Float32(2.0), 3.0)
ERROR: MethodError: no method matching f(::Float32, ::Float64)
Closest candidates are:
  f(!Matched::Float64, ::Float64) at none:1

julia> f(2.0, "3.0")
ERROR: MethodError: no method matching f(::Float64, ::String)
Closest candidates are:
  f(::Float64, !Matched::Float64) at none:1

julia> f("2.0", "3.0")
ERROR: MethodError: no method matching f(::String, ::String)

Как видите, аргументы должны быть точно типа Float64. Другие числовые типы, такие как целые числа или 32-битные числа с плавающей точкой, не преобразуются автоматически в 64-битные числа с плавающей точкой, и строки не анализируются как числа. Поскольку Float64 является конкретным типом, а конкретные типы не могут быть наследуемы в Julia, такое определение может быть применено только к аргументам, которые точно имеют тип Float64. Однако часто бывает полезно писать более общие методы, где объявленные типы параметров являются абстрактными:

julia> f(x::Number, y::Number) = 2x - y
f (generic function with 2 methods)

julia> f(2.0, 3)
1.0

Это определение метода применяется к любой паре аргументов, которые являются экземплярами Number. Они не обязательно должны быть одного типа, если только каждый из них является числовым значением. Проблема обработки различных числовых типов делегируется арифметическим операциям в выражении 2x - y.

Чтобы определить функцию с несколькими методами, просто несколько раз определите функцию с различными количествами и типами аргументов. Первое определение метода создает объект функции, а последующие определения методов добавляют новые методы к существующему объекту функции. При применении функции будет выполнен наиболее специфичный метод, соответствующий количеству и типам аргументов. Таким образом, два определения методов выше, взятые вместе, определяют поведение f для всех пар экземпляров абстрактного типа Number — но с другим поведением, специфичным для пар значений Float64. Если один из аргументов — 64-битный float, а другой — нет, то метод f(Float64,Float64) не может быть вызван, и должен использоваться более общий метод f(Number,Number):

julia> f(2.0, 3.0)
7.0

julia> f(2, 3.0)
1.0

julia> f(2.0, 3)
1.0

julia> f(2, 3)
1

Определение 2x + y используется только в первом случае, в то время как определение 2x - y используется во всех остальных. Автоматическое преобразование или приведение аргументов функции никогда не выполняется: все преобразования в Julia не являются магическими и полностью явными. Преобразование и продвижение, однако, показывает, как продуманное применение достаточно продвинутых технологий может быть неотличимо от магии. [Clarke61]

Для значений, отличных от чисел, и для меньше или больше двух аргументов функция f остается неопределенной, и ее применение по-прежнему приведет к MethodError:

julia> f("foo", 3)
ERROR: MethodError: no method matching f(::String, ::Int64)
Closest candidates are:
  f(!Matched::Number, ::Number) at none:1

julia> f()
ERROR: MethodError: no method matching f()
Closest candidates are:
  f(!Matched::Float64, !Matched::Float64) at none:1
  f(!Matched::Number, !Matched::Number) at none:1

Вы легко можете увидеть, какие методы существуют для функции, введя сам объект функции в интерактивной сессии:

julia> f
f (generic function with 2 methods)

Этот вывод сообщает нам, что f — это объект функции с двумя методами. Чтобы узнать, каковы подписи этих методов, используйте функцию methods:

julia> methods(f)
# 2 methods for generic function "f":
[1] f(x::Float64, y::Float64) in Main at none:1
[2] f(x::Number, y::Number) in Main at none:1

что показывает, что у f есть два метода: один, принимающий два Float64 аргумента, и один, принимающий аргументы типа Number. Также указывается файл и строка, где были определены методы: поскольку эти методы были определены в REPL, мы получаем кажущийся номер строки none:1.

В отсутствие объявления типа с ::, тип параметра метода по умолчанию Any, что означает, что он не ограничен, поскольку все значения в Julia являются экземплярами абстрактного типа Any. Таким образом, мы можем определить метод-ловушку для f следующим образом:

julia> f(x,y) = println("Whoa there, Nelly.")
f (generic function with 3 methods)

julia> f("foo", 1)
Whoa there, Nelly.

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

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

julia> methods(+)
# 180 methods for generic function "+":
[1] +(x::Bool, z::Complex{Bool}) in Base at complex.jl:227
[2] +(x::Bool, y::Bool) in Base at bool.jl:89
[3] +(x::Bool) in Base at bool.jl:86
[4] +(x::Bool, y::T) where T<:AbstractFloat in Base at bool.jl:96
[5] +(x::Bool, z::Complex) in Base at complex.jl:234
[6] +(a::Float16, b::Float16) in Base at float.jl:373
[7] +(x::Float32, y::Float32) in Base at float.jl:375
[8] +(x::Float64, y::Float64) in Base at float.jl:376
[9] +(z::Complex{Bool}, x::Bool) in Base at complex.jl:228
[10] +(z::Complex{Bool}, x::Real) in Base at complex.jl:242
[11] +(x::Char, y::Integer) in Base at char.jl:40
[12] +(c::BigInt, x::BigFloat) in Base.MPFR at mpfr.jl:307
[13] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt, e::BigInt) in Base.GMP at gmp.jl:392
[14] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt) in Base.GMP at gmp.jl:391
[15] +(a::BigInt, b::BigInt, c::BigInt) in Base.GMP at gmp.jl:390
[16] +(x::BigInt, y::BigInt) in Base.GMP at gmp.jl:361
[17] +(x::BigInt, c::Union{UInt16, UInt32, UInt64, UInt8}) in Base.GMP at gmp.jl:398
...
[180] +(a, b, c, xs...) in Base at operators.jl:424

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

Неопределенности методов

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

julia> g(x::Float64, y) = 2x + y
g (generic function with 1 method)

julia> g(x, y::Float64) = x + 2y
g (generic function with 2 methods)

julia> g(2.0, 3)
7.0

julia> g(2, 3.0)
8.0

julia> g(2.0, 3.0)
ERROR: MethodError: g(::Float64, ::Float64) is ambiguous. Candidates:
  g(x::Float64, y) in Main at none:1
  g(x, y::Float64) in Main at none:1
Possible fix, define
  g(::Float64, ::Float64)

Здесь вызов g(2.0, 3.0) может обрабатываться либо методом g(Float64, Any), либо методом g(Any, Float64), и ни один из них не более специфичен, чем другой. В таких случаях Julia генерирует MethodError, а не произвольно выбирает метод. Вы можете избежать неоднозначности методов, указав соответствующий метод для случая пересечения:

julia> g(x::Float64, y::Float64) = 2x + 2y
g (generic function with 3 methods)

julia> g(2.0, 3)
7.0

julia> g(2, 3.0)
8.0

julia> g(2.0, 3.0)
10.0

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

В более сложных случаях разрешение неоднозначностей методов включает определенный элемент проектирования; эта тема рассматривается более подробно ниже.

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

Определения методов могут по желанию содержать параметрические типы, квалифицирующие подпись:

julia> same_type(x::T, y::T) where {T} = true
same_type (generic function with 1 method)

julia> same_type(x,y) = false
same_type (generic function with 2 methods)

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

julia> same_type(1, 2)
true

julia> same_type(1, 2.0)
false

julia> same_type(1.0, 2.0)
true

julia> same_type("foo", 2.0)
false

julia> same_type("foo", "bar")
true

julia> same_type(Int32(1), Int64(2))
false

Такие определения соответствуют методам, подписи типов которых являются типами UnionAll (см. Типы UnionAll).

Этот вид определения поведения функции с помощью диспетчеризации довольно распространён — даже идиоматичен — в Julia. Параметры типа метода не ограничены использованием в качестве типов аргументов: они могут использоваться везде, где требуется значение в подписи функции или в теле функции. Вот пример, где параметр типа метода T используется как параметр типа параметрического типа Vector{T} в подписи метода:

julia> myappend(v::Vector{T}, x::T) where {T} = [v..., x]
myappend (generic function with 1 method)

julia> myappend([1,2,3],4)
4-element Array{Int64,1}:
 1
 2
 3
 4

julia> myappend([1,2,3],2.5)
ERROR: MethodError: no method matching myappend(::Array{Int64,1}, ::Float64)
Closest candidates are:
  myappend(::Array{T,1}, !Matched::T) where T at none:1

julia> myappend([1.0,2.0,3.0],4.0)
4-element Array{Float64,1}:
 1.0
 2.0
 3.0
 4.0

julia> myappend([1.0,2.0,3.0],4)
ERROR: MethodError: no method matching myappend(::Array{Float64,1}, ::Int64)
Closest candidates are:
  myappend(::Array{T,1}, !Matched::T) where T at none:1

Как видно, тип добавляемого элемента должен совпадать с типом элемента вектора, к которому он добавляется, иначе генерируется MethodError. В следующем примере параметр типа метода T используется в качестве возвращаемого значения:

julia> mytypeof(x::T) where {T} = T
mytypeof (generic function with 1 method)

julia> mytypeof(1)
Int64

julia> mytypeof(1.0)
Float64

Так же как вы можете задавать ограничения на подтипы параметров типа в объявлениях типов (см. Параметрические типы), вы также можете ограничивать параметры типа методов:

julia> same_type_numeric(x::T, y::T) where {T<:Number} = true
same_type_numeric (generic function with 1 method)

julia> same_type_numeric(x::Number, y::Number) = false
same_type_numeric (generic function with 2 methods)

julia> same_type_numeric(1, 2)
true

julia> same_type_numeric(1, 2.0)
false

julia> same_type_numeric(1.0, 2.0)
true

julia> same_type_numeric("foo", 2.0)
ERROR: MethodError: no method matching same_type_numeric(::String, ::Float64)
Closest candidates are:
  same_type_numeric(!Matched::T, ::T) where T<:Number at none:1
  same_type_numeric(!Matched::Number, ::Number) at none:1

julia> same_type_numeric("foo", "bar")
ERROR: MethodError: no method matching same_type_numeric(::String, ::String)

julia> same_type_numeric(Int32(1), Int64(2))
false

Функция same_type_numeric ведет себя очень похоже на функцию same_type , определённую выше, но она определена только для пар чисел.

Параметрические методы допускают тот же синтаксис, что и where выражения, используемые для записи типов (см. UnionAll Типы). Если имеется только один параметр, можно опустить фигурные скобки, окружающие его (в where {T}), но для ясности их часто предпочитают использовать. Несколько параметров могут быть разделены запятыми, например, where {T, S<:Real}, или записаны с помощью вложенных where, например, where S<:Real where T.

Переопределение методов

При переопределении метода или добавлении новых методов важно понимать, что эти изменения не вступают в силу немедленно. Это ключевой момент для способности Julia статически выводить и компилировать код для быстрой работы без обычных ухищрений JIT и накладных расходов. Действительно, любое новое определение метода не будет видно текущей среде выполнения, включая задачи и потоки (и ранее определенные @generated функции). Давайте начнём с примера, чтобы увидеть, что это означает:

julia> function tryeval()
           @eval newfun() = 1
           newfun()
       end
tryeval (generic function with 1 method)

julia> tryeval()
ERROR: MethodError: no method matching newfun()
The applicable method may be too new: running in world age xxxx1, while current world is xxxx2.
Closest candidates are:
  newfun() at none:1 (method too new to be called from this world context.)
 in tryeval() at none:1
 ...

julia> newfun()
1

В этом примере обратите внимание, что новое определение для newfun было создано, но не может быть вызвано немедленно. Новый глобальный объект немедленно виден функции tryeval, поэтому вы можете написать return newfun (без скобок). Но ни вы, ни ваши вызывающие функции, ни функции, которые они вызывают, и т.д. не могут вызвать это новое определение метода!

Но есть исключение: будущие вызовы к newfun из REPL работают как ожидается, имея возможность увидеть и вызвать новое определение newfun.

Однако будущие вызовы к tryeval будут по-прежнему видеть определение newfun как оно было на предыдущей инструкции в REPL, и, следовательно, до того вызова tryeval.

Возможно, вы захотите попробовать это сами, чтобы увидеть, как это работает.

Реализация этого поведения — «счётчик возраста мира». Это монотонно возрастающее значение отслеживает каждую операцию определения метода. Это позволяет описать «множество определений методов, видимых данной среде выполнения», как единственное число или «возраст мира». Это также позволяет сравнивать методы, доступные в двух мирах, просто сравнивая их порядковое значение. В примере выше мы видим, что «текущий мир» (в котором существует метод newfun), на единицу больше, чем локальный для задачи «мир выполнения», который был фиксирован при запуске выполнения tryeval.

Иногда необходимо обойти это (например, если вы реализуете вышеупомянутый REPL). К счастью, есть простое решение: вызовите функцию с помощью Base.invokelatest:

julia> function tryeval2()
           @eval newfun2() = 2
           Base.invokelatest(newfun2)
       end
tryeval2 (generic function with 1 method)

julia> tryeval2()
2

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

julia> f(x) = "original definition"
f (generic function with 1 method)

Начните другие операции, которые используют f(x):

julia> g(x) = f(x)
g (generic function with 1 method)

julia> t = @async f(wait()); yield();

Теперь добавим новые методы к f(x):

julia> f(x::Int) = "definition for Int"
f (generic function with 2 methods)

julia> f(x::Type{Int}) = "definition for Type{Int}"
f (generic function with 3 methods)

Сравните, как эти результаты отличаются:

julia> f(1)
"definition for Int"

julia> g(1)
"definition for Int"

julia> fetch(schedule(t, 1))
"original definition"

julia> t = @async f(wait()); yield();

julia> fetch(schedule(t, 1))
"definition for Int"

Шаблоны проектирования с параметрическими методами

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

Извлечение параметра типа из супертипа

Вот правильный шаблон кода для возвращения типа элемента T любого произвольного подтипа AbstractArray:

abstract type AbstractArray{T, N} end
eltype(::Type{<:AbstractArray{T}}) where {T} = T

используя так называемую треугольную диспетчеризацию. Обратите внимание, что если T является типом UnionAll , как, например, eltype(Array{T} where T <: Integer), то Any возвращается (как и версия eltype в Base).

Другой способ, который раньше был единственным правильным способом до появления треугольной диспетчеризации в Julia v0.6, это:

abstract type AbstractArray{T, N} end
eltype(::Type{AbstractArray}) = Any
eltype(::Type{AbstractArray{T}}) where {T} = T
eltype(::Type{AbstractArray{T, N}}) where {T, N} = T
eltype(::Type{A}) where {A<:AbstractArray} = eltype(supertype(A))

Ещё одним вариантом является следующий, который может быть полезным для адаптации к случаям, когда параметр T нужно сопоставить более узко:

eltype(::Type{AbstractArray{T, N} where {T<:S, N<:M}}) where {M, S} = Any
eltype(::Type{AbstractArray{T, N} where {T<:S}}) where {N, S} = Any
eltype(::Type{AbstractArray{T, N} where {N<:M}}) where {M, T} = T
eltype(::Type{AbstractArray{T, N}}) where {T, N} = T
eltype(::Type{A}) where {A <: AbstractArray} = eltype(supertype(A))

Распространённая ошибка — попытка получить тип элемента с помощью интроспекции:

eltype_wrong(::Type{A}) where {A<:AbstractArray} = A.parameters[1]

Однако несложно создать случаи, когда это потерпит неудачу:

struct BitVector <: AbstractArray{Bool, 1}; end

Здесь мы создали тип BitVector, который не имеет параметров, но где тип элемента по-прежнему полностью определён, с T равным Bool!

Создание подобного типа с другим параметром типа

При создании универсального кода часто возникает необходимость в создании аналогичного объекта с некоторыми изменениями в структуре типа, что также требует изменения параметров типа. Например, у вас может быть некий абстрактный массив с произвольным типом элементов, и вы хотите написать вычисление на нём со специфическим типом элементов. Мы должны реализовать метод для каждого AbstractArray{T} подтипа, который описывает, как вычислить это преобразование типа. Нет общего преобразования одного подтипа в другой подтип с другим параметром. (Быстрый обзор: вы видите, почему это так?)

Подтипы AbstractArray обычно реализуют два метода для достижения этого: метод для преобразования входного массива в подтип определённого AbstractArray{T, N} абстрактного типа; и метод для создания нового неинициализированного массива с определённым типом элементов. Примеры реализации этих методов можно найти в Julia Base. Вот простой пример использования их, гарантирующий, что input и output являются одного типа:

input = convert(AbstractArray{Eltype}, input)
output = similar(input, Eltype)

В качестве расширения этого, в случаях, когда алгоритм требует копии входного массива, convert недостаточно, так как возвращаемое значение может ссылаться на исходный ввод. Объединение similar (для создания выходного массива) и copyto! (для заполнения его входными данными) — универсальный способ выразить требование к изменяемой копии входного аргумента:

copy_with_eltype(input, Eltype) = copyto!(similar(input, Eltype), input)

Итерационная диспетчеризация

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

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

# First dispatch selects the map algorithm for element-wise summation.
+(a::Matrix, b::Matrix) = map(+, a, b)
# Then dispatch handles each element and selects the appropriate
# common element type for the computation.
+(a, b) = +(promote(a, b)...)
# Once the elements have the same type, they can be added.
# For example, via primitive operations exposed by the processor.
+(a::Float64, b::Float64) = Core.add(a, b)

Диспетчеризация на основе признаков

Естественным расширением итерационной диспетчеризации выше является добавление уровня к выбору метода, который позволяет диспетчеризовать по наборам типов, независимым от наборов, определённых иерархией типов. Мы могли бы построить такой набор, записав Union типов, но тогда этот набор не был бы расширяемым, так как Union типы не могут быть изменены после создания. Однако такой расширяемый набор может быть запрограммирован с помощью шаблона проектирования, часто называемого «священным признаком».

Этот шаблон реализуется путём определения универсальной функции, которая вычисляет разные одиночные значения (или типы) для каждого набора признаков, к которому могут принадлежать аргументы функции. Если эта функция является чистой, то на производительность не влияет по сравнению с обычной диспетчеризацией.

Пример в предыдущем разделе упускает из виду детали реализации map и promote, которые оба работают в терминах этих признаков. При итерации по матрице, например, в реализации map, важный вопрос заключается в том, в каком порядке проходить данные. Когда AbstractArray подтипы реализуют признак Base.IndexStyle, другие функции, такие как map могут диспетчеризоваться по этой информации, чтобы выбрать лучший алгоритм (см. Интерфейс абстрактного массива). Это означает, что каждый подтип не должен реализовывать собственную версию map, так как общие определения + классы признаков позволят системе выбрать наиболее быструю версию. Вот пример реализации map , иллюстрирующий диспетчеризацию на основе признаков:

map(f, a::AbstractArray, b::AbstractArray) = map(Base.IndexStyle(a, b), f, a, b)
# generic implementation:
map(::Base.IndexCartesian, f, a::AbstractArray, b::AbstractArray) = ...
# linear-indexing implementation (faster)
map(::Base.IndexLinear, f, a::AbstractArray, b::AbstractArray) = ...

Этот подход на основе признаков также присутствует в механизме promote, используемом скалярным +. Он использует promote_type, которая возвращает оптимальный общий тип для вычисления операции, учитывая два типа операндов. Это позволяет уменьшить проблему реализации каждой функции для каждой пары возможных аргументов типа до гораздо меньшей проблемы реализации операции преобразования из каждого типа в общий тип плюс таблицу предпочтительных правил повышения пар.

Вычисление типа результата

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

Для реализации примитивных операций, таких как сложение, мы используем функцию promote_type для вычисления желаемого типа результата. (Как и раньше, мы видели это в работе в вызове promote в вызове +).

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

  1. Напишите небольшую функцию op, которая выражает набор операций, выполняемых ядром алгоритма.
  2. Вычислите тип элемента R результирующей матрицы как promote_op(op, argument_types...), где argument_types вычисляется из eltype для каждого входного массива.
  3. Создайте выходную матрицу как similar(R, dims), где dims — желаемые размеры выходного массива.

Для более конкретного примера псевдокод общего умножения квадратных матриц может выглядеть следующим образом:

function matmul(a::AbstractMatrix, b::AbstractMatrix)
    op = (ai, bi) -> ai * bi + ai * bi

    ## this is insufficient because it assumes `one(eltype(a))` is constructable:
    # R = typeof(op(one(eltype(a)), one(eltype(b))))

    ## this fails because it assumes `a[1]` exists and is representative of all elements of the array
    # R = typeof(op(a[1], b[1]))

    ## this is incorrect because it assumes that `+` calls `promote_type`
    ## but this is not true for some types, such as Bool:
    # R = promote_type(ai, bi)

    # this is wrong, since depending on the return value
    # of type-inference is very brittle (as well as not being optimizable):
    # R = Base.return_types(op, (eltype(a), eltype(b)))

    ## but, finally, this works:
    R = promote_op(op, eltype(a), eltype(b))
    ## although sometimes it may give a larger type than desired
    ## it will always give a correct type

    output = similar(b, R, (size(a, 1), size(b, 2)))
    if size(a, 2) > 0
        for j in 1:size(b, 2)
            for i in 1:size(a, 1)
                ## here we don't use `ab = zero(R)`,
                ## since `R` might be `Any` and `zero(Any)` is not defined
                ## we also must declare `ab::R` to make the type of `ab` constant in the loop,
                ## since it is possible that typeof(a * b) != typeof(a * b + a * b) == R
                ab::R = a[i, 1] * b[1, j]
                for k in 2:size(a, 2)
                    ab += a[i, k] * b[k, j]
                end
                output[i, j] = ab
            end
        end
    end
    return output
end

Разделение логики преобразования и ядра

Один из способов значительно сократить время компиляции и сложность тестирования — изолировать логику преобразования в желаемый тип и вычисление. Это позволяет компилятору специализировать и встраивать логику преобразования независимо от остальной части тела более крупного ядра.

Это распространённый шаблон, используемый при преобразовании из более широкого класса типов в один конкретный тип аргумента, который на самом деле поддерживается алгоритмом:

complexfunction(arg::Int) = ...
complexfunction(arg::Any) = complexfunction(convert(Int, arg))

matmul(a::T, b::T) = ...
matmul(a, b) = matmul(promote(a, b)...)

Методы Varargs с параметрическими ограничениями

Параметры функций также могут использоваться для ограничения количества аргументов, которые могут быть переданы функции "varargs" (Функции Varargs). Нотация Vararg{T,N} используется для обозначения такого ограничения. Например:

julia> bar(a,b,x::Vararg{Any,2}) = (a,b,x)
bar (generic function with 1 method)

julia> bar(1,2,3)
ERROR: MethodError: no method matching bar(::Int64, ::Int64, ::Int64)
Closest candidates are:
  bar(::Any, ::Any, ::Any, !Matched::Any) at none:1

julia> bar(1,2,3,4)
(1, 2, (3, 4))

julia> bar(1,2,3,4,5)
ERROR: MethodError: no method matching bar(::Int64, ::Int64, ::Int64, ::Int64, ::Int64)
Closest candidates are:
  bar(::Any, ::Any, ::Any, ::Any) at none:1

Более полезно, можно ограничить методы varargs параметром. Например:

function getindex(A::AbstractArray{T,N}, indices::Vararg{Number,N}) where {T,N}

будет вызван только тогда, когда количество indices соответствует размерности массива.

Если нужно ограничить только тип передаваемых аргументов, Vararg{T} можно эквивалентно записать как T.... Например f(x::Int...) = x — сокращённая запись для f(x::Vararg{Int}) = x.

Примечание об необязательных и именованных аргументах

Как кратко упоминалось в Функциях, необязательные аргументы реализуются как синтаксис для нескольких определений методов. Например, это определение:

f(a=1,b=2) = a+2b

преобразуется в следующие три метода:

f(a,b) = a+2b
f(a) = f(a,2)
f() = f(1,2)

Это означает, что вызов f() эквивалентен вызову f(1,2). В этом случае результатом является 5, потому что f(1,2) вызывает первый метод f выше. Однако это не всегда так. Если вы определите четвёртый метод, который более специализирован для целых чисел:

f(a::Int,b::Int) = a-2b

то результат как f(), так и f(1,2) будет -3. Другими словами, необязательные аргументы связаны с функцией, а не с каким-либо конкретным методом этой функции. От типов необязательных аргументов зависит, какой метод будет вызван. Если необязательные аргументы определены через глобальную переменную, тип необязательного аргумента может даже измениться во время выполнения.

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

Объекты, подобные функциям

Методы связаны с типами, поэтому можно сделать любой произвольный объект Julia «вызываемым», добавив методы к его типу. (Такие «вызываемые» объекты иногда называют «функторами».)

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

julia> struct Polynomial{R}
           coeffs::Vector{R}
       end

julia> function (p::Polynomial)(x)
           v = p.coeffs[end]
           for i = (length(p.coeffs)-1):-1:1
               v = v*x + p.coeffs[i]
           end
           return v
       end

julia> (p::Polynomial)() = p(5)

Обратите внимание, что функция задаётся типом, а не именем. Как и для обычных функций, существует краткая синтаксическая форма. В теле функции p будет ссылаться на объект, который был вызван. Объект Polynomial может использоваться следующим образом:

julia> p = Polynomial([1,10,100])
Polynomial{Int64}([1, 10, 100])

julia> p(3)
931

julia> p()
2551

Этот механизм также является ключевым для работы конструкторов типов и замыканий (внутренних функций, которые ссылаются на своё окружающее окружение) в Julia.

Пустые обобщённые функции

Иногда бывает полезно ввести обобщённую функцию, ещё не добавив методы. Это может быть использовано для разделения определений интерфейса и реализаций. Это также может быть сделано для целей документирования или удобочитаемости кода. Синтаксис для этого — пустой function блок без кортежа аргументов:

function emptyfunc
end

Проектирование методов и избегание неоднозначностей

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

Выше было указано, что можно разрешить неоднозначности, такие как

f(x, y::Int) = 1
f(x::Int, y) = 2

определением метода

f(x::Int, y::Int) = 3

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

Ниже мы обсудим конкретные трудности и некоторые альтернативные способы решения таких проблем.

Аргументы Tuple и NTuple

Аргументы Tuple (и NTuple) представляют собой особые трудности. Например,

f(x::NTuple{N,Int}) where {N} = 1
f(x::NTuple{N,Float64}) where {N} = 2

неоднозначны из-за возможности N == 0: нет элементов, которые позволили бы определить, какой вариант Int или Float64 следует вызвать. Чтобы разрешить неоднозначность, один из подходов — определение метода для пустого кортежа:

f(x::Tuple{}) = 3

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

f(x::NTuple{N,Int}) where {N} = 1           # this is the fallback
f(x::Tuple{Float64, Vararg{Float64}}) = 2   # this requires at least one Float64

Ортогонализуйте свой дизайн

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

f(x::A, y::A) = ...
f(x::A, y::B) = ...
f(x::B, y::A) = ...
f(x::B, y::B) = ...

можно определить

f(x::A, y::A) = ...
f(x, y) = f(g(x), g(y))

где g преобразует аргумент в тип A. Это очень специфический пример более общего принципа ортогонального проектирования, в котором разные понятия назначаются разным методам. Здесь g скорее всего, потребуется определение по умолчанию:

g(x::A) = x

Связанная стратегия использует promote для приведения x и y к общему типу:

f(x::T, y::T) where {T} = ...
f(x, y) = f(promote(x, y)...)

Одна опасность этого дизайна — возможность того, что если нет подходящего метода повышения типа x и y до одного и того же типа, второй метод будет рекурсивно вызывать себя бесконечно и вызовет переполнение стека.

Диспетчеризация по одному аргументу за раз

Если вам нужно диспетчеризовать по нескольким аргументам, а у вас много обратных вызовов с слишком многими комбинациями, чтобы сделать практичным определение всех возможных вариантов, рассмотрите введение "каскада имён", где (например) вы диспетчеризуете по первому аргументу, а затем вызываете внутренний метод:

f(x::A, y) = _fA(x, y)
f(x::B, y) = _fB(x, y)

Тогда внутренние методы _fA и _fB могут диспетчеризовать по y без опасений о неоднозначностях друг с другом относительно x.

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

Абстрактные контейнеры и типы элементов

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

-(A::AbstractArray{T}, b::Date) where {T<:Date}

порождает неоднозначности для тех, кто определяет метод

-(A::MyArrayType{T}, b::T) where {T}

Лучший подход — избегать определения любого из этих методов: вместо этого полагайтесь на обобщённый метод -(A::AbstractArray, b) и убедитесь, что этот метод реализован с обобщёнными вызовами (например, similar и - ), которые делают правильные вещи для каждого типа контейнера и типа элемента отдельно. Это просто более сложная версия совета об ортогонализации ваших методов.

Когда этот подход невозможен, может стоить начать обсуждение с другими разработчиками о разрешении неоднозначности; просто потому, что один метод был определён первым, не обязательно означает, что его нельзя изменить или устранить. В крайнем случае, один разработчик может определить метод "пластырь"

-(A::MyArrayType{T}, b::Date) where {T<:Date} = ...

который решает неоднозначность путём грубой силы.

Сложные каскады методов с аргументами по умолчанию

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

function myfilter(A, kernel, ::Replicate)
    Apadded = replicate_edges(A, size(kernel))
    myfilter(Apadded, kernel)  # now perform the "real" computation
end

Это приведёт к конфликту с методом, который предоставляет значения по умолчанию для заполнения:

myfilter(A, kernel) = myfilter(A, kernel, Replicate()) # replicate the edge by default

Вместе эти два метода порождают бесконечную рекурсию с A постоянно увеличивающейся.

Лучшим дизайном было бы определить вашу иерархию вызовов так:

struct NoPad end  # indicate that no padding is desired, or that it's already applied

myfilter(A, kernel) = myfilter(A, kernel, Replicate())  # default boundary conditions

function myfilter(A, kernel, ::Replicate)
    Apadded = replicate_edges(A, size(kernel))
    myfilter(Apadded, kernel, NoPad())  # indicate the new boundary conditions
end

# other padding methods go here

function myfilter(A, kernel, ::NoPad)
    # Here's the "real" implementation of the core computation
end

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

[Clarke61]

Артюр К. Кларк, Профили будущего (1961): Третий закон Кларка.

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

Spec-Zone.ru

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