hnwの日記

イテレータを介して見るPHPクラスの内部構造

PHPにはイテレータの仕組みがあります。イテレータクラスのインスタンスは、配列同様にforeach文でループを作ることができます。たとえば次の例を見てみましょう。

<?php
$iter = new SplQueue();
$iter[] = 1;
$iter[] = 2;
$iter[] = 3;
$sum = 0;
foreach ($iter as $v) {
    $sum += $v;
}
var_dump($sum); // int(6)


SplQueueというのはキューを実現するイテレータで、ArrayAccessも実装しているので配列のように要素を追加することができます。この場合、3つの要素を持っているのでイテレーションも3回になります。

SplQueueによるイテレーションをネストしてみる

ところで、同じイテレータをネストした場合どうなるでしょうか?次のようなコードを考えてみます。

<?php
$iter = new SplQueue();
$iter[] = 1;
$iter[] = 2;
$iter[] = 3;
$sum = 0;
foreach ($iter as $v1) {
    foreach ($iter as $v2) {
        $sum += $v2;
    }
}
var_dump($sum); // int(18)


これを実際に試すと、内側のループも外側のループも3回ずつ計9回ループします。つまり、内側のイテレーションと外側のイテレーションは完全に独立しています。


これはよく考えると不思議な挙動です。というのも、ループ内でvalid()やnext()といったイテレータのインターフェースが呼ばれているとしたら、内外のイテレーションが独立していることの説明がつきません。どうして内側のループで呼ばれたcurrent()と外側のループで呼ばれたcurrent()とで異なる値を返すことができるのでしょうか?


実は、PHP言語レベルのイテレータインターフェースとは別のインターフェースがPHP内部に存在しています。イテレータの各メソッドやArrayAccess、__get()、__toString()など利用頻度の高いメソッドについてPHPメソッドとは別に関数ポインタを登録する仕組みが用意されています。詳しくは「Object handlers ― PHP Internals Book」を参照してください。


C言語イテレータを記述する場合、PHP言語には対応するメソッドがないインターフェースが利用できます。インスタンスイテレータ呼び出しされるとイテレータに対応するget_iteratorハンドラが呼ばれて、zend_object_iterator構造体へのポインタを返すことができます。zend_object_iterator構造体でイテレーションの状態を管理するように実装すれば、同じインスタンスを使っていても独立したイテレーションを作れるわけです。

普通のイテレータはネストできない

当然ですが、PHPレベルでイテレータを書くと同じイテレータインスタンスをネストして使うことはできません。先ほどの推測の通り、イテレータインスタンス1個につき状態を1つしか持てないからです。もしネストしたいのであれば、cloneして使う必要があります。

Cで実装された全てのイテレータがネストできるわけではない

さらに補足すると、Cで実装されたイテレータが全てSplQueueのような挙動になるわけではありません。インスタンス1個につき状態を1個しか持たないような実装にすれば、PHPで書いたときと同様にネストはできなくなります。


実際、大半の組み込みイテレータはネストして使うことができません。SplQueue、SplStack、SplDoublyLinkedListの3つだけがネストできる実装になっていますが、ArrayIteratorなど他のイテレータはネストできません。

SplQueueを継承したクラスもネストできない

ところで、SplQueueの子クラスの挙動を確認してみましょう。

<?php
class MySplQueue extends SplQueue {}
$iter = new MySplQueue();
$iter[] = 1;
$iter[] = 2;
$iter[] = 3;
$sum = 0;
foreach ($iter as $v1) {
    foreach ($iter as $v2) {
        $sum += $v2;
    }
}
var_dump($sum); // int(6)


使うクラスを子クラスにしただけで他は同じコードですが、SplQueueのときとは異なる動作になります。内側のループは3回ループするのですが、外側のループは1回で終了してしまいます。子クラスで何のメソッドも実装していないにもかかわらず、どうして親クラスのときと挙動が変わるのでしょうか。


その答えは、継承することで別の処理が呼ばれるようになるからです。


C実装されたイテレータに対してPHPで子クラスを作った場合、その子クラスでイテレータメソッドをオーバーライドしていなくても常にPHPレベルのイテレータメソッドが使われます。こうなるとCで実装してもインスタンス1つについて状態を1個しか持てないので、内側のループが終わった時点でSplQueueのvalid()がfalseを返してしまい、外側のループも即座に終了してしまうわけです。


これはかなり混乱の元なので、イテレータをCで実装する場合、CレベルとPHPレベルで挙動の差を作らない方が良いと個人的には感じます。つまり、インスタンス1つにつき状態を1つだけ持つような実装に統一するのが親切ではないでしょうか。

まとめ

  • PHPには、C言語レベルでしか提供されていないインターフェースが存在する
    • これを使うと、PHP言語レベルでは実現できないことを実現したり、速度を改善できたりする
  • SplQueue(とその兄弟)の挙動は例外的、中の人が意図していない可能性もありそう