Publi

Iteradores y generadores en PHP o por qué deberíamos utilizar yield más a menudo [con ejemplos]

Es uno de los grandes desconocidos de PHP. Una de esas características que, estando presentes en otros lenguajes no son tan ampliamente utilizadas como deberían. De hecho, en PHP, ¿cuántas veces hemos iterado con datos que tenemos en memoria? Es decir, recorremos los elementos de un array uno a uno y realizando operaciones con cada uno de ellos. Entonces pueden pasar varias cosas, puede que los recorramos todos, o puede que cuando se cumpla una determinada condición decidamos que no vamos a recorrer más. Y en muchas ocasiones, tal vez almacenamos un array completo de elementos en memoria (y PHP puede hacer que estas estructuras sean muy grandes).

Está claro que lo más cómodo es tenerlo todo almacenado en un array y recorrer todos los elementos, lo podemos hacer con un for, while, foreach… de toda la vida. Y si el array es pequeño (tanto en número de elementos como en el tamaño de dichos elementos) no pasa nada. No vamos a notar pérdidas de velocidad ni picos de memoria. Pero si nuestro programa está extrayendo información, por ejemplo, a través de API, leyendo archivos CSV (por ejemplo), haciendo consultas de muchos datos por MySQL, etc, sí que podemos notar que nuestros scripts se quedan sin memoria (o pueden quedarse sin ella). Por otro lado podremos crear soluciones mucho más elegantes.

¿Qué es eso de un iterador?

Los iteradores son objetos utilizados para controlar el comportamiento de los bucles. Simplificando mucho, cuando estamos ante un bucle, tenemos un valor inicial, una condición de continuación y un paso, por ejemplo:

  • valor inicial: i=0
  • condición de continuación: i<10
  • paso: i++

Con eso, contamos desde 0 mientras el valor de i es menor que 10 y en cada iteración vamos incrementando su valor.

1
2
3
<?php
for ($i=0; $i<10; $i++)
  echo $i."\n";

Y contaremos desde 0 hasta 9. Y lo mismo hacemos cuando tenemos elementos de un array, lo recorremos desde su posición inicial y vamos incrementando la posición hasta que hemos recorrido todos los elementos de dicho array (imaginando un bucle for. Luego podemos hacerlo con foreach que tiene un lenguaje más natural, pero es lo mismo. Bueno, hasta aquí no he contado nada nuevo. Lo interesante viene en que tanto el valor inicial como la condición de continuación y el paso pueden ser tareas más complejas como llamadas a funciones y podemos estar manejando estructuras algo más grandes y complejas. Es decir, hacemos que los valores sobre los que iteramos vayan cambiando en cada iteración, es decir, iremos generando valores a cada vuelta del bucle.

Por ejemplo, a modo muy chapucero, vamos a iterar sobre unas funciones que van a devolver líneas de un archivo llamado muchaslineas.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php

function inicial()
{
    global $fd;

    $fd = fopen("muchaslineas.txt", "r");
    return fgets($fd);
}

function siguiente()
{
    global $fd;

    return fgets($fd);
}

function termina()
{
    global $fd;

    if (!feof($fd))
        return false;

    fclose($fd);
    return true;
}

for ($dato=inicial(); !termina(); $dato=siguiente())
    echo $dato."\n";

Lo importante es ver el último bucle for en el que hemos traído las funciones que hemos creado arriba para recorrerlo, inicialmente se llamará a inicial() y en cada iteración del bucle comprobaremos si !termina() no hemos terminado y si es así $dato será el siguiente(). Perfectamente podríamos llamar a las funciones de fichero desde el bucle for, pero es mucho más humano hacerlo así (aunque todavía algo chapucero). Por otro lado, si estamos diferenciando las partes de nuestro código encargadas de cada tarea tal vez no nos venga bien leer un fichero en este bucle, por ejemplo si estamos programando con un patrón MVC, estamos trabajando en vista o controlador, y ese tipo de cosas son parte del modelo.

Un iterador en PHP

Pero en PHP tenemos una interfaz llamada Iterator utilizada para implementar iteradores. En ella tenemos algunos métodos más que nos serán útiles en nuestro iterador, sobre todo para control de errores, para rebobinar y para saber el índice del elemento.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?php
/* Este ejemplo es muy típico y podemos encontrarlo en muchos lugares,
     pero lo he adaptado un poco.*/

class Lineas implements Iterator
{
    protected $fh;

    protected $line;
    protected $i;

    public function __construct($fileName)
    {
        if (!$this->fh = fopen($fileName, 'r'))
        {
            throw new RuntimeException('Couldn\'t open file "' . $fileName . '"');
        }
    }

    public function rewind()
    {
        var_dump(__METHOD__);

        fseek($this->fh, 0);
        $this->line = fgets($this->fh);
        $this->i = 0;
    }

    public function valid()
    {
        var_dump(__METHOD__);
        return false !== $this->line;
    }

    public function current()
    {
        var_dump(__METHOD__);
        return $this->line;
    }

    public function key()
    {
        var_dump(__METHOD__);
        return $this->i;
    }

    public function next()
    {
        var_dump(__METHOD__);
        if (false !== $this->line)
        {
            $this->line = fgets($this->fh);
            $this->i++;
        }
    }

    public function __destruct()
    {
        var_dump(__METHOD__);
        fclose($this->fh);
    }
}

$lineas = new Lineas("muchaslineas.txt");

foreach ($lineas as $linea)
    echo $linea."\n";

?>

Como vemos, podemos implementar un Iterator inclyendo los siguientes métodos:

  • __construct() : Constructor, para inicializar nuestro objeto. Abrir un archivo, conectar con un servidor, realizar una petición, o lo que se nos ocurra
  • __destruct() : Libera nuestras estructuras, cierra archivos, desconecta de servidores, cierra peticiones, etc.
  • rewind() : Rebobina nuestro iterador, vuelve al primer elemento. Cuando volvamos a pedirle un elemento al objetvo, volverá a devolver el primero.
  • valid() : ¿El elemento actual es válido?
  • current() : Devuelve el elemento actual.
  • key() : Devuelve el índice del elemento actual.
  • next() : Nos movemos al siguiente elemento.

¿Beneficio de todo esto?

Como indiqué un poco más arriba, podemos mezclar el código la obtención de la ristra de datos (lectura de fichero) con el recorrido posterior que hacemos en los datos. Este recorrido puede implicar una búsqueda, presentación u operaciones sobre esos datos, aunque por un lado, ganamos elegancia, legibilidad y hacemos nuestro código un poco más fácil de mantener que si mezclamos la obtención y el procesamiento en el mismo lugar y, puede que más complejo si la fuente de datos no es tan directa como en el ejemplo: pensemos que es un dato que puede venir de una API o de caché que puede estar en base de datos o memoria. Además, disminuimos la utilización de memoria que conlleva almacenar los datos en un array para luego poder recorrerlos (siempre que hayamos implementado bien el iterador y no hayamos almacenado todo en éste).

Podemos hacer una pequeña prueba de memoria utilizando las funciones memory_get_usage() y memory_get_peak_usage() mientras recorremos nuestro iterador. (el archivo muchaslineas.txt contiene la obra de William Shakespeare en txt, un total de 124796 líneas y 5.5Mb de tamaño. Además, voy a poner aquí el resultado de la prueba tanto en tiempo como en memoria (memory_peak_usage). El código sin iterador podemos verlo al final del post. También he querido utilizar la función file() de PHP para ver cómo se comporta:

Con iteradorSin iteradorSin iterador (con file())
Tiempo0.210s0.120s0.091s
Memoria0.41Mb17.64Mb22.98Mb

Como vemos, la memoria, recopilando la información en un array de PHP crece mucho y con esto, va bajando el uso de CPU. Es normal, porque en PHP un array ocupa mucha memoria (no es como en C, por ejemplo), y el hecho de utilizar iteradores implica llamadas a funciones y eso también es costoso en un lenguaje interpretado, por lo que crece el uso de CPU. Aunque podemos observar que, aunque con los iteradores, la CPU crece al doble, el uso de memoria es muy reducido, ya que con los iteradores, sólo tendremos un elemento en memoria cada vez.

Yield

Una vez hemos hecho esta introducción a los iteradores (espero poder poner ejemplos en próximos posts sobre todo esto). Vamos a ver en qué puede ayudarnos esta palabra reservada en nuestros proyectos. Y empezaremos a hablar de generadores

Hemos visto que crear un generador puede ser costoso. Es decir, creamos una clase implementando la interfaz Iterator e implementamos ciertos métodos, algunos de los cuales tendrán poco código, pero tenemos que implementarlos. Todo queda muy bien, un código elegante y organizado, pero podemos llegar a tener muchas clases parecidas. No digo que sea malo, pero en ocasiones necesitamos algo más rápido. Y, al mismo tiempo, no queremos, como hasta ahora, tener todos los elementos almacenados en un array, lo que puede llegar a arruinar la memoria del script.

Yield, es una palabra clave que usaremos como si fuera return en una función, eso sí, la podremos llamar todas las veces que queramos, dentro o fuera de bucles, haciendo que la función actúe como un generador y si iteramos sobre él, nos irá devolviendo un elemento cada vez. Pero mejor lo vemos con un ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

function generaLineas($fileName)
{
    if (!$fileHandle = fopen($fileName, 'r'))
        return;

    while (false !== $line = fgets($fileHandle))
    {
        yield $line;
    }

    fclose($fileHandle);
}

$lineas = generaLineas("muchaslineas.txt");

foreach ($lineas as $linea)
{
    echo memory_get_usage()."...".$linea."\n";
}

echo memory_get_peak_usage();

De esta forma, al igual que con los generadores, sólo estamos almacenando un elemento cada vez, por lo que el impacto en memoria es más reducido (recordemos los 17Mb de almacenar todas las líneas del archivo frente a los 400Kb de almacenar una línea cada vez). Además, se harán menos llamadas a funciones, por lo que el impacto en CPU debería ser menor. Así que vamos a meter en nuestra tabla comparativa este nuevo caso:

Con iteradorSin iteradorSin iterador (con file())Con generador (yield)
Tiempo0.210s0.120s0.091s0.125s
Memoria0.41Mb17.64Mb22.98Mb0.40Mb

Y podemos ver que se ha reducido la huella de memoria un poco, así como el tiempo del script, que casi casi se ha equiparado a cuando generamos y procesamos el dato al mismo tiempo. Lo cual es buena noticia, y podemos empezar a crear funciones generadoras para este tipo de tareas y hacer nuestros scripts en PHP mucho más legibles y optimizados, sobre todo si tenemos problemas de límite de memoria y no queremos, o no podemos aumentar el memory_limit de PHP.

No todo es tan bonito…

Bueno, vemos que los generadores (implementando Iterator) nos obligan a la larga a escribir mucho código para implementar la clase, y además, terminan consumiento mucha CPU, aunque nos salvan en memoria. Y vemos también que con yield no tenemos tanto impacto en CPU y prácticamente utilizamos el mismo código (o un par de líneas menos) que para recopilar los datos en un array. Pero algo malo tiene que tener, ¿no?

No podemos rebobinar

Lo primero es que no podemos rebobinar. Es decir, si hemos recorrido un generador que utiliza yield, no podemos volver a recorrerlo (desde la misma variable), es decir. Esto nos dará un error:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

function generaLineas($fileName)
{
// La misma del ejemplo anterior
}

$lineas = generaLineas("muchaslineas.txt");

foreach ($lineas as $linea)
{
    echo $linea."\n";
}

foreach ($lineas as $linea)
{
    echo $linea."\n";
}

echo memory_get_peak_usage();

Es más, vemos que cuando se implementaba Iterator había un método rewind() que aquí no podemos llamar. Eso sí, lo que podríamos hacer, es llamar de nuevo al generador:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

function generaLineas($fileName)
{
// La misma del ejemplo anterior
}

$lineas = generaLineas("muchaslineas.txt");
foreach ($lineas as $linea)
{
    echo $linea."\n";
}

foreach ($lineas as $linea)
{
    echo $linea."\n";
}

echo memory_get_peak_usage();

En ocasiones no sería mucho problema, pero otras veces sí que nos puede suponer un ligero inconveniente. Es más, en PHP, cuando una función devuelve datos con yield, se crea un objeto de tipo Generator. Uno de sus métodos es rewind(), pero no va a rebobinar nada, una vez que hayamos empezado a recorrer nuestro generador, esta función devolverá una excepción. Aunque sí que podemos utilizar esta función para ejecutar nuestra función generadora hasta el primer yield.

Reutilización de nuestra lista de datos

Relacionado con el punto anterior, ya que no podemos rebobinar, nos vemos obligados a ejecutar la función generadora de nuevo si queremos obtener de nuevo la colección de elementos, lo que en algunos casos puede suponer un uso extra de CPU. Ya somos nosotros los que tenemos que evaluar qué es más económico, generar los datos dos veces o guardarlo todo en memoria y utilizar menos CPU. Es cierto que las versiones modernas de PHP están muy optimizadas, pero si la obtención de datos implica pedir de nuevo información a un sistema externo (APIs, bases de datos, etc) tal vez no nos compense generar dos veces. Aunque podríamos utilizar cachés… bueno, ya depende de cada caso particular.

No hay acceso aleatorio

Otro problema es que no tenemos acceso aleatorio. Esto es, no podemos acceder al elemento 25, luego al 50 y más tarde al 12. Ya que los elementos se van obteniendo sobre la marcha, si queremos el elemento 25 tendríamos que recorrer todos los elementos que se van generando hasta el 25, por lo que la complejidad del algoritmo de acceso aumentaría (en PHP, el acceso a un elemento de un array es O(1), aquí tendríamos un O(n)) y nos obligaría a llamar de nuevo al generador si queremos buscar otro elemento (ya que si el índice es anterior al que acabamos de buscar no podríamos encontrarlo).

Con esta restricción se acabó también ordenar los elementos (aunque podríamos utilizar un generador para extraer los elementos de una fuente e ir metiéndolos en un array de forma ordenada o con el criterio que necesitemos). Y tampoco podemos acceder a los elementos anterior y posterior, el anterior podemos ir almacenándolo explícitamente, el posterior no, aunque podríamos adaptar nuestro bucle.

Cuidado con los bloqueos

Si el recurso del que estamos extrayendo los datos tiene que ser bloqueado mientras extraemos la información y se va a utilizar concurrentemente. Es decir, vienen muchas visitas que vayan a echar mano del mismo recurso. El tiempo que permanece el recurso bloqueado es importante, y éste tiene que permanecer bloqueado mientras el generador esté activo. Ya que los elementos se van generando y utilizando sobre la marcha el tiempo total que permanecerá el recurso bloqueado será superior y esto puede darnos algún disgusto cuando muchos usuarios estén utilizándolo.

Si el recurso tiene que estar bloqueado, también lo estará mientras pasamos los datos a un array, aunque el coste de esto es algo menor. Es un caso especial, pero no está de más tenerlo en cuenta.

Experimentos

Aquí me gustaría poner algunos casos para los que podemos utilizar estos generadores.

¿Cómo saber qué se ejecuta cada vez?

Volvamos a los inicios, y comprendamos en qué orden se va ejecutando cada cosa en nuestro código. Ya que con yield, hay saltos en nuestro código que puede que nos líen un poco y enreden nuestra mente. Así que vamos a introducir trazas en nuestro código con la avanzada técnica de meter echos, var_dumps o print_rs donde se nos ocurra (hay técnicas más avanzadas, pero para algo rápido y para nuestro entorno de desarrollo es suficiente).

Entonces, veamos este código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

function generaLineas($fileName)
{
    echo __FUNCTION__.": COMIENZA LA FUNCION\n";
    if (!$fileHandle = fopen($fileName, 'r'))
        return;
    $i=0;
    echo __FUNCTION__.": VOY A EMPEZAR A LANZAR ELEMENTOS\n";
    while (false !== $line = fgets($fileHandle))
    {
        echo __FUNCTION__.": LANZO UN ELEMENTO\n";
        yield $line;
    }

    echo __FUNCTION__.": FINALIZO ITERACIONES\n";
    fclose($fileHandle);
}

echo "INICIO\n";
$lineas = generaLineas("treslineas.txt");
echo "TENGO EL ITERATOR\n";
/* $lineas->rewind(); */
/* echo "EMPIEZO A ITERAR\n"; */
foreach ($lineas as $linea)
{
    /* if (strlen($linea)>30) */
    /*  break; */
    echo "TENGO LINEA: ".$linea."\n";
}
echo "FIN\n";

Con esto obtendremos una respuesta parecida a esta:

INICIO
TENGO EL ITERATOR
generaLineas: EMPIEZA LA FUNCION
generaLineas: LANZO UN ELEMENTO
TENGO LINEA: Linea 1

generaLineas: LANZO UN ELEMENTO
TENGO LINEA: Linea 2

generaLineas: LANZO UN ELEMENTO
TENGO LINEA: Linea 3

generaLineas: FINALIZO ITERACIONES
FIN

Como vemos, $lineas = generaLineas(“treslineas.txt”); no ha ejecutado nada aún, porque vemos el mensaje que sale inmediatamente después de la ejecución de esa línea. Es cuando empezamos a iterar cuando empieza la función y se lanza el primer elemento, luego vemos “TENGO LINEA: “ y volvemos a la función hasta que se genera el segundo elemento y así hasta finalizar cuando la función generadora también termina y vamos al final del programa.

Hay dos líneas comentadas, con la orden rewind() a nuestro generador, si quitamos el comentario, veremos cómo la función se ejecuta hasta el primer yield.

Parando nuestro generador antes de tiempo

Hasta ahora hemos esperado a que nuestro generador termine, hacemos for o foreach finalizando todas las iteraciones y todo va bien… ¿pero qué pasaría si en un momento determinado dejamos de iterar dejando el generador en mitad? Bien, vamos de nuevo a leer muchaslineas dando 5 iteraciones, eso sí con las trazas del experimento anterior, para ver por dónde va el código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

function generaLineas($fileName)
{
    if (!$fileHandle = fopen($fileName, 'r'))
        return;
    $i=0;
    echo __FUNCTION__.": VOY A EMPEZAR A LANZAR ELEMENTOS\n";
    while (false !== $line = fgets($fileHandle))
    {
        echo __FUNCTION__.": LANZO UN ELEMENTO\n";
        yield $line;
    }

    echo __FUNCTION__.": FINALIZO ITERACIONES\n";
    fclose($fileHandle);
}

echo "INICIO\n";
$lineas = generaLineas("muchaslineas.txt");
echo "TENGO EL ITERATOR\n";
$lineas->rewind();
echo "EMPIEZO A ITERAR\n";
$i=0;
foreach ($lineas as $linea)
{
    if ($i++==5)
        break;
    echo "TENGO LINEA: ".$linea."\n";
}
echo "FIN\n";

Os adelanto la conclusión. El fichero no se cierra, es decir, al romper el bucle, después del último yield que se ha hecho cancelamos la ejecución. Es cierto que para un archivo a estas alturas no pasa nada, PHP se encargará de cerrarlo más adelante, pero a veces es importante que se ejecute todo aquello que hacemos tras los yield por lo que tenemos que buscar una forma de ejecutarlo. Es cierto que utilizando iteradores, esto sería el destructor del mismo y no tendríamos problema. Pero podemos hacer esto:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php

function generaLineas($fileName)
{
    if (!$fileHandle = fopen($fileName, 'r'))
        return;
    $i=0;
    try
    {
        while (false !== $line = fgets($fileHandle))
        {
            yield $line;
        }
    }
    finally
    {
        fclose($fileHandle);
    }
}

$lineas = generaLineas("muchaslineas.txt");

$i=0;
foreach ($lineas as $linea)
{
    if ($i++==5)
        break;
    echo "TENGO LINEA: ".$linea."\n";
}
echo "FIN\n";

Incluyendo la sentencia try { } finally { } ejecutaremos el código para cerrar nuestro generador. Eso sí, de esta forma cerraremos el fichero cuando termine la ejecución del programa. Después de echo “FIN”, que es cuando PHP puede asegurarnos que no vamos a seguir utilizando el generador. Porque perfectamente podríamos seguir iterando tras el foreach. Aunque, si queremos terminar el generador manualmente, sin tener que esperar a que el código finalice podemos hacer varias cosas:

  • $lineas->throw(new Exception(“Mensaje de excepción”)); – Aunque para esto tendríamos que incluir una sentencia catch en el generador. Lo veremos un poco más abajo.
  • unset($lineas) – Así destruimos el objeto, y se ejecuta todo lo que hay en el finally.
  • $lineas = [cualquierotracosa] – Podemos crear un nuevo generador, poner la variable a false o a cualquier otro valor. Y estaremos destruyendo el objeto igual que antes.di>

Lanzando excepciones

El objetivo es lanzar una excepción fuera del generador y que ésta la capture el generador. Las excepciones deben ser utilizadas para los errores y no para controlar el flujo del programa, que si no, queda feo. El caso es que podemos llamar a $lineas->throw() para emitir esa excepción. Veremos que el generador nos va a devolver el elemento actual, pero cambiará su estado y finalizará la generación de elementos saltando después a la sentencia finally, con lo que cerramos el generador de forma correcta:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

function generaLineas($fileName)
{
    if (!$fileHandle = fopen($fileName, 'r'))
        return;
    $i=0;
    try
    {
        while (false !== $line = fgets($fileHandle))
        {
            yield $line;
        }
    }
    catch (Exception $e)
    {
        echo "¡ AY ! Eso me ha dolido: ".$e."\n";
    }
    finally
    {
        fclose($fileHandle);
    }
}

$lineas = generaLineas("muchaslineas.txt");
$i=0;
foreach ($lineas as $linea)
{
    if ($i++==5)
        $lineas->throw (new Exception("TEST"));
}

Búsquedas

En el caso de que tengamos una ristra de datos no ordenada y nos veamos obligados a iterar en toda la lista para realizar una búsqueda de un dato en concreto, los generadores son muy interesantes para ahorrar memoria, ya que si tenemos muchos datos así no estamos obligados a almacenar la lista inicial de datos que, como hemos podido ver puede ser obtenida de diversas fuentes. Aunque para los ejemplos, seguiremos leyendo de muchaslineas.txt:

El criterio que usaré aquí será sacar las líneas de más de 60 caracteres en un array:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php

function generaLineas($fileName)
{
    if (!$fileHandle = fopen($fileName, 'r'))
        return;
    $i=0;
    try
    {
        while (false !== $line = fgets($fileHandle))
        {
            yield $line;
        }
    }
    catch (Exception $e)
    {
        echo "¡ AY ! Eso me ha dolido: ".$e."\n";
    }
    finally
    {
        fclose($fileHandle);
    }
}

$lineas = generaLineas("muchaslineas.txt");
$lineasLargas = array();
foreach ($lineas as $linea)
{
    if (strlen($linea)>60)
        $lineasLargas[] = $linea;
}

echo "Tengo ".count($lineasLargas)." entradas\n";

Como ya hemos visto, el generador se cierra bien, por lo que no tenemos que preocuparnos de eso. Sólo de meter cosas en el array. Aunque, ¿y si utilizamos una función generadora para esto? ¡Pues claro! Y la CPU no se resiente tanto. Además, tenemos ganancia en memoria, ya que no hay array con los resultados, que en este caso son cerca de 20000 líneas y pueden suponer varios Mb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php

function generaLineas($fileName)
{
    if (!$fileHandle = fopen($fileName, 'r'))
        return;
    $i=0;
    try
    {
        while (false !== $line = fgets($fileHandle))
        {
            yield $line;
        }
    }
    catch (Exception $e)
    {
        echo "¡ AY ! Eso me ha dolido: ".$e."\n";
    }
    finally
    {
        fclose($fileHandle);
    }
}

function buscaLargas($fichero, $tamanio)
{
    $lineas = generaLineas("muchaslineas.txt");

    foreach ($lineas as $linea)
    {
        if (strlen($linea)>$tamanio)
            yield $linea;
    }
}

$cuenta=0;
foreach (buscaLargas("muchaslineas.txt", 60) as $lin)
    $cuenta++;

echo "Tengo ".$cuenta." entradas\n";

Y esto perfectamente se podría hacer en casos en que la búsqueda deba finalizar nada más encontrar el primer elemento. Es más, en este caso sí que veremos una ganancia significativa en CPU frente a la utilización de arrays, ya que no tenemos que crear un array completo con los datos antes de realizar la búsqueda. Al ir generando elementos sobre la marcha, nos ahorraremos tener que extraer y meter algunos elementos y eso es bueno para nuestra CPU.

Algunas ideas curiosas

Por cierto, antes dije que no podíamos utilizar valores anteriores… pero, ¿qué tal si creamos una clase que almacena en un array los valores que vayamos generando con yield, justo antes de yieldearlos? Debemos tener cuidado, y asegurarnos de que no nos pasamos de memoria. Incluso puede ser un caché limitado, es decir, cachear los últimos n elementos.

También podemos crear funciones para aplicar una transformación con un callback a los elementos de un array, devolviendo en un generador los elementos transformados, así no gastamos el doble de memoria. Quien dice transformaciones dice: creación de listas HTML, operaciones de cálculo, recorte de cadenas (para generar titulares, por ejemplo), etc.

Podemos utilizar yield para obtener listas de elementos que necesiten un cálculo previo. Es decir, puede que no necesitemos calcular todos los elementos, o incluso que el número de elementos sea infinito. Podemos utilizarlo si utilizamos métodos de aproximaciones sucesivas a un valor que queremos calcular y vamos a dejar de calcular cuando nos hayamos acercado lo suficiente al valor que queremos calcular.

Extras de PHP para manejar yield

Generar un array desde un iterador

PHP nos brinda algunas utilidades para utilizar generadores (e iteradores, porque un generador es un iterador, tiene métodos parecidos). Podemos por ejemplo, convertir un generador o iterador en un array con todo lo generado con iterator_to_array(), esto puede sernos útil si alguna vez necesitamos recorrer un generador más de una vez, así ahorramos la CPU extra que utilizamos (en detrimento de la memoria, pero qué vamos a hacer).

yield de yields

Pues eso, hacer un yield de un generador o de un iterador o mejor dicho, de los elementos a los que éste hace yield. Lo hacemos con la construcción yield from de la siguiente forma (esto lo he sacado del manual de PHP, pero me ha parecido interesante incluirlo):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php
function contar_hasta_diez() {
    yield 1;
    yield 2;
    yield from [3, 4];
    yield from new ArrayIterator([5, 6]);
    yield from siete_ocho();
    return yield from nueve_diez();
}

function siete_ocho() {
    yield 7;
    yield from ocho();
}

function ocho() {
    yield 8;
}

function nueve_diez() {
    yield 9;
    return 10;
}

$gen = contar_hasta_diez();
foreach ($gen as $num) {
    echo "$num ";
}
echo $gen->getReturn();

De hecho, hacemos yield de los elementos de un array, de un generador o un iterador.

Dando la vuelta a yield

Hasta ahora hemos visto yield a la izquierda, es decir: yield [valor], pero… ¿podríamos hacer valor = $yield? ¿Y utilizar las funciones generadoras como receptoras de valores? Vamos a ver qué sale:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
function hazecho()
{
    while (1)
    {
        $valor = yield;
        echo strtoupper($valor)."\n";
    }
}

$echador = hazecho();
$palabras = array ('hola', 'mundo', 'desde', 'poesía', 'binaria');

foreach ($palabras as $palabra)
    $echador->send($palabra);

Ahora tenemos que utilizar el método send() para enviar, en este caso palabras al generador. Y el generador (que ya no debería llamarse generador, porque obtiene datos).

Apéndices

Código de recorrida de fichero sin generador (con fopen/fgets)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

function cargaLineas($filename)
{
    $fh = fopen($filename, 'r');
    if (!$fh)
        die("Bad file");
    $out = array();
    while (!feof($fh))
        $out[] = fgets($fh);

    fclose($fh);
    return $out;
}

$lineas = cargaLineas("muchaslineas.txt");

foreach ($lineas as $linea)
{
    echo memory_get_usage()."...".$linea."\n";
}

echo memory_get_peak_usage();

Código de recorrida de fichero sin generador (con file)

1
2
3
4
5
6
7
8
9
<?php
$lineas = file("muchaslineas.txt");

foreach ($lineas as $linea)
{
    echo memory_get_usage()."...".$linea."\n";
}

echo memory_get_peak_usage();

Foto principal: Andrew Filer

También podría interesarte...

Leave a Reply