Generators no PHP

iterGenerators foram adicionados no PHP na versão 5.5 (meados de 2013) e aqui estamos nós, anos depois, falando sobre eles. É que esse ainda não é um assunto muito difundido, digo, não é trivial encontrar casos e mais casos de uso para eles no contexto padrão de desenvolvimento para o PHP. Normalmente o uso deles fica abstraído em libraries e frameworks.

Mas, antes de tudo, é importante entendermos o que são Iterators, ademais, Iterators e Generators são assuntos intrinsecamente relacionados.

Para isso, recomendo a leitura do artigo: Iterators no PHP

Todos os exemplos desse artigo estão disponíveis no repositório: https://github.com/KennedyTedesco/php-iterators-generators

Desenvolvedor PHP Júnior
Formação: Desenvolvedor PHP Júnior
Nesta formação você aprenderá todos os fundamentos necessário para iniciar do modo correto com a linguagem PHP, uma das mais utilizadas no mercado. Além dos conceitos de base, você também conhecerá as características e a sintaxe da linguagem de forma prática.
CONHEÇA A FORMAÇÃO

O que é um Generator?

Numa explicação acessível, Generators são uma forma prática de se implementar Iterators. Isso quer dizer que, com Generators, podemos implementar complexos Iterators sem que precisemos criar objetos, implementar interfaces e tudo mais que vimos anteriormente. Generators dão para uma função a capacidade de retornar uma sequência de valores.

Para criar uma função Generator, basta que ela possua a palavra reservada yield. O operador yield é uma espécie de return, só que com algumas particularidades. E uma função desse tipo retorna um objeto da classe Generator, que é uma classe especial e específica para esse contexto, não sendo possível utilizá-la de outra forma. Esse objeto retornado pode ser iterado. É aí que entra a nossa base sobre Iterators que aprendemos anteriormente. A classe Generator implementa a interface Iterator.

Vejamos o exemplo mais elementar possível de uma função generator:

<?php

declare(strict_types=1);

function getBooks(): Generator
{
    yield 'Book 1';
    yield 'Book 2';
    yield 'Book 3';
    yield 'Book 4';
    yield 'Book 5';
}

foreach (getBooks() as $book) {
    echo $book . \PHP_EOL;
}

O resultado:

Book 1
Book 2
Book 3
Book 4
Book 5

Sempre que uma função usar o operador yield ela vai retornar um objeto do tipo Generator. E, se Generator é um Iterator, logo, ele pode ser iterado.

Não precisamos de um yield para cada registro da nossa coleção. Normalmente o que vamos ver é um yield dentro de um laço:

<?php

declare(strict_types=1);

function getBooks(): Generator
{
    for ($i = 0; $i < 100; $i++) {
        yield "Book {$i}";
    }
}

foreach (getBooks() as $book) {
    echo $book . \PHP_EOL;
}

E é possível que o yield retorne um conjunto de chave => valor:

<?php

declare(strict_types=1);

function getBooks(): Generator
{
    for ($i = 0; $i < 10; $i++) {
        yield $i * 2 => "Book {$i}";
    }
}

foreach (getBooks() as $key => $value) {
    echo "{$key} -> {$value}" . \PHP_EOL;
}

O resultado:

0 -> Book 0
2 -> Book 1
4 -> Book 2
6 -> Book 3
8 -> Book 4
10 -> Book 5
12 -> Book 6
14 -> Book 7
16 -> Book 8
18 -> Book 9

E a parte que “toca” a memória?

Um dos benefícios de se usar generators está no baixo consumo de memória associado. Com um generator recuperamos a informação sob demanda, sem alocar toda a coleção na memória. Trabalhamos com um dado por vez no buffer.

É relativamente comum aplicações que precisam trabalhar em grandes massas de dados. Vamos emular uma situação onde possamos visualizar essa relação do uso eficiente de memória?

<?php

declare(strict_types=1);

function getRecords(): array
{
    $records = [];
    for ($i = 0; $i < 100_000; $i++) {
        $records[] = "Record $i";
    }
    return $records;
}

function formatRecords(array $records): array
{
    $new = [];
    foreach ($records as $index => $record) {
        $new[] = "[$index] -> {$record}";
    }
    return $new;
}

$registros = formatRecords(getRecords());
foreach ($registros as $registro) {
    echo $registro . \PHP_EOL;
}

echo 'Used memory: ~' . \round((\memory_get_peak_usage()/1024/1024), 2) . 'MB';

A getRecords() aloca 100k registros na memória e os retorna. A formatRecords() recebe uma coleção de dados, formata-os, aloca-os na memória (em um novo array) e então os retorna.

O resultado da memória consumida por esse script foi:

Used memory: ~16.78MB

Agora, refatorando o exemplo para que as duas funções se transformem em generators:

<?php

declare(strict_types=1);

function getRecords(): Generator
{
    for ($i = 0; $i < 100_000; $i++) {
        yield "Record $i";
    }
}

function formatRecords(Generator $records): Generator
{
    foreach ($records as $index => $record) {
        yield "[$index] -> {$record}";
    }
}

$registros = formatRecords(getRecords());
foreach ($registros as $registro) {
    echo $registro . \PHP_EOL;
}

echo 'Used memory: ~' . \round((\memory_get_peak_usage()/1024/1024), 2) . 'MB';

O resultado:

Used memory: ~0.41MB

Uma diferença muito grande. Quanto maior o dataset, maior a diferença será.

Lendo grandes arquivos

Vamos supor que precisamos ler um documento de texto linha a linha. Uma das formas tradicionais de lidar com isso seria assim:

<?php

declare(strict_types=1);

function getLines(string $filePath): array
{
    $file = \fopen($filePath, 'rb');

    $lines = [];
    while (!\feof($file)) {
        $lines[] = \fgets($file);
    }

    \fclose($file);

    return $lines;
}

$lines = getLines(__DIR__.'/file.txt');

foreach ($lines as $line) {
    echo $line;
}

echo PHP_EOL . 'Used memory: ~' . \round((\memory_get_peak_usage()/1024/1024), 2) . 'MB';

Se o seu sistema é unix-based você pode gerar um arquivo com dados randômicos de 10MB (para testar o exemplo) executando:

base64 /dev/urandom | head -c 10000000 > file.txt

O resultado dessa execução no meu ambiente foi:

Used memory: ~18.26MB

Se refatorarmos o exemplo para:

<?php

declare(strict_types=1);

function getLines(string $filePath): Generator
{
    $file = \fopen($filePath, 'rb');

    while (!\feof($file)) {
        yield \fgets($file);
    }

    \fclose($file);
}

$lines = getLines(__DIR__.'/file.txt');

foreach ($lines as $line) {
    echo $line;
}

echo PHP_EOL . 'Used memory: ~' . \round((\memory_get_peak_usage()/1024/1024), 2) . 'MB';

O resultado será:

Used memory: ~0.41MB

Veja que a diferença do consumo de memória entre as duas abordagens é enorme.

Usando iterators e generators

No artigo sobre Iterators vimos sobre a interface SeekableIterator. Agora, vamos criar um Iterator personalizado que lê linha a linha de um arquivo e que também nos dará a opção de pular para uma linha arbitrária qualquer.

A classe se chama LineFileIterator:

<?php

declare(strict_types=1);

namespace Iterators;

use Generator;
use OutOfBoundsException;
use SeekableIterator;

final class LineFileIterator implements SeekableIterator
{
    private string $filePath;
    private Generator $generator;

    public function __construct(string $filePath)
    {
        $this->filePath = $filePath;
        $this->rewind();
    }

    public function rewind(): void
    {
        $this->generator = $this->getGenerator();
    }

    public function current()
    {
        return $this->generator->current();
    }

    public function key(): int
    {
        return (int) $this->generator->key();
    }

    public function next(): void
    {
        $this->generator->next();
    }

    public function valid(): bool
    {
        return $this->generator->valid();
    }

    public function seek($position): void
    {
        while ($this->valid()) {
            if ($this->generator->key() === $position) {
                return;
            }

            $this->generator->next();
        }

        throw new OutOfBoundsException("Invalid position ($position)");
    }

    private function getGenerator(): Generator
    {
        $file = \fopen($this->filePath, 'rb');

        while (!\feof($file)) {
            yield \fgets($file);
        }

        \fclose($file);
    }
}

Usando o mesmo arquivo de texto que criamos anteriormente, podemos testar essa implementação assim:

<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use Iterators\LineFileIterator;

$iterator = new LineFileIterator(__DIR__ . '/file.txt');

// Set the pointer to the line 50.000
$iterator->seek(50_000);

// Get the current line
echo $iterator->current();

// Move to the next line (50.001)
$iterator->next();

// Get the current line
echo $iterator->current();

echo 'Used memory: ~' . \round((\memory_get_peak_usage()/1024/1024), 2) . 'MB';

Nesse nosso exemplo construímos um iterator que por debaixo dos panos usa generators para acessar sob demanda linhas de um arquivo.

Palavras finais

O uso de Generators é indicado e muitas vezes se faz necessário quando é preciso iterar uma grande coleção de dados. Eles são, inclusive, base para programação assíncrona e um framework que se destaca utilizando-os é o AMP:

$iterator = new Producer(function (callable $emit) {
    yield $emit(1);
    yield $emit(new Delayed(500, 2));
    yield $emit(3);
    yield $emit(4);
});

Inclusive, também escrevi um artigo sobre Corrotinas e código assíncrono em PHP usando Generators, recomendo a leitura.

E ainda sobre programação assíncrona, recomendo a leitura dos artigos:

Até a próxima!

Desenvolvedor PHP Pleno
Formação: Desenvolvedor PHP Pleno
Nesta formação você aprenderá aspectos mais avançados da linguagem PHP, indo adiante nas características da linguagem, orientação a objetos, práticas de mercado, além da parte de integração com banco de dados. Ao final, estará apto a desenvolver sistemas usando banco de dados relacionais.
CONHEÇA A FORMAÇÃO
Deixe seu comentário

Head de desenvolvimento. Vasta experiência em desenvolvimento Web com foco em PHP. Graduado em Sistemas de Informação. Pós-graduando em Arquitetura de Software Distribuído pela PUC Minas. Zend Certified Engineer (ZCE) e Coffee Addicted Person (CAP). @KennedyTedesco

© 2004 - 2019 TreinaWeb Tecnologia LTDA - CNPJ: 06.156.637/0001-58 Av. Paulista, 1765, Conj 71 e 72 - Bela Vista - São Paulo - SP - 01311-200