最近在项目中遇到这样一个问题,一个 web 接口接受三个参数:
get_data(from_, to_, limit=100)
- 开始时间,from_
- 截至时间, to_
- 返回数量限制, limit,
- 返回一个 list。
用于获取一定时间段内的记录,但是这个接口返回数组长度的最大上限是 100,如果编写一个接口
get_all_data(from_, to)
不限制返回数量,要求接口返回指定时间段内所有数据。
基本做法
由于 get_all_data 的起止时间内,可能有超过 100 条数据,需要多次调用 get_data, 直到将整个时间段内所有数据都拿到:
def get_all_data(from_, to_):
# 记录所有数据的 buffer
all_data = []
while True:
data = get_data(from_, to_, limit = 100)
# 没有数据
if len(data) < 1:
# 返回 buffer 里所有的数据
return all_data
# 追加数据
all_data += data
# 已获得数据的最后一项 time 字段可以更新 get_data 的时间段
from_ = data[-1]["time"]
这种做法算是中规中矩,肯定是没有错误的,但是有几个问题:
- 如果该时间段内数据量大,all_data 会引起比较严重的性能问题。
如果有下面的代码,其实没必要获得所有的数据
for el in get_all_data(from_=1574913259, to_=1574913212): if el.value < 10: # 符合某种条件,退出 for break
假设在前几个数据就退出了 for 循环,那 get_all_data 返回大量数据的工作就失去意义了,如果 get_all_data 是 IO 密集型业务,这个问题就突出!
优雅如斯
比较好的做法是用多少,取多少,细水长流。
如果为了实现细水长流,就可能需要编写一个函数,一边调用 get_data 接口,一边执行业务逻辑。这样做性能问题倒是解决了,但是非常 Ugly,更优雅的做法是使用生成器。
只要将 get_all_data 稍作修改,借助 yield 就可以令函数成为生成器:
def get_all_data_gen(from_, to_):
# 不再需要 buffer
# all_data = []
while True:
data = get_data(from_, to_, limit = 100)
# 没有更多数据
if len(data) < 1:
# 当无更多数据时,抛出异常,停止迭代
raise StopIteration
for el in data:
# 生成器返回一个结果
yield el
from_ = data[-1]["time"]
此时调用
for el in get_all_data_gen(from_=1574913259, to_=1574913212):
if el.value < 10:
# 符合某种条件,退出 for
break
假设 (from_=1574913259, to_=1574913212) 内有 250 条数据,由于 get_data 每次只能获得 100 条数据,
- 使用 get_all_data, 无论如何都需要调用 3 次 get_data
- 使用 get_all_data_gen, 如果在前 100 条数据就退出遍历,那么 get_data 只会被调用 1 次。
很棒不是么,而且这两种方法在使用上几乎无差别。生成器是我最喜欢的 Python 特性之一。
yield,什么魔法?
生成器是通过一个或多个yield表达式构成的函数,每一个生成器都是一个迭代器(但是迭代器不一定是生成器)。 如果一个函数包含yield关键字,这个函数就会变为一个生成器。 生成器并不会一次返回所有结果,而是每次遇到yield关键字后返回相应结果,并保留函数当前的运行状态,等待下一次的调用。 来源(https://www.cnblogs.com/coder2012/p/4990834.html)
生成器不同于一般的函数,它可以不断的返回中间结果,而非一次性返回一个结果。在 get_all_data_gen 中,函数执行到 yield el 就会被 “中断”,下一次调用的时候,又会从中断的位置 “恢复”。如果不会再有下一个值,get_all_data_gen 就抛出 StopIteration 异常,通知调用者停止迭代。
中断 和 恢复
比如你正在数钱(土豪运动!),突然来了一个电话,是朋友找你商量周末出行计划。聊了 20 分钟之后愉快的决定了行程,但是你之前数了多少钱呢?忘了!
所以最好的做法是,来电话的时候,在一张纸上写下已数的钱数,保持桌面现场,接完电话再回来继续。
这样你就完成了:
对数钱流程,一次完美的中断和恢复!然后,继续数钱数到手抽筋,就像从没有被打扰过一样。
Python 如何做到的?
所有的 CPU,包括虚拟机在内,都会有一块专有内存用于保存一些与代码执行相关的内存,有时候称为 ctx,通常保存的是程序计数器(现在运行到哪一行代码了)、寄存器状态等等信息。如果将这一块内存保存下来,比如快照,随时都可以通过保存的快照,让 CPU 恢复到执行某一行代码时的状态,继续执行。
Python 虚拟机内部通过一个称之为 PyFrameObject 的对象保存这样的数据,其中包含了函数的字节码、全局变量和局部变量索引、异常状态等等,总之在函数里能看到的一切都包含在这个 PyFrameObject。
当生成器执行到 yield 语句的时候,生成器就向调用者返回一个数据,并且将 PyFrameObject 保存下来, Python 虚拟机继续执行调用者代码。
此时代码运行到 yield value1(实际上是字节码,在此简化为 python 代码),调用者会得到返回值 value1,Python 虚拟机继续执行调用者代码。当生成器被再次调用的时候,生成器的 PyFrameObject 被恢复,由于之前 IP 已经指向 yield value1 一行,所有生成器不会从头开始运行,而是继续下一行运行!
当生成器再次被调用的时候,之前保存的 PyFrameObject 被恢复到内存中,Python 虚拟机会从恢复的状态继续执行,就像生成器从未被打断过一样。当在此遇到 yield value2 的时候,PyFrameObject 会再一次被保存... ... 等待被恢复执行,或者销毁。