Методы
Напомним из Функций, что функция — это объект, который сопоставляет кортеж аргументов со значением возврата или вызывает исключение, если соответствующее значение вернуть нельзя. Часто одна и та же концептуальная функция или операция реализуется совершенно по-разному для различных типов аргументов: сложение двух целых чисел сильно отличается от сложения двух чисел с плавающей запятой, и то и другое отличается от сложения целого числа с числом с плавающей запятой. Несмотря на различия в реализации, все эти операции относятся к общей концепции «сложения». Соответственно, в Julia эти поведения принадлежат одному объекту: функции +.
Чтобы обеспечить плавное использование многих разных реализаций одного и того же концепта, функции необязательно определять сразу, но можно определять по частям, предоставляя определённые поведения для определённых комбинаций типов и количества аргументов. Определение одного возможного поведения для функции называется методом. До сих пор мы приводили примеры функций, определённых с одним методом, применимым ко всем типам аргументов. Однако сигнатуры определений методов могут быть снабжены аннотациями, указывающими типы аргументов помимо их количества, и может быть предоставлено более одного определения метода. Когда функция применяется к конкретному кортежу аргументов, применяется наиболее специфичный метод, применимый к этим аргументам. Таким образом, общее поведение функции — это мозаика поведений её различных определений методов. Если мозаика спроектирована правильно, даже если реализации методов могут быть совершенно разными, внешнее поведение функции будет казаться бесшовным и согласованным.
Выбор метода, который будет выполняться при применении функции, называется диспетчеризацией. Julia позволяет процессу диспетчеризации выбирать, какой из методов функции вызвать, основываясь на количестве заданных аргументов и типах всех аргументов функции. Это отличается от традиционных языков объектно-ориентированного программирования, где диспетчеризация происходит только на основе первого аргумента, который часто имеет специальный синтаксис аргумента и иногда подразумевается, а не явно записывается как аргумент. [1] Использование всех аргументов функции для выбора метода, который следует вызвать, а не только первого, известно как множественная диспетчеризация. Множественная диспетчеризация особенно полезна для математического кода, где не имеет смысла искусственно считать, что операции «принадлежат» одному аргументу больше, чем другим: принадлежит ли операция сложения в x + y объекту x больше, чем объекту y? Реализация математического оператора, как правило, зависит от типов всех его аргументов. Однако даже за пределами математических операций множественная диспетчеризация оказывается мощной и удобной парадигмой для структурирования и организации программ.
Определение методов
До сих пор в наших примерах мы определяли только функции с одним методом, имеющим неограниченные типы аргументов. Такие функции ведут себя так же, как и в традиционных языках с динамической типизацией. Тем не менее, мы почти постоянно использовали множественную диспетчеризацию и методы, не осознавая этого: все стандартные функции и операторы Julia, такие как упомянутая функция +, имеют много методов, определяющих их поведение при различных комбинациях типов и количества аргументов.
При определении функции можно необязательно ограничить типы параметров, к которым она применима, используя оператор проверки типа ::, введённый в разделе Составные типы:
julia> f(x::Float64, y::Float64) = 2x + y f (generic function with 1 method)
Это определение функции применяется только к вызовам, где x и y являются значениями типа Float64:
julia> f(2.0, 3.0) 7.0
Применение его к другим типам аргументов приведет к MethodError:
julia> f(2.0, 3)
ERROR: MethodError: no method matching f(::Float64, ::Int64)
Closest candidates are:
f(::Float64, !Matched::Float64) at none:1
julia> f(Float32(2.0), 3.0)
ERROR: MethodError: no method matching f(::Float32, ::Float64)
Closest candidates are:
f(!Matched::Float64, ::Float64) at none:1
julia> f(2.0, "3.0")
ERROR: MethodError: no method matching f(::Float64, ::String)
Closest candidates are:
f(::Float64, !Matched::Float64) at none:1
julia> f("2.0", "3.0")
ERROR: MethodError: no method matching f(::String, ::String)
Как видите, аргументы должны быть точно типа Float64. Другие числовые типы, такие как целые числа или 32-битные числа с плавающей запятой, автоматически не преобразуются в 64-битные числа с плавающей запятой, а строки не интерпретируются как числа. Поскольку Float64 — это конкретный тип, а конкретные типы не могут быть унаследованы в Julia, такое определение может быть применено только к аргументам, которые точно являются типа Float64. Однако часто бывает полезно писать более общие методы, где объявленные типы параметров являются абстрактными:
julia> f(x::Number, y::Number) = 2x - y f (generic function with 2 methods) julia> f(2.0, 3) 1.0
Это определение метода применяется к любой паре аргументов, которые являются экземплярами Number. Они не обязательно должны быть одного типа, если они оба числовые значения. Проблема обработки разных числовых типов делегируется арифметическим операциям в выражении 2x - y.
Чтобы определить функцию с несколькими методами, достаточно определить функцию несколько раз с разным количеством и типами аргументов. Первое определение метода создаёт объект функции, а последующие определения методов добавляют новые методы к существующему объекту функции. При применении функции будет выполнен наиболее специфический метод, соответствующий количеству и типам аргументов. Таким образом, два определения методов выше вместе определяют поведение для f для всех пар экземпляров абстрактного типа Number — но с другим поведением, специфичным для пар значений Float64. Если один из аргументов — 64-битное число с плавающей запятой, а другой — нет, то метод f(Float64,Float64) не может быть вызван, и должен быть использован более общий метод f(Number,Number):
julia> f(2.0, 3.0) 7.0 julia> f(2, 3.0) 1.0 julia> f(2.0, 3) 1.0 julia> f(2, 3) 1
Определение 2x + y используется только в первом случае, а определение 2x - y — во всех остальных. Никакое автоматическое преобразование или приведение типов аргументов функции никогда не выполняется: все преобразования в Julia не являются магическими и полностью явными. Преобразование и продвижение, однако, демонстрирует, как разумное применение достаточно сложных технологий может быть неотличимо от магии. [Clarke61]
Для значений, не являющихся числами, и для меньшего или большего количества аргументов функция f остаётся неопределённой, и её применение по-прежнему приведёт к MethodError:
julia> f("foo", 3)
ERROR: MethodError: no method matching f(::String, ::Int64)
Closest candidates are:
f(!Matched::Number, ::Number) at none:1
julia> f()
ERROR: MethodError: no method matching f()
Closest candidates are:
f(!Matched::Float64, !Matched::Float64) at none:1
f(!Matched::Number, !Matched::Number) at none:1
Вы легко можете увидеть, какие методы существуют для функции, введя сам объект функции в интерактивной сессии:
julia> f f (generic function with 2 methods)
Этот вывод показывает, что f — это объект функции с двумя методами. Чтобы узнать, каковы сигнатуры этих методов, используйте функцию methods:
julia> methods(f) # 2 methods for generic function "f": [1] f(x::Float64, y::Float64) in Main at none:1 [2] f(x::Number, y::Number) in Main at none:1
что показывает, что f имеет два метода, один принимающий два Float64 аргумента и один принимающий аргументы типа Number. Он также указывает файл и строку, где были определены методы: поскольку эти методы были определены в REPL, мы получаем видимый номер строки none:1.
При отсутствии объявления типа с ::, тип параметра метода по умолчанию — Any, что означает, что он не ограничен, так как все значения в Julia являются экземплярами абстрактного типа Any. Таким образом, мы можем определить универсальный метод для f следующим образом:
julia> f(x,y) = println("Whoa there, Nelly.")
f (generic function with 3 methods)
julia> f("foo", 1)
Whoa there, Nelly.
Этот универсальный метод менее специфичен, чем любое другое возможное определение метода для пары значений параметров, поэтому он будет вызван только для пар аргументов, к которым не применяется другое определение метода.
Хотя это кажется простым понятием, множественная диспетчеризация по типам значений, пожалуй, является самой мощной и центральной особенностью языка Julia. Основные операции обычно имеют десятки методов:
julia> methods(+)
# 180 methods for generic function "+":
[1] +(x::Bool, z::Complex{Bool}) in Base at complex.jl:227
[2] +(x::Bool, y::Bool) in Base at bool.jl:89
[3] +(x::Bool) in Base at bool.jl:86
[4] +(x::Bool, y::T) where T<:AbstractFloat in Base at bool.jl:96
[5] +(x::Bool, z::Complex) in Base at complex.jl:234
[6] +(a::Float16, b::Float16) in Base at float.jl:373
[7] +(x::Float32, y::Float32) in Base at float.jl:375
[8] +(x::Float64, y::Float64) in Base at float.jl:376
[9] +(z::Complex{Bool}, x::Bool) in Base at complex.jl:228
[10] +(z::Complex{Bool}, x::Real) in Base at complex.jl:242
[11] +(x::Char, y::Integer) in Base at char.jl:40
[12] +(c::BigInt, x::BigFloat) in Base.MPFR at mpfr.jl:307
[13] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt, e::BigInt) in Base.GMP at gmp.jl:392
[14] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt) in Base.GMP at gmp.jl:391
[15] +(a::BigInt, b::BigInt, c::BigInt) in Base.GMP at gmp.jl:390
[16] +(x::BigInt, y::BigInt) in Base.GMP at gmp.jl:361
[17] +(x::BigInt, c::Union{UInt16, UInt32, UInt64, UInt8}) in Base.GMP at gmp.jl:398
...
[180] +(a, b, c, xs...) in Base at operators.jl:424
Множественная диспетчеризация вместе с гибкой параметрической системой типов позволяют Julia абстрактно выражать алгоритмы высокого уровня, не привязанные к деталям реализации, но при этом генерировать эффективный, специализированный код для обработки каждого случая во время выполнения.
Неопределённости методов
Возможна ситуация, когда определено множество методов функции так, что нет единственного наиболее специфического метода, применимого к некоторым комбинациям аргументов:
julia> g(x::Float64, y) = 2x + y g (generic function with 1 method) julia> g(x, y::Float64) = x + 2y g (generic function with 2 methods) julia> g(2.0, 3) 7.0 julia> g(2, 3.0) 8.0 julia> g(2.0, 3.0) ERROR: MethodError: g(::Float64, ::Float64) is ambiguous. Candidates: g(x::Float64, y) in Main at none:1 g(x, y::Float64) in Main at none:1 Possible fix, define g(::Float64, ::Float64)
Здесь вызов g(2.0, 3.0) может быть обработан либо методом g(Float64, Any), либо методом g(Any, Float64), и ни один из них не является более специфичным, чем другой. В таких случаях Julia вызывает MethodError, а не произвольно выбирает метод. Можно избежать неопределённостей методов, указав подходящий метод для случая пересечения:
julia> g(x::Float64, y::Float64) = 2x + 2y g (generic function with 3 methods) julia> g(2.0, 3) 7.0 julia> g(2, 3.0) 8.0 julia> g(2.0, 3.0) 10.0
Рекомендуется определять метод разрешения неопределённостей первым, поскольку в противном случае неопределённость существует, хотя бы временно, до определения более специфичного метода.
В более сложных случаях разрешение неопределённостей методов включает в себя определённый элемент проектирования; этот вопрос рассматривается более подробно ниже.
Параметрические методы
Определения методов могут необязательно иметь параметры типа, квалифицирующие сигнатуру:
julia> same_type(x::T, y::T) where {T} = true
same_type (generic function with 1 method)
julia> same_type(x,y) = false
same_type (generic function with 2 methods)
Первый метод применяется, когда оба аргумента являются экземплярами одного и того же конкретного типа, независимо от того, какой это тип, в то время как второй метод выполняет роль универсального, охватывая все остальные случаи. Таким образом, в целом это определяет булеву функцию, которая проверяет, являются ли её два аргумента одного и того же типа:
julia> same_type(1, 2)
true
julia> same_type(1, 2.0)
false
julia> same_type(1.0, 2.0)
true
julia> same_type("foo", 2.0)
false
julia> same_type("foo", "bar")
true
julia> same_type(Int32(1), Int64(2))
false
Такие определения соответствуют методам, сигнатуры типов которых являются UnionAll типами (см. Типы UnionAll).
Этот вид определения поведения функции путём диспетчеризации довольно распространён — даже образцовый — в Julia. Параметры типа метода не ограничиваются использованием в качестве типов аргументов: они могут быть использованы везде, где в сигнатуре функции или теле функции может быть значение. Вот пример, где параметр типа метода T используется в качестве параметра типа параметрического типа Vector{T} в сигнатуре метода:
julia> myappend(v::Vector{T}, x::T) where {T} = [v..., x]
myappend (generic function with 1 method)
julia> myappend([1,2,3],4)
4-element Array{Int64,1}:
1
2
3
4
julia> myappend([1,2,3],2.5)
ERROR: MethodError: no method matching myappend(::Array{Int64,1}, ::Float64)
Closest candidates are:
myappend(::Array{T,1}, !Matched::T) where T at none:1
julia> myappend([1.0,2.0,3.0],4.0)
4-element Array{Float64,1}:
1.0
2.0
3.0
4.0
julia> myappend([1.0,2.0,3.0],4)
ERROR: MethodError: no method matching myappend(::Array{Float64,1}, ::Int64)
Closest candidates are:
myappend(::Array{T,1}, !Matched::T) where T at none:1
Как видно, тип добавляемого элемента должен соответствовать типу элементов вектора, к которому он добавляется, в противном случае будет вызвано MethodError. В следующем примере параметр типа метода T используется в качестве значения возврата:
julia> mytypeof(x::T) where {T} = T
mytypeof (generic function with 1 method)
julia> mytypeof(1)
Int64
julia> mytypeof(1.0)
Float64
Так же, как вы можете задавать ограничения подтипов на параметры типов в объявлениях типов (см. Параметрические типы), вы также можете ограничивать параметры типов методов:
julia> same_type_numeric(x::T, y::T) where {T<:Number} = true
same_type_numeric (generic function with 1 method)
julia> same_type_numeric(x::Number, y::Number) = false
same_type_numeric (generic function with 2 methods)
julia> same_type_numeric(1, 2)
true
julia> same_type_numeric(1, 2.0)
false
julia> same_type_numeric(1.0, 2.0)
true
julia> same_type_numeric("foo", 2.0)
ERROR: MethodError: no method matching same_type_numeric(::String, ::Float64)
Closest candidates are:
same_type_numeric(!Matched::T, ::T) where T<:Number at none:1
same_type_numeric(!Matched::Number, ::Number) at none:1
julia> same_type_numeric("foo", "bar")
ERROR: MethodError: no method matching same_type_numeric(::String, ::String)
julia> same_type_numeric(Int32(1), Int64(2))
false
Функция same_type_numeric ведет себя очень похоже на функцию same_type , определенную выше, но определена только для пар чисел.
Параметрические методы позволяют использовать тот же синтаксис, что и выражения where , используемые для записи типов (см. Типы UnionAll). Если есть только один параметр, фигурные скобки (в where {T}) можно опустить, но для ясности их часто предпочтительно использовать. Несколько параметров можно разделить запятыми, например, where {T, S<:Real}, или записать с использованием вложенных where, например, where S<:Real where T.
Переопределение методов
При переопределении метода или добавлении новых методов важно понимать, что эти изменения не вступают в силу немедленно. Это ключевой момент для способности Julia статически выводить и компилировать код для быстрой работы без обычных трюков JIT и накладных расходов. Действительно, любое новое определение метода не будет видно текущей среде выполнения, включая задачи и потоки (и любые ранее определенные @generated функции). Давайте начнем с примера, чтобы увидеть, что это означает:
julia> function tryeval()
@eval newfun() = 1
newfun()
end
tryeval (generic function with 1 method)
julia> tryeval()
ERROR: MethodError: no method matching newfun()
The applicable method may be too new: running in world age xxxx1, while current world is xxxx2.
Closest candidates are:
newfun() at none:1 (method too new to be called from this world context.)
in tryeval() at none:1
...
julia> newfun()
1
В этом примере обратите внимание, что новое определение для newfun было создано, но не может быть немедленно вызвано. Новый глобальный объект немедленно виден функции tryeval, поэтому вы можете написать return newfun (без скобок). Но ни вы, ни ваши вызывающие функции, ни функции, которые они вызывают, и т.д. не могут вызвать это новое определение метода!
Но есть исключение: будущие вызовы newfun из REPL работают как ожидается, имея возможность как видеть, так и вызывать новое определение newfun.
Однако будущие вызовы tryeval будут по-прежнему видеть определение newfun так, как оно было в предыдущем операторе в REPL, и, следовательно, до этого вызова tryeval.
Вы можете попробовать это самостоятельно, чтобы увидеть, как это работает.
Реализация этого поведения — это «счетчик возраста мира». Это монотонно возрастающее значение отслеживает каждый оператор определения метода. Это позволяет описать «множество определений методов, видимых в данной среде выполнения», как одно число или «возраст мира». Это также позволяет сравнивать методы, доступные в двух мирах, просто сравнивая их порядковое значение. В приведенном выше примере мы видим, что «текущий мир» (в котором существует метод newfun ), на один больше, чем локальный для задачи «мир выполнения», который был фиксирован при запуске выполнения tryeval.
Иногда необходимо обойти это (например, если вы реализуете вышеупомянутый REPL). К счастью, есть простое решение: вызовите функцию, используя Base.invokelatest:
julia> function tryeval2()
@eval newfun2() = 2
Base.invokelatest(newfun2)
end
tryeval2 (generic function with 1 method)
julia> tryeval2()
2
Наконец, давайте рассмотрим несколько более сложных примеров, где это правило вступает в силу. Определите функцию f(x), которая изначально имеет один метод:
julia> f(x) = "original definition" f (generic function with 1 method)
Начните другие операции, которые используют f(x):
julia> g(x) = f(x) g (generic function with 1 method) julia> t = @async f(wait()); yield();
Теперь добавим новые методы к f(x):
julia> f(x::Int) = "definition for Int"
f (generic function with 2 methods)
julia> f(x::Type{Int}) = "definition for Type{Int}"
f (generic function with 3 methods)
Сравните, как эти результаты отличаются:
julia> f(1) "definition for Int" julia> g(1) "definition for Int" julia> fetch(schedule(t, 1)) "original definition" julia> t = @async f(wait()); yield(); julia> fetch(schedule(t, 1)) "definition for Int"
Паттерны проектирования с параметрическими методами
Хотя сложная логика диспетчеризации не требуется для производительности или удобства использования, иногда это может быть лучшим способом выразить некоторый алгоритм. Вот несколько распространенных паттернов проектирования, которые иногда возникают при использовании диспетчеризации таким образом.
Извлечение параметра типа из супертипа
Вот правильный шаблон кода для возвращения типа элемента T любого произвольного подтипа AbstractArray:
abstract type AbstractArray{T, N} end
eltype(::Type{<:AbstractArray{T}}) where {T} = T
используя так называемую треугольную диспетчеризацию. Обратите внимание, что если T является UnionAll типом, как, например, eltype(Array{T} where T <: Integer), то возвращается Any (как и версия eltype в Base).
Другой способ, который раньше был единственным правильным способом до появления треугольной диспетчеризации в Julia v0.6, следующий:
abstract type AbstractArray{T, N} end
eltype(::Type{AbstractArray}) = Any
eltype(::Type{AbstractArray{T}}) where {T} = T
eltype(::Type{AbstractArray{T, N}}) where {T, N} = T
eltype(::Type{A}) where {A<:AbstractArray} = eltype(supertype(A))
Еще одним вариантом является следующий, который может быть полезен для адаптации к случаям, когда параметр T необходимо согласовать более узко:
eltype(::Type{AbstractArray{T, N} where {T<:S, N<:M}}) where {M, S} = Any
eltype(::Type{AbstractArray{T, N} where {T<:S}}) where {N, S} = Any
eltype(::Type{AbstractArray{T, N} where {N<:M}}) where {M, T} = T
eltype(::Type{AbstractArray{T, N}}) where {T, N} = T
eltype(::Type{A}) where {A <: AbstractArray} = eltype(supertype(A))
Одна распространённая ошибка — попытка получить тип элемента, используя интроспекцию:
eltype_wrong(::Type{A}) where {A<:AbstractArray} = A.parameters[1]
Однако несложно построить примеры, где это не сработает:
struct BitVector <: AbstractArray{Bool, 1}; end
Здесь мы создали тип BitVector, у которого нет параметров, но тип элемента всё равно полностью задан, причём T равно Bool!
Создание подобного типа с другим параметром типа
При разработке универсального кода часто возникает необходимость создания подобного объекта с внесённым изменением в структуру типа, что также требует изменения параметров типа. Например, у вас может быть некий абстрактный массив с произвольным типом элемента, и вы хотите написать вычисление на нём со специфическим типом элемента. Мы должны реализовать метод для каждого подтипа AbstractArray{T} , который описывает, как вычислить это преобразование типа. Нет общего преобразования одного подтипа в другой подтип с другим параметром. (Быстрый обзор: вы видите, почему это так?)
Подтипы AbstractArray обычно реализуют два метода для достижения этого: метод для преобразования входного массива в подтип конкретного AbstractArray{T, N} абстрактного типа и метод для создания нового неинициализированного массива со специфическим типом элемента. Примеры реализации этих методов можно найти в Julia Base. Вот базовый пример их использования, гарантирующий, что input и output имеют одинаковый тип:
input = convert(AbstractArray{Eltype}, input)
output = similar(input, Eltype)
В качестве расширения этого, в случаях, когда алгоритм требует копии входного массива, convert недостаточно, так как возвращаемое значение может ссылаться на исходный вход. Объединение similar (для создания выходного массива) и copyto! (для заполнения его входными данными) является универсальным способом выразить требование к мутабельной копии входного аргумента:
copy_with_eltype(input, Eltype) = copyto!(similar(input, Eltype), input)
Итерационная диспетчеризация
Для диспетчеризации списка аргументов с многоуровневыми параметрами часто лучше всего разделить каждый уровень диспетчеризации на отдельные функции. Это может показаться похожим на подход к одноуровневой диспетчеризации, но, как мы увидим ниже, оно всё же более гибкое.
Например, попытка диспетчеризации по типу элемента массива часто приводит к неоднозначным ситуациям. Вместо этого в коде обычно сначала происходит диспетчеризация по типу контейнера, а затем рекурсивно вызывается более специфический метод на основе eltype. В большинстве случаев алгоритмы удобны для этого иерархического подхода, а в других случаях эта строгость должна быть решена вручную. Эта ветвящаяся диспетчеризация наблюдается, например, в логике суммирования двух матриц:
# First dispatch selects the map algorithm for element-wise summation. +(a::Matrix, b::Matrix) = map(+, a, b) # Then dispatch handles each element and selects the appropriate # common element type for the computation. +(a, b) = +(promote(a, b)...) # Once the elements have the same type, they can be added. # For example, via primitive operations exposed by the processor. +(a::Float64, b::Float64) = Core.add(a, b)
Диспетчеризация на основе свойств
Естественным расширением итерационной диспетчеризации выше является добавление уровня к выбору метода, который позволяет диспетчеризировать по наборам типов, независимым от наборов, определённых иерархией типов. Мы могли бы создать такой набор, записав Union типов, но тогда этот набор не был бы расширяемым, так как Union-типы не могут быть изменены после создания. Однако такой расширяемый набор можно запрограммировать с помощью паттерна проектирования, часто называемого «Священным свойством».
Этот паттерн реализуется путём определения универсальной функции, которая вычисляет различное единственное значение (или тип) для каждого набора свойств, к которому могут принадлежать аргументы функции. Если эта функция чистая, нет никакого влияния на производительность по сравнению с обычной диспетчеризацией.
Пример в предыдущем разделе упустил детали реализации map и promote, которые оба работают с этими свойствами. При итерации по матрице, например, в реализации map, важный вопрос заключается в том, в каком порядке проходить данные. Когда AbstractArray подтипы реализуют свойство Base.IndexStyle, другие функции, такие как map , могут диспетчеризироваться на этой информации, чтобы выбрать лучший алгоритм (см. Интерфейс абстрактного массива). Это означает, что каждому подтипу не нужно реализовывать собственную версию map, так как общие определения + классы свойств позволят системе выбрать самую быструю версию. Вот пример реализации map , иллюстрирующей диспетчеризацию на основе свойств:
map(f, a::AbstractArray, b::AbstractArray) = map(Base.IndexStyle(a, b), f, a, b) # generic implementation: map(::Base.IndexCartesian, f, a::AbstractArray, b::AbstractArray) = ... # linear-indexing implementation (faster) map(::Base.IndexLinear, f, a::AbstractArray, b::AbstractArray) = ...
Этот подход на основе свойств также присутствует в механизме promote, используемом скалярной +. Он использует promote_type, которая возвращает оптимальный общий тип для вычисления операции, учитывая два типа операндов. Это позволяет сократить проблему реализации каждой функции для каждой пары возможных аргументов типа до гораздо меньшей проблемы реализации операции преобразования из каждого типа в общий тип плюс таблицу предпочтительных правил повышения по парам.
Вычисление типа результата
Обсуждение повышения на основе свойств переводит нас к следующему паттерну проектирования: вычисление типа результата для матричной операции.
Для реализации примитивных операций, таких как сложение, мы используем функцию promote_type для вычисления желаемого типа результата. (Как и прежде, мы видели это в работе в вызове promote в вызове +).
Для более сложных функций над матрицами может потребоваться вычислить ожидаемый тип возвращаемого значения для более сложной последовательности операций. Это часто выполняется следующими шагами:
- Напишите небольшую функцию
op, которая выражает набор операций, выполняемых ядром алгоритма. - Вычислите тип элемента
Rрезультирующей матрицы какpromote_op(op, argument_types...), гдеargument_typesвычисляется изeltype, примененного к каждому входному массиву. - Постройте выходную матрицу как
similar(R, dims), гдеdims— желаемые размеры выходного массива.
Для более конкретного примера псевдокод для умножения квадратных матриц может выглядеть так:
function matmul(a::AbstractMatrix, b::AbstractMatrix)
op = (ai, bi) -> ai * bi + ai * bi
## this is insufficient because it assumes `one(eltype(a))` is constructable:
# R = typeof(op(one(eltype(a)), one(eltype(b))))
## this fails because it assumes `a[1]` exists and is representative of all elements of the array
# R = typeof(op(a[1], b[1]))
## this is incorrect because it assumes that `+` calls `promote_type`
## but this is not true for some types, such as Bool:
# R = promote_type(ai, bi)
# this is wrong, since depending on the return value
# of type-inference is very brittle (as well as not being optimizable):
# R = Base.return_types(op, (eltype(a), eltype(b)))
## but, finally, this works:
R = promote_op(op, eltype(a), eltype(b))
## although sometimes it may give a larger type than desired
## it will always give a correct type
output = similar(b, R, (size(a, 1), size(b, 2)))
if size(a, 2) > 0
for j in 1:size(b, 2)
for i in 1:size(a, 1)
## here we don't use `ab = zero(R)`,
## since `R` might be `Any` and `zero(Any)` is not defined
## we also must declare `ab::R` to make the type of `ab` constant in the loop,
## since it is possible that typeof(a * b) != typeof(a * b + a * b) == R
ab::R = a[i, 1] * b[1, j]
for k in 2:size(a, 2)
ab += a[i, k] * b[k, j]
end
output[i, j] = ab
end
end
end
return output
end
Разделение логики преобразования и ядра
Один из способов значительно сократить время компиляции и сложность тестирования — изолировать логику преобразования в нужный тип и вычислений. Это позволяет компилятору специализироваться и встраивать логику преобразования независимо от остальной части тела более крупного ядра.
Это распространенный шаблон, наблюдаемый при преобразовании из более широкого класса типов в один конкретный тип аргумента, который фактически поддерживается алгоритмом:
complexfunction(arg::Int) = ... complexfunction(arg::Any) = complexfunction(convert(Int, arg)) matmul(a::T, b::T) = ... matmul(a, b) = matmul(promote(a, b)...)
Методы Varargs с параметрическими ограничениями
Параметры функции также могут использоваться для ограничения количества аргументов, которые могут быть переданы в функцию "varargs" (Функции Varargs). Для обозначения такого ограничения используется запись Vararg{T,N}. Например:
julia> bar(a,b,x::Vararg{Any,2}) = (a,b,x)
bar (generic function with 1 method)
julia> bar(1,2,3)
ERROR: MethodError: no method matching bar(::Int64, ::Int64, ::Int64)
Closest candidates are:
bar(::Any, ::Any, ::Any, !Matched::Any) at none:1
julia> bar(1,2,3,4)
(1, 2, (3, 4))
julia> bar(1,2,3,4,5)
ERROR: MethodError: no method matching bar(::Int64, ::Int64, ::Int64, ::Int64, ::Int64)
Closest candidates are:
bar(::Any, ::Any, ::Any, ::Any) at none:1
Более полезно, можно ограничить методы varargs с помощью параметра. Например:
function getindex(A::AbstractArray{T,N}, indices::Vararg{Number,N}) where {T,N}
будет вызван только тогда, когда количество indices соответствует размерности массива.
Когда необходимо ограничить только тип передаваемых аргументов, Vararg{T} можно эквивалентно записать как T.... Например, f(x::Int...) = x — это сокращение для f(x::Vararg{Int}) = x.
Замечание об опциональных и ключевых аргументах
Как кратко упоминалось в Функциях, опциональные аргументы реализуются как синтаксис для определения нескольких методов. Например, это определение:
f(a=1,b=2) = a+2b
переводится в следующие три метода:
f(a,b) = a+2b f(a) = f(a,2) f() = f(1,2)
Это означает, что вызов f() эквивалентен вызову f(1,2) В этом случае результатом является 5, потому что f(1,2) вызывает первый метод f выше. Однако это не всегда так. Если вы определите четвёртый метод, более специализированный для целых чисел:
f(a::Int,b::Int) = a-2b
то результат как f(), так и f(1,2) — -3. Другими словами, опциональные аргументы привязаны к функции, а не к какому-либо конкретному методу этой функции. Это зависит от типов опциональных аргументов, какой метод будет вызван. Если опциональные аргументы определены через глобальную переменную, тип опционального аргумента может даже измениться во время выполнения.
Ключевые аргументы ведут себя совершенно иначе, чем обычные позиционные аргументы. В частности, они не участвуют в диспетчеризации методов. Методы диспетчеризируются только на основе позиционных аргументов, а ключевые аргументы обрабатываются после того, как соответствующий метод будет идентифицирован.
Объекты, похожие на функции
Методы связаны с типами, поэтому можно сделать любой произвольный объект Julia "вызываемым", добавив методы к его типу. (Такие "вызываемые" объекты иногда называют "функторами").
Например, вы можете определить тип, хранящий коэффициенты многочлена, но ведёт себя как функция, вычисляющая многочлен:
julia> struct Polynomial{R}
coeffs::Vector{R}
end
julia> function (p::Polynomial)(x)
v = p.coeffs[end]
for i = (length(p.coeffs)-1):-1:1
v = v*x + p.coeffs[i]
end
return v
end
julia> (p::Polynomial)() = p(5)
Обратите внимание, что функция определяется по типу, а не по имени. Как и в обычных функциях, существует краткая синтаксическая форма. В теле функции p будет ссылаться на объект, который был вызван. Polynomial может использоваться следующим образом:
julia> p = Polynomial([1,10,100])
Polynomial{Int64}([1, 10, 100])
julia> p(3)
931
julia> p()
2551
Этот механизм также является ключом к тому, как работают конструкторы типов и замыкания (внутренние функции, которые ссылаются на свою окружающую среду) в Julia.
Пустые универсальные функции
Иногда полезно ввести универсальную функцию, не добавляя к ней методы. Это может использоваться для разделения определений интерфейса от реализаций. Это также может быть сделано для целей документирования или удобочитаемости кода. Синтаксис для этого — пустой function блок без кортежа аргументов:
function emptyfunc end
Проектирование методов и избежание неоднозначностей
Полиморфизм методов Julia — одно из самых мощных его свойств, но использование этой мощности может представлять проблемы при проектировании. В частности, в более сложных иерархиях методов нередко возникают неоднозначности.
Выше отмечалось, что можно разрешить неоднозначности, такие как
f(x, y::Int) = 1 f(x::Int, y) = 2
путем определения метода
f(x::Int, y::Int) = 3
Это часто правильная стратегия; однако существуют обстоятельства, когда следование этому совету без разбора может быть неэффективным. В частности, чем больше методов у универсальной функции, тем больше возможностей для неоднозначностей. Когда ваши иерархии методов становятся сложнее, чем этот простой пример, стоит хорошенько подумать об альтернативных стратегиях.
Ниже мы обсудим конкретные проблемы и некоторые альтернативные способы разрешения таких проблем.
Аргументы Tuple и NTuple
Аргументы Tuple (и NTuple представляют особые сложности. Например,
f(x::NTuple{N,Int}) where {N} = 1
f(x::NTuple{N,Float64}) where {N} = 2
неоднозначны из-за возможности, что N == 0: нет элементов, чтобы определить, какой вариант Int или Float64 должен быть вызван. Для разрешения неоднозначности одним из подходов является определение метода для пустого кортежа:
f(x::Tuple{}) = 3
В качестве альтернативы для всех методов, кроме одного, можно потребовать, чтобы в кортеже был хотя бы один элемент:
f(x::NTuple{N,Int}) where {N} = 1 # this is the fallback
f(x::Tuple{Float64, Vararg{Float64}}) = 2 # this requires at least one Float64
Ортогонализируйте свой дизайн
Когда вы, возможно, захотите диспетчеризировать по двум или более аргументам, подумайте, может ли функция-обёртка упростить дизайн. Например, вместо написания нескольких вариантов:
f(x::A, y::A) = ... f(x::A, y::B) = ... f(x::B, y::A) = ... f(x::B, y::B) = ...
вы можете рассмотреть определение
f(x::A, y::A) = ... f(x, y) = f(g(x), g(y))
где g преобразует аргумент в тип A. Это очень конкретный пример более общего принципа ортогонального проектирования, в котором отдельные понятия присваиваются отдельным методам. Здесь g очень вероятно, потребует определения по умолчанию
g(x::A) = x
Связанная стратегия использует promote для приведения x и y к общему типу:
f(x::T, y::T) where {T} = ...
f(x, y) = f(promote(x, y)...)
Один из рисков такого дизайна — возможность того, что если нет подходящего метода повышения, преобразующего x и y в один и тот же тип, второй метод будет рекурсивно вызывать себя бесконечно и вызовет переполнение стека.
Диспетчеризация по одному аргументу за раз
Если вам нужно диспетчеризировать по нескольким аргументам, и существует много обратных вызовов с слишком многими комбинациями, чтобы сделать практичным определение всех возможных вариантов, рассмотрите введение "каскада имён", где (например) вы диспетчеризируете по первому аргументу, а затем вызываете внутренний метод:
f(x::A, y) = _fA(x, y) f(x::B, y) = _fB(x, y)
Тогда внутренние методы _fA и _fB могут диспетчеризировать по y без опасений по поводу неоднозначностей друг с другом по отношению к x.
Помните, что у этой стратегии есть как минимум один существенный недостаток: во многих случаях пользователи не могут далее настраивать поведение f путём определения дополнительных специализаций вашей экспортированной функции f. Вместо этого им нужно определять специализации для ваших внутренних методов _fA и _fB, что размывает границы между экспортированными и внутренними методами.
Абстрактные контейнеры и типы элементов
По возможности старайтесь избегать определения методов, которые диспетчеризируют по конкретным типам элементов абстрактных контейнеров. Например,
-(A::AbstractArray{T}, b::Date) where {T<:Date}
порождает неоднозначности для всех, кто определяет метод
-(A::MyArrayType{T}, b::T) where {T}
Лучший подход — избегать определения любого из этих методов: вместо этого полагайтесь на универсальный метод -(A::AbstractArray, b) и убедитесь, что этот метод реализован с помощью универсальных вызовов (например, similar и - ), которые делают правильные вещи для каждого типа контейнера и типа элемента отдельно. Это просто более сложная разновидность совета о ортогонализации ваших методов.
Если этот подход невозможен, может стоить начать обсуждение с другими разработчиками о разрешении неоднозначности; просто потому, что один метод был определен первым, не обязательно означает, что он не может быть изменен или устранен. В качестве последнего средства один разработчик может определить "пластырь" метод
-(A::MyArrayType{T}, b::Date) where {T<:Date} = ...
который устраняет неоднозначность грубой силой.
Сложные каскады методов с аргументами по умолчанию
Если вы определяете каскад методов, который предоставляет значения по умолчанию, будьте осторожны, не опускайте какие-либо аргументы, соответствующие потенциальным значениям по умолчанию. Например, предположим, что вы пишете алгоритм цифровой фильтрации, и у вас есть метод, который обрабатывает края сигнала путём применения заполнения:
function myfilter(A, kernel, ::Replicate)
Apadded = replicate_edges(A, size(kernel))
myfilter(Apadded, kernel) # now perform the "real" computation
end
Это вызовет конфликт с методом, который предоставляет заполнение по умолчанию:
myfilter(A, kernel) = myfilter(A, kernel, Replicate()) # replicate the edge by default
Вместе эти два метода порождают бесконечную рекурсию с A постоянно увеличивающейся.
Лучший дизайн — определить вашу иерархию вызовов так:
struct NoPad end # indicate that no padding is desired, or that it's already applied
myfilter(A, kernel) = myfilter(A, kernel, Replicate()) # default boundary conditions
function myfilter(A, kernel, ::Replicate)
Apadded = replicate_edges(A, size(kernel))
myfilter(Apadded, kernel, NoPad()) # indicate the new boundary conditions
end
# other padding methods go here
function myfilter(A, kernel, ::NoPad)
# Here's the "real" implementation of the core computation
end
NoPad передаётся в той же позиции аргумента, что и любой другой вид отступа, поэтому он поддерживает чёткую иерархию вызова с уменьшенной вероятностью неоднозначностей. Кроме того, он расширяет интерфейс "public" myfilter: пользователь, который хочет явно управлять отступом, может напрямую вызвать вариант NoPad.
-
1Например, в C++ или Java, при вызове метода, как
obj.meth(arg1,arg2), объект obj «получает» вызов метода и неявно передаётся методу через ключевое словоthis, а не в качестве явного аргумента метода. Когда текущий объектthisявляется получателем вызова метода, его можно вообще опустить, написав простоmeth(arg1,arg2), при этомthisподразумевается как принимающий объект. - Clarke61Артур К. Кларк, Профили будущего (1961): Третий закон Кларка.
© 2009–2020 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.5.3/manual/methods/