PHP 生成器入門

柳公子發表於2018-07-16
本文首發於 入門 PHP 生成器,轉載請註明出處。

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 created
foobar - start
foobar - yielding...
Getting current value from the generator...
1
foobar - continued
foobar - yielding...
Getting current value from the generator...
2
foobar - continued
foobar - yielding...
Getting current value from the generator...
3
foobar - continued
foobar - yielding...
Getting current value from the generator...
4
foobar - continued
foobar - yielding...
Getting current value from the generator...
5
foobar - continued
foobar - 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

小結

生成器的功能遠超多數人的認知範圍。對於一些朋友來說可能是首次接觸生成器相關知識,一些朋友可能已經將它作為迭代器來使用,僅有很少一部分朋友使用生成器處理更多的事情。獲取你有一些很讚的想法?我很樂意進一步探討這些專案,並且希望你能從中學習到一些知識。:)

如果你需要更多資料,我推薦你閱讀 nikic 寫的 使用生成器處理多工

原文

An Introduction to Generators in PHP

相關文章