PHP學習筆記系列:PHP生成器概覽

icyfire發表於2019-02-16

生成器概述

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…

參考

相關文章