Программа курса← C#: Базовый курс
1Знакомство с C# и .NET
2Данные и типы
3Ветвления и циклы
4Методы и ошибки
5Коллекции и строки
6Классы и объекты

Исключения

Не всякую ошибку можно предусмотреть проверкой. Файла может не оказаться на месте, сеть – отвалиться, пользователь – ввести буквы вместо цифр. Для таких ситуаций в C# есть исключения.

Что происходит без обработки

int[] numbers = { 1, 2, 3 };
Console.WriteLine(numbers[10]);

Программа аварийно завершится, напечатав тип ошибки, сообщение и путь вызовов:

Unhandled exception. System.IndexOutOfRangeException:
Index was outside the bounds of the array.
   at Program.Main() in Program.cs:line 2

Это стек вызовов – список методов от места ошибки до точки входа. Читается снизу вверх и почти всегда отвечает на вопрос «откуда это взялось».

Не закрывайте окно с ошибкой, не прочитав первые три строки. Тип исключения и номер строки обычно объясняют проблему целиком, а поиск по одному лишь тексту сообщения уводит в чужие истории.

try и catch

try
{
    int number = int.Parse(Console.ReadLine());
    Console.WriteLine(100 / number);
}
catch (FormatException)
{
    Console.WriteLine("Это не число");
}
catch (DivideByZeroException)
{
    Console.WriteLine("На ноль делить нельзя");
}

В try – код, который может не получиться. В catch – что делать, если не получилось. Как только внутри try возникло исключение, оставшиеся строки блока пропускаются и управление уходит в подходящий catch.

Блоки проверяются сверху вниз, и срабатывает первый подходящий. Отсюда правило: частные типы идут выше общих.

catch (Exception ex)
{
    Console.WriteLine($"Что-то пошло не так: {ex.Message}");
}

Exception – предок всех исключений, он ловит всё. Поставив его первым, вы сделаете остальные блоки недостижимыми – компилятор об этом предупредит.

Что внутри исключения

Объект исключения несёт больше, чем текст:

Свойство Что содержит
Message описание для человека
StackTrace путь вызовов до места ошибки
InnerException исходное исключение, если это обёртка над другим
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
    Console.WriteLine(ex.StackTrace);
}

Никогда не пишите пустой catch { }. Программа продолжит работу в состоянии, которого вы не ожидали, и настоящая причина сбоя проявится позже и совсем в другом месте. Если исключение действительно можно игнорировать, напишите в блоке комментарий, почему.

finally

Блок, который выполняется всегда – и после удачного завершения, и после catch, и даже если из try сработал return:

StreamReader reader = null;

try
{
    reader = new StreamReader("data.txt");
    Console.WriteLine(reader.ReadToEnd());
}
catch (FileNotFoundException)
{
    Console.WriteLine("Файла нет");
}
finally
{
    reader?.Close();
}

Смысл в освобождении ресурсов: файл нужно закрыть в любом случае, иначе он останется заблокирован.

Для объектов, которые умеют освобождаться сами, есть более короткая форма – using:

using var reader = new StreamReader("data.txt");
Console.WriteLine(reader.ReadToEnd());

Файл закроется при выходе из области видимости, даже если случится исключение. Компилятор разворачивает эту строку в try-finally – вручную писать то же самое не нужно.

Бросить исключение самому

Когда метод не может выполнить свою работу, он сообщает об этом словом throw:

static int Divide(int a, int b)
{
    if (b == 0)
    {
        throw new ArgumentException("Делитель не может быть нулём", nameof(b));
    }

    return a / b;
}

Готовые типы под частые ситуации уже есть, и брать нужно самый точный:

Тип Когда
ArgumentException аргумент бессмысленный
ArgumentNullException аргумент равен null, а не должен
ArgumentOutOfRangeException аргумент вне допустимого диапазона
InvalidOperationException вызов неуместен в текущем состоянии объекта
NotSupportedException операция не поддерживается

Оператор nameof(b) подставляет имя параметра строкой. Переименуете параметр – текст обновится сам, потому что это не строка, а ссылка на имя.

Исключения – не замена проверкам

Главная ошибка новичка – использовать исключения как обычное ветвление:

try
{
    int number = int.Parse(input);
}
catch (FormatException)
{
    number = 0;
}

Здесь исключение обрабатывает ожидаемую ситуацию: пользователь вполне может ввести что угодно. Правильный вариант – проверка:

int.TryParse(input, out int number);

Разница не только в красоте. Выброс исключения обходится в тысячи процессорных тактов – это дорогая операция, рассчитанная на редкие случаи.

Отсюда простой критерий. Ситуация ожидаемая и проверяемая заранее (пустой ввод, отсутствующий ключ) – проверяйте условием. Ситуация исключительная и от вас не зависящая (сеть отвалилась, диск заполнен) – ловите исключение.

Что ловить, а что нет

Ловить исключение стоит, только если вы знаете, что с ним делать: показать понятное сообщение, попробовать ещё раз, взять запасной источник данных.

Если делать с ним нечего – не ловите. Пусть оно поднимется выше, к тому коду, который в состоянии принять решение. Исключение, проглоченное в глубине программы, превращает понятную аварию в загадочное неправильное поведение – а его отлаживать в разы дороже.