07.Colecciones

Colecciones

Java Collections Framework

Java Collections Framework

El Java Collections Framework es un conjunto de clases e interfaces que implementa estructuras de datos de uso habitual para colecciones de objetos: listas, conjuntos, mapas,…

  • Aunque su nombre oficial incluye el término “framework”, su funcionamiento es el de una librería. Basicamente, pone a nuestra disposición su conjunto de interfaces y las clases que los implementan para reutilizarlas libremente en nuestras aplicaciones.
  • Tanto colecciones como arrays nos permiten almacenar un conjunto de referencias a objetos y manejarlas como un único grupo. La principal diferencia radica en que, a diferencia de los arrays, las colecciones son dinámicas. No necesitan que especifiquemos cuál va a ser su capacidad en el momento de la instanciación y pueden “crecer” y “encoger” de forma automática a medida que añadimos o eliminamos objetos de la misma.
  • Las colecciones de Java sólo pueden almacenar tipos referenciados, por tanto, no podemos crear colecciones de tipos primitivos (int, double,…). Para almacenar valores de estos tipos, deberemos hacerlos a través de sus clases wrapper (envoltorio): Integer, Long, Double,…

Diagrama: Diagrama completo del framework

Resumen del diagrama: List: ordenada y con duplicados Set: no duplicados, no acceder por posición. Map: asociación claves y valores Queue: FIFO y otras formas.

Descripción de la intefaz

El interfaz java.util.Collection define la funcionalidad central que esperamos de cualquier colección que no sea un mapa. Sus métodos (ver figura de la página siguiente) se agrupan en cuatro grupos principales:

  • Añadir elementos.
  • Eliminar elementos.
  • Consultar el contenido de la colección.
  • Hacer accesible el contenido de la colección para procesamiento fuera de la colección.

Al ser Collection un subinterfaz de Java.lang.Iterable, cualquier colección que lo implemente podrá ser recorrida mediante bucles for-each.

Métodos para agregar objetos

  • boolean add(E e): Añade el nuevo objeto e a la colección
  • boolean addAll(Collection<? extends E> c): Añade todos los elementos de la colección c

Ambos métodos devuelven booleano, nos indicará si se agregado un elemento con éxito o si ha modificado la colección (hay colecciones que no aceptan duplicidad de objetos y pueden rechazar la inserción de objetos).

Métodos para eliminar objetos

  • void clear(): Elimina todos los elementos de la colección
  • boolean remove(Object o): Elimina el objeto o de la colección
  • boolean removeAll(Collection c): Elimina todos los elementos que se encuentren también en c.
  • boolean retainAll(Collection c): Mantiene sólo los elementos que se encuentren también c.

El argumento boolean devuelto por este método indica si la colección ha sido modificada en la ejecución.

Métodos para consultar a los objetos

  • boolean contains(Object o): true si la colección contiene al objeto o; false en otro caso.
  • boolean containsAll(Collection c): true si la colección contiene a todos los elementos de c.
  • boolean isEmpty(): true si la colección está vacía.
  • int size(): Devuelve el número de elementos de la colección.

Métodos para recorrer la colección

  • Iterator iterator(): Devuelve un iterador sobre la colección, lo más frecuente.
  • Object[] toArray(): Devuelve un array con todos los objetos de la colección, tipo Object
  • T[] toArray(T[] a): Devuelve un array con todos los objetos del tipo del array a

Introducción a las colecciones de objetos

Introducción a las colecciones de objetos

Surgen las colecciones como forma de agrupar objetos dentro de una arquitectuctura O.O.. Es decir organizar los objetos de una forma que se adapte a nuestras necesidades, y de forma dinámica. Y eso incluye organización de información y también funcionalidades específicas. Estas colecciones serán estruturas de datos dinámicas que cambian de tamaño en tiempo de ejecución, justo lo contrario a Arrays que debemos fijar el tamaño máximo previo a la ejecución del programa es una estrutura de datos estática. Para ello java nos ofrece Java Collections Framework que veremos a continuación.

Listas

Listas (interfaz List)

Es una colección de objetos en forma de nodos enlazados, donde poder insertar los objetos de manera dinámica y flexible. La interfaz List es la base para las colecciones que vamos a ver

interfaz list

Las dos implementaciónes más usadas de list son: ArrayList y LinkedList

ArrayList

Los elementos se guardan en una estructura de array dinámico. Es muy eficiente para recorrer todos los elementos, y algo más lento en insertar y borrar los objetos.

Crear un ArrayList

List<String> listStrings = new ArrayList<String>();
listStrings.add("One");
listStrings.add("Two");
listStrings.add("Three");
listStrings.add("Four");
System.out.println(listStrings);

Versión a partir de JDK7 (sintaxis más corta):

List<String> listStrings = new ArrayList<>();
listStrings.add("One");
listStrings.add("Two");
listStrings.add("Three");
listStrings.add("Four");
System.out.println(listStrings);

Forma alternativa y rápida (versiones ultimas de JDK) List.of: Se crea una lista de Integers indexados, a partir del método estático. Pero ojo se crea una lista inmutable -> nose puede modificar (concepto que veremos más adelante)

List<Integer> listNumbers = List.of(1, 2, 3, 4, 5, 6);

Alternativa y que nos genere algo modificable, usando el constructor de ArrayList:

 ArrayList<Integer> listaEspecial = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
listaEspecial.add(9);
listaEspecial.forEach(l -> System.out.println(l));

Crear una ArrayList a partir de otro (volcado de elementos): usando el constructor con parámetro

List<Integer> listNumberOne;  // existing collection
List<Integer> listNumberTwo = new ArrayList<>(listNumberOne);

Agregar objetos

Método add, que tiene dos versiones: add(Object): inserta en lista el objeto en la posición última. add(index, Object): insertar en la posición index el objeto

List<String> listStrings = new ArrayList<>();

listStrings.add("One");
listStrings.add("Two");
listStrings.add("Three");

listStrings.add(1, "Four");

Método addAll: agrega los objetos de una lista a otra (existe la versión con índice a partir de una posición se vuelcan).

listStrings.addAll(listWords);
listStrings.addAll(2, listWords);

Obtener objeto

Con el metodo get y un número se obtiene el objeto de dicha posición

String element = listStrings.get(1);

Actualizar objeto

Para ello tenemos el método set, especificando cual con la posición del arrayList:

listStrings.set(2, "Hi");

Eliminar objetos de la lista

Tenemos el método remove, y con un índice indicamos que objeto queremos eliminar de la lista, este método siempre es conveniente comprobar con el tamaño de la lista (size) que la posición existe:

listStrings.remove(2);

Para vaciar toda lista existe el clear().

Buscar objeto en la lista

Se pueden usar los métodos contains (devuelve booleano) y indexOf (devuelve la posición). Ejemplo:

import java.util.*;
public class ListaDemo3 {
 public static void main(String[] args) {
 List<String> lista = new ArrayList<>();
 String s = "bye";
 lista.add("hi");
 lista.add(s);
 System.out.println("hello está en la lista? " + lista.contains("hello"));
 System.out.println("bye está en la lista? " + lista.contains("bye"));
 System.out.println(s + " está en la lista? " + lista.contains(s));
 System.out.println("posición de " + s + " en la lista? " + lista.indexOf(s));
 }
}

Recorrer la lista

Hay muchas posibilidades para este menester, estas son las más típicas: For normal:

for (String element : listStrings) {
  System.out.println(element);
  }

Uso de Iterator o ListIterator (más posibilidades de recorrer y modificar), con esta forma los métodos imprescindibles:

  • next(): mueve el puntero de objeto a objeto, pero antes se obtiene el objeto
  • hasNext():devuelve verdadero si hay objeto en la siguiente posición. Ejemplo:
Libro libro1 = new Libro("0345404475", "Do Androids Dream of Electric Sheep?", "Philip K.Dick");
Libro libro2 = new Libro("0451457998", "2001: A Space Odissey", "Arthur C. Clarke");
List<Libro> biblio = new ArrayList<>()
biblio.add(libro1);
biblio.add(libro2);
Iterator<Libro> it = biblio.iterator(); // Iterator que apuntará al inicio de la colección
while(it.hasNext()) { // mientras haya "siguiente" elemento...
    Libro libro = it.next(); // leemos el "siguiente" elemento
    System.out.println("Título: " + libro));
}

ListIterator: métodos a parte de los de recorrido que podéis probar: add(), remove() y set() enlace a javadoc

Desde Java 8, se puede usar el forEach() con lambda:

listStrings.forEach(s -> System.out.println(s));

Volcar Array a List

Tenemos de la clase vista anteriormente Arrays un metodo muy cómodo que es asList. Ejemplo:

String[] array = {"new", "String", "array"};
List<String> list = Arrays.asList(array);
// y es modificafle es decir no inmutable

Ejercicios

Ejercicio 1:

Realiza un pequeño programa de agenda de personas, los datos recogidos serán número de telefono y nombre. El programa debe almacenar los contactos, nos debe dar el número de contactos de la agenda,listar la agenda (prueba todas las formas vistas), eliminar un contacto y resetear la agenda. Utiliza un ArrayList.

Ejercicio 2:

Escribe un método denominado swapPairs que intercambie los valores de un ArrayList de Strings por parejas. Es decir, intercambiará el primer elemento con el segundo, el tercero con el cuarto,…

En caso de que el número de elementos sea impar, el último elemento no se intercambiará.

Ejercicio 3:

Escribe un método denominado minToFront que reciba un ArrayList de enteros y mueva el mínimo de ellos al comienzo de la lista, manteniendo el orden del resto de números

Puedes asumir que la lista tendrá, al menos, un elemento. Por ejemplo: Test: 3, 8, 92, 4, 2, 17, 9 Resultado: [2, 3, 8, 92, 4, 17, 9]

Ejercicio 4:

Escribe un método denominado markLength4 que reciba un ArrayList de Strings como argumento e inserta la cadena “****” en frente de cada elemento que tenga 4 caracteres. Por ejemplo: Test: “this”, “is”, “lots”, “of”, “fun”, “for”, “every”, “Java”, “programmer” Resultado: **** , this, is, **** , lots, of, fun, for, every, ****, Java, programmer

Ejercicio 5:

Escribe un método denominado filterRange que reciba tres parámetros: un ArrayList de enteros, un valor min y un valor max. El método debe eliminar de la lista todos los elementos cuyo valor se encuentren en el rango [min, max]. Por ejemplo: Test: List lista = new ArrayList<>(Arrays.asList(new Integer[]{4, 7, 9, 2, 7, 7, 5, 3, 5, 1, 7, 8, 6, 7})); filterRange(lista, 5, 7); Resultado: [4, 9, 2, 3, 1, 8]

LinkedList

El diseño de esta colección de objetos varia a la del un ArrayList, en este caso es una lista enlazada. Y es más eficiente en inserciones y borrado de objetos. Ahora veremos sólo lo diferente al ArrayList.

Crear un LinkedList

List<String> listStrings = new LinkedList<>();

Obtener objeto en LinkedList

Con este implementación de List tenemos dos métodos bastante útiles: getFirst() y getLast(). Ejemplo:

LinkedList<Number> numbers = new LinkedList<Number>();
// add elements to the list...
// get the first and the last elements:
Number first = numbers.getFirst();
Number last = numbers.getLast();

Ejercicios Extra

Ejercicio 6:

Partiendo del esta interfaz:

public interface Ejercicio6 {
	
void insert(Object x); //Inserta x 
void remove(Object x ); //Elimina el primer x
void removeCurrent();  //Elimina el elemento current
boolean find(Object x); //Coloca current para poder ver x
void goFirst(); // Coloca current en la primera posición
void advance(); // Avanza current al siguiente nodo
boolean isOnList(); //No está vacía
Object getCurrent(); //Elemento en la posición current
Object getPrevious(); // Elemento de la posición anterior al current
}

Nos piden que implementemos dichas funcionalidades con la colección LinkedList, sobre un programa de reservas para un avión. Todas las reservas serán de un mismo avión. Los datos necesarios son origen, destino, plaza, businnes y los datos del pasajero (dni, nombre, apellidos, edad y nacionalidad).

Conjuntos (Sets)

Aspectos a tener en cuenta

Un conjunto (set) es una colección de objetos que no puede contener duplicados. Añadir un elemento ya presente en la colección no tendrá ningún efecto. Conjuntos

El interfaz Set define los mismos métodos que el interfaz Collection. Hay muchos tipos de colecciones que implementan la interfaz Set: HashSet, EnumSet, AbstractSet, LinkedHashSet, TreeSet, CopyOnWriteArraySet,…

Elegir una u otra dependerá del rendimiento en estos factores: búsqueda, lectura, ordenación etc…

Clases que implementan Set

Implementación HashSet

Es la implementación más usada de Set. Como su nombre indica, se implementa mediante un tabla hash. Una tabla hash básicamente es un array donde cada uno de sus elementos se asocia a una posición calculada a partir del propio contenido que se almacenará en dicha posición. En el caso concreto de Java, se emplea el valor hash devuelto por cada objeto (método hashCode( ) de Object) y se aplica una máscara sobre los bits menos significativos de dicho valor.

Importante: Dado que la posición que ocupa un elemento en la tabla hash depende de su propio contenido, y no del orden o instante en que se añadió a la tabla, no podemos garantizar el orden en que un iterador sobre la tabla nos devolverá su contenido (como veremos en el próximo ejemplo).

Ventaja:

  • Velocidad de acceso de lectura/escritura constante y rápido.

Desventaja:

  • No recomendable para recorrer todos los objetos de la colección.

Método HasCode()

Método de Object que podemos sobrescribir para cambiar el “id” de nuestros objetos. Es decir crear un hash único. Para una correcta sobrescritura de este método, hay que tener en cuenta estos aspectos:

  • Debe devolver un int. Si es necesario crear cast y/o parsear.
  • Debe tener la suficiente complejidad para que no se produzcan colisiones.
  • Es conveniente implementar el método equals: Este método viene a complementar al método hashCode y sirve para comparar objetos de una forma más rápida en estructuras Hash ya que únicamente nos devuelve un número entero. Cuando Java compara dos objetos en estructuras de tipo hash (HashMap, HashSet etc) primero invoca al método hashcode y luego el equals. Si los métodos hashcode de cada objeto devuelven diferente hash no seguirá comparando y considerará a los objetos distintos. En el caso en el que ambos objetos compartan el mismo hashcode Java invocará al método equals() y revisará a detalle si se cumple la igualdad. De esta forma las búsquedas quedan simplificadas en estructuras hash.
  • Se debe usar los mismos atributos para hashCode() como para equals().
  • Si hay algún cambio de valor en los atributos de equals(), el hashCode debe cambiar.
  • Al sobrescribir este método, dos objetos con el mismo contenido (iguales para nosotros) tiene que devolver el mismo hash: Probarlo!

Por tanto a partir de ahora si sobreescribimos el equals() debemos sobreescribir el hashCode() y viceversa.

Ejemplo:

class Money {
    
    private int amount;
    private String currencyCode;
    
    public Money(int dinero, String code) {
        this.amount = dinero;
        this.currencyCode = code;
    }
    
    public int getAmount() {
        return amount;
    }
    
    public String getCurrencyCode() {
        return currencyCode;
    }
    
    
    @Override
    public boolean equals(Object o) {
        
        if (o == this)
            return true;
        if (!(o instanceof Money))
            return false;
        Money other = (Money)o;
        boolean currencyCodeEquals = (this.currencyCode == null && other.currencyCode == null)
          || (this.currencyCode != null && this.currencyCode.equals(other.currencyCode));
        return this.amount == other.amount && currencyCodeEquals;
    }
    
    @Override
    public int hashCode() {
        //Todos los strings tienen este método
        if (currencyCode != null)
            return currencyCode.hashCode() + amount;
        return amount + 3;
    }
    

Implementar este método en cada clase de una aplicación es tedioso, repetitivo y propenso a errores, para hacer más sencilla su implementación existe el método Objects.hash desde la versión 7 de Java. Un ejemplo para una clase que representa un número de teléfono quedaría así:

public class PhoneNumber {

    ...

    @Override
    public int hashCode() {
        return Objects.hash(areaCode, prefix, lineNumber);
    }
}

Creación, agregar y recuperar

Ejemplo básico de declararlo y agregar objetos:

 // HashSet declaracion
      HashSet<String> hset = 
               new HashSet<String>();

      // Agregar elementos al HashSet
      hset.add("Apple");
      hset.add("Mango");
      hset.add("Grapes");
      hset.add("Orange");
      hset.add("Fig");

      //Agregar (intentar) objetos duplicados
      hset.add("Apple");
      hset.add("Mango");

      //Esta permitido agregar valores nulos
      hset.add(null);

      System.out.println(hset);

Los demás métodos típicos de Collection se usan igual con esta colección, cabe destacar algún método de la teoría de conjuntos como el retainAll(): ¿Que hace?

HashSet<Integer> numbersValues = new HashSet<Integer>();
  
            numbersValues.add(4);
            numbersValues.add(6);
            numbersValues.add(8);
            numbersValues.add(10);
            numbersValues.add(12);
  
            System.out.println("Initial hashSet " + numbersValues);
  
            HashSet<Integer> numbersValuesToRetain = new HashSet<Integer>();
            arrset2.add(4);
            arrset2.add(6);
            arrset2.add(8);
  
            System.out.println("Values to retain" + numbersValuesToRetain);
            numbersValues.retainAll(numbersValuesToRetain);
  
            System.out.println("HashSet after retainAll " + numbersValues);

Hay más…

Ejercicio 1

Escribe un método denominado maxLength que acepte un HashSet de cadenas de caracteres y devuelva la longitud de la cadena más larga. Si el Set está vacío, devolverá 0

Escribe un método denominado hasOdd que acepte un Set de enteros y devuelva true o false dependiendo de si el Set contiene algún número impar o no.

Escribe un método denominado removeEvenLength que acepte un Set de cadenas de caracteres y elimine del Set todas las cadenas de longitud par.

Ejercicio 2

Modifica el ejercicio de agenda de arrayList, y usa ahora HashSet. Debes evitar que en la agenda se pueda agregar dos personas con el mismo número. Crea tu propio calculo de hash para los objetos Persona. ¿Cómo lo harías?

Implementación TreeSet

Almacenamiento basado en un árbol. Un árbol normalmente se implementa como una variante de lista enlazada en la que cada nodo puede contener 1 o más hijos. A medida que se van insertando elementos en la colección, estos se irán “colocando” de forma ordenada sobre el árbol. La estructura normal es de un arbol binario. Por defecto en orden ascendente. El TreeSet no mantiene el orden de inserción, los elementos se ordenan por orden natural. Y tampoco tiene objetos duplicados. Implementa estas dos interfaces NavigableSet y SortedSet, esto provoca métodos adicionales para odenación y recorrer la coleccción:

Metodos principales

Ejemplo:

import java.util.*;
public class SetDemo3 {
 
 public static void main(String[] args) {
    TreeSet<Integer> ts = new TreeSet<>();
    ts.add(4); ts.add(-1); ts.add(0);
    ts.add(2); ts.add(7); ts.add(5);
    ts.add(3); ts.add(3); ts.add(3); // Sólo almacenará uno
    System.out.println("ts: " + ts); // Estarán ordenados de menor a mayor
    System.out.println("Mayor: " + ts.last() + ", Menor: " + ts.first());
    System.out.println("Primer elemento mayor que 0: " + ts.higher(0));
    System.out.println("Primer elemento menor o igual que 6: " + ts.floor(6));
    NavigableSet<Integer> range = ts.subSet(-5, true, 3, false);
    System.out.println("Elementos en el rango [-5, 3): " + range);
 }
}

Recorrer un TreeSet

Para este tipo de colección podemos usar los “mecanismos” ya vistos (for, iterator, forEach), pero también podemos usar otra forma más de lambda stream() y Map:

 TreeSet<Integer> ts = new TreeSet<Integer>();

     ts.addAll(Arrays.asList(10, 61, 87, 39));

     System.out.print("TreeSet:");

     // Usando forEach
     ts.forEach(i -> System.out.print(i + " "));
     System.out.println();

     System.out.print("TreeSet:");

     // Usando stream con map (mapear cada elemento), el Collectors lo transforma en una lista
     System.out.print(
         ts.stream()
             .map(i -> String.valueOf(i)) .collect(Collectors.toList())
             );

Aclaración final

Hemos visto en TreeSet se hace una inserción y se ordena de manera ascendente, ¿que ocurre si son objetos complejos? –> Será necesario comparar y ordenar Prueba a agregar al TreeSet objetos complejos ¿qué ocurre?

Ejercicio 3

Las operaciones típicas de conjuntos son: union, diferencia e intersección. Haz un programa que prueba con los datos de los conjuntos A y B las tres operaciones indicadas. El resultado del programa debe ser el siguiente. Consulta el API para saber los métodos que debes usar para hacer las operaciones deseadas. A:[5, 7, 9, 19] B:[10, 20, 5, 7] A union B: [19, 20, 5, 7, 9, 10] A diferencia B: [19, 9] A intersección B: [5, 7]

Ejercicio 4

Crea un pequeño programa donde se inserten números aleatorios del 1 al 100 en una estructura de datos. Debemos insertar 50 elementos y que no se repitan.

Ejercicio 5

Crea un método que muestre el ultimo elemento de un TreeSet de enteros y lo elimine.

Ordenación y Comparación

Acabamos de ver que con TreeSets se insertan los objetos de manera ordenada ascendentemente, pero cuando usamos objetos más complejos que un Integer necesitamos establecer como queremos que los objetos se ordenen, una condiciones de ordenación que debemos establecer. Hay dos formas:

  • Que las clases usen interfaz Comparable.
  • O crear una clase Comparator.

Nota: Usaremos TreeSet para estos ejemplos pero es aplicable para casi todas las colecciones.

Implementando la interfaz Comparable

Dicha interfaz sólo define un método:

public interface Comparable<T> {
 int compareTo(T o);
}

Este método devolverá:

  • Negativo: this menor que “o” (parámetro de entrada del método).
  • Cero: this igual que “o”.
  • Positivo: this mayor que “o”.

Ejemplo:

import java.time.LocalDateTime;
public class User implements Comparable<User> {
    private int id; // id del usuario
    private String loginName; // login de inicio de sesión
    private LocalDateTime lastLogin; // último inicio de sesión
 
    User(int id, String loginName) {
        this.id = id;
        this.loginName = loginName;
    }
 
    public LocalDateTime getlLastLogin() { return this.lastLogin; }
    public void regSystemLogin() { this.lastLogin = LocalDateTime.now(); }
 
    @Override
    public String toString() {
        return "{" + this.id + ": " + this.loginName + ": " + this.lastLogin + "}";
    }

    @Override
    public int compareTo(User other) {
        // Comparamos los id de ambos usuarios. Podemos aprovechamos del propio método compareTo()
        // de la clase Integer pues la clase Integer implementa el interfaz Comparable
        return Integer.valueOf(this.id).compareTo(other.id);
    }
 
    @Override
    public boolean equals(User other) {
        return this.id == other.id;
    }

    @Override
    public int hashCode() {
        int result = Integer.hashCode(this.id);
        result = 31*result + this.loginName.hashCode();
        result = 31*result + ((this.lastLogin==null)? 0: this.lastLogin.hashCode());
        return result;
    }

Como observais en el anterior ejemplo también están implementados el hashCode() y el equals().

Creando un Comparator

De esta manera es necesario crear una clase a parte que implemente la interfaz Comparator. Nos ofrece más flexibilidad y posibilidades que la anterior forma. Y podemos usar esta clase para otras clases que compartan mismos criterios de ordenación. En lo que coincide con la anterior forma es que debemos implementar el método compare(), pero recibirá los dos objetos a comparar. En este ejemplo se ordenará por el último acceso a los usuarios:

import java.time.LocalDateTime;
class UserComparator implements Comparator<User> {
 @Override
 public int compare(User u1, user u2) {
    // Podemos aprovechamos del propio método compareTo() de la clase LocalDateTime
    // ya que implementa el interfaz Comparable
    return u1.getLastLogin().compareTo(u2.getLastLogin());
 }
}

Una vez creada la clase podemos “insertarla” en el constructor del TreeSet:

 TreeSet<User> users2 = new TreeSet<>(new UserComparator()); 

Pero también al método sort() de Collections y Arrays.

Uso de Collections.sort()

Por último también podemos utilizar un método de Collection que modifica la coleción, es el sort():

List<String> fruits = new ArrayList<String>();
fruits.add("Apple");
fruits.add("Orange");
fruits.add("Banana");
fruits.add("Grape");

Collections.sort(fruits);
System.out.println(fruits);

Con objetos “de verdad” seguimos utilizando el compareTo():

public class Fruit implements Comparable<Object>{
    private int id;
    private String name;
    private String taste;

    Fruit(int id, String name, String taste){
        this.id=id;
        this.name=name;
        this.taste=taste;
    }
    @Override 
    public int compareTo(Object o) {
        Fruit f = (Fruit) o; 
        return this.id - f.id ;
    }
}

//Uso el mismo
Collections.sort(fruitList);
fruitList.forEach(fruit -> {
    System.out.println(fruit.getId() + " " + fruit.getName() + " " + 
      fruit.getTaste());
});

El .sort() también acepta una clase Comparator:

class SortByName implements Comparator<Fruit> {
    @Override
    public int compare(Fruit a, Fruit b) {
        return a.getName().compareTo(b.getName());
    }
}
//Uso
Collections.sort(fruitList, new SortByName());

Método estático Compare

Los clases wrappers de java tienen un método estático para comparar, y se comporta de la misma manera que lo anteriormente visto. Ejemplo de uso con Integer:

int val1 = 200;
int val2 = 250;
int val3 = 200;
System.out.println(Integer.compare(val1, val2));
System.out.println(Integer.compare(val1, val3));

Ejercicio 1

Partiendo de la clase Articulo, crea una lista de objetos que los almacene y los ordene por el código del artículo:

class Articulo {
 String codArticulo;
 String descripcion;
 int cantidad;
 Articulo(String codArticulo, String descripcion, int cantidad) {
    this.codArticulo = codArticulo;
    this.descripcion = descripcion;
    this.cantidad = cantidad;
 }

Implememta todo lo necesario.

Luego implementa la ordenación de menos a más cantidad de los articulos.

Ejercicio 2

Añade al siguiente código el comparador necesario para que los teléfonos aparezcan ordenados (Strings). Primero aparecerán ordenados de mayor a menor los números locales (sin símbolo “+” delante) y después los internacionales, también ordenados de mayor a menor: Input: 981555555 34981565656 666666666 +34666666666

Resultado: 981555555 666666666 +34981565656 +34666666666

Podéis probar las dos formas que hemos visto de implementar la ordenación.

Ejercicio 3

Partiendo de una clase estudiante con nombre y edad. Nos piden agregarlo a una lista y que los objetos se ordenen primero por su nombre y luego por la edad (dos condiciones de ordenación). Usa Collections.sort().

Mapas

Mapas (Interfaz Map)

Los mapas en Java van a ser una estructura que almacene objetos a partir de una clave. Se introducen en las colecciones de objetos pero es la única que no depende de Collection. Ver -> diagrama

Por tanto para Java no es una colección como tal, pero el concepto es el mismo. Almacenar y organizar los objetos de un programa. Aspectos importantes:

  • Las claves deben ser únicas -> K
  • Los objetos (V) se almacenarán y se recuperán a partir de la clave.
  • Se basa en una estructura dinámica de tipo diccionario cuyos nodos, o entradas, son instancias de la clase interna Map.Entry<K,V>

Existen tres implementaciones principales de Map:

  • HashMap: permite valores nulos y no es predecible el orden de los objetos (tablas Hash). Conveniente para inserciones y recuperaciones.
  • LinkedHashMap: permite valores nulos y si es predecible el orden de los objetos. Si se va iterar con frecuencia es la mejor opción.
  • TreeMap: no permite valores nulos y si es predecible el orden de los objetos. El más usado, buen rendimiento en búsquedas, acceso y eliminación.

Los Mapas son muy utilizados cuando queremos velocidad de acceso a los objetos y actualizarlos.

Funciones básicas de Map

Creación e inserción de elementos

Método V put(K key, V value): añade o reemplaza la entrada del mapa de la clave K. Devuelve el objeto antiguo si la entrada existe, en caso contrario null. Ejemplo:

Map<Integer, String> mapHttpErrors = new HashMap<>();
 
mapHttpErrors.put(200, "OK");
mapHttpErrors.put(303, "See Other");
mapHttpErrors.put(404, "Not Found");
mapHttpErrors.put(500, "Internal Server Error");
 
System.out.println(mapHttpErrors);

Para volcar un mapa a otro tenemos el método putAll o también el constructor de Map:

Map<Integer, String> mapErrors = new HashMap<>(mapHttpErrors);

Recuperación de objetos

Método principal es el V get(Value k): devuelve el objeto de la clave, null si no existe Otros métodos muy prácticos son el size(), isEmpty(), y por supuesto:

  • boolean containsKey(Object k) Devuelve true si existe una entrada de clave k o false si no existe.
  • boolean containsValue(Object v) Devuelve true si existe una entrada de valor v o false si no existe.

Ejemplo:

String status301 = mapHttpErrors.get(301);
System.out.println("301: " + status301);

if (mapHttpErrors.containsKey("200")) {
    System.out.println("Http status 200");
}
if (mapHttpErrors.containsValue("OK")) {
    System.out.println("Found status OK");
}

String status301 = mapHttpErrors.get(301);
System.out.println("301: " + status301);

Actualizar o eliminar

Para eliminar tenemos:

  • remove(Object key): elimina la entrada al mapa, si existe devuelve el objeto eliminado, sino null.

Para reemplazar:

  • replace(Object Key, Object Value): cambia un objeto por otro a partir de la clave.

Recorrer un Map

Hay muchas formas para recorrer un mapa: Método keySet() y usando un Iterator:

Map<String, String> mapCountryCodes = new HashMap<>();
 
mapCountryCodes.put("1", "USA");
mapCountryCodes.put("44", "United Kingdom");
mapCountryCodes.put("33", "France");
mapCountryCodes.put("81", "Japan");
 
Set<String> setCodes = mapCountryCodes.keySet();
Iterator<String> iterator = setCodes.iterator();
 
while (iterator.hasNext()) {
    String code = iterator.next();
    String country = mapCountryCodes.get(code);
 
    System.out.println(code + " => " + country);
}

Método values() con el cual obtenemos una colección:

Collection<String> countries = mapCountryCodes.values();
 
for (String country : countries) {
    System.out.println(country);
}

El método entrySet(), nos devuelve un Set para recorrerlo es necesario Map.Entry:

Set<Map.Entry<String, String>> entries = mapCountryCodes.entrySet();
 
for (Map.Entry<String, String> entry : entries) {
    String code = entry.getKey();
    String country = entry.getValue();
 
    System.out.println(code + " => " + country);
}

Y por último con lambda y el forEach es muy cómodo:

mapCountryCodes.forEach(
    (code, country) -> System.out.println(code + " => " + country));

Ejemplo:

import java.util.*;
class MapaDemo2 {
    public static void main(String[] args) {
    
    LinkedHashMap<Integer, String> map = new LinkedHashMap<>();
    map.put(1, "uno"); map.put(2, "dos"); map.put(3, "tres");
    System.out.println("Claves = " + map.keySet()); // set-vista de claves
    System.out.println("Valores = " + map.values()); // set-vista de valores
    System.out.println("Entradas = " + map.entrySet()); // set-vista de entradas
 
    Set<Integer> claves = map.keySet();
    for(Integer k: claves) { 
        System.out.println("clave: " + k + "-> val: " + map.get(k)); }
        Set<Map.Entry<Integer, String>> entradas = map.entrySet();
    
    for(Map.Entry<Integer, String> e: entradas) {
        System.out.println("clave: " + e.getKey() + "-> val: " + e.getValue());
     }
 }
}

Ejercicio 1

Tenemos la siguiente tabla de latitudes/longitudes de ciudades nacionales e internacionales:

CIUDAD LATITUD LONGITUD
LUGO 43.01 N 7.33 O
BARCELONA 41.23 N 2.11 E
MADRID 40.24 N 3.41 O
LIMA 12.03 S 77.03 0

Las coordenadas son obligatoriamente objetos de la siguiente clase, a la que añadirás los métodos necesarios class Coordenadas{ private latitud; private longitud; } Los datos de la tabla anterior se almacena obligatoriamente en un hashmap cuya clave es la capital, y el objeto que almacena es la coordenada. Tu programa debe:

  • Insertar los datos.
  • Recorrer el mapa (prueba de varias maneras).
  • Consultar y eliminar objetos.

Ejercicio 2

Crea una pequeña guía telefónica usando TreeMap. Los valores serán nombre y telefono. Funcionalidades:

  • Obtener el número a partir del nombre.
  • Actualizar número.
  • Mostrar todas las entradas de la guía.

Ejercicio 3

Tenemos almacenados en un TreeMap unos alumnos (nombre y nota media), la clave del mapa es el nombre del alumno. Nos piden que se almacene los alumnos por orden natural alfabético del alumno. Más tarde nos piden justo el orden inverso (prueba Collections.reverseOrder()) en el constructor del Treemap. También prueba los métodos firstkey(), lastkey() y headMap(), ¿que hacen?.

Genéricos

Introducción

Cuando hablamos de genérico nos referimos a “tipo genérico”. Es decir tipaje de objetos libre, no es necesario decir el tipo de clase que es o el tipo de dato que va a devolver un método. Con esta implicación se pueden crear Clases genéricas, métodos genéricos o incluso iterfaces genéricas. La idea principal de usar genéricos es ahorrar código y también generar código más flexible. La representación de algo genérico se expresa entre , la letra puede ser cualquiera y se suele representar en mayúsculas. Veamos un ejemplo:

Supongamos que deseamos definir un método que nos permita imprimir arrays de diferentes tipos de datos: enteros, caracteres, cadenas,… Hasta ahora, esto lo conseguíamos mediante la sobrecarga de métodos. Por cada tipo de dato, añadíamos a nuestra clase una nueva versión del método que esencialmente era la misma que las otras, con la salvedad de que el dato sobre el que operaba era diferente:

public class DemoGen1 {
 public static void imprimeArray(int[] arr) {
  for (int val: arr) System.out.printf("%s ", val);
    System.out.println();
 }
 public static void imprimeArray(double[] arr) {
  for (double val: arr) System.out.printf("%s ", val);
    System.out.println();
 }
 public static void main(String[] args) {
  double[] a1 = { 3.5, 2.0, 4, -1.67 };
  int[] a2 = { 5, 0, 4, -1 };
  imprimeArray(a1);
  imprimeArray(a2);
  }
}

La idea fundamental es, en lugar de indicar el tipo de dato, indicamos un tipo “genérico” (le llamaremos T) y realizaremos la implementación con él. Durante la ejecución, será como si dicho tipo genérico se “sustituyera” por el tipo correcto correspondiente:

public class DemoGen2 {
 
 public static <T> void imprimeArray(T[] arr) {
    for (T val: arr) System.out.printf("%s ", val);
        System.out.println();
}

 public static void main(String[] args) {
    Double[] a1 = { 3.5, 2.0, 4, -1.67 };
    Integer[] a2 = { 5, 0, 4, -1 };
    String[] a3 = { "mi", "casa, ", "teléfono " };
    imprimeArray(a1);
    imprimeArray(a2);
    imprimeArray(a3);
 }
}

Acabéis de ver un método genérico para imprimir un array, vamos ahora a por clases genéricas.

Nota importante: al usar métodos o clases con tipos de datos genéricos si se necesita usar tipos de datos primitivos se tiene que utilizar los wrappers correspondientes.

Clases genéricas

En la definición de una clase a la derecha del nombre de la clase con < > se indicará uno o varios tipos parametrizados (una lista de letras en mayúscula): [modif] class nombreClase<lista_tipos_param> [extends …] [implements …] { … }

public class ClaseGen<T> {
 T miVal; //se declara una variable tipo parametrizado

 //tipo T lo podemos usar en métodos sin problemas
 public ClaseGen(T val) {
 this.miVal = val;
 }
 public T getMiVal() {
 return this .miVal;
 }
 public String toString() {
 return "ClaseGen<" + this.miVal.getClass().getName() + ">: miVal = " + this.miVal;
 }
}
// En ejecución hay que indicar que tipos vamos a usar con esa clase genérica

public class DemoGen3 {
 public static void main(String[] args) {
    ClaseGen<Double> obj1 = new ClaseGen<Double>(23.75);
    double v1 = obj1.getMiVal();
    System.out.println("obj1 miVal = " + v1);
    ClaseGen<String> obj2 = new ClaseGen<String>("Hello!");
    String v2 = obj2.getMiVal();
    System.out.println("obj2 miVal = " + v2);
    System.out.println("obj1 = " + obj1);
    System.out.println("obj2 = " + obj2);
 }
}

Fíjate que al declarar una nueva variable de la clase genérica ClaseGen, debemos indicar el tipo “real” que reemplazará, en esa variable concreta, al tipo parametrizado (en este caso, Double). Al crear el objeto, todas las referencias internas de la clase en atributos, variables, métodos,… al tipo parametrizado T serán sustituidas por referencias al tipo indicado. No se pueden usar tipos primitivos.

Varios tipos parametrizados

Si por ejemplo, quisiésemos que el par de elementos fuesen de dos tipos, independientes el uno del otro, podríamos redefinir una clase Pair como:

class Pair<T, V> {
 private T first;
 private V second;
 public T getFirst() {
 return first;
 }
 public V getSecond() {
 return second;
 }
 public void setFirst(T first) {
 this.first = first;
 }
 public void setSecond(V second) {
 this.second = second;
 }
}

Ejercicio 1

Utilizando la definición de clase anterior, crea un main que pruebe estas posibilidades:

  • Un par de Integer y Float.
  • Un par de String y array de enteros.
  • Un par de Persona y Persona. Crea esa clase Persona.

Tipos acotados

Podemos restringir o acotar el tipo genérico en nuestras clases genéricas con el uso de herencia (extends): Por ejemplo, supón que quieres crear una clase genérica que almacene valores numéricos y sea capaz de realizar diferentes operaciones sobre ellos (calcular el inverso,… ). ¿Cómo podríamos restringir que sólo se pudieran crear objetos utilizando clases como Integer, Float o Double? Para ello usaremos una superclase, clase o interface que debe extender el tipo. En este ejemplo Number:

public class ClaseGen<T extends Number> {
   T miVal;
   public ClaseGen(T val) { this.miVal = val; }
   public T getMiVal() { return this.miVal; }
   public double getDoubleVal() {
      return this .miVal.doubleValue();
   }
   public double getMiValInverse() {
      return 1/this.miVal.doubleValue();
   }
   public String toString() {
      return "ClaseGen<" + this.miVal.getClass().getName() + ">: miVal = " + this.miVal;
 }
}

Hemos podido usar el método doubleValue() porque es un método de Number.

Métodos y constructores genéricos

Los métodos también pueden ser genéricos sin necesidad de que los tipos genéricos esten definidos al nivel de clase. Por ejemplo, imagina una clase Util que tiene muchos métodos. Sólo uno de esos métodos, el método imprimirArray() necesita un tipo genérico, no es necesario ni apropiado definir el tipo genérico a nivel de clase, se hace directamente en el método:

class Util {
    static <T> void imprimirArray (T[] t) {
        for(int i=0;i<t.length;i++)
            System.out.println(t[i].toString());
    }
}

De la misma forma que los métodos… los constructores pueden usar los tipos genéricos de la clase:

public class Pareja<K, V> {
   private K clave;
   private V valor;
   public Pareja(K clave, V valor) {
      this.clave = clave;
      this.valor = valor;
   }
 public K getClave() { return this.clave; }
 public T getValor() { return this.valor; }
 public void setClave(K clave) { this.clave = clave; }
 public void setValor(V valor) { this.valor = valor; }
 public String toString() { return "Pareja [" + this.clave + ", " + this.valor + "]"; }
}

Ejercicio 2

Añade a la clase genérica Pair un constructor de forma que al crear los pares se pueda pasar al constructor los valores de first y second.

Ejercicio 3

Crea un main para utiliza el método anterior (imprimirArray) de forma que imprime un array de Integer, un array de Double y un array de personas. Crea tu tres arrays sencillitos de las clases anteriores e invoca al método.

Ejercicio 4

Partiendo de la siguiente implementación de lista enlazada que debes modificarla para que trabaje con cualquier tipo de dato, no sólo entero. Pruébala desde un main y crea y recorre una listas de Persona y otra de Integer.

class Nodo{
  private Nodo sig;
  private int dato;
  public Nodo(int dato) {
    this.dato = dato;
    this.sig=null;
  }
  public Nodo(int dato, Nodo sig) {
    this.dato = dato;
    this.sig = sig;
  }
  public void setSiguiente(Nodo sig) {
    this.sig = sig;
  }
  public Nodo getSiguiente() {
    return sig;
  }
  public int getDato() {
    return dato;
  }
}
class MiListaEnlazada{
    private Nodo primero=null;
    public void insertar(int dato){
        if(primero==null){
            primero=new Nodo(dato);
        }else{
            Nodo temp= new Nodo(dato,primero);
            primero=temp;
        }
            
    }
    public int tamano(){
        int i=0;
        Nodo temp=primero;
        while(temp!=null){
            i++;
            temp=temp.getSiguiente();
        }
        return i;
    }
    public int obtener(int indice){
        Nodo temp=primero;
        int i=0;
        while(i<indice){
            temp=temp.getSiguiente();
            i++;
        }
            
        return temp.getDato();
    }
}

Inmutabilidad Extra

Definición de inmutabilidad

En programación, la inmutabilidad se refiere a la propiedad de un objeto cuyo estado no puede ser modificado una vez que ha sido creado. En Java, esto se logra haciendo que las instancias de una clase sean “inmutables”, lo que significa que sus campos no pueden cambiar después de que se hayan inicializado.

Clase inmutable

Una clase inmutable es simplemente aquella cuyas instancias no pueden ser modificadas una vez que su información ha sido definida. No habrá ninguna modificación a la misma durante su ciclo de vida. Para ello se usa el modificador final, y estos pasos a seguir:

  • No proporcionar ningún método que permita modificar el objeto (como p. ej. los setter).
  • Usar visibilidad privada para los atributos
  • Definir los atributos como final Aunque puede no ser estrictamente necesario, es conveniente, ya que también previene la modificación interna.
  • Declarar la clase como final.

Ejemplos:

public final class Persona {
    private final String nombre;
    private final int edad;

    public Persona(String nombre, int edad) {
        this.nombre = nombre;
        this.edad = edad;
    }

    public String getNombre() {
        return nombre;
    }

    public int getEdad() {
        return edad;
    }
}

final class Circulo { 

  final private Punto origen;
  final private double radio; 

  public Circulo(Punto p, double r) {
    origen = new Punto(p); 
    radio=r; 
  }
  public Punto getOrigen() {
    return new Punto(origen);
  }
  public double getRadio() { return radio; }
  
}

Métodos estáticos de Colecciones

  • List.of(…), Set.of(…) y Map.of(…).
  • Por su parte Java 10 añadió un nuevo método estático copyOf dentro de las interfaces List, Set y Map para facilitar la creación de colecciones inmutables a partir de una colección dada:
List.copyOf(lista);
Set.copyOf(conjunto);
Map.copyOf(mapa);

Usando Arrays.asList()

Arrays.asList() El método devuelve una lista de tamaño fijo respaldada por la array especificada. Dado que una array no se puede modificar estructuralmente, es imposible agregar elementos a la lista o eliminar elementos de ella. La lista arrojará un UnsupportedOperationException si se realiza alguna operación de cambio de tamaño en él. Sin embargo los objetos de la lista si que se pueden modificar. Ejemplo:

import java.util.Arrays;
import java.util.List;

class Main
{
	public static void main(String[] args)
	{
		String[] lang = new String[] { "C", "C++", "Java" };

		List<String> fixedLengthList = Arrays.asList(lang);

		try {
			// inmutable -> UnsupportedOperationException
			fixedLengthList.add("C#");

			System.out.println("List  : " + fixedLengthList);
		}
		catch (UnsupportedOperationException ex) {
			System.out.println("java.lang.UnsupportedOperationException");
		}

		// los objetos si se pueden modificar
		fixedLengthList.set(1, "Go");

		lang[2] = "JS";

        // cualquier cambio se ve reflejado, estamos con referencias
		System.out.println("Array : " + Arrays.toString(lang));
		// lambda
		fixedLengthList.forEach(e ->System.out.print(e.toString()+ " "));
		
	}
}

Crear un String si es inmutable

Una variable tipo String es inmutable. Por si queréis saber la razón -> enlace Es decir siempre que se crea uno y luego se modifica implica crear uno nuevo. Es decir lo que hace realmente hace Java es crear un String nuevo. Hay que pensar que un String es un array de caracteres, y como bien sabéis un array es una estructura estática.

String saludo = "Hola"; // Creamos la cadena con un contenido

saludo = saludo + " mundo"; // y le agregamos después una subcadena

Implica la creación de un nuevo objeto String como resultado. Cuando en la segunda línea extendemos el contenido original de la variable saludo, lo que ocurre es que se libera el objeto String original, el creado en la primera sentencia, y se crea otro nuevo para alojar el resultado de la operación. Dado que el tipo String es inmutable (no podemos modificar su contenido), cualquier operación de modificación sobre una variable de este tipo, como puede ser concatenar una cadena a otra o usar métodos como toUpperCase(), replace() o similares, implica la creación de un nuevo objeto String como resultado.

Si queremos crear programas más eficientes (en el caso en que se usen muchos Strings), la mejor solución es usar un “generador” de Strings mutable. Una posibilidad es usar StringBuilder:

StringBuilder saludo = new StringBuilder("Hola");
saludo.append(" mundo");

Si el programa va a tener concurrencia, lo más seguro será utilizar StringBuffer: enlace

No modificable

No modificable no nos permiten agregar, eliminar o cambiar objetos de una colección, pero cada objeto puede cambiar. Es decir no implica inmutabilidad

Se han agregado en Collections, metodos que nos aporta colecciones no modificables por ejemplo: Collections.unmodifiableSet() o Collections.unmodifiableCollection()

Ejemplo:

class Persona {
    private String nombre;
    private int edad;

    public Persona(String nombre, int edad) {
        this.nombre = nombre;
        this.edad = edad;
    }

    public String getNombre() {
        return nombre;
    }

    public int getEdad() {
        return edad;
    }

    public void setEdad(int edad) {
        this.edad = edad;
    }
}

public class Main {
    public static void main(String[] args) {
        // Crear un conjunto mutable
        Set<Persona> conjuntoMutable = new HashSet<>();
        conjuntoMutable.add(new Persona("Juan", 30));
        conjuntoMutable.add(new Persona("María", 25));
        
        // Crear un conjunto no modificable a partir del conjunto mutable
        Set<Persona> conjuntoNoModificable = Collections.unmodifiableSet(conjuntoMutable);

        // Intentar modificar un objeto en el conjunto no modifiable
        Persona juan = conjuntoNoModificable.iterator().next();
        juan.setEdad(40); // Esto modifica el objeto dentro del conjunto

        // Imprimir el conjunto no modifiable para mostrar que refleja los cambios en el objeto
        System.out.println("Contenido del conjunto no modifiable después de modificar un objeto:");
        for (Persona persona : conjuntoNoModificable) {
            System.out.println(persona.getNombre() + " - " + persona.getEdad());
        }

         // Intentar agregar un elemento al conjunto no modifiable
        try {
        	conjuntoNoModificable.add(new Persona("Carlos", 22)); // Esto lanzará UnsupportedOperationException
        } catch (UnsupportedOperationException e) {
            System.out.println("No se puede agregar elementos al conjunto no modifiable.");
        }
    }
}

Pura inmutabilidad

Crear clases inmutables + Estructura de datos no modificable

Diferencia entre no modificable e inmutable

Es sútil pero importante:

  • Una colección No modificable es como una vista, por tanto indirectamente puede ser modificada por otra refencia a la misma colección modicable (tal como habéis podido comprobar en el anterior ejemplo). Es simplemente una vista de sólo lectura de una colección. No se puede alterar directamente, pero si en el origen cambia también se verá alterado.

  • Sin embargo, una colección Inmutable, se considera una copia de sólo lectura de otra colección y no se puede modificar. Si la colección de origen cambia, la copia inmutable no se verá afectada.

Ejemplo:

ArrayList<String> arrayInicial = new ArrayList();
arrayInicial.add("Hola");
arrayInicial.add("Adios");
arrayInicial.add("Ciao");

List<String> arr = List.copyOf(arrayInicial);
// Coleccion inmutable 
arr.forEach(e -> System.out.println(e));
// Modificamos la colecion original
arrayInicial.add("Adeus");
System.out.println("*".repeat(5));
// Volvemos a recorrer la coleccion y no hay ningún cambio
arr.forEach(e -> System.out.println(e));
// La original
System.out.println("*".repeat(5));
arrayInicial.forEach(e -> System.out.println(e));

Conclusión

En muchos casos necesitaremos “elementos” que conserven los datos, por ejemplo mejorar la eficiencia. Situaciones como:

  • Patrones de diseño.
  • Programación funcional.
  • Programación concurrente (multihilo).

Bibliografía

https://luizcostatech.medium.com/java-lists-unmodifiable-vs-immutable-d670afc58106 https://www.baeldung.com/java-stream-immutable-collection https://gelopfalcon.medium.com/programaci%C3%B3n-funcional-con-java-inmutabilidad-ddab9d40df4a https://javaconceptoftheday.com/java-9-immutable-collections/ https://www.techiedelight.com/es/mutable-unmodifiable-immutable-empty-list-java/

Conclusión

Tomar la decisión de que colección elegir para tu arquitectura es difícil, y hay más factores de los que aparece en el diagrama pero puede servir de guía y es un buen resumen: Diagrama resumen