如何处理必须以异常安全方式获取多个资源的构造函数

How to handle constructors that must acquire multiple resources in an exception safe manner

我有一个非常重要的类型,拥有 多个资源。我如何以异常安全的方式构造它?

例如,这里有一个演示 class 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;
    // ...
};

现在这个特定的 class 的明显答案是 使用 std::vector<A>。这是个好建议。但是 X 只是更复杂场景的替身,在这些场景中 X 必须拥有多个资源,使用 "use the std::lib." 的好建议并不方便 我选择传达对这个数据结构的质疑只是因为它很熟悉。

crystal 明确:如果您可以设计 X 以便默认 ~X() 正确清理所有内容("the rule of zero"),或者如果 ~X() 只需要释放一个资源,那是最好的。然而,在现实生活中有时 ~X() 必须处理多个资源,而这个问题解决了这些情况。

所以这个类型已经有一个很好的析构函数和一个很好的默认构造函数。我的问题集中在一个不平凡的构造函数上,它接受两个 A,为它们分配 space,并构造它们:

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};
}

我有一个完全检测的测试 class 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 const& a): 1 产生的 A 泄露!

解决此问题的一种方法是在构造函数中添加 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?! 如果资源数量 在编译时未知怎么办?!

更好的方法吗?

Is there a better way?

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().

如果第二个资源成功,通过递增 size_X 设置为有效状态,这是一个 noexcept 操作。如果在此之后抛出任何内容,~X() 将正确清理长度为 1 的缓冲区。

现在尝试第三个资源:y 的构建副本。如果该构造抛出,~X() 将正确清理长度为 1 的缓冲区。如果没有抛出,通知 *this 它现在拥有长度为 2 的缓冲区。

使用此技术 要求X 是默认可构造的。例如,默认构造函数可以是私有的。或者您可以使用其他一些私有构造函数将 X 置于无资源状态:

: X{moved_from_tag{}}

在 C++11 中,如果您的 X 可以具有无资源状态通常是个好主意,因为这使您能够拥有一个 noexcept 移动构造函数,它捆绑了各种善良(并且是不同的主题post)。

C++11 委托构造函数是一种非常好的(可扩展的)技术,用于编写异常安全的构造函数,只要您在开始时有一个无资源状态要构造到(例如 noexcept 默认构造函数)。

是的,在 C++98/03 中有一些方法可以做到这一点,但它们并不那么漂亮。您必须创建 X 的实现细节库 class,其中包含 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}
    {
    }

    // ...
};

我认为问题源于违反单一职责原则:Class X 必须处理管理多个对象的生命周期(这甚至可能不是它的主要职责)。

class 的析构函数应该只释放 class 直接获取的资源。如果 class 只是一个组合(即 class 的一个实例拥有其他 classes 的实例)它应该理想地依赖自动内存管理(通过 RAII)并且只使用默认值析构函数。如果 class 必须手动管理一些专门的资源(例如打开文件描述符或连接、获取锁或分配内存),我建议将管理这些资源的责任分解为 class 专用为此目的,然后使用 class 的实例作为成员。

使用标准模板库实际上会有所帮助,因为它包含专门处理此问题的数据结构(例如智能指针和 std::vector<T>)。它们也可以组合,所以即使你的 X 必须包含具有复杂资源获取策略的多个对象实例,以异常安全的方式解决资源管理问题对于每个成员以及包含复合 classX.