Wextensible

Los iterables y la ventaja de su existencia

Figura
Figura. Un Array en JavaScript implementa Symbol.iterator

Un objeto es iterable si contiene el símbolo Symbol.iterator. Esa propiedad es una función que al ser ejecutada devuelve otro objeto iterador, cuya finalidad es precisamente iterar por el objeto iterable. En el vínculo anterior hicimos una exposición resumida sobre iterables e iteradores. En este tema profundizaremos más sobre este asunto.

El concepto de objetos iterables se introduce en ES6 para poder aplicar técnicas que necesitan iterar por los elementos de un objeto, como en un Array por ejemplo. Ya ES6 introduce algunas donde entra en juego este concepto: destructuring (desestructurado de datos) vía patrón de Array; el operador de propagación; métodos de Array que permiten construir un nuevo Array a partir de un iterable como Array.from(iterable); el bucle for of, como se observa en la Figura.

Cualquiera de las técnicas anteriores y otras existentes y nuevas que puedan implementarse no dependen del objeto sobre el que vamos a iterar. Sólo necesitan que ese objeto tenga el método Symbol.iterator para poder usarlas. Si en el futuro se crea un nuevo built-in que venga con un Symbol.iterator entonces podríamos usarlo con las técnicas actuales. Y a su vez la implementación de técnicas futuras no necesitarían modificaciones en los métodos Symbol.iterator de los objetos existentes.

Los built-in como String, Array, Map, Set e incluso arguments son iterables. Pero un Object no posee Symbol.iterator, por lo que no es iterable. En el siguiente código podrá ver que los primeros devuelven el tipo function para Symbol.iterator, mientras que con un Object devuelve undefined.

//String, Array, Set, Map y arguments son iterables
console.log(typeof ""[Symbol.iterator]); // function
console.log(typeof [][Symbol.iterator]); // function
console.log(typeof new Set()[Symbol.iterator]); // function
console.log(typeof new Map()[Symbol.iterator]); // function
function fun(){
    console.log(typeof arguments[Symbol.iterator]);
}
fun(); // function
//Object no es iterable
console.log(typeof {}[Symbol.iterator]); // undefined
    

Para entender la importancia de los iterables, observe el siguiente código donde el método del DOM querySelectorAll() también implementa Symbol.iterator. Si tenemos que iterar por un conjunto de elementos del DOM, la iterabilidad nos facilitará mucho las cosas:

<div id="a" class="x">A</div>
<div id="b" class="x">B</div>
<div id="c" class="x">C</div>
<script>
    let elementos = document.querySelectorAll(".x");
    console.log(elementos.constructor.name); // NodeList
    console.log(typeof elementos[Symbol.iterator]); // function
    //Podemos usar el bucle for of
    let cad = "";
    for (let elemento of elementos){
        cad += elemento.innerHTML;    
    }
    console.log(cad); // "ABC"
    //O el operador de propagación para usar métodos de Array
    cad = [...elementos].reduce((p, v) => p+v.innerHTML, "");
    console.log(cad); // "ABC"
    //O destructuring con patrón de Array para obtener variables
    //individuales que apunten a los elementos
    let [a, b, c] = elementos;
    console.log(a); // <div id="a" class="x">A</div>
    console.log(b); // <div id="b" class="x">B</div>
    console.log(c); // <div id="c" class="x">C</div>
</script>    
    

El método Symbol.iterator

Algunos objetos built-in ya vienen de fábrica con un método Symbol.iterator, por lo que no debemos preocuparnos qué hace exactamente. Pero puede ser interesante saber como implementarlo en objetos que no lo posean. Un Object como el siguiente no dispone de ese método y por tanto no podemos usar técnicas de iterables como el operador de propagación en Array:

//Un objeto Object no es iterable
let obj = {0: "a", 1: "b", length: 2};
//pues no dispone del método iterador
console.log(typeof obj[Symbol.iterator]); // undefined
//No podemos usar una técnica para iterables
try {
    console.log([...obj]);
} catch(e){
    console.log(e.message); // obj[Symbol.iterator] is not a function
}
    

Vamos a incorporarle al objeto anterior un método iterador. Ha de devolver un objeto con un método next(), de tal forma que cada vez que se ejecute devuelva un elemento del objeto. Vea que el objeto devuelto por next() contiene una propiedad value y otra done que indica si ya hemos finalizado la iteración.

//Será iterable si le ponemos un método iterador
obj[Symbol.iterator] = function() {
    let index = 0, obj = this;
    return {
        next: function(){
            if (index < obj.length){
                return {value: obj[index++], done: false};
            } else {
                return {value: undefined, done: true}
            }
        }
    };
}
//Ahora el objeto es iterable
console.log(typeof obj[Symbol.iterator]); //function
//Extraemos el objeto iterador
let iterador = obj[Symbol.iterator]();
console.log(iterador.next()); // Object {value: "a", done: false}
console.log(iterador.next()); // Object {value: "b", done: false}
console.log(iterador.next()); // Object {value: undefined, done: true}
//Podemos ahora usar una técnica para iterables
console.log([...obj]); // ["a", "b"]
    

Lo anterior nos sirve para explicar como funciona un método iterador, pero podemos conseguir el mismo resultado de otras formas. Pues si un objeto como el del ejemplo tiene propiedades que son números enteros no negativos consecutivos desde cero y, además, tiene una propiedad length con el total de elementos, podemos considerarlo un array-like. Esto quiere decir que es como un array y algunos métodos de Array son capaces de convertirlos en un Array, como Array.from(). Y ya convertido en Array es, por supuesto, iterable y le podemos aplicar cualquier técnica de iterables.

let obj = {0: "a", 1: "b", length: 2};
console.log(Array.from(obj)); // ["a", "b"]
    

Podríamos también dotar de un método iterador a un objeto que no posea claves numéricas, como el siguiente constructor:

function Persona(nombre, edad){
    this.nombre = nombre;
    this.edad = edad;
}
Persona.prototype[Symbol.iterator] = function(){
    let props = Object.keys(this); // ["nombre", "edad"]
    let index = -1, obj = this;
    return {
        next: function(){
            index++;
            if (index < props.length){
                return {value: `${props[index]}: ${obj[props[index]]}`, 
                        done: false};
            } else {
                return {value: undefined, done: true}
            }
        }
    };
}
let juan = new Persona("Juan", 27);
console.log([...juan]); // ["nombre: Juan", "edad: 27"]
    

Con Object.keys(this) obtenemos un Array con las claves del objeto sobre el que estamos aplicando el método. Así que en el fondo no estamos iterando por el objeto sino por sus claves. Y al igual que antes, si tenemos una forma tan sencilla de obtener un Array con las claves, todo el código anterior podría simplificarse ayudándonos con el método map():

function Persona(nombre, edad){
    this.nombre = nombre;
    this.edad = edad;
}
let juan = new Persona("Juan", 27);
let props = Object.keys(juan);
console.log(props); // ["nombre", "edad"]
console.log(props.map(v => `${v}: ${juan[v]}`));
    // ["nombre: Juan", "edad: 27"] 
    

Método iterador predeterminado

Figura
Figura. Un Array tiene el método Symbol.iterator.

En el tema sobre Arrays puse un apartado detallando aspectos del bucle for of. Ahí comentaba que el método values() es el predeterminado en un Array para el Symbol.iterator. Esto quiere decir que cuando usamos for (let v of arr) siendo arr un Array, se usará el método values() para obtener un objeto iterador. Esto se puede observar en la Figura, donde vemos que Symbol.iterator apunta a values().

Los Array, Set e incluso arguments tienen el método values() como iterador predeterminado. En cambio para Map es el método entries(). Para los String su Symbol.iterator no apunta a ningún otro método.

console.log([][Symbol.iterator]); // function values(){...}
console.log(new Set()[Symbol.iterator]); // function values(){...}
(function fun(){
    console.log(arguments[Symbol.iterator]); // function values(){...}
})();
console.log(new Map()[Symbol.iterator]); // function entries(){...}
console.log(""[Symbol.iterator]); // function [Symbol.iterator](){...}
    

Así que como fuente iterable de un for of podemos usar el propio objeto, la ejecución de su método [Symbol.iterator](), o la de su método values() si es un Array/Set/Arguments o entries() si es un Map.

//Para un Array
let arr = ["a", "b", "c"];
//Iteramos usando como fuente el propio Array
let cad = ""; for (let v of arr) cad += v;
console.log(cad); // "abc"
//O ejecutando el Symbol.iterator()
cad = ""; for (let v of arr[Symbol.iterator]()) cad += v;
console.log(cad); // "abc"
//O ejecutando su método values()
cad = ""; for (let v of arr.values()) cad += v;
console.log(cad); // "abc"

//Para un String
let str = "abc";
//Iteramos usando como fuente el propio String
cad = ""; for (let v of str) cad += v;
console.log(cad); // "abc"
//O ejecutando el Symbol.iterator()
cad = ""; for (let v of str[Symbol.iterator]()) cad += v;
console.log(cad); // "abc"
    

Si anulamos el Symbol.iterator de un Array, aún podremos seguir usando values(). Aunque ya no podremos usar el propio Array como fuente iterable.

let arr = ["a", "b", "c"];
//Anulamos su método iterador
arr[Symbol.iterator] = null;
//Por supuesto que no podemos usarlo
try {
    let cad = ""; for (let v of arr[Symbol.iterator]()) cad += v;
    console.log(cad); 
} catch(e){
    console.log(e.message); // arr[Symbol.iterator] is not a function
}
//pero tampoco podemos usar el Array como fuente iterable
try {
    let cad = ""; for (let v of arr) cad += v;
    console.log(cad); 
} catch(e){
    console.log(e.message); // arr[Symbol.iterator] is not a function
}
//Aunque values() sigue siendo operativo
let cad = ""; for (let v of arr.values()) cad += v;
console.log(cad); // "abc"
    

Iteradores que son también iterables

Ya vimos que la ejecución sobre un Array de un método iterador arr[Symbol.iterator]() nos devuelve un objeto iterador (un ArrayIterator). Pero lo curioso es que también ese objeto puede ser iterable. En el siguiente código lo ponemos en un for of y vemos que hace lo mismo que si hubiésemos puesto el Array:

let arr = ["a", "b", "c"];
let iter = arr[Symbol.iterator]();
console.log(iter); // ArrayIterator {}
let cad = ""; 
for (let v of iter) cad += v;
console.log(cad); // "abc"
//Hace lo mismo que con el Array
cad = ""; 
for (let v of arr) cad += v;
console.log(cad); // "abc"
    

Además ejecutando repetidamente el objeto iterador siempre obtenemos el mismo objeto:

let arr = ["a", "b", "c"];
//Ejecutando el Symbol.iterator dos o más veces
//siempre obtenemos el mismo objeto iterador
let iter2 = arr[Symbol.iterator]()[Symbol.iterator]();
cad = ""; for (let v of iter2) cad += v;
console.log(cad); // "abc"
    

Esto es porque el método iterador de un Array apunta a sí mismo. Lo vemos mejor con el ejemplo donde implementábamos un método iterador para un objeto cualquiera. Se observa como añadiendo un puntero a si mismo, en las subsiguientes ejecuciones del primer objeto iterador obtenemos una referencia a ese mismo objeto:

function agregarIterador(obj){
    obj[Symbol.iterator] = function() {
        let props = Object.keys(this), index = -1;
        return {
            //Un puntero a sí mismo devolverá en las subsiguientes
            //ejecuciones este mismo objeto
            [Symbol.iterator]: function() {
                return this;
            },
            next: function(){
                index++;
                if (index < props.length){
                    return {value: `${props[index]}: 
                                    ${obj[props[index]]}`, 
                            done: false};
                } else {
                    return {value: undefined, done: true};
                }
            }
        };
    }
}
let objeto = {a: 1, b: 2};
agregarIterador(objeto);
let iter2 = objeto[Symbol.iterator]()[Symbol.iterator]();
cad = ""; for (let v of iter2) cad += v + ", ";
console.log(cad); // "a: 1, b: 2, "
    

Estando ubicado un objeto iterador en un for of, será automáticamente ejecutado su método interno que apunta a sí mismo devolviendo el propio objeto.

Esto es interesante porque un objeto iterador empieza a iterar por el primer elemento y cuando llega al último se finaliza la iteración. Pero podríamos detenerlo en medio y recuperarlo posteriormente para proseguir la iteración en el punto donde lo detuvimos.

Veámos este ejemplo interactivo siguiente. Se trata de iterar por un texto tomando un número determinado de palabras. En esta funcion para paginar el texto manejamos un iterador y lo hacemos iterar desde el punto donde se encuentre tras la parada anterior, pues cada numItems leídos volveremos a cortar la iteración:

function paginar(iterador, numItems){
    let num = 0, conten = document.getElementById("quijote-iter");
    conten.textContent = "";
    for (let item of iterador){
        //hacer algo con el item
        conten.textContent += item + " ";
        num++;
        if (num == numItems) return false;
    }
    return true;
}
    

La función anterior devolverá un booleano para indicar si finalizó la iteración. Controlamos el proceso con un botón que iniciará la iteración e irá extrayendo elementos hasta la finalización:

let quijote = document.getElementById("quijote").textContent.trim();
quijote = quijote.replace(/\s+/g, " ");
let elementos = quijote.split(" ");
let iter = null;
document.getElementById("iniciar-iter").addEventListener("click", 
(event) => {
    if (iter === null){
        iter = elementos[Symbol.iterator]();
    }
    let mensajeIter = document.getElementById("mensaje-iter");
    //Paginamos cada 25 palabras
    if (paginar(iter, 25)){
        event.target.value = "Iniciar";
        mensajeIter.textContent = "Finalizado";
        iter = null;
    } else {
        event.target.value = "Extraer";
        mensajeIter.textContent = "Quedan por extraer";
    }
}, false);
    

Ejemplo: Paginar texto con un iterador iterable

En un lugar de la Mancha, de cuyo nombre no quiero acordarme, no ha mucho tiempo que vivía un hidalgo de los de lanza en astillero, adarga antigua, rocín flaco y galgo corredor. Una olla de algo más vaca que carnero, salpicón las más noches, duelos y quebrantos los sábados, lantejas los viernes, algún palomino de añadidura los domingos, consumían las tres partes de su hacienda. El resto della concluían sayo de velarte, calzas de velludo para las fiestas, con sus pantuflos de lo mesmo, y los días de entresemana se honraba con su vellorí de lo más fino.
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

Cierre de un objeto iterador

Cuando usamos un Array como fuente de datos de un bucle for of, automáticamente se ejecuta el método iterador [Symbol.iterator]() de ese Array. Por eso en cada bucle de los siguientes empieza siempre la iteración desde el inicio del Array, incluso después del segundo caso donde salimos antes de finalizar con break:

let arr = ["a", "b", "c", "d"];
//Primera iteración finalizada
let cad = "";
for (let item of arr){
    cad += item;
}
console.log(cad); // "abcd"
//Segunda iteración no finalizada
cad = "";
for (let item of arr){
    cad += item;
    if (item > "a") break;
}
console.log(cad); // "ab"
//Tercera iteración empieza otra vez 
//desde el inicio del Array
cad = "";
for (let item of arr){
    cad += item;
}
console.log(cad); // "abcd"
    

Sin embargo si usamos el propio objeto iterador como fuente del bucle y salimos antes de finalizar, el objeto conservará su puntero en la posición donde nos paramos. Esto lo vimos en el apartado anterior y se observa en estos ejemplos.

let arr = ["a", "b", "c", "d"];
let iter = arr[Symbol.iterator]();
//Primera iteración no finalizada
let cad = "";
for (let item of iter){
    cad += item;
    if (item > "a") break;
}
console.log(cad); // "ab"
//Siguiente iteración continua 
//a partir de la parada anterior
cad = "";
for (let item of iter){
    cad += item;
}
console.log(cad); // "cd"
//Siguientes iteraciones no 
//producen nada: el iterador finalizó
cad = "";
for (let item of iter){
    cad += item;
}
console.log(cad); // ""
    

Se observa en el segundo bucle que la iteración finalizó, así que subsiguientes iteraciones ya no producirán ningún valor. Podríamos reiniciar el objeto iterador volviendo a ejecutar el método arr[Symbol.iterator](). Pero la cuestión, es si podríamos reiniciar un objeto iterador en cualquier momento sin necesidad de ejecutar de nuevo el método. La respuesta es que no para un Array y en general para los iterables built-in. Pero si podríamos con un iterable construido por nosotros.

La siguiente función sobrescribe el método iterador de un Array, de forma similar a como hicimos en un apartado anterior para un objeto. En una variable local guardamos el índice del Array por donde interaremos. Los métodos los indicamos ahora usando la nueva notación de ES6 que no necesita los dos puntos, es decir, en lugar de metodo: function(){ } lo expresamos directamente con metodo(){ }. Los métodos next() y el que apunta a sí mismo son, como ya hemos visto, minimamente necesarios para estructurar un objeto iterador. Pero ahora además añadimos el método opcional return(). Vemos que inicializa el índice y devuelve un objeto que indica que la iteración finalizó.

function agregarIterador(arr){
    arr[Symbol.iterator] = function() {
        let index = -1;
        return {
            [Symbol.iterator]() {
                return this;
            },
            next(){
                index++;
                if (index < arr.length){
                    return {value: arr[index], done: false};
                } else {
                    return {value: undefined, done: true};
                }
            },
            return(){
                console.log("CIERRE");
                index = -1;
                return {value: undefined, done: true};
            }

        };
    }
}
let arr = ["a", "b", "c", "d"];
agregarIterador(arr);
let iter = arr[Symbol.iterator]();
    

Con lo anterior haremos algunas pruebas. En primer lugar saldremos anticipadamente de un bucle for of usando break. Se observa que se ejecuta automáticamente el método return() del iterador, pues obtenemos la cadena "CIERRE" por la consola.

//Primera iteración no finalizada
let cad = "";
for (let item of iter){
    cad += item;
    if (item > "a") break; //"CIERRE"
}
console.log(cad); // "ab"
    

El cierre del iterador lo reinicializa, tal como se observa si volvemos a iterar en un nuevo bucle:

//Siguiente iteración vuelve a empezar
cad = "";
for (let item of iter){
    cad += item;
}
console.log(cad); // "abcd"
//Siguiente iteración no produce nada:
//el iterador finalizó
cad = "";
for (let item of iter){
    cad += item;
}
console.log(cad); // ""
    

En el ejemplo anterior la última iteración no produce nada, porque previamente dejamos finalizar el bucle y el iterador se vació. Podemos reiniciarlo con la ejecución de iter.return() y sin necesidad de volver a usar arr[Symbol.iterator]():

//Pero si ejecutamos return() del iterador
//lo reiniciamos otra vez
iter.return(); // "CIERRE"
cad = "";
for (let item of iter){
    cad += item;
}
console.log(cad); // "abcd"
    

Funciones iteradoras

Hemos visto que podemos agregar un método iterarador a un objeto cualquiera. Pero podríamos pensar en general en una función iteradora como aquella que devuelve un objeto iterador, compuesto minímamente por un método next() que devuelva un objeto resultado con las propiedades value y done. Así por ejemplo la siguiente función es un iterador para una sucesión de Fibonacci:

function fibonacci(max) {
    let n = -1, ultimo = 1, penultimo = 1;
    return {
        [Symbol.iterator]() {
            return this;
        },
        next(){
            n++;
            if (n < max){
                let actual = (n > 1) ? ultimo + penultimo : 1;
                [penultimo, ultimo] = [ultimo, actual];
                return {value: actual, done: false};
            } else {
                return {value: undefined, done: true};
            }
        }
    };
}
let cad = "";
for (let item of fibonacci(7)){
    cad += item + ", ";
}
console.log(cad); // 1, 1, 2, 3, 5, 8, 13,
    

Cualquier función podria servir para realizar estos cálculos, pero si lo hacemos con una función iteradora tenemos la ventaja de que podemos usar el objeto iterador resultante como fuente iterable donde proceda. Como en este ejemplo que obtenemos los números primos hasta el entero vigésimo y usamos el operador de propagación de Array o el método Array.from() como fuentes del iterable:

function primos(hasta) {
    let n = 0;
    return {
        [Symbol.iterator]() {
            return this;
        },
        next(){
            while (n++ < hasta){
                if (n % 2 > 0) {
                    let sq = Math.floor(Math.sqrt(n));
                    if (sq%2==0) sq--;
                    let esPrimo = true;
                    for (let i=sq; i>2; i=i-2){
                        if (n % i == 0){
                            esPrimo = false;
                            break;
                        }
                    }
                    if (esPrimo) return {value: n, done: false};
                }
            }
            return {value: undefined, done: true};
        }
    };
}
console.log([...primos(20)]); // [1, 3, 5, 7, 11, 13, 17, 19]
console.log(Array.from(primos(20))); // [1, 3, 5, 7, 11, 13, 17, 19] 
    

Antes de acabar este tema quisiera comentar que una función generadora también puede ser una fuente iterable para un for of, el operador de propagación de Array o el método Array.from(). El ejemplo de los números primos lo convertimos en una función generadora. En cada ejecución al llegar al yield la función generadora se detiene devolviendo ese valor, prosiguiendo en la siguiente llamada en ese punto del código.

function* primos(hasta){
    let n = 0;
    while (n++ < hasta){
        if (n % 2 > 0) {
            let sq = Math.floor(Math.sqrt(n));
            if (sq%2==0) sq--;
            let esPrimo = true;
            for (let i=sq; i>2; i=i-2){
                if (n % i == 0){
                    esPrimo = false;
                    break;
                }
            }
            if (esPrimo) yield n;
        }
    }
}
console.log([...primos(20)]); // [1, 3, 5, 7, 11, 13, 17, 19]
console.log(Array.from(primos(20))); // [1, 3, 5, 7, 11, 13, 17, 19]
let cad = "";
for (let item of primos(20)){
    cad += item + ", ";
}
console.log(cad); // 1, 3, 5, 7, 11, 13, 17, 19,