Los generadores de PHP 5.5

20 de junio de 2013

Los generadores son una de las novedades más importantes y útiles introducidas por PHP 5.5. Sin embargo, los generadores también son uno de los conceptos más difíciles de entender cuando no estás acostumbrado a trabajar con ellos.

Los generadores simplifican drásticamente la creación de iteradores. Así que antes de explicar los generadores, es importante repasar qué son y para qué sirven los iteradores. Según la definición de la Wikipedia:

"Un iterador es un objeto que permite recorrer los valores almacenados en un contenedor".

Si utilizas por ejemplo la función range() junto con foreach() en tu código, en la práctica estás iterando sobre una lista de valores:

foreach (range(0, 10) as $numero) {
    echo "Número: $numero \n";
}

Si no existiera la función range() deberías crear un iterador a medida capaz de recorrer una serie de números. Para crear un iterador, crea una nueva clase que extienda de la clase Iterator de PHP. Esta clase Iterator define los siguientes cinco métodos abstractos que debes implementar en tu clase:

class Iterator extends Traversable {
    // devuelve el valor del elemento actual dentro de la iteración
    abstract public mixed current();

    // devuelve la clave del elemento actual dentro de la iteración
    abstract public scalar key();

    // mueve la iteración hasta el siguiente elemento
    abstract public void next();

    // mueve la iteración hasta el anterior elemento
    abstract public void rewind();

    // comprueba si la posición actual de la iteración es válida
    // (es decir, si el objeto iterador sigue teniendo elementos)
    abstract public boolean valid();
}

PHP ya incluye muchos iteradores útiles como ArrayIterator para recorrer arrays y objetos y RecursiveDirectoryIterator para recorrer de forma recursiva los contenidos de un directorio. Puedes consultar la lista de todos los iteradores que incluye PHP en la página Iteradores del manual oficial de PHP.

Volviendo al ejemplo anterior, como no existe un iterador equivalente a la función range(), deberías crearlo a mano extendiendo de la clase Iterator. A continuación se muestra un posible código de este iterador:

class RangeIterator extends Iterator
{
    public function __construct($primero, $ultimo, $salto)
    {
        $this->primero = $primero;
        $this->ultimo  = $ultimo;
        $this->salto   = $salto;
        $this->actual  = $this->primero;
    }

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

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

    public function next()
    {
        $this->actual += $this->salto;
    }

    public function rewind()
    {
        $this->actual = $this->primero;
    }

    public function valid()
    {
        return ($this->actual >= $this->primero)
            && ($this->actual <= $this->ultimo);
    }
}

Como demuestra claramente el código del ejemplo anterior, crear un iterador es algo aburrido y muy costoso. Los generadores son la solución a este problema, ya que tienen funcionalidades similares a los iteradores y cuestan mucho menos crearlos.

Observa el siguiente ejemplo que muestra el código de un generador equivalente a la función range() de PHP y por tanto, equivalente al iterador RangeIterator creado anteriormente:

function genera_numeros($primero, $ultimo, $salto = 1) {
    for ($numero = $primero; $numero <= $ultimo; $numero += $salto) {
        yield $numero;
    }
}

foreach (genera_numeros(0, 10) as $numero) {
    echo "Número: $numero \n";
}

Técnicamente, un generador es muy parecido a una función. La principal diferencia es que en vez de usar la palabra reservada return para devolver un valor, el generador utiliza la palabra reservada yield para devolver uno a uno todos sus valores.

Si la función genera_numero() anterior fuese normal, debería crear un array con todos los números y después devolverlos con la instrucción return $numeros;. Sin embargo, al utilizar yield en vez de return, la función se convierte en un generador. Cuando se ejecute, este generador devuelve un número cada vez, hasta que se cumpla la condición que hace detenerse al generador.

¿Qué sucede si la aplicación necesita 1 millón de números? Con la función normal que genera todos los números y los guarda en un array que devuelve con return, el consumo de memoría sería de más de 100 MB. Con el generador anterior, sólo se devuelve un número cada vez, por lo que el consumo de memoria se reduce a menos de 1 KB.

La forma en la que un generador devuelve sus resultados es la parte más difícil de entender al empezar a trabajar con generadores. Piensa que una función normal tiene que generar toda su información de una vez antes de devolverla con return. Los generadores, de ahí su nombre, generan la información "al vuelo" a medida que se les solicita y la van devolviendo con yield, que es como un return que se puede llamar varias veces sin salirse de la función.

Esto es posible porque cuando se llama a la función del generador, no se ejecuta su código, sino que se devuelve un objeto de la clase Generator. Después, el código del generador se ejecuta paso a paso dentro del foreach.

El resultado devuelto por yield también puede estar compuesto por una clave y un valor, lo que es ideal para simular que se está recorriendo un array asociativo. El siguiente ejemplo muestra un generador que utiliza cURL para realizar peticiones HTTP a las URL indicadas:

function getUrls($urls)
{
    foreach($urls as $url) {
        $curl = curl_init($url);

        yield $url => curl_exec($curl);
    }
}

foreach(getUrls(array(...)) as $url => $contenido) {
    // ...
}

Los generadores se pueden combinar entre sí (un generador devuelve un valor con yield que a su vez ha obtenido a través de otro generador que también ha utilizado el yield) y también se pueden combinar con los iteradores. El siguiente ejemplo muestra cómo combinar iteradores y generadores para recorrer todos los contenidos de un directorio y si existe algún archivo comprimido, también se muestran sus contenidos:

function recorreArchivoZip($archivoComprimido)
{
    $zip = zip_open($archivoComprimido);
    $elemento = zip_read($zip);

    while($elemento !== false) {
        yield $archivoComprimido."/".zip_entry_name($elemento);
    }

    zip_close($archivoComprimido);
}

function recorreDirectorio($archivos)
{
    foreach($archivos as $archivo) { 
        if(fnmatch('*.zip', $archivo)) {
            foreach(recorreArchivoZip($archivo) as $elemento) {
                yield $elemento;
            }
        } else {
            yield $archivo;
        }
    }
}

$dir = new DirectoryIterator("...");
foreach(recorreDirectorio($dir) as $archivo) {
    print $archivo."\n";
}

La principal limitación de los generadores que no se puede controlar su ejecución tal y como sucede en un iterador. Como los iteradores disponen de los métodos next() y rewind(), puedes ir hacia adelante o hacia atrás las veces que necesites. Los generadores no disponen de ese método rewind() por lo que siempre se ejecutan desde el principio hasta el final y en un único sentido.

Otra limitación de los generadores derivada de su incapacidad para ir hacia atrás es que no puedes ejecutar el mismo generador más de una vez. Si quieres repetir la ejecución, tienes que clonar el generador.

Referencias útiles

El manual oficial de PHP contiene varios artículos interesantes sobre los generadores.

Los ejemplos utilizados en este artículo han sido extraídos de la presentación Looking ahead: PHP 5.5 impartida por David Soria en la conferencia PHP UK 2013.