1. Знайомство зі стеком викликів (Call Stack)
На попередній лекції ми коротко згадали стек викликів. Тепер розберемося з ним докладніше.
Уявіть: користувач натискає кнопку в застосунку. Кнопка викликає метод OnClick() («Кнопка натиснута»). Той, своєю чергою, викликає LoadData() (завантаження даних), а той — ReadFromFile() (читання з файла).
Раптом у ReadFromFile() стається помилка — файл не знайдено. Хто винен?
Щоб розібратися, програма йде назад слідами: від ReadFromFile() → до LoadData() → до OnClick(). Цей шлях і називають стеком викликів — як стос тарілок: першою падає та, що зверху.
Програма рухається цим стосом униз, доки не знайде відповідний catch, водночас виконує всі finally, що трапляються на шляху, — щоб усе було закрито, вивільнено й прибрано.
У програмуванні стек викликів — це список, що «памʼятає», хто кого викликав, аби за потреби пройти цей «ланцюжок викликів» у зворотному напрямку й знайти причину збою.
Як це влаштовано
Коли виконується програма, кожен виклик методу (або функції) додає новий «рядок» у стек (список) викликів. Якщо в процесі роботи виникає виняток, клас .NET Exception зберігає інформацію про цей стек: де і в якій послідовності викликалися методи, які призвели до помилки.
2. Навіщо взагалі потрібен стек викликів
Стек викликів — ваш найкращий помічник під час налагодження складних помилок.
- Він показує не лише, що сталося, а й де саме та хто «винен».
- Іноді, дивлячись на стек, ви дивуєтеся, як узагалі програма дійшла до такого стану (особливо якщо хтось випадково передав не те значення аргументу).
Типова історія: припустімо, у вас величезний проєкт, де методи викликають один одного на десяток рівнів углиб. У якийсь момент зʼявляється NullReferenceException, а ви не розумієте, як програма туди потрапила. Відкриваєте стек — бачите весь ланцюжок викликів, і вже набагато простіше зрозуміти, з чого почати розбиратися.
Приклад:
class MyClass
{
public void MethodA() { MethodB(); }
public void MethodB() { MethodC(); }
public void MethodC() {
throw new Exception("Помилка!");
}
public void Main()
{
try
{
MethodA();
}
catch (Exception ex)
{
Console.WriteLine(ex.StackTrace);
}
}
}
3. Як створювати власні винятки
Іноді стандартних винятків не вистачає
У .NET є чимало стандартних винятків (ArgumentNullException, InvalidOperationException тощо), але інколи цього недостатньо:
- У вашому застосунку свої «правила гри»: наприклад, користувач не може купити більше 10 товарів за раз, або у вашій бізнес-логіці не має бути відʼємних сум.
- Ви хочете відокремлювати помилки логіки застосунку від «системних» помилок.
Тут і виникає бажання створити власний тип — адже це можливо.
using System;
// Власний виняток: користувача не знайдено
public class UserNotFoundException : Exception
{
// Базовий конструктор
public UserNotFoundException() : base("Користувача не знайдено.") { }
// Конструктор з повідомленням
public UserNotFoundException(string message) : base(message) { }
// Конструктор з повідомленням і вкладеним винятком
public UserNotFoundException(string message, Exception inner) : base(message, inner) { }
}
Коротко про конструктори
- Без параметрів — встановлює стандартне повідомлення
- З власним повідомленням — інколи потрібно додати деталі
- З вкладеним винятком (inner) — щоб, якщо помилка сталася «всередині іншої помилки», не втратити важливу інформацію
Як використовувати свій виняток
Припустімо, моделюємо метод пошуку користувача в нашому застосунку для керування задачами:
using System;
public class UserService
{
public string FindUserNameById(int userId)
{
// «Шукаємо» користувача, якщо не знайдено — викидаємо виняток
if (userId != 42)
throw new UserNotFoundException($"Користувача з ID {userId} не знайдено.");
return "Максим";
}
}
В основній програмі:
// В Main
var service = new UserService();
try
{
string name = service.FindUserNameById(17);
Console.WriteLine("Імʼя користувача: " + name);
}
catch (UserNotFoundException ex)
{
Console.WriteLine("Проблема пошуку користувача: " + ex.Message);
// Стек викликів тут теж доступний через ex.StackTrace
}
Якщо передаємо ID, відмінний від 42, отримаємо:
Проблема пошуку користувача: Користувача з ID 17 не знайдено.
4. Причини створювати свої винятки
Журналювання та розмежування помилок
Припустімо, у вашому застосунку багато різних помилок, і їх треба обробляти по-різному. Наприклад, помилки бази даних журналювати як «фатальні», користувацькі — показувати користувачеві червоним повідомленням, а помилки мережі — намагатися повторити операцію. Групування за типом винятку — дієвий спосіб це реалізувати.
ООП і наслідування
Можна побудувати ієрархію помилок для своєї предметної області:
public class MyAppException : Exception { ... }
public class OrderException : MyAppException { ... }
public class ProductException : MyAppException { ... }
public class TooManyItemsInOrderException : OrderException { ... }
Тепер, перехопивши MyAppException, ви обробите усі помилки своєї предметної області, а якщо потрібна особлива реакція на «занадто велике замовлення» — перехоплюйте вже найконкретніший випадок.
5. Що варто памʼятати під час створення власних винятків
- Не викидати винятки заради самих винятків
Створюйте власні винятки лише тоді, коли:- Це справді робить код зрозумілішим
- Є шанс, що їх перехоплюватимуть у викличному коді
- Потрібно передати більше інформації викличному коду (через поля або властивості)
- Добра практика: серіалізація
У .NET стандартні винятки підтримують серіалізацію (наприклад, для передавання мережею). У простих застосунках серіалізація рідко потрібна, але для складніших випадків — додайте атрибут [Serializable] і реалізуйте конструктор для серіалізації (див. офіційну документацію). Для навчальних прикладів це не обовʼязково, а на роботі — уточніть у лідера команди.
Серіалізація — це процес перетворення обʼєкта у формат, зручний для зберігання або передавання (наприклад, у файл чи мережею). Ми детальніше розглянемо серіалізацію пізніше.
6. Особливості стеку викликів: де може виникнути плутанина
Стек викликів показує лише шлях до тієї точки, де виник виняток. Якщо ви перехоплюєте виняток і викидаєте новий без зазначення «вкладеного» винятку (про конструктор Exception(string, Exception inner)), можете втратити першоджерело помилки. Це називають «приховуванням стеку».
Погано:
try
{
// Тут якась помилка
}
catch (Exception ex)
{
throw new Exception("Сталася невідома помилка."); // Стек минулої помилки втрачається!
}
Добре:
try
{
// Тут якась помилка
}
catch (Exception ex)
{
throw new Exception("Сталася невідома помилка.", ex); // Зберігаємо оригінальний стек!
}
Тоді в StackTrace залишиться і шлях до першої помилки, і ваше нове повідомлення.
7. Практичні поради та часті помилки
- Не варто викидати винятки для керування звичайною логікою (наприклад, для «виходу з циклу» — є елегантніші способи).
- Перехоплюйте потрібний тип винятку — завжди перехоплювати Exception — не найкраща ідея (можете «проковтнути» помилку, яку не слід було б ігнорувати).
- Завжди журналюйте стек викликів у разі складних помилок. Це збереже купу нервів під час пошуку багів.
- Використовуйте вкладені винятки — це допомагає не втрачати інформацію про першопричину збою.
- Описуйте свої винятки зрозуміло — щоб той, хто читатиме код за рік, одразу зрозумів, чому сталася ця помилка.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ