Функциональное программирование
Предпосылки: Циклы (while, изменение состояния в цикле), Коллекции (массивы, проход по коллекции), Память (общие объекты, изменение по ссылке), Объектно-ориентированное программирование (объекты, методы).
← Наследование и полиморфизм | Ошибки и исключения →
ООП хорошо группирует данные и поведение. Но это ещё не значит, что состояние легко отлаживать. Если один и тот же объект меняется шаг за шагом, после ошибки от промежуточных значений не остаётся следа.
Когда мутация прячет момент сбоя
Допустим, в маркетплейсе есть счёт продавца. На него за месяц приходят зарплата и бонус, с него списываются подписка и отдельная услуга:
class Account
attr_reader :name, :balance
def initialize(name, balance)
@name = name
@balance = balance
end
def deposit!(amount)
@balance = @balance + amount
end
def withdraw!(amount)
@balance = @balance - amount
end
end
def apply_salary(account)
account.deposit!(200)
end
def apply_bonus(account)
account.deposit!(80)
end
def charge_service(account)
account.withdraw!(150)
end
def settle_subscription(account)
account.withdraw!(650)
charge_service(account)
end
def monthly_processing(account)
apply_salary(account)
apply_bonus(account)
settle_subscription(account)
end
account = Account.new("Alice", 500)
monthly_processing(account)
puts account.name + ": " + account.balance.to_sВосклицательный знак в deposit!/withdraw! — ruby-конвенция для методов, которые меняют объект на месте. Запуск распечатает:
Alice: -20Итоговый баланс ушёл в минус. Каждая операция по отдельности выглядит разумно: зарплата, бонус, оплата подписки, списание за услугу. В какой строке баланс впервые стал отрицательным? Засеки время, прежде чем читать разбор — и держи эту цифру в голове.
Разбор по шагам
На старте 500. Зарплата даёт 700, бонус — 780, списание подписки — 130. Внутри
settle_subscriptionпрячется ещё один вызов —charge_serviceсписывает 150, и остаток уходит в-20. По финальной распечатке этого не видно:monthly_processingзовётsettle_subscription, и уже оттуда —charge_service, который увёл баланс в минус. Чтобы понять, где сломалось, нужно либо восстановить все шаги в голове, либо вставитьputsпосле каждого вызова — промежуточные значения нигде не сохраняются.
Здесь у тебя перед глазами весь код и один сбой, под который всё выстроено. В рабочей отладке ты видишь не код, а тикет: «у Alice баланс -20 после месячного закрытия, разберись». Открываешь monthly_processing, смотришь три вызова — всё выглядит нормально. Обычный ход — IDE «найти использования» для charge_service: она покажет вызов из settle_subscription, но не предупредит, что settle_subscription спрятан за monthly_processing и вызывается в цепочке. Добавляешь puts после каждого вызова верхнего уровня, запускаешь тест, замечаешь, что до settle_subscription баланс 130, а после — минус. Открываешь settle_subscription и находишь вложенный charge_service. Двадцать минут на один понятный баг — и это если воспроизведение локальное. Если баг нашёлся на продакшене, к этому добавятся миграция для коррекции балансов, письмо клиентам и переписанные тесты. Запускаемая версия — в 10-mutable-account.rb.
Новое значение вместо изменения на месте
Функциональный подход начинает с другого правила: не менять существующий объект, а возвращать новый.
.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_sub = withdraw(after_bonus, 650)
after_fee = withdraw(after_sub, 150)
puts original["balance"] # 500
puts after_salary["balance"] # 700
puts after_bonus["balance"] # 780
puts after_sub["balance"] # 130
puts after_fee["balance"] # -20Та же последовательность операций, но теперь у каждого шага своё имя и своё значение. Вопрос «где баланс ушёл в минус?» закрывается одной распечаткой: after_sub ещё положительный, after_fee уже нет — значит, последнее списание сделало остаток отрицательным. Прошлые версии счёта никуда не делись, и в дебаггере grep after_ возвращает всю историю.
Этот приём называют «неизменяющим обновлением»: старое значение остаётся прежним, а результатом становится новая версия структуры.
То же с коллекциями
На коллекциях эта же идея выглядит как цепочка преобразований. Пусть маркетплейс считает комиссию с продаж продавцов за день. Императивный вариант:
order_totals = [1500, 2200, 1800, 900]
commissions = []
i = 0
while i < order_totals.length # .length — количество элементов
if order_totals[i] > 1000
commissions.push(order_totals[i] * 0.15) # .push — добавить в конец
end
i = i + 1
endЗдесь в одном цикле смешаны фильтрация, преобразование и накопление результата.
Функциональный вариант делит это на шаги. Каждый метод принимает блок do |...| ... end — кусок кода, описывающий действие над одним элементом. Имя в |total| — параметр блока: на каждом шаге он указывает на текущий элемент (синтаксис блоков уже встречался в коллекциях объектов и полиморфизме):
order_totals = [1500, 2200, 1800, 900]
commissions = order_totals
.select do |total| total > 1000 end
.map do |total| total * 0.15 endselect (от англ. «выбрать») оставляет только элементы, для которых блок вернул true. map (от англ. «отобразить, преобразовать») создаёт новый массив, применяя блок к каждому элементу. Каждый шаг получает старую коллекцию и возвращает новую — оригинал не меняется.
Если нужно свернуть коллекцию в одно значение, используют reduce (от англ. «свести»). Его блок принимает два параметра: |sum, fee| — первый (sum) — накопленный результат, второй (fee) — текущий элемент. Начальное значение накопителя указывается в скобках — здесь 0:
total_commission = commissions.reduce(0) do |sum, fee|
sum + fee
endЭто не приём Ruby как такового. Та же декомпозиция на «отфильтровать → преобразовать → свернуть» встречается во многих языках:
JavaScript: orderTotals.filter(...).map(...)
Python: [total * 0.15 for total in order_totals if total > 1000]
C#: orderTotals.Where(...).Select(...)Меняется форма записи, но не сама мысль: разбить один перегруженный цикл на несколько шагов, у каждого из которых одна роль.
Блок как описание преобразования
Методы select, map и reduce принимают блок — кусок кода, который описывает действие над элементом:
tax_rate = 0.15
commissions = order_totals.map do |total|
total * tax_rate
endБлок здесь отвечает только за одну локальную задачу: как превратить один элемент в другой. Сам обход коллекции уже спрятан внутри метода.
Обратите внимание: внутри блока используется tax_rate, хотя параметр у блока один — total. Блок запоминает переменные из окружения, где он создан, — это называется замыканием (closure). Механика в Ruby разобрана в Блоках.
Другой стиль, не другой лагерь
ООП организует код вокруг объектов и их методов. Функциональный подход организует код вокруг преобразований и новых версий данных.
В реальных языках эти подходы часто живут вместе. 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.