异常安全 - 用于可靠回滚对象状态的模式

Exception safety - patterns for reliably rolling back object state

本文关键字:对象 状态 模式 安全 用于 异常      更新时间:2023-10-16

我一直在思考如何实现各种异常安全保证,尤其是保证,即发生异常时数据回滚到原始状态。

考虑以下精心设计的示例(C++11 代码)。假设有一个简单的数据结构存储一些值

struct Data
{
  int value = 321;
};

并且某些函数modify()对该值进行操作

void modify(Data& data, int newValue, bool throwExc = false)
{
  data.value = newValue;
  if(throwExc)
  {
    // some exception occurs, sentry will roll-back stuff
    throw std::exception();
  }
}

(可以看出这是多么做作)。假设我们想为modify()提供强大的异常安全保证。在异常的情况下,Data::value的值显然不会回滚到其原始值。人们可以天真地继续try整个功能,在适当的catch块中手动设置内容,这是非常乏味的,并且根本不可扩展。

另一种方法是使用一些作用域RAII帮助程序 - 有点像哨兵,它知道在发生错误时要临时保存和恢复什么:

struct FakeSentry
{
  FakeSentry(Data& data) : data_(data), value_(data_.value)
  {
  }
  ~FakeSentry()
  {
    if(!accepted_)
    {
      // roll-back if accept() wasn't called
      data_.value = value_;
    }
  }
  void accept()
  {
    accepted_ = true;
  }
  Data& data_ ;
  int   value_;
  bool accepted_ = false;
};

该应用程序很简单,只需要在modify()成功的情况下调用accept()

void modify(Data& data, int newValue, bool throwExc = false)
{
  FakeSentry sentry(data);
  data.value = newValue;
  if(throwExc)
  {
    // some exception occurs, sentry will roll-back stuff
    throw std::exception();
  }
  // prevent rollback
  sentry.accept();
}

这样可以完成工作,但也不能很好地扩展。每个不同的用户定义类型都需要有一个哨兵,知道该类型的所有内部结构。

我现在的问题是:在尝试实现强异常安全代码时,会想到哪些其他模式、习语或首选行动方案?

一般来说,它被称为ScopeGuard习语。并不总是可以使用临时变量和交换来提交(尽管在可以接受时很容易) - 有时您需要修改现有结构。

Andrei Alexandrescu和Petru Marginean在下面的论文中详细讨论了它:"通用:改变你编写异常安全代码的方式 - 永远"。


有Boost.ScopeExit库,它允许在不编码辅助类的情况下制作保护代码。文档中的示例:

void world::add_person(person const& a_person) {
    bool commit = false;
    persons_.push_back(a_person);           // (1) direct action
    // Following block is executed when the enclosing scope exits.
    BOOST_SCOPE_EXIT(&commit, &persons_) {
        if(!commit) persons_.pop_back();    // (2) rollback action
    } BOOST_SCOPE_EXIT_END
    // ...                                  // (3) other operations
    commit = true;                          // (4) disable rollback actions
}

D编程语言在语言中具有特殊的结构,用于此目的 - scope(failure)

Transaction abc()
{
    Foo f;
    Bar b;
    f = dofoo();
    scope(failure) dofoo_undo(f);
    b = dobar();
    return Transaction(f, b);
}:

Andrei Alexandrescu在他的演讲中展示了这种语言结构的优势:"D的三个不太可能的成功特征"


我已经对scope(failure)适用于MSVCGCCClagIntel编译器的功能进行了依赖于平台的实现。它在图书馆:stack_unwinding。在C++11中,它允许实现非常接近D语言的语法。这是在线演示

int main()
{
    using namespace std;
    {
        cout << "success case:" << endl;
        scope(exit)
        {
            cout << "exit" << endl;
        };
        scope(success)
        {
            cout << "success" << endl;
        };
        scope(failure)
        {
            cout << "failure" << endl;
        };
    }
    cout << string(16,'_') << endl;
    try
    {
        cout << "failure case:" << endl;
        scope(exit)
        {
            cout << "exit" << endl;
        };
        scope(success)
        {
            cout << "success" << endl;
        };
        scope(failure)
        {
            cout << "failure" << endl;
        };
        throw 1;
    }
    catch(int){}
}

输出为:

success case:
success
exit
________________
failure case:
failure
exit

通常的方法不是在出现异常时回滚,而是在没有异常的情况下提交。这意味着,首先以一种不一定会改变程序状态的方式做关键的事情,然后通过一系列非抛出操作来提交。

然后,您的示例将如下所示:

void modify(Data& data, int newValue, bool throwExc = false)
{ 
  //first try the critical part
  if(throwExc)
  {
    // some exception occurs, sentry will roll-back stuff
    throw std::exception();
  }
  //then non-throwing commit
  data.value = newValue;
}

当然,RAII在异常安全方面发挥着重要作用,但它并不是唯一的解决方案。
"try-and-commit"的另一个例子是copy-swap-idiomit:

X& operator=(X const& other) {
  X tmp(other);    //copy-construct, might throw
  tmp.swap(*this); //swap is a no-throw operation
}

如您所见,这有时会以额外的操作为代价(例如,如果 C 的复制 ctor 分配内存),但这是您必须为异常安全付出的代价。

我在最后面对案件时发现了这个问题。

如果您想在不使用复制和交换的情况下确保提交或回滚语义,我建议您为所有对象提供代理并一致地使用代理

这个想法是隐藏实现细节,并将对数据的操作限制为可以有效回滚的子集。

因此,使用数据结构的代码将是这样的:

void modify(Data&data) {
   CoRProxy proxy(data);
   // Only modify data through proxy - DO NOT USE data
   ... foo(proxy);
   ...
   proxy.commit(); // If we don't reach this point data will be rolled back
}
struct Data {
  int value;
  MyBigDataStructure value2; // Expensive to copy
};
struct CoRProxy {
  int& value;
  const MyBigDataStructure& value2; // Read-only access
  void commit() {m_commit=true;}
  CoRProxy(data&d):value(d.value),value2(d.value2),
      m_commit(false),m_origValue(d.value){;}
  ~CoRProxy() {if (!m_commit) std::swap(m_origValue,value);}
private:
  bool m_commit;
  int m_origValue;
};

重点是代理将接口限制为data proxy可以回滚的操作,并且(可选)提供对其余data的只读访问权限。如果我们真的想确保没有直接访问data我们可以将proxy发送到新函数(或使用 lambda)。

一个类似的用例是使用向量并在发生故障时回滚push_back。

template <class T> struct CoRVectorPushBack {
   void push_back(const T&t) {m_value.push_back(t);}
   void commit() {m_commit=true;}
   CoRVectorPushBack(std::vector<T>&data):
    m_value(data),m_origSize(data.size()),m_commit(false){;}
   ~CoRVectorPushBack() {if (!m_commit) value.resize(m_origSize);}
private:
   std::vector<T>&m_value;
   size_t m_origSize;
   bool m_commit;
};

这样做的缺点是需要为每个操作创建一个单独的类。好处是使用代理的代码简单明了且安全(我们甚至可以在push_back中添加if (m_commit) throw std::logic_error();)。