Генератори зʼявилися в PHP 5.5 і працюють не так, як здається з синтаксису. Наявність yield будь-де в тілі функції змінює саму природу виклику: PHP не виконує жодного рядка тіла, а створює й повертає обʼєкт класу Generator, який реалізує інтерфейс Iterator. Тіло стартує лише тоді, коли хтось уперше запитає значення — foreach, current(), send() або iterator_to_array(). Дійшовши до yield, функція заморожується: її локальні змінні, позиція виконання й навіть напівпройдений try живуть у власному стек-фреймі генератора. Наступна ітерація не починає функцію спочатку, а розморожує її рівно там, де зупинила. Саме тому в прикладі RuntimeException з fopen() вилітає не на рядку allLines(...), а вже всередині foreach — класична пастка, на якій ловлять на співбесіді.
Практичний сенс цієї ліні — памʼять. Функція, що повертає масив, зобовʼязана дорахувати всі елементи до return, тому пік споживання пропорційний обсягу даних: мільйон рядків по 200 байт — це не 200 МБ, а помітно більше, бо кожен елемент хеш-таблиці коштує ще десятки байтів службових даних. Генератор тримає одночасно один елемент, і memory_limit перестає бути функцією розміру файла чи вибірки. Друга, менш очевидна вигода — конвеєр: onlyErrors(allLines(...)) не створює жодного проміжного масиву, кожен рядок проходить крізь усі ланки й одразу звільняється. Третя — час до першого результату: споживач отримує перший рядок майже миттєво, а не після повного читання. Тому генератори — природний інструмент для парсингу великих файлів, ітерації по вибірці з БД (Model::cursor(), LazyCollection), потокової віддачі відповіді через StreamedResponse і для нескінченних послідовностей, які просто не існують як масив.
Протокол генератора ширший за foreach. Ключі — його повноцінна частина: без явного ключа PHP нумерує елементи 0, 1, 2…, а yield $key => $value дозволяє віддавати свої (номер рядка, ідентифікатор запису). yield from, доданий у PHP 7.0, делегує іншому генератору, масиву чи будь-якому Traversable і повертає його return-значення як результат виразу — у прикладі це дозволяє порахувати сумарну кількість рядків, не заводячи зайвого стану. Важливий нюанс: yield from зберігає ключі внутрішнього джерела, тому при склеюванні кількох файлів нумерація кілька разів починається з нуля, і iterator_to_array() без другого аргументу false мовчки перетре частину даних. return у генераторі теж не те, чим здається: він не потрапляє в foreach, а забирається окремо через getReturn() — і лише після того, як генератор дійшов до кінця. Нарешті, send() робить обмін двонаправленим: вираз yield усередині обчислюється в значення, передане ззовні, — на цьому побудовані корутини асинхронних рушіїв.
Головне обмеження генератора — одноразовість. Він не має курсора, який можна повернути назад: Generator::rewind() дозволений лише доки виконання стоїть на першому yield, інакше кидає Exception: Cannot rewind a generator that was already run. Через це другий foreach по тому самому обʼєкту падає, а не повертає порожнечу. Так само немає count() (генератор не знає, скільки в нього елементів, поки не дорахує), немає доступу за індексом, і його не приймають array_map(), array_filter(), sort() — усі вони працюють з масивами. Якщо дані потрібні двічі, зберігайте не генератор, а спосіб його створити: фабрику-замикання або клас з IteratorAggregate; саме так влаштований LazyCollection, який приймає замикання й тому ітерується скільки завгодно разів. Матеріалізація через iterator_to_array() теж законна — просто памʼятайте, що вона повертає ту саму памʼять, заради якої все й починалось.
Межі варто називати чесно, бо на них ловлять найчастіше. Генератор не прискорює обчислення — він лише розтягує їх у часі; на маленькій колекції з десятків елементів звичайний масив простіший і швидший, а зайва ланка генераторів робить стек-трейси менш читабельними. Генератор не зменшує памʼять поза PHP: Model::cursor() на MySQL з увімкненою буферизацією PDO (PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, за замовчуванням true у mysqlnd) все одно затягне весь результат у клієнтський буфер — тут або небуферизований запит зі своїми обмеженнями, або chunkById(), який ріже вибірку на окремі запити. І генератор — погане місце для тримання ресурсу з довгим життям: якщо споживач вийде через break, генератор просто зависне на yield до знищення, тож звільнення файла чи відкат транзакції мають бути у finally, а сама транзакція — краще в коді-споживачі, а не всередині ітератора.
/** Читає лог рядок за рядком: у памʼяті живе один рядок, а не весь файл. */
function lines(string $path): Generator
{
// ця перевірка спрацює НЕ на виклику lines(), а на першій ітерації
$handle = fopen($path, 'rb') ?: throw new RuntimeException("Немає {$path}");
$n = 0;
try {
while (($line = fgets($handle)) !== false) {
yield $n++ => rtrim($line, "\r\n"); // ключ задаємо явно
}
} finally {
fclose($handle); // виконається і при break у споживача
}
return $n; // видно лише через getReturn() після кінця
}
/** yield from делегує іншому генератору й повертає його return-значення. */
function allLines(string ...$paths): Generator
{
$total = 0;
foreach ($paths as $path) {
$total += (yield from lines($path)); // ключі 0,1,2… стартують заново!
}
return $total;
}
/** Ланка конвеєра: фільтр нічого не матеріалізує. */
function onlyErrors(iterable $lines): Generator
{
foreach ($lines as $key => $line) {
if (str_contains($line, ' ERROR ')) {
yield $key => $line;
}
}
}
$stream = allLines('app-09-01.log', 'app-09-02.log'); // тіло ще не виконувалось
foreach (onlyErrors($stream) as $key => $line) {
echo "{$key}: {$line}\n"; // пікова памʼять не залежить від розміру логів
}
echo $stream->getReturn(); // усього рядків; до завершення — Exception
// повторний foreach ($stream) → Cannot rewind a generator that was already run
Формула, яка закриває питання: «функція з `yield` повертає не дані, а обʼєкт `Generator`; він рахує значення по одному, памʼять стала, але прохід рівно один — перемотати не можна, лише викликати функцію ще раз». Далі одразу назвіть трійку `yield from` / `send()` / `getReturn()` — це показує, що ви бачили генератори не лише в `foreach`.