深入探討PHP 5.5的Generator觀念與用法

在物件導向時代,我們會使用foreach的方式遍訪每一個物件,傳統上我們會採用Iterator Pattern,去實作Iterator interface,簡單地說,generator就是更簡單的iterator。

Generator讓你不須實踐Iterator interface,也不須將資料放在記憶體中,直到foreach要取值時,才及時產生資料使用yield傳回。

Version


PHP 5.5

為什麼要使用Generator?


傳統上我們會將資料準備在記憶體當中,為了讓foreach能遍訪每一筆資料,我們必須實踐Iterator interface。假如資料筆數很龐大,會遇到以下問題 :

  • 需要浪費極大的記憶體空間儲存資料。
  • 需要浪費寫入記憶體與讀出記憶體的時間。

假如我們能在foreach遍訪每一筆資料時,才動態地產生資料傳回,這樣就不會浪費記憶體,也不用浪費時間讀寫記憶體,這就是generator要做的。

最簡單的Generator


1
2
3
4
5
6
7
8
9
10
function myGenerator() 
{

yield 'value1';
yield 'value2';
yield 'value3';
}

foreach (myGenerator() as $yieldedValue) {
echo $yieldedValue, PHP_EOL;
}

執行結果

1
2
3
value1
value2
value3

第1行

1
2
3
4
5
6
function myGenerator() 
{

yield 'value1';
yield 'value2';
yield 'value3';
}

因為使用了yield關鍵字,所以搖身一變成了generator函式。

傳統函式會使用return關鍵字,而且一個函式最多只能有一個return

而generator會使用yield取代return,而且可以有多個yield,回傳的是Generator物件,這是一個已經實踐Iterator interface的物件,可以給foreach使用。

yield與return的差異

與傳統函式最大的差別在於return回傳值後,函式所在的記憶體在stack會釋放,導致函式內的變數都會不見,但generator函式yield回傳generator物件後,generator函式所在的記憶體stack並沒有釋放,所有函式內的變數都被保留。除此之外,當generator函式再次被呼叫時,會從上一次yield的下一行開始執行,而不是從函式的第一行開始執行。

第8行

1
2
3
foreach (myGenerator() as $yieldedValue) {
echo $yieldedValue, PHP_EOL;
}

其流程圖如下所示 :

foreach雖然執行了3次myGenerator(),但事實上每次執行的內容都不一樣,第一次只執行到yield 'value1'就結束,而第二次會執行下一行的yield 'value2',最後才執行yield 'value3'

實作range()


以上簡單的範例只是讓我們了解generator函式是如何運行,但還沒展現其威力,接著我們來實作PHP的range()函式。

若使用傳統寫法,我們可能會這樣實作range()

1
2
3
4
5
6
7
8
9
10
11
12
13
function makeRange($length) 
{

$dataset = [];

for ($i = 0; $i < $length; $i++)
$dataset[] = $i;

return $dataset;
}

$customRange = makeRange(1000000);
foreach ($customRange as $i)
echo $i, PHP_EOL;

這種寫法雖然可行,但有2個問題 :

  • 若傳進makeRange()的值很大,$dataset會非常佔記憶體。
  • makeRange()return $dataset,因為makeRange()執行完stack會釋放,必須將$dataset複製到外面stack記憶體,若傳進makeRange()的值很大,記憶體複製會非常耗時。

若使用generator,就會有新的做法 :

1
2
3
4
5
6
7
8
9
10
function makeRange($length) 
{

for ($i = 0; $i < $length; $i++)
yield $i;
}

foreach (makeRange(1000000) as $i)
{
echo $i, PHP_EOL;
}

我們不必再準備一塊記憶體放$dataset,也不用擔心return $dataset造成記憶體複製的負擔。

取而代之的是每次foreach才去yield值,而每次yield之後,記憶體不會釋放,變數i值會保留,下一次yield就是$i++的值。

讀取4GB的超大檔案


Modern PHP書中還提到另外一個在實務上非常實用的範例,若我們需要讀取一個超大檔案(4GB),但server只分配1GB的記憶給PHP使用,顯然我們不可能再將4GB的檔案放在記憶體來foreach,若使用generator將有很棒的解法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function getRows($file) 
{

$handle = fopen($file, 'rb');

if ($handle === false)
throw new Exception();

while (feof($handle) === false)
yield fgetcsv($handle);

fclose($handle);
}

foreach (getRows('data.csv') as $row)
print_r($row);

getRows() generator函式一次只靠yield回傳一行,也就是說,記憶體的需求從原本的4GB降低到只需一行的記憶體。

Generator並非萬靈丹。

因為它沒有在記憶體保存所有資料,所以只能算是forward-only iterator,它只知道目前的資料為何,然後靠yield傳回,它無法後退,也無法快轉,若要求更進階的功能,就必須使用Standard PHP Library (SPL) iterator,並且實踐Iterator interface。

Conclusion


  • Generator與yield是PHP很大的syntax sugar,程式執行的方式與傳統函式不太一樣。
  • Generator並沒有替PHP增加新的功能,generator的所有功能都可以使用傳統函式搭配Iterator interfae達成,但generator更簡單、更快速、且更節省記憶體,但也只提供forward-only功能。
2015-09-06