Spec-Zone.ru › Julia 0.7

Методы

Вспомним из раздела Функции, что функция — это объект, который сопоставляет кортеж аргументов со значением возврата или выбрасывает исключение, если подходящее значение вернуть нельзя. Часто для одной и той же концептуальной функции или операции реализация существенно отличается в зависимости от типов аргументов: сложение двух целых чисел отличается от сложения двух чисел с плавающей запятой, и то и другое отличается от сложения целого числа и числа с плавающей запятой. Несмотря на различия в реализации, все эти операции подпадают под общее понятие «сложения». Соответственно, в 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-битное число с плавающей запятой, а другой — нет, то метод 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, y::Float64) in Main at none:1
  g(x::Float64, y) 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<:Number, ::T<:Number) 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(b, 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 в один и тот же тип, второй метод будет рекурсивно вызывать себя бесконечно и вызовет переполнение стека. Неэкспортируемая функция Base.promote_noncircular может использоваться как альтернатива; при неудачном повышении она всё равно выдаст ошибку, но ошибку, которая произойдет быстрее с более конкретным сообщением об ошибке.

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

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

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–2019 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v0.7.0/manual/methods/

Spec-Zone.ru

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