[單篇] PHP 知識補全 —— 生成器 (generator)和協程的實現

chongyi發表於2015-10-28

我部落格的原文地址 https://www.insp.top/article/php-knowledge... 轉載請註明原文地址!

先說一些廢話

PHP 5.5 以來,新的諸多特性又一次令 PHP 煥發新的光彩,雖然在本文寫的時候已是 PHP 7 alpha 2 釋出後的一段時間,但此時國內依舊是 php 5.3 的天下。不過我認為新的特性遲早會因為舊的版本的逐漸消失而變得越發重要,尤其是 PHP 7 的正式版出來後,因此本文的目的就是為了在這之前,幫助一些 PHPer 瞭解一些他們從沒有了解的東西。所以打算將以本篇作為部落格中 PHP 知識補全 系列文章的開篇。

其實在寫本文之前,我對生成器以及基於此特性延伸出來的 php 的協程實現並沒有比較直觀的瞭解,主要是我個人水平並不是很高,屬於典型的剛入了門的 PHPer。所以在看了前段時間鳥哥(laruence)部落格中對協程的講解(原文連結:《在PHP中使用協程實現多工排程》)後,在我個人對本篇的理解上,針對那些比較難以理解的概念(包括我個人在理解這一概念的時候的難點),以一個更為通俗的方式去講明白。當然由於本人也是剛剛去學習這一概念,所以有些不得當的地方在所難免,希望大神看見了請不吝賜教。

一切從 Iterator 和 Generator 開始

為便於新入門開發者理解,本文一半篇幅是講述迭代器介面(Iterator)和 Generator 類的,對此已經理解的話,可以直接跳過。

迭代和迭代器

在理解本文大多數概念前,有必要知道迭代和迭代器。事實上,迭代大家都知道是什麼,可是我不知道(真的,在此之前對這個概念沒有系統瞭解)。迭代是指反覆執行一個過程,每執行一次叫做一次迭代。實際上我們經常做這種事情,比如:

<?php
$mapping = [
    'red'   => '#FF0000',
    'green' => '#00FF00',
    'blue'  => '#0000FF'
];

foreach ($mapping as $key => $value) {
    printf("key: %d - value: %s\n", $key, $value);
}

我們可以看到透過 foreach 對陣列遍歷並迭代輸出其內容。在這一環節中,我們需要關注的重點是陣列。雖然我們迭代的過程是 foreach 語句中的程式碼塊,但實際上陣列 $mapping 在每一次迭代中發生了變化,意味著陣列內部也存在著一次迭代。如果我們把陣列看做一個物件,foreach 實際上在每一次迭代過程都會呼叫該物件的一個方法,讓陣列在自己內部進行一次變動(迭代),隨後透過另一個方法取出當前陣列物件的鍵和值。這樣一個可透過外部遍歷其內部資料的物件就是一個迭代器物件,其遵循的統一的訪問介面就是迭代器介面(Iterator)

PHP 提供了一個統一的迭代器介面。關於迭代器 PHP 官方文件有更為詳細的描述,建議去了解。

interface Iterator extends Traversable
{
    /**
     * 獲取當前內部標量指向的元素的資料
     */
    public mixed current ( void )

    /**
     * 獲取當前標量
     */
    public scalar key ( void )

    /**
     * 移動到下一個標量
     */
    public void next ( void )

    /**
     * 重置標量
     */
    public void rewind ( void )

    /**
     * 檢查當前標量是否有效
     */
    public boolean valid ( void )
}

我們來給出一個例項,去實現一個簡單的迭代器:

class Xrange implements Iterator
{
    protected $start;
    protected $limit;
    protected $step;
    protected $i;

    public function __construct($start, $limit, $step = 0)
    {
        $this->start = $start;
        $this->limit = $limit;
        $this->step  = $step;
    }

    public function rewind()
    {
        $this->i = $this->start;
    }

    public function next()
    {
        $this->i += $this->step;
    }

    public function current()
    {
        return $this->i;
    }

    public function key()
    {
        return $this->i + 1;
    }

    public function valid()
    {
        return $this->i <= $this->limit;
    }
}

透過 foreach 遍歷來看看這個迭代器的效果:

foreach (new Xrange(0, 10, 2) as $key => $value) {
    printf("%d %d\n", $key, $value);
}

輸出:

1 0
3 2
5 4
7 6
9 8
11 10

至此我們看到了一個迭代器的實現。一些人在瞭解這一特性會很激動的將其應用在實際專案中,但有些則疑惑這有什麼卵用呢?迭代器只是將一個普通物件變成了一個可被遍歷的物件,這在有些時候,如一個物件 StudentsContact,這個物件是用於處理學生聯絡方式的,透過 addStudent 方法註冊學生,透過 getAllStudent 獲取全部註冊的學生聯絡方式陣列。我們以往遍歷是透過 StudentsContact::getAllStudent() 獲取一個陣列然後遍歷該陣列,但是現在有了迭代器,只要這個類繼承這個介面,就可以直接遍歷該物件獲取學生陣列,並且可以在獲取之前在類的內部就對輸出的資料做好處理工作。

當然用處遠不止這麼點,但在這裡就不過多糾結。有一個在此基礎上更為強大的東西,生成器。

生成器,Generator

雖然迭代器僅需繼承介面即可實現,但依舊很麻煩,我們畢竟需要定義一個類並實現該介面所有方法,這十分繁瑣。在一些情景下我們需要更簡潔的辦法。生成器提供了一種更容易的方法來實現簡單的物件迭代,相比較定義類實現 Iterator 介面的方式,效能開銷和複雜性大大降低。

PHP 官方文件這樣說的:

生成器允許你在 foreach 程式碼塊中寫程式碼來迭代一組資料而不需要在記憶體中建立一個陣列, 那會使你的記憶體達到上限,或者會佔據可觀的處理時間。相反,你可以寫一個生成器函式,就像一個普通的自定義函式一樣, 和普通函式只返回一次不同的是, 生成器可以根據需要 yield 多次,以便生成需要迭代的值。

一個簡單的例子就是使用生成器來重新實現 range() 函式。 標準的 range() 函式需要在記憶體中生成一個陣列包含每一個在它範圍內的值,然後返回該陣列, 結果就是會產生多個很大的陣列。 比如,呼叫 range(0, 1000000) 將導致記憶體佔用超過 100 MB。

做為一種替代方法, 我們可以實現一個 xrange() 生成器, 只需要足夠的記憶體來建立 Iterator 物件並在內部跟蹤生成器的當前狀態,這樣只需要不到1K位元組的記憶體。

官方文件給了上文對應的例子,我們在此簡化了一下:

function xrange($start, $limit, $step = 1) {
    for ($i = $start; $i <= $limit; $i += $step) {
        yield $i + 1 => $i; // 關鍵字 yield 表明這是一個 generator
    }
}

// 我們可以這樣呼叫
foreach (xrange(0, 10, 2) as $key => $value) {
    printf("%d %d\n", $key, $value);
}

可能你已經發現了,這個例子的輸出和我們前面在說迭代器的時候那個例子結果一樣。實際上生成器生成的正是一個迭代器物件例項,該迭代器物件繼承了 Iterator 介面,同時也包含了生成器物件自有的介面,具體可以參考 Generator 類的定義。

當一個生成器被呼叫的時候,它返回一個可以被遍歷的物件.當你遍歷這個物件的時候(例如透過一個foreach迴圈),PHP 將會在每次需要值的時候呼叫生成器函式,並在產生一個值之後儲存生成器的狀態,這樣它就可以在需要產生下一個值的時候恢復呼叫狀態。

一旦不再需要產生更多的值,生成器函式可以簡單退出,而呼叫生成器的程式碼還可以繼續執行,就像一個陣列已經被遍歷完了。

我們需要注意的關鍵是 yield,這是生成器的關鍵。我們透過上面例子,可以看得出,yield 會將當前一個值傳遞給 foreach,換句話說,foreach 每一次迭代過程都會從 yield 處取一個值,直到整個遍歷過程不再存在 yield 為止的時候,遍歷結束。

我們也可以發現,yield 和 return 都會返回值,但區別在於一個 return 是返回既定結果,一次返回完畢就不再返回新的結果,而 yield 是不斷產出直到無法產出為止。

實際上存在 yield 的函式返回值返回的是一個 Generator 物件(這個物件不能手動透過 new 例項化),該物件實現了 Iterator 介面。那麼 Generator 自身有什麼獨特之處?繼續看:

yield

字面上解釋,yield 代表著讓位、讓行。正是這個讓行使得透過 yield 實現協程變得可能。

生成器函式的核心是 yield 關鍵字。它最簡單的呼叫形式看起來像一個 return 申明,不同之處在於普通 return 會返回值並終止函式的執行,而 yield 會返回一個值給迴圈呼叫此生成器的程式碼並且只是暫停執行生成器函式。

yield 和 return 的區別,前者是暫停當前過程的執行並返回值,而後者是中斷當前過程並返回值。暫停當前過程,意味著將處理權轉交由上一級繼續進行,直至上一級再次呼叫被暫停的過程,該過程則會從上一次暫停的位置繼續執行。這像是什麼呢?如果讀者在讀本篇文章之前已經在鳥哥的文章中粗略看過,應該知道這很像是一個作業系統的程式排程管理,多個程式在一個 CPU 核心上執行,在系統排程下每一個程式執行一段指令就被暫停,切換到下一個程式,這樣看起來就像是同時在執行多個任務。

但僅僅是如此還遠遠不夠,yield 更重要的特性是除了可以返回一個值以外,還能夠接收一個值!

function printer()
{
    while (true) {
        printf("receive: %s\n", yield);
    }
}

$printer = printer();

$printer->send('hello');
$printer->send('world');

上述例子輸出內容為:

receive: hello
receive: world

參考 PHP 官方中文文件:生成器 物件 我們可以得知 Generator 物件除了實現 Iterator 介面中的必要方法以外,還有一個 send 方法,這個方法就是向 yield 語句處傳遞一個值,同時從 yield 語句處繼續執行,直至再次遇到 yield 後控制權回到外部。

我們透過之前也瞭解了一個問題,yield 可以在其位置中斷並返回一個值,那麼能不能同時進行 接收返回 呢?當然,這可是實現協程的根本。我們對上述程式碼做出修改:

<?php
function printer()
{
    $i = 0;
    while (true) {
        printf("receive: %s\n", (yield ++$i));
    }
}

$printer = printer();

printf("%d\n", $printer->current());
$printer->send('hello');
printf("%d\n", $printer->current());
$printer->send('world');
printf("%d\n", $printer->current());

輸出內容如下:

1
receive: hello
2
receive: world
3

current 方法是迭代器( Iterator )介面必要的方法,foreach 語句每一次迭代都會透過其獲取當前值,而後呼叫迭代器的 next 方法。我們為了使程式不會無限執行,手動呼叫 current 方法獲取值。

上述例子已經足以表示 yield 在那一個位置作為雙向傳輸的 工具,已具備實現協程的條件。

協程

這一部分我不打算長篇大論,本文開頭已經給出了鳥哥部落格中更為完善的文章,本文的目的是出於補充對 Generator 的細節。

我們要知道,對於單核處理器,多工的執行原理是讓每一個任務執行一段時間,然後中斷、讓另一個任務執行然後在中斷後執行下一個,如此反覆。由於其執行切換速度很快,讓外部認為多個任務實際上是 “並行” 的。

鳥哥那篇文章這麼說道:

多工協作這個術語中的 “協作” 很好的說明了如何進行這種切換的:它要求當前正在執行的任務自動把控制傳回給排程器,這樣就可以執行其他任務了。這與 “搶佔” 多工相反, 搶佔多工是這樣的:排程器可以中斷執行了一段時間的任務, 不管它喜歡還是不喜歡。協作多工在 Windows 的早期版本 (windows95) 和 Mac OS 中有使用, 不過它們後來都切換到使用搶先多工了。理由相當明確:如果你依靠程式自動交出控制的話,那麼一些惡意的程式將很容易佔用整個CPU,不與其他任務共享。

我們結合之前的例子,可以發現,yield 作為可以讓一段任務自身中斷,然後回到外部繼續執行。利用這個特性可以實現多工排程的功能,配合 yield 的雙向通訊功能,以實現任務和排程器之間進行通訊。

這樣的功能對於讀寫和操作 Stream 資源時尤為重要,我們可以極大的提高程式對於併發流資源的處理能力,比如實現 tcp server。以上在 《在PHP中使用協程實現多工排程》 有更為詳盡的例子。本文不再贅述。

總結

PHP 自 5.4 到如今愈發穩定的 PHP 7,可以看到許多的新特性令這門語言愈發強大和完善,逐漸從純粹的 Web 語言變得有著更為廣泛的適用面,作為一枚 PHPer 的確不應當止步不前,我們依然有很多的東西需要不斷學習和加強。

雖然 “PHP 是世界上最好的語言” 這句話只是個調侃,但不可否認 PHP 即使不是最好,但也在努力變好的事實,對吧?

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章