El CatalogoArray con el que cerraste la lección anterior funciona, pero tres de cada cuatro líneas son infraestructura: un contador n paralelo a length, un Arrays.copyOf para duplicar la capacidad, un System.arraycopy para tapar huecos, un null manual para no filtrar memoria. Ninguna de esas líneas habla de bibliotecas, de préstamos ni de empleados. Y aun con todo ese esfuerzo, el catálogo sigue sin poder garantizar que no haya ISBN repetidos sin recorrerlo entero, ni encontrar un material por su referencia en menos de O(n).

El Java Collections Framework es la respuesta del JDK a ese problema, y es una de las mejores piezas de diseño de la plataforma. No es "unas cuantas clases útiles": es una arquitectura de interfaces, implementaciones y algoritmos que lleva veinticinco años siendo el vocabulario común de todo programador Java. Aprenderla bien significa dos cosas. La primera, que dejarás de escribir infraestructura. La segunda, y más importante, que aprenderás a elegir la estructura de datos correcta, que es una de las decisiones que más impacto tiene en el rendimiento y en la claridad de un programa real.

Esta lección es el mapa. No entra a fondo en ninguna implementación —eso son las siete lecciones siguientes—, sino que te da la vista aérea: qué interfaces existen y cómo se relacionan, qué implementación elegir para cada problema, cómo funciona el recorrido por dentro y qué operaciones comparten todas las colecciones. La tabla maestra del apartado 4 es el corazón de todo el módulo; volverás a ella una y otra vez.

Contenido

  1. Qué es el Framework de Colecciones
  2. La jerarquía de interfaces
  3. Declara por la interfaz, instancia la implementación
  4. La tabla maestra de implementaciones
  5. Los genéricos en las colecciones
  6. Iterable, Iterator y el for-each por dentro
  7. ConcurrentModificationException: fail-fast explicado
  8. Los métodos comunes de Collection
  9. Colecciones inmutables y de conveniencia
  10. Cómo elegir la colección correcta
  11. Primer refactor de BiblioTech
  12. Errores Comunes y Consejos
  13. Ejercicios

  1. Qué es el Framework de Colecciones

Una colección es un objeto que agrupa varios elementos y sabe gestionarlos: añadir, quitar, buscar, contar, recorrer. Frente al array, una colección tiene tres virtudes decisivas:

  • Crece y mengua sola. No hay capacidad que administrar.
  • Tiene semántica. Un Set garantiza que no hay duplicados; una Queue garantiza el orden FIFO. El array no garantiza nada: solo guarda.
  • Es intercambiable. Como todas implementan las mismas interfaces, cambiar ArrayList por LinkedList es cambiar una palabra.

El Framework se organiza en tres capas que conviene distinguir desde el principio:

Capa Qué es Ejemplos
Interfaces El contrato: qué operaciones ofrece un tipo de colección Collection, List, Set, Queue, Deque, Map
Implementaciones El cómo: la estructura de datos concreta ArrayList, LinkedList, HashSet, TreeMap, ArrayDeque
Algoritmos Operaciones genéricas sobre cualquier implementación Collections.sort, Collections.max, Collections.shuffle

Esa separación es la aplicación directa de todo lo que aprendiste en el módulo 4. List es una interfaz en el sentido exacto de 04-01: un contrato que dice qué se puede hacer sin decir cómo. ArrayList y LinkedList son dos implementaciones distintas del mismo contrato, y el polimorfismo de 03-06 permite intercambiarlas sin tocar el código que las usa. El Framework de Colecciones es, probablemente, el mejor ejemplo de diseño orientado a interfaces que vas a encontrar.

Todo vive en java.util, así que casi cualquier clase que escribas empezará con alguno de estos imports:

import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.HashMap;

  1. La jerarquía de interfaces

Esta es la estructura que hay que tener grabada:

classDiagram
    Iterable <|-- Collection
    Collection <|-- List
    Collection <|-- Set
    Collection <|-- Queue
    Set <|-- SortedSet
    SortedSet <|-- NavigableSet
    Queue <|-- Deque

    class Iterable {
        <<interface>>
        +iterator() Iterator
        +forEach(Consumer)
    }
    class Collection {
        <<interface>>
        +add(e) boolean
        +remove(o) boolean
        +contains(o) boolean
        +size() int
        +isEmpty() boolean
    }
    class List {
        <<interface>>
        ORDEN por posicion
        DUPLICADOS permitidos
        +get(int)
        +set(int, e)
        +indexOf(o)
    }
    class Set {
        <<interface>>
        SIN duplicados
        +add() devuelve false si ya estaba
    }
    class Queue {
        <<interface>>
        Orden de PROCESO (FIFO)
        +offer(e)
        +poll()
        +peek()
    }
    class Deque {
        <<interface>>
        Doble extremo: cola Y pila
        +addFirst(e)
        +addLast(e)
    }

Y aparte, sin ninguna relación de herencia con lo anterior:

classDiagram
    Map <|-- SortedMap
    SortedMap <|-- NavigableMap

    class Map {
        <<interface>>
        Pares clave to valor
        Claves UNICAS
        +put(k, v)
        +get(k)
        +containsKey(k)
        +keySet()
        +values()
        +entrySet()
    }

Lee las dos jerarquías así, de arriba abajo:

  • Iterable es la interfaz más general de todas y solo promete una cosa: "puedo darte un iterador para recorrerme uno a uno". Cualquier cosa que sea Iterable funciona con for-each. Fíjate en que un array no es Iterable —no es una clase, no implementa nada—; el for-each sobre arrays funciona porque el compilador lo trata como un caso especial (lo verás en el apartado 6).
  • Collection añade el vocabulario básico de "grupo de elementos": add, remove, contains, size, isEmpty, clear. Es lo mínimo común a listas, conjuntos y colas.
  • List: elementos ordenados por posición, con índice y duplicados permitidos. Es el equivalente natural del array.
  • Set: sin duplicados, sin acceso por índice. Modela el concepto matemático de conjunto.
  • Queue: elementos en un orden de proceso, normalmente FIFO. Se añade por un extremo y se consume por el otro.
  • Deque: cola de doble extremo; sirve a la vez como cola y como pila.

Por qué Map no es una Collection

Es la pregunta que hace todo el mundo la primera vez, y tiene una respuesta precisa: una Collection guarda elementos sueltos; un Map guarda pares clave→valor. Los métodos de Collection no tendrían sentido en un mapa:

  • ¿Qué haría map.add(x)? ¿x es una clave, un valor, un par?
  • ¿Qué devolvería map.iterator()? ¿Claves, valores o pares?
  • ¿Qué comprobaría map.contains(x)? El mapa necesita dos preguntas distintas: containsKey y containsValue.

Forzar Map extends Collection habría producido una interfaz llena de métodos ambiguos o no soportados. La solución de diseño es más limpia: Map es una jerarquía independiente, y ofrece tres vistas que sí son colecciones:

Map<String, Material> indice = new HashMap<>();

Set<String>              claves = indice.keySet();     // las claves, sin duplicados: un Set
Collection<Material>     valores = indice.values();    // los valores, pueden repetirse
Set<Map.Entry<String, Material>> pares = indice.entrySet();  // los pares

Y esas tres vistas sí son Iterable, así que sí se recorren con for-each. Es lo mejor de ambos mundos: Map conserva su API propia y coherente, y sigue conectado al resto del Framework a través de sus vistas. En 05-05 lo verás en detalle.

  1. Declara por la interfaz, instancia la implementación

Esta es la regla de oro del Framework, y probablemente el hábito profesional más rentable de esta lección:

List<Libro> catalogo = new ArrayList<>();       // CORRECTO
ArrayList<Libro> mal  = new ArrayList<>();      // funciona, pero es peor diseno

A la izquierda del =, el tipo más general que cubra lo que necesitas (la interfaz). A la derecha, la implementación concreta. Los motivos:

1. Puedes cambiar de implementación tocando una palabra. Si mañana descubres que necesitas comportamiento de cola, cambias new ArrayList<>() por new LinkedList<>() y nada más del programa se entera. Si habías declarado ArrayList<Libro>, tienes que revisar cada declaración, cada parámetro y cada tipo de retorno.

2. Las firmas de tus métodos se vuelven flexibles. Compara:

public void procesar(ArrayList<Libro> libros)   { ... }    // solo acepta ArrayList
public void procesar(List<Libro> libros)        { ... }    // acepta ArrayList, LinkedList, List.of(...)
public void procesar(Collection<Libro> libros)  { ... }    // acepta ademas Set y Queue
public void procesar(Iterable<Libro> libros)    { ... }    // acepta CUALQUIER cosa recorrible

La regla práctica en las firmas: pide el tipo más general que te sirva. Si solo recorres, Iterable o Collection. Si necesitas índices, List. Devolviendo, en cambio, conviene ser algo más específico para no ocultar garantías útiles (si devuelves algo sin duplicados, decláralo Set).

3. Comunicas la intención, no el mecanismo. List<Libro> dice "una secuencia ordenada con posible repetición". ArrayList<Libro> dice además "implementada sobre un array", un detalle interno que a quien llama no le importa.

Las excepciones son pocas y explícitas: cuando necesitas un método que solo existe en la implementación. ArrayList tiene trimToSize(), LinkedList tiene addFirst() heredado de Deque. En esos casos, declara por el tipo que ofrezca el método que realmente usas:

Deque<Reserva> reservas = new ArrayDeque<>();   // Deque, porque usaras addLast/pollFirst

Y una nota sobre el operador diamante <>: desde Java 7, el tipo del lado derecho se deduce del izquierdo, así que new ArrayList<Libro>() se escribe new ArrayList<>(). Escríbelo siempre así.

  1. La tabla maestra de implementaciones

Esta tabla es el corazón del módulo. Cada fila es una lección o un apartado de las siete siguientes, pero tenerlas juntas te da algo que ninguna lección aislada da: criterio de elección.

Implementación Interfaz Orden Duplicados null Acceso por índice/clave Añadir Eliminar Buscar Cuándo elegirla
ArrayList List Inserción O(1) O(1)* al final O(n) O(n) Lista por defecto. El 90 % de los casos
LinkedList List, Deque Inserción O(n) O(1) en extremos O(1) con iterador O(n) Cola/pila; inserciones masivas en extremos
HashSet Set Ninguno No Un null O(1) O(1) O(1) Conjunto por defecto: unicidad y pertenencia
LinkedHashSet Set Inserción No Un null O(1) O(1) O(1) Como HashSet pero con orden reproducible
TreeSet NavigableSet Ordenado No No O(log n) O(log n) O(log n) Necesitas los elementos siempre ordenados o rangos
HashMap Map Ninguno Claves no 1 clave, N valores O(1) O(1) O(1) O(1) Mapa por defecto: índice clave→valor
LinkedHashMap Map Inserción o acceso Claves no O(1) O(1) O(1) O(1) Orden reproducible; caché LRU
TreeMap NavigableMap Claves ordenadas Claves no Clave no O(log n) O(log n) O(log n) O(log n) Claves ordenadas, rangos, firstKey/floorKey
ArrayDeque Deque, Queue Inserción No O(1) extremos O(1) extremos O(n) Cola y pila por defecto
PriorityQueue Queue Por prioridad No O(log n) O(log n) la cabeza O(n) Procesar siempre "el más urgente" primero

* Amortizado: casi siempre O(1), pero cuando el array interno se llena hay una copia O(n). Verás por qué el promedio sigue siendo O(1) en 05-03.

Lee la tabla por columnas y saldrán los patrones que de verdad importan:

  • La columna "Orden" tiene tres valores. Ninguno significa que el orden de recorrido es impredecible y puede cambiar entre ejecuciones o al añadir elementos: nunca dependas de él. Inserción significa que se recorre en el orden en que metiste los elementos. Ordenado significa por Comparable o Comparator (05-09).
  • La columna "O(1) frente a O(log n)" resume la diferencia entre las estructuras de hash (HashXxx) y las de árbol (TreeXxx): el hash es más rápido, el árbol mantiene el orden. Elegir es decidir si necesitas ese orden.
  • La columna "null" es una fuente enorme de NullPointerException inesperadas. Las estructuras "modernas" (ArrayDeque, PriorityQueue) los rechazan a propósito, porque usan null como señal interna de "vacío". Las de árbol los rechazan porque no se puede comparar null con compareTo.
  • Las filas en negrita del "cuándo elegirla" son las cuatro respuestas por defecto: ArrayList, HashSet, HashMap, ArrayDeque. Empieza siempre por una de ellas y cámbiala solo si tienes un motivo concreto.

  1. Los genéricos en las colecciones

Todas las colecciones son genéricas: llevan entre ángulos el tipo de elemento que contienen.

List<Libro>            catalogo  = new ArrayList<>();
Set<String>            isbnVistos = new HashSet<>();
Map<String, Material>  indice    = new HashMap<>();

Por ahora, léelo así: <...> es el tipo de lo que hay dentro. List<Libro> es "una lista de libros". Map<String, Material> es "un mapa cuyas claves son cadenas y cuyos valores son materiales". Nada más.

Lo que ganas es seguridad en tiempo de compilación:

List<Libro> catalogo = new ArrayList<>();
catalogo.add(new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018));
// catalogo.add("Java Efectivo");         // ERROR DE COMPILACION, y eso es bueno

Libro primero = catalogo.get(0);          // sin casting: el compilador ya sabe que es un Libro

Antes de Java 5 las colecciones guardaban Object, y cada lectura requería un casting que podía fallar en ejecución:

List listaVieja = new ArrayList();                 // "raw type": NO lo escribas nunca
listaVieja.add(new Libro(...));
listaVieja.add("una cadena cualquiera");           // compila sin protestar
Libro l = (Libro) listaVieja.get(1);               // ClassCastException en EJECUCION

Los genéricos mueven ese error del tiempo de ejecución al tiempo de compilación, que es exactamente donde quieres los errores. Nunca uses tipos crudos (sin <>); el compilador te avisará con unchecked warning y con razón.

Autoboxing y su coste

Los genéricos solo aceptan tipos referencia, no primitivos. List<int> no compila; hay que escribir List<Integer>. El autoboxing de 01-04 hace la conversión automáticamente y el código parece natural:

List<Integer> diasRetraso = new ArrayList<>();
diasRetraso.add(5);                     // autoboxing: 5 -> Integer.valueOf(5)
int primero = diasRetraso.get(0);       // unboxing: Integer -> int

Pero el coste es real y conviene conocerlo:

Aspecto int[] datos List<Integer> datos
Memoria por elemento 4 bytes ~16 bytes de objeto + 4-8 de referencia
Localidad de caché Excelente (contiguo) Mala (objetos dispersos)
Coste de leer Directo Seguir referencia + unboxing

Para un millón de enteros, la diferencia es de unos 4 MB frente a más de 20 MB, y varias veces más lento al recorrer. En una aplicación de gestión da igual; en cálculo numérico intensivo, no. La regla: usa colecciones salvo que trabajes con muchos primitivos y el rendimiento sea crítico, en cuyo caso el array gana.

Hay además una trampa de comparación con Integer que ya viste en 01-05 y que reaparece aquí:

Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);        // true  (cache de -128 a 127)
System.out.println(c == d);        // false (objetos distintos)
System.out.println(c.equals(d));   // true  <- SIEMPRE equals

La teoría completa de los genéricos —parámetros de tipo propios (class Caja<T>), comodines (? extends, ? super), límites (<T extends Comparable<T>>) y el borrado de tipos que explica por qué List<int> es imposible— es la lección 10-01. En este módulo te basta con usar los genéricos existentes.

  1. Iterable, Iterator y el for-each por dentro

El for-each que aprendiste en 05-01 no es magia: es azúcar sintáctico. El compilador lo traduce a otra cosa, y saber a qué te va a explicar de golpe varios comportamientos que si no parecen arbitrarios.

Sobre un array, el compilador genera un for clásico:

for (Material m : catalogo) { System.out.println(m.getTitulo()); }

// el compilador genera algo equivalente a:
for (int i = 0; i < catalogo.length; i++) {
    Material m = catalogo[i];
    System.out.println(m.getTitulo());
}

Sobre un Iterable (cualquier colección), genera un bucle con un iterador:

for (Material m : listaCatalogo) { System.out.println(m.getTitulo()); }

// el compilador genera algo equivalente a:
Iterator<Material> it = listaCatalogo.iterator();
while (it.hasNext()) {
    Material m = it.next();
    System.out.println(m.getTitulo());
}

Un Iterator es un objeto que representa una posición dentro de un recorrido y ofrece tres métodos:

Método Qué hace
boolean hasNext() ¿Queda algún elemento por visitar?
E next() Devuelve el siguiente y avanza. Si no queda ninguno, NoSuchElementException
void remove() Elimina el último elemento devuelto por next(), de forma segura

El diseño es una aplicación preciosa del principio de 04-01: Iterator es un contrato que desacopla el recorrido de la estructura. Un ArrayList lo implementa moviendo un índice; una LinkedList, saltando de nodo en nodo; un HashSet, recorriendo cubetas. Tu bucle no se entera de ninguna de esas diferencias.

flowchart LR
    A["Empieza el for-each"] --> B["coleccion.iterator()"]
    B --> C{"it.hasNext()"}
    C -- si --> D["e = it.next()"]
    D --> E["cuerpo del bucle"]
    E --> C
    C -- no --> F["Fin del bucle"]

Normalmente no escribirás iteradores a mano. Hay una excepción importante, y es el motivo de que existan: eliminar elementos mientras recorres.

Iterator<Material> it = catalogo.iterator();
while (it.hasNext()) {
    Material m = it.next();
    if (!m.estaDisponible()) {
        it.remove();          // seguro: la coleccion sabe que ha sido el iterador
    }
}

Y, como Iterable es la interfaz más general de todas, un método que solo recorre puede aceptarla y funcionar con cualquier origen de datos:

public static double sumarTarifas(Iterable<Material> materiales) {
    double total = 0;
    for (Material m : materiales) { total += m.getTarifaDiaria(); }
    return total;
}
// vale para List, Set, Queue, TreeSet, keySet()... para cualquier cosa recorrible

Iterable también ofrece forEach, que recibe un Consumer de los que dominaste en 04-06:

catalogo.forEach(m -> System.out.println(m.describir()));
catalogo.forEach(System.out::println);            // referencia a metodo

Es equivalente a un for-each y a veces más legible, sobre todo cuando ya tienes la referencia a método escrita. Y en el módulo 10 verás que stream() abre una tercera vía, mucho más expresiva, para filtrar, transformar y agrupar en una sola expresión; en este módulo recorreremos siempre con for-each, Iterator y los métodos propios de las colecciones.

  1. ConcurrentModificationException: fail-fast explicado

Ahora, el error que produce más búsquedas en Internet de todo el Framework:

List<Material> catalogo = new ArrayList<>(...);

for (Material m : catalogo) {
    if (!m.estaDisponible()) {
        catalogo.remove(m);        // ConcurrentModificationException
    }
}

El nombre engaña: no hace falta ningún hilo concurrente. "Concurrente" aquí significa "durante el recorrido". Lo que ocurre es esto.

Cada colección lleva un contador interno llamado modCount (modification count) que se incrementa cada vez que su estructura cambia: cada add, cada remove, cada clear. Cuando pides un iterador, este guarda una copia del modCount actual. Y en cada next(), el iterador compara:

// dentro del iterador de ArrayList, simplificado:
final void checkForComodification() {
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}

Si la colección cambió por detrás del iterador, los dos contadores difieren y el iterador se niega a continuar.

flowchart TD
    A["catalogo.iterator()<br/>expectedModCount = modCount = 3"] --> B["it.next() → devuelve m0"]
    B --> C["catalogo.remove(m0)<br/>modCount pasa a 4"]
    C --> D["it.next()"]
    D --> E{"modCount 4<br/>distinto de<br/>expectedModCount 3"}
    E -- si --> F["ConcurrentModificationException"]

A esta política se le llama fail-fast: falla pronto y de forma ruidosa. Y es una virtud, no un capricho. Si el iterador siguiera adelante, el recorrido se desincronizaría y el resultado sería peor que una excepción: elementos saltados en silencio. Compruébalo con este ejemplo, que no lanza excepción y aun así está mal:

List<String> nombres = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal"));
for (String n : nombres) {
    if (n.startsWith("Marta")) { nombres.remove(n); }
}
System.out.println(nombres);      // [Diego Alonso, Nuria Vidal] ... y sin excepcion

Al eliminar el elemento 0 de una lista de 3, el tamaño baja a 2 y el índice interno del iterador pasa a 1, con lo que hasNext() (que compara 1 != 2) da true, se devuelve "Nuria Vidal", el índice pasa a 2 y hasNext() da false: el bucle termina antes de tiempo y "Diego Alonso" nunca se visita. Si el elemento a borrar es el penúltimo, el recorrido acaba sin excepción y sin haber mirado el último. Ese es el escenario que el fail-fast intenta evitar; que a veces no salte es una casualidad del recuento, no una garantía.

Las cuatro soluciones

1. Iterator.remove() — la solución clásica, válida en cualquier versión de Java:

Iterator<Material> it = catalogo.iterator();
while (it.hasNext()) {
    if (!it.next().estaDisponible()) {
        it.remove();          // el iterador ACTUALIZA su expectedModCount
    }
}

2. removeIf(Predicate) — desde Java 8, la solución preferida por legibilidad:

catalogo.removeIf(m -> !m.estaDisponible());

Una línea, sin iterador visible, y con el Predicate de 04-06. Es lo que debes escribir por defecto.

3. Recorrer una copia — cuando necesitas modificar la original mientras la lees entera:

for (Material m : new ArrayList<>(catalogo)) {    // recorres la COPIA
    if (!m.estaDisponible()) { catalogo.remove(m); }   // modificas la ORIGINAL
}

Cuesta memoria (duplica la lista) pero es imprescindible cuando la operación dentro del bucle puede añadir y quitar elementos.

4. Recorrer hacia atrás con índices — útil si necesitas la posición:

for (int i = catalogo.size() - 1; i >= 0; i--) {
    if (!catalogo.get(i).estaDisponible()) { catalogo.remove(i); }
}

Hacia atrás, porque al eliminar el elemento i los siguientes se desplazan; recorriendo en sentido inverso, los ya visitados no se mueven.

Situación Solución recomendada
Eliminar según un criterio simple removeIf
Eliminar con lógica compleja o efectos colaterales Iterator.remove()
Añadir y quitar durante el recorrido Recorrer una copia
Necesitas el índice del elemento eliminado for clásico hacia atrás
Modificar el estado de los objetos (no la colección) for-each normal: no hay problema

Esa última fila importa: for (Material m : catalogo) { m.prestar(); } es perfectamente legal. modCount cuenta cambios estructurales en la colección, no cambios en los objetos que contiene.

  1. Los métodos comunes de Collection

Todo lo que sea Collection —cualquier List, Set o Queue— entiende este vocabulario:

Método Qué hace Devuelve
add(E e) Añade el elemento boolean: false si la colección lo rechaza (duplicado en un Set)
remove(Object o) Elimina la primera aparición, usando equals boolean: true si había algo que eliminar
contains(Object o) ¿Está?, usando equals boolean
size() Cuántos elementos hay int
isEmpty() ¿Está vacía? boolean
clear() Vacía la colección void
addAll(Collection c) Añade todos los de otra boolean: si cambió algo
removeAll(Collection c) Elimina todos los que estén en la otra boolean
retainAll(Collection c) Conserva solo los que estén en la otra boolean
containsAll(Collection c) ¿Están todos los de la otra? boolean
forEach(Consumer) Aplica una acción a cada elemento void
removeIf(Predicate) Elimina los que cumplan el criterio boolean
toArray(T[] a) Vuelca a un array T[]

Dos observaciones sobre el valor devuelto que resuelven problemas reales:

add devuelve boolean, y en un Set ese booleano vale oro. false significa "ya estaba", así que detectar duplicados es una línea:

Set<String> isbnVistos = new HashSet<>();
if (!isbnVistos.add(libro.getIsbn())) {
    System.out.println("AVISO: ISBN duplicado, alta rechazada -> " + libro.getIsbn());
}

remove y contains usan equals, no ==. De ahí que 03-09 insistiera tanto en implementarlo bien: si tu clase no sobrescribe equals, contains comparará referencias y un objeto "igual" pero distinto no se encontrará jamás.

List<Ficha> fichas = new ArrayList<>();
fichas.add(new Ficha("Java Efectivo", "Joshua Bloch", 2018));

// funciona porque Ficha es un record: equals generado y correcto
System.out.println(fichas.contains(new Ficha("Java Efectivo", "Joshua Bloch", 2018)));  // true

Y toArray cierra el círculo con la lección anterior:

List<Material> lista = new ArrayList<>(...);
Material[] array = lista.toArray(new Material[0]);   // el idioma estandar

El new Material[0] no es un desperdicio: le dice al método qué tipo de array crear. Pasar new Material[lista.size()] también vale y suele ser algo más lento en las JVM modernas, por curioso que parezca. Usa siempre new Material[0].

  1. Colecciones inmutables y de conveniencia

Desde Java 9 hay métodos de fábrica que crean colecciones inmutables en una expresión:

List<String> empleados = List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal");
Set<String>  tipos     = Set.of("Libro", "Revista", "DVD");
Map<String, Integer> plazos = Map.of("Libro", 15, "Revista", 7, "DVD", 3);

Sus propiedades, que hay que conocer al detalle porque sorprenden:

  • Totalmente inmutables: cualquier add, remove, set o clear lanza UnsupportedOperationException.
  • No admiten null, ni como elemento, ni como clave, ni como valor: lanzan NullPointerException al crearlas.
  • Set.of y Map.of rechazan duplicados al construir, con IllegalArgumentException. Es intencionado: si escribes Set.of("a", "a") casi seguro que es un error tuyo.
  • Map.of admite hasta 10 pares. Para más, Map.ofEntries(Map.entry(k, v), ...).
  • Su orden de recorrido no está garantizado y puede cambiar entre ejecuciones (en Set.of y Map.of está deliberadamente aleatorizado para que nadie dependa de él). List.of sí conserva el orden, porque una lista es ordenada por definición.

Son perfectas para constantes, tablas fijas y valores de configuración:

public static final Set<String> TIPOS_PRESTABLES = Set.of("Libro", "Revista", "DVD");
public static final Map<String, Integer> DIAS_POR_TIPO = Map.of("Libro", 15, "Revista", 7, "DVD", 3);

unmodifiableList y la diferencia que importa

Collections.unmodifiableList hace algo parecido pero no igual: crea una vista de solo lectura sobre una lista existente.

List<String> original = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso"));
List<String> vista    = Collections.unmodifiableList(original);

vista.add("Nuria Vidal");        // UnsupportedOperationException, como esperabas
original.add("Nuria Vidal");     // permitido... y la VISTA tambien cambia
System.out.println(vista);       // [Marta Ruiz, Diego Alonso, Nuria Vidal]
List.of(...) Collections.unmodifiableList(lista) List.copyOf(lista)
¿Se puede modificar por su propia API? No No No
¿Cambia si alguien modifica la original? No hay original No: es una copia
Admite null No Sí (los que hubiera) No
Coste al crearla O(n) O(1): no copia nada O(n)

Para una copia defensiva de verdad en un getter (el patrón de 03-07), unmodifiableList no basta si quien llama puede alcanzar la lista original. Lo correcto es List.copyOf(lista) o Collections.unmodifiableList(new ArrayList<>(lista)).

Y hay tres utilidades más para casos frecuentes:

List<Material> vacia = Collections.emptyList();          // vacia, inmutable, sin coste
List<String>   uno   = Collections.singletonList("Marta Ruiz");   // exactamente un elemento
Map<String, Integer> vacio = Collections.emptyMap();

Su valor está en un consejo de diseño que vale la pena adoptar ya: nunca devuelvas null donde se espera una colección. Devuelve una colección vacía. Quien llama podrá escribir for (Material m : catalogo.buscar(...)) sin comprobar nada, y desaparece toda una familia de NullPointerException.

  1. Cómo elegir la colección correcta

Este árbol resuelve la inmensa mayoría de las decisiones reales:

flowchart TD
    A["Necesito guardar varios elementos"] --> B{"Cada elemento tiene<br/>una CLAVE para buscarlo?"}
    B -- si --> C{"Necesito las claves<br/>en orden?"}
    C -- "no" --> C1["HashMap"]
    C -- "orden de insercion" --> C2["LinkedHashMap"]
    C -- "orden natural o Comparator" --> C3["TreeMap"]

    B -- no --> D{"Puede haber<br/>DUPLICADOS?"}
    D -- no --> E{"Necesito orden?"}
    E -- "no" --> E1["HashSet"]
    E -- "orden de insercion" --> E2["LinkedHashSet"]
    E -- "ordenado" --> E3["TreeSet"]

    D -- si --> F{"Como accedo<br/>a los elementos?"}
    F -- "por posicion, recorro todo" --> F1["ArrayList"]
    F -- "solo por los extremos" --> G{"El orden de salida es...?"}
    G -- "FIFO o LIFO" --> G1["ArrayDeque"]
    G -- "por prioridad" --> G2["PriorityQueue"]

Y en cinco preguntas, por si prefieres el formato lista:

  1. ¿Busco por clave?Map. (HashMap salvo que necesites orden.)
  2. ¿Los duplicados son un error?Set. (HashSet salvo que necesites orden.)
  3. ¿Necesito posiciones e índices?List. (ArrayList casi siempre.)
  4. ¿Solo añado por un extremo y saco por el otro?Deque / Queue. (ArrayDeque.)
  5. ¿Siempre quiero atender primero al más urgente?PriorityQueue.

Una advertencia sobre la optimización prematura: para colecciones de decenas o cientos de elementos, la diferencia de rendimiento entre implementaciones es irrelevante. Elige por semántica: Set cuando los duplicados sean un error conceptual, Map cuando exista de verdad una clave. Un Set<String> de ISBN documenta que los ISBN son únicos mejor que cualquier comentario. El rendimiento importa a partir de decenas de miles de elementos, y ahí la tabla del apartado 4 te dará la respuesta correcta.

  1. Primer refactor de BiblioTech

Apliquemos ya lo aprendido al CatalogoArray de la lección anterior. Recuerda cómo era:

public class CatalogoArray {
    private Material[] materiales;
    private int        n;

    public void anadir(Material m) {
        if (m == null) { return; }
        if (n == materiales.length) {
            materiales = Arrays.copyOf(materiales, materiales.length * 2);
        }
        materiales[n] = m;
        n++;
    }

    public boolean eliminar(String referencia) {
        for (int i = 0; i < n; i++) {
            if (materiales[i].getReferencia().equals(referencia)) {
                System.arraycopy(materiales, i + 1, materiales, i, n - i - 1);
                materiales[n - 1] = null;
                n--;
                return true;
            }
        }
        return false;
    }
    // ...
}

Y así queda con colecciones:

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import com.nexussoftware.bibliotech.dominio.Material;

/** Catalogo de BiblioTech, primera version sobre el Framework de Colecciones. */
public class Catalogo {

    // Declarados por la INTERFAZ, instanciados con la implementacion por defecto
    private final List<Material> materiales  = new ArrayList<>();
    private final Set<String>    referencias = new HashSet<>();   // control de duplicados O(1)

    /** Anade si la referencia no estaba ya. Devuelve false si era duplicada. */
    public boolean anadir(Material m) {
        if (m == null) { return false; }
        if (!referencias.add(m.getReferencia())) {   // add devuelve false si ya estaba
            return false;
        }
        materiales.add(m);                           // sin capacidad, sin contador, sin copyOf
        return true;
    }

    /** Elimina por referencia. Sin arraycopy y sin nulls que limpiar. */
    public boolean eliminar(String referencia) {
        if (!referencias.remove(referencia)) { return false; }
        return materiales.removeIf(m -> m.getReferencia().equals(referencia));
    }

    /** Busca con cualquier criterio, usando el Predicate de 04-06. */
    public List<Material> buscar(Predicate<Material> criterio) {
        List<Material> resultado = new ArrayList<>();
        for (Material m : materiales) {
            if (criterio.test(m)) { resultado.add(m); }
        }
        return resultado;                 // sin copyOf(resultado, n): ya tiene el tamano justo
    }

    public int contar(Predicate<Material> criterio) {
        int n = 0;
        for (Material m : materiales) {
            if (criterio.test(m)) { n++; }
        }
        return n;
    }

    /** Ordena el catalogo en el sitio con cualquier Comparator. */
    public void ordenar(Comparator<Material> criterio) {
        materiales.sort(criterio);        // metodo propio de List desde Java 8
    }

    /** Vista de solo lectura: nadie de fuera puede alterar el catalogo. */
    public Collection<Material> listar() {
        return List.copyOf(materiales);
    }

    public int  tamano()   { return materiales.size(); }
    public boolean vacio() { return materiales.isEmpty(); }
}

Uso:

Catalogo catalogo = new Catalogo();
catalogo.anadir(new Libro("Java Efectivo",      "Joshua Bloch",  "978-0000000001", 2018));
catalogo.anadir(new Libro("Patrones de Diseno", "Erich Gamma",   "978-0000000002", 1994));
catalogo.anadir(new Libro("Refactorizacion",    "Martin Fowler", "978-0000000003", 1999));
catalogo.anadir(new Revista("Java Magazine",    "REV-2024-03",   42, "Mensual"));

if (!catalogo.anadir(new Libro("Java Efectivo (2a ed.)", "Joshua Bloch", "978-0000000001", 2018))) {
    System.out.println("AVISO: ISBN 978-0000000001 ya catalogado. Alta rechazada.");
}

catalogo.ordenar(Comparator.comparing(Material::getTitulo));
catalogo.listar().forEach(m -> System.out.println("  " + m.describir()));

System.out.println("Materiales prestados: " + catalogo.contar(m -> !m.estaDisponible()));
AVISO: ISBN 978-0000000001 ya catalogado. Alta rechazada.
  [Libro] Java Efectivo - Joshua Bloch (978-0000000001)
  [Revista] Java Magazine num. 42 (REV-2024-03)
  [Libro] Patrones de Diseno - Erich Gamma (978-0000000002)
  [Libro] Refactorizacion - Martin Fowler (978-0000000003)
Materiales prestados: 0

Cuenta lo que ha desaparecido: el campo n, la comprobación de capacidad, el Arrays.copyOf para crecer, el System.arraycopy para borrar, el null manual, el recorte final del array de resultados. Y cuenta lo que ha aparecido: un Set<String> que garantiza en O(1) que no hay ISBN repetidos, algo que la versión con arrays no tenía y que habría costado un bucle O(n) en cada alta.

Sigue habiendo una debilidad, y es deliberada: eliminar y cualquier búsqueda por referencia recorren la lista entera, O(n). Con un Map<String, Material> sería O(1). Eso es 05-05.

Errores Comunes y Consejos

Declarar por la implementación. ArrayList<Libro> lista = new ArrayList<>() funciona, pero te ata. Escribe List<Libro> lista = new ArrayList<>(). En los parámetros de tus métodos, pide el tipo más general que te sirva: Collection o Iterable si solo recorres.

Usar tipos crudos. List lista = new ArrayList(); compila con un aviso y devuelve Object, obligando a castings que fallan en ejecución. Pon siempre el <Tipo>.

Modificar la colección dentro de un for-each. Es la causa número uno de ConcurrentModificationException. Usa removeIf para criterios simples, Iterator.remove() para lógica compleja o recorre una copia si además añades.

Confiar en el orden de un HashSet o HashMap. No hay orden garantizado y puede cambiar al añadir elementos o entre versiones de Java. Si necesitas orden reproducible, usa las variantes Linked o Tree. Un test que dependa del orden de un HashSet es un test que fallará algún día.

Meter en un Set o usar como clave de Map objetos sin equals/hashCode correctos. El objeto se guardará pero no se encontrará nunca. En 05-05 verás la demostración exacta y el porqué; por ahora, si un objeto va a un Set o a una clave de Map, revisa 03-09.

Esperar que List.of sea modificable. Devuelve una lista inmutable. Si necesitas modificarla: new ArrayList<>(List.of(...)).

Confundir unmodifiableList con una copia. Es una vista: si alguien modifica la lista original, la vista cambia. Para una copia defensiva real, List.copyOf(lista).

Devolver null en lugar de una colección vacía. Obliga a quien llama a comprobar null en cada uso y garantiza NullPointerException el día que alguien lo olvide. Devuelve List.of() o Collections.emptyList().

Comparar Integer con == dentro de colecciones. Funciona hasta 127 por la caché de enteros y falla a partir de 128. Usa siempre equals, o compara con intValue().

Consejo de rendimiento: no optimices sin medir. Para 200 elementos, ArrayList y LinkedList son indistinguibles. Elige por semántica; el rendimiento aparece a partir de decenas de miles, y para entonces tendrás la tabla del apartado 4.

Ejercicios

Ejercicio 1: elegir la colección correcta

Para cada requisito de BiblioTech, indica qué interfaz y qué implementación usarías y por qué. Escribe también la declaración de la variable.

  1. El catálogo completo, recorrido a menudo y mostrado en el orden en que se dio de alta.
  2. Los ISBN ya catalogados, para rechazar altas duplicadas al instante.
  3. Buscar un material por su referencia sin recorrer el catálogo.
  4. Los préstamos pendientes de procesar, atendidos por orden de llegada.
  5. Los avisos de vencimiento, atendiendo siempre primero al de más días de retraso.
  6. Los tipos de material que existen, siempre listados alfabéticamente.
  7. Las últimas 10 operaciones, para poder deshacer la más reciente.
  8. Los días de préstamo por tipo, como constante del programa.

Ejercicio 2: purga del catálogo sin ConcurrentModificationException

Escribe una clase PurgaCatalogo con tres métodos estáticos que reciban un List<Material> y eliminen los materiales cuyo título esté vacío o cuya referencia sea null:

  • purgarConIterator(List<Material>), usando Iterator explícito.
  • purgarConRemoveIf(List<Material>), en una línea.
  • purgarSobreCopia(List<Material>), recorriendo una copia.

Añade un método demostrarError(List<Material>) que provoque a propósito la ConcurrentModificationException con un comentario que explique en qué momento exacto saltará.

Ejercicio 3: recorrer cualquier cosa

Escribe una clase de utilidades Recorridos con métodos que acepten el tipo más general posible:

  • int contar(Iterable<?> origen): cuenta elementos de cualquier cosa recorrible.
  • String unir(Iterable<Material> materiales, String separador): concatena los títulos.
  • double sumarTarifas(Collection<Material> materiales).
  • List<Material> disponibles(Collection<Material> materiales): los que estén disponibles, devolviendo siempre una lista (vacía si no hay ninguno, nunca null).

Demuestra que todos funcionan igual con un ArrayList, un HashSet, un List.of(...) y el keySet() de un mapa cuando proceda.

Soluciones

Solución 1

# Requisito Declaración Por qué
1 Catálogo completo List<Material> catalogo = new ArrayList<>(); Orden de inserción, duplicados posibles (dos ejemplares), recorrido frecuente: acceso O(1) y la mejor localidad de caché
2 ISBN catalogados Set<String> isbnVistos = new HashSet<>(); La unicidad es el requisito. add devuelve false si ya estaba: detección en O(1)
3 Buscar por referencia Map<String, Material> indice = new HashMap<>(); Existe una clave natural (la referencia). Búsqueda O(1) frente a O(n)
4 Préstamos por orden de llegada Deque<Prestamo> pendientes = new ArrayDeque<>(); FIFO puro: addLast para encolar, pollFirst para atender. ArrayDeque es la implementación por defecto de cola
5 Avisos por retraso Queue<Prestamo> avisos = new PriorityQueue<>(Comparator.comparingInt(Prestamo::diasRetraso).reversed()); "Siempre el más urgente primero" es exactamente la semántica de PriorityQueue
6 Tipos alfabéticos SortedSet<String> tipos = new TreeSet<>(); Sin duplicados y siempre ordenado, sin ordenar a mano tras cada alta
7 Últimas 10 operaciones Deque<OperacionCatalogo> historial = new ArrayDeque<>(); LIFO: push al registrar, pop al deshacer. Para limitar a 10, pollLast() cuando size() > 10
8 Días por tipo (constante) static final Map<String, Integer> DIAS = Map.of("Libro", 15, "Revista", 7, "DVD", 3); Tabla fija: inmutable, expresada en una línea y a prueba de modificaciones accidentales

La lección transversal del ejercicio: la colección correcta se deduce del enunciado. "Sin repetidos" dice Set. "Buscar por" dice Map. "Por orden de llegada" dice Queue. "Deshacer" dice pila. Si tienes que pensarlo mucho, probablemente el requisito no está bien formulado.

Solución 2

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import com.nexussoftware.bibliotech.dominio.Material;

public final class PurgaCatalogo {

    private PurgaCatalogo() { }

    /** Un material es invalido si no tiene titulo util o no tiene referencia. */
    private static boolean esInvalido(Material m) {
        return m == null
            || m.getTitulo() == null || m.getTitulo().isBlank()
            || m.getReferencia() == null;
    }

    /** Version 1: Iterator explicito. Funciona en cualquier version de Java. */
    public static void purgarConIterator(List<Material> catalogo) {
        Iterator<Material> it = catalogo.iterator();
        while (it.hasNext()) {
            Material m = it.next();          // hay que guardar lo que devuelve next()
            if (esInvalido(m)) {
                it.remove();                 // elimina el ULTIMO devuelto por next()
                // it.remove() actualiza expectedModCount: por eso es seguro
            }
        }
    }

    /** Version 2: removeIf. Java 8+. Es la que debes escribir por defecto. */
    public static void purgarConRemoveIf(List<Material> catalogo) {
        catalogo.removeIf(PurgaCatalogo::esInvalido);   // referencia a metodo estatico (04-06)
    }

    /**
     * Version 3: recorrer una copia. Necesaria cuando dentro del bucle
     * tambien se ANADEN elementos a la coleccion original.
     */
    public static void purgarSobreCopia(List<Material> catalogo) {
        for (Material m : new ArrayList<>(catalogo)) {   // se recorre la COPIA
            if (esInvalido(m)) {
                catalogo.remove(m);                      // se modifica la ORIGINAL
            }
        }
    }

    /** Version incorrecta, a proposito, para ver el fallo. */
    public static void demostrarError(List<Material> catalogo) {
        for (Material m : catalogo) {
            if (esInvalido(m)) {
                catalogo.remove(m);
                // El remove incrementa modCount. En la SIGUIENTE llamada a next()
                // el iterador compara modCount con su expectedModCount, ve que
                // difieren y lanza ConcurrentModificationException.
                //
                // Caso especial: si el elemento eliminado es el PENULTIMO, tras el
                // borrado hasNext() devuelve false, el bucle termina sin llamar a
                // next() y NO salta la excepcion... pero el ultimo elemento no se
                // ha visitado. Eso es peor que la excepcion: un fallo silencioso.
            }
        }
    }
}

Las tres versiones válidas hacen lo mismo con costes distintos: removeIf y Iterator.remove operan sobre la lista original sin memoria extra; purgarSobreCopia duplica la lista, y solo compensa cuando el bucle también añade elementos. La versión de removeIf es además la única que se lee de un vistazo.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import com.nexussoftware.bibliotech.dominio.Material;

public final class Recorridos {

    private Recorridos() { }

    /**
     * Iterable<?> es el tipo MAS GENERAL posible: sirve para List, Set, Queue,
     * keySet(), values()... El '?' significa "de cualquier tipo de elemento":
     * no nos importa QUE hay dentro porque solo contamos. La teoria de los
     * comodines se ve en 10-01.
     */
    public static int contar(Iterable<?> origen) {
        if (origen == null) { return 0; }
        int n = 0;
        for (Object ignorado : origen) { n++; }
        return n;
    }

    /** Iterable porque solo recorremos: no necesitamos size(), get() ni add(). */
    public static String unir(Iterable<Material> materiales, String separador) {
        if (materiales == null) { return ""; }
        StringBuilder sb = new StringBuilder();
        for (Material m : materiales) {
            if (m == null) { continue; }
            if (sb.length() > 0) { sb.append(separador); }
            sb.append(m.getTitulo());
        }
        return sb.toString();
    }

    /** Collection porque, ademas de recorrer, nos interesa isEmpty(). */
    public static double sumarTarifas(Collection<Material> materiales) {
        if (materiales == null || materiales.isEmpty()) { return 0.0; }
        double total = 0.0;
        for (Material m : materiales) {
            if (m != null) { total += m.getTarifaDiaria(); }
        }
        return total;
    }

    /** Devuelve SIEMPRE una lista, nunca null: quien llama puede recorrer sin comprobar. */
    public static List<Material> disponibles(Collection<Material> materiales) {
        if (materiales == null) { return List.of(); }     // vacia e inmutable, coste cero
        List<Material> resultado = new ArrayList<>();
        for (Material m : materiales) {
            if (m != null && m.estaDisponible()) { resultado.add(m); }
        }
        return resultado;
    }
}

Demostración de que el tipo general es lo que da la flexibilidad:

Material libro   = new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018);
Material revista = new Revista("Java Magazine", "REV-2024-03", 42, "Mensual");

List<Material>       lista    = new ArrayList<>(List.of(libro, revista));
Set<Material>        conjunto = new HashSet<>(lista);
List<Material>       fija     = List.of(libro, revista);
Map<String, Material> indice  = new HashMap<>();
indice.put(libro.getReferencia(), libro);
indice.put(revista.getReferencia(), revista);

System.out.println(Recorridos.contar(lista));               // 2
System.out.println(Recorridos.contar(conjunto));            // 2
System.out.println(Recorridos.contar(fija));                // 2
System.out.println(Recorridos.contar(indice.keySet()));     // 2  <- un Set<String>
System.out.println(Recorridos.contar(indice.values()));     // 2  <- una Collection<Material>

System.out.println(Recorridos.unir(indice.values(), " | "));
System.out.printf("Suma de tarifas: %.2f EUR%n", Recorridos.sumarTarifas(conjunto));
System.out.println("Disponibles: " + Recorridos.disponibles(fija).size());

Un solo método, contar, funciona sobre cinco orígenes distintos —incluidas las vistas de un mapa— porque pide Iterable, el contrato mínimo que todos cumplen. Es la regla de oro del apartado 3 llevada a las firmas de los métodos, y es lo que separa una API rígida de una reutilizable. Cuando en el módulo 10 conozcas los Streams, esta misma flexibilidad se expresará de forma aún más compacta, pero el principio será el mismo.

Conclusión

Ya tienes el mapa completo. Sabes que el Framework de Colecciones se organiza en tres capas —interfaces que definen contratos, implementaciones que eligen la estructura de datos y algoritmos que operan sobre cualquiera de ellas— y que esa separación es el mejor ejemplo de diseño orientado a interfaces del JDK. Conoces la jerarquía: IterableCollectionList/Set/Queue, con Deque extendiendo Queue y las variantes Sorted/Navigable; y sabes por qué Map va aparte: guarda pares, no elementos sueltos, y add, contains e iterator serían ambiguos en él. También sabes que se reconecta al resto del Framework mediante sus tres vistas: keySet, values y entrySet.

Has adoptado la regla de oro: List<Libro> catalogo = new ArrayList<>(). Declara por la interfaz, instancia la implementación, y pide en tus firmas el tipo más general que te sirva. Con eso, cambiar de implementación cuesta una palabra y tus métodos aceptan orígenes que ni habías previsto.

Tienes la tabla maestra de las diez implementaciones que usarás el resto de tu carrera, con su orden, sus duplicados, sus nulos y sus complejidades, y las cuatro respuestas por defecto grabadas: ArrayList, HashSet, HashMap, ArrayDeque. Y tienes el árbol de decisión para llegar a la elección correcta en cinco preguntas.

Entiendes los genéricos como usuario: <...> es el tipo de lo que hay dentro, da seguridad en compilación, elimina castings y obliga a Integer en lugar de int con un coste de memoria que conviene conocer —la teoría llega en 10-01—. Sabes que el for-each es azúcar sintáctico: sobre arrays se traduce a un for con índice, y sobre colecciones a un Iterator con hasNext/next. Y entiendes a fondo la ConcurrentModificationException: el modCount, la política fail-fast, por qué es una virtud, por qué a veces no salta y falla peor, y las cuatro soluciones con su criterio de elección, empezando por removeIf.

Dominas el vocabulario común de Collectionadd con su boolean revelador, remove y contains apoyados en equals, addAll, retainAll, forEach, removeIf, toArray(new T[0])— y las colecciones inmutables, con la diferencia precisa entre List.of, Collections.unmodifiableList (una vista) y List.copyOf (una copia), más el consejo que elimina toda una familia de fallos: nunca devuelvas null donde se espera una colección.

Y BiblioTech ya lo nota. El Catalogo ha perdido el contador, la capacidad, el copyOf, el arraycopy y los null manuales, y ha ganado algo que antes no tenía: un Set<String> que garantiza en tiempo constante que no hay ISBN repetidos. Queda una debilidad evidente: buscar por referencia sigue recorriendo la lista entera.

En la lección siguiente, ArrayList, entrarás en la implementación que usarás más que ninguna otra. Verás cómo funciona por dentro —el array interno, la diferencia entre capacidad y tamaño, el redimensionado y por qué añadir sigue siendo O(1) "amortizado" aunque a veces copie un millón de elementos—, su API completa con la trampa clásica de remove(int) frente a remove(Object) en una List<Integer>, las conversiones entre array y lista con las trampas de cada una, y el refactor definitivo del catálogo de BiblioTech, con el antes y el después de un método real.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados