ВЫДЕРЖКА СТАТЬИ

Использование множественной отправки в Julia

Отрывок из книги Богумила Каминского Юлия для анализа данных

В этой статье показано, как использовать Multiple Dispatch в Julia.

Прочтите ее, если вы работаете с данными или работаете с большими объемами данных, а также если вам интересен язык Julia.

Получите скидку 25 % на Julia for Data Analysis, введя fcckaminski в поле кода скидки при оформлении заказа на сайте manning.com.

Давайте узнаем, как определять функции, имеющие разные методы, и применим эти знания к функции с именем winsorized_mean.

Правила определения методов для функции

К счастью, определить методы относительно просто, если вы понимаете принципы работы системы типов Джулии. Вы просто добавляете ограничение типа к аргументам функции после ::. Если часть спецификации типа опущена, Джулия предполагает, что разрешено значение типа Any.

Предположим, мы хотим создать функцию fun, принимающую один позиционный аргумент со следующим поведением:

  • если ему передано число, оно должно напечатать "a number was passed", если это не значение, имеющее тип Float64, и в этом случае мы хотим напечатать "a Float64 value";
  • во всех остальных случаях мы хотим напечатать "unsupported type".

Вот пример того, как вы можете реализовать это поведение, определив три метода для функции fun.

julia> fun(x) = println("unsupported type")
 fun (generic function with 1 method)
  
 julia> fun(x::Number) = println("a number was passed")
 fun (generic function with 2 methods)
  
 julia> fun(x::Float64) = println("a Float64 value")
 fun (generic function with 3 methods)
  
 julia> methods(fun)
 # 3 methods for generic function "fun":
 [1] fun(x::Float64) in Main at REPL[3]:1
 [2] fun(x::Number) in Main at REPL[2]:1
 [3] fun(x) in Main at REPL[1]:1
  
 julia> fun("hello!")
 unsupported type
  
 julia> fun(1)
 a number was passed
  
 julia> fun(1.0)
 a Float64 value

В приведенном выше примере обратите внимание, что, например, 1 является Number (как и Int), но это не Float64, поэтому наиболее конкретным методом сопоставления является fun(x::Number).

Проблема неоднозначности метода

Что вы должны иметь в виду при определении нескольких методов для функции, так это избегать неоднозначности методов. Они случаются, когда компилятор Julia не может решить, какой метод для данного набора аргументов следует выбрать. Легче всего понять проблему на примере. Предположим, вы хотите определить функцию bar, принимающую два позиционных аргумента. Он должен сообщить вам, были ли какие-либо из них числами. Вот первая попытка реализовать такую ​​функцию:

julia> bar(x, y) = "no numbers passed"
 foo (generic function with 1 method)
  
 julia> bar(x::Number, y) = "first argument is a number"
 foo (generic function with 2 methods)
  
 julia> bar(x, y::Number) = "second argument is a number"
 foo (generic function with 3 methods)
  
 julia> bar("hello", "world")
 "no numbers passed"
  
 julia> bar(1, "world")
 "first argument is a number"
  
 julia> bar("hello", 2)
 "second argument is a number"
  
 julia> bar(1, 2)
 ERROR: MethodError: foo(::Int64, ::Int64) is ambiguous. Candidates:
   bar(x::Number, y) in Main at REPL[2]:1
   bar(x, y::Number) in Main at REPL[3]:1
 Possible fix, define
   bar(::Number, ::Number)

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

julia> bar(x::Number, y::Number) = "both arguments are numbers"
 foo (generic function with 4 methods)
  
 julia> bar(1, 2)
 "both arguments are numbers"
  
 julia> methods(bar)
 # 4 methods for generic function "foo":
 [1] bar(x::Number, y::Number) in Main at REPL[8]:1
 [2] bar(x::Number, y) in Main at REPL[2]:1
 [3] bar(x, y::Number) in Main at REPL[3]:1
 [4] bar(x, y) in Main at REPL[1]:1

Почему полезна множественная отправка?

Понимание того, как работают методы в Julia, является важной частью знаний, которыми вы должны обладать. Как вы могли видеть в приведенных выше примерах, это позволяет пользователям различать поведение функций в зависимости от типа любого позиционного аргумента функции. В сочетании с гибкой системой иерархии типов множественная диспетчеризация позволяет программистам Julia писать очень гибкий и повторно используемый код. Обратите внимание, что, указывая типы на подходящем уровне абстракции, пользователь не должен думать о каждом возможном конкретном типе, который будет передан функции, сохраняя при этом контроль над тем, какие значения принимаются. Например, если вы определили свой собственный подтип Number, как это делается, например, в пакете Decimals.jl (https://github.com/JuliaMath/Decimals.jl), в котором представлены типы, поддерживающие десятичную дробь произвольной точности с плавающей запятой. вычисления, вам не нужно переписывать свой код. Все будет просто работать с новым типом, даже если исходный код не был разработан специально для этого варианта использования.

Улучшенная реализация winsorized среднего

Мы готовы улучшить определение нашей функции winsorized_mean. Вот как вы могли бы это реализовать:

julia> function winsorized_mean(x::AbstractVector, k::Integer)
            k >= 0 || throw(ArgumentError("k must be non-negative"))
            length(x) > 2 * k || throw(ArgumentError("k is too large"))
            y = sort!(collect(x))
            for i in 1:k
                y[i] = y[k + 1]
                y[end - i + 1] = y[end - k]
            end
            return sum(y) / length(y)
        end
 winsorized_mean (generic function with 1 method)

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

julia> winsorized_mean([8, 3, 1, 5, 7], 1)
 5.0
  
 julia> winsorized_mean(1:10, 2)
 5.5
  
 julia> winsorized_mean(1:10, "a")
 ERROR: MethodError: no method matching winsorized_mean(::UnitRange{Int64}, ::String)
 Closest candidates are:
   winsorized_mean(::AbstractVector{T} where T, ::Integer) at REPL[6]:1
  
 julia> winsorized_mean(10, 1)
 ERROR: MethodError: no method matching winsorized_mean(::Int64, ::Int64)
 Closest candidates are:
   winsorized_mean(::AbstractVector{T} where T, ::Integer) at REPL[6]:1

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

julia> winsorized_mean(1:10, -1)
 ERROR: ArgumentError: k must be non-negative
  
 julia> winsorized_mean(1:10, 5)
 ERROR: ArgumentError: k is too large

Затем сделайте копию данных, хранящихся в векторе x, перед их сортировкой. Для этого мы используем функцию collect, которая принимает любую итерируемую коллекцию и возвращает объект, хранящий те же значения, которые имеют тип Vector. Мы передаем этот вектор функции sort!, чтобы отсортировать его на месте.

Вы можете спросить, зачем использовать функцию collect для выделения нового Vector. Причина в том, что, например, такие диапазоны, как 1:10, доступны только для чтения, поэтому позже мы не сможем обновить y с помощью y[i] = y[k + 1] и y[end - i + 1] = y[end - k]. Кроме того, в целом Julia может поддерживать индексацию массивов, не основанную на единице (см. https://github.com/JuliaArrays/OffsetArrays.jl). Однако Vector использует индексацию на основе 1. Таким образом, использование функции collect превращает любую коллекцию или общий AbstractVector в стандартный тип Vector, определенный в Julia, который является изменяемым и использует индексацию на основе 1.

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

Увеличивает ли скорость выполнения методов добавление аннотаций типа аргумента?

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

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

Однако ситуация отличается, если для некоторой функции определено несколько методов. Причина в том, что ограничения типа влияют на отправку метода. Затем каждый метод может иметь различную реализацию с использованием алгоритма, оптимизированного для значения данного типа. Использование множественной отправки позволяет компилятору Julia выбрать реализацию, которая лучше всего подходит для ваших данных.

Поясню на примере. Рассмотрим функцию sort, которую мы представили в главе 2. Вызвав methods(sort), вы узнаете, что она имеет пять различных методов, определенных в Base Julia (и, возможно, больше, если вы загрузили несколько пакетов Julia). Существует общий метод сортировки векторов с сигнатурой sort(v::AbstractVector; kws...) и специальный метод сортировки диапазонов, таких как 1:3, с сигнатурой sort(r::AbstractUnitRange).

В чем преимущество этого специализированного метода? Ответ заключается в том, что второй метод определяется как sort(r::AbstractUnitRange) = r. Поскольку мы знаем, что объекты типа AbstractUnitRange уже отсортированы (это диапазоны значений с шагом, равным 1), то мы можем просто вернуть переданное значение. В этом случае использование ограничения типа в сигнатуре метода может значительно повысить производительность операции sort.

Это все на данный момент. Спасибо за прочтение.