<? phpukraine СПІВБЕСІДИ
Пошук по платформі
CORE PHP · MIDDLE ЧАСТО ПИТАЮТЬ

Що таке генератори і коли yield кращий за масив?

Генератор — це функція з `yield`, яка при виклику не виконує жодного рядка тіла, а повертає обʼєкт `Generator` (реалізує `Iterator`): значення обчислюються по одному на вимогу, тому пікова памʼять не залежить від обсягу даних; ціна — один-єдиний прохід без перемотки, без `count()` і без доступу за індексом.

Експорт падає з Allowed memory size exhausted на 2 млн рядків — як переписати, не піднімаючи memory_limit?
Чим `yield` відрізняється від `return` і що взагалі повертає функція, у тілі якої є `yield`?
Чому `count()` на результаті такої функції падає, а `foreach` працює?
Чому другий `foreach` по тому самому генератору кидає «Cannot rewind»?
Навіщо `yield from`, якщо можна написати вкладений `foreach` з `yield` усередині?
Generator yield yield from Iterator memory send LazyCollection

Генератори зʼявилися в 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`, і код стартує лише на першій ітерації (тому валідація аргументів усередині генератора «мовчить» до `foreach`).
Що памʼять стає сталою замість лінійної: масив на мільйон рядків — це сотні мегабайтів (рядок плюс десятки байтів на елемент хеш-таблиці), генератор тримає один рядок і власний стек-фрейм.
Що `Generator` реалізує `Iterator`, тому працюють `foreach` та `iterator_to_array()`, але не працюють `count()`, `$gen[0]`, `array_map()` і будь-який другий прохід.
Що ключі є повноцінною частиною протоколу: без явного ключа PHP нумерує 0, 1, 2…, а `yield $key => $value` задає свій — і `yield from` (PHP 7.0+) ключі внутрішнього генератора **не** переномеровує.
Що `send()` робить генератор двонаправленим — значенням виразу `yield` усередині стає те, що передали ззовні; це основа корутин у ReactPHP/Amp.
Що межа економії — не лише PHP: `Model::cursor()` не врятує памʼять на MySQL, поки PDO буферизує весь результат на боці клієнта.
Класти перевірку аргументів на початок генератора і чекати винятку одразу після виклику: тіло не виконається, поки хтось не почне ітерацію.
«Для зручності» обгортати результат в `iterator_to_array()` — це повертає рівно ту памʼять, заради якої генератор і писався.
Забувати про дублікати ключів після `yield from`: `iterator_to_array($gen)` тихо перетирає рядки, потрібен другий аргумент `false`.
Викликати `getReturn()` до того, як генератор дійшов до кінця (або після `break`), і отримувати `Exception: Cannot get return value of a generator that hasn't returned`.
Писати `return $value;` у генераторі й чекати, що `foreach` віддасть це значення як останній елемент — воно доступне лише через `getReturn()`.
Вважати `Model::cursor()` або `LazyCollection` автоматичною гарантією низької памʼяті, не подивившись на буферизацію запиту в драйвері БД.
ПОРАДА

Формула, яка закриває питання: «функція з `yield` повертає не дані, а обʼєкт `Generator`; він рахує значення по одному, памʼять стала, але прохід рівно один — перемотати не можна, лише викликати функцію ще раз». Далі одразу назвіть трійку `yield from` / `send()` / `getReturn()` — це показує, що ви бачили генератори не лише в `foreach`.

оновлено 3 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`foreach` починає з виклику `rewind()`, а `Generator::rewind()` дозволений лише доти, доки генератор стоїть на першому `yield`. Після повного проходу (чи навіть після одного `next()`) він кидає виняток. Щоб пройти дані вдруге, треба або знову викликати функцію-генератор, або матеріалізувати результат через `iterator_to_array()`.