0

理解PHP中的Generator

 2 years ago
source link: https://gywbd.github.io/posts/2014/12/understand-generator-in-php.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

理解PHP中的Generator

2014-12-27

我最开始知道Generator的存在是在nodejs中,不久前TJ写了一篇名为告别Nodejs的文章,尽管是告别了nodejs,但他表示还会继续维护cokoa这两个框架,而这两个框架都是基于Generator的,javascript中的Generator是ES6中引入的新特性。后来我看到一篇关于在PHP中使用协程实现协作式(非抢占式)多任务调度系统的文章,才发现原来PHP中也有Generator,似乎是在5.5版中引入了。

本来我是打算利用上面提到的那篇文章中的示例写一篇介绍在PHP中使用协程的文章,但是PHP中的协程必须依赖于Generator来实现,而Generator又不是一句两句就可以说清楚的,所以在没有完全理解Generator之前就想搞明白如何在PHP中使用协程基本是不可能的,另外在一篇文章中把这两个东西都讲清楚也是不可能的,这样最终文章的篇幅可能会比较大,写起来也会很累,也未必能保证质量,所以我觉得有必要先专门写一篇文章介绍Generator。

Generator这个单词在这里对应的中文词语应该是“生成器”,在编程这个领域,我们可以把它想象成一个可以生成一系列数据的工具,这个工具可以具体为一个类、一个函数或者是一个语句(由特殊的关键字构成),而且事实上也确实如此。在PHP中Generator是由函数生成的,但这个函数又跟普通的函数不同,具体有什么不同等会会慢慢道来。

我们先介绍Generator的另外一个特点,这个特点也是php的官方文档介绍Generator的第一句话:Generator提供了一种方便的实现简单的Iterator(迭代器)的方式,使用Generator实现Iterator不需要创建一个类来继承Iterator接口。

Iterator接口

所以如果想搞清楚Generator,需要先了解Iterator接口。我们通常使用foreach对数组进行遍历,如果要对对象进行遍历,那么这个对象的类就必须实现Iterator接口,并且实现Iterator接口所提供的5个方法:

Iterator extends Traversable {
/* Methods */
abstract public mixed current ( void ) //返回当前位置的元素
abstract public scalar key ( void ) //返回当前元素对应的key
abstract public void next ( void ) //移到指向下一个元素的位置
abstract public void rewind ( void ) //倒回到指向第一个元素的位置
abstract public boolean valid ( void ) //判断当前位置是否有效
}

这5个方法非常简单明确,在foreach遍历过程中,这些方法都会被隐式调用,其中next()方法就是控制元素移动的,current()可以获取当前位置的元素。

关于Iterator接口再啰嗦两句,Iterator接口扩展了Traversable接口,Traversable是一个空接口,它相当于一个标志,所有实现Iterator接口的类肯定也实现了Traversable接口,所以我们通常可以用下面的代码来判断一个变量是否可以通过foreach进行遍历:

<?php
if( !is_array( $items ) && !$items instanceof Traversable )
//Throw exception here
?>

Generator对象

我们现在已经知道了Iterator接口的用法,那么Generator跟Iterator有什么关系呢?

上面说到Generator在php中是由一个函数生成的,它可以使用foreach进行遍历,所以我们可以推断Generator是一个实现了Iterator接口的类,我们先来看一个Generator的经典示例:

function xrange($start, $end, $step = 1) {
for ($i = $start; $i <= $end; $i += $step) {
yield $i;
}
}
foreach (xrange(1, 1000000) as $num) {
echo $num, "\n";
}

xrange()这个方法如同php内置的range方法,不同的是range会一次性返回包含所有元素的数组,而xrange是遍历过程中迭代一次返回一个,它之所以可以这么做是因为调用xrange返回的是一个Generator对象,我们再在上面的代码添加几行代码:

$range = xrange(1, 1000000);
var_dump($range); // object(Generator)#1
var_dump($range instanceof Iterator); // bool(true)

第二个var_dump输出的是object(Generator)#1,这表示$range变量是一个Generator对象,它是xrange函数返回的。这个对象可以使用foreach遍历,所以它肯定实现了Iterator接口,我们现在看看文档中Generator类的定义:

Generator implements Iterator {
/* Methods */
public mixed current ( void )
public mixed key ( void )
public void next ( void )
public void rewind ( void )
public mixed send ( mixed $value )
public mixed throw ( Exception $exception )
public bool valid ( void )
public void __wakeup ( void )
}

它实现了Iterator中的5个方法,还提供了三个新方法,其中__wakeup是一个魔术方法,用于序列化,Generator实现这个方法是为了防止序列化。另外两个新方法是throw和send,我们需要关注的只是send方法,这个等会介绍。我们现在解决了Generator对象可以遍历的问题,因为它实现了Iterator接口,下面的问题是它是怎么产生的。在回答这个问题之前,我们可以看看是否能够通过new关键字来创建Generator对象:

$g = new Generator(); 

运行这个代码会得到下面的错误:

Catchable fatal error: The "Generator" class is reserved for internal use and cannot be manually instantiated in ***

上面的错误的含义是“Generator类仅供内部参考,概不外借。”,所以我们不能使用new关键字来创建Generator对象。我们再看下xrange函数的代码,它会返回一个Generator对象,但是在这个函数的内部并没有任何跟Generator相关的字样,甚至连一个return语句都没有。但它也并非是毫无特色,它有一个yield关键字,这个关键字我们之前根本都没见过,所以可以大胆猜测它就是罪魁祸首。

yield

在php中,yield关键字只能在函数中使用(你可以尝试下在函数外使用,看看会发生什么),而且使用了yield关键字的函数都会返回一个Generator对象,我们把这种函数叫做generator函数(我自己取的名字,用于区分普通函数)。

yield语句有点像return语句,代码执行到yield语句,generator函数的执行就会终止,并且会返回yield语句中的表达式的值给Generator对象,这跟return语句一样,不同的是,这返回值只是作为遍历Generator对象的当前元素,而不能赋值给其他变量。

当对Generator对象继续迭代,generator函数中的yield后面的代码会继续执行,直到generator函数中的yield语句全部执行完毕,或者是碰到generator函数中的空return语句(返回null的return语句),在generator函数中使用带有非null返回值的return语句会报编译错误。

再来看一个简单的示例:

<?php
function gen() {
yield 1;
}
$g = gen();
echo $g->valid(); //1
echo $g->current(); //1
echo $g->next();
echo $g->valid(); //
echo $g->current(); //

上面的代码首先调用gen函数生成一个Generator对象赋值给变量$g,因为Generator对象实现了Iterator接口,所以可以直接使用Iterator接口中的方法。首先调用valid方法,它会返回1,表示这个对象当前处于可迭代状态;然后调用current方法,它也会输出1,就是yield所返回的值,它是当前迭代的元素的值,在这个示例中也是第一个元素;紧接着会调用next方法,它会对Generator对象做一次迭代,就是把当前迭代的位置向下移动一位,然后再次调用valid(),这个时候输出为空,这表示对Generator的迭代已终止,再次调用current()返回也是空值。

在这个示例中,gen函数是一个generator函数,调用它会返回一个Generator对象,并且赋值给变量$g,由于函数gen中只有一个yield语句,所以对$g的遍历只能进行一次。

通常的函数中只可能执行一次return语句,如果一个generator函数中有多个yield语句会怎么样?

多个yield语句

<?php
function gen() {
yield 1;
yield 2;
yield 3;
}
$g = gen();
echo $g->valid();
echo $g->current();
echo "\n";
echo $g->next();
echo $g->valid();
echo $g->current();
echo "\n";
echo $g->next();
echo $g->valid();
echo $g->current();
echo "\n";
echo $g->next();
echo $g->valid();
echo $g->current();

运行下上面的代码,看看它的输出。

Generator对象的中可迭代的元素就是所有yield语句返回的值的集合,在这个示例中是[1,2,3]。看起来跟数组很像,但它跟数组有本质的区别,遍历Generator对象的每次迭代都只会执行前一次yield语句之后的代码,而且碰到yield语句就会返回一个值,相当于从generator函数中返回,这有点像挂起一个进程(线程)的执行(yield在很多语言中就是用于挂起进程(线程)),然后又启动它继续执行,周而复始直到进程(线程)执行中止,这也是为什么Generator可以用于实现协程的原因。

当然我们一般不会写上面的代码,而是在generator函数中使用for循环,而遍历则使用foreach,跟上面介绍的xrange()函数一样。当我们使用foreach遍历数组的时候,有时候会使用到数组的key,所以yield是否也可以返回键值对的形式呢?当然可以,而且yield还可以返回null,或者是返回引用,关于这些特殊的用法可以参考yield的文档

send方法

yield也可以用于表达式的上下文中,例如用于赋值语句的右侧:

$data = (yield $value);

注意这里必须使用圆括号,要不然则会产生解析错误。

这里的yield相当于一个表达式,它需要跟Generator对象中的send函数配合使用。send函数接收一个参数,它会将这个参数的值传递给Generator对象并作为当前yield表达式的结果,同时还会恢复generator函数的执行。我们通过一个示例来说明这个过程:

<?php
function gen() {
$ret = (yield 'yield1');
var_dump($ret);
$ret = (yield 'yield2');
var_dump($ret);
}
$g = gen();
var_dump($g->current());
var_dump($g->send('ret1'));
var_dump($g->send('ret2'));

上面的代码运行后的输出为:

string(6) "yield1"
string(4) "ret1"
string(6) "yield2"
string(4) "ret2"
NULL

上面的代码首先是调用函数gen生成一个Generator对象,然后调用这个对象的current方法返回第一个值,显然它是第一个yield语句的返回值,也就是'yield1',这个时候gen函数的执行就会被中止,接着执行var_dump($g->send('ret1'));。

调用$g->send('ret1'),传入参数为字符串'ret1',按照上面的说明,它会赋值给第一个yield表达式,也就是(yield 'yield1')中的yield(注意:这个时候不包括'yield1'),它的值为'ret1',然后会赋值给$ret,所以第二个输出'ret1'就是gen函数中的第一个var_dump输出的。此时对Generator对象的迭代会恢复继续执行,实际上就是调用了一次next函数,它会执行到下一个yield语句:yield 'yield2',这个语句会返回'yield2',它会作为$g->send('ret1')的返回值,所以函数外第二个var_dump会输出'yield2'。

最后再次调用send函数,这次传入的参数为字符串'ret2',跟上面一样,Generator对象当前位置的元素是在gen函数的第二个yield上,所以’ret2'会被传递给第二个yield表达式,也就是作为(yield 'yield2')中的yield的值,并且会被赋值给$ret变量,然后gen函数恢复执行,它会执行gen函数中的最后一个var_dump,此时对Generator对象$g的遍历也结束了,第二个send函数的返回值为NULL,这也是函数外的最后一个var_dump的输出。

读了这么一段分析以后,你现在最大的困惑是什么呢?

我最大的困惑是为什么同一个yied关键字,它既是语句,又是表达式,而且这两种情况是同时存在的:

  1. 对于所有在generator函数中出现的yield,首先它都是语句,而跟在yield后面的任何表达式的值将作为调用generator函数的返回值,如果yield后面没有任何表达式(变量、常量都是表达式),那么它会返回NULL,这一点跟return语句一致。
  2. yield也是表达式,它的值就是send函数传过来的值(相当于一个特殊变量,只不过赋值是通过send函数进行的)。只要调用send方法,并且Generator对象的迭代并未终结,那么当前位置的yield就会得到send方法传递过来的值,这跟generator函数有没有把这个值赋值给某个变量没有任何关系。

从上面两点我们就可以看出,任何时候yield关键词都即是语句——可以为generator函数返回值,也是表达式——可以接收Generator对象发过来的值。

这里有一点需要特别注意,就是上面说的必须使用圆括号把yield表达式括起来,这个是PHP的文档中说的,这是不正确的,只有需要将yield表达式的值赋值给变量的时候才需要使用圆括号,有些情况下圆括号并非必须的,把上面的例子改一下:

<?php
function gen() {
var_dump(yield 'yield1');
var_dump(yield 'yield2');
}
$g = gen();
var_dump($g->current());
var_dump($g->send('ret1'));
var_dump($g->send('ret2'));

这次的输出跟上面的代码的输出结果一样,而这里并未使用圆括号将yield 'yield1'括起来。我们再看一个例子:

<?php
function logger($fileName) {
$fileHandle = fopen($fileName,'a');
while(true) {
fwrite($fileHandle,yield . "\n");
}
}
$logger = logger(__DIR__.'/log');
var_dump($logger->send('Foo'));
var_dump($logger->send('Bar'));

这个示例中的函数logger是一个generator函数,它用于写日志,我们看到它里面的yield根本就没有使用圆括号,它被当做一个字符串一样使用,而它的值就是调用logger函数创建的Generator对象的send方法传递过来的。猜一下上面的代码中的两个var_dump的输出是什么?

所以说yield本身就即是语句,可以像return一样从函数返回值,也是表达式,可以进行求值,还可以像普通变量一样进行运算和操作,例如上面的字符串连接,或者是算术运算。

跟Generator相关的所有知识基本都已介绍完,在所有这些概念中最关键的连个是:Generator对象和generator函数。

Generator对象和generator函数的通信

首先申明一点,Generator对象是官方的说法,而generator函数是我自己命名的(注意是小写),这个命名可以把它跟普通函数区分开来,而且这个函数作用就是返回一个Generator对象,而对Generator的操作又会反过来影响generator函数,这些都是通过yield关键字实现的,这相当于可以它们两者之间进行通信,整个过程可以概括为下面4点:

  1. generator函数中的yield语句可以中止generator函数的执行,并且将代码执行权交给Generator对象。这跟函数调用是类似的,return语句可以从子函数中退出,并返回到主函数的调用处,相当于将代码执行权转交给主函数。而且在多任务调度中也有这种模型,这叫非抢占式调度,就是由子任务主动交出调度权,而不是由某个任务调度器来管理,一些老的操作系统是按照这种模式来管理任务调度,现在的采用的都是抢占式调度,会有一个调度管理器来根据任务的优先级以及运行情况来决定调度权,它也可以强制收回某个任务的调度权,这种模式叫做抢占式调度。
  2. generator函数中的yield语句除了让渡调度权,还可以给Generator对象返回数据。
  3. Generator对象可以控制generator函数的执行,可以接收generator函数返回的数据,它有两种控制generator函数执行的方式,一种是next方法,另外一种就是send方法。
  4. Generator对象最重要的特点是可以给generator函数传递数据,这个也是send方法所做的事情。

再看一个示例:

<?php
function nums() {
for ($i = 0; $i < 5; ++$i) {
//get a value from the caller
$cmd = (yield $i);
if($cmd == 'stop')
return;//exit the function
}
}
$gen = nums();
foreach($gen as $v)
{
if($v == 3)//we are satisfied
$gen->send('stop');
echo "{$v}\n";
}
//Output
0
1
2
3
?>

在这个示例中对nums函数返回的Generator对象的遍历就是从nums函数中获取数据,这相当于从generator函数传递数据给Generator对象,而当Generator对象可以'stop'传递给nums函数来要求终止Generator的遍历了,这相当于从Generator对象到generator函数的通信。

Generator的作用

如果不考虑用Generator来实现协程,那么Generator的一个最大的作用就是为含有大量数据的集合(当前这些数据集是规则的,就像xrange所返回的那些数据)的遍历节省空间,这一点是显而易见的,我们写一个简单的benchmark来测试一下:比较range函数跟xrange函数的时间和空间的开销,代码如下:

<?php
$n = 100000;
$startTime = microtime(true);
$startMemory = memory_get_usage();
$array = range(1, $n);
foreach($array as $a) {
}
echo memory_get_usage() - $startMemory, " bytes\n";
echo microtime(true) - $startTime. " ms\n";
function xrange($start,$end,$step=1) {
for($i=$start;$i<$end;$i+=$step) {
yield $i;
}
}
$startTime = microtime(true);
$startMemory = memory_get_usage();
$g = xrange(1,$n);
foreach($g as $i) {
}
echo memory_get_usage() - $startMemory, " bytes\n";
echo microtime(true) - $startTime. " ms\n";

这段代码的输出为:

14649152 bytes
0.017426013946533 ms
408 bytes
0.012494802474976 ms

我的php版本是5.5.14,从这个测试可以看出,使用range函数生成一个含有100000个整数的数组,然后对这个数据进行遍历,它需要的存储空间为14649152个字节,大概为14M。而使用Generator则不需要生成出包含所有元素的数组,所以它的空间开销为408个字节,这个差别是非常惊人的,而且在运行时间上也更优,不过这一点上两种方式相差不多。

最后给大家留一个练习,使用Generator写一个Fibonacci序列生成器,因为Fibonacci数是无穷的,所以请考虑终止的问题。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK