Кратко про FP в Julia

74fa4ab2bd1977ed8e331887b1394dd4.png

В отличие от императивного подхода, где выражается последовательность операций, функциональное программирование (FP) сосредотачивается на »что» и »как» должно быть вычислено, а не на »когда». Это приводит к более чистому, модульному и легко тестируемому коду.

Juliа поддерживает анонимные функции, замыкания, и имеет систему типов, которая позволяет писать высокооптимизированный код без потери читаемости и удобства.

Основы

Чистые функции — это база. Они не имеют побочных эффектов и возвращают один и тот же результат при одинаковых входных данных:

function add(a, b)
    return a + b
end

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

Функции высшего порядка принимают другие функции в качестве аргументов или возвращают их как результат. Это позволяет создавать абстракции высокого уровня и различные шаблоны программирования:

function apply_twice(f, x)
    return f(f(x))
end

result = apply_twice(x -> x * 2, 5)  # 20

apply_twice принимает функцию f и применяет её дважды к значению x. Используем анонимную функцию (x -> x * 2), чтобы удвоить значение.

Лямбда-функции (или анон.функции) позволяют создавать компактные выражения для коротких функций без необходимости их именования. Замыкания же позволяют использовать переменные из внешнего контекста:

x = 10
multiplier = y -> x * y  # замыкание использующее переменную x из внешнего контекста

println(multiplier(5))  # 50

multiplier умножает свой аргумент на значение переменной x, определённой вне функции.

Неизменяемость данных и управление состоянием

В Julia базовые типы данных являются неизменяемыми. Т.е, создав экземпляр такого типа, нельзя изменить его содержимое. Попытка это сделать приведёт к созданию нового экземпляра, а не изменению существующего.

x = 10
x = 20  # создаётся новый объект, а не изменяется существующий

Julia предоставляет ключевое слово struct для определения неизменяемых пользовательских типов. После создания экземпляра такого типа, его поля не могут быть изменены:

struct Point
  x::Int
  y::Int
end

p = Point(1, 2)
# p.x = 3  # вызовет ошибку, так как структура Point неизменяема

Для создания изменяемых структур используется слово mutable struct. Однако в контексте FP предпочтение отдаётся неизменяемым структурам

Как можно использовать неизменяемость на практике в Julia для создания функционалки:

# неизменяемая структура данных
struct ImmutableVector
  data::Vector{Int}
end

# функция, создающая новый ImmutableVector с добавленным элементом
function add_element(ivec::ImmutableVector, element::Int)
  new_data = copy(ivec.data)
  push!(new_data, element)
  return ImmutableVector(new_data)
end

ivec = ImmutableVector([1, 2, 3])
ivec_new = add_element(ivec, 4)

Вместо изменения существующего экземпляра ImmutableVector, функция add_element создаёт новый экземпляр с изменённым состоянием.

Монады и функторы

Функтор — это тип данных, который можно отобразить с помощью функции, применённой к каждому его элементу. Если пошире, это любой тип, для которого можно определить функцию map. В Julia массивы являются примером функторов, поскольку к ним можно применить функцию map:

numbers = [1, 2, 3, 4, 5]
squared_numbers = map(x -> x^2, numbers)
println(squared_numbers)  # Выведет [1, 4, 9, 16, 25]

map применяет функцию к каждому элементу массива numbers, возвращая новый массив squared_numbers, где каждый элемент возведён в квадрат.

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

Хотя Julia не имеет встроенной поддержки монад в стандартной библиотеке, можно реализовать их самостоятельно:

struct Maybe{T}
  value::Union{Nothing, T}
end

# функция создания "успешного" значения
success(x) = Maybe(x)

# функция для создания "неудачного" значения
failure() = Maybe{Nothing}(nothing)

# bind для монады Maybe
function bind(m::Maybe, f)
  if isnothing(m.value)
    return failure()
  else
    return f(m.value)
  end
end

# пример использования
safe_divide = x -> x != 0 ? success(1/x) : failure()
result = bind(success(2), safe_divide)
println(result)  # Maybe(0.5)

Монаа Maybe может содержать либо значение, либо nothing, если произошла ошибка. bind позволяет применить функцию к значению внутри Maybe, только если это значение не nothing.

Рекурсия и оптимизация хвостовой рекурсии

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

Простой рекурсивный пример — функция для вычисления факториала числа:

function factorial(n)
    if n == 0
        return 1
    else
        return n * factorial(n - 1)
    end
end

println(factorial(5))  # 120

При больших значениях n такой подход может привести к переполнению стека.

Хвостовая рекурсия — это особый случай рекурсии, при котором рекурсивный вызов является последней операцией, выполняемой функцией.

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

function factorial_tail(n, acc=1)
    if n == 0
        return acc
    else
        return factorial_tail(n - 1, n * acc)
    end
end

println(factorial_tail(5))  # 120

acc используется для накопления результата, и рекурсивный вызов является последней операцией функции

Julia не всегда автоматически оптимизирует хвостовую рекурсию. Для этого можно использовать макрос @tailrec из пакета TailRecursion.jl для явного указания на необходимость такой оптимизации:

using TailRecursion

@tailrec function factorial_tail_opt(n, acc=1)
    if n == 0
        return acc
    else
        return factorial_tail_opt(n - 1, n * acc)
    end
end

println(factorial_tail_opt(5))  # 120

Как юзать fp для аналитики

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

# к примеру, есть такой массив температур
temperatures = [3.5, 4.1, 5.6, 7.2, 9.1, 12.3, 14.6, 14.9, 12.8, 9.7, 6.5, 4.3]

# функция для вычисления скользящего среднего
function moving_average(data, window_size)
    len = length(data)
    result = []

    for i in 1:(len - window_size + 1)
        window = data[i:(i + window_size - 1)]
        push!(result, sum(window) / window_size)
    end

    return result
end

# вчисляем скользящее среднее с окном в 3 месяца
average_temperatures = moving_average(temperatures, 3)

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

# массив словарей, представляющих данные о пациентах
patients = [
    Dict("name" => "Alice", "age" => 30),
    Dict("name" => "Bob", "age" => 45),
    Dict("name" => "Charlie", "age" => 25)
]

# функция для фильтрации пациентов по возрасту
function filter_patients(data, age_threshold)
    filter(patient -> patient["age"] > age_threshold, data)
end

# функция для преобразования данных пациентов
function transform_patients(data)
    map(patient -> Dict("patient_name" => patient["name"], "patient_age" => patient["age"]), data)
end

# фильтрация и трансформация
filtered_patients = filter_patients(patients, 30)
transformed_patients = transform_patients(filtered_patients)

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

© Habrahabr.ru