y_uti のブログ

統計、機械学習、自然言語処理などに興味を持つエンジニアの技術ブログです

PHP で非配列変数の添字を指定して代入したときの挙動

4/21 (木) に、PHP BLT #4 という勉強会が開催されていました。
phpblt.connpass.com

私は残念ながら都合が合わず参加できなかったのですが、参加された方々のツイートで以下のようなコードが話題になっていました*1

<?php
$var = null;
$var['a'] = 1;
var_dump($var);

このコードを実行すると、var_dump の結果として以下の文字列が出力されます。未定義の変数に対して代入した場合と同様に、$var が null でも、その場で配列が生成されて代入が実行される動作になるという話題でした。

$ php test_null.php
array(1) {
  ["a"]=>
  int(1)
}

整数値などの一般的なプリミティブ型では、このような結果にはなりません。上記のコードで最初の行が $var = 1 だった場合の実行結果は、次のとおりです。スカラー値を配列として使うことはできないと警告が表示され、代入文の実行後も $var の値は 1 のままです。

$ php test_int1.php
PHP Warning:  Cannot use a scalar value as an array in /home/y-uti/test_int1.php on line 3
int(1)

どのような値であれば null のときと同様に配列が生成され、どのような値であれば整数値のときと同様に警告が表示されるのか、いくつかの値で調べてみました*2。結果は以下のとおりです。

結果 警告メッセージなど
null 配列
false 配列
true true PHP Warning: Cannot use a scalar value as an array in ...
0 0 PHP Warning: Cannot use a scalar value as an array in ...
1 1 PHP Warning: Cannot use a scalar value as an array in ...
'' 配列
'abc' '1bc' PHP Warning: Illegal string offset 'a' in ...
new stdClass() 停止 PHP Fatal error: Uncaught Error: Cannot use object of type stdClass as array in ...

結果欄が「配列」となっているものは、null のときと同様に配列が生成されて 'a' キーの値が 1 になります。一方、true, 0, 1 では警告が表示され、代入は実行されません。文字列の 'abc' では、指定されたオフセット 'a' が文字列だという警告が表示されます。この場合は整数へのキャストによって 0 と解釈され、結果として $var[0] に 1 が代入され '1bc' が得られます。オブジェクトの場合も整数などと同じようなメッセージが表示されますが、オブジェクトでは Fatal error となり実行が終了します*3

それでは、このような処理がどこで実装されているのか、PHP の処理系のコードを見てみます。まず、vld を利用してバイトコード命令を出力させてみます。以下のようなバイトコード命令になるようです。これを眺めてみると、添字を指定して代入を行う処理は ASSIGN_DIM 命令だろうと予想できます。

$ php -dvld.active=1 test_null.php
... (省略)
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   ASSIGN                                                   !0, null
   3     1        ASSIGN_DIM                                               !0, 'a'
         2        OP_DATA                                                  1
   4     3        INIT_FCALL                                               'var_dump'
         4        SEND_VAR                                                 !0
         5        DO_ICALL
         6      > RETURN                                                   1

このバイトコード命令の処理は、zend_vm_def.h にあります*4ソースコード中の object_ptr が今回の記事での $var に対応します。

ZEND_VM_HANDLER(147, ZEND_ASSIGN_DIM, VAR|CV, CONST|TMPVAR|UNUSED|CV)
{
	USE_OPLINE
	zend_free_op free_op1;
	zval *object_ptr;
  ...

https://github.com/php/php-src/blob/249a8fd/Zend/zend_vm_def.h#L2150

object_ptr の型によって場合分けされ、それぞれ適切な処理が行われるようです。たとえば、$var が null の場合など、新規に配列を生成する実装は以下の箇所にあります。

		} else if (EXPECTED(Z_TYPE_P(object_ptr) <= IS_FALSE)) {
			if (OP1_TYPE == IS_VAR && UNEXPECTED(object_ptr == &EG(error_zval))) {
				ZEND_VM_C_GOTO(assign_dim_clean);
			}
			ZEND_VM_C_GOTO(assign_dim_convert_to_array);
		} else {

https://github.com/php/php-src/blob/249a8fd/Zend/zend_vm_def.h#L2234-L2239

ここで object_ptr の型が IS_FALSE 以下という比較が行われています。IS_FALSE の定義は zend_types.h にあります。確認してみると以下のようになっていて、未定義の場合、null の場合、false の場合に配列が生成される挙動と確かに一致しています。

/* regular data types */
#define IS_UNDEF					0
#define IS_NULL						1
#define IS_FALSE					2
#define IS_TRUE						3
#define IS_LONG						4
  ...

https://github.com/php/php-src/blob/249a8fd/Zend/zend_types.h#L304

一方、true の場合、整数の 0, 1, ... の場合は警告が表示されていました。これは、上記のコード断片の直後に実装されています。IS_TRUE や IS_LONG は IS_FALSE より大きな値として定義されているので、以下の else 節に処理が流れ、警告が表示されます。

		} else {
			zend_error(E_WARNING, "Cannot use a scalar value as an array");
ZEND_VM_C_LABEL(assign_dim_clean):
  ...

https://github.com/php/php-src/blob/249a8fd/Zend/zend_vm_def.h#L2239-L2249

文字列の場合についても見てみます。文字列に対する処理は以下の箇所から始まります。文字列の長さが 0 かどうかを調べて分岐することがわかります。前半は文字列長が 0 ではないケースです。

		} else if (EXPECTED(Z_TYPE_P(object_ptr) == IS_STRING)) {
			if (EXPECTED(Z_STRLEN_P(object_ptr) != 0)) {
				if (OP2_TYPE == IS_UNUSED) {
					zend_throw_error(NULL, "[] operator not supported for strings");
					FREE_UNFETCHED_OP((opline+1)->op1_type, (opline+1)->op1.var);
  ...

https://github.com/php/php-src/blob/249a8fd/Zend/zend_vm_def.h#L2210

文字列長が 0 の場合の処理は以下にあります。ここで、配列を生成している様子が何となく感じ取れます。なお、assign_dim_convert_to_array というラベルが定義されていますが、IS_FALSE と比較していた箇所の実装を見てみると、このラベルに GOTO する実装になっており、空文字の場合に null や false の場合と同じ処理が行われることをソースコードから確認できます。

			} 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);
			}

https://github.com/php/php-src/blob/249a8fd/Zend/zend_vm_def.h#L2227-L2233

さて、PHP の文字列では、文字列長を超えたオフセットを指定して代入すると、指定されたオフセットまでが空白文字で埋められることになっています。

警告
範囲外のオフセットに書き込んだ場合は、空いた部分に空白文字が埋められます。 整数型以外の型は整数型に変換されます。 無効なオフセット形式を指定した場合は E_NOTICE を発行します。 負のオフセットを指定した場合は、書き込み時は E_NOTICE を発行しますが読み込み時は空の文字列を返します。 文字列を代入した場合は最初の文字だけを使用します。 空文字列を代入した場合は NULL バイトを代入します。

http://php.net/manual/ja/language.types.string.php

コード例を以下に示します。長さ 3 の文字列を作成し、オフセット 5 を指定して 'f' を代入しています。

<?php
$str = 'abc';
$str[5] = 'f';
var_dump($str);

これを実行すると以下の結果が得られます。PHP のマニュアルに記載されているとおり、空いた部分が空白文字で埋められています。

$ php test_str.php
string(6) "abc  f"

ところが、ここまで処理系の実装を見てきたように、空文字の場合はこれが成り立ちません。

<?php
$str = '';
$str[5] = 'f';
var_dump($str);

これを実行すると、以下のように配列が得られます。あまり積極的に使うような機能ではないとも思いますが、この挙動には注意が必要そうです。

$ php test_str_empty.php
array(1) {
  [5]=>
  string(1) "f"
}

最後に、空文字列のこの挙動について、空でない文字列と同様の動作になるようにソースコードを変更してみます。単純に、文字列長のチェックを行っていた部分を除去してみました。ただし、先ほど述べたように GOTO で飛び込んでくる箇所があるため、該当部分を本当に削除してしまうとコンパイルエラーになります。そのため、以下のように条件分岐が恒真になるように書き換えました。

$ diff Zend/zend_vm_def.h.orig Zend/zend_vm_def.h
2211c2211
<                       if (EXPECTED(Z_STRLEN_P(object_ptr) != 0)) {
---
>                       if (1) {

zend_vm_def.h を書き換えた後、以下のように zend_vm_gen.php スクリプトを実行してファイルを生成します。ファイル生成後に make すれば変更が反映された PHP がビルドされます。

$ php Zend/zend_vm_gen.php
zend_vm_opcodes.h generated successfully.
zend_vm_opcodes.c generated successfully.
zend_vm_execute.h generated successfully.
$ make

ビルドされた PHP を使って実行すると、空文字列の場合についても配列が生成されず、空白文字で埋められた文字列が得られるようになりました。

$ sapi/cli/php test_str_empty.php
string(6) "     f"

*1:コードの意図を変えない範囲で、変数名などは変更しています。

*2:作成したプログラムは zend-assign-dim-test.php · GitHub にあります。

*3:なお、ArrayAccess インタフェースを実装したクラスであれば、エラーにならず offsetSet メソッドによって処理されます。

*4:この記事では PHP 7.0.5 の commit を参照しています。異なるバージョンでは実装も変わっている可能性があります。