如果您观察学习曲线的初始阶段,那么C ++中的异常处理将是一个没有学到的话题。 在线提供了大量有关C ++中异常处理的教程和示例。 但是很少有人解释您不应该做的事情以及与此相关的复杂性。 因此,在这里,我们将看到一些复杂之处,从何处以及为什么不应该引发异常,以及现代C ++中引入的一些新的功能,以及有关示例处理的异常。 我不是专家,但这是我从各种来源,课程和行业经验中学到的。
/!\:最初发布于@ www.vishalchovatiya.com 。
最后,我们将通过快速的基准代码看到使用异常的性能成本 。 最后,我们将以最佳实践和一些有关异常处理的CPP核心指南结束本文。
注意 :从C ++ 11弃用并在C ++ 17中将其删除的过程中,我们看不到任何有关动态异常的信息。
您可能会遇到的术语/大笨蛋/成语
- 可能抛出 :可能会或可能不会抛出异常。
- noexcept :这是说明符以及运算符,具体取决于您在何处以及如何使用它。 会看到后面 。
- RAII : 方案资源甲 cquisition 我的I nitialization是一个范围结合的资源管理机制,这意味着资源分配由构造&资源释放完成由析构函数定义的对象的范围期间进行。 我知道这是一个糟糕的名字,但概念很强大。
- 隐式声明的特殊成员函数 :我认为这不需要任何介绍。
1.在抛出用户定义的类型对象的同时实现复制和/或移动构造函数
struct demo
{
demo() = default ;
demo(demo &&) = delete ;
demo( const demo &) = delete ;
};
int main ()
{
throw demo{};
return 0 ;
}
- 在抛出表达式时,总是需要创建异常对象的副本,因为原始对象在堆栈展开过程中超出了范围。
- 在此期间,初始化,我们可以期望复制省略 (见本 ) -遗漏复制或移动构造函数 (对象直接构建到目标对象的存储)。
- 但是,即使可以使用复制省略或不使用复制省略,您也应该提供适当的复制构造函数和/或移动构造函数,这是C ++标准规定的(请参见15.1) 。 请参阅以下编译错误以供参考:
error: call to deleted constructor of 'demo' throw demo{}; ^~~~~~ note: 'demo' has been explicitly marked deleted here demo (demo &&) = delete ; ^ 1 error generated. compiler exit status 1
- 直到C ++ 14,以上错误才成立。 从C ++ 17开始,如果抛出的对象是prvalue,则会优化move / copy构造函数调用,即copy elision 。
- 如果我们按值捕获异常,则也可能期望复制省略(允许编译器这样做,但这不是强制性的)。 初始化catch子句参数时,异常对象是一个左值参数。
TL; DR
用于抛出异常对象的类需要复制和/或移动构造函数
2.在从构造函数中抛出异常时要谨慎
struct base
{
base(){ cout << "base\n" ;}
~base(){ cout << "~base\n" ;}
};
struct derive : base
{
derive(){ cout << "derive\n" ; throw -1 ;}
~derive(){ cout << "~derive\n" ;}
};
int main ()
{
try {
derive{};
}
catch (...){}
return 0 ;
}
- 当从构造函数抛出异常时,堆栈展开开始,仅当成功创建对象时,才会调用该对象的析构函数。 因此,请注意此处的动态内存分配。 在这种情况下,您应该使用RAII 。
base
derive
~base
- 如您在上述情况中看到的那样,不执行派生的析构函数,因为创建失败。
struct base
{
base() { cout << "base\n" ; }
~base() { cout << "~base\n" ; }
};
struct derive : base
{
derive() = default ;
derive( int ) : derive{}
{
cout << "derive\n" ;
throw - 1 ;
}
~derive() { cout << "~derive\n" ; }
};
int main ()
{
try {
derive{ 0 };
}
catch (...){}
return 0 ;
}
- 在构造函数委托的情况下,它被视为对象的创建,因此将调用派生的析构函数。
base
derive
~derive
~base
TL; DR
当从构造函数抛出异常时,仅在成功创建对象后,才会调用该对象的析构函数
3.避免将异常抛出析构函数
struct demo
{
~demo() { throw std ::exception{}; }
};
int main ()
{
try {
demo d;
}
catch ( const std ::exception &){}
return 0 ;
}
- 上面的代码看起来很简单,但是当您运行它时,它将如下所示终止,而不是捕获异常。 原因是默认情况下析构函数为
noexcept
(即非抛出)
$ clang++-7 -o main main.cpp
warning: '~demo' has a non-throwing exception specification but can still
throw [-Wexceptions]
~demo() { throw std::exception{}; }
^
note: destructor has a implicit non-throwing exception specification
~demo() { throw std::exception{}; }
^
1 warning generated.
$
$ ./main
terminate called after throwing an instance of 'std::exception'
what(): std::exception
exited, aborted
-
noexcept(false)
将解决我们的问题,如下所示。
struct X
{
~X() noexcept ( false ) { throw std ::exception{}; }
};
- 但是不要这样做。 默认情况下,析构函数是不抛出的,这是有原因的,除非在析构函数中捕获异常,否则我们不得在析构函数中抛出异常。
为什么不应该从析构函数引发异常?
因为在抛出异常时在堆栈展开期间会调用析构函数,并且在未捕获到前一个异常的情况下不允许我们抛出另一个异常–在这种情况下,将调用std::terminate
。
- 请考虑以下示例,以更加清楚。
struct base
{
~base() noexcept ( false ) { throw 1 ; }
};
struct derive : base
{
~derive() noexcept ( false ) { throw 2 ; }
};
int main ()
{
try {
derive d;
}
catch (...){ }
return 0 ;
}
- 当RAII破坏了对象d时,将引发异常。 但是同时,base的析构函数也将被调用,因为它是derive的子对象 ,它将再次引发异常。 现在我们同时有两个异常,它们是无效的场景&
std::terminate
将被调用。
-
#include<type_traits>
有一些类型特征实用程序,例如std::is_nothrow_destructible
,std::is_nothrow_constructible
等,通过它们可以检查特殊成员函数是否异常安全。
int main ()
{
cout << std ::boolalpha << std ::is_nothrow_destructible< std :: string >::value << endl ;
cout << std ::boolalpha << std ::is_nothrow_constructible< std :: string >::value << endl ;
return 0 ;
}
TL; DR
1.默认情况下,析构noexcept
(即非抛出)。
2.您不应该将异常抛出析构函数,因为在抛出异常时在堆栈展开期间会调用析构函数,并且在未捕获到前一个异常的情况下,我们也不允许抛出另一个异常–在这种情况下,将使用std::terminate
叫。
4.使用std :: exception_ptr(C ++ 11)示例重新抛出/嵌套异常处理
这更多是使用std::exception_ptr
的嵌套异常场景的最佳实践示例。 尽管您可以简单地使用std::exception
而不会使事情复杂化,但是std::exception_ptr
可以为我们提供利用try
/ catch
子句处理异常的功能。
void print_nested_exception ( const std ::exception_ptr &eptr= std ::current_exception(), size_t level= 0 )
{
static auto get_nested = []( auto &e) -> std ::exception_ptr {
try { return dynamic_cast < const std ::nested_exception &>(e).nested_ptr(); }
catch ( const std ::bad_cast&) { return nullptr ; }
};
try {
if (eptr) std ::rethrow_exception(eptr);
}
catch ( const std ::exception &e){
std :: cerr << std :: string (level, ' ' ) << "exception: " << e.what() << '\n' ;
print_nested_exception(get_nested(e), level + 1 ); // rewind all nested exception
}
}
// -----------------------------------------------------------------------------------------------
void func2 () {
try { throw std ::runtime_error( "TESTING NESTED EXCEPTION SUCCESS" ); }
catch (...) { std ::throw_with_nested( std ::runtime_error( "func2() failed" )); }
}
void func1 () {
try { func2(); }
catch (...) { std ::throw_with_nested( std ::runtime_error( "func1() failed" )); }
}
int main ()
{
try { func1(); }
catch ( const std ::exception&) { print_nested_exception(); }
return 0 ;
}
// Will only work with C++14 or above
- 上面的示例起初看起来很复杂,但是一旦实现了嵌套的异常处理程序(即
print_nested_exception
)。 然后,您只需要专注于使用std::throw_with_nested
函数std::throw_with_nested
异常。
exception: func1() failed
exception: func2() failed
exception: TESTING NESTED EXCEPTION SUCCESS
- 这里要重点关注的是
print_nested_exception
函数,在其中我们使用std::rethrow_exception
和std::exception_ptr
回退嵌套std::exception_ptr
。 -
std::exception_ptr
是类似类型的共享指针,尽管取消引用它是未定义的行为。 它可以保存nullptr或指向异常对象,并且可以构造为:
std ::exception_ptr e1; // null
std ::exception_ptr e2 = std ::current_exception(); // null or a current exception
std ::exception_ptr e3 = std ::make_exception_ptr( std ::exception{}); // std::exception
- 一旦创建了
std::exception_ptr
,我们可以像上面一样通过调用std::rethrow_exception(exception_ptr)
来使用它引发或重新抛出异常,这将引发有针对性的异常对象。
TL; DR
1.std::exception_ptr
将指向异常对象的生存期扩展到catch子句之外。
2.我们可以使用std::exception_ptr
延迟当前异常的处理并将其转移到其他宫殿。 不过,std::exception_ptr
实际用例是在线程之间。
5.适当地使用noexcept说明符vs运算符
void func () throw ( std ::exception) ; // dynamic excpetions, removed from C++17
void potentially_throwing () ; // may throw
void non_throwing () noexcept ; // "specifier" specifying non-throwing function
void print () {}
void (*func_ptr)() noexcept = print; // Not OK from C++17, `print()` should be noexcept too, works in C++11/14
void debug_deep () noexcept ( false ) {} // specifier specifying throw
void debug () noexcept ( noexcept (debug_deep())) {} // specifier & operator, will follow exception rule of `debug_deep`
auto l_non_throwing = []() noexcept {}; // Yeah..! lambdas are also in party
没有指定符
我认为不需要做任何介绍,它的用途如其名。 因此,让我们快速浏览一些指针:
- 可用于常规函数,方法, lambda函数和函数指针。
- 从C ++ 17开始,带有noexcept的函数指针不能指向可能抛出的函数。
- 最后,不要对基类/接口中的虚函数使用
noexcept
说明符,因为它会对所有替代强制实施限制。 - 除非确实需要,否则不要使用noexcept。 “在有用且正确时指定它” – Google的cppguide 。
noexcept运算符及其用途是什么?
- C ++ 11中添加了
noexcept
运算符,它使用一个表达式(不一定是常量)并执行编译时检查,以确定该表达式是否为非抛出(noexcept
)或潜在抛出的。 - 可以使用这种编译时检查的结果,例如,将
noexcept
说明符添加到同一类别,更高级别的函数(noexcept(noexcept(expr)))
或if constexpr中 。 - 我们可以使用
noexcept
运算符来检查某个类是否具有noexcept
构造函数,noexcept复制构造函数,noexcept move构造函数等,如下所示:
class demo
{
public :
demo() {}
demo( const demo &) {}
demo(demo &&) {}
void method () {}
};
int main ()
{
cout << std ::boolalpha << noexcept (demo()) << endl ; // C
cout << std ::boolalpha << noexcept (demo(demo())) << endl ; // CC
cout << std ::boolalpha << noexcept (demo( std ::declval<demo>())) << endl ; // MC
cout << std ::boolalpha << noexcept ( std ::declval<demo>().method()) << endl ; // Methods
}
// std::declval<T> returns an rvalue reference to a type
- 您一定想知道为什么以及如何使用此信息?
当您在函数内部使用库函数来建议编译器您的函数根据库实现而抛出或未抛出时,此功能将更为有用。 - 如果删除构造函数, 复制构造函数和move构造函数 ,则将打印出真实的原因,因为隐式声明的特殊成员函数始终是非抛出的。
TL; DR
noexcept
符和运算符是两个不同的东西。noexcept
运算符执行编译时检查,并且不对表达式求值。 虽然noexcept
符只能采用常量表达式,其结果为true或false。
6.使用std::move_if_noexcept
移动异常安全
struct demo
{
demo() = default ;
demo( const demo &) { cout << "Copying\n" ; }
// Exception safe move constructor
demo(demo &&) noexcept { cout << "Moving\n" ; }
private :
std :: vector < int > m_v;
};
int main ()
{
demo obj1;
if ( noexcept (demo( std ::declval<demo>()))){ // if moving safe
demo obj2 ( std ::move(obj1)) ; // then move it
}
else {
demo obj2(obj1); // otherwise copy it
}
demo obj3 ( std ::move_if_noexcept(obj1)) ; // Alternatively you can do this----------------
return 0 ;
}
- 我们可以使用
noexcept(T(std::declval<T>()))
来检查T
的移动构造函数是否存在,并且是否为noexcept
,以便通过移动另一个T
实例来确定是否要创建T
的实例(使用std::move
)。 - 另外,我们可以使用
std::move_if_noexcept
,它使用noexcept
运算符并将其强制转换为rvalue或lvalue 。 此类检查用于std::vector
和其他容器中。 - 在您处理不想丢失的关键数据时,这将很有用。 例如,我们从服务器收到了重要数据,我们不想在处理过程中不惜一切代价将其丢失。 在这种情况下,我们应该使用
std::move_if_noexcept
,它仅在移动构造函数是异常安全的情况下才移动关键数据的所有权。
TL; DR
使用std::move_if_noexcept
安全移动关键对象
7.具有基准示例的C ++中异常处理的实际成本
尽管有很多好处,但是由于开销大,大多数人仍然不喜欢使用异常。 因此,让我们清除它:
static void without_exception (benchmark::State &state) {
for ( auto _ : state){
std :: vector < uint32_t > v( 10000 );
for ( uint32_t i = 0 ; i < 10000 ; i++) v.at(i) = i;
}
}
BENCHMARK(without_exception); //----------------------------------------
static void with_exception (benchmark::State &state) {
for ( auto _ : state){
std :: vector < uint32_t > v( 10000 );
for ( uint32_t i = 0 ; i < 10000 ; i++){
try {
v.at(i) = i;
}
catch ( const std ::out_of_range &oor){}
}
}
}
BENCHMARK(with_exception); //--------------------------------------------
static void throwing_exception (benchmark::State &state) {
for ( auto _ : state){
std :: vector < uint32_t > v( 10000 );
for ( uint32_t i = 1 ; i < 10001 ; i++){
try {
v.at(i) = i;
}
catch ( const std ::out_of_range &oor){}
}
}
}
BENCHMARK(throwing_exception); //-----------------------------------------
- 正如你可以在上面看到,
with_exception
&without_exception
只有即异常语法单一的差异。 但是它们都没有引发任何异常。 - 尽管
throwing_exception
会执行相同的任务,但在上一次迭代中会抛出std::out_of_range
类型的异常。 - 如您在下面的条形图中所看到的,最后一个条形与前两个条形相比略高,前两个条形图显示了抛出异常的代价。
- 但是这里使用异常的代价为零 ,因为前两个小节是相同的。
- 我在这里不考虑优化,这是单独的情况,因为它可以完全整理一些组装说明。 此外,编译器和ABI的实施也起着至关重要的作用。 但仍然比通过设置防护(
if(error)
策略)并显式检查各处是否存在错误来浪费时间更好。 - 在发生异常的情况下,编译器会生成一个边表,该边表将可能引发异常(程序计数器)的任何点映射到处理程序列表。 引发异常时,将查询此列表以选择正确的处理程序(如果有),然后取消堆栈堆栈。 请参阅此以获取深入的知识。
- 顺便说一句,如果您想了解更多信息,我正在使用一个快速基准 &内部使用Google Benchmark 。
- 首先,请记住,除非抛出异常,否则使用try and catch并不会真正降低性能。这是“零成本”异常处理–除非引发异常,否则不会执行与异常处理相关的指令。
- 但是,与此同时,由于展开例程,它会增加可执行文件的大小,这对于嵌入式系统而言可能很重要。
TL; DR
除非引发异常,否则不会执行与异常处理相关的指令,因此使用try
/catch
实际上不会降低性能。
关于异常处理的最佳实践和一些CPP核心准则
C ++异常处理的最佳做法
- 理想情况下,您不应引发析构函数的异常,移动构造函数或 交换 类似函数。
- 身高 RAII 的异常安全的成语,因为你可能会留下异常的情况下,
–处于无效状态的数据,即无法进一步读取和使用的数据;
–泄漏的资源,例如内存,文件,ID或其他需要分配和释放的资源;
–内存损坏;
–不变的变量,例如size函数返回的元素比实际容纳在容器中的元素更多。
- 避免使用 原始的 new 和 delete 。 使用标准库中的解决方案,例如
std::unique_pointer
,std::make_unique
,std::fstream
,std::lock_guard
等。 - 此外,将代码分为修改部分和非修改部分非常有用,其中只有非修改部分才能引发异常。
- 欠一些资源时不要抛出异常。
一些CPP核心准则
- E.1:在设计初期制定错误处理策略
- E.3:仅将异常用于错误处理
- E.6:使用RAII防止泄漏
- E.13:在直接成为对象的所有者时切勿抛出
- E.16:析构函数,释放和 交换 必须永不失败
- E.17:不要试图捕获每个函数中的每个异常
- E.18:尽量减少使用显式 try / catch
- 26:如果无法抛出异常,请考虑快速失败
- E.31:正确订购 捕捞 物
From: https://hackernoon.com/7-best-practices-for-exception-handling-in-c-561k32e0