LoginSignup
17
9

More than 5 years have passed since last update.

PHPの厳密な緩やかな比較

Last updated at Posted at 2018-10-18

PHP / JavaScript

緩やかな比較…俺なんかが関わって良い世界ではなかった…

==による比較の厳密な定義というものが見当たらなかったので調べました。
実用的にはPHP型の比較表を見ればいいだけなのですがね。

ちなみにJavaScriptでは言語仕様で明記されています。

==の擬似コード

==による緩やかな比較はzend_vm_def.hで実装されています。

元コードはもちろんCなのでPHPで完全再現というわけにはいきませんが、以下はPHPっぽく表した擬似コードとなります。
擬似コードは調査時点での最新安定版PHP7.2.10のもので、バージョンによって異なる可能性があります。


/**
* ==の定義
* @param zval 引数1のzval構造体
* @param zval 引数2のzval構造体
* @return boolean 緩やかに同じならtrue、緩やかに異なればfalse
*/
function ==($op1, $op2){

    if($op1の型 === integer){
        if($op2の型 === integer){
            return $op1の整数値 === $op2の整数値;
        }else($op2の型 === double){
            return (double)$op1の整数値 === $op2の整数値;
        }
    }elseif($op1の型 === double){
        if($op2の型 === double){
            return $op1の小数値 === $op2の小数値;
        }else(op2の型 === integer){
            return $op1の小数値 === (double)$op2の整数値;
        }
    }elseif($op1の型 === string){
        if($op2の型 === string){
            if($op1のポインタ === $op2のポインタ){
                return true;
            }elseif($op1の1文字目が'9'より大きい || $op2の1文字目が'9'より大きい){
                if($op1の文字長 !== $op2の文字長){
                    return false;
                }else{
                    return memcmp($op1, $op2) === 0;
                }
            }else{
                return !zendi_smart_strcmp($op1の文字列値, $op2の文字列値);
            }
        }
    }

    return !compare_function($op1, $op2);
}

ざっくり現すとこんなかんじ。
・両方が数値型であれば数値にして、Cの==で比較して返す
・両方が文字列型で、両方の1文字目が数字でなければCのmemcmpで比較して返す
・両方が文字列型ならzendi_smart_strcmpを呼ぶ
・それ以外ならcompare_functionを呼ぶ

高速にさっと比較できるものはさっと調べ、それ以外は別の関数をさらに呼び出す形になっているようです。

'9'より大きいというのはchar型の文字コードが'9'より大きいという意味で、PHPで言えばord(op1の1文字目) > ord('9')です。
memcmpは、ほぼPHPのstrcmpで、文字列が等しいかを比較します。
zendi_smart_strcmpとcompare_functionはPHPの内部関数です。

zendi_smart_strcmpの擬似コード

zendi_smart_strcmpはzend_operators.cで実装されています。
==において、両方の引数が文字列型である場合に呼ばれます。


/**
* 文字列型同士を緩やかに比較して返す
* @param zend_string 文字列1
* @param zend_string 文字列2
* @return int 文字列が緩やかに等しければ0、等しくなければ0以外
*/
function zendi_smart_strcmp($op1, $op2){
    $ret1 = 文字列を数値に変換する($op1); // integerかdoubleか失敗のいずれかになる
    $ret2 = 文字列を数値に変換する($op2);

    // 何れかが数値文字列ではなかった
    if( !$ret1 || !$ret2 ){
        return zend_binary_strcmp($op1, $op2);
    }

    if(32ビットOSなら){
        if( $ret1がオーバーフローしている
            && $ret2がオーバーフローしている
            && $ret1 === $ret2
            && ( $ret1 > 9007199254740991 || $ret1 < -9007199254740991 )
        ){
            return zend_binary_strcmp($op1, $op2);
        }
    }else{
        if( $ret1がオーバーフローしている
            && $ret2がオーバーフローしている
            && $ret1 === $ret2
        ){
            return zend_binary_strcmp($op1, $op2);
        }
    }

    if( $ret1の型 === double || $ret2の型 === double ){
        if( $ret1の型 !== double ){
            $ret1 = (double)$ret1;
        }
        elseif( $ret2の型 !== double ){
            $ret2 = (double)$ret2;
        }elseif($ret1 === $ret2 && $ret1がオーバーフローしている){
            return zend_binary_strcmp($op1, $op2);
        }
        return $ret1 - $ret2;
    }

    return $ret1 <=> $ret2;
}

/**
* 文字列型同士を文字列のまま比較して返す
* @param char 文字列1
* @param char 文字列2
* @return int 等しければ0、等しくなければ文字列長の差
*/
function zend_binary_strcmp($op1, $op2){
    if($op1のポインタ === $op2のポインタ){
        return 0;
    }
    return memcmp($op1, $op2);
}

1文字目が'A'や'a'であるなど、数値文字列でないことが明らかな場合は==内でさっさと比較して返されているので、ここに来るのは数値文字列である可能性がある場合になります。
文字列を数値に変換するなどの処理内部は華麗にすっ飛ばしていますが、このあたりもえらいことになっていて、全てを追っていたら年が明けてまた暮れます。

またオーバーフロー云々はこのあたりとかこのあたりとかのアドホック修正が積み重なった挙げ句の果てです。
擬似コードではわかりやすく条件分岐していますが、元のコードでは条件文自体を書き換えています。

zend_operators.c
#if ZEND_ULONG_MAX == 0xFFFFFFFF
        if (oflow1 != 0 && oflow1 == oflow2 && dval1 - dval2 == 0. &&
            ((oflow1 == 1 && dval1 > 9007199254740991. )
            || (oflow1 == -1 && dval1 < -9007199254740991.))) {
#else
        if (oflow1 != 0 && oflow1 == oflow2 && dval1 - dval2 == 0.) {
#endif

言語実装という現場ではDIやらポリモーフィズムやら言ってる余裕なんてないということでしょう。
しかしそれはいいとしても、ZEND_ENABLE_ZVAL_LONG64やらUINT32_MAXやらを使わず直接0xFFFFFFFFなのは何故なのか。

compare_functionの擬似コード

compare_functionもzend_operators.cで実装されています。
こちらは片方が配列だったりオブジェクトだったりといった、ここまでで判定しきれなかった比較が全てやってきます。
つまりは遅いということなので、ここまで来るような比較はあまり書かない方がよいでしょう。
といってもロジックの遅さに比べたら塵芥の差なので気にする必要もないと思いますが。

[integer, integer]といった、最初の==で弾かれているはずのロジックも書かれていますが、これは==以外からも使われているためです。
具体的にはSplHeapCollatorなどから呼ばれています。
つまり、これらのソートは緩やかな比較で行われているということです。


/**
* 緩やかに比較して返す
* @param zval 引数1のzval構造体
* @param zval 引数2のzval構造体
* @return int 文字列が緩やかに等しければ0、等しくなければ0以外
*/
function compare_function($op1, $op2){

    $converted = false;

    start:

    switch([$op1の型, $op2の型]){

    case [integer, integer]:
        return $op1 > $op2 ? 1 : ( $op1 < $op2 ? -1 : 0);

    case [double, integer]:
        return ZEND_NORMALIZE_BOOL($op1 - (double)$op2);

    case [integer, double]:
        return ZEND_NORMALIZE_BOOL((double)$op1 - $op2);

    case [double, double]:
        if($op1 === $op2){ return 0;}
        return ZEND_NORMALIZE_BOOL($op1 - $op2);

    case [array, array]:
        return zend_compare_arrays($op1, $op2);

    case [null, null]:
    case [null, false]:
    case [false, null]:
    case [false, false]:
    case [true, true]:
        return 0;

    case [null, true]:
        return -1;
    case [true, null]:
        return 1;

    case [string, string]:
        return $op1 === $op2;

    case [null, string]:
        return strlen($op2) === 0 ? 0 : -1;
    case [string, null]:
        return strlen($op1) === 0 ? 0 : 1;

    case [object, null]:
        return 1;
    case [null, object]:
        return -1;

    default:
        if($op1が参照である){
            $op1 = $op1の参照先;
            goto start;
        }
        if($op2が参照である){
            $op2 = $op2の参照先;
            goto start;
        }

        if($op1の型 === object && $op1にcompareハンドラがある){
            return $op1のcompareハンドラ($op1, $op2);
        }
        if($op2の型 === object && $op2にcompareハンドラがある){
            return $op2のcompareハンドラ($op1, $op2);
        }

        if($op1の型 === object && $op2の型 === object){
            if($op1のポインタ === $op2のポインタ){
                return 0;
            }
            if($op1のcompare_objectsハンドラのポインタ === $op2のcompare_objectsハンドラのポインタ){
                return $op1のcompare_objectsハンドラ($op1, $op2);
            }
        }

        if($op1の型 === object){
            if($op1にgetハンドラがある){
                return compare_function($op1->get(), $op2);
            }elseif($op2の型 !== object && $op1にcast_objectハンドラがある){
                $op1 = $op1->cast_object($op2の型);
                return compare_function($op1, $op2);
            }
        }
        if($op2の型 === object){
            if($op2にgetハンドラがある){
                return compare_function($op1, $op2->get());
            }elseif($op1の型 !== object && $op2にcast_objectハンドラがある){
                $op2 = $op2->cast_object($op1の型);
                return compare_function($op1, $op2);
            }
        }elseif($op1の型 === object){
            return 1;
        }

        if(!$converted){

            if($op1の型 === null || $op1の型 === false){
                return (bool)$op2 === true ? -1 : 0;
            }elseif($op2の型 === null || $op2の型 === false){
                return (bool)$op1 === true ? -1 : 0;
            }elseif($op1の型 === true){
                return (bool)$op2 === true ? 0 : 1;
            }elseif($op2の型 === true){
                return (bool)$op1 === true ? 0 : -1;
            }else{
                $op1 = (int)$op1;
                $op2 = (int)$op2;
                $converted = true;
                goto start;
            }

        }elseif($op1の型 === array){
            return 1;
        }elseif($op2の型 === array){
            return -1;
        }elseif($op1の型 === object){
            return 1;
        }elseif($op2の型 === object){
            return -1;
        }else{
            return 0;
        }

    }
}

なにこれ。

両方の型がオブジェクトである場合、比較はzend_object_handlers.cで定義されているcompare_objectsハンドラで行われます。
同じクラスのインスタンスであり、全てのプロパティの値が同じときにだけtrueを返す、と定義されています。

ただし、これは任意に書き換えることができるため、正確な定義はそれぞれの実装を見なければわかりません。
例として、DateTimeでは時間が同じならtrueを返すようになっています。
時間さえ同じならそれ以外のプロパティが異なっていてもtrueです。
普通のオブジェクトでは当然そんなことはありません

compareハンドラについてはよくわかりませんでした。

ZEND_NORMALIZE_BOOLは返り値を0、1、-1の何れかにしてくれる、宇宙船演算子みたいなマクロです。

zend_compare_arraysは配列同士を比較する関数で、幾度かのたらい回しを経て、最終的に配列の全要素をcompare_functionで比較して返すという内容になっていました。

ちなみにzend_compare_arraysのすぐ下にあるzend_compare_objectsですが、いかにもオブジェクト同士を比較してそうな名前ですが全く使われておらず、何故かunregister_tick_functionだけからしか呼ばれないという謎関数でした。

===の擬似コード

参考までに===の定義も見てみましょう。

定義はzend_vm_def.hにありますが、たらい回しを経て実態はzend_is_identicalです。


/**
* ===の定義
* @param zval 引数1のzval構造体
* @param zval 引数2のzval構造体
* @return boolean 厳密に同じならtrue、厳密に異なればfalse
*/
function ===($op1, $op2){

    if($op1の型 !== $op2の型){
        return false;
    }

    switch($op1の型){

    case null:
    case false:
    case true:
        return true;

    case integer:
        return $op1の整数値 === $op2の整数値;

    case resource:
        return $op1のポインタ === $op2のポインタ;

    case double:
        return $op1の小数値 === $op2の小数値;

    case string:
        if($op1のポインタ === $op2のポインタ){
            return true;
        }
        return memcmp($op1, $op2) === 0;

    case array:
        if($op1のポインタ === $op2のポインタ){
            return true;
        }
        return zend_hash_compare($op1, $op2);

    case object:
        return $op1のポインタ === $op2のポインタ;

    default:
        return false;
    }
}

==に比べると非常にすっきりしていますね。
zend_hash_compareはzend_compare_arraysとだいたい同じで、配列の全要素を===で比較するというものです。

まとめ

===を使おう。

プリミティブ型ならまだしも、Object型の緩やかな比較がどうなるかをわかる人は変態だけだと思うのですよ。

感想

PHPのソース、マクロだらけ、たらい回しだらけで読むのがとてもつらい。
Z_OBJ_HANDLER_PとかZEND_VM_SMART_BRANCHとかなんなん。
もっとも言語は高速化こそが正義であって、可読性など二の次三の次ということなのでしょうが、おかげで後から見るのがとっても大変です。
こんなの触れる人は本当すごいですわ。

17
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
9