编写始终能够优雅退出的应用程序。






3.12/5 (41投票s)
2003年9月5日
11分钟阅读

125777
本文描述了如何编写能够始终优雅退出的应用程序。
引言
对于地上的每一个程序员来说,编写永不崩溃的程序始终是一个梦想。因此,本文介绍了一些关于捕获可能导致应用程序崩溃的异常的准则,从而确保程序的优雅退出。我包含了一些代码片段,演示了如何捕获异常。
编写 C++ 应用程序的准则
处理对象创建和销毁
-
每当您在堆上创建对象(使用 new 运算符)时,请始终记住使用 delete 运算符删除它。
您可以使用
new
表达式动态创建类型为T
的对象,如下所示:T* obj = new T(); //creates a new object obj of type T in heap
原始的 C++ 规则规定,如果内存分配失败,
new
运算符将始终返回一个NULL
指针。因此,在创建对象后、使用它之前检查NULL
是一个好习惯。当您想释放堆对象时,可以使用
delete
表达式,如下所示:delete obj; //Deletes the object obj from heap, and frees the memory
在删除对象之前,请始终检查
NULL
。(这是因为,您可能正在尝试删除一个已被删除的对象。)T* obj = new T(); //creates a new object obj of type T in heap if( obj != NULL ) { // perform operations on obj } { if( obj != NULL ) delete obj; //Deletes the object obj from heap, and frees the memory }
类的所有成员变量都应在构造函数中创建和初始化。大多数情况下这不可能,因此请始终确保对象在使用后才被初始化。
-
创建对象数组时,必须有一个可以不带参数调用的构造函数。
T* objs = new T[N]; //creates an array of N objects of type T.
要释放数组,您必须使用此形式的
delete
表达式:delete [] objs;
-
养成在基类中声明虚析构函数的习惯。
当您尝试通过指向基类的指针删除派生类对象时,只会执行基类析构函数,这会导致各种问题,例如内存和其他资源泄露。
class A { . . . }; class B : public A { . . . }; A* pB = new B; delete pB; // resource leak
一个普遍的规则是,如果一个类有一个虚函数,那么它很可能也需要一个虚析构函数——一旦我们决定承担 vtable 指针的开销,后续的虚函数将不会增加对象的大小。因此,在这种情况下,添加虚析构函数不会增加显著的开销。所以,在基类中拥有虚析构函数总是好的。
使用异常
当我开始使用异常时,很快就发现有效使用异常比最初看起来更难。事实上,我探索异常如何与非异常处理代码交互越多,我就越确信异常可能是 C++ 中最难使用的特性。因此,在使用异常时,有一些准则需要遵循。
-
当您传播异常时,请尝试将对象保留在函数进入时所处的状态。
这是异常处理的黄金法则。如果程序因异常传播到可以处理的点而进入了无效状态,那么处理异常对程序的好处就不大了。我个人认为编写达到此目标的代码非常困难。我将此准则放在第一位,因为它应该是始终的最终目标。我只会在这里给出一些简单的建议。
- 确保您的
const
函数确实是const
的。 - 尽早执行易发生异常的操作。
- 避免在可能传播异常的表达式中产生副作用。
try { stack[esp++] = element; } catch (...) { --esp; // reset state on exception }
- 确保您的
-
如果您无法将对象保留在函数进入时所处的“良好”状态,请尝试将其保留在“良好”状态。
之所以需要这样做,是因为客户端可能不像他们应该的那样关注异常处理。客户端可能处理了异常,但没有意识到对象不再有效。在这种情况下,请务必确保任何进一步使用该对象的尝试都将被拒绝。
-
如果您无法将对象保留在“良好”状态,请确保析构函数仍然可以正常工作。
如果准则 1 和 2 不可能实现,作为最后的手段,请尝试将对象保留在可以安全销毁的状态。永远不要忘记,当异常传播时,它会展开它传播经过的每个函数的堆栈帧。很多时候,这将调用抛出异常的对象的析构函数。
-
避免资源泄露。
最明显的资源泄露是内存泄露,但内存不是唯一会泄露的资源。从某种意义上说,资源泄露只是不一致状态的另一个例子。为了避免内存泄露,请始终尝试使用标准 C++ 库提供的
auto_ptr<>
模板类。在三种不同的情况下,异常可能导致资源泄露:在构造函数中、在析构函数中以及在函数中(无论是类成员还是非类成员)。当异常从构造函数传播时,已构造的局部对象将被销毁。如有必要,为对象从自由存储中分配的内存也将被释放。如果在构造过程中,直接获取了资源(如内存),而不是作为会释放资源的子对象的组成部分,那么可能会发生资源泄露。这种情况可以通过使用
try
块来捕获异常并尝试释放内存来处理。因此,将指针初始化为NULL
,并在catch
块中全部删除它们。当析构函数中出现资源泄露时:通常,我们不希望从析构函数抛出(或传播)异常。尽管如此,我们也无法总是阻止它。在这种情况下,请尝试使用
auto_ptr<>
,并将资源的拥有权转移给临时对象。当析构函数体退出时,这些对象将被销毁,从而删除它们的指针。 -
不要捕获您不必捕获的任何异常。
实际上,有一个关于错误处理的旧 C 规则,它指出:“不要测试您不知道如何处理的任何错误条件”。在 C++ 中,这意味着捕获您不知道如何处理的异常是浪费时间(您和计算机的时间)。我可以在这种情况下提供一些行为建议。
- 始终使用
catch(...)
块来处理传播的异常。 - 不要“处理”任何无法“修复”的异常。
- 不要从析构函数体中抛出异常。
- 如果陷入困境,请调用
terminate()
或exit()
。
在异常出现之前,异常终止程序的常规方法是调用 C 库函数
abort()
。在新的 C++ 世界中,我们应该改为调用terminate()
。terminate()
只是调用terminate_handler
。在默认情况下,这会调用abort()
,但用户可以替换默认的terminate_handler
为特定于程序的版本。因此,调用terminate()
以允许任何用户定义的terminate_handler
运行。 - 始终使用
-
不要向可能需要它的程序其他部分隐藏异常信息。
异常的目的是将信息从检测到错误的点传递到可以处理错误的点。如果您抛出与原始异常不同的异常,而不是重新抛出原始异常,您希望通过这样做来增加异常被处理的可能性。
- 始终重新抛出在
catch (...)
子句中捕获的异常。 - 仅当要提高抽象级别或功能时,才重新抛出不同的异常。
- 确保一个
catch
块不会隐藏另一个。
- 始终重新抛出在
-
除非您有强大的异常安全保证(准则 1),否则请假定在发生任何异常后都必须销毁该对象。
当您处理异常时,您会纠正错误,然后重做失败的操作。为了实现这一点,您必须确保导致操作失败的操作将其对象恢复到操作尝试之前的状态。这就是准则 1,也是 C++ 标准所称的“强保证”。如果您不能肯定准则 1 正在被遵守,那么一切都无所谓了。如果准则 2 得到了遵守,您可能处于可以重用对象的境地,但总的来说,唯一真正安全的事情是销毁对象并重新开始。
-
始终按引用捕获异常。
这不需要太多解释,因为我假设每个人都已经知道了。
我推测,我已经深入探讨了异常,所以我会用几句话来总结这个异常处理的主题。“异常处理显然是 C++ 语言的一个强大特性。虽然您可以选择完全不使用异常,但它们的优势远远大于成本。”
一点 UNIX 风味
UNIX 操作系统使用信号作为一种通知进程的机制,告知进程发生了某个事件,该事件通常与进程的当前活动无关,但需要进程注意。信号是异步传递给进程的;进程无法预测何时会收到信号。未能正确处理各种信号很可能会导致您的应用程序在收到这些信号时终止。当我们说“正在处理信号”时,我们的意思是我们的程序已准备好处理操作系统可能发送给它的信号(例如,通知用户请求终止的信号,或者我们尝试写入的网络连接已关闭等)。典型的信号处理程序如下所示:
/* This is the signal handler */ void catch_int(int sig_num) { /* re-set the signal handler again to catch_int, for next time */ signal(SIGINT, catch_int); /* do some action */ fflush( stdout ); cout <<"some message"<<endl; }
现在,编写信号处理程序的一些经验法则。
- 使其简短——信号处理程序应该是一个简短的函数,并能快速返回。与其在信号处理程序中执行复杂的操作,不如让函数设置一个标志(例如,一个全局变量,尽管它们本身就很糟糕),并让主程序偶尔检查该标志。
- 正确的信号屏蔽——不要懒于为信号处理程序定义正确的信号屏蔽,最好使用
sigaction()
系统调用。它比仅使用signal()
系统调用需要更多的努力,但它能让您晚上睡得更好,因为您没有留下额外的竞态条件发生的地方。 - 小心“故障”信号——如果您捕获表示程序错误的信号(
SIGBUS
、SIGSEGV
、SIGFPE
),除非您确切知道自己在做什么(这是一种非常罕见的情况),否则不要试图变得太聪明而让程序继续运行——只需执行最少量的必要清理,然后退出,最好是产生核心转储(使用abort()
函数)。 - 小心定时器——当您使用定时器时,请记住一次只能使用一个定时器,除非您也使用
VTALRM
信号。如果您需要同时激活多个定时器,请不要使用信号,或者设计一套函数,允许您使用某种增量列表来拥有多个虚拟定时器。 - 信号不是事件驱动框架——很容易走火入魔,试图将信号系统变成程序的事件驱动驱动程序,但信号处理函数并非为此目的而设计。如果您需要这样的东西,请使用更适合应用程序的框架。
在 Windows 中,我们还可以使用 try
-except
块来捕获许多不同的异常,例如 ACCESS_VIOLATION
、STACK_OVERFLOW
、ARRAY_BOUNDS_EXCEEDED
、DATATYPE_MISALIGNMENT
、FLT_DENORMAL_OPERAN
、FLT_DIVIDE_BY_ZERO
、FLT_INEXACT_RESULT
、FLT_INVALID_OPERATION
、FLT_OVERFLOW
、FLT_STACK_CHECK
、FLT_UNDERFLOW
、INT_DIVIDE_BY_ZERO
和 INT_OVERFLOW
。
使用 DEBUG 函数
-
使用 Assert
由于代码的某个部分没有按您认为的那样工作,bug 可能会潜伏。我曾盯着一行代码看上好几分钟才发现一个简单的拼写错误。更复杂的 bug 查找起来会很困难,因为我常常假设我看到了不存在的东西。克服这个问题的最有效技术之一是
Assert
宏。许多编译器都提供
Assert()
宏作为其默认库的一部分。Assert()
宏的目的是断言程序中的某个事实——即,记录(和测试)您认为在程序历史的给定时刻为真的内容。它的工作原理如下:您通过将事实作为参数传递给Assert
宏来断言某个事实。如果您的断言正确,宏将不执行任何操作,但如果您的断言不正确(断言的“事实”不为真),它将中止您的程序并显示错误消息。例如,
Assert( x > 10 ) //If x is greater than 10, nothing happens //Otherwise program halts and an error message is displayed.
Assert
宏是一个强大的调试工具,但为了在专业开发环境中接受它,它不能产生性能损失,也不能增加程序可执行版本的大小。为了实现这一点,如果未定义Debug
,预处理器会将Assert
宏折叠成无代码。因此,在您的开发环境中,您可以使用Assert
来查找 bug 和误解,但在代码发布时,不会有任何性能损失。 -
使用类不变式
大多数类都有一些应该始终为真的条件。例如,您的
Circle
对象可能永远不应该有零半径,或者您的Animal
的年龄应该始终大于 0 且小于 100。声明一个Invariants()
方法,该方法仅在所有这些条件都为真时才返回 true,这可能非常有帮助。然后,您可以将Assert(Invariants())
放在每个类方法的开始和结束处。
结论
“健壮的程序能够抵抗错误——它要么正确工作,要么根本不工作;而容错程序必须实际从错误中恢复。”
致谢
在处理此问题时,我参考了以下文章:
历史
- 提交日期:2003 年 9 月 5 日。