Генераторы в PHP: как делать правильно и где yield ломается
Генераторы через yield экономят память при обработке больших наборов данных, но ломаются на неочевидных вещах: возврате значений, ключах и повторном проходе. Разбираем рабочие приёмы для PHP 8.x.

Скрипт читает CSV на 800 тысяч строк, падает с Allowed memory size exhausted, и первое желание — поднять memory_limit до гигабайта. Генератор решает ту же задачу на 8 мегабайтах: строки проходят через скрипт по одной, а не грузятся в массив целиком. Но у yield есть несколько мест, где он ведёт себя не так, как ожидает человек, привыкший к массивам. Разберём, как писать генераторы в PHP 8.x без сюрпризов и когда они наоборот вредят.
Что даёт генератор и чего он не даёт
Генератор — это функция с ключевым словом yield внутри. Вызов такой функции не выполняет её тело, а возвращает объект Generator, реализующий Iterator. Тело выполняется порциями: до ближайшего yield, потом пауза до следующей итерации foreach.
Выигрыш — память. Функция, которая читает файл построчно и отдаёт каждую строку через yield, держит в памяти одну строку за раз, а не весь файл. Проигрыш — вы теряете произвольный доступ: count(), обращение по индексу, повторный проход по тому же объекту невозможны без дополнительных усилий.
Генератор — это одноразовый поток, а не массив. Если вам нужно пройти по данным дважды или узнать их количество заранее — генератор здесь не помощник, а источник багов.
Базовый пример: чтение большого файла
Классический случай, ради которого генераторы и придумали. Функция ниже читает файл построчно и отдаёт строки одну за одной. Проверено на PHP 8.3.
<?php
function readLines(string $path): Generator
{
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException("Не удалось открыть файл: $path");
}
try {
while (($line = fgets($handle)) !== false) {
// отдаём строку без завершающего перевода строки
yield rtrim($line, "\r\n");
}
} finally {
// finally выполнится, даже если foreach прервали через break
fclose($handle);
}
}
$total = 0;
foreach (readLines('big.csv') as $line) {
if ($line !== '') {
$total++;
}
}
echo "Непустых строк: $total\n";
Ключевой момент — finally вокруг fclose. Если по генератору прошли не до конца (сработал break в foreach или объект был уничтожен сборщиком), блок finally всё равно закроет дескриптор. Без него вы получите утечку открытых файлов.
Три приёма, которые чаще всего делают неправильно
1. yield с ключом и коллизия индексов
Синтаксис yield $key => $value позволяет задавать ключи. Но если вы отдаёте одинаковые ключи, а потом собираете результат через iterator_to_array(), поздние значения затрут ранние — как в обычном массиве.
<?php
function pairs(): Generator
{
yield 'a' => 1;
yield 'b' => 2;
yield 'a' => 3; // затрёт первое 'a'
}
// По умолчанию iterator_to_array сохраняет ключи
$result = iterator_to_array(pairs());
// $result === ['a' => 3, 'b' => 2] — три yield, два элемента
// Чтобы сохранить все значения, отключите ключи вторым аргументом
$all = iterator_to_array(pairs(), false);
// $all === [1, 2, 3]
Второй аргумент iterator_to_array() называется $preserve_keys. Значение false заставляет функцию переиндексировать результат числами и не терять значения при дублях.
2. Возврат значения из генератора
Генератор может вернуть итоговое значение через return — например, счётчик обработанных строк. Но это значение не попадает в foreach: его забирают отдельно через getReturn(), и только после того, как генератор полностью отработал.
<?php
function countingReader(string $path): Generator
{
$handle = fopen($path, 'rb');
$count = 0;
try {
while (($line = fgets($handle)) !== false) {
$count++;
yield rtrim($line, "\r\n");
}
} finally {
fclose($handle);
}
return $count; // доступно только после завершения обхода
}
$gen = countingReader('big.csv');
foreach ($gen as $line) {
// обработка строки
}
// getReturn() вызывать только после того, как foreach закончился
echo "Всего строк: " . $gen->getReturn() . "\n";
Если вызвать getReturn() до того, как генератор дошёл до конца, будет исключение Exception: Cannot get return value of a generator that hasn't returned.
3. yield from для делегирования
Конструкция yield from позволяет один генератор вложить в другой без ручного перекладывания элементов. Полезна, когда обработка разбита на слои: чтение, фильтрация, парсинг.
<?php
function onlyNonEmpty(iterable $lines): Generator
{
foreach ($lines as $line) {
if (trim($line) !== '') {
yield $line;
}
}
}
function parseCsv(iterable $lines): Generator
{
// yield from пробрасывает элементы вложенного итератора наружу
yield from array_map('str_getcsv', iterator_to_array(onlyNonEmpty($lines)));
}
Осторожно с ключами: yield from сохраняет ключи вложенного источника. Если вы делегируете нескольким генераторам с числовыми ключами, ключи будут дублироваться, и при сборке в массив вы снова потеряете элементы. В цепочке потоковой обработки лучше не полагаться на ключи вообще.
Односторонний поток: почему нельзя пройти дважды
Самая частая ошибка новичков — попытка перебрать генератор второй раз:
<?php
$gen = readLines('big.csv');
foreach ($gen as $line) { /* первый проход */ }
foreach ($gen as $line) { /* этот цикл не выполнится ни разу */ }
// Попытка вызвать rewind() на уже начатом генераторе:
// Exception: Cannot rewind a generator that was already run
Генератор нельзя перемотать в начало после того, как он начал работу. Если данные нужны дважды, есть выбор:
- вызвать функцию-генератор заново — она создаст свежий объект и перечитает источник (второй проход по файлу с диска);
- материализовать в массив через
iterator_to_array()— но тогда пропадает вся экономия памяти, ради которой затевался генератор; - переосмыслить алгоритм так, чтобы одного прохода хватило.
Когда генератор не нужен
Генераторы стоят дороже обычного массива на каждой итерации: есть накладные расходы на переключение контекста корутины. На малых наборах это заметнее выигрыша.
| Ситуация | Массив | Генератор |
|---|---|---|
| Данные помещаются в память, нужен двойной проход | Да | Нет |
Нужен count(), доступ по индексу, сортировка | Да | Нет |
| Большой файл или ответ БД, один проход | Нет (риск OOM) | Да |
| Бесконечная или ленивая последовательность | Нет | Да |
| Маленький список из 10–100 элементов | Да | Избыточно |
Правило простое: генератор оправдан там, где иначе не хватит памяти или где данные приходят потоком и не нужны целиком. Для десятка элементов, которые надо отсортировать и посчитать, обычный массив проще и быстрее.
Типичные ошибки, которые видно на ревью
- Открытый ресурс без finally.
fopenили курсор БД безfinally-закрытия — утечка приbreak. Замечание: «дескриптор не закроется при досрочном выходе из foreach». - iterator_to_array внутри генератора. Сводит на нет ленивость: вы снова загрузили всё в память. Замечание: «здесь генератор материализуется целиком, экономия памяти потеряна».
- Повторное использование объекта Generator. Передали генератор в две функции, вторая получила пустоту. Замечание: «второй потребитель получит уже исчерпанный поток, передавайте фабрику-замыкание».
- getReturn до конца обхода. Приводит к исключению. Замечание: «getReturn допустим только после завершения генератора».
Что настроить и померить дальше
Проверьте реальный эффект на своём объёме: оберните обработку в memory_get_peak_usage(true) до и после перехода на генератор. Если пик памяти не упал — значит, где-то в цепочке данные всё равно материализуются, ищите скрытый iterator_to_array или сбор в массив.
Для потоковой выборки из PDO смотрите в сторону небуферизованных запросов (атрибут PDO::MYSQL_ATTR_USE_BUFFERED_QUERY в значении false для MySQL) — только так генератор поверх курсора БД действительно экономит память, иначе весь результат уже в памяти клиента. Инвалидация и обработка ошибок посреди потока — отдельная тема, здесь мы разобрали только базовую механику yield.
Генератор изображений: Иллюстрация, обложка, баннер, схема — по текстовому описанию, без исходного фото.
Спросить за 20 ₽Источники: PHP Manual: Generators, PHP Manual: iterator_to_array
Частые вопросы
У меня PHP 7.4, а не 8.x — примеры сработают?
Почему второй foreach по генератору не выполняется?
Как посчитать количество элементов в генераторе?
Генератор реально экономит память или это миф?
Что делать, если скрипт всё равно падает по памяти при чтении из БД?
Можно ли передавать значения внутрь генератора?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.