Ночные эксперименты с компилятором.
Исключения вообще и в конструкторе в частности.
В прошлой статье мы затронули тему исключений в конструкторе. Затронули вскользь. И сегодня попробуем разобрать подробнее.
Исключения. Зачем они нужны? Вообще, это способ сообщить о какой-то нестандартной ситуации. Т.е. ваш код делает какое-то действие, которое заканчивается не так, как вы ожидаете. Например, пытаетесь прочитать файл, а он может быть уже удален или перемещен на новое место.
Раньше, еще в языке Си, были result-коды. Функция могла сообщить о результате действий некоторым возвращенным значением, результатом. Сюда же можно отнести и установку некоторых глобальных переменных. Опрос результата работы функции и проверка глобальных переменных более менее давала понять что же такого нештатного там произошло.
У этого подхода есть один серьезный недостаток. Код обработки ошибок находится вперемешку с основным кодом программы. И глобальные переменные нужно проверять сразу или значения в них перепишутся.
Чтобы отделить зерна от плевел и разнести основной код программы и код обработки ошибок по разным местам (они могут быть даже в разных файлах) придумали исключения.
Исключение — это некоторый объект. Может быть любого типа. Даже вашего собственного. В этом еще один плюс. Вы можете создать свой собственный класс исключений и описать ошибку так, как хотите вы.
Этот объект всплывает вверх по стеку вызовов функций, пока вы его где-нибудь не поймаете. Т.е. если в вашей программе func1() вызывает func2(), которая в свою очередь вызывает func3(), где создается объект исключения, тогда если вы не поймаете этот объект в func3, то он всплывет дальше по стеку в func2, если его не поймают и там, то он всплывет еще дальше в func1() и т.д. и т.п. Вплоть до функции main(). Если его не поймают и там, то операционная система завершит вашу программу с ошибкой.
Это считается как плюсом, так и минусом исключений, что их можно ловить в совершенно разных местах кода. Иногда даже неожиданных, связанных с совсем другой обработкой, других ситуаций, не предполагающих приход именно этого типа исключений.
Исключения в конструкторе.
Однако вернемся к нашей маленькой теме. Почему опасны исключения в конструкторе. И что значит, частичное создание объекта.
Давайте посмотрим на следующий код. Осторожно, он с намеренной ошибкой, которую мы сейчас разберем. Итак:
#include <iostream>
using namespace std;
class Sample {
public:
Sample() {
cout << "Sample construct" << endl;
}
~Sample() {
cout << "Sample delete" << endl;
}
};
class ConstructorExeption {
public:
ConstructorExeption() {
p = new Sample();
cout << "Construct" << endl;
throw 10;
}
~ConstructorExeption() {
delete p;
cout << "Delete" << endl;
}
private:
Sample *p;
};
int main() {
try {
ConstructorExeption ce;
} catch (int e) {
cout << "An exeption fired" << endl;
}
}
Помните, о чем мы говорили в прошлый раз? Когда мы пишем new, мы берем на себя ответственность, что вернем память операционной системе через delete.
В этом коде, казалось бы, все нормально. В конструкторе мы создаем объект Sample и в деструкторе его удаляем. Но все так безоблачно только на первый взгляд!
В конструкторе, после создания объекта Sample, происходит исключение. И получается, что объект ConstructorExeption создан лишь частично, неполностью. С точки зрения компилятора, он не создан вообще и деструктор к нему не вызовется. А это значит, что созданный нами Sample остается бесхозным. Мы его мало что не удалили, так еще и потеряли на него указатель. Мы бы может и хотели бы его удалить, но теперь вообще не сможем, так как просто не знаем, забыли, где он лежит в памяти.
Эта проблема называется утечкой памяти. И ладно бы хоть только памяти. Таким же образом могут утекать и более ценные ресурсы. Например, сокеты или файловые дескрипторы. Но круче всего, наверное, потерять заблокированный мьютекс (mutex). Заблокировать его и потерять. Круче некуда! =)
Как же выйти из этого положения? Один из подходов мы описали в прошлый раз. Это не делать сложные инициализации в конструкторе. Для этого пишется метод init(), где и происходит основная инициализация. Да и в целом не допускать исключений в конструкторе, пусть они происходят в другом методе, например init(). Так поступали в старом С++, до введения умных указателей.
Как нам могут помочь умные указатели вы наверное уже догадались. Сравните следующий код с первым вариантом:
#include <iostream>
#include <memory>
using namespace std;
class Sample {
public:
Sample() {
cout << "Sample construct" << endl;
}
~Sample() {
cout << "Sample delete" << endl;
}
};
class ConstructorExeption {
public:
ConstructorExeption() {
p = make_unique<Sample>();
cout << "Construct" << endl;
throw 10;
}
~ConstructorExeption() {
cout << "Delete" << endl;
}
private:
unique_ptr<Sample> p;
};
int main() {
try {
ConstructorExeption ce;
} catch (int e) {
cout << "An exeption fired" << endl;
}
}
Здесь все нормально. После исключения в конструкторе, зачищаются остатки частично созданного ConstructorExeption. Умный указатель на объект Sample теряет своего владельца. И удаляет Sample, чтобы он не стал бесхозным. Память возвращается системе, все счастливы и довольны.
В этой статье мы поговорили об исключениях. Коротко рассмотрели что такое исключения, зачем они нужны. Какие подходы к обработке ошибок были до введения исключений. Более подробно рассмотрели ситуацию с исключением в конструкторе и частичным созданием объекта.