Методы
Напомним из раздела Функции, что функция — это объект, который сопоставляет кортеж аргументов со значением возврата или вызывает исключение, если соответствующее значение вернуть нельзя. Часто одна и та же концептуальная функция или операция реализуется совершенно по-разному для разных типов аргументов: сложение двух целых чисел сильно отличается от сложения двух чисел с плавающей запятой, которые, в свою очередь, отличаются от сложения целого числа и числа с плавающей запятой. Несмотря на различия в реализации, все эти операции относятся к общему понятию «сложения». Соответственно, в Julia эти поведения принадлежат одному объекту: функции +.
Для удобного использования различных реализаций одного и того же понятия, функции не обязательно определять сразу, но можно определять их по частям, предоставляя определённое поведение для определённых комбинаций типов и количества аргументов. Определение одного возможного поведения для функции называется методом. До сих пор мы приводили только примеры функций, определённых с одним методом, применимым ко всем типам аргументов. Однако подписи определений методов могут быть аннотированы, чтобы указать типы аргументов помимо их количества, и можно предоставить больше одного определения метода. Когда функция применяется к определённому кортежу аргументов, применяется наиболее специфичный метод, применимый к этим аргументам. Таким образом, общее поведение функции представляет собой мозаику из поведений различных определений её методов. Если мозаика разработана правильно, то, несмотря на то, что реализации методов могут быть очень разными, внешнее поведение функции будет казаться плавным и согласованным.
Выбор метода, который нужно выполнить при применении функции, называется диспетчеризацией. Julia позволяет процессу диспетчеризации выбирать, какой из методов функции следует вызвать, основываясь на количестве предоставленных аргументов и типах всех аргументов функции. Это отличается от традиционных языков объектно-ориентированного программирования, где диспетчеризация происходит только на основе первого аргумента, который часто имеет специальный синтаксис аргумента и иногда подразумевается, а не явно написан как аргумент. [1] Использование всех аргументов функции для выбора вызываемого метода, а не только первого, известно как многократная диспетчеризация. Многократная диспетчеризация особенно полезна для математического кода, где мало смысла искусственно считать операции «принадлежащими» одному аргументу больше, чем любому другому: разве операция сложения в x + y принадлежит x больше, чем y? Реализация математического оператора, как правило, зависит от типов всех её аргументов. Однако многократная диспетчеризация оказывается мощной и удобной парадигмой для структурирования и организации программ, и за пределами математических операций.
Например, в C++ или Java при вызове метода, таком как obj.meth(arg1,arg2), объект obj «получает» вызов метода и неявно передаётся методу через ключевое слово this, а не в качестве явного аргумента метода. Когда текущий объект this является получателем вызова метода, его можно вообще опустить, написав просто meth(arg1,arg2), при этом this подразумевается как принимающий объект.
Определение методов
До сих пор в наших примерах мы определяли только функции с одним методом, имеющим не ограниченные типы аргументов. Такие функции ведут себя так же, как и в традиционных языках с динамической типизацией. Тем не менее, мы почти постоянно использовали многократную диспетчеризацию и методы, не осознавая этого: все стандартные функции и операторы 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, y::Float64) in Main at none:1 g(x::Float64, y) 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<:Number, ::T<:Number) 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(b, 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 в один и тот же тип, второй метод будет рекурсивно вызывать сам себя бесконечно и вызовет переполнение стека. Неэкспортируемая функция Base.promote_noncircular может использоваться в качестве альтернативы; при неудаче повышения всё равно будет выброшено исключение, но исключение будет выброшено быстрее с более подробным сообщением об ошибке.
Диспетчеризация по одному аргументу за раз
Если вам нужно диспетчеризовать по нескольким аргументам, и существует много вариантов с слишком большим количеством сочетаний, чтобы сделать практичным определение всех возможных вариантов, рассмотрите введение «каскада имён», где (например) вы диспетчеризуете по первому аргументу, а затем вызываете внутренний метод:
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 напрямую.
Артур К. Кларк, Профили будущего (1961): Третий закон Кларка.
© 2009–2019 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.1.1/manual/methods/