hnwの日記

PHPのジェネレータの実装を調べてみた

PHP 5.5で実装されたジェネレータについて、PHPソースコードを追いかけてみました。ボチボチ追いかけたつもりですが、間違いや説明不足に気づいた方はツッコミをお願いします。


該当箇所のgithub.comへのリンクも作ってみました。カギ括弧で囲った場所がソースコード断片へのリンクになっていますので、PHPソースコードリーディングに興味がある方はリンク先も参照してください。なぜか5.5.5のタグが切られていなかったので5.5.4が対象です。

ジェネレータ関数の返す値

yield文を含む関数をジェネレータ関数と呼びます。PHPのparse時にyield文が見つかると、「その関数がジェネレータ関数であるというフラグが立てられます」。


ジェネレータ関数は普通の関数と異なり、呼ばれたタイミングでは関数の実行を行いません。代わりに、「ジェネレータ関数に紐付いたGeneratorクラスのインスタンスを返します」。

Generatorクラスの性質

GeneratorはCで実装されているクラスですが、ユーザーがnewすることはできません。「Generatorクラスのコンストラクタにエラーが仕込んである」からです。ちなみに、ジェネレータ関数の返り値となるインスタンスを作る際は「コンストラクタを呼び出さずにオブジェクトを生成しています」ので、エラーが発生することはありません。


また、「Generatorクラスはfinalクラス」なので、継承することはできません。「cloneもできません」し、「serializeやunserializeもできません」。


Generatorクラスは「Iteratorインターフェースを実装しており」、「対応するPHPメソッドを持っています」。また、「Cで実装した場合のみ指定できるイテレータ関数も実装しています」。このように両方が実装されている場合、foreachループではCの関数が呼ばれ、イテレータメソッドを明示的に指定した場合はPHPメソッドの方が呼ばれます。


ところで、イテレータに対応するPHPメソッドとC関数となぜ2つとも実装する必要があるのでしょうか。実は、PHPメソッドだけを実装すれば期待通りに動作します。C関数を実装する理由は速度面のメリットからだというのが僕の理解です。C関数は関数ポインタで単に呼び出せるのに対し、PHPメソッドの呼び出しは命令実行器の状態保存・復元の必要があるなど、呼び出しのコストがやや高いのです。

ジェネレータ関数の停止・再開はどう実現されているか

ジェネレータ関数の再開を実現するのがzend_generators.cの「zend_generator_resume関数」です。この関数はGeneratorのrewindメソッドやnextメソッド(またはそれに対応するCの関数)が呼ばれたタイミングで実行されます。


zend_generator_resume関数では、「命令実行器を停止前の状態に復元して」から「zend_execute_ex()を呼び出してジェネレータ関数の処理を再開します」。停止前の状態やVMのジャンプ先はGeneratorインスタンスが内部的に管理しています。


ジェネレータ関数を再開する際、「普段の関数コールであれば返り値のzvalのダブルポインタを渡すはずの命令実行器の変数に対してgenerator構造体へのポインタを渡します」。このトリックにより、ジェネレータ関数内でyieldされた値を元のジェネレータに受け渡すことができます。


ジェネレータ関数はyield文を実行したタイミングで停止します。ZEND_YIELD opcodeの実装を見ていくと、「yieldされたキーと値を呼び出し元のGeneratorインスタンスにセットし」、「resumeしたときに続きから処理できるようにプログラムポインタを進め」、「元の処理に戻る」、という挙動だとわかります。


ちなみに、ZEND_YIELD opcodeに対応する処理が記述されているZend/zend_vm_def.hファイルですが、これを修正しただけではPHPの動作は変わりません。zend_vm_gen.phpを利用してzend_vm_opcodes.hなどを再生成する必要があります。あまり書き換えるファイルでは無いと思いますが、念のため。

まとめ

  • ジェネレータ関数をコールするとGeneratorクラスのインスタンスが返る
  • Generatorクラスをforeachでループさせた場合、必要な処理はCの関数ポインタ経由でコールされる
  • ジェネレータ関数の停止と復帰はZend VMの実行状態を保存・復元することで実現している