Collections de Java
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.
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;
// ...
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.
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.
iterator()
Obtiene un “iterador” para recorrer 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:
A partir de Java 6 hay una manera simplificada de recorrer una collection (que sirve si
no necesitamos borrar elementos). Se hace mediante una nuevo uso del keyword for:
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 non 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 (mienras que el tamaño de un array es fijo desde su
creación). El tiempo de acceso a un elemento en particular es ínfimo, pero 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.
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).
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. Hay ciertas cosas que todo objeto en Java sabe hacer. Dos de estas
cosas son:
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.
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
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.
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.
Tomado de
http://www.reloco.com.ar/prog/java/collections.html
Autor: Nicolás Lichtmaier.