🔷 C#

Обработка ошибок и исключений в C# (.NET): подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

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

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

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

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

Теория: как это работает изнутри

В C# (и в .NET в целом) ошибки называются исключениями (exceptions). Почему исключения? Потому что это исключительная ситуация, которая нарушает нормальный ход выполнения программы.

Когда в коде возникает ошибка (например, деление на ноль), среда выполнения .NET делает следующее:

  1. Создаёт объект исключения. Этот объект — экземпляр класса, который наследуется от System.Exception. Внутри этого объекта хранится вся информация об ошибке: её тип, текст сообщения, место в коде, где она произошла (стек вызовов).
  2. «Выбрасывает» (throw) исключение. Среда выполнения останавливает текущий блок кода и начинает искать «ловушку» для этого исключения.
  3. Ищет обработчик (catch). Она поднимается по стеку вызовов — из текущего метода в метод, который его вызвал, затем в метод, вызвавший тот, и так далее. Если она находит блок catch, который умеет обрабатывать данный тип исключения, она передаёт управление ему.
  4. Если обработчик не найден... Программа аварийно завершается. Пользователь видит страшное окно с ошибкой. Это то, чего мы хотим избежать.

Представь себе многоэтажное здание. На каждом этаже (в каждом методе) есть охранник (блок 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# первый подход (проверка) встречается чаще.

Типичные ошибки и подводные камни

Даже опытные программисты иногда допускают ошибки при работе с исключениями. Вот список самых распространённых «граблей».

  1. Пустой блок catch
    
    try { /* опасный код */ }
    catch { /* ничего не делаем */ } // ПЛОХО!
            

    Это «проглатывание» ошибки. Ты её поймал, но ничего не сделал. Программа продолжает работать, но в неправильном состоянии. Пользователь не знает об ошибке, а разработчик не может её отладить. Всегда хотя бы логируй ошибку!

  2. Слишком общий catch
    
    catch (Exception ex) { /* обработка */ } // Часто плохо
            

    Ловить Exception — это как ловить всех зверей одной сетью. Ты можешь поймать OutOfMemoryException (кончилась память) или StackOverflowException (переполнение стека), которые обычно не стоит обрабатывать. Лучше ловить конкретные типы исключений.

  3. Использование исключений для управления логикой
    
    try
    {
        int.Parse(userInput);
        // ... много кода ...
    }
    catch (FormatException)
    {
        // Пользователь ввёл не число
    }
            

    Это медленно и некрасиво. Вместо этого используй int.TryParse(), который возвращает bool и не выбрасывает исключение. Исключения — для исключительных ситуаций, а не для обычных проверок.

  4. Забывание про finally для ресурсов

    Если ты открыл файл, сетевое соединение или подключение к базе данных, ты обязан их закрыть. finally — лучший способ это гарантировать. В современном C# для этого есть конструкция using, которая автоматически вызывает Dispose() (аналог finally), но понимать принцип finally всё равно нужно.

  5. Проброс исключения с потерей стека (throw ex;)

    Мы уже говорили об этом в примере 5. throw ex; — это зло. Всегда используй throw;.

  6. Создание исключений с непонятным сообщением
    
    throw new Exception("Ошибка 42"); // Плохо
    throw new ArgumentException("Значение 'age' не может быть отрицательным. Получено: -5"); // Хорошо
            

    Сообщение об ошибке должно быть понятным и содержать контекст: что пошло не так, какие были значения, что ожидалось.

Лучшие практики и советы профессионалов

Как писать код, который будет не только работать, но и быть надёжным и удобным для поддержки?

  1. Лови исключения на том уровне, где ты можешь с ними что-то сделать.

    Не лови исключение в методе, который просто вызывает другой метод, если ты не можешь предложить никакого решения. Дай ошибке «всплыть» до уровня, где есть контекст для принятия решения (например, показать пользователю сообщение или повторить операцию).

  2. Используй конкретные типы исключений.

    Вместо catch (Exception) старайся ловить IOException, SqlException, FormatException и т.д. Это делает твои намерения ясными.

  3. Логируй все исключения.

    Даже если ты обработал ошибку и программа продолжила работу, запиши её в лог (файл, база данных, консоль). Это поможет тебе найти и исправить причину ошибки в будущем. Для логирования в C# часто используют библиотеки вроде Serilog, NLog или встроенный ILogger.

  4. Не используй исключения для проверки аргументов, если есть альтернатива.

    Для проверки аргументов используй if и выбрасывай ArgumentException только в том случае, если проверка не удалась. Для парсинга чисел используй TryParse.

  5. Создавай свои классы исключений для специфических ошибок.

    Если твоя программа работает с банковскими счетами, создай класс 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;
        }
    }
            
  6. Всегда освобождай ресурсы.

    Используй блок using для объектов, которые реализуют IDisposable (файлы, соединения, графические объекты). Это гарантирует, что ресурсы будут освобождены даже при исключении.

    
    using (var reader = new StreamReader("file.txt"))
    {
        string content = reader.ReadToEnd();
        Console.WriteLine(content);
    } // reader автоматически закроется здесь, даже если было исключение
            
  7. Документируй исключения, которые выбрасывает твой метод.

    В XML-комментариях к методу указывай, какие исключения он может выбросить и при каких условиях. Это поможет другим разработчикам (и тебе в будущем) правильно использовать твой код.

    
    /// 
    /// Делит одно число на другое.
    /// 
    /// Делимое.
    /// Делитель.
    /// Результат деления.
    /// Выбрасывается, если b равно 0.
    public static double Divide(double a, double b)
    {
        if (b == 0)
            throw new DivideByZeroException("Делитель не может быть нулём.");
        return a / b;
    }
            

Вопросы для самопроверки

Проверь, как ты усвоил материал. Постарайся ответить на вопросы, прежде чем смотреть ответы.

  1. Вопрос: Какие три ключевых слова образуют базовую конструкцию для обработки исключений в C#?
    Ответ

    try, catch, finally.

  2. Вопрос: В чём разница между throw; и throw ex;?
    Ответ

    throw; сохраняет оригинальный стек вызовов, показывая, где именно произошла ошибка. throw ex; сбрасывает стек вызовов, начиная его с текущего метода, что затрудняет отладку.

  3. Вопрос: Какой блок выполняется всегда — и при возникновении исключения, и при его отсутствии?
    Ответ

    Блок finally.

  4. Вопрос: Какое исключение возникнет, если попытаться обратиться к элементу массива по индексу, выходящему за его границы?
    Ответ

    System.IndexOutOfRangeException.

  5. Вопрос: Почему не рекомендуется использовать пустой блок catch { }?
    Ответ

    Потому что он «проглатывает» ошибку. Программа продолжает работу, но в неизвестном состоянии, и разработчик не узнаёт о проблеме. Это может привести к более серьёзным ошибкам в будущем.

  6. Вопрос: Какой метод безопаснее использовать для преобразования строки в число: int.Parse() или int.TryParse()? Почему?
    Ответ

    int.TryParse() безопаснее, потому что он не выбрасывает исключение при некорректном вводе, а возвращает false. Это делает код быстрее и чище, особенно если некорректный ввод — ожидаемая ситуация.

  7. Вопрос: Что такое фильтр исключений и с помощью какого ключевого слова он реализуется в C#?
    Ответ

    Фильтр исключений позволяет лови