F@N Ad-Tech Blog

株式会社ファンコミュニケーションズ nend・nex8のエンジニア・技術ブログ

PHPのジェネレータを使おう

こんにちは。新参者のsamです。

最近、都内を離れると行くところ行くところ、雨に見舞われ、友人にも私が雨男という認識が何故か広まって困っています。 先日は宮崎に帰省したところ、台風19号に直撃されて周りの友人から何故か非難を浴びるという理不尽を受けました。 雨男の汚名返上できる方法をご存知の方はぜひ教えてください。。

PHP5.5で導入されたジェネレータ

さて、少し前の話題で恐縮ですが、PHPでは5.5からジェネレータという機能が新たに追加されました。 同様の記事は巷に溢れかえっていますが、自分の学習と備忘録もかねて実装方法や特徴などをまとめてみました。

ジェネレータとは、foreach文を使って連続した値や配列などの値の集合に対して順序に沿って処理(イテレーション)することができる新たな言語構造です。言葉だけでは理解しづらいため順に説明します。

たとえばnlコマンドのような、パラメータで指定されたファイルから各行を読み込み行番号を付けて表示するプログラムを作る場合、次のように作ることができます。(※紙面の都合により、エラー処理は割愛しています)

・プログラム1【関数化しない最低限の実装】

<?php
$fp = fopen($argv[1], "rb");
$num = 1;
while(($line = fgets($fp)) !== false) {
    // ここで行番号を出力する
    printf("%5d: %s", $num++, $line);
}
fclose($fp);

配列を使った関数化

プログラム1のようにファイルを読み込んで何か処理を加えるというプログラムでは、テキストファイルに関する他の処理でもファイルのオープンからファイル読み込みやファイルのクローズに至る処理が全く同じ処理になりやすいにもかかわらず、関数化しにくいという難点がありました。

そこで汎用性を高めるために、実際にこのファイル読み込みだけを無理やり関数化してみます。

・プログラム2【配列を使った関数化実装】

<?php
// ファイルの行データの読み込み関数
function file_get_lines($filepath) {
    $lines = array();
    $fp = fopen($filepath, "rb");
    while(($line = fgets($fp)) !== false) {
        $lines[] = $line;
    }
    fclose($fp);
    // すべての行を配列で返す
    return $lines;
}

// 処理の開始
$num = 1;
foreach(file_get_lines($argv[1]) as $line) {
    // ここで行番号を出力する
    printf("%5d: %s", $num++, $line);
}

上記のようにファイルの読み込みだけを行う関数を作成すると、どうしても配列などの巨大な変数にファイルのデータをいったんすべて取り込んで処理を行う必要がありました。 そうするとどうしてもメモリを大きく消費してしまうというデメリットが残ってしまい、結局このような実装は避けられることが多かったのではないかと思います。

Iteratorインターフェイスを使ったクラス化

そこでPHPでは5.0からIteratorインターフェイスが提供されるようになり、foreach文にIteratorの実装クラスを渡せばファイルの読み込みなどもイテレーションができるようになりました。 実際にプログラム2をIteratorインターフェイスの実装クラスに置き換えてみるとたとえば下記のような実装ができます。

・プログラム3【Iteratorを使ったクラス化実装】

<?php
// ファイルの行データの読み込みクラス
class FileLineIterator implements Iterator {
    private $fp = null;
    private $line = null;

    public function __construct($filepath) {
        $this->fp = fopen($filepath, "rb");
    }
    public function __destruct() {
        @fclose($this->fp);
    }
    public function current() {
        return $this->line;
    }
    public function key() {
        throw new BadFunctionCallException();
    }
    public function next() {
        $this->line = fgets($this->fp);
        return $this->line;
    }
    public function rewind() {
        return rewind($this->fp);
    }
    public function valid() {
        return (!is_null($this->fp) && $this->line !== false);
    }
}

// 処理の開始
$num = 1;
$iterator = new FileLineIterator($argv[1]);
foreach($iterator as $line) {
    // ここで行番号を出力する
    printf("%5d: %s", $num++, $line);
}

このようにIteratorインターフェイスを実装すれば、ファイルの読み込みをクラス化することができるようになり柔軟なプログラミングができるようになりました。 ですが、このIteratorではファイルを順番に読み込むだけを行いたいのに、next, rewind, valid, currentの各メソッドも実装しなければならないという手間がかかってしまうというのが難点でした。(NoRewindIteratorなどにするともう少し手間は減りますが) また、このIteratorインターフェイスの実装では実行速度低下が著しいというデメリットもあります。

結局のところ実装の手間や実行速度を妥協してでもクラス化するか、実効速度重視で関数化しないようにするかというジレンマを感じていた方も多いのではないでしょうか。

ジェネレータを使った実装

そのジレンマを解決する手段の一つとして、ついにPHP 5.5からジェネレータが導入されました!

さっそくプログラム2をジェネレータを使った実装に置き換えてみましょう。

・プログラム4【ジェネレータを使った実装】

<?php
// ファイルの行データの読み込み関数
function file_get_lines($filepath) {
    $lines = array();
    $fp = fopen($filepath, "rb");
    while(($line = fgets($fp)) !== false) {
        // yieldキーワードで$lineを都度返却することができる
        yield $line;
    }
    fclose($fp);
}

// 処理の開始
$num = 1;
foreach(file_get_lines($argv[1]) as $line) {
    // ここで行番号を出力する
    printf("%5d: %s", $num++, $line);
}

プログラム3に比べてだいぶすっきりとして、プログラム2に近い実装になりました。

プログラム2と大きくは変わっていませんが、8行目にyieldというキーワードが新たに増えています。 ジェネレータではyieldキーワードを使って、変数の値を都度返却することができるようになりました。 つまり、すべての読み出しが終わらなくても都度値が返却され、とメモリ上にすべてのデータを保持することもないため、メモリを効率よく使うことができるようになっています。

イテレーションのパフォーマンス比較

実際に配列を使った実装(プログラム2)とIteratorインターフェイス実装(プログラム3)とジェネレータを使った実装(プログラム4)において、どの程度パフォーマンスに違いが出るのか、ざっくりと計測してみました。

検証にあたってはプログラム2~4の一部を下記のように変更して測定しました。また、読み込むファイルはランダムな255バイト+改行1バイト(LF)の文字が100,000行あるファイル(ファイルサイズ:25.6 MB)を読み込ませました。

なお、検証にはAWSのEC2インスタンス(m1.small)にUbuntu 14.04を載せて、PHPバージョンは5.5.9で検証しました。

<?php

// 処理の開始
$start_time = microtime(true);    // 開始時刻
$num = 1;
foreach(file_get_lines($argv[1]) as $line) {
    // 今回は読み込み処理のみ測定のためコメントアウト
    // printf("%5d: %s", $num++, $line);
}
$end_time = microtime(true);      // 終了時刻

// メモリの最大使用量
echo "Memory Peak Usage: ".number_format(memory_get_peak_usage(true))." Bytes".PHP_EOL;  
// 経過時間(終了時刻 - 開始時刻)
echo "Elapsed Time: ".sprintf("%.3f", ($end_time - $start_time))." sec".PHP_EOL;

結果は次のようになりました。

メモリの最大使用量 経過時間
プログラム2
(配列を使用)
43,253,760 バイト 1.115 秒
プログラム3
(Iteratorインターフェイスを使用)
262,144 バイト 3.648 秒
プログラム4
(ジェネレータを使用)
262,144 バイト 1.262 秒

プログラム2に比べてプログラム3やプログラム4はメモリの最大使用量が大きく減りました。 配列に一旦保持しない分が減ったのではないかと推測できます。

逆に経過時間(処理にかかった時間)については、プログラム3が極端に遅くなっており、プログラム2と4では、プログラム4の方が若干遅くなってしまいました。

プログラム2と4の違いは配列に入れてまとめて返却するか、ジェネレータで都度返却するかの違いでしかありませんので、ジェネレータの方がオーバーヘッドが大きいのではないかと推測できます。 (なお、10回以上実施しましたが、概ねこのような結果になり、経過時間ではジェネレータが、配列の実装を上回ることがありませんでした。)

PHPの公式ページのNoteにも同様のパフォーマンス比較をされた方の投稿がありますが、やはりジェネレータの方が経過時間が長くかかる傾向にあるようです。

パフォーマンスをまとめると下記のようになると思います。

メモリの使用量 実行速度
配列を使用した関数化
Iteratorを使用したクラス化
ジェネレータによる実装

また違った検証をされた方がいらっしゃれば是非ご共有いただけると助かります。

まとめ

PHPのジェネレータを使う上でのメリットを簡単にまとめてみました。

  • イテレーションが必要な処理のメモリ効率の向上が期待できる
  • 関数化しづらかったイテレーション処理をシンプルに関数化できる

今回の検証では特にジェネレータを使う上での大きなデメリットは見受けらず、積極的に使ってもただちに影響はなさそうです。 PHP5.5以上をお使いの方で下位互換性を気にしない方は、ガンガン使っていきましょう!

おまけ

ちなみに、ジェネレータをvar_dumpしてみると下記のような結果が得られます。

var_dump(file_get_lines($argv[1]));
class Generator#1 (0) { }

内部でIteratorの実装クラスであるGeneratorオブジェクトを生成しているようです。

参考

PHP公式:ジェネレータとは

PHP公式:Generatorクラス