RSS
菜单
全部文章快讯开发科技深度热点

异常处理(Python 从精通到入门 · 20)

内容摘要

异常不是"错误",而是 Python 里一套正常的控制流。学会用它,程序才不会一崩到底,还能告诉你到底哪里出了事。

异常不是"错误",而是 Python 里一套正常的控制流。学会用它,程序才不会一崩到底,还能告诉你到底哪里出了事。

你将学到

  • try/except/else/finally 的完整语义与执行顺序
  • 为什么该捕获具体异常,而不是一句裸 except:
  • 多异常写法、raise from 异常链、自定义异常
  • EAFP 与 LBYL 两种风格,以及 assert 的正确定位
  • 用 logging 记录问题,并写出一个健壮的读取/解析函数

前置知识

上一篇《模块与包》把代码拆成了多个文件。你至少见过 import、函数和类,就能看懂这一篇。

try / except 基本形态

把"可能出错"的代码放进 try,出错时用 except 接住。

try:
    n = int("abc")             # 这里会抛 ValueError
except ValueError:
    print("转换失败,输入不是数字")   # 输出: 转换失败,输入不是数字

没有 try 的话,int("abc") 会把整个程序打挂,抛一堆红色 traceback。接住之后,程序能继续往下走。要拿到异常对象本身,用 as:

try:
    n = int("abc")
except ValueError as e:
    print(f"出错了:{e}")        # 输出: 出错了:invalid literal for int() with base 10: 'abc'

else 与 finally

完整形态有四个块,执行顺序值得记牢。

def process(s):
    try:
        n = int(s)              # 可能抛异常
    except ValueError as e:
        print("转换失败:", e)
    else:
        print("转换成功:", n)    # 只有 try 没抛异常时才执行
    finally:
        print("收尾,无论如何都执行")   # 无论成败都会执行

process("42")
# 输出: 转换成功: 42
# 输出: 收尾,无论如何都执行

process("abc")
# 输出: 转换失败: invalid literal for int() with base 10: 'abc'
# 输出: 收尾,无论如何都执行
  • else:try 顺利跑完时执行,把"成功后的逻辑"和"可能出错的逻辑"分开,更清晰。
  • finally:无论有没有异常、有没有 return,都执行。清理资源(关文件、断连接)放这里。

不过清理文件更推荐用 with open(...),它会自动关闭,本质就是 try/finally 的封装。

捕获具体异常,别裸 except

# ❌ 裸 except 会吞掉所有异常,包括 Ctrl+C 和程序 bug
try:
    do_something()
except:
    pass

问题在于:它连 KeyboardInterrupt(你按 Ctrl+C)和拼写错误导致的 NameError 都一起吞了,调试时你会一头雾水。

# ✅ 只接住你预期的那种异常
try:
    result = compute()
except ValueError as e:
    log_the_problem(e)

如果实在想兜底,至少写 except Exception(不包含系统级退出异常),并且不要 pass 掉——记录日志或重新抛出。

try:
    do_something()
except Exception as e:
    print(f"未预期的错误:{e}")    # 至少让人知道出事了
    raise                        # 需要时再抛出去,别默默吞掉

多个异常与异常链

一个 try 可以配多个 except,Python 从上到下匹配,只执行第一个匹配的分支。多种同类处理可以合并成一个元组。

data = {"a": 1}
try:
    print(data["b"] / 0)
except KeyError as e:
    print("键不存在:", e)              # 输出: 键不存在: 'b'
except (ZeroDivisionError, TypeError) as e:
    print("运算非法:", e)              # 上面已匹配,这里不会执行

想让一个异常"带上"原始原因,用 raise ... from ... 建立异常链:

try:
    n = int("abc")
except ValueError as e:
    raise RuntimeError("配置解析失败") from e

这样 traceback 会显示:由 ValueError 直接导致了 RuntimeError,排查时一路看得到根因。想刻意隐藏原因可用 raise X from None(不推荐)。

自定义异常

当内置异常表达不了业务含义时,定义自己的异常。惯例是继承 Exception。

class ConfigError(Exception):
    """配置相关错误。"""

class MissingKeyError(ConfigError):     # 继承自己的异常,便于分层捕获
    """缺少必需的配置项。"""

def get_config(cfg, key):
    if key not in cfg:
        raise MissingKeyError(f"缺少配置项: {key}")
    return cfg[key]

try:
    get_config({}, "host")
except MissingKeyError as e:
    print(e)                            # 输出: 缺少配置项: host
except ConfigError as e:                # 更宽泛的同类异常也能接住
    print("配置错误:", e)

自定义异常的价值:调用方可以只捕获你的异常,从而区分"业务问题"和"程序 bug"。

EAFP vs LBYL

  • LBYL(Look Before You Leap):先检查再动手。
  • EAFP(Easier to Ask Forgiveness than Permission):先动手,出错再处理。

Python 社区更推崇 EAFP。原因之一是"检查"和"使用"之间存在时间差(尤其在并发场景),检查通过不代表使用时就安全。尤其是字符串转数字,别先检查是数字再转换:

s = "12a"

# ❌ LBYL:又啰嗦又不严谨("1"、"²" 这类字符能骗过 isdigit)
if s.isdigit():
    n = int(s)

# ✅ EAFP:直接尝试,让 int 自己判断
try:
    n = int(s)
except ValueError:
    n = 0

assert 的定位

assert 用来表达程序内部的不变量,是"我坚信这里必然成立"的断言。

def apply_discount(price, rate):
    assert 0 <= rate <= 1, "折扣率必须在 0~1 之间"
    return price * (1 - rate)

关键点:assert 会被 python -O 优化掉。所以它不能用来校验用户输入或做安全判断,那些必须用真实的 if ... raise。

# ❌ 用 assert 校验外部输入,一旦跑在 -O 下就形同虚设
assert user_input, "输入不能为空"

# ✅ 该报错就明确报错
if not user_input:
    raise ValueError("输入不能为空")

一句话:assert 给开发者自己看,用来抓 bug;raise 给运行时看,用来处理真问题。

logging 基础

用 print 调试,上线后没法关;用 logging 可以分级、带上下文、输出到文件。

import logging

logging.basicConfig(
    level=logging.INFO,                          # 低于这个级别的日志不显示
    format="%(asctime)s %(levelname)s %(message)s",
)

logging.debug("调试信息,通常不显示")             # 级别低于 INFO,被过滤
logging.info("程序启动")                         # 输出: ... INFO 程序启动
logging.warning("磁盘快满了")                    # 输出: ... WARNING 磁盘快满了

日志级别从低到高:DEBUG < INFO < WARNING < ERROR < CRITICAL。在 except 里想连 traceback 一起记下来,用 logging.exception:

try:
    n = int("abc")
except ValueError:
    logging.exception("转换失败")        # 会自动带上完整的异常堆栈

常见内置异常速查

异常 典型触发场景
ValueError 类型对但值不对,如 int("abc")
TypeError 类型本身不适合该操作,如 "a" + 1
KeyError 字典里没有这个键
IndexError 列表/元组下标越界
AttributeError 对象没有这个属性或方法
FileNotFoundError 打开的文件不存在
ZeroDivisionError 除以零

注意它们的共同父类是 Exception;而 KeyboardInterrupt、SystemExit 属于 BaseException 的分支,所以 except Exception 不会误伤它们。

一个健壮的读取与解析函数

把上面的知识串起来。

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

class ParseError(Exception):
    """自定义:解析失败。"""

def load_numbers(path):
    """读取文件,每行转成整数,跳过坏行。返回整数列表。"""
    numbers = []
    try:
        with open(path, encoding="utf-8") as f:
            for lineno, line in enumerate(f, start=1):
                line = line.strip()
                if not line:
                    continue                      # 空行跳过
                try:
                    numbers.append(int(line))
                except ValueError:
                    logging.warning("第 %d 行不是整数,已跳过: %r", lineno, line)
    except FileNotFoundError as e:
        raise ParseError(f"文件不存在: {path}") from e
    except OSError as e:
        raise ParseError(f"读取文件出错: {e}") from e
    return numbers

if __name__ == "__main__":
    print(load_numbers("numbers.txt"))            # 输出: [1, 2, 3](依文件内容而定)

这个函数体现了几个好习惯:坏行只警告不中断、明确捕获 FileNotFoundError、把底层异常用 raise ... from 包装成业务异常。

常见坑

❌ 裸 except 加 pass,把错误吞得一干二净;✅ 捕获具体类型并记录日志。

try:
    run()
except:                          # ❌ 出错也不说话,排查时抓瞎
    pass
try:
    run()
except ValueError as e:          # ✅
    logging.warning("参数错误: %s", e)

❌ 用 assert 校验用户输入;✅ 用 if ... raise。

assert age > 0, "年龄必须为正"    # ❌ 加了 -O 就失效
if age <= 0:
    raise ValueError("年龄必须为正")   # ✅

❌ 抛出宽泛的 Exception 丢失信息;✅ 用 raise ... from 保留因果链。

try:
    int(s)
except ValueError as e:
    raise RuntimeError("解析失败") from e   # ✅ traceback 里有根因
    # raise Exception("出错了")             # ❌ 原始原因被埋没

小结

  • try/except 接住异常;else 处理成功路径;finally 做清理,总会执行。
  • 捕获具体异常,别裸 except,更别 pass 掉。
  • 多个 except 自上而下匹配;raise ... from e 建立异常链。
  • 业务错误用自定义异常;assert 只用于内部不变量,别校验输入。
  • 优先 EAFP:先做、出错再处理,尤其别"先检查再转换"。
  • 用 logging 分级记录,logging.exception 会带上完整堆栈。

延伸阅读

  • 大师篇《上下文管理器与 with》会告诉你 with 背后正是 try/finally 的封装,理解它就知道资源为什么总能被释放。
  • 进阶篇《测试 pytest 与 TDD》里会讲 pytest.raises,专门用来断言"这段代码确实抛了预期的异常"。

上一篇:模块与包 · 下一篇:推导式与函数式工具

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《异常处理(Python 从精通到入门 · 20)》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。