PHP に新しい演算子を実装するチュートリアルの紹介です。
新たなトークン、AST ノード、オペコードを実装するための修正ポイントや、PHP コードからオペコード実行までの実装を(部分的ですが)手軽に知ることできる良い内容でした。
ここでは PHP 8.3.9 をベースに手順をなぞっていきます。変更箇所の詳細は元エントリに解説があるので、そちらを参考にしてください。
実装仕様
PHP 8 には存在しない|>
演算子を実装します。
この演算子は 1 |> 3
のように利用し、指定した開始値(1
)から終了値(3
)までの連続した値を持つ配列を生成します([1, 2, 3]
)。
- 2 つのオペランドを取る。値は int のみ。
- 左オペランドから右オペランドまでの連続した値を配列で返す。
- 増分を 1 に固定した range() のような挙動。
この演算子を実装すると、下記のように動作します。
$ php -r 'var_dump(1 |> 3);' array(3) { [0]=> int(1) [1]=> int(2) [2]=> int(3) }
なお、ここでは下記の箇所を元エントリから変更しています。
- 元エントリは PHP 7 の実装なので、PHP 8 で動くよう対応。
- 実装を簡略化するために、オペランドに float は扱わない。
PHP コードの実行
PHP コードが実行される流れは下記のようになります。それぞれのポイントにおいて |>
演算子の処理を追加していきます。
- Scan: PHP コードからトークン列を生成。
- Parse: トークン列から AST 生成。
- Compile: AST からオペコード列を生成。
- Execute: オペコード列を実行。
Scanner の変更
PHP コードに記載された |>
演算子を T_RANGE
というトークンに変換します。T_RANGE
も存在しないトークンなので、これも追加します。
変更ファイル
Zend/zend_language_scanner.l
Zend/zend_language_parser.y
Zend/zend_language_scanner.l
PHP コードに |>
を発見したら、T_RANGE
トークンを返すようにしています。
@@ -3107,6 +3107,10 @@ nowdoc_scan_done: RETURN_TOKEN(T_BAD_CHARACTER); } + +<ST_IN_SCRIPTING>"|>" { + RETURN_TOKEN(T_RANGE); +} */
Zend/zend_language_parser.y
T_RANGE
トークンを宣言します。
@@ -232,6 +232,7 @@ static YYSIZE_T zend_yytnamerr(char*, const char*); %token T_COALESCE "'??'" %token T_POW "'**'" %token T_POW_EQUAL "'**='" +%token T_RANGE "|>"
ext/tokenizer/tokenizer_data.c を再生成する必要があるので、下記コマンドを実行します。
$ php ext/tokenizer/tokenizer_data_gen.php Wrote /path/to/ext/tokenizer/tokenizer_data.stub.php Wrote /path/to/ext/tokenizer/tokenizer_data.c
ext/tokenizer/tokenizer_data.c に T_RANGE
が含まれていれば ok です。
case T_RANGE: return "T_RANGE";
動作確認
この時点で PHP をビルドすると |>
から T_RANGE
トークンが生成できることが確認できます。ビルド後、下記コードを実行すると T_RANGE
が出力されます。
$ php -r "var_dump(token_name(token_get_all('<?php 1|>2;')[2][0]));" string(7) "T_RANGE"
Parser の変更
T_RANGE
トークンから AST ノード ZEND_AST_RANGE
を生成するためのコードを追加します。
- 変更ファイル
- Zend/zend_language_parser.y
- Zend/zend_ast.h
Zend/zend_language_parser.y
T_RANGE の結合規則とパース処理を追加しています。T_RANGE
トークンは zend_ast_create() で ZEND_AST_RANGE
ノードを生成します。
@@ -69,7 +69,7 @@ static YYSIZE_T zend_yytnamerr(char*, const char*); %left '|' %left '^' %left T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG -%nonassoc T_IS_EQUAL T_IS_NOT_EQUAL T_IS_IDENTICAL T_IS_NOT_IDENTICAL T_SPACESHIP +%nonassoc T_IS_EQUAL T_IS_NOT_EQUAL T_IS_IDENTICAL T_IS_NOT_IDENTICAL T_SPACESHIP T_RANGE %nonassoc '<' T_IS_SMALLER_OR_EQUAL '>' T_IS_GREATER_OR_EQUAL %left '.' %left T_SL T_SR @@ -1219,6 +1220,8 @@ expr: { $$ = zend_ast_create(ZEND_AST_GREATER_EQUAL, $1, $3); } | expr T_SPACESHIP expr { $$ = zend_ast_create_binary_op(ZEND_SPACESHIP, $1, $3); } + | expr T_RANGE expr + { $$ = zend_ast_create(ZEND_AST_RANGE, $1, $3); } | expr T_INSTANCEOF class_name_reference { $$ = zend_ast_create(ZEND_AST_INSTANCEOF, $1, $3); } | '(' expr ')' {
Zend/zend_ast.h
ZEND_AST_RANGE
ノードタイプを追加します。
@@ -130,6 +130,7 @@ enum _zend_ast_kind { ZEND_AST_YIELD, ZEND_AST_COALESCE, ZEND_AST_ASSIGN_COALESCE, + ZEND_AST_RANGE, ZEND_AST_STATIC, ZEND_AST_WHILE,
Compiler の変更
ZEND_AST_RANGE
ノードからオペコードを生成できるようにコンパイル処理にコードを追加します。
- 変更ファイル
- Zend/zend_compile.c
Zend/zend_compile.c
コンパイル時に ZEND_AST_RANGE
ノードを見つけた場合、zend_compile_range() を実行します。この関数では ZEND_RANGE
オペコードを生成します。この時点で ZEND_RANGE
オペコードは定義していませんが、この後の Executor での変更で追加します。
@@ -10257,6 +10257,19 @@ static void zend_compile_const_expr_args(zend_ast **ast_ptr) } } +static void zend_compile_range(znode *result, zend_ast *ast) /* {{{ */ +{ + zend_ast *left_ast = ast->child[0]; + zend_ast *right_ast = ast->child[1]; + znode left_node, right_node; + + zend_compile_expr(&left_node, left_ast); + zend_compile_expr(&right_node, right_ast); + + zend_emit_op_tmp(result, ZEND_RANGE, &left_node, &right_node); +} +/* }}} */ + typedef struct { /* Whether the value of this expression may differ on each evaluation. */ bool allow_dynamic; @@ -10609,6 +10622,9 @@ static void zend_compile_expr_inner(znode *result, zend_ast *ast) /* {{{ */ case ZEND_AST_MATCH: zend_compile_match(result, ast); return; + case ZEND_AST_RANGE: + zend_compile_range(result, ast); + return; default: ZEND_ASSERT(0 /* not supported */); }
Executor の変更
ZEND_EXECUTOR
オペコードを実行するコードを追加します。これで、PHP コードの |>
演算子が動作するようになります。
- 変更ファイル
- Zend/zend_vm_def.h
Zend/zend_vm_def.h
ZEND_RANGE
オペコードのハンドラを実装するにあたって、対応するオペコード番号を指定する必要があります。存在しない番号を指定する必要があるので、Zend/zend_vm_opcodes.h
で宣言されている ZEND_VM_LAST_OPCODE の値に 1 を加算した値を利用します。
下記では ZEND_VM_LAST_OPCODE は 203 なので、これを加算した 204 を ZEND_RANGE
のオペコード番号とします。
// Zend/zend_vm_opcodes.h #define ZEND_VM_LAST_OPCODE 203
Zend/zend_vm_def.h
に ZEND_RANGE
オペコードを処理するハンドラを追加します。204 の箇所が上記で定めたオペコード番号です。
@@ -10140,3 +10140,57 @@ ZEND_VM_HELPER(zend_interrupt_helper, ANY, ANY) } ZEND_VM_CONTINUE(); } + +ZEND_VM_HANDLER(204, ZEND_RANGE, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV) +{ + USE_OPLINE + zval *op1, *op2, *result, tmp; + + SAVE_OPLINE(); + op1 = GET_OP1_ZVAL_PTR_DEREF(BP_VAR_R); + op2 = GET_OP2_ZVAL_PTR_DEREF(BP_VAR_R); + result = EX_VAR(opline->result.var); + + if (Z_TYPE_P(op1) == IS_LONG && Z_TYPE_P(op2) == IS_LONG) { + // 演算子処理メイン + zend_long min = Z_LVAL_P(op1), max = Z_LVAL_P(op2); + zend_ulong size, i; + + if (min > max) { + zend_throw_error(NULL, "Min should be less than (or equal to) max"); + HANDLE_EXCEPTION(); + } + + size = max - min; + + if (size >= HT_MAX_SIZE - 1) { + zend_throw_error(NULL, "Range size is too large"); + HANDLE_EXCEPTION(); + } + + ++size; + + Z_TYPE_INFO(tmp) = IS_LONG; + + array_init_size(result, size); + zend_hash_real_init(Z_ARRVAL_P(result), 1); + ZEND_HASH_FILL_PACKED(Z_ARRVAL_P(result)) { + for (i = 0; i < size; ++i) { + Z_LVAL(tmp) = min + i; + ZEND_HASH_FILL_ADD(&tmp); + } + } ZEND_HASH_FILL_END(); + ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION(); + } else { + // 引数が int(に変換できる値)以外ならエラー + zend_throw_error(NULL, "Unsupported operand types - only ints and floats are supported"); + FREE_OP1(); + FREE_OP2(); + HANDLE_EXCEPTION(); + } + + FREE_OP1(); + FREE_OP2(); + ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION(); +}
Zend/zend_vm_def.h
は PHP ビルドで直接利用されているファイルではなく、いくつかの .h や .c ファイルを生成するための元ファイルです。Zend/zend_vm_def.h
を変更した場合は、下記コマンドで反映します。
$ php Zend/zend_vm_gen.php zend_vm_opcodes.h generated successfully. zend_vm_opcodes.c generated successfully. zend_vm_execute.h generated successfully.
動作確認
ソースの変更は完了したので PHP をビルドしましょう。これで |>
演算子を実行できる状態になりました。
下記のように PHP コードを実行すると |>
演算子が動作します!不正な引数が与えられた場合は想定どおりエラーが発生するようになっています。
$ php -r 'var_dump(1 |> 3);' array(3) { [0]=> int(1) [1]=> int(2) [2]=> int(3) } $ php -r 'var_dump(1 |> 3.1);' Fatal error: Uncaught Error: Unsupported operand types - only ints and floats are supported in Command line code:1 Stack trace: #0 {main} thrown in Command line code on line 1 $ php -r 'var_dump(new stdClass |> 10);' Fatal error: Uncaught Error: Unsupported operand types - only ints are supported in Command line code:1 Stack trace: #0 {main} thrown in Command line code on line 1 $ php -r 'var_dump(4 |> 3);' Fatal error: Uncaught Error: Min should be less than (or equal to) max in Command line code:1 Stack trace: #0 {main} thrown in Command line code on line 1
|>
演算子を含む PHP コードに対する オペコードは下記のようになります。
<?php var_dump(1 |> 3);
オペコード
0000 INIT_FCALL 1 96 string("var_dump") 0001 T0 = RANGE int(1) int(3) // |> 演算子 0002 SEND_VAL T0 1 0003 DO_ICALL 0004 RETURN int(1)
AST のプリティプリント
PHP には AST を文字列表現に戻す機能があります。これは assert() のエラーメッセージ表示で利用されています*1。ZEND_AST_RANGE
ノードのプリティプリント対応を追加しておきます。
- 変更ファイル
- Zend/zend_ast.c
Zend/zend_ast.c
ZEND_AST_RANGE
ノードの文字列表現を追加しています。
@@ -2175,6 +2175,7 @@ static ZEND_COLD void zend_ast_export_ex(smart_str *str, zend_ast *ast, int prio EMPTY_SWITCH_DEFAULT_CASE(); } break; + case ZEND_AST_RANGE: BINARY_OP(" |> ", 170, 171, 171); case ZEND_AST_GREATER: BINARY_OP(" > ", 180, 181, 181); case ZEND_AST_GREATER_EQUAL: BINARY_OP(" >= ", 180, 181, 181); case ZEND_AST_AND: BINARY_OP(" && ", 130, 130, 131);
動作確認
対応前は zend_ast_export_ex エラーとなっていましたが、対応後はエラーメッセージに |>
演算子のコードが出力されています。
# 対応前 $ php -r 'assert(false && 1 |> 3);' php: /php-src/Zend/zend_ast.c:2504: zend_ast_export_ex: Assertion `0' failed. Aborted # 対応後 $ php -r 'assert(false && 1 |> 3);' Fatal error: Uncaught AssertionError: assert(false && 1 |> 3) in Command line code:1 Stack trace: #0 Command line code(1): assert(false, 'assert(false &&...') #1 {main} thrown in Command line code on line 1
ast 拡張
ast 拡張の util.php で AST をダンプすると下記のようにエラーとなります。
Fatal error: Uncaught LogicException: Unknown kind 532 in /php-src/php/lib/php/doc/ast/util.php:59 Stack trace: #0 /php-src/php/lib/php/doc/ast/util.php(59): ast\get_kind_name(532) #1 /php-src/php/lib/php/doc/ast/util.php(75): ast_dump(Object(ast\Node), 0) #2 /php-src/php/lib/php/doc/ast/util.php(75): ast_dump(Object(ast\Node), 0) #3 /php-src/php/lib/php/doc/ast/util.php(75): ast_dump(Object(ast\Node), 0) #4 Command line code(1): ast_dump(Object(ast\Node)) #5 {main} thrown in /php-src/php/lib/php/doc/ast/util.php on line 59 make: *** [dump-ast] Error 255
これは AST ノード名の文字列表現を ast 拡張内に保持しているため、ZEND_AST_RANGE
のコードが無いためです。下記あたりにコードを追加すれば対応できそうです。
https://github.com/nikic/php-ast/blob/4c5efd5dbf139b9ac80b1df151b89112bb5bd779/ast_data.c#L225
+ case ZEND_AST_RANGE: return "AST_RANGE";
さいごに
PHP に新しい演算子を追加するチュートリアルを PHP 8 環境で実施してみました。やはり、コードを読むだけでなく自分でコードを変更して、動作確認をしてと繰り返すことでより挙動がイメージできて良いですね。PHP の内部実装に興味がある方にはおすすめです。
このチュートリアルには改良版の実装を行う続編もあるので興味がある方はこちらもどうぞ。
docker compose で php-src から PHP (php コマンド) をビルドできる環境を用意しています。環境を準備するのが面倒という方は参考まで。
*1:オペコードのプリティプリント機能もあって、これは OPcache のオペコードダンプで利用されています。zend_dump_op_array() 参照。