2024-04-11    2024-04-17     14708 字  30 分钟

在我们的日常生活中,灵活应对突发状况是常态。比如说,在家炒菜时,火候的掌控至关重要。如果火太大,菜可能会糊;火太小,则菜可能生熟不均。为了避免这些情况,我们会经常检查食物的烹饪状态,并根据需要适时调整火力。这一过程,实际上是我们在遇到烹饪过程中的“偏差”时,立刻采取行动进行调整,确保最终的菜肴美味可口。

同样的逻辑也适用于更宏观的层面,比如国家政策的制定和执行。以COVID-19疫情为例,面对这一突如其来的公共卫生危机,国家迅速制定并实施了一系列控制和管理措施,如城市封锁、健康码推广、居家隔离等,以有效控制疫情的蔓延。这些措施,本质上是对“异常状况”的应对策略,目的是尽可能减轻疫情带来的影响,保证社会秩序和公共安全。在经济领域,政府对可能出现的波动和不确定性同样有着预先的应对策略。面对经济放缓的“异常情况”,政府可能会通过实施减税、增加公共支出等措施,以刺激经济、推动恢复。

当我们将这种应对突发状况的思维方式应用于编程时,同样可以发现它的价值。在 Python 编程中,我们通过异常处理机制来应对程序运行过程中可能遇到的各种意外情况。通过定义 tryexcept 语句,我们可以在代码中预设应对措施,当程序运行出现异常时,立即激活这些措施,确保程序能够优雅地处理问题,而不是无助地崩溃。

接下来让我们一起探讨什么是异常,以及 Python 如何处理那些可能使程序偏离既定轨道的“异常”情况。

异常处理基础

在 Python 中,异常是指在程序执行过程中发生的一个事件,该事件会打断正常的程序指令流程。这种情况通常表明程序发生了某些非预期的情况,需要程序以特定的方式进行响应。异常可能由各种各样的原因触发,比如逻辑错误、操作系统层面的问题、用户输入了无效数据、或资源相关的难题等等。

具体来说,当 Python 解释器遇到它无法按常规方式处理的情形时,它将引发一个异常。这一机制将异常变为控制流的一部分,使得程序能够以灵活的方式回应各类错误。当一个异常被引发时,它会沿着调用栈向上传递,直到遇到一个适当的异常处理器。如果异常没有被捕获和处理,它会到达程序的顶层,导致程序终止并可能显示一个错误消息,解释为什么程序停止运行。

关于对程序执行的影响,异常的最为直接的后果是中断当前的执行流。一旦异常发生,正执行的代码会立刻停止,Python 此时就开始寻找能够处理这个异常的代码段。如果在适当的位置设置了异常处理机制(例如下面介绍的 tryexcept 块),Python 解释器将转向这些代码,从而允许程序响应异常情况。如果异常未被任何 except 块捕获,程序将终止,Python 解释器会输出一个错误消息,其中包含异常类型和堆栈跟踪信息。为我们剖析问题提供了线索。

想象一下,我们有个小程序,目的是让用户输入一个整数,然后它会告诉用户这个数的倒数是多少。

user_input = int(input("请输入一个整数:"))
reciprocal = 1 / user_input
print("该数的倒数是:", reciprocal)

显然,当我们输入正常的任何非0整数时,该程序可以输出该非0整数的倒数,完成我们预期的工作。

但是,如果有个小朋友可能还不太懂,他输入了0,那会怎么样呢?在数学里,0是没有倒数的,因为你不能把数字除以0,这样的操作在数学上是没有意义的。幸运的是,Python 也是这么认为的。当小朋友输入了0,该程序就会困惑,然后就会报错,告诉你它不能完成这个除法运算。提示的信息如下:

Traceback (most recent call last):
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 2, in <module>
    reciprocal = 1 / user_input
                 ~~^~~~~~~~~~~~
ZeroDivisionError: division by zero

我们可以从该信息中提取出很多有用信息,让我们一行一行地分析这个错误信息:

Traceback (most recent call last): 这一行是错误信息的开始,告诉咱们 Python 开始回溯(Traceback),并尝试找到错误的源头。这里的 most recent call last 意思是说 Python 将从最近的一次函数调用开始追踪,向上回溯到错误发生的地点。

File "c:\Users\rolfz\Desktop\error_handling\err.py", line 2, in <module>: 这行告诉咱们错误发生的具体位置。错误是在位于 c:\Users\rolfz\Desktop\error_handling 路径下,名为 err.py 的文件中,第2行代码处发生的。这里的<module> 指的是这段代码不在任何函数内,而是在模块级别执行的。

reciprocal = 1 / user_input: 这是引发错误的具体代码行。Python 指出在尝试执行 1 / user_input 这个操作时出现了问题。

~~^~~~~~~~~~~~: 这行是指示器,它指向上一行中出现问题的具体位置。在这个例子中,它指向了 user_input,暗示这个变量是问题所在。

ZeroDivisionError: division by zero: 最后,这行描述了具体的错误类型——ZeroDivisionError,并说明了错误的原因是 division by zero,即尝试进行了除以零的操作。

一行一行分析发现,这个错误信息告诉我们,程序在尝试执行 1 / user_input 这行代码时,由于 user_input 的值为0,导致了一个除以零的操作,从而引发了 ZeroDivisionError 异常。

除了可能输入0这个值以外,该程序的用户还有没有可能输入其他?答案是肯定的,因为该程序并没有指示或者约束用户必须输入整数。比如该程序的用户是一个不认识中文的外国人。他/她可能会随意地猜这是让他/她干什么,然后按个人兴趣输入了一个 cat。此时,Python 会有什么行为呢?

按照程序的逻辑,Python 应该会终止程序的运行,并抛出一个异常。但是这个异常会是什么?是不是和输入0值时的一样, 为 ZeroDivisionError?我们重新执行以上程序,并输入cat,查看 Python 会抛出什么样的信息。

Traceback (most recent call last):
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 1, in <module>
    user_input = int(input("请输入一个整数:"))
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'cat'

前两行的错误信息和输入0的情况类似,第一行仍然表示 Python 开始回溯以找到错误的根源,该行是错误信息的开头。第二行指出了错误发生在路径 c:\Users\rolfz\Desktop\error_handling\ 下,文件名为 err.py 的文件中的第1行代码。<module> 仍然表示错误发生在模块级别,而不在任何特定的函数或类中。

从第三行开始有了区别,第三行指出出现程序问题的代码发生在 user_input = int(input("请输入一个整数:")) 。随后,Python 用了 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 作为指示器,并指向上一行中出现问题的具体位置。在这种情况下,它指向了整个 int(input("请输入一个整数:")) 表达式。

最后一行仍然是错误的具体描述。但是显示该种情况下,是 ValueError 异常。与 ZeroDivisionError 不同, ValueError 表明发生了一个值错误。结合第三行的信息,我们可以发现该值错误是程序尝试将字符串 'cat' 转换为十进制整数时失败了。跟在 ValueError 后的信息具体指出了 'cat' 不是一个有效的数字字符串 (invalid literal for int() with base 10: 'cat'),所以不能被解析为整数。

观察上面输入0引发的 ZeroDivisionError 和输入 'cat' 引发的 ValueError 之后,我们应该有了一个大致的印象:Python 可能为不同的错误情形提供了比较精确的异常类型。的确如此,ZeroDivisionErrorValueError 仅是 Python 异常体系中的一角。事实上,Python 内置了许多其他的异常类型,涵盖更广泛的错误情况。比如,下面列出了一些常见的内置异常类型:

  • TypeError:当操作或函数应用于不适当类型的对象时触发。
  • IndexError:当序列中使用的索引超出范围时引发。
  • KeyError:当字典中请求一个不存在的键时触发。
  • FileNotFoundError:试图打开不存在的文件时引发。
  • NameError:尝试访问一个未声明的变量时触发。
  • IOError:在输入/输出操作失败时引发,如“文件未找到”或“磁盘已满”等。
  • ImportError:在导入模块或其属性失败时引发。
  • KeyboardInterrupt:用户在程序请求输入时按下了中断键(例如Ctrl+C)引发。

这么列举似乎让我们感觉 Python 的错误类型无章可循。然而,实际上,在 Python 中,异常处理是高度有序的。异常在 Python 中是以对象的形式呈现,每一个异常都是 BaseException 类或其子类的一个实例。以 TypeError 为例,它是 Exception 类的一个子类,而 Exception 又是 BaseException 的子类。具体的异常类继承关系可以参考官网对异常类层级结构的介绍。这种设计不仅使得异常处理更加统一,也便于我们根据异常类型进行精确的错误处理。

异常的传递机制

在上面的例子中,我们仅介绍了当程序出现异常时,Python 的基本处理流程。但是,如何理解“异常沿着调用栈向上传递直至找到合适的异常处理器,若未被捕获则导致程序终止”的机制呢?为了理解这个概念,我们有必要进一步用实际的例子介绍下异常处理的传递过程。

首先,我们来解析这个机制的核心概念。当 Python 程序执行过程中遇到异常,如除以零这类错误时,程序将寻找能够处理这一异常的代码块。这个寻找过程是沿着函数的调用顺序逆向进行的,即从异常发生点开始,逐级回溯到最初的函数调用。这个回溯过程实际上是沿着程序的调用栈向上移动,寻找可能存在的异常处理器(tryexcept 块)。如果在回溯过程中找到了相应的异常处理器,Python 就会执行该处理器中的代码来处理异常。如果没有找到适当的处理器,异常会被传递到程序的最顶层,此时,如果顶层也没有处理该异常,程序就会终止并显示一个错误消息,解释程序停止运行的原因。

为了更好地理解这一过程,让我们通过一个具体的例子来演示 Python 异常处理的传递机制。假设我们有一个程序,它包含三个函数:funcAfuncBfuncC。其中,funcA 调用 funcBfuncB 调用 funcC,而 funcC 中有一个可能会引发异常的操作。

def funcC():
    print("Enter funcC")
    # 这里有一个可能会引发除以零异常的操作
    result = 1 / 0
    print("Exit funcC")
    return result

def funcB():
    print("Enter funcB")
    # 调用 funcC
    result = funcC()
    print("Exit funcB")
    return result

def funcA():
    print("Enter funcA")
    # 调用 funcB
    result = funcB()
    print("Exit funcA")
    return result

# 开始执行程序
funcA()

在这个程序中,当 funcA 被调用时,它接着调用 funcBfuncB 再调用 funcC。当执行到 funcC 中的 1 / 0 时,会引发一个 ZeroDivisionError 异常。Python 会检查 funcC 中是否有处理这个异常的代码,如果没有,异常会被传递到调用它的 funcB。同理,如果 funcB 中也没有处理,异常会继续传递到 funcA。这就是异常如何“沿着调用栈向上传递”的过程。

在这个例子中,如果所有函数(funcA, funcB, funcC)都没有处理这个异常(实际上也是),最终这个异常会传递到最顶层的函数调用(这里是 funcA 调用的地方)。如果在那里也没有处理(的确也没有),程序就会终止,并显示一个错误消息,说明是由于 ZeroDivisionError 异常导致程序停止运行。这就是当“异常没有被捕获和处理,它会到达程序的顶层”的含义。

可以结合该程序的运行结果来进一步理解:

Enter funcA
Enter funcB
Enter funcC
Traceback (most recent call last):
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 26, in <module>
    funcA()
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 20, in funcA
    result = funcB()
             ^^^^^^^
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 12, in funcB
    result = funcC()
             ^^^^^^^
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 4, in funcC
    result = 1 / 0
             ~~^~~
ZeroDivisionError: division by zero

从结果上看,程序开始执行时,首先进入 funcA,打印出 "Enter funcA",表示进入了函数 funcA。接着,当 funcA 执行到 result = funcB()这行时,程序进入 funcB 函数,打印出 "Enter funcB",表示进入了函数 funcB。紧接着,当 funcB 执行到 result = funcC() 这行,程序进入 funcC 函数,并打印出 "Enter funcC",表示进入了函数 funcC。在函数 funcC 中,result = 1 / 0 尝试执行除以零的操作,这是不被允许的,因此在这一行引发了 ZeroDivisionError 异常。

由于 funcC 中没有捕获和处理这个异常(即没有tryexcept 块),异常被向上传递给了调用它的 funcB 函数。funcB 也没有处理这个异常,因此异常继续向上传递给了 funcAfuncA 同样没有处理这个异常,因此异常继续向上传递至全局作用域。在全局作用域中也没有找到异常处理器,因此程序终止,并打印出错误消息和堆栈跟踪,如下:

Traceback (most recent call last):
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 26, in <module>
    funcA()
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 20, in funcA
    result = funcB()
             ^^^^^^^
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 12, in funcB
    result = funcC()
             ^^^^^^^
  File "c:\Users\rolfz\Desktop\error_handling\err.py", line 4, in funcC
    result = 1 / 0
             ~~^~~
ZeroDivisionError: division by zero

这部分错误和堆栈跟踪信息详细地描述了异常发生的位置和调用栈,指出了异常是在哪个文件的哪一行代码中被触发的。通过这个例子,我们可以看到当异常发生时,如果在当前函数中没有被处理,则会被传递到调用该函数的上一级函数中,这一过程会一直持续,直到找到适当的异常处理器,或者达到程序的最顶层导致程序终止。

基本的异常处理

在我们之前的讨论中,我们已经看到 Python 针对诸如用户输入错误和其他运行时异常的情况提供了一套全面的内置异常处理机制。尽管 Python 本身提供了强大的支持,但我们作为开发者通常还需要进行更精细和具体的异常处理以确保程序的健壮性和可靠性。观察 Python 的异常传递机制可以发现,Python 在检测到错误或异常时会自动沿着调用栈寻找适当的异常处理器。这一点突显了构建有效异常处理器在管理和响应程序错误中的重要性。

幸运的是,Python 通过 tryexcept 语句为我们提供了一种方法来显式构建这些异常处理器。利用这些工具,我们不仅可以精确控制程序在遇到特定错误时的反应,还能防止这些错误导致程序崩溃。具体来说,try 块让我们有机会尝试执行代码并监控可能出现的错误,而 except 块则定义了一种方法来响应和处理这些错误。这种处理策略不仅增强了程序对不确定情况的应对能力,也极大地提升了用户体验:用户将不再看到令人困扰的错误消息,而是接收到更友好、更具解释性的反馈。

下面是这两个语句的基本语法结构:

try:
    # Python 将尝试执行的代码块
    # 这里可以放置任何正常的Python代码
    # 如果在这个块中的代码抛出了异常,将会被接下来的except块捕获
except ExceptionType:
    # 当try块中发生了ExceptionType类型的异常时执行的代码
    # 这里定义如何处理异常

以上代码块的逻辑是:我们将那些可能触发异常的代码放在 try 块中。如果 try 块中的代码运行时没有问题,则执行完 try 块中的所有语句后,会跳过 except 块不执行。相反,如果在 try 块中的代码执行过程中发生了异常,Python 会自动识别这个异常的类型,并寻找与之匹配的 except 块来执行。在每个 except 块中,我们一般可以指定一个或多个处理异常的代码块。这就使程序在遇到特定错误时可以进行有针对性的响应。当然,考虑到基本上大多数异常类都继承于 Exception 类型。因此,我们在也可以使用更为通用的异常类型 Exception 来捕获几乎所有的异常情况。为此,我们可以拓展该 try ... except ... 语句,如下:

try:
    # 试图执行的代码
except ExceptionTypeA:
    # 处理TypeError异常的代码
except ExceptionTypeB:
    # 处理ValueError异常的代码
except Exception:
    # 处理除上述特定异常外的其他所有异常的代码

接下来,我们仍然以前面的接收一个整数输入并计算其倒数的程序为例,来说明如何运用 try ... except ... 语句。根据前面的演示,我们至少知道虽然这段代码直观且简单,但存在两个问题:如果用户输入的不是整数,或者输入了0,程序将直接抛出异常并崩溃。让我们增加 tryexcept 块来捕获并处理这些可能的错误:

try:
    user_input = int(input("请输入一个整数:"))
    reciprocal = 1 / user_input
    print("该数的倒数是:", reciprocal)
except ValueError:
    print("发生错误:请输入一个有效的整数。")
except ZeroDivisionError:
    print("发生错误:0没有倒数。")

在这个改进的版本中,try 块包含了可能抛出异常的代码。接着,我们设定了两个 except 块来处理不同类型的错误:

  • ValueError:当 int() 函数尝试将非数字字符串转换为整数时,如果转换失败,就会抛出 ValueError。对应的 except 块会捕获这个异常并给出我们设定的错误提示,相对而言,该提示比 Python 内置的提示更为友好。
  • ZeroDivisionError:如果用户输入0,try 块的内容在尝试计算其倒数时,将会抛出 ZeroDivisionError。相应的 except 块也会捕获这个异常并提示用户0不能用作除数。

逻辑上,运行该代码块,当用户输入一个非0整数时,其结果应该和前面的代码块的结果一致。同学们可以试着在自己的环境中执行,查看相应的结果是否正确。现在,我们试着运行该代码块,并故意输入错误的信息,查看其输出结果与前面的代码块输出结果有何异同。

输入非整数时:

请输入一个整数cat
发生错误请输入一个有效的整数

输入0时:

请输入一个整数0
发生错误0没有倒数

从结果上看,当用户输入非整数(如示例中的“cat”)时,try 语句尝试将该输入转换为整数导致了 ValueError 异常。由于我们的代码块中包含了处理 ValueErrorexcept 块,它将捕获这个异常,并输出相应的错误消息:“发生错误:请输入一个有效的整数。"。而当用户输入0时,程序将尝试计算数字0的倒数,这是数学上未定义的(即任何数字除以0都是未定义的),从而在 try 块中触发了 ZeroDivisionError 异常。同理,由于我们的代码块中预设了捕获 ZeroDivisionError 这类错误的 except 块。except 块捕获了这个异常,并输出相应的错误消息:“发生错误:0没有倒数。"。对比而言,该种处理方式改善了用户体验,他们得到的不是一些冷冰冰的错误代码,而是具体的、有帮助的信息。此外,这个例子也展示了如何根据不同的错误类型来执行不同的处理逻辑。这确保了无论用户如何输入,他们都可以得到恰当的反馈和指导。

其实,运用 try ... except ... 这种方式来设计我们的代码,还可以增强程序的健壮性,可以避免让程序直接崩溃。仍然以要求用户输入一个整数并计算它的倒数的程序为例。通常我们并不希望因为用户一次不合规范的输入,程序就直接终止了。我们更倾向于程序能提示用户“嘿,这输入不对,请再试一次”,然后用户如果这次输入对了,程序再顺利结束,并给出那个数的倒数。我们可以稍微修改下前面的 try ... except ... 语句来实现该逻辑。示例代码如下:

while True:
    try:
        user_input = int(input("请输入一个整数:"))
        reciprocal = 1 / user_input
        print("该数的倒数是:", reciprocal)
        break # 输入正确,退出循环
    except ValueError:
        print("发生错误:请输入一个有效的整数。")
    except ZeroDivisionError:
        print("发生错误:0没有倒数。")

该代码块的执行逻辑大致如下图所示:

graph TD
    A([开始]) --> B{循环: True}
    B --> C[尝试读取和处理输入]
    C -->|无异常| D[显示倒数]
    D --> E([结束循环])
    C -->|ValueError| F[打印: 发生错误:请输入一个有效的整数]
    F --> G[打印详细的错误信息]
    G --> B
    C -->|ZeroDivisionError| H[打印: 发生错误:0没有倒数。]
    H --> I[打印详细的错误信息]
    I --> B
  

运行该代码块,我们可以得到如下的结果:

请输入一个整数cat
发生错误请输入一个有效的整数
请输入一个整数0
发生错误0没有倒数
请输入一个整数1.25
发生错误请输入一个有效的整数
请输入一个整数经济管理学院
发生错误请输入一个有效的整数
请输入一个整数8
该数的倒数是: 0.125

可以看出,当程序遇到异常时,它不会直接崩溃和终止,而是会捕获这些异常,并给用户提供了另一次输入的机会。如果用户输入的内容不能被转换为整数,就会引发一个 ValueError,程序会捕捉到这个异常并打印出一条友好的错误信息:“发生错误:请输入一个有效的整数。"。同样地,如果用户输入了0,程序会尝试进行除法运算,但由于除数不能为零,这会引发一个 ZeroDivisionError,程序同样会捕捉到这个异常,并提示用户:“发生错误:0没有倒数。"。

这样的设计让用户在出错时不至于感到困惑,也不需要重新启动程序来再次尝试。程序简单明了地指出了错误,并引导用户进行正确的操作。最终,当用户按照程序的要求提供了一个有效的整数时,程序会显示其倒数,并成功结束。

尽管上述处理方法为用户提供了更友好的错误提示,但它也似乎会掩盖掉 Python 中原始的、更具体的错误信息。从调试的角度来说的话,这会使得在尝试诊断问题时缺乏足够的细节信息。那么,Python 中是否有一种机制,实现既能向用户提供易于理解的错误提示,同时又不失去对错误的详细记录呢?

答案是肯定的。Python 提供了使用 as 关键字的异常处理方法,这不仅允许我们捕获异常,还能保留与该异常相关的所有详细信息。使用 as 关键字可以将异常对象捕获到一个变量中,通常表示为 e,如 except SomeError as e:。这样,我们就可以访问 e 中存储的详细异常信息,比如错误消息和堆栈跟踪,这些都是诊断问题时不可或缺的信息。例如:

while True:
    try:
        user_input = int(input("请输入一个整数:"))
        reciprocal = 1 / user_input
        print("该数的倒数是:", reciprocal)
        break # 输入正确,退出循环
    except ValueError as e:
        print("发生错误:请输入一个有效的整数")
        print(f"详细的错误信息:{e}")
    except ZeroDivisionError as e:
        print("发生错误:0没有倒数。")
        print(f"详细的错误信息:{e}")

在这个例子中,当尝试除以零的操作发生时,会触发 ZeroDivisionError 异常并立即被捕获。程序首先会向用户展示一个简单直观的错误消息:“发生错误:尝试除以零。” 然后,通过打印出异常变量 e,程序提供了更详尽的异常信息。这种信息对开发者而言极其宝贵,因为它不仅指明了错误的性质,还可能包含了引发错误的具体上下文。此外,在实际应用中,适当地记录错误信息也是非常重要的,特别是在生产环境中。使用 except ... as e 可以帮助开发者捕获和记录详细的异常信息,这些信息可以用来分析错误的趋势、原因,进而改进产品。当发生值错误时,情况类似。该示例代码的执行结果如下:

请输入一个整数cat
发生错误请输入一个有效的整数
详细的错误信息invalid literal for int() with base 10: 'cat'
请输入一个整数0
发生错误0没有倒数
详细的错误信息division by zero
请输入一个整数8
该数的倒数是: 0.125

最后,值得注意的是,在前面,我们说也可以使用更为通用的异常类型 Exception 来捕获几乎所有的异常情况。的确是可以的,在编程中,通用的 except 块就像是汽车上的备胎,它确保了无论发生什么意外,程序都不会突然“爆胎”停下来。例如:

try:
    # 这里有些复杂的操作,可能会出错
except ValueError:
    # 处理值错误
except ZeroDivisionError:
    # 处理除以零的情况
except Exception as e:
    # 不管出了什么状况,都不让程序崩溃
    print(f"哎哟喂,出小差了:{e}")

这个通用的 except 块就是我们的“备胎”。它会捕获所有没有被前面特定的 except 块捕获的异常。这样做的好处是显而易见的,就像备胎一样让我们的旅途(程序运行)没有那么焦虑,担心中途因为各种各样意想不到的扎胎而停滞不前。

但是,就像开车时我们不可能随便在哪都能换备胎一样,在编程中滥用这个通用的 except 块也会有问题。因为它太过通用了,有时候会掩盖掉一些应该被注意到的问题。比如说,咱们的车在开的过程中出现了一些小问题,但是我们没有及时详细检查,只是简单地换上备胎继续开,此种情况无疑肯定会造成更大的安全隐患。

在编程中,我们的程序可能因为一个文件没找到(FileNotFoundError)而抛出异常,但是如果我们的通用 except 块只是打印出一个模糊的错误信息,我们可能就没法知道具体出了什么问题,也就错过了修复bug的机会。如果是开发阶段,我们可能想要程序在出现未处理的异常时停下来,这样就可以立即知道并解决它,而不是让一个可能的小问题变成未来的大麻烦。

所以,就像我们要定期对车进行保养,不要过分依赖备胎一样,我们在编程时也要谨慎地使用通用的 except 块,只在那些我们确实需要捕获任何异常以保证程序持续运行的场景下使用它。在其他时候,最好是明确指出我们期望捕获的每一种异常类型,这样代码不仅更安全,而且更容易维护和调试。

elsefinally 关键字

设想这样一个场景:你是一家销售公司的数据分析师,你每月末都面临着计算本月商品平均销售量的任务。作为一个热衷于 Python 的分析师,你决定编写一个 Python 程序来完成这一任务。从程序开发的角度考虑,你需要让这个程序能从一个文件中读取销售数据,然后基于这些数据计算出商品平均销售量。更重要的是,程序应在成功处理完所有数据后向用户提示“处理完成”。此外,不论在处理过程中是否遇到任何错误,程序都应保证进行适当的资源释放和其他清理工作,以确保系统资源的有效管理和数据的安全性。具体来说,这个程序需要实现以下几个关键功能:

  1. 读取文件:程序开始时,会尝试打开名为 data.txt 的文件,预期这个文件中包含若干行数值型数据。
  2. 处理数据:一旦文件成功打开,程序按行读取数据,每行数据会被转换为整数。程序接着计算这些数字的总和及数量,以此来得出平均销售量。
  3. 异常处理:
    • 文件不存在:如果尝试打开的文件不存在(FileNotFoundError),程序会输出相应的错误消息。
    • 数据格式错误:在数据转换过程中,如果遇到非整数数据(ValueError),程序需要捕获并处理这一异常,确保程序能继续处理其他数据。
  4. 完成处理后的操作:如果所有数据都被成功处理且无异常发生,程序将计算并显示平均值。
  5. 清理操作:无论处理过程中是否出现错误,程序都将执行一些清理操作,比如关闭打开的文件,确保所有系统资源得到妥善管理。

我们也可以画出该程序完成任务的流程图,如下:

flowchart
    start(开始) --> openFile{尝试打开文件 data.txt}
    openFile -- 文件存在 --> readFile(逐行读取文件)
    openFile -- 文件不存在 --> fileNotFound["文件未找到 (FileNotFoundError)"]
    readFile --> convertToInt{将数据转换为整数}
    convertToInt -- 转换成功 --> calculateSum(计算总数和数量)
    convertToInt -- 转换失败(数据非整数) --> dataError["数据格式错误 (ValueError)"]
    calculateSum --> calculateAverage(计算平均值)
    calculateAverage --> displayResult(显示结果)
    fileNotFound --> cleanup(清理操作:关闭文件)
    dataError --> continueProcessing(继续处理下一行数据)
    continueProcessing --> readFile
    displayResult --> cleanup
    cleanup --> END(结束)
  

以下是实际代码示例:

file = None
try:
    numbers = []
    file = open('data.txt', 'r')  
    for line in file:
        numbers.append(int(line.strip()))
except FileNotFoundError:
    print("错误:文件不存在。") 
except ValueError:
    print("错误:文件中的所有行必须是整数。")
else:
    average = sum(numbers) / len(numbers) 
    print(f"平均值是:{average}.")
finally:
    if file is not None:
        file.close()  
    print("无论成功与否,文件处理已完成。")

在前面的介绍中,我们已经了解了 try ... except ... 语句的基本用途。由于在打开文件和数据转换过程中可能会遇到多种异常——例如,文件可能不存在、没有读取权限,或者文件内容无法转换成整数——我们因此将相关代码包含在 try 块中。然后,我们使用 except 子句来捕获并处理这些特定的异常。在这段代码中,我们处理了两种主要的异常:

  • FileNotFoundError:当程序尝试打开一个不存在的文件时,就会触发这种异常。一旦发生这种情况,程序会向用户显示提示:“错误:文件不存在。”
  • ValueError:当程序尝试将文件内容转换为整数而失败时,就会触发这种异常。在这种情况下,用户会看到这样的提示:“错误:文件中的所有行必须是整数。”

通过这样的异常处理,我们确保了程序能够比较优雅地处理潜在的错误,并向用户提供明确且有用的反馈。那么,问题来了,既然可以用 try ... except ... 来处理可能发生的异常,为什么我们在这里使用了 elsefinally 字句呢?这其实体现了 Python 异常处理更为全面的方面。

关于使用 else 字句。在Python 中,else 字句只在没有异常的情况下执行。也就是说它是在 try 块成功执行之后执行。这使得我们可以将正常操作的代码和异常处理代码分开,将常规逻辑与异常处理逻辑分离,从而提高代码的结构清晰度和可读性。在许多情况下,else 可以用来执行那些只有在 try 块成功完成后才安全进行的操作。以本案例为例,我们计划在数据处理无误且无异常发生的情况下计算并展示数据的平均值。将这一逻辑放置在 else 子句中,可以确保仅在数据处理和验证都正确完成后才进行计算。如果 try 块中抛出异常,这通常意味着在读取或转换数据时出现问题,此时程序会提示用户检查提供的数据。这样的处理策略确保了仅在数据完全合规且无异常出现的情况下才进行后续计算,从而保障了程序得到的结果的可靠性。

那么为什么使用 finally 子句呢?在Python 中,无论 try 块中发生什么(无论是正常执行完毕,还是遇到了异常),finally 子句中的代码都是会被执行的。这个机制在关闭文件、释放锁、恢复资源等相关清理工作中特别有用,确保了即使在发生异常时,程序也能释放占用的资源。在该案例中,我们认为无论文件是否成功打开和数据是否成功处理,关闭文件都是必需的。将关闭文件的操作放在 finally 子句中,我们就可以保证,不管处理过程中是否发生异常,文件都会被关闭。

其实我们还可以进一步提升以上代码的呈现方式。在 Python 中,有一个 “Pythonic” 思想,指的是遵循 Python 的最佳实践1,编写既简洁又高效的代码。Python 为我们提供了 with 语句,该语句完美体现了这一理念,特别是在涉及资源管理如文件操作时。with 语句能够自动管理资源(如文件),确保即使在发生异常时也能正确关闭文件,这避免了资源的泄漏。通过使用 with 语句,代码的意图更明确,读者可以立即理解文件仅在 with 块的上下文中开启并自动关闭。直观上,使用 with 语句可以减少代码的冗余,至少不再需要我们显式编写文件关闭代码。

使用 with 语句比较简单,其基本的语法结构如下:

with expression as variable:
    # 执行的代码块

其中,expression 通常是一个返回上下文管理器2(context manager)的表达式。上下文管理器是一个包含了 __enter__()__exit__() 方法的对象,这两个方法分别在 with 块开始和结束时执行。variable 是可选的,用来接收 __enter__() 方法返回的值。这个变量在 with 块的内部可用来引用上下文管理器返回的对象。

最为常见的用例是文件操作,使用 with 语句可以确保文件在使用完毕后正确关闭,即使在读写过程中发生异常也是可以的。例如:

with open('example.txt', 'r') as file:
    content = file.read()
    print(content)
# 文件在这里已经被自动关闭

with 语句也支持同时管理多个资源,这可以通过在一行中使用多个 with 表达式实现。如:

with open('input.txt', 'r') as input_file, open('output.txt', 'w') as output_file:
    data = input_file.read()
    output_file.write(data)
# 两个文件都将在这里被自动关闭

我们甚至还可以通过定义类并实现 __enter__()__exit__() 方法的方式来创建自己的上下文管理器,例如:

class ManagedFile:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        self.file = open(self.name, 'r')
        return self.file

    def __exit__(self, exc_type, exc_value, traceback):
        if self.file:
            self.file.close()

with ManagedFile('example.txt') as file:
    content = file.read()
    print(content)

在这个例子中,ManagedFile 类负责打开和关闭文件。__enter__() 方法打开文件并返回文件对象,__exit__() 方法则负责关闭文件。

现在回到如何使用 with 语句来提升我们前面的示例代码。既然 with 语句可以实现自动打开并关闭文件,那么,至少前面的显式关闭文件这一块的内容就可以不要了:

try:
    numbers = []
    with open('data.txt', 'r') as file:
        for line in file:
            numbers.append(int(line.strip()))
except FileNotFoundError:
    print("错误:文件不存在。")
except ValueError:
    print("错误:文件中的所有行必须是整数。")
else:
    average = sum(numbers) / len(numbers)
    print(f"平均值是:{average}.")
finally:
    print("无论成功与否,文件处理已完成。")

此外,因为在 with 块内的代码只有在没有异常的情况下才会执行,本身来说就像一个隐式的 else。因此,我们甚至可以去掉 else 代码块:

try:
    numbers = []
    with open('data.txt', 'r') as file:
        for line in file:
            numbers.append(int(line.strip())) 
    average = sum(numbers) / len(numbers) 
    print(f"平均值是:{average}.")
except FileNotFoundError:
    print("错误:文件不存在。")
except ValueError:
    print("错误:文件中的所有行必须是整数。")
finally:
    print("无论成功与否,文件处理已完成。")

在这个版本中,with 语句自动处理文件的打开和关闭。这意味着,文件将在 with 块开始时打开,并在块执行结束时自动关闭,无论代码块的执行是正常结束还是由于异常而结束。finally 仍被保留用于输出处理完成的信息,但在此不再需要处理文件关闭。

关于 with 语句更为高级的使用方式,可以查看参考资料中的链接。

异常的主动触发

在前面的部分,我们已经讨论了怎么应对程序运行时自动弹出的异常。其实,Python 还提供了一个非常实用的工具:raise 关键字。通过使用 raise 关键字,我们可以在代码的特定位置主动引发异常。这样做的好处是,一旦程序没有按预期运行,比如某些必要的条件没被满足,它就会立刻停下来,给出明确的错误信息,而不是继续运行直到出现更严重的问题。换句话说,raise 让我们能够更精确地掌控错误处理的过程,确保只有在特定条件触发时才会响应特定的错误。这种能够主动引发和管理异常能力,是构建稳定和可靠程序的关键。

raise 语句的基本语法结构如下:

raise ExceptionType("Error message")

这里,ExceptionType 是异常的类型,比如 ValueError, TypeError, FileNotFoundError等,甚至是自定义的异常类型,而 "Error message" 是咱们想要附加到异常上的消息,它会在异常被捕获并打印时显示。在实际应用中,raise 语句通常与条件语句结合使用,这样可以确保只在特定条件下触发异常:

def check_age(age):
    if age < 18:
        raise ValueError("拒绝访问。您未满 18 岁。")
    print("允许访问。")

check_age(17)  # 由于输入的年龄小于18,因此这将引发 ValueError

除了这种正常的使用方式,Python 还允许我们先创建一个异常实例,然后使用 raise 来触发它,比如:

error = ValueError("Invalid input.")
raise error

这种处理方式可以让我们附加更多信息到异常对象上,因此可以为异常处理时的工作提供更细致的指示。比如,我们可以用 error.code = 4003 类似的方式添加错误代码。此外,使用异常实例还允许咱们将异常的构造与其抛出的逻辑分开,在某些情况下可以使代码更清晰。来看下这个示例:

def get_error(age):
    if age < 0:
        error = ValueError("Age cannot be negative.")
        error.code = 1001
        return error
    elif age < 18:
        error = ValueError("Access denied due to age restrictions.")
        error.code = 1002
        return error

def check_age(age):
    error = get_error(age)
    if error:
        raise error
    print("Access granted.")

check_age(-1)

这里,我们将异常封装在一个单独的函数中,从而使得 check_age 函数的主体更专注于处理逻辑,而不是异常的具体细节。

raise 还可以重新引发当前捕获的异常,这在异常链或异常处理的更高级应用中非常有用:

try:
    # 假设这里的代码出错了
    int("not a number")
except ValueError as e:
    print("Logging error...")
    raise  # 重新引发刚才捕获的异常

在这个例子中,当尝试将一个字符串转换为整数失败时,首先捕获到 ValueError,打印一条日志信息后,使用 raise 无参数形式重新引发同一个异常。这是 raise 较为高级的用法,更为具体的使用及原理,可以参考 Python’s raise: Effectively Raising Exceptions in Your Code,以及官方文档

参考资料


  1. Python 的最佳实践涉及对 Python 语言特有风格和习惯的理解,这些风格和习惯主要目的是提高代码的可读性、效率以及可维护性。Python 的设计哲学强调了简洁明了(“Beautiful is better than ugly."),可读性(“Readability counts."),以及实用性(“Simple is better than complex.")。以下是一些 Python 的最佳实践的关键方面:1. 遵循 PEP 8:PEP 8 是 Python 的风格指南,提供了代码格式化的标准,包括缩进、行长度、变量命名、导入规范等。遵守 PEP 8 可以让代码更加统一和易于阅读。例如,使用 4 个空格进行缩进,限制行的长度,以及使用小写字母和下划线来命名函数和变量。现在我们可以借助于一些分析工具来使我们的代码更加规范,比如 RuffBlack 等。2. 编写 Pythonic 代码: Pythonic 不仅是一种编码风格,更是一种哲学。编写 Pythonic 代码意味着利用 Python 的最佳功能和习惯用法,使代码简洁而富有表达力。例如,列表推导(list comprehensions)和生成器表达式(generator expressions)可以用一行代码替代复杂的循环和条件结构,使代码更紧凑、更易读。3. 使用异常处理:合理使用 try-except 结构来处理可能发生的错误情况。Python 强调编写健壮的代码,通过预测和处理异常来避免程序在运行时崩溃。4. 文档和注释:为代码编写清晰的文档字符串(docstrings)和注释。这不仅有助于其他人理解你的代码,也有助于未来的你回忆代码的工作细节。Python 的文档字符串非常强大,可以自动生成文档。5. 代码复用和模块化:推广使用函数和模块来避免代码重复。模块化的代码更易于测试和维护,同时也便于其他开发者使用和扩展。6. 利用 Python 标准库和第三方库:Python 有一个丰富的标准库和广泛的第三方库,这些库提供了许多强大的功能,从而无需重新发明轮子。学会搜索和使用这些资源,可以大幅提高开发效率和代码质量。7. 性能优化:虽然 Python 不是性能最优的语言,但合理的数据结构选择和算法设计可以显著提升性能。在需要时,还可以使用如 NumPy 这类的库来处理复杂的数学运算,或使用 C、C++ 等其他语言来编写关键代码段。8. 持续学习:Python 是一个持续发展的语言,新的版本和库不断地推出。定期更新知识库,学习新的模块和语言特性,可以帮助保持代码的现代性和效率。 ↩︎

  2. 请参考Context Managers and Python’s with StatementWith Statement Context Managers 以及 Context Manager Types。 ↩︎

  3. Python 的对象模型允许在运行时动态添加或修改属性。异常类,如 ValueErrorException,继承自 BaseException 类,它们也是 Python 对象。因此,可以向这些异常实例添加额外的属性来写入更多的上下文信息或错误细节。 ↩︎