[Перевод] Действительно ли генераторы помогают экономить память?

ifsg8sqn1bjobcyfgmtsrx_4b2e.jpeg

Недавно я обратил внимание на одно заблуждение, связанное с генераторами, а точнее — с тем, как они позволяют экономить память. Такое ощущение, что многие воспринимают генераторы как инструмент, который позволит им получить «большой прирост производительности» из ничего. Или за такую шляпу фокусника, в которую можно засунуть бесконечное количество данных и не тратить память в самом скрипте.

Сначала я удивился — откуда взялись такие идеи? Ведь мы много лет работали с большими объемами данных без всяких генераторов. Лучшая статья про генераторы в РНР, опубликованная ещё десять лет назад, Что генераторы могут для вас сделать Антонио Феррары тоже практически не упоминает экономию памяти. У меня и у самого всегда было чёткое ощущение, что хотя генераторы — это совершенно отличное изобретение, у которого есть множество разнообразных применений, но вот только экономии памяти среди них нет.

В итоге у меня разыгралось любопытство и я решил разобраться с этим вопросом.


Статьи

Как выяснилось, в интернете есть куча статей, безответственно заявляющих, что генераторы являются именно тем средством, которое позволяет экономить память. или по крайней мере выпячивающими экономию памяти на первый план. Но написать такую статью без передёргиваний довольно сложно. Навскидку, одна из популярных схем выглядит так:

Сначала формулируется реальная проблема, например


Ситуация, когда приходится работать с большими объёмами данных, является весьма распространённой. К примеру, получение данных через API и сохранение их в массив. Иногда набор данных может оказаться таким большим, что это вызовет ошибку переполнения памяти.

И это реально существующая проблема, тут всё чисто. А дальше следим за руками: переходя к использованию генераторов, автор статьи вдруг резко забывает про свой «массив, полученный через API», и подменяет его на сгенерированную последовательность. И дальше с энтузиазмом показывает, как можно с помощью генераторов сэкономить кучу памяти, когда каждое новое значение можно вычислить на основании предыдущего. Что просто невозможно в случае «массива больших данных, полученных через API»!

В этих статьях реальные данные подменяются на простую последовательность чисел — 1,2,3… И в то время как такую последовательность действительно можно получить, используя генератор, то с реальными данными такой номер не пройдёт. В итоге читатель получает неверное представление о том, что генераторы будто бы действительно могут каким-то волшебным способом сэкономить память при работе с большими объёмами данных.

Если говорить о реальных решениях проблемы «получения кучи данных из API», то генератор тут не поможет, а понадобятся куда более традиционные инструменты. Если какое-то API действительно настолько невежливо, что без вариантов вываливает на нас огромный объем данных, то во-первых, вместо сохранения в массив получаемые данные должны быть перенаправлены в файл (CURLOPT_FILE и.т.д.), а затем этот файл прочитан с помощью потокового парсера (такого как salsify/json-streaming-parser) чтобы избежать переполнения памяти (сюжетный поворот: этот парсер вполне может внутри себя использовать генераторы, но это будет уже совсем другая история).

Или, если ещё ближе к реальной жизни, то каждое нормальное API предоставляет функцию постраничной разбивки, и наш код может делать последовательные вызовы, запрашивая удобоваримый объем данных за раз.


Документация

Но меня всё ещё интересовал вопрос, откуда люди могли взять эту идею про экономию памяти? тогда я обратился к официальной документации, и сразу обратил внимание на следующий абзац:


«Генератор позволяет вам писать код, использующий foreach для перебора набора данных без необходимости создания массива в памяти, что может привести к превышению лимита памяти, либо потребует довольно много времени для его создания».

И я подумал, что когда люди его читают, то сразу перескакивают на те места, где говорится про превышение лимита памяти/времени, и в то же время совершенно пропускают слова про »код, использующий foreach». Которые, на самом деле, являются здесь ключевыми. Всё это только про foreach().

С этой точки зрения здесь всё написано правильно: если наш код использует foreach() чтобы перебрать какой-то набор данных, то в этом случае мы действительно можем использовать генератор, чтобы уменьшить потребление памяти. Но всё дело в том, что мы всегда можем решить ту же самую задачу без использования foreach(). И получается, что это это не генератору мы должны быть благодарны за экономию памяти, а общему принципу, когда мы читаем данные не все сразу целиком, а по очереди. Хотя надо признать, что использование foreach() может быть очень удобным. И таким образом мы можем сделать вывод, что 

Основное преимущество генераторов не в экономии памяти, а в удобстве.

Генераторы — это действительно отличная вещь, но они годятся куда на большее, чем дурацкие фокусы с потреблением памяти. Генераторы помогают писать кода более удобный и гибкий код. Как они это делают?


Циклы на вынос

Если вы заглянете внутрь любого фокуса, использующего генераторы для экономии памяти, вы увидите там старый добрый цикл for() или while(), устало утирающий пот со лба. Вот кто настоящий герой этой истории, тот, кто на самом деле обеспечивает весь этот хайп про «экономию памяти». Мы использовали их десятилетиями, без всяких генераторов и без всяких проблем с использованием памяти:


  • обрабатываем огромный файл? Ради бога, while (($buffer = fgets($fp)) !== false) {...} — читаем по одной строчке за раз
  • получаем данные из БД? Без проблем, while ($row = $stmt->fetch()) {...} (важно только не забыть использовать при этом небуферизованный запрос)
  • работаем с большой последовательностью чисел? Ну это совсем ерунда, for ($i = 1; $i <= PHP_INT_MAX; $i++) {...} — считаем звёзды на небе
  • и так далее, и так далее, и так далее…

Получается, что перерасход памяти является не той проблемой, которую решают генераторы. А решают они проблему структурирования кода.

Как вы могли заметить, в каждом примере выше вся обработка неизбежно происходит внутри цикла. Весь код обязательно должен быть внутри.

Представим, что у нас есть файл, в котором записан миллион чисел, каждое на своей строке. И нам надо их использовать в каких-то вычислениях — пусть это будет суммирование всех чётных чисел например. Разумеется, мы можем написать самый обычный цикл while():

$sum = 0;
$handle = fopen("file.txt", "r");
while (($num = fgets($handle)) !== false) {
    // тут идут наши вычисления
    $num = trim($num);
    if ($num % 2 === 0 ) {
        $sum += $num;
    }
}

Быстро, просто, с низким потреблением памяти. И без всяких генераторов. Но тут есть одна проблема: код получился не очень чистым. Он делает больше, чем считается приемлемым согласно стандартам чистого кода: он и читает данные из файла, и производит какие-то действия с ними. Если когда-нибудь придётся добавить ещё один источник данных, например, CSV файл, то придётся писать новый цикл и дублировать код внутри. Ещё один источник — ещё один дубль… и так далее.

Получается, что в идеале эти две операции следовало бы разделить (чтение данных и их обработка) следовало бы разделить. Без генераторов (или итераторов) это было бы невозможно (с тем же уровнем потребления памяти).

Теперь представим, что мы решили вынести сами вычисления в отдельную функцию (или у нас уже была готовая), принимающую в качестве параметра массив, который потом перебирает с помощью foreach():

function sum_even_values($array) {
    $result = 0;
    foreach ($array as $item) {
        if ($item % 2 === 0 ) {
            $result += $item;
        }
    }
    return $result;
}

При традиционном подходе нам придётся потратить кучу памяти:

$sum = sum_even_values(file("file.txt"));

поскольку нам неизбежно придётся прочитать весь файл в в массив.

И вот теперь генераторы приходят на помощь!

Используя генератор мы можем написать замену функции file(), которая не читает все данные сразу в память, а возвращает строки по одной:

function filerator($filename) {
    $handle = fopen($filename, "r");
    while (($line = fgets($handle)) !== false) {
        yield trim($line);
    }
}

и теперь мы можем использовать этот генератор с нашей функцией вычислений, поддерживая потребление памяти на низком уровне:

$sum = sum_even_values(filerator("file.txt"));

В итоге мы получили гораздо более чистый код, который и является настоящим преимуществом генераторов.

По сути, мы отделили цикл от его полезной нагрузки. С обычными циклами неизбежно приходится писать весь код внутри. Но генераторы позволяют отделить сам цикл от полезной нагрузки, делая её более мобильной. Они позволяют как бы взять цикл, положить его в посылку, и отправить куда-нибудь ещё, где он уже и будет исполняться. Это настолько удивительная возможность, что я чувствую себя ребенком, которому показали фокус с доставанием кролика из шляпы ;-)


Что генераторы не могут

Тут всё просто: они не обеспечивают полной функциональности массивов, а именно — случайного доступа к элементам. Если он вам нужен, то генератор тут не подойдёт.

Другими словами, генераторы — это не про массивы. А про потоки и последовательности. Если у нас есть формула, которая позволяет вычислить следующее значение на основании предыдущего, или источник данных, который может возвращать значения по одному, и мы не хотим получить их все разом, но при этом хотим перебирать их через foreach() — это будет как раз работа для генератора.

© Habrahabr.ru