MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python使用try-except块捕获异常

2021-12-236.3k 阅读

Python 异常处理基础

在 Python 编程中,异常是在程序执行期间发生的错误事件。这些事件会中断程序的正常流程,如果不加以处理,程序可能会崩溃并显示错误信息。异常处理机制允许程序员捕获并处理这些异常,使程序能够在遇到错误时继续运行或采取适当的补救措施。try - except 块是 Python 中用于异常处理的主要结构。

理解异常

异常在 Python 中以对象的形式表示。当程序执行过程中遇到错误情况时,会引发相应类型的异常对象。例如,当你尝试访问列表中不存在的索引时,Python 会引发 IndexError 异常;当你尝试将一个字符串和一个整数相加时,会引发 TypeError 异常。Python 内置了许多不同类型的异常,每种异常对应特定类型的错误情况。

try - except 块基本结构

try - except 块的基本结构如下:

try:
    # 可能会引发异常的代码块
    result = 10 / 0
    print(result)
except ZeroDivisionError:
    # 捕获到 ZeroDivisionError 异常时执行的代码块
    print("不能除以零")

在上述代码中,try 子句包含可能引发异常的代码。在这个例子中,10 / 0 会引发 ZeroDivisionError 异常。except 子句用于捕获并处理特定类型的异常,这里是 ZeroDivisionError。当 try 块中的代码引发 ZeroDivisionError 异常时,程序流程会立即跳转到相应的 except 块中执行,从而避免程序崩溃。

捕获多种异常

在实际编程中,一个代码块可能会引发多种不同类型的异常。Python 允许在 try - except 结构中捕获多种异常。

捕获多个不同类型异常

可以使用多个 except 子句来捕获不同类型的异常,如下所示:

try:
    num_list = [1, 2, 3]
    result = num_list[3]
    res = 10 / 0
except IndexError:
    print("索引超出范围")
except ZeroDivisionError:
    print("不能除以零")

在这段代码中,try 块中的代码可能会引发 IndexError(当访问列表中不存在的索引时)或 ZeroDivisionError(当执行除法运算时除数为零)。每个 except 子句分别处理相应类型的异常。如果 try 块中的代码引发了 IndexError,则执行第一个 except 块;如果引发了 ZeroDivisionError,则执行第二个 except 块。

使用元组捕获多种异常

也可以使用一个 except 子句捕获多种异常类型,通过在括号中列出异常类型的元组来实现:

try:
    num_list = [1, 2, 3]
    result = num_list[3]
    res = 10 / 0
except (IndexError, ZeroDivisionError):
    print("发生了索引错误或除零错误")

这种方式在处理的异常情况处理逻辑相同时较为方便。当 try 块中的代码引发 IndexErrorZeroDivisionError 时,都会执行这个 except 块中的代码。

捕获所有异常

有时候,我们可能希望捕获所有类型的异常,而不关心具体是哪种异常。可以使用不带任何异常类型的 except 子句来实现这一点。

捕获所有异常的语法

try:
    # 一些可能引发异常的代码
    value = int('abc')
except:
    print("发生了异常")

在这个例子中,int('abc') 会引发 ValueError 异常,由于没有指定具体的异常类型,这个通用的 except 子句会捕获到这个异常并执行相应的处理代码。然而,这种方式虽然方便,但在实际编程中需要谨慎使用,因为它捕获所有异常,包括程序员可能没有预料到的系统错误等,这可能会隐藏真正的问题,使调试变得困难。

更好的捕获所有异常的方式

为了在捕获所有异常时仍然能够获取到异常信息,同时又能保持一定的可控性,可以在捕获所有异常的 except 子句中获取异常对象:

try:
    value = int('abc')
except Exception as e:
    print(f"发生了异常: {e}")

这里使用 Exception 作为捕获的异常类型,Exception 是所有内置异常类的基类。通过 as e 将异常对象赋值给变量 e,这样就可以在处理代码中获取异常的具体信息,方便调试和日志记录。

异常的层次结构

Python 的异常具有层次结构,理解这个结构对于异常处理非常重要。

内置异常类的层次结构

BaseException 是所有异常类的基类。它有两个直接子类:ExceptionSystemExitKeyboardInterruptException 类是大多数用户定义和内置异常类的基类,例如 ValueErrorTypeErrorZeroDivisionError 等都继承自 ExceptionSystemExit 异常在调用 sys.exit() 函数时引发,用于正常退出程序。KeyboardInterrupt 异常在用户通过键盘中断程序执行(通常是按 Ctrl+C)时引发。

基于层次结构的异常捕获

由于异常的继承关系,在捕获异常时,如果一个 except 子句捕获基类异常,它也会捕获该基类的所有子类异常。例如:

try:
    value = int('abc')
except ValueError:
    print("值错误")
except Exception:
    print("其他异常")

在这个例子中,int('abc') 引发 ValueError,第一个 except 子句捕获到这个异常并执行相应代码。如果第一个 except 子句不存在,那么第二个 except 子句(捕获 Exception)会捕获到 ValueError,因为 ValueErrorException 的子类。但如果将两个 except 子句的顺序颠倒:

try:
    value = int('abc')
except Exception:
    print("其他异常")
except ValueError:
    print("值错误")

此时,由于 Exception 捕获了 ValueError,第二个 except 子句(捕获 ValueError)永远不会执行,这是因为一旦异常被捕获,就不会再被后续的 except 子句捕获。所以在编写 try - except 块时,要注意按照异常的具体程度从高到低(从子类到基类)的顺序排列 except 子句。

异常的传递

当一个函数中引发异常且该函数内没有捕获异常时,异常会向上传递到调用该函数的地方。

函数调用链中的异常传递

def divide_numbers(a, b):
    return a / b

def main():
    try:
        result = divide_numbers(10, 0)
        print(result)
    except ZeroDivisionError:
        print("不能除以零")

main()

在这个例子中,divide_numbers 函数中执行 a / b 时,如果 b 为零会引发 ZeroDivisionError 异常。由于 divide_numbers 函数中没有捕获异常,异常会传递到调用它的 main 函数。在 main 函数的 try 块中捕获到这个异常并进行处理。

多层函数调用的异常传递

异常可以在多层函数调用链中传递,例如:

def inner_function():
    return 10 / 0

def middle_function():
    return inner_function()

def outer_function():
    try:
        result = middle_function()
        print(result)
    except ZeroDivisionError:
        print("捕获到除零异常")

outer_function()

在这个例子中,inner_function 引发 ZeroDivisionError 异常,该异常传递到 middle_function,然后再传递到 outer_function。在 outer_functiontry 块中捕获到这个异常并处理。这种异常传递机制使得程序可以在合适的层次处理异常,避免在每个可能引发异常的地方都进行异常处理,提高代码的可读性和维护性。

自定义异常

除了使用 Python 内置的异常类型,程序员还可以定义自己的异常类型。

定义自定义异常类

自定义异常类需要继承自 Exception 类或其某个子类。例如:

class MyCustomError(Exception):
    pass

def check_value(value):
    if value < 0:
        raise MyCustomError("值不能为负数")
    return value

try:
    result = check_value(-5)
    print(result)
except MyCustomError as e:
    print(f"发生自定义异常: {e}")

在这个例子中,定义了一个 MyCustomError 类,它继承自 Exceptioncheck_value 函数在检测到 value 为负数时,使用 raise 语句引发 MyCustomError 异常。在 try 块中调用 check_value 函数并捕获 MyCustomError 异常,然后进行相应处理。

自定义异常的参数化

可以给自定义异常类添加参数,以便在引发异常时传递更多信息:

class MyCustomError(Exception):
    def __init__(self, value, message):
        self.value = value
        self.message = message
        super().__init__(self.message)

def check_value(value):
    if value < 0:
        raise MyCustomError(value, "值不能为负数")
    return value

try:
    result = check_value(-5)
    print(result)
except MyCustomError as e:
    print(f"发生自定义异常,值为 {e.value},错误信息: {e.message}")

在这个改进的版本中,MyCustomError 类的构造函数接受 valuemessage 两个参数。在引发异常时,可以将相关的值和错误信息传递给异常对象。在捕获异常时,可以通过访问异常对象的属性获取这些信息。

finally 子句

finally 子句是 try - except 结构的可选部分,无论 try 块中是否引发异常,finally 子句中的代码都会执行。

finally 子句的基本用法

try:
    file = open('test.txt', 'r')
    content = file.read()
    print(content)
except FileNotFoundError:
    print("文件未找到")
finally:
    file.close()

在这个例子中,try 块尝试打开一个文件并读取其内容。如果文件不存在,会引发 FileNotFoundError 异常并由 except 子句处理。无论是否发生异常,finally 子句中的 file.close() 语句都会执行,确保文件被正确关闭,避免资源泄漏。

finally 子句与 return 语句

try 块或 except 块中有 return 语句时,finally 子句仍然会执行。例如:

def divide_numbers(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        print("不能除以零")
        return None
    finally:
        print("执行 finally 子句")

result = divide_numbers(10, 2)
print(result)
result = divide_numbers(10, 0)
print(result)

在这个函数中,当 b 不为零时,try 块中的 return 语句返回除法结果,在返回之前会先执行 finally 子句。当 b 为零时,except 子句中的 return 语句返回 None,同样在返回之前会执行 finally 子句。

else 子句

try - except 结构还可以包含一个可选的 else 子句。else 子句中的代码只有在 try 块中没有引发任何异常时才会执行。

else 子句的用法

try:
    num1 = 10
    num2 = 2
    result = num1 / num2
except ZeroDivisionError:
    print("不能除以零")
else:
    print(f"结果是: {result}")

在这个例子中,try 块执行除法运算。如果没有发生 ZeroDivisionError 异常,程序会执行 else 子句,打印除法的结果。如果 try 块中引发了异常,else 子句将不会执行。

else 子句的优势

使用 else 子句可以将正常情况下的代码和异常处理代码清晰地分开,使代码结构更加清晰。例如,在处理文件操作时:

try:
    file = open('test.txt', 'r')
except FileNotFoundError:
    print("文件未找到")
else:
    content = file.read()
    print(content)
    file.close()

在这个例子中,文件打开操作放在 try 块中,如果文件不存在,except 子句处理异常。如果文件成功打开,else 子句执行读取文件内容和关闭文件的操作,这样可以避免在 try 块中混入过多正常情况和异常处理的代码,提高代码的可读性和维护性。

异常处理的最佳实践

在编写 Python 代码时,遵循一些异常处理的最佳实践可以使代码更加健壮和易于维护。

1. 精确捕获异常

尽量捕获具体的异常类型,而不是使用通用的 except 子句捕获所有异常。这样可以明确知道程序在处理哪种类型的错误,并且不会隐藏未预料到的异常。例如,在处理文件操作时,应该捕获 FileNotFoundErrorPermissionError 等具体异常,而不是使用通用的 except

2. 避免过度捕获

不要在不必要的地方捕获异常。例如,如果一个函数内部的代码逻辑已经处理了可能的异常情况,调用该函数的地方可能不需要再次捕获相同类型的异常,除非有额外的处理需求。过度捕获异常可能会使异常处理逻辑变得混乱,并且隐藏真正的问题。

3. 异常处理的粒度

在设计异常处理时,要考虑异常处理的粒度。对于不同的功能模块或操作,应该有合适的异常处理层次。例如,在一个复杂的数据库操作模块中,底层的数据库连接函数可以捕获并处理与连接相关的异常,而上层的业务逻辑函数可以捕获并处理与业务数据相关的异常,这样可以使异常处理更加清晰和高效。

4. 日志记录

在异常处理中,应该记录异常信息,以便调试和排查问题。可以使用 Python 的 logging 模块来记录异常信息,包括异常类型、异常消息以及异常发生的上下文(例如函数名、行号等)。例如:

import logging

try:
    value = int('abc')
except ValueError as e:
    logging.error(f"发生值错误: {e}", exc_info=True)

这里使用 logging.error 记录错误信息,exc_info = True 表示记录异常的堆栈跟踪信息,这对于定位问题非常有帮助。

5. 异常处理与性能

虽然异常处理是必要的,但频繁地引发和捕获异常会对程序性能产生一定影响。因此,在性能敏感的代码中,应该尽量避免使用异常来控制正常的程序流程,而是通过条件判断等方式来处理可能的错误情况。例如,在检查用户输入是否为有效的数字时,使用 str.isdigit() 方法进行判断,而不是直接尝试将输入转换为数字并捕获 ValueError 异常。

6. 自定义异常的合理使用

在适当的时候使用自定义异常可以使代码的逻辑更加清晰,特别是在处理特定领域的业务逻辑时。自定义异常应该继承自合适的内置异常类,并且提供足够的信息以便在捕获时进行处理和调试。同时,要注意自定义异常的命名规范,使其能够准确反映异常的含义。

7. 文档化异常

在编写函数和模块时,应该文档化可能引发的异常。可以使用文档字符串(docstring)来描述函数可能引发的异常类型以及在什么情况下会引发这些异常,这样可以帮助其他开发人员正确使用和理解代码。例如:

def divide_numbers(a, b):
    """
    执行两个数的除法运算。

    :param a: 被除数
    :param b: 除数
    :return: 除法运算的结果
    :raises ZeroDivisionError: 如果除数为零
    """
    return a / b

通过这样的文档化,其他开发人员在调用 divide_numbers 函数时就知道可能会遇到 ZeroDivisionError 异常,从而可以进行适当的异常处理。

8. 异常处理与单元测试

在编写单元测试时,应该覆盖异常处理的情况。通过单元测试可以验证代码在各种异常情况下的行为是否符合预期。例如,对于一个可能引发 FileNotFoundError 的文件读取函数,可以编写单元测试来验证在文件不存在时是否正确捕获并处理了该异常。

异常处理的常见错误

在使用 try - except 块进行异常处理时,容易出现一些常见错误,需要注意避免。

1. 异常捕获顺序错误

如前文所述,按照异常的具体程度从高到低(从子类到基类)的顺序排列 except 子句非常重要。如果顺序颠倒,可能会导致具体的异常被基类异常捕获,使得特定的异常处理代码永远不会执行。例如:

try:
    value = int('abc')
except Exception:
    print("捕获到异常")
except ValueError:
    print("捕获到值错误")

在这个例子中,ValueError 异常会被第一个 except 子句(捕获 Exception)捕获,第二个 except 子句永远不会执行。

2. 通用 except 子句的滥用

使用不带任何异常类型的通用 except 子句捕获所有异常可能会隐藏真正的问题。因为它会捕获所有类型的异常,包括系统错误和未预料到的异常,使得调试变得困难。除非在非常特殊的情况下,应该尽量避免使用通用 except 子句。如果确实需要捕获所有异常,应该使用 except Exception as e 的形式,以便获取异常信息。

3. 异常处理代码过于复杂

异常处理代码应该尽量简洁明了,只处理与异常相关的操作,例如记录错误信息、进行必要的清理工作等。如果异常处理代码过于复杂,可能会导致代码逻辑混乱,难以理解和维护。例如,在异常处理中进行复杂的业务逻辑计算或数据库事务操作,可能会使异常处理的目的变得不清晰,并且增加了出错的可能性。

4. 未正确处理异常传递

在函数调用链中,如果不了解异常传递机制,可能会导致异常处理不当。例如,在一个函数中引发了异常,但没有在合适的层次捕获和处理,可能会导致异常一直传递到程序的顶层,使程序崩溃。因此,需要根据程序的逻辑结构,在合适的地方捕获和处理异常,确保程序的稳定性。

5. 忽略异常

有时候,在捕获异常后,程序员可能会忽略异常,不进行任何处理。这可能会导致程序在出现错误的情况下继续运行,但处于不正确的状态。例如,在文件操作中捕获到 FileNotFoundError 异常后不进行任何提示或处理,可能会使后续依赖该文件的操作失败,而且很难排查问题所在。即使在某些情况下不希望对异常进行详细处理,也应该至少记录异常信息,以便后续分析。

6. 自定义异常定义不当

在定义自定义异常时,如果没有正确继承自合适的内置异常类,或者没有提供足够的信息,可能会导致异常处理不灵活或难以调试。例如,自定义异常类没有合理的构造函数来传递相关信息,或者继承自不恰当的基类,使得异常在捕获和处理时不符合预期的逻辑。

7. finally 子句中的错误

虽然 finally 子句中的代码通常用于资源清理等操作,但如果在 finally 子句中出现错误,可能会掩盖原有的异常。例如,在 finally 子句中进行文件关闭操作时,如果文件对象已经被错误地修改或不存在,可能会引发新的异常,而原有的异常信息可能会被丢失。因此,在 finally 子句中编写代码时要格外小心,确保其正确性。

8. else 子句的误用

else 子句的目的是在 try 块没有引发异常时执行额外的代码。如果在 else 子句中编写了可能引发异常的代码,而没有在 else 子句内部或外部进行适当的异常处理,可能会导致异常未被捕获。同时,要注意 else 子句与 try 块和 except 子句之间的逻辑关系,确保其使用符合程序的预期。

通过了解和避免这些常见错误,可以使 Python 程序的异常处理更加健壮和可靠,提高程序的稳定性和可维护性。在实际编程中,不断积累经验,根据具体的业务需求和代码结构,合理运用异常处理机制,是编写高质量 Python 代码的重要环节。同时,结合良好的代码设计、日志记录和单元测试等手段,可以更好地应对程序运行过程中可能出现的各种异常情况。