Исключения: обработка нештатных ситуаций
Исключения в 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;.