PHP 在 5.5 版本中引入了「產生器(Generator)」特性,不過這個特性並沒有引起人們的注意。在官方的 從 PHP 5.4.x 遷移到 PHP 5.5.x 中介紹說它能以一種簡單的方式實現迭代器(Iterator)。但是,除此之外,產生器又可以在哪些情境下使用?
產生器實現通過 yield 關鍵字完成。產生器提供一種簡單的方式實現迭代器,幾乎無任何額外開銷或需要通過實現迭代器介面的類這種複雜方式實現迭代。
文檔提供了一個簡單的執行個體示範這個簡單的迭代器,請看下面的代碼:
function xrange($start, $limit, $step = 1) { for ($i = $start; $i <= $limit; $i += $step) { yield $i; }}
讓我們將它與無迭代器支援的數組進行比較:
foreach xrange($start, $limit, $step = 1) { $elements = []; for ($i = $start; $i <= $limit; $i += $step) { $elements[] = $i; } return $elements;}
這兩個版本的函數都支援 foreach 迭代擷取所有元素:
foreach (xrange(1, 100) as $i) { print $i . PHP_EOL;}
所以除了一個更短的函數定義,我們還能擷取什麼呢?yield 到底做了什嗎?為什麼在第一個函數定義時依然可以返回資料,即使沒有 return 語句?
先從傳回值說起。產生器是 PHP 中的一個很特別的函數。當一個函數包含 yield,那麼這個函數即不再是一個普通函數,它永遠返回一個「Generator(產生器)」執行個體。產生器實現了 Iterator 介面,這就是為何它能夠進行 foreach 遍曆的原因。
接下來我使用 Iterator 介面中的方法,對之前的 foreach 迴圈進行重寫。你可以在 3v4l.org 查看結果。
$generator = xrange(1, 100);while($generator->valid()) { print $generator->current() . PHP_EOL; $generator->next();}
我們可以清楚的看到產生器是更進階的技術,現在讓我們編寫一個新的產生器樣本來更好的理解到底在產生器內部是如何進行處理的吧。
function foobar() { print 'foobar - start' . PHP_EOL; for ($i = 0; $i < 5; $i++) { print 'foobar - yielding...' . PHP_EOL; yield $i; print 'foobar - continued...' . PHP_EOL; } print 'foobar - end' . PHP_EOL;}$generator = foobar();print 'Generator created' . PHP_EOL;while ($generator->valid()) { print "Getting current value from the generator..." . PHP_EOL; print $generator->current() . PHP_EOL; $generator->next();}
Generator createdfoobar - startfoobar - yielding...Getting current value from the generator...1foobar - continuedfoobar - yielding...Getting current value from the generator...2foobar - continuedfoobar - yielding...Getting current value from the generator...3foobar - continuedfoobar - yielding...Getting current value from the generator...4foobar - continuedfoobar - yielding...Getting current value from the generator...5foobar - continuedfoobar - end
嗯?為什麼 Generator created 最先列印出來?這是因為產生器在被使用之前不會執行任何操作。在上例中就是$generator->valid()** 這句代碼才開始執行產生器。我們看到產生器一直運行到了第一個 **yield** 時,將控制流程程交還給調用者 **$generator->valid()。$generator->next() 調用時則恢複產生器執行,到下一個 yield 再次停止運行,如此反覆直到沒有更多的 yield 為止。我們現在擁有了可以在任何 yield 執行暫停和回複的終端函數。這個特性允許編寫用戶端所需的延遲函數。
你可以建立一個從 GitHub API 讀取所有使用者的功能。支援分頁處理,但是你可以隱藏這些細節並且僅當需要時再去擷取下一頁資料。你可以使用 yield 從當前頁面擷取每個使用者資料,直到當前頁所有使用者擷取完成,你就可以再去擷取下一頁資料。
class GitHubClient { function getUsers(): Iterator { $uri = '/users'; do { $response = $this->get($uri); foreach ($response->items as $user) { yield $user; } $uri = $response->nextUri; } while($uri !== null); }}
用戶端可以迭代出所有使用者或者在任何時候停止遍曆。
把產生器當迭代器使用真是無聊
是的,你的想法是對的。以上我給出的所有講解任何人都可以從 PHP 文檔中擷取到。但是作為迭代器這些使用,連它強大功能的一半都沒用到。產生器還提供了不屬於 Iterator 介面的 send() 和 throw() 功能。我們前面談到了暫停和恢複產生器執行功能。當需要恢複產生器時,不僅可以功過 Generator::next() 方法,還可以使用 Generator::send() 和 Generator::throw()方法。
Generator::send() 允許你指定 yield 的傳回值,而 Generator::throw() 允許向 yield 拋出異常。通過這些方法我們不僅可以從產生器中擷取資料,還能向產生器中發送新資料。
讓我們看一個從 Cooperative multitasking using coroutines(強烈推薦閱讀本文)摘取的 Logger 日誌樣本。
function logger($filename) { $fileHandle = fopen($filename, 'a'); while (true) { fwrite($fileHandle, yield . "\n"); }}$logger = logger(__DIR__ . '/log');$logger->send('Foo');$logger->send('Bar');
yield 在這裡是作為運算式使用的。當我們發送資料時,從 yield 返回資料然後作為參數傳入到 fwrite()。
講真,這個樣本在實際項目中沒毛用。它僅僅用於示範 Generator::send() 的使用原理,但是僅僅能夠發送資料並沒有太大作用。如果有一個類和普通函數支援的話就不一樣了。
使用產生器的樂趣來自於通過 yield 建立資料,然後由「產生器執行程式(generator runner)」依據這個資料來處理業務,然後再繼續執行產生器。這就是「協程(coroutines)」和「狀態流解析器(stateful streaming parsers)」執行個體。在講解協程和狀態流解析器之前,我們快速探索一下如何在產生器中返回資料,我們還沒有將接觸這方面的知識。從 PHP 5.5 開始我們可以在產生器內部使用 return; 語句,但是不能返回任何值。執行 return; 語句的唯一目的是結束產生器執行。
不過從 PHP 7.0 起支援傳回值。這個功能在用於迭代時可能有些奇怪,但是在其他使用情境如協程時將非常有用,例如,當我們在執行一個產生器時我們可以依據傳回值處理,而無需直接對產生器進行操作。下一節我們將講解 return 語句在協程中的使用。
非同步產生器
Amp 是一款 PHP 非同步編程的架構。支援非同步協程功能,本質上是等待處理結果的預留位置。「產生器執行程式」為 Coroutine類。它會訂閱非同步產生器(yielded promise),當有執行結果可用時則繼續產生器處理。如果處理失敗,則會拋出異常給產生器。你可以到 amphp/amp 版本庫查看實現細節。在 Amp 中的 Coroutine 本身就是一個 Promise。如果這個協程拋出未經捕獲的異常,這個協程就執行失敗了。如果解析成功,那麼就返回一個值。這個值看起來和普通函數的傳回值並無二致,只不過它處於非同步執行環境中。這就是需要產生器需要有傳回值的意義,這也是為何我們將這個特性加入到 PHP 7.0 中的原因,我們會將最後執行的yield 值作為傳回值,但這不是一個好的解決方案。
Amp 可以像編寫阻塞代碼一樣編寫非阻塞代碼,同時允許在同一進程中執行其它非阻塞事件。一個使用情境是,同時對一個或多個第三方 API 並行的建立多個 HTTP 要求,但不限於此。得益於事件迴圈,可以同時處理多個 I/O 處理,而不僅僅是只能處理多個 HTTP請求這類操作。
Loop::run(function() { $uris = [ "https://google.com/", "https://github.com/", "https://stackoverflow.com/", ]; $client = new Amp\Artax\DefaultClient; $promises = []; foreach ($uris as $uri) { $promises[$uri] = $client->request($uri); } $responses = yield $promises; foreach ($responses as $uri => $response) { print $uri . " - " . $response->getStatus() . PHP_EOL; }});
但是,擁有非同步功能的協程並非只能夠在 yield 右側出現變數,還可以在它的左側。這就是我們前面提到的解析器。
$parse = new Parser((function(){ while (true) { $line = yield "\r\n"; if (trim($line) === "") { continue; } print "New item: {$line}" . PHP_EOL; }})());for ($i = 0; $i < 100; $i++) { $parser->push("bar\r"); $parser->push("\nfoo");}
解析器會緩衝所有輸入直到接收的是 rn。這類產生器解析器並不能簡化簡單協議處理(如換行分隔字元協議),但是對於複雜的解析器,如在伺服器解析 HTTP 要求的 Aerys。