34

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

例如,这里有一个演示类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 个破坏。结果泄露AA(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?! 如果在编译时不知道资源数量怎么办?!

有没有更好的方法?

4

3 回答 3

39

有没有更好的方法?

是的

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的。所以那部分不会扔。它设置为包含大小为 0 的数组的 *this有效值。知道如何处理该状态。X~X()

现在添加未初始化内存的资源。如果抛出,你仍然有一个默认构造X~X()通过什么都不做来正确处理它。

现在添加第二个资源: 的构造副本x。如果抛出,~X()仍将释放data_缓冲区,但不运行任何~A().

如果第二个资源成功,则X通过递增 which 将其设置为有效状态,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但不包含构造逻辑的实现细节基类。去过那里,做到了,我喜欢委托构造函数。

于 2016-08-05T03:26:01.777 回答
7

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

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

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

于 2016-08-05T07:46:16.580 回答
1

在 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}
    {
    }

    // ...
};
于 2016-08-05T06:47:26.747 回答