Shin x Blog

PHPをメインにWebシステムを開発してます。Webシステム開発チームの技術サポートも行っています。

PHP に新しい演算子を実装するチュートリアル

PHP に新しい演算子を実装するチュートリアルの紹介です。

www.sitepoint.com

新たなトークン、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.hZEND_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() のエラーメッセージ表示で利用されています*1ZEND_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 の内部実装に興味がある方にはおすすめです。

このチュートリアルには改良版の実装を行う続編もあるので興味がある方はこちらもどうぞ。

www.sitepoint.com

docker compose で php-src から PHP (php コマンド) をビルドできる環境を用意しています。環境を準備するのが面倒という方は参考まで。

github.com

*1:オペコードのプリティプリント機能もあって、これは OPcache のオペコードダンプで利用されています。zend_dump_op_array() 参照。