PHP生成器实践

定义

生成器是一次生成一个值的特殊类型函数, 可以将其视为可恢复函数。调用该函数将返回一个可用于生成连续 x 值的生成器【Generator】。

生成器提供了一种「中断机制」,使得程序可以暂时返回,等在之后的某个时刻,继续回来运行。

简单的说就是在函数的执行过程中,yield语句会把你需要的值返回给调用生成器的地方,然后退出函数,下一次调用生成器函数的时候又从上次中断的地方开始执行,而生成器内的所有变量参数都会被保存下来供下一次使用(特别像调度系统保存上下文)。

初识生成器

一个简单的应用

<?php
function my_first_generator()
{
    yield '1' => '111';
    yield '2';
}

//  输出结果
// 1 --- 111
// 0 --- 2
foreach (my_first_generator() as $key => $value) {
    echo $key, ' --- ', $value, PHP_EOL;
}

$object = my_first_generator();
// 输出结果: 
// class Generator#2 (0) {
// }
var_dump($object);

可以看到函数my_first_generator的返回值实际上是一个Generator,也就是我们说的生成器,函数my_first_generator使用关键字yield来创建

一个生成器函数看起来像一个普通的函数,不同的是普通函数返回一个值,而一个生成可以yield生成许多它所需要的值,并且每一次的生成返回值只是暂停当前的执行状态,当下次调用生成器函数时,PHP会从上次暂停的状态继续执行下去。

原型解析

Generator implements Iterator {
    // 返回当前产生的值
    public current ( void ) : mixed
    // 返回当前产生的键
    public key ( void ) : mixed
    //  生成器继续执行
    public next ( void ) : void
    // 重置迭代器
    public rewind ( void ) : void
    // 向生成器中传入一个值
    public send ( mixed $value ) : mixed
    // 向生成器中抛入一个异常
    public throw ( Exception $exception ) : void
    // 检查迭代器是否被关闭
    public valid ( void ) : bool
    // 序列化回调
    public __wakeup ( void ) : void
}

可以看到Generator实现了Iterator接口,因此我们可以使用foreach遍历我们的生成器函数。

生成器的执行流程

除此之外Generator还添加了send方法,用来向生成器函数内部传入一个值,并且在生成器函数内部当做yield表达式的结果,然后继续执行生成器,直到遇到下一个yield后会再次停住。

<?php
function my_first_generator2()
{
    ######## 生成器内部 ########
    $a = 1;
    while (true) {
        echo "从这里开始,第{$a}次调用", PHP_EOL;
        // 这里是获取外部值和返回值到外部的零界点
        echo 'receive: ' . (yield '任务完成'), PHP_EOL;
        echo "从这里开始,第{$a}次调用结束", PHP_EOL;
        $a++;
    }
}

######## 调用方 ########
$object2 = my_first_generator2();

echo '-------------1-------------', PHP_EOL;
// 【执行完下边语句将输出:】
// 从这里开始,第1次调用                  # 这一段输出是生成器进行输出的
// receive: Hello                     # 这一段输出是生成器进行输出的
// 从这里开始,第1次调用结束               # 这一段输出是生成器进行输出的
// 从这里开始,第2次调用                  # 这一段输出是生成器进行输出的,执行完后,下一句包含yield,因此交还控制权到生成器调用方
// 任务完成                            # 这一段输出是下面语句本次次生成器调用的返回值
echo $object2->send('Hello'), PHP_EOL;

echo '-------------2-------------', PHP_EOL;
// 【执行完下边语句将输出:】
// receive: world                     # 这一段输出是生成器进行输出的
// 从这里开始,第2次调用结束               # 这一段输出是生成器进行输出的
// 从这里开始,第3次调用                  # 这一段输出是生成器进行输出的
// 任务完成                            # 这一段输出是下面语句本次次生成器调用的返回值
echo $object2->send('world'), PHP_EOL;


echo '-------------3-------------', PHP_EOL;
// 【执行完下边语句将输出:】
// receive: world2                     # 这一段输出是生成器进行输出的
// 从这里开始,第3次调用结束                # 这一段输出是生成器进行输出的
// 从这里开始,第4次调用                   # 这一段输出是生成器进行输出的
// 任务完成                             # 这一段输出是下面语句本次次生成器调用的返回值
echo $object2->send('world2'), PHP_EOL;
########## 输出结果 ##########
-------------1-------------
从这里开始,第1次调用
receive: Hello
从这里开始,第1次调用结束
从这里开始,第2次调用
任务完成
-------------2-------------
receive: world
从这里开始,第2次调用结束
从这里开始,第3次调用
任务完成
-------------3-------------
receive: world2
从这里开始,第3次调用结束
从这里开始,第4次调用
任务完成

结果分析:

  1. 当我们调用生成器函数$object2 = my_first_generator2();时,这时候返回的是一个生成器对象,这时候生成器函数内部并未执行任何内容

  2. 当我们开始调用生成器的方法send 时,生成器函数my_first_generator2内部代码开始执行,控制权交到了生成器内部,期间发生了如下流程

  • 执行while循环的第一个echo

    输出内容为:从这里开始,第1次调用

  • 执行第二个echo,并使用yield关键字获取send传递的hello进行第二个echo输出, [这里的使用流程是存在问题的,因为已经忽略了第一个yield的返回值,然后继续执行了详情看本文后段]

    输出内容为:receive: Hello

  • 执行第三个echo

    输出内容为:从这里开始,第1次调用结束

  • 执行次数加一 $a++

  • 继续执行, 直到再次遇到yield关键字时才会停住,并返回yield后的值作为返回值,并将控制权交还到函数外部 (期间PHP会将生成器内部的调用堆栈和状态保存下来供下次生成器调用时使用)

    输出内容为:从这里开始,第2次调用

  1. 函数外部获得控制权,执行echo输出

    输出内容为:任务完成

  2. 继续按照上面步骤1、步骤2、步骤3的执行顺序执行

可以看到yield不仅能够返回数据而且还可以接收数据,而且两者可以同时进行,此时yield便成了数据双向传输的工具,这就为了实现协程提供了可能性。

生成器的妙用

原始

我们使用range函数生成1-1000000的数,然后输出,检测消耗

<?php

function get_micro_time()
{
    $time = microtime();

    $timeArr = explode(' ', $time);

    return $timeArr[1] + $timeArr[0];
}

function get_mem()
{
    // 1KB=1024B 1MB=1024KB
    return round(memory_get_usage() / 1024, 2);
}

$startTime = get_micro_time();
$startMem  = get_mem();

$rangeArr = range(1, 1000000);
foreach ($rangeArr as $item) {
    echo $item, PHP_EOL;
}

$endMem  = get_mem();
$endTime = get_micro_time();
echo '开始时间', $startTime, PHP_EOL;
echo '开始内存', $startMem, 'K', PHP_EOL;
echo '结束时间', $endTime, PHP_EOL;
echo '结束内存', $endMem, 'K', PHP_EOL;
echo '耗时', $endTime - $startTime, PHP_EOL;
echo '内存消耗', $endMem - $startMem, 'K', PHP_EOL;


############ 下面是程序输出 ############
<省略输出的数>
开始时间1549003663.4123
开始内存348.49K
结束时间1549003670.8961
结束内存33120.6K
耗时7.4837079048157
内存消耗32772.11K

我们再使用生成器来优化内存消耗

<?php

function get_micro_time()
{
    $time = microtime();

    $timeArr = explode(' ', $time);

    return $timeArr[1] + $timeArr[0];
}

function get_mem()
{
    // 1KB=1024B 1MB=1024KB
    return round(memory_get_usage() / 1024, 2);
}

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

$startTime = get_micro_time();
$startMem  = get_mem();

$rangeArr = xrange(1, 1000000);
foreach ($rangeArr as $item) {
    echo $item, PHP_EOL;
}

$endMem  = get_mem();
$endTime = get_micro_time();
echo '开始时间', $startTime, PHP_EOL;
echo '开始内存', $startMem, 'K', PHP_EOL;
echo '结束时间', $endTime, PHP_EOL;
echo '结束内存', $endMem, 'K', PHP_EOL;
echo '耗时', $endTime - $startTime, PHP_EOL;
echo '内存消耗', $endMem - $startMem, 'K', PHP_EOL;

############ 下面是程序输出 ############
<省略输出的数>
开始时间1549003917.0235
开始内存349.39K
结束时间1549003930.2449
结束内存349.8K
耗时13.221457004547
内存消耗0.41000000000003K

对比使用生成器和原始的方式测试,可以看到生成器消耗的内存简直少到可以忽略不计,而原始调用函数生成的方式消耗的内存极有可能将程序搞挂。但是生成器更耗时,因为生成器需要保存程序上下文。

使用生成器优点是显而易见的.它可以让你在处理大数据集合的时候不用一次性的加载到内存中.甚至你可以处理无限大的数据流.

生成器对象使用迭代器语法

这里有个疑问初始化生成器对象后,第一次调用成员方法current的时候, 会运行到第一个yield处然后直接交还控制权。是因为没有send发送参数的原因么? 还是说调用current发现指针为空就直接挪到第一次出现yield的地方

代码如下:

function my_first_generator2()
{
    $a = 1;
    while (true) {
        echo "从这里开始,第{$a}次调用", PHP_EOL;
        echo 'receive: ' . (yield '任务完成'), PHP_EOL;
        echo "从这里开始,第{$a}次调用结束", PHP_EOL;
        $a++;
    }
}

$object2 = my_first_generator2();

echo '-------------0-------------', PHP_EOL;
echo $object2->current(), PHP_EOL;

echo '-------------1-------------', PHP_EOL;
echo $object2->send('Hello'), PHP_EOL;

echo '-------------2-------------', PHP_EOL;
echo $object2->send('world'), PHP_EOL;


echo '-------------3-------------', PHP_EOL;
echo $object2->send('world2'), PHP_EOL;

#### 输出结果为 ####
-------------0-------------
从这里开始,第1次调用
任务完成
-------------1-------------
receive: Hello
从这里开始,第1次调用结束
从这里开始,第2次调用
任务完成
-------------2-------------
receive: world
从这里开始,第2次调用结束
从这里开始,第3次调用
任务完成
-------------3-------------
receive: world2
从这里开始,第3次调用结束
从这里开始,第4次调用
任务完成

看了鸟哥的博客,发现好像我梳理的【生成器的执行流程】解析是错的使用姿势,因为会忽略第一次yield的返回值

要理解为什么要调用一次current, 需要考虑下面的代码片段:

<?php
function gen() {
    yield 'foo';
    yield 'bar';
}

$gen = gen();
var_dump($gen->send('something'));

// 如之前提到的在send之前, 当$gen迭代器被创建的时候一个renwind()方法已经被隐式调用
// 所以实际上发生的应该类似:
// $gen->rewind();
// var_dump($gen->send('something'));

// 这样renwind的执行将会导致第一个yield被执行, 并且忽略了他的返回值.
// 真正当我们调用yield的时候, 我们得到的是第二个yield的值! 导致第一个yield的值被忽略.
// 通过先调用一次current 我们可以确定第一个yield的值能被正确返回.
// string(3) "bar"

所以生成器的执行流程正确的姿势是

  • 1、先调用current

  • 2、再调用send向生成器内部传递值

正确的流程应该是这样的

先执行current,生成器函数my_first_generator2内部代码开始执行,遇到第一个yield,返回值然后将控制权交到函数外部。当我们开始调用生成器的方法send 时,控制权又交到了生成器内部,期间发生了如下流程

  • 函数外部调用current,执行while循环的第一个echo

    输出内容为:从这里开始,第1次调用

  • 遇到yield,交还控制权

    输出内容为:任务完成

  • 函数外部继续调用send,控制权交到生成器,执行第二个echo,并使用yield关键字获取send传递的hello进行第二个echo输出

    输出内容为:receive: Hello

  • 执行第三个echo

    输出内容为:从这里开始,第1次调用结束

  • 执行次数加一 $a++

  • 继续执行, 直到再次遇到yield关键字时才会停住,并返回yield后的值作为返回值,并将控制权交还到函数外部 (期间PHP会将生成器内部的调用堆栈和状态保存下来供下次生成器调用时使用)

    输出内容为:从这里开始,第2次调用

  • 循环按照上面步骤3、步骤4、步骤5、步骤6的执行顺序执行

推荐阅读

发表评论

电子邮件地址不会被公开。 必填项已用*标注