Документация Engee

Методы

Как говорилось в главе Функции, функция — это объект, который сопоставляет кортеж аргументов с возвращаемым значением или вызывает исключение, если соответствующее значение не может быть возвращено. Обычно одна и та же концептуальная функция или операция реализуется совершенно по-разному для разных типов аргументов: сложение двух целых чисел значительно отличается от сложения двух чисел с плавающей запятой, и оба эти принципа отличаются от сложения целого числа с числом с плавающей запятой. Несмотря на различия в реализации, все эти операции подпадают под общее понятие «сложение». Соответственно, в 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)
   @ Main none:1

Stacktrace:
[...]

julia> f(Float32(2.0), 3.0)
ERROR: MethodError: no method matching f(::Float32, ::Float64)

Closest candidates are:
  f(!Matched::Float64, ::Float64)
   @ Main none:1

Stacktrace:
[...]

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

Closest candidates are:
  f(::Float64, !Matched::Float64)
   @ Main none:1

Stacktrace:
[...]

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

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

julia> f("foo", 3)
ERROR: MethodError: no method matching f(::String, ::Int64)

Closest candidates are:
  f(!Matched::Number, ::Number)
   @ Main none:1

Stacktrace:
[...]

julia> f()
ERROR: MethodError: no method matching f()

Closest candidates are:
  f(!Matched::Float64, !Matched::Float64)
   @ Main none:1
  f(!Matched::Number, !Matched::Number)
   @ Main none:1

Stacktrace:
[...]

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

julia> f
f (generic function with 2 methods)

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

julia> methods(f)
# Два метода для универсальной функции f из Main:
 [1] f(x::Float64, y::Float64)
     @ none:1
 [2] f(x::Number, y::Number)
     @ 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)
# Три метода для универсальной функции f из Main:
 [1] f(x::Float64, y::Float64)
     @ none:1
 [2] f(x::Number, y::Number)
     @ none:1
 [3] f(x, y)
     @ none:1

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

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

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

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

julia> methods(+)
# 180 методов для универсальной функции «+»:
[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 можно абстрактно выражать высокоуровневые алгоритмы, отделенные от деталей реализации.

Специализация методов

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

Есть еще один вид специализации, который происходит без вмешательства программиста: Компилятор Julia может автоматически специализировать метод для используемых типов аргументов. Такие специализации не включаются в список, выводимый функцией methods, так при этом новые методы (Method) не создаются. Однако их можно просматривать с помощью таких средств, как @code_typed.

Например, при создании метода

mysum(x::Real, y::Real) = x + y

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

julia> mysum(1, 2)
3

julia> mysum(1.0, 2.0)
3.0

Julia скомпилирует mysum дважды: один раз для x::Int, y::Int и еще раз для x::Float64, y::Float64. Смысл двойной компиляции заключается в повышении производительности: методы, вызываемые для + (и используемые mysum), различаются в зависимости от типов x и y, и благодаря компиляции разных специализаций в Julia становится возможен заблаговременный поиск методов. Это позволяет программе выполняться гораздо быстрее, так как во время выполнения ей не приходится заниматься поиском методов. Автоматическая специализация в Julia позволяет писать универсальные алгоритмы, ожидая, что компилятор сгенерирует эффективный, специализированный код для каждого необходимого случая.

В ситуациях, когда количество возможных специализаций может быть неограниченным, этот механизм специализации по умолчанию в Julia может не применяться. Дополнительные сведения см. в разделе Учитывайте то, когда специализация в 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)
    @ Main none:1
  g(x, y::Float64)
    @ Main none:1

Possible fix, define
  g(::Float64, ::Float64)

Stacktrace:
[...]

Здесь вызов 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
   @ Main 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
   @ Main 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
   @ Main none:1
  same_type_numeric(!Matched::Number, ::Number)
   @ Main none:1

Stacktrace:
[...]

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

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

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

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

Однако несложно смоделировать случаи, когда она завершается сбоем:

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

Здесь создан тип BitVector, который не имеет параметров, но тип элемента все еще полностью определен, а T имеет значение Bool.

Еще одна ошибка заключается в попытке пройти вверх по иерархии типов с помощью supertype:

eltype_wrong(::Type{AbstractArray{T}}) where {T} = T
eltype_wrong(::Type{AbstractArray{T, N}}) where {T, N} = T
eltype_wrong(::Type{A}) where {A<:AbstractArray} = eltype_wrong(supertype(A))

Хотя этот подход работает для объявленных типов, он завершается сбоем для типов без супертипов:

julia> eltype_wrong(Union{AbstractArray{Int}, AbstractArray{Float64}})
ERROR: MethodError: no method matching supertype(::Type{Union{AbstractArray{Float64,N} where N, AbstractArray{Int64,N} where N}})
Closest candidates are:
  supertype(::DataType) at operators.jl:43
  supertype(::UnionAll) at operators.jl:48

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

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

# Сначала диспетчеризация выбирает алгоритм сопоставления для поэлементного суммирования.
+(a::Matrix, b::Matrix) = map(+, a, b)
# Затем диспетчеризация обрабатывает каждый элемент и выбирает подходящий
# общий тип элемента для вычисления.
+(a, b) = +(promote(a, b)...)
# После получения элементов одинакового типа их можно сложить.
# Например, через примитивные операции, доступные в процессоре.
+(a::Float64, b::Float64) = Core.add(a, b)

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

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

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

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

map(f, a::AbstractArray, b::AbstractArray) = map(Base.IndexStyle(a, b), f, a, b)
# универсальная реализация:
map(::Base.IndexCartesian, f, a::AbstractArray, b::AbstractArray) = ...
# реализация с линейным индексированием (более быстрая)
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

    ## этого недостаточно, поскольку предполагается, что `one(eltype(a))` является конструируемым:
    # R = typeof(op(one(eltype(a)), one(eltype(b))))

    ## это завершается сбоем, поскольку предполагается, что `a[1]` существует и является представителем всех элементов массива
    # R = typeof(op(a[1], b[1]))

    ## это неверно, поскольку предполагается, что `+` вызывает `promote_type`
    ## но это не является истинным для некоторых типов, таких как Bool:
    # R = promote_type(ai, bi)

    # это неправильно, поскольку зависимость от возвращаемого значения
    # при выводе типов очень хрупкая (а также не оптимизируемая):
    # R = Base.return_types(op, (eltype(a), eltype(b)))

    ## но в конечном итоге это работает:
    R = promote_op(op, eltype(a), eltype(b))
    ## хотя иногда результатом может быть более крупный тип, чем нужно
    ## всегда будет выдан правильный тип

    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)
                ## здесь не используется `ab = zero(R)`,
                ## поскольку `R` может иметь значение `Any`, а `zero(Any)` не определен
                ## также необходимо объявить `ab::R`, чтобы сделать тип `ab` постоянным в цикле,
                ## поскольку возможно, что 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) (Функции с переменным количеством аргументов). Для указания такого содержимого используется нотация 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)
   @ Main none:1

Stacktrace:
[...]

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)
   @ Main none:1

Stacktrace:
[...]

Более полезным является ограничение методов с переменным количеством аргументов параметром. Пример:

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

Аргументы TupleNTuple) создают особые проблемы. Например,

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           # это резервный вариант
f(x::Tuple{Float64, Vararg{Float64}}) = 2   # для этого требуется по крайней мере один тип 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)  # теперь выполняется «реальное» вычисление
end

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

myfilter(A, kernel) = myfilter(A, kernel, Replicate()) # реплицирует границу по умолчанию

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

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

struct NoPad end  # указывает, что заполнение не требуется или что оно уже применено

myfilter(A, kernel) = myfilter(A, kernel, Replicate())  # предельные условия по умолчанию

function myfilter(A, kernel, ::Replicate)
    Apadded = replicate_edges(A, size(kernel))
    myfilter(Apadded, kernel, NoPad())  # указывает новые предельные условия
end

# здесь идут другие методы заполнения

function myfilter(A, kernel, ::NoPad)
    # Это и есть «реальная» реализация вычисления ядра
end

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

Определение методов в локальной области

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

julia> function f(x)
           g(y::Int) = y + x
           g(y) = y - x
           g
       end
f (generic function with 1 method)

julia> h = f(3);

julia> h(4)
7

julia> h(4.0)
1.0

Однако локальные методы не следует определять условным образом или в зависимости от порядка выполнения, например:

function f2(inc)
    if inc
        g(x) = x + 1
    else
        g(x) = x - 1
    end
end

function f3()
    function g end
    return g
    g() = 0
end

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

В таких случаях используйте анонимные функции:

function f2(inc)
    g = if inc
        x -> x + 1
    else
        x -> x - 1
    end
end

1. Например, в C++ или Java в вызове метода типа obj.meth(arg1,arg2) объект obj «получает» вызов метода и неявным образом передается методу через ключевое слово this, а не как явный аргумент метода. Когда текущий объект this является получателем вызова метода, его можно вообще опустить, написав только meth(arg1,arg2), где this подразумевается как принимающий объект. !!! note Во всех примерах в этой главе предполагается, что методы для функции определяются в одном и том же модуле. Чтобы добавить методы к функции в другом модуле, необходимо импортировать его с помощью import или использовать имя, дополненное именами модулей. См. раздел об управлении пространствами имен.
2. Arthur C. Clarke, Profiles of the Future (1961): Clarke’s Third Law.