現象
PHP_INT_MAXやPHP_INT_MINは環境によって変わりますが、例として3v4lではPHP_INT_MAX
= 9223372036854775807
、PHP_INT_MIN
= -9223372036854775808
です。
var_dump(PHP_INT_MAX);
var_dump(PHP_INT_MIN);
マニュアルには通常は PHP_INT_MIN === ~PHP_INT_MAX となる
と書かれています。
試してみましょう。
var_dump(PHP_INT_MIN === ~PHP_INT_MAX);
trueになりました。
めでたしめでたし。
ここで試しに数値と比較してみましょう。
var_dump(PHP_INT_MAX, PHP_INT_MAX === 9223372036854775807);
var_dump(PHP_INT_MIN, PHP_INT_MIN === -9223372036854775808);
あれ?
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);
var_dumpするだけで-9223372036854775808
が何故かfloat(-9.2233720368548E+18)
になりました。
計算したりPHP_INT_MINから取ってくると正しく-9223372036854775808
になるのに、値をそのまま出すとfloatになるとかさっぱりですね。
ちなみにこの現象、いつから発生しているのかというと、なんとPHP4の時代からずっとです。
バグチケットは2018/08/07に提出されたのですが、ここまで誰一人気付いてなかったことにびっくりだよ。
境界値のテストは一応存在するのですが、(int)
って書いてあるせいで検出されていませんでした。
なお、バグチケットではvar_exportのせいだみたいに書いてありますが、実際はprintやecho、sprintfなんかでも起こるので数値処理の問題だと考えた方が適切でしょう。
原因調査
ここから下はソースを斜め読みしただけで実際にデバッグしたわけではないので、本当かどうかはわからないし保証もされません。
調査に使用したバージョンはPHP7.2.8です。
===
による比較は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
は、字句解析の時点では9223372036854775807
も1
も範囲内なので正常にパースされるということですね。
実用上は(int)
って入れればいいだけなので対処はたいしたことではないですが、根治のためには字句解析に手を入れないといけないということで、わりと深刻じゃないか、このバグ?