Python 错误处理机制详解

在程序开发中,无论你写代码有多小心,运行时错误都是不可避免的——比如除法遇到了零、字符串转数字失败、打开了不存在的文件。

早期很多语言依赖“返回错误码”的方式处理异常,但这种方案缺点很明显:正常结果和错误混在一起难区分,每调用一次函数就要加判断,多层嵌套上报时代码会变得冗余又难读。

幸运的是,Python 提供了一套 try...except...else...finally...结构化异常处理机制,让错误处理逻辑和业务逻辑彻底分离,代码优雅又健壮。


一、try-except 的核心用法

1.1 基础流程演示

先看一段最经典的示例:

try:
    # 这里放【可能抛出异常】的核心业务代码
    print('try... 开始执行可能出错的代码')
    result = 10 / 0  # 会触发 ZeroDivisionError
    print('result:', result)  # 抛出异常后,这行不会执行
except ZeroDivisionError as e:
    # 捕获 ZeroDivisionError,执行【对应处理逻辑】
    print('except:', e)
finally:
    # 无论是否出错、甚至 except/else 里有 return/break,【这部分都会执行】
    print('finally... 收尾工作(比如关闭文件、释放连接)')
print('END')  # 错误处理完成后,程序继续向下走

运行流程可以用一句话总结:

先冲 try 块,出错就跳对应的 except,不管怎么样都走 finally,最后程序正常收尾。


二、进阶:多异常、else 子句

2.1 捕获多种不同的异常

一段代码可能抛出多种错误,我们可以写多个 except 分别处理:

try:
    print('try...')
    result = 10 / int('a')  # 这里是 ValueError
    print('result:', result)
except ValueError as e:
    print('捕获到值错误:', e)
except ZeroDivisionError as e:
    print('捕获到除零错误:', e)
finally:
    print('finally...')
print('END')

2.2 无错时的 else

如果 try完全没抛出异常,就会执行 else 子句——这个设计的好处是:把「业务逻辑」和「无错后的补充逻辑」也清晰分开了:

try:
    print('try...')
    result = 10 / int('2')
except ValueError as e:
    print('捕获到值错误:', e)
except ZeroDivisionError as e:
    print('捕获到除零错误:', e)
else:
    print('✅ 无错误!result =', result)  # 只有 try 顺利跑完才会来这里
finally:
    print('finally...')
print('END')

三、Python 异常的继承关系

Python 里的所有异常都是对象,都继承自顶层基类 BaseException。我们平时开发中,99% 的情况只需要捕获它的子类 Exception 或更具体的异常类型

几个常见的异常层级:

  • BaseException(顶层,一般不直接捕获)
    • Exception(常规错误基类,开发常用)
      • ArithmeticError(算术错误基类)
        • ZeroDivisionError(除零/取模零)
      • LookupError(索引/键错误基类)
        • IndexError(列表越界)
        • KeyError(字典不存在的键)
      • TypeError(类型不匹配,比如给 len() 传了整数)
      • ValueError(值无效,比如 int('abc')
      • OSError(操作系统错误基类,比如打开不存在的文件)

完整层级可以参考 Python 官方异常文档


四、调试利器:调用栈 Traceback

当你没有捕获异常时,Python 会自动打印出详细的调用链(Traceback),帮你快速定位错误的“触发点”和“传播路径”。

4.1 调用栈示例

def foo(s):
    return 10 / int(s)  # 这里是错误的【触发点】

def bar(s):
    return foo(s) * 2  # 这里是错误的【第一层传播】

def main():
    bar('0')  # 这里是错误的【第二层传播】

if __name__ == '__main__':
    main()

运行后的输出:

Traceback (most recent call last):
  File "err_traceback.py", line 12, in <module>
    main()
  File "err_traceback.py", line 9, in main
    bar('0')
  File "err_traceback.py", line 6, in bar
    return foo(s) * 2
  File "err_traceback.py", line 3, in foo
    return 10 / int(s)
ZeroDivisionError: division by zero

4.2 如何快速读调用栈

记住从下往上读

  1. 最后一行是错误类型核心原因(这个是解决问题的关键)
  2. 往上依次是传播链的每一层调用:最近的那个是触发点,最远的是程序入口。

五、记录错误但不中断程序:logging 模块

如果不想让程序因为一个错误就崩溃,又需要完整记录错误信息(比如 Traceback),可以用 Python 内置的 logging 模块:

import logging

# 可以先配置一下 logging 的级别(可选)
logging.basicConfig(level=logging.ERROR)

def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    try:
        bar('0')
    except Exception as e:
        # logging.exception 会自动记录 Traceback
        logging.exception('程序运行出错:')
    print('✅ 虽然出错了,但程序继续执行')

if __name__ == '__main__':
    main()

六、自定义异常与异常传递

6.1 自定义异常

当内置异常类型不够用时,可以继承 Exception(或它的合适子类)定义自己的异常:

# 继承 ValueError 比较合理,因为是“值无效”类的错误
class InvalidAgeError(ValueError):
    pass

def check_age(age):
    if not isinstance(age, int):
        raise TypeError('年龄必须是整数')
    if age < 0 or age > 120:
        raise InvalidAgeError(f'无效年龄:{age},必须在 0-120 之间')
    print(f'年龄 {age} 验证通过')

try:
    check_age(150)
except InvalidAgeError as e:
    print('捕获到自定义异常:', e)

自定义异常的最佳实践

  1. 优先用内置异常(不要重复造轮子)
  2. 只在「有特殊处理逻辑」或「需要更清晰的语义」时才自定义
  3. 继承关系要合理(比如“无效年龄”继承 ValueError 而不是 OSError

6.2 异常传递(重新抛出)

有时候我们捕获异常不是为了处理它,而是为了做一些记录或转换,然后再扔给上层调用者处理:

def load_config():
    try:
        f = open('config.txt', 'r')
        # ... 读取配置 ...
        f.close()
    except FileNotFoundError as e:
        print('⚠️ 配置文件不存在,先记录日志再扔给上层')
        logging.exception(e)
        raise  # 直接 re-raise 原异常

还有一种场景是转换异常类型

class ConfigLoadError(Exception):
    pass

def load_config():
    try:
        f = open('config.txt', 'r')
        # ...
    except (FileNotFoundError, PermissionError) as e:
        # 把底层的文件错误,转换成更上层的“配置加载错误”
        raise ConfigLoadError('配置加载失败') from e

七、错误处理的 5 条最佳实践

  1. 精确捕获,别乱抓:只捕获你知道怎么处理的异常,比如只抓 FileNotFoundError 就够了,别一上来就抓 Exception 甚至 BaseException
  2. 避免空 exceptexcept: 会捕获所有异常(包括键盘中断 Ctrl+C),非常危险,绝对不要写
  3. try 块要小:只把「可能抛出异常的那一行/几行」放进去,不要把整个函数都包起来
  4. 抛出异常时带上下文:比如别只写 raise ValueError,要写 raise ValueError(f'无效参数:{s},应该是数字')
  5. 文档说明可能的异常:在函数的 docstring 里写清楚,会抛出哪些异常、为什么抛

八、小练习:修复一段求和代码

练习代码

from functools import reduce

def str2num(s):
    return int(s)  # 这里是问题所在!不能处理浮点数

def calc(exp):
    ss = exp.split('+')
    ns = map(str2num, ss)
    return reduce(lambda acc, x: acc + x, ns)

def main():
    r = calc('100 + 200 + 345')
    print('100 + 200 + 345 =', r)
    r = calc('99 + 88 + 7.6')  # 这里会报 ValueError
    print('99 + 88 + 7.6 =', r)

main()

修复思路

修改 str2num 函数,先尝试转整数,失败后再转浮点数

def str2num(s):
    # 去掉字符串首尾的空格,避免 ' 123 ' 这样的问题
    s = s.strip()
    try:
        return int(s)
    except ValueError:
        return float(s)

总结

Python 的结构化异常处理是编写可靠软件的核心工具:

  • 它把业务逻辑和错误处理彻底分离
  • 调用栈 Traceback 帮你快速定位问题
  • 自定义异常和传递让错误处理更灵活

记住:错误处理不是为了“掩盖错误”,而是为了“优雅地处理错误、提供有用的信息、保证程序在可控范围内运行”