Подтвердить что ты не робот

Как обращаться с конструкторами, которые должны получать несколько ресурсов в безопасном порядке

У меня нетривиальный тип, которому принадлежат несколько ресурсов. Как я могу построить его безопасным образом?

Например, вот демон-класс X, который содержит массив A:

#include "A.h"

class X
{
    unsigned size_ = 0;
    A* data_ = nullptr;

public:
    ~X()
    {
        for (auto p = data_; p < data_ + size_; ++p)
            p->~A();
        ::operator delete(data_);
    }

    X() = default;
    // ...
};

Теперь очевидным ответом для этого конкретного класса является использование std::vector<A>. И этот хороший совет. Но X является только резервной копией для более сложных сценариев, в которых X должен владеть несколькими ресурсами, и нетрудно использовать хороший совет "использовать std:: lib". Я решил передать вопрос с этой структурой данных просто потому, что он знаком.

Чтобы быть кристально понятным: если вы можете создать свой X таким образом, чтобы по умолчанию ~X() правильно очистил все ( "правило нуля" ) или если ~X() должен только освободить один ресурс, тогда это лучше всего. Однако в реальной жизни есть моменты, когда ~X() приходится иметь дело с несколькими ресурсами, и этот вопрос затрагивает эти обстоятельства.

Итак, этот тип уже имеет хороший деструктор и хороший конструктор по умолчанию. Мой вопрос сосредотачивается на нетривиальном конструкторе, который принимает два A 's, выделяет для них пространство и строит их:

X::X(const A& x, const A& y)
    : size_{2}
    , data_{static_cast<A*>(::operator new (size_*sizeof(A)))}
{
    ::new(data_) A{x};
    ::new(data_ + 1) A{y};
}

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

int
main()
{
    A a1{1}, a2{2};
    try
    {
        std::cout << "Begin\n";
        X x{a1, a2};
        std::cout << "End\n";
    }
    catch (...)
    {
        std::cout << "Exceptional End\n";
    }
}

Вывод:

A(int state): 1
A(int state): 2
Begin
A(A const& a): 1
A(A const& a): 2
End
~A(1)
~A(2)
~A(2)
~A(1)

У меня есть 4 конструкции и 4 разрушения, и каждое уничтожение имеет соответствующий конструктор. Все хорошо.

Однако, если конструктор копирования A{2} выдает исключение, я получаю этот вывод:

A(int state): 1
A(int state): 2
Begin
A(A const& a): 1
Exceptional End
~A(2)
~A(1)

Теперь у меня есть 3 конструкции, но только 2 разрушения. A, полученный из A(A const& a): 1, просочился !

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

X(const A& x, const A& y)
    : size_{2}
    , data_{static_cast<A*>(::operator new (size_*sizeof(A)))}
{
    try
    {
        ::new(data_) A{x};
        try
        {
            ::new(data_ + 1) A{y};
        }
        catch (...)
        {
            data_->~A();
            throw;
        }
    }
    catch (...)
    {
        ::operator delete(data_);
        throw;
    }
}

Это правильно выводит:

A(int state): 1
A(int state): 2
Begin
A(A const& a): 1
~A(1)
Exceptional End
~A(2)
~A(1)

Но это уродливо! Что делать, если есть 4 ресурса? Или 400?! Что делать, если количество ресурсов неизвестно во время компиляции?!

Есть ли лучший способ?

4b9b3361

Ответ 1

Есть ли лучший способ?

Да

С++ 11 предоставляет новую функцию, называемую делегирование конструкторов, которая очень хорошо справляется с этой ситуацией. Но это немного тонко.

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

Ключевым моментом здесь является полное построение X, прежде чем вы начнете добавлять к нему ресурсы, а затем добавляйте ресурсы по одному, сохраняя X в допустимом состоянии при добавлении каждого ресурса. После того, как X будет полностью построена, ~X() очистит любой беспорядок при добавлении ресурсов. До С++ 11 это может выглядеть так:

X x;  // no resources
x.push_back(A(1));  // add a resource
x.push_back(A(2));  // add a resource
// ...

Но в С++ 11 вы можете написать конструктор multi-resource-acquizition следующим образом:

X(const A& x, const A& y)
    : X{}
{
    data_ = static_cast<A*>(::operator new (2*sizeof(A)));
    ::new(data_) A{x};
    ++size_;
    ::new(data_ + 1) A{y};
    ++size_;
}

Это очень похоже на то, что код полностью игнорирует безопасность исключений. Разница заключается в этой строке:

    : X{}

Это говорит: Постройте меня по умолчанию X. После этой конструкции *this полностью построена, и если в последующих операциях выбрано исключение, ~X() запускается. Это революционно!

Обратите внимание, что в этом случае построенный по умолчанию X не получает ресурсов. Действительно, это даже неявно noexcept. Так что эта часть не будет бросать. И он устанавливает *this в действительный X, который содержит массив размером 0. ~X() знает, как справиться с этим состоянием.

Теперь добавьте ресурс неинициализированной памяти. Если это выбрано, у вас все еще есть построенный по умолчанию X и ~X(), который правильно справляется с этим, ничего не делая.

Теперь добавьте второй ресурс: построенная копия X. Если это выбрано, ~X() все равно освободит буфер data_, но без запуска ~A().

Если второй ресурс завершается успешно, установите X в допустимое состояние, увеличив size_, что является операцией noexcept. Если что-то после этого бросает, ~X() будет правильно очищать буфер длиной 1.

Теперь попробуйте третий ресурс: построенная копия y. Если эта конструкция выбрасывает, ~X() будет правильно очищать ваш буфер длиной 1. Если он не выбрасывает, сообщите *this, что теперь он владеет буфером длины 2.

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

: X{moved_from_tag{}}

В С++ 11, как правило, хорошая идея, если ваш X может иметь resourceless состояние, поскольку это позволяет вам иметь конструктор перемещения noexcept, который поставляется в комплекте со всеми видами добра (и является предметом другой должности).

Конструкторы делегирования С++ 11 - очень хороший (масштабируемый) метод для написания конструкторов исключаемых безопасностей, если у вас есть состояние без ресурсов, которое нужно построить в начале (например, конструктор noexcept по умолчанию).

Да, есть способы сделать это в С++ 98/03, но они не такие красивые. Вам необходимо создать базовый класс элемента реализации X, который содержит логику уничтожения X, но не строительную логику. Был там, сделал это, мне нравится делегировать конструкторы.

Ответ 2

Я думаю, что проблема связана с нарушением принципа единой ответственности: класс X должен иметь дело с управлением временем жизни нескольких объектов (и это, вероятно, даже не его основная ответственность).

Деструктор класса должен освобождать только те ресурсы, которые класс получил напрямую. Если класс является просто составным (то есть экземпляр класса владеет экземплярами других классов), он в идеале должен полагаться на автоматическое управление памятью (через RAII) и просто использовать деструктор по умолчанию. Если класс должен управлять некоторыми специализированными ресурсами вручную (например, открывает дескриптор файла или соединение, получает блокировку или выделяет память), я бы рекомендовал выделить ответственность за управление этими ресурсами классу, выделенному для этой цели, а затем использовать экземпляры этот класс в качестве членов.

Использование стандартной библиотеки шаблонов действительно помогло бы, потому что она содержит структуры данных (такие как интеллектуальные указатели и std::vector<T>), которые исключительно решают эту проблему. Они также являются выполнимыми, поэтому даже если ваш X должен содержать несколько экземпляров объектов со сложными стратегиями получения ресурсов, проблема безопасного управления ресурсами решается как для каждого члена, так и для содержащего составного класса X.

Ответ 3

В С++ 11, возможно, попробуйте что-то вроде этого:

#include "A.h"
#include <vector>

class X
{
    std::vector<A> data_;

public:
    X() = default;

    X(const A& x, const A& y)
        : data_{x, y}
    {
    }

    // ...
};