进阶篇 / 第 10 章
第 10 章 · 迭代器、生成器与装饰器
for 循环背后、yield、@decorator、with 语句——它们不是语法糖,而是语言层面的核心机制。本章要把这层黑箱打开:迭代协议如何工作、生成器如何用 O(1) 内存处理百万行文件、装饰器如何在不修改被装饰函数的前提下给它加上能力、上下文管理器如何保证资源一定被释放。前置知识:第 6 章函数与作用域(尤其是「函数是对象」这一点)、第 9 章模块机制。学完本章,你能读懂 Python 标准库里大量以 @ 开头的代码,也能自己写出计时器、重试、缓存、参数校验、事务管理器这类工程必备的装饰器与上下文管理器。迭代协议:for 循环背后的故事
你写了无数次 for 循环,但它究竟是怎么工作的?答案藏在一个叫「迭代器协议(iterator protocol)」的约定里。
可迭代对象 vs 迭代器
| 概念 | 英文 | 要求的方法 | 能否重复遍历 | 例子 |
|---|---|---|---|---|
| 可迭代对象 | iterable | 有 __iter__() | 通常可以 | list、str、dict、file 对象 |
| 迭代器 | iterator | 有 __iter__() 和 __next__() | 不能(一次就耗尽) | iter([1,2,3]) 的返回值、生成器 |
这条协议规定:
- 可迭代对象调用
iter(obj)(内部即obj.__iter__())返回一个迭代器。 - 迭代器调用
next(it)(内部即it.__next__())返回下一个元素。 - 元素取完时,迭代器抛出
StopIteration异常。
protocol_demo.py · 手动模拟 for 循环
data = ["a", "b", "c"]
# 普通写法
for x in data:
print(x)
# 等价的展开写法
it = iter(data) # 调 data.__iter__(),得到列表迭代器
while True:
try:
x = next(it) # 调 it.__next__()
print(x)
except StopIteration:
break # 元素耗尽,for 内部就是靠这个结束
# 更简洁的官方等价写法(for 循环的内部逻辑)
it = iter(data)
for x in it: # for 会自动捕获 StopIteration
print(x)
哪些对象是可迭代的
下面这些对象都实现了迭代协议,所以能放进 for:
| 类型 | 可迭代 | 是迭代器本身 | 重复遍历 | 说明 |
|---|---|---|---|---|
| list / tuple / str | ✅ | ❌ | ✅ | 每次 for 都新建一个迭代器 |
| dict | ✅ | ❌ | ✅ | 遍历得到的是 key |
| set / frozenset | ✅ | ❌ | ✅ | 顺序不保证 |
| range | ✅ | ❌ | ✅ | 惰性,无需分配完整序列 |
| 文件对象 | ✅ | ✅ | ❌ | 读完指针到底,第二次遍历为空 |
| 生成器 | ✅ | ✅ | ❌ | 一次性消耗品 |
| enumerate / zip / map | ✅ | ✅ | ❌ | 都是迭代器 |
nums = [1, 2, 3]
print(hasattr(nums, "__iter__"), hasattr(nums, "__next__")) # True False
it = iter(nums)
print(hasattr(it, "__iter__"), hasattr(it, "__next__")) # True True
# 迭代器耗尽后再次遍历 = 空
print(list(it)) # []
print(list(it)) # []
map/filter、zip 都是一次性的:for 完一遍后再 list() 它,得到的是空列表。如果需要重复使用,要么把它转成 list(data = list(gen)),要么每次重新生成。自己写一个迭代器
range_step.py · 自定义迭代器类
class Countdown:
"""一个倒计时迭代器:从 start 每次递减 step,降到 stop 以下停止。"""
def __init__(self, start: int, stop: int = 0, step: int = 1) -> None:
if step == 0:
raise ValueError("step 不能为 0")
self.current = start
self.stop = stop
self.step = step
def __iter__(self):
# 迭代器的 __iter__ 必须返回自身
return self
def __next__(self) -> int:
if (self.step > 0 and self.current <= self.stop) or \
(self.step < 0 and self.current >= self.stop):
raise StopIteration # 必须抛这个异常才算结束
value = self.current
self.current -= self.step
return value
# 使用
for n in Countdown(5):
print(n, end=" ") # 5 4 3 2 1
print()
for n in Countdown(10, 0, -3):
print(n, end=" ") # 10 7 4 1
print()
# 迭代器也是可迭代对象,可以用 list() 收集
print(list(Countdown(3))) # [3, 2, 1]
# 反向来看:list 的迭代器可以转回 list
it = iter([1, 2, 3])
print(list(it), list(it)) # [1, 2, 3] []
__iter__() 返回自己,__next__() 返回下一个值或抛 StopIteration。有了这两条,for、list()、sum()、解包、in 全部自动可用——这就是「面向接口编程」在 Python 里的体现。可迭代对象的三种来源
在实践中,数据进入 for 循环有三条路:
- 来自容器:list、dict、set——数据已经在内存里了,迭代只是走一遍。
- 来自计算:range、
x ** 2 for x in range(10)——数据是算出来的,不需要全部物化。 - 来自外部:文件、socket、网络响应——数据是流式到达的,边到边处理。
后两种场景就是「惰性(lazy)」与「流式(streaming)」的用武之地,也就是下一节的主题。
生成器:把循环变成函数
生成器(generator)是 Python 里最优雅的语言机制之一。它解决的问题是:当你只需要逐个处理海量数据时,为什么必须先把所有数据放进内存?
普通函数 vs 生成器函数
两者的区别只有一处:函数体里出现了 yield,整个函数就自动变成生成器函数(generator function)。
generator_demo.py · 两种写法的对比
# ---------- 普通函数:返回完整列表,占内存 ----------
def squares_list(n: int) -> list[int]:
"""返回前 n 个平方组成的列表。"""
return [x * x for x in range(n)]
# ---------- 生成器函数:产出器,每次只产出一个 ----------
def squares_gen(n: int):
"""逐个产出前 n 个平方,内存占用 O(1)。"""
for x in range(n):
yield x * x
# 对比内存与用法
a = squares_list(1_000_000)
print(type(a)) # <class 'list'>
print(sys.getsizeof(a) // 1024, "KB") # 约 8000 KB
b = squares_gen(1_000_000)
print(type(b)) # <class 'generator'>
print(sys.getsizeof(b), "bytes") # 约 200 bytes
# 生成器只能遍历一次
print(next(b)) # 0
print(next(b)) # 1
print(list(b)[:5]) # [4, 9, 16, 25, 36] —— 只剩下还没取过的部分
# 对比:list(生成器) 也可以,但会一次性物化
print(len(list(squares_gen(5)))) # 5
import sys(from sys import getsizeof 也可以)。1 000 000 个 int 的列表约 8 MB,而生成器对象本身只有 200 字节左右——差了四万倍。这就是为什么处理日志、大文件、爬虫分页时几乎清一色用生成器。yield 的运行机制:暂停与恢复
理解 yield 的关键,是把它理解为「保存现场并返回」。每次 next(),生成器就执行到下一个 yield,把值交出去,同时冻结当前所有局部变量;下次 next(),从冻结处继续。
def counter():
"""演示 yield 如何保存与恢复现场。"""
x = 10
yield x # 交出 10,此时 x=10 被保存
x = x + 5
yield x # 交出 15,此时 x=15 被保存
x = x * 2
yield x # 交出 30
g = counter()
print(next(g)) # 10
print(next(g)) # 15
print(next(g)) # 30
try:
next(g) # 已经没有更多 yield
except StopIteration:
print("生成器已耗尽")
双向通信:send / throw / close
yield 表达式的值,就是下次调用 gen.send(v) 时传入的 v。这让生成器变成了可以双向对话的协程式对象。
echo_server.py · 用 send 实现「回声服务」
def echo_server():
"""一个回声服务生成器:收到什么就原样送回什么,收到 'quit' 就结束。"""
while True:
try:
received = yield # 第一次进来时接收 send(None)
except GeneratorExit:
print("[server] 连接被关闭")
raise # 必须重新抛出 GeneratorExit
if received == "quit":
print("[server] 收到退出指令,结束服务")
break
print(f"[server] 收到: {received}")
yield f"[echo] {received}"
srv = echo_server()
# 启动生成器,让它执行到第一个 yield
print(next(srv)) # None(第一次 send 必须是 None 或不传)
print(srv.send("你好")) # [echo] 你好
print(srv.send("python")) # [echo] python
print(srv.send("quit")) # [server] 收到退出指令,结束服务
# close() 会向生成器抛 GeneratorExit,用于清理
g = echo_server()
next(g)
g.close()
next()」或 send(None) 会让生成器运行到第一个 yield,此时 received 为 None。如果你在 next(g) 之前就 send("数据"),会抛 TypeError: can't send non-None value to a just-started generator。另外,生成器里捕获了 GeneratorExit 却不重新抛出,Python 会报 RuntimeError: generator ignored GeneratorExit。生成器处理大文件的威力
log_analyzer.py · 用生成器流式分析日志
from collections import Counter
from pathlib import Path
from typing import Iterator
def read_lines(path: Path) -> Iterator[str]:
"""逐行读取文件,调用时才逐行进入内存。"""
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
yield line.rstrip("\n")
def parse_errors(lines) -> Iterator[tuple[int, str]]:
"""从日志行中挑出 ERROR 级别的行,产出 (行号, 内容)。"""
for no, line in enumerate(lines, start=1):
if "ERROR" in line:
yield no, line
def summarize(path: Path, top: int = 5) -> dict:
"""统计错误数与出现最多的错误类型。"""
counter = Counter()
total = 0
for _, line in parse_errors(read_lines(path)):
total += 1
# 取 ERROR 后面第一个词作为错误类型
counter[line.split("ERROR", 1)[1].strip().split()[0]] += 1
return {"total_errors": total, "top": counter.most_common(top)}
# 演示:生成器可以像管道一样层层传递
def demo():
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False,
encoding="utf-8") as f:
for i in range(1, 6):
f.write(f"2024-01-01 INFO 启动 {i}\n")
f.write("2024-01-01 ERROR DatabaseTimeout 数据库超时\n")
for i in range(6, 10):
f.write(f"2024-01-01 INFO 请求 {i}\n")
f.write("2024-01-01 ERROR DiskFull 磁盘已满\n")
f.write("2024-01-01 ERROR DatabaseTimeout 数据库超时\n")
name = f.name
result = summarize(Path(name))
print("错误总数 :", result["total_errors"]) # 3
print("Top 错误 :", result["top"][0]) # ('DatabaseTimeout', 2)
Path(name).unlink()
if __name__ == "__main__":
demo()
summarize 完全不知道数据从文件来,它只接收一个「能迭代出字符串」的参数。这是生成器最重要的工程价值——把「怎么产生数据」与「怎么消费数据」彻底解耦。想换成读网络或数据库?只改 read_lines 一个函数即可。yield from:委托给另一个生成器
手写 for x in sub(): yield x 冗长且低效(多了一层函数调用)。Python 3.3+ 提供 yield from(PEP 380)做委托。
# 对比:手写委托 vs yield from
def sub_gen():
yield 1
yield 2
yield 3
def manual_delegate():
for x in sub_gen():
yield x * 10 # 还能在中间加工
def from_delegate():
result = yield from sub_gen() # 直接委托,无加工
print("子生成器返回值:", result)
yield 4
print(list(manual_delegate())) # [10, 20, 30]
print(list(from_delegate())) # [1, 2, 3, 4](并打印 子生成器返回值: None)
# ---------- 真实场景:递归目录遍历 ----------
import os
from pathlib import Path
def walk_files(root: Path, suffix: str):
"""递归找出目录下所有指定后缀的文件(用 yield from 委托)。"""
for entry in os.scandir(root):
if entry.is_dir():
yield from walk_files(Path(entry.path), suffix)
elif entry.name.endswith(suffix):
yield Path(entry.path)
def walk_files_manual(root: Path, suffix: str):
"""同样的功能,手写版本。"""
for entry in os.scandir(root):
if entry.is_dir():
for f in walk_files_manual(Path(entry.path), suffix):
yield f
elif entry.name.endswith(suffix):
yield Path(entry.path)
# 测试
import tempfile
with tempfile.TemporaryDirectory() as d:
p = Path(d)
(p / "a.txt").write_text("x")
(p / "b.md").write_text("y")
(p / "sub").mkdir()
(p / "sub" / "c.txt").write_text("z")
print(sorted(f.name for f in walk_files(p, ".txt"))) # ['a.txt', 'c.txt']
print(sorted(f.name for f in walk_files_manual(p, ".txt")))
del_eggs.py · yield from 的隐藏能力:把迭代器的返回值传出来
def sub():
"""子生成器:return 的值会成为外层 yield from 表达式的值。"""
yield 10
yield 20
return "子生成器完成啦"
def main():
received = yield from sub()
print("收到子生成器的返回值:", received)
yield received.upper()
print(list(main()))
# 输出:
# 收到子生成器的返回值: 子生成器完成啦
# ['子生成器完成啦']
return x 会结束生成器并把 x 交给调用方;但在 for 循环里这个返回值被丢弃。yield from 让你能接住它——协程调度、异步迭代器都依赖这个特性。生成器表达式与惰性求值
如果生成器函数只用了一个 yield,就可以写成「生成器表达式(generator expression)」——一个用圆括号包裹的推导式。
# 列表推导式 vs 生成器表达式的语法差异只有一个字符:括号
squares_list = [x * x for x in range(1_000_000)] # 方括号:立即物化
squares_gen = (x * x for x in range(1_000_000)) # 圆括号:惰性
# 类型确认
print(type(squares_list)) # <class 'list'>
print(type(squares_gen)) # <class 'generator'>
# 内存对比
import sys
from sys import getsizeof
print(getsizeof(squares_list) // 1024, "KB") # 约 8000 KB
print(getsizeof(squares_gen), "bytes") # 约 200 bytes
# 惰性的另一个含义:外层迭代不执行时,内层根本不会算
data = [1, 2, 3, 4, 5]
gen = (x for x in data if x > 100)
print(list(gen)) # [] —— 判断根本没发生多少次
print(data) # 原列表没被修改
# 传给 sum / max / any / all:它们会自动消费生成器
print(sum(x * x for x in range(10))) # 285
print(max((len(w) for w in ["a", "bb", "ccc"]))) # 3
print(any(x < 0 for x in [1, 2, -1])) # True
print(all(x > 0 for x in [1, 2, 3])) # True
# 嵌套推导式(列表可以嵌套,生成器表达式可以带条件过滤)
pairs = [(i, j) for i in range(3) for j in range(i, 3)]
print(pairs) # [(0, 0), (0, 1), (0, 2), (1, 1), (1, 2), (2, 2)]
evens = (n for n in range(20) if n % 2 == 0)
print(list(evens)) # [0, 2, 4, ..., 18]
list(gen))或 tuple(gen) 才能重复使用。装饰器:一个函数改造另一个函数
装饰器(decorator)是 Python 里最常被滥用也最强大的工具。它本质上就是一个「接收函数、返回新函数」的高阶函数。
不用语法糖,先看本质
def my_decorator(func):
"""最基础的装饰器:包装 func,返回一个新函数。"""
def wrapper(*args, **kwargs):
print(f"[前] 调用 {func.__name__},参数={args} {kwargs}")
result = func(*args, **kwargs)
print(f"[后] {func.__name__} 返回 {result}")
return result
return wrapper
def greet(name: str) -> str:
"""原始函数。"""
return f"你好,{name}!"
# ===== 不用 @ 语法糖 =====
wrapped = my_decorator(greet)
print(wrapped("世界"))
# [前] 调用 greet,参数=('世界',) {}
# 你好,世界!
# [后] greet 返回 你好,世界!
# ===== 用 @ 语法糖,完全等价 =====
@my_decorator
def greet(name: str) -> str:
return f"你好,{name}!"
print(greet("Python"))
# [前] 调用 greet,参数=('Python',) {}
# 你好,Python!
# [后] greet 返回 你好,Python!
@my_decorator 只是 greet = my_decorator(greet) 的简写。理解这一点,你就知道:装饰后的函数其实是一个全新的函数对象,名字叫 wrapper,这会带来一个必须解决的问题——元信息丢失。元信息丢失问题
def naive_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@naive_decorator
def calculate(a: int, b: int) -> int:
"""计算两数之和。"""
return a + b
print(calculate.__name__) # wrapper —— 原始名字丢了
print(calculate.__doc__) # None —— 文档字符串丢了
print(calculate.__module__) # __main__ —— 模块信息也变了
import inspect
print(inspect.signature(calculate)) # (*args, **kwargs) —— 签名毁了
help(calculate) # 帮不上任何忙
解决办法是 functools.wraps:
from functools import wraps
def good_decorator(func):
@wraps(func) # 把 func 的元信息复制到 wrapper
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@good_decorator
def calculate(a: int, b: int) -> int:
"""计算两数之和。"""
return a + b
print(calculate.__name__) # calculate
print(calculate.__doc__) # 计算两数之和。
print(calculate.__wrapped__) # 指向原始函数,可以再取回来
import inspect
print(inspect.signature(calculate)) # (a: int, b: int) -> int
print(inspect.unwrap(calculate) is calculate.__wrapped__) # True
@wraps(func)。没有它,你的函数在文档生成、IDE 补全、日志追踪、性能剖析工具里都会「隐身」。这已经近乎强制规范了。保留原函数引用
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
import time
t0 = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - t0
print(f"[计时] {func.__name__} 耗时 {elapsed:.6f}s")
return result
return wrapper
@timer
def slow_task():
"""一个假装很慢的任务。"""
total = 0
for i in range(200_000):
total += i
return total
print(slow_task()) # [计时] slow_task 耗时 0.0xxx s
# 通过 __wrapped__ 仍可访问原始实现(绕过装饰层)
print(slow_task.__wrapped__.__doc__)
# 要多次调用原始函数时用它;不要用它绕过必要的校验/日志
带参数的装饰器
如果装饰器需要「配置」(比如重试几次、超时多少秒),就要写成三明治结构:最外层接收配置,中间返回真正的装饰器。
retry.py · 重试装饰器
import time
from functools import wraps
from typing import Callable, TypeVar
F = TypeVar("F", bound=Callable[..., object])
def retry(times: int = 3, delay: float = 0.5, exceptions=(Exception,)):
"""失败自动重试的装饰器工厂。
:param times: 最大尝试次数(含第一次)
:param delay: 每次重试之间的等待秒数
:param exceptions: 触发重试的异常类型元组
"""
if times < 1:
raise ValueError("times 必须 >= 1")
def decorator(func: F) -> F:
@wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as exc:
last_exc = exc
if attempt == times:
break
print(f"[重试] {func.__name__} 第 {attempt} 次失败:{exc!r},{delay}s 后再试")
time.sleep(delay)
# 所有尝试都失败,抛出最后一次异常
raise last_exc
return wrapper
return decorator
# ---------- 使用 ----------
import random
attempts = {"n": 0}
@retry(times=5, delay=0.05, exceptions=(ConnectionError,))
def flaky_api() -> str:
"""模拟一个时好时坏的接口。"""
attempts["n"] += 1
if attempts["n"] < 3:
raise ConnectionError("连接被重置")
return "成功返回数据"
print(flaky_api()) # 两次重试日志后输出「成功返回数据」
print("总尝试次数:", attempts["n"]) # 3
# 不带括号时无法传参——必须用括号
@retry() # 默认 times=3
def another() -> str:
return "OK"
print(another())
retry(5, 0.1, ...) 在定义时调用一次,返回 decorator;decorator(flaky_api) 在装饰时调用,返回 wrapper;wrapper() 才是每次真正调用函数时执行。所以「配置」在模块加载时就固定了,「行为」在调用时发生。类装饰器与有状态装饰器
装饰器不一定是函数,任何可调用对象都可以。类的实例(实现了 __call__)常常更适合有状态的装饰器。
class_decorators.py · 用类实现的装饰器
from functools import wraps
from collections import Counter
class CountCalls:
"""统计函数被调用次数的类装饰器(有状态)。"""
def __init__(self, func):
self.func = func
self.counter = Counter()
wraps(func)(self) # 把 func 的元信息挂到这个实例上
def __call__(self, *args, **kwargs):
key = f"{self.func.__name__}:{args}:{sorted(kwargs.items())}"
self.counter[key] += 1
print(f"[计数] {self.func.__name__} 第 {self.counter[key]} 次调用")
return self.func(*args, **kwargs)
def stats(self) -> dict:
return dict(self.counter)
@CountCalls
def greet(name: str) -> str:
return f"你好,{name}!"
print(greet("A")) # [计数] greet 第 1 次调用 / 你好,A!
print(greet("B")) # ... 第 1 次调用
print(greet("A")) # ... 第 2 次调用
print(greet.stats()) # {"greet:('A',):[]": 2, "greet:('B',):[]": 1}
print(greet.__name__) # greet —— 实例伪装得像原函数
# ---------- 另一个例子:类装饰器修改类 ----------
import functools
def add_repr(cls):
"""给类自动补一个可读的 __repr__。"""
@functools.wraps(cls)
def wrapper(*args, **kwargs):
obj = cls(*args, **kwargs)
obj.info = f"{cls.__name__}(构造完成)"
return obj
return wrapper
@add_repr
class Service:
def __init__(self, name: str) -> None:
self.name = name
s = Service("订单服务")
print(s.info) # Service(构造完成)
print(s.name) # 订单服务
装饰器叠加:从下往上应用
import functools
import time
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[log] -> {func.__name__}")
return func(*args, **kwargs)
return wrapper
def count_time(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = func(*args, **kwargs)
print(f"[time] {func.__name__} 用时 {time.perf_counter() - t0:.6f}s")
return result
return wrapper
@log_calls
@count_time
def work(n: int) -> int:
"""被两层装饰的函数。"""
return sum(range(n))
work(100_000)
# [log] -> work
# [time] work 用时 0.00xxxs
#
# 等价于 work = log_calls(count_time(work))
# 所以 log_calls 在最外层:先被 log 看到,再进入计时,再进 work
work() 时,先执行 log_calls 的包装体,再执行 count_time 的包装体,最后才是原函数,退出时顺序相反。调试多层装饰器时,记住这个顺序就不会乱。带参数的类装饰器
import functools
def register(name: str):
"""把函数注册进全局注册表。"""
def decorator(func):
REGISTRY[name] = func
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator
REGISTRY: dict[str, object] = {}
@register("double")
def _double(x: int) -> int:
return x * 2
@register("triple")
def _triple(x: int) -> int:
return x * 3
for cmd, fn in REGISTRY.items():
print(cmd, "->", fn(5)) # double -> 10 / triple -> 15
实用装饰器工具箱
下面这些装饰器在任何真实项目里都会反复用到,值得逐个理解并抄进自己的 utils/decorators.py。
1. 计时装饰器
import time
import functools
def timeit(func):
"""打印函数执行耗时的装饰器。"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
dt = time.perf_counter() - t0
print(f"{func.__name__} 耗时 {dt:.6f}s")
return wrapper
@timeit
def compute(n: int) -> int:
return sum(range(n))
compute(1_000_000) # compute 耗时 0.01xxx s
2. 内存缓存(memoize)
import functools
@functools.cache # Python 3.9+ 内置版 lru_cache(maxsize=None)
def fib(n: int) -> int:
"""朴素递归斐波那契,加缓存后从指数级降到 O(n)。"""
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(50)) # 12586269025(瞬间完成)
print(fib.cache_info()) # CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)
# 限制大小的版本:适合不希望无限增长的场景
@functools.lru_cache(maxsize=128)
def parse_line(line: str) -> tuple:
return tuple(line.split(","))
print(parse_line("a,b,c"))
print(parse_line.cache_info())
parse_line.cache_clear() # 手动清空
list 或 dict。若需要缓存 list 参数,请自己写包装层把参数转成 tuple。另外缓存的返回值是同一个对象,调用方若修改它会污染缓存——只对不可变返回值使用缓存最安全。3. 参数校验装饰器
import functools
import inspect
def require_types(**expected):
"""按参数名校验类型,不满足就抛 TypeError。
用法:@require_types(x=int, y=(int, float))
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
bound = inspect.signature(func).bind(*args, **kwargs)
for name, want in expected.items():
if name not in bound.arguments:
continue
value = bound.arguments[name]
if not isinstance(value, want):
raise TypeError(
f"{func.__name__} 的参数 {name} 期望 {want},"
f"实际得到 {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@require_types(a=int, b=(int, float))
def divide(a, b):
"""两数相除。"""
return a / b
print(divide(10, 2)) # 5.0
try:
divide("x", 2)
except TypeError as e:
print("捕获到:", e) # 捕获到: divide 的参数 a 期望 (<class 'int'>),实际得到 str
4. 权限检查装饰器
import functools
CURRENT_USER = {"name": "alice", "roles": {"admin"}}
def require_role(*roles: str):
"""要求当前用户拥有其中任一角色才能调用。"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
user_roles = CURRENT_USER["roles"]
if not user_roles.intersection(roles):
raise PermissionError(
f"用户 {CURRENT_USER['name']} 缺少角色 {roles}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@require_role("admin", "ops")
def delete_all() -> str:
"""危险操作。"""
return "已删除全部数据"
print(delete_all()) # 已删除全部数据
CURRENT_USER["roles"] = {"guest"}
try:
delete_all()
except PermissionError as e:
print("拒绝:", e) # 拒绝: 用户 alice 缺少角色 ('admin', 'ops')
5. 弃用警告装饰器
import functools
import warnings
def deprecated(reason: str = "", version: str = ""):
"""调用被弃用的函数时发出 DeprecationWarning。"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
msg = f"{func.__name__} 已弃用"
if version:
msg += f"(将在 {version} 移除)"
if reason:
msg += f":{reason}"
warnings.warn(msg, DeprecationWarning, stacklevel=2)
return func(*args, **kwargs)
return wrapper
return decorator
@deprecated(reason="请改用 new_api()", version="3.0")
def old_api(x: int) -> int:
return x * 2
import warnings
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
print(old_api(3)) # 6
print(caught[0].message) # old_api 已弃用(将在 3.0 移除):请改用 new_api()
上下文管理器:with 的实现
第 8 章我们已经用过 with open(...) as f:。它背后同样是「函数即对象」的机制。
上下文管理器协议
一个对象要能用于 with,需要实现:
__enter__():进入时调用,返回值赋给as后面的名字。__exit__(exc_type, exc_value, traceback):退出时一定被调用(正常或异常)。返回真值表示「吞掉异常」,否则异常继续向上抛。
context_manager_demo.py · 手写一个上下文管理器类
class Timer:
"""测量 with 块内代码耗时的上下文管理器。"""
def __init__(self, label: str = "代码块") -> None:
self.label = label
self.start = 0.0
self.elapsed = 0.0
def __enter__(self) -> "Timer":
import time
self.start = time.perf_counter()
print(f"[{self.label}] 开始")
return self # 返回值会绑定给 as 后的名字
def __exit__(self, exc_type, exc_value, tb) -> bool:
import time
self.elapsed = time.perf_counter() - self.start
if exc_type is None:
print(f"[{self.label}] 正常结束,耗时 {self.elapsed:.6f}s")
else:
print(f"[{self.label}] 因 {exc_type.__name__} 结束,耗时 {self.elapsed:.6f}s")
return False # False = 不吞异常
with Timer("数据处理") as t:
total = sum(range(500_000))
print("计算结果:", total)
print("记录耗时:", t.elapsed) # 通过 as 拿到 __enter__ 的返回值
# 异常也会触发 __exit__
try:
with Timer("危险操作") as t2:
raise RuntimeError("出错了")
except RuntimeError as e:
print("异常已传播:", e)
@contextmanager:函数写法
大多数时候用装饰器更方便。注意结构:yield 之前是进入逻辑,之后是退出逻辑。
from contextlib import contextmanager
import time
@contextmanager
def open_file(path: str, mode: str = "r"):
"""打开文件的上下文管理器(即使出错也保证关闭)。"""
print(f"[open] {path}")
f = open(path, mode, encoding="utf-8")
try:
yield f # 这里把 f 交给 with 的调用方
except Exception as exc:
print(f"[error] 读取 {path} 出错:{exc!r}")
raise # 决定是否向上抛
finally:
f.close() # 无论正常还是异常都会执行
print(f"[close] {path}")
# 测试
import tempfile, os
name = tempfile.mktemp(suffix=".txt")
with open(name, "w", encoding="utf-8") as f:
f.write("第一行\n第二行\n")
with open_file(name) as f:
print("内容:", repr(f.read()))
os.unlink(name)
# 另一个:临时目录
@contextmanager
def temp_dir():
"""自动创建并清理临时目录。"""
import tempfile, shutil
d = tempfile.mkdtemp(prefix="demo_")
try:
yield d
finally:
shutil.rmtree(d, ignore_errors=True)
with temp_dir() as d:
print("临时目录:", d)
open(os.path.join(d, "x.txt"), "w").write("hi")
print("离开后目录已清理:", not os.path.exists(d))
@contextmanager 装饰的函数必须恰好 yield 一次。零次会报 RuntimeError: generator didn't yield,多次会报 RuntimeError: generator didn't stop after throw()。另外 try/finally 里的清理代码不能放在 yield 之前(那样还没用上资源就释放了)。contextlib 里的现成工具
| 工具 | 用途 | 示例 |
|---|---|---|
contextlib.suppress(E1, E2) | 忽略指定异常 | with suppress(FileNotFoundError): os.remove(p) |
contextlib.ExitStack | 动态管理多个资源 | 见下 |
contextlib.redirect_stdout | 临时重定向输出 | 见下 |
contextlib.chdir(3.11+) | 临时切换工作目录 | with chdir(tmp): ... |
contextlib.asynccontextmanager | 异步版上下文管理器 | 第 15 章讲 |
import io
import os
import tempfile
from contextlib import ExitStack, redirect_stdout, suppress
# 1. 忽略异常
with suppress(FileNotFoundError):
os.remove("/绝对不存在的路径.txt")
print("删除失败被安全忽略,程序继续")
# 2. 重定向 stdout:常用于测试与日志捕获
buf = io.StringIO()
with redirect_stdout(buf):
print("这条不会打印到终端")
captured = buf.getvalue().strip()
print("已捕获:", captured)
# 3. ExitStack:数量不确定的资源
with ExitStack() as stack:
files = []
for name in ["a.txt", "b.txt", "c.txt"]:
f = stack.enter_context(open(name, "w", encoding="utf-8"))
f.write(f"内容 {name}")
files.append(f)
print("已同时打开", len(files), "个文件")
# 离开 with 时按相反顺序自动关闭全部文件
print("全部已关闭:", all(f.closed for f in files))
for name in ["a.txt", "b.txt", "c.txt"]:
os.remove(name)
实际例子:数据库事务
transaction.py · 用上下文管理器实现事务
from contextlib import contextmanager
class FakeDB:
"""模拟数据库连接,用来演示事务语义。"""
def __init__(self) -> None:
self.in_tx = False
self.undo_log: list[str] = []
def begin(self) -> None:
self.in_tx = True
self.undo_log.clear()
print("[db] BEGIN")
def execute(self, sql: str) -> None:
if not self.in_tx:
raise RuntimeError("不在事务中")
if sql.startswith("UPDATE"):
self.undo_log.append(sql)
print("[db]", sql)
def commit(self) -> None:
self.in_tx = False
print("[db] COMMIT")
@contextmanager
def transaction(db: FakeDB):
"""事务上下文:正常则提交,异常则回滚。"""
db.begin()
try:
yield db
except Exception:
print("[db] ROLLBACK:", db.undo_log or "无变更")
db.in_tx = False
raise
else:
db.commit()
finally:
db.undo_log.clear()
# 成功路径
db = FakeDB()
with transaction(db) as conn:
conn.execute("UPDATE account SET balance = 0")
conn.execute("UPDATE account SET balance = 900")
print("事务后状态:", db.in_tx)
# 失败路径:自动回滚,异常继续抛出
db2 = FakeDB()
try:
with transaction(db2) as conn:
conn.execute("UPDATE account SET balance = 0")
raise ValueError("业务校验失败")
except ValueError as e:
print("业务异常:", e)
print("已回滚:", not db2.in_tx)
@lru_cache 管理内存)、连接池,全是这个模式。掌握它,你的代码可靠性会有质的提升。三个概念的统一视角
迭代器、生成器、装饰器看似无关,其实都建立在同一个基础上:「函数是对象」。
| 机制 | 本质 | 用到的协议 / 工具 | 典型用途 |
|---|---|---|---|
| 迭代器 | 有状态的对象,每次产出一个值 | __iter__ / __next__ | 封装遍历逻辑 |
| 生成器 | 用 yield 写出来的迭代器 | yield / yield from | 惰性求值、流式处理 |
| 装饰器 | 接收函数、返回函数的函数 | functools.wraps | 横切关注点(日志、缓存、校验) |
| 上下文管理器 | 接收函数、返回类的装饰器 | contextlib / __enter__ / __exit__ | 资源获取与必然释放 |
选择决策表
| 你的需求 | 该用什么 |
|---|---|
| 数据已在内存,要遍历 | 直接 for,不需要任何机制 |
| 要产出大量数据,只要遍历一次 | 生成器函数或生成器表达式 |
| 要封装「遍历某资源」的逻辑并复用 | 实现 __iter__/__next__ 的类 |
| 要在多个函数上复用同一段前置/后置逻辑 | 装饰器(带 @wraps) |
| 要保证资源一定被释放 | with + @contextmanager |
| 要替换/扩展一个已有类的方法行为 | 装饰器(作用在方法上,注意第一个参数是 self) |
| 要写框架式 API(Flask 路由那种) | 带参数的装饰器 + 注册表 |
self 会作为第一个位置参数传给 *args,所以通用的 *args, **kwargs 写法天然兼容方法与函数。但如果装饰器要区分二者,可以检查 args and isinstance(args[0], cls),或用 inspect.ismethod。最佳实践与常见错误
必须遵守的规则
- 装饰器永远加
@wraps(func)。 - 装饰器保持透明:返回原函数的返回值,不要擅自吞掉参数或改变语义。
- 清理代码放 finally:装饰器与上下文管理器的清理逻辑都要保证异常路径下也执行。
- 不要在生成器里写
return 值给循环用:普通for拿不到,只有yield from能接住。 - 明确迭代器的一次性:需要复用就
list(gen)或重新生成。
三个典型错误代码
import functools
# ❌ 错误一:返回列表而非函数,调用会失败
def bad_decorator(func):
return [func, "extra"]
# ❌ 错误二:把 *args 直接传给另一个普通函数(需要 kwargs 透传时漏掉)
def bad2(func):
def wrapper(x):
return func(x) # ❌ 少传了 **kwargs
return wrapper
# ❌ 错误三:循环闭包:装饰器/lambda 捕获的是变量而非值
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2] —— 全是 2
# ✅ 修正:默认参数绑定当前值
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]
# ❌ 错误四:生成器只被部分消费就丢弃
def read_all():
for line in open("big.log", encoding="utf-8"):
yield line
gen = read_all()
first = next(gen)
# 如果这里就丢掉 gen,生成器不会自动关闭;用 with 或显式 close
gen.close()
# ✅ 修正:正确的带参装饰器模板
def logging_with_level(level: str = "INFO"):
import logging
logger = logging.getLogger(__name__)
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.log(logging.INFO, "%s 正在执行,参数=%s", func.__name__, args)
try:
return func(*args, **kwargs)
finally:
logger.log(logging.INFO, "%s 已结束", func.__name__)
return wrapper
return decorator
@logging_with_level("INFO")
def business_op(x: int) -> int:
return x + 1
print(business_op(1)) # 2(日志未配置 handler 时不显示)
lambda i=i: ... 或 functools.partial 把值绑死。自检清单
- □ 能说出
for x in obj的完整展开过程。 - □ 能手写一个实现了
__iter__/__next__的迭代器类。 - □ 写涉及大量数据的函数时优先考虑生成器,并解释内存差异。
- □ 所有自定义装饰器都加了
@wraps。 - □ 带参装饰器能正确写出「工厂 → 装饰器 → 包装器」三层。
- □ 能用
@contextmanager封装资源管理,且清理逻辑在 finally 中。
本章小结
- 迭代协议:
__iter__返回迭代器,__next__产出元素,StopIteration结束;for就是这三步的语法糖。 - 可迭代 ≠ 迭代器:容器可重复遍历,迭代器(文件、生成器、map/zip)一次性消耗。
- 生成器:含
yield的函数返回生成器对象,内存 O(1),可暂停/恢复;send/throw/close支持双向通信。 yield from:委托子生成器并接收其return值,比手写 for-yield 快且清晰。- 生成器表达式(圆括号)用于惰性求值;用一次选生成器,要复用选列表。
- 装饰器是「函数 → 函数」的高阶函数;
@dec等价于func = dec(func);必须加functools.wraps。 - 带参装饰器用三层结构:
retry(...)→decorator(func)→wrapper(...);多层装饰自下而上应用。 - 类装饰器通过
__call__实现,适合有状态的装饰(如调用计数、注册表)。 - 上下文管理器实现
__enter__/__exit__;函数版用@contextmanager,结构是「yield前进入、yield后清理(finally)」。 - 标准工具:
functools.cache/lru_cache/wraps/partial、contextlib.suppress/ExitStack/redirect_stdout。 - 典型 bug:循环闭包 late binding、忘
@wraps、@contextmanager中 yield 次数不对、生成器被部分消费。
练习题
-
手写迭代器:实现类
Range3,行为与内置range一致(支持 start/stop/step,支持负步长,支持len()和__contains__),并能被list()收集。参考答案要点实现__iter__返回自身、__next__判越界后抛StopIteration;__len__返回max(0, (stop-start+step-1)//step)(step>0 情形,负步长类推);__contains__可用self.start <= v < self.stop快速判断。写好后用assert list(Range3(0, 10, 3)) == [0, 3, 6, 9]验证。 -
内存对比实验:写一段脚本,分别用列表推导式与生成器表达式生成 1000 万个偶数的平方,打印两者的
sys.getsizeof与time耗时,并解释差异。参考答案要点a = [i*i for i in range(10_000_000)]与b = (i*i for i in range(10_000_000))。列表约 80 MB 且构建耗时长;生成器对象约 200 字节,若sum(b)消费则峰值内存极低但耗时会略高(逐个 yield 有开销)。要点:列表「快占内存」,生成器「省内存省时间地开始」。 -
生成器管道:用三个生成器函数实现整数流水处理:
gen_source()产出 1–100,gen_filter(numbers)只留偶数,gen_map(numbers, fn)把每个数平方,consume(gen)求总和。禁止使用 list 中转。参考答案
要点:每个函数只关心「上一步给我一个可迭代对象」,这就是惰性管道的解耦威力。def gen_source(): for i in range(1, 101): yield i def gen_filter(numbers): for n in numbers: if n % 2 == 0: yield n def gen_map(numbers, fn): for n in numbers: yield fn(n) def consume(gen): total = 0 for v in gen: total += v return total print(consume(gen_map(gen_filter(gen_source()), lambda x: x * x))) # 2^2+4^2+...+100^2 -
带参装饰器:实现
throttle(interval),被装饰函数两次调用的间隔小于interval秒时直接返回None(限流),用@wraps保留元信息。参考答案要点用time.monotonic()记录上次调用时间(不要用time.time(),它会被系统时钟调整影响);闭包里保存last = [0.0];若now - last[0] < interval则返回 None,否则更新并调用原函数。注意@wraps不能省。 -
装饰器叠加顺序:有
@a、@b、@c三个装饰器,写出完整展开形式,并说明函数调用时打印顺序(每个装饰器在调用前和调用后各打印一次)。参考答案展开形式:f = a(b(c(f)))。调用顺序:进入时a前 → b前 → c前 → f → c后 → b后 → a后(因为 a 在最外层)。定义时应用顺序是自下而上(先 c 后 b 后 a),执行时进入自上而下、退出自下而上。这是理解多层装饰日志顺序的关键。 -
类装饰器:实现类装饰器
Count,为被装饰函数自动添加count属性(记录调用次数)与reset()方法,装饰后仍能正常调用原函数。参考答案要点
装饰后import functools class Count: def __init__(self, func): self.func = func self.count = 0 functools.wraps(func)(self) def __call__(self, *a, **kw): self.count += 1 return self.func(*a, **kw) def reset(self): self.count = 0f.count与f.reset()都能访问(因为实例把属性暴露给了包装后的名字)。注意wraps(func)(self)会尝试设置self.__name__等属性,若原函数有__dict__需注意冲突。 -
上下文管理器:用
@contextmanager写temporary_file(content),自动创建临时文件并写入内容,退出时删除。演示异常路径下文件同样被删除。参考答案
测试:import os, tempfile from contextlib import contextmanager @contextmanager def temporary_file(content: str): f = tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") path = f.name try: f.write(content) f.flush() yield path finally: if not f.closed: f.close() os.unlink(path)with temporary_file("hello") as p: print(open(p).read());再在 with 块内raise ValueError,块外用os.path.exists(p)验证为 False。关键是删除写在 finally 里。 -
缓存装饰器陷阱:
@functools.cache装饰的函数返回 list,调用方append了一下,第二次调用结果被污染。解释原因并给出两种修复方案。参考答案原因:cache按参数缓存返回值,而 list 是可变对象,缓存的是同一个引用。修复:① 只对不可变返回值使用缓存(改成返回 tuple);② 缓存内部序列、每次返回副本——写@cache的私有函数_cached(*args) -> tuple,对外函数返回list(_cached(*args))。还可加info.clear()提供手动清缓存的入口。 -
生成器 close 与资源释放:写一个生成器函数,函数体里用
with open(...)打开文件。解释:如果只消费前 5 行就丢弃生成器,文件何时被关闭?为什么?参考答案要点生成器被丢弃时引用计数归零,会触发其close(),进而在挂起位置抛出GeneratorExit;由于 with 块在生成器帧的栈上,with的__exit__会被调用,文件随即关闭。但更稳妥的写法是把生成器放在with contextlib.closing(gen):中,或用try/finally在生成器内部保证释放——依赖析构时机在有循环引用时并不可靠。 -
综合工具库:写一个
utils/decorators.py,包含四个装饰器:log_calls(打印进入/退出)、retry(可配次数与异常)、timed(打印耗时)、validate(**types)(类型校验),并写一个 demo 脚本把它们叠加使用,验证元信息与行为都正确。参考答案要点每个装饰器都以@functools.wraps(func)开头、参数用*args, **kwargs透传;timed的计时与打印放 finally 以覆盖异常路径;retry只捕获指定异常且最后一次不再重试;validate用inspect.signature(func).bind(*args, **kwargs)绑定参数再逐个 isinstance 校验。叠加顺序示例:@log_calls在@retry之外,这样重试过程也只记一条日志。