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