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

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

提问人:Howard Hinnant 提问时间:8/5/2016 最后编辑:TemplateRexHoward Hinnant 更新时间:2/10/2020 访问量:3734

问:

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

例如,下面是一个演示类,其中包含一个数组:XA

#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>。这是一个很好的建议。但这只是必须拥有多个资源的更复杂场景的替代品,并且使用“使用 std::lib”的好建议并不方便。我选择用这种数据结构来传达这个问题,仅仅是因为它很熟悉。XX

需要明确的是:如果你能设计出一个默认的正确清理所有内容(“零规则”),或者只需要释放一个资源,那么这是最好的。然而,在现实生活中,有时必须处理多种资源,这个问题解决了这些情况。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/catchtry/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?!如果在编译时不知道资源数量怎么办?!

没有更好的方法?

C ++11 异常 C++-FAQ 委托构造函数

评论

5赞 Howard Hinnant 8/5/2016
@EJP:你错过了这篇文章的全部内容。有了这种精确的数据结构,即使是荒谬的。此示例是需要在构造函数中获取多个资源的更复杂类型的简单示例。如果您需要完全按照我上面显示的操作,请不要使用此代码,或者 .使用 vector。new/deletenew/delete
8赞 KABoissonneault 8/5/2016
我的一般准则是,不要有具有多重职责的对象。即使是动态分配的内存也是一项责任,可以委派给 。一个对象应该具有单一的职责,或者对简单对象的“产品”形成一个单一的抽象。在您的例子中,您可以轻松地创建内部类,其中有自己的析构函数来处理其独特的职责。std::unique_ptrX
1赞 Howard Hinnant 8/5/2016
@KABoissonneault:这是一个很好的指导方针,也是我经常使用的指导方针。但生活可能会变得复杂。这篇文章是关于一种被低估的处理这种并发症的技术。
2赞 M.M 8/5/2016
使用一个类来管理每个资源,然后为类提供此类类类型的多个成员。
1赞 Victor Savu 8/5/2016
@HowardHinnant 即使将资源管理责任与 X 类隔离开来并非易事,但它仍然是主要问题,并且很可能值得在 Stack Overflow 上发表文章。用更糟糕的设计来掩盖它(例如,声明一个默认构造函数,它可能没有任何有效的含义,只是为了确保析构函数被调用)不是一个解决方案。

答:

41赞 Howard Hinnant 8/5/2016 #1

没有更好的方法?

是的

C++11 提供了一个名为委托构造函数的新功能,它非常优雅地处理这种情况。但这有点微妙。

在构造函数中抛出异常的问题在于,要意识到要构造的对象的析构函数在构造函数完成之前不会运行。尽管子对象(基和成员)的析构函数将在抛出异常时运行,但只要这些子对象被完全构造。

这里的关键是在开始向其添加资源之前完全构造,然后一次添加一个资源,在添加每个资源时保持有效状态。一旦完全构建,将在您添加资源时清理任何混乱。在 C++11 之前,这可能如下所示:XXX~X()

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

请注意,在这种情况下,默认构造不会获取任何资源。事实上,它甚至是含蓄的.所以那部分不会扔掉。它设置为保存大小为 0 的数组的有效值。 知道如何应对这种状态。Xnoexcept*thisX~X()

现在添加未初始化内存的资源。如果抛出,您仍然会构建一个默认值,并通过不执行任何操作来正确处理它。X~X()

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

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

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

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

: X{moved_from_tag{}}

在 C++11 中,如果你可以有一个无资源的状态,这通常是一个好主意,因为这使您能够拥有一个与各种优点捆绑在一起的移动构造函数(并且是另一篇文章的主题)。Xnoexcept

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

是的,在 C++98/03 中有一些方法可以做到这一点,但它们并不那么漂亮。您必须创建一个 的 implementation-detail 基类,该基类包含 的销毁逻辑,但不包含构造逻辑。去过那里,做过,我喜欢委派构造函数。XX

评论

0赞 Victor Savu 8/5/2016
哎呀,我刚刚意识到我在向唱诗班讲道:)是否可以更新您的问题,使问题更加明显?这个问题可以通过遵循单一责任原则,用 C++ 98/03 以一种优雅的方式解决(参见下面的答案)。恐怕在这种情况下,委派构造函数有助于减轻痛苦,以至于它们最终隐藏了主要问题:解决方案架构。
0赞 Victor Savu 8/5/2016
此解决方案与data_ 没有什么不同,使用 ,而不是手动分配,而不是放置 new 和 。这在没有委托构造函数的情况下工作,甚至根本不需要 X 的默认构造函数。(即使不需要 c++11 也可以工作,但我个人并不关心这一点,因为为什么不使用 c++14,因为它现在已经是 2016 年了)因此,stackoverflow.com/a/38782850/1660116 这里的解决方案与你的解决方案相同,只是更易于维护和灵活。std::vector<A>data_.reserve(2)data_.push_back(...)++size
1赞 Quentin 8/5/2016
我仍然不相信这比几个类每个类处理一个资源更好,但这是对委托构造函数(及其相应的析构函数)的巧妙劫持。我相信你确实存在这是唯一解决方案的情况。
0赞 Howard Hinnant 8/10/2016
感谢Francesco Giacomini(stackoverflow.com/users/4377355/francesco-giacomini)在这个答案中发现了一个o型问题,并试图修复它。
2赞 Remy Lebeau 8/5/2016 #2

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

    // ...
};
7赞 Victor Savu 8/5/2016 #3

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

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

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