C++ 中的异常安全

C++
2020年3月21日 07:39

最近看了一篇文章,文章名可以翻译成:一劳永逸地改变你书写异常安全的方式。原文年代久远,作者在 C++98 的基础上使用了一种复杂的机制实现了一套异常安全设施,我尝试用 C++11 的新特性实现了一个简单的异常安全设施,不禁感叹:Modern C++ 真是赞,短短几行代码就实现了之前几百行代码才能完成的事。本文顺着原文的思路对异常安全进行简单介绍,并用 C++11 实现一个简单的异常安全设施。

一个简单的事务操作

我们有这么一个类:

class User
{        
    // ...    
    string GetName();
    void AddFriend(User& newFriend);
private:
    typedef vector<User*> UserCont;
    UserCont friends_;
    UserDatabase* pDB_;
};  

void User::AddFriend(User &newFriend)
{
    // Add the new friend to the database
    pDB_->AddFriend(GetName(), newFriend.GetName());

    // Add the new friend to the vector of friends
    friends_.emplace_back(&newFriend);
}

AddFriend 函数的工作很简单:向数据库插入一条数据,并更新缓存(这里用 vector 简单代替),看起来十分简单的代码其实隐藏着一个严重的问题:在内存耗尽的情况下,vector::push_back 操作会失败,这样代码就执行了一半:数据库更新了缓存没有更新。虽然概率比较小,但是墨菲定律告诉我们:一件糟糕的事如果有可能发生那就一定会发生。如果交换这两行的顺序呢?先更新缓存然后操作数据库,很不巧,数据库也有一定几率出错,所以这无法改善任何问题。
一种简单的解法是使用 try...catch

void User::AddFriend(User& newFriend)
{
    friends_.emplace_back(&newFriend);
    try
    {
        pDB_->AddFriend(GetName(), newFriend.GetName());
    }
    catch (...)
    {
        friends_.pop_back();
        throw;
    }
}

感觉是一种可行的方案,一旦出现异常就回滚代码。但试想一下:如果这一事务中包含了三条语句,这种写法就需要嵌套两层 try...catch,相信没人愿意这么写。

一点来自长者的人生经验

有经验的工程师会告诉你:你应该使用 RAII,当程序出错时在析构函数中完成资源的自动释放。接下来我们沿着这个思路解一下这个问题。
使用 RAII 需要创建一个辅助类,在这个类的析构函数中完成操作的回滚:

class VectorInserter
{
public:
    VectorInserter(std::vector<User*>& v, User& u)
    : container_(v), commit_(false)
    {
        container_.emplace_back(&u);
    }
    void Commit() noexcept {
        commit_ = true;
    }
    ~VectorInserter()
    {
        if (!commit_) container_.pop_back();
    }
private:
    std::vector<User*>& container_;
    bool commit_;
};

这里比较关键的是一个 Commit() 函数,表示操作无异常已经提交,析构时无需回滚操作。有了这个类,我们就可以写出下面的异常安全代码:

void User::AddFriend(User& newFriend)
{
    VectorInserter ins(friends_, &newFriend);
    pDB_->AddFriend(GetName(), newFriend.GetName());
    // Everything went fine, commit the vector insertion
    ins.Commit();
}

任务执行后提交操作,辅助类对象析构时不回滚代码;如果中途出现异常无法执行提交,则析构时回滚代码。看起来已经很完善了,但是这里还是存在两个问题:

  • 不同事务操作不尽相同,我们需要为每组事务都写一个配套的类;
  • 辅助类只实现了构造函数,编译器默认生成的拷贝构造函数在拷贝的对象操作未提交时,会导致析构函数多次调用 vector::pop_back

我们需要实现一套通用的异常安全辅助工具来解决上面两个的问题:

  • 支持多种类型的回滚操作;
  • 实现拷贝构造函数,解决拷贝对象的内存操作安全性,或者直接禁用拷贝构造函数;

由于 C++98 无法对多种可调用对象进行抽象,且不支持变参函数模板,作者使用了一套复杂的机制对其进行建模来支持不同类型的回滚操作,在传统 C++ 语言上实现了一个 ScopeGuard 库,以 AddFriend 为例,这个库提供这样的机制来书写异常安全代码:

void User::AddFriend(User& newFriend)
{
    friends_.push_back(&newFriend);
    ScopeGuard guard = MakeObjGuard(friends_, &UserCont::pop_back);
    pDB_->AddFriend(GetName(), newFriend.GetName());
    guard.Dismiss();
}

ScopeGuard 对象持有需要回滚的操作,在异常发生的条件下自动调用,Dismiss() 类似于之前的 Commit()

C++ 11 实现异常安全设施

C++11 带来了 std::function 以及 lambda 表达式,前者是一个类型安全的可调用对象包装器,后者是匿名函数。转变一下思路——我们不需要在回滚操作的通用性上下功夫,而是直接将这些操作交给 lambda 表达式,表达式内部完成各种操作,外部使用 std::function 进行包装就好了,如此一来,一个非常简单的异常安全设施就实现了:

class ScopeGuard 
{
public:
    explicit ScopeGuard(std::function<void()> rollBack): rollBack_(rollBack), dismiss_(false) {}
    ~ScopeGuard() { if (!dismiss_) rollBack_(); }
    void Dismiss() { dismiss_ = true; }
    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;
private:
    std::function<void()> rollBack_;
    bool dismiss_;
};

这里使用 std::function<void()> 接收并保存一个 lambda 表达式,在析构时决定是否执行,同时禁用拷贝构造函数及赋值运算符。
使用方式:

step1();
ScopeGuard scopeGuard([=]() { revert1(); });
step2();
scopeGuard.Dismiss();

StackOverFlow 上看到一个很有趣的帖子,大家在问题里留言,看看谁能用 C++11 写出最简洁的 ScopeGuard 代码,我比较看好的是这个:

class scope_guard 
{
public: 
    template<class Callable> 
scope_guard(Callable && undo_func) try : f(std::forward<Callable>(undo_func)) {
    } catch(...) {
        undo_func();
        throw;
    }
    scope_guard(scope_guard && other) : f(std::move(other.f)) {
        other.f = nullptr;
    }
    ~scope_guard() {
        if(f) f(); // must not throw
    }
    void dismiss() noexcept {
        f = nullptr;
    }
    scope_guard(const scope_guard&) = delete;
    void operator = (const scope_guard&) = delete;
private:
    std::function<void()> f;
};

这个实现也在所有的回答中获得了最多的赞同。这里不使用 dismiss 来判断任务是否提交,而是判断函数包装器 f 是否为空(又省了一行代码),同时实现了移动构造函数 scope_guard(scope_guard && other),它接受一个右值(通过 std::move 转换),同时将 f 置为空指针,避免析构时多次执行回滚操作。
使用方法:

step1();
scope_guard guard1 = [&]() { revert1(); };
step2();
guard1.dismiss();

后记

之前我对 Modern C++ 的优雅感触不深,现在通过对比可以很清晰的看出:更加丰富且高性能的语言特性,可以让人们跳出繁琐的细节,从更高的维度审视一个课题。ScopeGuard 的作者在 C++98 下实现了一套异常安全设施实属不易,但是在 Modern C++ 下,我们可以跳出旧有的限制,写出更简单更优雅的代码。