65.9K
CodeProject 正在变化。 阅读更多。
Home

C++11 强异常保证的测试器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (4投票s)

2013年1月17日

CPOL

9分钟阅读

viewsIcon

23011

downloadIcon

119

编写了一个强异常保证测试器,用于测试类模板在面对第三方异常时的健壮性。

引言

你是否曾想过你的类模板在面对异常时有多健壮?如果答案是肯定的,这个测试器可能会有所帮助。该技术改编自 Matt Arnold 的一个原始想法,并在此处为 C++11 类模板的使用进行了改进。

我们将首先阐述异常处理中缺乏强安全性(strong safety)的问题。然后,我们将提供一些关于如何使用测试器的技巧(你会发现它非常直接)。最后,我们将深入探讨其工作原理及其潜在的缺点。

问题所在

假设你编写了一个类模板,例如 `my_class<T>`。这个模板可能包含各种类型的对象 `T`,其中很多可能不是你自己实现的。有人在使用你的类模板,并将其实例化为某个 `third_party` 类型。现在,这个人调用了你类的一个方法,例如

my_class<third_party> my_class_object;  // Object created
my_class_object.my_method(arguments…);  // Method called

你可能在编写 `my_method` 时非常小心,所以你自己的实现不会抛出任何异常。但是,如果 `third_party` 中的某个内部操作在 `my_method` 中抛出了异常怎么办?你认为你的客户端(那个使用你类模板的“其他人”)会作何反应?

你可以从两个不同的角度来分析这种情况:

  1. 我应该在 `my_method` 中捕获这类异常,还是应该让它们向上冒泡?
  2. 异常抛出后,我的容器(`my_class_object`)的状态是什么?

首先要注意的是,我们在这里非常广泛地使用了“容器”一词。你的类模板是包含一个或多个 `T` 对象的容器。

在我看来,如果 `my_class<T>` 是一个通用的容器模板(旨在支持广泛的 `T` 类型),最好的选择是让这类异常向上冒泡。否则,我们可能会向客户端隐藏异常的根源,并且很可能需要根据异常类型以不同的方式处理它们。这可能会导致代码不够优雅且不健壮。

关于第二个问题,我们的容器在异常抛出后将处于以下三种状态之一:

  1. 损坏 (Broken)。这是最糟糕的情况。很可能某些资源已泄露。可能存在悬空指针。容器的原始状态(异常发生前)已不可恢复。甚至可能无法安全地销毁它。这是实现错误的症状。
  2. 不可用但安全 (Unusable but safe)。没有资源泄露。除非检查,否则无法知道容器的内容。我们容器的原始状态已不可恢复,但至少可以安全地销毁它。在这种情况下,我们的容器据说满足 **基本异常保证 (basic exception guarantee)**。
  3. 未受影响 (Untouched)。这是理想情况。容器保持异常发生前的状态和内容。容器已证明对第三方异常透明。在这种情况下,我们的容器据说满足 **强异常保证 (strong exception guarantee)**。
还有一个更强的保证。如果 `my_method` 本身不抛出异常,并且调用的是 `T` 中不会抛出异常的方法(例如析构函数),那么 `my_method` 被称为满足 **不抛出异常保证 (nothrow guarantee)**。

编写一个强异常安全的方法是否总是可能的?是的。对于构造函数以外的任何类型的方法,步骤如下:

  1. 复制你的容器。如果此过程抛出异常,你的容器将保持不变。
  2. 在副本上执行所需的方法。如果此过程抛出异常,你的原始容器将再次保持不变。
  3. 交换原始容器与已转换容器的内部数据。确保这些交换操作不抛出异常(对于原始类型和指针类型,这是有保证的,并且可以为更复杂的类型实现)。

如果你对这些技术(以及影响构造函数的技术)非常感兴趣,可以参考 [2],这是一本非常权威的读物。

那么,既然有编写强异常安全方法的标准方法,为什么要费心呢?好吧,尽管安全,但最终的方法实现可能会相当低效;想想我们在上面第一步中进行了深度且可能成本高昂的复制。

你的目标应该是编写优雅、强异常安全的代码,除非效率受到严重影响。至少要达到基本异常保证。这里包含的测试器将帮助你实现这一目标。

测试器

使用测试器非常简单。例如,让我们测试 `std::list<>` 的一些方法。

首先,包含一些必要的头文件并创建一个非空的列表进行测试:

#include <list>
#include "strong_tester.h"
#include "third_party.h"

int main(int argn, char *argc[])
{
    std::list<third_party> tested{9, 5, 3, 7, 1, 16, 34, 56, 32, -12, -34};
    // …
请注意,列表是为 `third_party` 类型实例化的。`third_party` 对象从普通整数初始化。`third_party` 这个名字是有意为之的:它代表了你的测试容器可能持有的“最坏”的类(你没有编写,并且对其没有任何控制)。`third_party` 是一个邪恶的类,会时不时地抛出异常。

现在,让我们测试 `reverse` 方法,例如:

    // Test of void reverse() noexcept
    strong_test("void reverse() noexcept",        // Method signature 
                tested,                           // Container to be tested
                &std::list<third_party>::reverse  // Method to be tested
                );
 
    return 0;
}

如果我们编译并运行这个程序,屏幕上会显示以下输出:

Test of void reverse() noexcept

就是这样。看到这个输出后,我们可以(例如)有 99% 的把握认为 `reverse` 方法是强异常安全的(实际上它是,因为 `reverse` 满足不抛出异常保证)。我们稍后会讨论那个 1% 的不确定性。

让我们现在测试赋值运算符。`operator=` 有三个签名,即:

list& operator= (const list& x);                    // Signature 1
list& operator= (list&& x);                         // Signature 2
list& operator= (initializer_list<value_type> il);  // Signature 3

让我们全部测试。对于签名 1,我们需要另一个列表来复制:

std::list<third_party> other{4, 6, 2, 5, 90, -32, -5, 67, 45, -11, 59, -6, -32, 12, 11};
 
// Test of list& operator= (const list& x)
typedef std::list<third_party>&
        (std::list<third_party>::*assignment_ptr_type)
        (const std::list<third_party>&);                                  // [1]

assignment_ptr_type assignment_ptr=&std::list<third_party>::operator=;    // [2]

strong_test("list& operator= (const list& x)",    // Method signature
            tested,                               // Container to be tested
            assignment_ptr,                       // Method to be tested
            other                                 // Method argument
            );                                                            // [3]

首先,我们 typedef 一个 `assignment_ptr_type` 类型,它匹配方法签名 **[1]**。其次,我们创建一个指向 `operator=` 的成员函数指针(称为 `assignment_ptr`)**[2]**。最后,我们执行测试 **[3]**。

对于签名 2,我们需要一个临时列表:

// Test of list& operator= (list&& x)
typedef std::list<third_party>& 
        (std::list<third_party>::*move_assignment_ptr_type)
        (std::list<third_party>&&);
 
move_assignment_ptr_type move_assignment_ptr=&std::list<third_party>::operator=;
 
strong_test("list& operator= (list&& x)",        // Method signature         
            tested,                              // Container to be tested
            move_assignment_ptr,                 // Method to be tested
            std::list<third_party>               // Method argument
              {4, 6, 2, 5, 90, -32, -5, 67, 45, -11, 59, -6, -32, 12, 11}
            );

对于签名 3,我们需要一个初始化列表:

// Test of list& operator= (initializer_list<value_type> il)
typedef std::list<third_party>& 
        (std::list<third_party>::*il_assignment_ptr_type)
        (std::initializer_list<third_party>);
 
il_assignment_ptr_type il_assignment_ptr=&std::list<third_party>::operator=;
 
strong_test("list& operator= (initializer_list<value_type> il)",  // Method signature
            tested,                                               // Container to be tested
            il_assignment_ptr,                                    // Method to be tested
            std::initializer_list<third_party>                    // Method argument
              ({4, 6, 2, 5, 90, -32, -5, 67, 45, -11, 59, -6, -32, 12, 11})
            );

这是与前面三个测试相对应的结果:

Test of list& operator= (const list& x)
Strong exception guarantee NOT fulfilled. Sources: [ASSIGNMENT] [COPY_CONSTRUCTOR]
 
Test of list& operator= (list&& x)
 
Test of list& operator= (initializer_list<value_type> il)
Strong exception guarantee NOT fulfilled. Sources: [ASSIGNMENT] [COPY_CONSTRUCTOR]

等等,等等。你是在告诉我,标准模板库列表的拷贝(与移动相对)赋值运算符不是强异常安全的吗?嗯,我想这取决于供应商的实现,但出于效率的考虑,它通常不是(如果不是总是的话)。

那么,我们如何解释上面的结果呢?如果输出匹配以下模式:

Test of <method_signature>

你几乎可以肯定你的方法是强异常安全的。相反,如果结果匹配以下模式:

Test of <method_signature>
Strong exception guarantee NOT fulfilled. Sources: [<source_1>] [<source_2>]…

如果容器类参数 `T` 在其任何内部操作:*source_1*、*source_2*... 中抛出异常,那么你可以 100% 确定你的方法不是强异常安全的。

因此,只要 `T` 的赋值运算符或拷贝构造函数本身可能抛出异常,`std::list<T>` 的拷贝赋值运算符就不是强异常安全的。

这些是我们考虑过的缺乏保证的来源:

  • [CONSTRUCTOR]:当面对 `T` 构造函数中的异常时,容器的被测方法不是强异常安全的。
  • [NEW_ALLOCATION]:...在尝试分配 `T` 对象时发生内存耗尽。你可能永远不会看到这个来源。
  • [COPY_CONSTRUCTION]:...当面对 `T` 拷贝构造函数中的异常时。
  • [MOVE_CONSTRUCTOR]:...当面对 `T` 移动构造函数中的异常时。
  • [ASSIGNMENT]:...当面对 `T` 拷贝赋值运算符中的异常时。
  • [MOVE_ASSIGNMENT]:...当面对 `T` 移动赋值运算符中的异常时。
  • [OP== OR OP!=]:...当面对 `T` 的 `operator==` 或 `operator!=` 中的异常时。我们不对它们进行单独区分,因为一个运算符通常是基于另一个实现的。
  • [OP<= OR OP>]:...当面对 `T` 的 `operator<=` 或 `operator>` 中的异常时。我们不对它们进行单独区分,因为一个运算符通常是基于另一个实现的。
  • [OP>= OR OP<]:...当面对 `T` 的 `operator>=` 或 `operator<` 中的异常时。我们不对它们进行单独区分,因为一个运算符通常是基于另一个实现的。

到目前为止,我们只测试了零个或最多一个参数的方法。你可以测试具有与其签名中定义的参数数量相同的方法。只需确保你遵循正确的参数顺序。

机制

函数模板 `strong_test_impl` 负责一次一个地执行测试。它是这样写的:

template <typename Container, typename Operation, typename... Arguments>
void strong_test_impl(
            std::set<std::string>& failure_sources,
            const Container& tested,
            const Operation& operation,
            Arguments&&... arguments
            )
{
    Container copy(tested);          // [1]

    try
    {
        thrower::enable_throw();     // [2]
        // Operation that throws...
        (copy.*operation)(std::forward<Arguments>(arguments)...);  // [3]
        thrower::disable_throw();    // [4]
    }
    catch (std::exception& ex)
    {
        thrower::disable_throw();    // [5]

        // Strong exception guarantee test
        if(copy!=tested)             // [6]
            failure_sources.insert(ex.what());  // [7]
    }
}

首先,我们复制要测试的容器 **[1]**。在此操作期间,`third_party` 的抛出机制被禁用,因此除了极不可能的 NEW_ALLOCATION 之外,不会抛出任何异常。其次,启用 `third_party` 中的异常 **[2]**。我们在 **[3]** 中调用要测试的方法及其所需的参数。请注意,该方法是在副本上调用的,而不是在要测试的容器上调用的。如果没有抛出异常,我们就禁用 `third_party` 中的抛出机制,等待下一次测试 **[4]**。相反,如果抛出了异常,则在 catch 块中捕获它。然后我们禁用异常 **[5]**,以便立即进行实际测试:比较原始容器和复制的容器 **[6]**。如果它们不相等,则意味着强异常安全保证已被违反,并且缺乏保证的来源被存储在 `std::set` **[7]** 中。

正如你所见,这个想法很简单。但是,如果在 catch 块中,复制的容器和被测试的容器相等怎么办?这意味着什么?嗯,你可能得出结论,强异常安全得到了保障,但这不一定是真的。要理解原因,请考虑一个排序方法,例如。很可能第一个重要的操作将是比较两个元素,然后再采取进一步的行动。如果第一个比较抛出异常,复制的容器尚未被修改。但是,如果相同类型的异常发生在稍后,又有什么能阻止它被修改呢?

缓解上述问题的一个可能方法是随机性。事实上,`third_party` 中的异常是随机抛出的,每次调用方法时都有 25% 的概率。但是一旦引入了随机性,就必须多次重复每个测试,并观察整体行为。这是 `strong_test` 函数模板的任务。

template <typename Container, typename Operation, typename... Arguments>
void strong_test(
            const std::string& test_name,
            const Container& tested,
            const Operation& operation,
            Arguments&&... arguments
            )
{
    // Number of runs for a single operation to be tested
    const size_t number_of_runs=1000;
 
    // If strong exception guarantee is not fulfilled,
    // the source is stored in failure_sources.
    std::set<std::string> failure_sources;
 
    // The same operation is tested number_of_runs times.
    for(size_t i=0; i< number_of_runs; ++i)
            strong_test_impl(failure_sources,
                             tested,
                             operation,
                             std::forward<Arguments>(arguments)...
                             );
 
    //
    // Failure sources printing
    //
    
    // (non-relevant code here)
}

`strong_test` 函数模板是不言自明的。`number_of_runs` 被选为 1000。

限制不确定性

测试器会失败并检测不到强异常安全保证的缺乏吗?是的,这是因为问题的随机性。但是,通过遵循以下技巧,你可以很大程度上限制这个缺点:

  1. 在开始测试之前,尽可能地填充你的容器;操作的数量越多,检测异常的可能性就越高。
  2. 使参数容器(`other`)比被测试容器(`tested`)更大。
  3. 如果需要,将 `number_of_runs` 设得更大。参见 *strong_test.h*。
  4. 改变抛出概率。参见 *thrower.h*,并考虑除 4 之外的数字的倍数。
将上述技巧作为经验法则。

源代码

附带了强异常保证测试器的实现代码以及一个主示例代码。代码是用 Code::Blocks 编写的,并使用 MinGw (mingw-get-inst-20120426) 编译。

参考文献

  1. 从 C++ 标准库异常安全性规范中学到的经验。David Abrahams。Boost C++ 库。(https://boost.ac.cn/community/exception_safety.html)。
  2. Exceptional C++。47 个工程谜题、编程问题和解决方案。Herb Sutter。Addison-Wesley。第 8 至 17 项。
© . All rights reserved.