PHP Generator 笔记

PHP Generator 笔记

图片来源 https://wpengine.com/try/php7-hosting/

先说,标题虽然是《Generator 笔记》,但实际上本文会主要内容会是yield

以鄙人的拙见,目前大多数 PHPer 对 PHP 的 yield 关键字并不怎么了解,但实际上这却是一块非常值得学习的地方,至少于我而言如此。
yield 为 PHP 引入了生成器协程 等一些复杂概念,导致入门门槛也挺高。

关于学不学yield, 目前,我遇到过以下几类人:

  • 学个 PHP 都是为了简单快速搞东西上线,不用这玩意也能满足老板需求(不想学)
  • 自己写的代码也遇不到啥问题非要yield搞定的( 没有应用场景)
  • yield 带来了生成器,协程,异步等一系列复杂性概念,文档少的可怜,(门槛高)
  • 理解协程的又难免会和 Golang 的 goroutines 做对比。(不屑于学)

我算第三类,文档少的可怜。当然,还有就是自己的聪明程度不够。
关于 PHP yield这个,我尝试看了许多博文,可按照国内的技术尿性,大家的博文内容都写的是一样的。也不知道啥时候搜索引擎可以帮我智能去重就好了。值得庆幸的是,Google 还是给了我一些不错的文章:

当然官方文档也是不错的内容: RFC:generatorsPHP 手册 > 语言参考 > 生成器

由于PHP的资料太少,我反其道而行之,选择了去学习 Python 的生成器。关于 Python 的生成器,我主要看了大神 Dabeaz 的几个 PPT,都可以在他博客里面找到。本人的笔记,全部借(cao)鉴(xi)自此大神的 PPT。

yield的概念

迭代器协议

在 PHP 中, 可以实现 Iterator 接口, 从而可以让对象自行决定如何遍历以及每次遍历时哪些值可用。这样的接口,我们称之为迭代器协议 (Iteration Protocol) 。

1
2
3
4
5
6
7
8
Iterator extends Traversable {
/* 方法 */
abstract public mixed current ( void ) // 返回当前元素
abstract public scalar key ( void ) // 返回当前元素的键
abstract public void next ( void ) // 向前移动到下一个元素
abstract public void rewind ( void ) // 返回到迭代器的第一个元素
abstract public boolean valid ( void ) // 检查当前位置是否有效
}

生成器 Generator 就是是实现了迭代器协议的一种对象。这种对象比较特殊:

  1. 无法直接使用 new 实例化
  2. 得使用 yield 产生
  3. 可以通过send方法传值给yield所在位置

为什么需要引入关键字 yield 呢?

我们先来看一个例子,需要读取一个文件每一行出来:

1
2
3
4
5
6
7
8
9
10
11
// 使用数组.
function getLines($file) {
$f = fopen($file, 'r');
$lines = [];
if (!$f) throw new Exception();
while (false !== $line = fgets($f)) {
$lines[] = $line;
}
fclose($f);
return $lines;
}

上述代码有个明显的问题:如果当文件足够大的情况下,数组$lines会特别大,内存随时会跪。

幸运的是,我们可以使用迭代器,一行一行的读,参见这里的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 自己实现文件遍历
class FileIterator implements Iterator {
protected $f;
protected $data;
protected $key;
public function __construct($file) {
$this->f = fopen($file, 'r');
if (!$this->f) throw new Exception();
}
public function __destruct() {
fclose($this->f);
}
public function current() {
return $this->data;
}
public function key() {
return $this->key;
}
public function next() {
$this->data = fgets($this->f);
$this->key++;
}
public function rewind() {
fseek($this->f, 0);
$this->data = fgets($this->f);
$this->key = 0;
}
public function valid() {
return false !== $this->data;
}
}

如你所见,虽然这样解决了内存问题,但是代码复杂的多了。于是乎,Generator也上线了,一个函数里面只要有yield关键字,就是Generator,它实现了迭代器协议。

1
2
3
4
5
6
7
8
9
// 使用 yield 完成相同功能
function getLines($file) {
$f = fopen($file, 'r');
if (!$f) throw new Exception();
while ($line = fgets($f)) {
yield $line;
}
fclose($f);
}

注意到上述代码完成了和迭代器相同的功能,复杂性大大降低,相较于数组实现,性能开销也会明显下降

生成器

如你所见,生成器写法和普通函数写法基本上一致,只是没有用return,而是yield

生成器和普通函数区别其实挺大的。

  • 调用一个生成器只是会创建一个生成器对象,而不会直接运行这个函数:
1
2
3
4
5
6
7
8
9
>>> function xrange($start, $limit, $step = 1) {
... for ($i = $start; $i <= $limit; $i += $step) {
... echo "call me"; <------
... yield $i; |
... } |
... } |
=> null |
>>> $gen = xrange(1,3) # < --- 注意看没有执行echo,没有任何输出。它
=> Generator {#176} # < --- 显示它是一个Generator
  • 调用current()函数可以唤醒(Resume)生成器,执行到yield关键字的地方继续暂停(Suspends),继续往下执行需要执行next()。即yield可以产生中断点

    这个和Python不一样哦。Python是直接调用next的(虽然可以使用装饰器)

1
2
3
4
5
6
7
8
9
>>> $gen->current();  // <--- 注意这一行也只执行了第一个echo.
call me⏎ // 执行完current之后,生成器里面的代码会继续往后执行,
=> 1 // 直到遇到yield这个关键字,然后继续暂停.
^----注意这个1是来自yield后面的$i所产生而返回的,即yield将$i返回给了调用者。
>>> $gen->next();
call me⏎ // 执行到第二次循环,然后输出.
=> null
>>> $gen->current(); // 现在拿到的是$i=2的时候。
=> 2

如上,因为生成器是需要的时候(执行next())才会执行。不会贪婪的一次性生成所有数据放在内存中,而是特别的懒,我们称为惰性求值( Lazy Evaluation),所以内存占用很小。也许你也注意到了,我们可以一直next()调用往下执行,但是并没有prev之类的接口。所以生成器属于 One-time Operation,一次性操作。因此上面的xrange 你只能遍历一次,如果想要多次,你必须得重新调用一次生成器

  • 不同于return返回的是一个value, yield可以让你产生一系列的值。

yield 使用场景

yield 最容易让人想到的一个点就是可以让一个普通的函数随便变成高大上的生成器,而生成器是可以迭代的。即yield作为最简单的用途来说,就是可以放在循环中,一直迭代数据。

但其实从 Python 中 yield 的作用来看,主要被分成了三种类型( 参见 PPT):

PHP 亦是如此。下文以 PHP 为例子,把三种方式都简单记录一下。

生产者

如前文,当yield放在一个循环中,我们可以利用yield 作为生产者产生有限或者无限的数据。如前文提到的 xrange 这种基础用法。考虑这样的一种情况:

我需要打开一个日志文件,然后将所有符合某种规则的日志行全部提取出来,然后需要对这些行进行一定的处理,然后做展示,大概这样:

1
2
3
4
5
6
7
8
9
10
11
foreach($lines as $line) {
// 可能有逻辑其他处理
if (preg_match($regex, $line)) {
// 此条件下其他处理逻辑
$line = andParsedLine($line);
if ($isDuplcaite($line)) {
// 其他逻辑
echo $line;
}
}
}

这就是传说中的 Pyramid of doom(金字塔厄运),大概这样Pyramid of doom

这个流程其实很类似于我们 Linux 下的管道:

grep xxx file | awk xxxx | sort | uniq | head ...

同样的道理,我们可以把函数封装成这样 => functionC(functionB(functonA())) 从而避免或者减少金字塔厄运(事实上,为了解决这样的问题,还有比如PromiseThunk很多玩意儿):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 一行一行的读文件(产生数据)
function readByLine($file) {
$f = fopen($file, 'r');
while (!feof($f)) {
yield $f;
}
fclose($f);
}
// 使用正则过滤(继续生产数据)
function grep($gen, $regex) {
foreach ($gen as $line) {
if (preg_match($regex, $line)) {
yield $line;
}
}
}
// 现实文件的前n行(还是生产数据)
function head($gen, $n)
{
$current = 0;
foreach ($gen as $line) {
if ($current > $n) {
break;
}
echo $line;
}
}
head(grep(readByLine("/www/xxxx.log"), 'xxxxx'), 3);

因为 yield 是惰性求值的,在需要的时候才会做计算,我们不用担心这样的代码会出现内存过大之类的问题。(相反,如果上述代码换成 return来写,就可能会爆哦)

很多时候, PHP 中一些自带的函数 array_map, array_filter, array_column等等,我们都可以用 yield 将其替换为 生成器 (Python中这些API已经如此了)

题外话: https://3v4l.org/hQPpe

PHP 5.x 的时候生成器方案比普通快。
PHP 7.x 的时候生成器方案比普通慢。
在 HHVM 上 普通方案的速度太快(比生成器快接近3倍)

前文提到过协程, 中断点 我们还没有接触,显然可以知道yield的作用其实还有很多。

消费者

Generator 不仅仅实现了Iterator接口, 而且还有自定义的一些方法,比如send()

这个send可不得了,它可以实现所谓的双向通讯。具体来说,上面我们处理日志的流程是这样的:

1
2
3
4

+------------+ +------+ +------+
/www/xxxx.log -->| readByLine |+--->| grep |+-->| head |+-->
+------------+ +------+ +------+

可以发现,无论是grep还是head,它们都是自己主动去Pull之前的数据。我们仅仅用到了yield的返回功能,还差发送功能。

现在我们看一下 Generator::send

向生成器中传入一个值,并且当做 yield 表达式的结果,然后继续执行生成器。

如果当这个方法被调用时,生成器不在 yield 表达式,那么在传入值之前,它会先运行到第一个 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 yieldstatement 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
2
3
4
5
6
7
8
9
10
11
12
13
// 可以作为消费者接受数据的grep.
function grep($regex) {
while (true) {
$line = yield; // <-- 可以从这里接受一个数据(通过generator->send传递给他.)
if (preg_match($regex, $line)) {
echo "Matched: {$line}\n";
}
}
}
# >>> $filter->send("python generator");
# => null
# >>> $filter->send("shellvon");
# Matched: shellvon

如上面的代码所示,我们原来的处理流程可以变成这样:

1
2
3
   as source     +------+  send() +------+ send()  +--------+
/www/xxxx.log -->| grep |+------->| head |+------->| output |
+------+ +------+ +--------+

如上图所示,上图有几个特点:

  • 您可以使用 send()方法把数据push给下一个操作逻辑注意与之前迭代器的Pull相比较
  • 您可能发现 readByLine 没有在上图出现,那是因为上图画的都是消费者,不是生产者。readByLine 其实还是存在,以生产者身份。而这样一个典型的管道操作是需要有一个数据源的,即生产者
  • 生产者显然不是一个协程,readByLine可以仍然保持之前的迭代器,通过send将数据发出来。

如果脑洞更加大一点, 相比于之前的 Pull,我们 Push 的时候,可以同时 send 给多个消费者。更加复杂:

1
2
3
4
5
6
7
8
9
                           +-----------+
+------>| coroutine |+- - - - - - - +
| +-----------+ |
+-------+--+ +-----------+ +-----------+
source+--->|coroutine |--->| coroutine |+- - - ->| coroutine |
+-------+--+ +-----------+ +-----------+
| +-----------+ |
+------>| coroutine |+- - - - - - - +
+-----------+

当然,多个消费者最后可以再 send 给同一个 pipe , 聚合回来。

另外,其实我们的 source 本身来源也可以不唯一。比如我们可以使用 PHP SPL 中提供的一些有用组件: MultipleIteratorAppendIterator,之前的 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, 目前其实已经有不少的项目和文章了,比如:

附录

在学习协程和生成器的时候,我检索了各种关键字,其中发现了不少我个人觉得营养价值比较高的文章或者博客,我作为附录放这里,希望我每一次看的时候都能学到更多。

感谢支持
0%