简单易懂的Python:错误
玩接球游戏
读取回溯
你的朋友,一个例外
警惕尿布反模式
除、否则、最后
卓越
使用异常
自定义例外
审查
喜欢这些文章吗?那就买本书吧! Jason C. McDonald 的《Dead Simple Python》由 No Starch Press 出版。
例外情况。
这是许多程序员的头号敌人之一。在许多编程语言中,我们习惯于将异常与某种程度的失败联系起来;这意味着某个地方的某些东西被错误地使用了。
如果我告诉你,你不必害怕异常呢?它们其实是想成为你的朋友,帮助你编写更好的代码?
Python 提供了许多常见的错误处理工具,但它们的使用方式可能与你以往的习惯大相径庭,而且它的功能远不止于清理错误。你甚至可以说,Python 的错误处理机制内部结构更加复杂。
杰罗尼莫!
玩接球游戏
如果您对例外情况不太熟悉,我们先从一般定义开始……
异常:(计算机)正常处理过程中的中断,通常由错误情况引起,可以由程序的其他部分处理。(维基词典)
我们先来看一个简单的例子:
def initiate_security_protocol(code):
if code == 1:
print("Returning onboard companion to home location...")
if code == 712:
print("Dematerializing to preset location...")
code = int(input("Enter security protocol code: "))
initiate_security_protocol(code)
>>> Enter security protocol code: 712
Dematerializing to preset location...
>>> Enter security protocol code: seven one two
Traceback (most recent call last):
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/security_protocols.py", line 7, in <module>
code = int(input("Enter security protocol code: "))
ValueError: invalid literal for int() with base 10: 'seven one two'
显然,这是个问题。我们不希望程序因为用户输入了一些奇怪的内容而突然崩溃。正如那句老话所说……
一位质量保证工程师走进一家酒吧。他点了一杯啤酒。他点了五杯啤酒。他点了负一杯啤酒。他点了一只蜥蜴。
我们需要防止出现异常输入。在这种情况下,只有一个主要的故障点:就是那个int()函数。它期望接收一个可以转换为整数的数据类型,如果接收不到,就会抛出异常。为了正确处理这种情况,我们将可能ValueError出错的代码封装在一个代码块中。try...except
try:
code = int(input("Enter security protocol code: "))
except ValueError:
code = 0
initiate_security_protocol(code)
当我们再次测试代码时,就不会再出现那个错误了。如果我们无法从用户那里获取所需信息,我们会直接使用代码0代替。当然,我们可以重写initiate_security_protocol()函数来处理不同的代码0,不过为了节省时间,这里我就不赘述了。
陷阱提醒:不知何故,作为一名精通多种语言的程序员,我经常忘记except在 Python 中使用 `if` 语句,而不是像catch大多数其他语言那样使用 `if` 语句。这篇文章里我已经打错了三次(然后立刻就改正了)。这只是一个需要记忆的地方。好在 Python 没有 `if` 关键字,所以语法错误会更容易被发现。如果你也懂多种语言,遇到这种情况时不要慌张。正确的写法是 ` if`catch,而不是` exceptif` catch。
读取回溯
在深入探讨该语句的细节之前try...except,让我们再回顾一下这个错误信息。毕竟,如果不讨论错误信息,一篇关于错误处理的文章又有什么意义呢?在 Python 中,我们称之为“回溯”(Traceback),因为它追溯了错误的源头,从第一行代码到最后一行代码。在许多其他语言中,这被称为“堆栈跟踪”(stack trace)。
Traceback (most recent call last):
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/security_protocols.py", line 7, in <module>
code = int(input("Enter security protocol code: "))
ValueError: invalid literal for int() with base 10: 'seven one two'
我习惯从下往上阅读这些消息,因为这样能让我快速找到最重要的信息。如果你看最后一行,你会看到ValueError,这就是抛出的具体异常。具体细节如下:在这种情况下,无法使用将字符串转换'seven one two'为整数int()。我们还了解到,它试图转换为十进制整数,这在其他情况下可能很有用。例如,想象一下,如果那一行写的是……
ValueError: invalid literal for int() with base 10: '5bff'
如果我们忘记指定十进制(base-16),比如int('5bff', 16)使用默认的十进制(base-10),就完全有可能发生这种情况。简而言之,你一定要仔细阅读并理解错误信息的最后一行!我曾经无数次因为只看了一眼错误信息,就浪费了半个小时去追查错误的bug,最后才发现只是漏掉了一个参数或者用错了函数。
错误信息上方是导致错误的代码行号(code = int(input("Enter security protocol code: ")))。再上方是文件的绝对路径(security_protocols.py)和行号7。该语句in <module>表示代码位于任何函数之外。在这个例子中,回调函数只有一个步骤,所以我们来看一个稍微复杂一些的例子。我已经修改并扩展了之前的代码。
Traceback (most recent call last):
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/databank.py", line 6, in <module>
decode_message("Bad Wolf")
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/databank.py", line 4, in decode_message
initiate_security_protocol(message)
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/security_protocols.py", line 2, in initiate_security_protocol
code = int(code)
ValueError: invalid literal for int() with base 10: 'Bad Wolf'
我们遇到了和之前类似的错误——我们试图将字符串转换为整数,但失败了。倒数第二行显示了出错的代码;果然,就是那个引发错误的调用int()。根据上一行的说明,这段有问题的代码位于security_protocols.py`<string>` 函数内部的第 2 行initiate_security_protocol()。太好了!我们其实可以就此打住,把它包装在一个 `<string>` 中try...except。明白为什么从下往上阅读可以节省时间了吧?
然而,假设情况并非如此简单。也许我们没有修改模块的选项security_protocols.py,因此我们需要在模块执行之前就databank.py阻止问题发生。如果我们查看接下来的两行代码,会发现第4 行,在decode_message()函数内部,我们调用了initiate_security_protocol()出现问题的函数。而这个函数又在第 6 行被调用databank.py,此时它位于任何函数之外,而我们正是在这里将参数传递"Bad Wolf"给了它。
数据输入本身没有问题,因为我们想要解码消息“Bad Wolf”。但是,为什么要把我们正在尝试解码的消息直接传递给安全协议呢?或许我们需要重写那个函数(或者除了其他修改之外再重写一下?)。正如你所见,回溯信息对于理解错误根源至关重要。养成仔细阅读回溯信息的习惯;许多有用的信息可能隐藏在意想不到的地方。
顺便说一下,第一行每次都一样,但如果你忘记了如何阅读这些消息,它就非常有用。最近执行的代码列在最后。因此,正如我之前所说,你应该从下往上阅读它们。
你的朋友,一个例外
“事后请求原谅比事前获得许可容易得多。”——格蕾丝·霍珀海军少将
这句话最初是关于主动性的;如果你相信某个想法,就应该冒险尝试,而不是等待别人的许可才去实现它。然而,在这里,它完美地诠释了 Python 的错误处理理念:如果某个程序可能经常以一种或多种特定方式出错,那么通常最好使用try...except异常处理语句来处理这些情况。
这种理念的正式名称是“请求原谅比请求许可更容易”,或EAFP。
这有点抽象,所以我们来看另一个例子。假设我们想在字典里查找信息。
datafile_index = {
# Omitted for brevity.
# Just assume there's a lot of data in here.
}
def get_datafile_id(subject):
id = datafile_index[subject]
print(f"See datafile {id}.")
get_datafile_id("Clara Oswald")
get_datafile_id("Ashildir")
See datafile 6035215751266852927.
Traceback (most recent call last):
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/databank.py", line 30, in <module>
get_datafile_id("Ashildir")
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/databank.py", line 26, in get_datafile_id
id = datafile_index[subject]
KeyError: 'Ashildir'
第一个函数调用运行正常。我们在字典中查找database_index键 `a` "Clara Oswald",该键存在,因此我们返回与其关联的值(`a` 6035215751266852927),并将该数据打印到我们精心格式化的print()语句中。然而,第二个函数调用失败了。KeyError抛出异常,因为 `a`"Ashildir"不是字典中的键。
技术说明: Pythoncollections.defaultdict为这个问题提供了另一种解决方案;尝试访问不存在的键时,会在字典中创建键值对,并使用默认值。但是,由于这是一个演示错误处理的示例,因此我没有使用它。
由于我们不可能合理地记住字典中的所有键,尤其是在实际应用中,因此我们需要一些方法来处理尝试访问不存在的键这种常见情况。你的第一反应可能是在尝试访问之前先检查字典中是否存在该键……
def get_datafile_id(subject):
if subject in datafile_index:
id = datafile_index[subject]
print(f"See datafile {id}.")
else:
print(f"Datafile not found on {subject})
在 Python 文化中,这种方法被称为“三思而后行”[LBYL]。
但这并不是最有效的方法!这里就体现了“宽恕而非许可”的原则:我们不先进行测试,而是使用try...except……
def get_datafile_id(subject):
try:
id = datafile_index[subject]
print(f"See datafile {id}.")
except KeyError:
print(f"Datafile not found on {subject}")
其背后的逻辑很简单:我们只需访问一次密钥(即“权限”方法),而不是访问两次,并将实际异常作为逻辑分支的手段。
在 Python 中,我们并不认为异常是需要避免的。事实上,try...except异常是许多 Python 设计模式和算法的常规组成部分。不要害怕抛出和捕获异常!实际上,即使是键盘中断也是通过KeyboardInterrupt异常来处理的。
警告: try...except虽然警告功能强大,但并非万能。例如,None函数直接返回通常比抛出异常更好。只有当发生真正错误且最好由调用者处理时,才应该抛出异常。
警惕尿布反模式
每个Python开发者迟早都会发现这种方法有效:
try:
someScaryFunction()
except:
print("An error occured. Moving on!")
裸块except允许你一次性捕获所有异常。在 Mike Pirnat 的著作《如何在 Python 中犯错》(O'Reilly,2018)中,他称之为“尿布模式” ,这真的是一个非常糟糕的做法。我让他来总结一下……
……所有关于实际错误的宝贵上下文信息都被困在了“尿布”里,永远无法重见天日,也无法出现在你的问题跟踪系统中。当稍后发生“崩溃”异常时,堆栈跟踪指向的是次要错误发生的位置,而不是 try 代码块内部的实际错误。
简而言之,你应该始终显式地捕获特定的异常类型。任何你无法预见的故障都可能与需要解决的某些错误有关;例如,当你极其复杂的搜索函数突然抛出异常OSError而不是预期的异常KeyError时TypeError。
正如往常一样,Python之禅对此也有话要说……
错误绝不应该默默无闻地通过,
除非明确地设置了静默模式。
换句话说,这又不是宝可梦——你不应该把它们全部抓走!
您可以在文章《最邪恶的 Python 反模式》中详细了解为什么尿布模式是一个如此糟糕的主意。
除、否则、最后
太好了,这样我就不用一次性捕获所有异常了。那么,我该如何处理多个可能出现的失败情况呢?
你会很高兴地发现,Pythontry...except拥有的工具比它最初展现出来的要多得多。
class SonicScrewdriver:
def __init__(self):
self.memory = 0
def perform_division(self, lhs, rhs):
try:
result = float(lhs)/float(rhs)
except ZeroDivisionError:
print("Wibbly wobbly, timey wimey.")
result = "Infinity"
except (ValueError, UnicodeError):
print("Oy! Don't diss the sonic!")
result = "Cannot Calculate"
else:
self.memory = result
finally:
print(f"Calculation Result: {result}\n")
sonic = SonicScrewdriver()
sonic.perform_division(8, 4)
sonic.perform_division(4, 0)
sonic.perform_division(4, "zero")
print(f"Memory Is: {sonic.memory}")
在展示输出结果之前,请仔细阅读代码。你认为这三个sonic.perform_division()函数调用分别会输出什么?最终存储的是什么sonic.memory?看看你能不能找出答案。
你觉得自己答对了吗?让我们看看你是否答对了。
Calculation Result: 2.0
Wibbly wobbly, timey wimey.
Calculation Result: Infinity
Oy! Don't diss the sonic!
Calculation Result: Cannot Calculate
Memory Is: 2.0
你感到惊讶吗?还是猜对了?让我们来分析一下。
try:当然,这就是我们尝试运行的代码,它可能会也可能不会引发异常。
except ZeroDivisionError:当我们尝试除以零时,就会发生这种情况。我们称该值为"Infinity"计算结果,并打印出一条关于时空连续体本质的恰当信息。
except (ValueError, UnicodeError):当引发以下两个异常之一时,就会发生此异常。ValueError当传递的任何参数无法通过 `std::float` 进行类型转换float()时,就会发生此异常;而UnicodeError当 Unicode 编码或解码出现问题时,就会发生此异常。实际上,第二个异常只是为了说明问题而添加的;ValueError对于所有参数无法转换为浮点数的合理场景,`std::float` 就足够了。无论哪种情况,我们都使用 `std::float` 的值"Cannot Calculate"作为结果,并提醒用户不要对硬件提出不合理的要求。
这里就变得有趣了。这段代码仅在没有抛出异常的情况下else:运行。在这种情况下,如果我们得到了一个有效的除法计算结果(数值),我们实际上希望将其存储在内存中;相反,如果结果是“无穷大”或“无法计算”,则不会存储。
无论如何,该finally:部分都会运行。在这种情况下,我们会打印出计算结果。
顺序很重要。我们必须遵循这个模式try...except...else...finally。else如果存在,则必须放在所有except语句之后。finally始终放在最后。
一开始很容易混淆else两者finally,所以务必理解它们的区别。else仅在未引发异常时运行;finally每次都会运行。
最终版有多正式finally?
你认为以下操作会起到什么作用?
class SonicScrewdriver:
def __init__(self):
self.memory = 0
def perform_division(self, lhs, rhs):
try:
result = float(lhs)/float(rhs)
except ZeroDivisionError:
print("Wibbly wobbly, timey wimey.")
result = "Infinity"
except (ValueError, UnicodeError):
print("Oy! Don't diss the sonic!")
result = "Cannot Calculate"
else:
self.memory = result
return result
finally:
print(f"Calculation Result: {result}\n")
result = -1
sonic = SonicScrewdriver()
print(sonic.perform_division(8, 4))
下面那return句话else应该就到此为止了吧?其实不然!如果我们运行那段代码……
Calculation Result: 2.0
2.0
由此可以得出两个重要的结论:
-
finally即使在我们return执行语句之后,该函数仍在运行。它没有像往常一样退出。 -
该
return语句确实在代码块执行之前运行。我们finally知道这一点是因为输出结果为 `null`2.0,而不是我们在语句中-1赋值给 `null` 的值。resultfinally
finally每次都会运行,即使结构return中其他地方也有try...except。
os.abort()然而,我也用 `if` 语句代替 `if`语句进行了测试return result,在这种情况下,finally代码块根本没有执行;程序直接中止了。你可以在任何地方直接停止程序执行,Python 会放弃当前正在执行的操作并退出。即使出现这种异常finally行为,这条规则仍然不变。
卓越
所以,我们可以用它来捕获执行try...except。但如果我们真的想抛出一个执行呢?
在 Python 术语中,我们说我们抛出了一个异常,就像这门语言中的大多数事情一样,实现这一点显而易见:只需使用raise关键字即可:
class Tardis:
def __init__(self):
pass
def camouflage(self):
raise NotImplementedError('Chameleon circuits are stuck.')
tardis = Tardis()
tardis.camouflage()
执行该代码时,我们会看到引发的异常。
Traceback (most recent call last):
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/tardis.py", line 10, in <module>
tardis.camoflague()
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/tardis.py", line 7, in camoflague
raise NotImplementedError('Chameleon circuits are stuck.')
NotImplementedError: Chameleon circuits are stuck.
唉,看来我们只能用那张警亭停车表格了。不过至少这样更容易记住停车位置。
注意:` NotImplementedErrorNone` 异常是Python 内置异常之一,有时用于指示某个函数尚未完成(但将来会完成),因此不应立即使用。它与 `None`值不可互换。请参阅文档以了解何时使用哪个异常。NotImplemented
关键代码显然是 ` raise NotImplementedError('Chameleon circuits are stuck.').`。在关键字之后raise,我们需要指定要引发的异常对象的名称。大多数情况下,我们会创建一个基于 `Exception` 类的新对象,正如您从括号的使用中看到的那样。所有异常都接受一个字符串作为第一个参数,用于指定异常消息。某些异常接受或需要更多参数,请参阅相关文档。
使用异常
有时我们需要在捕获异常后对其进行一些处理。有一些非常简单的方法可以做到这一点。
最直接的方法是打印异常信息。为此,我们需要能够操作捕获到的异常对象。让我们将语句更改except为`<exception_object> except NotImplementedError as e:`,其中e`<name>` 是我们绑定到异常对象的名称。然后,我们就可以直接使用 ` e<exception_object>` 作为对象了。
tardis = Tardis()
try:
tardis.camouflage()
except NotImplementedError as e:
print(e)
异常类定义了__str__()返回异常消息的函数,所以如果我们将其强制转换为字符串str(),就能得到异常消息。你可能还记得之前的文章中提到过,`__init__` 函数会print()自动将其参数强制转换为字符串。当我们运行这段代码时,会得到……
Chameleon circuits are stuck.
太好了,这很简单!
沸腾
那么,如果我们想再次抛出异常呢?
等等,什么?我们才刚抓到它,为什么还要再提一遍?
例如,如果您需要在后台执行一些清理工作,但最终仍然希望调用者处理异常。以下是一个示例……
class Byzantium:
def __init__(self):
self.power = 0
def gravity_field(self):
if self.power <= 0:
raise SystemError("Gravity Failing")
def grab_handle():
pass
byzantium = Byzantium()
try:
byzantium.gravity_field()
except SystemError:
grab_handle()
print("Night night")
raise
在上面的例子中,我们只是想抓住某个实体(grab_handle()),打印一条额外的消息,然后让异常继续抛出raise。当我们重新抛出异常时,我们称之为“冒泡”。
Night night
Traceback (most recent call last):
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/byzantium.py", line 18, in <module>
byzantium.gravity_field()
File "/home/jason/Code/FiringRanges/PyFiringRange/Sandbox/byzantium.py", line 8, in gravity_field
raise SystemError("Gravity Failing")
SystemError: Gravity Failing
注意:你可能觉得我们需要加上except SystemError as e:`and`raise e或 `or` 之类的语句,但这完全是多余的。要让异常向上冒泡,我们只需要raise单独调用 `this` 即可。
那么,如果我们想在抛出异常的同时添加一些额外信息呢?你可能首先想到的是抛出一个新的异常,但这会引入一些问题。为了演示这一点,我将在执行顺序中添加另一层。注意,当我处理该异常时SystemError,我抛出的是一个新的RuntimeError异常。我在第二个代码块中捕获了这个新的异常try...except。
byzantium = Byzantium()
def test():
try:
byzantium.gravity_field()
except SystemError:
grab_handle()
raise RuntimeError("Night night")
try:
test()
except RuntimeError as e:
print(e)
print(e.__cause__)
运行此程序后,我们将得到以下输出。
Night night
None
当我们捕获到这个新异常时,我们完全不知道它是由什么引起的。为了解决这个问题,Python 3在PEP 3134中引入了显式异常链。实现起来很简单。看看我们的新函数,这是我对上一个例子唯一做的改动。test()
byzantium = Byzantium()
def test():
try:
byzantium.gravity_field()
except SystemError as e:
grab_handle()
raise RuntimeError("Night night") from e
try:
test()
except RuntimeError as e:
print(e)
print(e.__cause__)
你明白我在这里的操作了吗?在except语句中,我将名称绑定e到了我们之前捕获的异常。然后,在抛出新RuntimeError异常时,我将其与之前的异常链接起来from e。现在我们的输出是……
Night night
Gravity Failing
运行该程序时,新抛出的异常会记住它的来源——之前的异常信息存储在它的__cause__属性中(输出的第二行会显示)。这对于日志记录尤其有用。
使用异常类还有许多其他技巧可以实现,尤其是在引入 PEP 3134 之后。和往常一样,我建议您阅读文档,我在文章末尾提供了链接。
自定义例外
Python 拥有大量的异常处理机制,并且它们的用法都有非常详细的文档说明。我经常参考这份异常处理列表来选择合适的异常处理程序。然而,有时候,我们需要一些更……个性化的异常处理方法。
所有错误类型的异常都派生自 `Exception` 类Exception,而 `Exception` 类又派生自 ` BaseExceptionException` 类。这种双重层次结构的目的是为了捕获所有错误,Exceptions而无需处理像 `Exception` 这样特殊的、非系统退出的异常KeyboardInterrupt。当然,这在实践中对你影响不大,因为 `Exception`except Exception实际上总是我之前提到的“尿布反模式”的另一种形式。无论如何,不建议你直接从 `Exception` 派生BaseException——只需知道它的存在即可。
创建自定义异常时,实际上可以继承任何你喜欢的异常类。有时,最好继承与你创建的异常用途最接近的异常类。但是,如果你不知道该选择哪个,也可以直接继承自 `Exception` 类Exception。
咱们来做一个吧?
class SpacetimeError(Exception):
def __init__(self, message):
super().__init__(message)
class Tardis():
def __init__(self):
self._destination = ""
self._timestream = []
def cloister_bell(self):
print("(Ominous bell tolling)")
def dematerialize(self):
self._timestream.append(self._destination)
print("(Nifty whirring sound)")
def set_destination(self, dest):
if dest in self._timestream:
self.cloister_bell()
self._destination = dest
def engage(self):
if self._destination in self._timestream:
raise SpacetimeError("You should not cross your own timestream!")
else:
self.dematerialize()
tardis = Tardis()
# Should be fine
tardis.set_destination("7775/349x10,012/acorn")
tardis.engage()
# Also fine
tardis.set_destination("5136/161x298,58/delta")
tardis.engage()
# The TARDIS is not going to like this...
tardis.set_destination("7775/349x10,012/acorn")
tardis.engage()
显然,最后一个操作会导致我们提出的SpacetimeError异常被抛出。
我们再来看一下这个异常类声明。
class SpacetimeError(Exception):
def __init__(self, message):
super().__init__(message)
其实写起来非常简单。如果你还记得我们之前对类的讲解,super().__init__()就会知道 `this` 是调用基类的初始化器,Exception在本例中就是 `this`。我们将传递给异常构造函数的消息SpacetimeError传递给基类的初始化器。
事实上,如果我所做的只是将参数传递message给super()类,我可以让它变得更简单:
class SpacetimeError(Exception):
pass
Python 本身就能处理基本操作。
这就是我们需要做的全部,当然,和往常一样,我们还可以利用它做更多的事情。自定义异常不仅仅是一个好听的名字;我们可以用它们来处理各种不寻常的错误情况,但这显然超出了本指南的范围。
审查
恭喜你顺利完成了我们对 Python 错误探索的讲解,没有被蒸发、删除、升级,或者误送到其他县,为你欢呼三声!让我们回顾一下要点:
- 不要害怕Python中的异常!我们可以利用它们使我们的代码更加简洁。
- 使用代码块捕获异常
try...except。(是 `if` 语句except,不是`if` 语句catch!) - 永远不要使用尿布反模式,这只是一个简单的
except:声明,或者(通常)是一个except Exception: - 该
else代码块可以包含在最后一个代码块之后except,并且只有在代码块中的代码没有引发异常时才会运行try。 - 该
finally代码块可以包含在语句的末尾try...except,并且总是会执行,即使我们处于OR代码块return中。exceptelse - 我们通过抛出异常来“抛出”它,方法是通过
raise WhateverError("Our message") - 在代码块内
except,我们可以使用裸露的异常向上冒泡raise(重新引发) 。 - 我们可以通过继承该类
Exception或其众多子类之一来创建自定义异常类。
一如既往,文档中包含的信息远不止这些。我强烈建议大家查阅一下:
- Python教程:错误和异常
- Python 参考:内置异常
- Python 参考:内置异常 -
NotImplementedError - Python 参考:内置常量 -
NotImplemented - PEP 3134:异常链和嵌入式回溯
- 最邪恶的 Python 反模式
感谢deniska和grym(Freenode IRC #python) 提出的修改建议。