k-holyのPHPとか諸々メモ

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

issetとemptyとoffsetExist

タイトルは「いとしさとせつなさと…」みたいな感じで読んでください。

マジックメソッド、ArrayAccessインタフェース、Traversableインタフェースを実装した配列風のクラスを作成する際に引っかかりがちな(?)、__isset() と offsetExists() の仕様上の罠についてメモです。

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

罠というか言語仕様の不備と言ってもいいんじゃないかと考えてるのですが、PHPマニュアルの記述とともに isset(), empty(), offsetExists() それぞれについて見てみます。

isset()

個人的にはコード内で一番使用頻度が高いんじゃないかという言語構造で、PHPを使う上で最初に把握しておいて欲しいところでもあります。

http://www.php.net/manual/ja/function.isset.php

変数がセットされており、それが NULL でないことを調べます。

変数が、 unset() により割当を解除された場合、 何も値が設定されていない状態になります。 NULLに設定されている変数を調べた場合、 isset() はFALSEを返します。 NULLバイト("\0")はPHPの定数 NULLと等価ではないことにも注意してください。

未定義の変数、配列の未定義キーの要素、オブジェクトの未定義プロパティ、そして NULL という、一言で書くと「値があるかどうか」を調べるのに最も便利で速い方法として、頼りになります。

よく使うのはこういうやつですね。

<?php

$data['foo'] = (isset($data['foo']) && strlen($data['foo']) >= 1) ? $data['foo'] : 'default';

配列の値に関わらずキーが未定義かどうかを調べたい場合は、その名の通りの関数 array_key_exists() があって、こちらは値がNULLの場合もTRUEを返します。

また、オブジェクトのプロパティについても同じく property_exists() があって、これも値がNULLの場合にTRUEを返します。

ただし、マジックメソッド経由でアクセス可能なプロパティに対して property_exists() はTRUEを返さないので、 _get() と _set() による擬似的なプロパティを扱うクラスの場合、以下の点に要注意です。

注意:

オブジェクトのアクセス不能なプロパティに対して isset() を使用した場合は、もしオーバーロードメソッド __isset() が宣言されていればそれをコールします。

__isset() も実装しておくことで、先ほどのコードがこう書けるようになるわけです。

<?php

$data->foo = (isset($data->foo) && strlen($data->foo) >= 1) ? $data->foo : 'default';

empty()

個人的には極めて使用頻度の低い言語構造で、PHPを使う上で最初は避けて通って欲しいところでもあります。

http://www.php.net/manual/ja/function.empty.php

変数が空であるかどうかを検査します。 変数が空であるとみなされるのは、変数が存在しない場合や 変数の値が FALSE に等しい場合です。 empty() は、変数が存在しない場合でも警告を発しません。

変数が存在しなくても警告は発生しません。 つまり、 empty() は本質的に !isset($var) || $var == false と同じことを簡潔に記述しているだけです。

未だにWeb上のサンプルコードでおそらく isset() と同じ動作が期待されているであろうケースでこれが使われるのを見ますが、 isset() と empty() は全く異なる動作をします。

次のような値は空であるとみなされます。

  • "" (空文字列)

  • 0 (整数 の 0)

  • 0.0 (浮動小数点数の 0)

  • "0" (文字列 の 0)

  • NULL

  • FALSE

  • array() (空の配列)

  • $var; (変数が宣言されているが、値が設定されていない)

特にスカラー値に使った場合、多くのケースで害にしかならないことが分かると思います。(PHP4現役の頃、XMLを扱うPEARのライブラリでこれが使われていたせいで、文字列の"0"が取得できないバグに見舞われたことがあります…)

個人的には、配列が返されることが確定している状況で、空の配列かどうかを調べるのに使うことがありますが、最近は配列風オブジェクトを多用していることもあって素直に count() 使うことが多くなってます。

このように、使用頻度は極めて低いのですが、この関数を決して放っておけない理由があるのです。

注意:

オブジェクトのアクセス不能なプロパティに対して empty() を使用した場合は、もしオーバーロードメソッド __isset() が宣言されていればそれをコールします。

「ちょっと待てよ」と言いたくなりませんか、これ。

isset() と empty() って全然動作が違うじゃないですか。なんでそれが一緒くたに扱われるんでしょうか。

ArrayAccess::offsetExists()

オブジェクトに配列アクセスを実装する上で必要になるインタフェース ArrayAccess の必須メソッドです。

名前から判断すると通常のオブジェクトにおける property_exists() や 通常の配列における array_key_exists() に相当する動作を期待されているものと考えてしまうのですが…。

http://www.php.net/manual/ja/arrayaccess.offsetexists.php

オフセットが存在するかどうかを返します。

このメソッドが実行されるのは、ArrayAccess を実装したオブジェクト上で isset() あるいは empty() を使用した場合です。

注意:

empty() を使用すると ArrayAccess::offsetGet() がコールされ、 ArrayAccess::offsetExists() が TRUE を返すかどうかで空かどうかを判断します。

えー

isset() と empty() って(略)

どう対応すればいいのか戸惑いますが、使用頻度を考えると、とりあえず empty() のことは無視するしかない気がします。はい、無視しましょう。

実際の実装例

これらに IteratorAggregate による Traversable 実装も加えると、大体以下のような感じでしょうか。

動的なプロパティ定義については、初期化時のみ許可する場合と、後からでも無制限に定義できるようにする場合の、二通りの方針が考えられますが、後者のケースです。

<?php

namespace Acme;

class ArrayObject implements \ArrayAccess, \IteratorAggregate
{

    protected $attributes;

    public function __construct($attributes)
    {
        $this->initialize($attributes);
    }

    public function initialize($attributes)
    {
        $this->attributes = array();
        if (!empty($attributes)) {
            foreach($attributes as $name => $value) {
                $this->attributes[$name] = $value;
            }
        }
    }

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

    public function offsetSet($name, $value)
    {
        $this->attributes[$name] = $value;
    }

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

    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);
    }

    public function getIterator()
    {
        return new \ArrayIterator($this->attributes);
    }

}

offsetExists() は値がNULLの場合にFALSEを返すよう実装してますので、こんな感じになります。

<?php

$data = new \Acme\ArrayObject(array(
    'foo' => 'Foo',
    'bar' => null,
    'baz' => '',
));

var_dump(isset($data->foo)); // bool(true)
var_dump(isset($data->bar)); // bool(false)
var_dump(isset($data->baz)); // bool(true)
var_dump(isset($data->qux)); // bool(false)

var_dump(isset($data['foo'])); // bool(true)
var_dump(isset($data['bar'])); // bool(false)
var_dump(isset($data['baz'])); // bool(true)
var_dump(isset($data['qux'])); // bool(false)

foreach ($data as $name => $value) {
    var_dump($name, $value);
    // string(3) "foo" string(3) "Foo"
    // string(3) "bar" NULL
    // string(3) "baz" string(0) ""
}

この結果を見ると、おそらく ArrayIterator::valid() は array_key_exists() 相当の動作で実装されているものと思われます。

同じコードをArrayObjectで確かめてみます。

<?php
$data = new \ArrayObject(array(
    'foo' => 'Foo',
    'bar' => null,
    'baz' => '',
));

echo '<hr>';
var_dump(isset($data->foo)); // bool(false)
var_dump(isset($data->bar)); // bool(false)
var_dump(isset($data->baz)); // bool(false)
var_dump(isset($data->qux)); // bool(false)

var_dump(isset($data['foo'])); // bool(true)
var_dump(isset($data['bar'])); // bool(false)
var_dump(isset($data['baz'])); // bool(true)
var_dump(isset($data['qux'])); // bool(false)

foreach ($data as $name => $value) {
    var_dump($name, $value);
    // string(3) "foo" string(3) "Foo"
    // string(3) "bar" NULL
    // string(3) "baz" string(0) ""
}

マジックメソッドを実装していないため、プロパティアクセスの方は isset() にFALSEが返されています。予想通りの結果ではないでしょうか。

他のArrayAccess::offsetExists()実装例

前回、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の場合にそこでループが終了してしまうわけです。

こう書いたように、サンプルでも ArrayAccess::offsetExists() をそのように実装しています。

しかし、実は世間一般の ArrayAccess::offsetExists() の実装では isset() ではなく array_key_exists() 相当になっているのでは、という疑念が湧いてきました。

そこで、身近なところにあるFabien先生のコードを調べてみたところ…。

<?php

/*
 * This file is part of Pimple.
 *
 * Copyright (c) 2009 Fabien Potencier
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is furnished
 * to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

/**
 * Pimple main class.
 *
 * @package pimple
 * @author  Fabien Potencier
 */
class Pimple implements ArrayAccess
{
    protected $values = array();

    /**
     * Instantiate the container.
     *
     * Objects and parameters can be passed as argument to the constructor.
     *
     * @param array $values The parameters or objects.
     */
    public function __construct (array $values = array())
    {
        $this->values = $values;
    }

// …中略…

    /**
     * Checks if a parameter or an object is set.
     *
     * @param string $id The unique identifier for the parameter or object
     *
     * @return Boolean
     */
    public function offsetExists($id)
    {
        return array_key_exists($id, $this->values);
    }

}

げえっ

isset() も empty() も関係なく、まんま array_key_exists() じゃないですか。

これが、今回はまった罠です…まあ if (isset($data['foo']) && !is_null($data['foo'])) みたいに書けばいいのは分かるのですが…。

この動作を前提にすると、相当量のコード修正が必要になりそうです。それじゃ配列のふりしている意味がないし…これまで isset() の便利さに頼り過ぎたのが悪かったんでしょうか。

別に Fabien 先生のコードにケチをつけようというわけではなくて、isset() と empty() という全く動作の異なる関数を一緒くたにする ArrayAccess::offsetExists() の仕様が問題なんじゃないかと思います。

こういう時は代替手段の有無を基準に考えるべきでしょうから、Fabien 先生に習って array_key_exists() の結果を返すべきだとは思うのですが…どうしたもんでしょう。

追記:ArrayAccessへのempty()はoffsetExists()とoffsetGet()が返す値が評価される

id:ngyuki さんよりブックマークコメントにて empty() 時の ArrayAccess::offsetExists() の動作について教えていただきました。

empty のときは offsetExists で存在を確認 → offsetGet で値の検査 をしているので ArrayAccess::offsetExists() の仕様はおかしくないような?

えっ…。

PHPマニュアルより

http://www.php.net/manual/ja/arrayaccess.offsetexists.php

注意:

empty() を使用すると ArrayAccess::offsetGet() がコールされ、 ArrayAccess::offsetExists() が TRUE を返すかどうかで空かどうかを判断します。

あっ…ということで、確認してみました。

<?php

$data = new \Acme\ArrayObject(array(
    'foo' => 'Foo',
    'bar' => null,
    'baz' => '',
));

var_dump(empty($data->foo)); // bool(false)
var_dump(empty($data->bar)); // bool(true)
var_dump(empty($data->baz)); // bool(true)
var_dump(empty($data->qux)); // bool(true)

var_dump(empty($data['foo'])); // bool(false)
var_dump(empty($data['bar'])); // bool(true)
var_dump(empty($data['baz'])); // bool(true)
var_dump(empty($data['qux'])); // bool(true)

$data = new \ArrayObject(array(
    'foo' => 'Foo',
    'bar' => null,
    'baz' => '',
));

var_dump(empty($data->foo)); // bool(true)
var_dump(empty($data->bar)); // bool(true)
var_dump(empty($data->baz)); // bool(true)
var_dump(empty($data->qux)); // bool(true)

var_dump(empty($data['foo'])); // bool(false)
var_dump(empty($data['bar'])); // bool(true)
var_dump(empty($data['baz'])); // bool(true)
var_dump(empty($data['qux'])); // bool(true)

empty() の場合まず offsetExists() がFALSEを返したらTRUEを、そうでなければ offsetGet() で取得した値を empty() で評価するという流れなんですね。

マニュアルの記述を勝手に読み違えていたようです…。

id:ngyuki さんありがとうございました。

それはそれとして、やはり値がNULLの場合の isset() の問題は変わらず発生してしまいますが、ArrayAccessに対する empty() がそういう仕様なのであれば、答えは見えた気がします。

ArrayAccess::offsetExists() はその名前の通り指定されたキーが有効かどうかを返すべきであって、これを実装したオブジェクトに対して値の存在を確認したい場合 if (isset($data['foo']) && !is_null($data['foo'])) と書くのが正しいのですね?

ArrayAccess::offsetExists()を正しく実装してみる?

というわけで、offsetExists() はこちらに変更しました。

<?php

namespace Acme;

class ArrayObject implements \ArrayAccess, \IteratorAggregate
{
    // 中略

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

}

isset() empty() is_null() それぞれの確認結果は以下の通りです。

要注意な場所には ※ を付けました。

<?php

$data = new \Acme\ArrayObject(array(
    'foo' => 'Foo',
    'bar' => null,
    'baz' => '',
));

var_dump(isset($data->foo)); // bool(true)
var_dump(isset($data->bar)); // bool(true) ※
var_dump(isset($data->baz)); // bool(true)
var_dump(isset($data->qux)); // bool(false)

var_dump(isset($data['foo'])); // bool(true)
var_dump(isset($data['bar'])); // bool(true) ※
var_dump(isset($data['baz'])); // bool(true)
var_dump(isset($data['qux'])); // bool(false)

var_dump(empty($data->foo)); // bool(false)
var_dump(empty($data->bar)); // bool(true)
var_dump(empty($data->baz)); // bool(true)
var_dump(empty($data->qux)); // bool(true)

var_dump(empty($data['foo'])); // bool(false)
var_dump(empty($data['bar'])); // bool(true)
var_dump(empty($data['baz'])); // bool(true)
var_dump(empty($data['qux'])); // bool(true)

var_dump(is_null($data->foo)); // bool(false)
var_dump(is_null($data->bar)); // bool(true)
var_dump(is_null($data->baz)); // bool(false)
var_dump(is_null($data->qux)); // bool(true)

var_dump(is_null($data['foo'])); // bool(false)
var_dump(is_null($data['bar'])); // bool(true)
var_dump(is_null($data['baz'])); // bool(false)
var_dump(is_null($data['qux'])); // bool(true)

キーが存在して値がNULLの場合 isset($data->bar) isset($data['bar']) にTRUEが返されるようになりました。

ここで、ArrayObjectの場合どうなるか、もう一度確認してみます。

<?php
$data = new \ArrayObject(array(
    'foo' => 'Foo',
    'bar' => null,
    'baz' => '',
));

echo '<hr>';
var_dump(isset($data->foo)); // bool(false)
var_dump(isset($data->bar)); // bool(false)
var_dump(isset($data->baz)); // bool(false)
var_dump(isset($data->qux)); // bool(false)

var_dump(isset($data['foo'])); // bool(true)
var_dump(isset($data['bar'])); // bool(false) ※
var_dump(isset($data['baz'])); // bool(true)
var_dump(isset($data['qux'])); // bool(false)

var_dump(empty($data->foo)); // bool(true)
var_dump(empty($data->bar)); // bool(true)
var_dump(empty($data->baz)); // bool(true)
var_dump(empty($data->qux)); // bool(true)

var_dump(empty($data['foo'])); // bool(false)
var_dump(empty($data['bar'])); // bool(true)
var_dump(empty($data['baz'])); // bool(true)
var_dump(empty($data['qux'])); // bool(true)

var_dump(is_null($data->foo)); // Notice: Undefined property: ArrayObject::$foo in... bool(true)
var_dump(is_null($data->bar)); // Notice: Undefined property: ArrayObject::$bar in... bool(true)
var_dump(is_null($data->baz)); // Notice: Undefined property: ArrayObject::$baz in... bool(true)
var_dump(is_null($data->qux)); // Notice: Undefined property: ArrayObject::$qux in... bool(true)

var_dump(is_null($data['foo'])); // bool(false)
var_dump(is_null($data['bar'])); // bool(true)
var_dump(is_null($data['baz'])); // bool(false)
var_dump(is_null($data['qux'])); // Notice: Undefined index: qux in... bool(true)

ArrayObject::offsetExists() の実装は array_key_exists() && is_null() みたいな感じですよね…。

一体どうすれば……。