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

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

Например, вот демо-класс 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, выделяет для них пространство и конструирует их:

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?! Что если количество ресурсов неизвестно во время компиляции?!

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

3 ответа

Решение

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

ДА

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

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

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

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

Но в C++11 вы можете написать конструктор множественных ресурсов следующим образом:

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 в безресурсное состояние:

: X{moved_from_tag{}}

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

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

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

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

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

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

В C++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}
    {
    }

    // ...
};
Другие вопросы по тегам