Лекция 14. Обработка исключительных ситуаций

1. Обработка ошибок

Когда программа конструируется из раздельных модулей, и, особенно, когда эти модули находятся в независимо разработанных библиотеках, обработка ошибок должна быть разделена на две части:

  1. генерация информации о возникновении ошибочной ситуации, которая не может быть разрешена локально;
  2. обработка ошибок, обнаруженных в других местах.

Автор библиотеки может обнаружить ошибки во время выполнения, но, как правило, не имеет представления о том, что делать в этом случае. Пользователь библиотеки может знать, как поступить в случае возникновения ошибок, но не в состоянии их обнаружить в противном случае, ошибки обрабатывались бы в коде пользователя, а их поиск не перепоручался бы библиотеке. Для помощи в решении подобных проблем введено понятие исключения.

Фундаментальная идея состоит в том, что функция, обнаружившая проблему, но не знающая как её решить, генерирует исключение в надежде, что вызвавшая её (непосредственно или косвенно) функция сможет решить возникшую проблему. Функция, которая хочет решать проблемы данного типа, может указать, что она перехватывает такие исключения.

Такой стиль обработки ошибок предпочтительней многих традиционных техник. Рассмотрим альтернативы. При обнаружении проблемы, которая не может быть решена локально, функция может:

  1. прекратить выполнение;
  2. возвратить значение, означающее «ошибка»;
  3. возвратить допустимое значение и оставить программу в ненормальном состоянии.

Вариант 1 – «прекратить выполнение» – это то, что происходит по умолчанию, когда не перехватывается исключение. Для большинства ошибок мы должны придумать кое-что получше. Библиотека, безусловно завершающая выполнение, не может использоваться в программе, первое требование к которой – надёжность.

Вариант 2 – «возвратить значение, сигнализирующее об ошибке» – не всегда выполним, потому что часто нет приемлемого значения. Даже в тех случаях, когда такой подход применим, он часто неудобен, потому что результат каждого вызова должен проверяться на ошибочное значение. Это может сильно увеличить размер программы.

Вариант 3 – «возвратить допустимое значение и оставить программу в ненормальном состоянии» – имеет тот недостаток, что вызывающая функция может не заметить, что программа находится в ненормальном состоянии.

Механизм обработки исключений предоставляет альтернативу традиционным методам в тех случаях, когда они не достаточны, не элегантны и подвержены ошибками. Он предоставляет способ явного отделения кода обработки ошибок от «обычного» кода, делая таким образом программу более читабельной и лучше подходящей для различных инструментальных средств. Механизм обработки исключений предоставляет более регулярный способ обработки ошибок, упрощая в результате взаимодействие между отдельно написанными фрагментами кода.

Надо понимать, что обработка ошибок остаётся сложной задачей и что механизм обработки исключений – несмотря на бóльшую формализацию, чем альтернативные методы – относительно менее структурирован по сравнению со средствами языка, обеспечивающими локальное управление выполнением. Механизм обработки исключений C++ предоставляет программисту средство обработки ошибок в том месте, где их наиболее естественно обрабатывать при данной структуре системы. Исключения делают сложность обработки ошибок более явной. Однако исключения не являются причиной этой сложности.

Механизм обработки исключений можно рассматривать в качестве альтернативы механизму возврата из функции. Поэтому вполне законно использовать исключения, которые не имеют никакого отношения к ошибкам. Однако изначальной целью механизма обработки исключений является обработка ошибок и обеспечение устойчивости при возникновении ошибок.

2. Генерация и перехват исключений

Обработка исключительных ситуаций обеспечивает способ передачи управления и информации в неопределённую точку, где было выражено желание обрабатывать ситуации данного типа. Ситуации любого типа могут возбуждаться (throw) и перехватываться (catch), и для функции может быть перечислено множество ситуаций, которые могут быть в ней возбуждены.

Реакция будет вызвана только в случае исполнения выражения возбуждения внутри блока с контролем или в функциях, вызванных из этого блока.

Синтаксис блока с контролем:
try { ... } <список реакций>

Список реакций представляет собой следующую конструкцию:
catch (<объявление ситуации>) { ... } [ catch (<объявление ситуации>) { ... } ... ]

Выражение возбуждения имеет следующий синтаксис:
throw <выражение>;

При возбуждении ситуации (т.е. выполнении оператора throw) управление передаётся на реакцию. Тип операнда оператора throw определяет, какие реакции могут перехватывать данную ситуацию.

Если среди реакций блока с контролем не найдено подходящей реакции, поиск подходящей реакции продолжается в блоке с контролем, охватывающем данный блок с контролем.

Если в программе не нашлось подходящей реакции, вызывается функция terminate(). Функция terminate() вызывает функцию, заданную при последнем обращении к функции set_terminate(). По умолчанию функция, вызываемая из функции terminate(), есть abort(). Функция, вызываемая функцией terminate(), должна завершать выполнение программы.

Исключение является объектом некоторого класса, являющегося представлением исключительного случая. Код, обнаруживший ошибку, генерирует объект инструкций throw. Фрагмент кода выражает своё желание обрабатывать исключение при помощи инструкции catch. Результатом генерации исключения инструкцией throw является раскручивание стека до тех пор, пока не будет обнаружен подходящий catch в функции, которая непосредственно или косвенно вызвала функцию, сгенерировавшую исключение.

Сам факт генерации исключения передаёт информацию об ошибке и её типе. Кроме того, исключение может содержать дополнительную информацию. Фундаментальной целью техники обработки исключений является передача информации для восстановления после возникновений проблемы и для осуществления этого надёжным и удобным способом.

Пример 1

FILE *open(char *fname) { FILE *f = fopen(fname, "r"); if (!f) throw fname; return f; } void main() { try { FILE *f1 = open("in1.txt"); FILE *f2 = open("in2.txt"); } catch (char *str) { printf("Impossible to open file '%s'!\n", str); return; } ... }

Пример 2

class Ex1 { private: int reason; public: Ex1(int r) : reason(r) { } int Reason() { return reason; } }; class Ex2 { }; void f1() { ... if (...) throw Ex1(0); if (...) throw Ex1(2); ... if (...) throw Ex2(); } void f2() { ... if (...) throw Ex2(); } void main() { try { ... f1(); ... f2(); ... } catch (Ex1 ex) { switch (ex.Reason()) { case 0: ... case 1: ... case 2: ... } } catch (Ex2 ex) { ... } }

3. Перехват исключений

Рассмотрим пример.

try { throw E(); } catch (H) { ... }

Обработчик будет вызван, если:

  1. Н того же типа, что и Е;
  2. Н является однозначно доступным публичным базовым классом для Е;
  3. Н и Е являются указателями, и 1 или 2 выполняется для типов, на которые они ссылаются;
  4. Н является ссылкой, и 1 или 2 выполняется для типа, на который ссылается Н.

4. Группировка исключений

Часто исключения естественным образом разбиваются на семейства. Из этого следует, что наследование может оказаться полезным для структурирования исключений и помочь при их обработке. Например, исключения для математической библиотеки можно организовать следующим образом.

class MathErr { ... }; class Overflow     : public MathErr { ... }; class Underflow    : public MathErr { ... }; class ZeroDivision : public MathErr { ... }; // Переполнение сверху // Переполнение снизу // Деление на 0

Это позволяет нам обрабатывать исключение любого класса, производного от MathErr, не заботясь о том, какое именно исключение возникло.

try { ... } catch (Overflow) { ... } catch (MathErr) { ... } // Обработка исключения Overflow и всех производных от него исключений // Обработка любого исключения MathErr, не являющегося Overflow

Организация исключений в виде иерархий может иметь большое значение для надёжности кода. При введении нового исключения в математическую библиотеку каждый фрагмент кода, который пытается обрабатывать все математические исключения, подлежал бы модификации. Это требует огромных затрат труда и в общем случае невозможно.

5. Повторная генерация

Перехватив исключение, обработчик может решить, что у него не получается полностью обработать ошибку. В этом случае обработчик делает то, что может, после чего вновь генерирует исключение.

try { ... } catch (MathErr) { if (...) ... else { ... throw; } } // Возможно полностью обработать ошибку? // Делаем, что возможно // Повторная генерация исключения

Факт повторной генерации отмечается отсутствием операнда у throw. Если осуществлена попытка повторной генерации при отсутствии исключения, будет вызвана функция terminate().

Повторно генерируемое исключение является исходным исключением, т.е. даже если обработчик работал с объектом базового класса, охватывающий блок получит изначально сгенерированный объект производного класса.

6. Перехват всех исключений

В объявлении ситуации для обработчика catch можно задать многоточие, которое даёт отождествление с любой ситуацией. Реакция с многоточием, если она есть, должна быть последней в списке реакций некоторого блока с контролем. try { // Делаем что-то } catch (...) { // Обработка всех исключений }

7. Порядок записи обработчиков

Поскольку исключения производных классов могут быть перехвачены обработчиками исключений базовых классов, порядок, в котором записаны обработчики в инструкции try, имеет большое значение. Обработчики проверяются в порядке их записи.

try { // ... } catch (std::ios_base::failure) { // ... } catch (std::exception) { // ... } catch (...) { // ... } // Обработка ошибок в потоке ввода/вывода // Обработка исключений стандартной библиотеки // Обработка всех остальных исключений

8. Исключения в конструкторах

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

  1. Возвратить объект в «неправильном» состоянии и полагаться на то, что пользователь проверит его состояние.
  2. Присвоить значение нелокальной переменной для указания на неуспешное создание объекта и полагаться на то, что пользователь его проверит.
  3. Не осуществлять никакой инициализации в конструкторе и полагаться на то, что пользователь вызовет функцию инициализации (которую ещё надо написать!) до первого использования объекта.
  4. Пометить объект как неинициализированный и при первом вызове функции-члена класса для этого объекта осуществить инициализацию (такая функция может вернуть сообщение об ошибке в случае неуспешной инициализации, но пользователь опять-таки должен проверять возвращаемое функцией значение).

Исключения позволяют передать информацию о неуспешной инициализации из конструктора. Например, класс Vector мог бы защититься от запроса слишком большого количества памяти, генерируя соответствующее исключение. class Vector { ... public: class Size { ... }; Vector(int n = 0); ... }; Vector::Vector(int n) { if (n < 0 || n > MAX_SIZE) throw Size(); ... }

Код, создающий вектора, теперь может перехватить ошибку Vector::Size и попытаться сделать что-то осмысленное.

9. Исключения, не являющиеся ошибками

Если исключение ожидается и перехвачено таким образом, что не оказывает отрицательного влияния на поведение программы, почему это может считаться ошибкой? Механизмы обработки исключений можно рассматривать как ещё одну управляющую структуру.

void f(Queue q) { try { for ( ; ; )
{ int x = q.Get(); ... } } catch (Queue::Empty) { return; } } // Функция Get генерирует исключение Empty, если очередь пуста

Обработка исключений является менее структурированным механизмом, чем локальные управляющие структуры, к тому же обработка исключений часто менее эффективна, если исключение действительно генерируется. Поэтому исключения следует использовать в тех случаях, когда традиционные управляющие структуры являются неэлегантным решением, или ими невозможно воспользоваться.

Неумеренное использование исключений ведёт к непонятному коду. Обычно следует придерживаться точки зрения «обработка исключений является обработкой ошибок». При таком подходе код оказывается понятным образом разделён на две части: обыкновенный код и код обработки ошибок.

10. Спецификация исключений

Генерация и перехват исключений изменяют способ взаимодействия между функциями. Поэтому может быть полезно указать в объявлении набор исключений, которые могут быть сгенерированы функцией.
int f(int n) throw (ex1, ex2);

Такое объявление означает, что функция f может сгенерировать только исключения ex1, ex2 и исключения, являющиеся производными от этих типов, но не другие. Самым важным преимуществом является то, что объявление функции принадлежит интерфейсу, который видят те, кто её вызывает.

Предполагается, что функция, объявленная без спецификации исключений, может сгенерировать любое исключение.
int g(int n);

Функцию, которая не генерирует исключений, можно объявить с пустым списком спецификаций исключений.
int h(int n) throw();

11. Пример. Класс Stack, генерирующий исключения

Модифицируем класс Stack (из лекции 11) так, чтобы при переполнении стека и попытке взять элемент из пустого стека генерировались соответствующие исключения.
#include <cstdio> class Stack; class StackEmpty { private: Stack *stack; public: StackEmpty(Stack *p) : stack(p) { } Stack* GetPtr() { return stack; } }; class StackFull { private: Stack *stack; int n; public: StackFull(Stack *p, int i) : stack(p), n(i) { } Stack* GetPtr() { return stack; } int GetValue() { return n; } }; class Stack { private: enum { SIZE = 100 }; int stack[SIZE]; int *cur; public: Stack() { cur = stack; } ~Stack() { } int Push(int n) throw (StackFull); int Pop() throw (StackEmpty); int IsEmpty() const { return cur == stack; } int operator >> (int& s) { s = Pop(); return s; } int operator << (int s) { return Push(s); } }; int Stack::Push(int n) throw (StackFull) { if (cur - stack < SIZE) { *cur++ = n; return n; } else throw StackFull(this, n); } int Stack::Pop() throw (StackEmpty) { if (cur != stack) return *--cur; else throw StackEmpty(this); } void main() { Stack s; int n; try { s << 1; s << 2; s << 3; s << 4; s << 5; s >> n; printf("%d\n", n); s >> n; printf("%d\n", n); s >> n; printf("%d\n", n); s >> n; printf("%d\n", n); } catch (StackFull s) { printf("Attempt to put a value %d to the full stack at the address %p\n", s.GetValue(), s.GetPtr()); } catch (StackEmpty s) { printf("Attempt to get a value from the empty stack at the address %p\n", s.GetPtr()); } }