1. 程式人生 > >生成器 yield理解

生成器 yield理解

yield

生成器是PHP 5.5.0才引入的功能

如要深入,可以看鳥哥的文摘 協程實現多工排程

生成器優點

  • 生成器會對PHP應用的效能有非常大的影響
  • PHP程式碼執行時節省大量的記憶體
  • 比較適合計算大量的資料

那麼,這些神奇的功能究竟是如何做到的?我們先來舉個例子

概念引入

首先,來看一個簡單的PHP函式:

function createRange($number){
    $data = [];
    for($i=0;$i<$number;$i++){
        $data[] = time();
    }
    return $data;
}

這裡的程式碼也非常簡單:

我們建立一個函式, 函式內包含一個 for 迴圈,我們迴圈的把當前時間放到$data裡面, for迴圈執行完畢,把 $data 返回出去。

我們再寫一個函式,把這個函式的返回值迴圈打印出來:

$result = createRange(5);
foreach($result as $value){
    sleep(1);//這裡停頓1秒,我們後續有用
    echo $value.'<br />';
}

執行結果:

1540537809
1540537809
1540537809
1540537809
1540537809

猶豫數量小,sleep(1) 效果你們看不出來

思考一個問題

我們注意到,在呼叫函式 createRange 的時候給 $number 的傳值是10,一個很小的數字。假設,現在傳遞一個值10000000(1000萬)。

那麼,在函式 createRange 裡面,for迴圈就需要執行1000萬次。且有1000萬個值被放到 $data 裡面,而$data陣列在是被放在記憶體內。所以,在呼叫函式時候會佔用大量記憶體。

這裡,生成器就可以大顯身手了。

生成器的使用

建立生成器

我們直接修改程式碼,你們注意觀察:

function createRange($number){
    for($i=0;$i<$number;$i++){
        yield time();
    }
}

看下這段和剛剛很像的程式碼,我們刪除了陣列 $data ,而且也沒有返回任何內容,而是在 time() 之前使用了一個關鍵字 yield

使用生成器

我們再執行一下第二段程式碼:

$result = createRange(10); // 這裡呼叫上面我們建立的函式
foreach($result as $value){
    sleep(1);
    echo $value.'<br />';
}

執行結果:

1540538241
1540538242
1540538243
1540538244
1540538245

我們奇蹟般的發現了,輸出的值和第一次沒有使用生成器的不一樣。這裡的值(時間戳)中間間隔了1秒。

這裡的間隔一秒其實就是 sleep(1) 造成的後果。但是為什麼第一次沒有間隔?那是因為: 未使用生成器時: createRange 函式內的 for 迴圈結果被很快放到 $data 中,並且立即返回。所以, foreach 迴圈的是一個固定的陣列。 使用生成器時: createRange 的值不是一次性快速生成,而是依賴於 foreach 迴圈。 foreach 迴圈一次, for 執行一次。

到這裡,你應該對生成器有點兒頭緒。

深入理解生成器

下面我們來對於剛剛的程式碼進行剖析。

function createRange($number){
    for($i=0;$i<$number;$i++){
        yield time();
    }
}

$result = createRange(10); // 這裡呼叫上面我們建立的函式
foreach($result as $value){
    sleep(1);
    echo $value.'<br />';
}

我們來還原一下程式碼執行過程。

  1. 首先呼叫 createRange 函式,傳入引數10,但是 for 值執行了一次然後停止了,並且告訴 foreach 第一次迴圈可以用的值。
  2. foreach 開始對 $result 迴圈,進來首先 sleep(1) ,然後開始使用 for 給的一個值執行輸出。
  3. foreach 準備第二次迴圈,開始第二次迴圈之前,它向 for 迴圈又請求了一次。
  4. for 迴圈於是又執行了一次,將生成的時間戳告訴 foreach .
  5. foreach 拿到第二個值,並且輸出。由於 foreach 中 sleep(1) ,所以, for 迴圈延遲了1秒生成當前時間

所以,整個程式碼執行中,始終只有一個記錄值參與迴圈,記憶體中也只有一條資訊。

無論開始傳入的 $number 有多大,由於並不會立即生成所有結果集,所以記憶體始終是一條迴圈的值。

繼續舉例深入理解

function gen() {
    $ret = (yield 'yield1');
    var_dump($ret);
    $ret = (yield 'yield2');
    var_dump($ret);
}
 
$gen = gen();
var_dump($gen->current());    // string(6) "yield1"
var_dump($gen->send('ret1')); // string(4) "ret1"   (the first var_dump in gen)
                              // string(6) "yield2" (the var_dump of the ->send() return value)
var_dump($gen->send('ret2')); // string(4) "ret2"   (again from within gen)
                              // NULL               (the return value of ->send())

上面的程式碼首先是呼叫函式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關鍵字,它既是語句,又是表示式,而且這兩種情況是同時存在的:

對於所有在generator函式中出現的yield,首先它都是語句,而跟在yield後面的任何表示式的值將作為呼叫generator函式的返回值,如果yield後面沒有任何表示式(變數、常量都是表示式),那麼它會返回NULL,這一點跟return語句一致。

  • yield也是表示式,它的值就是send函式傳過來的值(相當於一個特殊變數,只不過賦值是通過send函式進行的)。只要呼叫send方法,並且Generator物件的迭代並未終結,那麼當前位置的

  • yield就會得到send方法傳遞過來的值,這跟generator函式有沒有把這個值賦值給某個變數沒有任何關係。

從上面兩點我們就可以看出,任何時候yield關鍵詞都即是語句——可以為generator函式返回值,也是表示式——可以接收Generator物件發過來的值。

但有點疑問就是:

在執行完gen中的var_dump之後,generator應該終止啊。但是,為什麼卻又恢復了,繼續執行下一條yield語句呢。 我猜是因為當yield作為表示式的時候,generator並沒有進行迭代。只有yield被當做了語句執行之後,generator才會終止吧。

舉例補充說明這個終止

function gen() {
	for($i=1;$i<=100;$i++) {
		$cmd = (yield $i);
		if($cmd=='stop') {
			return;
		}
	}
}
$gen = gen();
$i=0;
foreach($gen as $item) {
	echo $item."\n";
	if($i>=10) {
		$gen->send('stop');
	}
	$i++;
}

這是個很好地例子:

  1. yield作為語句(類似return語句),會返回$i給呼叫者。
  2. yield作為表示式。獲取send函式傳遞值,賦值給$cmd。
  3. 實現Generator物件和generator函式的通訊。這個很重要。應該能實現很多generator的互動.

概念理解

到這裡,你應該已經大概理解什麼是生成器了。下面我們來說下生成器原理。

首先明確一個概念:生成器yield關鍵字不是返回值,他的專業術語叫產出值,只是生成一個值

那麼程式碼中 foreach 迴圈的是什麼?其實是PHP在使用生成器的時候,會返回一個 Generator 類的物件。 foreach 可以對該物件進行迭代,每一次迭代,PHP會通過 Generator 例項計算出下一次需要迭代的值。這樣 foreach 就知道下一次需要迭代的值了。

而且,在執行中 for 迴圈執行後,會立即停止。等待 foreach 下次迴圈時候再次和 for 索要下次的值的時候,迴圈才會再執行一次,然後立即再次停止。直到不滿足條件不執行結束。

迭代生成陣列: 鍵=>值

如,

function createRange($number){
    for($i=0;$i<$number;$i++){
        yield $i => time();
    }
}

實際開發應用

  • 讀取超大檔案 PHP開發很多時候都要讀取大檔案,比如csv檔案、text檔案,或者一些日誌檔案。這些檔案如果很大,比如5個G。這時,直接一次性把所有的內容讀取到記憶體中計算不太現實。

這裡生成器就可以派上用場啦。簡單看個例子:讀取text檔案

<?php
header("content-type:text/html;charset=utf-8");
function readTxt()
{
    # code...
    $handle = fopen("./test.txt", 'rb');

    while (feof($handle)===false) {
        # code...
        yield fgets($handle);
    }

    fclose($handle);
}

foreach (readTxt() as $key => $value) {
    # code...
    echo $value.'<br />';
}

使用生成器讀取檔案,第一次讀取了第一行,第二次讀取了第二行,以此類推,每次被載入到記憶體中的文字只有一行,大大的減小了記憶體的使用。

這樣,即使讀取上G的文字也不用擔心,完全可以像讀取很小檔案一樣編寫程式碼。