日期时间 datetime 模块

处理日期时间是程序员最经常”看起来简单实际处处是坑”的领域——时区、闰秒、夏令时、格式化、跨月加减……任何一个疏忽都能引发生产事故。Python 的 datetime 模块把这些抽象成了几个类:datetimedatetimetimedeltatzinfo。这一篇讲清楚怎么”对”地用它们,以及现代 Python 的 zoneinfo

一、四个核心类

  • date:日期(年月日)
  • time:时间(时分秒微秒)
  • datetime:日期时间(date + time)
  • timedelta:时长(差值)
1
2
3
4
5
6
from datetime import date, time, datetime, timedelta

d = date(2025, 9, 25)
t = time(14, 30, 0)
dt = datetime(2025, 9, 25, 14, 30, 0)
delta = timedelta(days=7, hours=3)

二、获取当前时间

1
2
3
4
5
6
from datetime import date, datetime

date.today() # 2025-09-25
datetime.now() # 2025-09-25 14:30:15.123456
datetime.utcnow() # UTC 时间(不带时区,不推荐用)
datetime.now(tz=...) # 带时区的(推荐)

优先用 datetime.now(tz=...),不带时区的对象在跨时区场景会出问题。

三、时间戳

1
2
3
4
5
6
# datetime → 时间戳
datetime.now().timestamp() # 1758787815.123456 秒

# 时间戳 → datetime
datetime.fromtimestamp(1758787815)
datetime.fromtimestamp(1758787815, tz=timezone.utc)

四、格式化与解析

strftime = string format time(datetime → 字符串);strptime = string parse time(字符串 → datetime)。

1
2
3
4
5
6
7
8
dt = datetime.now()

# 格式化
dt.strftime("%Y-%m-%d %H:%M:%S")
# '2025-09-25 14:30:15'

# 解析
datetime.strptime("2025-09-25 14:30", "%Y-%m-%d %H:%M")

常用格式符

符号 含义
%Y 4 位年 2025
%m 月(补零) 09
%d 日(补零) 25
%H 24 小时(补零) 14
%M 30
%S 15
%f 微秒 123456
%A 星期全称 Thursday
%a 星期缩写 Thu
%B 月全称 September
%b 月缩写 Sep
%Z 时区名 UTC
%z 时区偏移 +0800

ISO 8601(推荐格式)

1
2
3
4
5
6
7
8
# 输出
dt.isoformat() # '2025-09-25T14:30:15.123456'

# 解析
datetime.fromisoformat("2025-09-25T14:30:15")

# date 也支持
date.fromisoformat("2025-09-25")

存到数据库、发到 API 时永远用 ISO 8601 格式——无歧义、易解析、跨语言。

五、timedelta:时间差

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from datetime import timedelta

delta = timedelta(days=7, hours=3, minutes=30)
print(delta) # 7 days, 3:30:00
print(delta.total_seconds()) # 613800.0

# 加减
now = datetime.now()
tomorrow = now + timedelta(days=1)
last_week = now - timedelta(weeks=1)

# 两个 datetime 相减 → timedelta
diff = datetime.now() - datetime(2025, 1, 1)
print(diff.days)

注意:timedelta 支持 days/seconds/microseconds/milliseconds/minutes/hours/weeks,但不支持 months 和 years(因为月长度不定,年可能闰)。要加月/年用 dateutil.relativedelta 或手动。

1
2
3
from dateutil.relativedelta import relativedelta
next_month = datetime.now() + relativedelta(months=1)
next_year = datetime.now() + relativedelta(years=1)

六、时区处理(重头戏)

Python 3.9+ 内置 zoneinfo(IANA 时区数据库):

1
2
3
4
5
6
7
8
9
10
11
12
13
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

# 建立带时区的时间
utc_now = datetime.now(tz=timezone.utc)
beijing = datetime.now(tz=ZoneInfo("Asia/Shanghai"))
tokyo = datetime.now(tz=ZoneInfo("Asia/Tokyo"))

print(utc_now.isoformat()) # 2025-09-25T06:30:00+00:00
print(beijing.isoformat()) # 2025-09-25T14:30:00+08:00

# 时区之间转换
utc_now.astimezone(ZoneInfo("America/New_York"))

准则

  • 存储用 UTC(时间戳或 ISO 8601 with +00:00);
  • 展示时转当地时区
  • 拒绝 naive datetime(不带时区的 datetime)参与跨时区比较。

Python 3.8 及更早需要用第三方 pytzpython-dateutil

七、naive vs aware

  • naive datetime:不带时区信息(tzinfo 是 None);
  • aware datetime:带时区信息。
1
2
3
4
naive = datetime.now()
aware = datetime.now(tz=timezone.utc)

naive < aware # ❌ TypeError: 不能比较

规则别混用。要么全 naive(本地时间,只在单机小工具用),要么全 aware(生产系统)。

八、time 模块(相对底层)

除了 datetime,还有 time 模块——更”贴近系统”的接口:

1
2
3
4
5
6
7
8
9
import time

time.time() # 当前 UNIX 时间戳(秒,float)
time.time_ns() # 纳秒

time.sleep(2) # 阻塞 2 秒
time.monotonic() # 单调时钟,测耗时用
time.perf_counter() # 高精度性能计时器(推荐用来测代码耗时)
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())

测代码耗时

1
2
3
4
5
6
import time

start = time.perf_counter()
do_stuff()
elapsed = time.perf_counter() - start
print(f"用了 {elapsed:.4f}s")

九、UNIX 时间戳与 datetime 互转

1
2
3
4
5
6
7
8
# 时间戳 → naive datetime(本地时区)
datetime.fromtimestamp(1758787815)

# 时间戳 → aware datetime(UTC)
datetime.fromtimestamp(1758787815, tz=timezone.utc)

# datetime → 时间戳
dt.timestamp() # naive 会被当作本地时间;aware 会用其时区

十、日期算术:谨慎处理月和年

1
2
3
4
5
6
7
8
9
10
11
12
13
# 加一天:安全
d + timedelta(days=1)

# 加一月:不安全!月长度不定
# 用 relativedelta
from dateutil.relativedelta import relativedelta
d + relativedelta(months=1)

# 计算月末
from calendar import monthrange
year, month = 2025, 9
_, last_day = monthrange(year, month) # (0, 30)
month_end = date(year, month, last_day)

跨月边缘的 bug 特别多——比如 1 月 31 日加一个月,应该是 2 月 28 还是 3 月 3?不同库处理不同,一定要读文档。

十一、常见操作汇总

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 今天开始/结束
today_start = datetime.combine(date.today(), time.min) # 00:00:00
today_end = datetime.combine(date.today(), time.max) # 23:59:59.999999

# 本周一
today = date.today()
monday = today - timedelta(days=today.weekday()) # 0=周一, 6=周日

# 本月 1 号
first_of_month = today.replace(day=1)

# 两个日期之间的天数
(date(2025, 12, 31) - date.today()).days

# 遍历日期区间
def date_range(start, end):
d = start
while d < end:
yield d
d += timedelta(days=1)

for d in date_range(date(2025, 9, 1), date(2025, 9, 5)):
print(d)

十二、格式化与国际化

strftime("%A") 输出星期名,取决于 locale:

1
2
3
4
5
6
import locale
locale.setlocale(locale.LC_TIME, "zh_CN")
datetime.now().strftime("%A") # '星期四'

locale.setlocale(locale.LC_TIME, "en_US")
datetime.now().strftime("%A") # 'Thursday'

f-string 内部也能用格式说明符:

1
f"{datetime.now():%Y-%m-%d %H:%M:%S}"

十三、第三方库

  • python-dateutil:更强解析、relativedelta
  • arrow:更”人性化”的 API;
  • pendulum:类似 arrow,更完整;
  • whenever(新兴):类型安全的时区处理。

日常业务用内置 datetime + zoneinfo 就够。特殊需求再上第三方。

十四、一个完整例子:会议时区转换

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
# meeting.py
"""把一个会议时间显示给多个时区的参与者。"""

from datetime import datetime
from zoneinfo import ZoneInfo


def format_for(dt_utc: datetime, tz_name: str) -> str:
tz = ZoneInfo(tz_name)
local = dt_utc.astimezone(tz)
return local.strftime(f"%Y-%m-%d %H:%M ({tz_name})")


def schedule_meeting():
# 组织者在北京,定 2025-09-30 14:00 北京时间
beijing_time = datetime(2025, 9, 30, 14, 0, tzinfo=ZoneInfo("Asia/Shanghai"))

# 转 UTC 存储
utc_time = beijing_time.astimezone(ZoneInfo("UTC"))
print("存储用:", utc_time.isoformat())

# 展示给不同参与者
zones = [
"Asia/Shanghai",
"Asia/Tokyo",
"Europe/London",
"America/New_York",
"America/Los_Angeles",
]
for z in zones:
print(format_for(utc_time, z))


if __name__ == "__main__":
schedule_meeting()

十五、常见陷阱

  1. naive 与 aware 混用:TypeError。生产系统统一 aware。
  2. datetime.utcnow() 返回 naive:Python 3.12 已弃用它,用 datetime.now(tz=timezone.utc)
  3. .timestamp() 在 naive 上按本地时区解释:跨时区时结果不对。
  4. 加一个月用 days=30:不同月长度不同,用 relativedelta
  5. strptime 格式不匹配ValueError。加 try 或校验。
  6. %f 精度:Python 只到微秒(6 位),没有纳秒 datetime。要更精细用 time.time_ns
  7. 夏令时(DST)ZoneInfo 会自动处理,但边界时(”2 点重复”或”跳过 2 点”)依然有陷阱。

十六、小结与延伸阅读

  • 四大类:date / time / datetime / timedelta;
  • 存 UTC,展示时转当地时区;
  • zoneinfo(3.9+)或 pytz(3.8-);
  • ISO 8601 格式是万能通用格式;
  • 加减用 timedelta,跨月用 relativedelta;
  • 测代码性能用 time.perf_counter
  • 别混用 naive 和 aware;
  • datetime.utcnow() 已弃用。

延伸阅读:

下一篇 JSON 数据处理 我们讲怎么优雅地处理 JSON 数据。

正则表达式 re 模块实战

正则表达式(regex)是文本处理的瑞士军刀。你能用它一行代码解决”从日志里提取所有 IP”、”验证邮箱格式”、”把驼峰命名改成蛇形”、”从 HTML 里抽出所有链接”(虽然 HTML 更该用 BeautifulSoup)。Python 的正则由 re 模块提供。这一篇不做完整语法参考(那要一整本书),而是把日常最常用的模式和 API 讲清楚。

一、re 模块的四个核心函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import re

# 1. 匹配:在字符串开头找
re.match(r"\d+", "123abc") # <Match object>
re.match(r"\d+", "abc123") # None,不是开头

# 2. 搜索:在字符串任意位置找
re.search(r"\d+", "abc123def") # <Match object>

# 3. 找全部
re.findall(r"\d+", "a1b22c333") # ['1', '22', '333']

# 4. 替换
re.sub(r"\d+", "N", "a1b22c333") # 'aNbNcN'
  • match:从头匹配;
  • search:任意位置匹配(一次);
  • findall:找全部;
  • sub:替换。

再加两个:

1
2
3
4
5
6
# 拆分
re.split(r"\s+", "hello world python") # ['hello', 'world', 'python']

# 迭代匹配
for m in re.finditer(r"\d+", "a1b22c333"):
print(m.start(), m.group())

二、正则的基础语法

字符类

  • . 任意字符(除换行)
  • \d 数字,等于 [0-9]
  • \D 非数字
  • \w 字母/数字/下划线,等于 [a-zA-Z0-9_](Unicode 模式下也含中文)
  • \W 非上述
  • \s 空白(空格、tab、换行)
  • \S 非空白
  • [abc] a 或 b 或 c
  • [^abc] 非 a b c
  • [a-z] a 到 z

量词

  • * 0 次或多次
  • + 1 次或多次
  • ? 0 或 1 次
  • {n} 恰好 n 次
  • {n,} 至少 n 次
  • {n,m} n 到 m 次

贪婪 vs 非贪婪:默认贪婪(尽量多匹配),加 ? 变非贪婪(尽量少)。

1
2
re.findall(r"<.+>", "<a><b>")     # ['<a><b>']   贪婪
re.findall(r"<.+?>", "<a><b>") # ['<a>', '<b>'] 非贪婪

锚点

  • ^ 字符串开头
  • $ 字符串结尾
  • \b 单词边界

分组与捕获

  • (...) 捕获组
  • (?:...) 非捕获组(只分组不捕获)
  • (?P<name>...) 命名组
1
2
3
4
5
6
7
8
9
10
m = re.match(r"(\d{4})-(\d{2})-(\d{2})", "2025-09-25")
m.group() # '2025-09-25'(整体)
m.group(1) # '2025'
m.group(2) # '09'
m.groups() # ('2025', '09', '25')

# 命名组
m = re.match(r"(?P<year>\d{4})-(?P<mon>\d{2})-(?P<day>\d{2})", "2025-09-25")
m.group("year") # '2025'
m.groupdict() # {'year': '2025', 'mon': '09', 'day': '25'}

  • a|b 匹配 a 或 b

三、raw string 与转义

永远用 r"..."(raw string)写正则。否则 \d 里的 \d 会被 Python 解释成”转义”,需要写 "\\d"

1
2
re.findall("\\d+", s)     # 能用,但难读
re.findall(r"\d+", s) # ✅ 推荐

四、编译正则

如果一个正则要用很多次,先 编译 一下:

1
2
3
pattern = re.compile(r"\d+")
pattern.findall("a1b22")
pattern.sub("N", "a1b22")

好处:

  • 只解析一次;
  • 少写一个参数;
  • 有个”专属对象”,可读性高。

五、常用模式实战

1. 验证格式

1
2
3
4
5
6
7
8
9
10
11
# 邮箱(简化版)
re.fullmatch(r"[\w.+-]+@[\w-]+\.[\w.-]+", email)

# 手机号(中国大陆 11 位)
re.fullmatch(r"1[3-9]\d{9}", phone)

# 身份证(18 位)
re.fullmatch(r"\d{17}[\dX]", id_card)

# IPv4
re.fullmatch(r"(\d{1,3}\.){3}\d{1,3}", ip)

fullmatch vs match:fullmatch 要求整个字符串匹配(避免”1234567890abc”被 match 通过)。

2. 抽取字段

1
2
3
4
5
# 从日志里抽 IP + 状态码 + 路径
log = '192.168.1.1 - - [2025-09-25:10:11:12] "GET /login HTTP/1.1" 200'
m = re.search(r'(\S+) .+? "(\S+) (\S+) .+?" (\d+)', log)
print(m.groups())
# ('192.168.1.1', 'GET', '/login', '200')

3. 拆分

1
2
3
# 拆分句子(多种标点)
re.split(r"[,.!?、;。!?]\s*", "hi, 咖飞. how? 学 Python!")
# ['hi', '咖飞', 'how', '学 Python', '']

4. 替换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 简单替换
re.sub(r"\d+", "N", "abc 123 def 456") # 'abc N def N'

# 引用捕获组:\1 或 \g<name>
re.sub(r"(\w+)@\w+", r"\1@匿名", "user@example.com")
# 'user@匿名'

# 用函数做动态替换
def to_upper(m):
return m.group().upper()

re.sub(r"[a-z]+", to_upper, "hi 咖飞 abc") # 'HI 咖飞 ABC'

# 只替换前 N 次
re.sub(r"\d", "N", "1234", count=2) # 'NN34'

5. 命名组 + sub

1
2
3
# 交换年月
re.sub(r"(?P<y>\d{4})-(?P<m>\d{2})", r"\g<m>/\g<y>", "2025-09")
# '09/2025'

六、re 的 flags

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
re.IGNORECASE / re.I     # 忽略大小写
re.MULTILINE / re.M # ^ 和 $ 匹配每行的开头结尾
re.DOTALL / re.S # . 匹配换行
re.VERBOSE / re.X # 允许写多行 + 注释

# 多个 flag 用 | 组合
re.findall(r"hello", "Hello", re.I) # ['Hello']

# 写多行正则
pattern = re.compile(r"""
(\d{4}) # 年
-
(\d{2}) # 月
-
(\d{2}) # 日
""", re.VERBOSE)

VERBOSE 特别适合复杂正则,别忘了实际字符要转义或用字符类包起来(否则空格会被忽略)。

七、常见误区

1. 贪婪陷阱

1
2
re.findall(r"<.+>", "<h1>hello</h1>")   # ['<h1>hello</h1>']  整个吃掉
re.findall(r"<.+?>", "<h1>hello</h1>") # ['<h1>', '</h1>'] 非贪婪

2. \d 只匹配 ASCII 数字

Python 的 \d 默认也匹配 Unicode 数字(如中文数字”零”),如果只想 ASCII:

1
2
3
re.findall(r"\d", "1 二 3")            # ['1', '3']
re.findall(r"\d", "1 二 3", re.ASCII) # ['1', '3']
re.findall(r"[0-9]", "1 二 3") # ['1', '3'] 更明确

3. 中文匹配

Unicode 中文范围:[一-龥](常用汉字):

1
2
re.findall(r"[一-龥]+", "hi 咖飞 abc 学 Python")
# ['咖飞', '学']

4. HTML 不该用正则

HTML 嵌套结构复杂,用正则容易翻车。用 BeautifulSoup / lxml。

八、re 的性能

  • 编译一次的正则比字符串正则快(避免重复解析);
  • 大字符串上跑贪婪匹配可能会指数级慢(catastrophic backtracking);
  • Python 的 re 是回溯引擎;性能极端场景考虑 regex 第三方库(支持 possessive quantifier)。

注意re 内部有 LRU 缓存,即使不 compile 也会缓存约 100 条,但显式 compile 更明确。

九、Walrus + regex:简洁写法

1
2
3
4
if m := re.match(r"(\d+)-(\d+)", s):
print(m.group(1), m.group(2))
else:
print("不匹配")

海象运算符让”判断 + 取值”合并,比传统写法短。

十、几个日常必备正则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 去除多余空白
re.sub(r"\s+", " ", text).strip()

# 只保留字母数字
re.sub(r"[^a-zA-Z0-9]", "", text)

# 只保留中文
re.sub(r"[^一-龥]", "", text)

# 驼峰转下划线
re.sub(r"([A-Z])", r"_\1", "UserProfileID").lower().lstrip("_")
# 'user_profile_i_d' 最后一个 D 也被分开,业务上一般够用

# 更精细的驼峰
re.sub(r"(?<!^)(?=[A-Z])", "_", "UserProfileID").lower()
# 'user_profile_i_d'

十一、一个完整例子:日志敏感信息脱敏

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
# masker.py
"""对日志行做敏感信息脱敏。"""

import re

PATTERNS = [
# 手机号
(re.compile(r"1[3-9]\d{9}"),
lambda m: m.group()[:3] + "****" + m.group()[-4:]),

# 邮箱:xxx@yyy → x***@yyy
(re.compile(r"([\w.+-])[\w.+-]*(@[\w.-]+)"),
r"\1***\2"),

# 身份证 18 位
(re.compile(r"\d{6}(\d{8})\d{3}[\dX]"),
lambda m: m.group()[:6] + "********" + m.group()[-4:]),

# 银行卡(简化:连续 16-19 位数字)
(re.compile(r"\b\d{16,19}\b"),
lambda m: m.group()[:4] + " **** **** " + m.group()[-4:]),
]


def mask(line: str) -> str:
for pattern, repl in PATTERNS:
line = pattern.sub(repl, line)
return line


if __name__ == "__main__":
tests = [
"user phone 13812345678 login",
"email kafei.gk@example.com",
"id: 410102199001011234",
"card 6222 0212 3456 7890",
]
for t in tests:
print(mask(t))

十二、常见陷阱

  1. 忘 raw string"\d" 会有告警,写 r"\d"
  2. matchsearch 混淆:match 只匹配开头。
  3. findall 有捕获组时返回不同
    • 无捕获组:返回整体匹配字符串列表;
    • 一个捕获组:返回捕获内容列表;
    • 多个捕获组:返回元组列表。
    1
    2
    3
    re.findall(r"\d+", "a1b2")            # ['1', '2']
    re.findall(r"(\d+)", "a1b2") # ['1', '2']
    re.findall(r"(\d+)(\w)", "a1b2c") # [('1', 'b'), ('2', 'c')]
  4. 贪婪吃太多:加 ? 变非贪婪。
  5. catastrophic backtracking:类似 (a+)+ 遇到长字符串会几秒钟卡死。
  6. 在替换里用 \1 但字符串没加 r"\1" 会被解释成 \x01。用 raw string。

十三、小结与延伸阅读

  • 五大 API:matchsearchfindallsubsplit
  • 永远用 r"..."
  • 分组用 (...),命名组 (?P<n>...)
  • flags:IMSX
  • 贪婪 +、非贪婪 +?
  • HTML 别用正则,用专门的解析库;
  • 复杂正则用 re.compile + VERBOSE
  • 海象运算符 + re.match 是简洁的匹配-取值合体。

延伸阅读:

下一篇 日期时间 datetime 模块 我们讲怎么优雅地处理时间。

装饰器进阶:带参数装饰器与类装饰器

前面 装饰器入门 讲了装饰器的基本形式:”接受函数,返回函数”。真实项目里的装饰器还有几种进阶形态:带参数装饰器(如 @retry(times=3))、类装饰器(用类作为装饰器)、装饰整个类保留 signature 的装饰器(用 functools.wraps+inspect)。这一篇把它们讲清。

一、带参数装饰器:三层嵌套

前面提过,@retry(times=3) 的实现结构:

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
import functools

def retry(times=3, delay=1):
"""外层:接收装饰器参数,返回真正的装饰器。"""
def decorator(func):
"""真正的装饰器:接收函数,返回包装。"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
"""包装:真正执行的地方。"""
for i in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
if i == times - 1:
raise
print(f"[{i+1}] 失败:{e}")
import time; time.sleep(delay)
return wrapper
return decorator


@retry(times=3, delay=0.5)
def flaky():
import random
if random.random() < 0.7:
raise RuntimeError("挂了")
return "OK"

核心思想

  • retry(3, 0.5) 先被调用——返回 decorator;
  • decorator 再作为装饰器包装 flaky——返回 wrapper。

@retry(times=3, delay=0.5) = retry(3, 0.5)(flaky) = decorator(flaky)。

二、装饰器同时支持”带参数”和”不带参数”

想让 @log@log(level="INFO") 都行?看起来矛盾——一个是接收函数,一个是接收参数。技巧:

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
import functools

def log(_func=None, *, level="INFO"):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] 调用 {func.__name__}")
return func(*args, **kwargs)
return wrapper

if _func is None:
# 用 @log(level=...) 形式:只传关键字参数,_func 是 None
return decorator
# 用 @log 形式:_func 是被装饰的函数
return decorator(_func)


@log
def a():
print("a")


@log(level="DEBUG")
def b():
print("b")


a() # [INFO] 调用 a → a
b() # [DEBUG] 调用 b → b

规则:把装饰器参数改为仅关键字参数(用 *),第一个位置参数留给”可能的被装饰函数”。

三、类作为装饰器

用类做装饰器就是”实现 __call__ 的对象”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func) # 类似 wraps
self.func = func
self.count = 0

def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)


@CountCalls
def greet(name):
print(f"你好,{name}")


greet("咖飞")
greet("小王")
print(greet.count) # 2

好处:可以给”包装器”挂状态(比如计数)而不用闭包。

带参数的类装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Retry:
def __init__(self, times=3):
self.times = times

def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(self.times):
try:
return func(*args, **kwargs)
except Exception:
pass
raise
return wrapper


@Retry(times=5)
def foo():
...

四、装饰整个类

装饰器不仅能装函数,还能装类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def add_repr(cls):
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls


@add_repr
class Point:
def __init__(self, x, y):
self.x, self.y = x, y


print(Point(3, 4)) # Point(x=3, y=4)

Python 内置的 @dataclass 就是最经典的”装饰类”——自动生成 __init____repr____eq__

五、functools.lru_cache 与 cache

标准库里的一大明星装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from functools import lru_cache, cache

@lru_cache(maxsize=128)
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)


# Python 3.9+ 无界版
@cache
def fib(n): ...


fib(50) # 秒出,不用 lru_cache 要算好久
fib.cache_info() # CacheInfo(hits=X, misses=Y, maxsize=128, currsize=Z)
fib.cache_clear() # 清缓存

要求:参数必须可哈希——不能传 list/dict/set。

六、方法装饰器

装饰类的方法要注意 self——用 *args 通吃:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] {func.__name__} 被调")
return func(*args, **kwargs)
return wrapper


class Service:
@logged
def run(self):
print("running")


Service().run()

*args 会包含 self

七、functools.wraps 究竟做了什么

1
2
3
4
5
6
7
import functools

def deco(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper

@functools.wraps(func) 相当于把 wrapper 的:

  • __name__ 设为 func.__name__
  • __doc__ 设为 func.__doc__
  • __module____qualname____wrapped__ 等;
  • __dict__ 更新。

好处:调试器、日志、Sphinx 文档、类型检查都能看到原函数信息。

八、保留 signature

标准 wraps 保留元信息,但 signature(参数列表)不保留——如果你想让 IDE 补全参数:

1
2
3
4
5
6
7
8
9
10
import functools
import inspect

def keep_signature(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# 手动设 signature
wrapper.__signature__ = inspect.signature(func)
return wrapper

实际项目里 functools.wraps 已经够用,除非你用非常严格的类型系统。

九、装饰器的”参数被吃掉”陷阱

看这段:

1
2
3
4
5
6
7
8
9
10
11
12
13
def deco(func):
def wrapper(x, y):
return func(x + 1, y + 1)
return wrapper


@deco
def add(x, y, z=10):
return x + y + z


add(1, 2) # 5
add(1, 2, z=100) # ❌ wrapper 不接受 z

修复:wrapper 用 *args, **kwargs 通吃:

1
2
3
4
5
def deco(func):
def wrapper(*args, **kwargs):
# 处理 args/kwargs
return func(*args, **kwargs)
return wrapper

十、多个装饰器叠加

1
2
3
4
@a
@b
@c
def f(): ...

等价 f = a(b(c(f)))。执行时 c 最先运行,a 最外层。

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
def log(func):
@functools.wraps(func)
def w(*args, **kw):
print(f"log begin {func.__name__}")
r = func(*args, **kw)
print(f"log end {func.__name__}")
return r
return w

def timed(func):
@functools.wraps(func)
def w(*args, **kw):
print(f"timer start")
r = func(*args, **kw)
print(f"timer end")
return r
return w


@log
@timed
def work():
print("working")


work()
# log begin work
# timer start
# working
# timer end
# log end work

十一、类方法装饰器:classmethod/staticmethod/property

前面 类方法、静态方法与 property 讲过。这里补充:这些也是装饰器——只是内置的。你可以自己写”内置装饰器风格”的东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class register:
"""把方法注册到某个字典。"""
_registry = {}

def __init__(self, name):
self.name = name

def __call__(self, func):
type(self)._registry[self.name] = func
return func


@register("add")
def do_add(a, b):
return a + b


@register("mul")
def do_mul(a, b):
return a * b


print(register._registry["add"](3, 4)) # 7
print(register._registry["mul"](3, 4)) # 12

十二、常见业务装饰器

权限校验

1
2
3
4
5
6
7
8
9
def require_role(role):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if role not in current_user["roles"]:
raise PermissionError()
return func(*args, **kwargs)
return wrapper
return decorator

参数校验

1
2
3
4
5
6
7
8
def validate_positive(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for a in args:
if isinstance(a, (int, float)) and a < 0:
raise ValueError(f"参数不能为负:{a}")
return func(*args, **kwargs)
return wrapper

结果缓存(带 TTL)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import time
from functools import wraps

def ttl_cache(ttl=60):
def decorator(func):
cache = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.time()
if key in cache:
val, ts = cache[key]
if now - ts < ttl:
return val
val = func(*args, **kwargs)
cache[key] = (val, now)
return val
return wrapper
return decorator

单例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def singleton(cls):
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance


@singleton
class Config:
pass


Config() is Config() # True

十三、一个完整例子:API 端点装饰器

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
# webapi.py
"""一个 mini 版的 Flask-like 路由装饰器。"""

import functools
from typing import Callable


class App:
def __init__(self):
self.routes: dict[tuple[str, str], Callable] = {}

def route(self, path: str, method: str = "GET"):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[{method}] {path}")
return func(*args, **kwargs)
self.routes[(method, path)] = wrapper
return wrapper
return decorator

def dispatch(self, method: str, path: str):
handler = self.routes.get((method, path))
if handler is None:
return 404, "Not Found"
return 200, handler()


app = App()


@app.route("/")
def home():
return "首页"


@app.route("/hello", method="POST")
def hello():
return "hi 咖飞"


if __name__ == "__main__":
print(app.dispatch("GET", "/"))
print(app.dispatch("POST", "/hello"))
print(app.dispatch("GET", "/miss"))

十四、常见陷阱

  1. @functools.wrapsfunc.__name__、docstring 丢失。
  2. 参数装饰器少一层嵌套@retry(3) 报错,因为 retry 直接被当装饰器。三层嵌套是标配。
  3. wrapper 不用 *args, **kwargs:签名不匹配时报错。
  4. 类装饰器忘 update_wrapper__name__ 变成类名而不是函数名。
  5. @lru_cache 参数是 list/dict:TypeError。要 tuple 化。
  6. 循环里 def deco:会捕获循环变量。改用默认参数或 partial。
  7. 装饰类时想改 __init__:改要小心,可能破坏原类。

十五、小结与延伸阅读

  • 带参数装饰器 = 三层嵌套(装饰器工厂 → 装饰器 → wrapper);
  • 类装饰器 = 实现 __call__ 的类;
  • 装饰器也能装类;
  • *args, **kwargs 让 wrapper 通吃参数;
  • 混合 “@deco / @deco(…)” 用 _func=None + 关键字参数技巧;
  • functools.lru_cache/cache 是最常用的内置装饰器;
  • 记得 functools.wraps 保留元信息。

延伸阅读:

下一篇 正则表达式 re 模块实战 我们讲字符串处理的终极武器——正则。

生成器 yield 深入

上一篇 迭代器与可迭代对象 讲了迭代器协议,也提到”写迭代器最优雅的方式是生成器”。这一篇要把 yield、生成器函数、生成器表达式、yield from、协程用法一次讲透。学完你会发现:许多”看起来要一大堆循环、列表”的问题,用生成器一句话就能解决。

一、什么是生成器

只要函数里有 yield,它就是生成器函数——调用它不会立刻执行函数体,而是返回一个生成器对象(一种迭代器)。

1
2
3
4
5
6
7
8
9
10
11
12
def count_down(n):
while n > 0:
yield n
n -= 1

g = count_down(3)
print(g) # <generator object count_down at ...>

print(next(g)) # 3
print(next(g)) # 2
print(next(g)) # 1
print(next(g)) # StopIteration

或者直接 for 循环:

1
2
for x in count_down(3):
print(x) # 3 2 1

二、yield 与 return

  • return 结束函数,返回一个值;
  • yield 暂停函数,产出一个值,下次调用 next 时从暂停处继续。

关键:生成器函数的局部状态在 yield 之间保留——变量、循环、if 判断都保持原样。

1
2
3
4
5
6
7
8
9
10
11
12
def demo():
print("A")
yield 1
print("B")
yield 2
print("C")


g = demo()
print(next(g)) # A → yield 1
print(next(g)) # B → yield 2
print(next(g)) # C → StopIteration

三、生成器表达式

前面 列表推导式与生成表达式 讲过:

1
2
3
4
5
6
7
8
# 列表推导式:立即算出全部
squares_list = [x*x for x in range(10)]

# 生成表达式:惰性
squares_gen = (x*x for x in range(10))

print(next(squares_gen)) # 0
print(next(squares_gen)) # 1

生成表达式内存友好,处理大数据首选。

作为函数唯一参数时括号可省:

1
2
sum(x*x for x in range(1_000_000))   # 内存 O(1)
max(u.age for u in users)

四、生成器的核心价值:惰性

一次只产生一个值 → 内存友好 + 早退可能。

1
2
3
4
5
6
7
8
9
10
11
def read_large_file(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip()


# 处理一个 100GB 的日志文件
for line in read_large_file("huge.log"):
if "ERROR" in line:
print(line)
# 想早退随时 break

不用一次读进内存,也不用先造一个大列表。

五、yield from:委托给另一个生成器

1
2
3
4
5
6
7
8
9
10
11
def sub():
yield 1
yield 2

def main():
yield 0
yield from sub() # 委托:把 sub 产出的一次性 yield 出去
yield 3


list(main()) # [0, 1, 2, 3]

yield from x 等价于:

1
2
for v in x:
yield v

功能远超简单循环——它还转发 sendthrow、返回值。用来”拆分复杂生成器”、”扁平化嵌套”非常好用。

例子:扁平化任意深度嵌套

1
2
3
4
5
6
7
8
9
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item) # 递归 yield
else:
yield item


list(flatten([1, [2, [3, [4, 5]]], 6])) # [1, 2, 3, 4, 5, 6]

六、生成器 = 惰性数据管道

生成器可以像 Unix 管道一样串联:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line

def filter_errors(lines):
for line in lines:
if "ERROR" in line:
yield line

def parse(lines):
for line in lines:
parts = line.split()
yield {"time": parts[0], "msg": " ".join(parts[1:])}


# 链起来
pipeline = parse(filter_errors(read_lines("huge.log")))
for event in pipeline:
print(event)

每一步都不占额外内存——线性时间、常数空间。这是生成器的杀手锏。

七、生成器可以 send 数据回去(协程)

yield 不只是”产出”,还能”接收”外部传入的数据:

1
2
3
4
5
6
7
8
9
10
def echo():
while True:
received = yield
print("收到:", received)


g = echo()
next(g) # 启动:跑到第一个 yield 处停下
g.send("hi") # 收到:hi
g.send("hello") # 收到:hello

规则

  • next(g)g.send(None) 让生成器跑到下一个 yield;
  • g.send(v) 把 v 塞给最近一次 yield 的返回值;
  • 第一次一定要用 next(g) 启动(或 g.send(None))。

这就是协程的基础。不过现代 Python 里协程一般用 async/await(见 异步编程 asyncio 入门),yield 协程用得少了。

八、生成器可以有 return

return 让生成器结束,返回值放在 StopIteration 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
def gen():
yield 1
yield 2
return "done"


g = gen()
next(g) # 1
next(g) # 2
try:
next(g) # StopIteration
except StopIteration as e:
print(e.value) # 'done'

配合 yield from 可以把这个值传出去:

1
2
3
4
5
6
def outer():
result = yield from gen()
print(f"内层返回:{result}")


list(outer()) # [1, 2] + 打印"内层返回:done"

九、close 与 throw

1
2
3
4
5
g = count_down(10)
next(g)
g.close() # 关闭生成器,触发 GeneratorExit

g.throw(ValueError, "错") # 在生成器内部抛异常

日常业务代码用得少,主要在写协程/异步框架时才用。

十、生成器 vs 迭代器

  • 写法:生成器(yield)比手写 __iter__ / __next__ 简洁一个数量级;
  • 性能:几乎等同;
  • 能力:完全一样。

能用生成器就别写迭代器类

十一、性能与陷阱

一次性

跟迭代器一样,生成器只能遍历一次:

1
2
3
g = (x*x for x in range(5))
list(g) # [0, 1, 4, 9, 16]
list(g) # []

不能索引

1
2
g = (x for x in range(5))
g[0] # ❌ TypeError

要索引先 list(g)

不能 len

1
len((x for x in range(5)))    # ❌

要长度先 list(g) 或自己累加计数。

十二、几个必会的生成器模式

无限序列

1
2
3
4
5
6
7
8
9
def naturals():
n = 1
while True:
yield n
n += 1


from itertools import islice
list(islice(naturals(), 10)) # [1..10]

分块

1
2
3
4
5
6
7
8
9
from itertools import islice

def chunks(seq, n):
it = iter(seq)
while chunk := list(islice(it, n)):
yield chunk


list(chunks(range(10), 3)) # [[0,1,2], [3,4,5], [6,7,8], [9]]

滑动窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from collections import deque

def window(seq, size):
it = iter(seq)
buf = deque(islice(it, size), maxlen=size)
if len(buf) < size:
return
yield tuple(buf)
for x in it:
buf.append(x)
yield tuple(buf)


list(window([1, 2, 3, 4, 5], 3))
# [(1, 2, 3), (2, 3, 4), (3, 4, 5)]

去重(保序)

1
2
3
4
5
6
7
8
9
def dedupe(seq):
seen = set()
for x in seq:
if x not in seen:
seen.add(x)
yield x


list(dedupe([1, 2, 2, 3, 1, 4])) # [1, 2, 3, 4]

十三、一个完整例子:文件按事件切分

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
# events.py
"""从日志流里按'空行分隔'的规则切分事件。"""


def read_events(lines):
"""lines 是行迭代器;产出一批批事件(一批 = 空行前的所有行)。"""
buffer = []
for line in lines:
line = line.rstrip()
if not line:
if buffer:
yield buffer
buffer = []
else:
buffer.append(line)
if buffer:
yield buffer


def enrich(events):
"""给每个事件加上一个 id。"""
for i, ev in enumerate(events, 1):
yield {"id": i, "lines": ev}


def keep_errors(events):
"""只保留含 ERROR 关键字的事件。"""
for ev in events:
if any("ERROR" in ln for ln in ev["lines"]):
yield ev


if __name__ == "__main__":
sample = [
"INFO start",
"INFO loading",
"",
"ERROR db failure",
"INFO retry",
"",
"INFO shutdown",
]
pipeline = keep_errors(enrich(read_events(iter(sample))))
for e in pipeline:
print(e)
# {'id': 2, 'lines': ['ERROR db failure', 'INFO retry']}

这是”生成器管道”的经典案例——三个函数各司其职、惰性组合。

十四、常见陷阱

  1. 生成器只走一次:想反复用要 list()
  2. yield 与 return 混用要小心:return 的值只出现在 StopIteration 里,普通调用者看不到。
  3. 生成器内部异常没处理:整个生成器就结束了,后面 yield 不会执行。
  4. 忘了消费gen() 单纯调用不会执行内部代码——要 next(gen()) 或 for 循环才走。
  5. 两个生成器共享同一个数据源:迭代第一个会把源用掉,第二个空。
  6. 无限生成器忘 break/islice:程序卡死。

十五、小结与延伸阅读

  • yield 让函数变生成器,返回可迭代的生成器对象;
  • 生成器是惰性的——一次产出一个值;
  • 生成器表达式 (x for x in ...)
  • yield from x 委托,扁平嵌套生成器;
  • 生成器可以 return 值,藏在 StopIteration.value;
  • 生成器 + 管道 = 高效数据流处理;
  • 生成器只走一次;
  • 现代协程用 async/await,不用 yield-based。

延伸阅读:

下一篇 装饰器进阶:带参数装饰器与类装饰器 我们把装饰器讲到更高阶。

迭代器与可迭代对象

for x in obj: 背后到底发生了什么?为什么列表、字典、字符串、文件、range 都能这么用?答案就是 Python 的迭代协议——__iter____next__。理解迭代器,你就能写出能被 for 循环、内置函数(sum/max/any 等)无缝使用的自定义类,也能明白生成器(下一篇)为什么那么强大。

一、可迭代对象 vs 迭代器

两个概念要分清:

  • 可迭代对象(Iterable):能被 for 用的东西,有 __iter__ 方法;
  • 迭代器(Iterator):真正”产生下一个值”的对象,有 __next__ 方法(同时也有 __iter__)。

关系:每个迭代器都是可迭代对象,反过来不成立

举例:

  • 列表 [1, 2, 3] 是可迭代对象,但不是迭代器;
  • iter([1, 2, 3]) 返回一个迭代器。
1
2
3
4
5
6
7
lst = [1, 2, 3]
it = iter(lst) # 拿到迭代器

print(next(it)) # 1
print(next(it)) # 2
print(next(it)) # 3
print(next(it)) # StopIteration

二、for 循环的底层

看这段代码:

1
2
for x in [10, 20, 30]:
print(x)

Python 实际做的是:

1
2
3
4
5
6
7
_iter = iter([10, 20, 30])     # 拿迭代器
while True:
try:
x = next(_iter) # 取下一个
except StopIteration:
break
print(x)

任何实现了 __iter__ 的对象都能被 for 循环。这就是”鸭子协议”在 Python 里的强大之处。

三、自定义可迭代对象

方式 1:让类同时是迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Counter:
def __init__(self, start, stop):
self.i = start
self.stop = stop

def __iter__(self):
return self # 自己就是迭代器

def __next__(self):
if self.i >= self.stop:
raise StopIteration
v = self.i
self.i += 1
return v


for x in Counter(1, 5):
print(x)
# 1 2 3 4

问题:这个 Counter 只能迭代一次——self.i 变了就回不去。

1
2
3
c = Counter(1, 5)
list(c) # [1, 2, 3, 4]
list(c) # [] 已经走完了!

方式 2:可迭代对象 + 独立迭代器(推荐):

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
class Range:
def __init__(self, start, stop):
self.start, self.stop = start, stop

def __iter__(self):
return RangeIter(self.start, self.stop) # 每次返回新迭代器


class RangeIter:
def __init__(self, start, stop):
self.i, self.stop = start, stop

def __iter__(self):
return self

def __next__(self):
if self.i >= self.stop:
raise StopIteration
v = self.i
self.i += 1
return v


r = Range(1, 5)
list(r) # [1, 2, 3, 4]
list(r) # [1, 2, 3, 4] 可以反复遍历

内置的 liststrdict 都是这种”可迭代对象 + 独立迭代器”的设计。

四、更简单:用 yield(预告)

写迭代器最优雅的方式是生成器函数

1
2
3
4
5
6
7
8
9
def counter(start, stop):
while start < stop:
yield start
start += 1


for x in counter(1, 5):
print(x)
# 1 2 3 4

比手写 __iter__ / __next__ 简单太多。生成器我们下一篇 生成器 yield 深入 详细讲。

五、常见的可迭代对象

  • 序列:list, tuple, str, bytes, range;
  • 集合类:set, frozenset;
  • 映射:dict(默认迭代 key),dict.values(), dict.items();
  • 文件对象:每次迭代产生一行;
  • 迭代器/生成器:自身就可迭代;
  • map/filter/zip/enumerate:都返回迭代器;
  • NumPy array、pandas DataFrame:可迭代。

六、iter 的另一种用法

iter(callable, sentinel) 反复调用 callable,直到返回 sentinel:

1
2
3
4
# 读文件直到遇到空行
with open("data.txt") as f:
for line in iter(f.readline, ""):
print(line.rstrip())

场景:读 socket、循环读输入等。

七、”惰性求值”的威力

迭代器最大的价值是惰性——一次只产生一个元素:

1
2
3
4
5
# 内存占 8 * 10^9 = 8GB
sum([x*x for x in range(10**9)]) # ❌ OOM

# 内存 O(1)
sum(x*x for x in range(10**9)) # ✅ 生成表达式,慢但能跑完

rangemapfilterzipenumerateopen 全都是惰性的。

八、迭代器只能走一次

1
2
3
it = iter([1, 2, 3])
list(it) # [1, 2, 3]
list(it) # [] 空了

想反复遍历,要么保留原始可迭代对象,要么每次 iter(source) 造新迭代器。

九、itertools:迭代器工具箱

标准库 itertools 提供了一堆迭代器工具(详细见 itertools 与 functools):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from itertools import count, cycle, chain, islice, repeat

# 无限数字
for i in islice(count(10), 5):
print(i)
# 10 11 12 13 14

# 无限循环
for x in islice(cycle([1, 2, 3]), 8):
print(x)
# 1 2 3 1 2 3 1 2

# 拼接多个序列
list(chain([1, 2], [3, 4], [5])) # [1, 2, 3, 4, 5]

# 截取
list(islice(range(100), 10, 20)) # [10..19]

十、检查可迭代性

1
2
3
4
5
from collections.abc import Iterable, Iterator

isinstance([1, 2], Iterable) # True
isinstance([1, 2], Iterator) # False
isinstance(iter([1, 2]), Iterator) # True

十一、enumerate / zip 用法回顾

1
2
3
4
5
6
7
8
9
10
11
# enumerate 加索引
for i, x in enumerate(items, start=1):
print(f"{i}. {x}")

# zip 并行迭代
for a, b in zip(names, ages):
print(a, b)

# zip 长度不等:以最短为准,严格模式
for a, b in zip(names, ages, strict=True): # 3.10+
...

十二、迭代器的组合

1
2
3
4
5
6
7
8
9
# 找第一个满足条件的
def first(pred, iterable, default=None):
for x in iterable:
if pred(x):
return x
return default

# 或者用 next + 生成表达式
first_neg = next((x for x in nums if x < 0), None)

十三、一个完整例子:分块迭代器

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
66
67
68
69
# chunker.py
"""把一个可迭代对象按 n 个一组切分。"""

from itertools import islice
from collections.abc import Iterator, Iterable


def chunked(iterable: Iterable, size: int) -> Iterator[list]:
"""惰性地把 iterable 按 size 分组。"""
it = iter(iterable)
while True:
chunk = list(islice(it, size))
if not chunk:
break
yield chunk


def paginate(items: Iterable, page_size: int, page: int) -> list:
"""获取第 page 页(从 1 开始)。"""
it = iter(items)
# 跳过前 (page-1) * page_size 个
for _ in range((page - 1) * page_size):
next(it, None)
return list(islice(it, page_size))


class PeekableIterator:
"""支持 peek 的迭代器包装。"""

_SENTINEL = object()

def __init__(self, source):
self._it = iter(source)
self._cache = self._SENTINEL

def __iter__(self):
return self

def peek(self, default=None):
if self._cache is self._SENTINEL:
try:
self._cache = next(self._it)
except StopIteration:
return default
return self._cache

def __next__(self):
if self._cache is not self._SENTINEL:
v = self._cache
self._cache = self._SENTINEL
return v
return next(self._it)


if __name__ == "__main__":
# 分块
for chunk in chunked(range(11), 3):
print(chunk)
# [0, 1, 2] [3, 4, 5] [6, 7, 8] [9, 10]

# 分页
print(paginate(range(20), page_size=5, page=3)) # [10, 11, 12, 13, 14]

# peek
p = PeekableIterator([1, 2, 3])
print(p.peek()) # 1
print(p.peek()) # 1(没消耗)
print(next(p)) # 1
print(next(p)) # 2

十四、可迭代对象拆包

用星号解包:

1
2
first, *rest = range(5)         # first=0, rest=[1, 2, 3, 4]
*init, last = "abcde" # init=['a', 'b', 'c', 'd'], last='e'

这里的 rest 类型是 list,即使右侧是任意可迭代对象。

十五、常见陷阱

  1. 迭代器只能走一次:想反复用要保存原始序列。
  2. 在遍历中修改可迭代对象:list 中断、set/dict 抛 RuntimeError。
  3. raise StopIteration:手写迭代器无限循环。
  4. __iter__ 直接 return self(但状态不重置):多次遍历只成功一次。
  5. for x in it: ...for x in it: ...:第二个循环什么都不出,因为 it 已耗尽。
  6. 把生成器传给 len():TypeError,先 list() 转。

十六、小结与延伸阅读

  • 可迭代对象有 __iter__,迭代器有 __next__
  • for 循环调 iter() + 循环 next() + 捕 StopIteration;
  • 自定义迭代器优先用生成器函数;
  • 迭代器是惰性的,处理大数据首选;
  • iter(callable, sentinel) 有奇效;
  • 迭代器只能走一次;
  • itertools 是迭代器工具库。

延伸阅读:

下一篇 生成器 yield 深入 我们讲写迭代器的最优雅方式——yield。

with 语句与上下文管理器

with open(...) as f: 是每个 Python 学习者的必经之路。但 with 的用途远不止”打开文件”——数据库连接、锁、临时切换目录、性能计时……几乎所有”需要成对进入/退出”的场景都能用它。这一篇讲清楚 with 背后的上下文管理器协议__enter__ / __exit__),以及现代 Python 提供的 contextlib 工具箱。

一、with 是什么

with 语句保证”进入时执行一段代码、退出时(无论正常还是异常)执行另一段代码”。最经典的用途是自动关闭文件:

1
2
3
with open("data.txt", encoding="utf-8") as f:
data = f.read()
# 到这里 f 已经自动关闭了

等价于:

1
2
3
4
5
f = open("data.txt", encoding="utf-8")
try:
data = f.read()
finally:
f.close()

with 更简洁、更不容易漏掉 close

二、上下文管理器协议

任何实现了 __enter____exit__ 方法的对象,都是上下文管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self # 会赋给 as 后的名字

def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.end = time.perf_counter()
print(f"耗时 {self.end - self.start:.4f}s")
# 返回 True 会吞掉异常,返回 False/None 让异常继续传播

with Timer():
sum(i * i for i in range(1_000_000))
# 耗时 0.0234s

关键点

  • __enter__(self) 返回的对象会被赋给 as 后的变量;
  • __exit__(self, exc_type, exc_val, exc_tb):即使 with 块内抛异常也会被调用;
  • __exit__ 的返回值:True 表示”我处理了,别再抛”,其他值让异常继续传播。

三、exit 的三个参数

1
2
def __exit__(self, exc_type, exc_val, exc_tb):
...
  • exc_type:异常类型(如 ValueError),没异常时为 None
  • exc_val:异常实例;
  • exc_tb:traceback 对象。

利用它可以做异常翻译

1
2
3
4
5
6
7
8
9
class SafeAPI:
def __enter__(self):
return self

def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is ConnectionError:
print(f"连接问题:{exc_val},已重试")
return True # 吞掉
return False # 其他异常继续抛

别滥用”吞异常”——除非你真的知道自己在处理什么。

四、多个上下文管理器

1
2
3
4
5
6
7
8
9
10
with open("a.txt") as a, open("b.txt") as b:
...

# 括号跨行(Python 3.10+)
with (
open("a.txt") as a,
open("b.txt") as b,
open("c.txt") as c,
):
...

打开顺序从左到右,关闭顺序从右到左(栈式)。

五、contextlib.contextmanager:用函数写上下文管理器

写类太麻烦,@contextmanager 让你用生成器函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from contextlib import contextmanager
import time

@contextmanager
def timer():
start = time.perf_counter()
try:
yield # yield 之前是 __enter__,之后是 __exit__
finally:
elapsed = time.perf_counter() - start
print(f"耗时 {elapsed:.4f}s")


with timer():
sum(i*i for i in range(1_000_000))

规则

  • yield 之前的代码 = __enter__ 里做的事;
  • yield 的值会赋给 as 后的名字;
  • yield 之后的代码 = __exit__ 里做的事;
  • try/finally 保证退出代码一定执行。

带值:

1
2
3
4
5
6
7
8
9
10
11
@contextmanager
def open_db(uri):
conn = connect(uri)
try:
yield conn # 会赋给 `with ... as conn`
finally:
conn.close()


with open_db("sqlite:///data.db") as conn:
conn.execute("SELECT ...")

处理 with 块内的异常

1
2
3
4
5
6
7
@contextmanager
def catch_and_log(logger):
try:
yield
except Exception:
logger.exception("with 块内出错")
raise

六、常用的 contextlib 工具

suppress:忽略特定异常

1
2
3
4
5
6
7
8
9
10
from contextlib import suppress

with suppress(FileNotFoundError):
Path("maybe.txt").unlink()

# 等价于
try:
Path("maybe.txt").unlink()
except FileNotFoundError:
pass

redirect_stdout / redirect_stderr

1
2
3
4
5
6
7
8
9
from contextlib import redirect_stdout
import io

buf = io.StringIO()
with redirect_stdout(buf):
print("这段话不会显示在控制台")
print("而是被捕获到 buf 里")

print("捕获到:", buf.getvalue())

nullcontext:占位

有时候某些代码路径需要”什么都不做”的上下文:

1
2
3
4
5
from contextlib import nullcontext

ctx = open("data.txt") if need_file else nullcontext()
with ctx as f:
...

ExitStack:动态数量的 with

不知道要打开多少个上下文时:

1
2
3
4
5
from contextlib import ExitStack

with ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths]
# files 里所有文件会在退出时依次关闭

七、几个经典的上下文管理器场景

1. 临时切换工作目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from contextlib import contextmanager
import os
from pathlib import Path

@contextmanager
def cd(path):
old = Path.cwd()
os.chdir(path)
try:
yield
finally:
os.chdir(old)


with cd("/tmp"):
# 这段代码在 /tmp 里运行
print(Path.cwd())
# 出来后回到原目录

2. 数据库事务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@contextmanager
def transaction(conn):
try:
yield conn
except Exception:
conn.rollback()
raise
else:
conn.commit()


with transaction(conn) as tx:
tx.execute("...")
tx.execute("...")

3. 加锁

1
2
3
4
5
6
7
import threading

lock = threading.Lock()

with lock:
# 独占访问共享资源
critical_section()

threading.Lock 已经实现了上下文管理器协议,直接用。

4. 环境变量临时替换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@contextmanager
def env_var(name, value):
old = os.environ.get(name)
os.environ[name] = value
try:
yield
finally:
if old is None:
os.environ.pop(name, None)
else:
os.environ[name] = old


with env_var("DEBUG", "1"):
run_test()
# 出来后 DEBUG 恢复原状

5. 计时(性能测试)

1
2
3
4
5
6
7
8
9
@contextmanager
def timer(label=""):
start = time.perf_counter()
yield
print(f"{label} 用时 {time.perf_counter() - start:.4f}s")


with timer("解析"):
parse_big_file()

八、异步上下文管理器 async with

Python 3.5+ 支持异步:

1
2
3
4
5
import aiohttp

async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
data = await resp.text()

对应的协议是 __aenter____aexit__。写异步版:

1
2
3
4
5
6
7
8
9
10
11
12
13
from contextlib import asynccontextmanager

@asynccontextmanager
async def open_ws(url):
ws = await connect_ws(url)
try:
yield ws
finally:
await ws.close()


async with open_ws("wss://...") as ws:
...

九、类实现 vs 函数实现

场景 选择
只包一段简单逻辑,一次进/出 @contextmanager 函数
需要复杂状态、多个方法 类(__enter__/__exit__
想支持 stack.enter_context(x)

大部分场景 @contextmanager 更简洁。

十、__enter__ 可以返回任意值

1
2
3
4
5
6
7
8
9
10
class Session:
def __enter__(self):
return {"user": "咖飞", "start": time.time()}

def __exit__(self, *args):
pass


with Session() as info:
print(info["user"])

as 后面的名字接收 __enter__ 的返回值,不必是 self。

十一、一个完整例子:文件锁

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
# filelock.py
"""进程级文件锁(简化版,跨平台请用 filelock 库)。"""

import time
from pathlib import Path
from contextlib import contextmanager


@contextmanager
def file_lock(path: Path, timeout: float = 10):
"""基于目录存在与否的粗粒度锁。"""
lock_dir = Path(f"{path}.lock")
deadline = time.time() + timeout

while True:
try:
lock_dir.mkdir() # 目录不存在时创建,已存在则报错
break
except FileExistsError:
if time.time() > deadline:
raise TimeoutError(f"获取 {path} 锁超时")
time.sleep(0.1)

try:
yield
finally:
try:
lock_dir.rmdir()
except OSError:
pass


# 使用
if __name__ == "__main__":
with file_lock(Path("shared.txt")):
# 独占访问 shared.txt
Path("shared.txt").write_text("safe write", encoding="utf-8")

注意:这只是演示,生产环境用 filelockportalocker 这类库更可靠。

十二、常见陷阱

  1. __enter__return selfwith X() as xx 是 None。
  2. @contextmanager 里没 try/finally:抛异常时清理代码跳过。
  3. __exit__ 里再抛异常:会覆盖原异常。要么让原异常传,要么明确 raise ... from
  4. __exit__ 返回 True 吞异常:调用方莫名其妙,慎用。
  5. with 里返回 / break__exit__ 依然会执行(这是它的强项)。
  6. 多个 with 时忽略关闭顺序:先开的最后关(栈式)。
  7. 在异步代码用普通 with:应该用 async with

十三、小结与延伸阅读

  • with 保证”进入-退出”成对执行;
  • 协议:__enter__ / __exit__
  • 函数式实现用 @contextmanager + 生成器;
  • contextlib 提供 suppressredirect_stdoutExitStacknullcontext
  • 场景:文件、锁、事务、切目录、临时环境、计时;
  • 异步版:async with + __aenter__/__aexit__
  • 别乱吞异常。

延伸阅读:

到这里模块五(异常与文件处理)4 篇结束! 下一篇进入 模块六:中级进阶,从 迭代器与可迭代对象 开始。

文件读写操作

程序早晚要和文件系统打交道——读配置、写日志、导入 CSV、生成报表。Python 的文件 IO 简单直观,但一些细节(编码、二进制/文本模式、大文件、路径处理)经常让新手踩坑。这一篇讲清楚 open 的正确姿势、pathlib 现代化的路径处理、CSV/JSON 的读写、以及大文件的流式处理。

一、open:打开文件

1
2
3
f = open("hello.txt", mode="r", encoding="utf-8")
data = f.read()
f.close()

永远推荐用 with

1
2
3
with open("hello.txt", encoding="utf-8") as f:
data = f.read()
# 自动关闭,即使抛异常也保证关

二、open 的关键参数

1
open(file, mode="r", buffering=-1, encoding=None, newline=None)
  • file:路径(字符串或 Path 对象);
  • mode:模式(下节详解);
  • encoding:文本模式下的编码,永远显式写 encoding="utf-8"
  • buffering:缓冲,一般不用管;
  • newline:换行符处理,写 CSV 时经常用到。

三、mode:文件模式

mode 含义 文件不存在 已存在时
r 只读(默认) 报错 从头读
w 只写 创建 清空后重写
a 追加 创建 末尾追加
x 独占创建 创建 报错(防覆盖)
r+ 读写 报错 保留内容
b 二进制模式(加在上面) - -
t 文本模式(默认,加在上面) - -

常用组合

  • "r" / "rt" = 文本读;
  • "rb" = 二进制读(图片、PDF);
  • "w" = 文本写(覆盖);
  • "a" = 文本追加(日志);
  • "wb" = 二进制写。

四、文本 vs 二进制

文本模式

  • 返回 str
  • 换行会被”翻译”(Windows 的 \r\n 变成 \n);
  • 必须指定 encoding。

二进制模式

  • 返回 bytes
  • 原样读写,不做换行翻译;
  • 不能指定 encoding;
  • 用于图片、PDF、Excel、压缩包等。

五、read / readline / readlines / 遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
with open("data.txt", encoding="utf-8") as f:
# 一次读全部
text = f.read()

with open("data.txt", encoding="utf-8") as f:
# 一次读一行
line = f.readline()

with open("data.txt", encoding="utf-8") as f:
# 一次读所有行到列表
lines = f.readlines()

with open("data.txt", encoding="utf-8") as f:
# 按行迭代(最推荐,省内存)
for line in f:
print(line.rstrip())

大文件永远用第 4 种——文件对象本身是可迭代的,逐行读,内存友好。

六、写文件

1
2
3
4
5
6
with open("out.txt", "w", encoding="utf-8") as f:
f.write("第一行\n")
f.write("第二行\n")

# 写多行
f.writelines(["行 A\n", "行 B\n"])

write 不会自动加换行,要自己写 \n

七、追加

1
2
with open("log.txt", "a", encoding="utf-8") as f:
f.write(f"{msg}\n")

日志、增量写数据用 "a"

八、pathlib:现代化的路径处理

别再用 os.path 拼路径(Windows 反斜杠、字符串拼接易错)。用 pathlib

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
from pathlib import Path

p = Path("data") / "input.txt"
print(p) # data/input.txt(跨平台自动用正确分隔符)

# 常用方法
p.exists() # 是否存在
p.is_file()
p.is_dir()
p.parent # 父目录
p.name # input.txt
p.stem # input
p.suffix # .txt
p.with_suffix(".json") # data/input.json

# 目录操作
p.parent.mkdir(parents=True, exist_ok=True) # 递归创建

# 读写(方便的一步)
Path("hello.txt").write_text("hi 咖飞", encoding="utf-8")
Path("hello.txt").read_text(encoding="utf-8")

# 二进制
Path("img.png").read_bytes()
Path("img.png").write_bytes(b"...")

# 遍历目录
for f in Path("logs").iterdir():
print(f)

for f in Path("logs").glob("*.log"): # 匹配模式
print(f)

for f in Path("logs").rglob("*.log"): # 递归所有子目录
print(f)

九、编码陷阱

永远显式指定 encoding

1
2
open("file.txt", "r", encoding="utf-8")     # ✅
open("file.txt", "r") # ❌ 用平台默认编码,Windows 上是 GBK

Windows 上不指定 encoding 会用 GBK,读 UTF-8 中文会乱码。

BOM:Windows 记事本存的 UTF-8 有时带 BOM(3 字节头 \xef\xbb\xbf),用 encoding="utf-8-sig" 自动去掉。

判断文件编码chardet 库能猜(不 100% 准)。安全做法是要求所有输入都是 UTF-8。

十、CSV 处理

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
import csv

# 读
with open("data.csv", encoding="utf-8", newline="") as f:
reader = csv.reader(f)
for row in reader:
print(row)

# 带表头的读
with open("data.csv", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
print(row["name"], row["age"])

# 写
with open("out.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["name", "age"])
writer.writerow(["咖飞", 3])
writer.writerows([["小王", 20], ["小李", 25]])

# 带表头的写
with open("out.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["name", "age"])
writer.writeheader()
writer.writerow({"name": "咖飞", "age": 3})

关键:写 CSV 时必须 newline="",否则 Windows 上会写出多余的空行。

对于复杂的数据分析,用 pandas.read_csv / to_csv 更强大。

十一、JSON 处理

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

# 写
data = {"name": "咖飞", "age": 3, "hobbies": ["读书", "跑步"]}
with open("data.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)

# 读
with open("data.json", encoding="utf-8") as f:
data = json.load(f)

# 字符串版本
s = json.dumps(data, ensure_ascii=False)
d = json.loads(s)

关键参数

  • ensure_ascii=False:让中文正常显示(默认是 True,会转成 \uXXXX);
  • indent=2:格式化,便于人读;
  • sort_keys=True:按 key 排序,方便 diff。

复杂的 JSON 讲解见 JSON 数据处理

十二、大文件处理

不能一次 f.read()(会 OOM)。用迭代:

1
2
3
4
5
6
7
8
9
# 按行处理(文本)
with open("huge.log", encoding="utf-8") as f:
for line in f:
process(line)

# 按块处理(二进制或超大文本)
with open("huge.bin", "rb") as f:
while chunk := f.read(1024 * 1024): # 1MB 一次
process(chunk)

处理 GB 级文件也不成问题。

十三、临时文件

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

# 临时文件(会自动删除)
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", encoding="utf-8", delete=True) as f:
f.write("temp content")
print(f.name) # /tmp/tmpXXXXXX.txt

# 临时目录
with tempfile.TemporaryDirectory() as d:
print(d)
# 退出块时目录被清空

十四、文件锁与并发

多进程/多线程同时写一个文件会出问题。简单方案:

1
2
3
4
5
6
7
import fcntl        # Unix
# import msvcrt # Windows

with open("data.txt", "a") as f:
fcntl.flock(f, fcntl.LOCK_EX)
f.write("safe line\n")
fcntl.flock(f, fcntl.LOCK_UN)

跨平台的锁库:filelock(pip install filelock)。

十五、安全删除与移动

1
2
3
4
5
6
7
8
9
10
11
from pathlib import Path
import shutil

p = Path("old.txt")
p.unlink(missing_ok=True) # 删除文件(missing_ok 3.8+)

Path("dir").rmdir() # 删空目录
shutil.rmtree("dir") # 删非空目录(危险!)

shutil.copy("a.txt", "b.txt") # 复制文件
shutil.move("a.txt", "b/a.txt") # 移动

十六、一个完整例子:日志分析

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
66
67
68
69
70
71
72
73
74
75
76
# log_analyze.py
"""分析 Web 访问日志,统计 IP 请求次数。"""

from pathlib import Path
from collections import Counter
import csv
import json


def parse_line(line: str) -> dict | None:
"""
简单解析日志行:
2025-09-21 10:00:00 GET /path 200 1.2.3.4
"""
parts = line.split()
if len(parts) < 6:
return None
return {
"date": parts[0],
"time": parts[1],
"method": parts[2],
"path": parts[3],
"status": int(parts[4]),
"ip": parts[5],
}


def analyze(log_path: Path):
ip_counter = Counter()
status_counter = Counter()
total = 0
with log_path.open(encoding="utf-8") as f:
for line in f:
rec = parse_line(line.strip())
if rec is None:
continue
total += 1
ip_counter[rec["ip"]] += 1
status_counter[rec["status"]] += 1
return {
"total": total,
"top_ips": ip_counter.most_common(10),
"status": dict(status_counter),
}


def save_report(report: dict, out_dir: Path):
out_dir.mkdir(parents=True, exist_ok=True)

# JSON
(out_dir / "report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)

# Top IP CSV
with (out_dir / "top_ips.csv").open("w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["ip", "count"])
writer.writerows(report["top_ips"])


if __name__ == "__main__":
log_path = Path("access.log")
if not log_path.exists():
# 造一份测试数据
log_path.write_text(
"2025-09-21 10:00:00 GET /a 200 1.1.1.1\n"
"2025-09-21 10:00:01 GET /b 200 1.1.1.1\n"
"2025-09-21 10:00:02 GET /a 404 2.2.2.2\n"
"2025-09-21 10:00:03 GET /c 200 1.1.1.1\n",
encoding="utf-8",
)
report = analyze(log_path)
save_report(report, Path("out"))
print(report)

十七、常见陷阱

  1. 忘记指定 encoding:Windows 上 GBK 与 UTF-8 混用是乱码首因。
  2. 忘用 with:文件对象没关闭,Linux 上有文件描述符泄漏。
  3. 写 CSV 忘 newline="":Windows 上多空行。
  4. open("w") 覆盖了重要文件:写模式会清空。想追加用 "a"
  5. Path("a") + Path("b")/ 才是拼接运算符,不是 +
  6. 一次 read() 读大文件:OOM。用迭代。
  7. 相对路径乱:脚本从不同目录启动时相对路径变。用 Path(__file__).parent 确定脚本自身位置。

十八、小结与延伸阅读

  • with open(..., encoding="utf-8") as f: 是万能模板;
  • mode:r/w/a/x,加 b 是二进制;
  • 逐行遍历文件对象最省内存;
  • pathlib 取代 os.path 拼路径;
  • CSV 写记得 newline=""
  • JSON 中文用 ensure_ascii=False
  • 大文件用块读;
  • 临时文件/目录用 tempfile

延伸阅读:

下一篇 with 语句与上下文管理器 我们讲清楚 with 背后的原理,以及怎么自己写一个。

自定义异常与最佳实践

上一篇 异常处理机制 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 一起用。

异常处理机制 try/except

程序不可能”永远走 happy path”。用户会输入奇怪的数据、网络会断、文件会不存在、磁盘会满、依赖会挂——这些都会让程序抛出异常。Python 用 try/except 处理异常,规则简单,但用好并不容易。这一篇讲清楚异常的语法、几种典型模式、”要 catch 还是让它挂”的判断、以及 Python 3.11+ 的一些新语法。

一、什么是异常

异常是”运行时出错时抛出的对象”:

1
2
3
4
5
6
7
8
1 / 0
# ZeroDivisionError: division by zero

int("abc")
# ValueError: invalid literal for int() with base 10: 'abc'

open("no-such-file.txt")
# FileNotFoundError: ...

Python 里所有异常都是 BaseException 的子类。日常业务代码几乎只 catch Exception 及其子类。

二、基本语法

1
2
3
4
5
6
7
8
9
10
11
12
try:
risky_operation()
except ValueError:
handle_value_error()
except (FileNotFoundError, PermissionError) as e:
print(f"文件问题:{e}")
except Exception as e:
print(f"其他错误:{e}")
else:
print("try 里没抛异常时执行")
finally:
print("无论如何都执行(清理资源)")

执行顺序

  1. 运行 try 块;
  2. 如果抛异常 → 从上到下匹配 except;
  3. 如果没抛异常 → 执行 else;
  4. 无论走哪条路 → 执行 finally。

三、抓具体异常,不要裸 except

1
2
3
4
5
# ❌ 万恶之源
try:
do_stuff()
except:
pass # 什么错都吞掉,KeyboardInterrupt 也吞

问题:

  • 遮蔽真正的 bug;
  • 屏蔽 Ctrl+C(因为 KeyboardInterrupt 也是异常);
  • 让调试变噩梦。

规则

  • 尽量抓具体的异常(except ValueError);
  • 不确定时 except Exception(不会吞 KeyboardInterrupt/SystemExit);
  • 永远不写裸 except:

四、只 catch 你能处理的

能处理的意思是”知道怎么办”——重试、返回默认值、给用户友好提示、清理状态。如果你只是想”让程序不崩”,那不是处理,那是掩盖 bug

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ❌ 反面:只是想不崩
try:
result = fetch_from_api()
except Exception:
result = None # 上层拿到 None 不知所措

# ✅ 正面:有明确处理策略
try:
result = fetch_from_api()
except NetworkError:
result = read_from_cache() # 网络挂就用缓存
except TimeoutError:
result = None
logger.warning("超时,返回 None")

五、抛异常:raise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def divide(a, b):
if b == 0:
raise ValueError("除数不能为 0")
return a / b

# 重新抛
try:
do_stuff()
except ValueError:
logger.error("出错")
raise # 保留原始 traceback

# 抛新异常但保留原因
try:
do_stuff()
except ValueError as e:
raise RuntimeError("上层错误") from e

raise ... from e 让 traceback 显示”是从这个原因引发的”,调试非常清晰。

六、常见内置异常

异常 意思
ValueError 值不对(例:int("abc")
TypeError 类型不对(例:"a" + 1
KeyError 字典 key 不存在
IndexError 列表索引越界
AttributeError 属性不存在
NameError 变量不存在
FileNotFoundError 文件不存在
PermissionError 权限不足
IOError / OSError IO / 系统错误
ZeroDivisionError 除零
TimeoutError 超时
StopIteration 迭代结束
KeyboardInterrupt Ctrl+C
RuntimeError 通用运行时错误
NotImplementedError 抽象方法未实现
AssertionError assert 失败

七、异常的继承结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
BaseException
├── SystemExit
├── KeyboardInterrupt
└── Exception ← 日常抓这个
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── OSError
│ ├── FileNotFoundError
│ └── PermissionError
├── ValueError
├── TypeError
└── ...

推论except LookupError 能同时抓 KeyErrorIndexErrorexcept OSError 能抓所有 IO 相关。

八、EAFP vs LBYL

Python 的哲学是 “Easier to Ask Forgiveness than Permission”(EAFP)——先做,出错再处理:

1
2
3
4
5
6
7
8
9
10
11
# ✅ EAFP(Pythonic)
try:
value = d[key]
except KeyError:
value = default

# ❌ LBYL(Look Before You Leap,Java 风格)
if key in d:
value = d[key]
else:
value = default

好处:

  • EAFP 只在异常路径慢,正常路径快;
  • 避免 TOCTOU 问题(检查 → 使用之间状态变了);
  • 代码更直接。

但也不是所有场景都 EAFP。当”检查便宜且大概率失败”时 LBYL 更好

1
2
3
4
if not items:
return # LBYL:空判断很便宜

# 而不是 items[0] 抛 IndexError 再 catch

九、finally:清理资源

finally 无论有没有异常都会执行,适合清理:

1
2
3
4
5
f = open("file.txt")
try:
process(f)
finally:
f.close() # 保证关闭

更 Pythonic 的做法是用 with

1
2
with open("file.txt") as f:
process(f) # 自动关闭

with 用的是上下文管理器 的机制。

十、else 子句

try/else 用得少,但语义清晰:

1
2
3
4
5
6
7
try:
result = do_stuff()
except NetworkError:
result = None
else:
# 只在 do_stuff 没抛异常时执行
log("成功")

规则:把不该抛异常的代码放 else,try 里只放”可能抛”的那句

十一、异常链:raise ... from

想”包装”底层异常成业务异常,同时保留 traceback:

1
2
3
4
5
6
7
8
9
class DatabaseError(Exception):
pass


def query(sql):
try:
return raw_query(sql)
except IOError as e:
raise DatabaseError("查询失败") from e

调用方看到:

1
2
3
4
5
IOError: connection reset

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

DatabaseError: 查询失败

一眼就能看到”表面上抛 DatabaseError,其实底层是 IOError”。

raise ... from None 可以隐藏原因,避免长链暴露实现细节。

十二、Python 3.11+ 的新语法

1. except*:异常组

Python 3.11 引入了 ExceptionGroup,用来同时处理多个异常(并发场景很有用):

1
2
3
4
5
6
7
try:
...
except* ValueError as eg:
for e in eg.exceptions:
print("值错误:", e)
except* TypeError as eg:
...

普通业务不用管,遇到 asyncio.TaskGroup 会用到。

2. add_note

异常可以携带额外备注:

1
2
3
4
5
try:
do_stuff()
except Exception as e:
e.add_note(f"user_id={uid}")
raise

日志里会显示这条备注,无需重新包装异常。

十三、assert:不是异常处理

1
assert x > 0, "x 必须为正"

assert 用来校验假设——不该出现的情况。别用它做业务校验!因为 Python 加 -O 参数就会跳过所有 assert。

规则

  • 输入验证 → raise ValueError
  • 内部不变量 → assert
  • 生产环境的关键校验 → if not ...: raise

十四、常见异常模式

1. 重试

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

def with_retry(func, times=3, delay=1):
for i in range(times):
try:
return func()
except Exception as e:
if i == times - 1:
raise
print(f"重试 {i+1}/{times}{e}")
time.sleep(delay)

2. 默认值

1
2
3
4
5
def get_config(key, default=None):
try:
return config[key]
except KeyError:
return default

3. 转换异常

1
2
3
4
try:
x = int(user_input)
except ValueError:
raise BadRequest("请输入数字") from None

4. 日志 + 重抛

1
2
3
4
5
try:
do_stuff()
except Exception:
logger.exception("do_stuff 失败") # 自动记录 traceback
raise

logger.exception 是日志里最有用的方法之一——自动包含完整 traceback。

十五、一个完整例子:健壮的输入读取

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
# safe_input.py
"""带异常处理的输入函数集合。"""

import logging
logger = logging.getLogger(__name__)


def read_int(prompt: str, min_val: int = None, max_val: int = None) -> int:
while True:
raw = input(prompt).strip()
try:
n = int(raw)
except ValueError:
print("请输入整数")
continue

if min_val is not None and n < min_val:
print(f"必须 >= {min_val}")
continue
if max_val is not None and n > max_val:
print(f"必须 <= {max_val}")
continue
return n


def safe_divide(a, b):
"""安全除法。"""
try:
return a / b
except ZeroDivisionError:
logger.warning(f"除零:{a}/{b}")
return float("inf") if a > 0 else float("-inf")
except TypeError as e:
raise TypeError(f"不能除:{a!r} / {b!r}") from e


if __name__ == "__main__":
age = read_int("年龄(1-150):", min_val=1, max_val=150)
print(f"你 {age} 岁")

print(safe_divide(10, 2)) # 5.0
print(safe_divide(10, 0)) # inf
try:
safe_divide("a", "b")
except TypeError as e:
print("捕获:", e)

十六、常见陷阱

  1. except::吞掉一切,包括 Ctrl+C。永远不要写。
  2. except Exception: pass:跟裸 except 差不多糟,除非你真的知道自己在干嘛。
  3. except Exception as e: raise e:丢失原始 traceback。要 raise(不带 e)。
  4. 在 try 里放太多代码:只把”可能抛”的一句放 try,其他放 else。
  5. assert 做业务校验-O 参数下失效。用 raise
  6. 异常链爆炸:无脑 raise ... from e 让 traceback 特别长。有时用 from None 隐藏。
  7. finally 里 return:会覆盖 try/except 里的 return,谨慎使用。

十七、小结与延伸阅读

  • try/except/else/finally 四件套;
  • 抓具体异常,别裸 except;
  • Python 提倡 EAFP:先做,出错再处理;
  • 清理资源优先用 with,其次 finally
  • raise ... from e 保留原因,raise ... from None 隐藏;
  • assert 用于内部假设,不做业务校验;
  • 日志记异常用 logger.exception

延伸阅读:

下一篇 自定义异常与最佳实践 我们讲怎么设计自己的异常层次。

抽象类与接口(abc 模块)

Python 是鸭子类型(duck typing)的天堂——不需要 Java 那种”接口”,你的类只要行为符合期望就行。但项目大了之后,你会想”强制“某些子类必须实现特定方法。这就是抽象基类(Abstract Base Class, ABC) 的用武之地。这一篇讲 abc 模块、ABC@abstractmethod,以及 Python 3.8+ 引入的 typing.Protocol —— 一种更”鸭子”的类型接口。

一、为什么需要抽象类

看这段代码:

1
2
3
4
5
6
7
8
9
10
11
class Storage:
def save(self, key, value):
pass # 什么都没实现

class FileStorage(Storage):
def save(self, key, value):
# 写文件
pass

class BadStorage(Storage):
pass # 忘了实现 save,但也能实例化!

BadStorage().save(k, v) 什么都不会做(继承的空方法),但代码不报错。这种 bug 隐蔽又痛苦。

抽象类能强制:

1
2
3
4
5
6
7
8
9
10
11
from abc import ABC, abstractmethod

class Storage(ABC):
@abstractmethod
def save(self, key, value):
...

class BadStorage(Storage):
pass

BadStorage() # TypeError: Can't instantiate abstract class BadStorage

二、abc 的基本用法

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
from abc import ABC, abstractmethod


class Shape(ABC):
"""所有图形的抽象基类。"""

@abstractmethod
def area(self) -> float:
"""计算面积。子类必须实现。"""

@abstractmethod
def perimeter(self) -> float:
"""计算周长。子类必须实现。"""

def describe(self):
"""具体方法:抽象类里也可以有非抽象方法。"""
print(f"面积 {self.area():.2f}, 周长 {self.perimeter():.2f}")


class Circle(Shape):
def __init__(self, r):
self.r = r

def area(self):
return 3.14159 * self.r ** 2

def perimeter(self):
return 2 * 3.14159 * self.r


class BrokenShape(Shape):
def area(self):
return 0
# 没实现 perimeter


Circle(3).describe() # ok
BrokenShape() # ❌ TypeError: 没实现 perimeter

要点:

  • 继承 ABC(等价于 metaclass=ABCMeta);
  • @abstractmethod 装饰抽象方法;
  • 子类只有实现全部抽象方法后才能实例化
  • 抽象类可以有具体方法(非抽象),子类直接继承。

三、抽象属性与抽象 classmethod / staticmethod

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from abc import ABC, abstractmethod

class Base(ABC):
@property
@abstractmethod
def name(self):
...

@classmethod
@abstractmethod
def factory(cls):
...

@staticmethod
@abstractmethod
def helper():
...

注意装饰器顺序@abstractmethod 一定在最内层(最下面)。

四、Protocol:结构化子类型(Python 3.8+)

Python 3.8 引入了 typing.Protocol——一种”更鸭子”的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from typing import Protocol


class Drawable(Protocol):
def draw(self) -> None: ...


class Circle: # 注意:没继承 Drawable!
def draw(self):
print("画圆")


class Square:
def draw(self):
print("画方")


def render(item: Drawable): # 类型注解
item.draw()


render(Circle()) # ok
render(Square()) # ok

关键CircleSquare 没有继承 Drawable——但因为它们有 draw 方法,就”隐式满足”了这个 Protocol。这就是结构化子类型(structural subtyping)。

Protocol 的优势:

  • 不需要修改已有类的继承树;
  • 更符合 Python 的鸭子类型精神;
  • 与 mypy / pylance 等静态检查工具无缝配合。

runtime_checkable 让 Protocol 支持 isinstance 检查:

1
2
3
4
5
6
7
8
from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...


isinstance(Circle(), Drawable) # True

五、ABC vs Protocol:什么时候用什么

特性 ABC Protocol
需要显式继承 ✅ 是 ❌ 否
强制实现方法 ✅ 实例化时报错 ⚠️ 只在 mypy 时警告
支持默认实现 ✅ (3.8+)
isinstance 检查 ✅ 需 @runtime_checkable
侵入性(是否要改子类代码)

规则

  • 想强制运行时检查 → ABC;
  • 纯粹类型注解 + 静态检查 → Protocol;
  • 面向已有代码/第三方库 → Protocol(不用继承就适配);
  • 框架 API(要求实现方一定按契约) → ABC。

六、abc 提供的常用抽象类

Python 标准库里已经定义了很多 ABC,最典型是在 collections.abc

1
2
3
4
5
from collections.abc import Iterable, Sized, Container, Mapping, Sequence

isinstance([1, 2, 3], Iterable) # True
isinstance([1, 2, 3], Sized) # True
isinstance({"a": 1}, Mapping) # True

这些基类定义了”什么是可迭代对象”、”什么是序列”等标准接口。写库函数时用它们做参数检查比 isinstance(x, list) 优雅得多——因为你不关心具体类型,只关心行为。

1
2
3
4
5
6
7
8
9
10
11
def sum_all(items: Iterable[float]) -> float:
"""接收任何可迭代对象。"""
total = 0
for x in items:
total += x
return total

sum_all([1, 2, 3]) # 列表
sum_all((1, 2, 3)) # 元组
sum_all(range(10)) # range
sum_all(x for x in [...]) # 生成器

七、注册”虚拟子类”

ABC 支持”注册”一个不实际继承的类作为它的子类:

1
2
3
4
5
6
7
8
9
10
11
12
from abc import ABC

class Drawable(ABC):
pass

class Circle:
def draw(self): pass

Drawable.register(Circle)

isinstance(Circle(), Drawable) # True
issubclass(Circle, Drawable) # True

但这不会强制 Circle 实现方法——只是”官方认可”。用于把第三方类”塞进”你的类型系统。日常用得少,知道有这功能即可。

八、抽象方法可以有默认实现

@abstractmethod 装饰的方法可以有方法体,子类可以 super().method() 使用:

1
2
3
4
5
6
7
8
9
10
11
12
class Base(ABC):
@abstractmethod
def area(self):
return 0 # 默认值,子类可以 super().area() 使用


class Circle(Base):
def __init__(self, r):
self.r = r

def area(self):
return 3.14 * self.r ** 2

子类必须显式重写这个方法名字,才能实例化。这是 abstractmethod 的核心机制。

九、多重继承下的抽象类

多重继承 + 抽象类要小心 MRO。所有抽象方法都必须被 MRO 里某个类实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
class A(ABC):
@abstractmethod
def m1(self): ...

class B(ABC):
@abstractmethod
def m2(self): ...

class C(A, B):
def m1(self): print("m1")
def m2(self): print("m2")

C() # ok

十、typing.Protocol 支持默认实现

Protocol 里的方法可以带默认实现,实现方省略就用默认的:

1
2
3
4
5
6
7
8
9
10
11
from typing import Protocol

class Loggable(Protocol):
name: str
def log(self, msg: str) -> None:
print(f"[{self.name}] {msg}")

class Service:
name = "svc"

Service().log("hi") # ❌ AttributeError!Protocol 的默认实现不会被继承

注意:Protocol 类的方法不会自动继承给实现方。想真的复用,还是 ABC 或 mixin。

十一、一个完整例子:插件系统

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
66
67
68
69
70
# plugins.py
"""基于 ABC 的插件基类,展示抽象接口的实际用途。"""

from abc import ABC, abstractmethod


class Plugin(ABC):
"""所有插件的基类。"""

@property
@abstractmethod
def name(self) -> str:
...

@abstractmethod
def process(self, data: str) -> str:
...

def on_load(self) -> None:
"""可选钩子:加载时调用。有默认实现(什么都不做)。"""

def on_unload(self) -> None:
"""可选钩子:卸载时调用。"""


class UppercasePlugin(Plugin):
name = "uppercase"

def process(self, data: str) -> str:
return data.upper()


class ReversePlugin(Plugin):
name = "reverse"

def process(self, data: str) -> str:
return data[::-1]


class BadPlugin(Plugin):
name = "bad"
# 没实现 process


class PluginManager:
def __init__(self):
self.plugins: list[Plugin] = []

def register(self, plugin: Plugin) -> None:
assert isinstance(plugin, Plugin), "必须是 Plugin 的子类"
plugin.on_load()
self.plugins.append(plugin)

def apply(self, data: str) -> str:
for p in self.plugins:
data = p.process(data)
return data


if __name__ == "__main__":
mgr = PluginManager()
mgr.register(UppercasePlugin())
mgr.register(ReversePlugin())

print(mgr.apply("hello world")) # DLROW OLLEH

try:
mgr.register(BadPlugin()) # TypeError(BadPlugin 没实现 process)
except TypeError as e:
print("坏插件被拒绝:", e)

十二、常见陷阱

  1. 忘继承 ABC@abstractmethod 不生效,子类不实现也能实例化。
  2. 装饰器顺序错@abstractmethod 必须在最内层。
  3. 忘了实现抽象属性property + abstractmethod 组合要写全。
  4. Protocol 里的方法体被”继承”:其实不会——把默认实现放 Mixin 里。
  5. register 之后子类反而绕过检查Drawable.register(Circle)Circle 就算不实现 draw 也过 isinstance。用要谨慎。
  6. 过度设计:小项目直接鸭子类型即可。抽象类是”当你确实需要契约”时的工具,别为了”看起来专业”而滥用。

十三、小结与延伸阅读

  • ABC:显式继承 + 强制实现,运行时报错;
  • Protocol:结构化匹配,纯类型注解,静态检查;
  • @abstractmethod 装饰必须实现的方法;
  • collections.abc 里有 Iterable / Sequence / Mapping 等标准接口;
  • register 可以把不继承的类”认作”子类;
  • Protocol 更”鸭子”,ABC 更严格;
  • 简单场景直接用鸭子类型,别过度设计。

延伸阅读:

到这里模块四(面向对象编程)6 篇结束! 下一篇进入 模块五:异常与文件处理,从 异常处理机制 try/except 开始。