Функциональное программирование
Предпосылки: Циклы (while, изменение состояния в цикле), Коллекции (массивы, проход по коллекции), Память (общие объекты, изменение по ссылке), Объектно-ориентированное программирование (объекты, методы).
← Наследование и полиморфизм | Ошибки и исключения →
ООП хорошо группирует данные и поведение. Но это ещё не значит, что состояние легко отлаживать. Если один и тот же объект меняется шаг за шагом, после ошибки приходится восстанавливать историю изменений.
Мутация скрывает историю
Когда объект меняют на месте, остаётся только его текущее состояние. Чтобы понять, как оно получилось, нужно воспроизводить цепочку вызовов:
- где объект передавали дальше;
- какой метод его изменил;
- был ли это прямой вызов или вложенный.
Это не делает программу неверной для процессора. Но человеку становится труднее быстро восстановить историю состояния.
Вот пример — достаточно посмотреть, как мутация прячет момент, в котором состояние впервые стало неправильным.
Почему здесь трудно быстро восстановить историю
Баланс впервые уходит ниже нуля во время
charge_service. Но этот шаг спрятан внутриsettle_subscription, поэтому по верхнему сценарию это не видно сразу.
Новое значение вместо изменения на месте
Функциональный подход часто начинает с другого правила: не менять существующий объект, а возвращать новый.
.merge создаёт новый хеш, в котором часть полей обновлена, а оригинал остаётся нетронутым:
def deposit(account, amount)
account.merge("balance" => account["balance"] + amount)
end
def withdraw(account, amount)
account.merge("balance" => account["balance"] - amount)
end
original = { "name" => "Alice", "balance" => 500 }
after_salary = deposit(original, 200)
after_bonus = deposit(after_salary, 80)
after_fee = withdraw(after_bonus, 150)
puts original["balance"] # 500
puts after_salary["balance"] # 700
puts after_bonus["balance"] # 780
puts after_fee["balance"] # 630Теперь каждый шаг имеет своё значение. Если итог неверен, можно посмотреть на промежуточные версии и увидеть, после какого преобразования состояние стало неправильным.
Этот подход часто называют «неизменяющим обновлением»: старое значение остаётся прежним, а результатом становится новая версия структуры.
То же самое с коллекциями
На коллекциях та же идея выглядит как цепочка преобразований. Императивный вариант:
salaries = [1500, 2200, 1800, 900]
taxes = []
i = 0
while i < salaries.length # .length — количество элементов
if salaries[i] > 1000
taxes.push(salaries[i] * 0.15) # .push — добавить в конец
end
i = i + 1
endЗдесь в одном цикле смешаны фильтрация, преобразование и накопление результата.
Функциональный вариант делит это на шаги. Каждый метод принимает блок do |...| ... end — кусок кода, описывающий действие над одним элементом. Имя в |salary| — параметр блока: на каждом шаге он указывает на текущий элемент (синтаксис блоков мы уже встречали в коллекциях объектов и полиморфизме):
salaries = [1500, 2200, 1800, 900]
taxes = salaries
.select do |salary| salary > 1000 end
.map do |salary| salary * 0.15 endselect (от англ. «выбрать») оставляет только элементы, для которых блок вернул true. map (от англ. «отобразить, преобразовать») создаёт новый массив, применяя блок к каждому элементу. Каждый шаг получает старую коллекцию и возвращает новую — оригинал не меняется.
Если нужно свернуть коллекцию в одно значение, используют reduce (от англ. «свести»). Его блок принимает два параметра: |sum, tax| — первый (sum) — накопленный результат, второй (tax) — текущий элемент. Начальное значение накопителя указывается в скобках — здесь 0:
total_tax = taxes.reduce(0) do |sum, tax|
sum + tax
endЭто тоже не приём Ruby как такового. Та же декомпозиция на «отфильтровать → преобразовать → свернуть» встречается во многих языках:
JavaScript: salaries.filter(...).map(...)
Python: [salary * 0.15 for salary in salaries if salary > 1000]
C#: salaries.Where(...).Select(...)Меняется форма записи, но не сама мысль: разбить один перегруженный цикл на несколько шагов, у каждого из которых одна роль.
Блок как описание преобразования
Методы select, map и reduce принимают блок — кусок кода, который описывает действие над элементом:
tax_rate = 0.15
taxes = salaries.map do |salary|
salary * tax_rate
endБлок здесь отвечает только за одну локальную задачу: как превратить один элемент в другой. Сам обход коллекции уже спрятан внутри метода.
Это ещё один способ уменьшить количество деталей, которые нужно держать в голове при чтении кода.
Другой стиль, не другой лагерь
ООП организует код вокруг объектов и их методов. Функциональный подход организует код вокруг преобразований и новых версий данных.
В реальных языках эти подходы часто живут вместе. Ruby остаётся объектно-ориентированным языком, но при этом удобно поддерживает map, select, reduce и работу с неизменяемыми промежуточными результатами. JavaScript, Scala, Elixir, C# и многие другие языки делают то же самое своими средствами.
Неизменяемые преобразования особенно полезны там, где важна история: финансовые операции, цепочки обработки данных, отладка сложных состояний. Если изменение нужно в одном месте и его последствия локальны — мутация проще и понятнее.
Следующая проблема уже не про организацию вычислений, а про сбои: файл не найден, число не удалось прочитать, сеть оборвалась. Тогда программе нужен способ сообщить об ошибке и не потерять контекст.
Sources
Как Ruby реализует блоки и замыкания — в Блоках.
- McCarthy, J., 1960, Recursive Functions of Symbolic Expressions and Their Computation by Machine. Communications of the ACM.
- Thomas, D. et al., 2023, Programming Ruby 3.3. Pragmatic Bookshelf.