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






4.83/5 (4投票s)
编写了一个强异常保证测试器,用于测试类模板在面对第三方异常时的健壮性。
引言
你是否曾想过你的类模板在面对异常时有多健壮?如果答案是肯定的,这个测试器可能会有所帮助。该技术改编自 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` 中抛出了异常怎么办?你认为你的客户端(那个使用你类模板的“其他人”)会作何反应?
你可以从两个不同的角度来分析这种情况:
- 我应该在 `my_method` 中捕获这类异常,还是应该让它们向上冒泡?
- 异常抛出后,我的容器(`my_class_object`)的状态是什么?
首先要注意的是,我们在这里非常广泛地使用了“容器”一词。你的类模板是包含一个或多个 `T` 对象的容器。
在我看来,如果 `my_class<T>` 是一个通用的容器模板(旨在支持广泛的 `T` 类型),最好的选择是让这类异常向上冒泡。否则,我们可能会向客户端隐藏异常的根源,并且很可能需要根据异常类型以不同的方式处理它们。这可能会导致代码不够优雅且不健壮。
关于第二个问题,我们的容器在异常抛出后将处于以下三种状态之一:
- 损坏 (Broken)。这是最糟糕的情况。很可能某些资源已泄露。可能存在悬空指针。容器的原始状态(异常发生前)已不可恢复。甚至可能无法安全地销毁它。这是实现错误的症状。
- 不可用但安全 (Unusable but safe)。没有资源泄露。除非检查,否则无法知道容器的内容。我们容器的原始状态已不可恢复,但至少可以安全地销毁它。在这种情况下,我们的容器据说满足 **基本异常保证 (basic exception guarantee)**。
- 未受影响 (Untouched)。这是理想情况。容器保持异常发生前的状态和内容。容器已证明对第三方异常透明。在这种情况下,我们的容器据说满足 **强异常保证 (strong exception guarantee)**。
编写一个强异常安全的方法是否总是可能的?是的。对于构造函数以外的任何类型的方法,步骤如下:
- 复制你的容器。如果此过程抛出异常,你的容器将保持不变。
- 在副本上执行所需的方法。如果此过程抛出异常,你的原始容器将再次保持不变。
- 交换原始容器与已转换容器的内部数据。确保这些交换操作不抛出异常(对于原始类型和指针类型,这是有保证的,并且可以为更复杂的类型实现)。
如果你对这些技术(以及影响构造函数的技术)非常感兴趣,可以参考 [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。
限制不确定性
测试器会失败并检测不到强异常安全保证的缺乏吗?是的,这是因为问题的随机性。但是,通过遵循以下技巧,你可以很大程度上限制这个缺点:
- 在开始测试之前,尽可能地填充你的容器;操作的数量越多,检测异常的可能性就越高。
- 使参数容器(`other`)比被测试容器(`tested`)更大。
- 如果需要,将 `number_of_runs` 设得更大。参见 *strong_test.h*。
- 改变抛出概率。参见 *thrower.h*,并考虑除 4 之外的数字的倍数。
源代码
附带了强异常保证测试器的实现代码以及一个主示例代码。代码是用 Code::Blocks 编写的,并使用 MinGw (mingw-get-inst-20120426) 编译。
参考文献
- 从 C++ 标准库异常安全性规范中学到的经验。David Abrahams。Boost C++ 库。(https://boost.ac.cn/community/exception_safety.html)。
- Exceptional C++。47 个工程谜题、编程问题和解决方案。Herb Sutter。Addison-Wesley。第 8 至 17 项。