Colecciones de Java: la interfaz del mapa

Java Collections Framework es un marco fundamental y esencial. La interfaz Mapa es una interfaz de alto nivel desde la cual muchas colecciones amplían la funcionalidad.

Introducción

El Java Collections Framework es un marco fundamental y esencial que cualquier desarrollador de Java fuerte debe conocer como la palma de su mano.

Una Colección en Java se define como un grupo o colección de objetos individuales que actúan como un solo objeto.

Hay muchas clases de colección en Java y todas ellas amplían las interfaces java.util.Collection y java.util.Map. Estas clases en su mayoría ofrecen diferentes formas de formular una colección de objetos dentro de un solo objeto.

Java Collections es un marco que proporciona numerosas operaciones sobre una colección: búsqueda, clasificación, inserción, manipulación, eliminación, etc.

Esta es la tercera parte de una serie de artículos de Java Collections:

Limitaciones de listas y conjuntos

En primer lugar, analicemos las limitaciones de List y Set. Proporcionan muchas funciones para agregar, eliminar y comprobar la presencia de elementos, así como mecanismos de iteración. Pero cuando se trata de recuperar elementos específicos, no son muy útiles.

La interfaz Set no proporciona ningún medio para recuperar un objeto específico, ya que no está ordenado. Y la interfaz List simplemente brinda la posibilidad de recuperar elementos por su índice.

Desafortunadamente, los índices no siempre hablan por sí mismos y, por lo tanto, tienen poco significado.

Mapas

Ahí es donde aparece la interfaz java.util.Map. Un ‘Mapa’ asocia elementos a claves, permitiéndonos recuperar elementos por esas claves. Tales asociaciones tienen mucho más sentido que asociar un índice a un elemento.

Map es una interfaz genérica con dos tipos, uno para las claves y otro para los valores. Por lo tanto, si quisiéramos declarar un ‘Mapa’ almacenando el conteo de palabras en un texto, escribiríamos:

1
Map<String, Integer> wordsCount;

Tal ‘Mapa’ usa una ‘Cadena’ como su clave y un ‘Entero’ como su valor.

Adición de elementos

Profundicemos ahora en las operaciones Map, comenzando con la adición de elementos. Hay varias formas de agregar elementos a un Mapa, siendo la más común el método put():

1
2
Map<String, Integer> wordsCount = new HashMap<>();
wordsCount.put("the", 153);

Nota: Además de asociar un valor a una clave, el método put() también devuelve el valor previamente asociado, si lo hay, y null en caso contrario.

Pero, ¿y si sólo queremos añadir un elemento si no tiene nada asociado a su clave? Entonces tenemos algunas posibilidades, la primera es probar la presencia de la clave con el método containsKey():

1
2
3
if (!wordsCount.containsKey("the")) {
    wordsCount.put("the", 150);
}

Gracias al método containsKey(), podemos comprobar si un elemento ya está asociado a la clave the y solo añadir un valor si no es así.

Sin embargo, eso es un poco detallado, especialmente considerando que hay otras dos opciones. En primer lugar, veamos el más antiguo, el método putIfAbsent():

1
wordsCount.putIfAbsent("the", 150);

Esta llamada de método logra el mismo resultado que la anterior, pero usando solo una línea.

Ahora, veamos la segunda opción. Desde Java 8, existe otro método similar a putIfAbsent(): computeIfAbsent().

Funciona más o menos de la misma manera que el anterior, pero toma una función lambda en lugar de un valor directo, lo que nos da la posibilidad de instanciar el valor solo si no hay nada adjunto a la clave. aún.

El argumento de la función es la clave, en caso de que la instanciación del valor dependa de él. Entonces, para lograr el mismo resultado que con los métodos anteriores, tendríamos que hacer:

1
wordsCount.computeIfAbsent("the", key -> 3 + 150);

Proporcionará el mismo resultado que antes, solo que no calculará el valor 153 si ya hay otro valor asociado a la clave the.

Nota: Este método es particularmente útil cuando el valor es pesado para instanciar o si el método se llama con frecuencia y queremos evitar crear demasiados objetos.

Recuperando Elementos

Hasta ahora, aprendimos cómo poner elementos en un ‘Mapa’, pero ¿qué hay de recuperarlos?

Para lograr eso, usamos el método get():

1
wordsCount.get("the");

Ese código devolverá el número de palabras de la palabra the.

Si ningún valor coincide con la clave dada, get() devuelve null. Sin embargo, podemos evitar eso usando el método getOrDefault():

1
wordsCount.getOrDefault("duck", 0);

Nota: Aquí, si no hay nada asociado a la clave, obtendremos 0 en lugar de null.

Ahora, eso es para recuperar un elemento a la vez usando su clave. Veamos cómo recuperar todos los elementos. La interfaz Map ofrece tres métodos para lograr esto:

  • entrySet(): Devuelve un Conjunto de Entry<K, V> que son pares clave/valor que representan los elementos del mapa
  • keySet(): Devuelve un Set de claves del mapa
  • values(): Devuelve un Set de valores del mapa

Eliminación de elementos

Ahora que sabemos cómo colocar y recuperar elementos de un mapa, ¡veamos cómo eliminar algunos!

Primero, veamos cómo eliminar un elemento por su clave. Para ello, utilizaremos el método remove(), que toma una clave como parámetro:

1
wordsCount.remove("the");

El método eliminará el elemento y devolverá el valor asociado, si lo hay; de lo contrario, no hace nada y devuelve null.

El método remove() tiene una versión sobrecargada que también toma un valor. Su objetivo es eliminar una entrada solo si tiene la misma clave y valor que los especificados en los parámetros:

1
wordsCount.remove("the", 153);

Esta llamada eliminará la entrada asociada a la palabra the solo si el valor correspondiente es 153, de lo contrario, no hace nada.

Este método no devuelve un ‘Objeto’, sino que devuelve un ‘booleano’ que indica si un elemento se ha eliminado o no.

Iterando sobre Elementos

No podemos hablar de una colección de Java sin explicar cómo iterar sobre ella. Veremos dos formas de iterar sobre los elementos de un Mapa.

El primero es el bucle for-each, que podemos usar en el método entrySet():

1
2
3
for (Entry<String, Integer> wordCount: wordsCount.entrySet()) {
    System.out.println(wordCount.getKey() + " appears " + wordCount.getValue() + " times");
}

Antes de Java 8, esta era la forma estándar de iterar a través de un Mapa. Afortunadamente para nosotros, se ha introducido una forma menos detallada en Java 8: el método forEach() que toma un BiConsumer<K, V>:

1
wordsCount.forEach((word, count) -> System.out.println(word + " appears " + count + " times"));

Dado que algunos pueden no estar familiarizados con la interfaz funcional, BiConsumer - acepta dos argumentos y no devuelve ningún valor. En nuestro caso, pasamos una palabra y su recuento, que luego se imprimen a través de una expresión Lambda.

Este código es muy conciso y más fácil de leer que el anterior.

Comprobar la presencia de un elemento

Aunque ya teníamos una descripción general de cómo verificar la presencia de un elemento en un Mapa, hablemos de las posibles formas de lograrlo.

En primer lugar, está el método containsKey(), que ya usamos y que devuelve un valor booleano que nos dice si un elemento coincide o no con la clave dada. Pero también existe el método containsValue() que verifica la presencia de un cierto valor.

Imaginemos un Mapa que represente los puntajes de los jugadores para un juego y el primero en llegar a 150 victorias, entonces podríamos usar el método containsValue() para saber si un jugador gana el juego o no:

1
2
3
4
5
6
7
8
9
Map<String, Integer> playersScores = new HashMap<>();
playersScores.put("James", 0);
playersScores.put("John", 0);

while (!playersScores.containsValue(150)) {
    // Game taking place
}

System.out.println("We have a winner!");

Recuperación de tamaño y verificación de vacío {#recuperación de tamaño y verificación de vacío}

Ahora, en cuanto a ‘Lista’ y ‘Conjunto’, existen operaciones para contar el número de elementos.

Esas operaciones son size(), que devuelve el número de elementos del Mapa, y isEmpty(), que devuelve un booleano que indica si el Mapa contiene o no algún elemento:

1
2
3
4
5
6
Map<String, Integer> map = new HashMap<>();
map.put("One", 1);
map.put("Two", 2);

System.out.println(map.size());
System.out.println(map.isEmpty());

La salida es:

1
2
2
false

Mapa Ordenado

Ahora hemos cubierto las principales operaciones que podemos realizar en Map a través de la implementación de HashMap. Pero hay otras interfaces de mapas que heredan de él que ofrecen nuevas funciones y hacen que los contratos sean más estrictos.

El primero sobre el que aprenderemos es la interfaz SortedMap, que garantiza que las entradas del mapa mantendrán un cierto orden en función de sus claves.

Además, esta interfaz ofrece funciones que aprovechan el orden mantenido, como los métodos firstKey() y lastKey().

Reutilicemos nuestro primer ejemplo, pero usando un SortedMap esta vez:

1
2
3
4
5
6
7
SortedMap<String, Integer> wordsCount = new TreeMap<>();
wordsCount.put("the", 150);
wordsCount.put("ball", 2);
wordsCount.put("duck", 4);

System.out.println(wordsCount.firstKey());
System.out.println(wordsCount.lastKey());

Debido a que el orden predeterminado es el natural, esto producirá el siguiente resultado:

1
2
ball
the

Si desea personalizar los criterios de orden, puede definir un ‘Comparador’ personalizado en el constructor ‘TreeMap’.

Al definir un ‘Comparador’, podemos comparar claves (no entradas de mapa completas) y ordenarlas en función de ellas, en lugar de valores:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
SortedMap<String, Integer> wordsCount =
    new TreeMap<String, Integer>(new Comparator<String>() {
        @Override
        public int compare(String e1, String e2) {
            return e2.compareTo(e1);
        }
    });

wordsCount.put("the", 150);
wordsCount.put("ball", 2);
wordsCount.put("duck", 4);

System.out.println(wordsCount.firstKey());
System.out.println(wordsCount.lastKey());

Como el orden se invierte, la salida ahora es:

1
2
the
ball

Mapa navegable

La interfaz NavigableMap es una extensión de la interfaz SortedMap y agrega métodos que permiten navegar el mapa más fácilmente encontrando las entradas más bajas o más altas que una clave determinada.

Por ejemplo, el método lowerEntry() devuelve la entrada con la clave mayor que es estrictamente menor que la clave dada:

Tomando el mapa del ejemplo anterior:

1
2
3
4
5
6
SortedMap<String, Integer> wordsCount = new TreeMap<>();
wordsCount.put("the", 150);
wordsCount.put("ball", 2);
wordsCount.put("duck", 4);

System.out.println(wordsCount.lowerEntry("duck"));

La salida sería:

1
ball

Mapa concurrente

Finalmente, la última extensión de ‘Mapa’ que cubriremos es ‘ConcurrentMap’, que hace que el contrato de la interfaz de ‘Mapa’ sea más estricto al garantizar que sea seguro para subprocesos, que se puede usar en un contexto de subprocesos múltiples sin temiendo que el contenido del mapa sea inconsistente.

Esto se logra realizando las operaciones de actualización, como put() y remove(), sincronizado.

Implementaciones

Ahora, echemos un vistazo a las implementaciones de las diferentes interfaces Map. No los cubriremos todos, solo los principales:

  • HashMap: esta es la implementación que más usamos desde el principio, y es la más sencilla, ya que ofrece un mapeo simple de clave/valor, incluso con claves y valores nulos. Es una implementación directa de Map y, por lo tanto, no garantiza el orden de los elementos ni la seguridad de subprocesos.
  • EnumMap: Una implementación que toma constantes enum como claves del mapa. Por lo tanto, el número de elementos en el Mapa está limitado por el número de constantes del enum. Además, la implementación está optimizada para manejar la cantidad generalmente bastante pequeña de elementos que contendrá un ‘Mapa’.
  • TreeMap: como una implementación de las interfaces SortedMap y NavigableMap, TreeMap garantiza que los elementos que se le agreguen observarán un cierto orden (basado en la clave). Este orden será el orden natural de las claves, o el impuesto por un ‘Comparador’ que podemos dar al constructor ‘TreeMap’.
  • ConcurrentHashMap: esta última implementación probablemente sea la misma que HashMap, espere que garantice la seguridad de subprocesos para las operaciones de actualización, como lo garantiza la interfaz ConcurrentMap.

Conclusión

El marco de Java Collections es un marco fundamental que todo desarrollador de Java debería saber cómo usar.

En este artículo, hemos hablado sobre la interfaz Map. Cubrimos las operaciones principales a través de un HashMap así como algunas extensiones interesantes como SortedMap o ConcurrentMap.

Licensed under CC BY-NC-SA 4.0