Исключения — один из самых недооценённых механизмов платформы .NET. Новички либо оборачивают весь код в один огромный блок перехвата, либо вовсе не думают об ошибках, пока приложение не упадёт у заказчика. И то и другое приводит к одному результату: сложно искать причины, а пользователи видят невнятные сообщения.
В этой статье соберём правила, которые помогают писать надёжный код: когда выбрасывать исключение, как и где его перехватывать, что логировать, как освобождать ресурсы и чего категорически не делать.
Что такое исключение и когда оно уместно
Исключение сообщает, что операция не может быть выполнена так, как задумано. Не «пользователь ввёл неправильное значение», а «программа оказалась в ситуации, которую её код не ожидал или не может обработать на текущем уровне».
Хорошая проверка: если событие происходит регулярно и ожидаемо, это не исключительная ситуация. Конец файла при чтении, отсутствие записи в словаре, неверный формат строки от пользователя — всё это повседневные события. Для них лучше использовать возвращаемые значения: методы вида «попробовать разобрать», проверки наличия ключа, булевы флаги результата.
Исключения дороги по времени: их выбрасывание заметно медленнее обычного возврата значения. В цикле на сотни тысяч итераций это ощутимо. Но главная причина не производительность, а ясность: исключение прерывает обычный ход программы, и использовать его для штатной логики значит запутывать читателя кода.
Правило первое: перехватывайте только то, что умеете обработать
Блок перехвата должен делать что-то осмысленное: восстановиться, повторить, подставить значение по умолчанию, преобразовать ошибку в более понятную или записать её и сообщить пользователю. Если ничего из этого вы сделать не можете, не перехватывайте. Пусть исключение поднимется выше, туда, где решение есть.
Самый вредный антипаттерн — пустой перехват:
try
{
SaveReport(data);
}
catch (Exception)
{
}
Ошибка проглочена, программа продолжает работу как ни в чём не бывало, отчёт не сохранён, а причину никто не узнает. Если вы всё же уверены, что ошибку можно игнорировать, оставьте комментарий, почему, и хотя бы запишите факт в журнал.
Другая крайность — ловить базовый тип исключений везде. Он перехватывает всё подряд, включая ошибки, которые обрабатывать нельзя: нехватку памяти, нарушения внутреннего состояния. Лучше перехватывать конкретные типы: ошибки ввода-вывода, истёкшее время ожидания, ошибки сети.
Правило второе: сохраняйте стек вызовов
Когда вы перехватили исключение, но хотите передать его дальше, используйте повторный вызов без указания объекта:
catch (IOException ex)
{
logger.Error(ex, "Не удалось прочитать файл настроек");
throw;
}
Вариант с указанием объекта сбрасывает стек и теряет информацию о том, где ошибка произошла на самом деле. Разница критична при разборе инцидентов: вместо точного места вы увидите лишь строку, где исключение выбросили повторно.
Если нужно обернуть исключение в более высокоуровневое, передавайте исходное как внутреннее:
catch (SqlException ex)
{
throw new OrderStorageException("Не удалось сохранить заказ", ex);
}
Так верхний слой получает понятное сообщение предметной области, а при необходимости разработчик спустится по цепочке до первопричины.
Правило третье: освобождайте ресурсы гарантированно
Файлы, соединения, потоки, сокеты нужно закрывать независимо от того, успешно ли завершилась операция. Для этого есть блок finally и, что удобнее, оператор using.
using (var reader = new StreamReader(path))
{
var text = reader.ReadToEnd();
Process(text);
}
Как только выполнение покидает блок, нормально или из-за ошибки, ресурс освобождается. Это короче и надёжнее, чем вручную писать закрытие в finally. Если ваш класс владеет неуправляемыми ресурсами, реализуйте интерфейс освобождения и пользуйтесь им так же.
Помните: в finally не должны выбрасываться новые исключения. Иначе исходная ошибка будет потеряна, а вы получите вторичную, куда менее информативную.
Правило четвёртое: проверяйте входные данные заранее
Публичные методы должны проверять аргументы в начале и сразу сообщать о нарушении контракта. Для этого есть стандартные типы: для пустой ссылки, для значения вне допустимого диапазона, для неверного состояния объекта.
- Пустую ссылку там, где она недопустима, сообщайте исключением аргумента с указанием его имени.
- Значение вне диапазона сопровождайте сообщением, что именно ожидалось.
- Если метод нельзя вызывать в текущем состоянии объекта, используйте исключение недопустимой операции.
Ранние проверки превращают загадочный сбой где-то в глубине в понятное сообщение у входа. Вы экономите время тому, кто будет искать причину.
Правило пятое: создавайте собственные типы с умом
Собственное исключение оправдано, когда вызывающему коду нужно отличать эту ошибку от остальных и реагировать по-разному. Например, «заказ не найден» и «недостаточно средств» нужно обрабатывать по-разному, а общего типа для них мало.
Рекомендации:
- наследуйте от базового типа исключений, а не от устаревших специализированных веток;
- давайте имени окончание, указывающее на исключение;
- добавляйте конструкторы с сообщением и внутренним исключением;
- включайте в тип полезные свойства: идентификатор заказа, имя параметра, код ошибки;
- не создавайте иерархию из двадцати классов, если вызывающий код различает только два случая.
public class OrderNotFoundException : Exception
{
public int OrderId { get; }
public OrderNotFoundException(int orderId)
: base($"Заказ {orderId} не найден")
{
OrderId = orderId;
}
}
Правило шестое: логируйте один раз и в нужном месте
Частая проблема: исключение пишется в журнал на каждом уровне, через который проходит. В итоге одна ошибка превращается в десяток одинаковых записей, и журнал становится нечитаемым. Договоритесь о правиле: логирует тот, кто окончательно обрабатывает ошибку. Если вы пробрасываете исключение выше, оставляйте логирование верхнему уровню.
В запись помещайте:
- тип и сообщение исключения;
- полный стек вызовов;
- внутренние исключения;
- контекст: идентификатор пользователя или запроса, входные параметры операции;
- время и версию приложения.
Не пишите в журнал пароли, ключи и персональные данные, даже если они были в параметрах операции.
Единая точка обработки на верхнем уровне
В любом приложении должен быть последний рубеж, где собираются необработанные исключения. В веб-приложении это промежуточный обработчик, который превращает ошибку в корректный ответ. В настольной программе это глобальный обработчик событий необработанных исключений. В фоновой службе — обёртка вокруг основного цикла.
На этом уровне вы решаете три задачи: записываете подробности в журнал, показываете пользователю дружелюбное сообщение без технических деталей и по возможности даёте программе продолжить работу или аккуратно завершиться. Детали стека пользователю показывать не нужно: он их не поймёт, а злоумышленнику они подскажут устройство системы.
Асинхронный код и исключения
В асинхронных методах исключение попадает внутрь задачи и выбрасывается при ожидании её результата. Поэтому всегда ожидайте задачи и не оставляйте их «висеть» без присмотра: потерянное исключение никто не увидит.
- Не используйте асинхронные методы без возвращаемого значения за пределами обработчиков событий: ошибки из них трудно перехватить.
- При ожидании нескольких задач помните, что наружу вылетит только первое исключение, а остальные придётся смотреть в самой задаче.
- Для отмены операций используйте специальный механизм и соответствующее исключение, а не обычные ошибки.
Чек-лист правил хорошего тона
- Не используйте исключения для обычной логики.
- Не перехватывайте то, что не умеете обработать.
- Никогда не оставляйте пустой блок перехвата.
- Пробрасывайте повторно без потери стека.
- Освобождайте ресурсы через using или finally.
- Проверяйте аргументы на входе.
- Заводите собственные типы только когда они нужны вызывающему коду.
- Логируйте один раз, с контекстом.
- Показывайте пользователю понятное сообщение, а подробности оставляйте в журнале.
Заключение
Грамотная обработка исключений — это не множество блоков перехвата, а продуманная стратегия. Низкие уровни сообщают о проблемах точно и вовремя, средние добавляют контекст или превращают ошибки в понятия предметной области, верхний собирает всё в единую точку, записывает и аккуратно информирует пользователя.
Попробуйте пройтись по своему проекту с этим списком: найдите пустые перехваты, потерянные стеки, дублирующееся логирование. Обычно уже одна такая ревизия заметно упрощает поддержку и сокращает время поиска ошибок в боевой среде.