Ночные эксперименты с компилятором.

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

В прошлой статье мы затронули тему исключений в конструкторе. Затронули вскользь. И сегодня попробуем разобрать подробнее.

Исключения. Зачем они нужны? Вообще, это способ сообщить о какой-то нестандартной ситуации. Т.е. ваш код делает какое-то действие, которое заканчивается не так, как вы ожидаете. Например, пытаетесь прочитать файл, а он может быть уже удален или перемещен на новое место.

Раньше, еще в языке Си, были 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, чтобы он не стал бесхозным. Память возвращается системе, все счастливы и довольны.

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

Оставьте комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *