楼层: 首页/ 软件技术/ Python 基础/ 装饰器与生成器
6

装饰器与生成器

Decorators & Generators

这俩是 Python 进阶的"分水岭"——搞懂了,你就从"会写脚本"升级到"看得懂框架源码"。装饰器是函数的包装纸,不改原函数就能加功能;生成器是"按需吐数据的流水线",省内存。

装饰器原理:函数也是对象

论装饰器到底是什么

Python 里函数能当参数传、能当返回值、能塞进变量——它就是个普通对象。装饰器做的事:接收一个函数,返回一个新函数(通常是包了一层的),然后用 @名字 语法"贴"在原函数头上。效果上,原函数被替换成了"包装版",但你不用改原函数一行代码。典型用途:打日志、计时、权限检查、缓存。

import time from functools import wraps # 一个计时装饰器:被装饰的函数跑完,自动打印耗时 def timer(func): @wraps(func) # 这行别忘,否则函数名会变成 wrapper def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) # 调用原函数 cost = time.perf_counter() - start print(f"[timer] {func.__name__} 耗时 {cost:.4f}s") return result return wrapper @timer def slow_add(a, b): time.sleep(0.1) return a + b slow_add(1, 2) # [timer] slow_add 耗时 0.1005s # 带参数的装饰器:再多包一层 def repeat(times): def decorator(func): @wraps(func) def wrapper(*a, **kw): last = None for _ in range(times): last = func(*a, **kw) return last return wrapper return decorator @repeat(3) def hello(): print("hi") hello() # 打印三遍 hi

迭代器协议:for 循环背后的规矩

你天天写 for x in lst:,但 Python 凭什么知道一个东西能被 for 遍历?答案是迭代器协议:一个对象只要实现了两个魔术方法,就能被 for 驱动。搞懂它,生成器、enumerate、文件逐行读就全通了。

论两条规矩:__iter__ 和 __next__

大白话:可迭代对象(iterable)像一份菜单,你能指着它说"给我出下一道菜";迭代器(iterator)就是那个上菜的机器人。for 循环干的事,就是不停喊"下一道",直到机器人说"没了"(抛出 StopIteration)。

协议:① __iter__() 返回迭代器自身;② __next__() 返回下一个值,没了就 raise StopIteration。

手动造一个"倒计时"迭代器,体验协议

class Counter: def __init__(self, start): self.n = start def __iter__(self): return self # 自己就是迭代器 def __next__(self): if self.n <= 0: raise StopIteration # 没了,通知 for 循环结束 self.n -= 1 return self.n + 1 for x in Counter(3): print(x, end=" ") # 3 2 1 # 手动用 next() 驱动,看 for 到底在干啥 it = iter(Counter(2)) print(next(it)) # 2 print(next(it)) # 1 next(it) # 抛 StopIteration

应用场景:自己造迭代器很少手写——因为生成器(下一节)用一个 yield 就自动帮你实现了这套协议。知道协议存在,是为了看懂 iter()/next() 内建函数、理解为什么文件对象能 for line in f。

防坑:迭代器是"一次性"的

迭代器遍历一次就空了。it = iter([1,2,3]); list(it) 得到 [1,2,3],再 list(it) 就是空 []。想重复遍历,要么重新 iter(),要么用列表。这和生成器"抽完即止"是一个道理。

练习:判断可迭代(点开对答案)

问:为什么 for i in 123: 会报错,但 for i in "abc": 不报错?
答:int 没有 __iter__,不是可迭代对象;str 实现了 __iter__,能被 iter() 包成迭代器逐字符吐。一句话:能被 iter() 调起的,才进得了 for。

生成器 yield:按需产出,省内存

# 普通函数:一口气把所有数算完放进列表,占内存 def fib_list(n): a, b, result = 0, 1, [] for _ in range(n): result.append(a) a, b = b, a + b return result # 全算完才返回 # 生成器:用 yield,每次调 next() 才往下算一步,不占内存 def fib_gen(n): a, b = 0, 1 for _ in range(n): yield a # 吐一个,暂停在这,下次从这继续 a, b = b, a + b for x in fib_gen(10): print(x, end=" ") # 0 1 1 2 3 5 8 13 21 34 # 生成器表达式:列表推导式的圆括号版,省内存 big_sum = sum(x*x for x in range(1_000_000)) # 不会真的生成一百万个元素,而是边算边求和

itertools:现成的迭代工具

工具干嘛用
itertools.count(10)从 10 开始数,无限数下去,配合 islice 截断。
itertools.cycle([1,2])1,2,1,2,... 无限循环。
itertools.chain(a, b)把多个迭代器串成一个。
itertools.groupby(lst, key)按 key 分组(注意先排序)。
itertools.islice(it, n)从迭代器里切前 n 个。

装饰器进阶:带参数、类装饰器、functools.wraps

前面写了最基础的计时装饰器。实战里你会遇到三种进阶形态:带参数的装饰器(多包一层)、类装饰器(用类当装饰器)、functools.wraps(保住原函数的名字和文档)。

① 带参数装饰器:repeat(3) 这种,外层接参数,中层接函数,内层接调用

from functools import wraps def repeat(times=2): """带参数的装饰器:@repeat(3) 会让函数执行 3 次""" def decorator(func): @wraps(func) # 关键:保住 func 的 __name__ 和 __doc__ def wrapper(*args, **kwargs): last = None for i in range(times): last = func(*args, **kwargs) return last return wrapper return decorator @repeat(3) def greet(name): """打个招呼""" print(f"hi {name}") greet("小明") # hi 小明 # hi 小明 # hi 小明 print(greet.__name__) # greet 没有 @wraps 的话这里会是 wrapper

② 类装饰器:用类实现 __call__,适合需要"记住状态"的场景

from functools import wraps class CountCalls: """类装饰器:统计函数被调用了几次""" def __init__(self, func): wraps(func)(self) # 把 func 的元信息复制到 self 上 self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 print(f"[第{self.count}次调用 {self.func.__name__}]") return self.func(*args, **kwargs) @CountCalls def add(a, b): return a + b add(1, 2) # [第1次调用 add] add(3, 4) # [第2次调用 add] print(add.count) # 2 状态保存在装饰器实例里

论为什么必须用 functools.wraps

不加 @wraps(func),被装饰后的函数 __name__ 会变成 wrapper,__doc__ 也丢了。后果:调试时栈追踪里全是 wrapper,logging 看不出谁在报错;Flask/FastAPI 的路由名还会串掉。写自定义装饰器第一行就写 @wraps(func),形成肌肉记忆。

生成器进阶:yield from 与 send / throw / close

普通生成器只会"吐数据"。进阶玩法是:yield from 把嵌套生成器拍平;send() 往生成器里"塞"数据(协程雏形);throw() 在生成器里抛异常;close() 提前终止。这就是 Python 协程的老祖宗。

① yield from:拍平嵌套迭代,还能拿到子生成器的 return 值

def inner(): yield 1 yield 2 return "inner 做完了" def outer(): yield 0 result = yield from inner() # 把子生成器的 yield 直接传出去,并接住 return yield result yield 3 print(list(outer())) # [0, 1, 2, 'inner 做完了', 3]

② send() / throw() / close():双向协程雏形

def echo(): print("生成器启动") try: while True: received = yield # yield 右边的值会被 send() 塞进来 print(f"收到:{received}") except GeneratorExit: print("被 close() 关闭,做清理") gen = echo() next(gen) # 先 next 一下,让它跑到第一个 yield # 生成器启动 gen.send("你好") # 往生成器里塞数据 # 收到:你好 gen.send("世界") # 收到:世界 gen.close() # 主动关闭,触发 GeneratorExit # 被 close() 关闭,做清理
生成器只能消费一次

生成器是"一次性"的:遍历完就空了,不会自动重置。list(gen) 跑完再 list(gen) 就是空列表。需要反复用就存成列表,或者每次重新调用生成器函数。另外 send() 第一次必须先 next() 或 send(None),否则会报 TypeError: can't send non-None value to a just-started generator——因为生成器还没跑到 yield 那行,没地方接数据。

本章面试题 · 装饰器与生成器

1.(概念题)写装饰器时为什么要加 functools.wraps?不加会怎样?

查看答案

答案:@wraps(func) 会把原函数的 __name__、__doc__、__module__ 等元信息复制到包装函数上。不加的话,wrapper.__name__ 全是 "wrapper",调试时看不出谁在调用,Flask/FastAPI 的路由名和文档也会错乱。

2.(代码题)写一个装饰器,给被装饰函数加"调用前打印参数、调用后打印返回值"的功能。

查看答案

答案:在 wrapper 里先 print 参数,调 func,再 print 返回值。解析:核心就是"在调原函数前后各插一句 print",*args/**kwargs 保证任意签名都能包。别忘 @wraps(func)。

3.(概念题)生成器和列表有什么区别?什么时候必须用生成器?

查看答案

答案:列表一次性把所有元素放进内存;生成器"按需吐一个算一个",内存占用恒定,还能表示无限序列。处理超大文件、流式数据、无限序列(如斐波那契无限长)时必须用生成器。代价是生成器只能遍历一次。

4.(代码题)yield from 解决了什么问题?list(b()) 输出什么?(b 里 yield from a(),a yield 1,2,b 还 yield 0 和 3)

查看答案

答案:yield from 把子生成器的 yield 直接桥接到外层,不用手写 for x in a(): yield x。输出 [0, 1, 2, 3]。它还能接住子生成器的 return 值和 send/throw/close,是 asyncio 协程的底层基石。

5.(思考题)为什么说"生成器是 Python 协程的雏形"?send() 扮演了什么角色?

查看答案

答案:普通函数一去不回,生成器能在 yield 处暂停、被 next 唤醒——这就是"协程"的核心语义。send(x) 让调用方和生成器双向通信:不仅能拉数据,还能推数据进去。Python 3.4 的 @asyncio.coroutine 就是用 yield from + send 实现的,后来才演变成 async/await 语法。

内存管理:Python 怎么回收对象

C 语言要手动 malloc/free,Python 自动帮你收拾。但"自动"不等于"不用懂"——搞懂引用计数和垃圾回收,才解释得清"为啥内存涨了"和"怎么省"。

论两招:引用计数 + 分代 GC

大白话:每个对象心里记着"有几个人引用我"(引用计数)。一旦归零,当场销毁。这招又快又及时,但有个漏洞——循环引用:a 里塞着 b,b 里塞着 a,外部没人要了,可它俩互相记着,计数都不归零。于是 Python 再加一个分代垃圾回收(GC),定期扫描这种"只被彼此拽着"的孤儿,统一销毁。

看引用计数,手动触发 GC

import sys, gc # 引用计数:sys.getrefcount 能看,但它自己也会 +1 a = [] print(sys.getrefcount(a)) # 至少 2:getrefcount 调用本身 + 局部 # 循环引用:a 和 b 互相拽着 class Node: def __init__(self): self.partner = None x = Node(); y = Node() x.partner = y; y.partner = x del x, y # 外部引用没了,但它俩还互相指着 gc.collect() # 1 手动跑一次分代 GC,回收这对孤儿

__slots__:牺牲灵活,换内存和速度

# 默认每个实例都带一个 __dict__ 字典存属性,百万实例时很占内存 class PointSlow: def __init__(self, x, y): self.x = x self.y = y # 加了 __slots__:实例不再带 __dict__,属性写死在类里 class PointFast: __slots__ = ("x", "y") # 只允许这两个属性 def __init__(self, x, y): self.x = x self.y = y p = PointFast(1, 2) print(p.x) # 1 p.z = 3 # 报错!__slots__ 没列 z,禁止乱加属性

弱引用 weakref:缓存别把对象"焊死"

import weakref class Big: pass b = Big() r = weakref.ref(b) # 弱引用:不增加引用计数 print(r()) # <__main__.Big object ...> 用 r() 取回原对象 del b # 唯一强引用删了,对象立刻被回收 print(r()) # None 对象没了,弱引用自动变成 None

应用场景:① 百万级小对象(游戏里的粒子、日志条目)用 __slots__ 能省一大截内存;② 做 LRU 缓存、对象注册表时用 weakref.WeakKeyDictionary,没人用的对象自动清,不会造成"逻辑上的内存泄漏"。

防坑:__slots__ 的代价

加了 __slots__ 后,实例不能再动态加属性,也不能随便多继承多个都定义了 __slots__ 的类(会有坑)。它是性能优化,不是默认写法——只有当你真的要造成千上万个轻量对象时才上,平时别过早优化。

练习:内存小测(点开对答案)

问:全局放一个 dict,里面长期存着临时对象,会怎样?
答:这些对象的引用计数永远 ≥1,GC 永远收不掉,造成内存只涨不降。修法:用完 d.pop(key) 删掉,或改用 weakref.WeakValueDictionary,外部一没人引用就自动从缓存消失。