Проект ЦИТадель
Третья статья цикла отвечает на вопрос: как функция сообщает о неуспехе и как вызывающий код узнаёт о своей обязанности отреагировать. Для этого используются исключения, ошибки как обычные значения, типы-суммы вроде Result, а для нарушенных инвариантов — остановка операции, задачи или процесса. Сравнивать механизмы полезно по двум признакам: виден ли возможный отказ в интерфейсе и насколько легко его случайно проигнорировать.
Раздел 1 различает ожидаемый отказ и дефект программы. Разделы 2–3 рассматривают исключения и ошибки-значения, раздел 4 — отсутствие значения, раздел 5 — реакцию на нарушенный инвариант. Раздел 6 переносит ту же дисциплину в сетевой контракт. Раздел 7 содержит сводку, раздел 8 — практикум.
Под словом «ошибка» живут две принципиально разные вещи.
Ожидаемый отказ — предусмотренный контрактом исход исправной программы: файла нет, соединение оборвалось, ввод не прошёл разбор, ключ отсутствует. Вызывающий код может сообщить о нём, выбрать другой путь или повторить операцию, если повтор безопасен.
Дефект программы — нарушение заявленного инварианта: например, недостижимая по модели ветка всё же выполнена или внутренняя структура оказалась повреждена. Одно и то же низкоуровневое событие классифицируется по контексту. Деление на ноль в калькуляторе может быть ошибкой пользовательского ввода, а в алгоритме, где знаменатель доказанно положителен, — дефектом реализации.
Эти случаи требуют разной реакции. Ожидаемый отказ следует включить в контракт и обработать на подходящем уровне. Нарушенный внутренний инвариант нужно зафиксировать и прекратить работу той области состояния, которой больше нельзя доверять. Язык помогает выразить это различие, но не способен выбрать классификацию вместо проектировщика. Связь с отказами узлов рассмотрена в главе 1 курса о распределённых системах.
throw прерывает обычный поток исполнения и передаёт управление ближайшему подходящему обработчику. Во время раскрутки стека выполняются предусмотренные языком механизмы очистки: например, деструкторы автоматических объектов в C++ и блоки finally или контекстные конструкции в других языках. Исключения позволяют не смешивать основной путь с повторяющимися проверками, а необработанное исключение обычно становится заметным сбоем. При этом у механизма есть ограничения:
throws.finally и контекстные менеджеры делают эту дисциплину выполнимой, но не восстанавливают автоматически предметный инвариант, частично изменённый до исключения.Проверяемые исключения Java делают часть отказов видимой: вызывающий код должен обработать их или объявить передачу дальше. На практике отношение к ним различается. Они документируют контракт и поддерживаются компилятором, но длинные цепочки и обобщённые интерфейсы могут приводить к оборачиванию в непроверяемые исключения. Поэтому важна не только видимость отказа, но и удобство его преобразования и передачи.
Альтернатива: отказ — не особый режим исполнения, а обычное значение, возвращаемое обычным способом.
Go обычно возвращает результат вместе со значением интерфейсного типа error. Поток отказов виден непосредственно в коде, а fmt.Errorf с %w позволяет добавить контекст и сохранить цепочку для errors.Is/errors.As. Цена — повторяющиеся проверки. Компилятор не требует использовать полученную ошибку, поэтому явное игнорирование остаётся возможным и контролируется соглашениями и линтерами.
Rust представляет ожидаемый неуспех типом Result<T, E>. Чтобы получить T, код должен сопоставить варианты, преобразовать результат, явно вызвать unwrap/expect или передать ошибку оператором ?. Сам тип Result помечен must_use, поэтому неиспользованное значение вызывает предупреждение по умолчанию; это предупреждение можно усилить до ошибки настройкой линтера или явно подавить. Следовательно, механизм делает игнорирование заметным, но не математически невозможным.
Оператор ? выполняет ранний возврат ошибки, совместимый с возвращаемым типом функции. Он похож на распространение исключения по краткости, но остаётся частью обычного типизированного результата. Сравнение механизмов поэтому не сводится к победе одного: исключения дают нелокальную передачу, Go — явный поток значений, Rust — различимый вариант результата и сильную диагностику неиспользования.
Отсутствие значения — отдельный частый случай: элемент не найден, поле не задано, результат неприменим. В языках, где null допустим для любого ссылочного типа, сигнатура может не отличать обязательное значение от отсутствующего. Тони Хоар позднее назвал введение null-ссылки «ошибкой на миллиард долларов».
Option/Maybe и строгие nullable-типы записывают отсутствие в типе. Сила гарантии зависит от языка и настроек: Kotlin и Swift различают nullable и non-null типы, TypeScript требует режима strictNullChecks, а C# использует анализ и предупреждения nullable reference types. На внешней границе статической аннотации недостаточно — входные JSON, данные БД и ответы сети необходимо валидировать. Подробнее это показано в статье «Современный JavaScript и TypeScript».
Если нарушен внутренний инвариант и дальнейшему состоянию нельзя доверять, продолжение работы может скрыть причину и повредить данные. Поэтому дефект обычно фиксируют диагностикой и прекращают работу затронутой области: запросом, задачей, процессом или всем приложением. assert, panic и недостижимые ветви подходят только там, где условие действительно является внутренним обещанием, а не проверяемым пользовательским вводом.
Граница остановки должна совпадать с областью состояния, которую можно безопасно отбросить или восстановить. Erlang-супервизор может перезапустить изолированный процесс; серверный фреймворк может перехватить сбой на границе запроса; отдельный рабочий процесс можно заменить. Но такое поведение не возникает автоматически: необработанная паника в горутине Go завершает программу, а обычный поток Python с исключением не становится самостоятельно перезапускаемым актором. Проектирование переборок связано со статьями «Конкурентность в современных языках» и «Непрерывность и восстановление».
Сетевой API также должен иметь машинно-различимый словарь исходов. Классы HTTP нельзя свести к правилу «4xx не повторять, 5xx повторять». После 401 клиент может повторить запрос с учётными данными, 408 и 429 часто предполагают повтор с ограничением частоты, а 5xx не гарантирует, что повтор безопасен или поможет. Решение зависит от конкретного кода, идемпотентности операции, тела ошибки и заголовков вроде Retry-After.
Коды, стабильные идентификаторы ошибок, различение временного и постоянного состояния и правила повторов проектируются как часть контракта. Внутренняя трасса и диагностический контекст должны попасть в журнал и наблюдаемость, но не в публичный ответ. Связанные правила разобраны в статьях «JSON и дизайн API» и «Надёжность поверх ненадёжной сети».
| Механизм | Насколько исход виден? | Что мешает его игнорировать? | Диагностика причины | Ограничение |
|---|---|---|---|---|
| Код возврата (C) | Частично: зависит от отдельного типа/соглашения | Нет | Добавляется вручную | Легко спутать с обычным результатом |
| Непроверяемые исключения | Обычно нет | Нет | Трасса стека | Нелокальный поток и широкие обработчики |
| Проверяемые исключения (Java) | В сигнатуре throws | Обработать или передать | Трасса стека | Сложность композиции и преобразования |
Значения error (Go) | В возвращаемых значениях | Нет | Цепочка и контекст | Повторяющиеся проверки; можно игнорировать |
Result + ? (Rust) | Да | Предупреждение must_use | Тип ошибки и её цепочка | Нужно проектировать и преобразовывать E |
Option / nullable | Зависит от языка и строгого режима | Зависит от диагностики | — | Описывает только отсутствие значения |
| Паника / аварийная остановка | Для нарушенного инварианта; область остановки и восстановления должна быть спроектирована заранее | |||
Сначала классифицируйте исход в контексте конкретного API. Ожидаемые отказы следует сделать видимыми настолько, насколько позволяет язык: возвращаемым типом, перечисленным исключением, документированным классом исключения или отдельным nullable-типом. Затем определите, где отказ обрабатывается, где получает дополнительный контекст и где передаётся выше. Для нарушенных инвариантов задайте границу остановки и восстановления. Выбор механизма важен, но ясный контракт и последовательное применение важнее моды на конкретный синтаксис.
Сквозной сюжет — «прочитать конфигурацию: файл → разбор → значение по ключу» — содержит три места ожидаемого отказа и один потенциальный дефект. Языки выбраны так, чтобы наглядно показать разные механизмы. Большинство заданий можно выполнить в онлайн-песочницах; для последнего понадобится локальный 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 не изолируется таким образом.
Result по-разному делают отказ видимым и препятствуют игнорированию; ни один механизм не отменяет проектирование ошибок.Option и строгие nullable-типы выражают отсутствие значения, но сила проверки зависит от языка, режима и валидации внешних данных.