Ошибки и исключения

Предпосылки: Функции (вызовы функций), Память (стек вызовов, кадры).

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

В предыдущей заметке маркетплейс считал баланс счёта: зарплата, бонус, списание подписки, комиссия. Пока всё шло по плану, история значений отвечала на вопрос «где баланс ушёл в минус». Теперь сценарий сложнее: продавец Alice запросила выплату на внешний счёт, и на этом пути что-то сломалось. Банк вернул отказ, или сети не было, или в базе у продавца оказался битый номер счёта.

Ошибки бывают всегда. Вопрос в другом: как сигнал о сбое проходит через программу и кто обязан на него отреагировать.

Коды ошибок: проверяй после каждого шага

Самый прямой подход — вернуть специальное значение, означающее неуспех. В языке без встроенного механизма исключений (например, в C) это единственный вариант. Снаружи форма выглядит так:

int result = request_payout(account_id, amount);
if (result != OK) {
    log_error(result);
    return result;
}

Каждый шаг возвращает код. Рядом с ним стоит ручная проверка: если не OK, вызывающий либо сам справляется, либо возвращает тот же код выше. Сигнал об ошибке идёт как обычное значение, бок о бок с полезным результатом.

В Ruby тот же подход можно сымитировать, возвращая пару [успех, значение] из каждого шага:

def request_payout(account, amount)
  ok, details = bank_transfer(account, amount)
  return [false, details] unless ok
 
  ok, receipt = record_payout(account, amount)
  return [false, receipt] unless ok
 
  [true, receipt]
end

Цена такого стиля видна сразу: основной путь (перевод — запись — возврат квитанции) утоплен в шумной ткани проверок. Если поверх request_payout появится ещё одна функция, ей тоже придётся либо обработать ошибку, либо протащить её наверх вручную. Проверку легко забыть, и тогда код пойдёт дальше с невалидным значением.

Исключения: сигнал идёт вверх сам

Другой подход — не возвращать код после каждой операции, а прервать обычный ход выполнения и передать управление отдельному обработчику. В Ruby для этого есть конструкция begin/rescue/ensure/end:

begin
  bank_transfer(account, amount)
  record_payout(account, amount)
  notify_seller(account)
rescue => error
  puts "Выплата не прошла: " + error.message
ensure
  close_bank_session
end

Если bank_transfer, record_payout или notify_seller поднимут исключение, управление сразу переходит в rescue. Строки ниже места сбоя не выполняются. Блок ensure выполняется в любом случае — и после успеха, и после ошибки: в нём закрывают сессию, освобождают ресурсы, снимают блокировки.

Форма rescue => error ловит типичные ошибки приложения (в Ruby это StandardError и его потомки); системные сбои вроде прерывания интерпретатора она намеренно не трогает.

Основной путь выглядит чище: три осмысленных шага без обвязки проверок. Но путь ошибки теперь скрыт в механике исключения, а не лежит рядом с каждой строкой. По коду функции нельзя сказать, какие именно сбои она может поднять.

Раскрутка стека: обработчик может быть выше места ошибки

Сильная сторона исключений в том, что обработчик не обязан стоять рядом с местом сбоя. Ошибка поднимается по цепочке вызовов функций — по тем самым кадрам стека:

flowchart BT
    P["bank_transfer -> ошибка сети"] -->|"обработчика нет"| R["request_payout"]
    R -->|"обработчика нет, идёт выше"| H["handle_payout_request<br>обработчик есть — ловится здесь"]
def bank_transfer(account, amount)
  raise "Сеть недоступна" unless network_up?
  # ...
end
 
def request_payout(account, amount)
  bank_transfer(account, amount)
  record_payout(account, amount)
end
 
def handle_payout_request(account, amount)
  begin
    request_payout(account, amount)
    puts "Выплата отправлена"
  rescue => error
    puts "Выплата не прошла: " + error.message
  ensure
    close_bank_session
  end
end

Ни bank_transfer, ни request_payout обработчика не содержат — исключение проходит через них наверх, пока не встретит rescue в handle_payout_request. Такой подъём называется раскруткой стека (stack unwinding): промежуточные кадры снимаются в обратном порядке вызовов. Важно, что во время раскрутки всё равно срабатывают все ensure-блоки — ресурсы освобождаются даже у тех функций, которые сами ошибку не ловят.

Result: ошибка становится частью типа

Коды ошибок легко забыть проверить. Исключения нельзя забыть, но по сигнатуре функции не видно, какие сбои она может поднять. Rust выбирает третий путь: делает ошибочный исход явной частью результата.

fn request_payout(account_id: u64, amount: u64) -> Result<Receipt, PayoutError> {
    let _ = bank_transfer(account_id, amount)?;   // если Err — сразу вверх
    let receipt = record_payout(account_id, amount)?;
    Ok(receipt)
}
 
fn handle_payout_request(account_id: u64, amount: u64) {
    match request_payout(account_id, amount) {
        Ok(receipt)  => println!("Выплата отправлена: {:?}", receipt),
        Err(error)   => println!("Выплата не прошла: {:?}", error),
    }
}

Result<Receipt, PayoutError> означает: функция вернёт либо квитанцию, либо описание сбоя. Символ ? после вызова значит «если здесь Err, не продолжай обычный путь, а верни ту же ошибку вызывающему коду». match в точке, где ошибку уже нельзя протащить выше, заставляет явно разобрать оба варианта.

Путь ошибки виден прямо в сигнатуре. Вызывающий код обязан выбрать один из вариантов: разобрать Ok и Err, передать ошибку дальше через ?, или намеренно завершить программу. Цена — больше церемонии в точке вызова.

Три ответа на один вопрос

ПодходКак идёт сигнал об ошибкеЧто хорошоЦена
Код ошибкикак обычное возвращаемое значениевсё явно рядом с местом вызованужно проверять после каждого шага
Исключениепрыгает вверх до ближайшего обработчикаосновной путь кода корочепуть ошибки не виден по сигнатуре
Resultявляется частью типа результатавызывающий код сразу видит возможность сбоябольше церемонии в точке вызова

Это не три разных вида ошибок. Это три способа провести один и тот же факт через программу: обычный путь продолжать нельзя, нужен отдельный путь обработки. Выбор между ними — компромисс между шумом в коде, невидимостью сбоев и громкостью предупреждений.

Остался ещё один слой, который до сих пор считался данностью: как вообще исходный текст программы превращается в то, что реально выполняется. ruby file.rb и gcc file.c ведут себя по-разному именно здесь — см. компиляцию и интерпретацию.

Sources

  • Klabnik, S. & Nichols, C., 2023, The Rust Programming Language. No Starch Press.
  • Thomas, D. et al., 2023, Programming Ruby 3.3. Pragmatic Bookshelf.
  • Kernighan, B. & Ritchie, D., 1988, The C Programming Language. Prentice Hall.

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