Spec-Zone.ru › Julia 1.9

Методы

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

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

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

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

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

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

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

julia> f(x::Float64, y::Float64) = 2x + y
f (generic function with 1 method)

Это определение функции применяется только к вызовам, где x и y являются значениями типа Float64:

julia> f(2.0, 3.0)
7.0

При применении ее к другим типам аргументов появится ошибка MethodError:

julia> f(2.0, 3)
ERROR: MethodError: no method matching f(::Float64, ::Int64)

Closest candidates are:
  f(::Float64, !Matched::Float64)
   @ 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 не являются магическими и полностью явными. Преобразование и продвижение, однако, показывает, как продуманное применение достаточно передовых технологий может быть незаметно от магии. [Clarke61]

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

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

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

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

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

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

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

```julia 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 подразумевается как получающий объект.
  • Clarke61Артур К. Кларк, Профили будущего (1961): Третий закон Кларка.

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

Spec-Zone.ru

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