Ленивые вычисления в PHP: как генераторы и итераторы экономят память и ускоряют код

от автора

Вы когда-нибудь пытались загрузить в память CSV-файл на миллион строк и увидели что-то вроде:

PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted

Даже если увеличить memory_limit, ощущение всё равно неприятное: мы держим в памяти весь массив данных, хотя работаем с ним построчно.

Решение? Ленивые вычисления — подход, при котором данные генерируются и обрабатываются только тогда, когда они реально нужны.
В PHP это можно сделать двумя способами: с помощью генераторов (yield) и через Iterator API. Сегодня разберём оба.

Что такое ленивые вычисления

Обычно, когда мы создаём массив, PHP загружает в память сразу все элементы:

function getNumbersArray(int $count): array {     $result = [];     for ($i = 1; $i <= $count; $i++) {         $result[] = $i;     }     return $result; }  foreach (getNumbersArray(5) as $number) {     echo $number . PHP_EOL; }

Здесь в памяти хранится сразу весь массив [1, 2, 3, 4, 5].

А теперь попробуем ленивый подход:

function getNumbersGenerator(int $count): Generator {     for ($i = 1; $i <= $count; $i++) {         yield $i;     } }  foreach (getNumbersGenerator(5) as $number) {     echo $number . PHP_EOL; }

📌 Разница: генератор не хранит всё — он отдаёт элемент только тогда, когда foreach его запросит.

Читаем огромный CSV без боли

Представим, что у нас есть файл data.csv на 2 ГБ. Обычный file() или fgetcsv в массиве — мгновенный Out of Memory.
С генератором — всё просто:

function readCsv(string $filename): Generator {     $handle = fopen($filename, 'r');     if ($handle === false) {         throw new RuntimeException("Не удалось открыть файл $filename");     }      while (($row = fgetcsv($handle)) !== false) {         yield $row;     }      fclose($handle); }  foreach (readCsv('data.csv') as $row) {     // Обрабатываем строку }

📊 Память: даже для 2 ГБ CSV этот код будет занимать несколько килобайт, потому что в памяти всегда только одна строка.

Бенчмарк: массив vs генератор

$startMemory = memory_get_usage();  $array = range(1, 1_000_000); // создаёт массив из миллиона чисел  echo "Массив: " . (memory_get_usage() - $startMemory) / 1024 / 1024 . " MB\n";  unset($array);  $startMemory = memory_get_usage();  function bigGenerator(): Generator {     for ($i = 1; $i <= 1_000_000; $i++) {         yield $i;     } }  foreach (bigGenerator() as $n) {     // просто итерируем }  echo "Генератор: " . (memory_get_usage() - $startMemory) / 1024 / 1024 . " MB\n";

Результат на моей машине:

Массив: 120 MB Генератор: 0.5 MB

Iterator API

Генераторы — это быстро и просто. Но иногда нужно больше контроля: хранить состояние, управлять ключами или даже динамически менять источник данных.
Тогда в бой идёт Iterator API.

Пример: собственный итератор

class RangeIterator implements Iterator {     private int $start;     private int $end;     private int $current;      public function __construct(int $start, int $end) {         $this->start = $start;         $this->end = $end;         $this->current = $start;     }      public function current(): int {         return $this->current;     }      public function key(): int {         return $this->current;     }      public function next(): void {         $this->current++;     }      public function rewind(): void {         $this->current = $this->start;     }      public function valid(): bool {         return $this->current <= $this->end;     } }  foreach (new RangeIterator(1, 5) as $num) {     echo $num . PHP_EOL; } 

Когда что использовать

Ситуация

Что выбрать

Нужно просто отдать данные по мере запроса

Генератор

Нужно хранить внутреннее состояние или сложную логику

Iterator API

Потоковая обработка из файла/БД

Генератор

Множественные обходы коллекции с сохранением состояния

Iterator API

Реальный кейс из продакшена

Мы парсили API автопродаж, которое возвращало сотни тысяч записей.
Раньше мы собирали всё в массив — скрипт ел по 1–2 ГБ памяти.
После перехода на генератор:

function fetchCars(): Generator {     $page = 1;     do {         $data = apiRequest('cars', ['page' => $page]);         foreach ($data['items'] as $car) {             yield $car;         }         $page++;     } while (!empty($data['items'])); }

📉 Память упала с 2 ГБ до 10 МБ, время выполнения осталось почти тем же.

🔍 Для «гиков»: как генераторы работают под капотом

1. Генератор — это объект

В PHP генератор — это объект класса Generator, реализующий Iterator и Traversable.
Он умеет:

  • хранить текущее состояние функции;

  • приостанавливать выполнение на yield;

  • возобновлять выполнение с того же места.

2. Жизненный цикл

  1. Вызов функции-генератора не запускает её сразу — создаётся объект Generator.

  2. При первой итерации выполнение идёт до yield.

  3. yield возвращает значение и «замораживает» функцию.

  4. Следующая итерация продолжает выполнение с того же места.

  5. Когда функция завершается — генератор помечается как завершённый.

3. На уровне Zend Engine

Если скомпилировать функцию с yield через VLD (Vulcan Logic Disassembler), мы увидим, что каждый yield — это инструкция, которая:

  • сохраняет стек вызовов;

  • запоминает переменные;

  • возвращает значение в вызывающий код.

4. Разница с массивами

  • Массив создаёт все элементы в памяти.

  • Генератор хранит один текущий элемент (zval) и перезаписывает его.

  • Поэтому можно обойти миллион элементов, используя пару сотен килобайт.

5. Пример бесконечного генератора

function counter(): Generator {     $i = 0;     while (true) {         yield $i++;     } }  foreach (counter() as $num) {     if ($num > 5) break;     echo $num . PHP_EOL; }

С массивом это невозможно — память просто закончится.

Benchmark results (1,000,001 rows)

Method

Time

Memory used

Peak diff

Rows

Array (eager)

1.401s

120 B

395.92 MB

1,000,001

Generator

1.012s

0 B

0.00 MB

1,000,001

Эти результаты показывают, что ленивые генераторы могут значительно сократить использование памяти при обработке больших наборов данных, таких как CSV.

Визуальные результаты

Пиковый расход памяти:

Время выполнения:

Исходный код

Вы можете попробовать всё сами — код, использованный в этой статье, имеет открытый исходный код:

👉 github.com/phpner/phpner-php-lazy-evaluation-demo

Включает в себя:

Тест CSV: массив (жадный) против генератора (ленивый)
Моделирование потока NDJSON
Профилирование памяти и времени
Инструменты с поддержкой CLI
Генератор примеров данных
Тесты PHPUnit

Вывод

  • Генераторы (yield) и Iterator API — must-have для оптимизации.

  • Они позволяют обрабатывать миллионы записей без перегрузки памяти.

  • Генераторы — для простых случаев, Iterator API — для сложных.

  • Если вы ещё ими не пользовались — попробуйте в следующем проекте.

💬 А вы используете генераторы в продакшене? Поделитесь в комментариях своими кейсами!

Источник

Перевод оригинала на dev.to


ссылка на оригинал статьи https://habr.com/ru/articles/939814/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *