Methods

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

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

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

Note

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

Defining Methods

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

При определении функции можно опционально ограничить типы параметров, к которым она применима, используя оператор утверждения типа ::, введенный в разделе Composite Types:

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)
The function `f` exists, but no method is defined for this combination of argument types.

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)
The function `f` exists, but no method is defined for this combination of argument types.

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)
The function `f` exists, but no method is defined for this combination of argument types.

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)
The function `f` exists, but no method is defined for this combination of argument types.

Как вы можете видеть, аргументы должны быть точно типа 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 не магические и полностью явные. Conversion and Promotion, однако, показывает, как умелое применение достаточно продвинутых технологий может быть неотличимо от магии. [Clarke61]

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

julia> f("foo", 3)
ERROR: MethodError: no method matching f(::String, ::Int64)
The function `f` exists, but no method is defined for this combination of argument types.

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

Stacktrace:
[...]

julia> f()
ERROR: MethodError: no method matching f()
The function `f` exists, but no method is defined for this combination of argument types.

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)
# 2 methods for generic function "f" from 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)
# 3 methods for generic function "f" from 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 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 способность абстрактно выражать высокоуровневые алгоритмы, отделенные от деталей реализации.

Method specializations

Когда вы создаете несколько методов одной и той же функции, это иногда называется "специализацией". В этом случае вы специализируете функцию, добавляя к ней дополнительные методы: каждый новый метод является новой специализацией функции. Как показано выше, эти специализации возвращаются с помощью 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 может избежать этой специальной настройки. См. Be aware of when Julia avoids specializing для получения дополнительной информации.

Method Ambiguities

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

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)
    @ Main none:1
  g(x::Float64, y)
    @ 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

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

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

Parametric Methods

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

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 Types).

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

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

Параметр типа T в этом примере гарантирует, что добавляемый элемент x является подтипом существующего типа элементов векторa v. Ключевое слово where вводит список этих ограничений после определения сигнатуры метода. Это работает так же для однострочных определений, как показано выше, и должно появляться до return type declaration, если оно присутствует, как показано ниже:

julia> (myappend(v::Vector{T}, x::T)::Vector) 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)
The function `myappend` exists, but no method is defined for this combination of argument types.

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)
The function `myappend` exists, but no method is defined for this combination of argument types.

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

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

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)
The function `same_type_numeric` exists, but no method is defined for this combination of argument types.

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)
The function `same_type_numeric` exists, but no method is defined for this combination of argument types.

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

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

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

Redefining Methods

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

Design Patterns with Parametric Methods

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

Extracting the type parameter from a super-type

Вот правильный шаблон кода для возврата типа элемента 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

Building a similar type with a different type parameter

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

Iterated dispatch

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

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

Trait-based dispatch

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

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

Пример в предыдущем разделе обошел детали реализации map и promote, которые оба работают в терминах этих черт. При итерации по матрице, например, в реализации map, важным вопросом является, какой порядок использовать для обхода данных. Когда подтипы AbstractArray реализуют черту Base.IndexStyle, другие функции, такие как map, могут использовать эту информацию для выбора наилучшего алгоритма (см. Abstract Array Interface). Это означает, что каждому подтипу не нужно реализовывать собственную версию 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, который возвращает оптимальный общий тип для вычисления операции, учитывая два типа операндов. Это позволяет свести задачу реализации каждой функции для каждой пары возможных аргументов типов к гораздо меньшей задаче реализации операции преобразования из каждого типа в общий тип, плюс таблица предпочтительных правил попарного повышения.

Output-type computation

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

Для реализации примитивных операций, таких как сложение, мы используем функцию 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

Separate convert and kernel logic

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

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

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

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

Parametrically-constrained Varargs methods

Параметры функции также могут использоваться для ограничения количества аргументов, которые могут быть переданы функции "varargs" (Varargs Functions). Нотация 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)
The function `bar` exists, but no method is defined for this combination of argument types.

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)
The function `bar` exists, but no method is defined for this combination of argument types.

Closest candidates are:
  bar(::Any, ::Any, ::Any, ::Any)
   @ Main none:1

Stacktrace:
[...]

Более полезно, что можно ограничить методы 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.

Note on Optional and keyword Arguments

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

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

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

Function-like objects

Методы связаны с типами, поэтому возможно сделать любой произвольный объект 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.

Empty generic functions

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

function emptyfunc end

Method design and the avoidance of ambiguities

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

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

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

определяя метод

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

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

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

Tuple and NTuple arguments

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           # this is the fallback
f(x::Tuple{Float64, Vararg{Float64}}) = 2   # this requires at least one Float64

Orthogonalize your design

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

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. Это очень специфический пример более общего принципа orthogonal design, в котором отдельные концепции назначаются отдельным методам. Здесь g скорее всего потребуется определение по умолчанию.

g(x::A) = x

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

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

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

Dispatch on one argument at a time

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

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

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

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

Abstract containers and element types

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

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

создает неопределенности для любого, кто определяет метод

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

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

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

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

это разрешает неоднозначность грубой силой.

Complex method "cascades" with default arguments

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

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

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

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

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

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

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

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

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

# other padding methods go here

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

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

Defining methods in local scope

Вы можете определить методы внутри local scope, например

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
  • 1In C++ or Java, for example, in a method call like obj.meth(arg1,arg2), the object obj "receives" the method call and is implicitly passed to the method via the this keyword, rather than as an explicit method argument. When the current this object is the receiver of a method call, it can be omitted altogether, writing just meth(arg1,arg2), with this implied as the receiving object.
  • Clarke61Arthur C. Clarke, Profiles of the Future (1961): Clarke's Third Law.