小白学 Python
AI 编程指南GitHub
© 2026 小白学 Python · 基于 walter201230/Python 教程
课程目录关于本站联系方式隐私政策GitHub

异常处理

完整讲解 · 6 段教学·配 5 道练习题·预计 40 分钟

本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。

教学 01 / 06

第十九节:异常处理

写代码这件事,最让新手崩溃的瞬间是哪种?想象一下:你刚学完语法,兴冲冲写了几十行代码,按下回车,结果终端弹出一坨红字:

Traceback (most recent call last):
  File "main.py", line 5, in <module>
    print(10 / 0)
ZeroDivisionError: division by zero

一脸懵。这玩意儿是啥?我哪里写错了?为啥程序就崩了?

其实啊,这就是 Python 的「异常机制」在工作。程序遇到了它处理不了的情况,比如除以零、读不存在的文件、把字符串当数字加,于是它就「抛」一个异常出来,整个程序停下来,告诉你:「我不干了,你来收拾」。

可是真实世界里的程序怎么可能因为一个小问题就停摆?银行 ATM 你输错密码三次它会提示,不会直接死机;网页加载图片失败会显示一个占位图,不会整页白屏;微信收消息网络不通它会重试,不会闪退。这些场景背后,全都是「异常处理」在做事。

程序为什么会「炸」

我们先从最简单的例子开始:

python到闯关页运行这段 →
print(10 / 0)

输出:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

这就叫「异常」。Python 在执行 10 / 0 的时候发现:除数是零,这事儿数学上没法干,于是它创建了一个 ZeroDivisionError 对象,把这个对象「抛」出来。如果没人接,这个异常就会一路冒泡到最外层,最后由 Python 解释器接住,打印 traceback,然后退出程序。

再看几个:

python到闯关页运行这段 →
nums = [1, 2, 3]
print(nums[10])
# IndexError: list index out of range

d = {'name': '小明'}
print(d['age'])
# KeyError: 'age'

每一种「错误」都有它自己的名字。这些名字就是异常的「类型」,是 Python 内置的一组类。它们不是给你看着玩的,是供你用来「精确捕捉」的——不同类型用不同的处理方式。

这一节要学什么

Python 早就给我们准备好了一整套工具:

  • try / except:基本捕获结构
  • 多种异常处理:一段代码可能抛多种错
  • else / finally:完整四件套
  • raise:主动抛出异常
  • 自定义异常类:让错误也变成代码结构的一部分

学完这一节,再也不怕红字 traceback。

教学 02 / 06

一、最朴素的 try / except

把第一段代码改造一下。我们不希望除以零让整个程序崩溃,而是希望它在崩之前给出一句友好的提示,然后继续往下走:

python到闯关页运行这段 →
try:
    result = 10 / 0
    print(result)
except ZeroDivisionError:
    print('哎呀,除数不能是零')

print('程序继续运行')

输出:

哎呀,除数不能是零
程序继续运行

是不是发现,红字 traceback 没了,程序也没崩,最后那句「程序继续运行」也乖乖打印出来了。

三个关键点

try / except 的语法就这么简单,记住三件事:

  • try: 块里放「可能出问题」的代码
  • except XxxError: 块里放「出了问题该怎么办」的代码
  • 如果 try 块没出错,except 块完全跳过,跟没写一样

一个常被忽略的细节

如果 try 块里有十行代码,第三行炸了,后面七行还会执行吗?

答案是:不会。Python 一旦在 try 里发现异常,就立刻跳到对应的 except,try 里剩下的代码全部跳过。我们写个例子验证一下:

python到闯关页运行这段 →
try:
    print('第一行')
    print(10 / 0)
    print('第三行——这一行不会被执行')
except ZeroDivisionError:
    print('我接住了')

print('程序继续')

输出:

第一行
我接住了
程序继续

果然,「第三行」没打印出来。这个细节非常重要,很多新手 debug 半天找不到问题,就是因为以为 try 里抛了异常之后下面还会跑——不会的,跳过了。

练习 1 / 5safe_divide:用 try/except 处理除零去闯关页做这题 →
教学 03 / 06

二、一次捕获多种异常

刚才的例子只捕了 ZeroDivisionError,但实际写代码时,一段代码往往可能抛好几种异常。比如:

python到闯关页运行这段 →
def parse_age(s):
    try:
        age = int(s)
        return 100 / age
    except ZeroDivisionError:
        return '年龄不能是 0'
    except ValueError:
        return '输入的不是数字'


print(parse_age('25'))
print(parse_age('0'))
print(parse_age('abc'))

输出:

4.0
年龄不能是 0
输入的不是数字

两个 except 分支,分别处理不同的异常类型。Python 会按顺序匹配,第一个匹配上的分支被执行,后面的跳过。

多个异常用同一段代码处理

那如果两种异常想用同一段代码处理呢?写两次太啰嗦。有简便写法:

python到闯关页运行这段 →
def parse_age(s):
    try:
        age = int(s)
        return 100 / age
    except (ZeroDivisionError, ValueError):
        return '输入有问题'


print(parse_age('25'))
print(parse_age('0'))
print(parse_age('abc'))

输出:

4.0
输入有问题
输入有问题

把多个异常类型用一对小括号括起来,逗号分隔,就能一锅端。注意这个括号,不写括号语法就不对了。

拿到异常对象——as e

光知道「出错了」还不够,有时候你需要知道「具体错在哪儿」。Python 允许你用 as 把异常对象抓出来:

python到闯关页运行这段 →
def parse_age(s):
    try:
        age = int(s)
        return 100 / age
    except (ZeroDivisionError, ValueError) as e:
        return f'出错啦:{type(e).__name__} - {e}'


print(parse_age('0'))
print(parse_age('abc'))

输出:

出错啦:ZeroDivisionError - division by zero
出错啦:ValueError - invalid literal for int() with base 10: 'abc'

e 就是异常对象本身。type(e).__name__ 是它的类名,str(e) 是它的错误信息。在做日志、做调试的时候,这种写法比简单打印「出错啦」要有用得多。

万能兜底——except Exception

有时候不知道代码里到底会抛什么异常,又不想写一长串 except。这时候可以用一个「父类」来兜底:

python到闯关页运行这段 →
try:
    risky_operation()
except Exception as e:
    print(f'未知错误:{e}')

Exception 是绝大多数异常的「祖宗」,写它就等于「能接住几乎所有异常」。

但是!这里有个坑:很多新手喜欢写 except: 后面什么类型都不写,或者写 except BaseException:。这两种写法都太狠了——它们会把 KeyboardInterrupt(你按 Ctrl+C 想中断程序)和 SystemExit(程序主动 sys.exit())也接住,导致你的程序按 Ctrl+C 都停不下来。

记住:兜底用 Exception,不要用 BaseException,更不要写裸 except:。

异常的家族——继承层级

Python 异常有一棵继承树:

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── ArithmeticError
      │    ├── ZeroDivisionError
      │    └── OverflowError
      ├── LookupError
      │    ├── IndexError
      │    └── KeyError
      ├── ValueError
      ├── TypeError
      ├── AttributeError
      ├── NameError
      ├── OSError
      │    └── FileNotFoundError
      └── ...

读懂这棵树有几个要点:

  • 最顶层是 BaseException,所有异常都继承自它
  • SystemExit / KeyboardInterrupt / GeneratorExit 跟「程序流程控制」相关,不应该被普通的 try/except 接住
  • 业务相关的异常都在 Exception 下面

理解了这个层级,可以玩一个小技巧:捕获父类等于捕获所有子类。

python到闯关页运行这段 →
try:
    nums = [1, 2, 3]
    print(nums[10])
except LookupError as e:
    print(f'查找错误:{e}')

输出:

查找错误:list index out of range

IndexError 是 LookupError 的子类,所以用父类去接子类,完全没问题。

那么如果 except 写了多个,顺序应该「父在前」还是「子在前」?

原则是:子在前,父在后。具体的异常先写,宽泛的兜底放最后。否则父类一上来就把所有子类一锅端,后面那些 except 形同虚设。

练习 2 / 5捕获 ValueError去闯关页做这题 →
教学 04 / 06

三、else 和 finally——四件套补全

try / except 还有两个好搭档:else 和 finally。

else——没出错的时候才走

python到闯关页运行这段 →
def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print('除数不能是零')
    else:
        print(f'计算成功,结果是 {result}')


divide(10, 2)
divide(10, 0)

输出:

计算成功,结果是 5.0
除数不能是零

else 块只在 try 块没有抛异常的时候执行。可能要问:「我把那行 print 直接写在 try 里不也一样吗?」

不一样。区别在于「捕获范围」:

python到闯关页运行这段 →
def divide(a, b):
    try:
        result = a / b
        print(f'计算成功,结果是 {result}')   # 假设这里也可能抛异常
    except ZeroDivisionError:
        print('除数不能是零')

如果 print 那一行也抛了 ZeroDivisionError(举例而已),它会被同一个 except 接住——可这就误伤了,因为本意是只接住「计算」那一步的错误。

else 把「成功后才做的事」隔离出来,让 try 块里只放「真正可能出错的那一行」,逻辑更干净。这是它的核心价值。

finally——不管啥情况都得跑

finally 块更狠:不管 try 块成功还是失败,不管异常被接住还是没被接住,finally 一定会执行。

python到闯关页运行这段 →
def safe_divide(a, b):
    try:
        result = a / b
        print(f'结果:{result}')
    except ZeroDivisionError:
        print('除数不能是零')
    finally:
        print('清理工作执行了')


safe_divide(10, 2)
print('---')
safe_divide(10, 0)

输出:

结果:5.0
清理工作执行了
---
除数不能是零
清理工作执行了

「清理工作执行了」这句话,无论 try 出不出错,都跑了。

为什么需要 finally?

想想平时写代码:打开文件、连数据库、申请资源……这些操作完事儿都得「关闭」「释放」。如果中间出错了直接跳走,资源就泄露了。finally 就是用来确保「无论如何这段清理代码都得跑」的。

python到闯关页运行这段 →
def read_first_line(path):
    f = open(path, 'r')
    try:
        return f.readline()
    finally:
        f.close()
        print(f'已关闭 {path}')

不过呢,开文件这种场景,现代 Python 推荐用 with 语句,它内部就用了 finally 的机制,写法更简洁。with 自带「无论如何都关文件」的能力,本质上跟 finally 是一回事。

完整四件套的顺序

python到闯关页运行这段 →
try:
    # 可能出错的代码
    pass
except SomeError:
    # 出错时怎么办
    pass
except AnotherError:
    # 多个 except
    pass
else:
    # 没出错的话执行
    pass
finally:
    # 不管出不出错,都要执行
    pass

顺序是固定的:try → except(多个) → else → finally。else 和 finally 都是可选的,但写的话只能按这个顺序。

练习 3 / 5finally:清理一定要跑去闯关页做这题 →
教学 05 / 06

四、主动抛异常——raise

到目前为止都是在「接」异常,那能不能「抛」一个?当然能。raise 关键字就是干这个的。

python到闯关页运行这段 →
def set_age(age):
    if age < 0:
        raise ValueError(f'年龄不能是负数,你传了 {age}')
    if age > 200:
        raise ValueError(f'年龄不能超过 200,你传了 {age}')
    return age


try:
    set_age(-5)
except ValueError as e:
    print(f'参数有问题:{e}')

输出:

参数有问题:年龄不能是负数,你传了 -5

raise XxxError('msg') 的意思是「我现在就抛一个 XxxError,里面带这条消息」。平时写函数的时候,遇到「调用方传的参数不合理」「业务规则不满足」这种情况,就应该主动 raise,让调用方知道「你这数据我不收」,而不是闷头返回一个奇怪的值。

为啥不直接 return None?

想想看:

python到闯关页运行这段 →
def set_age(age):
    if age < 0:
        return None      # 不好的写法
    return age

调用方拿到 None,根本不知道是「这次没数据」还是「我传错了」。但是 raise ValueError('年龄不能是负数'),调用方一看就明白:「啊,我传的参数不合规」。

异常是 Python 里传递错误信息的标准方式,比 return None、return -1、return False 表达力强得多。

异常链——raise ... from e

再来看一个稍微高级的场景。假设有这么一个函数:

python到闯关页运行这段 →
def load_config(path):
    with open(path, 'r') as f:
        return f.read()

如果 path 不存在,会抛 FileNotFoundError。但是站在「使用配置」这一层来看,「文件不存在」这个错误太底层了。我希望对外抛一个更业务化的错误,比如 ConfigError('配置加载失败'),但又不想丢掉「底层到底是啥错」这个信息。怎么办?

「异常链」给出了答案:

python到闯关页运行这段 →
class ConfigError(Exception):
    pass


def load_config(path):
    try:
        with open(path, 'r') as f:
            return f.read()
    except FileNotFoundError as e:
        raise ConfigError(f'配置加载失败:{path}') from e


try:
    load_config('not_exist.json')
except ConfigError as e:
    print(f'业务错误:{e}')

输出:

业务错误:配置加载失败:not_exist.json

注意 raise ConfigError(...) from e 这个写法。from e 就是把「原始异常」挂在新异常的 __cause__ 上。如果不被外层接住,traceback 会同时打印两层错误:

Traceback (most recent call last):
  File "...", line X, in load_config
    with open(path, 'r') as f:
FileNotFoundError: [Errno 2] No such file or directory: 'not_exist.json'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
ConfigError: 配置加载失败:not_exist.json

「The above exception was the direct cause of the following exception」——这句话就是异常链的标志。看到这种 traceback 要知道:底下那个错才是根因,上面那个错是它包装出来的。

显式 from e 表达的是「我故意把底层异常包装成业务异常」;不写 from e 表达的是「我处理上一个异常的时候不小心又出错了」。语义不同,建议显式包装的时候一定加 from e。

练习 4 / 5raise:主动抛异常去闯关页做这题 →
教学 06 / 06

五、自定义异常类

刚才那个 ConfigError 看到了吧?自定义异常类其实就一行:

python到闯关页运行这段 →
class ConfigError(Exception):
    pass

继承 Exception 就行。但是为啥要自定义?直接用内置的 ValueError、RuntimeError 不行吗?

问题:内置异常分不清场景

来用一个「打卡」业务场景说明。某打卡系统业务规则有:

  • 还没到上班时间,不能打卡
  • 已经打过卡了,不能重复打
  • 不在公司网络,不能打卡

如果用内置异常:

python到闯关页运行这段 →
def punch(user, time, ip):
    if time < 9:
        raise ValueError('未到上班时间')
    if user.already_punched_today:
        raise ValueError('今天已经打过卡了')
    if not ip.startswith('192.168.'):
        raise ValueError('不在公司网络')

调用方接到 ValueError,根本分不清是哪种情况。如果想分别处理,只能去字符串匹配——这非常脆弱。

解法:自定义异常体系

python到闯关页运行这段 →
class PunchError(Exception):
    """打卡相关错误的基类。"""
    pass


class PunchTimeError(PunchError):
    pass


class PunchDuplicateError(PunchError):
    pass


class PunchLocationError(PunchError):
    pass


def punch(user_already_punched, hour, ip):
    if hour < 9:
        raise PunchTimeError('未到上班时间')
    if user_already_punched:
        raise PunchDuplicateError('今天已经打过卡了')
    if not ip.startswith('192.168.'):
        raise PunchLocationError('不在公司网络')
    return '打卡成功'


# 调用方
try:
    punch(False, 8, '192.168.1.1')
except PunchTimeError as e:
    print(f'时间问题:{e},请等到 9 点')
except PunchDuplicateError as e:
    print(f'已打卡:{e}')
except PunchLocationError as e:
    print(f'位置问题:{e},请连公司 wifi')
except PunchError as e:
    print(f'打卡失败:{e}')

输出:

时间问题:未到上班时间,请等到 9 点

这个写法的好处:

  • 每种错误有自己的类,调用方可以精确捕获
  • 一个公共父类 PunchError,调用方也可以一锅端兜底
  • 类名本身就是文档——不用看消息,光看类名就知道出啥事了

这就是自定义异常类的价值——让错误也变成代码结构的一部分。

自定义异常带数据

自定义异常类还可以带数据,比如:

python到闯关页运行这段 →
class PunchError(Exception):
    def __init__(self, message, user_id=None, hour=None):
        super().__init__(message)
        self.user_id = user_id
        self.hour = hour


try:
    raise PunchError('未到上班时间', user_id=123, hour=8)
except PunchError as e:
    print(f'消息:{e}')
    print(f'用户:{e.user_id}')
    print(f'时间:{e.hour}')

输出:

消息:未到上班时间
用户:123
时间:8

需要更多上下文信息时,把它放进异常对象。后端写日志、做监控的时候,这种结构化错误就特别值钱。

内置异常速查表

工作里 90% 的场景能覆盖:

异常类触发场景例子
ValueError类型对了但值不合法int('abc')
TypeError类型不对'a' + 1
KeyError字典里没这个键{'a': 1}['b']
IndexError序列下标越界[1, 2][5]
AttributeError对象没这个属性'abc'.foo
NameError引用了未定义的变量print(undefined_x)
FileNotFoundError文件不存在open('not_exist.txt')
ZeroDivisionError除以零1 / 0
RuntimeError运行时通用错误用得不多,能避就避
KeyboardInterrupt用户按 Ctrl+C这个别接住
SystemExitsys.exit() 抛的这个也别接住

最后那两个再强调一下:KeyboardInterrupt 和 SystemExit 都是 BaseException 的直接子类,不是 Exception 的子类。所以正常写 except Exception 是接不到它们的——这是好事,避免误伤。

小结

把这一节的内容捋一遍:

  1. try / except 是 Python 处理错误的核心结构,记住「子在前,父在后」
  2. 多异常用 except (A, B) 一锅端;用 as e 拿到异常对象
  3. else 是「没出错才走」,finally 是「无论如何都走」
  4. raise 主动抛异常,raise ... from e 显式异常链
  5. 自定义异常类 让错误也变成代码结构的一部分

异常处理写得好不好,是新手和老手最大的分水岭之一。新手要么完全不写——程序一炸就完蛋;要么乱写——except Exception: pass 把所有错误都吞了,出问题神仙都救不回来。老手会精确捕捉、合理传递、必要时包装、绝不偷偷吞。

练习 5 / 5自定义异常:InvalidScoreError去闯关页做这题 →

读完了?动手练一遍才算真会。

去闯关模式练习 →