Generators no PHP

Generators foram adicionados no PHP na versão 5.5 (meados de 2013) e aqui estamos nós, quase cinco 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.

Primeiro de tudo, é importante entendermos o que são Iterators, ademais, Iterators e Generators são assuntos intrinsicamente relacionados.

PHP Básico
Curso de PHP Básico
CONHEÇA O CURSO

O que é um Iterator?

No contexto prático do PHP, Iterator é um mecanismo que permite que um objeto seja iterado e ele próprio fica no controle dessa iteração. Mas o seu uso não limita a essa “feature”, é o que veremos mais pra frente.

Há de se destacar que é possível iterar um objeto “limpo” (digo, aquele que não implementa nenhuma interface específica) e o que será levado em consideração são os seus atributos públicos.

Um exemplo:

<?php

class Maiable
{
    public $from =  'email@domain.com';
    public $to = 'email@domain.com';
    public $subject = "Assunto";
    protected $type = 1;
}

$maiable = new Maiable();

foreach($maiable as $atributo => $valor) {
    echo "<strong>{$atributo}:</strong> {$valor} <br>";
}

O resultado será:

from: email@domain.com 
to: email@domain.com 
subject: Assunto 

No entanto, temos no PHP a Iterator, uma interface que quando implementada, os objetos provenientes ganham a capacidade de serem iterados com base nas seguintes assinaturas de métodos:

Iterator extends Traversable
{
    abstract public mixed current ( void )
    abstract public scalar key ( void )
    abstract public void next ( void )
    abstract public void rewind ( void )
    abstract public boolean valid ( void )
}
  • current() – Retorna elemento corrente;
  • key() – Obtêm a chave corrente;
  • next() – Avança o cursor para o próximo elemento;
  • rewind() – Retornar o cursor para o início;
  • valid() – Checa se a posição atual existe;

Vamos então a um exemplo:

class BookStore implements Iterator
{
    private $books = [];
    private $index;

    public function __construct()
    {
        $this->index = 0;

        $this->books = [
            'Book 1',
            'Book 2',
            'Book 3',
            'Book 4',
            'Book 5',
        ];
    }

    public function current()
    {
        return $this->books[$this->index];
    }

    public function key()
    {
        return $this->index;
    }

    public function next()
    {
        $this->index++;
    }

    public function rewind()
    {
        $this->index = 0;
    }

    public function valid()
    {
        return array_key_exists($this->index, $this->books);
    }
}

Nota: Esse é um exemplo puramente didático. Estamos inicializando o array de forma estática e ele por si só não justifica o uso de Iterator. A ideia aqui é passar a essência do mecanismo. Adiante veremos casos de usos mais proximos da realidade, usando Generators.

Observe que os atributos são protegidos e que mantemos no $index o “cursor” atual da iteração.

Uma possível forma de iterar sobre esse objeto:

$books = new BookStore();

while ($books->valid()) {
    echo "<strong>[{$books->key()}]</strong> = {$books->current()} <br>";

    $books->next();
}

Resultado:

[0] = Book 1 
[1] = Book 2 
[2] = Book 3 
[3] = Book 4 
[4] = Book 5 

Essa interface faz com que o nosso objeto tenha a capacidade de decidir como e o que iterar. E destaco esses termos em negrito pois eles são a essência dos interators.

Enquanto existir um índice válido a ser recuperado os valores serão impressos. Na última iteração, $books->next() incrementa em mais um o valor de $index chegando ao valor 5, no entanto, no array $books o maior índice existente é o 4, logo, o while é interrompido.

Outra forma possível seria:

for ($books->rewind(); $books->valid(); $books->next()) {
    echo "<strong>[{$books->key()}]</strong> = {$books->current()} <br>";
}

Essas são as formas mais “primitivas” de iterar o nosso objeto iterável. Estamos manualmente cuidando de avançar o cursor usando $books->next();.

Podemos facilitar isso se usarmos a estrutura foreach, ela reconhece quando o objeto implementa a interface Traversable e trata de avançar o cursor e recuperar os valores automaticamente.

A interface Iterator estende a Traversable.

A nossa implementação pode ser simplificada para:

$books = new BookStore();

foreach($books as $key => $value) {
    echo "<strong>[{$key}]</strong> = {$value} <br>";
}

Refatorando para ArrayIterator

O lado chato da abordagem de implementar diretamente a interface Iterator é a necessidade de implementar todos os seus métodos. É util quando a lógica para se iterar ou avançar o cursor precisa ser específica. Agora, se ela é trivial, como é o caso do nosso exemplo, podemos usar a classe ArrayIterator, também nativa do PHP.

A ArrayIterator implementa não só a interface Iterator como várias outras (que não fazem parte do escopo desse artigo):

ArrayIterator implements ArrayAccess, SeekableIterator, Countable, Serializable {

Ela recebe no seu construtor um array e itera sobre ele, abstraindo de nós a necessidade de implementar os métodos necessários da interface Iterator.

Podemos simplificar e alterar a nossa classe para implementar a interface IteratorAggregate que assina um único método, chamado getIterator(). E, nele, tudo o que temos que retornar é um Iterator, no caso, vamos retornar a instância da ArrayIterator:

class BookStore implements IteratorAggregate
{
    private $books;

    public function __construct()
    {
        $this->books = [
            'Book 1',
            'Book 2',
            'Book 3',
            'Book 4',
            'Book 5',
        ];
    }

    public function getIterator()
    {
        return new ArrayIterator($this->books);
    }
}

Qual o principal benefício de se usar um Iterator?

Os exemplos anteriores não tiveram o objetivo de explicitar isso, mas um grande benefício do uso de Iterators trata-se de um melhor aproveitamento da memória. Com eles, não precisamos carregar grandes datasets de uma só uma vez, podemos carregar item a item, sob demanda, quando preciso. Digamos, é uma implementação “lazy loading”.

Nos tópicos seguintes, sobre Generators, veremos alguns casos de uso mais reais e então entenderemos como os Iterators são importantes para lidar com grandes coleções de dados.

PHP Intermediário
Curso de PHP Intermediário
CONHEÇA O CURSO

O que é um Generator?

Em termos práticos 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 toda àquela complexidade 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 de Iterators que aprendemos anteriormente. A classe Generator implementa a interface Iterator.

Bom, vamos praticar um pouco? O exemplo mais elementar possível de uma função generator:

function getLinhas() {
    yield "Linha 1";
    yield "Linha 2";
    yield "Linha 3";
    yield "Linha 4";
    yield "Linha 5";
}

var_dump(getLinhas());

O resultado:

object(Generator)#1 (0) { }

Já confirmamos o que anteriormente foi explicado. Sempre que uma função usa o operador yield ela vai retornar um objeto do tipo Generator.

E, se Generator é um Iterator, logo, podemos iterar sobre ele, certo? Sim!

Vejamos:

<?php

function getLinhas() {
    yield "Linha 1";
    yield "Linha 2";
    yield "Linha 3";
    yield "Linha 4";
    yield "Linha 5";
}

foreach (getLinhas() as $linha) {
    echo "{$linha} <br>";
}

Resultado:

Linha 1 
Linha 2 
Linha 3 
Linha 4 
Linha 5 

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 como, por exemplo:

<?php

function getLinhas() {
    for ($i = 0; $i < 100; $i++) {
        yield "Linha {$i}";
    }
}

foreach (getLinhas() as $linha) {
    echo "{$linha} <br>";
}

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

function getLinhas() {
    for ($i = 0; $i < 100; $i++) {
        yield $i => 'Linha ' . $i * 2;
    }
}

foreach (getLinhas() as $chave => $valor) {
    echo "[{$chave}] => {$valor} <br>";
}

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

Comentamos anteriormente que o principal benefício de se usar um Iterator está no baixo consumo de memória associado. Com um Iterator recuperamos a informação sob demanda, sem alocar toda a coleção na memória.

É relativamente comum aplicações que recuperam milhares de dados de uma base de dados (quando não podem por algum requisito usar paginação) precisarem carregar esses dados em um array e em seguida formatar esses dados carregando-os em um novo array. O pico de consumo de memória nesses casos pode ser altíssimo.

Vamos emular uma situação onde possamos ver o benefício de usar Generators?

<?php

function getRegistros() {
    $registros = [];

    for ($i = 0; $i < 10000; $i++) {
        $registros[] = "Registro $i";
    }

    return $registros;
}

function formataRegistros($registros) {
    $new = [];

    foreach ($registros as $index => $registro) {
        $new[] = "[$index] -> {$registro}";
    }

    return $new;
}

$registros = formataRegistros(getRegistros());

echo 'Memória: ' . bcdiv(memory_get_peak_usage(), 1048576, 2) . ' MB<hr><br>';

foreach ($registros as $registro) {
    echo "{$registro} <br>";
}

A getRegistros() aloca na memória (em um array) milhares de registros e os retorna. A formataRegistros() recebe uma coleção de dados, formata-os, aloca-os na memória (em um array) e então os retorna.

O resultado da memória total gasta nessa operação foi:

Memória: 2.19 MB

Faça um paralelo disso com uma aplicação sua, real, que precisa recuperar milhares de registros, depois formatá-los de alguma maneira ou relacioná-los com outros registros etc.

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

<?php

function getRegistros() {
    for ($i = 0; $i < 10000; $i++) {
        yield "Registro $i";
    }
}

function formataRegistros($registros) {
    foreach ($registros as $registro) {
        yield "-> {$registro}";
    }
}

$registros = formataRegistros(getRegistros());

echo 'Memória: ' . bcdiv(memory_get_peak_usage(), 1048576, 2) . ' MB<hr><br>';

foreach ($registros as $registro) {
    echo "{$registro} <br>";
}

O resultado:

Memória: 0.38 MB

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

Outro possível caso de uso: Você precisa ler um documento de texto linha a linha e tratar essas informações de alguma maneira. Usando a forma tradicional de lidar com isso, teríamos algo como:

<?php

function getLinhas($arquivo) {
    $handle = fopen($arquivo, 'r');

    $linhas = [];

    while (($buffer = fgets($handle, 4096)) !== false) {
        $linhas[] = $buffer;
    }

    fclose($handle);

    return $linhas;
}

$linhas = getLinhas('file.txt');

foreach($linhas as $linha) {
    // TODO
}

echo 'Memória: ' . bcdiv(memory_get_peak_usage(), 1048576, 2) . ' MB';

Se o seu sistema é unix 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 na minha máquina foi:

Memória: 19.56 MB

Se refatorarmos isso para um Generator:

<?php

function getLinhas($arquivo) {
    $handle = fopen($arquivo, 'r');

    while (($buffer = fgets($handle, 4096)) !== false) {
        yield $buffer;
    }

    fclose($handle);
}

$linhas = getLinhas('file.txt');

foreach($linhas as $linha) {
    // TODO
}

echo 'Memória: ' . bcdiv(memory_get_peak_usage(), 1048576, 2) . ' MB';

O resultado:

Memória: 0.37 MB

Veja que a diferença do consumo de memória entre as duas abordagens é enorme. Se o tamanho do arquivo que você está lendo ou precisa processar for muito grande você pode, inclusive, estourar o limite de memória do PHP se não usar um Iterator.

Com os Generators é possível que processemos arquivos de dezenas ou centenas de GB’s sem estourar o limite de memória disponível para a aplicação.

Concluindo

O uso de Generators é indicado e muitas vezes se faz necessário quando é preciso iterar uma grande coleção de dados de acesso sequencial.

Não são muitos os casos de uso para Generators na programação regular (digo, do dia a dia). Mas, certamente possuem o seu espaço. Generators são, inclusive, a base para programação assíncrona com PHP. E um framework que se destaca utilizando-os é o AMP.

PHP Avançado
Curso de PHP Avançado
CONHEÇA O CURSO
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