RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

PHPの for と foreach について掘り下げてみた

f:id:syoneshin:20200924125242j:plain こんにちは。やなせたかしです。

今回は「繰り返し」について掘り下げてみようと思います。

PHPに限ったことではないですが、繰り返しはプログラミングでは基本的な操作です。たとえば、

  • while
  • for

など、処理を繰り返す構文です。その中でも利用頻度が高いのはforでしょうか?サンプルコードでも配列を繰り返す時に使われたりと目にすることが多いと思います。
PHPであれば、 foreachという構文もあります。これも繰り返しのようです。

この中でも、forforeachどちらを使うのか?が議論されたり、どちらのほうが性能がいいだの悪いだの、過去にも比較されてきました。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...はこのパーザーにマッチした要素の位置っぽいですね。正規表現のマッチっぽくも見えます。

さらに追っていくと、PHPforの定義が見えてきます。

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 $i0アサイ
L6 L12 に飛ぶ
L7 - L9 $b += $a[$i]の処理
L10 - L11 $i++
L12 T9count($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 $vV5から値を取得しアサイン 終端ならL10へ
L8 $b += $v
L9 L6に飛ぶ
L10 V5を解放

opcodeレベルではとてもシンプルになっています。ここで注目したいのは、L5 - L6の部分です。

FE_RESET_R,FE_FETCH_Rは、foreachで使用する命令です。配列$aからの値の取得自体がforeachの機能である、ということですね。 逆にforは普通の配列アクセス命令でした。こういったところにも単純な繰り返しのforと、配列を逐次処理するforeachの違いがあります。

まとめ

forforeachの両者を比較してみると、構文ごとの目的の違いがわかりました。

  • forは繰り返し処理のための構文
  • foreachは逐次処理のための構文

一見どちらも繰り返しを目的としているようですが、foreachのほうが用途を限定されています。

foreachはしばしばforをシンプルに書けるようになったと表現されたりしますが、実際にopcodeで比較してみると、forシンタックスシュガーではなく、内部ではしっかり別の処理です。

何気なく使っている構文でも、調べてみると意外と奥深いものです。

Copyright © RAKUS Co., Ltd. All rights reserved.