异常和错误处理
为什么要使用异常?
使用异常对我有什么好处?基本答案是:使用异常进行错误处理可以使你的代码更简单、更清晰、更不容易遗漏错误。但是,“老式的 errno
和 if
语句”有什么问题呢?基本答案是:使用它们,你的错误处理和正常代码会紧密地交织在一起。这样,你的代码就会变得混乱,并且很难确保你已经处理了所有错误(想想“意大利面条式代码”或“一团乱麻的测试”)。
首先,有些事情没有异常是无法正确完成的。考虑在构造函数中检测到的错误;你如何报告错误?你抛出异常。这是 RAII(资源获取即初始化)的基础,而 RAII 是一些最有效的现代 C++ 设计技术的基础:构造函数的工作是为类建立不变量(创建成员函数运行的环境),这通常需要获取资源,例如内存、锁、文件、套接字等。
想象一下我们没有异常,你将如何处理在构造函数中检测到的错误?请记住,构造函数通常用于初始化/构造变量中的对象
vector<double> v(100000); // needs to allocate memory
ofstream os("myfile"); // needs to open a file
vector
或 ofstream
(输出文件流)构造函数可以将变量设置为“坏”状态(就像 ifstream
默认做的那样),以便所有后续操作都失败。这不理想。例如,对于 ofstream
,如果你忘记检查打开操作是否成功,你的输出就会简单地消失。对于大多数类来说,结果会更糟。至少,我们必须编写
vector<double> v(100000); // needs to allocate memory
if (v.bad()) { /* handle error */ } // vector doesn't actually have a bad(); it relies on exceptions
ofstream os("myfile"); // needs to open a file
if (os.bad()) { /* handle error */ }
对于每个对象来说,这是一项额外的测试(需要编写、记住或遗忘)。对于由多个对象组成的类来说,这会变得非常混乱,特别是如果这些子对象相互依赖的话。有关更多信息,请参阅 The C++ Programming Language 第 8.3 节、第 14 章和 附录 E,或(更学术的)论文 Exception safety: Concepts and techniques。
因此,没有异常编写构造函数可能会很棘手,但是普通的旧函数呢?我们可以返回错误代码或设置非局部变量(例如,errno
)。设置全局变量效果不佳,除非你立即测试它(否则其他函数可能会重新设置它)。如果你可能有多个线程访问全局变量,甚至不要考虑这种技术。返回值的问题在于,选择错误返回值可能需要巧妙的设计,并且可能无法实现
double d = my_sqrt(-1); // return -1 in case of error
if (d == -1) { /* handle error */ }
int x = my_negate(INT_MIN); // Duh?
my_negate()
没有可能的返回值:每个可能的 int
都是某个 int
的正确答案,并且对于二进制补码表示中最负的数没有正确答案。在这种情况下,我们需要返回成对的值(并且像往常一样记住测试)有关更多示例和解释,请参阅 Stroustrup 的 Beginning programming book。
使用异常的常见反对意见
- “但是异常很昂贵!” 不尽然。现代 C++ 实现将使用异常的开销降低到几个百分点(例如,3%),这是与无错误处理进行比较的。编写带有错误返回代码和测试的代码也不是免费的。根据经验,当你没有抛出异常时,异常处理极其便宜。在某些实现中,它不花费任何成本。所有成本都发生在你抛出异常时:也就是说,“正常代码”比使用错误返回代码和测试的代码更快。你只在出现错误时才承担成本。
- “但是 JSF++ Stroustrup 本人直接禁止了异常!” JSF++ 适用于硬实时和安全关键应用(飞行控制软件)。如果计算时间过长,可能会有人死亡。因此,我们必须保证响应时间,而我们目前无法在工具支持级别上做到这一点。在这种情况下,甚至禁止自由存储分配!实际上,JSF++ 关于错误处理的建议模拟了异常的使用,以期待我们拥有正确处理事务的工具的那一天,即使用异常。
- “但是从由
new
调用的构造函数中抛出异常会导致内存泄漏!” 胡说!那是一个老掉牙的说法,是由某个编译器的 bug 引起的——而且那个 bug 在十多年前就被立即修复了。
我如何使用异常?
请参阅 The C++ Programming Language 第 8.3 节、第 14 章和 附录 E。该附录侧重于在要求苛刻的应用程序中编写异常安全代码的技术,不适合初学者。
在 C++ 中,异常用于表示无法在本地处理的错误,例如构造函数中获取资源失败。例如
class VectorInSpecialMemory {
int sz;
int* elem;
public:
VectorInSpecialMemory(int s)
: sz(s)
, elem(AllocateInSpecialMemory(s))
{
if (elem == nullptr)
throw std::bad_alloc();
}
...
};
不要将异常仅仅作为从函数返回值的一种方式。大多数用户都认为——正如语言定义所鼓励的那样——** 异常处理代码是错误处理代码 **,并且实现经过优化以反映这一假设。
一项关键技术是资源获取即初始化(有时缩写为 RAII),它使用带有析构函数的类来强制执行资源管理的顺序。例如
void fct(string s)
{
File_handle f(s,"r"); // File_handle's constructor opens the file called "s"
// use f
} // here File_handle's destructor closes the file
如果 `fct()` 的“使用 f”部分抛出异常,析构函数仍然会被调用,并且文件会正确关闭。这与常见的不安全用法形成对比
void old_fct(const char* s)
{
FILE* f = fopen(s,"r"); // open the file named "s"
// use f
fclose(f); // close the file
}
如果 `old_fct` 的“使用 `f`”部分抛出异常——或者只是简单地返回——文件就不会关闭。在 C 程序中,`longjmp()` 是一个额外的危险。
我不应该将异常用于什么?
C++ 异常旨在支持**错误处理**。
- 仅在表示错误时使用
throw
(具体来说,这意味着函数无法完成其宣称的功能,并建立其后置条件)。 - 仅在你知道可以处理错误时使用
catch
来指定错误处理操作(可能通过将其转换为另一种类型并重新抛出该类型的异常,例如捕获bad_alloc
并重新抛出no_space_for_file_buffers
)。 - **不要**使用
throw
来指示函数使用中的编码错误。使用断言或其他机制,要么将进程发送到调试器,要么使进程崩溃并收集崩溃转储供开发人员调试。 - **不要**在发现组件不变量意外违反时使用
throw
,请使用断言或其他机制终止程序。抛出异常不会修复内存损坏,并可能导致重要用户数据进一步损坏。
在其他语言中还有其他异常用法——很流行——但在 C++ 中并不地道,并且 C++ 实现也故意不支持(这些实现是基于异常用于错误处理的假设进行优化的)。
特别是,不要将异常用于控制流。throw
并非仅仅是函数返回值的另一种方式(类似于 return
)。这样做会很慢,并且会使大多数 C++ 程序员感到困惑,他们理所当然地习惯于只将异常用于错误处理。同样,throw
也不是跳出循环的好方法。
try
/ catch
/ throw
如何提高软件质量?
通过消除 if 语句的其中一个原因。
try
/ catch
/ throw
的常用替代方法是返回一个返回码(有时称为错误码),调用者通过诸如 if
之类的条件语句显式测试它。例如,printf()
、scanf()
和 malloc()
就是这样工作的:调用者应该测试返回值以查看函数是否成功。
尽管返回码技术有时是最合适的错误处理技术,但添加不必要的 if
语句会带来一些糟糕的副作用
- **降低质量:**众所周知,条件语句包含错误的几率大约是其他任何类型语句的十倍。因此,在其他条件相同的情况下,如果你能从代码中消除条件/条件语句,你的代码可能会更健壮。
- **减慢上市时间:** 由于条件语句是分支点,与白盒测试所需的测试用例数量有关,不必要的条件语句会增加用于测试的时间。基本上,如果你不执行每个分支点,你的代码中就会有指令在用户/客户看到之前**从未**在测试条件下执行过。这很糟糕。
- **增加开发成本:** 不必要的控制流复杂性增加了查找、修复和测试错误的成本。
因此,与通过返回码和 if
进行错误报告相比,使用 try
/ catch
/ throw
可能会导致代码中的 bug 更少,开发成本更低,并且上市时间更快。当然,如果你的组织没有任何使用 try
/ catch
/ throw
的经验知识,你可能希望先在一个玩具项目上使用它,以确保你了解自己在做什么——在将武器带到枪战前线之前,你应该始终在射击场上习惯它。
我仍然不相信:一个 4 行代码片段表明返回码并不比异常差;那我为什么要在数量级更大的应用程序中使用异常呢?
因为异常比返回码更好地扩展。
一个 4 行示例的问题在于它只有 4 行。给它 4000 行,你就会看到不同。
这是一个经典的 4 行示例,首先是带异常的
try {
f();
// ...
} catch (std::exception& e) {
// ...code that handles the error...
}
这是相同的例子,这次使用返回码(`rc` 代表“返回码”)
int rc = f();
if (rc == 0) {
// ...
} else {
// ...code that handles the error...
}
人们指着那些“玩具”例子说:“异常并没有提高编码、测试或维护成本;那我为什么要在‘真实’项目中用它们呢?”
原因:异常能帮助你处理实际应用程序。在玩具示例上你可能不会看到太多(如果有的话)好处。
在现实世界中,**检测**问题的代码通常必须将错误信息传播回一个不同的函数来**处理**问题。这种“错误传播”通常需要经过几十个函数——`f1()` 调用 `f2()` 调用 `f3()` 等等,并且在 `f10()`(或 `f100()`)中发现了问题。有关问题的信息需要一直传播回 `f1()`,因为只有 `f1()` 有足够的上下文来实际知道如何处理问题。在交互式应用程序中,`f1()` 通常靠近主事件循环,但无论如何,检测问题的代码通常与处理问题的代码不同,并且错误信息需要通过所有中间的堆栈帧进行传播。
异常使得“错误传播”变得容易
void f1()
{
try {
// ...
f2();
// ...
} catch (some_exception& e) {
// ...code that handles the error...
}
}
void f2() { ...; f3(); ...; }
void f3() { ...; f4(); ...; }
void f4() { ...; f5(); ...; }
void f5() { ...; f6(); ...; }
void f6() { ...; f7(); ...; }
void f7() { ...; f8(); ...; }
void f8() { ...; f9(); ...; }
void f9() { ...; f10(); ...; }
void f10()
{
// ...
if ( /*...some error condition...*/ )
throw some_exception();
// ...
}
只有检测错误的 `f10()` 和处理错误的 `f1()` 会有任何多余的代码。
然而,使用返回码会强制“错误传播的冗余代码”进入这两者之间的所有函数。这是使用返回码的等效代码
int f1()
{
// ...
int rc = f2();
if (rc == 0) {
// ...
} else {
// ...code that handles the error...
}
}
int f2()
{
// ...
int rc = f3();
if (rc != 0)
return rc;
// ...
return 0;
}
int f3()
{
// ...
int rc = f4();
if (rc != 0)
return rc;
// ...
return 0;
}
int f4()
{
// ...
int rc = f5();
if (rc != 0)
return rc;
// ...
return 0;
}
int f5()
{
// ...
int rc = f6();
if (rc != 0)
return rc;
// ...
return 0;
}
int f6()
{
// ...
int rc = f7();
if (rc != 0)
return rc;
// ...
return 0;
}
int f7()
{
// ...
int rc = f8();
if (rc != 0)
return rc;
// ...
return 0;
}
int f8()
{
// ...
int rc = f9();
if (rc != 0)
return rc;
// ...
return 0;
}
int f9()
{
// ...
int rc = f10();
if (rc != 0)
return rc;
// ...
return 0;
}
int f10()
{
// ...
if (...some error condition...)
return some_nonzero_error_code;
// ...
return 0;
}
返回码解决方案“分散”了错误逻辑。函数 `f2()` 到 `f9()` 有明确的手写代码,用于将错误条件传播回 `f1()`。这很糟糕
- 它用额外的决策逻辑(最常见的 bug 原因)使函数 `f2()` 到 `f9()` 变得混乱。
- 它增加了代码的体积。
- 它模糊了函数 `f2()` 到 `f9()` 中编程逻辑的简单性。
- 它要求返回值执行两个不同的职责——函数 `f2()` 到 `f10()` 需要处理“我的函数成功了,结果是 `xxx`”和“我的函数失败了,错误信息是 `yyy`”。当 `xxx` 和 `yyy` 的类型不同时,你有时需要额外的引用参数来将“成功”和“不成功”的情况都传播给调用者。
如果你只关注上面示例中的 `f1()` 和 `f10()`,异常不会给你带来太大的改进。但如果你把目光放远,你会看到所有中间函数都有显著的不同。
结论:异常处理的好处之一是提供了一种更清晰、更简单的方式将错误信息传播回能够处理错误的调用者。另一个好处是你的函数不需要额外的机制来将“成功”和“不成功”两种情况都传播回调用者。玩具示例通常不强调错误传播或处理两种返回类型问题,因此它们不能代表真实的生产代码。
异常如何简化我的函数返回类型和参数类型?
当你使用返回码时,你通常需要两个或更多不同的返回值:一个用于指示函数成功并给出计算结果,另一个用于将错误信息传播回调用者。如果函数有,比如说,5 种失败方式,你可能需要多达 6 种不同的返回值:“成功计算”返回值,以及针对 5 种错误情况的可能不同的位包。
让我们简化为两种情况
- “我成功了,结果是
xxx
。” - “我失败了,错误信息是
yyy
。”
让我们来看一个简单的例子:我们想创建一个支持四种算术运算(加、减、乘、除)的 Number
类。这是一个明显的重载运算符的地方,所以我们来定义它们
class Number {
public:
friend Number operator+ (const Number& x, const Number& y);
friend Number operator- (const Number& x, const Number& y);
friend Number operator* (const Number& x, const Number& y);
friend Number operator/ (const Number& x, const Number& y);
// ...
};
使用起来非常简单
void f(Number x, Number y)
{
// ...
Number sum = x + y;
Number diff = x - y;
Number prod = x * y;
Number quot = x / y;
// ...
}
但我们遇到了一个问题:错误处理。加数可能导致溢出,除法可能导致除以零或下溢等。哎呀。我们如何同时报告“我成功了,结果是 xxx
”以及“我失败了,错误信息是 yyy
”?
如果我们使用异常,那很容易。把异常看作是一种单独的返回类型,只在需要时才使用。所以我们只需定义所有异常并在需要时抛出它们
void f(Number x, Number y)
{
try {
// ...
Number sum = x + y;
Number diff = x - y;
Number prod = x * y;
Number quot = x / y;
// ...
}
catch (Number::Overflow& exception) {
// ...code that handles overflow...
}
catch (Number::Underflow& exception) {
// ...code that handles underflow...
}
catch (Number::DivideByZero& exception) {
// ...code that handles divide-by-zero...
}
}
但是,如果我们使用返回码而不是异常,生活就会变得艰难而混乱。当你无法将“好”数字和错误信息(包括错误发生细节)都塞进 Number
对象时,你最终可能会使用额外的引用参数来处理这两种情况之一:“我成功了”或“我失败了”或两者兼而有之。不失一般性地,我将通过正常的返回值处理计算结果,并通过引用参数处理“我失败了”的情况,但你也可以很容易地反过来做。结果如下
class Number {
public:
enum ReturnCode {
Success,
Overflow,
Underflow,
DivideByZero
};
Number add(const Number& y, ReturnCode& rc) const;
Number sub(const Number& y, ReturnCode& rc) const;
Number mul(const Number& y, ReturnCode& rc) const;
Number div(const Number& y, ReturnCode& rc) const;
// ...
};
现在是使用方法——此代码与上述代码等效
int f(Number x, Number y)
{
// ...
Number::ReturnCode rc;
Number sum = x.add(y, rc);
if (rc == Number::Overflow) {
// ...code that handles overflow...
return -1;
} else if (rc == Number::Underflow) {
// ...code that handles underflow...
return -1;
} else if (rc == Number::DivideByZero) {
// ...code that handles divide-by-zero...
return -1;
}
Number diff = x.sub(y, rc);
if (rc == Number::Overflow) {
// ...code that handles overflow...
return -1;
} else if (rc == Number::Underflow) {
// ...code that handles underflow...
return -1;
} else if (rc == Number::DivideByZero) {
// ...code that handles divide-by-zero...
return -1;
}
Number prod = x.mul(y, rc);
if (rc == Number::Overflow) {
// ...code that handles overflow...
return -1;
} else if (rc == Number::Underflow) {
// ...code that handles underflow...
return -1;
} else if (rc == Number::DivideByZero) {
// ...code that handles divide-by-zero...
return -1;
}
Number quot = x.div(y, rc);
if (rc == Number::Overflow) {
// ...code that handles overflow...
return -1;
} else if (rc == Number::Underflow) {
// ...code that handles underflow...
return -1;
} else if (rc == Number::DivideByZero) {
// ...code that handles divide-by-zero...
return -1;
}
// ...
}
这一点是,你通常必须搞乱使用返回码的函数接口,特别是如果需要将更多错误信息传播回调用者。例如,如果有 5 种错误条件,并且“错误信息”需要不同的数据结构,你最终可能会得到一个相当混乱的函数接口。
异常不会导致这些混乱。异常可以被认为是独立的返回值,就像函数根据它可以抛出的内容自动“增长”新的返回类型和返回值一样。
注意:请不要给我写信说你建议使用返回码并将错误信息存储在命名空间作用域、全局或静态变量中,例如 Number::lastError()
。那不是线程安全的。即使你今天没有多个线程,你也很少会永久阻止未来任何人将你的类与多个线程一起使用。当然,如果你这样做,你应该写很多很多大的丑陋注释,警告未来的程序员你的代码不是线程安全的,并且它可能无法在不进行大量重写的情况下实现线程安全。
异常将“好路径”(或“快乐路径”)与“坏路径”分离是什么意思?
这是异常相对于返回码的另一个好处。
“好路径”(有时称为“快乐路径”)是当一切顺利——没有问题发生时——的控制流路径。
“坏路径”(或“错误路径”)是当出现问题时控制流所走的路径。
异常,如果做得好,可以将“快乐路径”与“错误路径”分开。
这是一个简单的例子:函数 f()
应该依次调用函数 g()
、h()
、i()
和 j()
,如下所示。如果其中任何一个因“foo”或“bar”错误而失败,f()
应立即处理错误然后成功返回。如果发生任何其他错误,f()
应将错误信息传播回调用者。
这是使用异常的代码
void f() // Using exceptions
{
try {
GResult gg = g();
HResult hh = h();
IResult ii = i();
JResult jj = j();
// ...
}
catch (FooError& e) {
// ...code that handles "foo" errors...
}
catch (BarError& e) {
// ...code that handles "bar" errors...
}
}
“好路径”和“坏路径”清晰分离。“好”(或“快乐”)路径是 try
块的主体——你可以线性阅读,如果没有错误,控制流会简单地流经这些行。“坏”路径是 catch
块的主体以及任何调用者中任何匹配的 catch
块的主体。
使用返回码而不是异常会使代码变得混乱,以至于难以看清相对简单的算法。“好”(“快乐”)路径和“坏”路径无法挽回地混杂在一起
int f() // Using return-codes
{
int rc; // "rc" stands for "return code"
GResult gg = g(rc);
if (rc == FooError) {
// ...code that handles "foo" errors...
} else if (rc == BarError) {
// ...code that handles "bar" errors...
} else if (rc != Success) {
return rc;
}
HResult hh = h(rc);
if (rc == FooError) {
// ...code that handles "foo" errors...
} else if (rc == BarError) {
// ...code that handles "bar" errors...
} else if (rc != Success) {
return rc;
}
IResult ii = i(rc);
if (rc == FooError) {
// ...code that handles "foo" errors...
} else if (rc == BarError) {
// ...code that handles "bar" errors...
} else if (rc != Success) {
return rc;
}
JResult jj = j(rc);
if (rc == FooError) {
// ...code that handles "foo" errors...
} else if (rc == BarError) {
// ...code that handles "bar" errors...
} else if (rc != Success) {
return rc;
}
// ...
return Success;
}
通过将好/快乐路径与坏/错误路径混杂在一起,代码意图变得更难理解。与使用异常的版本形成对比,后者几乎是自文档化的——基本功能非常明显。
我将之前的 FAQ 理解为异常处理简单易行;我理解对了吗?
不!错了!停下!回去!不要收取 200 美元。
我的意思不是异常处理简单易行。我的意思是异常处理是值得的。好处大于成本。
以下是一些成本
- **异常处理不是免费午餐。**它需要纪律和严谨。要理解这些纪律,你真的应该阅读 FAQ 的其余部分和/或一本关于这个主题的优秀书籍。
- **异常处理并非万灵药**。如果你与一个马虎、没有纪律的团队合作,你的团队很可能会遇到问题,无论他们是使用异常还是返回码。不称职的木匠即使使用一把好锤子,也会干出糟糕的工作。
- **异常处理并非一刀切**。即使你已经决定使用异常而不是返回码,这并不意味着你将它们用于所有事情。这是纪律的一部分:你需要知道何时应该通过返回码报告条件,何时应该通过异常报告。
- **异常处理是一个方便的替罪羊**。如果你与那些归咎于工具的人合作,请警惕建议异常(或任何其他新事物,就此而言)。那些自我脆弱到需要为自己的失误归咎于别人或别的事物的人,总是会归咎于所使用的任何“新”技术。当然,理想情况下,你会与那些在情感上能够学习和成长的人合作:与他们合作,你可以提出各种建议,因为这类人会找到方法让它成功,你也会乐在其中。
幸运的是,关于正确使用异常有很多智慧和见解。异常处理并非新生事物。整个行业已经看到数百万行代码和数百人世纪的努力在使用异常。评审团已经做出了判决:异常**可以**被正确使用,并且当它们**被**正确使用时,它们能改进代码。
学习如何做。
异常处理似乎让我的生活更艰难;那**肯定**意味着异常处理本身就是坏的;显然**我**不是问题,对吧??
你**绝对**可能是问题所在!
C++ 异常处理机制强大且有用,但如果你有错误的思维模式,结果可能会一团糟。它是一个工具;正确使用它会帮助你;但如果你使用不当,不要责怪工具。
如果你得到糟糕的结果,例如,如果你的代码看起来不必要地复杂或充满了 try
块,你可能正在遭受错误的思维定式。本 FAQ 为你列出了一些错误的思维定式。
警告:不要简单化这些“错误思维模式”。它们是指导方针和思维方式,而不是死板的规则。有时你会做与它们建议的完全相反的事情——不要写信给我关于某种情况,它是其中一个或多个的例外(无双关语)——我**保证**有例外。这不是重点。
以下是一些“错误的异常处理思维模式”,没有明显的顺序
- **返回码思维定式:** 这导致程序员在他们的代码中堆积大量的
try
块。基本上,他们将throw
视为一种美化的返回码,将try
/catch
视为一种美化的“如果返回码指示错误”测试,并且他们在几乎所有可能throw
的函数周围都放置了一个这样的try
块。 - **Java 思维定式:** 在 Java 中,非内存资源通过显式
try
/finally
块回收。当这种思维模式在 C++ 中使用时,会导致大量不必要的try
块,与 RAII 相比,这会使代码混乱,并使逻辑更难理解。本质上,代码在“好路径”和“坏路径”(后者指在异常期间采取的路径)之间来回切换。使用 RAII,代码大部分是乐观的——它都是“好路径”,清理代码隐藏在资源拥有对象的析构函数中。这也有助于降低代码审查和单元测试的成本,因为这些“资源拥有对象”可以单独验证(使用显式try
/catch
块,每个副本都必须单独进行单元测试和检查;它们不能作为一个组来处理)。 - **围绕物理抛出者而非逻辑原因组织异常类:** 例如,在银行应用程序中,假设五个子系统中的任何一个在客户资金不足时都可能抛出异常。正确的方法是抛出一个表示抛出**原因**的异常,例如“资金不足异常”;错误的思维模式是每个子系统抛出一个子系统特定的异常。例如,
Foo
子系统可能抛出FooException
类的对象,Bar
子系统可能抛出BarException
类的对象等等。这通常会导致额外的try
/catch
块,例如,捕获FooException
,将其重新打包为BarException
,然后抛出后者。一般来说,异常类应该表示问题,而不是注意到问题的那段代码。 - **使用异常对象中的位/数据来区分不同类别的错误:** 假设我们银行应用程序中的
Foo
子系统为错误的账户号码、尝试清算非流动资产和资金不足抛出异常。当这三种逻辑上不同的错误由同一个异常类表示时,捕获者需要通过if
来判断问题到底是什么。如果你的代码只想处理错误的账户号码,你需要catch
主异常类,然后使用if
来确定它是否是你真正想处理的,如果不是,则重新抛出它。一般来说,首选的方法是将错误条件的逻辑类别编码到异常对象的**类型**中,而不是异常对象的**数据**中。 - **逐子系统设计异常类:** 在过去,任何给定返回码的具体含义都局限于给定的函数或 API。仅仅因为一个函数使用返回码 3 表示“成功”,另一个函数仍然可以使用 3 表示完全不同的意思,例如“因内存不足而失败”。一致性总是**首选**的,但通常没有实现,因为它**不需要**实现。具有这种心态的人通常以相同的方式对待 C++ 异常处理:他们假设异常类可以局限于子系统。这会带来无尽的麻烦,例如,大量的额外
try
块用于catch
然后throw
重新打包的相同异常的变体。在大型系统中,异常层次结构**必须**以系统范围的思维模式进行设计。异常类跨越子系统边界——它们是连接架构的智能胶水的一部分。 - **使用原始(而不是智能)指针:** 这实际上只是非 RAII 编码的一个特例,但我把它提出来是因为它太常见了。使用原始指针的结果是,如上所述,大量的额外
try
/catch
块,其唯一目的就是delete
一个对象然后重新throw
异常。 - **混淆逻辑错误与运行时情况:** 例如,假设你有一个函数
f(Foo* p)
,它绝不能用 nullptr 调用。然而你发现有人有时无论如何都会传递 nullptr。有两种可能性:要么他们传递 nullptr 是因为他们从外部用户那里得到了坏数据(例如,用户忘记填写某个字段,最终导致了 nullptr),要么他们只是在自己的代码中犯了错误。在前一种情况下,你应该抛出异常,因为它是一个运行时情况(即,你无法通过仔细的代码审查检测到的东西;它不是一个 bug)。在后一种情况下,你绝对应该修复调用者代码中的 bug。你仍然可以添加一些代码在日志文件中写入消息,如果它再次发生,你甚至可以抛出异常,但你绝不能仅仅更改f(Foo* p)
中的代码;你必须,**必须**,**必须**修复f(Foo* p)
调用者中的代码。
还有其他“错误的异常处理思维模式”,但希望这些能帮助你。请记住:不要将它们视为死板的规则。它们是指导方针,而且每条都有例外。
我有太多的 try 块;我该怎么办?
尽管你正在使用 try
/catch
/throw
的**语法**,但你可能仍然拥有返回码的**思维模式**。例如,你可能会在几乎每个调用周围都放置一个 try 块
void myCode()
{
try {
foo();
}
catch (FooException& e) {
// ...
}
try {
bar();
}
catch (BarException& e) {
// ...
}
try {
baz();
}
catch (BazException& e) {
// ...
}
}
尽管这使用了 try
/catch
/throw
语法,但整体结构与使用返回码的方式非常相似,因此随之而来的软件开发/测试/维护成本基本上与使用返回码时相同。换句话说,这种方法相对于使用返回码并没有为你带来太多好处。一般来说,这是一种不好的做法。
一种解决方法是为每个 try 块问自己这个问题:“我为什么要在这里使用 try 块?”有几种可能的答案
- 你的回答可能是:“这样我就可以真正处理异常。我的 catch 子句处理错误并继续执行,而不抛出任何额外的异常。我的调用者永远不知道异常发生了。我的 catch 子句不抛出任何异常,也不返回任何错误码。”在这种情况下,你保持 try 块原样——它可能很好。
- 你的回答可能是:“这样我就可以有一个执行*blablabla*的catch子句,之后我将重新抛出异常。”在这种情况下,考虑将try块更改为对象,其析构函数执行*blablabla*。例如,如果你有一个try块,其catch子句关闭文件然后重新抛出异常,考虑用一个析构函数关闭文件的File对象替换整个东西。这通常称为RAII。
- 你的回答可能是:“这样我就可以重新打包异常:我捕获一个
XyzException
,提取详细信息,然后抛出PqrException
。”当这种情况发生时,考虑一个更好的异常对象层次结构,它不需要这种捕获/重新打包/重新抛出的想法。这通常涉及扩大XyzException
的含义,尽管显然你不应该走得太远。 - 还有其他答案,但以上是我见过的一些常见答案。
主要的一点是问“为什么?”。如果你发现了你这样做的**原因**,你可能会发现有更好的方法来达到你的目标。
话虽如此,不幸的是,有些人对返回码的思维模式根深蒂固,以至于他们似乎看不到任何替代方案。如果你是这样的人,仍然有希望:找一个导师。如果你**看到**它做得对,你可能会明白。风格有时是“领会”的,而不仅仅是“传授”的。
我可以从构造函数中抛出异常吗?从析构函数中呢?
- 对于构造函数,是的:当你无法正确初始化(构造)对象时,应该从构造函数中抛出异常。没有真正令人满意的替代方法可以通过 `throw` 退出构造函数。有关更多详细信息,请参阅此处。
- 对于析构函数,实际上不行:你可以在析构函数中抛出异常,但该异常不能离开析构函数;如果析构函数通过抛出异常退出,则很可能发生各种坏事,因为标准库和语言本身的基本规则将被违反。不要这样做。有关更多详细信息,请参阅此处。
有关示例和详细解释,请参阅 TC++PL3e 的附录 E。
有一点需要注意:异常不能用于某些硬实时项目。例如,请参阅 JSF 航空器 C++ 编码标准。
如何处理失败的构造函数?
抛出异常。
构造函数没有返回类型,因此无法使用返回码。因此,表示构造函数失败的最佳方法是抛出异常。如果你无法使用异常,那么“最不坏”的解决方法是通过设置内部状态位将对象置于“僵尸”状态,这样对象就好像已经死亡一样,尽管技术上它仍然活着。
“僵尸”对象的想法有很多缺点。你需要添加一个查询(“检查器”)成员函数来检查这个“僵尸”位,以便你的类的用户可以知道他们的对象是真正活着的,还是一个僵尸(即一个“活死人”对象),而且在几乎所有你构造一个对象的地方(包括在一个更大的对象或一个对象数组中),你都需要通过 if
语句检查那个状态标志。你还需要在你的其他成员函数中添加一个 if
:如果对象是僵尸,则执行一个空操作,或者做一些更令人讨厌的事情。
在实践中,“僵尸”这个东西变得相当丑陋。当然,你应该优先选择异常而不是僵尸对象,但如果你没有使用异常的选项,僵尸对象可能是“最不坏”的替代方案。
注意:如果构造函数通过抛出异常完成,则与对象本身关联的内存会被清理——没有内存泄漏。例如
void f()
{
X x; // If X::X() throws, the memory for x itself will not leak
Y* p = new Y(); // If Y::Y() throws, the memory for *p itself will not leak
}
关于这个主题有一些细节,所以你需要继续阅读。具体来说,你需要知道如果构造函数本身分配内存如何防止内存泄漏,并且你还需要了解如果你使用“placement” new
而不是上面示例代码中使用的普通 new
会发生什么。
如何处理失败的析构函数?
向日志文件写入消息。终止进程。或者打电话给蒂尔达阿姨。但是**不要**抛出异常!
原因如下(系好安全带)
C++ 的规则是,你绝不能从在另一个异常的“栈展开”过程中调用的析构函数中抛出异常。例如,如果有人说 throw Foo()
,栈将展开,以便在
throw Foo()
和
}
catch (Foo e)
{
之间所有的栈帧都将被弹出。这称为**栈展开**。
在栈展开过程中,所有这些栈帧中的局部对象都被销毁。如果这些析构函数之一抛出异常(比如说它抛出一个 Bar
对象),C++ 运行时系统就陷入了两难境地:它应该忽略 Bar
并回到它最初要去的
}
catch (Foo e)
{
那里吗?它应该忽略 Foo
并寻找一个
}
catch (Bar e)
{
处理程序吗?没有好的答案——任何一种选择都会丢失信息。
因此 C++ 语言保证在这种情况下会调用 terminate()
,而 terminate()
会终止进程。砰,你死了。
避免这种情况的简单方法是**永远不要从析构函数中抛出异常**。但如果你真的想聪明一点,你可以说**在处理另一个异常时,永远不要从析构函数中抛出异常**。但在第二种情况下,你处于困境:析构函数本身需要代码来处理抛出异常和做“其他事情”,并且调用者无法保证当析构函数检测到错误时会发生什么(它可能会抛出异常,也可能会做“其他事情”)。所以整个解决方案更难编写。所以简单的方法是**总是**做“其他事情”。也就是说,**永远不要从析构函数中抛出异常**。
当然,“永不”这个词应该打上引号,因为总会有某种情况使得这个规则不成立。但至少在 99% 的情况下,这是一个很好的经验法则。
如果我的构造函数可能抛出异常,我应该如何处理资源?
你对象内部的每个数据成员都应该清理自己的烂摊子。
如果构造函数抛出异常,则不会运行对象的析构函数。如果你的对象已经做了需要撤销的事情(例如分配一些内存、打开文件或锁定信号量),那么这些“需要撤销的东西”**必须**由对象内部的数据成员记住。
例如,与其将内存分配到原始的 Fred*
数据成员中,不如将分配的内存放入“智能指针”成员对象中,当智能指针消亡时,这个智能指针的析构函数将 delete
Fred
对象。模板 std::unique_ptr
就是一个这样的“智能指针”的例子。你也可以编写自己的引用计数智能指针。你还可以使用智能指针“指向”磁盘记录或其他机器上的对象。
顺便说一下,如果你认为你的 Fred
类将被分配到智能指针中,请对你的用户友好,并在你的 Fred
类中创建一个 typedef
#include <memory>
class Fred {
public:
typedef std::unique_ptr<Fred> Ptr;
// ...
};
这个 typedef
简化了所有使用你的对象的代码的语法:你的用户可以使用 Fred::Ptr
而不是 std::unique_ptr<Fred>
#include "Fred.h"
void f(std::unique_ptr<Fred> p); // explicit but verbose
void f(Fred::Ptr p); // simpler
void g()
{
std::unique_ptr<Fred> p1( new Fred() ); // explicit but verbose
Fred::Ptr p2( new Fred() ); // simpler
// ...
}
如何更改 char
数组的字符串长度,以防止即使/当有人抛出异常时也发生内存泄漏?
如果你真正想做的是处理字符串,首先就不要使用 char
数组,因为数组是邪恶的。而是使用一些 string
类的对象。
例如,假设你想获取一个字符串的副本,对副本进行修改,然后将另一个字符串附加到修改后的副本末尾。`char` 数组的方法看起来像这样
void userCode(const char* s1, const char* s2)
{
char* copy = new char[strlen(s1) + 1]; // make a copy
strcpy(copy, s1); // of s1...
// use a try block to prevent memory leaks if we get an exception
// note: we need the try block because we used a "dumb" char* above
try {
// ...code that fiddles with copy...
char* copy2 = new char[strlen(copy) + strlen(s2) + 1]; // append s2
strcpy(copy2, copy); // onto the
strcpy(copy2 + strlen(copy), s2); // end of
delete[] copy; // copy...
copy = copy2;
// ...code that fiddles with copy again...
}
catch (...) {
delete[] copy; // we got an exception; prevent a memory leak
throw; // re-throw the current exception
}
delete[] copy; // we did not get an exception; prevent a memory leak
}
像这样使用 char*
既繁琐又容易出错。为什么不直接使用某个 string
类的对象呢?你的编译器可能提供了 string
类的对象,它可能同样快,而且肯定比你自己编写的 char*
代码简单安全得多。例如,如果你使用标准化委员会的 std::string
类,你的代码可能看起来像这样
#include <string> // Let the compiler see std::string
void userCode(const std::string& s1, const std::string& s2)
{
std::string copy = s1; // make a copy of s1
// ...code that fiddles with copy...
copy += s2; // append s2 onto the end of copy
// ...code that fiddles with copy again...
}
char*
版本需要你编写大约三倍于 std::string
版本的代码。大部分的节省来自 std::string
的自动内存管理:在 std::string
版本中,我们不需要编写任何代码……
- 当我们增加字符串长度时,重新分配内存。
- 在函数结束时
delete[]
任何东西。 - 捕获并重新抛出任何异常。
我应该抛出什么?
C++,与几乎所有其他带有异常的语言不同,在你可以抛出的内容方面非常灵活。事实上,你可以抛出任何你喜欢的东西。这就引出了一个问题,你应该抛出什么?
通常,最好抛出对象,而不是内置类型。如果可能,你应该抛出最终派生自 std::exception
类的类的实例。通过让你的异常类继承(最终)自标准异常基类,你将为你的用户提供便利(他们可以选择通过 std::exception
捕获大多数事物),此外你可能还会提供更多信息(例如你的特定异常可能是 std::runtime_error
或其他异常的细化)。
最常见的做法是抛出一个临时对象
#include <stdexcept>
class MyException : public std::runtime_error {
public:
MyException() : std::runtime_error("MyException") { }
};
void f()
{
// ...
throw MyException();
}
在这里,创建并抛出 MyException
类型的一个临时对象。MyException
类继承自 std::runtime_error
类,而 std::runtime_error
类(最终)继承自 std::exception
类。
我应该捕获什么?
秉承 C++ “条条大路通罗马”的传统(翻译:“给程序员提供选项和权衡,以便他们可以在他们的情况下决定什么对他们最好”),C++ 允许你选择多种捕获方式。
- 你可以按值捕获。
- 你可以按引用捕获。
- 你可以按指针捕获。
事实上,你拥有声明函数参数的所有灵活性,并且某个特定异常是否匹配(即,将被某个 catch 子句捕获)的规则几乎与调用函数时参数兼容性的规则完全相同。
鉴于如此大的灵活性,你如何决定捕获什么?很简单:除非有充分理由不这样做,否则按引用捕获。避免按值捕获,因为那会导致复制,并且复制对象可能具有与抛出的对象不同的行为。只有在非常特殊的情况下,你才应该按指针捕获。
但 MFC 似乎鼓励使用按指针捕获;我应该也这样做吗?
看情况。如果你正在使用 MFC 并捕获其异常之一,那么无论如何都要按照它们的方式去做。对于任何框架都是如此:入乡随俗。不要试图强迫一个框架适应你的思维方式,即使“你的”思维方式“更好”。如果你决定使用一个框架,就要接受它的思维方式——使用它的作者期望你使用的惯用语。
但是,如果你正在创建自己的框架和/或不直接依赖 MFC 的系统部件,那么不要仅仅因为 MFC 这样做而按指针捕获。当你**不在**罗马时,你**不一定**要像罗马人一样做。在这种情况下,你**不应该**这样做。像 MFC 这样的库早于 C++ 语言中异常处理的标准化,其中一些库使用向后兼容的异常处理形式,这要求(或至少鼓励)你按指针捕获。
按指针捕获的问题是,不清楚谁(如果有人)负责删除指向的对象。例如,考虑以下内容
MyException x;
void f()
{
MyException y;
try {
switch ((rand() >> 8) % 3) { // the ">> 8" (typically) improves the period of the lowest 2 bits
case 0: throw new MyException;
case 1: throw &x;
case 2: throw &y;
}
}
catch (MyException* p) {
// should we delete p here or not???!?
}
}
这里有三个基本问题
- 在 `catch` 子句中决定是否 `delete p` 可能很困难。例如,如果对象 `x` 在 `catch` 子句的作用域内不可访问,例如当它被埋在某个类的私有部分中或在某个其他编译单元中是 `static` 时,可能很难弄清楚该怎么做。
- 如果你通过在 `throw` 中始终使用 `new`(因此在 `catch` 中始终使用 `delete`)来解决第一个问题,那么异常总是使用堆,这可能会在由于系统内存不足而抛出异常时导致问题。
- 如果你通过在 `throw` 中始终**不**使用 `new`(因此在 `catch` 中始终**不**使用 `delete`)来解决第一个问题,那么你可能无法将异常对象分配为局部变量(因为它们可能会过早销毁),在这种情况下,你将不得不担心线程安全、锁、信号量等(`static` 对象本身不是线程安全的)。
这并不是说不可能解决这些问题。重点很简单:如果你按引用而不是按指针捕获,生活会更容易。当你不必让生活变得艰难时,为什么要这样做呢?
结论:避免抛出指针表达式,避免按指针捕获,**除非**你正在使用一个“希望”你这样做的现有库。
throw;
(不带 throw
关键字后面的异常对象)是什么意思?我会在哪里使用它?
你可能会看到类似这样的代码
class MyException {
public:
// ...
void addInfo(const std::string& info);
// ...
};
void f()
{
try {
// ...
}
catch (MyException& e) {
e.addInfo("f() failed");
throw;
}
}
在此示例中,语句 throw;
意味着“重新抛出当前异常”。这里,一个函数捕获了一个异常(通过非常量引用),修改了异常(通过向其中添加信息),然后重新抛出了该异常。这种惯用法可以用来实现一种简单的堆栈跟踪形式,通过在程序的重要函数中添加适当的 catch 子句。
另一个重新抛出的惯用语是“异常分发器”
void handleException()
{
try {
throw;
}
catch (MyException& e) {
// ...code to handle MyException...
}
catch (YourException& e) {
// ...code to handle YourException...
}
}
void f()
{
try {
// ...something that might throw...
}
catch (...) {
handleException();
}
}
这种惯用法允许一个函数(`handleException()`)被重用,以处理其他多个函数中的异常。
我如何进行多态抛出?
有时人们会写出这样的代码
class MyExceptionBase { };
class MyExceptionDerived : public MyExceptionBase { };
void f(MyExceptionBase& e)
{
// ...
throw e;
}
void g()
{
MyExceptionDerived e;
try {
f(e);
}
catch (MyExceptionDerived& e) {
// ...code to handle MyExceptionDerived...
}
catch (...) {
// ...code to handle other exceptions...
}
}
如果你尝试这样做,你可能会在运行时感到惊讶,因为进入的是你的 catch (...)
子句,而不是你的 catch (MyExceptionDerived&)
子句。这发生是因为你没有多态抛出。在函数 f()
中,语句 throw e;
抛出一个与表达式 e
的静态类型相同的对象。换句话说,它抛出了 MyExceptionBase
的一个实例。throw
语句的行为就好像抛出的对象被复制了,而不是进行了“虚复制”。
幸好纠正起来相对容易
class MyExceptionBase {
public:
virtual void raise();
};
void MyExceptionBase::raise()
{ throw *this; }
class MyExceptionDerived : public MyExceptionBase {
public:
virtual void raise();
};
void MyExceptionDerived::raise()
{ throw *this; }
void f(MyExceptionBase& e)
{
// ...
e.raise();
}
void g()
{
MyExceptionDerived e;
try {
f(e);
}
catch (MyExceptionDerived& e) {
// ...code to handle MyExceptionDerived...
}
catch (...) {
// ...code to handle other exceptions...
}
}
请注意,`throw` 语句已移至虚函数中。`e.raise()` 语句将表现出多态行为,因为 `raise()` 被声明为 `virtual`,并且 `e` 是按引用传递的。像以前一样,抛出的对象将是 `throw` 语句中参数的*静态*类型,但在 `MyExceptionDerived::raise()` 中,该静态类型是 `MyExceptionDerived`,而不是 `MyExceptionBase`。
当我抛出这个对象时,它会被复制多少次?
视情况而定。可能是“零”。
被抛出的对象必须具有可公开访问的拷贝构造函数。编译器可以生成代码,将抛出的对象复制任意次,包括零次。然而,即使编译器从未实际复制被抛出的对象,它也必须确保异常类的拷贝构造函数存在且可访问。
为什么 C++ 不提供“finally”构造?
因为 C++ 支持一种几乎总是更好的替代方案:“资源获取即初始化”技术。基本思想是将资源表示为局部对象,以便局部对象的析构函数将释放资源。这样,程序员就不会忘记释放资源。例如
// wrap a raw C file handle and put the resource acquisition and release
// in the C++ type's constructor and destructor, respectively
class File_handle {
FILE* p;
public:
File_handle(const char* n, const char* a)
{ p = fopen(n,a); if (p==0) throw Open_error(errno); }
File_handle(FILE* pp)
{ p = pp; if (p==0) throw Open_error(errno); }
~File_handle() { fclose(p); }
operator FILE*() { return p; } // if desired
// ...
};
// use File_handle: uses vastly outnumber the above code
void f(const char* fn)
{
File_handle f(fn,"rw"); // open fn for reading and writing
// use file through f
} // automatically destroy f here, calls fclose automatically with no extra effort
// (even if there's an exception, so this is exception-safe by construction)
在一个系统中,最坏的情况是我们需要为每种资源提供一个“资源句柄”类。但是,我们不必为每次资源获取都提供一个“finally”子句。在实际系统中,资源获取的次数远多于资源种类,因此“资源获取即初始化”技术比使用“finally”构造产生的代码更少。
另外,请查看 TC++PL3e 附录 E 中资源管理的示例。
为什么捕获异常后无法恢复?
换句话说,为什么 C++ 不提供一种原语,用于返回到抛出异常的点并从那里继续执行?
基本上,从异常处理程序恢复的人永远无法确定抛出点之后的代码是否编写为在什么都没发生的情况下继续执行。异常处理程序无法知道需要“纠正”多少上下文才能恢复。为了正确编写这样的代码,抛出者和捕获者需要深入了解彼此的代码和上下文。这会产生一种复杂的相互依赖关系,无论在哪里允许这种依赖,都会导致严重的维护问题。
Stroustrup 在设计 C++ 异常处理机制时认真考虑了允许恢复的可能性,并且在标准化过程中对这个问题进行了相当详细的讨论。请参阅 The Design and Evolution of C++ 的异常处理章节。
如果你想在抛出异常之前检查是否可以修复问题,请调用一个函数进行检查,然后仅在问题无法在本地处理时才抛出。`new_handler` 就是一个例子。