diff --git a/09-обработка-ошибок/README.md b/09-обработка-ошибок/README.md index e69de29..dd4f08f 100644 --- a/09-обработка-ошибок/README.md +++ b/09-обработка-ошибок/README.md @@ -0,0 +1,495 @@ +# 9. Обработка ошибок + +- [9.1. Порождение и обработка исключительных ситуаций](#9-1-порождение-и-обработка-исключительных-ситуаций) +- [9.2. Типы](#9-2-типы) +- [9.3. Блоки finally](#9-3-блоки-finally) +- [9.4. Функции, не порождающие исключения (nothrow), и особая природа класса Throwable](#9-4-функции-не-порождающие-исключения-nothrow-и-особая-природа-класса-throwable) +- [9.5. Вторичные исключения](#9-5-вторичные-исключения) +- [9.6. Раскрутка стека и код, защищенный от исключений](#9-6-раскрутка-стека-и-код-защищенный-от-исключений) +- [9.7. Неперехваченные исключения](#9-7-неперехваченные-исключения) + +Обработка ошибок – это слабо формализованная область программного инжиниринга, связанная с обработкой ожидаемых и возникших ошибочных ситуаций, способных помешать нормальному функционированию системы. Обработка исключительных ситуаций (исключений) – подход к обработке ошибок, принятый во многих современных языках (включая D) и уже успевший породить великое множество руководств, методик и, конечно, споров. + +Исключения – это средство языка, реализующее обработку ошибок благодаря специальным обходным путям передачи управления. Если функции не удается вернуть вызвавшему ее коду осмысленный результат, она может породить объект исключения, в котором закодирована причина ошибки. Порождение исключений (throwing) – это карточка «Бесплатно освободитесь из тюрьмы»[^1], освобождающая функцию от ее обычных обязанностей. Исключение пропускает всех инициаторов вызовов, не предусматривающих его обработку, и попадает в *место обработки*, где и принимаются чрезвычайные меры. В хорошо спроектированной программе гораздо меньше мест обработки, чем мест порождения исключений, что способствует централизованной обработке ошибок с многократным использованием кода. Все это было бы проблематично организовать на основе традиционных методик с вездесущими кодами ошибки. + +[В начало ⮍](#9-обработка-ошибок) + +## 9.1. Порождение и обработка исключительных ситуаций + +D использует популярную модель исключений. Функция может инициировать исключения с помощью инструкции `throw` (см. раздел 3.11), порождающей объект особого типа. Чтобы завладеть этим объектом, код должен использовать инструкцию `try` (см. раздел 3.11), где этот объект указан в блоке `catch`. Перефразируя пословицу, лучше один пример кода, чем 1024 слова. Так что рассмотрим пример: + +```d +import std.stdio; + +void main() +{ + try + { + auto x = fun(); + } + catch (Exception e) + { + writeln(e); + } +} + +int fun() +{ + return gun() * 2; +} + +int gun() +{ + throw new Exception("Вернемся прямо в main"); +} +``` + +Вместо того чтобы вернуть значение типа `int`, функция `gun` решает породить исключение, в данном случае это экземпляр класса `Exception`. В порожденном объекте можно передать любую информацию о том, что произошло. Управление передается исключительным путем (что освобождает от возвращения результата не только функцию, породившую исключение, но и всех инициаторов ее вызова) тому из инициаторов вызова, который готов обработать ошибку с помощью блока `catch`. + +После выполнения инструкции `throw` функция `fun` полностью пропускается, поскольку она не готова обработать исключение. Вот в чем принципиальная разница между обработкой ошибок старой школы, когда ошибки вручную проводились через все уровни вызовов, и относительно новым подходом – обработкой исключений, когда управление искусно передается из места возникновения ошибки (`gun`) непосредственно туда, где есть все необходимое для ее обработки (блок `catch` в функции `main`). Такой подход обещает более простую, централизованную обработку ошибок, освобождая множество функций от обязанности проталкивать ошибки дальше по стеку; `fun` может оставаться в блаженном неведении о прямом сообщении между `gun` и `main`. + +К сожалению, непосредственная передача потока управления из места порождения в место обработки – это также и слабое звено обработки исключений: на самом деле, то блаженное неведение – лишь несбыточная мечта. В действительности, функции, пересекаемые исключением, должны помнить о дополнительных неявных точках выхода и гарантировать поддержку инвариантов программы независимо от того, каким путем будет передано управление. D предоставляет надежные механизмы, обеспечивающие сохранение инвариантности при возникновении исключений. В свое время мы обсудим их в этой главе. + +[В начало ⮍](#9-1-порождение-и-обработка-исключительных-ситуаций) [Наверх ⮍](#9-обработка-ошибок) + +## 9.2. Типы + +Базовая иерархия исключений в D проста (рис. 9.1). Инструкция `throw` порождает не просто какие-то значения, а только объекты-потомки класса `Throwable`. В подавляющем большинстве случаев код действительно порождает исключение как экземпляр потомка класса `Exception`, подкласса `Throwable`. Это обычные исключения, после которых возможно восстановление, и они распознаются языком именно так. Исключения-наследники `Throwable`, но не `Exception` (такие как `AssertError`; см. главу 10) относятся к фатальным ошибкам, после которых восстановление невозможно, и должны использоваться в коде крайне редко, практически никогда. (О том, что язык гарантирует в случае фатальных ошибок, а что нет, см. подробнее в разделе 9.4.) + +![image-9-2](images/image-9-2.png) + +***Рис. 9.1.*** *Обычные исключения – потомки класса `Exception`, поэтому их можно обработать с помощью блока `catch(Exception)`. Класс `Error` – прямой наследник класса `Throwable`. Обычный код должен перехватывать только исключения типа `Exception` и потомков `Exception`. Остальные исключения лишь позволяют аккуратно завершить работу программы в случае нахождения ошибки в ее логике* + +Инструкция `try` может определять больше одного блока `catch`, например: + +```d +try +{ + ... +} +catch (SomeException e) +{ + ... +} +catch (SomeOtherException e) +{ + ... +} +``` + +Исключения распространяются от места порождения до самого раннего места обработки, следуя правилу *первого совпадения*: сразу же после обнаружения блока `catch`, обрабатывающего исключение порожденного класса или его предка, этот блок `catch` активируется и порожденное исключение передается в него. Вот пример, порождающий и обрабатывающий исключения двух различных типов: + +```d +import std.stdio; + +class MyException : Exception +{ + this(string s) { super(s); } +} + +void fun(int x) +{ + if (x == 1) + { + throw new MyException(""); + } + else + { + throw new StdioException(""); + } +} + +void main() +{ + foreach (i; 1 .. 3) + { + try + { + fun(i); + } + catch (StdioException e) + { + writeln("StdioException"); + } + catch (Exception e) + { + writeln("Exception"); + } + } +} +``` + +Эта программа выводит на экран: + +``` +Exception +StdioException +``` + +Первый вызов `fun` порождает объект исключения типа `MyException`. При его сопоставлении с первым `catch`-обработчиком совпадения нет, но зато оно обнаруживается при сопоставлении со вторым блоком `catch`, поскольку `MyException` является потомком `Exception`. А в случае исключения, порожденного второй инструкцией `throw`, совпадение обнаруживается при сопоставлении с первым же `catch`-обработчиком. До первого совпадения этот процесс может затронуть и несколько уровней функций, как показывает следующий более замысловатый пример: + +```d +import std.stdio; + +class MyException : Exception +{ + this(string s) { super(s); } +} + +void fun(int x) +{ + if (x == 1) + { + throw new MyException(""); + } + else if (x == 2) + { + throw new StdioException(""); + } + else + { + throw new Exception(""); + } +} + +void funDriver(int x) +{ + try + { + fun(x); + } + catch (MyException e) + { + writeln("MyException"); + } +} + +unittest +{ + foreach (i; 1 .. 4) + { + try + { + funDriver(i); + } + catch (StdioException e) + { + writeln("StdioException"); + } + catch (Exception e) + { + writeln("Просто Exception"); + } + } +} +``` + +Эта программа выводит на экран: + +``` +MyException +StdioException +Просто Exception +``` + +поскольку обработчики в соответствии с концепцией пробуются по мере того, как поток управления всплывает вверх по стеку вызовов. + +У правила первого совпадения есть очевидный недостаток. Если блок `catch` для исключения типа `E1` расположен до блока `catch` для исключения типа `E2` и при этом `E2` является подтипом `E1`, то обработчик для `E2` оказывается недоступным. В такой ситуации компилятор диагностирует ошибку. Например: + +```d +import std.stdio; + +void fun() +{ + try + { + ... + } + catch (Exception e) + { + ... + } + catch (StdioException e) + { + ... // Ошибка! + // Недоступный обработчик catch! + } +} +``` + +Ошибочность подобного кода очевидна, но всегда есть угроза динамического маскирования между разными функциями. Любая функция легко может сделать недееспособными `catch`-обработчики вызвавшей ее функции. Тем не менее в большинстве случаев это не ошибка, а лишь нормальное следствие динамики стека вызовов. + +[В начало ⮍](#9-2-типы) [Наверх ⮍](#9-обработка-ошибок) + +## 9.3. Блоки finally + +Инструкция `try` может завершаться блоком `finally`, что фактически означает: «Непременно выполните этот код, даже если наступит конец света или начнется потоп». Было или нет порождено исключение, блок `finally` будет выполнен просто как часть инструкции `try`, и вам решать, закончится ли это сбоем программы, порождением исключения, возвратом с помощью инструкции `return` или досрочным выходом из включающего цикла с помощью инструкции `break`. Например: + +```d +import std.stdio; + +string fun(int x) +{ + string result; + try + { + if (x == 1) + { + throw new Exception("некоторое исключение"); + } + result = "исключение не было порождено"; + return result; + } + catch (Exception e) + { + if (x == 2) throw e; + result = "исключение было порождено и обработано: " ~ e.toString; + return result; + } + finally + { + writeln("Выход из fun"); + } +} +``` + +При нормальном ходе выполнения функция `fun` вернет некоторое значение, при исключительном – породит исключение, но в любом случае она всегда напечатает в стандартный поток вывода: `Выход из fun`. + +[В начало ⮍](#9-3-блоки-finally) [Наверх ⮍](#9-обработка-ошибок) + +## 9.4. Функции, не порождающие исключения (nothrow), и особая природа класса Throwable + +С помощью ключевого слова `nothrow` можно объявить функцию, не порождающую исключения: + +```d +nothrow int iDontThrow(int a, int b) +{ + return a / b; +} +``` + +Функции, не порождающие исключения, уже упоминались в разделе 5.11.2. А вот и новый поворот сюжета: атрибут `nothrow` обещает, что функция не породит объект типа `Exception`. Но у функции по-прежнему остается право порождать объекты более грозного класса `Throwable`. Собственно, восстановление после исключений типа `Throwable` считается невозможным, поэтому компилятору разрешается не «продумывать» ход событий при возникновении такого исключения, соответственно он оптимизирует код исходя из предположения, что никакого исключения нет. Для функций с атрибутом `nothrow` компилятор упрощает последовательности входа и выхода, не планируя экстренные мероприятия на случай, если будет порождено исключение. + +Проясним и подчеркнем особый статус класса `Throwable`. Первое правило для исключений `Throwable`: исключения `Throwable` не обрабатывают. Решив обработать такие исключения с помощью блока `catch`, вы не можете рассчитывать на то, что деструкторы структур будут вызваны, а блоки `finally` – выполнены. Это означает, что состояние вашей системы неопределенно и может быть нарушен целый ряд высокоуровневых инвариантов, на которые вы полагаетесь при нормальном исполнении. Тем не менее D гарантирует безопасность базовых типов и целостность стандартной библиотеки. Вы не можете рассчитывать на высокоуровневую целостность состояния своего приложения, поскольку не сработало неизвестное количество кода, обеспечивающего эту целостность. Так что при обработке `Throwable` вам доступны только несколько простых операций. Все, что вы сможете сделать в большинстве случаев, – это вывести сообщение об ошибке в стандартный поток или в файл журнала, попытаться сохранить в отдельном файле то, что еще можно сохранить, и, стиснув зубы, достойно завершить выполнение программы насколько это возможно. + +[В начало ⮍](#9-4-функции-не-порождающие-исключения-nothrow-и-особая-природа-класса-throwable) [Наверх ⮍](#9-обработка-ошибок) + +## 9.5. Вторичные исключения + +Иногда во время обработки исключения порождается еще одно. Например: + +```d +import std.conv; + +class MyException : Exception +{ + this(string s) { super(s); } +} + +void fun() +{ + try + { + throw new Exception("порождено в fun"); + } + finally + { + gun(100); + } +} + +void gun(int x) +{ + try + { + throw new MyException(text("порождено в gun #", x)); + } + finally + { + if (x > 1) + { + gun(x - 1); + } + } +} +``` + +Что происходит, когда вызвана функция `fun`? Ситуация на грани непредсказуемости. Во-первых, `fun` пытается породить исключение, но благодаря упомянутой привилегии блока `finally` всегда выполняться «даже если наступит конец света или начнется потоп», `gun(100)` вызывается тогда же, когда из `fun` вылетает `Exception`. В свою очередь, вызов `gun(100)` создает исключение типа `MyException` с сообщением `"порождено в gun #100"`. Назовем второе исключение *вторичным*, чтобы отличать его от порожденного первым, которое мы назовем *первичным*. Затем уже функция `gun` с помощью блока `finally` порождает добавочные вторичные исключения – ровно 100 исключений. Такой код испугал бы и самого Макиавелли. + +Ввиду необходимости обрабатывать вторичные исключения язык может выбрать один из следующих вариантов поведения: + +- немедленно прервать выполнение; +- продолжить распространять первичное исключение, игнорируя все вторичные; +- заменить первичное исключение вторичным и продолжить распространять его; +- продолжить в той или иной форме распространять и первичное, и все вторичные исключения. + +С точки зрения сохранения информации о происходящем последний подход видится наиболее обоснованным, но и самым сложным в реализации. Например, осмысленно обработать залп исключений гораздо труднее, чем одно исключение. + +D выбрал подход простой и эффективный. Всякий объект типа `Throwable` содержит ссылку на следующий вторичный объект типа `Throwable`. Этот вторичный объект доступен через свойство `Throwable.next`. Если вторичных исключений (больше) нет, значением свойства `Throwable.next` будет `null`. По сути, создается односвязный список с полной информацией обо всех вторичных ошибках в порядке их возникновения. В голове списка находится первичное исключение. Вот ключевые моменты определения `Throwable`: + +```d +class Throwable +{ + this(string s); + override string toString(); + @property Throwable next(); +} +``` + +Разделение исключений на первичное и вторичные позволяет реализовать очень простую модель поведения. В любой момент, когда бы ни порождалось исключение, первичное исключение или уже порождено, или нет. Если нет, то порождаемое исключение становится первичным. Иначе порождаемое исключение добавляется в конец односвязного списка, головой которого является первичное исключение. Продолжив начатый выше пример, напечатаем всю цепочку исключений: + +```d +unittest +{ + try + { + fun(); + } + catch (Exception e) + { + writeln("Первичное исключение: исключение типа ", typeid(e), " ", e); + Throwable secondary; + while ((secondary = secondary.next) !is null) + { + writeln("Вторичное исключение: исключение типа ", typeid(e), " ", e); + } + } +} +``` + +Этот код напечатает: + +``` +Первичное исключение: исключение типа Exception порожденов fun +Вторичное исключение: исключение типа MyException порожденов gun #100 +Вторичное исключение: исключение типа MyException порожденов gun #99 +... +Вторичное исключение: исключение типа MyException порожденов gun #1 +``` + +Вторичные исключения появляются в этой последовательности, поскольку присоединение к списку исключений выполняется в момент порождения исключения. Каждый раз инструкция `throw` извлекает первичное исключение (если есть), регистрирует новое исключение и инициирует или продолжает процесс порождения исключений. + +Благодаря вторичным исключениям код на D может порождать исключения внутри деструкторов и блоков инструкций `scope`. В месте обработки исключения доступна полная информация о том, что произошло. + +[В начало ⮍](#9-5-вторичные-исключения) [Наверх ⮍](#9-обработка-ошибок) + +## 9.6. Раскрутка стека и код, защищенный от исключений + +Пока исключение в полете, управление переносится из изначального места возникновения исключения через всю иерархию вверх до обработчика, при сопоставлении с которым происходит совпадение. Все участвующие в цепочке вызовов функции пропускаются. Ну, или почти все. Должную очистку после вызова пропускаемых функций обеспечивает так называемая *раскрутка стека* (*stack unwinding*) – часть процесса распространения исключения. Язык гарантирует, что пока исключение в полете, выполняются следующие фрагменты кода: + +- деструкторы расположенных в стеке объектов-структур всех пропущенных функций; +- блоки `finally` всех пропускаемых функций; +- инструкции `scope(exit)` и `scope(failure)`, действующие на момент порождения исключения. + +Раскрутка стека – бесценная помощь в обеспечении корректности программы при возникновении исключений. Программы, использующие исключения, обычно предрасположены к утечке ресурсов. Многие ресурсы рассчитаны только на использование в режиме «получить/освободить», а при порождении исключений то и дело возникают малозаметные потоки управления, «забывающие» освободить ресурсы. Такие ресурсы лучше всего инкапсулировать в структуры, которые надлежащим образом освобождают управляемые ресурсы в своих деструкторах. Эта тема уже обсуждалась в разделе 7.1.3.6, пример такой инкапсуляции – стандартный тип `File` из модуля `std.stdio`. Структура `File` управляет системным дескриптором файла и гарантирует, что при уничтожении объекта типа `File` внутренний дескриптор будет корректно закрыт. Объект типа `File` можно копировать; счетчик ссылок отслеживает все активные копии; копия, уничтожаемая последней, закрывает файл в его низкоуровневом представлении. Этот популярный идиоматический подход к использованию деструкторов высоко ценят программисты на C++. (Данная идиома известна как RAII, см. раздел 6.16.) Другие языки и фреймворки также используют подсчет ссылок, вручную или автоматически. + +Утечка ресурсов – лишь одно из проявлений более масштабной проблемы. Иногда шаблон «выполнить/отменить» связан с ресурсом, который невозможно «пощупать». Например, при записи текста HTML-файла многие теги (например, ``) полагается закрывать парным тегом (``). Нелинейный поток управления, включающий порождение исключений, может привести к генерации некорректно сформированных HTML-документов. Например: + +```d +void sendHTML(Connection conn) +{ + conn.send(""); + ... // Отправить полезную информацию в файл + conn.send(""); +} +``` + +Если код между двумя вызовами `conn.send` преждевременно прервет выполнение функции `sendHTML`, то закрывающий тег не будет отправлен и результатом станет некорректный поток HTML-данных. Такую же проблему могла бы вызвать инструкция `return`, расположенная в середине `sendHTML`, но `return` можно хотя бы увидеть невооруженным глазом, просто внимательно просмотрев тело функции. Исключение же, напротив, может быть порождено любой из функций, вызывающих `sendHTML` (напрямую или косвенно). Из-за этого оценка корректности `sendHTML` становится гораздо более сложным и трудоемким процессом. Более того, у рассматриваемого кода есть серьезные проблемы со связанностью, поскольку корректность `sendHTML` зависит от того, как поведет себя при порождении исключений потенциально огромное число других функций. + +Одно из возможных решений – имитировать RAII (даже если никакие ресурсы не задействованы): определить структуру, которая отсылает закрывающий тег в своем деструкторе. В лучшем случае это паллиатив. На самом деле, нужно гарантировать выполнение определенного кода, а не захламлять программу типами и объектами. + +Другое возможное решение – воспользоваться блоком `finally`: + +```d +void sendHTML(Connection conn) +{ + try + { + conn.send(""); + ... + } + finally + { + conn.send(""); + } +} +``` + +У этого подхода другой недостаток – масштабируемость, вернее ее отсутствие. Слабая масштабируемость `finally` становится очевидной, как только появляются несколько вложенных пар `try`/`finally`. Например, добавим в пример еще и отправку корректно закрытого тега ``. Для этого потребуются *два* вложенных блока `try`/`finally`: + +```d +void sendHTML(Connection conn) +{ + try + { + conn.send(""); + ... // Отправить заголовок + try + { + conn.send(""); + ... // Отправить содержимое + } + finally + { + conn.send(""); + } + } + finally + { + conn.send(""); + } +} +``` + +Тот же результат можно получить альтернативным способом – с единственным блоком `finally` и дополнительной переменной состояния, отслеживающей, насколько продвинулось выполнение функции: + +```d +void sendHTML(Connection conn) +{ + int step = 0; + try + { + conn.send(""); + ... // Отправить заголовок + step = 1; + conn.send(""); + ... // Отправить содержимое + step = 2; + } + finally + { + if (step > 1) conn.send(""); + if (step > 0) conn.send(""); + } +} +``` + +При таком подходе дела обстоят куда лучше, но теперь целый кусок кода посвящен только управлению состоянием, что затуманивает истинное предназначение функции. + +Такие ситуации удобнее всего обрабатывать с помощью инструкций `scope`. Работающая функция в какой-то момент исполнения может включать инструкцию `scope`. Таким образом, любые фрагменты кода, представляющие собой логические пары, окажутся еще и объединенными физически. + +```d +void sendHTML(Connection conn) +{ + conn.send(""); + scope(exit) conn.send(""); + ... // Отправить заголовок + conn.send(""); + scope(exit) conn.send(""); + ... // Отправить содержимое +} +``` + +Новая организация кода обладает целым рядом привлекательных качеств. Во-первых, код теперь расположен линейно, без излишних вложенностей. Это позволяет легко разместить в коде сразу несколько пар типа «открыть/закрыть». Во-вторых, этот подход устраняет необходимость пристального рассмотрения кода функции `sendHTML` и вызываемых ею функций на предмет скрытых потоков управления, возникающих при возможном порождении исключений. В-третьих, взаимосвязанные понятия сгруппированы, что упрощает чтение и сопровождение кода. В-четвертых, код получается компактным, поскольку накладные расходы на запись инструкции `scope` малы. + +[В начало ⮍](#9-6-раскрутка-стека-и-код-защищенный-от-исключений) [Наверх ⮍](#9-обработка-ошибок) + +## 9.7. Неперехваченные исключения + +Если найти обработчик для исключения не удалось, встроенный обработчик просто выводит сообщение об исключении в стандартный поток ошибок и завершает выполнение с ненулевым кодом выхода. Эта схема работает не только для исключений, распространяемых из `main`, но и для исключений, порождаемых блоками `static this`. + +Как уже говорилось, обычно исключения типа `Throwable` не обрабатывают. В очень редких случаях вы, возможно, все же захотите обработать `Throwable` и принять какие-то экстренные меры, даже если наступит конец света или начнется потоп. Но при этом не рассчитывайте на осмысленное состояние системы в целом; логика вашей программы, скорее всего, будет нарушена, так что вы уже мало что сможете сделать. + +[В начало ⮍](#9-7-неперехваченные-исключения) [Наверх ⮍](#9-обработка-ошибок) + +[^1]: Имеется в виду карточка из игры «Монополия». – *Прим. пер.* diff --git a/09-обработка-ошибок/images/image-9-2.png b/09-обработка-ошибок/images/image-9-2.png new file mode 100644 index 0000000..c6eb44c Binary files /dev/null and b/09-обработка-ошибок/images/image-9-2.png differ