k-holyのPHPとか諸々メモ

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

offsetExistとPHPTALの罠

テンプレートをValidに書けることが売りの(?)テンプレートエンジン PHPTAL で、 マジックメソッド、ArrayAccessインタフェースを実装した配列風オブジェクトを使った時に引っかかった罠についてメモです。

内容的には ArrayAccess + マジックメソッドで公開する値と内部の値を変換するクラスにTraversableを実装する際の注意点 issetとemptyとoffsetExist のほぼ続編になります。

今度は PHPTAL でテンプレート変数を展開する際に呼ばれる PHPTAL_Context::path() メソッドで怒られてしまいました。

PHPTAL_VariableNotFoundException [0] "Foo\Bar object 'baz' doesn't have method/property named 'qux' (in path '.../baz/qux')"

こんな感じで、PHPTAL_Context::path() から PHPTAL_Context::pathError() が呼ばれて例外がスローされています。

スロー元のソースはこんな感じで書かれていました。

<?php
/**
 * Resolve TALES path starting from the first path element.
 * The TALES path : object/method1/10/method2
 * will call : $ctx->path($ctx->object, 'method1/10/method2')
 *
 * This function is very important for PHPTAL performance.
 *
 * This function will become non-static in the future
 *
 * @param mixed  $base    first element of the path ($ctx)
 * @param string $path    rest of the path
 * @param bool   $nothrow is used by phptal_exists(). Prevents this function from
 * throwing an exception when a part of the path cannot be resolved, null is
 * returned instead.
 *
 * @access private
 * @return mixed
 */
public static function path($base, $path, $nothrow=false)
{
    if ($base === null) {
        if ($nothrow) return null;
        PHPTAL_Context::pathError($base, $path, $path, $path);
    }

    $chunks  = explode('/', $path);
    $current = null;

    for ($i = 0; $i < count($chunks); $i++) {
        $prev    = $current;
        $current = $chunks[$i];

        // object handling
        if (is_object($base)) {
            $base = phptal_unravel_closure($base);

            // look for method. Both method_exists and is_callable are required because of __call() and protected methods
            if (method_exists($base, $current) && is_callable(array($base, $current))) {
                $base = $base->$current();
                continue;
            }

            // look for property
            if (property_exists($base, $current)) {
                $base = $base->$current;
                continue;
            }

            if ($base instanceof ArrayAccess && $base->offsetExists($current)) {
                $base = $base->offsetGet($current);
                continue;
            }

            if (($current === 'length' || $current === 'size') && $base instanceof Countable) {
                $base = count($base);
                continue;
            }

            // look for isset (priority over __get)
            if (method_exists($base, '__isset')) {
                if ($base->__isset($current)) {
                    $base = $base->$current;
                    continue;
                }
            }
            // ask __get and discard if it returns null
            elseif (method_exists($base, '__get')) {
                $tmp = $base->$current;
                if (null !== $tmp) {
                    $base = $tmp;
                    continue;
                }
            }

            // magic method call
            if (method_exists($base, '__call')) {
                try
                {
                    $base = $base->__call($current, array());
                    continue;
                }
                catch(BadMethodCallException $e) {}
            }

            if ($nothrow) {
                return null;
            }

            PHPTAL_Context::pathError($base, $path, $current, $prev);
        }

        // array handling
        if (is_array($base)) {
            // key or index
            if (array_key_exists((string)$current, $base)) {
                $base = $base[$current];
                continue;
            }

            // virtual methods provided by phptal
            if ($current == 'length' || $current == 'size') {
                $base = count($base);
                continue;
            }

            if ($nothrow)
                return null;

            PHPTAL_Context::pathError($base, $path, $current, $prev);
        }

        // string handling
        if (is_string($base)) {
            // virtual methods provided by phptal
            if ($current == 'length' || $current == 'size') {
                $base = strlen($base);
                continue;
            }

            // access char at index
            if (is_numeric($current)) {
                $base = $base[$current];
                continue;
            }
        }

        // if this point is reached, then the part cannot be resolved

        if ($nothrow)
            return null;

        PHPTAL_Context::pathError($base, $path, $current, $prev);
    }

    return $base;
}

テンプレート変数の記述に含まれる / を区切りにして各要素を辿りつつ、クロージャ、ArrayAccess、マジックメソッドなど様々なものを対象に最終的に取得した値を返すように実装されているものと思いますが…。

<?php
if ($base instanceof ArrayAccess && $base->offsetExists($current)) {
    $base = $base->offsetGet($current);
    continue;
}

ArrayAccess 実装クラスのインスタンスの場合、offsetExists() で値の取得可否が判断されるようです。

つまり、offsetGet() から値を返すように実装していても、offsetExists() が FALSE を返してしまうと無視されてしまいます。

<?php
// look for isset (priority over __get)
if (method_exists($base, '__isset')) {
    if ($base->__isset($current)) {
        $base = $base->$current;
        continue;
    }
}
// ask __get and discard if it returns null
elseif (method_exists($base, '__get')) {
    $tmp = $base->$current;
    if (null !== $tmp) {
        $base = $tmp;
        continue;
    }
}

__isset() が実装されていれば、その結果によって値の取得を試みます。

__isset() が実装されておらず __get() が実装されていれば値の取得を試みますが、NULLが返された場合は無視され、例外がスローされてしまいます。

つまり、 __isset() → offsetExists() そして __get() → offsetGet() とマジックメソッド経由で呼ばれるよう実装している場合、 まずは ArrayAccess::offsetExists() が呼ばれて、それが FALSE を返すとその後また __isset() 経由で ArrayAccess::offsetExists() が呼ばれてまた FALSE が返された結果、 __get() も offsetGet() も呼ばれず例外がスローされるという流れになるわけです。

やりたかったのはこういうやつなんですが…。(コードは簡略化してます)

<?php
namespace Acme;

class Image implements \ArrayAccess
{
    use DataTrait;

    private $attributes = array();

    public function __construct($attributes = array(), $options = array())
    {
        $this->attributes = array(
            'id' => null,
            'name' => null,
            'size' => null,
            'width' => null,
            'height' => null,
            'encoded_data' => null,
            'mime_type' => null,
        );
        $this->initialize($attributes);
    }

    private function get_data_uri()
    {
        if (isset($this->attributes['mime_type']) && isset($this->attributes['encoded_data'])) {
            return sprintf('data:%s;base64,%s', $this->attributes['mime_type'], $this->attributes['encoded_data']);
        }
        return null;
    }

}

trait DataTrait
{

    public function initialize($attributes = array())
    {
        foreach (array_keys($this->attributes) as $name) {
            if (isset($attributes[$name])) {
                $this->offsetSet($name, $attributes[$name]);
            }
        }
        return $this;
    }

    public function offsetGet($name)
    {
        if (method_exists($this, 'get_' . $name)) {
            return $this->{'get_' . $name}();
        }
        if (array_key_exists($name, $this->attributes)) {
            return $this->attributes[$name];
        }
        return null;
    }

    public function offsetSet($name, $value)
    {
        if (method_exists($this, 'set_' . $name)) {
            return $this->{'set_' . $name}($value);
        }
        if (array_key_exists($name, $this->attributes)) {
            $this->attributes[$name] = $value;
        }
    }

    public function offsetExists($name)
    {
        return array_key_exists($name, $this->attributes);
    }

    public function offsetUnset($name)
    {
        if (array_key_exists($name, $this->attributes)) {
            $this->attributes[$name] = null;
        }
    }

    public function __get($name)
    {
        return $this->offsetGet($name);
    }

    public function __set($name, $value)
    {
        $this->offsetSet($name, $value);
    }

    public function __isset($name)
    {
        return $this->offsetExists($name);
    }

    public function __unset($name)
    {
        $this->offsetUnset($name);
    }

}

offsetExists() を array_key_exists() だけではなく offsetGet() が返す値の有無も見るようにすれば、回避することはできるのですが、 そこで話は前回の issetとemptyとoffsetExist にループするわけで…。

offsetExists() や __isset() がどうあるべきかの解釈が開発者によって異なっているのが根本的な原因だと思うのですが、そればかりはどうしようもないので、はてさて。

場当たり的な対応として __isset() は array_key_exists() && !is_null() を返して、 offsetExists() は array_key_exists() を返すという手もありますが、そういう解釈が正しいのかどうか。

PHPTAL の場合は素直にpublicメソッドで実装しておけば真っ先に呼んでくれるのですが、マジックメソッドを多用してるとそれはそれでまた別の罠(似非プロパティ名と同じ名前のメソッドが定義されてるとそちらが先に呼ばれる)にはまりがちなので、程々にしておくのが吉ということでしょう。