hnwの日記

PHP5.5のジェネレータをSPLのイテレータと組み合わせてみる

リリースが間近になったPHP5.5ではジェネレータが導入される予定です。これはイテレータを簡単に記述する文法を導入するもので、Pythonのジェネレータに非常に良く似ています。


もう少し詳しく説明しましょう。PHP5.5では、yieldというキーワードが導入されました。これはジェネレータで値を受け渡すための構文です。このyieldを含む関数はジェネレータ関数と呼ばれます。関数がジェネレータ関数かどうかは、PHPの解釈のタイミングで自動的に判断されます。


ジェネレータ関数が呼ばれると、ジェネレータ関数に対応するジェネレータオブジェクトが返されます。関数呼び出しの時点ではジェネレータ関数の中身は1行も実行されません。ジェネレータオブジェクトから値を取り出すタイミングで初めてジェネレータ関数の先頭からyield文までが実行され、yieldされた値を取り出して停止します。イテレータから次の値を取り出すと、関数の実行を再開して次のyieldまでが実行されます。


具体例を示します。2回のyieldの前後でechoするようなジェネレータをforeachで回してみましょう。

<?php
function generator() {
  echo "foo\n";
  yield 1;
  echo "bar\n";
  yield 2;
  echo "baz\n";
}

$gen = generator();
echo "hoge\n";
foreach ($gen as $val) {
  var_dump($val);
}


この実行結果は次のようになります。

hoge
foo
int(1)
bar
int(2)
baz


generator()の呼び出し時点ではgenerator()の中身が全く実行されていないこと、foreachで値を取り出すたびに関数実行を再開しては停止していることがわかります。


関数を呼び出しても関数の中身が1行も実行されないというのは、これまでのPHP文法からすると異質ですよね。generatorsのRequest for Commentsによれば、PythonとJavaScript1.7とC#のジェネレータが全部そんなインターフェースだからいいんじゃないの?ということのようです。(参照:rfc中の「Recognition of generator functions」)

ジェネレータのメリット

ジェネレータの一番のメリットは、イテレータが簡潔に書けることでしょう。使い捨てのイテレータを作るのに rewind, valid, current, key, next の5メソッドを実装するなんてありえないですよね。関数スタイルでイテレータが書けるジェネレータは楽チンです。


また、性能面でもメリットがあります。PHPのジェネレータを提案したNikita Popovさんが行ったベンチマークを紹介しましょう。これは、range関数をジェネレータ化したxrange関数を作り、サイズを変えながらforeachで回す速度を測定したものです。(参照:rfc中の「Performance」)


僕の手元のPHP 5.5.0alpha3で実行したところ、次のような結果になりました。

サイズ range xrange
100 0.000035 0.000049
1000 0.00024 0.00037
10000 0.0040 0.0036
100000 0.071 0.036
1000000 0.73 0.34

(単位:秒)


rangeは配列を返すインターフェースなので、大きい値が指定された場合に大きい配列を返す必要があります。一方、ジェネレータは都度値を返すだけなので、メモリ効率の意味で有利です。実行時間で見ても、サイズが10000あたりからrangeよりxrangeの方が有利になってくるようですね。

ジェネレータでもrewindしたい

僕がジェネレータを使ってみて戸惑った点は、一度でもyieldしたジェネレータはrewindできないことです*1。例えば、次のようにジェネレータを2回目のforeachに入れようとすると、例外が発生します。

<?php
// rangeのジェネレータ版
function xrange($start, $end, $step = 1) {
    for ($i = $start; $i <= $end; $i += $step) {
        yield $i;
    }
}

$range = xrange(1,10);
foreach ($range as $val) {
  var_dump($val);
  // 0,1,2を表示してループ脱出
  if ($val >=2) break;
}

// foreachは最初にrewindメソッドを呼び出すので、例外で落ちる
foreach ($range as $val) {
  var_dump($val);
}


ジェネレータは前にしか進めないイテレータなので、rewindはできないという理屈です。また、foreachがイテレータの先頭から読もうとするのも正しい挙動です。


そうなると、途中まで読んだジェネレータの続きをforeachで回すことはできないのでしょうか。これはSPLに含まれるNoRewindIteratorで実現できます。NoRewindIteratorは、コンストラクタ引数としてイテレータを受け取り、rewindで何もしない以外は元のイテレータと同じ挙動のイテレータを返します。

<?php
// rangeのジェネレータ版
function xrange($start, $end, $step = 1) {
    for ($i = $start; $i <= $end; $i += $step) {
        yield $i;
    }
}

$range = new NoRewindIterator(xrange(1,10));
foreach ($range as $val) {
  var_dump($val);
  // 0,1,2を表示してループ脱出
  if ($val >=2) break;
}
// イテレータを前進させる
$range->next();
foreach ($range as $val) {
  // 3から10まで表示させる
  var_dump($val);
}


少し変則的な使い方のような気はしますが、NoRewindIteratorを利用して途中まで読んだジェネレータを引き続き処理することができました。自分でnextメソッドを呼び出す点といい色々イマイチな気はしますが、一番マシな方法のように思います。

ジェネレータを先頭から再実行するRewindableGenerator

一方、ジェネレータをrewindしてジェネレータ関数の先頭から再実行したい場合もあるでしょう。このような場合には、ジェネレータを別途取得するか、初期状態のジェネレータをcloneする必要があります。これを実現するRewindableGeneratorがrfc上で例示されています*2。(参照:rfc中の「Cloning a generator」)


RewindableGeneratorクラスは、コンストラクタ引数でジェネレータを受け取り、rewind可能なイテレータを実現するものです。rewindメソッドが呼ばれた場合、元のジェネレータをcloneして再度先頭から実行してくれます。


RewindableGeneratorは次のように利用します。

<?php
require 'RewindableGenerator.php';
require 'xrange.php';

$range = new RewindableGenerator(xrange(1,10));
foreach ($range as $value) {
}
// 以下はcloneしたジェネレータへのアクセスになる
foreach ($range as $value) {
}

ジェネレータの返す値をメモ化しておくMemoizeIterator

ジェネレータの処理が重いような場合、RewindableGeneratorを使うと非効率になる状況も考えられます。重いジェネレータを何度も利用したいような状況のために、ジェネレータの返す値をメモ化しておくようなクラスを作ってみました。


これは次のように利用します。

<?php
require 'MemoizeIterator.php';
require 'xrange.php';

$range = new MemoizeIterator(xrange(1,10));
foreach ($range as $value) {
}
// 以下はキャッシュ値へのアクセスになる
foreach ($range as $value) {
}


MemoizeIteratorはSPLのCachingIteratorを利用しています。コンストラクタ引数でイテレータを受け取って、イテレータの返す値をキャッシュしておき、rewind後のイテレータアクセスについてはキャッシュから値を返すようなイテレータです。キャッシュが尽きると元のイテレータの続きにアクセスしますので、rewindを何度呼んでも矛盾はありません。


ただし、ジェネレータをMemoizeIteratorでラップした場合、yieldした値はPHPの配列としてキャッシュされます。言い換えると、配列を作るのに比べてメモリを消費しないというジェネレータのメリットは失われますので注意してください。

まとめ

PHP5.5のジェネレータについて、SPLのクラスに絡めて紹介してみました。ジェネレータはイテレータを簡単に作ることができる上に、SPLのイテレータクラスを利用することができるので、夢が広がりんぐです。イマイチ使われていないPHPイテレータが注目されるといいですね。

*1:rfcにはそう書いてあるんですが、PHP5.5.0alpha3ではnextメソッドが呼ばれていなければ最初のyieldまで実行されていてもrewind出来ます。バグなんじゃないかと疑っています。

*2:RewindableGeneratorのコードをそのままコピペすると、PHP5.5.0alpha3ではcloseメソッドの呼び出しで怒られます。適宜修正してください。