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 не всегда хорошая идея (можете "проглотить" ошибку, которую не должны были).
- Всегда логируйте стек вызовов при сложных ошибках. Это спасёт кучу нервов во время поиска багов.
- Используйте вложенные исключения — это помогает не терять информацию о первопричине сбоя.
- Описывайте свои исключения понятно — чтобы читающий код спустя год понял, почему возникла эта ошибка.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ