Обработка ошибок и исключений в C# (.NET): подробное руководство
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты — шеф-повар на кухне. Ты готовишь сложное блюдо по рецепту. Всё идёт хорошо, пока вдруг... не заканчивается мука. Или ломается миксер. Или ты случайно пересолил суп. Что ты делаешь в такой ситуации? Ты не бросаешь всю кухню и не уходишь домой. Ты обрабатываешь проблему: посылаешь помощника за мукой, чинишь миксер или добавляешь картошку в пересоленный суп.
В программировании происходит то же самое. Твоя программа — это рецепт. Она выполняет шаг за шагом. Но иногда что-то идёт не по плану: пользователь ввёл букву вместо числа, файл, который нужно открыть, не найден, интернет пропал. Если программа не знает, что делать с такой ситуацией, она «падает» — аварийно завершается, показывая непонятную ошибку.
Обработка ошибок и исключений — это твой план действий на случай «кухонных катастроф». Ты заранее говоришь программе: «Если случится проблема такого типа, сделай вот это, а не падай». Это делает твою программу надёжной и дружелюбной к пользователю.
Важно: Ошибка — это не всегда конец света. Иногда это просто сигнал: «Эй, тут что-то пошло не так, давай попробуем иначе или хотя бы вежливо сообщим пользователю».
Теория: как это работает изнутри
В C# (и в .NET в целом) ошибки называются исключениями (exceptions). Почему исключения? Потому что это исключительная ситуация, которая нарушает нормальный ход выполнения программы.
Когда в коде возникает ошибка (например, деление на ноль), среда выполнения .NET делает следующее:
- Создаёт объект исключения. Этот объект — экземпляр класса, который наследуется от
System.Exception. Внутри этого объекта хранится вся информация об ошибке: её тип, текст сообщения, место в коде, где она произошла (стек вызовов). - «Выбрасывает» (throw) исключение. Среда выполнения останавливает текущий блок кода и начинает искать «ловушку» для этого исключения.
- Ищет обработчик (catch). Она поднимается по стеку вызовов — из текущего метода в метод, который его вызвал, затем в метод, вызвавший тот, и так далее. Если она находит блок
catch, который умеет обрабатывать данный тип исключения, она передаёт управление ему. - Если обработчик не найден... Программа аварийно завершается. Пользователь видит страшное окно с ошибкой. Это то, чего мы хотим избежать.
Представь себе многоэтажное здание. На каждом этаже (в каждом методе) есть охранник (блок try-catch). Если на 5-м этаже происходит пожар (исключение), охранник на 5-м этаже может его потушить. Если нет — пожар поднимается на 4-й этаж, потом на 3-й, и так до первого этажа. Если и там никого нет — здание сгорает (программа падает).
Ключевая идея: Исключения — это не просто ошибки. Это механизм передачи информации об ошибке вверх по цепочке вызовов, пока кто-нибудь не возьмёт на себя ответственность за её обработку.
Синтаксис в C# (.NET): все формы и варианты
В C# для работы с исключениями используются три ключевых слова: try, catch и finally. Они образуют конструкцию, которая выглядит так:
try
{
// Здесь находится код, который может вызвать исключение.
// Например, открытие файла, деление, работа с сетью.
}
catch (ТипИсключения переменная)
{
// Этот блок выполняется, если в try произошло исключение указанного типа.
// Здесь мы "обрабатываем" ошибку: логируем её, показываем сообщение, пытаемся исправить.
}
finally
{
// Этот блок выполняется ВСЕГДА, независимо от того, было исключение или нет.
// Здесь мы освобождаем ресурсы: закрываем файлы, соединения с БД.
}
Давай разберём каждый элемент подробнее.
1. Блок try
Это «опасная зона». Ты помещаешь в него код, который, по твоему мнению, может выбросить исключение. Без try программа просто упадёт. С try ты даёшь ей шанс на спасение.
2. Блок catch
Это «спасательная шлюпка». Ты можешь иметь несколько блоков catch для разных типов исключений. C# будет проверять их по порядку сверху вниз, пока не найдёт подходящий.
Варианты записи catch:
catch { ... }— ловит любое исключение. Используй редко, только если ты действительно не знаешь, что может пойти не так.catch (Exception ex) { ... }— ловит любое исключение, но даёт доступ к объектуex, из которого можно узнать детали.catch (DivideByZeroException ex) { ... }— ловит только исключения типаDivideByZeroException(деление на ноль).catch (IOException ex) when (ex.Message.Contains("доступ")) { ... }— ловит исключение только при выполнении дополнительного условия (фильтр исключений).
3. Блок finally
Это «уборщик». Он выполняется всегда: и после успешного выполнения try, и после обработки в catch, и даже если в catch произошла новая ошибка. Он нужен для гарантированной очистки ресурсов.
4. Оператор throw
Ты можешь не только ловить, но и создавать исключения. Это называется «выбросить исключение». Например, если ты пишешь метод, который проверяет возраст пользователя, и возраст отрицательный — это исключительная ситуация.
if (age < 0)
{
throw new ArgumentException("Возраст не может быть отрицательным!");
}
Ты также можешь «пробросить» исключение дальше, не обрабатывая его, а просто добавив информацию:
catch (Exception ex)
{
// Логируем ошибку
Console.WriteLine($"Ошибка: {ex.Message}");
// И пробрасываем дальше, чтобы кто-то другой её обработал
throw; // Важно: просто throw, а не throw ex; — так сохраняется стек вызовов
}
Справочная таблица: основные типы исключений в C#
| Тип исключения | Когда возникает | Пример |
|---|---|---|
System.DivideByZeroException |
Деление целого числа на ноль | int x = 5 / 0; |
System.NullReferenceException |
Попытка обратиться к объекту, который равен null |
string s = null; s.Length; |
System.IndexOutOfRangeException |
Обращение к элементу массива по индексу, выходящему за границы | int[] arr = {1,2}; arr[5]; |
System.FormatException |
Неверный формат строки при преобразовании (например, буква вместо числа) | int.Parse("abc"); |
System.IO.FileNotFoundException |
Попытка открыть несуществующий файл | File.OpenRead("нет_такого.txt"); |
System.ArgumentException |
Методу передан некорректный аргумент | Enumerable.Range(-1, 10); |
System.InvalidOperationException |
Вызов метода в недопустимом для объекта состоянии | Изменение коллекции во время её перебора |
Примеры кода — от простого к сложному
Давай посмотрим, как всё это работает на практике. Каждый пример — это маленькая программа, которую ты можешь запустить у себя.
Пример 1: Простейший try-catch
using System;
class Program
{
static void Main()
{
Console.Write("Введи число: ");
string input = Console.ReadLine(); // Пользователь может ввести что угодно
try
{
// Пытаемся преобразовать строку в число
int number = int.Parse(input);
Console.WriteLine($"Ты ввёл число: {number}");
}
catch (FormatException)
{
// Если пользователь ввёл не число, мы не даём программе упасть
Console.WriteLine("Ошибка! Это не число. Пожалуйста, введи цифры.");
}
Console.WriteLine("Программа продолжает работу. Спасибо, что не упала!");
}
}
Объяснение: int.Parse может выбросить FormatException, если строка не является числом. Мы ловим это исключение и выводим понятное сообщение, вместо того чтобы показывать пользователю красный текст ошибки.
Пример 2: Несколько catch-блоков
using System;
class Program
{
static void Main()
{
try
{
Console.Write("Введи первое число: ");
int a = int.Parse(Console.ReadLine());
Console.Write("Введи второе число: ");
int b = int.Parse(Console.ReadLine());
int result = a / b;
Console.WriteLine($"Результат деления: {result}");
}
catch (FormatException)
{
Console.WriteLine("Ошибка: нужно вводить только цифры!");
}
catch (DivideByZeroException)
{
Console.WriteLine("Ошибка: на ноль делить нельзя!");
}
catch (Exception ex) // Ловим все остальные ошибки
{
Console.WriteLine($"Произошла неожиданная ошибка: {ex.Message}");
}
}
}
Объяснение: Мы обрабатываем разные типы ошибок по-разному. Если пользователь ввёл букву — одно сообщение. Если решил поделить на ноль — другое. А если случилось что-то совсем неожиданное (например, кончилась память), мы ловим это общим catch (Exception).
Пример 3: Блок finally
using System;
using System.IO;
class Program
{
static void Main()
{
StreamReader reader = null; // Объявляем переменную для чтения файла
try
{
reader = new StreamReader("test.txt"); // Пытаемся открыть файл
string content = reader.ReadToEnd(); // Читаем всё содержимое
Console.WriteLine(content);
}
catch (FileNotFoundException)
{
Console.WriteLine("Файл test.txt не найден!");
}
finally
{
// Этот код выполнится ВСЕГДА
if (reader != null)
{
reader.Close(); // Закрываем файл, чтобы освободить ресурсы
Console.WriteLine("Файл закрыт.");
}
}
}
}
Объяснение: finally гарантирует, что файл будет закрыт, даже если в try произошла ошибка. Если бы мы забыли закрыть файл, он мог бы быть заблокирован для других программ.
Пример 4: Создание своего исключения (throw)
using System;
class Program
{
// Метод, который проверяет возраст
static void CheckAge(int age)
{
if (age < 0)
{
// Создаём и выбрасываем своё собственное исключение
throw new ArgumentException("Возраст не может быть отрицательным!");
}
if (age > 150)
{
throw new ArgumentException("Возраст не может быть больше 150 лет!");
}
Console.WriteLine($"Возраст {age} лет принят.");
}
static void Main()
{
try
{
Console.Write("Введи свой возраст: ");
int userAge = int.Parse(Console.ReadLine());
CheckAge(userAge);
}
catch (FormatException)
{
Console.WriteLine("Нужно вводить число!");
}
catch (ArgumentException ex) // Ловим наше собственное исключение
{
Console.WriteLine($"Ошибка в возрасте: {ex.Message}");
}
}
}
Объяснение: Мы не ждём, пока ошибка случится сама. Мы сами проверяем данные и, если они некорректны, создаём исключение с понятным сообщением. Это делает код чище и понятнее.
Пример 5: Проброс исключения (throw; vs throw ex;)
using System;
class Program
{
static void MethodThatThrows()
{
throw new InvalidOperationException("Что-то пошло не так в методе!");
}
static void Main()
{
try
{
MethodThatThrows();
}
catch (Exception ex)
{
// Плохая практика: throw ex; — сбрасывает стек вызовов
// Хорошая практика: throw; — сохраняет стек вызовов
Console.WriteLine("Поймали ошибку, но пробрасываем дальше...");
throw; // Сохраняем оригинальный стек вызовов
}
}
}
Объяснение: Разница между throw; и throw ex; критична для отладки. throw ex; делает вид, что ошибка произошла в текущем методе, и ты теряешь информацию о том, где она случилась на самом деле. Всегда используй throw;.
Пример 6: Фильтр исключений (when)
using System;
using System.Net.Http;
class Program
{
static async Task Main()
{
try
{
HttpClient client = new HttpClient();
string response = await client.GetStringAsync("https://несуществующий-сайт.рф");
Console.WriteLine(response);
}
catch (HttpRequestException ex) when (ex.Message.Contains("Name or service not known"))
{
Console.WriteLine("Не удалось найти сайт. Проверь интернет или адрес.");
}
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
{
Console.WriteLine("Сайт найден, но страница не существует (ошибка 404).");
}
catch (HttpRequestException ex)
{
Console.WriteLine($"Ошибка сети: {ex.Message}");
}
}
}
Объяснение: Фильтр when позволяет ловить исключение только при выполнении определённого условия. Это очень удобно, когда один тип исключения может означать разные проблемы.
Пример 7: Вложенные try-catch
using System;
class Program
{
static void Main()
{
try // Внешний блок
{
Console.Write("Введи число: ");
string input = Console.ReadLine();
try // Внутренний блок
{
int number = int.Parse(input);
Console.WriteLine($"Квадрат числа: {number * number}");
}
catch (FormatException)
{
Console.WriteLine("Внутренняя ошибка: это не число!");
throw; // Пробрасываем исключение дальше, во внешний блок
}
}
catch (Exception ex) // Внешний блок ловит то, что не поймал внутренний
{
Console.WriteLine($"Внешняя обработка: {ex.Message}");
}
}
}
Объяснение: Иногда нужно обработать ошибку частично внутри метода, а затем дать возможность обработать её на более высоком уровне. Вложенные try-catch дают такую возможность.
Сравнение с Python: чем похоже и чем отличается
Если ты уже знаком с Python, тебе будет легче. Концепция исключений очень похожа, но есть важные различия в синтаксисе и философии.
| Характеристика | C# (.NET) | Python |
|---|---|---|
| Ключевые слова | try, catch, finally, throw |
try, except, finally, raise |
| Блок для обработки | catch |
except |
| Выброс исключения | throw new Exception(); |
raise Exception() |
| Типизация исключений | Строгая. Все исключения — наследники System.Exception. Нужно указывать тип. |
Динамическая. Можно выбросить что угодно, но лучше использовать встроенные типы. |
| Блок else | Нет. Код, который выполняется, если не было исключения, пишется после try-catch или в try. |
Есть. else выполняется, если в try не было исключения. |
| Фильтры | Есть. catch (Exception ex) when (условие) |
Нет встроенного синтаксиса. Используется if внутри except. |
| Стек вызовов | Сохраняется при throw;, теряется при throw ex; |
Сохраняется всегда при raise без аргументов. |
| Производительность | Исключения относительно «дорогие» (медленные). Не стоит использовать их для управления логикой. | Тоже «дорогие». Рекомендация та же — не использовать для обычного потока управления. |
Главное отличие в философии: В C# принято проверять (try-parse, проверка на null) перед тем, как делать операцию. В Python часто используется принцип «лучше попросить прощения, чем разрешения» (EAFP — Easier to Ask for Forgiveness than Permission), то есть сначала делаем, а потом ловим исключение. Оба подхода имеют право на жизнь, но в C# первый подход (проверка) встречается чаще.
Типичные ошибки и подводные камни
Даже опытные программисты иногда допускают ошибки при работе с исключениями. Вот список самых распространённых «граблей».
-
Пустой блок catch
try { /* опасный код */ } catch { /* ничего не делаем */ } // ПЛОХО!Это «проглатывание» ошибки. Ты её поймал, но ничего не сделал. Программа продолжает работать, но в неправильном состоянии. Пользователь не знает об ошибке, а разработчик не может её отладить. Всегда хотя бы логируй ошибку!
-
Слишком общий catch
catch (Exception ex) { /* обработка */ } // Часто плохоЛовить
Exception— это как ловить всех зверей одной сетью. Ты можешь пойматьOutOfMemoryException(кончилась память) илиStackOverflowException(переполнение стека), которые обычно не стоит обрабатывать. Лучше ловить конкретные типы исключений. -
Использование исключений для управления логикой
try { int.Parse(userInput); // ... много кода ... } catch (FormatException) { // Пользователь ввёл не число }Это медленно и некрасиво. Вместо этого используй
int.TryParse(), который возвращаетboolи не выбрасывает исключение. Исключения — для исключительных ситуаций, а не для обычных проверок. -
Забывание про finally для ресурсов
Если ты открыл файл, сетевое соединение или подключение к базе данных, ты обязан их закрыть.
finally— лучший способ это гарантировать. В современном C# для этого есть конструкцияusing, которая автоматически вызываетDispose()(аналогfinally), но понимать принципfinallyвсё равно нужно. -
Проброс исключения с потерей стека (throw ex;)
Мы уже говорили об этом в примере 5.
throw ex;— это зло. Всегда используйthrow;. -
Создание исключений с непонятным сообщением
throw new Exception("Ошибка 42"); // Плохо throw new ArgumentException("Значение 'age' не может быть отрицательным. Получено: -5"); // ХорошоСообщение об ошибке должно быть понятным и содержать контекст: что пошло не так, какие были значения, что ожидалось.
Лучшие практики и советы профессионалов
Как писать код, который будет не только работать, но и быть надёжным и удобным для поддержки?
-
Лови исключения на том уровне, где ты можешь с ними что-то сделать.
Не лови исключение в методе, который просто вызывает другой метод, если ты не можешь предложить никакого решения. Дай ошибке «всплыть» до уровня, где есть контекст для принятия решения (например, показать пользователю сообщение или повторить операцию).
-
Используй конкретные типы исключений.
Вместо
catch (Exception)старайся ловитьIOException,SqlException,FormatExceptionи т.д. Это делает твои намерения ясными. -
Логируй все исключения.
Даже если ты обработал ошибку и программа продолжила работу, запиши её в лог (файл, база данных, консоль). Это поможет тебе найти и исправить причину ошибки в будущем. Для логирования в C# часто используют библиотеки вроде Serilog, NLog или встроенный
ILogger. -
Не используй исключения для проверки аргументов, если есть альтернатива.
Для проверки аргументов используй
ifи выбрасывайArgumentExceptionтолько в том случае, если проверка не удалась. Для парсинга чисел используйTryParse. -
Создавай свои классы исключений для специфических ошибок.
Если твоя программа работает с банковскими счетами, создай класс
InsufficientFundsException. Это сделает код самодокументируемым.public class InsufficientFundsException : Exception { public decimal Balance { get; } public decimal Amount { get; } public InsufficientFundsException(decimal balance, decimal amount) : base($"Недостаточно средств. Баланс: {balance}, запрошено: {amount}") { Balance = balance; Amount = amount; } } -
Всегда освобождай ресурсы.
Используй блок
usingдля объектов, которые реализуютIDisposable(файлы, соединения, графические объекты). Это гарантирует, что ресурсы будут освобождены даже при исключении.using (var reader = new StreamReader("file.txt")) { string content = reader.ReadToEnd(); Console.WriteLine(content); } // reader автоматически закроется здесь, даже если было исключение -
Документируй исключения, которые выбрасывает твой метод.
В XML-комментариях к методу указывай, какие исключения он может выбросить и при каких условиях. Это поможет другим разработчикам (и тебе в будущем) правильно использовать твой код.
////// Делит одно число на другое. /// /// Делимое. /// Делитель. ///Результат деления. ///Выбрасывается, если b равно 0. public static double Divide(double a, double b) { if (b == 0) throw new DivideByZeroException("Делитель не может быть нулём."); return a / b; }
Вопросы для самопроверки
Проверь, как ты усвоил материал. Постарайся ответить на вопросы, прежде чем смотреть ответы.
-
Вопрос: Какие три ключевых слова образуют базовую конструкцию для обработки исключений в C#?
Ответ
try,catch,finally. -
Вопрос: В чём разница между
throw;иthrow ex;?Ответ
throw;сохраняет оригинальный стек вызовов, показывая, где именно произошла ошибка.throw ex;сбрасывает стек вызовов, начиная его с текущего метода, что затрудняет отладку. -
Вопрос: Какой блок выполняется всегда — и при возникновении исключения, и при его отсутствии?
Ответ
Блок
finally. -
Вопрос: Какое исключение возникнет, если попытаться обратиться к элементу массива по индексу, выходящему за его границы?
Ответ
System.IndexOutOfRangeException. -
Вопрос: Почему не рекомендуется использовать пустой блок
catch { }?Ответ
Потому что он «проглатывает» ошибку. Программа продолжает работу, но в неизвестном состоянии, и разработчик не узнаёт о проблеме. Это может привести к более серьёзным ошибкам в будущем.
-
Вопрос: Какой метод безопаснее использовать для преобразования строки в число:
int.Parse()илиint.TryParse()? Почему?Ответ
int.TryParse()безопаснее, потому что он не выбрасывает исключение при некорректном вводе, а возвращаетfalse. Это делает код быстрее и чище, особенно если некорректный ввод — ожидаемая ситуация. -
Вопрос: Что такое фильтр исключений и с помощью какого ключевого слова он реализуется в C#?
Ответ
Фильтр исключений позволяет лови