Un Patrón por día – Patrón Iterator
Un Patrón por día – Patrón Iterator
Los patrones de diseño son soluciones probadas para problemas genéricos recurrentes, que siguen principios de diseño y buenas prácticas. Esto significa que para ciertos problemas con los que varias personas se encontraron, existen soluciones genéricas que siguen principios como Única Responsabilidad, Alta Cohesión, Bajo Acoplamiento, etc. y en general permiten reutilizar el código y/o aislar los posibles cambios en el sistema. Todo patrón tiene ventajas y desventajas, las cuales hay que evaluar antes de aplicarlo.
Este es el segundo día de esta serie, y le toca al patrón Iterator.
La idea central del patrón Iterator es recorrer los elementos de un contenedor sin exponer la implementación de ese contenedor. Su nombre proviene de la palabra iterar (iterate en inglés), que significa repetir una acción o un proceso. El patrón expone los elementos contenidos en algún contenedor, uno a uno y en un orden definido por el contenedor, de manera tal que se pueda repetir una acción sobre cada uno de estos elementos, posiblemente con una condición de corte (por ejemplo evaluar si es el elemento que buscamos, y detener la búsqueda cuando se encuentra el elemento).
La idea se logra creando una interfaz llamada Iterable, que define un método llamado iterator() o getIterator(). Cualquier clase que contenga elementos (por ejemplo una lista enlazada) puede implementar esta interfaz, debiendo proveer una implementación al método iterator(). Este método retorna un objeto de tipo Iterator, siendo Iterator una interfaz que define métodos hasNext() y next(). Las implementaciones concretas de la interfaz Iterator se encargan de cómo recorrer los elementos, y es en esa clase concreta que implementa Iterator donde se aisla la lógica de cómo recorrer secuencialmente los elementos contenidos en ese contenedor en particular. Para cada contenedor (por ejemplo Lista, Mapa, Matriz) habrá una clase concreta que implementa la interfaz Iterator (por ejemplo ListaIterator, MapaIterator, MatrizIterator). Algunos contenedores pueden tener varias implementaciones concretas de Iterator, donde cada una ofrece una forma distinta de recorrer los elementos (por ejemplo ListaIterator y ListaReverseIterator para una lista enlazada, o un iterator concreto para cada recorrido en un árbol binario).
Recomiendo releer los párrafos de arriba luego de ver este ejemplo de implementación:
interface Iterable {
Iterator iterator();
}
class MiVector implements Iterable {
int[] arrayNumeros;
Iterator iterator() {
return new MiVectorIterator(this);
}
}
/* En este caso next() retornará un int, que es el tipo de elementos que almacena mi colección. Para una interfaz Iterator verdaderamente genérica debería retornar Object o utilizar Generics */
interface Iterator {
boolean hasNext();
int next();
}
/* Asumimos que MiVector no puede contener elementos nulos */
class MiVectorIterator implements Iterator {
MiVector miVector;
int indiceActual;
MiVectorIterator(MiVector miVector) {
this.miVector = miVector;
indiceActual = 0;
}
boolean hasNext() {
return (miVector.arrayNumeros.length > indiceActual);
}
int next() {
indiceActual++;
if (miVector.arrayNumeros.length > indiceActual) {
return miVector.arrayNumeros[indiceActual];
} else {
throw new NoSuchElementException();
}
}
}
Como se evidencia en el ejemplo, la clase MiVectorIterator está terriblemente acoplada con la clase MiVector. Esto está bien, porque la lógica para recorrer el contenido de MiVector es totalmente dependiente de la implementación de MiVector, y MiVectorIterator existe para encapsular esa lógica. La forma de recorrer los elementos mediante un Iterator es la siguiente:
class Main {
public static void main (String[] args) {
MiVector miVector = new MiVector();
// Inicializar la instancia miVector
Iterator iterator = miVector.iterator();
int acumulador = 0;
while (iterator.hasNext()) {
int valorActual = iterator.next();
acumulador += valorActual;
}
System.out.println(“El acumulado de los valores de miVector es ” + acumulador);
}
}
La clase Main depende de la clase MiVector y de la clase Iterator, pero no de MiVectorIterator. Tampoco depende de la implementación de MiVector. De hecho, el único método de MiVector que usa la clase Main (además del constructor y la lógica de inicialización) es iterator(). Main podría recibir una instancia de MiVector de alguna otra clase encargada de crearla, y Main ni siquiera necesitaría saber la clase concreta de MiVector, sólo saber que implementa Iterable (es decir, saber que puede proveer un Iterator).
Corolario: El patrón Iterator siempre es una buena idea cuando se tiene una clase que contiene varios objetos y se quiere proveer una forma de recorrer uno a uno todos esos objetos sin exponer la forma en que nuestra clase contiene esos objetos. Es muy raro escribir un iterator (a menos que estemos escribiendo nuestras propias estructuras de datos), pero utilizar un iterator es algo muy común. De hecho, en Java y en varios otros lenguajes el operador foreach está implementado utilizando un iterator. Todo el paquete Collections de Java (que incluye clases como LinkedList) implementa la interfaz Iterable.
Objetivo del patrón: exponer una forma de recorrer los elementos contenidos en una clase sin exponer la forma en que esa clase contiene a esos elementos.
Ventajas: Oculta la implementación de la clase que contiene elementos, y la encapsula en un iterador concreto, separando a su vez la responsabilidad de contener elementos (en la clase MiVector en el ejemplo) de la responsabilidad de recorrer los elementos (en la clase MiVectorIterator en el ejemplo).
Desventajas: Un Iterator no es thread-safe. Las implementaciones típicas de Iterator (es decir, el patrón en su forma más pura) tienen un comportamiento no definido si la colección se modifica mientras se está iterando. La mayoría de los lenguajes que tienen una implementación de Iterator agregan algunos controles para intentar que el iterator falle cuando se modifica la colección en forma concurrente, pero a pesar de ser un poco más robustos que una implementación pura, aun así no pueden ofrecer garantías del comportamiento del iterator y del estado de la colección. Una variante hace una copia profunda de la colección en cada instancia del iterator, por lo que si la colección original se modifica mientras el iterator está leyendo, el iterator podrá terminar la lectura sin errores, pero sobre la versión vieja de la colección (es decir, el iterator no falla porque no ve los cambios concurrentes a la colección).