跳到主要内容

错误处理

程序不可能永远只遇到理想输入:用户可能输入文字而不是数字,文件可能不存在,网络请求可能超时。错误处理就是识别这些情况、保留有用信息,并决定程序应该恢复、重试、提示用户还是停止。

本文使用 Python 3.10 及以上版本。学完后,你应该能够:

  • 区分语法错误、运行时异常和逻辑错误;
  • 阅读 traceback,定位异常发生的位置;
  • 使用 try、except、else 和 finally 处理可预期的失败;
  • 使用 raise 和自定义异常表达业务规则;
  • 保留原始异常上下文,并在资源使用后可靠地清理;
  • 避免过度捕获、静默失败和把异常当作普通返回值等问题。

先看一个完整例子​

下面的函数把用户输入转换为年龄。它只捕获自己能够处理的异常,并把转换成功和清理动作分开:

def read_age(text: str) -> int | None:
try:
age = int(text)
except ValueError:
print("年龄必须是整数")
return None
else:
if not 0 <= age <= 150:
print("年龄必须在 0 到 150 之间")
return None
return age
finally:
print("输入处理结束")


print(read_age("18")) # 输入处理结束,然后是 18
print(read_age("未知")) # 先提示错误,然后是 None

try 放可能失败的代码,except 处理指定异常,else 只在没有异常时执行,finally 无论是否发生异常都会执行。

三类常见错误​

语法错误:代码无法开始运行​

语法错误(SyntaxError)表示代码不符合 Python 语法,例如少了冒号或括号:

# if score >= 60 # SyntaxError:缺少冒号
# print("及格")

解释器通常会在运行前报告文件名、行号和错误位置。语法错误不能通过运行时的 try 捕获;应先修正代码本身。

运行时异常:执行过程中发生意外​

语法正确的代码,执行时仍可能失败:

number = int("abc") # ValueError
item = ["a"][3] # IndexError
result = 10 / 0 # ZeroDivisionError
missing = unknown_name # NameError

这些失败会创建异常对象,并沿着调用栈向外传播,直到被匹配的 except 处理;如果没有处理,就会终止程序并显示 traceback。

逻辑错误:程序运行了,但结果不对​

逻辑错误不一定抛出异常,例如把加法写成减法:

def total(price, quantity):
return price - quantity # 语法正确,但业务逻辑错误

try 无法自动发现逻辑错误。应通过清晰的代码、断言、单元测试和实际检查结果来发现它们。

读懂 traceback​

未处理异常通常会输出 traceback。它从调用链的外层列出到内层,最后一行是异常类型和消息:

Traceback (most recent call last):
File "app.py", line 8, in <module>
show_total("abc")
File "app.py", line 4, in show_total
return int(text) * 2
ValueError: invalid literal for int() with base 10: 'abc'

定位问题时重点看三处:

  1. 最后一行:异常类型是 ValueError,消息说明字符串不能转换为整数;
  2. 最后一个自己的调用栈帧:show_total() 的 int(text) 是直接出错的位置;
  3. 调用路径:从入口回看参数是如何传到出错位置的。

不要只看最后一行就盲目捕获异常。先理解输入为什么不符合预期,再决定修正输入、处理异常还是让它继续向外传播。

使用 try 和 except​

最小结构如下:

try:
number = int(input("请输入整数:"))
except ValueError:
print("输入格式不正确")

try 代码块中的语句按顺序执行。一旦发生异常,剩余语句会跳过,Python 会寻找匹配的 except。如果没有异常,所有 except 都会跳过。

捕获具体异常​

优先捕获能够处理的具体异常:

def divide(left: float, right: float) -> float | None:
try:
return left / right
except ZeroDivisionError:
print("除数不能为零")
return None

不要把所有错误都写成 except Exception,更不要直接写空的 except::

try:
do_work()
except Exception:
pass # 错误被静默丢弃,问题会更难排查

宽泛捕获可能把编程错误、资源错误甚至用户主动中断都隐藏起来。只有在边界层需要记录、转换或统一返回错误时,才考虑捕获 Exception,并且应记录或重新抛出异常。

一次处理多种异常​

无论几种异常采用同一处理方式,都可以放在元组中:

try:
value = int(text)
except (TypeError, ValueError):
print("请输入可转换为整数的文本")

如果处理逻辑不同,应分开写。多个 except 按顺序匹配,因此子类异常要放在父类异常前面:

try:
data = load_data()
except FileNotFoundError:
print("文件不存在")
except OSError:
print("文件系统操作失败")

FileNotFoundError 是 OSError 的子类;如果先捕获 OSError,前一个分支就永远不会执行。

取得异常对象​

使用 as 可以读取异常消息,或把异常交给日志系统:

try:
number = int("abc")
except ValueError as error:
print(f"转换失败:{error}")

异常消息适合辅助诊断,不应作为稳定的业务判断条件。需要让调用方区分错误时,应使用异常类型或自定义异常。

else 和 finally​

else:只放成功路径​

else 仅在 try 没有抛出异常时执行:

try:
number = int(text)
except ValueError:
print("不是整数")
else:
print(f"转换成功:{number}")

把成功路径放到 else 有两个好处:try 范围更小,成功代码不会被误认为是可能失败的代码;如果 else 中发生异常,也不会被前面的 except 错误地捕获。

finally:一定要执行的收尾代码​

finally 无论 try 是否成功、是否发生异常、是否执行了 return,通常都会执行:

file = None
try:
file = open("notes.txt", encoding="utf-8")
content = file.read()
except OSError as error:
print(f"读取失败:{error}")
finally:
if file is not None:
file.close()

不过,手动关闭文件容易遗漏边界情况。对于文件、锁和数据库连接等支持上下文管理协议的资源,优先使用 with:

try:
with open("notes.txt", encoding="utf-8") as file:
content = file.read()
except OSError as error:
print(f"读取失败:{error}")

with 会在离开代码块时自动调用资源的清理逻辑,即使代码块中抛出了异常。

finally 中的 return 要谨慎​

finally 中的 return 会覆盖 try 或 except 中的返回值,甚至压掉异常:

def confusing():
try:
1 / 0
finally:
return "异常被隐藏"


print(confusing()) # 异常不会再向外抛出

除非有非常明确的理由,否则不要在 finally 中使用 return、break 或 continue。

主动抛出异常:raise​

当函数发现调用方违反了约定,可以使用 raise 主动报告失败:

def withdraw(balance: float, amount: float) -> float:
if amount <= 0:
raise ValueError("取款金额必须大于 0")
if amount > balance:
raise ValueError("余额不足")
return balance - amount

异常应该在最接近问题发生的位置创建,但不一定要在那里处理。底层函数负责说明“发生了什么”,上层边界负责决定“如何呈现或恢复”。

在 except 中单独写 raise,可以原样重新抛出当前异常并保留完整 traceback:

try:
save_record(record)
except OSError:
log_error()
raise

不要写成 raise error 来替代无参数的 raise;无参数形式更清楚地表达“记录后继续交给上层处理”。

自定义异常​

当调用方需要区分某种业务失败时,可以定义继承自 Exception 的异常:

class InsufficientBalanceError(Exception):
"""余额不足。"""


def withdraw(balance: float, amount: float) -> float:
if amount > balance:
raise InsufficientBalanceError(
f"需要 {amount:.2f} 元,但余额只有 {balance:.2f} 元"
)
return balance - amount


try:
withdraw(50, 80)
except InsufficientBalanceError as error:
print(f"无法取款:{error}")

可以用一个项目级基类,方便调用方一次捕获本项目的所有业务异常:

class OrderError(Exception):
"""订单相关错误的基类。"""


class InvalidOrderStateError(OrderError):
pass


class PaymentError(OrderError):
pass

通常不需要为每个错误都创建一个类。只有当调用方需要不同处理方式、错误需要携带结构化信息,或者错误属于稳定的公共接口时,自定义异常才有明显价值。

异常链:保留“原始原因”​

底层异常不一定适合直接暴露给用户。例如,文件解析失败时,可以把 JSONDecodeError 转换为更符合业务语义的异常,同时保留原始原因:

import json


class ConfigError(Exception):
pass


def load_config(text: str) -> dict[str, object]:
try:
value = json.loads(text)
except json.JSONDecodeError as error:
raise ConfigError("配置文件格式错误") from error
if not isinstance(value, dict):
raise ConfigError("配置根节点必须是对象")
return value

raise NewError from error 会设置显式异常链。调试时可以从 traceback 看见“业务错误由什么底层错误引起”,调用方则只需要依赖稳定的 ConfigError。

如果确实不希望显示底层异常上下文,可以使用 from None,但这会减少诊断信息,应谨慎使用:

raise ConfigError("配置不可用") from None

异常层级与捕获边界​

常见层级可以简单理解为:

BaseException
├── Exception
│ ├── ValueError
│ ├── OSError
│ └── RuntimeError
└── KeyboardInterrupt / SystemExit / GeneratorExit

业务代码通常捕获 Exception 或它的具体子类,而不是 BaseException。这样不会把用户按下 Ctrl+C 或程序退出信号当成普通业务错误吞掉。

except 的位置也很重要:

  • 底层函数:通常只抛出有意义的异常,不擅自打印或决定界面文案;
  • 服务层:可以把底层异常转换成稳定的领域异常;
  • 程序入口或接口边界:记录 traceback,并向用户返回简洁、安全的提示。

这种分层能避免同一个异常被多处重复打印,也能避免把内部路径、SQL 或密钥等敏感信息直接返回给用户。

重试、回退与错误恢复​

捕获异常后,必须有明确动作,常见选择包括:

  • 修正输入:提示用户重新输入;
  • 重试:只对临时性失败使用,并限制次数;
  • 回退:使用缓存或默认值,但要明确记录;
  • 转换后抛出:让上层按统一类型处理;
  • 终止当前操作:保留日志并安全结束。

例如,用户输入可以循环重试,但不能无限重试:

def ask_positive_integer() -> int:
for _ in range(3):
try:
value = int(input("请输入正整数:"))
if value <= 0:
raise ValueError("必须大于 0")
return value
except ValueError as error:
print(f"输入无效:{error}")
raise ValueError("连续 3 次输入失败")

网络、数据库等外部系统的重试还应考虑超时、退避、幂等性和是否值得重试。不要对所有异常无条件重试。

assert 不是用户输入校验​

assert condition, message 适合检查程序内部“不应该发生”的状态:

def average(total: float, count: int) -> float:
assert count > 0, "count 应该大于 0"
return total / count

运行 Python 时使用优化选项 -O,断言可能会被移除。因此,不能用 assert 代替必须始终执行的参数校验、权限检查或安全检查:

def create_user(name: str) -> None:
if not name.strip():
raise ValueError("用户名不能为空")

错误处理与调试、测试的关系​

错误处理不是“把错误藏起来”。更可靠的流程是:

  1. 用 traceback、日志或调试器找到失败位置和输入;
  2. 用最小复现示例确认失败条件;
  3. 用测试固定预期行为,包括成功、边界和异常场景;
  4. 只捕获能处理的异常,并让无法处理的异常继续暴露。

例如,可以用标准库 unittest 验证函数既能成功,也能在非法输入时抛出指定异常:

import unittest


class WithdrawTests(unittest.TestCase):
def test_withdraw_success(self):
self.assertEqual(withdraw(100, 30), 70)

def test_withdraw_rejects_insufficient_balance(self):
with self.assertRaises(InsufficientBalanceError):
withdraw(50, 80)

测试异常时,应检查异常类型;只有错误消息属于稳定接口时,才额外检查消息内容。这样重构提示文案时不会让无关测试失效。

常见错误处理反模式​

捕获后什么也不做​

try:
parse(data)
except Exception:
pass

这会让程序继续运行,却可能带着错误状态继续工作。至少应记录、返回明确结果,或重新抛出异常。

try 范围过大​

try:
data = load_data()
result = calculate(data)
save(result)
except Exception:
print("失败")

这样无法知道究竟是读取、计算还是保存失败。让 try 只包住真正需要处理的最小语句范围。

用字符串判断错误​

if "不存在" in str(error):
...

消息可能随 Python 版本、操作系统或翻译变化。优先判断异常类型,必要时读取异常对象中的结构化属性。

重复记录同一个异常​

每一层都打印同一个 traceback,会产生噪声。通常在最接近恢复决策的地方处理,在应用入口统一记录未处理异常。

一份实用检查清单​

编写错误处理时,可以依次问自己:

  • 这是语法错误、运行时异常,还是逻辑错误?
  • 哪一段代码真的可能失败?try 是否足够小?
  • 我捕获的是最具体且能够处理的异常吗?
  • 捕获后是修正、重试、回退、转换,还是安全终止?
  • 是否需要用 with 保证资源释放?
  • 是否保留了原始异常上下文?
  • 用户看到的提示是否简洁,日志是否包含排查所需信息?
  • 是否为成功、边界和失败路径编写了测试?

好的错误处理不是让异常永远不出现,而是让可预期的失败可恢复、不可预期的问题可定位、对外提示安全清晰。