2026 г.

Как современные языки обращаются с ошибками

Проект ЦИТадель

Третья статья цикла отвечает на вопрос: как функция сообщает о неуспехе и как вызывающий код узнаёт о своей обязанности отреагировать. Для этого используются исключения, ошибки как обычные значения, типы-суммы вроде Result, а для нарушенных инвариантов — остановка операции, задачи или процесса. Сравнивать механизмы полезно по двум признакам: виден ли возможный отказ в интерфейсе и насколько легко его случайно проигнорировать.

Раздел 1 различает ожидаемый отказ и дефект программы. Разделы 2–3 рассматривают исключения и ошибки-значения, раздел 4 — отсутствие значения, раздел 5 — реакцию на нарушенный инвариант. Раздел 6 переносит ту же дисциплину в сетевой контракт. Раздел 7 содержит сводку, раздел 8 — практикум.

1. Ожидаемый отказ и дефект программы

Под словом «ошибка» живут две принципиально разные вещи.

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

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

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

2. Исключения: сила и цена нелокальности

throw прерывает обычный поток исполнения и передаёт управление ближайшему подходящему обработчику. Во время раскрутки стека выполняются предусмотренные языком механизмы очистки: например, деструкторы автоматических объектов в C++ и блоки finally или контекстные конструкции в других языках. Исключения позволяют не смешивать основной путь с повторяющимися проверками, а необработанное исключение обычно становится заметным сбоем. При этом у механизма есть ограничения:

  • Непроверяемые исключения не перечислены в сигнатуре. Читателю приходится опираться на документацию, соглашения и типы исключений. Это не относится ко всем системам: Java поддерживает проверяемые исключения, записанные в throws.
  • Обработчик может находиться далеко от причины. Каждый промежуточный слой обязан сохранять допустимое состояние при досрочном выходе. RAII, finally и контекстные менеджеры делают эту дисциплину выполнимой, но не восстанавливают автоматически предметный инвариант, частично изменённый до исключения.
  • Исключение можно проглотить. Слишком широкий обработчик без журнала или реакции скрывает исходный сбой. Это проверяется ревью и линтерами, а не самим механизмом исключений.

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

3. Ошибки как значения: видимость и обязательность

Альтернатива: отказ — не особый режим исполнения, а обычное значение, возвращаемое обычным способом.

Go обычно возвращает результат вместе со значением интерфейсного типа error. Поток отказов виден непосредственно в коде, а fmt.Errorf с %w позволяет добавить контекст и сохранить цепочку для errors.Is/errors.As. Цена — повторяющиеся проверки. Компилятор не требует использовать полученную ошибку, поэтому явное игнорирование остаётся возможным и контролируется соглашениями и линтерами.

Rust представляет ожидаемый неуспех типом Result<T, E>. Чтобы получить T, код должен сопоставить варианты, преобразовать результат, явно вызвать unwrap/expect или передать ошибку оператором ?. Сам тип Result помечен must_use, поэтому неиспользованное значение вызывает предупреждение по умолчанию; это предупреждение можно усилить до ошибки настройкой линтера или явно подавить. Следовательно, механизм делает игнорирование заметным, но не математически невозможным.

Оператор ? выполняет ранний возврат ошибки, совместимый с возвращаемым типом функции. Он похож на распространение исключения по краткости, но остаётся частью обычного типизированного результата. Сравнение механизмов поэтому не сводится к победе одного: исключения дают нелокальную передачу, Go — явный поток значений, Rust — различимый вариант результата и сильную диагностику неиспользования.

4. Отсутствие значения: null и Option

Отсутствие значения — отдельный частый случай: элемент не найден, поле не задано, результат неприменим. В языках, где null допустим для любого ссылочного типа, сигнатура может не отличать обязательное значение от отсутствующего. Тони Хоар позднее назвал введение null-ссылки «ошибкой на миллиард долларов».

Option/Maybe и строгие nullable-типы записывают отсутствие в типе. Сила гарантии зависит от языка и настроек: Kotlin и Swift различают nullable и non-null типы, TypeScript требует режима strictNullChecks, а C# использует анализ и предупреждения nullable reference types. На внешней границе статической аннотации недостаточно — входные JSON, данные БД и ответы сети необходимо валидировать. Подробнее это показано в статье «Современный JavaScript и TypeScript».

5. Нарушенный инвариант и граница остановки

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

Граница остановки должна совпадать с областью состояния, которую можно безопасно отбросить или восстановить. Erlang-супервизор может перезапустить изолированный процесс; серверный фреймворк может перехватить сбой на границе запроса; отдельный рабочий процесс можно заменить. Но такое поведение не возникает автоматически: необработанная паника в горутине Go завершает программу, а обычный поток Python с исключением не становится самостоятельно перезапускаемым актором. Проектирование переборок связано со статьями «Конкурентность в современных языках» и «Непрерывность и восстановление».

6. Ошибки в сетевом контракте

Сетевой API также должен иметь машинно-различимый словарь исходов. Классы HTTP нельзя свести к правилу «4xx не повторять, 5xx повторять». После 401 клиент может повторить запрос с учётными данными, 408 и 429 часто предполагают повтор с ограничением частоты, а 5xx не гарантирует, что повтор безопасен или поможет. Решение зависит от конкретного кода, идемпотентности операции, тела ошибки и заголовков вроде Retry-After.

Коды, стабильные идентификаторы ошибок, различение временного и постоянного состояния и правила повторов проектируются как часть контракта. Внутренняя трасса и диагностический контекст должны попасть в журнал и наблюдаемость, но не в публичный ответ. Связанные правила разобраны в статьях «JSON и дизайн API» и «Надёжность поверх ненадёжной сети».

7. Сводка и правило выбора

МеханизмНасколько исход виден?Что мешает его игнорировать?Диагностика причиныОграничение
Код возврата (C)Частично: зависит от отдельного типа/соглашенияНетДобавляется вручнуюЛегко спутать с обычным результатом
Непроверяемые исключенияОбычно нетНетТрасса стекаНелокальный поток и широкие обработчики
Проверяемые исключения (Java)В сигнатуре throwsОбработать или передатьТрасса стекаСложность композиции и преобразования
Значения error (Go)В возвращаемых значенияхНетЦепочка и контекстПовторяющиеся проверки; можно игнорировать
Result + ? (Rust)ДаПредупреждение must_useТип ошибки и её цепочкаНужно проектировать и преобразовывать E
Option / nullableЗависит от языка и строгого режимаЗависит от диагностикиОписывает только отсутствие значения
Паника / аварийная остановкаДля нарушенного инварианта; область остановки и восстановления должна быть спроектирована заранее

Сначала классифицируйте исход в контексте конкретного API. Ожидаемые отказы следует сделать видимыми настолько, насколько позволяет язык: возвращаемым типом, перечисленным исключением, документированным классом исключения или отдельным nullable-типом. Затем определите, где отказ обрабатывается, где получает дополнительный контекст и где передаётся выше. Для нарушенных инвариантов задайте границу остановки и восстановления. Выбор механизма важен, но ясный контракт и последовательное применение важнее моды на конкретный синтаксис.

8. Практикум: способы представить и обработать отказ

Сквозной сюжет — «прочитать конфигурацию: файл → разбор → значение по ключу» — содержит три места ожидаемого отказа и один потенциальный дефект. Языки выбраны так, чтобы наглядно показать разные механизмы. Большинство заданий можно выполнить в онлайн-песочницах; для последнего понадобится локальный Python.

Задание 1. Потерянная ошибка (Python). Реализуйте цепочку чтения и разбора конфигурации. Добавьте слишком широкий except Exception: pass и покажите, какой последующий симптом скрывает исходную причину. Затем откройте файл без with, искусственно вызовите исключение до явного close и проверьте состояние ресурса, сохранив на него ссылку. Перепишите участок с контекстным менеджером.

Задание 2. Явная цепочка (Go). Реализуйте ту же операцию с возвращаемым error и обёртыванием контекста через fmt.Errorf с %w. Проверьте цепочку с errors.Is или errors.As. Затем явно проигнорируйте одну ошибку и подключите errcheck, чтобы сравнить требования компилятора и линтера.

Задание 3. Результат как тип (Rust Playground). Реализуйте ту же цепочку на Result с оператором ?. Попробуйте использовать результат как готовое значение и отдельно оставьте Result неиспользованным: сравните ошибку типов с предупреждением unused_must_use. Включите #![deny(unused_must_use)] и посмотрите, как политика проекта усиливает предупреждение. Затем замените одно ? на unwrap и объясните, оправдан ли этот выбор инвариантом.

Задание 4. Null и строгий режим (TypeScript Playground). Верните null из функции поиска и обратитесь к результату без проверки. Сравните диагностику при выключенном и включённом strictNullChecks. Затем перепишите интерфейс как размеченный результат, различающий «не найдено» и ошибку поиска.

Задание 5. Словарь ошибок API. Для операции «выдать книгу» составьте таблицу HTTP-кодов, стабильных кодов предметных ошибок и правил повторов. Отдельно разберите 401, 409, 429 и 503: какие дополнительные условия и заголовки нужны клиенту? Определите, какая диагностика попадёт в журнал, а какая — в ответ.

Задание 6*. Перезапуск рабочего процесса (Python). Запустите задание в отдельном multiprocessing.Process. Для ожидаемого отказа верните структурированный результат через очередь, а для искусственного нарушения инварианта выбросьте необработанное исключение. Родитель должен увидеть ненулевой exitcode, сохранить диагностику и запустить новый процесс со свежим состоянием. Объясните, почему тот же опыт нельзя эквивалентно провести с обычным потоком Python и почему необработанная паника горутины Go не изолируется таким образом.

Итоги

  • Ожидаемый отказ и нарушение внутреннего инварианта различаются контрактом и контекстом, а не видом низкоуровневого события.
  • Исключения, значения Go и Result по-разному делают отказ видимым и препятствуют игнорированию; ни один механизм не отменяет проектирование ошибок.
  • Option и строгие nullable-типы выражают отсутствие значения, но сила проверки зависит от языка, режима и валидации внешних данных.
  • При дефекте прекращают работу области недостоверного состояния. Граница остановки и порядок перезапуска должны быть спроектированы явно и поддерживать безопасное восстановление.
  • HTTP-класс сам по себе не определяет возможность повтора: нужны семантика конкретного кода, идемпотентность и контракт API.

Литература

  1. C. A. R. Hoare, "Null References: The Billion Dollar Mistake" — доклад Тони Хоара о последствиях введения null-ссылок.
  2. J. Duffy, "The Error Model" — подробный разбор модели ошибок в исследовательской ОС Midori. joeduffyblog.com
  3. Глава об обработке ошибок книги о Rust (doc.rust-lang.org); статьи блога Go об ошибках и их обёртывании (go.dev/blog).
  4. Связанные материалы сайта: «JSON и дизайн API» и «Надёжность поверх ненадёжной сети» (сетевой контракт), «Современный JavaScript и TypeScript» (nullable-типы и валидация).
  5. Оглавление раздела «Языки и парадигмы программирования»; другие статьи цикла: «Как современные языки управляют памятью», «Конкурентность в современных языках» и «Системы типов: что доказывает компилятор».
404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией