引言
我经常遇到一些开发者,他们对Python的错误处理机制了如指掌,但当我查看他们的代码时,却发现代码质量远远不够。Python的异常处理就是这样一个领域,它有一个广为人知的表层,以及一个更深层次、几乎不为人知的层面,许多开发者甚至没有意识到它的存在。如果你想测试一下自己对这个话题的理解,试着回答以下问题:
- 你何时应该捕获你调用的函数引发的异常,何时又不应该?
- 你如何确定应该捕获哪些异常类?
- 当你捕获到一个异常时,你应该如何处理它?
- 为什么说捕获所有异常是一种不好的做法,又在什么情况下这样做是可以接受的?
你准备好探索本文Python中错误处理的奥秘了吗?
Python中错误处理的两种方式
在Python中,编写错误处理代码主要有两种风格,通常以它们难以发音的缩写“LBYL”和“EAFP”来称呼。如果你还不熟悉这些,下面是它们的简要介绍。
三思而后行(LBYL)
“三思而后行”的错误处理模式意味着,在执行可能失败的操作之前,你应该先检查执行该操作的条件是否已经满足。
if can_i_do_x():
do_x()
else:
handle_error()
以从磁盘删除文件的任务为例。使用 LBYL 可以将其编码如下:
if os.path.exists(file_path):
os.remove(file_path)
else:
print(f"Error: file {file_path} does not exist!")
尽管初看之下这段代码似乎相当可靠,但实际上并非如此。
问题的核心在于,我们必须了解删除文件时可能遇到的所有问题,以便在调用remove()函数之前进行相应的检查。显然,文件必须存在,但文件不存在并不是导致删除失败的唯一原因。以下是一些可能导致文件无法删除的其他原因:
- 路径可能是指向一个目录而非文件
- 文件的拥有者可能不是尝试删除文件的用户
- 文件可能被设置为只读
- 文件所在的磁盘可能被设置为只读模式
- 文件可能被其他进程锁定,这在Windows系统中尤为常见
如果我们还需要对这些情况进行检查,那么上述的删除文件示例会变成什么样子?
正如你所看到的,使用“先检查再执行”(LBYL)的方式编写健壮的代码逻辑相当困难,因为你需要预见到所有可能导致函数调用失败的情况,有时候这些情况实在太多了。
使用LBYL模式时遇到的另一个问题是竞态条件。如果你先检查失败条件,然后执行操作,那么在检查和执行操作之间的短暂时间内,条件有可能发生变化。
请求宽恕比请求许可更容易(EAFP)
我相信你已经意识到,我对“先检查再执行”(LBYL)模式的看法并不高(但实际上在某些情况下它是有用的,你稍后会看到)。与之相对的模式认为,“请求宽恕比请求许可更容易”。这是什么意思呢?这意味着你应该先执行操作,然后再处理可能出现的错误。
在Python中,“请求宽恕比请求许可更容易”(EAFP)的最佳实践是使用异常来实现:
try:
do_x()
except SomeError:
handle_error()
以下是使用 EAFP 删除文件的方法:
try:
os.remove(file_path)
except OSError as error:
print(f"Error deleting file: {error}")
我相信您会认同,在大多数情况下,“请求宽恕比请求许可更容易”(EAFP)的方式比“先检查再执行”(LBYL)更受青睐。
这种模式的一个显著改进是,目标函数负责检查错误并报告,这样我们作为调用者就可以安心地调用函数,并相信它会在操作失败时通知我们。
然而,我们需要明确在except子句中应该捕获哪些异常,因为我们漏掉的任何异常都可能导致Python应用程序崩溃。对于文件删除操作,我们可以安全地假设任何引发的错误都将是OSError或其子类之一,但在其他情况下,了解一个函数可能引发哪些异常需要查阅文档或源代码。
您可能会问,为什么不捕获所有可能的异常以确保没有遗漏。这种做法并不推荐,因为它带来的问题比解决的问题还要多,我仅在稍后会讨论的一些特殊情况下才会推荐这样做。问题在于,通常您代码中的bug会以意外的异常形式表现出来。如果您每次调用函数时都在捕获并忽略所有异常,那么您很可能会错过那些本不应该发生的异常,也就是那些需要修复的bug所导致的异常。
为了避免错过那些以意外异常形式出现的应用程序错误,您应该总是捕获尽可能少的异常类列表,并且在合适的情况下,根本不捕获任何异常。将不捕获异常作为一种错误处理策略的想法保持在心中。这听起来可能有些矛盾,但实际上并非如此。我稍后会再次讨论这一点。
错误处理在现实世界中的应用
遗憾的是,传统的错误处理知识并不总是那么管用。即使你对“先检查再执行”(LBYL)和“请求宽恕比请求许可更容易”(EAFP)了如指掌,并且对try和except的使用烂熟于心,很多时候你可能仍然不确定该怎么做,或者觉得你编写的错误处理代码还有改进的空间。
因此,现在我们将以一种全新的视角来探讨错误,这种视角专注于错误本身,而不是处理它们的技巧。希望这能让你更容易知道如何应对。
首先,我们需要根据错误的来源进行分类。需要考虑的有两种类型:
- 你的代码发现了一个问题,需要生成一个错误。我将这种类型称为“新错误”。
- 你的代码从它调用的函数中接收到了一个错误。我将这种类型称为“冒泡错误”。
归根结底,错误的存在无非这两种情况,对吧?要么你需要自己引入一个新错误并将其放入系统中供应用程序的其他部分处理,要么你从其他地方接收到一个错误,需要决定如何处理。
如果你不熟悉“冒泡”这个词,它描述的是异常的一个特性。当一段代码抛出异常时,出错函数的调用者有机会在try/except块中捕获这个异常。如果调用者没有捕获它,那么异常就会向上传递给调用堆栈中的下一个调用者,这个过程会一直持续,直到有代码决定捕获并处理这个异常。当异常向调用堆栈的顶部传播时,我们称之为“冒泡”。如果异常没有被捕获,一直冒泡到顶部,Python将会中断应用程序,这时你会看到一个包含错误传播路径的堆栈跟踪,这是一个非常有用的调试工具。
可恢复错误与不可恢复错误
除了区分错误是新的还是冒泡的,你还需要判断它是否可恢复。可恢复错误是指处理它的代码可以在继续之前纠正的错误。例如,如果一段代码尝试删除一个文件,却发现文件不存在,这不是什么大问题,它可以选择忽略这个错误并继续执行。
不可恢复错误是指代码无法纠正的错误,或者说,是一个使代码在当前级别无法继续执行的错误。举个例子,考虑一个需要从数据库读取数据,进行修改并保存回去的函数。如果读取操作失败,该函数必须提前终止,因为它无法完成剩余的工作。
错误处理的四种类型
现在你可以根据错误的来源和是否可恢复来轻松地对错误进行分类,这样就只有四种不同的错误配置需要你知道如何处理。在接下来的部分,我将详细告诉你每一种错误类型应该如何处理!
- 类型1:处理新的可恢复错误
这是一个简单的情况。你的应用程序中有一段代码发现了错误条件。幸运的是,这段代码能够自己从这个错误中恢复过来并继续执行。
你认为处理这种情况的最佳方式是什么?当然是从错误中恢复过来并继续执行,而不需要打扰到其他部分!
def add_song_to_database(song):
# ...
if song.year is None:
song.year = 'Unknown'
# ...
这里我们有一个函数,负责将歌曲信息写入数据库。假设在数据库设计中,歌曲的年份字段是必填项。
借鉴“先检查再执行”(LBYL)的思想,我们可以事先检查歌曲的年份属性是否已经设置,以避免数据库写入操作失败。如果年份信息缺失,我们该如何处理这种错误呢?在这个例子中,我们可以将年份设置为“未知”,然后继续执行,因为我们知道至少不会因为这个原因导致数据库写入失败。
当然,错误恢复的具体方式会根据每个应用程序和错误的性质而有所不同。在上述例子中,我假设歌曲的年份以文本形式存储在数据库中。如果年份以数字形式存储,那么将年份设为0可能是一个可接受的处理方式。然而,在某些应用程序中,年份信息可能是必需的,这种情况下,年份未知就构成了一个不可恢复的错误。
这说得通吗?如果在应用程序的当前状态下发现错误或不一致,并且你能够纠正这种状态而不引发错误,那么就无需抛出错误,直接纠正状态并继续执行即可。
- 类型2:处理冒泡的可恢复错误
第二种情况是第一种情况的变体。这里的错误并非新产生的,而是从调用的函数中冒泡上来的。与前一个案例一样,错误的性质是接收错误的代码知道如何从中恢复并继续。
我们如何处理这种情况呢?我们采用“请求宽恕比请求许可更容易”(EAFP)的方法来捕获错误,然后执行必要的恢复操作并继续执行。
以下是add_song_to_database()函数的另一部分代码,展示了这种情况的处理方式:
def add_song_to_database(song):
# ...
try:
artist = get_artist_from_database(song.artist)
except NotFound:
artist = add_artist_to_database(song.artist)
# ...
这个函数试图从数据库中获取与歌曲关联的艺术家信息,但这个过程有时会失败,比如在添加某位艺术家的第一首歌时。该函数采用“请求宽恕比请求许可更容易”(EAFP)的方法来捕捉数据库中的“未找到”(NotFound)错误,然后通过将未知艺术家添加到数据库中来修正错误,之后继续执行。
与第一个案例类似,需要处理错误的代码清楚如何调整应用程序的状态以便继续运行,因此它可以处理错误并继续执行。在这个代码之上的调用栈中的任何层级都不需要知道发生了错误,因此这个错误的冒泡在这里停止。
- 类型3:处理新的不可恢复错误
第三种情况更加有趣。现在我们遇到了一个新的错误,其严重性到了代码无法处理、也无法继续执行的地步。唯一合理的行动是停止当前函数,并在调用栈中向上一级报告错误,希望调用者知道如何处理。正如上文讨论的,在Python中,通知调用者错误的推荐方式是抛出一个异常,这正是我们将要做的事情。
这种策略之所以有效,是因为不可恢复错误有一个有趣的特性。在大多数情况下,当不可恢复错误上升到调用栈的足够高的位置时,它最终会变得可以恢复。因此,错误可以一直冒泡到调用栈,直到它变得可以恢复,在这一点上它将成为类型2错误,我们知道如何处理。
让我们再次看看add_song_to_database()函数。我们已经知道,如果歌曲的年份缺失,我们决定可以恢复并防止数据库错误,方法是将年份设置为“未知”(Unknown)。然而,如果歌曲没有名称,那么在这个级别上就很难知道正确的做法是什么,因此我们可以说缺少名称对于这个函数来说是一个不可恢复的错误。以下是我们如何处理这个错误的方式:
def add_song_to_database(song):
# ...
if song.name is None:
raise ValueError('The song must have a name')
# ...
决定使用哪种异常类通常取决于你的应用程序以及你的个人偏好。对于许多错误情况,可以直接使用Python内置的异常类。如果现有的内置异常都不适用,你也可以创建自己的异常子类。下面是一个使用自定义异常的示例:
class ValidationError(Exception):
pass
# ...
def add_song_to_database(song):
# ...
if song.name is None:
raise ValidationError('The song must have a name')
# ...
这里需要特别指出的是,raise关键字会中断当前函数的执行。这是必需的,因为我们已经认定这个错误是无法恢复的,所以错误之后函数的剩余部分将无法完成它们的任务,也不应该继续执行。抛出异常会中断当前函数,并开始将错误向上冒泡,从最近的调用者开始,沿着调用栈一直向上,直到有代码决定捕获这个异常。
- 类型4:处理冒泡的不可恢复错误
现在我们遇到了一段代码,它调用了一些函数,而这个函数抛出了一个错误,我们的函数不知道如何修复问题以便我们可以继续执行,因此我们必须将这个错误视为不可恢复的。我们现在该怎么办?
答案可能会让你感到惊讶。在这种情况下我们什么都不做!
我之前提到过,不处理错误可以是一种极佳的错误处理策略,这正是我的意思。
让我给你展示一个通过什么都不做来处理错误的例子:
def new_song():
song = get_song_from_user()
add_song_to_database(song)
假设在new_song()调用的两个函数都有可能失败并抛出异常。这里有一些可能导致这些函数出错的情况:
- 用户可能在get_song_from_user()函数等待输入时按下Ctrl-C,或者在GUI应用程序中,用户可能点击了关闭或取消按钮。
- 在任一函数执行过程中,数据库可能因为云服务的问题而离线,导致所有的查询和提交操作暂时失败。
如果我们无法从这些错误中恢复,那么尝试捕获它们也就没有意义。实际上,最好的策略是什么也不做,让异常自然地冒泡上去。最终,这些异常会到达知道如何恢复的代码层级,到那时它们就会变成类型2错误,这些错误很容易被捕捉和处理。
你可能认为这种情况非常罕见。但我认为你错了。实际上,你应该设计你的应用程序,使得尽可能多的代码位于不需要处理错误处理的函数中。将错误处理代码提升到更高级别的函数是一个非常有效的策略,它有助于保持代码的清晰和可维护性。
我预计你们中的一些人可能不同意。也许你认为上面的add_song()函数至少应该打印一条错误消息来通知用户发生了失败。我并不反对这个观点,但让我们思考一下。我们能确定有控制台可以打印错误消息吗?或者这是一个GUI应用程序?GUI应用程序没有标准输出,它们通过警告或消息框以图形化的方式向用户展示错误。也许这是一个网络应用程序?在网络应用程序中,你通过向用户返回HTTP错误响应来展示错误。这个函数需要知道这是哪种类型的应用程序以及如何向用户展示错误吗?关注点分离原则告诉我们它不需要。
我再次强调,在这个函数中什么也不做并不意味着我们忽略了错误,而是意味着我们允许错误冒泡,以便应用程序中具有更多上下文的其他部分能够适当地处理它。
本文由mdnice多平台发布