Colecciones en Java y Genéricos (JCF)

En las próximas lecciones vas a implementar listas, pilas y colas a mano, para entender exactamente cómo funcionan por dentro. Antes de llegar ahí, la buena noticia: en el día a día no hace falta escribir nada de esto. Java lo trae todo, optimizado durante treinta años y probado por millones de aplicaciones.

Pero fijate lo que ganaste: cuando alguien te diga “usá un HashMap”, vas a saber que por dentro hay un arreglo y un mecanismo de colisiones. Cuando veas LinkedList, vas a saber por qué acceder al elemento 500 es lento. Esa es la diferencia entre usar una herramienta y entenderla.


1. La jerarquía del Java Collections Framework

Jerarquía del Java Collections Framework con sus interfaces e implementaciones principales Iterable Collection List Set Queue Map NO extiende Collection ArrayList índice O(1) · el default LinkedList extremos O(1) admite duplicados HashSet sin orden · O(1) TreeSet ordenado · O(log n) sin duplicados ArrayDeque pila y cola · O(1) PriorityQueue sale el menor primero orden de procesamiento HashMap sin orden · O(1) TreeMap ordenado por clave clave → valor Todo lo que cuelga de Collection guarda elementos sueltos y se puede recorrer con for-each. Map guarda asociaciones, así que su interfaz es distinta: por eso queda afuera de la jerarquía.
Las cajas de arriba son interfaces — el contrato que vas a formalizar más adelante como TAD (Tipo Abstracto de Dato); las de abajo, implementaciones. Programá siempre contra las de arriba.

Esa última frase es una regla concreta, no un consejo:

// Bien: el tipo de la variable es la interfaz
List<String> nombres = new ArrayList<>();
Map<String, Integer> stock = new HashMap<>();

// Mal: te atás a la implementación
ArrayList<String> nombres = new ArrayList<>();

Con la primera forma, cambiar a LinkedList es tocar una palabra. Con la segunda, si alguien usó un método propio de ArrayList, es tocar todo. Es el mismo principio que vas a formalizar más adelante como TAD (Tipo Abstracto de Dato) —programar contra la especificación, no contra la implementación— aplicado acá a la biblioteca estándar.


2. Genéricos: el problema que vinieron a resolver

Antes de Java 5 las colecciones guardaban Object. Todo compilaba, y los errores aparecían cuando el programa ya estaba en producción.

Sin genéricos el error aparece en ejecución; con genéricos aparece en compilación Sin genéricos — la colección guarda Object List lista = new ArrayList(); lista.add("hola"); lista.add(42); ← compila perfecto String s = (String) lista.get(1); ClassCastException en EJECUCIÓN, con usuarios Con genéricos — la colección declara qué guarda List<String> lista = new ArrayList<>(); lista.add("hola"); lista.add(42); ← ni siquiera compila Error de compilación en tu IDE, antes de nada Los genéricos no hacen el programa más rápido: adelantan el momento en que descubrís el error. Eso vale oro.
El casteo desaparece y el error se mueve de la madrugada de un domingo a los tres segundos después de escribir la línea.

Con genéricos, además, el compilador ya sabe qué sale de la colección y el casteo desaparece:

List<String> nombres = new ArrayList<>();
nombres.add("Laura");
String primero = nombres.get(0);   // sin casteo: el compilador sabe que es String

El <> vacío de la derecha se llama diamante y le dice al compilador “el mismo tipo que declaré a la izquierda”. Escribir new ArrayList<String>() no está mal, es redundante.


3. Las cuatro familias, y cuándo usar cada una

List — orden de inserción, duplicados permitidos

List<String> tareas = new ArrayList<>();
tareas.add("Estudiar POO");
tareas.add("Practicar listas");
tareas.add("Estudiar POO");        // se repite, y está bien

System.out.println(tareas.get(1));      // acceso por índice
System.out.println(tareas.size());      // 3

Set — sin duplicados, y el orden depende de la implementación

Set<String> etiquetas = new HashSet<>();
etiquetas.add("java");
etiquetas.add("poo");
etiquetas.add("java");              // ignorado, ya estaba

System.out.println(etiquetas.size());   // 2

HashSet no garantiza ningún orden. LinkedHashSet conserva el orden de inserción. TreeSet mantiene los elementos ordenados y te da operaciones como first(), last() y headSet().

Cuidado: para que un HashSet detecte duplicados de tus propias clases, esas clases tienen que implementar equals() y hashCode() correctamente. Sin eso, dos objetos idénticos entran los dos. Es el tema central de la próxima lección.

Map — asociar una clave a un valor

Es la colección más usada de todas, y la que más se subutiliza:

Map<String, Integer> stock = new HashMap<>();
stock.put("yerba", 12);
stock.put("café", 5);
stock.put("yerba", 20);            // sobrescribe: las claves son únicas

System.out.println(stock.get("yerba"));           // 20
System.out.println(stock.get("azúcar"));          // null — no está
System.out.println(stock.getOrDefault("azúcar", 0));  // 0 — mucho mejor

// Recorrer un Map:
for (Map.Entry<String, Integer> entrada : stock.entrySet()) {
    System.out.println(entrada.getKey() + " → " + entrada.getValue());
}

Los métodos modernos de Map eliminan casi todos los if que se solían escribir a mano:

// En lugar de: if (!mapa.containsKey(k)) mapa.put(k, new ArrayList<>());
mapa.computeIfAbsent(clave, k -> new ArrayList<>()).add(valor);

// En lugar de: contador.put(p, contador.containsKey(p) ? contador.get(p) + 1 : 1);
contador.merge(palabra, 1, Integer::sum);

// En lugar de: if (mapa.get(k) == null) mapa.put(k, v);
mapa.putIfAbsent(clave, valor);

Queue / Deque — orden de procesamiento

Los vas a implementar a mano más adelante, en TAD Pila y TAD Cola, pero ya podés usarlos hoy: ArrayDeque para pila y cola; PriorityQueue cuando el próximo a salir no es el que llegó primero, sino el de mayor prioridad.


4. Cómo funciona un HashMap por dentro

Esto explica de una vez por qué get() es O(1) y por qué equals/hashCode importan tanto.

Recorrido interno de una búsqueda en un HashMap desde la clave hasta el bucket clave "gato" lo que vos escribís hashCode() devuelve 3181970 3181970 % 16 → bucket 2 una cuenta: por eso es O(1) bucket 0 — vacío bucket 1 — vacío bucket 2 bucket 3 — vacío colisión: dos claves distintas cayeron en el mismo bucket "gato" → 4 "toga" → 9 Dentro del bucket, equals() compara la clave real y decide cuál de los dos es. hashCode() elige el cajón; equals() elige el elemento dentro del cajón. Si hashCode está mal, la clave se busca en el cajón equivocado y el HashMap responde "no está" aunque el objeto esté guardado.
Con hash bien distribuido casi no hay colisiones y get() es una cuenta. Con hash malo, todo cae en un bucket y el mapa degenera en una lista: O(n).

Esa última frase del pie es el motivo por el que la próxima lección existe. Un hashCode() mal implementado no rompe la compilación ni lanza ninguna excepción: solo hace que tu HashMap sea cien veces más lento, o que directamente no encuentre lo que guardaste.


5. Escribir tus propios genéricos

No son solo para usar; también podés escribirlos. Una clase genérica declara sus parámetros de tipo entre <>:

public class Caja<T> {
    private T contenido;

    public void guardar(T contenido) { this.contenido = contenido; }
    public T sacar() { return contenido; }
}

Caja<String> cajaTexto = new Caja<>();
cajaTexto.guardar("hola");
String s = cajaTexto.sacar();   // sin casteo

Un método genérico declara su propio parámetro de tipo antes del retorno:

public static <T> T primero(List<T> lista) {
    if (lista.isEmpty()) throw new NoSuchElementException("Lista vacía");
    return lista.get(0);
}

Y podés acotar el tipo con extends, para poder usar métodos del tipo acotado:

// T tiene que ser comparable, así podemos usar compareTo
public static <T extends Comparable<T>> T maximo(List<T> lista) {
    T mayor = lista.get(0);
    for (T elemento : lista) {
        if (elemento.compareTo(mayor) > 0) mayor = elemento;
    }
    return mayor;
}

Por convención los parámetros de tipo son una sola letra mayúscula: T (type), E (element), K y V (key, value), R (result).

Type erasure: la letra chica

Los genéricos existen solo en tiempo de compilación. La JVM no sabe nada de ellos: en el bytecode, List<String> y List<Integer> son la misma cosa. Se llama borrado de tipos, y explica limitaciones que de otra forma parecen arbitrarias:

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());   // true — son la misma clase

// T[] arreglo = new T[10];   // no se puede: en ejecución no se sabe qué es T

6. Cuál elegir

Árbol de decisión para elegir la colección adecuada ¿Necesitás asociar una clave a un valor? NO SÍ ¿Se permiten elementos repetidos? ¿Las claves tienen que estar ordenadas? SÍ, se repiten ArrayList NO, son únicos HashSet NO, da igual el orden HashMap SÍ, ordenadas TreeMap Si además necesitás conservar el orden de inserción, cambiá HashSet por LinkedHashSet y HashMap por LinkedHashMap. Si trabajás con pilas o colas, ArrayDeque. Todo lo demás es un caso especial.
Cuatro preguntas cubren el 90 % de las decisiones. Cuando ninguna encaja, ahí sí conviene mirar el caso especial.
Necesito…Uso
Orden de inserción y acceso por índiceArrayList
Insertar y borrar mucho en los extremosArrayDeque
Elementos únicos, sin importar el ordenHashSet
Elementos únicos, siempre ordenadosTreeSet
Clave → valor, acceso rapidísimoHashMap
Clave → valor, recorrido en orden de claveTreeMap
Clave → valor, en orden de inserciónLinkedHashMap
Sacar siempre el de mayor prioridadPriorityQueue

7. Errores frecuentes

ErrorQué pasaCómo se arregla
Declarar ArrayList<T> x = new ArrayList<>()Te atás a la implementación y cambiarla obliga a tocar todo el código que la usa.Declarar con la interfaz: List<T> x = new ArrayList<>().
Usar objetos propios en HashSet/HashMap sin equals/hashCodeSe guardan duplicados y get() devuelve null con la clave correcta.Implementar ambos métodos de forma coherente (Iteradores, Ordenamiento y Contrato equals/hashCode).
Modificar una colección mientras se la recorre con for-eachConcurrentModificationException.Iterator.remove() o removeIf() (Iteradores, Ordenamiento y Contrato equals/hashCode).
mapa.get(k) sin contemplar nullNullPointerException al desempaquetar un Integer que vino en null.getOrDefault(k, valorPorDefecto).
Usar LinkedList “porque insertar es más rápido”En la práctica es más lenta que ArrayList por los fallos de caché.ArrayList salvo que midas y demuestres lo contrario.
Usar clave mutable en un HashMapSi el objeto cambia, su hashCode cambia y queda perdido en el bucket viejo.Claves inmutables: String, Integer, o clases con campos final.
Intentar modificar una lista de List.of(...)UnsupportedOperationException: es inmutable.new ArrayList<>(List.of(...)) si necesitás modificarla.

8. Ejercicio práctico guiado

Desafío: contador de frecuencias de palabras

Escribí un programa que reciba un texto y muestre cuántas veces aparece cada palabra.

  1. Normalizá el texto: todo a minúsculas, sin signos de puntuación.
  2. Contá las frecuencias con un Map<String, Integer>.
  3. Mostrá el resultado ordenado por frecuencia descendente y, a igual frecuencia, alfabéticamente.
  4. Mostrá también cuántas palabras distintas hay, usando un Set.
  5. Ignorá palabras vacías de significado (de, la, el, y, que…).
Ver solución sugerida
import java.util.*;

public class ContadorDePalabras {

    private static final Set<String> VACIAS = Set.of(
        "de", "la", "el", "y", "que", "en", "a", "los", "las", "un", "una", "es"
    );

    public static Map<String, Integer> contar(String texto) {
        Map<String, Integer> frecuencias = new HashMap<>();

        // \\p{L}+ toma secuencias de letras, incluidas las acentuadas
        for (String palabra : texto.toLowerCase().split("[^\\p{L}]+")) {
            if (palabra.isBlank() || VACIAS.contains(palabra)) {
                continue;
            }
            // merge: si no está, guarda 1; si está, aplica Integer::sum
            frecuencias.merge(palabra, 1, Integer::sum);
        }
        return frecuencias;
    }

    public static void main(String[] args) {
        String texto = """
            La programación orientada a objetos organiza el software en objetos.
            Cada objeto combina estado y comportamiento, y el estado de un objeto
            se protege con encapsulamiento. La herencia y el polimorfismo permiten
            que el software crezca sin reescribir el software existente.
            """;

        Map<String, Integer> frecuencias = contar(texto);

        // Un Set nos da las palabras distintas sin escribir una sola línea de lógica
        Set<String> distintas = frecuencias.keySet();
        System.out.println("Palabras distintas (sin contar vacías): " + distintas.size());
        System.out.println("Total de apariciones: " +
            frecuencias.values().stream().mapToInt(Integer::intValue).sum());
        System.out.println();

        // Ordenamos: primero por frecuencia descendente, después alfabéticamente
        List<Map.Entry<String, Integer>> ordenadas = new ArrayList<>(frecuencias.entrySet());
        ordenadas.sort(
            Map.Entry.<String, Integer>comparingByValue().reversed()
                .thenComparing(Map.Entry.comparingByKey())
        );

        System.out.println("Top 8:");
        for (Map.Entry<String, Integer> e : ordenadas.subList(0, Math.min(8, ordenadas.size()))) {
            System.out.printf("  %-16s %s%n", e.getKey(), "▮".repeat(e.getValue()) + " " + e.getValue());
        }

        // Bonus: agrupar palabras por su longitud, con computeIfAbsent
        Map<Integer, List<String>> porLongitud = new TreeMap<>();
        for (String palabra : distintas) {
            porLongitud.computeIfAbsent(palabra.length(), k -> new ArrayList<>()).add(palabra);
        }
        System.out.println("\nPalabras de 10 letras: " + porLongitud.getOrDefault(10, List.of()));
    }
}

Tres cosas para mirar acá.

frecuencias.merge(palabra, 1, Integer::sum) reemplaza al clásico if (mapa.containsKey(p)) mapa.put(p, mapa.get(p) + 1); else mapa.put(p, 1);. Una línea en lugar de cuatro, y sin posibilidad de equivocarse en el caso de la primera aparición.

porLongitud.computeIfAbsent(len, k -> new ArrayList<>()).add(palabra) es el patrón para armar un mapa de listas. Sin él tendrías que chequear si la lista existe antes de agregar, en cada iteración.

Y VACIAS es un Set, no una List, porque lo único que hacemos con él es preguntar contains. En un Set eso es O(1); en una List sería O(n) y se ejecuta una vez por palabra del texto. Elegir la colección correcta es una decisión de rendimiento, no de estilo.


Para llevarte

  • El JCF separa interfaces (el TAD) de implementaciones. Declará siempre con la interfaz.
  • Map no extiende Collection: guarda asociaciones, no elementos sueltos.
  • Los genéricos no aceleran nada: adelantan el error de la producción al momento de escribir la línea.
  • El borrado de tipos hace que los genéricos no existan en ejecución. De ahí vienen sus limitaciones.
  • En un HashMap, hashCode() elige el bucket y equals() elige el elemento dentro del bucket.
  • Un hashCode() mal hecho no lanza ninguna excepción: solo hace que no encuentres lo que guardaste.
  • merge, computeIfAbsent, getOrDefault y putIfAbsent eliminan la mayoría de los if alrededor de un mapa.
  • Elegir la colección correcta es una decisión de rendimiento: contains sobre un Set es O(1); sobre una List, O(n).