Spec-Zone.ru › Julia 1.6

Методы

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

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

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

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

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

До сих пор в наших примерах мы определяли только функции с одним методом, имеющим неограниченные типы аргументов. Такие функции ведут себя так же, как и в традиционных языках с динамической типизацией. Тем не менее, мы практически постоянно использовали множественную диспетчеризацию и методы, не осознавая этого: все стандартные функции и операторы 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-битным числом с плавающей точкой, а другой нет, то метод 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> methods(f)
# 3 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
[3] f(x, y) in Main at none:1

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

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

Обратите внимание, что в сигнатуре третьего метода тип аргументов x и y не указан. Это сокращённый способ выражения f(x::Any, y::Any).

Хотя это кажется простым понятием, множественная диспетчеризация по типам значений, пожалуй, является самой мощной и центральной особенностью языка 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 Vector{Int64}:
 1
 2
 3
 4

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

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

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

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

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

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

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

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 вариант.

  • 1Например, в C++ или Java, в вызове метода, как obj.meth(arg1,arg2), объект obj «получает» вызов метода и неявно передаётся в метод через ключевое слово this, а не как явный аргумент метода. Когда текущий this объект является получателем вызова метода, его можно опустить, написав просто meth(arg1,arg2), при этом this подразумевается как получающий объект.
  • Clarke61Артур К. Кларк, Профили будущего (1961): Третий закон Кларка.

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

Spec-Zone.ru

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