てきとうなさいと べぇたばん

PHP 整数型のリテラルの最小値が違うのはプログラマが昔から苦しめられた名残りだったようだ

簡単に説明するとこういうことになってて、LNUMしか見ていないのでオーバーフローになるらしい。

※ 追記しました 下へどうぞ。

PHPのInteger

PHPのInteger(整数型)は、C言語のlongで実装されている。最小値は通常であれば-2の63乗である

-9223372036854775808

であるはず。

PHPでのリテラル値では…

だが、PHPでこのリテラルを指定すると

$ php -r 'var_dump(-9223372036854775808);'
float(-9.2233720368548E+18)

となってしまう。すなわち、何故かFloat(浮動小数点型)になってしまうのだ。整数型の範囲を外れればFloatになるのがPHPの仕様だが、何故か範囲内である-9223372036854775808なのにもかかわらずFloatになってしまう。

ちなみに、PHP7から新しく出てきた定数、PHP_INT_MINを指定すると、きちんと-9223372036854775808となっている。また、 pow関数を使うと、やはり-9223372036854775808となる。

# php -r 'var_dump(PHP_INT_MIN);'
int(-9223372036854775808)
$ php -r 'var_dump(pow(-2, 63));'
int(-9223372036854775808)

うーんと…?

ソースコード

まず最初に疑ったのは、字句解析の部分で、zend_language_scanner.l。1118行目にはこう書いてある。(PHP 7.0.9)

LNUM    [0-9]+

で、これを解析している部分は1655行目。

<ST_IN_SCRIPTING>{LNUM} {
    if (yyleng < MAX_LENGTH_OF_LONG - 1) { /* Won't overflow */
        errno = 0;
        ZVAL_LONG(zendlval, ZEND_STRTOL(yytext, &end, 0));
        /* This isn't an assert, we need to ensure 019 isn't valid octal
         * Because the lexing itself doesn't do that for us
         */
        if (end != yytext + yyleng) {
                zend_throw_exception(zend_ce_parse_error, "Invalid numeric literal", 0);
                ZVAL_UNDEF(zendlval);
                RETURN_TOKEN(T_LNUMBER);
        }
    }

オーバーフローしていなかったらここに到達のはず。ZEND_STRTOLとは、strtolのプリプロセッサである。文字列をlong型に変更してくれる。しかし、これだと、マイナス値が取れないというか、別のトークンになる。マイナスのリテラルはどこにあるのかな?

Zend/zend_language_scanner.lの1127行目に定義されてたTOKENSの中にあるようだ。

TOKENS [;:,.\[\]()|^&+-/*=%!~$<>?@]

デバッグしてみる

configureに--enable-debugオプションつけてコンパイル。んで、gdbを起動。

$ gdb --command .gdbinit sapi/cli/php

で、ブレークポイントを設定。zend_language_scanner.cの2780行目。

(gdb) b zend_language_scanner.c:2734
Note: breakpoint 1 also set at pc 0x5dfece.
(gdb) run -r '-9223372036854775808;'
Starting program: /home/tekitoh/php-7.0.9/sapi/cli/php -r '-9223372036854775808;'
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, lex_scan (zendlval=0x7fffffffc390) at Zend/zend_language_scanner.c:2780
2780            yyaccept = 2;
(gdb) l
2775        RETURN_TOKEN(T_LNUMBER);
2776    }
2777    #line 2778 "Zend/zend_language_scanner.c"
2778    yy137:
2779            YYDEBUG(137, *YYCURSOR);
2780            yyaccept = 2;
2781            yych = *(YYMARKER = ++YYCURSOR);
2782            if (yych <= '9') {
2783                if (yych == '.') goto yy151;
2784                if (yych <= '/') goto yy136;
(gdb)

ここから、とりあえずnばっかり打ちまくる。

(gdb) n
3022            yyaccept = 2;
(gdb) n
3023            YYMARKER = ++YYCURSOR;
(gdb) n
3024            YYFILL(3);
(gdb) n
3025            yych = *YYCURSOR;
(gdb) n
3027            if (yych <= '9') {
(gdb) n
3028                if (yych == '.') goto yy151;
(gdb) n
3029                if (yych <= '/') goto yy136;
(gdb) n
3030                goto yy154;
(gdb) n
3022            yyaccept = 2;
(gdb) n
3023            YYMARKER = ++YYCURSOR;
(gdb) n
3024            YYFILL(3);
(gdb) n
3025            yych = *YYCURSOR;
(gdb) n
3027            if (yych <= '9') {
(gdb) n
3028                if (yych == '.') goto yy151;
(gdb) n
3029                if (yych <= '/') goto yy136;
(gdb) n
3030                goto yy154;
(gdb) n
3022            yyaccept = 2;
(gdb) n
3023            YYMARKER = ++YYCURSOR;
(gdb) n
3024            YYFILL(3);
(gdb) n
3025            yych = *YYCURSOR;
(gdb) n
3027            if (yych <= '9') {
(gdb) n
3028                if (yych == '.') goto yy151;
(gdb) n
3029                if (yych <= '/') goto yy136;
(gdb) n
3030                goto yy154;
(gdb)

たぶん、ここで一文字ずつポインタを移動させて、数値である限り取得し続けていることがわかる。たぶんそうだろう。

(gdb) l
1665                ZVAL_UNDEF(zendlval);
1666                RETURN_TOKEN(T_LNUMBER);
1667            }
1668        } else {
1669            errno = 0;
1670            ZVAL_LONG(zendlval, ZEND_STRTOL(yytext, &end, 0));
1671            if (errno == ERANGE) { /* Overflow */
1672                errno = 0;
1673                if (yytext[0] == '0') { /* octal overflow */
1674                    errno = 0;
(gdb)

数値のパースが終わったら、ここにたどり着く。格納されているyytextに対してzval構造体のzendlvalにlongで突っ込もうとしている。strtol関数にて文字列になっている数値がlong型に変換されることになる。…そのはずである。

(gdb) n
1671            if (errno == ERANGE) { /* Overflow */
(gdb) n
1672                errno = 0;
(gdb)

しかし、結果はOverflowのコメントが付いたこのブロックにたどり着く。その後はオーバーフローをしているのでdouble型になる。ということになる。

(gdb) l
1668        } else {
1669            errno = 0;
1670            ZVAL_LONG(zendlval, ZEND_STRTOL(yytext, &end, 0));
1671            if (errno == ERANGE) { /* Overflow */
1672                errno = 0;
1673                if (yytext[0] == '0') { /* octal overflow */
1674                    errno = 0;
1675                    ZVAL_DOUBLE(zendlval, zend_oct_strtod(yytext, (const char **)&end));
1676                } else {
1677                    ZVAL_DOUBLE(zendlval, zend_strtod(yytext, (const char **)&end));
(gdb)

しかし、これでは-2の63乗である「-9223372036854775808」を指定すると、longの範囲内(一般的に64bit環境)のはずなのに、オーバーフローしているのでdouble型に変換されてしまうというおかしなことになっている。

そもそも、字句解析でデータ型を決めるというよりも、おそらくは構文解析で決めるべきものではないだろうかと思う。…とはいえなんとも言えない。

Pythonでは

参考までに、Python(CPython)を調べてみる。Pythonではどうなのだろうか。字句解析部分のソースを読んでみた。

https://hg.python.org/cpython/file/default/Parser/tokenizer.c#l1650 1650行目から数値の字句解析が始まっている。仕組みとしては単純で、一文字ずつisdigitを使って数値かどうかチェックしている。そして、それがすべて数値であるとわかったら NUMBERという定数を返す

少なくとも、字句解析でFloatかIntかを判断していない。判断しているのは「実数か小数点か、n進数か」であると思う。

https://hg.python.org/cpython/file/default/Python/ast.c#l2096

ここで、変換してるのかな

static expr_ty
ast_for_atom(struct compiling *c, const node *n)

    case NUMBER: {

        PyObject *pynum = parsenumber(c, STR(ch));
        if (!pynum)
            return NULL;

        if (PyArena_AddPyObject(c->c_arena, pynum) < 0) {
            Py_DECREF(pynum);
            return NULL;

        }
        return Num(pynum, LINENO(n), n->n_col_offset, c->c_arena);
    }

Python/ast.cにあった。strtolへのエイリアス関数。

assert(s != NULL);
errno = 0;
end = s + strlen(s) - 1;
imflag = *end == 'j' || *end == 'J';
if (s[0] == '0') {
    x = (long) PyOS_strtoul(s, (char **)&end, 0);
    if (x < 0 && errno == 0) {
        return PyLong_FromString(s, (char **)0, 0);
    }
}
else
    x = PyOS_strtol(s, (char **)&end, 0);

要するに、AST(抽象構文木)に突っ込む際にchar *からlong型に変更しているということになる。これならわかる。

これはなんだろう

うーん。わからない。少なくとも、現状以下のコードがtrueになっていなければならないのにfalseになることだけは確か。(PHP 7.0.9 Debian ソースからインストール)

# php -r 'var_dump(-9223372036854775808 === PHP_INT_MIN);'
bool(false)

コレって既知のバグ?ちょっとわからない。

PHPの整数型のマニュアルには、「使用可能なリテラルの定数は以下の様なものがあります」とある。

decimal     : [1-9][0-9]*
            | 0

hexadecimal : 0[xX][0-9a-fA-F]+

octal       : 0[0-7]+

binary      : 0b[01]+

integer     : [+-]?decimal
            | [+-]?hexadecimal
            | [+-]?octal
            | [+-]?binary

うーん?

追記

そもそも、C言語自体がリテラルの処理に苦心しているようだった。

#include <stdio.h>

int main(void) {
    long a = -9223372036854775808L;
    printf("%ld\n", a);
    return 0;
}

まず、「-9223372036854775808」とするとwarningを出す。

$ gcc -o long_hoge long_hoge.c
long_hoge.c: In function ‘main’:
long_hoge.c:4:15: warning: integer constant is so large that it is unsigned
     long a = -9223372036854775808;
               ^
long_hoge.c:4:5: warning: this decimal constant is unsigned only in ISO C90
     long a = -9223372036854775808;
     ^

一応、手元で試した限りは「-9223372036854775808UL」(unsigned longとして処理させる)とすれば警告は出なかったけど、それはそれでおかしい。

limits.h(/usr/include/limits.h)にはLONG_MINの定義があるのだけど、以下のように-LONG_MAX-1Lとされている。

#  define LONG_MIN    (-LONG_MAX - 1L)

すなわち、PHPのIntegerとFloatとのリテラル上のバグというよりも、浮動小数点のページにも記載されている通り、双方の解釈のしようがあるとも取れるのでどっちかというとバグとは言いづらいというか怪しいということになる模様。

LNUM [0-9]+

浮動小数点のページにもLNUMがありどっちともつかないと。

結論として

明確にリテラルとして指定したい場合にはPHP_INT_MINを使うか、PHP 5系の場合には「-9223372036854775807 - 1」などとしましょう。

$ php -r 'var_dump(-9223372036854775807 - 1);'
int(-9223372036854775808)

参考

教えていただいたり、調べていただいたみなさま、ありがとうございます。