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
Curso PHP - Fundamentos
Conhecer o cursoO 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 $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 $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á.
Curso PHP Avançado
Conhecer o cursoLendo grandes arquivos
Vamos supor que precisamos ler um documento de texto linha a linha. Uma das formas tradicionais de lidar com isso seria assim:
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:
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:
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:
- Introdução à programação assíncrona em PHP usando o ReactPHP
- Introdução ao Swoole, framework PHP assíncrono baseado em corrotinas
Até a próxima!