检测和处理错误
本节阅读量:上一节介绍了新C++程序员在这门语言中会遇到的许多常见语义错误。如果只是误用语言功能或代码逻辑有误,这类错误通常比较容易更正。
但程序中的大多数错误并不是由于无意中误用语言功能而发生的。相反,大多数错误来自程序员的错误假设,或缺乏适当的错误检测与处理。
例如,在设计用于查找学生成绩的函数时,您可能会做出以下假设:
- 被查找的学生将存在。
- 所有学生的姓名都是唯一的。
- 使用字母评分(而不是及格/不及格)。
如果这些假设中有任何一个不成立怎么办?如果没有预料到这些情况,程序可能会发生故障或崩溃(通常是在函数写完之后的某个未来时间点)。
这种假设错误通常发生在三个关键位置:
- 当被调用函数返回时,程序员可能假设它成功了,但其实它失败了。
- 当程序接收到输入(来自用户或文件)时,程序员可能会假设输入的格式正确。
- 调用函数时,程序员可能会假设传入参数在语义上是有效的,但实际上并不是。
许多新程序员编写代码后,只会测试那些显而易见正确的代码路径。但其实也应该测试其他容易出错的路径。
本课将讨论函数内部的错误处理策略(出错时该怎么办)。在后面的课程中,我们将讨论如何验证用户输入,然后介绍一个有用工具,用来帮助记录和验证假设。
处理函数中的错误
函数可能会因为许多原因而失败——调用方可能传入了一个无效参数,或者函数体中的某些操作可能会失败。例如,如果找不到文件,那么打开文件进行读取的函数可能会失败。
当这种情况发生时,您有很多选项可供选择。处理错误并没有固定的最佳方法,实际选择取决于问题的性质,以及问题是否可以修复。
可以使用4种通用策略:
- 处理函数中的错误
- 将错误传递回调用方以处理
- 停止程序
- 抛出异常
处理函数中的错误
如果可能,最好的策略是在发生错误的同一函数中恢复错误,这样就可以在不影响函数外部代码的情况下更正错误。这里有两个选项:重试直到成功,或取消正在执行的操作。
如果错误是由程序无法控制的原因导致的,程序可以重试直到成功。例如,如果程序需要互联网连接,而用户已经断开连接,则可以显示警告,然后使用循环定期重新检查互联网连接。或者,如果用户输入无效内容,程序可以要求用户重试,并循环直到用户成功输入有效内容。
另一种策略是忽略错误和/或取消操作。例如:
|
|
在上面的例子中,如果用户为y传递了无效值,我们就忽略打印除法运算结果的请求。这样做的主要问题是,调用方或用户无法识别出问题。在这种情况下,打印错误消息可能会有所帮助:
|
|
然而,如果调用方期望被调用函数产生返回值或某些有用的副作用,那么忽略错误可能就不是一个可行选项。
将错误传回调用方
在许多情况下,错误无法在检测到错误的函数中被合理处理。例如,考虑以下函数:
|
|
如果y是0,我们应该怎么做?我们不能跳过程序逻辑,因为函数需要返回某个值。也不应该要求用户输入y的新值,因为这是一个计算函数,用户输入逻辑可能在其他函数中。
在这种情况下,最好的选择是将错误传递回调用方,希望调用方能够处理它。
如何才能做到这一点?
如果函数具有void返回类型,则可以将其更改为返回指示成功或失败的布尔值。例如,如果之前这样:
|
|
则可以改为:
|
|
这样,调用方可以检查返回值,以查看函数是否因某种原因而失败。
如果函数返回正常值,则情况会稍微复杂一些。在某些情况下,函数不会使用完整的返回值范围。这时,可以使用通常不可能出现的返回值来指示错误。例如,考虑以下函数:
|
|
某个数x的倒数定义为1/x,一个数乘以它的倒数等于1。
然而,如果用户调用reciprocal(0),会发生什么情况?我们会得到一个除以零错误,并导致程序崩溃,所以很明显,应该防止这种情况。但这个函数必须返回一个double值,所以应该返回什么值?显然,该函数永远不会产生0.0作为合法结果,因此可以返回0.0来指示错误情况。
|
|
然而,如果需要完整的返回值范围,则不可能使用返回值来指示错误(因为调用程序将无法区分返回值是有效值还是错误值)。
注释
在这种情况下,返回std::optional将是一个不错的选择。
致命错误
如果错误严重到程序无法继续正常运行,则称为不可恢复错误(也称为致命错误,fatal error)。在这种情况下,最好的做法是终止程序。如果错误点在main()函数中,最好的办法是直接return一个非零值。然而,如果错误发生在深层嵌套的子函数中,将错误传播回main()可能不太现实。在这种情况下,可以使用退出语句(例如std::exit())。
例如:
|
|
Exceptions (异常)
由于将错误从函数返回给调用者比较复杂(许多不同方法会导致不一致,而不一致会导致错误),C++提供了一种完全独立的方法来将错误传递回调用者:exceptions。
基本思想是,当错误发生时,会“抛出”异常。如果当前函数没有“捕获”错误,则函数调用方有机会捕获该错误。如果调用方没有捕获错误,则调用方的调用方有机会捕获错误。错误会在调用堆栈中逐层向上移动,直到被捕获并处理(此时程序正常继续执行),或者直到main()函数中也无法处理该错误(此时程序因异常错误而终止)。
异常处理将在后续章节介绍。
何时使用std::cout、std::cerr与日志
您可能想知道什么时候应该使用std::cerr、std::cout,什么时候应该将信息记录到文本文件。
默认情况下,std::cout和std::cerr都会将文本打印到控制台。然而,现代操作系统提供了将输出流重定向到文件的方法,因此可以把输出捕获到文件中,以便以后审查或自动处理。
在这里,区分两种类型的应用程序会很有用:
- 交互式应用程序,用户在运行后将与之交互的应用程序。大多数独立应用程序,如游戏和音乐应用程序,都属于这一类。
- 非交互式应用程序,是不需要用户交互即可运行的应用程序。这些程序的输出可以用作其他应用的输入
非交互式应用程序又可以分为两种类型:
- 工具类,通常是为了产生某些即时结果而启动,并在产生结果后终止。一个例子是Unix的grep命令,它是一个用于搜索文本中匹配某种模式的行的实用程序。
- 服务类,通常在后台静默运行以执行某些功能。一个例子是病毒扫描程序。
这里有一些经验法则:
- 面向用户的文本输出结果使用std::cout。
- 对于交互式程序,请将std::cout用于普通的面向用户的错误消息(例如,“您的输入无效”)。使用std::cerr或日志文件记录状态和诊断信息,这些信息可能有助于诊断问题,但对普通用户来说可能并不有趣。这可以包括技术警告和错误(例如,函数x的输入错误)、状态更新(例如,成功打开文件x,未能连接到互联网服务x)、长任务的完成百分比(例如,格式转换完成50%)等。
- 对于非交互式程序(工具或服务),仅将std::cerr用于错误输出(例如,无法打开文件x)。这允许错误与正常输出分开显示或分析。
- 对于本质上是事务性的任何应用程序类型(例如,处理特定事件的应用程序类型,如交互式web浏览器或非交互式web服务器),使用日志文件生成事件的事务日志,这些事件可以在以后查看。例如,输出当前正在处理的文件、完成百分比、开始计算的某个阶段的时间戳、警告和错误消息。