Spec-Zone.ru › Julia 1.4

Методы

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

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

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

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

До сих пор в наших примерах мы определяли только функции с одним методом, имеющим неограниченные типы аргументов. Такие функции ведут себя так же, как и в традиционных языках с динамической типизацией. Тем не менее, мы использовали многократное диспетчерирование и методы почти постоянно, не осознавая этого: все стандартные функции и операторы 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::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)

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

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

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

# 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-типы не могут быть изменены после создания. Однако такой расширяемый набор можно запрограммировать с помощью шаблона проектирования, часто называемого "Святым свойством".

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

Пример в предыдущем разделе пропустил реализацию деталей 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 передаётся в той же позиции аргумента, что и любой другой вид отступа, поэтому он поддерживает чёткую иерархию вызова с уменьшенной вероятностью неоднозначностей. Кроме того, он расширяет интерфейс "public" myfilter: пользователь, который хочет явно управлять отступом, может напрямую вызвать вариант NoPad.

  • 1Например, в C++ или Java, при вызове метода, как obj.meth(arg1,arg2), объект obj «получает» вызов метода и неявно передаётся методу через ключевое слово this, а не в качестве явного аргумента метода. Когда текущий объект this является получателем вызова метода, его можно вообще опустить, написав просто meth(arg1,arg2), при этом this подразумевается как принимающий объект.
  • 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.4.2/manual/methods/

Spec-Zone.ru

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