Чистый С++: Как правильно разрушать

в 10:50, , рубрики: c++, Блог компании Intel, общение, Программирование, учебник, метки: , ,

Чистый С++: Как правильно разрушать
Добрый день, Серега вновь добрался до клавиатуры и рассуждает о C++. Сегодня поговорим о том, зачем еще в C++ нужны классы, как работают деструкторы и на какие еще грабли можно наступить, если смешать два языка. Под катом ничего нового и выдающегося для тех, кто знает C++ еще со времен ДОСа. Если же вы еще только изучаете этот язык — добро пожаловать.

Как вы, наверное, заметили в прошлый раз, у класса есть очень важная вещь — деструктор! Эта функция будет вызвана ВСЕГДА, когда класс разрушается. Не важно что произошло, выход из функции посередине или даже выброшенное исключение, деструктор класса будет вызван в любом случае, пока программа работает. (Для справки, программа уже не работает, если исключение никто не ловит, поэтому в main нужно ставить try...catch на все типы исключений.). На C деструктор вызывается вручную, что заставляет писать много лишних подробностей.

if(!Create1(...))
    return -1;
if(!Create2(...))
{
    Destroy1(...);
    return -1;
}
…
if(!CreateN(...))
{
    Destroy1(...);
    Destroy2(...);
    ...
    DestroyN-1(...);
    return -1;
}

А дополнительные ветвления и циклы лишь добавляют путаницы и громоздкости в коде.
Деструктор не заменяется секцией finalize, доступной в других языках! Дело в том, что он вызывается только у уже созданных объектов, а разобраться в том, кого уже создали, а кого нет в finalize возможно далеко не всегда. Приходится городить вложенные блоки и множественные секции финализации, что также делает код излишне запутанным. Вот хороший пример подобной ситуации:

#include <iostream>
#include <stdexcept>

class Foo
{
private:
    static int sm_count;
    int instance;
public:
    Foo()
    {
        instance = ++sm_count;
        if(instance > 5)
            throw std::runtime_error("Нельзя создавать больше 5ти объектов.");
        std::cout << "Экземпляр № " << instance << " класса Foo создан.n";

    }
    ~Foo()
    {
        std::cout << "Экземпляр № " << instance << " класса Foo разрушен.n";
    }
};

int Foo::sm_count = 0;

int main()
{
    try
    {
        Foo* pFoo = new Foo[10];
    }
    catch(const std::exception& e)
    {
        std::cout << e.what() << "n";
    }
    return 0;
}

Грамотное и повсеместное использование деструкторов приближает C++ по стилю программирования к языкам со сборщиком мусора. При таком подходе программист занят построением модели, а не выслеживанием симметричных выделений и освобождений ресурсов. Однако такое сближение сильно обманчиво и часто заканчивается обращением к удаленному с кучи объекту и аварийным выходом из программы. А если при этом еще активно пользоваться конструкциями языка C (упомянутый в прошлый раз «Ц с классами»), то однажды гарантированно произойдет обращение к данным объекта одного типа через указатель на другой тип. Вот очень хороший пример (делить на заголовочные файлы не обязательно, но полезно, чтобы было легко менять порядок их включения):

// HeaderFile1

class Interface1
{
public:
    virtual void SomeFunc(int a) = 0;
};

class Interface2
{
public:
    virtual void AnotherFunc(double b) = 0;
};

// HeaderFile2

class Implementation;

class Storage
{
private:
    Implementation* m_pImpl;
public:
    Storage(Implementation* in_pImpl)
        : m_pImpl(in_pImpl)
    {}

    Interface1* GetInterface1()
    {
        return (Interface1*)m_pImpl;
    }
    Interface2* GetInterface2()
    {
        return (Interface2*)m_pImpl;
    }
};

// HeaderFile3

#include <iostream>

class ImplementationBase
{
protected:
    virtual void BaseFunc()
    {
        std::cout << "BaseFunc была выполнена.n";
    }
};

class Implementation
    : private ImplementationBase
    , public Interface1
    , public Interface2
{
public:
    virtual void SomeFunc(int a)
    {
        std::cout << "SomeFunc была выполнена с аргументом a = " << a << ".n";
    }

    virtual void AnotherFunc(double b)
    {
        std::cout << "AnotherFunc была выполнена с аргументом b = " << b << ".n";
    }
};

// C++ source file
// #include <HeaderFile1>
// #include <HeaderFile2>
// #include <HeaderFile3>

int main()
{
    Implementation impl;
    Storage storage(&impl);
    storage.GetInterface1()->SomeFunc(42);
    storage.GetInterface2()->AnotherFunc(37.7);
    return 0;
}

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

Вернемся к деструкторам. При их написании главное не бросить случайно исключение. Дело в том, что деструктор может быть вызван именно в процессе обработки исключения. В этом случае, процесс размотки стека процедуры размотки стека заканчивается самым простым и ожидаемым образом: немедленным выходом из программы. Так что внимательно за ними следите. Ни в коем случае не выделяйте в них память, ресурсы и уж тем более не бросайте исключений явно. Это требование диктует определенное отношение ко всяким «закрывающим» функциям. Например, какой нибудь Socket::Close() более не может сделать throw std::runtime_error("Close socket error: socket was not opened"). Дело в том, что самое логичное, что можно сделать с «закрывающей» функцией — вызвать ее в деструкторе. А вот обкладывать этот вызов различными проверками условий — совсем даже не логично. И если вы работаете в коллективе, то кто-то обязательно постарается закрыть уже закрытое. Так-что запишите себе куда нибудь простое правило: в любой «закрывающей» функции нужно тихо и без лишних телодвижений делать именно то, что от нее требуется — «закрыть» то, что просят, даже если это физически невозможно.
Еще раз обращаю внимание на то, что нельзя в таких функциях и деструкторах ничего выделять или захватывать. Очень распространенная ошибка:

~object::object()
{
    g_logger.put_message(std::string("Object ") + m_name + std::string(" was deleted."));
}

Если этот деструктор будет вызван в процессе обработки исключения вида «кончилась память», то вы долго будете искать причину вылета программы. При этом даже упомянутый тут логгер не поможет, т.к. не сумеет сформировать и сохранить так нужное сообщение. Как-же быть в такой ситуации? Самый простой способ, но не самый «красивый» — сгенерировать сообщение заранее, когда было все хорошо, и держать его в приватной части до поры до времени. Более «продвинутый» вариант:

~object::object()
{
    g_logger << "Object " << m_name << " was deleted.";
}

Надеюсь понятно, что g_logger здесь не имеет права заниматься выделениями памяти и открытиями файлов, а обязан иметь наготове буффер фиксированного размера и сливать его в заранее открытый файл по заполнению?

Плавно перейдем к выделению ресурсов. Правильный подход — сначала выделить, а затем использовать. Вот неправильный пример:


std::vector<int> items;
...
items.push_back(item1);
items.push_back(item2);
Правильно делать так:
std::vector<int> items;
...
items.reserve(items.size() + 2);
items.push_back(item1);
items.push_back(item2);

Нужно постоянно помнить о том, что память может кончится в самый неподходящий момент, и состояние программы в такой момент обязано оставаться определенным. Если обязано быть два элемента в массиве — значит либо два целых, либо ни одного. Никаких «недосозданных» структур данных быть не должно, т.к. это прямой путь к сбою при работе деструкторов. Хорошая программа — это такая, которая даже при нехватке памяти корректно сохраняет свои данные и тихо выходит. Ну, может быть не тихо, а вежливо попрощавшись. К сожалению, это не всегда так легко достижимо. Например, std::list не имеет метода reserve. Для таких случаев приходится заводить «пустое» состояние элемента данных, вроде null_ptr для указателей или -1 для индексов, и класть его сначала в структуру данных. А в деструкторе аккуратно обходить такие элементы. Здесь уместно вспомнить про увлечение всякими операторами, создающими на стеке временные объекты. Эти объекты, в свою очередь, выделяют ресурсы, которые не выделяются, а бросают исключение прямо посередине сложного выражения, оставляя части данного выражения в полувычесленном состоянии. Например итератор, сдвигаемый оператором ++ в середине выражения будет абсолютно бесполезен в секции catch.

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


typedef std::pair<int, int> DataItem;
std::vector<DataItem> items;
...
items.push_back(DataItem(item1, item2));

Однако это уже не такая простая тема моделирования предметной области поставленной задачи.

Автор: Softogen

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js