De todas las clases del Framework de Colecciones, ArrayList es la que más vas a escribir. En cualquier proyecto Java real, entre el 70 % y el 90 % de las colecciones son ArrayList, y con razón: combina el acceso instantáneo por posición de un array con el crecimiento automático de una colección, y su recorrido es el más rápido de todas las implementaciones de List. Es la respuesta por defecto cuando necesitas "una lista de cosas", y solo deberías cambiarla cuando tengas un motivo concreto y medido.

Precisamente porque la usarás tanto, merece la pena entenderla por dentro. En esta lección verás que un ArrayList es literalmente el CatalogoArray que escribiste en 05-01 —un array interno más un contador—, pero escrito por los ingenieros del JDK y afinado durante veinticinco años. Entender ese array interno explica de golpe casi todo: por qué get(i) es instantáneo, por qué remove(0) es caro, por qué existe un constructor que pide la capacidad, por qué add es O(1) "amortizado" aunque a veces copie un millón de elementos, y por qué subList puede sorprenderte. Al terminar, el catálogo de BiblioTech habrá dejado de ser un array para siempre.

Contenido

  1. Qué es un ArrayList por dentro
  2. Capacidad frente a tamaño
  3. El redimensionado y el coste amortizado
  4. El constructor con capacidad inicial
  5. Crear y llenar una lista
  6. La API completa, método a método
  7. remove(int) frente a remove(Object): la trampa clásica
  8. subList es una vista
  9. Tabla de complejidad de cada operación
  10. Recorrido y borrado seguro
  11. ArrayList frente a array
  12. Conversión array ↔ lista
  13. Listas de listas
  14. equals y hashCode de una lista
  15. Refactorización de BiblioTech
  16. Errores Comunes y Consejos
  17. Ejercicios

  1. Qué es un ArrayList por dentro

Si abres el código fuente de java.util.ArrayList en el JDK, lo primero que encuentras es esto (simplificado):

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess {

    private static final int DEFAULT_CAPACITY = 10;

    transient Object[] elementData;   // el array interno donde viven los elementos
    private int size;                 // cuantos elementos hay REALMENTE
}

Dos campos. Un array y un entero. Es exactamente el CatalogoArray de 05-01: un Material[] materiales y un int n. La diferencia no está en la idea, sino en que aquí toda la gestión de capacidad, crecimiento y desplazamientos está escrita, probada y optimizada por otros.

flowchart TB
    subgraph AL["objeto ArrayList"]
        S["size = 4"]
        E["elementData →"]
    end
    subgraph ARR["Object[] elementData (capacidad 10)"]
        direction LR
        C0["[0]"]
        C1["[1]"]
        C2["[2]"]
        C3["[3]"]
        C4["[4] null"]
        C5["[5] null"]
        C6["[6] null"]
        C7["[7] null"]
        C8["[8] null"]
        C9["[9] null"]
    end
    E --> ARR
    C0 --> L1["Libro<br/>Java Efectivo"]
    C1 --> L2["Libro<br/>Patrones de Diseno"]
    C2 --> L3["Libro<br/>Refactorizacion"]
    C3 --> L4["Revista<br/>Java Magazine"]

De esa estructura se deduce todo lo demás:

  • get(i) es O(1): es un acceso directo elementData[i], igual que en un array.
  • add(e) al final es O(1): escribir en elementData[size] e incrementar size... salvo cuando el array se llena.
  • add(0, e) y remove(0) son O(n): hay que desplazar todos los elementos siguientes.
  • contains(e) e indexOf(e) son O(n): hay que recorrer comparando con equals.
  • Recorrer es rapidísimo: memoria contigua, la mejor localidad de caché posible (05-01).

Fíjate también en implements RandomAccess. Es una interfaz marcadora: no declara ningún método, solo dice "esta lista permite acceso por índice en tiempo constante". Algunos algoritmos del JDK, como Collections.binarySearch, consultan esa marca para elegir estrategia. LinkedList no la lleva.

  1. Capacidad frente a tamaño

Esta distinción es el origen de la mitad de los malentendidos con ArrayList, y ya la conoces del CatalogoArray:

Concepto Qué es Cómo se consulta
Tamaño (size) Cuántos elementos hay realmente lista.size()
Capacidad Cuántos caben antes de tener que crecer No se puede consultar desde la API pública

En el diagrama anterior, el tamaño es 4 y la capacidad es 10: hay seis celdas libres, invisibles desde fuera. Y ahí está la gran mejora respecto a los arrays: la capacidad es un detalle interno que no te concierne. Con Material[] catalogo = new Material[10], catalogo.length valía 10 aunque solo hubieras rellenado 4, y era tu trabajo llevar la cuenta. Con ArrayList, size() dice siempre la verdad.

List<String> nombres = new ArrayList<>();
System.out.println(nombres.size());       // 0  (capacidad interna: 10 en cuanto anadas el primero)
nombres.add("Marta Ruiz");
nombres.add("Diego Alonso");
System.out.println(nombres.size());       // 2

Un detalle de eficiencia que introdujo Java 7: un new ArrayList<>() no reserva nada al construirse; apunta a un array compartido vacío y solo reserva las 10 posiciones cuando añades el primer elemento. Así, crear miles de listas que quizá nunca se usen no cuesta memoria.

Existe un método para devolver la capacidad sobrante al sistema:

ArrayList<Material> lista = new ArrayList<>(1000);
// ... solo se llenan 30 ...
lista.trimToSize();      // reduce la capacidad interna a 30

Ojo: trimToSize() solo existe en ArrayList, no en la interfaz List, así que para llamarlo tendrías que declarar la variable como ArrayList. Es una de las poquísimas excepciones legítimas a la regla de oro de 05-02, y solo tiene sentido en listas enormes que ya no van a crecer.

  1. El redimensionado y el coste amortizado

¿Qué pasa cuando añades el elemento número 11 a una lista de capacidad 10? Exactamente lo que hacía tu CatalogoArray:

// dentro de ArrayList, simplificado:
private Object[] grow(int minCapacity) {
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);   // capacidad * 1.5
    return elementData = Arrays.copyOf(elementData, newCapacity);
}

oldCapacity >> 1 es un desplazamiento de bits a la derecha, es decir, dividir entre dos. Así que la nueva capacidad es 1,5 veces la anterior. Y Arrays.copyOf crea un array nuevo y copia todos los elementos: una operación O(n).

flowchart LR
    A["Array de capacidad 10<br/>size = 10, LLENO"] --> B["add(elemento 11)"]
    B --> C["new Object[15]"]
    C --> D["copiar los 10 elementos: O(n)"]
    D --> E["elementData apunta al array nuevo"]
    E --> F["el array viejo queda para el recolector"]
    F --> G["escribir el elemento 11<br/>size = 11"]

La secuencia de capacidades partiendo de una lista vacía es:

Al añadir el elemento nº Capacidad antes Capacidad después ¿Hubo copia? Elementos copiados
1 0 10 Reserva inicial 0
11 10 15 10
16 15 22 15
23 22 33 22
34 33 49 33
50 49 73 49
74 73 109 73

Por qué add sigue siendo O(1)

Si algunas llamadas a add cuestan O(n), ¿cómo puede la tabla de 05-02 decir que add es O(1)? Porque es O(1) amortizado, y merece la pena entender la idea porque aparece en muchas estructuras de datos.

Amortizado significa coste medio por operación a lo largo de una secuencia larga, no coste de una operación aislada. Haz la cuenta: para llegar a 1000 elementos, el número total de elementos copiados en todos los redimensionados es de aproximadamente 2000 (la suma de una serie geométrica de razón 1,5 converge a unas dos veces el tamaño final). Es decir, unas 2 copias por elemento añadido, de media, sin importar si añades mil o un millón. Dos operaciones por elemento es una constante, y una constante es O(1).

La clave está en que el array crece multiplicativamente (×1,5), no aditivamente. Si creciera de 10 en 10, para llegar a 1000 elementos habría 100 redimensionados que copiarían 10+20+30+...+990 ≈ 50 000 elementos: 50 por elemento añadido, y creciendo. Eso sí sería un problema, y sería O(n) amortizado.

La analogía útil: mudarse de casa es carísimo, pero si solo te mudas cuando duplicas tus pertenencias, el coste medio por objeto que acumulas es constante.

  1. El constructor con capacidad inicial

Hay tres constructores, y saber cuándo usar el segundo es un detalle de profesional:

List<Material> a = new ArrayList<>();                    // capacidad por defecto (10 al llenar)
List<Material> b = new ArrayList<>(500);                 // capacidad inicial 500
List<Material> c = new ArrayList<>(otraColeccion);       // copia con el tamano justo

¿Cuándo importa la capacidad inicial? Cuando sabes de antemano, aunque sea aproximadamente, cuántos elementos vas a meter y son muchos.

// Cargar el catalogo completo: sabemos que son unos 5000 materiales
List<Material> catalogo = new ArrayList<>(5000);
for (String linea : lineasDelFichero) {
    catalogo.add(convertir(linea));
}

Sin la capacidad inicial, llegar a 5000 elementos habría provocado unos 20 redimensionados y unas 10 000 copias de referencias. Con ella, cero. Para 5000 elementos la diferencia es de milisegundos; para varios millones, notable.

¿Cuándo NO importa? Casi siempre. Para listas de decenas o cientos de elementos, new ArrayList<>() es perfecto y añadir un número mágico solo ensucia el código. No caigas en la microoptimización: usa el constructor con capacidad cuando el tamaño sea grande y previsible, y el vacío el resto de las veces.

El tercer constructor es el que usarás para copias defensivas (03-07) y para convertir entre colecciones:

List<Material> copiaIndependiente = new ArrayList<>(catalogo);   // copia superficial modificable
List<String>   deUnSet            = new ArrayList<>(conjuntoISBN);
List<String>   deUnaListaFija     = new ArrayList<>(List.of("a", "b", "c"));  // ahora si es modificable

  1. Crear y llenar una lista

import java.util.ArrayList;
import java.util.List;

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

catalogo.add(new Libro("Java Efectivo",      "Joshua Bloch",  "978-0000000001", 2018));
catalogo.add(new Libro("Patrones de Diseno", "Erich Gamma",   "978-0000000002", 1994));
catalogo.add(new Libro("Refactorizacion",    "Martin Fowler", "978-0000000003", 1999));
catalogo.add(new Revista("Java Magazine",    "REV-2024-03",   42, "Mensual"));
catalogo.add(new Dvd("Refactorizacion en vivo", "DVD-0007",   95));

Y las formas abreviadas cuando ya tienes los elementos:

// Lista MODIFICABLE a partir de elementos sueltos
List<String> empleados = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal"));

// Lista INMUTABLE (cuidado: add lanzara UnsupportedOperationException)
List<String> fijos = List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal");

// Anadir varios de golpe a una lista existente
catalogo.addAll(otrosMateriales);
Collections.addAll(catalogo, material1, material2, material3);

El idioma new ArrayList<>(List.of(...)) es el más práctico para inicializar una lista modificable con contenido: dice lo que hace y ocupa una línea.

  1. La API completa, método a método

Añadir

List<String> l = new ArrayList<>(List.of("A", "B", "C"));

l.add("D");                 // [A, B, C, D]   al final, O(1) amortizado
l.add(1, "X");              // [A, X, B, C, D] en la posicion 1, O(n): desplaza el resto
l.addAll(List.of("E","F")); // [A, X, B, C, D, E, F]
l.addAll(0, List.of("Z"));  // [Z, A, X, B, C, D, E, F]

add(int, E) acepta índices de 0 a size() (ambos incluidos: size() significa "al final"). Fuera de ese rango, IndexOutOfBoundsException — cómo tratarla es el módulo 6.

Leer y escribir

String primero = l.get(0);          // O(1)
String ultimo  = l.get(l.size()-1); // el idioma para el ultimo elemento

String anterior = l.set(1, "Y");    // SUSTITUYE y devuelve lo que habia. O(1)

Confundir add(1, "Y") con set(1, "Y") es un error frecuente: el primero inserta (la lista crece), el segundo reemplaza (la lista mantiene su tamaño).

Eliminar

l.remove(0);                  // por INDICE, devuelve el elemento eliminado
l.remove("B");                // por OBJETO, devuelve boolean. Usa equals
l.removeIf(s -> s.isEmpty()); // por CRITERIO, devuelve boolean
l.clear();                    // vacia la lista

Los tres primeros son O(n) en el caso general: eliminar en la posición i obliga a desplazar los size - i - 1 elementos posteriores con un System.arraycopy interno, exactamente como hacía tu CatalogoArray. Solo remove(size()-1) es O(1).

Buscar

boolean hay = l.contains("C");       // O(n), usa equals
int pos     = l.indexOf("C");        // primera aparicion, o -1 si no esta. O(n)
int ultima  = l.lastIndexOf("C");    // ultima aparicion

contains e indexOf dependen por completo de equals. Esto conecta directamente con 03-09: si tu clase no lo sobrescribe, hereda el de Object, que compara referencias, y un objeto "igual pero distinto" no se encontrará nunca:

class MaterialSinEquals { /* no sobrescribe equals */ }

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

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

// Si Ficha fuera una clase normal sin equals:
// System.out.println(...) -> false, aunque los datos sean identicos

Regla práctica: si una clase va a vivir en una colección y se va a buscar, necesita equals (y hashCode). En 05-05 verás por qué los dos, siempre juntos.

Transformar y ordenar

l.replaceAll(String::toUpperCase);              // aplica una UnaryOperator a cada elemento
l.sort(Comparator.naturalOrder());              // ordena en el sitio
l.sort(Comparator.comparing(Material::getTitulo));
l.forEach(System.out::println);                 // recorre aplicando un Consumer

Los cuatro llegaron en Java 8 y usan las interfaces funcionales de 04-06. replaceAll es especialmente útil y poco conocido: modifica cada elemento en el sitio, sin crear otra lista.

List<Material> catalogo = ...;
catalogo.sort(Comparator.comparing(Material::getTipo)
                        .thenComparing(Material::getTitulo));

Prefiere lista.sort(comparador) sobre Collections.sort(lista, comparador): es el método propio de la interfaz y no necesita clase de utilidades.

Consultar

int n         = l.size();
boolean vacia = l.isEmpty();       // preferible a size() == 0: mas legible

  1. remove(int) frente a remove(Object): la trampa clásica

List tiene dos métodos remove sobrecargados:

E       remove(int index);       // elimina por POSICION
boolean remove(Object o);        // elimina por VALOR

Con la mayoría de los tipos no hay ambigüedad, pero con List<Integer> la hay, y produce el bug más famoso del Framework:

List<Integer> numeros = new ArrayList<>(List.of(10, 20, 30, 40));

numeros.remove(1);                  // que elimina?
System.out.println(numeros);        // [10, 30, 40]  <- elimino la POSICION 1, no el valor 1

La resolución de sobrecarga (03-03) prefiere siempre la coincidencia exacta sin conversiones: 1 es un int, así que llama a remove(int index) sin autoboxing. Si tu intención era eliminar el valor 20, el resultado es correcto por casualidad; si querías eliminar el valor 1 de una lista que empieza por 10, has borrado el elemento equivocado en silencio.

Peor todavía:

List<Integer> ids = new ArrayList<>(List.of(100, 200, 300));
ids.remove(100);         // IndexOutOfBoundsException: Index 100 out of bounds for length 3

Buscabas el valor 100 y has pedido la posición 100.

Las soluciones

List<Integer> numeros = new ArrayList<>(List.of(10, 20, 30, 40));

numeros.remove(Integer.valueOf(20));    // 1. explicito: por VALOR
numeros.remove((Integer) 30);           // 2. casting: por VALOR
numeros.remove(1);                      // 3. por POSICION (queda claro que es lo que quieres)

Integer.valueOf(20) es la forma preferida por legibilidad. Y una regla general muy útil: desconfía de las sobrecargas cuando uses colecciones de Integer. El mismo problema no ocurre con List<String> ni con List<Material>, porque remove("B") no puede confundirse con un índice.

Llamada sobre List<Integer> Se resuelve como Efecto
lista.remove(2) remove(int) Elimina el elemento en la posición 2
lista.remove(Integer.valueOf(2)) remove(Object) Elimina el valor 2
lista.remove((Integer) 2) remove(Object) Elimina el valor 2
lista.indexOf(2) Solo hay una versión Busca el valor 2 (autoboxing)
lista.contains(2) Solo hay una versión Busca el valor 2 (autoboxing)

Fíjate en las dos últimas filas: contains e indexOf solo aceptan Object, así que ahí el autoboxing sí ocurre y todo funciona como esperas. La ambigüedad es exclusiva de remove.

  1. subList es una vista

subList(desde, hasta) devuelve la porción de la lista entre esos índices (desde incluido, hasta excluido). Pero no es una copia: es una vista sobre la lista original, igual que Arrays.asList era una vista sobre el array.

List<String> l = new ArrayList<>(List.of("A", "B", "C", "D", "E"));
List<String> centro = l.subList(1, 4);     // [B, C, D]

centro.set(0, "X");
System.out.println(l);         // [A, X, C, D, E]   <- ha cambiado la ORIGINAL

centro.clear();
System.out.println(l);         // [A, E]            <- se han borrado de la ORIGINAL

Ese comportamiento es intencionado y muy potente —clear() sobre una sublista es el idioma estándar para eliminar un rango—, pero hay que conocerlo, porque las sorpresas son de dos tipos:

1. Los cambios se propagan en ambos sentidos. Modificar la vista modifica la original y viceversa.

2. Modificar la original estructuralmente invalida la vista. Si añades o quitas elementos en la lista original directamente, cualquier uso posterior de la sublista lanza ConcurrentModificationException, por el mismo mecanismo de modCount que viste en 05-02:

List<String> l = new ArrayList<>(List.of("A", "B", "C", "D", "E"));
List<String> centro = l.subList(1, 4);
l.add("F");                          // modificacion estructural de la original
System.out.println(centro);          // ConcurrentModificationException

Si lo que quieres es una copia independiente, envuélvela:

List<String> copiaDelCentro = new ArrayList<>(l.subList(1, 4));   // ahora si es independiente

Usos legítimos de subList tal cual:

// Paginar resultados sin copiar la lista entera
int desde = pagina * porPagina;
int hasta = Math.min(desde + porPagina, resultados.size());
List<Material> paginaActual = resultados.subList(desde, hasta);

// Borrar un rango completo
catalogo.subList(0, 10).clear();          // elimina los 10 primeros

// Ordenar solo un tramo
catalogo.subList(0, 5).sort(Comparator.comparing(Material::getTitulo));

  1. Tabla de complejidad de cada operación

Esta tabla es la que te permitirá decidir con criterio en 05-04:

Operación Complejidad Por qué
get(i) O(1) Acceso directo elementData[i]
set(i, e) O(1) Escritura directa
add(e) (al final) O(1) amortizado Escribir y sumar 1; ocasionalmente copiar todo
add(0, e) (al principio) O(n) Desplazar todos los elementos a la derecha
add(i, e) (en medio) O(n) Desplazar los n - i posteriores
remove(size()-1) (último) O(1) Poner a null y restar 1
remove(0) (primero) O(n) Desplazar todos los posteriores a la izquierda
remove(i) O(n) Desplazar los n - i - 1 posteriores
remove(Object) O(n) Buscar (O(n)) + desplazar (O(n))
contains(e) / indexOf(e) O(n) Recorrer comparando con equals
size() / isEmpty() O(1) Es un campo, no se cuenta nada
clear() O(n) Pone todas las celdas a null para no filtrar memoria
Recorrer con for-each O(n), muy rápido Memoria contigua: localidad de caché óptima
sort(cmp) O(n log n) TimSort (05-09)
contains tras sort + binarySearch O(log n) Solo si la mantienes ordenada (05-09)

El mensaje que hay que llevarse: ArrayList es excelente por los índices y por el final de la lista, y mediocre por el principio y por el medio. Si tu programa hace lista.add(0, x) o lista.remove(0) dentro de un bucle sobre miles de elementos, estás haciendo O(n²) sin darte cuenta, y es el momento de mirar un ArrayDeque (05-07).

  1. Recorrido y borrado seguro

Las tres formas de recorrer, con su criterio:

// 1. for-each: por defecto, cuando solo lees
for (Material m : catalogo) {
    System.out.println(m.describir());
}

// 2. for clasico con indice: cuando necesitas la posicion o escribes con set
for (int i = 0; i < catalogo.size(); i++) {
    System.out.printf("%2d. %s%n", i + 1, catalogo.get(i).getTitulo());
}

// 3. forEach con Consumer: cuando ya tienes la accion como referencia a metodo
catalogo.forEach(System.out::println);

Un aviso sobre la opción 2: catalogo.get(i) es O(1) en ArrayList, pero será O(n) en LinkedList. Un bucle indexado sobre una LinkedList es O(n²) y es uno de los errores de rendimiento más habituales. El for-each es correcto en ambas.

Y el borrado durante el recorrido, retomando lo de 05-02 con los idiomas concretos:

// CORRECTO Y PREFERIDO: una linea
catalogo.removeIf(m -> !m.estaDisponible());

// CORRECTO: iterador explicito, cuando la logica es compleja
Iterator<Material> it = catalogo.iterator();
while (it.hasNext()) {
    Material m = it.next();
    if (!m.estaDisponible()) {
        registrarBaja(m);        // efecto colateral antes de borrar
        it.remove();
    }
}

// CORRECTO: for clasico HACIA ATRAS, cuando necesitas el indice
for (int i = catalogo.size() - 1; i >= 0; i--) {
    if (!catalogo.get(i).estaDisponible()) {
        System.out.println("Baja en la posicion " + i);
        catalogo.remove(i);
    }
}

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

Un detalle de eficiencia poco conocido: removeIf sobre un ArrayList está implementado en una sola pasada que marca los elementos a eliminar y luego compacta el array de golpe, así que es O(n). Un bucle que llame a remove(Object) n veces sería O(n²). Otra razón para preferir removeIf.

  1. ArrayList frente a array

Aspecto Material[] List<Material>
Tamaño Fijo al crear Crece y mengua solo
Añadir / eliminar A mano con copyOf / arraycopy add / remove
Tamaño real length es la capacidad; hay que llevar un contador size() es la verdad
Acceso por índice a[i] — el más rápido posible get(i) — O(1), con una llamada de método
Primitivos int[] sin envoltorios List<Integer> con autoboxing
Memoria (1 M de int) ~4 MB ~20 MB
Recorrido El más rápido Muy rápido (mismo array por dentro)
Buscar por criterio Bucle a mano contains, indexOf, removeIf
Ordenar Arrays.sort lista.sort
Multidimensional int[][] natural List<List<Integer>>, más verboso
Seguridad de tipos Covariante: ArrayStoreException en ejecución Genéricos: error en compilación
API disponible La clase Arrays Toda la interfaz List y Collections

Cuándo el array sigue siendo mejor:

  1. Primitivos en cantidad. int[], double[], byte[]. Un buffer de bytes de E/S (módulo 7) siempre es byte[].
  2. Tamaño fijo por naturaleza. Una matriz 3×12, un tablero de ajedrez, los 256 valores de una tabla de conversión.
  3. Máximo rendimiento en cálculo numérico, donde la localidad de caché y la ausencia de envoltorios marcan la diferencia.
  4. Implementar estructuras de datos, como hacen ArrayList, HashMap y ArrayDeque por dentro.
  5. APIs que lo exigen: main(String[] args), String.split, toArray.

Para todo lo demás, ArrayList.

  1. Conversión array ↔ lista

Es una operación cotidiana y cada dirección tiene su trampa.

De lista a array

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

Material[] array = lista.toArray(new Material[0]);   // EL IDIOMA CORRECTO
Object[]   malo  = lista.toArray();                  // pierde el tipo: casi nunca util

El new Material[0] no se desperdicia: le indica al método el tipo de array que debe crear. Como el array pasado es demasiado pequeño, toArray crea internamente uno del tamaño correcto y devuelve ese. Sorprendentemente, new Material[0] suele ser más rápido que new Material[lista.size()] en las JVM modernas, porque evita rellenar de ceros un array que se va a sobrescribir entero. Usa siempre new T[0].

El array resultante es independiente: añadir a la lista después no lo cambia. Pero es una copia superficial: los objetos son los mismos.

De array a lista

Tres formas, con comportamientos muy distintos:

Material[] array = { libro1, libro2, libro3 };

// 1. VISTA de tamano fijo respaldada por el array (05-01)
List<Material> vista = Arrays.asList(array);
vista.set(0, otro);        // OK, y modifica array[0]
// vista.add(otro);        // UnsupportedOperationException

// 2. Lista MODIFICABLE e INDEPENDIENTE  <- lo que quieres casi siempre
List<Material> modificable = new ArrayList<>(Arrays.asList(array));
modificable.add(otro);     // OK, y no toca 'array'

// 3. Lista INMUTABLE e independiente (Java 9+)
List<Material> inmutable = List.of(array);
// inmutable.set(0, otro); // UnsupportedOperationException
Forma Modificable ¿Refleja cambios del array? Cuándo usarla
Arrays.asList(a) Solo set Sí, en ambos sentidos Envolver un array para pasarlo a una API que pide List
new ArrayList<>(Arrays.asList(a)) Totalmente No La opción por defecto
List.of(a) No No Constantes; rechaza null

Y las dos trampas que ya conoces de 05-01, que aquí vuelven a morder:

int[] primitivos = { 1, 2, 3 };
List<int[]> mal = Arrays.asList(primitivos);      // UNA lista de UN elemento (el array entero)
System.out.println(mal.size());                   // 1

Integer[] envueltos = { 1, 2, 3 };
List<Integer> bien = Arrays.asList(envueltos);    // 3 elementos, como esperabas
System.out.println(bien.size());                  // 3

Los genéricos solo funcionan con tipos referencia (10-01), así que un int[] se toma como un único objeto. Para pasar de int[] a List<Integer> sin Streams, el bucle explícito es la vía:

List<Integer> lista = new ArrayList<>(primitivos.length);
for (int n : primitivos) { lista.add(n); }        // autoboxing en cada add

En el módulo 10 verás que Arrays.stream(primitivos).boxed().toList() hace lo mismo en una expresión.

  1. Listas de listas

Como una List puede contener cualquier tipo, puede contener otras listas. Es el equivalente flexible del array bidimensional de 05-01:

List<List<Material>> porTipo = new ArrayList<>();

List<Material> libros = new ArrayList<>();
libros.add(new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018));
libros.add(new Libro("Refactorizacion", "Martin Fowler", "978-0000000003", 1999));

List<Material> revistas = new ArrayList<>();
revistas.add(new Revista("Java Magazine", "REV-2024-03", 42, "Mensual"));

porTipo.add(libros);
porTipo.add(revistas);
porTipo.add(new ArrayList<>());     // los DVD, todavia sin ninguno

// Acceso: dos indices, igual que en un array bidimensional
System.out.println(porTipo.get(0).get(1).getTitulo());   // Refactorizacion

// Recorrido anidado
for (List<Material> grupo : porTipo) {
    System.out.println("Grupo de " + grupo.size() + " materiales:");
    for (Material m : grupo) {
        System.out.println("   " + m.getTitulo());
    }
}

La ventaja frente a Material[][] es que cada sublista crece por su cuenta, sin dimensionar nada de antemano.

Pero fíjate en la debilidad: el índice 0 significa "libros" por convenio, y ese convenio no está escrito en ninguna parte. Es exactamente el problema que resuelve un Map<String, List<Material>> de 05-05, donde la clave dice explícitamente qué contiene cada grupo.

Y una advertencia sobre la inicialización:

List<List<String>> matriz = new ArrayList<>();
for (int i = 0; i < 3; i++) {
    matriz.add(new ArrayList<>());       // cada fila necesita SU PROPIA lista
}
matriz.get(0).add("dato");               // ahora si

Si olvidas crear cada sublista, matriz.get(0) devuelve null y el add lanza NullPointerException. Y si añades tres veces la misma lista (List<String> fila = new ArrayList<>(); matriz.add(fila); matriz.add(fila);), las tres filas serán la misma por aliasing (03-02), y escribir en una las cambia todas.

  1. equals y hashCode de una lista

AbstractList sí sobrescribe equals y hashCode, a diferencia de los arrays. Dos listas son iguales si tienen los mismos elementos en el mismo orden:

List<String> a = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso"));
List<String> b = new LinkedList<>(List.of("Marta Ruiz", "Diego Alonso"));
List<String> c = new ArrayList<>(List.of("Diego Alonso", "Marta Ruiz"));

System.out.println(a.equals(b));      // true  <- la implementacion NO importa
System.out.println(a.equals(c));      // false <- el ORDEN si importa
System.out.println(a.hashCode() == b.hashCode());   // true

Que a.equals(b) sea true siendo b una LinkedList es deliberado: el contrato de List.equals compara contenido, no clase. Es una diferencia importante con los arrays, donde equals compara referencias y hacía falta Arrays.equals.

El hashCode se calcula así, y explica que dos listas iguales lo tengan igual:

int hash = 1;
for (E e : lista) {
    hash = 31 * hash + (e == null ? 0 : e.hashCode());
}

Consecuencia práctica: el hashCode de una lista depende de sus elementos, así que cambia si la lista cambia. Usar una lista mutable como clave de un HashMap o como elemento de un HashSet es una receta para el desastre; lo verás demostrado en 05-05. Si necesitas una lista como clave, usa una inmutable (List.of(...) o List.copyOf(...)).

Y ojo: para que equals de la lista funcione, los elementos deben tener equals correcto. Una List<Ficha> compara bien porque Ficha es un record; una lista de objetos sin equals compararía por referencia elemento a elemento.

  1. Refactorización de BiblioTech

Es el momento de convertir el catálogo definitivamente. Compara el método buscarPorTipo en sus dos versiones.

Antes: con arrays

/** Version del modulo 4, con Material[] y contador manual. */
public Material[] buscarPorTipo(String tipo) {
    Material[] resultado = new Material[n];      // hay que reservar el MAXIMO posible
    int encontrados = 0;
    for (int i = 0; i < n; i++) {                // hasta n, no hasta materiales.length
        if (materiales[i] != null && materiales[i].getTipo().equals(tipo)) {
            resultado[encontrados] = materiales[i];
            encontrados++;
        }
    }
    return Arrays.copyOf(resultado, encontrados);  // y recortar al final
}

Cuatro problemas en once líneas: reservar de más, llevar un contador aparte, recorrer solo hasta n sin olvidarlo, y recortar al devolver.

Después: con List

/** Version del modulo 5. */
public List<Material> buscarPorTipo(String tipo) {
    List<Material> resultado = new ArrayList<>();
    for (Material m : materiales) {
        if (m.getTipo().equals(tipo)) { resultado.add(m); }
    }
    return resultado;
}

Y con el Predicate de 04-06, un solo método sirve para cualquier criterio:

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;
}

El Catalogo completo

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
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 sobre ArrayList. */
public class Catalogo {

    private final List<Material> materiales;
    private final Set<String>    referencias = new HashSet<>();   // unicidad en O(1)

    public Catalogo() {
        this.materiales = new ArrayList<>();
    }

    /** Capacidad inicial: util solo cuando cargamos un catalogo grande de golpe. */
    public Catalogo(int materialesPrevistos) {
        this.materiales = new ArrayList<>(Math.max(materialesPrevistos, 10));
    }

    public boolean anadir(Material m) {
        if (m == null) { return false; }
        if (!referencias.add(m.getReferencia())) {
            return false;                             // referencia duplicada
        }
        return materiales.add(m);                     // siempre true en una List
    }

    /** Inserta en una posicion concreta. O(n): desplaza el resto. */
    public boolean insertar(int posicion, Material m) {
        if (m == null || posicion < 0 || posicion > materiales.size()) { return false; }
        if (!referencias.add(m.getReferencia())) { return false; }
        materiales.add(posicion, m);
        return true;
    }

    public boolean eliminar(String referencia) {
        if (!referencias.remove(referencia)) { return false; }
        return materiales.removeIf(m -> m.getReferencia().equals(referencia));
    }

    /** Sustituye el material de una posicion conservando el tamano. */
    public Material reemplazar(int posicion, Material nuevo) {
        Material anterior = materiales.set(posicion, nuevo);   // devuelve el que habia
        referencias.remove(anterior.getReferencia());
        referencias.add(nuevo.getReferencia());
        return anterior;
    }

    public Material obtener(int posicion)  { return materiales.get(posicion); }   // O(1)
    public boolean  contiene(Material m)   { return materiales.contains(m); }     // O(n), usa equals
    public int      posicionDe(Material m) { return materiales.indexOf(m); }      // O(n)

    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;
    }

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

    public void ordenar(Comparator<Material> criterio) { materiales.sort(criterio); }

    /** Pagina de resultados usando subList, con los limites bien controlados. */
    public List<Material> pagina(int numeroPagina, int porPagina) {
        int desde = numeroPagina * porPagina;
        if (desde >= materiales.size() || desde < 0) { return List.of(); }
        int hasta = Math.min(desde + porPagina, materiales.size());
        return new ArrayList<>(materiales.subList(desde, hasta));   // COPIA, no vista
    }

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

    /** Cuando una API antigua pide un array. */
    public Material[] comoArray()    { return materiales.toArray(new Material[0]); }

    public int     tamano()  { return materiales.size(); }
    public boolean vacio()   { return materiales.isEmpty(); }
    public void    vaciar()  { materiales.clear(); referencias.clear(); }
}

Uso completo:

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"));
catalogo.anadir(new Dvd("Refactorizacion en vivo", "DVD-0007",   95));

catalogo.ordenar(Comparator.comparing(Material::getTipo)
                           .thenComparing(Material::getTitulo));

System.out.println("--- Catalogo (" + catalogo.tamano() + " materiales) ---");
catalogo.listar().forEach(m -> System.out.println("  " + m.describir()));

List<Material> baratos = catalogo.buscar(m -> m.getTarifaDiaria() < 0.30);
System.out.println("Tarifa inferior a 0,30 EUR/dia: " + baratos.size());

System.out.println("Pagina 0 (2 por pagina):");
catalogo.pagina(0, 2).forEach(m -> System.out.println("  " + m.getTitulo()));
--- Catalogo (5 materiales) ---
  [DVD] Refactorizacion en vivo (DVD-0007)
  [Libro] Java Efectivo - Joshua Bloch (978-0000000001)
  [Libro] Patrones de Diseno - Erich Gamma (978-0000000002)
  [Libro] Refactorizacion - Martin Fowler (978-0000000003)
  [Revista] Java Magazine num. 42 (REV-2024-03)
Tarifa inferior a 0,30 EUR/dia: 4
Pagina 0 (2 por pagina):
  Refactorizacion en vivo
  Java Efectivo

Comparado con el CatalogoArray de 05-01, ha desaparecido toda la gestión de memoria y han aparecido operaciones que antes eran impensables: paginación, inserción posicional, reemplazo, unicidad garantizada. Y sigue quedando una debilidad, deliberada: eliminar y cualquier búsqueda por referencia recorren la lista entera. Con 5 materiales da igual; con 50 000, no. Ese es el trabajo de 05-05.

Errores Comunes y Consejos

lista.length en lugar de lista.size(). length es de arrays, length() de String, size() de colecciones.

Confundir add(i, e) con set(i, e). El primero inserta y la lista crece; el segundo reemplaza y el tamaño no cambia. Si tu lista crece cuando no debía, mira aquí.

remove(int) en una List<Integer>. lista.remove(2) elimina la posición 2, no el valor 2. Usa remove(Integer.valueOf(2)) para eliminar por valor.

Modificar la lista dentro de un for-each. ConcurrentModificationException. Usa removeIf, Iterator.remove() o recorre hacia atrás con índices.

Bucle indexado sobre una lista que podría no ser ArrayList. for (int i = 0; i < lista.size(); i++) lista.get(i) es O(n) en ArrayList y O(n²) en LinkedList. Si el parámetro es List, recorre con for-each.

lista.add(0, x) o lista.remove(0) dentro de un bucle. Cada llamada desplaza toda la lista: O(n²) en total. Si necesitas trabajar por el principio, usa un ArrayDeque (05-07).

Creer que subList es una copia. Es una vista: modificarla modifica la original, y modificar la original estructuralmente la invalida con ConcurrentModificationException. Si quieres una copia, new ArrayList<>(lista.subList(a, b)).

Esperar que Arrays.asList devuelva una lista modificable. Es de tamaño fijo: set sí, add/remove lanzan UnsupportedOperationException. Usa new ArrayList<>(Arrays.asList(...)).

Arrays.asList sobre un int[]. Devuelve una lista de un elemento. Necesitas Integer[] o un bucle explícito.

Buscar con contains en una clase sin equals. Nunca encontrará nada, aunque los datos coincidan. Repasa 03-09.

Devolver la lista interna desde un getter. Quien llame podrá vaciarla. Devuelve List.copyOf(materiales) o, como mínimo, Collections.unmodifiableList(...) sabiendo que es una vista (05-02).

Consejo: isEmpty() en vez de size() == 0. Se lee mejor y en algunas implementaciones es más rápido.

Consejo: capacidad inicial solo cuando importe. Con miles o millones de elementos previstos, new ArrayList<>(n) ahorra redimensionados. Con veinte, es ruido.

Ejercicios

Ejercicio 1: gestor de préstamos con ArrayList

Escribe GestorPrestamos con un List<Prestamo> interno y estos métodos:

  • void registrar(Prestamo p).
  • boolean devolver(String referencia, int dia): busca el préstamo por su referencia (PR-0001), invoca registrarDevolucion(dia) y devuelve true si lo encontró.
  • List<Prestamo> vencidos(int diaActual): los que estén vencidos y sin devolver.
  • int purgarDevueltos(): elimina los ya devueltos con removeIf y devuelve cuántos quitó.
  • List<Prestamo> ultimos(int cuantos): los cuantos últimos registrados, usando subList con los límites bien controlados y devolviendo una copia.
  • Prestamo masAntiguo(): el de menor diaPrestamo, o null si no hay ninguno.

Ejercicio 2: la trampa de remove documentada

Escribe una clase DemostracionRemove con un método main que:

  1. Cree un List<Integer> con los valores 10, 20, 30, 40, 50.
  2. Muestre qué ocurre con remove(1), remove(Integer.valueOf(20)) y remove((Integer) 30), imprimiendo la lista tras cada operación.
  3. Muestre por qué remove(100) lanzaría IndexOutOfBoundsException (sin ejecutarlo: coméntalo y explica).
  4. Cree un List<String> equivalente y explique por qué ahí la ambigüedad no existe.
  5. Escriba un método boolean eliminarValor(List<Integer> lista, int valor) seguro y reutilizable.

Ejercicio 3: conversiones y sus trampas

Escribe ConversorColecciones con métodos estáticos que demuestren, cada uno con su salida por consola:

  • void demostrarAsList(): que Arrays.asList es una vista de tamaño fijo respaldada por el array (modifica por ambos lados y muestra qué operaciones fallan).
  • void demostrarCopiaIndependiente(): que new ArrayList<>(Arrays.asList(a)) es independiente.
  • void demostrarToArray(): la conversión de vuelta con toArray(new String[0]).
  • void demostrarPrimitivos(): la trampa de Arrays.asList(int[]) frente a Arrays.asList(Integer[]).
  • List<Integer> aLista(int[] primitivos): la conversión correcta con un bucle.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.List;
import com.nexussoftware.bibliotech.dominio.Prestamo;

public class GestorPrestamos {

    private final List<Prestamo> prestamos = new ArrayList<>();

    public void registrar(Prestamo p) {
        if (p != null) { prestamos.add(p); }        // add al final: O(1) amortizado
    }

    /**
     * Busqueda lineal O(n) por referencia. Con muchos prestamos habria que
     * indexar por referencia en un Map<String, Prestamo>: eso es 05-05.
     */
    public boolean devolver(String referencia, int dia) {
        if (referencia == null) { return false; }
        for (Prestamo p : prestamos) {              // for-each: solo leemos
            if (referencia.equals(p.getReferencia())) {
                p.registrarDevolucion(dia);         // modificamos el OBJETO, no la lista: legal
                return true;
            }
        }
        return false;
    }

    public List<Prestamo> vencidos(int diaActual) {
        List<Prestamo> resultado = new ArrayList<>();
        for (Prestamo p : prestamos) {
            if (!p.estaDevuelto() && p.estaVencido(diaActual)) {
                resultado.add(p);
            }
        }
        return resultado;              // lista vacia si no hay ninguno, NUNCA null
    }

    /**
     * removeIf hace una sola pasada O(n). Un bucle con remove(Object)
     * seria O(n^2) y ademas daria ConcurrentModificationException.
     */
    public int purgarDevueltos() {
        int antes = prestamos.size();
        prestamos.removeIf(Prestamo::estaDevuelto);   // referencia a metodo (04-06)
        return antes - prestamos.size();
    }

    public List<Prestamo> ultimos(int cuantos) {
        if (cuantos <= 0 || prestamos.isEmpty()) { return List.of(); }
        int desde = Math.max(0, prestamos.size() - cuantos);   // proteccion del limite inferior
        // subList devuelve una VISTA: la copiamos para que sea independiente
        return new ArrayList<>(prestamos.subList(desde, prestamos.size()));
    }

    public Prestamo masAntiguo() {
        Prestamo mejor = null;
        for (Prestamo p : prestamos) {
            if (mejor == null || p.getDiaPrestamo() < mejor.getDiaPrestamo()) {
                mejor = p;
            }
        }
        return mejor;      // null si la lista esta vacia; documentalo en el javadoc
    }

    public int tamano() { return prestamos.size(); }
}

Puntos de diseño a retener. devolver modifica el objeto dentro de un for-each, lo cual es perfectamente legal: modCount solo cuenta cambios estructurales de la lista. purgarDevueltos usa removeIf en lugar de un bucle con remove, pasando de O(n²) a O(n) y evitando la excepción. ultimos protege el límite inferior con Math.max y copia la vista de subList. Y todos los métodos que devuelven colecciones devuelven una lista vacía en lugar de null.

En 05-09 verás que masAntiguo() se escribe en una línea con Collections.min(prestamos, Comparator.comparingInt(Prestamo::getDiaPrestamo)).

Solución 2

package com.nexussoftware.bibliotech.presentacion;

import java.util.ArrayList;
import java.util.List;

public class DemostracionRemove {

    public static void main(String[] args) {
        List<Integer> numeros = new ArrayList<>(List.of(10, 20, 30, 40, 50));
        System.out.println("Inicial:                    " + numeros);

        // 1) remove(int): el literal 1 es un int -> se elige remove(int index)
        //    sin autoboxing, porque la sobrecarga prefiere la coincidencia exacta.
        numeros.remove(1);
        System.out.println("Tras remove(1):             " + numeros + "  <- borro la POSICION 1");

        // 2) remove(Object): Integer.valueOf(30) es un Integer -> remove(Object)
        numeros.remove(Integer.valueOf(30));
        System.out.println("Tras remove(valueOf(30)):   " + numeros + "  <- borro el VALOR 30");

        // 3) El casting produce el mismo efecto que valueOf, pero se lee peor
        numeros.remove((Integer) 40);
        System.out.println("Tras remove((Integer) 40):  " + numeros + "  <- borro el VALOR 40");

        // 4) La llamada peligrosa, comentada a proposito:
        // numeros.remove(100);
        //    -> IndexOutOfBoundsException: Index 100 out of bounds for length 2
        //    El programador queria borrar el VALOR 100 y ha pedido la POSICION 100.
        //    El error se descubre en ejecucion, no al compilar.

        // 5) Con String no hay ambiguedad: "Marta Ruiz" no es un int,
        //    asi que solo puede resolverse como remove(Object).
        List<String> empleados = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso"));
        empleados.remove("Marta Ruiz");
        System.out.println("Empleados:                  " + empleados);

        // 6) Metodo seguro y reutilizable
        List<Integer> ids = new ArrayList<>(List.of(101, 205, 307));
        System.out.println("eliminarValor(205): " + eliminarValor(ids, 205) + " -> " + ids);
        System.out.println("eliminarValor(999): " + eliminarValor(ids, 999) + " -> " + ids);
    }

    /**
     * Elimina por VALOR sin ambiguedad posible. El parametro es int por comodidad
     * de quien llama, y el envoltorio explicito garantiza que se invoque
     * remove(Object) y no remove(int).
     */
    public static boolean eliminarValor(List<Integer> lista, int valor) {
        return lista.remove(Integer.valueOf(valor));
    }
}
Inicial:                    [10, 20, 30, 40, 50]
Tras remove(1):             [10, 30, 40, 50]  <- borro la POSICION 1
Tras remove(valueOf(30)):   [10, 40, 50]  <- borro el VALOR 30
Tras remove((Integer) 40):  [10, 50]  <- borro el VALOR 40
Empleados:                  [Diego Alonso]
eliminarValor(205): true -> [101, 307]
eliminarValor(999): false -> [101, 307]

La lección de fondo va más allá de remove: cuando dos sobrecargas se distinguen solo por primitivo frente a envoltorio, la que gana es la del primitivo, porque la resolución de sobrecarga prefiere no aplicar boxing. Es la misma regla de 03-03, ahora con consecuencias visibles.

Solución 3

package com.nexussoftware.bibliotech.presentacion;

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

public final class ConversorColecciones {

    private ConversorColecciones() { }

    public static void demostrarAsList() {
        System.out.println("--- Arrays.asList es una VISTA de tamano fijo ---");
        String[] array = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };
        List<String> vista = Arrays.asList(array);

        vista.set(0, "Marta R.");                        // permitido
        System.out.println("array[0] tras vista.set: " + array[0]);   // Marta R.

        array[1] = "Diego A.";                            // por el otro lado
        System.out.println("vista tras array[1]=:    " + vista);      // Diego A.

        // vista.add("Nuevo");    -> UnsupportedOperationException: tamano FIJO
        // vista.remove(0);       -> UnsupportedOperationException
        System.out.println("add y remove lanzarian UnsupportedOperationException");
    }

    public static void demostrarCopiaIndependiente() {
        System.out.println("--- new ArrayList<>(Arrays.asList(a)) es INDEPENDIENTE ---");
        String[] array = { "Marta Ruiz", "Diego Alonso" };
        List<String> copia = new ArrayList<>(Arrays.asList(array));

        copia.add("Nuria Vidal");        // ahora si se puede
        copia.set(0, "Marta R.");

        System.out.println("copia:   " + copia);            // [Marta R., Diego Alonso, Nuria Vidal]
        System.out.println("array:   " + Arrays.toString(array));   // [Marta Ruiz, Diego Alonso]
        System.out.println("El array original NO se ha visto afectado");
    }

    public static void demostrarToArray() {
        System.out.println("--- De lista a array ---");
        List<String> lista = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso"));

        // new String[0] indica el TIPO. toArray crea internamente uno del tamano justo.
        String[] array = lista.toArray(new String[0]);
        System.out.println("array: " + Arrays.toString(array) + " (length " + array.length + ")");

        lista.add("Nuria Vidal");        // el array ya generado no cambia
        System.out.println("tras anadir a la lista, array sigue con " + array.length);

        Object[] sinTipo = lista.toArray();     // pierde el tipo: casi nunca util
        System.out.println("toArray() sin argumento devuelve Object[]: " + sinTipo.length);
    }

    public static void demostrarPrimitivos() {
        System.out.println("--- La trampa de los primitivos ---");
        int[] primitivos = { 1, 2, 3 };
        List<int[]> mal = Arrays.asList(primitivos);
        System.out.println("Arrays.asList(int[]).size()     = " + mal.size()
                           + "   <- UNA lista de UN elemento: el array entero");

        Integer[] envueltos = { 1, 2, 3 };
        List<Integer> bien = Arrays.asList(envueltos);
        System.out.println("Arrays.asList(Integer[]).size() = " + bien.size()
                           + "   <- tres elementos, como esperabas");
        System.out.println("Causa: los genericos solo admiten tipos referencia (10-01)");
    }

    /** Conversion correcta de int[] a List<Integer>. */
    public static List<Integer> aLista(int[] primitivos) {
        if (primitivos == null) { return List.of(); }
        // capacidad inicial exacta: sabemos cuantos van a entrar
        List<Integer> lista = new ArrayList<>(primitivos.length);
        for (int n : primitivos) {
            lista.add(n);                 // autoboxing: int -> Integer en cada add
        }
        return lista;
    }

    public static void main(String[] args) {
        demostrarAsList();
        demostrarCopiaIndependiente();
        demostrarToArray();
        demostrarPrimitivos();
        System.out.println("aLista(new int[]{7,8,9}) = " + aLista(new int[] { 7, 8, 9 }));
    }
}

La conclusión operativa del ejercicio cabe en una regla: Arrays.asList solo para envolver un array de solo lectura que hay que pasar a una API que pide List; new ArrayList<>(Arrays.asList(...)) para todo lo demás; toArray(new T[0]) para la vuelta. Y con primitivos, siempre un bucle o —a partir del módulo 10— Arrays.stream(...).boxed().

Conclusión

Ya conoces por dentro la colección que más vas a usar. Sabes que un ArrayList es un array interno más un int size: exactamente el CatalogoArray que escribiste tú, pero afinado por el JDK. De esa estructura se deriva todo su perfil de rendimiento: get y set en O(1), add al final en O(1) amortizado, y O(n) para insertar o eliminar en cualquier otro sitio porque hay que desplazar el resto con un System.arraycopy interno.

Distingues capacidad de tamaño, y sabes que la capacidad es un detalle interno que no puedes consultar y que casi nunca debes gestionar. Entiendes el redimensionado: el array crece a ×1,5 con una copia O(n), y precisamente porque crece de forma multiplicativa el coste medio por elemento se mantiene constante — eso es el coste amortizado, una idea que reaparecerá con HashMap y ArrayDeque. Y sabes cuándo el constructor con capacidad inicial merece la pena: con miles o millones de elementos previstos, no con veinte.

Dominas la API completa: add en sus dos formas, get, set frente a add(i, e), remove en sus tres variantes, indexOf, contains y su dependencia total de equals —el hilo que viene de 03-09—, sort, replaceAll, removeIf y forEach con las interfaces funcionales de 04-06. Conoces la trampa de remove(int) frente a remove(Object) en una List<Integer> y las tres formas de desactivarla, y sabes que subList es una vista: modificarla cambia la original, modificar la original la invalida, y para una copia hay que envolverla en un new ArrayList<>(...).

Sabes recorrer y borrar con seguridad —removeIf por defecto, Iterator.remove() para lógica compleja, for hacia atrás si necesitas el índice—, tienes la tabla que compara ArrayList con array y los cinco casos en que el array sigue ganando, y manejas las conversiones en ambos sentidos con las trampas de Arrays.asList y de los primitivos. Conoces las listas de listas y su debilidad —el índice que significa algo por convenio no escrito— y sabes que dos listas son iguales si tienen los mismos elementos en el mismo orden, sea cual sea su implementación, con la advertencia de que su hashCode cambia cuando cambia su contenido.

El Catalogo de BiblioTech es ya una clase profesional: alta con control de duplicados, inserción posicional, reemplazo, baja, búsqueda por cualquier Predicate, ordenación por cualquier Comparator, paginación con subList y vista inmutable hacia el exterior. Cero líneas de gestión de memoria.

En la lección siguiente, LinkedList, verás la otra implementación de List: una cadena de nodos enlazados en ambos sentidos, sin array y sin capacidad. Entenderás qué implica eso para la memoria y para la caché del procesador, y sobre todo desmontarás el mito que casi todo el mundo repite mal: que "LinkedList inserta en O(1)". Es cierto solo si ya tienes la posición, y llegar a ella cuesta O(n). Verás la comparación honesta operación por operación, por qué en la práctica ArrayList gana casi siempre, por qué LinkedList sobrevive sobre todo como Deque, cómo insertar correctamente mientras recorres con un ListIterator, y el único caso de BiblioTech en el que LinkedList es realmente la elección acertada: la cola de reservas que se consume por el principio y crece por el final.

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