老王的厨房里挂着三张菜谱:红烧肉、糖醋鱼、麻婆豆腐。食客越来越多,老板跑来提要求:“每道菜出锅前,记一下花了多久,好算成本。”老王不想改菜谱——那是几十年练出来的手艺。这时帮厨小赵说:“王叔您照常做,我站旁边记就行。”于是,菜还是那道菜,出锅却多了一份时间记录。这位不修改菜谱、只负责在旁边加流程的帮厨,就是“装饰器”。 可生意再火,老王也发愁:自助餐要提前备 1000 份菜,冰箱(内存)塞得满满当当,剩下还浪费。他决定改成“按单炒菜”:客人点一道,他炒一道;炒到一半来了新单,他先记着当前进度,回头接着炒。这种“要一道给一道、随取随续”的接单模式,就是“生成器”。 这篇文章就用这间厨房,带你搞懂 Python 里两样高级武器:给函数配帮厨的装饰器,和按单炒菜的生成器。

岗位关联

学会装饰器与生成器,你就能看懂框架源码里那些“魔法”写法,写出高复用、省内存的生产级 Python 代码——这是从“会写”走向“会设计”的关键一步。

目录
  1. 可视化演示
  2. 装饰器入门:给函数配个帮厨
  3. 闭包原理:帮厨的备忘小抄
  4. 带参装饰器:能接单的帮厨工厂
  5. 内置装饰器:厨房的三个固定工位
  6. 生成器基础:按单炒菜的大厨
  7. 生成器进阶:客人可以中途改单
  8. yield from:整块甜品交给副厨
  9. 实战应用:厨房的黄金时代
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

通过文氏图展示条件概率P(A|B)=P(A∩B)/P(B)的计算过程,调节滑块改变事件概率,实时观察条件概率的变化。

本节配有交互动效(进入视口后自动加载,约 3KB)。

条件概率 P(A|B)=P(A∩B)/P(B) 后验概率公式 文氏图交集区域代表A与B同时发生 P(A|B) = P(A∩B) / P(B)

装饰器入门:给函数配个帮厨

老王的招牌菜“红烧肉”远近闻名。老板想记录每道菜的出菜时间,老王不想改菜谱——改坏了算谁的?帮厨小赵主动请缨:“菜谱不用动,我站在灶台边计时就行。出锅前我记一笔,菜还是您那盘菜。”装饰器就是这位小赵:不动原函数一行代码,在它的前后插入额外动作。

小赵能做到这一切,前提是 Python 里“函数也是对象”——函数可以像变量一样被传递、被赋值、被返回。既然老王(原函数)能被当作参数递给小赵,小赵就能包一层再还回来。在 Python 里,装饰器就是一个“接收函数、返回新函数”的普通函数。

最朴素的装饰器长这样:def timer(func): 定义一个包装函数 wrapper(*args, kwargs),在调用 func 前后做点手脚,最后返回 wrapper。而 @timer 写在函数定义上方,等价于一行赋值:func = timer(func)。@ 只是语法糖,把“帮我给函数加包装”这件事写得更直观。

有了帮厨队伍,老板的厨房开始规范起来:有的负责记录时间,有的负责检查摆盘。重要的是,哪道菜要配哪位帮厨,写在菜谱上方一行 @ 就够,菜谱本身一个字都不用改。装饰器之所以是“高级武器”,就是因为它把“横切关注点”(日志、计时、鉴权)从业务代码里剥离出来,让每道菜谱保持纯粹。

decorated=extra∘original\text{decorated} = \text{extra} \circ \text{original}
装饰后的函数 = 附加动作套在原始函数外面,就像帮厨站在大厨身边:菜品不变,流程变长。
  1. original(原函数)老王的菜谱,也就是函数本体,它是被包装的对象。
    original:X→Y\text{original}: X \to Y
  2. extra(附加动作)帮厨增加的记录、检查等额外步骤,它接收原函数产出的结果。
    extra:Y→Z\text{extra}: Y \to Z
  3. 合成(∘,composition)装饰器把 original 送进 extra 的流程里,得到的新函数叫 decorated。调用它,等价于先跑原函数、再执行附加动作。
    decorated=extra∘original\text{decorated} = \text{extra} \circ \text{original}

闭包原理:帮厨的备忘小抄

小赵记性好,但好记性不如烂笔头。主厨交代他“出锅前撒一把葱花”,他就把这条规矩写在随身小抄上。有一天小赵跳槽到隔壁餐厅,只要小抄还在,他给新主厨打下手时,依然会记得撒葱花。语法上,小抄上记的“葱花规矩”,并不属于新主厨的厨房——它属于小赵自己。

装饰器的内层函数(小赵)引用了外层函数(主厨)的局部变量(葱花规矩)。按理说,外层函数执行完就结束了,局部变量应该被回收。但 Python 发现内层函数还“惦记”着这个变量,就会把它打包进一个叫闭包的特殊对象里,跟着内层函数走。

技术细节有三点:第一,被“惦记”的变量叫自由变量;第二,它们存在内层函数的 __closure__ 元组里,每个单元对应一个变量;第三,如果内层函数要修改自由变量,必须显式声明 nonlocal,否则 Python 会认为它是一个新的局部变量。来看看一个标准闭包示例 make_adder(n):它接收一个数 n,返回一个新函数,新函数把任何输入都加上 n。

回到厨房:闭包就是那张小抄。装饰器能“记住”参数、记住配置,靠的全是这张小抄。很多框架里“携带配置的包装函数”,本质上都是闭包在发力——比如 flask 的路由注册、click 的命令行选项,都是先把参数包进闭包,再返回一个懂规矩的函数。

make_adder(n)=g,g(x)=n+x\text{make\_adder}(n) = g, \qquad g(x) = n + x
外层函数返回的不只是一个函数,还捎带上了它“记住”的变量 n——这就是小抄跟着帮厨走。
  1. make_adder(外层函数)主厨定规矩,接收参数 n,并返回一个内层函数作为“成品帮厨”。
    make_adder(n):R→(R→R)\text{make\_adder}(n): \mathbb{R} \to (\mathbb{R} \to \mathbb{R})
  2. g(内层函数)小抄上写的是“加 n”,g 就是被返回的闭包函数,它把 n 与参数 x 相加。
    g(x)=n+xg(x) = n + x
  3. n(自由变量)n 不在 g 的参数列表里却被 g 引用,Python 特地把 n 装进闭包单元,让 n 跟着 g 去任何地方。
    n∈closure(g)n \in \text{closure}(g)

带参装饰器:能接单的帮厨工厂

老板又提新需求:“小赵,以后不光计时,还要在记录里写上菜名标签,方便对账。”小赵心想:光靠一张通用小抄不够了,得按老板的要求定制。于是出现了一种新角色:帮厨工厂。工厂根据订单参数——比如“标签前缀”——造出一个符合要求的帮厨。

对应到 Python,就是带参装饰器。它比普通装饰器多一层:最外层函数接收参数(如 repeat(3) 里的 3),返回一个真正的装饰器,这个装饰器再接收函数,返回包装函数。三层嵌套像套娃,但每一层职责清晰:最外层“接参数”,中间层“接函数”,最内层“接调用”。

经典例子 @repeat(3):repeat(3) 先执行,拿到参数 3,返回装饰器 decorator;decorator 作用于原函数,返回 wrapper;以后每次调用,原函数被执行 3 遍。整个过程可以拆解为“先定制、再包装”两步——和普通装饰器唯一的区别是,多了一道“先用参数定制”的工序。

帮厨工厂让老板的厨房彻底规范化:想给接口加上“限流 + 日志 + 超时”,写三个带参装饰器叠上去就行,每个帮厨由对应工厂定制、互不干扰。日常开发中,functools.lru_cache(maxsize=128)、flask 的 app.route('/path') 都是带参装饰器的典型代表——你传的是参数,拿到的是定制好的包装。

repeat(3)=decorator,decorator(f)=w\text{repeat}(3) = \text{decorator}, \qquad \text{decorator}(f) = w
带参装饰器 = 参数先定制一个帮厨,再由这个定制帮厨去包原函数,三段式流水线。
  1. repeat(3)(工厂接单)先调用最外层函数,参数 3 进入闭包,返回一个定制好的装饰器。
    repeat(3)=decorator\text{repeat}(3) = \text{decorator}
  2. decorator(f)(定制包装)装饰器接收原函数 f,返回包装函数 w,这一步和普通装饰器完全一致。
    decorator(f)=w\text{decorator}(f) = w
  3. w(最终交付)以后调用 w(x),内部会按工厂定的规矩,把 f 执行指定次数。
    w(x)=run(f,x,3)w(x) = \text{run}(f, x, 3)

内置装饰器:厨房的三个固定工位

老王的厨房规模大了,设了三个固定工位。第一个是“独立帮工”:他只管按单做事,不需要知道主厨是谁,有点像外聘的计件师傅。第二个是“值班领班”:他代表整个厨房接活,知道厨房有哪些人、有什么资源。第三个是“扫码取餐口”:顾客扫个码,餐食就从窗口递出来,完全不用和后厨打交道。

这三个工位对应 Python 类的三个内置装饰器:@staticmethod(独立帮工:不接收 self 也不接收 cls,就是放在类里的普通函数)、@classmethod(值班领班:自动接收类 cls,能访问类属性、支持继承)、@property(扫码取餐口:把方法伪装成属性,obj.price 直接取值,背后其实在跑函数)。

关键区别在签名。实例方法写 def f(self, x);@staticmethod 转换后写成 def f(x)——self 消失了;@classmethod 则写成 def f(cls, x)——self 换成了 cls。@property 则把读操作变成 obj.attr,还可以搭配 setter 控制赋值。这些内置装饰器让类设计优雅许多:只读属性、工厂方法、工具函数都能各归其位。

三个工位让厨房既灵活又有秩序:外聘帮工只做事不问出身,值班领班代表集体做决策,扫码窗口把内部复杂度藏起来。内置装饰器不多,但用好它们,类的可读性立刻上一个台阶——这也是你读 Django、SQLAlchemy 源码时最常见的三种“类魔法”。

Method(self,x)  ⟼  f(x)\text{Method}(self, x) \;\longmapsto\; f(x)
@staticmethod 把“实例方法”转型成“普通函数”,签名的 self 被抹掉,像独立帮工不需要认识主厨。
  1. Method(self, x)(实例方法)默认签名里有 self,代表实例自己,Python 调用时会自动传入。
    Method(self,x)\text{Method}(self, x)
  2. longmapsto(转型)@staticmethod 做了一次签名层面的“换人”,self 参数被移除。
    ⟼\longmapsto
  3. f(x)(普通函数)转型后不接收 self 也不接收 cls,像独立帮工,只做事、不问厨师是谁。
    f(x)f(x)

生成器基础:按单炒菜的大厨

自助餐模式让老王头疼:每天提前备 1000 份菜,冰箱塞爆,晚上剩下几百份倒掉。他决定改做“按单炒菜”:客人点一道,他炒一道;不点不炒,冰箱里永远只有一份食材。这就是生成器——按需生产,绝不囤货。

语法上,只要函数体里出现 yield 关键字,这个函数就“变身”了。注意:调用它不会执行函数体,而是返回一个生成器对象。next(gen) 或 for 循环才会真正驱动函数体运行,一路执行到 yield,暂停,把一个值“递”出来。这里有个极易踩的坑:写 gen = countdown(5) 时,厨房并没有开火,只是挂了单。

生成器的价值在内存。列表 list(range(108)) 会一次性吃掉几百 MB 内存;而生成器每次只产生一个数,内存恒定。它还能表达无限序列——比如不断吐出奇数,因为根本不需要事先存下来。代价是:它只能从前往后走一遍,不支持下标访问,也不能回头。这就像按单炒菜:菜出完就没了,想再吃要重新下单。

老王的冰箱终于空出来了。处理几 GB 日志、几百万条训练样本时,按单炒菜(生成器)让内存占用稳稳地停在“一盘菜”的量级——这是数据工程师和 AI 工程师处理海量数据的首选姿势。

memorylist=O(n),memorygen=O(1)\text{memory}_{\text{list}} = O(n), \qquad \text{memory}_{\text{gen}} = O(1)
列表的内存随元素数量线性增长,生成器的内存永远只占一份——这是它们最本质的分水岭。
  1. memory_list = O(n)列表一次性把 n 个元素全部装进内存,相当于冰箱里备齐 1000 份菜。
    memorylist=O(n)\text{memory}_{\text{list}} = O(n)
  2. memory_gen = O(1)生成器任意时刻只保留一个产出值,内存是常数,永不随数据量膨胀。
    memorygen=O(1)\text{memory}_{\text{gen}} = O(1)
  3. 两者关系n 越大,差距越悬殊;生成器本质上是在用“时间换空间”。
    lim⁡n→∞memorylistmemorygen=∞\lim_{n \to \infty} \frac{\text{memory}_{\text{list}}}{\text{memory}_{\text{gen}}} = \infty

生成器进阶:客人可以中途改单

老王正炒着宫保鸡丁,客人喊了一嗓子:“别放花生!”老王手一顿,把刚想倒进去的花生收回,调整完继续往下炒。这个“炒一半还能沟通”的能力,就是 send 方法——它能在生成器暂停时,从外部传一个值进去,代替 yield 表达式的结果。

gen.send(value) 把 value 送回暂停处的 yield 表达式,生成器接着往下跑,直到下一个 yield 再停下来。特别地,第一次唤醒不能用 send(非None),得用 gen.send(None) 或 next(gen)。有了 send,生成器就不只是“单向水管”,而是“双向对讲机”——这就是协程的雏形:两个函数互相喊话、交替推进。

同一个家族还有“生成器表达式”:(x * x for x in range(n))。它和列表推导式 [x * x for x in range(n)] 长得像,但前者是惰性的生成器,后者立即建出整个列表。写代码时一个括号之差,内存天壤之别。很多 AI 框架的数据加载器(DataLoader)就是靠生成器表达式 + send 类机制,实现“边训练边喂数据”。

客人能改单,主厨能沟通,厨房从“单向出菜”进化成“前后场对话”。很多异步框架的协程、async for 异步生成器,都是在这个机制上长出来的——理解了 send,你就在心里种下了一颗协程的种子。

yk=gen.send(xk)y_k = \text{gen}.\text{send}(x_k)
每 send 一次,调用方往暂停处塞进一个消息 x_k,生成器接着跑,再吐出一个新结果 y_k。
  1. gen(暂停中的生成器)生成器运行到 yield 停住,像一个做到一半的厨师,等待外部消息。
    state=PAUSED\text{state} = \text{PAUSED}
  2. send(x_k)(客人喊话)x_k 被送回 yield 表达式,作为它的结果继续往下执行。
    value=yield\text{value} = \text{yield}
  3. 循环往复每 send 一次,生成器前进一段并再次暂停,形成双向对话般的交替推进。
    x0→y0→x1→y1→⋯x_0 \to y_0 \to x_1 \to y_1 \to \cdots

yield from:整块甜品交给副厨

老王接了个婚宴大单,几十桌。他忙不过来,把甜品区整个交给副厨阿强:“阿强,甜品你全权负责,客人有什么特殊要求直接跟你对接,不用经过我。”这种“整块移交 + 直接对接”的委托方式,就是 yield from。

作用:在一个生成器里写 yield from sub_gen,等于把 sub_gen 整个“外包”出去,主生成器不再逐个搬运 sub_gen 的值,而是让调用方直接和 sub_gen 打交道。写出来最省,读起来最清楚——你看代码时一眼就知道“这一整段是委托出去的”。

简单场景下,yield from s 和 for v in s: yield v 结果一样;但前者会自动透传 send()、throw()、close() 等消息,还能拿到子生成器的返回值。协程嵌套协作时,这种“协议透传”是普通 for 循环做不到的——就像老王如果一道道转达客人的要求,不仅慢,还可能传错话。

老王松了口气:每块业务交给一个副厨,主厨房只管协调。代码也一样——生成器层层委托,结构像菜单一样清晰,消息像服务员一样直达对应后厨。Python 标准库 contextlib、各种协程框架里,yield from 都是不可或缺的“委托枢纽”。

yield from  s  ⟺  for v in s: yield v\text{yield from}\; s \;\Longleftrightarrow\; \text{for } v \text{ in } s \text{: } \text{yield } v
在“只取值”的简单场景下,yield from 与 for+yield 结果等价;但前者还偷偷多干了“协议透传”的活。
  1. yield from s(委托)把子生成器 s 整体外包出去,主生成器只做传话人。
    yield from  s\text{yield from}\; s
  2. for+yield(等价写法)手动逐个转发值时,二者行为一致,这是多数入门教程展示的样子。
    for v in s: yield v\text{for } v \text{ in } s \text{: } \text{yield } v
  3. Longleftrightarrow(关键差异)等价只在“只取值”时成立,send/throw 的协议透传是 yield from 独有能力。
    send(m)→sub_gen. send(m)\text{send}(m) \to \text{sub\_gen}.\,\text{send}(m)

实战应用:厨房的黄金时代

厨房转型完成:每个厨师都配了帮厨(日志装饰器),招牌菜提前卤好冷藏、客人点了热一下再上(缓存装饰器),大订单按单炒菜绝不囤货(生成器流式处理)。这套组合拳让餐厅既快又省——装饰器管“加能力”,生成器管“省资源”。

最常见的实战:日志装饰器——给每个 API 端点记录调用时间、入参、出参,异常时自动告警,业务函数保持干净;缓存装饰器——functools.lru_cache 记住函数结果,相同参数直接秒回,省去重复计算;流式读文件——for line in open(...) 逐行读取,几十 GB 日志也只占一行内存,配合生成器管道可以边读边统计。

还有装饰器叠加:@a 在 @b 之上时,@b 先作用(离函数近的先包),@a 再包一层。这就像帮厨排队:最里面的帮厨最先接触原函数,最外面的帮厨最先接触调用方。叠加顺序不同,行为就不同——比如“先鉴权再计时”和“先计时再鉴权”,失败的请求计不计时,结果完全不一样。

从一张菜谱到一整套厨房体系,装饰器和生成器从来不是孤立技巧:装饰器让横切逻辑复用,生成器让数据流受控。框架作者每天都在用这套思维方式写代码——你掌握了它们,读源码时那些“魔法”就会变成“原来如此”。

ttotal=twrapper+tfunct_{\text{total}} = t_{\text{wrapper}} + t_{\text{func}}
加了装饰器之后,调用方感受到的总耗时 = 包装层的附加开销 + 原函数本身的耗时。
  1. t_func(原函数耗时)老王炒菜本身的时间,不掺任何水分的业务逻辑耗时。
    tfunct_{\text{func}}
  2. t_wrapper(包装开销)帮厨计时、记录日志、查缓存等附加动作的时间。
    twrappert_{\text{wrapper}}
  3. t_total(总耗时)调用方感受到的总时间,约等于两者相加;缓存命中时 t_func 趋近于 0。
    ttotal=twrapper+tfunct_{\text{total}} = t_{\text{wrapper}} + t_{\text{func}}

AI 应用场景

AI 服务统一鉴权与观测

阶段:模型部署与监控(MLOps)

解决的问题:AI 服务往往有几十个推理接口,传统做法是每个接口单独写鉴权、耗时统计、Token 计数,重复代码多、容易漏。用装饰器把“鉴权 + 计时 + 日志”写成通用包装,所有接口一行 @ 搞定,新模型上线零成本接入。

具体案例:某智能客服团队给 12 个模型端点统一加装饰器后,新模型上线改造成本从 1 天降到 10 分钟;线上出问题时,通过装饰器统一打印的 trace 日志,10 秒定位慢请求。

训练数据的流式加载

阶段:数据处理与训练管线

解决的问题:图像数据集动辄几十 GB,一次性读入内存直接 OOM。用生成器按 batch 产出数据,在每次 yield 前做随机裁剪、翻转等增强,内存恒定在一个 batch 的量级,训练循环代码也变得极其简洁。

具体案例:某 CV 团队训练目标检测模型时,用生成器实现“边读图、边增强、边喂 GPU”,4 卡训练 8 小时内存稳定,数据加载不再是训练瓶颈。

推理日志实时异常检测

阶段:模型监控与告警

解决的问题:推理服务每天产生几十 GB 日志,全量 grep 太慢。用生成器逐行流式解析,遇到错误率或延迟飙升立刻触发告警,内存占用恒定,管道式代码也容易扩展新指标。

具体案例:在线推荐系统用生成器管道串联“读日志 → 解析 → 聚合指标 → 告警”,异常检测延迟从分钟级降到秒级,磁盘 IO 压力也大幅下降。

Python 演示

python47 行
import timefrom functools import wraps  def timer(func):    """装饰器: 给原函数附加耗时统计, 原函数本身一字不改"""    @wraps(func)    def wrapper(*args, **kwargs):        start = time.perf_counter()        result = func(*args, **kwargs)        cost = time.perf_counter() - start        print("{} 运行耗时 {:.4f} 秒".format(func.__name__, cost))        return result    return wrapper  @timerdef sum_to_n(n):    """普通函数: 一次性算出 0 到 n 的和"""    return sum(range(n + 1))  def countdown(n):    """生成器函数: 执行到 yield 暂停并吐出一个值, 下次 next 再续上"""    while n >= 0:        yield n        n -= 1  def read_lines(path):    """生成器: 逐行读取大文件, 内存永远只占一行"""    with open(path, "r", encoding="utf-8") as f:        for line in f:            yield line.rstrip("\n")  if __name__ == "__main__":    total = sum_to_n(1000000)    print("sum_to_n(1000000) =", total)     print("countdown(5) 逐个产出:")    for x in countdown(5):        print("  得到", x)     gen = read_lines("no_such_file.txt")    print("创建 read_lines 生成器后函数体未执行, 文件尚未打开")    print("生成器类型:", type(gen).__name__)
目的:一个文件同时演示装饰器(计时包装)和生成器(yield 惰性产出),并验证“创建生成器不会执行函数体”。
  1. def wrapper(*args, **kwargs):万能传菜口,不管原函数收什么参数都能接住并转发。
  2. @wraps(func)把原函数的名字、文档字符串等元信息复制到包装函数上,调试时不会看到满屏的 "wrapper"。
  3. @timer语法糖,等价于 sum_to_n = timer(sum_to_n),把原函数名重新绑定到包装函数。
  4. yield n暂停函数执行并把 n 递出去,下次 next() 从这一行继续。
  5. while n >= 0:配合 yield 形成“每取一次、状态推进一次”的生成循环。
  6. for line in f:文件对象本身可迭代,逐行读取正是生成器流式处理的经典写法。
  7. gen = read_lines("no_such_file.txt")创建生成器不执行函数体,所以文件根本没被打开,完美展示惰性求值。
  8. for x in countdown(5):for 循环本质是不断调用 next(),直到 StopIteration 为止。

常见误区

误区我以为 `@timer` 之后,原函数就被替换销毁了,以后想用原函数,只能重新写一遍或者提前保存一份拷贝。

正解装饰器只是把函数名重新绑定到包装函数上,原函数对象一直存在,配合 functools.wraps 还能通过包装函数的 __wrapped__ 属性找回来。这就像老王配了帮厨小赵后,老王本人还在后厨炒菜,只是“老王”这个名字现在默认指向“带帮厨的完整流程”。想绕过帮厨,找到原函数对象直接调用即可,但日常调用走的是新流程。

为何常见:因为外部调用 func() 时,行为已经完全变成包装后的样子,很难意识到底层函数对象仍然活着。

误区我写了一个带 yield 的函数,然后 `g = mygen(5)`,以为这时候函数已经跑起来、停在第一个 yield,可以直接拿 g 当结果用。

正解调用生成器函数只会返回一个生成器对象,函数体一行代码都没执行。要等调用 next(g) 或用 for 迭代,函数体才开始跑,并在遇到 yield 时暂停。就像给厨师下单后,厨房并没有立刻开火,只有后厨喊“下一道!”才动锅——这也是“惰性”的由来。

为何常见:普通函数“调用即执行”的习惯太根深蒂固,忽略了带 yield 的函数被“工厂化”的差异。

误区惰性求值既省内存又省时间,以后凡是处理大批量数据,无脑用生成器就对了,性能肯定吊打列表。

正解生成器只保证内存占用低(O(1) 级别),并不保证速度快。逐项生成有函数帧切换和迭代协议的开销,在很多场景下比一次性构建列表慢。比如小数据量循环,列表反而更快。真正优势是“内存受控”和“流式处理”,适合海量数据和无界序列。自助餐虽浪费食材,但客人多时出餐快;按单炒菜省食材,但每道都现炒。

为何常见:“懒”听起来就像“快”,把省内存误当省时间,是惰性求值最常见的认知偏差。

误区反正都是放在类里、不用实例也能调用的装饰器,我就随便混着用,反正都能跑。

正解@staticmethod 装饰的函数不接收 self 也不接收 cls,就是放在类命名空间里的普通函数;@classmethod 装饰的函数会接收 cls(类本身),可以访问类属性、调用其他类方法,并且继承时正确传入子类。这就像“独立帮工”(不关心主厨是谁)和“值班领班”(代表整个厨房)的区别。混用会导致取不到类信息、继承行为出错。

为何常见:在简单类里两者调用方式看起来一样,只有用到继承和类属性时才暴露差异。

误区我看了很多例子,`yield from sub` 不就是 `for v in sub: yield v` 的缩写嘛,学会了缩写就完事了。

正解在“只取值”的简单场景两者等价;但 yield from 会把 send()、throw()、close() 这些消息自动透传给子生成器,并处理子生成器的返回值——这是普通 for+yield 做不到的。就像主厨把甜品整块交给副厨,并告诉服务员“甜品的所有要求直接对接副厨”,而不是一道道帮你转达。

为何常见:入门资料大多只演示简单迭代,很少展示 send/throw 的透传差异,容易形成“纯缩写”的印象。

面试题

Q1. 在 Python 中,@timer 放在函数定义上方,等价于下列哪种写法?

单选题 · easy

  • A.func(timer) 直接调用
  • B.func = timer(func)← 正确答案
  • C.timer = func(timer)
  • D.timer(func()) 自动调用

答案:B。@timer 是语法糖,等价于把函数名重新绑定到包装结果:func = timer(func)。选项 B 正确;A 把函数当参数传给 timer 的顺序反了,C 把赋值方向弄反,D 是直接调用函数拿返回值,都不对。就像帮厨小赵站在老王身边,“老王”这个名字仍然存在,但喊一声过来的是“配了帮厨的老王”。

Q2. 定义一个含有 yield 语句的函数并执行 gen = countdown(10),此时发生了什么?

单选题 · easy

  • A.立即计算出全部十个结果
  • B.创建生成器但函数体未执行← 正确答案
  • C.函数体执行到第一个暂停状态
  • D.返回一个只读的元组对象

答案:B。函数体里出现 yield,调用它只返回生成器对象,函数体一行都不跑。只有 next() 或 for 循环才会驱动执行。生成器函数像挂单厨师:你下单(创建对象),厨房不开火,直到有人喊“下一道”才动锅。A 错在以为立即算完,C 错在以为已执行,D 错在返回类型是生成器而非元组。

Q3. functools.wraps(func) 的主要作用是什么?

单选题 · easy

  • A.让包装函数继承原函数的元信息← 正确答案
  • B.让包装函数运行速度更快
  • C.让原函数可以接受更多参数
  • D.让原函数保持原有的返回值

答案:A。wraps 把原函数的名字、__doc__ 等元信息复制到包装函数上,否则调试时看到的函数名全是 "wrapper"。这是帮厨把小抄上先誊一份主厨的签名,方便外人认出这道菜出自谁手。B 的性能、C 的参数数目、D 的返回值都与 wraps 无关,那是装饰器本身的逻辑决定的。

Q4. 装饰器函数内部通常需要定义并返回一个内层函数,主要原因是?

单选题 · mid

  • A.内层函数才能访问原函数参数
  • B.必须要有闭包才能保存变量
  • C.包装逻辑需要延迟到调用时执行← 正确答案
  • D.内层函数速度比外层函数更快

答案:C。装饰器在函数定义时执行一次,而真正的包装逻辑要等函数被调用才跑,所以必须用内层函数把逻辑包起来返回。若装饰器直接执行逻辑,那在导入模块时就跑完了。A、B 是闭包附带的好处但非根本原因,D 毫无依据。像小赵不能在主厨上班前就替他切好菜,要等客人点菜时再动手。

Q5. 关于生成器与列表的区别,下列说法正确的有哪些?

多选题 · mid

  • A.生成器按需产出并节省内存← 正确答案
  • B.生成器可以重复迭代任意多次
  • C.生成器对象只能顺序访问← 正确答案
  • D.生成器一定比列表运行更快

答案:AC。生成器按需产出,内存只占当前一项,且只能迭代一遍、只能顺序访问(不能下标),所以 A、C 正确。B 错:生成器是一次性消耗品,想重来必须重新调用函数创建新对象。D 错:生成器省内存但不一定省时间,逐项产出有额外开销。自助餐 vs 按单炒菜:按单炒菜省料,但每道都是现炒,不保证更快。

Q6. 闭包中内层函数使用的“自由变量”存储在哪里?

单选题 · mid

  • A.全局命名空间的变量表里
  • B.外层函数的名字空间之外
  • C.内层函数代码对象的字节码里
  • D.闭包单元里由函数对象携带← 正确答案

答案:D。自由变量存在闭包单元(__closure__)里,挂在返回的函数对象上,随函数一起传递。A 错:它不是全局变量;B 错:它恰恰被内层函数捕获;C 错:字节码是代码指令,不是变量存储处。帮厨的小抄存放在帮厨的口袋里(函数对象的属性),不在主厨的厨房里,也不在餐厅大堂的公告栏上。

Q7. 下列哪个操作能让一个已耗尽的生成器重新从头开始迭代?

单选题 · hard

  • A.next(gen) 调用两次即可
  • B.传入 list(gen) 再取出来
  • C.gen.send(None) 恢复运行
  • D.重新调用生成器函数创建新对象← 正确答案

答案:D。生成器是一次性对象,迭代完就耗尽,无法复位。想重头再来,只能重新调用生成器函数,创建一个全新对象。A 会继续向下走直到 StopIteration;B 会把剩余元素全部消费掉;C 只是恢复已暂停的生成器,对已耗尽的无效。厨房按单炒菜,炒完这轮就没存货,想再吃一遍得重新下单。

Q8. yield from sub_gen 与 for v in sub_gen: yield v 的本质区别是什么?

单选题 · hard

  • A.前者迭代速度必然大幅提升
  • B.前者自动透传 send 与 throw← 正确答案
  • C.前者会复制一份新的子生成器
  • D.前者只能处理可迭代的生成器

答案:B。在“只取值”的简单场景两者等价,但 yield from 会把 send()、throw()、close() 等协议消息自动透传给子生成器,还能拿到子生成器的返回值,这是 for+yield 做不到的。A 无依据;C 不会复制;D 说反了——yield from 后面跟任意可迭代对象都可以。像主厨把甜品区全权交给副厨,客人要求直接对接副厨,而不是一道道转达。

Q9. 下列哪些属于 Python 内置的装饰器?

多选题 · hard

  • A.@staticmethod← 正确答案
  • B.@classmethod← 正确答案
  • C.@property← 正确答案
  • D.@synchronized

答案:ABC。@staticmethod、@classmethod、@property 都是 Python 内置装饰器,对应三种类方法形态;@synchronized 是 Java 风格关键字,Python 没有。staticmethod 不传 self,classmethod 传 cls,property 把方法变属性访问。它们就是厨房的三个固定工位:独立帮工、值班领班、扫码取餐口。

Q10. 关于生成器协程(send 与暂停恢复),下列说法正确的有哪些?

多选题 · extreme

  • A.send 能把数值回传给暂停处← 正确答案
  • B.首次唤醒要用 send(None)← 正确答案
  • C.生成器只能被 for 循环消费
  • D.生成器可被多个线程并发消费

答案:AB。send 能把数值回传给暂停处的 yield 表达式,A 正确;生成器首次唤醒不能 send 非 None 值,必须用 send(None) 或 next(gen),B 正确。C 错:next(gen) 同样可以逐个取值,for 只是更方便的语法。D 错:生成器不是线程安全的,多线程并发消费会乱序。协程雏形正是靠“send 传值 + 暂停恢复”这对组合实现的。