C++ 异常处理:机制、应用与最佳实践
1. 异常处理概述
在程序运行过程中,错误的发生是难以避免的。若这些错误未得到妥善处理,极有可能导致程序崩溃。传统的错误处理方式是在程序的各个部分分散编写代码,让每个部分自行处理自身的错误。例如,在调用函数时,需要检查函数是否成功执行,若失败,程序员需添加代码来捕获并处理错误。以下是一个典型的检查函数返回值以判断是否出错的示例:
k = f();
if(k == ERROR_VALUE_1)
{
...
}
else if(k == ERROR_VALUE_2)
{
...
}
else if(k == ERROR_VALUE_N)
{
...
}
else // No error happened.
{
...
}
若函数不返回值,可使用指针或引用参数来获取结果。而 C++ 提供了异常机制作为另一种错误处理技术。异常用于报告运行时发生的错误,它能将错误的检测与处理分离,即错误可能在程序的某一部分发生,而处理则在另一部分进行。这种机制的理念是在程序中设置特定的点来处理错误,而非使用分散的代码,这使得代码更简洁,错误管理更易于阅读和控制。
1.1 异常的抛出与捕获语法
以下是一个典型的抛出和捕获异常的示例:
f()
{
try
{
g();
}
catch(type exc_1)
{
...
}
catch(type exc_2)
{
...
}
catch(...) // catches any type of exception.
{
...
}
}
g()
{
...
if(error_occurs)
throw exception; // g() creates an exception.
...
}
若函数(如
g()
)中发生错误且无法处理,可使用
throw
语句创建一个异常,通知调用函数。为了捕获异常,可能抛出异常的函数调用必须放在以
try
语句开头的块中。处理异常的代码则放在
try
语句之后,以
catch
关键字开头的一个或多个块中。需要注意的是,
try
块和第一个
catch
块之间,以及连续的
catch
块之间不允许放置任何代码,否则程序将无法编译。每个
catch
块指定了它能捕获的异常类型,该类型可以是基本类型或用户定义类型,通过
catch
关键字后的括号内的单个参数来识别。
1.2 异常的基本概念
异常的基本概念是,当程序的某一部分(如
g()
)发生“异常”事件时,该部分能够通知另一部分(如
f()
),并传递与该事件相关的信息。程序员需要确定什么是“异常”,例如,导致程序故障的语句(如除以零的尝试)就是“异常”。需要注意的是,“异常”事件并非总是灾难性的,程序员应指定其严重程度,它可能只是无法执行某项任务。本质上,创建异常是程序中无法在本地处理“异常”事件的部分(如
g()
)将其转发到更高级别(如
f()
)进行处理的一种方式。
2. 捕获异常
throw
语句创建一个异常,其参数指定了抛出的异常类型。
throw
会终止当前函数(如
g()
)的执行,并将程序的执行转移到调用函数(如
f()
)。在调用函数中,程序会顺序检查
catch
语句,看是否有与抛出的异常类型匹配的语句。例如,
throw 20;
创建一个
int
类型的异常并传递值 20。若有针对整数异常的
catch
语句(如
catch(int a)
),它将捕获该异常,
a
的值将变为 20,并执行其块中的代码。需要注意的是,若写成
catch(double a)
,异常将不会被捕获,因为编译器不执行通常的算术转换,其支持的转换是有限的。具体来说,它支持忽略
const
说明符的类型转换,例如,
throw
的非
const
整数参数可以匹配
catch
的
const
整数参数。它还支持从派生类型到基类类型的转换,以及数组参数或函数到相应指针的转换。
2.1 catch 块的处理逻辑
catch
块可能包含修复问题的代码,若问题可以修复。若无法修复,程序员可以决定终止程序。其余的
catch
块不会被检查。需要注意的是,不一定要声明参数名称,即可以写成
catch(int)
。通常,传递给
catch
语句的
throw
参数提供了与异常相关的信息,因此
catch
参数用于保存此信息。
2.2 catch(…) 块的作用
catch(...)
块是可选的,它能捕获所有类型的异常。它捕获未被之前的
catch
块捕获的异常,有点像
switch
语句中的
default
情况。需要注意的是,它应放在
catch
块的末尾。若将其放在其他位置,由于它会捕获所有异常,之后的
catch
语句将不会被检查。若缺少该块,且异常未被任何
catch
语句捕获,如在讨论异常转发时会看到的,异常将被传递到
main()
,直到找到匹配的
catch
块。若未找到匹配的块,程序将终止。
2.3 示例:密码验证程序
以下是一个读取密码并检查其有效性的程序示例:
#include <iostream> // Example 22.1
#include <string>
using std::cout;
using std::cin;
using std::string;
int check_pswd(const string& str);
int main()
{
string pswd;
while(1)
{
try
{
cout << "Enter password: ";
cin >> pswd;
if(pswd == "end")
break;
check_pswd(pswd);
}
catch(const char *msg)
{
cout << msg;
continue; /* If it was missing, the statement after the
last catch block would be executed. */
}
catch(int code)
{
if(code == 10)
cout << "Error: Password must contain three
digits at least\n";
else if(code == 20)
cout << "Error: Password must contain one special
character at least\n";
continue;
}
catch(...)
{
cout << "Error: Another exception\n";
continue;
}
cout << "Password " << pswd << " is accepted !!!\n";
}
return 0;
}
int check_pswd(const string& str)
{
bool found;
int i, len, dig;
len = str.size();
if(len < 6)
throw "Error: Short length\n";
dig = 0;
found = false;
for(i = 0; i < len; i++)
{
if(str[i] >= '0' && str[i] <= '9')
dig++;
if(str[i] == '!' || str[i] == '$' || str[i] == '#')
found = true;
}
if(dig < 3)
throw 10;
if(found == false)
throw 20;
return 0;
}
该程序的工作流程如下:
1.
check_pswd()
首先检查密码的长度。若长度小于六个字符,
throw
语句创建一个异常并将一个字面字符串传递给调用函数(即
main()
),异常类型为
const char*
。
throw
语句终止
check_pswd()
,程序控制转移到
main()
。
2. 程序检查是否有与异常类型匹配的
catch
语句。
const char*
块捕获异常并执行其代码,将字符串
"Error: Short length\n"
传递给
msg
参数并显示在屏幕上。
continue
语句使程序继续下一次循环迭代,用户输入新密码。
3. 若密码长度可接受,
check_pswd()
检查密码是否包含少于三个数字。若是,创建一个
int
类型的异常,
check_pswd()
终止,
main()
中的
int
块捕获异常,代码变为 10,程序显示相应消息。
4. 若数字数量有效,
check_pswd()
检查密码是否包含
!
、
$
或
#
字符。若不包含,创建一个
int
类型的异常,
catch
块中代码变为 20,程序显示相应消息。
5. 若密码被接受,
check_pswd()
不创建异常并返回值 0,程序跳过
catch
块并显示密码。程序运行直到用户输入
end
,
break
语句终止循环。
2.4 异常处理中的作用域问题
try
和
catch
块定义了自己的作用域。若一个名称要在两个块中使用,则必须在它们之外声明。例如:
void f()
{
int a = 5;
try
{
int i = 20;
...
}
catch(int)
{
a++; // Correct.
cout << i; // Wrong, i is not in the same scope.
int j = 20;
}
catch(...)
{
a--; // Correct.
cout << j; // Wrong, j is not in the same scope.
}
}
3. noexcept 说明符
3.1 异常规范的发展
C++98 提供了指定函数是否可能抛出异常以及异常类型的能力,即异常规范。例如:
void f() throw(int, double);
void g() throw(); // It does not create exceptions.
f()
会创建
int
和
double
类型的异常,而
g()
不创建异常。然而,异常规范在实践中效果不佳,被 C++11 弃用。这是因为原则上,异常规范应包括从其他嵌套函数调用可能抛出的异常。例如,
f()
告知读者它可能抛出
int
和
double
异常,但
f()
可能调用另一个函数,该函数又调用其他函数,这些函数可能会创建异常,此时需要添加这些异常的类型。若这些函数由他人实现(如库函数),则很难或不可能找到可能的异常,特别是当异常未被指定时。因此,C++17 最终移除了异常规范,保留
throw()
作为
noexcept
的弃用同义词。
3.2 noexcept 说明符的使用
C++11 引入了
noexcept
说明符,用于指示函数不抛出异常。一般来说,若知道一个函数不抛出异常,在调用它时可以简化代码,因为无需添加处理异常的代码,还能使编译器进行一些优化。例如:
void f() noexcept; // It does not throw exceptions.
在类的成员函数中,
noexcept
关键字在
const
之后、
final
、
override
或
=0
说明符(对于虚函数)之前添加。若虚函数声明为
noexcept
,派生类中重写的函数也必须是
noexcept
。
3.3 noexcept 函数的特性
需要注意的是,当将函数声明为
noexcept
时,它可能有意或无意地抛出异常或调用抛出异常的函数。例如:
void f() noexcept
{
vector<A> v(1000000);
}
vector
的构造函数可能无法为
v
的所有元素找到可用内存并抛出异常,尽管函数被声明为
noexcept
。因此,
noexcept
在函数声明中的存在只是向阅读代码的人和编译器表明该函数无意抛出异常,但实际上并不能防止函数抛出异常或调用可能抛出异常的函数。实际上,只有在确定它调用的所有函数(直接或间接)也都声明为
noexcept
时,才最好将函数声明为
noexcept
。在实践中,编译器允许
noexcept
函数包含
throw
语句或调用可能抛出异常的函数。若抛出异常,必须在该函数内的某个地方捕获且不重新抛出。若未捕获,异常将不会传播到调用函数,而是程序终止。本质上,
noexcept
防止异常离开函数。例如:
void f(int *p) noexcept
{
if(*p == 10)
throw *p; // Create an exception.
}
编译不会失败,但编译器可能会发出警告消息。若
throw
被执行,异常不会传播,由于未被捕获,程序将终止。当然,可以将函数代码放在
try
块中,例如:
void f(int *p) noexcept
{
try
{
...
}
catch(...)
{
}
}
3.4 noexcept 运算符
C++11 还引入了
noexcept
运算符,它是一个一元运算符,根据其操作数是否可能抛出异常返回
true
或
false
。与
sizeof
类似,
noexcept
不计算操作数。例如:
noexcept(f(&i)) /* Since f() is declared not to create exceptions the
operator returns true. */
void h();
noexcept (h()) /* Since h() is not declared not to create exceptions the
operator returns false. */
noexcept
运算符的返回值可作为参数,用于声明函数是否可能抛出异常。例如:
void a() noexcept(noexcept(b())) /* If b() does not create exceptions,
neither does a(). If b() may create exceptions, a() may also create
exceptions. */
4. 异常的讨论
4.1 异常与传统错误处理的比较
有人可能会问,为什么不使用传统技术,即让函数返回对应错误的特定值,让调用函数检查返回值并处理错误。当然可以这样做,但并非总是可行。例如,函数返回的整数值可能都是有效的,没有一个可用于错误编码。即使可行,每次函数调用后都进行这些检查,不仅繁琐,还会显著增加程序的大小。而通过将函数调用放在
try
块中,
catch
块只需定义一次。
4.2 异常在对象创建和销毁中的应用
有时无法返回值,例如在创建对象时,若类的构造函数中发生错误(如错误的初始化值),没有返回值可检查。有人可能会说,可以在类中定义一个私有变量来保存对象的状态,每次构造对象时用公共函数检查其值。若值为 0,表示对象构造成功;否则,表示错误代码。但实际上,构造函数抛出异常来通知我们失败要简单得多。对于析构函数,一般来说,析构函数不应抛出异常。若抛出,应自行捕获并处理,否则可能会发生内存泄漏和程序的不可预测行为。
4.3 异常在函数调用链中的优势
在某些情况下,错误需要通过函数调用链传递给原始调用者。例如,若在嵌套函数中发生错误(如
a()
调用
b()
,
b()
调用
c()
,
c()
调用
d()
且错误发生在
d()
中),可以抛出异常并立即通知进行第一次调用的函数(如
a()
)。传统方法是从每个函数返回值给其直接调用者,一直到第一个调用者(如
a()
),这需要更多代码,使程序更难读、复杂、低效且容易出错,因为程序员可能会忘记转发某些返回值。
4.4 异常使用的灵活性
需要明确的是,并非总是推荐使用异常。将错误检测与处理分离确实能简化程序并使其更易读,但这并非适用于所有情况。每个程序都有自己的要求。例如,若程序接受用户输入,输入错误数据并非严重问题,无需抛出异常,程序可以简单地丢弃输入并提示用户输入新数据。在这种情况下,处理错误输入的代码与处理整体数据输入过程的代码结合在一起。毕竟,“异常”这个名称意味着它用于表示特殊错误,而非琐碎错误。在另一个例子中,当潜在故障是正常情况时,使用
if-else
条件检查返回值的传统方法可能就足够了。当然,可以同时使用返回值和异常,没有一个规则适用于所有应用程序,由程序员决定。若混合使用能提高代码的理解和维护性,那可能是最佳实践。此外,每个程序员对如何处理错误的看法可能不同。例如,你可能认为某个特定错误非常严重,无法解决,因此无需抛出异常,而是决定显示诊断消息或写入文件并调用函数(如
exit()
)来终止程序。而其他人可能决定抛出异常,让更高级别来处理错误。
4.5 异常在大型应用中的重要性
在大型应用程序中,很可能需要使用异常机制,例如当使用会抛出异常的库时。一般来说,在大型应用程序的开发中,不同的程序员团队分别实现不同的部分,为了更好地控制和维护程序,对于在不同部分发生且无法在本地处理的严重错误,最灵活、可靠和有效的错误管理策略是使用异常,以便将错误转发到中央点(如
main()
)进行处理。
5. 类类型的异常
5.1 抛出对象作为异常的优势
除了使用基本数据类型(如
int
)创建异常外,异常也可以抛出对象,这通常是更常见的选择。这样做有两个主要优势:一是可以使用不同的对象来区分导致异常的不同类型的问题;二是对象可以包含足够的信息来识别导致异常的原因。
5.2 示例:使用对象异常的密码验证程序
以下是修改后的密码验证程序,使用对象抛出异常:
#include <iostream> // Example 22.3
#include <string>
using std::cout;
using std::cin;
using std::string;
class Err_Rpt
{
private:
int code;
string msg;
public:
Err_Rpt(): code(0), msg("") {}
Err_Rpt(int c, const char *m) : code(c), msg(m) {}
void show() const {cout << "C:" << code << ' ' << msg;}
};
int check_pswd(const string& str);
int main()
{
string pswd;
while(1)
{
try
{
cout << "Enter password: ";
cin >> pswd;
check_pswd(pswd);
break;
}
catch(const Err_Rpt& err)
{
err.show();
}
catch(int code)
{
cout << "C:" << code << ' ' << "Error: Password must
contain one special character at least\n";
}
catch(...)
{
cout << "Error: Another exception\n";
}
}
cout << "Password " << pswd << " is accepted !!!\n";
return 0;
}
int check_pswd(const string& str)
{
bool found;
int i, len, dig;
len = str.size();
if(len < 6)
throw Err_Rpt(5, "Error: Short length\n"); /* Create an exception
with type an object of the Err_Rpt class. */
dig = 0;
found = false;
for(i = 0; i < len; i++)
{
if(str[i] >= '0' && str[i] <= '9')
dig++;
if(str[i] == '!' || str[i] == '$' || str[i] == '#')
found = true;
}
if(dig < 3)
throw Err_Rpt(10, "Error: Password must contain three digits at
least\n");
if(found == false)
throw 20;
return 0;
}
在这个程序中,
check_pswd()
抛出一个
int
类型的异常和两个
Err_Rpt
对象类型的异常。每个对象用指示错误类型的值进行初始化,这些异常被相应的
catch
块捕获,
catch
块调用传递对象的
show()
方法来显示其成员中包含的信息。
5.3 异常对象的传递方式
有人可能会问,是否可以传递对象而不是引用,写成
catch(Err_Rpt err);
,或者传递基本类型的引用,写成
catch(int& code);
。当然可以。实际上,当抛出异常时,即使指定了引用,实际传递的也是对象的副本。这是因为
Err_Rpt
对象在
check_pswd()
结束后就不存在了。具体来说,当抛出异常对象时,由于
try
块退出且将超出作用域,首先将其复制到一个临时对象,然后销毁原对象。因此,从
try
块内部抛出局部对象没有问题。需要注意的是,在多级异常系统中,对象可能会被多次复制,直到最终被捕获,因此最好不要包含过多信息。
6. 异常与继承
6.1 引用在异常处理中的作用
在异常处理中使用引用的原因是,正如我们在第 20 章中所知,基类的引用可以引用从它派生的类的对象。例如,在以下程序中,有三个相关的异常类:
#include <iostream> // Example 22.4
class Err1
{
public:
virtual void show() const {std::cout << "Err1\n";}
};
class Err2 : public Err1
{
public:
virtual void show() const override {std::cout << "Err2\n";}
};
class Err3 : public Err2
{
public:
virtual void show() const override {std::cout << "Err3\n";}
};
void f();
int main()
{
try
{
f();
}
catch(const Err1& err)
{
err.show();
}
return 0;
}
void f()
{
throw Err3();
}
由于
catch
块中的参数是基类的引用,它可以匹配任何派生类的异常。因为
show()
是虚函数,执行哪个
show()
方法取决于
err
所引用的对象的类型。因此,程序显示
Err3
。若不使用引用,基类的复制构造函数将被调用来复制派生对象的基类子对象,派生部分将被截断。若关于错误的诊断信息在该部分,将丢失。因此,安全的做法是使用引用。在我们的例子中,若不使用引用,总是会调用
Err1
的
show()
方法,程序将显示
Err1
,而不管
throw
的对象参数是什么。
6.2 异常抛出和捕获的最佳实践
在抛出异常时,最佳实践是抛出对象,一般规则是按值抛出对象,按引用捕获对象。若要不同地处理每个异常,可以为每种类型添加单独的
catch
语句,但要注意顺序。
catch
块的顺序应该是派生顺序的逆序,即从最后派生的类到基类。例如:
int main()
{
int i;
try
{
f();
}
catch(const Err3& err)
{
i = 3;
}
catch(const Err2& err)
{
i = 2;
}
catch(const Err1& err)
{
i = 1;
}
return 0;
}
若
Err1&
catch
块放在第一位,它将捕获所有
Err1
、
Err2
和
Err3
异常,
i
的值将始终为 1。在异常类的继承层次结构中,第一个
catch
块应引用最后派生的类,最后一个块应引用基类。
6.3 虚函数的异常声明规则
需要注意的是,若基类中的虚函数声明为不抛出异常,派生类中声明时也必须如此。另一方面,若虚函数可以抛出异常,在派生类中可以声明它不抛出。例如:
class A
{
public:
virtual void f() noexcept {};
virtual void g() {};
};
class B : public A
{
public:
virtual void f() {}; // Wrong.
virtual void f() noexcept override {}; // Correct.
virtual void g() noexcept override {}; // Correct.
};
7. 异常转发
7.1 异常处理的多级系统
异常处理系统可以是多级的,即每一级尽可能处理更多的问题,将其余问题留给更高级别。若当前函数未捕获异常,异常将被转发到其调用函数,再到更高级别的调用函数,一直到
main()
,直到在路径中找到能捕获异常的
catch
块。若
main()
未捕获异常,程序默认终止。这个过程称为栈展开。例如:
int main() f() g() h()
{ { { {
... ... ... ...
try try try throw 20;
{ { { }
f(); g(); h();
} } }
catch(int) catch(double) catch(const char*)
{ { {
... ... ...
} } }
} } }
h()
中的异常被传递到更高级别,最终被
main()
的
catch
块捕获,该块可以处理
int
类型的异常。若将
throw 20;
改为
throw 1.2;
,该异常将被
f()
的
catch
块捕获;若写成
throw "Test";
,将被
g()
的
catch
块捕获。若声明了
A
类,
a
是其对象,写成
throw a;
,该异常将到达
main()
,由于没有
A
类型的
catch
块,程序将终止。
7.2 throw 机制的重要特性
这个例子还展示了
throw
机制的一个非常重要的方面。
return
将程序的执行转移到函数调用后的第一条语句,而
throw
将执行一直转移到能捕获异常的第一个
catch
块。
8. 异常重抛
8.1 异常重抛的场景
当
catch
块捕获到异常时,可能无法处理它,或者认为更高级别处理更好。在这种情况下,可以重抛异常,将其转发到更高级别、与异常类型更相关的地方进行处理。要实现这一点,只需编写
throw
,无需任何参数。
throw
必须在
catch
块内或从
catch
块调用(直接或间接在调用链中)的函数内。否则,调用
throw
会导致程序终止。重抛的异常是原来捕获的异常。
8.2 示例:异常重抛程序
以下是一个示例程序:
#include <iostream> // Example 22.5
void f();
void g();
int main()
{
try
{
f();
}
catch(int a)
{
std::cout << "int exception is caught: " << a << '\n';
}
return 0;
}
void f()
{
try
{
g();
}
catch(int) /* Since the catch block does not use the argument I don't
use a parameter name. */
{
std::cout << "int exception is rethrown\n";
throw; // Rethrow the exception.
}
std::cout << "f() terminates\n"; /* Since throw rethrows the
exception, this statement won't be executed. */
}
void g()
{
throw 10;
}
g()
抛出的
int
异常被
f()
中的
catch
块捕获,
f()
重抛该异常,最终在
main()
中被捕获。
throw
的原始参数也被传递到
main()
,因此程序显示:
int exception is rethrown
int exception is caught: 10
8.3 嵌套 try 块的处理
这个程序也是嵌套
try
块的一个例子。如前面所见,一个
try
块及其
catch
块可以嵌套在另一个
try
块中。在这个例子中,外部块在
main()
中,内部块在
f()
中。每个
try
块都有自己的一组
catch
块,用于处理可能在其中抛出的异常。当从内部块抛出异常时,首先由其自己的
catch
块处理。若没有匹配的异常类型,将由外部
try
块的
catch
块处理。例如,若在
f()
中将
catch
块的类型从
int
改为
double
,抛出的异常将被传播到
main()
中的外部
try
块,在那里
int
类型的
catch
块捕获异常。
8.4 修改异常初始参数的值
一般来说,若要更改异常初始参数的值,可以将引用传递给
catch
块。例如,更改
f()
的
catch
块:
catch(int& b)
{
b = 20;
std::cout << "int exception is rethrown\n";
throw; // Rethrow the exception.
}
现在,异常重抛时传递给
main()
的值将是 20。
9. 异常与内存管理
9.1 自动变量的内存释放
当执行
throw
语句时,
throw
与捕获异常的
catch
块之间路径上为所有自动变量保留的内存将从栈中释放。若自动变量是对象,将调用其析构函数。例如:
#include <iostream> // Example 22.6
#include <cstdlib> // for the exit()
class A
{
private:
int code;
public:
A(int c) {code = c;}
~A() {std::cout << "a_" << code << " is destroyed\n";}
};
void f();
void g();
int main()
{
try
{
f();
}
catch(int)
{
std::cout << "int exception is caught\n";
exit(EXIT_FAILURE); // I just used exit() to remember it.
}
std::cout << "Program terminates\n";
return 0;
}
void f()
{
A a1(1);
try
{
g();
}
catch(double)
{
std::cout << "double exception is caught\n";
}
std::cout << "f() terminates\n";
}
void g()
{
A a2(2);
throw 10;
std::cout << "g() terminates\n"; /* I put it there just to remember
that since the throw is executed, this statement won't be executed. */
}
g()
抛出的
int
异常从
f()
转发到
main()
,在
f()
和
g()
调用时在栈中分配的内存被释放,
a2
和
a1
对象的内存依次释放,
exit()
终止程序。因此,程序显示:
a_2 is destroyed
a_1 is destroyed
int exception is caught
若将
throw 10;
改为
throw 2.3;
,异常将被
f()
捕获,
f()
终止时调用
a1
的析构函数,然后程序在
main()
中继续执行
catch
块后的下一条语句。因此,程序显示:
a_2 is destroyed
double exception is caught
f() terminates
a_1 is destroyed
Program terminates
9.2 动态分配内存的处理
对于动态分配的内存,情况有所不同。例如:
void f()
{
int i, *p = new int[10];
for(i = 0; i < 10; i++)
{
cin >> p[i];
if(p[i] == -1)
throw 10;
}
delete[] p;
}
若抛出异常,为自动变量
i
和
p
保留的内存将被释放,但
p
指针指向的内存不会被释放,即会发生内存泄漏。解决这个问题的一种方法是在
throw
之前添加另一个
delete[] p;
。关键是程序员有责任在抛出异常之前释放动态分配的内存。另一种方法是使用标准库的智能指针,这将在第 24 章讨论。需要注意的是,安全地处理异常以确保没有内存泄漏、对象处于有效状态以及程序尽可能正常运行是一项困难的任务,涉及的技术超出了本书的范围。
10. 标准异常
10.1 标准库提供的异常类
标准库提供了几个异常类,可在程序中使用。它们位于
std
命名空间中,都派生自
exception
类,
exception
类在
exception
头文件中定义。例如,以下程序抛出这样一个异常:
#include <iostream> // Example 22.7
#include <exception>
void f();
int main()
{
try
{
f();
}
catch(std::exception& e)
{
std::cout << e.what() << '\n';
}
return 0;
}
void f()
{
throw std::exception();
}
what()
成员函数被声明为虚函数,它返回一个字符串,该字符串依赖于具体实现。其在
exception
类中的声明如下:
virtual const char* what() const noexcept;
若要指定返回的字符串,可以在从
exception
派生的类中重写它,并抛出该类的异常。例如:
class SomeErr : public std::exception
{
public:
virtual const char *what() const noexcept override {return "Unexpected
error\n";}
};
void f()
{
throw SomeErr();
}
要处理派生类的异常,可以仍然使用基类引用的同一个
catch
块。若要不同地处理它们,只需在其之前添加一个
SomeErr
类型的
catch
块。
10.2 常见的标准异常类型
标准库提供了许多异常类型,这里仅提及一些。从
exception
类派生了
logic_error
和
runtime_error
类,这些类以及从它们派生的类在
stdexcept
文件中定义。从
logic_error
类派生了
domain_error
、
invalid_argument
、
length_error
和
out_of_range
类,它们处理逻辑错误。简要来说,
domain_error
用于指示函数有效域的违反(如尝试计算负数的平方根),
invalid_argument
用于指示意外参数,
length_error
用于指示没有足够的空间(如向字符串对象添加超过最大允许长度的字符时抛出),
out_of_range
用于指示超出范围的值(如用越界索引访问向量对象时抛出)。例如,假设有一个
Rectangle
类,若在构造函数中传递了错误的维度,可以抛出
out_of_range
异常:
Rectangle::Rectangle(double w, double h)
{
if(w <= 0 || h <= 0)
throw out_of_range("Wrong dimension");
...
}
从
runtime_error
类派生了
range_error
、
overflow_error
和
underflow_error
类,它们处理与有效值范围的违反或在算术计算中无法表示值(如由于溢出)相关的错误。有关异常类型的更多信息,请参考标准库文档。
10.3 bad_alloc 异常
bad_alloc
异常在使用
new
和
new[]
运算符进行内存分配失败时抛出,例如由于内存不足。
bad_alloc
类在
new
文件中定义,派生自
exception
。例如:
#include <iostream> // Example 22.8
#include <cstdlib>
#include <new>
int main()
{
int num;
double *p;
try
{
std::cout << "Enter number: ";
std::cin >> num;
p = new double[num];
}
catch(const std::bad_alloc& e)
{
std::cout << e.what() << '\n';
exit(EXIT_FAILURE);
}
std::cout << "Successful allocation\n";
delete[] p;
return 0;
}
若内存分配失败,抛出
bad_alloc
异常,程序显示相应消息并终止。标准还提供了让
new
运算符返回空指针而不创建
bad_alloc
异常的能力。在这种情况下,代码如下:
p = new (std::nothrow) double[num];
if(p == nullptr)
{
std::cout << "Memory allocation error\n";
exit(EXIT_FAILURE);
}
11. 未捕获异常的处理
11.1 未捕获异常的默认行为
如前所述,未捕获的异常默认会导致程序终止。例如,若没有找到与抛出的异常匹配的
catch
块,或者创建异常的函数不在
try
块中,就会发生这种情况。若异常未被捕获,将调用
terminate()
库函数,该函数默认调用
abort()
函数,从而终止程序。
11.2 修改 terminate() 的行为
标准允许我们更改
terminate()
的行为,使其调用另一个函数而不是
abort()
。为此,需要使用
set_terminate()
函数。
terminate()
和
set_terminate()
函数在
exception
文件中声明。
set_terminate()
的声明如下:
typedef void (*terminate_handler)();
terminate_handler set_terminate(terminate_handler p);
即
set_terminate()
接受一个指向无参数、返回类型为
void
的函数的指针作为参数,它返回前一个终止处理函数的地址(如果有的话)。一般来说,除非想在程序退出前做一些“最后的”事情,如输出一些诊断消息以帮助定位问题,或向环境返回特定的错误代码以指示失败,否则不建议重写
terminate()
的行为。需要注意的是,注册的函数在执行任何所需任务后应终止程序。若不这样做并返回调用者,程序的行为将是未定义的。
11.3 示例:自定义终止函数
以下程序注册了一个新的终止函数,在发生未捕获异常时,该函数写入一条消息并调用
abort()
:
#include <iostream> // Example 22.9
#include <exception>
#include <cstdlib>
void end_prg();
void f();
int main()
{
std::set_terminate(end_prg); // Set a new terminate function.
try
{
f();
}
catch(int)
{
}
return 0;
}
void f()
{
throw 1.2;
}
void end_prg()
{
std::cout << "Unhandled exception. Program terminates\n";
abort();
}
由于异常未被捕获,程序调用
terminate()
,
terminate()
调用
end_prg()
,
end_prg()
显示消息,
abort()
终止程序。需要注意的是,可以使用
catch(...)
块捕获未知类型的异常,这样可以防止程序终止,但仍然需要处理异常。
12. 练习题
12.1 递归函数异常抛出
编写一个递归函数,接受一个整数参数(如
num
),当它自己调用
num
次时抛出代码为 10 的
int
表达式。编写一个程序,读取一个整数,调用该函数,并捕获抛出的异常。
#include <iostream>
void f(int num);
int main()
{
int i;
std::cout << "Enter number: ";
std::cin >> i;
try
{
f(i);
}
catch(int c)
{
std::cout << c << '\n';
}
return 0;
}
void f(int num)
{
static int cnt = 0;
cnt++;
if(cnt == num)
throw 10;
f(num);
}
12.2 账户类异常处理
定义
Account
类,包含私有成员账户持有人姓名(如
name
)和账户余额(如
bal
)。该类应包含
create_acnt()
公共函数,在创建
Account
对象后调用,接受持有人姓名和初始存款金额作为参数。姓名必须只包含字符,若不包含或金额值为负,
create_acnt()
应创建一个
Err_Rpt
类对象类型的异常并附带相关信息消息。若参数有效,程序应将它们分别存储在
name
和
bal
成员中。程序应持续读取姓名和存款金额,直到它们有效,即
create_acnt()
不抛出异常。接下来,程序应读取一个金额并调用
deposit()
和
withdraw()
函数,这两个函数是
Account
的公共函数,分别接受要添加或扣除的金额作为参数。若金额无效,函数应抛出
Err_Rpt
类型的异常;若有效,应相应更新
bal
值。最后,程序应调用公共
show()
函数,显示
name
和
bal
的值。
#include <iostream>
#include <string>
using std::cout;
using std::cin;
using std::string;
class Err_Rpt
{
public:
string msg;
Err_Rpt(const char *m) : msg(m) {}
};
class Account
{
private:
string name;
double bal;
public:
void create_acnt(const string& n, double amnt);
void deposit(double amnt);
void withdraw(double amnt);
void show() const {cout << "N:" << name << ' ' << "B:" << bal << '\n';}
};
void Account::create_acnt(const string& n, double amnt)
{
int i;
for(i = 0; i < n.size(); i++)
{
if((n[i] >= 'a' && n[i] <= 'z') ||
(n[i] >= 'A' && n[i] <= 'Z'))
continue;
else
throw Err_Rpt("Error: Name must contain letters only\n");
}
if(amnt < 0)
throw Err_Rpt("Error: Not acceptable amount\n");
name = n;
bal = amnt;
}
void Account::deposit(double amnt)
{
if(amnt < 0)
throw Err_Rpt("Error: Not acceptable amount to deposit\n");
bal += amnt;
}
void Account::withdraw(double amnt)
{
if(amnt < 0 || amnt > bal)
throw Err_Rpt("Error: Not acceptable amount to withdraw\n");
bal -= amnt;
}
int main()
{
double amnt;
string name;
Account acnt;
while(1)
{
try
{
cout << "Enter initial amount: ";
cin >> amnt;
cin.get();
cout << "Enter name: ";
getline(cin, name);
acnt.create_acnt(name, amnt);
break;
}
catch(const Err_Rpt& err)
{
cout << err.msg;
}
}
try
{
cout << "Enter amount to deposit: ";
cin >> amnt;
acnt.deposit(amnt);
cout << "Enter amount to withdraw: ";
cin >> amnt;
acnt.withdraw(amnt);
}
catch(const Err_Rpt& err)
{
cout << err.msg;
return 0;
}
acnt.show();
return 0;
}
12.3 圆形和椭圆类异常处理
定义
Circle
类,包含私有成员圆的半径(如
rad
)。从
Circle
类以公共访问方式派生
Ellipse
类,包含私有成员椭圆的半长轴(如
axis
)。定义
f()
函数,接受一个对象的引用和一个整数值作为参数并进行减法运算。在类中添加适当的函数,使以下程序正常工作:
int main()
{
Circle cir(5); // The radius of the circle should become 5.
Ellipse ell(10, 6); /* The radius of the circle should become 10 and
the axis of the ellipse 6. The constructor of the Ellipse should call the
constructor of the Circle. */
Circle &r1 = cir, &r2 = ell;
/* The following calls of f() should be placed in a try-catch section
and if an exception occurs the program should catch it, display a
corresponding message and terminate. */
f(r1, 3);
f(r2, 1);
f(r2, 10);
return 0;
}
void f(???? &c, int n) // Find the reference type.
{
???? tmp = c-n; /* Find the type of tmp. If the type of c is Circle,
the function should make the subtraction and reduce the radius of c by n.
If the new value of the radius is negative, the function should create an int
exception with code 10. If the type of c is Ellipse, the function should
reduce the radius and axis of c by n. If the new value of either the radius
or axis is negative, the function should create an int exception with code 10
and 20, respectively.
tmp.show(); /* If no exception is thrown, the program should display
the values of the tmp members. In this example, in the first call of f(),
the program should display 2 (5-3), in the second call it should display 9
(10-1) and 5 (6-1), while in the third call it must throw an exception with
code 20 since the new value of the axis is negative. */
}
答案如下:
#include <iostream>
class Circle
{
private:
float rad;
public:
Circle(float r) {rad = r;}
virtual ~Circle() {};
float get() const {return rad;}
void set(float r) {rad = r;}
virtual Circle& operator-(int n);
virtual void show() const;
};
class Ellipse : public Circle
{
private:
float axis;
public:
Ellipse(float r, float a) : Circle(r), axis(a) {}
virtual Ellipse& operator-(int n) override;
virtual void show() const override;
};
void f(Circle& c, int n);
Circle& Circle::operator-(int n)
{
rad -= n;
if(rad < 0)
throw 10;
return *this;
}
void Circle::show() const
{
std::cout << "R:" << rad << '\n';
}
Ellipse& Ellipse::operator-(int n)
{
float rad;
rad = get()-n;
if(rad < 0)
throw 10;
set(rad);
axis -= n;
if(axis < 0)
throw 20;
return *this;
}
void Ellipse::show() const
{
std::cout << "R:" << get() << " A:" << axis << '\n';
}
void f(Circle& c, int n)
{
Circle& tmp = c-n;
tmp.show();
}
int main()
{
Circle cir(5);
Ellipse ell(10, 6);
Circle &r1 = cir, &r2 = ell;
try
{
f(r1, 3);
f(r2, 1);
f(r2, 10);
}
catch(int a)
{
if(a == 10)
std::cout << "Error: negative radius\n";
else if(a == 20)
std::cout << "Error: negative axis\n";
return 0;
}
return 0;
}
注释:由于
rad
是私有成员,为了让
Ellipse
能够访问它,在
Circle
中添加了公共的
set/get
函数。因为在
f()
中看到一个整数从一个对象中减去,所以需要重载
-
运算符。为了根据
c
引用的对象类型调用
operator-()
,该函数必须在基类中声明为虚函数。在
f()
中,
c
声明为基类的引用,这样可以传递派生类的引用(如
r2
)并利用虚函数机制。由于
show()
可以显示
Circle
和
Ellipse
成员的值,所以它必须声明为虚函数。注意
tmp
的类型,它必须是基类的引用,这样才能调用
tmp
所引用的类的
show()
方法。
tmp
引用的是
operator-()
返回的对象。若不声明为引用,程序将总是调用
Circle
的
show()
方法。
13. 未解决的练习题
13.1 A 和 B 类异常处理
定义
A
和
B
类,
B
类应包含五个
A
对象作为公共成员。当创建第三个
A
对象时,
A
类应抛出带有
No
消息的异常,程序应捕获该异常,显示消息并终止。
A
的析构函数会被调用多少次?编写一个程序验证答案。
A
的构造函数不应接受参数。
13.2 StrChk 类异常处理
定义
StrChk
类,包含一个
string
类型的私有成员(如
name
)。在类中添加适当的函数,使以下程序正常工作。在发生异常时,使用以下
Err_Rpt
类型,程序应显示
msg
中包含的消息。
class Err_Rpt
{
private:
string msg;
public:
Err_Rpt(const char *m) : msg(m) {}
// Add any function you think it is needed.
};
int main()
{
/* The following code should be placed in a try-catch block and
if the Err_Rpt exception occurs the program should capture it, display
the msg and terminate. */
StrChk s1("First"), s2("Sec"); /* If the length of the string is
less than 3, the constructor should throw an exception. Otherwise, the
argument should be copied into name. */
StrChk s3 = s1+s2; /* With s1+s2 the name members should be
merged (e.g., s3.name should become FirstSec). If the length of the new
string is greater than 10, the program should throw an exception. */
s3[10] = 'a'; /* If the index is out of bounds, the program
should throw an exception. That is, in this example, an exception should
be thrown. */
/* Experiment with the lengths of the strings, for example, change
s1 and s2 to test the operation of the program. For example, if you
change Sec to Second the program should throw the second exception. */
s3.show();
return 0;
}
通过对 C++ 异常处理的全面学习,我们了解了异常的抛出、捕获
和处理机制,以及如何在不同场景下运用异常来优化程序的健壮性和可维护性。异常处理是 C++ 编程中不可或缺的一部分,它能帮助我们更好地应对运行时错误,提高程序的稳定性。
13.3 异常处理的总结与回顾
在学习了 C++ 异常处理的众多方面后,我们来总结一下关键要点:
1.
异常的基本概念
:异常是 C++ 中用于处理运行时错误的机制,它能将错误检测和处理分离,使代码更清晰。
2.
异常的抛出与捕获
:使用
throw
语句抛出异常,
try-catch
块捕获异常。
catch
块可以根据异常类型进行不同的处理。
3.
异常与继承
:在异常处理中,基类引用可以引用派生类对象,利用多态性实现更灵活的异常处理。但要注意
catch
块的顺序,应从派生类到基类。
4.
异常转发与重抛
:异常可以在多级函数调用中转发,若当前
catch
块无法处理异常,可以使用
throw
重抛异常。
5.
异常与内存管理
:异常处理时要注意自动变量和动态分配内存的释放,避免内存泄漏。
6.
标准异常
:C++ 标准库提供了许多异常类,可用于常见的错误处理,如
logic_error
、
runtime_error
等。
7.
未捕获异常的处理
:未捕获的异常默认会导致程序终止,可以使用
set_terminate
函数自定义终止行为。
13.4 异常处理的实际应用案例分析
为了更好地理解异常处理在实际项目中的应用,我们来看几个具体案例。
案例一:文件操作异常处理
在进行文件操作时,可能会遇到文件打开失败、读取错误等问题。以下是一个简单的文件读取程序,使用异常处理来应对可能的错误:
#include <iostream>
#include <fstream>
#include <string>
void readFile(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
throw std::runtime_error("Failed to open file: " + filename);
}
std::string line;
while (std::getline(file, line)) {
std::cout << line << std::endl;
}
file.close();
}
int main() {
try {
readFile("test.txt");
} catch (const std::exception& e) {
std::cerr << "Exception caught: " << e.what() << std::endl;
}
return 0;
}
在这个案例中,
readFile
函数尝试打开指定的文件,如果打开失败,抛出一个
std::runtime_error
异常。在
main
函数中,使用
try-catch
块捕获并处理该异常,输出错误信息。
案例二:网络请求异常处理
在进行网络请求时,可能会遇到连接超时、服务器错误等问题。以下是一个简单的网络请求模拟程序,使用异常处理来应对可能的错误:
#include <iostream>
#include <stdexcept>
// 模拟网络请求函数
void makeNetworkRequest() {
// 模拟连接超时
bool connectionTimedOut = true;
if (connectionTimedOut) {
throw std::runtime_error("Network connection timed out");
}
// 模拟服务器错误
bool serverError = false;
if (serverError) {
throw std::runtime_error("Server returned an error");
}
std::cout << "Network request successful" << std::endl;
}
int main() {
try {
makeNetworkRequest();
} catch (const std::exception& e) {
std::cerr << "Exception caught: " << e.what() << std::endl;
}
return 0;
}
在这个案例中,
makeNetworkRequest
函数模拟了网络请求过程,根据不同的错误情况抛出相应的
std::runtime_error
异常。在
main
函数中,使用
try-catch
块捕获并处理该异常,输出错误信息。
13.5 异常处理的性能考虑
虽然异常处理能提高程序的健壮性,但也会带来一定的性能开销。在异常抛出和捕获过程中,会涉及到对象的复制、栈展开等操作,这些操作会消耗一定的时间和资源。因此,在使用异常处理时,需要权衡性能和代码的可读性与可维护性。
一般来说,对于不频繁发生的错误,使用异常处理是合适的,因为它能使代码更清晰。但对于频繁发生的错误,如输入验证错误,使用传统的错误处理方式(如返回错误码)可能更高效。
13.6 异常处理的最佳实践总结
为了更好地使用异常处理,以下是一些最佳实践建议:
1.
明确异常类型
:使用有意义的异常类型,避免使用通用的异常类型,如
catch(...)
。这样可以使代码更易读和维护。
2.
避免过度使用异常
:异常处理应该用于处理真正的异常情况,而不是用于正常的程序流程控制。
3.
保持异常信息清晰
:在抛出异常时,提供足够的信息,方便调试和定位问题。
4.
异常处理的层次结构
:在多级函数调用中,合理设计异常处理的层次结构,使异常能够在合适的层次被捕获和处理。
5.
异常安全
:确保在异常发生时,程序的状态仍然是一致的,避免数据损坏和内存泄漏。
6.
测试异常处理
:编写测试用例,确保异常处理代码能够正常工作。
13.7 异常处理的未来发展趋势
随着 C++ 语言的不断发展,异常处理机制也可能会有一些改进和优化。例如,未来可能会有更高效的异常处理实现,减少性能开销。同时,可能会引入更多的标准异常类型,以满足不同场景的需求。
另外,随着软件系统的复杂性不断增加,异常处理在分布式系统、并发编程等领域的应用也会越来越重要。未来的异常处理机制可能会更好地支持这些复杂场景,提供更强大的功能。
13.8 总结与展望
通过对 C++ 异常处理的深入学习,我们了解了异常处理的基本概念、机制和应用。异常处理是 C++ 编程中非常重要的一部分,它能帮助我们编写更健壮、更可靠的代码。
在实际项目中,我们应该根据具体情况合理使用异常处理,遵循最佳实践原则,提高代码的质量和可维护性。同时,我们也应该关注异常处理的未来发展趋势,不断学习和掌握新的技术和方法。
希望本文能对大家理解和使用 C++ 异常处理有所帮助,在今后的编程中能够灵活运用异常处理机制,应对各种运行时错误。
13.9 练习题答案与解析
练习题 13.1 答案与解析
#include <iostream>
class A {
public:
A() {
static int count = 0;
count++;
if (count == 3) {
throw std::string("No");
}
}
~A() {
std::cout << "A destructor called" << std::endl;
}
};
class B {
public:
A a[5];
};
int main() {
try {
B b;
} catch (const std::string& e) {
std::cout << e << std::endl;
}
return 0;
}
解析:在
A
类的构造函数中,使用静态变量
count
记录创建对象的次数。当创建第三个对象时,抛出一个
std::string
类型的异常。在
main
函数中,使用
try-catch
块捕获该异常并输出消息。由于异常抛出时,已经创建的前两个
A
对象会被销毁,所以
A
的析构函数会被调用两次。
练习题 13.2 答案与解析
#include <iostream>
#include <string>
class Err_Rpt {
private:
std::string msg;
public:
Err_Rpt(const char *m) : msg(m) {}
const std::string& getMsg() const {
return msg;
}
};
class StrChk {
private:
std::string name;
public:
StrChk(const std::string& str) {
if (str.length() < 3) {
throw Err_Rpt("String length is less than 3");
}
name = str;
}
StrChk operator+(const StrChk& other) const {
std::string newStr = name + other.name;
if (newStr.length() > 10) {
throw Err_Rpt("New string length is greater than 10");
}
return StrChk(newStr);
}
char& operator[](size_t index) {
if (index >= name.length()) {
throw Err_Rpt("Index is out of bounds");
}
return name[index];
}
void show() const {
std::cout << name << std::endl;
}
};
int main() {
try {
StrChk s1("First"), s2("Sec");
StrChk s3 = s1 + s2;
s3[10] = 'a';
s3.show();
} catch (const Err_Rpt& e) {
std::cerr << "Exception caught: " << e.getMsg() << std::endl;
}
return 0;
}
解析:
StrChk
类的构造函数检查字符串长度,若小于 3 则抛出异常。
operator+
函数将两个
StrChk
对象的
name
成员合并,若新字符串长度大于 10 则抛出异常。
operator[]
函数检查索引是否越界,若越界则抛出异常。在
main
函数中,使用
try-catch
块捕获并处理这些异常。
13.10 总结表格
| 异常处理要点 | 详细说明 |
|---|---|
| 异常基本概念 | 分离错误检测和处理,使代码更清晰 |
| 抛出与捕获 |
throw
抛出,
try-catch
捕获,根据类型处理
|
| 异常与继承 |
基类引用可引用派生类对象,注意
catch
顺序
|
| 转发与重抛 |
多级调用中转发,
throw
重抛异常
|
| 内存管理 | 注意自动变量和动态内存释放,避免泄漏 |
| 标准异常 | 标准库提供多种异常类,用于常见错误处理 |
| 未捕获异常 | 默认终止程序,可自定义终止行为 |
| 最佳实践 | 明确类型,避免过度使用,保持信息清晰等 |
13.11 异常处理流程图
graph TD;
A[程序开始] --> B[执行可能抛出异常的代码];
B --> C{是否抛出异常};
C -- 是 --> D[执行 throw 语句,抛出异常];
D --> E[栈展开,寻找匹配的 catch 块];
E --> F{是否找到匹配的 catch 块};
F -- 是 --> G[执行 catch 块中的代码];
G --> H[继续执行后续代码];
F -- 否 --> I[调用 terminate 函数,程序终止];
C -- 否 --> H[继续执行后续代码];
通过以上的总结和分析,我们对 C++ 异常处理有了更全面和深入的理解。希望大家在实际编程中能够灵活运用这些知识,编写出高质量、健壮的代码。
超级会员免费看
1585

被折叠的 条评论
为什么被折叠?



