Стек вызовов и исключения
В этой статье продолжим разбор внутреннего устройства программ на .NET, поговорим о программном стеке вызовов и работе с ним, а также о возникновении исключений и их обработке.
Стек
Стек — структура данных, работающая по принципу LIFO(last in first out - последний вошел - первым вышел). Добавление элементов в стек происходит с одного конца, вершины стека. Удаление из стека происходит также, начиная с вершины стека, то есть с последнего добавленного элемента по порядку.
Принцип работы стека можно сравнить с обоймой - доступ мы можем получить только к верхнему элементу в стеке, для доступа к добавленному перед ним, необходимо удалить данный верхний элемент.
В C# стек представлен в виде коллекции Stack
из пространства имен System.Collections
и универсальной версией Stack<T>
из System.Collections.Generic
.
Таким образом, для стека обязательно должны реализовываться три основных метода:
Push
- добавление нового элемента в вершину;Pop
- получение верхнего элемента при его удалении;Peek
- чтение верхнего элемента без удаления.
var stack = new Stack<int>(); stack.Push(1); stack.Push(2); stack.Push(3); Console.WriteLine(stack.Peek()); //3 Console.WriteLine(stack.Pop()); //3 Console.WriteLine(stack.Peek()); //2 stack.Pop(); Console.WriteLine(stack.Peek()); //1
Программный стек
У нас уже заходила речь в одной из предыдущих статей о том, что на стеке размещаются значение переменной типа значения, если оно является локальной переменной/аргументом/возвращаемым значением метода, а также ссылки на объекты в куче (если объект не является частью ссылочного типа).
Так вот для этих целей используется программный стек выполнения - заранее выделяемый под каждую программу участок памяти размером обычно 1 мб, который организован по принципу структуры данных стек.
Таким образом, при каждом вызове метода .NET инициализирует стек-фрейм (кадр стека, можно условно ассоциировать с контейнером), где и хранится вся необходимая информация для выполнения метода: параметры, локальные переменные, служебная информация, которая используется вызванной функцией, чтобы в нужный момент возвратить управление вызвавшей функции. Каждый такой новый фрейм (контейнер вызваемого метода) помещается на вершину стека и удаляется после завершения выполнения метода. У программного стека также есть особенность, что он растет в адресном пространстве памяти не снизу вверх, а сверху вниз.
Мы не будем сейчас вдаваться в подробности устройства стека вызовов. На текущем этапе лишь важно понимать, что стек вызовов служит для хранения порядка выполнения кода, то есть для хранения адресов возврата в порядке вызова методов, которые впоследствии используются для возврата из метода, а также локальные переменные. При вызове метода фрейм метода добавляется на вершину стека, а при его завершении удаляется. Таким образом, программа завершается после после того, как цепочка вызова всех методов становится пустой, то есть программа будет работать до тех пор, пока стек вызовов не станет пустым.
StackTrace и StackFrame
При работе программы могут возникать непредвиденные ошибки, исключения (о них мы поговорим чуть дальше), например деление на ноль. И для того, чтобы программист исправил возможную свою ошибку в коде или обработал исключение, ему неплохо было бы вообще понять место возникновения данной "ошибки". В этих целях и полезен стек вызовов, откуда можно достать всю необходимую служебную информацию.
В C# для получения информации о стеке существует класс StackTrace
из пространства имен System.Diagnostics
, а за кадр (фрейм) стека отвечает класс StackFrame
.
Трассировка стека - цепочка вызова методов, которые находятся в стеке на данный момент. То есть в любой момент выполнения кода мы можем рассмотреть состояние стека вызовов, что и будет являться трассировкой. Каждый же кадр стека знает кто его вызывает, но не знает о существовании кадров, которые будет порождать он, так как на момент обращения к текущему кадру, кадров выше не существует (они либо уже выполнены, либо еще не созданы).
using System.Diagnostics; class Program { public static void Method1() { Method2(); } public static void Method2() { Method3(); } public static void Method3() { StackTrace currentTrace = new StackTrace(true); Console.WriteLine(quot;Количество фреймов: {currentTrace.FrameCount}"); for (int i = 0; i < currentTrace.FrameCount; i++) { StackFrame frame = currentTrace.GetFrame(i); Console.WriteLine(); Console.WriteLine(quot;Метод {frame.GetMethod().Name} из файла {frame.GetFileName()} " + quot;расположен на строчке {frame.GetFileLineNumber()}"); } } public static void Main(string[] args) { Method1(); } }
В данном примере в методе Method3()
мы создаем объект трассировки StackTrace
, а затем получаем поочередно все фреймы стека, начиная с верхнего, нулевого, и заканчивая нижним фреймом метода Main
. Таким нехитрым образом получается достать много полезной информации о нашем коде, в данном примере мы выводим имена цепочки вызывающих методов и их расположение, с помощью методов объекта фрейма GetFileName()
и GetFileLineNumber()
.
Нужно отметить, что для того, чтобы объект класса StackTrace
собрал дополнительную инфу о расположении методов в коде необходимо использовать конструктор с булевым параметром fNeedFileInfo
и передать в него true
, иначе он не соберет данную информацию и мы получим на выходе методов GetFileName()
и GetFileLineNumber()
значения по умолчанию (null
и 0
соответственно).
Но не будем сейчас подробно останавливаться на стек трейсе об этом мы еще успеем поговорить в теме рефлексии. Это все был важный материал для понимания работы исключений, о которых далее и пойдет речь)
Ошибки и исключения
При разработке программ и их поддержке разработчики так или иначе сталкиваются с различными проблемами и ошибками кода. В одних случаях проблемы возникают из-за "плохого" кода, из-за чего работа приложения будет некорректной, а в других — из-за некорректного пользовательского ввода данных, которые не были учтены в коде приложения. Так или иначе приложение начинает приложение начинает при возникновении данных проблем начинает работать не так, как задумано, и программисту необходимо данные ошибки находить и исправлять/обрабатывать.
При разработке программного обеспечения мы сталкиваемся с тремя основными типами ошибок:
- Программные ошибки, баги (bug - жук:) — ошибки в коде, допущенные программистом, приводящие к некорректной работе программы. Например, при обращении к элементам массива в цикле происходит выход за его пределы. Баги могут быть как простые вроде приведенного примера, так и с нарушением более глобальной сложной логики программы, и должны исправляться в процессе отладки кода программы.
- Пользовательские ошибки — ошибки, которые допускает конечный пользователь во время работы с программой. Например, некорректный пользовательский ввод в текстовое поле, в котором ожидался ввод почты пользователя, а получили набор цифр, что может привести к возможным ошибкам в дальнейшей работе с программой. Такие проблемы обычно решаются путем добавления валидации входных данных.
- Исключения — ситуации, которые не предусмотрены стандартным поведением программы. Например, когда коде программы мы открываем несуществующий файл для чтения из него или пытаемся подключиться к web-ресурсу, который по какой-то причине недоступен (его забанило правительственное ведомство нашего государства, а у нас не включен vpn:). Такие случаи обычно не зависят от программиста, и при их возникновении, чтобы программа не "падала" от непонимания, что делать дальше, эти исключительные ситуации необходимо обрабатывать.
В рамках .NET CLR будет генерировать соответствующие исключения даже для программных и пользовательских ошибок, которые не учел программист.
В языках, где механизмы для работы с исключениями не стандартизированы, программистам приходится придумывать свою логику для обработки ошибок (например, тот же C). Причем данная логика у всех команд разработчиков будет так или иначе своей, что несет за собой дополнительные сложности при понимании кода и вкатывании в проект. Одним из таких подходов обработки ошибок на языке C, где нет для этого стандартных средств, являлось определение числовых констант кодов ошибок, с помощью макросов (через директиву #define), и булевых флагов. В случае возникновения ошибки - функция возвращала определенный числовой код, и по полученному коду программистом выстраивалась логика действий по обработке. В случае с булевым флагом - флагу в глобальной области видимости присваивается соответствующие значение, если произошла ошибка, и на основании этого, после завершения функции, выстраивается логика по обработке.
/* Пример обработки ошибок в С, с помощью кодов */ #define CONN_FAIL 400 //объявление кода ошибки int ConnDatabase() { //Здесь происходит какая-то логика по подключению к базе, //но по какой-то причине могла возникнуть ошибка подключения, //в результате чего возвращается следующее значение return CONN_FAIL; } int main() { int res = ConnDatabase(); if (res == CONN_FAIL) { printf("Fail connection to db"); } return 0; }
/* Пример обработки ошибок в С, с помощью булевого флага */ bool error; double Div(double a, double b) { if (b == 0) { error = true; return NULL; } return a / b; } int main() { error = false; double res = Div(5, 0); if (error == true) { printf("Zero division error"); } else { printf("Result: %f", res); } return 0; }
При таком подходе в рамках одного проекта может быть определена куча таких кодов, с которыми придется работать присоединяющимся к проекту новым программистам. Но проблема кроется не только в том, что кодов может быть много, но и в том, что нет никакой стандартизации их объявления, то есть числовые коды одного проекта могут обозначать совершенно другие ситуации в другом, а при подключении какой-то библиотеки к проекту они и вовсе могут совпасть, но не по смыслу:)
Для недопущения этого безобразия, в C#, как в любом нормальном современном япе, есть стандартные средства для генерации и обработки исключений, что не может не радовать, а генерируемые исключения являются объектами, которые содержат в себе различную полезную информацию о том, что произошло и где, в отличие от непонятных числовых кодов (документация по которым в сделку проекта естественно не входила:)
Исключения в C#
Как уже было сказано ранее, исключение - ситуация, которая не предусмотрена стандартным поведением программы. В C# все исключения происходят от базового класса System.Exception
, а все остальные классы исключений – это производные классы, которые позволяют конкретизировать тип возникшего исключения.
Независимо от того, какое исключение было сгенерировано, данное исключение будет иметь функциональность, которая наследуется от базового класса Exception
. Таким образом все исключения имеют ряд полезных свойств, в которых отражается информация о причинах возникновения исключения и места:
Message
— свойство с текстовым описанием ошибки, использующееся только для чтения и устанавливающееся в конструкторе;HelpLink
— свойство для указания или получения URL-ссылки для доступа к веб-странице с подробным описанием ошибки;Source
— свойства служащее для указания названия сборки или объекта, в котором возникло исключение;TasrgetSite
— свойство, доступное только для чтения, в котором содержится различная информация о методе, в котором возникло исключение;StackTrace
— свойство, доступное только для чтения, которое содержит цепочку вызовов методов до возникновения исключения;InnerException
— свойство, доступное только для чтения, которое может использоваться для получения информации о предыдущих исключениях, которые послужили причиной возникновения текущего исключения. Запись предыдущих исключений осуществляется путем их передачи конструктору самого последнего сгенерированного исключения.
Генерация исключений
При возникновении определенных системных ошибок при работе программы CLR самостоятельно генерирует соответствующие исключения. А все неперехваченные исключения приводят к аварийному завершению программы, и скорее всего вы уже сталкивались с данными ситуациями.
Вот простейший пример деления на 0, в результате чего CLR выбросит исключение:
class Program { public static int Div(int a, int b) { return a / b; } public static void Main() { Console.WriteLine(Div(5, 0)); Console.WriteLine(Div(5, 1)); } }
Код выше будет падать из-за вызванного неперехваченного исключения в первом вызове метода Div()
при делении в нем на 0. В результате в консоли мы получим сообщение с информацией об исключении и стектрейсе, из которого мы можем понять, что было вызвано исключение DevideByZeroException
в строке 5
метода Div()
, при его вызове из строки 10
в файле Program.cs
. Умение читать такие сообщения со стектрейсом является важным навыком для поиска ошибок в коде.
Для того, чтобы сгенерировать исключение вручную используется ключевое слово throw
с объектом исключения.
//создаем объект исключения var ex = new Exception("Wow, its exception message"); //генерируем(выбрасываем) исключение throw ex;
Но нам не обязательно нужно сначала создавать объект исключения, а лишь потом его выбрасывать, обычно это делается в то же время:
throw new Exception("Wow, its exception message");
Мы можем в своих методах генерировать любые исключения классов, унаследованных от Exception
, это при дальнейшем анализе дает больше конкретики о том, что произошло. В предыдущем примере с делением CLR сгенерировал исключение DivideByZeroException
, что сразу говорит о произошедшим, в то время как я показал генерацию базового Exception
с непонятным сообщением (не надо так делать:).
Давайте возьмем пример с делением и бросим подходящее исключение самостоятельно с каким-то логичным сообщением о произошедшем, опередив CLR:
class Program { public static int Div(int a, int b) { if (b == 0) throw new DivideByZeroException("Division by 0 is not allowed!!!!"); return a / b; } public static void Main() { Console.WriteLine(Div(5, 0)); } }
Предлагаю еще рассмотреть еще одну ситуация с генерацией и переходим к обработки исключений.
Допустим, мы в методе используем в качестве параметра ссылочный тип и передаем при вызове в качестве аргумента ссылку на null
. В таком случае подходящим типом для исключения будет ArgumentNullException
, который в конструкторе принимает в качестве первого параметра имя параметра, с которым произошла проблема:
class Program { public static void Hello(string name) { if (name == null) throw new ArgumentNullException(nameof(name), "Name must not be null or empty"); Console.WriteLine(quot;Hello {name}"); } public static void Main() { Hello(null); } }
Ситуация с null
действительно типовая и часто встречается в начале общедоступных методов для проверки на возможное нежелательное значение параметров. Из-за того, что таких параметров могло быть много и производилась куча проверок, загрязняя код, в .NET 6 классу ArgumentNullException
был добавлен статический метод ThrowIfNull
, который генерирует соответствующее исключение, если параметр равен null
.
class Program { public static void Hello(string name, string id) { ArgumentNullException.ThrowIfNull(name, nameof(name)); ArgumentNullException.ThrowIfNull(id, nameof(id)); Console.WriteLine(quot;Hello {name} with {id}"); } public static void Main() { Hello("Artem", null); } }
Обработка исключений
Как уже было сказано, неперехваченные исключения приводят к аварийному завершению программы, так что вызывать код генерирующий исключение и никак его не обрабатывать - не круто. Для перехвата и обработки исключений используется конструкция try catch finally
.
try { // Блок с потенциально проблемным кодом, // который может сгенерировать исключение } catch(Exception ex) { // Блок перехвата исключения, // если оно было сгенерировано } finally { // Финальный блок для закрытия каких-либо ресурсов, // который выполняется всегда в независимости, // было вызвано исключение или нет }
Алгоритм работы данной конструкции сводится к следующему:
- В блок
try
помещается код, который в ходе выполнения может привести к возникновению исключений. - Если в блоке
try
возникает исключение, то CLR останавливает выполнение инструкций вtry
и начинает искать подходящий блокcatch
, который предназначен для обработки данного исключения. Для блока catch мы указываем тип перехватываемого исключения, а внутри блока уже прописываем необходимые инструкции, которые необходимо выполнить для обработки исключения, например, зафиксировать каким-либо образом сообщение об ошибке: вывести в лог, отправить сообщение о проблеме на почту или в телегу. - Блок
finally
выполняется в конце всегда независимо, было вызвано исключение или нет, за исключением пары случаев, но о них позже. - Если при вызванном исключении оно было успешно "отловлено" с помощью
catch
, после блокаfinally
выполнение переходит на следующую за данным блоком инструкцию.
Например, вот так мы можем отловить исключение при нашем делении на 0, и вывести сообщение об ошибке.
class Program { public static int Div(int a, int b) { if (b == 0) throw new DivideByZeroException("Division by 0 is not allowed!!!!"); return a / b; } public static void Main() { try { int res = Div(5, 0); Console.WriteLine(quot;Result: {res}"); } catch (DivideByZeroException ex) { Console.WriteLine(quot;Error: {ex.Message}"); } finally { Console.WriteLine("End divide"); } Console.WriteLine("End program"); } }
Следует выделить несколько правил конструкции try catch finally
:
- При использовании
try
всегда должен использоваться как минимум одинcatch
либоfinally
; - Блоков
catch
может быть много, либо ни одного, но они не могут существовать без блокаtry
. - Блок
finally
не является обязательным при использованииtry catch
, но, если он используется, то он может быть только один.
То есть при наличии блока catch
мы можем опустить блок finally
, и, наоборот, при наличии блока finally
мы можем опустить блок catch
и не обрабатывать исключение. Но если мы не обработаем исключение, то блок finally
отработает, а дальше приложение аварийно завершит работу, в случае возникновения исключения, как уже говорилось ранее.
Так как в предыдущем примере нам не нужна какая-то дополнительная логика для закрытия ресурсов, то и блок finally
нет никакого смысла использовать в данном случае и его можно опустить:
try { int res = Div(5, 0); Console.WriteLine(quot;Result: {res}"); } catch (DivideByZeroException ex) { Console.WriteLine(quot;Error: {ex.Message}"); } Console.WriteLine("End program");
В реальном коде код внутри блока try
может быть гораздо сложнее и генерировать более одного исключения. Для обработки каждого случая можно использовать множество блоков catch
для каждого конкретного типа исключения.
Давайте добавим в наш пример возможность считывания чисел для деления с клавиатуры:
try { Console.Write("Enter numerator: "); int a = int.Parse(Console.ReadLine()); Console.Write("Enter denumerator: "); int b = int.Parse(Console.ReadLine()); int res = Div(a, b); Console.WriteLine(quot;Result: {res}"); } catch (DivideByZeroException ex) { Console.WriteLine(quot;Error: {ex.Message}"); }
Теперь при вводе в консоле не чисел для числителя и знаменателя метод Parse()
будет генерировать исключение FormatException
, которое не будет перехвачено с помощью блока catch
, т.к. он перехватывает в данном случае только DivideByZeroException
, и программа аварийно завершится в таком случае. Чтобы этого избежать, необходимо добавить блок catch
для обработки данных исключений:
try { Console.Write("Enter numerator: "); int a = int.Parse(Console.ReadLine()); Console.Write("Enter denumerator: "); int b = int.Parse(Console.ReadLine()); int res = Div(a, b); Console.WriteLine(quot;Result: {res}"); } catch (DivideByZeroException ex) { Console.WriteLine(quot;Error: {ex.Message}"); } catch (FormatException ex) { Console.WriteLine(quot;Error: {ex.Message}"); Console.WriteLine("You entered not a number!!!"); } catch (Exception ex) { Console.WriteLine(quot;Unreported error: {ex.Message}"); }
Но при написании нескольких блоков catch
нужно учитывать, что сгенерированное исключение будет обрабатываться первым подходящим боком catch
, то есть блоки catch
должны быть расположены в своей последовательности так, чтобы первый catch
перехватывал наиболее специфическое исключение (то есть производный тип, расположенный ниже всех в цепочке наследования типов исключений), а последний catch
— самое общее исключение (то есть базовый класс Exception
). Если же расположить catch
с более базовым классом перед остальными, например Exception
, то это будет ошибка компиляции и у нас ничего не получится, так как блоки catch
за ним будут являться недостижимыми.
Исключения в стеке вызовов
При возникновении исключения в ходе выполнения программы CLR отвечает за поиск соответствующего обработчика catch
. Для этого среда CLR в обратном порядке начинает проходить через стек вызовов, начиная с фрейма текущего метода, в котором возникло исключение, т.к. он находится на вершине стека. Если CLR находит подходящий обработчик, то передает ему объект исключения, а если нет, то переходит к предыдущему фрейму вверх по стеку. Если же, пройдя через весь стек вызовов, CLR не найдет подходящий обработчик, она выполнит обработку исключения по умолчанию и аварийно завершит работу программы.
Разберем на примере из 4-х методов, которые вызываются по цепочке, и в последнем возникает исключение:
class Program { public static void Method1() { try { Method2(); } catch(NotImplementedException ex) { Console.WriteLine(ex.Message); Console.WriteLine(ex.StackTrace); Console.WriteLine(); } Console.WriteLine("Продолжаем выполнение"); } public static void Method2() { Method3(); Console.WriteLine("Это сообщение не напечатается"); } public static void Method3() { try { throw new NotImplementedException("Исключение из метода 3"); } catch(IOException ex) { Console.WriteLine("Тут не поймайется исключение"); } } public static void Main() { Method1(); } }
Method3() находится на вершине стека вызовов, когда в нем возникает исключение NotImplementedException
. CLR не найдет подходящего catch
в Method3()
и перейдет к следующему блоку try
в стеке вызовов, предварительно удалив поочередно с вершины стека фреймы Method3
и Method2
, добавляя информацию о них в стек трейс исключения. В Method1() найдется подходящий обработчик, поэтому выполнение продолжится с него.
Таким образом, исключения являются довольно затратным механизмом и к ним лучше прибегать в крайнем случае, а если все же применяем, то не нужно тянуть исключение через весь стек, лучше его обрабатывать в том же методе, который и вызывает проблемный код.
Повторная генерация исключений
Иногда все же бывает необходимо обработать исключение, а затем, повторно сгенерировав, "отправить" вверх по стеку. Но способов повторной генерации существует несколько, и выбор "неправильного" приводит к изменению информации об исключении в StackTrace
, что будет весьма затруднять поиск проблемы и путать.
Один из вариантов, использовать обычную генерацию исключения с помощью throw ex
, с перехваченным объектом исключения ex
.
class Program { public static void Method1() { try { Method2(); } catch(NotImplementedException ex) { throw ex; } } public static void Method2() { Method3(); } public static void Method3() { throw new NotImplementedException("Исключение из метода 3"); } public static void Main() { try { Method1(); } catch(Exception ex) { Console.WriteLine(ex.TargetSite); Console.WriteLine(ex.StackTrace); } } }
Такой подход будет заново генерировать исключение, то есть для него вся служебная информация о стек трейсе исключения будет новой, начиная от строки, где повторно было сгенерировано исключение. Хотя по факту мы могли ожидать информацию об исключении из Method3()
, что может ввести в заблуждение.
Для того чтобы бросить текущее перехваченное исключение дальше можно использовать ключевое слово throw
без объекта исключения, в таком случае информация будет та, которую мы и ожидали.
public static void Method1() { try { Method2(); } catch(NotImplementedException ex) { throw; } }
Но такой вариант не дает полной картины, если необходимо получить информацию, включая повторную генерацию. В таком случае мы можем создать новый объект исключения, обернув в него исходное:
public static void Method1() { try { Method2(); } catch(NotImplementedException ex) { throw new Exception("Rethrown", ex); } }
Создание собственных классов исключений
В .NET уже содержится множество системных исключений, генерируемых исполняющий средой, и которые можно использовать в своих целях. Но в некоторых ситуациях бывает нужно отделить возникающую проблему в приложении от системных исключений. В таком случае можно создать собственный класс исключений.
Для создания собственного класса исключений необходимо и достаточно унаследовать свой класс от System.Exception
или производного от него (о наследовании мы поговорим позже в лекциях об ООП:). Но есть четкая иерархия исключений, которой стоит придерживаться. System.Exception
является базовым общим классом для всех создаваемых исключений, от него напрямую наследуются классы System.SystemException
и System.ApplicationException
. SystemException
определяет исключения, генерируемые самой платформой .NET, а ApplicationException
определяет исключения, порождаемые приложением.
Таким образом, Microsoft рекомендуют собственные классы исключений наследовать именно от ApplecationException
. Как уже было сказано, чтобы создать класс исключения достаточно его просто унаследовать от Exception или от унаследуемого от него.
public class HouseOnFireException : ApplicationException { }
Но это не является хорошей практикой, т.к. мы не сможем передать даже никакое сообщение при генерации исключения. Для решения этой проблемы необходимо задать конструкторы, которые будут при создании объекта исключения обращаться к конструктору базового класса, с помощью base
(о нем мы также подробнее поговорим уже в курсе лекций по ООП:)
public class HouseOnFireException : ApplicationException { public HouseOnFireException() { } public HouseOnFireException(string message) : base(message) { } public HouseOnFireException(string message, Exception innerException) : base(message, innerException) { } }
Все, теперь мы можем генерировать исключение нашего класса:)
Как-то так:) В следующей лекции поговорим еще раз про типизацию и разберем основы приведения типов, и наверно закончим с базой шарпа. А далее начнем курс лекций по ООП. Так что подписывайтесь на тг канал, ставьте лайки и вступайте в наш чат, там ничего нет.