第2章 类以下层级的语法最佳实践
第2章 类以下层级的语法最佳实践
学习目标
- 能为映射/过滤、惰性取用、横切增强和资源清理分别选出推导式、生成器、装饰器或上下文管理器,并说出选错时出错的位置
- 能用
@wraps保留装饰函数的元数据、用@contextmanager把获取与释放绑成作用域,并说明异常时清理是否仍执行 - 自测:生成器表达式为什么只能遍历一次?把它当列表按下标取值会在哪一步报错?
为什么要在函数这一层挑语法
写 Python 时,同样一件事往往有好几种写法。把一段处理写成一行紧凑的表达,还是写成显式的循环;把“用到时才拿数据”写成按需取用,还是一次性全装进内存;把“进入前做准备、退出后收拾”写成固定形状,还是散落在各处。选哪一种,决定了别人能不能一眼看懂这段代码在做什么、出错时好不好追。
好的语法选择不是炫技,而是把责任讲清楚:谁负责取数据、谁负责存状态、谁负责善后。选错了,代码不会立刻报错,但会在别人接手、改需求或排查故障时露出破绽——一段看似聪明的写法,读起来像谜语;一处本该自动清理的资源,因为写法散乱而漏掉。
本章把“类以下”这一层的几样工具拆开看:它们各自替你管哪一段责任,什么时候该用,什么时候用了反而更糟。先建立这张全景,再去看具体写法,你就不会把“短”当成“好”,也不会为了显得高级而把简单的事写复杂。
先看全景:五种工具各管哪段责任
本章要拆的是“类以下”这一层——还没到类,光是函数和表达式这一层——的几样语法工具。它们各自替你管一段责任:谁负责算、谁负责存状态、谁负责善后。下面这张交互图把五种工具串成一条线,先点一遍每个节点看看它管什么、出什么错,再逐项拆开看。
推导式适合单一可读的映射或过滤,生成器表达式惰性传给消费者。复杂副作用时改回显式循环。
点开“注入故障”开关,能看到每种工具最常踩的那个坑——这正是后面“常见误区”要展开的内容。
推导式与生成器表达式
把一段映射或过滤写紧凑时,最常用的是 ↡一行表达式把循环里的映射和过滤浓缩成一个新列表。,它把“对每个元素做变换并收集”压成一行,读的人一眼能看出输入到输出的形状。它的惰性兄弟是生成器表达式——把方括号换成圆括号,元素按需产出而不一次装满内存。
判断该用哪种,看的是可读性和副作用。单一、可读的映射或过滤,推导式最合适;一旦出现多层嵌套、分支或复杂异常,继续硬塞进一行只会让代码变成谜语,这时应改回显式循环。生成器表达式适合把结果惰性地传给下游消费者(求和、拼接、入库),但当消费者需要多次访问或按下标取值时,它就得先物化成列表。
先写出这段处理“正常输入做什么、空输入做什么、出错时怎么传播”的契约,再决定用哪种写法。这样即使日后把实现换成别的工具,调用者仍能依据同一份契约判断结果对不对。
迭代器与生成器
推导式把数据一次性算完,而有些场景需要“边走边产”。这时用 ↡用 yield 暂停并把局部状态保存下来的函数,恢复后接着执行。,它不预先装满结果,而是在每次被取用时算出下一个值并交出,取完即停。它依赖的是迭代协议——iter() 拿到一个迭代器,next() 推进它,耗尽就抛 StopIteration。
生成器把暂停点和局部变量一起保存下来,所以像滑窗这种“记住前面几个元素”的逻辑写起来很自然。下面这段滑窗生成器两种视角对比同一段逻辑:
def windows(values, size):
iterator = iter(values)
window = []
for value in iterator:
window.append(value)
if len(window) == size:
yield tuple(window)
window.pop(0)iter(values) 创建迭代器 —— 把可迭代对象交给 next 协议
window 局部状态 —— 随 yield 暂停而保留,下次恢复继续
yield tuple 产出快照 —— 冻结当前窗口为不可变元组返回
pop(0) 滑动窗口 —— 弹出最早元素,为下一轮腾位要记住一个边界:惰性只降低“同时驻留在内存里的元素数”,它不会自动限制上游无限生产,也不会让你对生成器按下标取值。需要多次遍历或随机访问,就先用 list() 物化。抽象不能消除成本,它只是把成本挪了位置——生成器把内存成本换成时间成本,这笔账要写在明处。
协程
原书用生成器的 send 把数据推进一个暂停的执行体,这种 ↡能在某一点暂停、等待外部推入数据后再继续执行的执行体。 思路,把“消费—暂停—再消费”做成了协议。它和普通函数的区别在于:函数被调用就一路跑到底,协程可以在中间停住,等外面喂进来一个值再往下走。
现代 Python 的 async 协程语法已经不一样了——它由事件循环调度,而不是靠手动 send。但问题类别没变:消息怎么进、怎么取消、怎么关闭、异常怎么往外传,仍是同一类协议问题。所以看原书的 send 协程时,要提取的是“暂停点 + 输入 + 关闭”这套不变量,而不是照搬它的 API。
正因如此,把生成器 send 协程和 async 协程混用会直接报错:两者的调度协议不同,一个靠手动推值,一个靠事件循环。需要 I/O 并发就用 async/await,生成器 send 只留给惰性拉取场景。
装饰器
有些横切的需求——记日志、计时、缓存、权限检查——不想写进每个函数里。这时用 ↡在定义阶段用另一个可调用对象替换原函数的语法,常用于横切关注点。,它在函数定义的那一刻就把原函数换成一个包装器,调用者看到的接口不变,行为却被增强了一层。
包装器要做两件事:透传参数和返回值,以及保留原函数的身份。如果不保留元数据,help() 和调试器看到的就只剩 wrapper,原始函数名和文档都丢了。下面这段用 @wraps 复制元数据的装饰器两种视角对比同一段逻辑:
from functools import wraps
def traced(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"call={func.__name__}")
return func(*args, **kwargs)
return wrapperdef traced 装饰器函数 —— 接收被装饰函数 func
@wraps(func) 复制元数据 —— __name__/__doc__ 透传到 wrapper
wrapper 替换体 —— 调用时先记日志再转发,签名不变
return func 转发调用 —— 透传 *args/**kwargs,保留返回值装饰器适合“接口不变、只加一层”的横切契约;一旦把业务控制流藏进不可见的全局状态,调试就会变得困难。包装器必须说明它改了什么、吞了什么异常——透传是默认,拦截是例外。
上下文管理器
资源用完必须释放——文件要关、锁要放、事务要回滚。散落在各处的 close() 一遇异常就会漏。这时用 ↡把资源获取与释放绑成一段作用域、即使异常也执行清理的协议。,它把“进入前获取、退出后释放”绑成一个词法作用域,无论正常退出还是抛异常,清理都会执行。
最轻量的写法是用 @contextmanager 把一个生成器变成上下文管理器:yield 之前是获取,yield 把资源交给 with 块,finally 里是释放。下面这段打开文件的上下文管理器两种视角对比同一段逻辑:
from contextlib import contextmanager
@contextmanager
def opened(path):
handle = open(path, encoding="utf-8")
try:
yield handle
finally:
handle.close()open(path) 获取资源 —— yield 前执行,进入 with 块
yield handle 交接控制 —— 把资源交给 with 块内代码使用
finally 释放资源 —— 无论正常退出还是异常都执行 close
try/finally 异常安全 —— 保证资源不泄漏,除非主动吞掉退出方法必须明确是否吞掉异常:finally 里只做释放,不要顺手 except 把异常静默掉;__exit__ 返回 False 让异常继续传播。上下文管理器适合文件、锁、事务和临时资源,它的价值不在“少写一行 close()”,而在“异常路径也保证释放”。
常见误区
误区 1
现象 → 生成器表达式当列表用,取下标报错
原因 → 生成器是惰性迭代器,无 __getitem__,且只能遍历一次
修法 → 需要多次访问或下标取值时用 list(gen) 物化,或直接用列表推导
误区 2
现象 → 装饰后函数 help() 显示的是 wrapper 而非原函数
原因 → 装饰器替换了 __name__/__doc__,元数据丢失
修法 → 用 @functools.wraps(func) 复制被装饰函数的元数据到包装器
误区 3
现象 → with 块内抛异常后资源没释放
原因 → 上下文管理器的 __exit__/finally 被另一层 try 吞掉,或用了裸 except 静默异常
修法 → finally 中只做释放,不捕获异常;__exit__ 返回 False 让异常继续传播
误区 4
现象 → 生成器 send 协程与 async 协程混用,运行时报错
原因 → 两者协议不同:send 推入值到生成器,async 由事件循环调度
修法 → 现代 I/O 并发用 async/await,生成器 send 仅用于惰性拉取场景
小结
- 推导式用于可读映射/过滤,复杂副作用时改回显式循环
- 生成器保存暂停点与局部状态,惰性只降低驻留元素数
- 装饰器在定义阶段替换可调用对象,
@wraps保留元数据 - 上下文管理器把获取与释放绑成作用域,异常也执行清理
async/await取代生成器send协程,协议不同不可混用
练习与验收
练习
问题 1: 为什么生成器表达式只能遍历一次,而列表可以反复遍历?
问题 2: 装饰器不加 @wraps 会丢失什么?
问题 3: 用 @contextmanager 实现一个计时上下文:进入时记录开始时间,退出时打印耗时毫秒,要求异常时也打印并重新抛出。(独立实现)
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 列表推导式
一行表达式把循环里的映射和过滤浓缩成一个新列表。
- 生成器
用 yield 暂停并把局部状态保存下来的函数,恢复后接着执行。
- 协程
能在某一点暂停、等待外部推入数据后再继续执行的执行体。
- 装饰器
在定义阶段用另一个可调用对象替换原函数的语法,常用于横切关注点。
- 上下文管理器
把资源获取与释放绑成一段作用域、即使异常也执行清理的协议。