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