ArrayAccess + マジックメソッドで公開する値と内部の値を変換するクラスにTraversableを実装する際の注意点
とてもターゲットの狭い記事ですが、ほぼ自分用メモです。
たとえば、外部からセットされたDateTimeオブジェクトを内部的にUnixTimestampに変換して保持し、外部に返す際は再びDateTimeオブジェクトに変換して返すようなオブジェクト。
そのために、マジックメソッド経由で値を変換して受ける/返す機能を持つクラスやTraitを、ArrayAccessとマジックメソッドを使って作成するとします。
それだけなら問題起きないのですが、気を利かせて(?)Traversableを実装して foreach時にも上記の仕様通りに値を返したい場合。
内部的には $attributes みたいなプロパティに配列で保持するとして、IteratorAggregate を implements してgetIterator() で $attributes の ArrayIterator を返すような手抜き実装だと、内部の値がそのまま返されてしまうので具合が悪いです。
なので、そういうマジックメソッドの仕様に合った Iterator実装クラスを作成してそれを返すか、自身でIteratorを実装する必要があります。
後者の具体例は以下のようにします。
- Iterator::key() で $attributes から現在のインデックスをキー名に変換して返す
- Iterator::current() で1を使って取得したキー名から値を取得し、変換結果を返すメソッドを呼ぶ
- 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に実装されない限り、そこまでする意欲が沸きませんが…。