装饰器与生成器
这俩是 Python 进阶的"分水岭"——搞懂了,你就从"会写脚本"升级到"看得懂框架源码"。装饰器是函数的包装纸,不改原函数就能加功能;生成器是"按需吐数据的流水线",省内存。
装饰器原理:函数也是对象
论装饰器到底是什么
Python 里函数能当参数传、能当返回值、能塞进变量——它就是个普通对象。装饰器做的事:接收一个函数,返回一个新函数(通常是包了一层的),然后用 @名字 语法"贴"在原函数头上。效果上,原函数被替换成了"包装版",但你不用改原函数一行代码。典型用途:打日志、计时、权限检查、缓存。
迭代器协议:for 循环背后的规矩
你天天写 for x in lst:,但 Python 凭什么知道一个东西能被 for 遍历?答案是迭代器协议:一个对象只要实现了两个魔术方法,就能被 for 驱动。搞懂它,生成器、enumerate、文件逐行读就全通了。
论两条规矩:__iter__ 和 __next__
大白话:可迭代对象(iterable)像一份菜单,你能指着它说"给我出下一道菜";迭代器(iterator)就是那个上菜的机器人。for 循环干的事,就是不停喊"下一道",直到机器人说"没了"(抛出 StopIteration)。
协议:① __iter__() 返回迭代器自身;② __next__() 返回下一个值,没了就 raise 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:按需产出,省内存
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) 这种,外层接参数,中层接函数,内层接调用
② 类装饰器:用类实现 __call__,适合需要"记住状态"的场景
论为什么必须用 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 值
② send() / throw() / 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
__slots__:牺牲灵活,换内存和速度
弱引用 weakref:缓存别把对象"焊死"
应用场景:① 百万级小对象(游戏里的粒子、日志条目)用 __slots__ 能省一大截内存;② 做 LRU 缓存、对象注册表时用 weakref.WeakKeyDictionary,没人用的对象自动清,不会造成"逻辑上的内存泄漏"。
加了 __slots__ 后,实例不能再动态加属性,也不能随便多继承多个都定义了 __slots__ 的类(会有坑)。它是性能优化,不是默认写法——只有当你真的要造成千上万个轻量对象时才上,平时别过早优化。
练习:内存小测(点开对答案)
问:全局放一个 dict,里面长期存着临时对象,会怎样?
答:这些对象的引用计数永远 ≥1,GC 永远收不掉,造成内存只涨不降。修法:用完 d.pop(key) 删掉,或改用 weakref.WeakValueDictionary,外部一没人引用就自动从缓存消失。