PHP
バグ
PHP_INT_MIN

-9223372036854775808がPHP_INT_MINじゃなかった

現象

PHP_INT_MAXやPHP_INT_MINは環境によって変わりますが、例として3v4lではPHP_INT_MAX = 9223372036854775807PHP_INT_MIN = -9223372036854775808です。

    var_dump(PHP_INT_MAX);
    var_dump(PHP_INT_MIN);

01.png

マニュアルには通常は PHP_INT_MIN === ~PHP_INT_MAX となると書かれています。
試してみましょう

    var_dump(PHP_INT_MIN === ~PHP_INT_MAX);

02.png

trueになりました。
めでたしめでたし。

ここで試しに数値と比較してみましょう。

    var_dump(PHP_INT_MAX, PHP_INT_MAX === 9223372036854775807);
    var_dump(PHP_INT_MIN, PHP_INT_MIN === -9223372036854775808);

03.png

あれ?

PHP_INT_MIN === -9223372036854775808がfalseになりました。

実はPHP_INT_MINだけではなく、数値同士の比較でもよくわからないことが起こります。

-9223372036854775807-1の計算結果は正しく-9223372036854775808になりますが、それを-9223372036854775808と比較するとfalseになります。
でも-9223372036854775808同士の比較はPHP_INT_MIN同士の比較trueになるという。

どうなってるのかよくわかりませんね。

さて原因はというと、実はPHP_INT_MINというよりむしろ-9223372036854775808のほうにあります

    var_dump(PHP_INT_MIN);
    var_dump(-9223372036854775808);

04.png

var_dumpするだけで-9223372036854775808が何故かfloat(-9.2233720368548E+18)になりました。
計算したりPHP_INT_MINから取ってくると正しく-9223372036854775808になるのに、値をそのまま出すとfloatになるとかさっぱりですね。

ちなみにこの現象、いつから発生しているのかというと、なんとPHP4の時代からずっとです。
バグチケットは2018/08/07に提出されたのですが、ここまで誰一人気付いてなかったことにびっくりだよ。
境界値のテストは一応存在するのですが、(int)って書いてあるせいで検出されていませんでした。

なお、バグチケットではvar_exportのせいだみたいに書いてありますが、実際はprintやecho、sprintfなんかでも起こるので数値処理の問題だと考えた方が適切でしょう。

原因調査

ここから下はソースを斜め読みしただけで実際にデバッグしたわけではないので、本当かどうかはわからないし保証もされません。
調査に使用したバージョンはPHP7.2.9です。

===による比較はzend_operators.h内のfast_is_identical_functionで行われています。

static zend_always_inline int fast_is_identical_function(zval *op1, zval *op2)
{
    if (Z_TYPE_P(op1) != Z_TYPE_P(op2)) {
        return 0;
    } else if (Z_TYPE_P(op1) <= IS_TRUE) {
        return 1;
    }
    return zend_is_identical(op1, op2);
}

・型が異なればfalseを返す。
・片方の型がundefined/null/false/trueであればtrueを返す。
・それ以外はzend_is_identicalの結果を返す。

zend_is_identicalはzend_operators.cで定義されています。

switch (Z_TYPE_P(op1)) {
    case IS_LONG:
        return (Z_LVAL_P(op1) == Z_LVAL_P(op2));

Z_LVAL_Pはlong値を持ってくるマクロです。
比較部分は特にひっかかるところがなさそうです。

ではどこが問題なのかというと、実はそれ以前、字句解析でスクリプトをパースするところです。
以下は数値をパースする部分の抜粋です。

<ST_IN_SCRIPTING>{LNUM} {

    // 19桁未満なら
    if (yyleng < MAX_LENGTH_OF_LONG - 1) {
        // longにする
        ZVAL_LONG(zendlval, ZEND_STRTOL(yytext, &end, 0));

    // 19桁以上なら
    } else {
        // longにする
        ZVAL_LONG(zendlval, ZEND_STRTOL(yytext, &end, 0));

        // オーバーフローしていた
        if (errno == ERANGE) {
            // 1文字目が0だった
            if (yytext[0] == '0') {
                // 8進数からdoubleにする
                ZVAL_DOUBLE(zendlval, zend_oct_strtod(yytext, (const char **)&end));
            } else {
                // 10進数からdoubleにする
                ZVAL_DOUBLE(zendlval, zend_strtod(yytext, (const char **)&end));
            }
        }
    }
}

LNUMの定義は[0-9]+となっていて、ここには数値が入ってきます。
-は入りません。
そしてこの関数では、入ってきた値をZVAL_LONGマクロでlong型にする、ただしオーバーフローしていたらdouble型にする、となっています。

ここから先はzend_types.hあたりに書いてあると思うのですが力尽きたので誰か続きよろ。
まあとにかく、ここで字句解析する際には-が入ってこないので、正負にかかわらず9223372036854775807は範囲内で、9223372036854775808はオーバーフローと判定されるみたいです。
ということで-9223372036854775808は、字句解析が終わった時点でfloat型になってしまいました。

-9223372036854775807-1は、字句解析の時点では92233720368547758071も範囲内なので正常にパースされるということですね。

実用上は(int)って入れればいいだけなので対処はたいしたことではないですが、根治のためには字句解析に手を入れないといけないということで、わりと深刻じゃないか、このバグ?