for против foreach: что быстрее — и почему почти всегда всё равно

Короткий ответ

foreach быстрее на обходе массивов в подавляющем большинстве случаев. И дело не в магии, а в том, как эти два цикла устроены внутри.

Но — спойлер из конца статьи — на реальных проектах вы почти никогда не заметите разницы. Разбираемся, почему.


Как устроены оба цикла

for — цикл «по индексам»


$length = count($items); // важно: вынесли за пределы цикла!

for ($i = 0; $i < $length; $i++) {
echo $items[$i];
}


На каждой итерации PHP делает три вещи: инкремент $i, сравнение $i < $length и обращение $items[$i] — двойное разыменование (сначала массив, потом элемент по ключу).

И вот классическая ловушка, которая убивает производительность for быстрее всего:


// ПЛОХО: count() вызывается на КАЖДОЙ итерации
for ($i = 0; $i < count($items); $i++) {
echo $items[$i];
}


count() внутри условия — это вызов функции на каждом шаге. На массиве в миллион элементов — миллион лишних вызовов. Именно поэтому советы «for медленный» исторически отчасти справедливы: не сам for медленный, а так его пишут.

foreach — цикл «по элементам»

foreach ($items as $item) {
    echo $item;
}

У foreach нет счётчика, нет сравнения условия, нет разыменования по ключу. Он работает напрямую со внутренним итератором массива — хеш-таблицы Zend Engine — и просто переходит к следующему элементу. Синтаксически вы просите меньше работы, и PHP её делает меньше.

Замер: проверим на практике

Тот же принцип, что и в прошлой заметке про lazy() — честный бенчмарк, который можно запустить в tinker:

<?php
// benchmark_loops.php — for vs foreach

$items = range(1, 1_000_000); // массив на миллион элементов

// === 1. for с count() внутри условия — так писать НЕ надо ===
$start = microtime(true);
$sum = 0;
for ($i = 0; $i < count($items); $i++) {
    $sum += $items[$i];
}
printf("for с count() в условии: %.3f сек\n", microtime(true) - $start);

// === 2. for с вынесенным count() — «правильный» for ===
$start = microtime(true);
$sum = 0;
$length = count($items);
for ($i = 0; $i < $length; $i++) {
    $sum += $items[$i];
}
printf("for с вынесенным count(): %.3f сек\n", microtime(true) - $start);

// === 3. foreach по значению ===
$start = microtime(true);
$sum = 0;
foreach ($items as $item) {
    $sum += $item;
}
printf("foreach по значению:      %.3f сек\n", microtime(true) - $start);

// === 4. foreach со ссылкой — заметьте разницу ===
$start = microtime(true);
$sum = 0;
foreach ($items as &$item) {
    $sum += $item;
}
unset($item); // обязательно!
printf("foreach по ссылке:        %.3f сек\n", microtime(true) - $start);

Типичный результат на PHP 8.2+ (конкретные цифры зависят от машины, но соотношения стабильно):

for с count() в условии: 0.281 сек
for с вынесенным count(): 0.142 сек
foreach по значению:      0.126 сек
foreach по ссылке:        0.131 сек

Что видно:

  1. Правильный for почти не отличается от foreach. Разница в 10–15% — это микроскопия на фоне реальных задач.
  2. for с count() в условии — в два раза медленнее. Это и есть главный источник мифа «for тормозит».
  3. foreach по ссылке чуть дороже по значению — PHP создаёт ссылочную пару. Но нюанс тоньше, о нём ниже.

Один нюанс про foreach по ссылке

Если массив создаётся «на лету» (результат функции), foreach ($items as &$item) заставит PHP сначала материализовать весь результат. Для больших данных это иногда заметнее, чем разница между циклами. Плюс после ссылочного foreach обязателен unset($item) — иначе последующая запись в $item тихо изменит последний элемент массива. Классическая ошибка копипасты.


А если использовать foreach по ключам?

foreach ($items as $i => $item) {
    // нужен и индекс, и значение
}

Сюда же относится частый совет «замени for на foreach ($array as $i => $v)». По скорости это паритет с правильным for — но читается такой цикл проще и работает ещё и для коллекций, где индексы могут быть строковыми. Так что если в for вам нужен только порядковый номер — да, as $i => $item часто удобнее.


Почему на реальном проекте это не важно

Вот в чём подвох всех этих микросекунд. Цикл на миллионе элементов разница между for и foreach экономит порядка 0.02 секунды. А теперь вспомним, что действительно съедает время в типичном Laravel-приложении:

  • запрос к базе без индекса — 50–500 мс;
  • N+1 в цикле по моделям — сотни мс и растёт с данными;
  • сериализация и рендер — десятки мс.

Один неоптимизированный SQL-запрос стоит дороже, чем разница между циклами на миллионе итераций. Цикл for вместо foreach вы спасаете только если внутри него — почти ничего. А если внутри него запрос к базе, никакой foreach не поможет: проблема в логике, а не в синтаксисе цикла.


Мини-шпаргалка

foreach ($items as $item);          // базовый обход — выбор по умолчанию
foreach ($items as $i => $item);    // нужен индекс
for ($i = 0; $i < $n; $i++);        // нужен именно счётчик: каждый 2-й, реверс, шаг
for (...; $i < count($arr); ...);   // никогда так не делайте — count() в условии

И три вывода:

  1. На обходе массивов foreach чуть быстрее или равен правильному for — и всегда читается проще.
  2. for берут не за скорость, а за потребность: шаг больше единицы, обратный порядок, арифметика по индексам.
  3. Если код тормозит — профилируйте. Разница между for и foreach почти никогда не окажется в ответе.