Advanced Core

Обробка Винятків

Глибоке розуміння механізмів обробки винятків у C# — від базових конструкцій до професійних практик написання надійного коду

Обробка Винятків

Вступ та Контекст

Уявіть, що ви створюєте систему обробки банківських транзакцій. Користувач вводить суму переказу, натискає "Підтвердити", і раптом... база даних недоступна. Або файл конфігурації пошкоджений. Або сума перевищує доступний баланс. Як ваша програма має відреагувати на ці ситуації?

Проблема: У ранніх мовах програмування (C, Pascal) помилки обробляли через коди повернення. Кожен виклик функції потрібно було перевіряти вручну. Це призводило до громіздкого, важкочитабельного коду, де логіка бізнесу змішувалася з перевіркою помилок.

Винятки (Exceptions) вирішують цю проблему елегантно. Вони дозволяють відокремити нормальну логіку програми від коду обробки помилок, автоматично передають інформацію про помилку вгору по стеку викликів і гарантують, що критичні ресурси будуть звільнені навіть у випадку збою.

Еволюція Обробки Помилок

Loading diagram...
graph LR
    A[Error Codes ❌ Громіздко] -->|Проблема: легко забути перевірити| B[Assertions ❌ Тільки для debug]
    B -->|Потрібен runtime механізм| C[Винятки ✅ Структуровані]
    C -->|Розвиток| D[Exception Filters ✅ when clause]
    D -->|Альтернатива| E[Result Pattern ✅ Без overhead]

    style A fill:#64748b,stroke:#334155,color:#ffffff
    style B fill:#64748b,stroke:#334155,color:#ffffff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#f59e0b,stroke:#b45309,color:#ffffff
    style E fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Передумови

Перед вивченням цієї теми вам потрібно розуміти:

  • Методи та їх виклики
  • Поняття стеку викликів (call stack)
  • Базові концепції ООП (класи, наслідування)
  • Інтерфейси (опціонально для розуміння IDisposable)

Фундаментальні Концепції

Що таке Виняток?

Виняток (Exception) — це об'єкт, що представляє помилку або виняткову ситуацію, яка виникає під час виконання програми. Коли виникає виняток, нормальний потік виконання програми перериваєтьс я, і управління передається до найближчого обробника винятків (exception handler).

Теорія: Винятки vs Помилки Логіки

Важливо розуміти різницю між винятками та помилками логіки:

  • Винятки (Exceptions) — це несподівані ситуації, які програма може спробувати обробити: відсутній файл, проблеми з мережею, невалідний ввід користувача.
  • Помилки логіки (Bugs) — це дефекти в коді: звернення до null посилання через неправильну логіку, вихід за межі масиву через некоректний індекс. Це має бути виправлено на етапі розробки, а не оброблено в runtime.

Ієрархія Винятків

Усі винятки в .NET успадковуються від базового класу System.Exception:

Loading diagram...
classDiagram
    Exception <|-- SystemException
    Exception <|-- ApplicationException

    SystemException <|-- ArgumentException
    SystemException <|-- InvalidOperationException
    SystemException <|-- NullReferenceException
    SystemException <|-- IndexOutOfRangeException
    SystemException <|-- DivideByZeroException
    SystemException <|-- IOException

    ArgumentException <|-- ArgumentNullException
    ArgumentException <|-- ArgumentOutOfRangeException

    IOException <|-- FileNotFoundException
    IOException <|-- DirectoryNotFoundException

    class Exception {
        +string Message
        +string StackTrace
        +Exception InnerException
        +string Source
        +GetBaseException()
    }

    class SystemException {
        «CLR generated»
    }

    class ApplicationException {
        «User defined»
    }

    style Exception fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style SystemException fill:#64748b,stroke:#334155,color:#ffffff
    style ApplicationException fill:#f59e0b,stroke:#b45309,color:#ffffff

Ключові типи винятків:

ТипКоли виникаєПриклад
ArgumentNullExceptionПараметр методу є null, а метод очікує значенняПередача null у string.Format
ArgumentOutOfRangeExceptionАргумент поза допустимим діапазономВід'ємний індекс масиву
InvalidOperationExceptionОперація недійсна в поточному стані об'єктаЧитання з закритого Stream
NullReferenceExceptionСпроба доступу до члена null посиланняstring s = null; s.Length;
IndexOutOfRangeExceptionІндекс масиву за межамиarr[100] для масиву з 10 елементів
DivideByZeroExceptionДілення на нуль (тільки для цілих чисел)int x = 5 / 0;
FileNotFoundExceptionФайл не знайденоFile.ReadAllText("missing.txt")
IOExceptionПомилка введення-виведенняНемає прав доступу до файлу

Теоретичні аспекти ієрархії винятків:

Ієрархія винятків у .NET побудована за принципом поліморфізму, що дозволяє обробляти винятки різних рівнів специфічності. Кожен тип винятку має свою роль:

  • SystemException — винятки, які генеруються безпосередньо CLR при виявленні помилок під час виконання
  • ApplicationException — базовий клас для винятків, що створюються в додатках (хоча сучасна практика рекомендує успадковуватися безпосередньо від Exception)
  • Специфічні винятки (наприклад, ArgumentException) успадковуються від більш загальних, що дозволяє ловити їх на різних рівнях ієрархії

Ця ієрархія дозволяє реалізувати точну обробку помилок — від конкретних до загальних, зберігаючи при цьому можливість обробки груп подібних винятків.

Анатомія Винятку

Кожен об'єкт винятку містить критично важливу діагностичну інформацію:

ExceptionAnatomy.cs
try
{
    int[] numbers = [1, 2, 3];
    Console.WriteLine(numbers[10]); // Вихід за межі масиву
}
catch (IndexOutOfRangeException ex)
{
    Console.WriteLine($"Message: {ex.Message}");
    Console.WriteLine($"StackTrace:\n{ex.StackTrace}");
    Console.WriteLine($"Source: {ex.Source}");
    Console.WriteLine($"TargetSite: {ex.TargetSite}");
}

/* Вивід:
Message: Index was outside the bounds of the array.
StackTrace:
   at Program.<Main>$(String[] args) in Program.cs:line 4
Source: YourProgramName
TargetSite: Void <Main>$(System.String[])
*/

Ключові властивості:

  • Message: Зрозуміле людині повідомлення про помилку.
  • StackTrace: Послідовність викликів методів, що призвела до помилки (критично для налагодження).
  • InnerException: Вкладений виняток, якщо поточний виняток був викликаний іншим.
  • Source: Ім'я додатку або об'єкта, що викликав виняток.
  • TargetSite: Метод, що викинув виняток.

Try-Catch-Finally

Основна конструкція для обробки винятків складається з трьох блоків: try, catch та finally.

Базовий Синтаксис

TryCatchBasic.cs
try
{
    // Код, який може викинути виняток
    int result = 10 / 0;
}
catch (DivideByZeroException ex)
{
    // Обробка конкретного типу винятку
    Console.WriteLine($"Помилка: {ex.Message}");
}
finally
{
    // Код, який виконається завжди (навіть якщо виняток не було)
    Console.WriteLine("Очищення ресурсів...");
}

Як це працює:

  1. try блок: Код всередині try виконується як зазвичай.
  2. Якщо виняток виникає: CLR шукає відповідний catch блок.
  3. catch блок: Виконується, якщо тип винятку співпадає.
  4. finally блок: Виконується завжди — незалежно від того, виник виняток чи ні, навіть якщо в catch є return.
Loading diagram...
graph TD
    A[Початок try блоку] --> B{Виникає виняток?}
    B -->|Ні| C[Виконати весь try блок]
    B -->|Так| D[Зупинити виконання try]

    D --> E{Є відповідний catch?}
    E -->|Так| F[Виконати catch блок]
    E -->|Ні| G[Пробр осити виняток далі]

    C --> H[Виконати finally]
    F --> H
    G --> H

    H --> I{Був return?}
    I -->|Так| J[Повернути значення]
    I -->|Ні| K[Продовжити виконання]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style E fill:#f59e0b,stroke:#b45309,color:#ffffff
    style H fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style G fill:#64748b,stroke:#334155,color:#ffffff

Множинні Catch Блоки

Ви можете обробляти різні типи винятків по-різному:

MultipleCatch.cs
try
{
    string filePath = "data.json";
    string content = File.ReadAllText(filePath);
    int number = int.Parse(content);
    int result = 100 / number;
}
catch (FileNotFoundException ex)
{
    // Специфічна обробка для відсутнього файлу
    Console.WriteLine($"Файл не знайдено: {ex.FileName}");
}
catch (FormatException ex)
{
    // Обробка невалідного формату числа
    Console.WriteLine($"Невалідний формат даних: {ex.Message}");
}
catch (DivideByZeroException ex)
{
    // Обробка ділення на нуль
    Console.WriteLine("Ділення на нуль!");
}
catch (Exception ex)
{
    // Загальна обробка будь-якого іншого винятку
    Console.WriteLine($"Невідома помилка: {ex.Message}");
}
Порядок має значення! Блоки catch перевіряються зверху вниз. Розміщуйте більш конкретні винятки перед більш загальними. Якщо ви розмістите catch (Exception ex) першим, він перехопить всі винятки, і наступні блоки ніколи не виконаються.

Гарантії Finally Блоку

Теорія: Коли Finally НЕ виконується

Блок finally виконується майже завжди, але є рідкісні винятки:

  1. Environment.FailFast(): Негайне завершення процесу без очищення.
  2. Stack Overflow: Переповнення стеку (рекурсія без виходу).
  3. Критичні помилки CLR: Внутрішні збої віртуальної машини.
  4. Збій живлення: Відключення сервера.

В усіх нормальних сценаріях finally гарантовано виконається.

FinallyGuarantee.cs
static string ReadFile(string path)
{
    FileStream? stream = null;
    try
    {
        stream = File.OpenRead(path);
        using var reader = new StreamReader(stream);
        return reader.ReadToEnd(); // return не перешкоджає finally!
    }
    catch (IOException ex)
    {
        Console.WriteLine($"Помилка читання: {ex.Message}");
        return string.Empty; // return з catch теж не перешкоджає
    }
    finally
    {
        // Виконається ЗАВЖДИ перед поверненням з методу
        stream?.Dispose();
        Console.WriteLine("Файл закрито");
    }
}
Best Practice: Використовуйте finally для звільнення ресурсів (закриття файлів, з'єднань з БД, мережевих сокетів). Ще краще — використовуйте using statement, який автоматично генерує try-finally блок.

Розмотування Стеку (Stack Unwinding)

Коли виникає виняток, CLR починає розмотування стеку викликів (stack unwinding) — пошук відповідного обробника:

Loading diagram...
sequenceDiagram
    participant Main
    participant ProcessData
    participant ReadFile
    participant FileSystem

    Main->>ProcessData: Виклик ProcessData()
    ProcessData->>ReadFile: Виклик ReadFile()
    ReadFile->>FileSystem: File.OpenRead()
    FileSystem-->>ReadFile: ❌ FileNotFoundException

    Note over ReadFile: Немає catch блоку
    ReadFile-->>ProcessData: Пробросити виняток вгору

    Note over ProcessData: Є catch (IOException)
    ProcessData->>ProcessData: Обробити виняток
    ProcessData-->>Main: Повернути результат

    Note over Main: Виняток оброблено,\nпрограма продовжує роботу

Демонстрація Stack Unwinding:

StackUnwindingDemo.cs
using System;
using System.IO;

class Program
{
    static void Main()
    {
        Console.WriteLine("Main: Початок");

        try
        {
            ProcessData("data.txt");
            Console.WriteLine("Main: Операція успішна");
        }
        catch (IOException ex)
        {
            Console.WriteLine($"Main: Перехопив IOException: {ex.Message}");
            Console.WriteLine($"\nStack Trace:\n{ex.StackTrace}");
        }

        Console.WriteLine("Main: Завершення");
    }

    static void ProcessData(string filename)
    {
        Console.WriteLine("  ProcessData: Обробка даних з файлу...");

        // Тут немає try-catch для FileNotFoundException
        // Виняток пробросится до Main()
        string content = ReadFile(filename);

        Console.WriteLine($"  ProcessData: Прочитано {content.Length} символів");
    }

    static string ReadFile(string path)
    {
        Console.WriteLine("    ReadFile: Відкриття файлу...");

        // Якщо файл не існує — викинеться FileNotFoundException
        // Стек розмотується: ReadFile -> ProcessData -> Main
        string content = File.ReadAllText(path);

        Console.WriteLine("    ReadFile: Файл прочитано");
        return content;
    }
}

/* Вивід (якщо файлу немає):
Main: Початок
  ProcessData: Обробка даних з файлу...
    ReadFile: Відкриття файлу...
Main: Перехопив IOException: Could not find file '/path/to/data.txt'.

Stack Trace:
   at System.IO.FileStream.ValidateFileHandle(SafeFileHandle fileHandle)
   at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access)
   at System.IO.File.ReadAllText(String path)
   at Program.ReadFile(String path) in Program.cs:line 38
   at Program.ProcessData(String filename) in Program.cs:line 25
   at Program.Main() in Program.cs:line 12
Main: Завершення
*/

Покрокове пояснення:

  1. Main() викликає ProcessData() — додає frame у стек.
  2. ProcessData() викликає ReadFile() — ще один frame.
  3. ReadFile() викликає File.ReadAllText() — виникає FileNotFoundException.
  4. Пошук обробника:
    • CLR шукає catch у ReadFile() — немає.
    • Повертається до ProcessData() — немає catch.
    • Повертається до Main() — знайдено catch (IOException)!
  5. Виняток перехопленоFileNotFoundException є підкласом IOException, тому блок спрацьовує.
  6. Програма продовжує нормальне виконання після catch.
Важливе спостереження: У StackTrace видно весь шлях виклику — від File.ReadAllText()ReadFile()ProcessData()Main(). Це дозволяє точно відстежити, де саме виникла помилка та як ми до неї дійшли.

Що відбувається:

  1. CLR шукає catch блок у поточному методі.
  2. Якщо не знайдено — повертається до методу, що викликав (caller).
  3. Процес повторюється до тих пір, поки не буде знайдено catch або досягнуто Main().
  4. Якщо жоден обробник не знайдено — програма аварійно завершується з unhandled exception.

Exception Filters (Фільтри Винятків)

C# 6.0 ввів фільтри винятків з ключовим словом when — потужний механізм для умовної обробки винятків.

Синтаксис

ExceptionFilter.cs
try
{
    await httpClient.GetStringAsync("https://api.example.com/data");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    Console.WriteLine("Ресурс не знайдено (404)");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
    Console.WriteLine("Не авторизовано (401). Оновіть токен");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"Інша HTTP помилка: {ex.StatusCode}");
}

Теоретичні аспекти Exception Filters:

Exception filters з ключовим словом when представляють потужний механізм управляння потоком обробки винятків. На відміну від звичайної умови в catch блоці, фільтри обчислюються до того, як виняток вважається "спійманим". Це означає:

  1. Стек не розгортається до обчислення умови фільтра
  2. Якщо умова фільтра повертає false, виняток не вважається обробленим у цьому catch блоці
  3. Виняток продовжує шукати відповідний обробник далі по стеку
  4. У разі винятку в самому фільтрі (коли виконується умова), оригінальний виняток замінюється новим

Цей механізм дозволяє зберігати оригінальний StackTrace навіть при використанні фільтрів, що робить налагодження значно простішим.

Переваги над If-Else

try
{
    // ...
}
catch (HttpRequestException ex)
{
    if (ex.StatusCode == HttpStatusCode.NotFound)
    {
        Console.WriteLine("404");
    }
    else if (ex.StatusCode == HttpStatusCode.Unauthorized)
    {
        Console.WriteLine("401");
    }
    else
    {
        Console.WriteLine("Інша помилка");
    }
}

Чому фільтри кращі:

КритерійIf-Else в CatchException Filters (when)
ЧитабельністьВкладена логіка, важко читатиПлоска структура, легко сканувати
DebuggingСтек вже розмотаноСтек не розмотується до перевірки
Точки зупинкиBreak on first chance не допомагаєDebugger зупиняється на правильному catch
PerformanceВиняток завжди спійманоВиняток пробрасується, якщо умова false

Теорія: Стек не розмотується при перевірці фільтра

Це критична відмінність! Коли CLR зустрічає catch з when:

  1. Виняток НЕ перехоплюється негайно.
  2. Обчислюється умова when.
  3. Якщо false — CLR продовжує пошук далі по стеку, не очищуючи поточний frame.
  4. Якщо true — тільки тоді виняток перехоплюється.

Це означає, що стек залишається неушкодженим для налагодження, навіть якщо фільтр не спрацював.

Складні Фільтри з Логуванням

Фільтри можуть викликати методи (але будьте обережні з побічними ефектами!):

FilterWithLogging.cs
static bool LogException(Exception ex)
{
    Console.WriteLine($"[LOG] {DateTime.Now}: {ex.GetType().Name} - {ex.Message}");
    return false; // Завжди повертаємо false, щоб НЕ перехоплювати
}

try
{
    // Потенційно небезпечний код
    RiskyOperation();
}
catch (Exception ex) when (LogException(ex))
{
    // Цей блок НІКОЛИ не виконається, бо LogException повертає false
    // Але виняток буде залоговано!
}
catch (SpecificException ex)
{
    // Реальна обробка
}
Побічні ефекти у фільтрах: Умова when має бути чистою функцією (pure function) — не змінювати стан програми. Виключення: логування для діагностики. Але навіть тут будьте обережні — якщо логування викине виняток, він замінить оригінальний!

Throwing Exceptions (Викидання Винятків)

Оператор throw

Щоб викинути виняток, використовуйте ключове слово throw:

ThrowBasic.cs
public void SetAge(int age)
{
    if (age < 0 || age > 150)
    {
        throw new ArgumentOutOfRangeException(
            nameof(age),
            age,
            "Вік має бути в діапазоні 0-150"
        );
    }

    Age = age;
}
Best Practice: Використовуйте nameof() для передачі імені параметра. Це захистить від помилок при рефакторингу.

Throw Expressions (C# 7.0+)

Починаючи з C# 7.0, throw може використовуватися як вираз у таких контекстах:

ThrowExpressions.cs
// Null-coalescing з throw
public string Name { get; }

public User(string name)
{
    Name = name ?? throw new ArgumentNullException(nameof(name));
}

// Тернарний оператор
public int Divide(int a, int b)
{
    return b != 0
        ? a / b
        : throw new DivideByZeroException();
}

// Expression-bodied member
public string GetFileName() =>
    _fileName ?? throw new InvalidOperationException("Файл не встановлено");

// Lambda
Func<string, int> parser = s =>
    int.TryParse(s, out int result)
        ? result
        : throw new FormatException($"'{s}' не є числом");

Re-throwing: throw vs throw ex

Це критична різниця, яка впливає на налагодження:

try
{
    DoSomething();
}
catch (Exception ex)
{
    Console.WriteLine($"Помилка: {ex.Message}");
    throw ex; // ❌ ВТРАТА StackTrace!
}

Що відбувається:

  • throw ex: Створюється новий StackTrace з поточної точки. Ви втрачаєте інформацію про оригінальне місце помилки.
  • throw (без аргументу): Пробрасує оригінальний виняток з незмінним StackTrace.
Ніколи не використовуйте throw ex без вагомої причини. Це ускладнює налагодження і приховує справжню причину помилки.

Wrapping Exceptions (Обгортання Винятків)

Іноді потрібно "обгорнути" виняток нижчого рівня у більш зрозумілий контекстний:

ExceptionWrapping.cs
public User LoadUser(int userId)
{
    try
    {
        string json = File.ReadAllText($"users/{userId}.json");
        return JsonSerializer.Deserialize<User>(json)!;
    }
    catch (FileNotFoundException ex)
    {
        // Обгортаємо технічний виняток у доменний
        throw new UserNotFoundException(
            userId,
            $"Користувача {userId} не знайдено",
            ex // ВАЖЛИВО: передаємо як InnerException!
        );
    }
    catch (JsonException ex)
    {
        throw new DataCorruptedException(
            $"Дані користувача {userId} пошкоджені",
            ex
        );
    }
}

Переваги:

  • Приховує технічні деталі від викликаючого коду.
  • Надає доменно-специфічну інформацію.
  • Зберігає оригінальний виняток в InnerException для налагодження.

Custom Exceptions (Власні Винятки)

Коли Створювати Власний Виняток?

Створюйте власний виняток, якщо:

  1. Потрібна доменна семантика: InsufficientFundsException зрозуміліший за InvalidOperationException.
  2. Потрібні додаткові властивості: зберігання AccountId, RequestedAmount, AvailableBalance.
  3. Різна обробка: викликаючий код має обробляти це інакше, ніж інші винятки.
Не створюйте винятки без потреби! Якщо ArgumentNullException чи InvalidOperationException достатньо описують ситуацію — використовуйте їх.

Стандартний Шаблон

CustomException.cs
// 1. Успадкуйте від Exception або більш конкретної підкласи
// 2. Назва має закінчуватися на "Exception"
// 3. Реалізуйте три конструктори (стандартна практика)
public class InsufficientFundsException : Exception
{
    public string AccountId { get; }
    public decimal RequestedAmount { get; }
    public decimal AvailableBalance { get; }

    // Конструктор без параметрів
    public InsufficientFundsException()
    {
    }

    // Конструктор з повідомленням
    public InsufficientFundsException(string message)
        : base(message)
    {
    }

    // Конструктор з повідомленням та inner exception
    public InsufficientFundsException(string message, Exception innerException)
        : base(message, innerException)
    {
    }

    // Додатковий конструктор з доменними даними
    public InsufficientFundsException(
        string accountId,
        decimal requestedAmount,
        decimal availableBalance)
        : base($"Недостатньо коштів на рахунку {accountId}. " +
               $"Запитано: {requestedAmount:C}, доступно: {availableBalance:C}")
    {
        AccountId = accountId;
        RequestedAmount = requestedAmount;
        AvailableBalance = availableBalance;
    }
}

Використання

UsingCustomException.cs
public class BankAccount
{
    public string Id { get; init; }
    public decimal Balance { get; private set; }

    public void Withdraw(decimal amount)
    {
        if (amount > Balance)
        {
            throw new InsufficientFundsException(
                Id,
                amount,
                Balance
            );
        }

        Balance -= amount;
    }
}

// Обробка
try
{
    account.Withdraw(1000m);
}
catch (InsufficientFundsException ex)
{
    Console.WriteLine(ex.Message);
    Console.WriteLine($"Рахунок: {ex.AccountId}");
    Console.WriteLine($"Бракує: {ex.RequestedAmount - ex.AvailableBalance:C}");

    // Можна відобразити користувачу конкретну інформацію
    ShowInsufficientFundsDialog(ex);
}

Серіалізація (Застаріла, але корисно знати)

До .NET Core 3.0 винятки потрібно було робити серіалізованими для передачі між AppDomains:

[Serializable]
public class CustomException : Exception
{
    public string CustomProperty { get; set; }

    // Конструктор серіалізації
    protected CustomException(SerializationInfo info, StreamingContext context)
        : base(info, context)
    {
        CustomProperty = info.GetString(nameof(CustomProperty))!;
    }

    public override void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        base.GetObjectData(info, context);
        info.AddValue(nameof(CustomProperty), CustomProperty);
    }
}
У сучасному .NET (Core/5+) AppDomains практично не використовуються, тому серіалізація винятків рідко потрібна. Але якщо ви підтримуєте legacy код або працюєте з .NET Framework — майте це на увазі.

Best Practices (Кращі Практики)

1. Fail Fast (Збій Швидко)

Принцип: Якщо виявлено некоректний стан, який може призвести до пошкодження даних — негайно викиньте виняток. Не намагайтеся "якось" продовжити роботу.

public void ProcessPayment(Payment payment)
{
    if (payment == null)
    {
        // Мовчки ігноруємо — НЕ РОБІТЬ ТАК!
        return;
    }

    // Продовжуємо з невалідними даними...
}

Переваги:

  • Помилки виявляються рано — ближче до причини.
  • Легше налагоджувати — StackTrace вказує на реальну проблему.
  • Запобігає каскадним збоям через невалідні дані.
Використовуйте Guard Clauses: У C# 11+ є ArgumentNullException.ThrowIfNull(), ArgumentException.ThrowIfNullOrEmpty() та подібні методи для лаконічної валідації.

2. Don't Swallow Exceptions (Не "Ковтайте" Винятки)

Один з найгірших анти-патернів:

try
{
    RiskyOperation();
}
catch (Exception)
{
    // Порожній catch — "проковтнули" виняток
}

Чому це катастрофічно:

  • Програма продовжує працювати в некоректному стані.
  • Неможливо налагодити — немає жодної інформації.
  • Помилка проявиться пізніше в непередбачуваному місці.

Що робити замість цього:

try
{
    RiskyOperation();
}
catch (Exception ex)
{
    // ЗАВЖДИ хоч що-небудь робіть з винятком:
    _logger.LogError(ex, "Помилка при виконанні операції");

    // Можливі варіанти:
    // 1. Пробросити далі
    throw;

    // 2. Обгорнути в інший виняток
    throw new OperationFailedException("Операція не вдалася", ex);

    // 3. Повернути Result<T> з помилкою (паттерн Result)
    return Result.Failure<Data>(ex.Message);
}

3. Ловіть Конкретні Винятки

try
{
    var data = LoadFromDatabase();
    ProcessData(data);
    SaveToFile(data);
}
catch (Exception ex) // Ловимо ВСЕ — погана ідея
{
    Console.WriteLine("Щось пішло не так");
}

Чому це важливо:

  • ThreadAbortException, StackOverflowException, OutOfMemoryExceptionНЕ треба обробляти. Це критичні збої CLR.
  • Ловлячи Exception, ви можете приховати помилки в бібліотеках, яких не очікували.

4. Використовуйте using або try-finally для Ресурсів

Ресурси (файли, з'єднання з БД, сокети) завжди треба звільняти:

FileStream stream = File.OpenRead("data.txt");
ProcessFile(stream);
stream.Dispose(); // НЕ виконається, якщо ProcessFile викине виняток!

5. Винятки — Не для Контролю Потоку

Анти-патерн: Exception-Driven Development

// НІКОЛИ не робіть так!
try
{
    int index = list.IndexOf(item);
    return list[index];
}
catch (ArgumentOutOfRangeException)
{
    return null; // Використовуємо виняток як if
}

Чому це погано:

  • Продуктивність: Створення винятку — дорога операція (захоплення StackTrace).
  • Читабельність: Код важко зрозуміти.
  • Семантика: Винятки для виняткових ситуацій, не для звичайної логіки.

Правильно:

int index = list.IndexOf(item);
if (index >= 0)
{
    return list[index];
}
return null;

6. Розуміння Вартості Винятків

Теорія: Performance Impact

Викидання та обробка винятку включає:

  1. Створення об'єкта в купі (heap allocation).
  2. Захоплення StackTrace — CLR проходить весь стек викликів і зберігає інформацію про кожен frame (дорого!).
  3. Розмотування стеку — пошук обробника та виклик finally блоків.

Приблизна вартість: ~100-1000x повільніше за звичайний if-else.

Коли це прийнятно:

  • Справді виняткові ситуації (відсутній файл, проблеми мережі).
  • Рідко виникають (< 1% випадків).

Коли НЕ використовувати:

  • Валідація введення користувача → TryParse, TryGetValue.
  • Бізнес-логіка (недостатньо коштів) → Result Pattern або повернення bool.

Порівняльна Таблиця: Best Practices vs Anti-Patterns

Best PracticeAnti-PatternНаслідки Anti-Pattern
Викидайте конкретні виняткиthrow new Exception("error")Важко обробляти, плутає викликаючий код
Ловіть конкретні виняткиcatch (Exception)Ховає критичні помилки, ускладнює налагодження
throw; для re-throwthrow ex;Втрата оригінального StackTrace
using / try-finally для ресурсівВиклик Dispose() без захистуВитік ресурсів при винятку
Fail Fast при невалідних данихМовчазне ігнорування або default значенняКаскадні збої, важко налагоджувати
Логування ВСіХ винятківПорожній catch { }"Чорна діра" — помилка зникає безслідно
Винятки для виняткових ситуаційВинятки для звичайного потокуПогана продуктивність, плутана логіка
ArgumentNullException.ThrowIfNull()Перевірка вручну + throwБільше коду, можливі помилки
Обгортання у доменні винятки з InnerExceptionВтрата оригінального виняткуНеможливо дізнатися справжню причину
Альтернатива Винятками: Result PatternДля бізнес-логіки розгляньте Result Pattern — повернення об'єкта Result<T>, який містить або успішне значення, або інформацію про помилку. Це:
  • Швидше (немає overhead винятків)
  • Явніше (сигнатура методу показує, що може бути помилка)
  • Функціональний підхід (Railway Oriented Programming)
Бібліотеки: FluentResults, ErrorOr, LanguageExt.

Troubleshooting (Усунення Проблем)

Типові Помилки та Рішення

1. NullReferenceException — Найчастіша Помилка

string name = user.Profile.Name; // user або Profile може бути null

Рішення:

// Null-conditional operator
string? name = user?.Profile?.Name;

// Pattern matching (C# 9+)
if (user is { Profile.Name: var name })
{
    Console.WriteLine(name);
}

// Nullable Reference Types (включити в .csproj)
<Nullable>enable</Nullable>

2. Забули await — Винятки "Зникають"

async Task ProcessAsync()
{
    DoWorkAsync(); // Забули await — виняток НЕ буде оброблено!
}

Рішення:

async Task ProcessAsync()
{
    await DoWorkAsync(); // Тепер виняток пробросится
}

// Або якщо не потрібен результат, але треба обробити помилку
_ = DoWorkAsync().ContinueWith(task =>
{
    if (task.IsFaulted)
    {
        _logger.LogError(task.Exception, "Помилка фонової задачі");
    }
}, TaskScheduler.Default);

3. Deadlock у async void

async void Button_Click(object sender, EventArgs e)
{
    await LoadDataAsync();
    // Якщо тут виняток — програма впаде!
}

Рішення:

async void Button_Click(object sender, EventArgs e)
{
    try
    {
        await LoadDataAsync();
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Помилка завантаження");
        MessageBox.Show($"Помилка: {ex.Message}");
    }
}
async void — небезпечно! Використовуйте тільки для event handlers. В усіх інших випадках повертайте Task або Task<T>.

Налагодження з Debugger

Налаштування Exception Settings у Visual Studio / Rider

  1. Break on Thrown Exceptions: Debug → Windows → Exception Settings
  2. Увімкніть для типів, які вас цікавлять (наприклад, System.NullReferenceException)
  3. Debugger зупиниться в момент викидання, а не в catch

Conditional Breakpoints на Винятках

try
{
    ProcessItems(items);
}
catch (Exception ex) when (ex.Message.Contains("critical"))
{
    // Поставте breakpoint тут з умовою:
    // ex.Message.Contains("specific error")
    throw;
}

Практика та Резюме

Завдання для Закріплення

🟢 Легкий Рівень: Базова Обробка Винятків

Напишіть метод SafeDivide(int a, int b), який:

  • Виконує ділення a / b
  • Обробляє DivideByZeroException
  • Повертає 0 у випадку помилки
  • Логує помилку в консоль

🟡 Середній Рівень: Власний Виняток з Валідацією

Створіть клас EmailValidator з методом Validate(string email), який:

  • Перевіряє, чи містить email символ @
  • Викидає власний виняток InvalidEmailException з інформацією про email
  • InvalidEmailException має властивість InvalidEmail та відповідне повідомлення

Напишіть тестовий код, який обробляє цей виняток.


🔴 Складний Рівень: Retry Logic з Exception Filters

Реалізуйте метод ExecuteWithRetry<T>(Func<T> operation, int maxAttempts), який:

  • Виконує operation до maxAttempts разів
  • Повторює лише при HttpRequestException з кодом 429 (Too Many Requests) або 503 (Service Unavailable)
  • Використовує exception filters для перевірки статус-кодів
  • Чекає експоненційно зростаючий час між спробами (100ms, 200ms, 400ms...)
  • Логує кожну спробу
  • Пробрасує виняток після вичерпання спроб

Резюме

У цьому розділі ви вивчили:

  1. Фундаментальні концепції:
- Винятки як механізм обробки помилок
- Ієрархія винятків та клас `System.Exception`
- Розмотування стеку (stack unwinding)

2. Try-Catch-Finally:

- Структура блоків обробки винятків
- Гарантії виконання `finally`
- Порядок множинних `catch` блоків

3. Exception Filters:

- Ключове слово `when` для умовної обробки
- Переваги над `if-else` у налагодженні
- Збереження стеку при перевірці фільтра

4. Throwing Exceptions:

- Оператор `throw` та throw expressions
- Критична різниця між `throw` та `throw ex`
- Обгортання винятків (exception wrapping)

5. Custom Exceptions:

- Коли створювати власні винятки
- Стандартний шаблон з трьома конструкторами
- Додавання доменних властивостей

6. Best Practices: - ✅ Fail Fast — збій при першій помилці - ✅ Ловіть конкретні винятки, не Exception - ✅ Використовуйте using для ресурсів - ❌ Не "ковтайте" винятки - ❌ Не використовуйте винятки для контролю потоку - ❌ Ніколи не пишіть throw ex

Наступні Кроки:

Корисні Посилання