К этому моменту мы рассматривали использование исключений только в обычных функциях, которые не являются методами класса. Тем не менее, исключения одинаково полезны и в методах, и даже в перегрузке операторов.
Исключения в перегрузке операторов
Рассмотрим следующую перегрузку оператора индексации [] для простого целочисленного класса-массива:
1 2 3 4 |
int& ArrayInt::operator[](const int index) { return m_data[index]; } |
Хотя эта функция отлично работает, но это только до тех пор, пока значением переменной index
является корректный индекс массива. Здесь явно не хватает механизма обработки ошибок. Давайте добавим стейтмент assert для проверки index
:
1 2 3 4 5 |
int& ArrayInt::operator[](const int index) { assert (index >= 0 && index < getLength()); return m_data[index]; } |
Теперь, если пользователь передаст недопустимый index
, то программа выдаст ошибку. Хотя это сообщит пользователю, что что-то пошло не так, лучшим вариантом было бы «по-тихому» сообщить caller-у, что что-то пошло не так и пусть он с этим разберется соответствующим образом (как именно — мы пропишем позднее).
К сожалению, поскольку перегрузка операторов имеет особые требования к количеству и типу параметров, которые они могут принимать и возвращать, нет никакой гибкости для передачи кодов ошибок или логических значений обратно в caller. Однако, мы можем использовать исключения, которые не изменяют сигнатуру функции, например:
1 2 3 4 5 6 7 |
int& ArrayInt::operator[](const int index) { if (index < 0 || index >= getLength()) throw index; return m_data[index]; } |
Теперь, если пользователь передаст недопустимый index
, operator[] сгенерирует исключение типа int.
Когда конструкторы терпят неудачу
Конструкторы — это еще одна часть классов, в которой исключения могут быть очень полезными. Если конструктор не сработал, то сгенерируйте исключение, которое сообщит, что объект не удалось создать. Создание объекта прерывается, а деструктор никогда не выполняется (обратите внимание, это означает, что ваш конструктор должен самостоятельно выполнять очистку памяти перед генерацией исключения).
Классы-Исключения
Одной из основных проблем использования фундаментальных типов данных (например, типа int) в качестве типов исключений является то, что они, по своей сути, являются неопределенными. Еще более серьезной проблемой является неоднозначность того, что означает исключение, когда в блоке try имеется несколько стейтментов или вызовов функций:
1 2 3 4 5 6 7 8 9 10 |
// Используем перегрузку operator[] для ArrayInt try { int *value = new int(array[index1] + array[index2]); } catch (int value) { // Какие исключения мы здесь ловим? } |
В этом примере, если мы поймаем исключение типа int, что оно нам сообщит? Был ли передаваемый index
недопустим? Может оператор +
вызвал целочисленное переполнение или может оператор new не сработал из-за нехватки памяти? Хотя мы можем генерировать исключения типа const char*, которые будут указывать ПРИЧИНУ сбоя, это все еще не даст нам возможности обрабатывать исключения из разных источников по-разному.
Одним из способов решения этой проблемы является использование классов-исключений. Класс-Исключение — это обычный класс, который выбрасывается в качестве исключения. Создадим простой класс-исключение, который будет использоваться с нашим ArrayInt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <string> class ArrayException { private: std::string m_error; public: ArrayException(std::string error) : m_error(error) { } const char* getError() { return m_error.c_str(); } }; |
Вот полная программа:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
#include <iostream> #include <string> class ArrayException { private: std::string m_error; public: ArrayException(std::string error) : m_error(error) { } const char* getError() { return m_error.c_str(); } }; class ArrayInt { private: int m_data[4]; // ради сохранения простоты примера укажем значение 4 в качестве длины массива public: ArrayInt() {} int getLength() { return 4; } int& operator[](const int index) { if (index < 0 || index >= getLength()) throw ArrayException("Invalid index"); return m_data[index]; } }; int main() { ArrayInt array; try { int value = array[7]; } catch (ArrayException &exception) { std::cerr << "An array exception occurred (" << exception.getError() << ")\n"; } } |
Используя такой класс, мы можем генерировать исключение, возвращающее описание возникшей проблемы, это даст нам точно понять, что именно пошло не так. И, поскольку исключение ArrayException имеет уникальный тип, мы можем обрабатывать его соответствующим образом (не так как другие исключения).
Обратите внимание, в обработчиках исключений объекты класса-исключения принимать нужно по ссылке, а не по значению. Это предотвратит создание копии исключения компилятором, что является затратной операцией (особенно в случае, когда исключение является объектом класса), и предотвратит обрезку объектов при работе с дочерними классами-исключениями. Передачу по адресу лучше не использовать, если у вас нет на это веских причин.
Исключения и Наследование
Так как мы можем выбрасывать объекты классов в качестве исключений, а классы могут быть получены из других классов, то нам нужно учитывать, что произойдет, если мы будем использовать унаследованные классы в качестве исключений. Оказывается, обработчики могут обрабатывать исключения не только одного определенного класса, но и исключения дочерних ему классов!
Рассмотрим следующий пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#include <iostream> #include <cassert> class Parent { public: Parent() {} }; class Child: public Parent { public: Child() {} }; int main() { try { throw Child(); } catch (Parent &parent) { std::cerr << "caught Parent"; } catch (Child &child) { std::cerr << "caught Child"; } return 0; } |
Здесь выбрасывается исключение типа Child. Однако, результат выполнения данной программы:
caught Parent
Что случилось?
Во-первых, как мы уже говорили, дочерние классы могут быть пойманы обработчиком родительского класса. Поскольку Child является дочерним классу Parent, то из этого следует, что Child «является» Parent («является» — тип отношений). Во-вторых, когда C++ пытается найти обработчик для выброшенного исключения, он делает это последовательно. Первое, что он проверяет — подходит ли обработчик исключений класса Parent для исключений класса Child. Поскольку Child «является» Parent, то блок catch для объектов класса Parent подходит и, соответственно, выполняется! В этом случае блок catch для объектов класса Child никогда не выполнится.
Чтобы этот пример работал по-другому, нам нужно изменить порядок последовательности блоков catch:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#include <iostream> #include <cassert> class Parent { public: Parent() {} }; class Child: public Parent { public: Child() {} }; int main() { try { throw Child(); } catch (Child &child) { std::cerr << "caught Child"; } catch (Parent &parent) { std::cerr << "caught Parent"; } return 0; } |
Результат:
caught Child
Таким образом, обработчик Child будет ловить и обрабатывать исключения класса Child. Исключения класса Parent не соответствуют обработчику Child (Child «является» Parent, но Parent «не является» Child) и, соответственно, будут обрабатываться только обработчиком Parent.
Правило: Обработчики исключений дочерних классов должны находиться перед обработчиками исключений родительского класса.
Интерфейсный класс std::exception
Многие классы и операторы из Стандартной библиотеки С++ выбрасывают классы-исключения при сбое. Например, оператор new и std::string могут выбрасывать std::bad_alloc при нехватке памяти. Неудачное динамическое приведение типов с помощью оператора dynamic_cast выбрасывает исключение std::bad_cast и т.д. Начиная с C++14, существует больше 20 классов-исключений, которые могут быть выброшены, а в C++17 их еще больше.
Хорошей новостью является то, что все эти классы-исключения являются дочерними классу std::exception. std::exception — это небольшой интерфейсный класс, который используется в качестве родительского класса для любого исключения, которое выбрасывается в Стандартной библиотеке C++.
В большинстве случаев, если исключение выбрасывается Стандартной библиотекой С++, то нам все равно, было ли это неудачное выделение, конвертирование или что-либо другое. Нам достаточно знать, что произошло что-то катастрофическое, из-за чего в нашей программе произошел сбой. Благодаря std::exception мы можем настроить обработчик исключений типа std::exception, который будет ловить и обрабатывать как std::exception, так и все (20+) дочерние ему классы-исключения!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
#include <iostream> #include <exception> // для std::exception #include <string> // для этого примера int main() { try { // Здесь должен находиться код, использующий Стандартную библиотеку С++. // Сейчас мы намеренно спровоцируем генерацию одного из исключений std::string s; s.resize(-1); // генерируется исключение std::bad_alloc } // Этот обработчик ловит std::exception и все дочерние ему классы-исключения catch (std::exception &exception) { std::cerr << "Standard exception: " << exception.what() << '\n'; } return 0; } |
Результат выполнения программы:
Standard exception: string too long
В этом примере всё довольно просто. В std::exception есть виртуальный метод what(), который возвращает строку C-style с описанием исключения. Большинство дочерних классов переопределяют функцию what(), изменяя это сообщение. Обратите внимание, эта строка C-style предназначена для использования только в качестве описания.
Иногда нам нужно будет обрабатывать определенный тип исключений несколько иначе, нежели остальные типы исключений. В таком случае мы можем добавить обработчик исключений для этого конкретного типа, а все остальные исключения «перенаправить» в родительский обработчик. Например:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
try { // Здесь должен находиться код, использующий Стандартную библиотеку С++ } // Этот обработчик ловит std::bad_alloc и все дочерние ему классы-исключения catch (std::bad_alloc &exception) { std::cerr << "You ran out of memory!" << '\n'; } // Этот обработчик ловит std::exception и все дочерние ему классы-исключения catch (std::exception &exception) { std::cerr << "Standard exception: " << exception.what() << '\n'; } |
В этом примере исключения типа std::bad_alloc перехватываются и обрабатываются первым обработчиком. Исключения типа std::exception и всех других дочерних ему классов-исключений обрабатываются вторым обработчиком.
Такие иерархии наследования позволяют использовать определенные обработчики для перехвата определенного типа исключений или для перехвата одним (родительским) обработчиком всей иерархии исключений.
Использование стандартных исключений напрямую
Ничто не генерирует std::exception напрямую, и вы также должны придерживаться этого правила. Однако, вы можете генерировать исключения других классов из Стандартной библиотеки С++, если они адекватно отражают ваши потребности. Найти список всех стандартных классов-исключений из Стандартной библиотеки С++ вы можете здесь.
std::runtime_error (находится в заголовочном файле stdexcept) является популярным выбором, так как имеет общее имя, а конструктор принимает настраиваемое сообщение:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> #include <stdexcept> int main() { try { throw std::runtime_error("Bad things happened"); } // Этот обработчик ловит std::exception и все дочерние ему классы-исключения catch (std::exception &exception) { std::cerr << "Standard exception: " << exception.what() << '\n'; } return 0; } |
Результат:
Standard exception: Bad things happened
Создание собственных классов-исключений, дочерних классу std::exception
Конечно, вы можете создать свои собственные классы-исключения, дочерние классу std::exception, и переопределить виртуальный константный метод what(). Вот вышеприведенная программа, но уже с классом-исключением ArrayException, дочерним std::exception:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
#include <iostream> #include <string> #include <exception> // для std::exception class ArrayException: public std::exception { private: std::string m_error; public: ArrayException(std::string error) : m_error(error) { } // Возвращаем std::string в качестве константной строки C-style // const char* what() const { return m_error.c_str(); } // до C++11 const char* what() const noexcept { return m_error.c_str(); } // C++11 и выше }; class ArrayInt { private: int m_data[4]; // чтобы не усложнять, укажем значение 4 в качестве длины массива public: ArrayInt() {} int getLength() { return 4; } int& operator[](const int index) { if (index < 0 || index >= getLength()) throw ArrayException("Invalid index"); return m_data[index]; } }; int main() { ArrayInt array; try { int value = array[7]; } catch (ArrayException &exception) // сначала ловим исключения дочернего класса-исключения { std::cerr << "An array exception occurred (" << exception.what() << ")\n"; } catch (std::exception &exception) { std::cerr << "Some other std::exception occurred (" << exception.what() << ")\n"; } } |
В C++11 к виртуальной функции what() добавили спецификатор noexcept (который означает, что функция обещает не выбрасывать исключения самостоятельно). Следовательно, в C++11 и в более новых версиях наше переопределение метода what() также должно иметь спецификатор noexcept.
Вам решать, хотите ли вы создавать свои собственные классы-исключения, использовать классы-исключения из Стандартной библиотеки С++ или писать классы-исключения, дочерние std::exception. Всё зависит от ваших целей.
переопределить виртуальный константный метод what().
а разве не нужно добавлять virtual к const char* what()… ? виртуальная ведь функция…
virtual имеет смысл только в родительском классе, у дочерних классов добавляют для удобства, чтобы не забыть. Еще в дочерних классах вместо virtual используют override, что позволяет опознать перегруженную функцию.
Не совсем так:
«Модификатор override может использоваться с любым методом, который должен быть переопределением. Достаточно просто указать override в том месте, где обычно указывается const (после скобок с параметрами). Если метод не переопределяет виртуальную функцию родительского класса, то компилятор выдаст ошибку.»
В статье написано:
можно еще писать:
Уф… провел следующее исследование: Код:
ArrayException("Invalid index") является анонимным объектом и по идее (из предыдущих уроков данного туториала) имеет область видимости данной строки.
Но! Смотрим далее:
Что мы видим? Мы ломим НЕконстантную ссылку на анонимный объект! Как такое может быть???
Дописав наш пример и выполнив следующий код мы получим:
Результат:
ArrayException construct:1
An array exception occurred (Invalid index)
ArrayException destruct:1
End of main
Что происходит?
1. Создается анонимный объект throw ArrayException("Invalid index", 1) — вызывается конструктор ArrayException;
2. сatch принимает неконстантную ссылку на объект;
3. Выполняется сatch;
4. Вызывается деструктор.
Из чего делается вывод:
Область видимости ArrayException("Invalid index", 1) почему-то выходит за пределы выражения, даже без объявления константной ссылки. Скорее всего под капотом C++ происходят какие-то совсем сложные вещи.
Исключения не относятся к стандартному потоку кода, поэтому не удаляются при развёртывании стека и могут быть переданы по НЕконстантной ссылке (посмотри диспетчер задач или мониторинг системы Visual Studio, там явно написано, что любая твоя прога запускает два потока).