生成式就是「把 for 循环压成一行」。它不改变性能,改变的是读法。
装饰器是一个「接收函数、返回新函数」的函数。
@timer 等价于 slow_sum = timer(slow_sum),原函数一个字都不用改。
functools.wraps 一定要加。 不加,函数名和文档全没了,
排错时你会在调用栈里看到一堆叫 wrap 的东西。
正文十四章走完了。附录 A 是三个「语法上的加速器」,前两节在这一篇。 教材附录 A 开头两节是【列表生成式】和【装饰器语法说明】。 这一篇全是纯 Python,本机真跑:两种写法对比、条件与嵌套、生成式与 map 的实测耗时、装饰器、 带参数的重试装饰器,都是实测输出。 附录不是选修。前面十四章里那些「一行造个列表」「给函数加个计时」的写法,靠的就是这两样东西。 只是正文没铺开讲。
一、两种写法,同一个结果
造一个平方表:for 循环要四行,生成式一行,两边结果一模一样。
sq = [i * i for i in range(8)] —— 中括号、表达式、for 子句,三样东西。
生成式就是「把 for 循环压成一行」。它不改变性能,改变的是读法。
读法的差别在哪?看到 sq = [] 之后跟着 append,你得往下读几行才知道这是在造列表;
看到生成式,一眼就知道「这是个新列表」。
判断用哪个其实很简单:「这是在造一个新列表」用生成式,「在攒东西、中间还有别的动作」用循环。
生成式还有个隐性好处:它是个表达式,能直接当参数传、能直接赋给变量、能直接 return。
循环是语句,做不到这些。
代价也要知道:
生成式没法单步调试,不能在里面 print,报错时行号指向一整行里的某一段。
写复杂了就得拆回循环。
二、加条件、加嵌套
带条件:[i for i in range(10) if i % 2 == 0] 得到 [0, 2, 4, 6, 8]。
这里的 if 是筛选——不满足的直接丢掉。
两层循环:[(a, b) for a in range(2) for b in range(2)] 得到四个组合。
顺序跟写 for 嵌套一样,前面那个是外层。
顺手加工:[w.strip().upper() for w in 一堆字符串],去空格再转大写。
演示里三个带空格的词变成 ['螺丝', '扳手', '电钻']。
| 写法 | if 在哪 |
结果个数 |
|---|---|---|
| 筛选 | 表达式后面 | 变少(不满足的丢掉) |
| 三目 | 表达式里面 | 不变(每个都留) |
这两个位置一定要分清。 写在后面是「先过滤」,写在里面是「每个都留,但值可能换一个」。 写反了不会报错,只会给你一堆不该有的数据。这类错误最难查,因为它不炸。 嵌套也别过头。三层以上就别用生成式了,压成一行之后谁也看不懂,还不如老实写循环。
三、生成式、map 谁快
演示第三段拿 20 万个元素做 x * 2 + 1:生成式最快一轮 0.0278 秒,map 加 lambda 是 0.0412 秒,
生成式快了 48%。
原因是 lambda 每调一次都有函数调用开销,而生成式的表达式是直接编进循环体的。
20 万次调用,开销就攒出来了。
但这个差距不值得为它改代码。 0.03 秒和 0.04 秒的差别,
在你的程序里根本看不出来——真正慢的永远是别的地方。
选哪个看可读性:要「变换」用 map 更直白,要「变换 + 筛选」用生成式更省事。 三个以上条件时,
老实写 for 循环。
顺便交代这个数的口径:同一进程、同一份数据、各预热一次、取三轮里最快的一轮。
换台机器数字会变,但「生成式略快」这个方向是稳的。
四、装饰器:不改原函数,加一层
@timer 挂在 slow_sum 上面,调用 slow_sum(1000000) 时先打印「用了多少毫秒」,
再把 499999500000 返回出来。
装饰器就是一个「接收函数、返回新函数」的函数。 就这么朴素。
@timer 等价于 slow_sum = timer(slow_sum)——原函数没改一个字,外面多套了一层。
所以那个 @ 不是什么魔法,就是一行赋值。理解这一点,装饰器就不用背了。
functools.wraps 一定要加。演示里能打印出 'slow_sum' 和 '把 1 到 n 加起来。',全靠它。
不加会怎样?slow_sum.__name__ 变成 wrap,__doc__ 变成 None。
排错看调用栈时,你会看到一堆叫 wrap 的函数,根本不知道是谁。
这个坑很隐蔽:功能完全正常,只是日志和调试信息全废了。所以它常常是上线之后才被发现。
五、带参数的装饰器
演示第五段那个函数前两次必失败、第三次成功,
套上 retry(times=3) 之后返回「第 3 次成功(一共调了 3 次)」。
带参数的装饰器是三层嵌套:最外层收参数,中间层收函数,最内层是真正执行的。
看着绕,但写顺了很省事——网络请求加重试、数据库连接重连、消息队列拉取失败重试,都是这个形状。
有个细节值得说:重试只对「可能恢复的失败」有用。 参数写错了、权限不对,
重试三次只是浪费三倍时间。
所以生产代码里的重试要配「只重试哪几类异常」,别一把抓 Exception。
演示里抓的是 Exception,只因为它是演示。
还有一个必须配的:退避。第一次隔 1 秒,第二次 2 秒,第三次 4 秒。
固定间隔去撞一个正在恢复的服务,等于帮忙压垮它。
六、什么时候别用装饰器
| 情况 | 为什么别用 |
|---|---|
| 只在一个地方用 | 直接在函数里写那三行更清楚 |
| 要改函数内部逻辑 | 装饰器只能包在外面,改不了里面的 if |
| 好几层叠着 | 调用栈难追,行号全在包装层 |
| 要传递额外状态 | 得靠闭包或全局变量,容易出隐蔽 bug |
装饰器的正确用途是「横切关注点」:计时、日志、重试、权限检查、缓存。
判断标准很简单:这段代码跟业务逻辑无关,而且要在很多个函数上都来一遍。 两条都满足,
才值得抽出来。
只满足一条就别用。
一个函数要计时,直接在函数里写两行 perf_counter 就好了,套一层装饰器只是让代码多了一次跳转。
七、动手 Demo:列表生成式与装饰器
先看同一个平方表的两种写法,再加条件和嵌套。 然后测一下生成式和 map 的耗时,最后写两个装饰器:一个计时,一个带参数重试。
import functools
import time
def sec(title):
print(' %s' % title)
print(' ' + '-' * 68)
print(' ① 造一个列表,两种写法')
print(' ' + '-' * 68)
sq1 = []
for i in range(8):
sq1.append(i * i)
sq2 = [i * i for i in range(8)]
print(' for 循环:')
print(' sq = []')
print(' for i in range(8):')
print(' sq.append(i * i)')
print(' → %r' % sq1)
print(' 生成式:')
print(' sq = [i * i for i in range(8)]')
print(' → %r' % sq2)
print(' 两者相等?%r' % (sq1 == sq2))
print(' 生成式就是「把 for 循环压成一行」。')
print(' 它不改变性能,改变的是读法:一眼能看出「这是在造一个新列表」,而不是「在攒东西」。')
print()
print(' ② 加条件、加嵌套')
print(' ' + '-' * 68)
evens = [i for i in range(10) if i % 2 == 0]
pairs = [(a, b) for a in range(2) for b in range(2)]
words = [w.strip().upper() for w in [' 螺丝 ', '扳手 ', ' 电钻']]
print(' 带条件 [i for i in range(10) if i%%2==0] → %r' % evens)
print(' 两层循环 [(a,b) for a in range(2) for b in range(2)] → %r' % pairs)
print(' 顺手加工 [w.strip().upper() for w in ...] → %r' % words)
print(' 条件写在后面叫「筛选」,写在前面(i if c else 0)叫「三目」。两个位置不同,结果不同,别混。')
print(' 两层循环的顺序跟写 for 嵌套一样:前面那个是外层。三层以上就别用生成式了,压成一行反而看不懂。')
print()
print(' ③ 生成式、map、filter 谁快')
print(' ' + '-' * 68)
data = list(range(200000))
def comp():
return [x * 2 + 1 for x in data]
def amap():
return list(map(lambda x: x * 2 + 1, data))
comp(), amap() # 各预热一次,冷启动不算
best1 = best2 = None
for _ in range(3):
t0 = time.perf_counter()
r1 = comp()
d1 = time.perf_counter() - t0
t0 = time.perf_counter()
r2 = amap()
d2 = time.perf_counter() - t0
best1 = d1 if best1 is None else min(best1, d1)
best2 = d2 if best2 is None else min(best2, d2)
print()
print('| 写法 | 最快一轮 | 结果一样吗 |')
print('|---|---:|---|')
print('| 列表生成式 | %.4f 秒 | %r |' % (best1, r1 == r2))
print('| map + lambda | %.4f 秒 | 同上 |' % best2)
print()
print(' 生成式这次快了 %.0f%%。' % ((best2 / best1 - 1) * 100))
print(' 因为 map 里那个 lambda 每调一次都有函数调用开销,而生成式的表达式是直接编进循环体的。')
print(' 但这个差距不值得为它改代码。')
print(' 选哪个看可读性:要「变换」用 map 更直白,要「变换 + 筛选」用生成式更省事。')
print(' 三个以上条件时,老实写 for 循环。')
print()
print(' ④ 装饰器:不改原函数,加一层功能')
print(' ' + '-' * 68)
def timer(fn):
@functools.wraps(fn)
def wrap(*a, **kw):
t0 = time.perf_counter()
r = fn(*a, **kw)
print(' %s 用了 %.2f 毫秒' % (fn.__name__, (time.perf_counter() - t0) * 1000))
return r
return wrap
@timer
def slow_sum(n):
u"""把 1 到 n 加起来。"""
return sum(range(n))
print(' @timer 挂在 slow_sum 上面,调用时:')
v = slow_sum(1000000)
print(' 返回值 %d' % v)
print(' 函数名还是 %r(functools.wraps 保住的)' % slow_sum.__name__)
print(' 文档字符串还在:%r' % (slow_sum.__doc__ or '').strip())
print(' 装饰器就是一个「接收函数、返回新函数」的函数。')
print(' @timer 等价于 slow_sum = timer(slow_sum)——原函数没改一个字,外面多套了一层。')
print(' functools.wraps 一定要加。')
print(' 不加的话,slow_sum.__name__ 会变成 wrap,文档也没了——排错看调用栈时会看到一堆叫 wrap 的函数,')
print(' 根本不知道是谁。')
print()
print(' ⑤ 带参数的装饰器:自动重试')
print(' ' + '-' * 68)
def retry(times=3, delay=0.05):
def deco(fn):
@functools.wraps(fn)
def wrap(*a, **kw):
last = None
for _ in range(times):
try:
return fn(*a, **kw)
except Exception as e:
last = e
time.sleep(delay)
raise last
return wrap
return deco
calls = [0]
@retry(times=3, delay=0.01)
def flaky():
calls[0] += 1
if calls[0] < 3:
raise RuntimeError('第 %d 次失败' % calls[0])
return '第 %d 次成功' % calls[0]
print(' 一个前两次必失败、第三次才成功的函数:')
print(' %s(一共调了 %d 次)' % (flaky(), calls[0]))
print(' 带参数的装饰器是「三层嵌套」:最外层收参数,中间层收函数,最内层是真正执行的。')
print(' 看着绕,但写顺了很省事——网络请求加重试、数据库连接重连都是这个形状。')
print()
print(' ⑥ 什么时候别用装饰器')
print(' ' + '-' * 68)
print()
print('| 情况 | 为什么别用 |')
print('|---|---|')
for r in (('只在一个地方用', '直接在函数里写那三行更清楚,多一层跳转反而难读'),
('要改函数内部逻辑', '装饰器只能包在外面,改不了里面的 if'),
('好几层装饰器叠着', '调用栈会变得很难追,出错时看到的行号全在包装层'),
('需要传递额外状态', '得靠闭包或全局变量,容易出隐蔽的 bug')):
print('| %s | %s |' % r)
print()
print(' 装饰器的正确用途是「横切关注点」:计时、日志、重试、权限检查、缓存。')
print(' 这些事情跟业务逻辑无关,又要在很多函数上用,才值得抽出来。')
实跑输出:
--------------------------------------------------------------------
列表生成式与装饰器
--------------------------------------------------------------------
① 造一个列表,两种写法
--------------------------------------------------------------------
for 循环:
sq = []
for i in range(8):
sq.append(i * i)
→ [0, 1, 4, 9, 16, 25, 36, 49]
生成式:
sq = [i * i for i in range(8)]
→ [0, 1, 4, 9, 16, 25, 36, 49]
两者相等?True
生成式就是「把 for 循环压成一行」。
它不改变性能,改变的是读法:一眼能看出「这是在造一个新列表」,而不是「在攒东西」。
② 加条件、加嵌套
--------------------------------------------------------------------
带条件 [i for i in range(10) if i%2==0] → [0, 2, 4, 6, 8]
两层循环 [(a,b) for a in range(2) for b in range(2)] → [(0, 0), (0, 1), (1, 0), (1, 1)]
顺手加工 [w.strip().upper() for w in ...] → ['螺丝', '扳手', '电钻']
条件写在后面叫「筛选」,写在前面(i if c else 0)叫「三目」。两个位置不同,结果不同,别混。
两层循环的顺序跟写 for 嵌套一样:前面那个是外层。三层以上就别用生成式了,压成一行反而看不懂。
③ 生成式、map、filter 谁快
--------------------------------------------------------------------
| 写法 | 最快一轮 | 结果一样吗 |
|---|---:|---|
| 列表生成式 | 0.0278 秒 | True |
| map + lambda | 0.0412 秒 | 同上 |
生成式这次快了 48%。
因为 map 里那个 lambda 每调一次都有函数调用开销,而生成式的表达式是直接编进循环体的。
但这个差距不值得为它改代码。
选哪个看可读性:要「变换」用 map 更直白,要「变换 + 筛选」用生成式更省事。
三个以上条件时,老实写 for 循环。
④ 装饰器:不改原函数,加一层功能
--------------------------------------------------------------------
@timer 挂在 slow_sum 上面,调用时:
slow_sum 用了 20.02 毫秒
返回值 499999500000
函数名还是 'slow_sum'(functools.wraps 保住的)
文档字符串还在:'把 1 到 n 加起来。'
装饰器就是一个「接收函数、返回新函数」的函数。
@timer 等价于 slow_sum = timer(slow_sum)——原函数没改一个字,外面多套了一层。
functools.wraps 一定要加。
不加的话,slow_sum.__name__ 会变成 wrap,文档也没了——排错看调用栈时会看到一堆叫 wrap 的函数,
根本不知道是谁。
⑤ 带参数的装饰器:自动重试
--------------------------------------------------------------------
一个前两次必失败、第三次才成功的函数:
第 3 次成功(一共调了 3 次)
带参数的装饰器是「三层嵌套」:最外层收参数,中间层收函数,最内层是真正执行的。
看着绕,但写顺了很省事——网络请求加重试、数据库连接重连都是这个形状。
⑥ 什么时候别用装饰器
--------------------------------------------------------------------
| 情况 | 为什么别用 |
|---|---|
| 只在一个地方用 | 直接在函数里写那三行更清楚,多一层跳转反而难读 |
| 要改函数内部逻辑 | 装饰器只能包在外面,改不了里面的 if |
| 好几层装饰器叠着 | 调用栈会变得很难追,出错时看到的行号全在包装层 |
| 需要传递额外状态 | 得靠闭包或全局变量,容易出隐蔽的 bug |
装饰器的正确用途是「横切关注点」:计时、日志、重试、权限检查、缓存。
这些事情跟业务逻辑无关,又要在很多函数上用,才值得抽出来。
第三段那两个秒数每次跑都不一样,你这台机器跑出来是别的数。
要看的是「生成式比 map 快」这个方向,不是具体数字。
第四段的打印顺序值得注意:「用了多少毫秒」那行是装饰器里打的,「返回值」那行是外面打的。
中间隔了一层,但看起来像顺着的。
第五段那个 delay=0.01 是为了让演示快点跑完。
真实项目里是 1 秒起步,而且要指数退避——1 秒、2 秒、4 秒。
第六段那张表里「只在一个地方用」这条最常见。
见过太多人为了一个函数写个装饰器,结果读代码的人还得跳过去看一眼。
还有一点:装饰器会掩盖函数签名。
用 IDE 看 slow_sum 的参数提示,看到的是「任意位置参数加任意关键字参数」,不是原来的 n。
functools.wraps 能修好一部分,但类型注解还是丢的。
30 秒实验
- 把演示第二段的
if i%2==0去掉,看结果变成什么;再把它挪到表达式里面写成三目,比较两者。 - 把演示第四段的
@functools.wraps(fn)那一行删掉,再看__name__和__doc__打印出什么。 - 把演示第五段的
times=3改成times=2,看那个必失败的函数最后抛出什么异常。
常见误区
- 「生成式比 for 循环快很多。」省的是
append那点开销,量级在几十毫秒。 为这点差别改代码,不划算。 - 「装饰器能改函数内部的逻辑。」它只能在调用前后加东西,包在外面。
要改里面的
if,只能改原函数。 - 「
functools.wraps可有可无。」不加,函数名和文档全丢,日志和调试信息一起废。 功能正常但查不了错,比报错还难受。
自测三问(点开对答案)
1. `@timer` 到底做了什么?为什么说它不是魔法? —— 它等价于 `slow_sum = timer(slow_sum)`:把原函数传给 `timer`, `timer` 返回一个新的 `wrap` 函数,这个名字再绑回 `slow_sum`。 所以 `@` 就是一行赋值的语法糖,原函数一个字都没改,只是外面多套了一层。 2. 生成式里 `if` 写在后面和写在里面,有什么区别? —— 写在后面是筛选——不满足条件的元素直接丢掉,结果变少; 写在里面是三目——每个元素都留,只是值可能换成另一个。 演示里 `[i for i in range(10) if i%2==0]` 得到 5 个, 而 `[i if i%2==0 else 0 for i in range(10)]` 得到 10 个。 3. 为什么 `functools.wraps` 必须加? —— 因为装饰器返回的是新函数 `wrap`,不加 `wraps` 的话,原函数的 `__name__`、`__doc__`、 `__module__` 全被覆盖成 `wrap` 的。 功能还能跑,但日志里全是 `wrap`,调用栈里看不出是谁,排错基本靠猜。下一篇:生成器怎么做到「只占 200 字节」,递归为什么一加缓存就从半秒变一瞬间。
- ☐ (本篇完,下一篇 A.2 生成器与递归)