読者です 読者をやめる 読者になる 読者になる

k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

ArrayAccess + マジックメソッドで公開する値と内部の値を変換するクラスにTraversableを実装する際の注意点

PHP SPL

とてもターゲットの狭い記事ですが、ほぼ自分用メモです。

たとえば、外部からセットされたDateTimeオブジェクトを内部的にUnixTimestampに変換して保持し、外部に返す際は再びDateTimeオブジェクトに変換して返すようなオブジェクト。

そのために、マジックメソッド経由で値を変換して受ける/返す機能を持つクラスやTraitを、ArrayAccessとマジックメソッドを使って作成するとします。

それだけなら問題起きないのですが、気を利かせて(?)Traversableを実装して foreach時にも上記の仕様通りに値を返したい場合。

内部的には $attributes みたいなプロパティに配列で保持するとして、IteratorAggregate を implements してgetIterator() で $attributes の ArrayIterator を返すような手抜き実装だと、内部の値がそのまま返されてしまうので具合が悪いです。

なので、そういうマジックメソッドの仕様に合った Iterator実装クラスを作成してそれを返すか、自身でIteratorを実装する必要があります。

後者の具体例は以下のようにします。

  1. Iterator::key() で $attributes から現在のインデックスをキー名に変換して返す
  2. Iterator::current() で1を使って取得したキー名から値を取得し、変換結果を返すメソッドを呼ぶ
  3. Iterator::valid() で1を使って取得したキー名が有効かどうかを返す

なお、3についてはPHPマニュアルの Iterator::valid の User Contributed Notes に以下のようなソースが提示されていますが、猿真似するとはまります。

<?php
function valid(){    
   return $this->offsetExists($this->position);
}

http://www.php.net/manual/ja/iterator.valid.php

ArrayAccess::offsetExists() は isset() および empty() に対して返す値を定義するため、キーが有効でも値がNULLの場合にはFALSEを返すよう実装しているはずなので、上記のケースだと foreach で返された値がNULLの場合にそこでループが終了してしまうわけです。

サンプルだけ置いておきます。

<?php

trait DataTrait
{

    /**
     * ArrayAccess::offsetGet()
     *
     * @param mixed
     * @return mixed
     */
    public function offsetGet($name)
    {
        if (method_exists($this, 'get_' . $name)) {
            return $this->{'get_' . $name}();
        }
        $camelize = $this->camelize($name);
        if (method_exists($this, 'get' . $camelize)) {
            return $this->{'get' . $camelize}();
        }
        if (array_key_exists($name, $this->attributes)) {
            return $this->attributes[$name];
        }
        return null;
    }

    /**
     * ArrayAccess::offsetSet()
     *
     * @param mixed
     * @param mixed
     */
    public function offsetSet($name, $value)
    {
        if (method_exists($this, 'set_' . $name)) {
            return $this->{'set_' . $name}($value);
        }
        $camelize = $this->camelize($name);
        if (method_exists($this, 'set' . $camelize)) {
            return $this->{'set' . $camelize}($value);
        }
        if (array_key_exists($name, $this->attributes)) {
            $this->attributes[$name] = $value;
        }
    }

    /**
     * ArrayAccess::offsetExists()
     *
     * @param mixed
     * @return bool
     */
    public function offsetExists($name)
    {
        return (array_key_exists($name, $this->attributes) && isset($this->attributes[$name]));
    }

    /**
     * ArrayAccess::offsetUnset()
     *
     * @param mixed
     */
    public function offsetUnset($name)
    {
        if (array_key_exists($name, $this->attributes)) {
            $this->attributes[$name] = null;
        }
    }

    /**
     * magic getter
     *
     * @param string
     */
    public function __get($name)
    {
        return $this->offsetGet($name);
    }

    /**
     * magic setter
     *
     * @param string
     * @param mixed
     */
    public function __set($name, $value)
    {
        $this->offsetSet($name, $value);
    }

    /**
     * magic isset
     *
     * @param string
     * @return bool
     */
    public function __isset($name)
    {
        return $this->offsetExists($name);
    }

    /**
     * magic unset
     *
     * @param string
     */
    public function __unset($name)
    {
        $this->offsetUnset($name);
    }

    /**
     * Iterator::current()
     *
     * @return mixed
     */
    public function current()
    {
        return $this->offsetGet($this->key());
    }

    /**
     * Iterator::key()
     *
     * @return scalar
     */
    public function key()
    {
        $keys = array_keys($this->attributes);
        return (array_key_exists($this->position, $keys)) ? $keys[$this->position] : null;
    }

    /**
     * Iterator::next()
     */
    public function next()
    {
        $this->position++;
    }

    /**
     * Iterator::rewind()
     */
    public function rewind()
    {
        $this->position = 0;
    }

    /**
     * Iterator::valid()
     *
     * @return bool
     */
    public function valid()
    {
        return (array_key_exists($this->key(), $this->attributes));
    }

    /**
     * @param string  $string
     * @return string
     */
    private function camelize($string)
    {
        return str_replace(' ', '', ucwords(str_replace('_', ' ', $string)));
    }

}

offsetExists() は isset($this->attributes[$name]) ってやってますが、値がNULLオブジェクトの場合はNULLを返す、みたいな使い方をする場合は具合が悪いので $this->offsetGet($name) にした方がいいですね。

key() などは連想配列が順序を持つPHP特性に依存した実装内容ですが、それを問題とするなら、そもそもこういうクラスに Traversable を実装するのが問題なので許してください。

camelize()の実装内容はちょっとトリッキーですが ZendFramework や Doctrine や Guzzle など著名なフレームワークでも似たようなコードが使われているので問題ないと思います。(マルチバイトは未検証です)

あと今回のケースでは toArray() みたいなメソッドを用意してやって、IteratorAggregate::getIterator() でそいつの結果を ArrayIterator に入れて返すというのもありだと思います。

個人的には Array へのキャストで動く __toArray() マジックメソッドみたいなのがPHPに実装されない限り、そこまでする意欲が沸きませんが…。