PHP 核心特性 - 生成器基礎篇

心智極客發表於2019-11-13

提出

PHP RFC 裡描述了生成器的提出過程。考慮這樣的需求

實現一個函式,用於獲取檔案內容,並可對檔案內容進行遍歷。

實現 1 - 普通函式

最普通的方式就是一次性讀取檔案內容,然後再進行遍歷。

<?php

function getLinesFromFile($fileName) {

    // 開啟檔案
    if (!$fileHandle = fopen($fileName, 'r')) {
        return;
    }

    // 一次讀取每一行並儲存
    $lines = [];
    while (false !== $line = fgets($fileHandle)) {
        $lines[] = $line;
    }

    fclose($fileHandle);

    return $lines;
}

$lines = getLinesFromFile('test.txt');
foreach ($lines as $line) {

}

當使用該函式讀取大檔案時,就會因為記憶體不足而報錯。

PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted

實現 2 自定義物件

既然一次性讀取檔案內容行不通,只能考慮邊讀取邊遍歷,可使用物件導向的思想來解決。

PHP 中的物件,只要實現了 Iterator 介面,就可用 foreach 來進行遍歷。

Iterator extends Traversable {
    // 返回當前索引遊標指向的元素
    abstract public current ( void ) : mixed
    // 返回當前索引遊標指向的鍵
    abstract public key ( void ) : scalar
    // 移動當前索引遊標到下一元素
    abstract public next ( void ) : void
    // 重置索引遊標
    abstract public rewind ( void ) : void
    // 判斷當前索引遊標指向的元素是否有效
    abstract public valid ( void ) : bool
}

利用這點,可以手動實現 Iterator 介面,來實現邊讀取檔案邊進行遍歷功能。

<?php

class LineIterator implements Iterator {
    protected $fileHandle;

    protected $line;
    protected $i;

    public function __construct($fileName) {
        if (!$this->fileHandle = fopen($fileName, 'r')) {
            throw new RuntimeException('Couldn\'t open file "' . $fileName . '"');
        }
    }

    public function rewind() {
        fseek($this->fileHandle, 0);
        $this->line = fgets($this->fileHandle);
        $this->i = 0;
    }

    public function valid() {
        return false !== $this->line;
    }

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

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

    public function next() {
        if (false !== $this->line) {
            $this->line = fgets($this->fileHandle);
            $this->i++;
        }
    }

    public function __destruct() {
        fclose($this->fileHandle);
    }
}

$lines = new LineIterator('test.txt');
foreach ($lines as $line) {
    echo $line;
}

實現 3 - 生成器

該問題很典型,在很多情景中都會出現一次性讀取的記憶體不足問題。為了避免每一次都要手動實現 Iterator 介面,PHP 提供了生成器來解決該問題。也就是說,生成器已經幫我們實現了 Iterator 介面,因此可以直接使用。

<?php
function getLinesFromFile($fileName) {
    if (!$fileHandle = fopen($fileName, 'r')) {
        return;
    }

    while (false !== $line = fgets($fileHandle)) {
        yield $line;
    }

    fclose($fileHandle);
}

$lines = getLinesFromFile('test.txt');
foreach ($lines as $line) {

}

使用生成器,既保持了程式碼的簡潔,也降低了效能開銷,效率也比自己定義類要高。

本質

基本語法

生成器使用 yield 關鍵字來定義,主要有三種定義方法,分別生成 null、值以及鍵值對

yield;
yield $value;
yield $key => $value;

Generator 物件

生成器看上去是函式,實際上是 Generator 類的例項。

function simpleGenerator()
{   
    yield;
}

echo get_class(simpleGenerator())  // Generator

既然是物件,就可以將其賦值給變數。

$gen = simpleGenerator();

Generator 物件已經實現了 Iterator 介面

$gen instanceof Iterator // true

內部結構

我們來看一下 Generator 的內部結構。

final class Generator implements Iterator {

    // 實現 Iterator 介面
    void  rewind();
    bool  valid();
    mixed current();
    mixed key();
    void  next();

    // 傳入值
    mixed send(mixed $value);
    // 傳入異常
    mixed throw(Exception $exception);
    // 防止被序列化
    public __wakeup ( void ) : void
}

函式說明

__wakeup - 丟擲異常以表示生成器不能被序列化

serialize($gen); // Exception with message 'Serialization of 'Generator' is not allowed'

rewindvalidcurrentkeynext 方法是對迭代器介面的實現。首先定義生成器

function myGenerator()
{   

    echo "第一次開始\n";
    yield;

    echo "第二次開始\n";
    yield "值2";

    echo "第三次開始\n";

    yield "鍵" => "值3";
    echo "結束";
}

$gen = myGenerator();

觸發生成器 - 無論執行哪個方法(rewind, valid, current, key, nextsend)都會觸發生成器,然後先執行 yield 之前的語句。

$gen->valid();
// 先觸發生成器,執行程式碼,所以會列印 "第一次開始"
// 執行 valid() 方法,當生成器關閉時候將返回 false。所以本次返回 true。

current 方法將返回傳遞給生成器的值或者返回一個 null

$gen->current(); //  null

next 方法重新開始下一個生成器

$gen->next();   // 第二次開始

rewind 方法在生成器裡面是沒有意義的,也就是說,生成器不能像陣列那樣重新設定索引,只能繼續執行或者停止。所以如果是開始的時候執行該方法將會返回 null,而當第一個 yield 執行後,使用 rewind 方法,就會丟擲異常。

$gen->rewind(); // Exception with message 'Cannot rewind a generator that was already run'

第二次 yield 開始後,與之前的流程類似

$gen->current();  // "值2"
$gen->next(); // 第三次開始
$gen->key(); // "鍵"
$gen->current();  // "值3"
$gen->next(); // 結束
$gen->valid(); // false

這幾個函式實現了迭代介面,因此,可以用 foreach 來對生成器進行迭代

<?php

function genRange($start, $limit){
    for ($i = $start; $i <= $limit; $i++) {
        yield $i;
    }
}

$gen = genRange(1, 10);
foreach ($gen as $value) {
    echo $value." ";
}

foreach 語句等價於

while ( $gen->valid()) { 
    echo $gen->current()." ";
    $gen->next();
}

輸出結果

1 2 3 4 5 6 7 8 9 10

throw - 向生成器傳入異常

function gen() {
    try {
        yield;
    } catch (Exception $e) {
        echo "Exception: {$e->getMessage()}\n";
    }
    echo "Bar\n";
}

$gen = gen();
$gen->throw(new Exception('Test')); // echos "Exception: Test" and "Bar"

send - 向生產器傳送值,該值將會替換當前表示式上下文的結果,並進行下一次迭代(相當於替換值然後執行 next())

function echoLogger() {
    while (true) {
        // 接受外部的傳值
        $log = yield;
        echo 'Log: ' . $log . "\n";
    }
}

$logger = echoLogger();
$logger->send('Foo');  // Log: foo
$logger->send('Bar');  // Log: bar

點選 連結,免費加入心智極客的知識星球分享群,共同成長。

相關文章