PHPの for / foreach 完全攻略:エンジニアのための徹底解説

対象は PHP 8.1+(可能なら 8.2/8.3)です。
PSR‑12/PSR‑3/PSR‑7/PSR‑15 の思想(責務分離・出力境界の明確化)を前提にしています。

目次

1. for / foreach の基本的な使い方

for 文は、初期化、継続条件、更新の3つの式を明示することで、数値や添字による反復を正確に表現します。C言語由来の構文のため、読み手の予測可能性が高く、添字の厳密な制御(スキップ、逆順、ステップ幅の変更)が容易です。配列長をループ条件に使う際には、count() の呼び出しオーバーヘッドを避けるため、事前に変数にキャッシュするのが良い習慣です。

// NG: count() を毎回呼ぶ
for ($i = 0; $i < count($arr); $i++) {
    // ...
}

// OK: count() を事前にキャッシュ
for ($i = 0, $len = count($arr); $i < $len; $i += 2) {
    // 偶数インデックスのみ処理
    process($arr[$i]);
}

foreach 文は、PHPの「配列=ハッシュテーブル+順序」という特性、そして Traversable/Iterator を介したコレクション反復に最適化された構文です。foreach ($xs as $x) は「コレクションから要素を順に取り出す」という意図を直接的に表現するため、添字が不要な場合はこれを第一選択とし、キーも必要な場合は as $k => $v を使い、配列・ArrayObject・任意の IteratorAggregate に透過的に対応できます。

foreach ($users as $id => $user) {
    // $id はキー、$user は値
    $user->notify();
}

重要なポイントは、for は “数・添字” を回すのに向き、foreach “要素” を回すのに向くという役割分担です。可読性、誤用リスク、意図の明確さから、まず foreach を選ぶのが現場の健全な初期判断になります。

2. 応用的な使い方:参照・巨大配列・イテレータ・ネスト回避

2.1 参照渡しによる破壊的更新(& の正しい使いどころ)

foreach ($arr as &$v) は、元配列をインプレースで書き換える意図を明確にする場合にのみ使います。
PHP 7+ の Copy-On-Write 最適化により、速度面でのメリットは限定的です。参照の持ち越しによるバグ(ループ後も $v が参照のまま残る)を防ぐため、ループ後には必ず unset($v); を実行して参照を解除する必要があります。

foreach ($prices as &$p) {
    $p = round($p * 1.1, 2);
}
unset($p); // 重要:参照解除(次の foreach で事故るのを防ぐ)

2.2 ジェネレータ(yield)で“配列にしない”という選択

巨大なデータを扱う場合、「全部メモリに載せない」ことが最大の最適化になるケースが多いです。yield を使うジェネレータは、イテレータを安価に定義できるため、1億行のCSVの逐次処理やデータベース結果のストリーミングに威力を発揮します。

function readLines(string $path): Generator {
    $fp = fopen($path, 'rb');
    try {
        while (!feof($fp)) {
            yield fgets($fp);
        }
    } finally {
        fclose($fp);
    }
}

foreach (readLines('/var/log/app.log') as $line) {
    // 行ごとに処理、ピークメモリは一定に保たれる
}

2.3 ネスト回避:高階関数とクエリオフロード

二重三重ループは最適化余地のサインです。

  • 集約は可能なら DB(SQL)や検索基盤へオフロード
  • どうしてもアプリ側なら、インデックス化(array_columnarray_combineハッシュ参照でネストを解消
  • array_map/array_filter/array_reduce で“何をしているか”を宣言的に表現(副作用を減らす)
$byId = array_column($users, null, 'id'); // id => user の辞書
$total = array_reduce($orders, fn($acc, $o) => $acc + $byId[$o->userId]->weight, 0);

2.4 SPL / Iterator 経由の拡張性

ArrayIterator, RecursiveIteratorIterator, CallbackFilterIterator などを組むと、条件付きの深さ優先走査遅延フィルタリングforeach のまま表現できます。
業務ドメインに合わせた IteratorAggregate を作り、“回し方”をオブジェクトに隠蔽するのは堅牢な設計です。

3. 代案:while / do‑while, 高階関数, ディスパッチ表, 並列

3.1 while / do‑while(条件主導・1回は必ず実行)

  • while は「条件が真の間」回す。for より前処理/後処理の自由度が高い反面、無限ループ事故の温床。
  • do‑while最低1回実行を保証。ユーザー入力ループなどで使い分けます。
do {
    $line = fgets($fp);
    // …
} while ($line !== false);

3.2 高階関数群(array_map/array_filter/array_reduce

array_map/array_filter/array_reduce は、副作用のない変換を宣言的に記述できるため、テストや並列化の観点で優れています。PHP 8.1+ の JIT(Just-In-Time)コンパイラが有効な環境では、シンプルなラムダ呼び出しのオーバーヘッドはほとんどなく、foreach と同等以上の性能を発揮する場合があります。

$actives = array_values(array_filter($users, fn($u) => $u->active));
$names   = array_map(fn($u) => $u->name, $actives);

3.3 ディスパッチ表(配列によるルックアップ)

条件分岐をO(1) の辞書参照に落とす。多分岐の if/else/switch を除去し、foreach の中身を最小化します。

$handlers = [
    'A' => fn($x) => processA($x),
    'B' => fn($x) => processB($x),
];
foreach ($items as $it) {
    ($handlers[$it->type] ?? $default)($it);
}

3.4 並列(プロセス/拡張)

標準 PHP は並列が得意ではありませんが、プロセス分割symfony/process + 仕事分割)や Swoole/Amp/ReactPHPI/O の同時進行を実現できます。
巨大バッチはOS レベルの並列(プロセスプール)+ジェネレータの分割供給が実務解です。

4. 実測ベンチマーク(PHP 8.3)と解釈(現場の意思決定に直結)

条件:CLI / OPcache CLI 有効 / JIT なし / 100万要素 int 配列 / I/O なし

■ 計測スクリプト

bench_loops.php
<?php
declare(strict_types=1);

// 設定
const N = 1_000_000;
$arr = range(1, N);

// 高分解能タイマ
function t(callable $fn, int $repeat = 3): float {
    $best = INF;
    for ($i=0; $i<$repeat; $i++) {
        $t0 = hrtime(true);
        $fn();
        $dt = (hrtime(true) - $t0) / 1e9;
        if ($dt < $best) $best = $dt;
    }
    return $best;
}

// for(count を毎回呼ぶ)
$for_count_each = t(function() use ($arr) {
    $sum = 0;
    for ($i = 0; $i < count($arr); $i++) {
        $sum += $arr[$i];
    }
});

// for(count 結果をキャッシュ)
$for_cached_len = t(function() use ($arr) {
    $sum = 0;
    for ($i = 0, $len = count($arr); $i < $len; $i++) {
        $sum += $arr[$i];
    }
});

// foreach(値渡し)
$foreach_value = t(function() use ($arr) {
    $sum = 0;
    foreach ($arr as $v) {
        $sum += $v;
    }
});

// foreach(参照渡し/書き換えなし)
$foreach_ref_no_write = t(function() use ($arr) {
    $sum = 0;
    foreach ($arr as &$v) {
        $sum += $v;
    }
    unset($v);
});

// foreach(参照渡しで書き換えあり)
$foreach_ref_write = t(function() use ($arr) {
    foreach ($arr as &$v) {
        $v += 1;
    }
    unset($v);
});

// array_map(合計を返す形に包む)
$array_map = t(function() use ($arr) {
    $out = array_map(static fn($v) => $v + 1, $arr);
});

// ジェネレータ(逐次供給→foreach)
function gen(array $arr): Generator {
    foreach ($arr as $v) yield $v;
}
$generator_foreach = t(function() use ($arr) {
    $sum = 0;
    foreach (gen($arr) as $v) {
        $sum += $v;
    }
});

// while(インデックス)
$while_index = t(function() use ($arr) {
    $sum = 0; $i = 0; $len = count($arr);
    while ($i < $len) {
        $sum += $arr[$i++];
    }
});

// 結果表示
$rows = [
    ['for (count毎回)', $for_count_each],
    ['for (countキャッシュ)', $for_cached_len],
    ['foreach (値渡し)', $foreach_value],
    ['foreach (&参照, 書換なし)', $foreach_ref_no_write],
    ['foreach (&参照, 書換あり)', $foreach_ref_write],
    ['array_map (+1)', $array_map],
    ['generator→foreach', $generator_foreach],
    ['while (index)', $while_index],
];
usort($rows, fn($a,$b) => $a[1] <=> $b[1]);

printf("%-26s | %9s\n", 'Case', 'sec');
echo str_repeat('-', 40), "\n";
foreach ($rows as [$name, $sec]) {
    printf("%-26s | %9.6f\n", $name, $sec);
}
実行コマンド例
php -d opcache.enable_cli=1 bench_loops.php

以下のベンチマーク結果は、特定の環境下の一例に過ぎず、PHPのバージョンやJITの有無によって変動します。

中央値の一例(環境差あり)

Casesec(小さいほど速い)
for (countキャッシュ)0.028
foreach (値渡し)0.030
while (index)0.031
generator→foreach0.036
foreach (&参照, 書換なし)0.039
array_map (+1)0.069
for (count毎回)0.108
foreach (&参照, 書換あり)0.126

解釈(重要な順)

  1. for (count毎回) は明確に遅い必ず $len = count($arr) をキャッシュ
  2. for(lenキャッシュ)foreach(値渡し) は実質同等 → 可読性/意図で選ぶ。添字が要るなら for。
  3. 参照 foreach はオーバーヘッド+安全性低下 → 破壊更新に限定。unset($v) を必ず。
  4. array_map は遅いが“副作用なしの宣言”が武器 → ホットパス外やテスト容易性優先で採用。
  5. ジェネレータは小規模では不利だが大規模で圧勝 → 「配列化しない」ことでメモリを一定に保てる。

最終指針

  • デフォルト:foreach(値渡し)
  • 添字やステップ制御が必要:for(count キャッシュ)
  • 巨大データ:ジェネレータ+foreach
  • 副作用なしを表明:array_map/filter/reduce
  • 破壊更新:foreach 参照(&)だが安全策必須。
  • パフォーマンスはアルゴリズム・I/O 削減が支配的。ループ構文の差は二次的。

5. バグ回避と可読性の鉄則(現場のチェックリスト)

  • count() はキャッシュしましょう。
  • foreach (&$v) を使ったら、必ず unset($v) を実行しましょう。
  • ループ中に配列自体を unset/push しないようにしましょう。
  • ビジネスロジックを関数化し、ループ本体は3〜5行に抑えましょう。
  • 巨大なデータは “配列にしない”(ジェネレータ/ストリーム)ことを基本としましょう。
  • プロファイラ(Blackfire/Xdebugなど)でボトルネックを実測し、感覚ではなくデータに基づいて判断しましょう。

6. サンプル:実務パターン別コード(良い例/悪い例)

6.1 良い例:辞書化+単一走査

// NG: ネストして O(n*m)
foreach ($orders as $o) {
    foreach ($users as $u) {
        if ($u->id === $o->userId) { /* ... */ }
    }
}

// OK: 事前に辞書を作って O(n)
$byId = array_column($users, null, 'id');
foreach ($orders as $o) {
    $u = $byId[$o->userId] ?? null;
    if ($u) { /* … */ }
}

6.2 良い例:ジェネレータで CSV を加工しながら吐く

function rows(string $csv): Generator {
    $f = new SplFileObject($csv);
    $f->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY);
    foreach ($f as $row) {
        if ($row === [null]) continue;
        yield $row;
    }
}

foreach (rows('input.csv') as [$id, $name, $age]) {
    echo implode(',', [$id, strtoupper($name), $age]), "\n";
}

6.3 悪い例:参照の持ち越しバグ

foreach ($a as &$x) { /* … */ }
foreach ($b as $y) { $y = $x; } // $x が参照のまま残り、意図せず書き換えが波及

7. 結論

  • 読みやすさと安全性を最優先 → まず foreach(値渡し)
  • 数値・添字制御が要件for(count キャッシュ)
  • メモリ圧とスループットジェネレータ+ストリームで配列化回避。
  • 副作用なしの変換 → 高階関数で意図を宣言。
  • 速度は実測で判断。ループ構文差より前処理・データ構造・I/Oが効く。

あわせて読みたい ~徹底解説シリーズ~

おすすめ本を紹介!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

神奈川県出身
どこにでもいる現役ゲーム会社サーバーエンジニア。大学ではひたすらゲーム!ゲーム!という人生を送ってました。

このブログは「ゲームを作ってみたい!」「プログラミングに関する知識をつけたい!」そんな皆さんの少しでもお役になれば嬉しいなと思い開設しました。

趣味
YouTube鑑賞、ストリートな格闘ゲーム、キャンプ

最近の出来事
・痔主になってしまいました....
・Google Cloud Professional Cloud Architect取得しました。

コメント

コメントする

目次