1. 程式人生 > >PHP yield 分析,以及協程的實現,超詳細版(上)

PHP yield 分析,以及協程的實現,超詳細版(上)

出錯 同時 分享圖片 spl 們的 是什麽 cti 接下來 版本


參考資料

  1. http://www.laruence.com/2015/05/28/3038.html
  2. http://php.net/manual/zh/class.generator.php
  3. http://www.cnblogs.com/whoamme/p/5039533.html
  4. http://php.net/manual/zh/class.iterator.php


PHP的 yield 關鍵字是php5.5版本推出的一個特性,算是比較古老的了,其他很多語言中也有類似的特性存在。但是在實際的項目中,目前用到還比較少。網上相關的文章最出名的就是鳥哥的那篇了,但是都不夠細致理解起來較為困難,今天我來給大家超詳細的介紹一下這個特性。


function gen(){
  while(true){
    yield "gen\n";
  }
}

$gen = gen();

var_dump($gen instanceof Iterator);
echo "hello, world!";

如果事先沒了解過yield,可能會覺得這段代碼一定會進入死循環。但是我們將這段代碼直接運行會發現,輸出hello, world!,預想的死循環沒出現。
究竟是什麽樣的力量,征服了while(true)呢,接下來就帶大家一起來領略一下yield關鍵字的魅力。

首先要從foreach說起,我們都知道對象,數組和對象可以被foreach語法遍歷,數字和字符串缺不行。其實除了數組和對象之外PHP內部還提供了一個 Iterator 接口,實現了Iterator接口的對象,也是可以被foreach語句遍歷,當然跟普通對象的遍歷就很不一樣了。


以下面的代碼為例:

class Number implements Iterator{
  protected $key;
  protected $val;
  protected $count;

  public function __construct(int $count){
    $this->count = $count;
  }

  public function rewind(){
    $this->key = 0;
    $this->val = 0;
  }

  public function next
(){   $this->key += 1;   $this->val += 2;   }   public function current(){     return $this->val;   }   public function key(){   return $this->key + 1;   }   public function valid(){     return $this->key < $this->count;   } } foreach (new Number(5) as $key => $value){   echo "{$key} - {$value}\n"; }

這個例子將輸出
1 - 0
2 - 2
3 - 4
4 - 6
5 - 8

關於上面的number對象,被遍歷的過程。如果是初學者,可能會出現有點懵的情況。為了深入的了解Number對象被遍歷的時候內部是怎麽工作的,我將代碼改了一下,將接口內的每個方法都盡心輸出,借此來窺探一下遍歷時對象內部方法的的執行情況。

  class Number implements Iterator{  
        protected $i = 1;
        protected $key;
        protected $val;
        protected $count; 
        public function __construct(int $count){
            $this->count = $count;
            echo "第{$this->i}步:對象初始化.\n";
            $this->i++;
        }
        public function rewind(){
            $this->key = 0;
            $this->val = 0;
            echo "第{$this->i}步:rewind()被調用.\n";
            $this->i++;
        }
        public function next(){
            $this->key += 1;
            $this->val += 2;
            echo "第{$this->i}步:next()被調用.\n";
            $this->i++;
        }
        public function current(){
            echo "第{$this->i}步:current()被調用.\n";
            $this->i++;
            return $this->val;
        }
        public function key(){
            echo "第{$this->i}步:key()被調用.\n";
            $this->i++;
            return $this->key;
        }
        public function valid(){
            echo "第{$this->i}步:valid()被調用.\n";
            $this->i++;
            return $this->key < $this->count;
        }
    }

    $number = new Number(5);
    echo "start...\n";
    foreach ($number as $key => $value){
        echo "{$key} - {$value}\n";
    }
    echo "...end...\n";

以上代碼輸出如下

技術分享圖片
第1步:對象初始化.
start...
第2步:rewind()被調用.
第3步:valid()被調用.
第4步:current()被調用.
第5步:key()被調用.
0 - 0
第6步:next()被調用.
第7步:valid()被調用.
第8步:current()被調用.
第9步:key()被調用.
1 - 2
第10步:next()被調用.
第11步:valid()被調用.
第12步:current()被調用.
第13步:key()被調用.
2 - 4
第14步:next()被調用.
第15步:valid()被調用.
第16步:current()被調用.
第17步:key()被調用.
3 - 6
第18步:next()被調用.
第19步:valid()被調用.
第20步:current()被調用.
第21步:key()被調用.
4 - 8
第22步:next()被調用.
第23步:valid()被調用.
...end...
View Code


看到這裏,我相信大家對Iterator接口已經有一定認識了。會發現當對象被foreach的時候,內部的valid,current,key方法會依次被調用,其返回值便是foreach語句的key和value。循環的終止條件則根據valid方法的返回而定。如果返回的是true則繼續循環,如果是false則終止整個循環,結束遍歷。當一次循環體結束之後,將調用next進行下一次的循環直到valid返回false。而rewind方法則是在整個循環開始前被調用,這樣保證了我們多次遍歷得到的結果都是一致的。

那麽這個跟yield有什麽關系呢,這便是我們接下來要說的重點了。首先給大家介紹一下我總結出來的 yield 的特性,包含以下幾點。
1.yield只能用於函數內部,在非函數內部運用會拋出錯誤。
2.如果函數包含了yield關鍵字的,那麽函數執行後的返回值永遠都是一個Generator對象。
3.如果函數內部同事包含yield和return 該函數的返回值依然是Generator對象,但是在生成Generator對象時,return語句後的代碼被忽略。
4.Generator類實現了Iterator接口。
5.可以通過返回的Generator對象內部的方法,獲取到函數內部yield後面表達式的值。
6.可以通過Generator的send方法給yield 關鍵字賦一個值。
7.一旦返回的Generator對象被遍歷完成,便不能調用他的rewind方法來重置
8.Generator對象不能被clone關鍵字克隆

首先看第1點,可以明白我們文章開頭的gen函數執行後返回的是一個Generatory對象,所以代碼可以繼續執行下去輸出hello, world!,因此$gen是一個Generator對象,由於其實現了Iterator,所以這個對象可以被foreach語句遍歷。下面我們來看看對其進行遍歷,會是什麽樣的效果。為了防止被死循環,我加多了一個break語句只進行十次循環,方便我們了解yield的一些特性。
代碼如下:

    $i = 0;
    foreach ($gen as $key => $value) {
        echo "{$key} - {$value}";
        if(++$i >= 10){
            break;
        }
    }


以上代碼輸出為
0 - gen
1 - gen
2 - gen
3 - gen
4 - gen
5 - gen
6 - gen
7 - gen
8 - gen
9 - gen
通過觀察不難發現其中的規律。在包含yield的函數返回的對象被foreach遍歷時, 函數體內部的代碼會被對應的執行。PHP 會分析其內部的代碼從而生成對應的Iterator接口的方法。
其中key方法實現是返回的是yield出現的次序,從0開始遞增。
current方法則是yield後面表達式的值。
而valid方法則在當前yield語句存在的時候返回true, 如果當前不在yield語句的時候返回false。
next方法則執行從當前到下一個yield、或者return、或者函數結束之間的代碼。
網上也有文章讓大家把yield理解為暫時停止函數的執行,等待外部的激活從而再次執行。雖然看起來確實像那麽回事,但我不建議大家這麽理解,因為他本身是返回一個叠代器對象,其返回值是可以被用於叠代的。我們理解了他被foreach叠代時,其內部是如運作的之後更易於理解yield關鍵字的本質。
下面我們再做一個簡單的測試,以便更直觀的展示他的特性。

    function gen1(){
        yield 1;
        echo "i\n";
        yield 2;
        yield 3+1;
    }
    $gen = gen1();
    foreach ($gen as $key => $value) {
        echo "{$key} - {$value}\n";
    }

以上的代碼輸出
0 - 1
i
1 - 2
2 - 4
我們來分析一下輸出的結果,首先當遍歷開始時rewind被執行由於第一個yield之前無任何語句,無任何輸出。
key的值為yield出現的次序為0,current為yield表達式後的值也就是1。
foreach開始,valid因為當前為第一個yield,所以返回true。正常輸出0 - 1
此時next方法被執行,跳轉到了第二個yield,第一個到第二個之間的代碼被執行輸出了i。
再次進入循環 執行vaild,由於當前在第二個yield上面,所以依然是true
由於next執行了,所以key的值也有剛剛的0變為了1,current的值為2,正常輸出 1 - 2。
這時候繼續執行next(),進入循環vaild()執行,由於此時到了第三個yield返回依然是true。key的值為2, yield為4。正常輸出 2 - 4
再次執行next(),由於後續沒有yield了vaild()返回為false, 所以循環到此便終止了。

下面我們用代碼來驗證一下

    $gen = gen1();
    var_dump($gen->valid());
    echo $gen->key().‘ - ‘.$gen->current()."\n";
    $gen->next(); 
    var_dump($gen->valid());
    echo $gen->key().‘ - ‘.$gen->current()."\n";
    $gen->next(); 
    var_dump($gen->valid());
    echo $gen->key().‘ - ‘.$gen->current()."\n";
    $gen->next(); 
    var_dump($gen->valid());


輸出值如下
bool(true)
0 - 1
i
bool(true)
1 - 2
bool(true)
2 - 4
bool(false)
跟我們的分析完全一致,至此我們了解了Iterator接口在遍歷時內部的運作方式,也了解了包含yield關鍵字的函數所生成的對象內部是如何實現Iterator接口的方法的。對於yild的特性了解一半了,但是如果我們僅僅將其用於生成可以被遍歷的對象的話,yield目前對我們來說,似乎無太大的用處。當然我們可以利用他來生成一些集合對象,節約一些內存知道數據真正被用到的時候在生成。例如:
我們可以寫一個方法

    function gen2(){
        yield getUserData();
        yield getBannerList();
        yield getContext();
    }
    #中間其他操作
    #然後在view中獲得數據
    $data = gen2();
    foreach ($data as $key => $value) {
        handleView($key, $value);
    }


通過以上的代碼,我們將幾個獲取數據的操作都延遲到了數據被渲染的時候執行。節省了中間進行其他操作時獲取回來的數據占用的內存空間。然而實際開放項目的過程中,這些數據往往被多處使用。而且這樣的結構讓我們單獨控制數據變得艱難,以此帶來的性能提升相對於便利性來說,好處微乎其微。不過還好的是,我們對yield的了解才剛剛到一半,已經有這樣的功效了。相信我們在了解完另外一半之後,它的功效將大大提升。
接下來我們來繼續了解yield, 由於yield返回的是一個Generator類的對象,這個對象除了實現了Iterator接口之外,內部還有一個相當重要的方法就是send方法,即我們提到的第6點特性,通過send方法我們可以給yield發送一個值作為yield語句的值。
首先大家考慮一下下面的代碼

    function gen3(){
        echo "test\n";
        echo (yield 1)."I\n";
        echo (yield 2)."II\n";
        echo (yield 3 + 1)."III\n";
    }
    $gen = gen3();
    foreach ($gen as $key => $value) {
        echo "{$key} - {$value}\n";
    }


執行以後輸出
0 - 1
I
1 - 2
II
2 - 4
III
可能這段輸出比較難理解,我們接下來,一步一步分析一下為什麽得出這樣的輸入。由於我們知道了foreach的時候gen內部是如何操作的,那麽我們便用代碼來實現一次。

    $gen = gen3();
    $gen->rewind();
    echo $gen->key().‘ - ‘.$gen->current()."\n"; 
    $gen->next(); 

執行後輸出
0 - 1
I
通過這兩句我們發現,當前的key為0,current則為1也就是yield後面表達式的值。因為yield 1被括號括起來了,所以yield後面表達式的值是1,如果沒有括號則為1."I\n".當然因為1."I\n"是一個錯誤語法。如果想要測試的朋友需要給1加上雙引號。
當執行next時,第1個yield到第二個yieldz之間的的語法被執行。也就是echo (yield 1)."I\n"被執行了,由於我們使用的是next(),所以yield當前是無值的。所以輸出了I。需要註意的是在第一個yield之後的語法將不會被執行,而 echo (yield 2). "II\n";屬於下一個yield塊的語句,所以不會被執行。
到這裏,是時候讓我們今天最後的主角send方法來表現一下了。

public mixed Generator::send ( mixed $value )
這個是手冊裏send方法的描述,可以看出來他可以接受一個mixed類型的參數,也會返回一個mixed類型的值。
傳入的參數會被做 yield 關鍵字在語句中的值,而他的返回值則是next之後,$gen->current()的值。

下面我們來嘗試一下

    $gen = gen3(); 
    $gen->rewind();
    echo $gen->key().‘ - ‘.$gen->current()."\n"; 
    echo $gen->send("send value - ");  

執行後輸出
0 - 1
send value - I
2
這時候我們發現,我們通過send方法成功的將一個值傳遞給了一個函數的內部,並且當做yield關鍵字的值給輸出了,由於下一個yield的值為2,所以我們調用send返回的值為2,同樣被輸出。

雖然我們知道了send可以完成內部對函數內部的yield表達式傳值,也知道了可以通過$gen->current()獲得當前yield表達式之後的值,但是這個有什麽用呢。可以看一下這個函數

    function gen4(){
        $id = 2;
        $id = yield $id;
        echo $id;
    }

    $gen = gen4();
    $gen->send($gen->current() + 3);

根據上面對yield代碼的理解,我們不難發現這個函數會輸出5,因為current()為2,而當我們send之後 yield的值為 2 + 3,也就是5.同時yield到函數結束之間的代碼被執行。也就是$id = 5; echo $id;
通過這樣一個簡單的例子,我們發現。我們不但從函數內部獲得了返回值,並且將他的返回值再次發送給了函數內部參與後續的計算。

關於yield的介紹就到此為止了,本文至此也告一段落。後續將會給大家帶來,關於yield的下篇,實現一個調度器使得我們只需要將gen()函數返回的gen對象傳遞給調度器,其內部的代碼就能自動的執行。並且讓利用yield來實現並行(偽),以及在多個$gen對象執行之間建立聯系和控制其執行順序,請大家多多關註。另外由於本人才疏學淺,yield特性較多也較為繁瑣。文章內容難免有出錯或者不周全的地方,如果大家發現有錯誤的地方,也希望大家留言告知, 祝大家周末愉快~


PHP yield 分析,以及協程的實現,超詳細版(上)