自定义异常与最佳实践

上一篇 异常处理机制 try/except 讲了怎么抓异常。这一篇讲怎么设计自己的异常——什么时候用内置异常、什么时候造新异常、怎么组织异常层次、怎么携带上下文。写库/框架/中大型项目时,一个”好设计的异常体系”能省下无数调试时间。

一、什么时候自定义异常

先看一个反面例子:

1
2
3
4
5
6
7
8
9
# ❌ 什么都抛 ValueError
def transfer(from_acct, to_acct, amount):
if amount <= 0:
raise ValueError("金额必须 > 0")
if amount > from_acct.balance:
raise ValueError("余额不足")
if from_acct.frozen:
raise ValueError("账户被冻结")
...

调用方看到 ValueError 不知道具体是哪种:

1
2
3
4
try:
transfer(a, b, 100)
except ValueError:
... # 只知道出错,不知道该怎么办

自定义异常能表达更精确的语义:

1
2
3
4
5
6
7
8
9
10
11
12
class BankError(Exception): ...
class InvalidAmount(BankError): ...
class InsufficientFunds(BankError): ...
class AccountFrozen(BankError): ...

def transfer(from_acct, to_acct, amount):
if amount <= 0:
raise InvalidAmount(amount)
if amount > from_acct.balance:
raise InsufficientFunds(need=amount, have=from_acct.balance)
if from_acct.frozen:
raise AccountFrozen(from_acct)

调用方:

1
2
3
4
5
6
7
8
try:
transfer(a, b, 100)
except InsufficientFunds as e:
show_topup_page() # 引导充值
except AccountFrozen:
show_frozen_notice() # 提示解冻
except InvalidAmount:
show_amount_help()

二、最简自定义异常

1
2
class MyError(Exception):
pass

一行搞定。继承 Exception 就好,不用继承 BaseException(那是给 KeyboardInterrupt 用的)。

三、异常携带信息

给异常加参数:

1
2
3
4
5
6
7
8
9
10
11
12
class InsufficientFunds(Exception):
def __init__(self, need: float, have: float):
self.need = need
self.have = have
super().__init__(f"需要 ¥{need}, 只有 ¥{have}")


try:
raise InsufficientFunds(need=100, have=50)
except InsufficientFunds as e:
print(e.need, e.have) # 100, 50
print(str(e)) # 需要 ¥100, 只有 ¥50
  • super().__init__(msg)str(e) 有个友好的字符串;
  • 结构化的字段(needhave)方便调用方按需处理。

四、异常层次设计

给你的项目/库定义一个根异常,其他异常都继承它:

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyLibError(Exception):
"""MyLib 所有异常的根。"""

class ConnectionError(MyLibError):
"""网络连接问题。"""

class TimeoutError(MyLibError):
"""超时问题。"""

class ValidationError(MyLibError):
"""输入校验失败。"""
class InvalidType(ValidationError): ... # 嵌套子类
class OutOfRange(ValidationError): ...

调用方一律 except MyLibError 就能把你的库的所有异常兜住,不会误伤别的:

1
2
3
4
5
try:
my_lib.do_stuff()
except MyLibError as e:
log(e)
return None
  • 想更精细就 except ConnectionError / except ValidationError
  • 千万不要跟 Python 内置异常同名(除非你就是要”包装内置”,那也要谨慎)。

五、经典的异常层次范例

看 requests 库怎么设计的:

1
2
3
4
5
6
7
8
9
10
RequestException                  ← 所有异常的根
├── HTTPError
├── ConnectionError
│ ├── ProxyError
│ └── SSLError
├── Timeout
│ ├── ConnectTimeout
│ └── ReadTimeout
├── URLRequired
└── TooManyRedirects

调用方可以:

  • except requests.Timeout:所有超时;
  • except requests.ConnectionError:所有连接问题;
  • except requests.RequestException:所有 requests 异常。

六、包装底层异常

一个函数不应该”向调用方泄漏内部实现细节”。把底层异常包成业务异常:

1
2
3
4
5
6
7
8
9
10
11
12
import json

class ConfigError(Exception): ...

def load_config(path):
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError as e:
raise ConfigError(f"配置文件 {path} 不存在") from e
except json.JSONDecodeError as e:
raise ConfigError(f"配置文件 {path} 格式错误") from e

调用方只 except ConfigError 就够,不用关心底层是 file 还是 json 问题。from e 让 traceback 保留原始原因

七、”警告”不是异常

有时候你想告诉调用方”这个功能未来会移除”、”这个参数不推荐”,用 warnings 而不是异常:

1
2
3
4
5
import warnings

def old_func(x):
warnings.warn("old_func 已弃用,请用 new_func", DeprecationWarning)
...

warnings 默认打印到 stderr,可以被过滤/升级为异常。

八、异常 vs 返回错误码

Python 里几乎所有错误都用异常表达,而不是返回 -1False。原因:

  • 强制调用方处理(不写 try 就崩,比”忘检查返回值”明显得多);
  • 保留完整的 traceback;
  • 不占用返回值。

返回错误码的少数场景

  • 处理”预期的失败”且频率高(例如 dict.get(k, default) 找不到 key);
  • 性能极端敏感的循环(try/except 有一点开销)。

九、命名约定

  • 异常类名以 Error 或 Exception 结尾ValueErrorTimeoutError
  • 约定俗成有的名字别乱用(AttributeErrorKeyError 等);
  • 一般项目的根异常可以叫 MyProjectError

十、异常与 dataclass

Python 3.7+ 可以用 dataclass 让异常代码更干净,但 dataclass 生成的 __init__ 会覆盖 Exception 的初始化,需要额外注意:

1
2
3
4
5
6
7
8
9
10
from dataclasses import dataclass


@dataclass
class InsufficientFunds(Exception):
need: float
have: float

def __str__(self):
return f"需要 ¥{self.need}, 只有 ¥{self.have}"

十一、异常的性能

Python 的异常只在被抛出时才有开销——正常路径几乎零成本。所以 EAFP 风格并不慢:

1
2
3
4
5
# Python 里这样写是完全正常的,不是"性能杀手"
try:
return d[key]
except KeyError:
return default

在循环里高频抛异常就慢了:

1
2
3
4
5
6
7
8
9
10
11
12
13
# ❌ 循环里预期会有大量 KeyError
for k in keys:
try:
v = d[k]
process(v)
except KeyError:
pass

# ✅ 用 get
for k in keys:
v = d.get(k)
if v is not None:
process(v)

十二、日志与异常

打异常日志的正确姿势:

1
2
3
4
5
6
7
8
import logging
logger = logging.getLogger(__name__)

try:
do_stuff()
except Exception:
logger.exception("do_stuff 失败") # 自动附加 traceback
raise

logger.exception 会自动把当前异常的完整 traceback 记进日志,比 logger.error(str(e)) 有用得多。

十三、context manager 里的异常

with 语句与上下文管理器。简而言之:__exit__ 里返回 True 会吞掉异常(一般别这么做)。

十四、一个完整例子:文件处理库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# fileproc.py
"""文件处理库,展示异常层次设计。"""

import json


# ============ 异常层次 ============

class FileProcError(Exception):
"""所有异常的根。"""


class InvalidInput(FileProcError):
"""输入不合法。"""


class ParseError(FileProcError):
"""解析失败。"""
def __init__(self, msg, path, line=None):
self.path = path
self.line = line
super().__init__(f"{msg} (file={path}, line={line})")


class WriteError(FileProcError):
"""写入失败。"""


# ============ 业务函数 ============

def load_json(path: str) -> dict:
if not path.endswith(".json"):
raise InvalidInput(f"{path} 不是 .json 文件")
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError as e:
raise ParseError("文件不存在", path) from e
except json.JSONDecodeError as e:
raise ParseError(e.msg, path, e.lineno) from e


def save_json(path: str, data: dict) -> None:
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except OSError as e:
raise WriteError(f"写 {path} 失败:{e}") from e


# ============ 调用方 ============

if __name__ == "__main__":
try:
data = load_json("config.json")
data["updated"] = True
save_json("config.json", data)
except InvalidInput as e:
print(f"[输入错误] {e}")
except ParseError as e:
print(f"[解析错误] {e.path}:{e.line} - {e}")
except WriteError as e:
print(f"[写入错误] {e}")
except FileProcError as e:
print(f"[未知错误] {e}")

十五、常见陷阱

  1. 异常吞掉不打日志except: pass 让 bug 无处可查。
  2. 重新抛异常时改成 raise e:会丢失原 traceback,要 raiseraise ... from e
  3. __init__ 里忘 super().__init__(msg)str(e) 会是空的。
  4. 在自定义异常里存太多状态:异常应该是”信号”,不要变成通用容器。
  5. 抛异常代替返回值:控制流用异常是反模式,正常业务分支还是用返回值。
  6. 异常翻译过多层:层数太多 traceback 冗长且难追。一层包装够了。
  7. 异常里带敏感信息raise Error(f"password={pwd}") 会被记进日志。

十六、小结与延伸阅读

  • 简单的 class MyError(Exception): pass 就能造异常;
  • 给项目建立一个根异常,所有内部异常继承它;
  • 包装底层异常时用 from
  • 异常应表达”是什么错”,不是”返回的错误码”;
  • 打日志用 logger.exception
  • 命名以 Error / Exception 结尾;
  • 别把异常当控制流用。

延伸阅读:

下一篇 文件读写操作 我们讲怎么读写文件,异常和 with 一起用。