PHP の echo と print の違い (闇)
この記事は、闇PHP Advent Calendar 2015 の 7 日目として書いたものです。qiita.com
PHP 7.0.0 がリリースされたので改めてオペコードを眺めていたところ、次のように欠番があることに気付きました。
#define ZEND_ASSIGN_REF 39 #define ZEND_ECHO 40 #define ZEND_JMP 42 #define ZEND_JMPZ 43
php-src/zend_vm_opcodes.h at php-7.0.0 · php/php-src · GitHub
PHP 5 系の最新バージョンである 5.6.16 のコードを参照してみたところ、ここには ZEND_PRINT という命令があったようです*1。直前の 40 番には ZEND_ECHO があります。そこで今回は ZEND_ECHO, ZEND_PRINT に注目しながら PHP のコンパイラの実装を追ってみます。
#define ZEND_ASSIGN_REF 39 #define ZEND_ECHO 40 #define ZEND_PRINT 41 #define ZEND_JMP 42 #define ZEND_JMPZ 43
php-src/zend_vm_opcodes.h at PHP-5.6.16 · php/php-src · GitHub
PHP の echo と print
はじめに、PHP の echo と print は何が違うのかを簡単におさらいしておきます。echo と print は、どちらも文字列を出力する命令ですが、次のような違いがあります。
echo
- 文 (statement) である。したがって評価結果は値を持たない
- カンマで区切って複数の文字列を出力できる
- 式 (expression) である。評価結果は常に 1 である
- echo とは違い複数の文字列を出力することはできない
文と式の違いにより、次のような結果になります。先頭の $e への代入は構文エラーです。echo は文なので代入の右辺に置くことはできません。それ以外の各行はすべて正しい PHP プログラムです。
<?php $e = echo "Hello, world!\n"; // 構文エラー $p = print "Hello, world!\n"; // 正しい PHP プログラム。$p の値は 1 になる echo "Hello, world!\n"; // 正しい PHP プログラム。 print "Hello, world!\n"; // 正しい PHP プログラム。評価結果の 1 は捨てられる
PHP のパーザを参照して、具体的な実装箇所を確認します。PHP 5.6.16 の zend_language_parser.y では、echo は次のように unticked_statement の構文規則として定義されています。
unticked_statement: ... | T_ECHO echo_expr_list ';' ...
php-src/zend_language_parser.y at php-5.6.16 · php/php-src · GitHub
一方、print は次のとおり expr_without_variable の箇所にあります。
expr_without_variable: ... | T_PRINT expr { zend_do_print(&$$, &$2 TSRMLS_CC); } ...
php-src/zend_language_parser.y at php-5.6.16 · php/php-src · GitHub
PHP 5.6.16 での echo と print の実装
PHP 5.6.16 では、echo と print は ZEND_ECHO 命令、ZEND_PRINT 命令にそれぞれコンパイルされます。PHP 5 のコンパイラは抽象構文木を作らないので、zend_language_parser.y のアクション部から zend_compile.c の zend_do_echo, zend_do_print が呼ばれ、それぞれバイトコード命令を出力する実装になっています。この部分の実装は次のとおりです。
void zend_do_print(znode *result, const znode *arg TSRMLS_DC) /* {{{ */ { zend_op *opline = get_next_op(CG(active_op_array) TSRMLS_CC); opline->result_type = IS_TMP_VAR; opline->result.var = get_temporary_variable(CG(active_op_array)); opline->opcode = ZEND_PRINT; SET_NODE(opline->op1, arg); SET_UNUSED(opline->op2); GET_NODE(result, opline->result); } /* }}} */ void zend_do_echo(const znode *arg TSRMLS_DC) /* {{{ */ { zend_op *opline = get_next_op(CG(active_op_array) TSRMLS_CC); opline->opcode = ZEND_ECHO; SET_NODE(opline->op1, arg); SET_UNUSED(opline->op2); } /* }}} */
php-src/zend_compile.c at php-5.6.16 · php/php-src · GitHub
プログラム実行時に各バイトコード命令がどのように処理されるかは、zend_vm_def.h に書かれています。ZEND_ECHO と ZEND_PRINT については以下のとおりです。PHP 5.6.16 でも実行時の処理は共通化されており、ZEND_PRINT 命令は、評価結果に 1 を設定して ZEND_ECHO 命令を実行しているということがわかります。
ZEND_VM_HANDLER(40, ZEND_ECHO, CONST|TMP|VAR|CV, ANY) { USE_OPLINE zend_free_op free_op1; zval *z; SAVE_OPLINE(); z = GET_OP1_ZVAL_PTR(BP_VAR_R); if (OP1_TYPE == IS_TMP_VAR && Z_TYPE_P(z) == IS_OBJECT) { INIT_PZVAL(z); } zend_print_variable(z); FREE_OP1(); CHECK_EXCEPTION(); ZEND_VM_NEXT_OPCODE(); } ZEND_VM_HANDLER(41, ZEND_PRINT, CONST|TMP|VAR|CV, ANY) { USE_OPLINE ZVAL_LONG(&EX_T(opline->result.var).tmp_var, 1); ZEND_VM_DISPATCH_TO_HANDLER(ZEND_ECHO); }
PHP 7.0.0 での print の実装
それでは、同様に PHP 7.0.0 の実装を見てみます。PHP 7 のコンパイラは抽象構文木を作るので、zend_language_parser.y では zend_ast 構造体を作り、zend_compile.c は zend_ast 構造体を解析してバイトコード命令を出力するという二段階の処理になっています。zend_language_parser.y の該当箇所は次のとおりです。echo と print は、それぞれ ZEND_AST_ECHO, ZEND_AST_PRINT という種類 (zend_ast->kind) を持つノードに変換されます。
statement: ... | T_ECHO echo_expr_list ';' { $$ = $2; } ...
php-src/zend_language_parser.y at php-7.0.0 · php/php-src · GitHub
expr_without_variable: ... | T_PRINT expr { $$ = zend_ast_create(ZEND_AST_PRINT, $2); } ...
php-src/zend_language_parser.y at php-7.0.0 · php/php-src · GitHub
zend_compile.c が抽象構文木からバイトコード命令を出力する処理は、次のように実装されています。ZEND_AST_ECHO の場合は、zend_compile_stmt 関数の switch 文で構文木のノードの種類が判定され、zend_compile_echo 関数を呼び出します。
void zend_compile_stmt(zend_ast *ast) /* {{{ */ { ... case ZEND_AST_ECHO: zend_compile_echo(ast); break; ...
php-src/zend_compile.c at php-7.0.0 · php/php-src · GitHub
zend_compile_echo 関数の実装は次のとおりです。ここで ZEND_ECHO バイトコード命令を出力している様子がわかります。
void zend_compile_echo(zend_ast *ast) /* {{{ */ { zend_op *opline; zend_ast *expr_ast = ast->child[0]; znode expr_node; zend_compile_expr(&expr_node, expr_ast); opline = zend_emit_op(NULL, ZEND_ECHO, &expr_node, NULL); opline->extended_value = 0; } /* }}} */
php-src/zend_compile.c at php-7.0.0 · php/php-src · GitHub
ZEND_AST_PRINT の場合を見てみます。print は式なので、zend_compile_expr 関数の switch 文でノードの種類が判定され、zend_compile_print 関数が呼び出されます。
void zend_compile_expr(znode *result, zend_ast *ast) /* {{{ */ { ... case ZEND_AST_PRINT: zend_compile_print(result, ast); return; ...
php-src/zend_compile.c at php-7.0.0 · php/php-src · GitHub
zend_compile_print 関数の実装は次のとおりです。PHP 7.0.0 では、ここで ZEND_ECHO 命令を出力していることに注目してください。これによって PHP 7.0.0 では、元の PHP のソースコードに print が書かれていても実行時には ZEND_ECHO バイトコード命令と同じ処理が実行されることになります。末尾の 2 行では、print の評価結果として定数 1 を設定しています*2。
void zend_compile_print(znode *result, zend_ast *ast) /* {{{ */ { zend_op *opline; zend_ast *expr_ast = ast->child[0]; znode expr_node; zend_compile_expr(&expr_node, expr_ast); opline = zend_emit_op(NULL, ZEND_ECHO, &expr_node, NULL); opline->extended_value = 1; result->op_type = IS_CONST; ZVAL_LONG(&result->u.constant, 1); } /* }}} */
バイトコード命令を確認する
さて、PHP 5.6.16 と PHP 7.0.0 の実装を見てきたので、次のプログラムからどのようなバイトコード命令が出力されるのか実際に確認してみます*3。
<?php $p = print "Hello, world!\n";
PHP 5.6.16 では次のようになります。PRINT 命令を実行して評価結果を ~0 に格納し、ASSIGN 命令で $result に代入しています。
$ php -dvld.active=1 -dvld.execute=0 test.php ... compiled vars: !0 = $p line #* E I O op fetch ext return operands ------------------------------------------------------------------------------------- 2 0 E > PRINT ~0 'Hello%2C+world%21%0A' 1 ASSIGN !0, ~0 3 2 > RETURN 1
一方、PHP 7.0.0 では次のようになります。文字列の出力には ECHO 命令が利用され、ASSIGN 命令では定数 1 を $result に代入しています。
$ php -dvld.active=1 -dvld.execute=0 test.php ... compiled vars: !0 = $p line #* E I O op fetch ext return operands ------------------------------------------------------------------------------------- 2 0 E > ECHO 'Hello%2C+world%21%0A' 1 ASSIGN !0, 1 2 > RETURN 1
先ほどソースコードで見たように、zend_compile_print 関数で result に定数の 1 を設定していることにより、ASSIGN 命令のオペランドが定数になっています。もし print の評価結果が何かの条件で 0, 1 になるような仕様だったとしたら、その値はコンパイル時には決まらないので、PHP 5.6.16 のように仮想機械のレジスタを使って渡す必要がありました。print の評価結果が常に 1 という仕様だったためにオペランドを定数に置き換えられ、その結果として ECHO 命令を共通に利用するようにリファクタリングできたのですね。
OPcache での最適化
ここまで PHP 5.6.16 と PHP 7.0.0 のコンパイラの実装を比較してきましたが、実は PHP 5.6.16 でも、OPcache が有効になっていれば ZEND_PRINT 命令は ZEND_ECHO 命令に変換され、PHP 7.0.0 と同じバイトコード命令が実行されます。先ほどと同じプログラムで、OPcache を有効にしてバイトコード命令列を確認します。
$ php -dopcache.enable_cli=1 -dvld.active=1 -dvld.execute=0 test.php ... compiled vars: !0 = $p line #* E I O op fetch ext return operands ------------------------------------------------------------------------------------- 2 0 E > ECHO 'Hello%2C+world%21%0A' 1 ASSIGN !0, 1 3 2 > RETURN 1
OPcache によって ZEND_PRINT 命令が ZEND_ECHO 命令に変換される過程を簡単に追ってみます。この最適化は ext/opcache/Optimizer/block_pass.c に実装されています。実装箇所はいくつかに分かれていますが、今回は上記のコード例に関係する部分に限定して見ていきます。まず、以下の処理で、ZEND_PRINT の結果をオペランドとして利用する箇所を定数の 1 に置き換えます。この処理で、ASSIGN 命令の第二オペランドが ~0 から 1 に変換されることになります。この段階では、ZEND_PRINT 命令自体は特に変換されず、そのまま残っていることに注意してください。
... if (ZEND_OP2_TYPE(opline) == IS_TMP_VAR && VAR_SOURCE(opline->op2) && VAR_SOURCE(opline->op2)->opcode == ZEND_PRINT) { ZEND_OP2_TYPE(opline) = IS_CONST; LITERAL_LONG(opline->op2, 1); } ...
php-src/block_pass.c at php-5.6.16 · php/php-src · GitHub
さて、この変換の結果、ZEND_PRINT の結果である ~0 はどこでも利用されなくなります。以下の処理でそのような命令が発見され*4、ZEND_PRINT であれば ZEND_ECHO に変換されます。これで、PHP 5.6.16 でも PHP 7.0.0 と同じバイトコード命令列に変換されました。
... while (opline >= block->start_opline) { /* usage checks */ if (RES_NEVER_USED(opline)) { switch (opline->opcode) { ... case ZEND_PRINT: opline->opcode = ZEND_ECHO; ZEND_RESULT_TYPE(opline) = IS_UNUSED; break; ...
php-src/block_pass.c at php-5.6.16 · php/php-src · GitHub
なお、PHP 7.0.0 では OPcache の有効無効に関わらずコンパイラがこの最適化を行うので、ここで見てきた実装は block_pass.c から削除されています。
おわりに
今回の記事では、PHP 7.0.0 で ZEND_PRINT 命令が無くなっていることに注目して、PHP 5.6.16 と PHP 7.0.0 のコンパイラの実装を比較してみました。
PHP 7.0.0 のオペコードを眺めてみると、ZEND_PRINT のほかにも欠番になっている命令があることがわかります。たとえば 100 番の命令も欠番になっています。私達は 10 進数の世界に生きているので、100 番が欠番というのは目立ちますね。
#define ZEND_FETCH_LIST 98 #define ZEND_FETCH_CONSTANT 99 #define ZEND_EXT_STMT 101 #define ZEND_EXT_FCALL_BEGIN 102
php-src/zend_vm_opcodes.h at php-7.0.0 · php/php-src · GitHub
PHP 5.6.16 を見てみると、どうやら 100 番には ZEND_GOTO という命令があったようです。こちらも調べてみると面白いかもしれません。
*1:https://github.com/php/php-src/commit/0f815642 のコミットで削除されています。
*2:なお、その直前で extended_value を設定していますが、これは、元が echo だったのか print だったのかを実行時に区別できるように設定しているようです (https://github.com/php/php-src/commit/df2ff75, https://github.com/php/php-src/commit/45cb42f)。ただし、ざっと確認した限りでは、この区別を利用している処理は今のところは特になさそうです。
*3:確認には vld 拡張モジュールを利用しています。モジュールの導入方法などは今回の記事では説明を省略します。
*4:これを発見するためには、バイトコード命令列の解析が必要です。今回はそこまでソースコードを追うことはできませんでした。