Programación 3

Universidad de Alicante, 2023–2024

Guía fácil para usar el Java Collection Framework

Java Collection Framework es como se conoce a la librería de clases contenedoras de Java que podemos encontrar en el paquete estándar java.util. Estas clases sirven para almacenar colecciones de objetos, como listas, conjuntos, mapas, …

Todas estas clases permiten guardar en ellas referencias a objetos. Por ejemplo:

// Lista de enteros. Puede haber enteros repetidos en la lista:
List<Integer> lista_de_manzanas;  

// Conjunto de enteros. No puede haber enteros repetidos:
Set<Integer> conjunto_de_naranjas;

// Un mapa que asocia a una cadena un entero, como en una lista de notas de un examen:
//    [("Juan Goytisolo", 9.5), ("Pablo Iglesias", 5.0), ...]
Map<String, Integer> mapa_de_notas;

Gracias al boxing/unboxing podemos usarlas también con tipos primitivos (int, double, etc). Sin embargo, debemos definirlos usando las clases que representan estos tipos primitivos (Integer, Double, etc):

List<Integer> lista_de_enteros;  // aquí podemos guardar y recuperar directamente valores tipo 'int'.

Vamos a ver como funcionan estas colecciones de objetos. Puedes encontrar la mayor parte del código de está página en el fichero GuiaFacilJCF.java

Listas

Llamamos lista a cualquier colección de objetos ordenados por posición, como en un array. En una lista podemos añadir elementos, acceder a ellos por su posición en la lista, eliminar elementos de la lista y otras operaciones, como vaciar la lista, copiarla, etc. En una lista puede haber objetos repetidos, es decir, objetos que son iguales según el método equals() de su clase.

Crear una lista

Vamos a crear una lista de objetos de tipo Integer:

List<Integer> lista_de_enteros = new ArrayList<Integer>();

Porqué usamos ‘new ArrayList’ y no ‘new List’ quedará claro cuando hablemos de polimorfismo e interfaces.

Desde la versión 1.7 de Java se puede obviar el tipo de elemento en la lista en la instrucción new:

List<Integer> lista_de_enteros = new ArrayList<>();

El compilador se encarga de realizar lo que se llama inferencia de tipos, deduciendo del tipo en tiempo de compilación de lista_de_enteros cuál es el tipo de sus elementos y por tanto qué tipo debe ir entre <> (a <> se le conoce como el operador diamante en la jerga de Java).

Añadir elementos a la lista

El método add(·) añade una referencia a un objeto al final de la lista

lista_de_enteros.add(new Integer(4));
lista_de_enteros.add(new Integer(5));
lista_de_enteros.add(new Integer(7));
lista_de_enteros.add(2,new Integer(6)); // lo añade en la posición 2, entre el 5 y el 7
lista_de_enteros.add(4); // añado el 4 otra vez, usando 'boxing'
// esta última línea equivale a: lista_de_enteros.add(new Integer(4));

Tamaño de una lista

Usa size() para saber el tamaño de una lista:

int tam = lista_de_enteros.size();

devuelve 5: la lista contiene 5 elementos.

Obtener elementos de una lista

El método get(int) sirve para acceder a cualquier elemento de la lista, por su posición:

Integer primero = lista_de_enteros.get(0);
int primer = lista_de_enteros.get(0); // 'unboxing', obteniendo directamente un 'int'.

devuelve el objeto en la posición 0, es decir, el primero de los que añadí.

Integer ultimo = lista_de_enteros.get(lista_de_enteros.size()-1);

me devuelve el último elemento de la lista.

Saber si un elemento está en la lista

Usa el método contains(·) para preguntarle a la lista si contiene el objeto dado:

lista_de_enteros.contains(new Integer(7)); // me devolverá 'true'
lista_de_enteros.contains(new Integer(8)); // me devolverá 'false'

Obtener la posición de un objeto en la lista

El método indexOf(·) me indica la posición de la primera ocurrencia del objeto en la lista (recuerda que un mismo objeto puede estar repetido en diferentes posiciones):

lista_de_enteros.indexOf(new Integer(6)); // devuelve 2
lista_de_enteros.indexOf(new Integer(10)); // devuelve -1

Recorrer los elementos de la lista

Podemos usar un bucle convencional:

for (int i=0; i < lista_de_enteros.size(); i++) {
  System.out.println(lista_de_enteros.get(i));
}

el cual nos permitiría recorrer sólo una parte de la lista si nos interesa (p. ej., los cuatro primeros elementos).

Con un bucle for como éste:

for (Integer entero : lista_de_enteros) {
  System.out.println(entero);
}

donde la variable ‘entero’ de tipo Integer (también puede ser de tipo ‘int’ en este caso) va tomando el valor del siguiente elemento de la lista ‘lista_de_enteros’ en cada iteración. Con este tipo de bucle recorremos TODOS los elementos de la lista.

Una forma algo más sofisticada, pero más flexible de hacer esto es usar iteradores:

Iterator<Integer> iterador = lista_de_enteros.iterator(); 
while (iterador.hasNext()) {
  Integer entero = iterador.next();
  System.out.println(entero); // imprime un elemento
}

Fíjate que en el ‘while’ podríamos añadir más condiciones para detener el bucle donde nos interese, lo cual no podemos hacer con el ‘for’ anterior.

Eliminar un objeto de la lista

Para esto usamos el método remove(·) de dos formas:

boolean quitado = lista_de_enteros.remove(new Integer(7)); // o remove(7)

quita la primera aparicíon del 7 en la lista y devuelve ‘true’. Si no hay ningún 7 devolvería ‘false’.

Integer unEntero = lista_de_enteros.remove(1);

quita el segundo elemento de la lista y lo devuelve.

NOTA: Podrías pensar que en esta segunda llamada a remove() sobre un List, el compilador de Java hará el boxing y llamará a la primera versión del método, pero no lo hace y llama a esta segunda versión, que acepta exactamente un argumento de tipo int (pruébalo). Sobre listas con otro tipo de elementos no se nos plantearía esta duda.

Saber si una lista está vacía o vaciarla

Esto es fácil:

boolean estaVacia = lista_de_enteros.isEmpty(); // devolverá 'false'
lista_de_enteros.clear(); // ahora sí que está vacía

Conjuntos

Llamamos conjunto a cualquier colección de objetos de la misma clase sin ningún orden en particular. Además, cada elemento sólo aparece una vez, al contrario que en una lista, donde podían repetirse.

Crear un conjunto

Vamos a crear un conjunto de objetos de tipo Integer:

Set<Integer> conjunto_de_enteros = new HashSet<Integer>();

Porqué ‘new HashSet’ y no ‘new Set’ quedará claro cuando hablemos de polimorfismo e interfaces.

Añadir elementos al conjunto

El método add(·) añade una referencia a un objeto al conjunto.

conjunto_de_enteros.add(new Integer(4));
conjunto_de_enteros.add(new Integer(5));
conjunto_de_enteros.add(new Integer(7));
boolean repe = conjunto_de_enteros.add(new Integer(4));
// no añade el 4 porque ya está en el conjunto y además devuelve 'false'

(con todas las colecciones del JCF podemos usar el boxing/unboxing, así que no lo comentaremos más.)

Tamaño de un conjunto

conjunto_de_enteros.size();

me devuelve 3: el conjunto contiene 3 elementos.

Saber si un elemento está en la lista

conjunto_de_enteros.contains(new Integer(7)); // me devolverá 'true'
conjunto_de_enteros.contains(new Integer(8)); // me devolverá 'false'

Un Set no tiene los métodos get(·) e indexOf(·), los elementos no están en ninguna posición en particular. Básicamente, con un conjunto lo que podemos hacer es añadir elementos, eliminarlos y preguntar si un elemento pertenece al conjunto.

Recorrer todos los elementos del conjunto

Con un bucle for como éste:

for (Integer entero : conjunto_de_enteros) {
  System.out.println(entero);
}

Este bucle imprime todos los enteros del conjunto, uno en cada línea. El problema es que no recorre el conjunto en ningún orden en particular. No hay ningún orden definido en el conjunto.

Eliminar un objeto del conjunto

boolean quitado = conjunto_de_enteros.remove(new Integer(7));

devuelve ‘true’. Si no hay ningún 7 devolvería ‘false’.

Saber si un conjunto está vacío o vaciarlo

boolean estaVacio = conjunto_de_enteros.isEmpty(); // devolverá 'false'
conjunto_de_enteros.clear(); // ahora sí que está vacío

Listas y conjuntos == colecciones

Como puedes observar, hay operaciones sobre las listas y los conjuntos que son iguales. Y es que tanto las listas como los conjuntos son colecciones de objetos y por tanto comparten algunas operaciones. Esto es así porque ambas clases son a su vez del tipo Collection, de manera que podemos hacer lo siguiente:

Collection<Integer> coleccion = lista_de_enteros;

// añado un entero a la lista
coleccion.add(new Integer(10));

coleccion = conjunto_de_enteros;

// añado un entero al conjunto
coleccion.add(new Integer(11));

Date cuenta de que ambos enteros se añaden a colecciones distintas: el primero a la lista, el segundo al conjunto, aunque usamos la misma referencia para referirnos a ambas colecciones. Si no acabas de entender porqué es así, lo verás claro cuando estudiemos la herencia.

Colas

Una cola es una colección de elementos que se ‘encolan’, de manera que el elemento que se insertó primero, es el elemento que está en la ‘cabeza’ de la cola. Hay tres operaciones básicas que se pueden hacer con una cola:

  • encolar (o insertar) : añadir un elemento al final de la cola
  • examinar : consultar el elemento que está en la cabeza de la cola
  • desencolar : eliminar el elemento que está en la cabeza de la cola.

En general, no podemos acceder a elementos en cualquier posición de la cola, como en una lista, si no sólo al que está en cabeza. De la misma forma, no podemos eliminar elementos de la cola que no estén en la cabeza de ésta.

Crear una cola

Queue<Integer> cola = new LinkedList<>(); // LinkedList implementa el interface Queue

Encolar, desencolar y examinar la cabeza de la cola

Hay dos formas de realizar estas operaciones con una cola: usando métodos que lanzan excepciones no verificadas si fallan (por ejemplo, si intentamos desencolar de una cola vacía), o métodos que devuelven un valor especial (null o false) en caso de error. En esta tabla tienes las operaciones de Queue que lanzan excepciones (throw) o devuelven un valor (return) si fallan:

throw return
encolar add(e) offer(e)
desencolar remove() poll()
consultar element() peek()

Encolar elementos

// usando add()
cola.add(5);
cola.add(6);

// usando offer()
cola.offer(5);
cola.offer(6);

Si no hay errores (por alguna razón no podemos encolar más elementos), ambos métodos se comportan igual.

Consultar el elemento en cabeza

cola.offer(5);
cola.offer(6);
int a = cola.peek(); // el elemento no se desencola
a = cola.element();  // misma operación

Desencolar un elemento

cola.offer(5);
cola.offer(6);
int a = cola.poll(); // devuelve el elemento en cabeza y lo desencola
a = cola.remove(); // misma operación

Las operaciones habituales que podemos usar con otras coleeciones, como size(), clear(), isEmpty() también funcionan con las colas.

Colas de prioridad

Son un tipo de colas donde los elementos se encolan, pero se ordenan en la cola en base a un criterio. De manera que en la cabeza de la cola se encuentra el elemento con mayor (o menor) prioridad. Hay dos formas de definir este criterio de ordenación: Haciendo que los elementos de la lista implementen el interfaz Comparable, o mediante un objeto de tipo Comparator que se le proporciona a la cola en el momento de su creación y que implementa el orden deseado.

Por ejemplo, la clase Integer implementa el interfaz Comparable y por tanto podemos guardar enteros en una cola de prioridad, de manera que el elemento menor de los que insertemos en la cola será el que ocupe la posición de cabeza:

PriorityQueue<Integer> cola = new PriorityQueue();
cola.add(5);
cola.add(2);
cola.add(3);
int a = cola.peek(); // retorna 2
a = cola.poll(); // retorna 2 y lo desencola
a = cola.peek(); // retorna 3

Si queremos que los enteros se ordenen de otra manera en la cola (por ejemplo de mayor a menor), debemos proporcionar a la cola un comparador de enteros que los ordene de esa forma.

Esto se hace creando una clase que implemente el interfaz Comparator<T>, que nos dice que debemos implementar el método compare(a,b) para comparar dos objetos de tipo T ‘a’ y ‘b’, de forma que

  • si a < b, entonces compare(a,b) devuelve un número negativo.
  • si a.equals(b), entonces compare(a,b) devuelve cero.
  • si a > b, entonces compare(a,b) devuelve un número positivo.

Siguiendo con el ejemplo de la cola de enteros, si queremos que se ordenen ‘al revés’ de mayor a menor, entonces debemos invertir el orden haciendo que

  • si a < b, entonces compare(a,b) devuelve un número positivo.
  • si a.equals(b), entonces compare(a,b) devuelve cero.
  • si a > b, entonces compare(a,b) devuelve un número negativo.

El siguiente código hace precisamente esto con una cola de prioridad para enteros:

Comparator<Integer> comparador = new Comparator<Integer>() {
  @Override
  public int compare(Integer a, Integer b) {
    return b - a;
  }
};

PriorityQueue<Integer> cola = new PriorityQueue(comparador);
cola.add(5);
cola.add(2);
cola.add(3);
int a = cola.peek(); // retorna 5
a = cola.poll(); // retorna 5 y lo desencola
a = cola.peek(); // retorna 3

Mapas

Los mapas permiten establecer una correspondencia entre pares de objetos: uno que actúa como clave y otro como valor asociado a esa clave. Un diccionario, por ejemplo, es un mapa entre cadenas de texto: la palabra que buscamos en el diccionario actúa como clave y su significado como valor asociado.

Un mapa en Java se define así:

Map<Clave, Valor> = new HashMap<Clave, Valor>();

donde Clave y Valor son dos clases cualesquiera.

Así, un diccionario lo implementaríamos como un mapa entre cadenas:

Map<String, String> diccionario = new HashMap<String, String>();

Otro uso típico de un mapa es aquel en el que queremos ‘indexar’ algo usando como índice no números enteros, sino otro tipo de objeto. Por ejemplo, podríamos definir un damero para jugar al ajedrez como un mapa entre coordenadas de las casillas del damero (A3, B7,…) y piezas del juego:

Map<CoordenadaAjedrez, PiezaAjedrez> damero = new HashMap<>();

La ventaja de hacerlo así es doble:

  • podemos usar cualquier tipo de objeto como índice (es decir, como clave).
  • no necesitamos guardar en el mapa información sobre aquellas casillas que no están ocupadas.

Si lo implementáramos como un array bidimensional, las casillas vacías ocuparían memoria. Pero en el ajedrez siempre hay más casillas vacías que ocupadas. Además, tendríamos que indexar el array con enteros, por lo que tendríamos que ‘traducir’ las coordenadas de alguna manera (A3 -> [0][2], B7 -> [1][6], …).

En cualquier caso, podríamos incluir en el mapa casillas vacías, si así nos interesa, asociándolas, por ejemplo, con el valor ‘null’.

Veamos como podemos trabajar con mapas:

Añadir una entrada al mapa

El método put(·) añade una correspondencia entre una clave y su valor:

damero.put(new CoordenadaAjedrez('A',3),
           new PiezaAjedrez("ALFIL",Color.BLANCO));

damero.put(new CoordenadaAjedrez('B',7),
           new PiezaAjedrez("CABALLO",Color.NEGRO));

damero.put(new CoordenadaAjedrez('B',7),
           new PiezaAjedrez("REINA",Color.BLANCO));

La última instrucción cambia la pieza asociada a la casilla B7 por una reina blanca. Es como si hubíeramos sacado al caballo negro del tablero y puesto a la reina blanca en su lugar. El mapa tiene por tanto 2 elementos (pares clave-valor) y no 3.

Fíjate en que nada nos impide asociar el mismo valor a claves distintas:

damero.put(new CoordenadaAjedrez('F',4),
           new PiezaAjedrez("REINA",Color.BLANCO));
// ahora el mapa sí tiene 3 pares clave-valor

O asociar una clave con un valor ‘null’:

damero.put(new CoordenadaAjedrez('A', 1), null);
// ahora el mapa tiene 4 elementos

Tamaño de un mapa

damero.size(); // devuelve 4

El mapa contiene 4 entradas: ([A3, ALFIL blanco], [B7, REINA blanca], [F4, REINA blanca], [A1, null]).

Obtener el valor asociado a una clave en el mapa

El método get(·) nos permite buscar en el mapa usando una clave:

PiezaAjedrez pieza = damero.get(new CoordenadaAjedrez('A',3));  

devuelve una referencia al alfil. Si la clave no está en el mapa, devolverá ‘null’.

Saber si una clave está en el mapa

Usa el método containsKey(·) para preguntarle al mapa si la clave dada tiene algún valor asociado:

damero.containsKey(new CoordenadaAjedrez('A',3)); // devuelve 'true'
damero.containsKey(new CoordenadaAjedrez('H',3)); // devuelve 'false'

Saber si un valor está asociado a alguna clave

Usa el método containsValue(·) para preguntarle al mapa si el valor dado está asociado al menos a una clave:

damero.containsValue(new PiezaAjedrez("ALFIL",Color.BLANCO)); // devuelve 'true'
damero.containsValue(new PiezaAjedrez("REY",Color.NEGRO)); // devuelve 'false'

Recorrer un mapa

Los mapas no se pueden recorrer directamente, como hacemos con una lista o un conjunto. Sin embargo, si que podemos obtener el conjunto de claves guardadas en el mapa, recorrer este conjunto y pedirle al mapa el valor asociado a cada clave:

Set<CoordenadaAjedrez> coordenadas = damero.keySet();

for (CoordenadaAjedrez coord : coordenadas) {
  PiezaAjedrez pieza = damero.get(coord);
  System.out.println(coord.toString() + " -> " + pieza.toString());
}

Eliminar una entrada del mapa

Para esto usamos el método remove(·):

PiezaAjedrez pieza = damero.remove(new CoordenadaAjedrez('A',3));

Elimina la entrada asociada a la casilla A3 y me devuelve la pieza que estaba en esa casilla. Si la casilla no estuviera en el mapa, delvolvería ‘null’.

Saber si un mapa está vacío o vaciarlo

Esto es fácil:

boolean estaVacio = damero.isEmpty(); // devolverá 'false'
damero.clear(); // ahora sí que está vacía

Corolario

Es posible que ya te hayas dado cuenta: cuando necesitan comparar objetos entre sí, para saber si son iguales, estas clases utilizan el método equals(·) de esos objetos. Así pues, un objeto CoordenadaAjedrez será igual a otro si así lo dice el método equals(·) de la clase CoordenadaAjedrez.

Por ejemplo, cuando le preguntamos a un conjunto si contiene un determinada objeto ‘objeto1’, éste usa el método equals(·) de la clase del objeto para buscar un objeto en el conjunto tal que objeto1.equals(objeto_del_conjunto) devuelva ‘true’.

hashCode()

Este método, que se suele implementar en todas las clases, devuelve un entero que actua como identificador para un objeto. Es un método compatible con equals(), de manera que si objeto1.equals(objeto2) devuelve ‘true’, entonces objeto1.hasCode() devuelve el mismo valor que objeto2.hashCode().

Algunas implementaciones de listas, conjuntos y mapas utilizan hashCode(·) en lugar de equals(·) para saber si dos objetos son iguales.

API del JCF

Por último, todo programador Java hace un uso intensivo de la documentación del API (Application Programming Interface) del lenguaje. La documentación completa del Java Collections Framework para la versión 1.8 de Java se puede consultar aquí:

Java Collections Framework