5

生成器概述

PHP从5.5.0版本开始支持生成器(Generator),根据PHP官方文档的说法:生成器提供了一种更容易的方法来实现简单的对象迭代,相比较定义类实现 Iterator 接口的方式,性能开销和复杂性大大降低。

所以生成器首先是一个迭代器(Iterator),也就是说它可以使用foreach进行遍历。生成器就类似一个返回数组的函数,它可以接收参数,并被调用。

我们以range()函数为例,把它实现为生成器:

<?php
function xrange($start, $end, $step = 1) {
    for ($i = $start; $i <= $end; $i += $step) {
        yield $i;
    }
}

echo 'results from range():';
foreach (range(1, 10, 3) as $v) {
    echo "$v ";
}

echo PHP_EOL . 'results from xrange():';
foreach (xrange(1, 10, 3) as $v) {
    echo "$v ";
}

结果看起来是一样的:

results from range():1 4 7 10 
results from xrange():1 4 7 10

可以看到,xrange()使用yield关键字,而不是return。使用yield关键字后,调用函数时就会返回一个生成器(Generator)的对象(Generator是一个内部类,不能直接实例化),这个对象实现了Iterator接口,所以正如前面说过,生成器是迭代器,我们可以通过以下代码验证下:

<?php
// bool(true)
var_dump(xrange() instanceof Iterator);

跟普通函数只返回一次值不同的是, 生成器可以根据需要yield多次,以便生成需要迭代的值。 普通函数return后,函数会被从栈中移除,中止执行,但是yield会保存生成器的状态,当被再次调用时,迭代器会从上次yield的地方恢复调用状态继续执行。看下下面代码的执行结果:

<?php
function xrange($start, $end, $step = 1) {
    echo "The generator has started" . PHP_EOL; 
    for ($i = $start; $i <= $end; $i += $step) {
        yield $i;
        echo "Yielded $i" . PHP_EOL;
    }
    echo "The generator has ended" . PHP_EOL; 
}

foreach (xrange(1, 10, 3) as $v) {
    echo "return $v" . PHP_EOL;
}
The generator has started
return 1
Yielded 1
return 4
Yielded 4
return 7
Yielded 7
return 10
Yielded 10
The generator has ended

可以看到,每次迭代,在yield后,代码不会继续执行,而是先执行调用者的代码,然后在下一次迭代,迭代器的代码继续执行,一直到没有yield可以执行为止。

生成器语法

return值

前面说过,函数里使用yield关键字后,在被调用时会返回一个生成器对象,所以生成器函数的核心是yield关键字。它的调用形式看起来像一个return申明,不同之处在于普通return会返回值并终止函数的执行,而yield会返回一个值给循环调用此生成器的代码并且只是暂停执行生成器函数。

一个生成器函数不可以通过return返回值(很显而易见,因为生成器函数被调用后返回的是一个生成器对象), 在PHP 5.6版本及之前,如果使用return返回一个值的话,会产生一个编译错误:

PHP Fatal error:  Generators cannot return values using "return" in /path/to/php_code.php on line x

在PHP 7中,可以使用getReturn()得到return的返回值:

<?php

function gen_return() {
    for ($i = 0; $i < 3; $i++) {
        yield $i;
    }
    
    return 1; 
}

$gen = gen_return();
foreach($gen as $v);
echo $gen->getReturn(); // 1

不过有个前提,就是生成器已经完成了迭代,否则会报以下错误:

PHP Fatal error:  Uncaught Exception: Cannot get return value of a generator that hasn't returned in /path/to/php_code.php:x

另外,return空无论是在PHP 7还是之前支持生成器的PHP版本都是一个有效的语法,它会终止生成器继续执行。

生成null值

如果yield后面没有跟任何的参数,则会返回NULL值:

<?php

function gen_nulls() {
    for ($i = 0; $i < 3; $i++) {
        yield;
    }
}

var_dump(iterator_to_array(gen_nulls()));

输出:

array(3) {
  [0]=>
  NULL
  [1]=>
  NULL
  [2]=>
  NULL
}

生成键值对

PHP的数组支持关联键值对数组,生成器其实也支持生成键值对:

<?php

function gen_key_values() {
    for ($i = 0; $i < 3; $i++) {
        yield 'key' . $i => $i;
    }
}

var_dump(iterator_to_array(gen_key_values()));

输出:

array(3) {
  ["key0"]=>
  int(0)
  ["key1"]=>
  int(1)
  ["key2"]=>
  int(2)
}

注入值

除了生成值,生成器还能从外面接收值。通过生成器对象的send()方法,我们可以从外面传递值到生成器里。这个值会作为yield表达式的结果,我们可以利用这个值来做一些计算或者其他事情,例如根据值来中止生成器的执行:

<?php
function nums() {
    for ($i = 0; $i < 5; ++$i) {
        // 从caller获取值
        $cmd = (yield $i);
        if ($cmd === 'stop') {
            return; // 退出生成器
        }
    }
}

$gen = nums();

foreach ($gen as $v) {
    if ($v === 3) {
        $gen->send('stop');
    }
    echo $v . PHP_EOL;
}

输出结果:

0
1
2
3

send()方法的返回值是下一个yield的值,如果没有,则返回NULL。

需要注意的是, 如果在一个表达式上下文(例如上面的情况,在一个赋值表达式的右侧)中使用yield,必须使用圆括号把yield申明包围起来。 例如:

$data = (yield $value);

下面的代码在PHP5中会产生一个编译错误:

$data = yield $value;

yield from表达式

在PHP 7里,使用yield from表达式允许你在生成器里通过其他生成器、Traversable对象或者数组产生值。这种方式叫做生成器委托。下面的例子来自官方文档:

<?php
function count_to_ten() {
    yield 1;
    yield 2;
    yield from [3, 4];
    yield from new ArrayIterator([5, 6]);
    yield from seven_eight();
    yield 9;
    yield 10;
}

function seven_eight() {
    yield 7;
    yield from eight();
}

function eight() {
    yield 8;
}

foreach (count_to_ten() as $num) {
    echo "$num ";
}

输出:

1 2 3 4 5 6 7 8 9 10 

为什么不使用Iterator

生成器也是迭代器,那为什么不直接使用迭代器呢?其实文章刚开始就说到了:生成器提供了一种更容易的方法来实现简单的对象迭代,相比较定义类实现 Iterator 接口的方式,性能开销和复杂性大大降低。

更低的复杂度

要使用迭代器,必须要实现Iterator接口里的所有方法,这无疑大大增加了使用成本,具体可以看看官方文档里的例子:Comparing generators with Iterator objects

更低的内存占用

除了复杂度,另外一个使用生成器的原因就是使用生成器可以大大减少内存的使用。以文章最开始的例子为例,标准的 range() 函数需要在内存中生成一个数组包含每一个在它范围内的值,然后返回该数组,这样就会产生多个很大的数组。 比如,调用 range(0, 1000000) 将导致内存占用超过 100 MB。而我们实现的xrange()生成器, 只需要足够的内存来创建 生成器对象并在内部跟踪生成器的当前状态,这样只需要不到1K字节的内存。

<?php
function xrange($start, $end, $step = 1) {
    for ($i = $start; $i <= $end; $i += $step) {
        yield $i;
    }
}

echo 'Test for range():' . PHP_EOL;
$startTime = microtime(true);
$m = memory_get_peak_usage();
foreach (range(1, 1000000) as $v);
$endTime = microtime(true);
echo 'time:' . bcsub($endTime, $startTime, 4) . PHP_EOL;
echo 'memory (byte):' . (memory_get_peak_usage() - $m);
echo PHP_EOL;

echo 'Test for xrange():' . PHP_EOL;
$startTime = microtime(true);
$m = memory_get_peak_usage(true);
foreach (xrange(1, 1000000) as $v);
$endTime = microtime(true);
echo 'time:' . bcsub($endTime, $startTime, 4) . PHP_EOL;
echo 'memory (byte):' . (memory_get_peak_usage(true) - $m);

测试结果:

Test for range():
time:0.2319
memory (byte):144376424
Test for xrange():
time:0.1382
memory (byte):0

可以看到,在内存占用上,xrange()远远低于range(),甚至在速度上也占优。在诸如读取文件之类的场景,使用生成器也可以大大减少内存的占用:

<?php
function file_lines($filename) {
    $file = fopen($filename, 'r'); 
    while (($line = fgets($file)) !== false) {
        yield $line; 
    } 
    fclose($file); 
}

foreach (file_lines('somefile') as $line) {
    // do something
}

使用生成器实现协程

PHP的生成器特性使得在PHP中实现协程成为了可能,下面是一篇使用协程实现多任务调度的文章,虽然是12年的文章,但是仍然很有参考意义:

http://nikic.github.io/2012/1...

参考


icyfire
1.4k 声望53 粉丝