Исключения: обработка нештатных ситуаций

Исключения в Ada — структурный механизм для нештатных ситуаций: возбудил (raise), перехватил по имени (when), обработал — без молчаливого продолжения с битыми данными.

Исключение (exception) — поименованное событие, сигнализирующее о нарушении нормального хода программы; будучи возбуждённым (raise), оно прерывает текущее выполнение и передаёт управление ближайшему обработчику (exception ... when), а при его отсутствии распространяется вверх по стеку вызовов.

Философия Ada по части ошибок проста и бескомпромиссна: лучше остановиться, чем продолжить с неверными данными. В системе управления самолётом «тихо вернуть ноль» вместо корректного значения может стоить жизней. Поэтому в Ada нештатные ситуации не растворяются в кодах возврата, которые так легко забыть проверить, а возбуждают исключение — событие, которое невозможно случайно проигнорировать: либо вы его перехватили и обработали, либо программа прерывается. Этот механизм был в языке с самого начала, в 1983 году, задолго до того, как исключения стали мейнстримом в C++ и Java.

Зачем нужны исключения, а не коды возврата

Классическая беда кодов возврата — их легко проигнорировать. Функция вернула -1, но вызывающий не проверил, и программа поехала дальше с мусором. Исключение устроено иначе: если его не перехватить, оно «всплывает» через все уровни вызовов и в конце концов останавливает задачу или программу. Игнорирование требует усилия (надо явно написать пустой обработчик), а реакция — это поведение по умолчанию. Для надёжных систем такая инверсия — огромное преимущество: ошибки громко заявляют о себе, а не прячутся.

Второе преимущество — отделение основного кода от обработки сбоев. Тело подпрограммы пишется в расчёте на «всё хорошо», а раздел exception в конце аккуратно собирает реакции на «всё плохо». Логика и обработка ошибок не переплетаются в гирлянду if err != nil, а живут в разных секциях.

with Ada.Text_IO; use Ada.Text_IO;
procedure Safe_Divide is
   A : constant Integer := 10;
   B : constant Integer := 0;
   R : Integer;
begin
   R := A / B;                         -- деление на ноль
   Put_Line ("Result =" & R'Image);    -- сюда не попадём
exception
   when Constraint_Error =>
      Put_Line ("Ошибка: деление на ноль перехвачено");
end Safe_Divide;

Вывод:

Ошибка: деление на ноль перехвачено

Объявление, возбуждение и перехват

Своё исключение объявляется как обычная сущность: My_Error : exception;. Возбуждается оператором raise My_Error;. Перехватывается в разделе exception по имени через ветви when. Можно перехватывать конкретное исключение, перечислять несколько через |, а ловушка when others ловит всё остальное.

package body Bank is
   Insufficient_Funds : exception;

   procedure Withdraw (Account : in out Money; Amount : in Money) is
   begin
      if Amount > Account then
         raise Insufficient_Funds
            with "Запрошено больше, чем на счёте";   -- с сообщением
      end if;
      Account := Account - Amount;
   end Withdraw;
end Bank;

--  На стороне клиента:
begin
   Withdraw (Balance, 1000.0);
exception
   when Insufficient_Funds =>
      Put_Line ("Недостаточно средств");
   when Constraint_Error | Storage_Error =>
      Put_Line ("Системная проблема");
   when others =>
      Put_Line ("Неизвестная ошибка");
end;

Конструкция raise E with "текст"; (Ada 2005 и новее) присоединяет к исключению поясняющее сообщение, которое потом извлекается из пакета Ada.Exceptions функцией Exception_Message. Это превращает исключение из «голого факта» в диагностируемое событие.

Получение информации об исключении

Иногда обработчику нужны детали: что именно произошло, с каким сообщением. Пакет Ada.Exceptions даёт тип Exception_Occurrence и функции Exception_Name, Exception_Message, Exception_Information. Связать «пойманный экземпляр» с переменной можно синтаксисом when E : others =>.

with Ada.Exceptions; use Ada.Exceptions;
with Ada.Text_IO;    use Ada.Text_IO;
...
exception
   when E : others =>
      Put_Line ("Поймано: " & Exception_Name (E));
      Put_Line ("Детали:  " & Exception_Message (E));
end;

Как работает под капотом

Когда возбуждается исключение, среда исполнения сворачивает стек вызовов (stack unwinding), ища по пути ближайший подходящий раздел exception. По мере сворачивания корректно завершаются объекты с финализацией (controlled-типы получают вызов Finalize), освобождаются ресурсы, связанные с покидаемыми областями. Если ни один обработчик не нашёлся в текущей задаче, она завершается; необработанное исключение в главной программе её прекращает с диагностикой.

Важно понимать стоимость. Современные реализации применяют модель «zero-cost exceptions»: пока исключение не возбуждено, накладных расходов почти нет — обычный путь исполнения не платит ничего. Зато само возбуждение и сворачивание стека — операция сравнительно дорогая. Вывод инженерный: исключения — для действительно исключительных, нештатных ситуаций, а не для штатной передачи управления в горячем цикле. Для жёсткого реального времени, где даже редкая дорогая операция нежелательна, существуют ограниченные профили (об этом — в разделе о параллелизме и в обзоре SPARK).

Стратегии обработки: восстановление и распространение

Перехватить исключение — это лишь половина дела; важнее решить, что делать дальше. В инженерной практике сложились несколько устойчивых стратегий, и выбор между ними определяет надёжность системы. Первая — локальное восстановление: обработчик исправляет ситуацию и продолжает работу. Например, при ошибке чтения файла можно подставить значения по умолчанию и идти дальше. Эта стратегия уместна, когда у проблемы есть осмысленный «план Б», не нарушающий корректности результата.

Вторая стратегия — распространение с обогащением: обработчик не справляется сам, но добавляет контекст и пробрасывает исключение выше, где о ситуации знают больше. Низкоуровневая процедура чтения сектора диска не знает, какую операцию пользователь хотел выполнить, — поэтому она логирует технические детали и перевозбуждает исключение, а уже верхний уровень решает, повторить операцию, сообщить пользователю или перейти в безопасный режим. Третья стратегия, типичная для систем высокой надёжности, — переход в безопасное состояние (fail-safe): при любой неустранимой ошибке система не пытается «угадать», а переводит управляемый объект в заведомо безопасную конфигурацию (двигатель — на холостой ход, шлагбаум — в закрытое положение). Здесь исключение — это сигнал «доверять текущему состоянию больше нельзя».

Финализация и гарантии при сворачивании стека

Отдельная сила механизма Ada — взаимодействие исключений с финализацией. Типы, производные от Ada.Finalization.Controlled, получают вызов процедуры Finalize при выходе объекта из области видимости — в том числе при аварийном выходе из-за исключения. Это даёт надёжный аналог идиомы «получение ресурса есть инициализация» (RAII): объект, владеющий ресурсом (файлом, блокировкой, памятью), гарантированно освободит его, даже если область покидается через исключение, а не нормальным путём. В отличие от языков, где забытый finally или незакрытый ресурс — частый дефект, здесь освобождение привязано к времени жизни объекта и срабатывает автоматически при сворачивании стека. Поэтому в Ada редко пишут громоздкие конструкции «попробовать/в конце закрыть»: ресурсами управляют controlled-типы, а исключения лишь корректно через них проходят, не оставляя утечек.

Исключения и предсказуемость: две стороны медали

У механизма исключений есть аспект, важный именно для систем реального времени: предсказуемость по времени. Модель «zero-cost» означает, что нормальный путь исполнения (когда исключение не возбуждается) не несёт накладных расходов вовсе — нет проверок, нет дополнительного кода. Это идеально для горячих путей. Но обратная сторона — само возбуждение исключения и сворачивание стека стоят сравнительно дорого и, что важнее, их длительность зависит от глубины стека и числа финализируемых объектов на пути. В коде, где время отклика жёстко ограничено, такая переменная стоимость нежелательна.

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

Подчеркнём ещё раз главную ценность исключений для надёжности — инверсию умолчания. В мире кодов возврата по умолчанию ошибка игнорируется (надо приложить усилие, чтобы её проверить), и потому забытые проверки — массовый источник дефектов. В мире исключений по умолчанию ошибка останавливает программу (надо приложить усилие, чтобы её проглотить), и потому ни одна нештатная ситуация не исчезает бесследно. Для систем высокой ответственности эта инверсия бесценна: она превращает «тихий сбой с продолжением на битых данных» — самый опасный исход — в громкое, локализованное, диагностируемое событие. Всё остальное в механизме (сообщения, Ada.Exceptions, финализация при сворачивании) лишь обслуживает эту центральную идею: ошибки не должны теряться. Понимание, что исключения существуют не ради удобства синтаксиса, а ради этой гарантии, — то, что отличает осознанное владение языком от формального.

Частые ошибки

  • Глотать исключения пустым when others => null;. Это возвращает ровно ту беду, от которой исключения спасают, — молчаливое продолжение с битым состоянием. Если ловите, то реагируйте: логируйте, восстанавливайте или перевозбуждайте.
  • Использовать исключения как обычный goto/возврат в горячем коде. Возбуждение дорого; для штатных ветвлений используйте обычные условия, а исключения берегите для нештатного.
  • Терять контекст при перевозбуждении. Чтобы пробросить пойманное исключение дальше, пишите голый raise; (без имени) — он сохранит исходный экземпляр и его сообщение; raise Some_Error; создаст новое и потеряет детали.
  • Полагать, что исключение «починит» данные. Исключение лишь сообщает о проблеме и передаёт управление; восстановление инварианта — ваша работа в обработчике.

Итоги

  • Исключение нельзя случайно проигнорировать: не перехваченное, оно всплывает по стеку и останавливает выполнение.
  • Объявление — E : exception;, возбуждение — raise E with "msg";, перехват — раздел exception ... when.
  • when others ловит всё; Ada.Exceptions даёт имя, сообщение и детали пойманного.
  • Под капотом — сворачивание стека с корректной финализацией; модель zero-cost: дорог только сам бросок.
  • Исключения — для нештатных ситуаций; «глотать» их пустым обработчиком опасно, перевозбуждать — голым raise;.
Проверьте себя
1. Чем механизм исключений Ada надёжнее кодов возврата для систем высокой ответственности?
AИсключения работают быстрее любых проверок
BНе перехваченное исключение по умолчанию всплывает и останавливает программу, тогда как код возврата легко молча проигнорировать
CИсключения автоматически исправляют повреждённые данные
DКоды возврата вообще не поддерживаются в Ada
2. Как корректно пробросить уже пойманное исключение дальше, не потеряв его сообщение и контекст?
Araise Some_Error; с тем же именем
BГолый raise; без имени
Creturn из обработчика
DПовторно вызвать ту же подпрограмму