PHP yield 分析(詳細)

PHP yield 分析


參考資料

  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 特性較多也較爲繁瑣。文章內容難免有出錯或者不周全的地方,如果大家發現有錯誤的地方,也希望大家留言告知, 祝大家週末愉快~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章