序
先说,标题虽然是《Generator 笔记》,但实际上本文会主要内容会是yield
。
以鄙人的拙见,目前大多数 PHPer 对 PHP 的 yield
关键字并不怎么了解,但实际上这却是一块非常值得学习的地方,至少于我而言如此。yield
为 PHP 引入了生成器,协程 等一些复杂概念,导致入门门槛也挺高。
- 学个 PHP 都是为了简单快速搞东西上线,不用这玩意也能满足老板需求(不想学)
- 自己写的代码也遇不到啥问题非要
yield
搞定的( 没有应用场景) yield
带来了生成器,协程,异步等一系列复杂性
概念,文档少的可怜,(门槛高)- 理解协程的又难免会和 Golang 的 goroutines 做对比。(不屑于学)
我算第三类,文档少的可怜。当然,还有就是自己的聪明程度不够。
关于 PHP yield
这个,我尝试看了许多博文,可按照国内的技术尿性,大家的博文内容都写的是一样的。也不知道啥时候搜索引擎可以帮我智能去重就好了。值得庆幸的是,Google 还是给了我一些不错的文章:
- Cooperative-multitasking-using-coroutines-in-PHP
- 中文: 在PHP中使用协程实现多任务调度
- What-generators-can-do-for-you
当然官方文档也是不错的内容: RFC:generators 和 PHP 手册 > 语言参考 > 生成器
由于PHP的资料太少,我反其道而行之,选择了去学习 Python
的生成器。关于 Python 的生成器,我主要看了大神 Dabeaz 的几个 PPT,都可以在他博客里面找到。本人的笔记,全部借(cao)鉴(xi)自此大神的 PPT。
yield的概念
迭代器协议
在 PHP 中, 可以实现 Iterator 接口, 从而可以让对象自行决定如何遍历以及每次遍历时哪些值可用。这样的接口,我们称之为迭代器协议 (Iteration Protocol) 。
1 | Iterator extends Traversable { |
生成器 Generator 就是是实现了迭代器协议的一种对象。这种对象比较特殊:
- 无法直接使用 new 实例化
- 得使用 yield 产生
- 可以通过
send
方法传值给yield所在位置
为什么需要引入关键字 yield
呢?
我们先来看一个例子,需要读取一个文件每一行出来:
1 | // 使用数组. |
上述代码有个明显的问题:如果当文件足够大的情况下,数组$lines
会特别大,内存随时会跪。
幸运的是,我们可以使用迭代器,一行一行的读,参见这里的例子:
1 | // 自己实现文件遍历 |
如你所见,虽然这样解决了内存问题,但是代码复杂的多了。于是乎,Generator
也上线了,一个函数里面只要有yield
关键字,就是Generator
,它实现了迭代器协议。
1 | // 使用 yield 完成相同功能 |
注意到上述代码完成了和迭代器相同的功能,复杂性大大降低,相较于数组实现,性能开销也会明显下降。
生成器
如你所见,生成器写法和普通函数写法基本上一致,只是没有用return
,而是yield
。
生成器和普通函数区别其实挺大的。
- 调用一个生成器只是会创建一个生成器对象,而不会直接运行这个函数:
1 | >>> function xrange($start, $limit, $step = 1) { |
调用
current()
函数可以唤醒(Resume)生成器,执行到yield
关键字的地方继续暂停(Suspends),继续往下执行需要执行next()
。即yield
可以产生中断点。这个和Python不一样哦。Python是直接调用next的(虽然可以使用装饰器)
1 | >>> $gen->current(); // <--- 注意这一行也只执行了第一个echo. |
如上,因为生成器是需要的时候(执行next()
)才会执行。不会贪婪的一次性生成所有数据放在内存中,而是特别的懒,我们称为惰性求值( Lazy Evaluation),所以内存占用很小。也许你也注意到了,我们可以一直next()
调用往下执行,但是并没有prev
之类的接口。所以生成器属于 One-time Operation,一次性操作。因此上面的xrange
你只能遍历一次,如果想要多次,你必须得重新调用一次生成器。
- 不同于
return
返回的是一个value,yield
可以让你产生一系列的值。
yield 使用场景
yield
最容易让人想到的一个点就是可以让一个普通的函数随便变成高大上的生成器,而生成器是可以迭代的。即yield
作为最简单的用途来说,就是可以放在循环中,一直迭代数据。
但其实从 Python 中 yield
的作用来看,主要被分成了三种类型( 参见 PPT):
- 迭代器 Iterator 可以作为生产者产生数据
- 接受消息 可以作为消费者接受数据
- 陷阱 Trap 可以实现多任务调度
PHP 亦是如此。下文以 PHP 为例子,把三种方式都简单记录一下。
生产者
如前文,当yield
放在一个循环中,我们可以利用yield
作为生产者产生有限或者无限的数据。如前文提到的 xrange
这种基础用法。考虑这样的一种情况:
我需要打开一个日志文件,然后将所有符合某种规则的日志行全部提取出来,然后需要对这些行进行一定的处理,然后做展示,大概这样:
1 | foreach($lines as $line) { |
这就是传说中的 Pyramid of doom(金字塔厄运),大概这样
这个流程其实很类似于我们 Linux 下的管道:
grep xxx file | awk xxxx | sort | uniq | head ...
同样的道理,我们可以把函数封装成这样 => functionC(functionB(functonA()))
从而避免或者减少金字塔厄运(事实上,为了解决这样的问题,还有比如Promise
,Thunk
很多玩意儿):
1 | // 一行一行的读文件(产生数据) |
因为 yield
是惰性求值的,在需要的时候才会做计算,我们不用担心这样的代码会出现内存过大之类的问题。(相反,如果上述代码换成 return
来写,就可能会爆哦)
很多时候, PHP 中一些自带的函数 array_map
, array_filter
, array_column
等等,我们都可以用 yield
将其替换为 生成器 (Python中这些API已经如此了)
PHP 5.x 的时候生成器方案比普通快。
PHP 7.x 的时候生成器方案比普通慢。
在 HHVM 上 普通方案的速度太快(比生成器快接近3倍)
前文提到过协程, 中断点 我们还没有接触,显然可以知道yield
的作用其实还有很多。
消费者
Generator
不仅仅实现了Iterator
接口, 而且还有自定义的一些方法,比如send()
。
这个send
可不得了,它可以实现所谓的双向通讯。具体来说,上面我们处理日志的流程是这样的:
1 |
|
可以发现,无论是grep
还是head
,它们都是自己主动去Pull
之前的数据。我们仅仅用到了yield
的返回功能,还差发送功能。
现在我们看一下 Generator::send
向生成器中传入一个值,并且当做 yield 表达式的结果,然后继续执行生成器。
这基本就是 PHP 中实现 协程 的原理,然而 PHP 里面严格意义上叫做半协程(semicoroutines),因为控制权并没有真正的协程那么自由:
Generators), also known as semicoroutines。while both of these can yield multiple times, suspending their execution and allowing re-entry at multiple entry points, they differ in that coroutines can control where execution continues after they yield, while generators cannot, instead transferring control back to the generator’s caller.[6] That is, since generators are primarily used to simplify the writing of iterators, the
yield
statement in a generator does not specify a coroutine to jump to, but rather passes a value back to a parent routine.
不过我一直觉得概念其实并没有那么的重要,不用太过于纠结。
看到这个函数,我们需要想到几点:
- 调用
send
时,我们会去唤醒之前协程有yield
表达式的地方。 - 这时候执行权在协程那边,您可以理解为 Goto 语句。
- 当遇到下一次
yield
的时候,才会把数据返回,并且交回控制权。
我们还是来看一下 yield
作为消费者的话,以 grep
举例:
1 | // 可以作为消费者接受数据的grep. |
如上面的代码所示,我们原来的处理流程可以变成这样:
1 | as source +------+ send() +------+ send() +--------+ |
如上图所示,上图有几个特点:
- 您可以使用
send()
方法把数据push给下一个操作逻辑,注意与之前迭代器的Pull相比较 - 您可能发现
readByLine
没有在上图出现,那是因为上图画的都是消费者,不是生产者。readByLine
其实还是存在,以生产者身份。而这样一个典型的管道操作是需要有一个数据源的,即生产者。 - 生产者显然不是一个协程,
readByLine
可以仍然保持之前的迭代器,通过send
将数据发出来。
如果脑洞更加大一点, 相比于之前的 Pull,我们 Push 的时候,可以同时 send 给多个消费者。更加复杂:
1 | +-----------+ |
当然,多个消费者最后可以再 send
给同一个 pipe , 聚合回来。
另外,其实我们的 source
本身来源也可以不唯一。比如我们可以使用 PHP SPL 中提供的一些有用组件: MultipleIterator 和 AppendIterator,之前的 head
我们可以使用 LimitIterator。
甚至,按照 Dabeaz 大神的说法,我们的来源和 send
的 目标也好,不一定非要是字符串,非要是文件。
陷阱
这里的陷阱是指计算机专业术语,我在 Github 找到了一个中文解释:
程序中使用请求系统服务的系统调用而引发的事件,称作陷入中断(trap interrupt),也称软中断(soft interrupt),系统调用(system call)简称trap。
之所以用到这个概念,主要原因是因为我们的 yield
可以产生中断点与操作系统的 Trap 很类似。即:
我们可以利用 yield
会获取(通过你send数据给它)与返回控制权这个特性完成一个类似操作系统的调度器。
这就是文章开始提到的 在PHP中使用协程实现多任务调度 的内容(PHP Generator的发明人 Nikita Popov 在PHP的 rfc 中也说到了Dabeaz 大神的 PPT,所以我觉得他的这篇文章应该也是受到此 PPT 的影响,具体参见 Part6 及其 Part7)
所以其实本章节更好的题目应该是 利用协程完成多任务调度。因为中文/英文都特别详细,我就不用做啥笔记了。附上地址:
因为Nikita Popov 的那篇 Cooperative multitasking using coroutines in PHP, 目前其实已经有不少的项目和文章了,比如:
附录
在学习协程和生成器的时候,我检索了各种关键字,其中发现了不少我个人觉得营养价值比较高的文章或者博客,我作为附录放这里,希望我每一次看的时候都能学到更多。