🐍 附录A 列表生成式、装饰器、生成器和递归
📄 一句话概括

生成式就是「把 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 秒实验

  1. 把演示第二段的 if i%2==0 去掉,看结果变成什么;再把它挪到表达式里面写成三目,比较两者。
  2. 把演示第四段的 @functools.wraps(fn) 那一行删掉,再看 __name__ 和 __doc__ 打印出什么。
  3. 把演示第五段的 times=3 改成 times=2,看那个必失败的函数最后抛出什么异常。

常见误区

  1. 「生成式比 for 循环快很多。」省的是 append 那点开销,量级在几十毫秒。 为这点差别改代码,不划算。
  2. 「装饰器能改函数内部的逻辑。」它只能在调用前后加东西,包在外面。 要改里面的 if,只能改原函数。
  3. 「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 字节」,递归为什么一加缓存就从半秒变一瞬间。

相关笔记