编写高质量Python (第34条) 不要用 send 给生成器注入数据

第 34 条 不要用 send 给生成器注入数据

​ yield 表达式让我们能够轻松地写出生成器函数,使得调用者可以每次只获取输出序列中的一项结果(参见第 30 条)。但问题是,这种通道是单向的,也就是说,无法让生成器其一端接收数据,同时在另一端给出计算结果。假如能实现双向通信,那么生成器的适应面回更广。

​ 例如,我们想用软件实现无线电广播,用它来发信号。为了编写这个程序,我们必须用一个函数来模拟正弦波,让它能够给出一系列按照正弦方式分布的点。

import math


def wave(amplitude, steps):
    step_size = 2 * math.pi / steps
    for step in range(step_size):
        radians = step * step_size
        fraction = math.sin(radians)
        output = amplitude * fraction
        yield output

​ 有了这个 wave 函数,我们可以让它按照某个固定的振幅生成一系列可供传输的值。

def transmit(output):
    if output is None:
        print(f'Output is None')
    else:
        print(f'Output: {output:>5.1f}')


def run(it):
    for output in it:
        transmit(output)


run(wave(3.0, 8))

>>>
Output:   0.0
Output:   2.1
Output:   3.0
Output:   2.1
Output:   0.0
Output:  -2.1
Output:  -3.0
Output:  -2.1

​ 这样写可以生成基本的波形,但问题是,该函数在产生这些值的时候,只能按照刚开始给定的振幅来计算,而没办法使振幅在整个生成过程中根据某个因素发生变化(例如在发送调幅广播信号时,我们就需要那么做)。现在,我们要让生成器在计算每个值的时候,都能考虑懂啊振幅的变化,从而实现调幅。

​ Python 的生成器支持 send 方法,这可以让生成器变成双向通道。send 方法可以把参数发给生成器,让它成为上一条 yield 表达式的求值结果,并将生成器推进到下一条 yield 表达式,然后把 yield 右边的值返回给 send 方法的调用者。然而在一般情况下,我们还是会通过内置的 next 函数来推进生成器,按照这个写法,上一条 yield 表达式的求值结果总是 None。

def my_generator():
    received = yield 1
    print(f'received = {received}')


it = iter(my_generator())
output = next(it)
print(f'output = {output}')
try:
    next(it)
except StopIteration:
    pass


>>>
output = 1
received = None

​ 如果不通过 for 循环或内置的 next 函数推进生成器,而是改用 send 方法,那么调用方法时传入的参数就会成为上一条 yield 表达式的值,生成器拿到这个值后,会继续运行到下一条 yield 表达式那里。可是,刚开始推进生成器的时候,它是从头执行的,而不是从某一条 yield 表达式那里继续的,所以,首次调用 send 方法时,只能传 None,要是传入其他值,程序运行时就会抛出异常。

it = iter(my_generator())
output = it.send(None)  # Get first generator
print(f'output = {output}')

try:
    it.send('hello!')  # Send value into the generator
except StopIteration:
    pass
    
>>>
output = 1
received = hello!

​ 我们可以利用这种机制让调用者把振幅发送过来,这样函数就能根据这个输入值调整生成的正弦波幅值了。首先修改 wave 函数的代码,让它把 yield 表达式的求值结果(也就是调用者通过 send 发过来的振幅)保存到 amplitude 变量里,这样就能根据该变量计算下次应该生成的值。

def wave_modulating(steps):
    step_size = 2 * math.pi / steps
    amplitude = yield   # Received initial amplitude
    for step in range(steps):
        radians = step * step_size
        fraction = math.sin(radians)
        output = amplitude * fraction
        amplitude = yield output  # Receive next amplitude

​ 然后,要修改 run 函数调用 wave_modulating 函数的方式。它现在必须把每次所要使用的振幅发给 wave_modulating 生成器。首次必须发送 None,因为此时生成器还没有遇到过 yield 表达式,它不需要知道上一条 yield 表达式的求值结果。

def run_modulating(it):
    amplitudes = [
        None, 7, 7, 7, 2, 2, 2, 2, 10, 10, 10, 10, 10, 10
    ]
    for amplitude in amplitudes:
        output = it.send(amplitude)
        transmit(output)


run_modulating(wave_modulating(12))

>>>
Output is None
Output:   0.0
Output:   3.5
Output:   6.1
Output:   2.0
Output:   1.7
Output:   1.0
Output:   0.0
Output:  -5.0
Output:  -8.7
Output: -10.0
Output:  -8.7
Output:  -5.0

​ 这样写没问题,程序可以按照每次输入的值调整输出信号的振幅。首次输出的肯定是 None,因为此时 wave_modulating 函数并不知道 amplitude 是多少,它只有在运行第一条 yield 表达式后,才能把调用者通过 send 发来的值保存到这个变量里。

​ 这种写法有个缺点,就是它很难让初次阅读这段代码的人立刻理解它的意思。把 yield 放在赋值语句的右侧,本身就不太直观,因为我们必须先了解生成器这个高级特性,才能明白 yield 与 send 是如何配合的。

​ 现在假设程序的需求变得更复杂了。这次要生成的载波不是简单的正弦波,而是由多个信号序列构成的复合波形。要实现这个需求,可以连用几条 yield from 语句,把多个生成器接起来(参见 第 33 条)。下面先来验证一下,这种写法能否处理振幅固定的情况。

def complex_wave():
    yield from wave(7.0, 3)
    yield from wave(2.0, 4)
    yield from wave(10.0, 5)

run(complex_wave())

>>>
Output:   0.0
Output:   6.1
Output:  -6.1
Output:   0.0
Output:   2.0
Output:   0.0
Output:  -2.0
Output:   0.0
Output:   9.5
Output:   5.9
Output:  -5.9
Output:  -9.5

​ 可以看到,利用 yield from 表达式确实可以处理比较简单的情况。那既然这样,我们再试试看,它能否处理那种采用 send 方法写成的函数。下面就用这种写法,接连多次调用 wave_modulating 生成器。

def complex_wave_modulating():
    yield from wave_modulating(3)
    yield from wave_modulating(4)
    yield from wave_modulating(5)

run_modulating(complex_wave_modulating())

>>>
Output is None
Output:   0.0
Output:   6.1
Output:  -6.1
Output is None
Output:   0.0
Output:   2.0
Output:   0.0
Output: -10.0
Output is None
Output:   0.0
Output:   9.5
Output:   5.9
Output:  -5.9

​ 这样写大方向是对的,但问题在于:程序竟然输出了那么多 None!这是为什么呢?因为每条 yield from 表达式其实都遍历一个嵌套进去的生成器,所以每个嵌套器都必须分别执行它们各自的第一条 yield 语句,只有执行过这条语句后,这些生成器才能通过 send 方法所传来的值决定这条语句的求值结果,并把这个结果放在 amplitude 变量里以计算下一次输出的值。所以,complex_wave_modulating 函数处理完前一个嵌套的生成器之后,会进入下一个嵌套的生成器,而这时就必须先把生成器的第一条 yield 语句运行过去,这就导致后面两个嵌套生成器会各自从 amplitudes 列表里浪费一个值,并使得每个嵌套生成器所拿到的第一个结果必定是 None,还会让最后那个嵌套生成器少执行两次。

​ 这意味着,虽然 yield from 语句与 send 方法单独用着都没有问题,然而结合使用的效果却不太让人满意。当然也可以在 run_modulating 函数里添加一些代码,使得计算波形的步调与 amplitudes 列表提供的振幅值步调一致,但这只能使程序越写越复杂,因为利用 send 方法写成的 wave_modulating 函数本身已经不太直观了,而我们又把这个生成器函数通过 yield from 语句套到了 complex_wave_modulating 里面,这理解起来就更加困难了。所以,建议抛开 send 方法,改用更简单的方法来做。

​ 最简单的方法,是把迭代器传给 wave 函数,让 wave 每次用到振幅的时候,通过 Python 内置的 next 函数推进这个迭代器并返回一个输入振幅。于是,这就促使多个生成器之间,产生连锁反应(其他类似案例参见 第32条)。

def wave_cascading(amplitude_it, steps):
    step_size = 2 * math.pi / steps
    for step in range(steps):
        radians = step * step * step_size
        fraction = math.sin(radians)
        amplitude = next(amplitude_it)
        output = amplitude * fraction
        yield output

​ 这样,我们只需要把同一个迭代器分别传给这几条 yield from 语句里的 wave_cascading 就行。迭代器是由状态的(参见 第 31 条),所以下一个 wave_cascading 会从上一个 wave_cascading 使用完的地方,继续向下使用 amplitude_it 迭代器。

def complex_wave_cascading(amplitude_it):
    yield from wave_cascading(amplitude_it, 3)
    yield from wave_cascading(amplitude_it, 4)
    yield from wave_cascading(amplitude_it, 5)

​ 要想触发这个组合的生成器,只需要把振幅值放在列表中,并把针对列表制作的迭代器传给 complex_wave_cascading 就好。

def run_cascading():
    amplitudes = [7, 7, 7, 2, 2, 2, 2, 10, 10, 10, 10, 10, 10]
    it = complex_wave_cascading(iter(amplitudes))
    for amplitude in amplitudes:
        output = next(it)
        transmit(output)

run_cascading()

​ 这种写法最大的优点在于,迭代器可以来自任何地方,而且完全可以是动态的。此方案只有一个缺陷,就是必须假设负责输出的生成器能保证线程安全,但有时其实保证不了这一点。如果代码要跨越边界,那么用 async 函数实现可能更好(参见 第 62 条)。

  • 18
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值