Функциональное программирование

Предпосылки: Циклы (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

Итоговый баланс ушёл в минус. Каждая операция по отдельности выглядит разумно: зарплата, бонус, оплата подписки, списание за услугу. В какой строке баланс впервые стал отрицательным? Засеки время, прежде чем читать разбор — и держи эту цифру в голове.

Здесь у тебя перед глазами весь код и один сбой, под который всё выстроено. В рабочей отладке ты видишь не код, а тикет: «у 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 end

select (от англ. «выбрать») оставляет только элементы, для которых блок вернул 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.

Наследование и полиморфизм | Ошибки и исключения