y_uti のブログ

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

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) である。したがって評価結果は値を持たない
  • カンマで区切って複数の文字列を出力できる

print

  • 式 (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-src/zend_vm_def.h at php-5.6.16 · php/php-src · GitHub

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-src/zend_compile.c at php-7.0.0 · php/php-src · GitHub

バイトコード命令を確認する

さて、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:これを発見するためには、バイトコード命令列の解析が必要です。今回はそこまでソースコードを追うことはできませんでした。