26 de agosto de 2022

Iterables

Los objetos iterables son una generalización de arrays. Es un concepto que permite que cualquier objeto pueda ser utilizado en un bucle for..of.

Por supuesto, las matrices o arrays son iterables. Pero hay muchos otros objetos integrados que también lo son. Por ejemplo, las cadenas o strings son iterables también. Como veremos, muchos operadores y métodos se basan en la iterabilidad.

Si un objeto no es técnicamente una matriz, pero representa una colección (lista, conjunto) de algo, entonces el uso de la sintaxis for..of es una gran forma de recorrerlo. Veamos cómo funciona.

Symbol.iterator

Podemos comprender fácilmente el concepto de iterables construyendo uno.

Por ejemplo: tenemos un objeto que no es un array, pero parece adecuado para for..of.

Como un objeto range que representa un intervalo de números:

let range = {
  from: 1,
  to: 5
};

// Queremos que el for..of funcione de la siguiente manera:
// for(let num of range) ... num=1,2,3,4,5

Para hacer que el objeto range sea iterable (y así permitir que for..of funcione) necesitamos agregarle un método llamado Symbol.iterator (un símbolo incorporado especial usado solo para realizar esa función).

  1. Cuando se inicia for..of, éste llama al método Symbol.iterator una vez (o genera un error si no lo encuentra). El método debe devolver un iterador : un objeto con el método next().
  2. En adelante, for..of trabaja solamente con ese objeto devuelto.
  3. Cuando for..of quiere el siguiente valor, llama a next() en ese objeto.
  4. El resultado de next() debe tener la forma {done: Boolean, value: any}, donde done=true significa que el bucle ha finalizado; de lo contrario, el nuevo valor es value.

Aquí está la implementación completa de range:

let range = {
  from: 1,
  to: 5
};

// 1. Una llamada a for..of inicializa una llamada a esto:
range[Symbol.iterator] = function() {

  // ... devuelve el objeto iterador:
  // 2. En adelante, for..of trabaja solo con el objeto iterador debajo, pidiéndole los siguientes valores
  return {
    current: this.from,
    last: this.to,

    // 3. next() es llamado en cada iteración por el bucle for..of
    next() {
      // 4. debe devolver el valor como un objeto {done:.., value :...}
      if (this.current <= this.last) {
        return { done: false, value: this.current++ };
      } else {
        return { done: true };
      }
    }
  };
};

// ¡Ahora funciona!
for (let num of range) {
  alert(num); // 1, luego 2, 3, 4, 5
}

Note una característica fundamental de los iterables: separación de conceptos.

  • El range en sí mismo no tiene el método next().
  • En cambio, la llamada a range[Symbol.iterator]() crea un otro objeto llamado “iterador”, y su next() genera valores para la iteración.

Por lo tanto, el objeto iterador está separado del objeto sobre el que itera.

Técnicamente, podríamos fusionarlos y usar el range mismo como iterador para simplificar el código.

De esta manera:

let range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() {
    this.current = this.from;
    return this;
  },

  next() {
    if (this.current <= this.to) {
      return { done: false, value: this.current++ };
    } else {
      return { done: true };
    }
  }
};

for (let num of range) {
  alert(num); // 1, luego 2, 3, 4, 5
}

Ahora range[Symbol.iterator]() devuelve el objeto range en sí: tiene el método next()