こんにちは。やなせたかしです。
今回は「繰り返し」について掘り下げてみようと思います。
PHPに限ったことではないですが、繰り返しはプログラミングでは基本的な操作です。たとえば、
- while
- for
など、処理を繰り返す構文です。その中でも利用頻度が高いのはfor
でしょうか?サンプルコードでも配列を繰り返す時に使われたりと目にすることが多いと思います。
PHPであれば、 foreach
という構文もあります。これも繰り返しのようです。
この中でも、for
とforeach
どちらを使うのか?が議論されたり、どちらのほうが性能がいいだの悪いだの、過去にも比較されてきました。2020年の現在、(あくまでPHP上での)両者にどんな差があるのでしょうか。
forを眺める
以下のコードを例に考えてみます。何の変哲もないPHPコードです。
<?php $a = [1,2,3]; $b = 0; for($i = 0; $i < count($a); $i++){ $b += $a[$i]; }
配列の和を計算しています。この for の終了条件は$i < count($a)
です。配列の大きさまで$i
が増加すると終了します。
とくに何かあるわけではないコードのようにも見えますが、よく見るとコードの関心ごとがバラバラなことに気づきます。
たとえば、
$i
はただの変数で、配列そのものとの関係はない- ループ内でアクセスしている配列が
$a
であるかはループの条件に絡まない - 和をとる配列は
$a
でなくても$aa
にしても構わない
そう、for
はただのループで、「配列の操作」とは関係がないのです。
この「関係がない」というのは、コードを見てもわかることですが、どこまで関係がないのか?
改めてfor
を振り返ってみましょう。
まず、for
という構文について。
さきほども書いた通り、ループを表現する構文です。では構文としてはどのようなルールになっているでしょうか?
ルールを正確に知るため、PHPのパーザーを呼んで文法そのものを見てみましょう。PHPのコードを理解している部分ですから、正確な文法がわかるはずです。
PHPそのもののソースは、以下から手に入ります。
https://github.com/php/php-src
ということでPHPのパーザーでは、for
の構文は以下のように定義されています。
%token T_FOR "for (T_FOR)" statement: | T_FOR '(' for_exprs ';' for_exprs ';' for_exprs ')' for_statement { $$ = zend_ast_create(ZEND_AST_FOR, $3, $5, $7, $9); };
for()
の中にfor_exprs
というのが並んでいるのがわかります。そして、最後にfor_statement
というものがあります。
このfor_exprs
というものは何を表しているでしょうか。これは何もない(NULLと表します)か、カンマ区切りのexpr
のリストのようです。
ちなみに、expr
は式を表します。メソッド呼び出しのほか、$a
だけでも式ですし、$i = 0
なども式にあたります。
そしてzend_ast_create
というものを呼び出しています。ここでZEND_AST_FOR
というものを渡しているので、ここでfor文
を作っているようです。
$3,$5...
はこのパーザーにマッチした要素の位置っぽいですね。正規表現のマッチっぽくも見えます。
さらに追っていくと、PHPのfor
の定義が見えてきます。
void zend_compile_for(zend_ast *ast) { zend_ast *init_ast = ast->child[0]; // ← 1つ目のfor_exprs zend_ast *cond_ast = ast->child[1]; // ← 2つ目のfor_exprs zend_ast *loop_ast = ast->child[2]; // ← 3つ目のfor_exprs zend_ast *stmt_ast = ast->child[3]; // ← for_statement //.... }
ということで、パーザーはfor
をこんな風に理解しています。
<?php for(開始状態の式; 終了条件の式; ループ式) for内部の文
となります。当たり前のことですが、調べてみるとしっかり書かれているものです。では、for
の命令を確認してみましょう。
以下のソースのopcodeをダンプします。 opcodeとは、PHPで書かれたコードをコンパイルして得られるもので、Zendエンジンで直接実行されます。
<?php $a = [1, 2, 3]; $b = 0; for ($i = 0; $v = count($a); $i++) { $b += $a[$i]; }
すると、以下のようなダンプが出力されます。
L0 (3): EXT_STMT L1 (3): ASSIGN CV0($a) array(...) L2 (4): EXT_STMT L3 (4): ASSIGN CV1($b) int(0) L4 (6): EXT_STMT L5 (6): ASSIGN CV2($i) int(0) L6 (6): JMP L12 L7 (7): EXT_STMT L8 (7): T6 = FETCH_DIM_R CV0($a) CV2($i) L9 (7): ASSIGN_ADD CV1($b) T6 L10 (6): T8 = POST_INC CV2($i) L11 (6): FREE T8 L12 (6): T9 = COUNT CV0($a) L13 (6): T10 = IS_SMALLER CV2($i) T9 L14 (6): EXT_STMT L15 (6): JMPNZ T10 L7 L16 (10): RETURN int(1)
なんだかアセンブリっぽいですね。
処理を見ていきましょう。L3まではfor
と関係ない処理です。ということで、それ以降を確認していきましょう。ループに関する個所は以下のような感じです。わりとイメージ通りの命令になっているのではないでしょうか。
行 | 処理 |
---|---|
L5 | $i に0 をアサイン |
L6 | L12 に飛ぶ |
L7 - L9 | $b += $a[$i] の処理 |
L10 - L11 | $i++ |
L12 | T9 に count($a) の結果を保存 |
L13 | T10 に $i < T9 の結果を保存 |
L15 | T10 が 0 でなければ L7に飛ぶ |
さきほどまとめた文法と照らし合わせると、それぞれの式・文が評価されている命令の並びがわかります。初めに終了条件の式に飛ぶ以外は、普通にループしているだけですね。
要素 | 行 |
---|---|
開始状態の式 | L5 |
for内部の文 | L7 - L9 |
ループ式 | L10 - L11 |
終了条件の式 | L12 - L15 |
ここで注目したいのが、for内部の文
です。命令のL8
を見てみましょう。配列から値を取り出す処理です。
L8 (7): T6 = FETCH_DIM_R CV0($a) CV2($i)
ここで使われている命令は、FETCH_DIM_R
となっています。これは配列にインデックスアクセスするときの命令です。当たり前のことを確認しているようですが、結構大事なところです。
このように実際の命令まで追っていくと、やはりfor
というのは「配列の操作」とは直接関係のない構文である、と言えるでしょう。
foreachを眺める
続いて、foreach
を見てみましょう。ソースは以下の通りです。やりたいことは変わりません。
<?php $a = [1, 2, 3]; $b = 0; foreach ($a as $v) { $b += $v; }
では、まずはforeach
の文法を確認しましょう。さきほどと同じように、PHPのパーザーにある定義を確認します。
%token T_FOREACH "foreach (T_FOREACH)" statement: | T_FOREACH '(' expr T_AS foreach_variable ')' foreach_statement { $$ = zend_ast_create(ZEND_AST_FOREACH, $3, $5, NULL, $7); } | T_FOREACH '(' expr T_AS foreach_variable T_DOUBLE_ARROW foreach_variable ')' foreach_statement { $$ = zend_ast_create(ZEND_AST_FOREACH, $3, $7, $5, $9); }
どうやらforeach
は2つの文法が定義されているようです。
上側は配列の value だけを、下側はT_DOUBLE_ARROW
とあるように配列の key,value 両方使用するタイプですね。
ここで出てくるそれぞれの要素も、なんとなくイメージできそうです。
そして、さらに追いかけると、foreach
の定義にたどり着きます。
void zend_compile_foreach(zend_ast *ast) /* {{{ */ { zend_ast *expr_ast = ast->child[0]; // foreachで回すもの zend_ast *value_ast = ast->child[1]; // foreachで回される「値」 zend_ast *key_ast = ast->child[2]; // foreachで回される「キー」 zend_ast *stmt_ast = ast->child[3]; // 逐次処理される文 //・・・ }
PHPパーザーはこのように理解しています。余談ですが、読み進めていくとforeach
は「値」が参照かどうかで処理を切り替えている、ということもここから読み取れます。
今回は値を渡した時の挙動に絞って見ていきましょう。
ということで、実行されている様子を調べます。 調べるのは以下のコードです。
<?php $a = [1, 2, 3]; $b = 0; foreach ($a as $v) { $b += $v; }
このopcodeのダンプは以下の通りです。
L0 (3): EXT_STMT L1 (3): ASSIGN CV0($a) array(...) L2 (4): EXT_STMT L3 (4): ASSIGN CV1($b) int(0) L4 (6): EXT_STMT L5 (6): V5 = FE_RESET_R CV0($a) L10 L6 (6): FE_FETCH_R V5 CV2($v) L10 L7 (7): EXT_STMT L8 (7): ASSIGN_ADD CV1($b) CV2($v) L9 (6): JMP L6 L10 (6): FE_FREE V5 L11 (10): RETURN int(1) LIVE RANGES: 5: L6 - L10 (loop)
見ての通り、for
と比べてかなり異なります。
L3までは飛ばして、foreach
内部を見ていきましょう。
行 | 処理 |
---|---|
L5 | V5 に$a のイテレーターを保存 無ければL10へ |
L6 | $v にV5 から値を取得しアサイン 終端ならL10へ |
L8 | $b += $v |
L9 | L6に飛ぶ |
L10 | V5 を解放 |
opcodeレベルではとてもシンプルになっています。ここで注目したいのは、L5 - L6
の部分です。
FE_RESET_R
,FE_FETCH_R
は、foreach
で使用する命令です。配列$a
からの値の取得自体がforeach
の機能である、ということですね。
逆にfor
は普通の配列アクセス命令でした。こういったところにも単純な繰り返しのfor
と、配列を逐次処理するforeach
の違いがあります。
まとめ
for
とforeach
の両者を比較してみると、構文ごとの目的の違いがわかりました。
for
は繰り返し処理のための構文foreach
は逐次処理のための構文
一見どちらも繰り返しを目的としているようですが、foreach
のほうが用途を限定されています。
foreach
はしばしばfor
をシンプルに書けるようになったと表現されたりしますが、実際にopcodeで比較してみると、for
のシンタックスシュガーではなく、内部ではしっかり別の処理です。
何気なく使っている構文でも、調べてみると意外と奥深いものです。