如何处理 RAII 的构造函数失败

How to handle constructor failure for RAII

本文关键字:RAII 构造函数 失败 处理 何处理      更新时间:2023-10-16

我熟悉 RAII 的优点,但我最近在代码中绊倒了一个问题,如下所示:

class Foo
{
  public:
  Foo()
  {
    DoSomething();
    ...     
  }
  ~Foo()
  {
    UndoSomething();
  } 
} 

一切都很好,除了构造函数...部分中的代码抛出异常,结果UndoSomething()从未被调用。

有一些明显的方法可以解决该特定问题,例如将...包装在 try/catch 块中,然后调用 UndoSomething() ,但 a:这是重复代码,b:try/catch 块是我尝试使用 RAII 技术避免的代码异味。而且,如果涉及多个 Do/Undo 对,代码可能会变得更糟且更容易出错,我们必须中途清理。

我想知道有更好的方法来做到这一点 - 也许一个单独的对象接受一个函数指针,并在它反过来被破坏时调用该函数?

class Bar 
{
  FuncPtr f;
  Bar() : f(NULL)
  {
  }
  ~Bar()
  {
    if (f != NULL)
      f();
  }
}   

我知道这不会编译,但它应该显示原理。然后,Foo变成了...

class Foo
{
  Bar b;
  Foo()
  {
    DoSomething();
    b.f = UndoSomething; 
    ...     
  }
}

请注意,foo 现在不需要析构函数。这听起来像是比它的价值更麻烦,还是这已经是一种常见的模式,在 boost 中有一些有用的东西来为我处理繁重的工作?

问题是你的类试图做太多。RAII 的原则是它获取资源(在构造函数中或以后),析构函数释放它;该类的存在只是为了管理该资源。

在您的情况下,除DoSomething()UndoSomething()之外的任何内容都应该是类用户的责任,而不是类本身。

正如Steve Jessop在评论中所说:如果你有多个资源要获取,那么每个资源都应该由自己的RAII对象管理;将这些资源聚合为另一个类的数据成员可能是有意义的,该类依次构造每个资源。然后,如果任何获取失败,所有以前获取的资源将由各个类成员的析构函数自动释放。

(另外,请记住三法则;您的类需要防止复制,或者以某种合理的方式实现它,以防止多次调用UndoSomething())。

只需将DoSomething/UndoSomething变成正确的RAII句柄:

struct SomethingHandle
{
  SomethingHandle()
  {
    DoSomething();
    // nothing else. Now the constructor is exception safe
  }
  SomethingHandle(SomethingHandle const&) = delete; // rule of three
  ~SomethingHandle()
  {
    UndoSomething();
  } 
} 

class Foo
{
  SomethingHandle something;
  public:
  Foo() : something() {  // all for free
      // rest of the code
  }
} 

我也将使用RAII解决此问题:

class Doer
{
  Doer()
  { DoSomething(); }
  ~Doer()
  { UndoSomething(); }
};
class Foo
{
  Doer doer;
public:
  Foo()
  {
    ...
  }
};

执行者是在 ctor 主体启动之前创建的,当析构函数通过异常失败或对象正常销毁时,该操作者会被销毁。

你的一个类中有太多的东西。 将 DoSomething/UndoSomething 移动到另一个类("Something"),并将该类的对象作为类 Foo 的一部分,因此:

class Foo
{
  public:
  Foo()
  {
    ...     
  }
  ~Foo()
  {
  } 
  private:
  class Something {
    Something() { DoSomething(); }
    ~Something() { UndoSomething(); }
  };
  Something s;
} 

现在,在调用 Foo 的构造函数时已经调用了 DoSomething,如果 Foo 的构造函数抛出,那么 UndoSomething 就会被正确调用。

try/catch通常不是代码异味,它应该用于处理错误。 但是,在您的情况下,这将是代码异味,因为它不是处理错误,只是清理。 这就是析构函数的用途。

(1) 如果在构造函数失败时应调用析构函数中的所有内容,只需将其移动到私有清理函数,该函数由析构函数调用,并在失败时由构造函数调用。 这似乎是你已经做过的事情。 干得好。

(2)一个更好的想法是:如果有多个可以单独破坏的执行/撤消对,则应将它们包装在自己的小RAII类中,该类执行小型任务,并在自身清理之后进行清理。我不喜欢您目前给它一个可选的清理指针功能的想法,这令人困惑。 清理应始终与初始化配对,这是 RAII 的核心概念。

经验法则:

  • 如果您的类手动管理某些内容的创建和删除,则它做得太多了。
  • 如果你的类手动编写了复制作业/构造,它可能管理太多了
  • 例外情况:唯一目的是管理一个实体的类

第三条规则的例子是std::shared_ptrstd::unique_ptrscope_guardstd::vector<>std::list<>scoped_lock,当然还有下面的Trasher类。


补遗。

你可以走得更远,写一些东西来与C风格的东西进行交互:

#include <functional>
#include <iostream>
#include <stdexcept>

class Trasher {
public:
    Trasher (std::function<void()> init, std::function<void()> deleter)
    : deleter_(deleter)
    {
        init();
    }
    ~Trasher ()
    {
        deleter_();
    }
    // non-copyable
    Trasher& operator= (Trasher const&) = delete;
    Trasher (Trasher const&) = delete;
private:
    std::function<void()> deleter_;
};
class Foo {
public:
    Foo ()
    : meh_([](){std::cout << "hello!" << std::endl;},
           [](){std::cout << "bye!"   << std::endl;})
    , moo_([](){std::cout << "be or not" << std::endl;},
           [](){std::cout << "is the question"   << std::endl;})
    {
        std::cout << "Fooborn." << std::endl;
        throw std::runtime_error("oh oh");
    }
    ~Foo() {
        std::cout << "Foo in agony." << std::endl;
    }
private:
    Trasher meh_, moo_;
};
int main () {
    try {
        Foo foo;
    } catch(std::exception &e) {
        std::cerr << "error:" << e.what() << std::endl;
    }
}

输出:

hello!
be or not
Fooborn.
is the question
bye!
error:oh oh

因此,~Foo()永远不会运行,但您的初始化/删除对会运行。

一件好事是:如果你的 init 函数本身抛出,你的删除函数不会被调用,因为 init 函数抛出的任何异常都会直接通过Trasher(),因此~Trasher()不会被执行。

注意:重要的是有一个最外层的try/catch,否则,标准不要求堆叠放卷。