viernes, 19 de noviembre de 2021

JAVA Colecciones - Java Collections

 JAVA Colecciones - Java Collections

http://www.reloco.com.ar/prog/java/collections.html


Collections de Java

Introducción

Un tema que aparece frecuentemente en la programación es el de organizar una colección de objetos en memoria. Este tema también es conocido con el título “estructuras de datos”, y se relaciona también a distintos algoritmos para agilizar el acceso. Antes de hablar específicamente de las colecciones de Java voy a desviarme un rato hablando un poco sobre el problema en general de almacenar colecciones de datos en memoria.

Algoritmos y estructuras de datos

Todo programador aprende, apenas empieza, un par de estructuras de datos simples. Podríamos decir que la primer estructura de datos es la variable. Almacena un solo objeto, y el tiempo de acceso es ínfimo. La segunda estructura de datos que siempre se aprende es el arreglo (en inglés "array"). Esta colección nos permite obtener un valor dado una posición en una tabla. Es muy veloz ya que esta estructura tiene un fuerte paralelo con cómo se organiza internamente la memoria de una computadora. Los valores en un arreglo están en orden, y en ese mismo orden están dispuestos en la memoria, por lo tanto el acceso lo maneja directamente el hardware (porque la memoria funciona físicamente como un arreglo).

Un arreglo en Java se hace así (pero asumamos que ya se sabe).

String[] nombres = new String[10];

Muchos programadores se detienen aquí. Ayuda a esto el hecho de que en algunos lenguajes el arreglo era la estructura de datos más compleja soportada (por ejemplo en BASIC... ¡o incluso C!).

Uno puede usar arreglos para todo. ¿Quiero guardar la lista de helados que me gusta? ¿Las notas que se sacaron los alumnos de una clase? ¿La colección de coordenadas que forman un mapa? Puede guardarse en un arreglo... ahora... supongamos que quiero guardar las notas de los alumnos de una clase. Si quiero usar un arreglo podría actuar así: Creo una clase con el nombre del alumno y su nota:

class Nota
{
	String alumno;
	int nota;
	
	Nota(String alumno, int nota) { this.alumno = alumno; this.nota = nota; }

	public int getNota() { return nota; }
	public String getAlumno() { return alumno; }
}

Ahora almaceno las notas de 500 alumnos:

	Nota[] notas = new Nota[500];
	
	notas[0] = new Nota("Juan", 7);
	notas[1] = new Nota("Pedro", 8);
	notas[2] = new Nota("Manuel", 4);
	notas[3] = new Nota("David", 6);
	
	// ...

	notas[499] = new Nota("Elías", 9);

Ahora... ¿Qué pasa si queremos averiguar la nota de Elías? No sabemos "a priori" en qué posición del arreglo está guardada la nota. La única manera que tenemos de proceder es simplemente recorrer el arreglo y preguntar si ya llegamos al alumno que queremos:

	for(Nota n : notas)
		if(n.getNombre().equals("Elías"))
			return n.getNota();

¡Esto es lentísimo! Está bien, “Elías” es el peor caso, ya que se encuentra último. A Elías lo encontraremos después de haber efectuado 499 comprobaciones. Pero si suponemos que todos los alumnos son accedidos más o menos con una misma probabilidad tenemos que el número de comprobaciones promedio que se necesitarán por búsqueda de alumno es de 250!

¿Podemos hacerlo mejor? ¿Qué tal si pudiéramos contar con que el orden de las notas coincide con el orden alfabético del nombre de los alumnos? En ese caso podríamos ir al alumno 250, y preguntar: ¿Elías es mayor o menor alfabéticamente que vos? Si el alumno 250 es Luis, Elías claramente está antes qué él. Luego, Elías está entre el 0 y el 249. Entonces vamos a comprobar al alumno 125, que es, por ejemplo, “Carlos”. Elías está después que Carlos, entonces sabemos que su posición es mayor a 125 (y era menor a 249). Así vamos acotando hasta llegar a Elías en, a lo sumo, 9 comprobaciones. A este algoritmo se le suele llamar “búsqueda dicotómica”.

No hace falta entender del todo el párrafo anterior. El quid de la cuestión es entender que, mediante algoritmos, redujimos la necesidad de comprobar 250 veces en promedio, a comprobar a lo sumo nueve veces. Y si en vez de 500 nombres tenemos 100.000 necesitaremos comprobar, a lo sumo, 17 veces. El uso de este algoritmo depende del hecho de que los datos que ordenamos están ordenados.

Asimismo cada una de las soluciones puede traer problemas secundarios que serán más o menos importantes de acuerdo a nuestras necesidades. En el ejemplo anterior, por ejemplo, necesitamos mantener un arreglo ordenado... ¿qué pasa si se agrega un nuevo alumno? Habrá que insertarlo en la posición correcta de acuerdo al orden alfabético y desplazar a todos lo que queden a su derecha un lugar. En el peor caso es recorrer todo el arreglo!

Todo esto quiere decir que el tipo de estructura de datos que se use dependerá de lo que puede asumir de la colección almacenada y de cómo se espera que esa colección sea usada (cómo sea leída y cómo sea modificada). Algunas preguntas a hacerse son:

  • ¿Me interesa mantener el orden abitrario original? ¿Puedo (como con las notas) reordenar los ítems?
  • ¿Hay duplicados?
  • ¿Con qué frecuencia es necesario añadir nuevos elementos?

Colecciones en Java

Java tiene desde la versión 1.2 todo un juego de clases e interfaces para guardar colecciones de objetos. En él, todas las entidades conceptuales están representadas por interfaces, y las clases se usan para proveer implementaciones de esas interfaces. Una introducción conceptual debe entonces enfocarse primero en esas interfaces.

La interfaz nos dice qué podemos hacer con un objeto. Un objeto que cumple determinada interfaz es “algo con lo que puedo hacer X”. La interfaz no es la descripción entera del objeto, solamente un mínimo de funcionalidad con la que debe cumplir.

Como corresponde a un lenguaje tan orientado a objetos, estas clases e interfaces están estructuradas en una jerarquía. A medida que se va descendiendo a niveles más específicos aumentan los requerimientos y lo que se le pide a ese objeto que sepa hacer.

Collection

La interfaz más importante es Collection. Una Collection es todo aquello que se puede recorrer (o “iterar”) y de lo que se puede saber el tamaño. Muchas otras clases extenderán Collection imponiendo más restricciones y dando más funcionalidades. Es de notar que el requisito de “que se sepa el tamaño” hace inconveniente utilizar estas clases con colecciones de objetos de las que no se sepa “a priori” la cantidad (ésto podría considerarse una limitación de este framework).

Por las dudas vuelvo a aclarar: No puedo construir una Collection. No se puede hacer “new” de una Collection, sino que todas las clases que realmente manejan colecciones “son” Collection, y admiten sus operaciones.

Las operaciones básicas de una collection entonces son:

add(T)
Añade un elemento.
iterator()
Obtiene un “iterador” que permite recorrer la colección visitando cada elemento una vez.
size()
Obtiene la cantidad de elementos que esta colección almacena.
contains(t)
Pregunta si el elemento t ya está dentro de la colección.

Si yo tengo un objeto Collection hay muchas cosas que no puedo asumir. No puedo asumir que el orden en el que lo recorro sea relevante, es decir, que si lo recorro de nuevo vea los elementos en el mismo orden en el que los vi la primera vez. Tampoco puedo asumir que no hay duplicados. No puedo asumir características de rendimiento: Preguntar si existe un objeto en la colección puede tardar desde muy poco hasta... mucho =).

Una capacidad de un objeto Collection es la de poder ser recorrido. Como a este nivel no está definido un orden, la única manera es proveyendo un iterador, mediante el método iterator(). Un iterador es un objeto “paseador” que nos permite ir obteniendo todos los objetos al ir invocando progresivamente su método next(). También, si la colección es modificable, podemos remover un objeto durante el recorrido mediante el método remove() del iterador. El siguiente ejemplo recorre una colección de Integer borrando todos los ceros:

void borrarCeros(Collection<Integer> ceros)
{
	Iterator<Integer> it = ceros.iterator();
	while(it.hasNext())
	{
		int i = it.next();
		if(i == 0)
			it.remove();
	}
}

En este ejemplo hago uso de la conversión automática entre Integer e int.

A partir de Java 6 hay una manera simplificada de recorrer una collection (que sirve si no necesitamos borrar elementos). Se hace mediante un nuevo uso del keyword for:

void mostrar(Collection<?> col)
{
	for(Object o : col)
		System.out.println(o);
}

Internamente este código no hace otra cosa que obtener el iterador, pero queda mucho más elegante y legible de esta manera. Pero al mismo tiempo, el no disponer del iterador explícitamente hace imposible usar esta sintaxis para hacer lo que hice en el ejemplo previo: ir borrando elementos.

Hay cuatro interfaces que extienden Collection: List, Set, Map y Queue. Cada una de ellas agrega condiciones sobre los datos que almacena y como contrapartida ofrece más funcionalidad. A continuación cuento de qué se trata cada una de ellas.

List

Un List, o simplemente lista, es una Collection. La diferencia que tiene una lista con una Collection es que la lista mantiene un orden arbitrario de los elementos y permite acceder a los elementos por orden. Podríamos decir que en una lista, por lo general, el orden es dato. Es decir, el orden es información importante que la lista también nos está almacenando.

No hay ningún método en Collection para obtener el tercer elemento. No lo puede haber porque, como se dijo, a nivel Collection ni siquiera estamos seguros de que si volvemos a recorrer la colección los elementos aparecerán en el mismo orden. Una lista sí debe permitir acceder al tercer elemento, por eso se añaden los siguientes métodos:

get(int i)
Obtiene el elemento en la posición i.
set(int i, T t)
Pone al elemento t en la posición i.

Existen en Java principalmente dos implementaciones de List, las dos útiles en distintos casos. Una de ellas es casi igual a los arreglos comunes (como el de notas que usaba más arriba para ejemplificar). Esta implementación es ArrayList. La ventaja de ArrayList por sobre un array común es que es expansible, es decir que crece a medida que se le añaden elementos (mientras que el tamaño de un array es fijo desde su creación). Lo bueno es que el tiempo de acceso a un elemento en particular es ínfimo. Lo malo es que si queremos eliminar un elemento del principio, o del medio, la clase debe mover todos los que le siguen a la posición anterior, para “tapar” el agujero que deja el elemento removido. Esto hace que sacar elementos del medio o del principio sea costoso.

La otra implementación es LinkedList (lista enlazada). En ésta, los elementos son mantenidos en una serie de nodos atados entre sí como eslabones de una cadena. Cada uno de estos nodos apunta a su antecesor y al elemento que le sigue. Nada más. No hay nada en cada uno de esos nodos que tenga algo que ver con la posición en la lista. Para obtener el elemento número “n”, esta implementación de List necesita entonces empezar desde el comienzo, desde el primer nodo, e ir avanzando al “siguiente” n veces. Buscar el elemento 400 entonces implica 400 de esos pasitos. La ventaja es que es posible eliminar elementos del principio de la lista y del medio de manera muy eficiente. Para eliminar un elemento solamente hay que modificar a sus dos “vecinos” para que se “conecten” entre sí ignorando al elemento que se está borrando. Como en una cadena, se retira un eslabón abriendo los eslabones adyacentes al que se eliminá y cerrándolos de modo que lo excluyan. No es necesario hacerle ningún cambio al resto de los elementos de la lista.

En otros lenguajes lidiar con listas enlazadas puede ser un poco más trabajoso. En Java, LinkedList se usa exactamente igual que otros tipos de List, por lo que no hay que saber nada adicional para empezar a usarla. Bueno, esto no es del todo cierto... hay que tener muy en claro sus particularidades en cuanto a rendimiento. Su método get(int) es particularmente lento porque, como dije, necesita recorrer para llegar al elemento pedido. Esto hace que recorrer la ista con un simple for(int i = 0 ; i < lista.size(); i++) sea tremendamente lento! La complejidad pasa de ser lineal a cuadrática, es decir: Si se recorre así una lista de 300 elementos, se tarda como si tuviera 44.850 elementos! Una LinkedList sólo debe recorrerse mediante iteradores.

Un uso ideal de LinkedList es para la creación de “colas”, en las que los elementos se añaden al final, y se eliminal del comienzo. Para este uso se puede usar, en vez de List, la interfaz Queue (también implementada por LinkedList) que es más específica para esta tarea.

Set

Un Set es una Collection. Set es la palabra inglesa para “conjunto” y los desarrolladores de Java estaban pensando en lo que matemáticamente se conoce como conjunto. Por sobre lo que es una collection, un set agrega una sola restricción: No puede haber duplicados.

Por lo general en un set el orden no es dato. Si bien es posible que existan sets que nos aseguren un orden determinado cuando los recorremos (por ejemplo obtener strings en orden alfabético), ese orden no es arbitrario y decidido por nosotros, ya que la interfaz Set no tienen ninguna funcionalidad para manipularlo (como si lo admite la interfaz List).

La ventaja de utilizar Sets es que preguntar si un elemento ya está contenido mediante “contains()” suele ser muy eficiente. Entonces es conveniente utilizarlos cada vez que necesitemos una colección en la que no importe el orden, pero que necesitemos preguntar si un elemento está o no.

Como, a diferencia de Collection, el orden no necesariamente es preservado, no existen métodos para “obtener el primer elemento”.

HashSet

Existen varias implementaciones de Set. La más comunmente usada es HashSet.

Los Sets (y los Maps) aprovechan cierta cosa característica de Java: Todos los objetos heredan de Object, por lo tanto todos los métodos de la clase Object están presentes en todos los objetos. Dicho de otra manera, hay ciertas cosas que todo objeto en Java sabe hacer. Dos de estas cosas son:

  • Saber si es igual a otro, con su método equals().
  • Devolver un número entero de modo tal que si dos objetos son iguales ese número también lo será (se conoce esto como un hash). Esto todo objeto lo sabe hacer con su método hashCode().

La clase HashSet aprovecha la segunda de las funciones. A cada objeto que se añade a la colección se le pide que calcule su “hash”. Este valor será un número entre -2147483647 y 2147483648. Basado en ese valor se lo guarda en una tabla. Más tarde, cuando se pregunta con contains() si un objeto x ya está, habrá que saber si está en esa tabla. ¿En qué posición de la tabla está? HashSet puede saberlo, ya que para un objeto determinado, el hash siempre va a tener el mismo valor. Entonces la función contains de HashSet saca el hash del objeto que le pasan y va con eso a la tabla. En la posición de la tabla hay una lista de objetos que tienen ese valor de hash, y si uno de esos es el buscado contains devuelve true.

Un efecto de este algoritmo es que el orden en el que aparecen los objetos al recorrer el set es impredecible. También es importante darse cuenta de que es crítico que la función hashCode() tiene que devolver siempre el mismo valor para los objetos que se consideran iguales (o sea que equals() da true). Si esto no es así, HashSet pondrá al objeto en una posición distinta en la tabla que la que más adelante consultará cuando se llame a contains, y entonces contains dará siempre falso, por más que se haya hecho correctamente el add. Esto mismo puede suceder si se usan como claves objetos que varíen.

TreeSet

Antes de entrar en la descripción de TreeSet vaya una breve explicación. Otra cosa que pueden saber hacer los objetos con independencia de cómo y dónde son usados es saber ordenarse. A diferencia de “equals” y “hashCode”, que están en todos los objetos, la capacidad de “ordernarse” está sólo en aquellos que implementan la interfaz Comparable. Cuando un objeto implementa esta interfaz promete saber compararse con otros (con el método compareTo()), y responder con este método si él está antes, después o es igual al objeto que se le pasa como parámetro. Al orden resultante de usar este método se le llama en Java “orden natural”. Muchas de las clases de Java implementan Comparable, por ejemplo String lo hace, definiendo un orden natural de los strings que es el obvio, el alfabético. También implementan esta interfaz Date, Number, etc. y los “órdenes naturales” que definen estas implementaciones también los los que uno esperaría.

Si yo creo una clase mía llamada Alumno, queda a mi cargo, si así lo quiero, la definición de un orden natural para los alumnos. Puedo elegir usar el apellido, el nombre, el número de matrícula, etc. De acuerdo al atributo que elija para definir el orden natural codificaré el método compareTo(). Lo que es importante es que la definición de este método sea compatible con el equals(); esto es que a.equals(b) si y sólo si a.compareTo(b) == 0.

TreeSet usa una técnica completamente diferente a la explicada para HashSet. Construye un árbol con los objetos que se van agregando al conjunto. Un árbol es una forma en computación de tener un conjunto de cosas todo el tiempo en orden, y permitir que se agreguen más cosas y que el orden se mantenga. Al tener todo en orden TreeSet puede fácilmente saber si un objeto está, por medio de una técnica similar a la explicada en el comienzo de este artículo para buscar los nombres de las notas.

Una ventaja de TreeSet es que el orden en el que aparecen los elementos al recorrerlos es el orden natural de ellos (los objetos deberán implementar Comparable, como lo explico arriba; si no lo hacen se deberá especificar una función de comparación manualmente). Una desventaja es que mantener todo ordenado tiene un costo, y esta clase es un poquito menos eficiente que HashSet.

Map

Un Map representa lo que en otros lenguajes se conoce como “diccionario” y que se suele asociar a la idea de “tabla hash” (aunque no se implemente necesariamente con esa técnica). Un Map es un conjunto de valores, con el detalle de que cada uno de estos valores tiene un objeto extra asociado. A los primeros se los llama “claves” o “keys”, ya que nos permiten acceder a los segundos.

Cuando digo que las claves forman un conjunto, lo digo en el sentido Java: Son un “Set”, no puede haber duplicados. En otros lenguajes existen estructuras parecidas que admiten la existencia de claves duplicadas (a veces conocidos como “multimap”). La única manera de lograr esto en Java es haciendo que el map guarde listas de valores en vez de los valores sueltos.

Un Map no es una Collection ya que esa interfaz le queda demasiado chica. Podríamos decir que Collection es unidimensional, mientras que un Map es bidimensional. No hay una manera trivial de expresar un Map en una simple serie de objetos que podemos recorrer. Sí podríamos recorrer una serie de objetos si cada uno de ellos representase un par {clave, valor} (y de hecho eso se puede hacer). Pero esta forma de recorrer un Map no es la forma primaria en que se usa.

Algunos de los métodos más importantes de un Map son:

get(Object clave)
Obtiene el valor correspondiente a una clave. Devuelve null si no existe esa clave en el map.
put(K clave, V valor)
Añade un par clave-valor al map. Si ya había un valor para esa clave se lo reemplaza.

Además hay algunas funciones que son útiles a la hora de recorrer eficientemente el contenido de un Map:

keySet()
Todas las claves (devuelve un Set, es decir, sin duplicados).
values()
Todos los valores (los valores sí pueden estar duplicados, por lo tanto esta función devuelve una Collection).
entrySet()
Todos los pares clave-valor (devuelve un conjunto de objetos Map.Entry, cada uno delos cuales devuelve la clave y el valor con los métodos getKey() y getValue() respectivamente).

Queue y Deque

Se conoce como cola a una colección especialmente diseñada para ser usada como almacenamiento temporario de objetos a procesar. Las operaciones que suelen admitir las colas son “encolar”, “obtener siguiente”, etc. Por lo general las colas siguen un patrón que en computación se conoce como FIFO (por la sigla en inglés de “First In - First Out” - “lo que entra primero, sale primero”), lo que no quiere decir otra cosa que lo obvio: El orden en que se van obteniendo los “siguientes” objetos coincide con el orden en que fueron introducidos en la cola. Esto análogo a su tocaya del supermercado: La gente que hace la cola va siendo atendida en el orden en que llegó a ésta.

Hasta hace poco, para implementar una cola FIFO en Java la única opción provista por la biblioteca de colecciones era LinkedList. Como ya se dijo más arriba, esta implementación ofrece una implementación eficiente de las operaciones “poner primero” y “sacar último”. Sin embargo, aunque la implentación es la correcta, a nivel de interfaz dejaba algo que desear. Los métodos necesarios para usar una LinkedList como una cola eran parte solamente de la clase LinkedList, no existía ninguna interfaz que abstraiga el concepto de “cola”. Esto hacía imposible crear código genérico que use indistintamente diferentes implementaciones de colas (que por ejemplo no sean FIFO sino que tengan algún mecanismo de prioridades). Esta situación cambió recientemente a partir del agregado a Java de dos interfaces expresamente diseñadas para el manejo de colas: La interfaz Queue tiene las operaciones que se esperan en una cola. También se creó Deque, que representa a una “double-ended queue”, es decir, una cola en la que los elementos pueden añadirse no solo al final, sino también “empujarse” al principio.

No hay comentarios:

Publicar un comentario