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

PHP 7.1では文字列のオフセットの指定が正しく修正されたけどそれは後方互換性のない修正の模様

PHP 7.1.0RC6のコード。Zend/zend_vm_def.hの該当部分。 PHP 7.0.13のコード。Zend/zend_vm_def.hの該当部分。

PHP Warning: Illegal string offset

PHP 7.0までは、このコードは配列を出してたらしい。

<?php
$array = "";
$array["a"] = "b";
var_dump($array);

出力結果は以下の通り。

array(1) {
  'a' =>
    string(1) "b"
}

しかし、PHP 7.1RC6では以下のようになった。

PHP Warning:  Illegal string offset 'a' in /usr/local/tekitoh/incompatible_code.php on line 3
string(1) "b"

ひとまず、vldモジュールでopcodeを読んでみよう。

$ php -dvld.active=1 -dvld.execute=0 -f ~/docker/incompatible_code.php
Finding entry points
Branch analysis from position: 0
Jump found. Position 1 = -2
filename:       /home/tekitoh/docker/incompatible_code.php
function name:  (null)
number of ops:  11
compiled vars:  !0 = $array
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   EXT_STMT
         1        ASSIGN                                                   !0, ''
   3     2        EXT_STMT
         3        ASSIGN_DIM                                               !0, 'a'
         4        OP_DATA                                                  'b', $2
   4     5        EXT_STMT
         6        EXT_FCALL_BEGIN
         7        SEND_VAR                                                 !0
         8        DO_FCALL                                      1          'var_dump'
         9        EXT_FCALL_END
   5    10      > RETURN                                                   1

branch: #  0; line:     2-    5; sop:     0; eop:    10; out1:  -2
path #1: 0,

で、該当のopcodeは"ASSIGN_DIM"。

PHP 7.0.13(2016/11/20現在、現行バージョン)では、以下のようになっている。php-srcではZend/zend_vm_def.h

ZEND_VM_HANDLER(147, ZEND_ASSIGN_DIM, VAR|CV, CONST|TMPVAR|UNUSED|CV)
{
/* ... */
    } else if (EXPECTED(Z_TYPE_P(object_ptr) == IS_STRING)) {
        if (EXPECTED(Z_STRLEN_P(object_ptr) != 0)) {
        /* ... */
        } else {
           zval_ptr_dtor_nogc(object_ptr);
ZEND_VM_C_LABEL(assign_dim_convert_to_array):
           ZVAL_NEW_ARR(object_ptr);
           zend_hash_init(Z_ARRVAL_P(object_ptr), 8, NULL, ZVAL_PTR_DTOR, 0);
           ZEND_VM_C_GOTO(try_assign_dim_array);
        }

どうやら、代入先の変数の値が""(空文字列)の場合、上記のコードで言うところのZ_STR_LEN_Pが0な(文字列が空文字列の場合である)ときにhash(PHPのarray)として扱われる(初期化し直される)ようだ。

PHP 7.1.0RC6では、きちんとデータ型がどれかによって挙動が変わっている。hashとして初期化し直されるということもなくなっているようだ。php-srcではZend/zend_vm_def.h

ZEND_VM_HANDLER(147, ZEND_ASSIGN_DIM, VAR|CV, CONST|TMPVAR|UNUSED|NEXT|CV, SPEC(OP_DATA=CONST|TMP|VAR|CV))
{
/* ... */
    if (EXPECTED(Z_TYPE_P(object_ptr) == IS_ARRAY)) {
    /* 配列のときのオフセット */
    } else {
    /* ... */
        } else if (EXPECTED(Z_TYPE_P(object_ptr) == IS_STRING)) {
            /* 文字列のときのオフセット */
        }

参考

PHP 5.4 以降では、文字列のオフセットは整数あるいは整数と見なせる文字列に限られるようになりました。 それ以外の場合は警告が発生します。

マニュアルのとおりになった。

どうすればよいか

初期化するときにデータ型を定義しておこうが正しいかなと。変数$hogeが配列なら

$hoge = [];

とか。または

$hoge = null;

とか。

余談:文字列のオフセットについて

文字列のオフセットを指定するときに、文字ではなく文字列を指定するとどうなるのか。

<?php
$hoge = "fuga";
$hoge[0] = "hoge";
?>

この場合の$hoge[0]は、"huga"になる。opcodeを見てみると。

$ php -dvld.active=1 -dvld.execute=0 -f ~/docker/hoge_string.php
Finding entry points
Branch analysis from position: 0
Jump found. Position 1 = -2
filename:       /home/tekitoh/docker/hoge_string.php
function name:  (null)
number of ops:  11
compiled vars:  !0 = $hoge
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   EXT_STMT
         1        ASSIGN                                                   !0, 'fuga'
   3     2        EXT_STMT
         3        ASSIGN_DIM                                               !0, 0
         4        OP_DATA                                                  'hoge', $2
   4     5        EXT_STMT
         6        EXT_FCALL_BEGIN
         7        SEND_VAR                                                 !0
         8        DO_FCALL                                      1          'var_dump'
         9        EXT_FCALL_END
   5    10      > RETURN                                                   1

branch: #  0; line:     2-    5; sop:     0; eop:    10; out1:  -2
path #1: 0,

ASSIGN_DIMの中にあるzend_assign_to_string_offset関数でわかる。

if (Z_TYPE_P(value) != IS_STRING) {
    /* Convert to string, just the time to pick the 1st byte */
    zend_string *tmp = zval_get_string(value);

    string_len = ZSTR_LEN(tmp);
    c = (zend_uchar)ZSTR_VAL(tmp)[0];
    zend_string_release(tmp);
} else {
    string_len = Z_STRLEN_P(value);
    c = (zend_uchar)Z_STRVAL_P(value)[0];
}

文字列の0番目を抜き出しているのがわかる。なので文字列だったとしても0番目のみが抜き出される。

参考

http://php.net/manual/ja/language.types.array.php#language.types.array.syntax.modifying

既に $arr に何らかの値 (リクエスト変数からの文字列など) が入っている場合にはその値がそのまま残り、 [] が実際には 文字列アクセス演算子 を表してしまうからです。

超余談: Zend Engineのopcodeのデバッグについて

Zend/zend_vm_def.h にあるのはあくまでもテンプレートファイルのようなもので、実際には Zend/zend_vm_gen.php で生成している。

run -r '$a = ""; $a["a"] = "hoge";'

gdbでひとまず実行して発見したのが以下。

static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_ASSIGN_DIM_SPEC_CV_CONST_OP_DATA_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)

手元では Zend/zend_vm_execute.h の39100行目だった。元の(Zend/zend_vm_def.h)ファイルではこのようになっていたけど、CONSTだったりTMPVARだったりとPHPコードがその場その場で違えば違う関数で処理されていた。

ZEND_VM_HANDLER(147, ZEND_ASSIGN_DIM, VAR|CV, CONST|TMPVAR|UNUSED|NEXT|CV, SPEC(OP_DATA=CONST|TMP|VAR|CV))

おわりに

PHP 7.1の変更は地味ながら大胆な変更だとおもいました。データ型は型ヒントくらいだと思っていたのでそんなに変更するのかというのは驚きです。

とはいえ、mt_randの修正もされていますから、正しい方向なのかなともおもいました。