Apuntes · Capítulo 4Objetos y clases

Arreglos, ArrayList y diseño con clases

Arreglos, ArrayList y diseño con clases

Hasta ahora cada objeto andaba solo: una tarjeta, un termómetro, una cuenta. Los programas de verdad trabajan con muchos objetos a la vez: las canciones de una lista de reproducción, los ejemplares de una biblioteca, los productos de una bodega. La semana pasada aprendimos a proteger el estado de un objeto con encapsulamiento e invariantes, y a probarlo con JUnit. Esta semana juntamos objetos en colecciones y, con eso, aparece una pregunta nueva: cuando un programa tiene varias clases que colaboran, ¿cómo decidimos qué clases hay y qué hace cada una?

El capítulo tiene dos partes. En la primera vemos los arreglos de Java con ojos de C, descubrimos que un arreglo de objetos es en realidad un arreglo de referencias, construimos a mano un arreglo que crece y después usamos ArrayList, que hace ese trabajo por nosotros. En la segunda pasamos de un enunciado a un diseño: sustantivos y verbos, responsabilidades, tarjetas CRC y diagramas de clases UML.

Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9), secciones 3.8 (3.8.1 a 3.8.4, repaso desde C), 7.1 (7.1.1 For-each Loops y 7.1.3 Array Literals), 7.2.4 Dynamic Arrays, 7.3 (7.3.1 a 7.3.3, sobre ArrayList y las clases wrapper), 5.3.4 Object-oriented Analysis and Design y 5.4.1 Designing the classes. Eck no trata UML; para eso están las lecturas complementarias de la guía de la semana. El código está en code/week-04 y se prueba con make test dentro del contenedor del curso.

Ruta de lectura.

  • Antes de clase (obligatorio, 15–20 min): Arreglos en Java, vistos desde C hasta Un arreglo de objetos es un arreglo de referencias, y la introducción de Diseñar con clases.
  • En clase: en la sesión A, los arreglos parcialmente llenos y el arreglo que crece, ArrayList y Una lista de reproducción encapsulada; en la sesión B, Diseñar con clases, de Del enunciado a las clases a Mermaid: diagramas como texto. En el laboratorio, Laboratorio: modelar un dominio con pruebas.
  • Después de clase: Errores comunes al quitar elementos, ¿Arreglo o ArrayList?, Las pruebas de Playlist y Dos errores de diseño opuestos, el Resumen y los Ejercicios. Para ampliar, el artículo de las tarjetas CRC citado en Tarjetas CRC y las secciones de Eck de la nota Lectura.

Arreglos en Java, vistos desde C

Ya los usaron en la semana 1. Repasemos lo que cambia respecto de C, porque todo lo demás del capítulo se apoya en esto:

  • Un arreglo es un objeto. Se crea con new, vive en el heap y la variable guarda una referencia a él, igual que con cualquier clase.
  • Conoce su longitud: a.length, un campo (sin paréntesis). En C había que pasar el tamaño aparte, como un parámetro más.
  • Cada índice se verifica. a[3] en un arreglo de longitud 3 lanza ArrayIndexOutOfBoundsException; en C lee lo que haya en esa memoria.
  • Empieza inicializado: 0 para números, false para boolean y null para referencias. Nunca con basura.
  • Su tamaño no cambia. Un arreglo de 10 casillas tiene 10 para siempre. Para tener más, hay que crear otro.

Un arreglo también se puede crear con sus valores, con un literal de arreglo (array literal, Eck 7.1.3):

int[] scores = {14, 17, 11};
String[] days = {"lunes", "martes", "miércoles"};

Y se recorre de dos maneras. El for clásico, igual que en C, da el índice; el for-each (Eck 7.1.1) da directamente cada elemento, sin índice:

int sum = 0;
for (int i = 0; i < scores.length; i++) {
    sum += scores[i];
}

int best = 0;
for (int s : scores) {          // "para cada s en scores"
    best = Math.max(best, s);
}

El for-each es más corto y no se puede equivocar de índice, así que es el que se usa cuando solo hay que leer todos los elementos en orden. El for clásico hace falta cuando se necesita la posición, cuando se recorre al revés o de a dos, o cuando se quiere modificar el arreglo en la casilla i.

Un arreglo de objetos es un arreglo de referencias

Aquí está la diferencia que más sorprende. Comparen:

jshell> int[] counts = new int[3]
counts ==> int[3] { 0, 0, 0 }

jshell> Song[] queue = new Song[3]
queue ==> Song[3] { null, null, null }

new int[3] crea tres enteros. new Song[3] no crea ninguna canción: crea un arreglo de tres casillas, cada una capaz de guardar una referencia a un Song, y las tres empiezan en null. Las canciones hay que crearlas aparte, con su propio new, y guardarlas en las casillas.

Song es la canción de esta semana (Song.java). Es un record, como Money la semana pasada: un valor inmutable con título, artista y duración en segundos, y un constructor compacto que valida:

public record Song(String title, String artist, int seconds) {

    public Song {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("la canción necesita un título");
        }
        if (seconds <= 0) {
            throw new IllegalArgumentException("duración no positiva: " + seconds);
        }
    }
}

ArraysDemo.java llena un arreglo de canciones y muestra qué guarda de verdad:

int[] counts = new int[3];
Song[] queue = new Song[3];
IO.println("counts[0] = " + counts[0] + ", queue[0] = " + queue[0]);

Song song = new Song("Cumbia del puente", "Los Rieleros", 187);
queue[0] = song;
queue[1] = song;                    // dos casillas, una sola canción
IO.println("queue[0] == queue[1]: " + (queue[0] == queue[1]));

Song[] copy = queue.clone();        // copia las referencias, no las canciones
IO.println("copy == queue: " + (copy == queue) + ", copy[0] == queue[0]: " + (copy[0] == queue[0]));

IO.println("queue[2].title() ...");
IO.println(queue[2].title());       // queue[2] sigue en null
$ java -cp build music.ArraysDemo
counts[0] = 0, queue[0] = null
queue[0] == queue[1]: true
copy == queue: false, copy[0] == queue[0]: true
queue[2].title() ...
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "music.Song.title()" because "queue[2]" is null
	at music.ArraysDemo.main(ArraysDemo.java:19)

Cuatro cosas que leer en esa salida:

  • queue[0] es null: el arreglo nació sin canciones.
  • Después de queue[0] = song; queue[1] = song;, las dos casillas apuntan al mismo objeto. Es el alias de la semana 2, ahora dentro de un arreglo.
  • clone() crea un arreglo nuevo (copy == queue es false) pero copia las referencias: copy[0] y queue[0] llegan a la misma canción. Es una copia superficial (shallow copy). Con canciones inmutables no hay problema; con objetos mutables, cambiar el objeto desde un arreglo se vería desde el otro.
  • Usar una casilla que sigue en null termina en NullPointerException, y el mensaje dice exactamente qué expresión era null: queue[2].
Animación: paso 1, la variable queue apunta a un arreglo Song[] de tres casillas, todas null. Paso 2, se crea un objeto Song "Cumbia del puente" y queue[0] apunta a él. Paso 3, queue[1] apunta al mismo objeto. Paso 4, copy = queue.clone() crea un segundo arreglo cuyas casillas 0 y 1 apuntan a la misma canciónAnimación: paso 1, la variable queue apunta a un arreglo Song[] de tres casillas, todas null. Paso 2, se crea un objeto Song "Cumbia del puente" y queue[0] apunta a él. Paso 3, queue[1] apunta al mismo objeto. Paso 4, copy = queue.clone() crea un segundo arreglo cuyas casillas 0 y 1 apuntan a la misma canción
Un arreglo de objetos guarda referencias. new Song[3] crea tres null; asignar copia la flecha, y clone() copia el arreglo pero no las canciones.

Error común. Creer que new Song[10] crea diez canciones y recorrer el arreglo llamando a queue[i].title(). Las casillas que nadie llenó siguen en null, y el programa se cae en la primera.

Arreglos parcialmente llenos

¿Qué hacemos si no sabemos de antemano cuántas canciones va a tener la lista? La técnica clásica, la misma que se usa en C (Eck 3.8.4), es reservar un arreglo más grande de lo necesario y llevar aparte un contador de cuántas casillas están en uso. Las casillas 0 a count - 1 tienen datos; de count en adelante, no importa lo que haya.

Esa pareja, arreglo más contador, tiene un invariante: 0 <= count <= songs.length, y las casillas en uso son exactamente las primeras count. Es justo el tipo de regla que la semana pasada aprendimos a proteger dentro de una clase.

Un arreglo que crece

Cuando el arreglo se llena, no se le pueden agregar casillas. Lo que sí se puede hacer es crear uno más grande, copiar las referencias y olvidarse del viejo. Eso es un arreglo dinámico (dynamic array, Eck 7.2.4). ArrayPlaylist.java lo hace a mano:

public class ArrayPlaylist {
    private Song[] songs = new Song[2];     // capacidad inicial pequeña, para verlo crecer
    private int count = 0;                  // songs[0..count-1] están en uso

    public void add(Song song) {
        if (count == songs.length) {
            grow();
        }
        songs[count] = song;
        count++;
    }

    public int size() {
        return count;
    }

    public int capacity() {
        return songs.length;
    }

    public Song get(int index) {
        if (index < 0 || index >= count) {
            throw new IllegalArgumentException("índice " + index + " fuera de 0.." + (count - 1));
        }
        return songs[index];
    }

    private void grow() {
        Song[] bigger = new Song[songs.length * 2];
        for (int i = 0; i < count; i++) {
            bigger[i] = songs[i];           // se copian referencias, no canciones
        }
        songs = bigger;                     // el arreglo viejo queda para el garbage collector
    }
}

Fíjense en tres decisiones:

  • El tamaño se duplica. Si creciera de a una casilla, cada add copiaría todo el arreglo. Duplicando, las copias son cada vez más raras: agregar n elementos cuesta, en total, del orden de n copias. En promedio, cada add es barato.
  • size y capacity son cosas distintas. size es cuántas canciones hay; capacity, cuántas caben sin crecer. Quien usa la clase solo debería necesitar size.
  • get verifica contra count, no contra songs.length. Con una canción y capacidad 2, la casilla 1 existe en el arreglo, pero no está en uso. El arreglo de Java no puede saberlo; la clase sí.
Animación: paso 1, la variable songs apunta a un arreglo de capacidad 2 lleno con Pista 1 y Pista 2, count = 2. Paso 2, al agregar Pista 3 se crea un arreglo de capacidad 4 y se copian las dos referencias. Paso 3, songs pasa a apuntar al arreglo nuevo y el viejo queda sin referencias. Paso 4, Pista 3 entra en la casilla 2 y count pasa a 3Animación: paso 1, la variable songs apunta a un arreglo de capacidad 2 lleno con Pista 1 y Pista 2, count = 2. Paso 2, al agregar Pista 3 se crea un arreglo de capacidad 4 y se copian las dos referencias. Paso 3, songs pasa a apuntar al arreglo nuevo y el viejo queda sin referencias. Paso 4, Pista 3 entra en la casilla 2 y count pasa a 3
Cuando el arreglo se llena, se crea otro del doble, se copian las referencias y el viejo queda para el garbage collector. Las canciones no se copian.

La parte final de PlaylistDemo.java agrega cinco pistas y muestra cómo crece:

ArrayPlaylist small = new ArrayPlaylist();
for (int i = 1; i <= 5; i++) {
    small.add(new Song("Pista " + i, "Varios", 60 * i));
    IO.println("size " + small.size() + ", capacity " + small.capacity());
}
size 1, capacity 2
size 2, capacity 2
size 3, capacity 4
size 4, capacity 4
size 5, capacity 8

Idea clave. Un arreglo dinámico es un arreglo parcialmente lleno que, cuando se llena, se reemplaza por otro del doble de tamaño. Es una clase con un invariante (count dice cuántas casillas valen) y un detalle interno (capacity) que el resto del programa no necesita conocer.

ArrayList

Escribir ArrayPlaylist una vez enseña mucho. Escribirla para cada tipo de objeto sería absurdo. La biblioteca estándar trae la versión general: ArrayList, del paquete java.util. Por dentro es lo mismo que acabamos de escribir: un arreglo, un contador y una copia cuando se llena.

import java.util.ArrayList;

ArrayList<Song> songs = new ArrayList<>();
songs.add(new Song("Lima gris", "Grupo Garúa", 222));

ArrayList<Song> se lee “lista de Song”: el tipo entre ángulos dice qué guarda la lista. El compilador lo usa para dos cosas: no deja agregar nada que no sea un Song, y sabe que songs.get(0) devuelve un Song, sin necesidad de conversiones. En new ArrayList<>() los ángulos van vacíos (el “diamante”) porque el compilador deduce el tipo de la declaración. Cómo se escribe una clase así, con un tipo como parámetro, es el tema de la semana 10, sobre genéricos (generics); por ahora basta con leerlo.

Los métodos que más se usan:

Método Qué hace Con un arreglo
add(x) agrega al final a[count++] = x, y crecer si hace falta
add(i, x) inserta en la posición i, corre el resto mover a mano
get(i) el elemento en i a[i]
set(i, x) reemplaza el elemento en i a[i] = x
remove(i) quita el de la posición i, corre el resto mover a mano
size() cuántos elementos hay count (no a.length)
isEmpty() si size() es 0 count == 0
contains(x) si hay uno igual a x recorrer y comparar
indexOf(x) posición del primero igual a x, o −1 recorrer y comparar

En jshell, con la clase Song cargada:

jshell> ArrayList<Song> songs = new ArrayList<>()
songs ==> []

jshell> songs.add(new Song("Lima gris", "Grupo Garúa", 222))
$5 ==> true

jshell> songs.size()
$6 ==> 1

jshell> songs.get(0)
$7 ==> Song[title=Lima gris, artist=Grupo Garúa, seconds=222]

jshell> songs.get(1)
|  Exception java.lang.IndexOutOfBoundsException: Index 1 out of bounds for length 1
|        at Preconditions.outOfBounds (Preconditions.java:100)
|        ...
|        at ArrayList.get (ArrayList.java:428)
|        at (#8:1)

Igual que ArrayPlaylist.get, get(1) verifica contra el tamaño (1), no contra la capacidad del arreglo interno. (El toString que muestra jshell para la canción lo genera el record; para ArrayList, la clase ya lo trae.)

En Java. ArrayList arranca con un arreglo interno vacío y, al primer add, reserva 10 casillas. Cuando se llena, crea uno 1,5 veces más grande (no el doble, como el nuestro). El principio es el mismo. Si se sabe de antemano cuántos elementos habrá, new ArrayList<>(1000) reserva esa capacidad desde el inicio y evita las copias.

Clases wrapper y autoboxing

Hay una restricción: entre los ángulos solo pueden ir tipos de referencia, nunca primitivos.

jshell> ArrayList<int> bad = new ArrayList<>()
|  Error:
|  unexpected type
|    required: reference
|    found:    int
|  ArrayList<int> bad = new ArrayList<>();
|            ^-^

Para guardar números en una lista se usan las clases wrapper (Eck 7.3.2): cada tipo primitivo tiene una clase cuyos objetos guardan un valor de ese tipo. int tiene Integer; double, Double; char, Character; boolean, Boolean; y así los demás.

El compilador convierte entre los dos automáticamente. Pasar de int a Integer se llama autoboxing; de Integer a int, unboxing. BoxingDemo.java:

ArrayList<Integer> list = new ArrayList<>();
list.add(5);                        // el compilador escribe list.add(Integer.valueOf(5))
list.add(1);
list.add(7);
IO.println("lista: " + list);

list.remove(1);                     // remove(int index): quita la posición 1
IO.println("después de remove(1): " + list);

list.add(1);
IO.println("después de add(1): " + list);
list.remove(Integer.valueOf(1));    // remove(Object o): quita el valor 1
IO.println("después de remove(Integer.valueOf(1)): " + list);

int first = list.get(0);            // unboxing: Integer a int
IO.println("first + 1 = " + (first + 1));
$ java -cp build BoxingDemo
lista: [5, 1, 7]
después de remove(1): [5, 7]
después de add(1): [5, 7, 1]
después de remove(Integer.valueOf(1)): [5, 7]
first + 1 = 6

Error común. En un ArrayList<Integer>, list.remove(1) no quita el número 1: quita el elemento de la posición 1. ArrayList tiene dos métodos remove, uno que recibe un int (la posición) y otro que recibe un objeto (el valor a buscar). Con el argumento 1, que es un int, el compilador elige el primero sin convertir nada. Para quitar el valor hay que pasar un objeto: list.remove(Integer.valueOf(1)). Es el overloading de la semana 2 jugando en contra.

Un Integer es un objeto en el heap, con su referencia: una lista de un millón de Integer ocupa bastante más memoria que un int[] del mismo tamaño. Para muchos números y cálculos intensos, un arreglo de primitivos sigue siendo la mejor opción.

Una lista de reproducción encapsulada

Con ArrayList ya podemos escribir la clase que nos interesa. Playlist.java guarda sus canciones en un ArrayList<Song> privado: es el mismo encapsulamiento de la semana 3, ahora aplicado a una colección.

package music;

import java.util.ArrayList;

public class Playlist {
    private final String name;
    private final ArrayList<Song> songs = new ArrayList<>();

    public Playlist(String name) {
        this.name = name;
    }

    public void add(Song song) {
        if (song == null) {
            throw new IllegalArgumentException("no se puede agregar null");
        }
        songs.add(song);
    }

    public int size() {
        return songs.size();
    }

    public Song get(int index) {
        return songs.get(index);
    }

    public boolean contains(Song song) {
        return songs.contains(song);
    }

    /** Duración total en segundos; 0 si está vacía. */
    public int totalSeconds() {
        int total = 0;
        for (Song s : songs) {
            total += s.seconds();
        }
        return total;
    }

    /** La canción más larga; si hay empate, la primera. La lista no puede estar vacía. */
    public Song longest() {
        if (songs.isEmpty()) {
            throw new IllegalArgumentException("la lista " + name + " está vacía");
        }
        Song best = songs.get(0);
        for (Song s : songs) {
            if (s.seconds() > best.seconds()) {
                best = s;
            }
        }
        return best;
    }

    // ... removeShorterThan y songs(), más abajo
}

Observen:

  • El campo es final, pero la lista cambia. final dice que la variable songs siempre apunta a la misma lista, no que la lista no cambie. Es una referencia constante a un objeto mutable.
  • add rechaza null. Así totalSeconds y longest pueden llamar a s.seconds() sin preguntarse si s es null. Un invariante de la clase (“ninguna canción es null”) simplifica todos los demás métodos.
  • longest no tiene respuesta para una lista vacía. Devolver null le pasaría el problema a quien llama, que tendría que acordarse de revisarlo. Lanzamos IllegalArgumentException, la única excepción que conocemos; en la semana 9 verán que hay otra más precisa para “el objeto no está en un estado que permita esta operación”.
  • Ningún método devuelve songs. Si Playlist tuviera public ArrayList<Song> getSongs() { return songs; }, cualquiera podría hacer p.getSongs().add(null) o .clear() y saltarse todas las reglas: la fuga del getter que la semana 3 vimos con un arreglo. Cuando hace falta entregar las canciones, se entrega una copia:
/** Una copia de las canciones: cambiarla no cambia la lista. */
public ArrayList<Song> songs() {
    return new ArrayList<>(songs);
}

new ArrayList<>(songs) crea una lista nueva con las mismas referencias. Como Song es inmutable, compartir las canciones no es peligroso; lo que no se comparte es la lista.

contains compara con equals

PlaylistDemo.java:

Playlist trip = new Playlist("Viaje");
trip.add(new Song("Lima gris", "Grupo Garúa", 222));
trip.add(new Song("Cumbia del puente", "Los Rieleros", 187));
trip.add(new Song("Vals del Rímac", "Trío Malecón", 238));
trip.add(new Song("Intro", "Varios", 41));

IO.println(trip.name() + ": " + trip.size() + " canciones, " + trip.totalSeconds() + " s");
IO.println("la más larga: " + trip.longest().title());
Song wanted = new Song("Cumbia del puente", "Los Rieleros", 187);
IO.println("¿tiene Cumbia del puente? " + trip.contains(wanted));
IO.println("quitadas por cortas: " + trip.removeShorterThan(60) + ", quedan " + trip.size());
$ java -cp build music.PlaylistDemo
Viaje: 4 canciones, 688 s
la más larga: Vals del Rímac
¿tiene Cumbia del puente? true
quitadas por cortas: 1, quedan 3
...

La tercera línea merece atención. Preguntamos por wanted, un Song nuevo que no es ninguno de los objetos que están en la lista. Y contains responde true. La razón: contains (y también indexOf y remove(Object)) no compara con ==, sino con equals. Song es un record, y el equals que el compilador genera para un record compara los componentes: dos canciones con el mismo título, artista y duración son iguales.

Idea clave. Las búsquedas de ArrayList usan equals. Con un record eso significa “igual valor”. Con una clase que no define equals, como Thermometer o Copy, significa lo mismo que ==: “el mismo objeto”. En la semana 5 escribiremos equals para nuestras clases y veremos qué pasa cuando se hace mal.

Errores comunes al quitar elementos

removeShorterThan quita las canciones cortas. La primera versión que a uno se le ocurre es un for-each con remove adentro. RemoveInLoop.java lo intenta con una lista de nombres:

ArrayList<String> names = new ArrayList<>();
names.add("alice");
names.add("bob");
names.add("carol");
names.add("dave");
for (String n : names) {
    if (n.startsWith("b")) {
        names.remove(n);
    }
}
IO.println(names);
$ java -cp build RemoveInLoop
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1050)
	at RemoveInLoop.main(RemoveInLoop.java:11)

El for-each sobre una lista funciona con un iterador (iterator): un objeto que recuerda en qué posición va el recorrido. ArrayList cuenta cuántas veces se modificó; si el iterador ve que la lista cambió por otro camino mientras él la recorría, se detiene con ConcurrentModificationException antes que devolver un elemento equivocado. (Los iteradores se estudian en la semana 11.)

Lo más traicionero es que no siempre falla. Si en la misma lista de cuatro nombres se quitan los que empiezan con “c”, y hay dos seguidos al final:

// names = [alice, bob, carol, cindy]
for (String n : names) {
    if (n.startsWith("c")) {
        names.remove(n);
    }
}
IO.println(names);
[alice, bob, cindy]

No hay excepción, y cindy sigue ahí. Al quitar carol (la posición 2), cindy se corrió a la posición 2 y la lista quedó con tamaño 3; el iterador, que ya iba a pasar a la posición 3, concluye que terminó y nunca mira a cindy. El programa da un resultado equivocado sin avisar.

La solución con lo que sabemos: un for con índice, de atrás hacia adelante. Al quitar el elemento i, los que se corren son los de después, y esos ya se revisaron:

/** Quita las canciones que duran menos de seconds. Devuelve cuántas quitó. */
public int removeShorterThan(int seconds) {
    int removed = 0;
    // De atrás hacia adelante: al quitar una, las que faltan revisar no se mueven.
    for (int i = songs.size() - 1; i >= 0; i--) {
        if (songs.get(i).seconds() < seconds) {
            songs.remove(i);
            removed++;
        }
    }
    return removed;
}

Error común. Modificar una lista (agregar o quitar) dentro de un for-each sobre esa misma lista. A veces lanza ConcurrentModificationException y a veces se salta elementos en silencio. Para quitar, recorran con índice de atrás hacia adelante. (En la semana 12 verán removeIf, que lo hace en una línea.)

¿Arreglo o ArrayList?

Arreglo ArrayList
Tamaño fijo al crearlo crece y se achica
Tipos primitivos y referencias solo referencias (Integer, no int)
Sintaxis a[i], a.length get(i), size()
Insertar o quitar en medio a mano add(i, x), remove(i)
Buscar a mano contains, indexOf

Regla práctica: si la cantidad de elementos cambia mientras el programa corre, ArrayList. Si es fija y conocida (los 12 meses, las casillas de un tablero) o se trata de muchos números primitivos, un arreglo.

Las pruebas de Playlist

Las pruebas siguen el patrón de la semana 3: PlaylistTest.java junto a Playlist.java, en el mismo paquete; una prueba por comportamiento, con un nombre que dice qué se espera, y preparar, actuar y verificar separados por líneas en blanco. Hay dos cosas nuevas.

PlaylistTest.java empieza así:

class PlaylistTest {
    private final Song a = new Song("A", "X", 200);
    private final Song b = new Song("B", "X", 45);
    private final Song c = new Song("C", "Y", 300);
    private Playlist list;

    // Se ejecuta antes de cada prueba: cada una empieza con una lista nueva.
    @BeforeEach
    void setUp() {
        list = new Playlist("Prueba");
    }

    @Test
    void totalSecondsAddsAllSongs() {
        list.add(a);
        list.add(b);
        list.add(c);

        assertEquals(545, list.totalSeconds());
    }

    @Test
    void longestOfSeveral() {
        list.add(a);
        list.add(c);
        list.add(b);

        assertSame(c, list.longest());
    }
    // ...
}

@BeforeEach marca un método que JUnit ejecuta antes de cada prueba. Ahí va la preparación que todas comparten. Es importante saber cómo lo hace: JUnit crea un objeto nuevo de la clase de prueba para cada método @Test, llama a setUp() y después a la prueba. Así, lo que una prueba le agregue a list no lo ve la siguiente: cada prueba empieza desde cero, y el orden en que se ejecuten no importa. (Por eso mismo los campos a, b y c también se crean de nuevo en cada prueba.) Cuando la preparación es más que una línea, como en LibraryTest.java, que crea una biblioteca con dos ejemplares y un socio, @BeforeEach evita repetirla en cada prueba.

assertSame(esperado, real) verifica identidad: que real sea el mismo objeto que esperado (==). assertEquals verifica igualdad (equals). En longestOfSeveral queremos saber que longest devuelve la canción que agregamos, no una parecida. Con Song, que es un record, assertEquals también pasaría; la prueba longestKeepsTheFirstOnATie muestra cuándo importa la diferencia: dos canciones distintas con la misma duración, y queremos la primera. Y assertNull(x) verifica que x sea null, como en findCopy("B-999") de la biblioteca.

Las pruebas de una colección deben cubrir los casos de borde, no solo el caso normal: la lista vacía (emptyPlaylistHasZeroDuration, longestOfEmptyPlaylistIsRejected), el elemento repetido (sameSongCanBeAddedTwice), el empate y los elementos seguidos que hay que quitar (removeShorterThanRemovesConsecutiveSongs, la prueba que habría detectado el error del for-each).

Diseñar con clases

Hasta aquí las clases venían dadas: una tarjeta, una cuenta, una lista de reproducción. En un problema real nadie las da. Hay un enunciado, escrito por alguien que no programa, y hay que decidir qué clases escribir. No hay una única respuesta correcta, pero sí hay diseños que se dejan probar, cambiar y entender, y otros que no. Esta sección presenta tres herramientas sencillas: leer el enunciado buscando sustantivos y verbos, las tarjetas CRC y los diagramas de clases.

Del enunciado a las clases

Tomemos este enunciado:

Una biblioteca de barrio presta libros a sus socios. De cada libro se conoce su ISBN, su título y su autor, y la biblioteca puede tener varios ejemplares del mismo libro, cada uno con su código de inventario. Un socio se registra con un código y su nombre, y puede pedir prestados hasta tres ejemplares a la vez. Cada préstamo vence a los catorce días; cuando el socio devuelve el ejemplar, queda disponible para otro. La biblioteca quiere saber cuántos ejemplares de un libro están disponibles y qué préstamos están vencidos.

La técnica (Eck 5.3.4) es simple:

  1. Los sustantivos son candidatos a clase: biblioteca, libro, ejemplar, socio, préstamo. También ISBN, título, autor, código, nombre, fecha.
  2. Descarten los que son solo un dato de otra cosa. Título, autor e ISBN describen a un libro: son campos de Book, no clases. Lo mismo el código y el nombre de un socio. Una fecha ya tiene clase en la biblioteca estándar (java.time.LocalDate).
  3. Distingan cosas parecidas. “Libro” y “ejemplar” no son lo mismo: Cien años de soledad es un libro; los tres volúmenes en el estante son tres ejemplares. Un socio se lleva un ejemplar, no “el libro”. Confundirlos es el error de diseño más común en este dominio.
  4. Los verbos son candidatos a método: prestar, vencer, devolver, saber cuántos hay disponibles. La pregunta es de qué clase.
  5. Los sustantivos que nombran una relación en el tiempo suelen ser clases: un préstamo une un ejemplar, un socio y una fecha. No es un campo de nadie; tiene sus propios datos y su propio comportamiento (vencer).

Quedan cinco clases: Library, Book, Copy (ejemplar), Member (socio) y Loan (préstamo).

Responsabilidades

Una responsabilidad es algo que una clase sabe o hace. Para repartir los verbos sirve una regla: la responsabilidad va donde están los datos para cumplirla.

¿Quién decide si un préstamo está vencido? Para saberlo hacen falta la fecha límite, si ya se devolvió y la fecha de hoy. Las dos primeras las tiene el préstamo. Entonces el método va en Loan:

/** Vencido: no se devolvió y la fecha límite ya pasó. Loan tiene los datos, así que decide Loan. */
public boolean isOverdue(LocalDate today) {
    return !returned && today.isAfter(due);
}

Si lo pusiéramos en Library, la biblioteca tendría que pedirle al préstamo su fecha y su estado y hacer la cuenta ella: es el “pregunta y luego decide por él” que la semana 3 desaconsejaba con Tell, Don’t Ask. Y cuando la regla cambie (por ejemplo, un día de gracia), habría que buscarla lejos de los datos que usa. Member responde “¿cuántos de mis préstamos están vencidos?” preguntándole a cada uno de sus préstamos, sin saber cómo se calcula:

public int overdueCount(LocalDate today) {
    int n = 0;
    for (Loan loan : activeLoans) {
        if (loan.isOverdue(today)) {
            n++;
        }
    }
    return n;
}

¿Y prestar? Hacen falta un ejemplar disponible y un socio que no haya llegado a su límite, y el resultado cambia a los dos. Ninguno de ellos ve al otro; la biblioteca, que conoce a ambos, coordina:

public Loan lend(Copy copy, Member member, LocalDate today) {
    if (!copy.isAvailable()) {
        throw new IllegalArgumentException("el ejemplar " + copy.code() + " ya está prestado");
    }
    if (!member.canBorrow()) {
        throw new IllegalArgumentException(member.name() + " ya tiene " + Member.MAX_LOANS + " préstamos");
    }
    Loan loan = new Loan(copy, member, today.plusDays(LOAN_DAYS));
    copy.markOnLoan();
    member.addLoan(loan);
    return loan;
}

Cada regla queda en la clase que tiene los datos: Copy sabe si está disponible, Member sabe si puede pedir más (canBorrow, con su invariante de a lo más MAX_LOANS préstamos activos), Library coordina. Fíjense también en el acceso de paquete de la semana 3: el constructor de Loan y los métodos markOnLoan, addLoan y compañía no son public. Solo el código del paquete library puede usarlos, así que desde afuera la única forma de crear un préstamo es lend, que verifica las reglas.

LibraryDemo.java:

$ java -cp build library.LibraryDemo
vence el 2026-09-15; disponibles: 1
¿vencido el 10 de septiembre? false
¿vencido el 20 de septiembre? true
devuelto; disponibles: 2, préstamos de Alice: 0

Idea clave. Una clase con una responsabilidad clara es fácil de probar (sus pruebas no necesitan montar medio programa) y fácil de cambiar (la regla está en un solo lugar). Pregunten siempre: ¿quién tiene los datos para hacer esto?

Tarjetas CRC

Kent Beck y Ward Cunningham propusieron en 1989 una técnica de papel y lápiz para pensar un diseño en grupo: las tarjetas CRC (class, responsibilities, collaborators). Cada clase candidata va en una tarjeta pequeña, de esas de cartulina, con tres partes:

  • arriba, el nombre de la clase;
  • a la izquierda, sus responsabilidades: qué sabe y qué hace, en frases cortas;
  • a la derecha, sus colaboradores: las clases a las que les pide algo para cumplirlas.

Las de la biblioteca:

Loan (préstamo)
Responsabilidades Colaboradores
sabe qué ejemplar, a qué socio y hasta cuándo Copy, Member
sabe si se devolvió
decide si está vencido en una fecha
al devolverse, libera el ejemplar Copy
Member (socio)
Responsabilidades Colaboradores
sabe su código y su nombre
conoce sus préstamos activos Loan
sabe si puede pedir otro (máximo 3)
cuenta sus préstamos vencidos Loan
Library (biblioteca)
Responsabilidades Colaboradores
conoce sus ejemplares y sus socios Copy, Member
busca un ejemplar por código Copy
cuenta los ejemplares disponibles de un libro Copy, Book
presta y recibe devoluciones Copy, Member, Loan

Lo valioso de las tarjetas es el ejercicio en grupo: cada persona toma una tarjeta y se “actúa” un escenario (“Alice pide Cuentos del Rímac”). Si para cumplir un paso nadie tiene los datos, falta una responsabilidad o una clase; si una tarjeta se llena y las demás quedan vacías, esa clase hace demasiado. Y las tarjetas son baratas: se rompen y se escriben de nuevo, cosa que nadie hace con código ya escrito.

Lectura. El artículo original, A Laboratory For Teaching Object-Oriented Thinking, tiene cuatro páginas y se lee en un rato.

Diagramas de clases UML

Las tarjetas sirven para pensar; para mostrar el diseño a otros se usa un diagrama de clases de UML (Unified Modeling Language), la notación estándar para dibujar programas orientados a objetos. UML es enorme; en este curso usamos una parte pequeña, como boceto para pensar y conversar, no como plano completo del programa (Martin Fowler lo llama UML as sketch).

Una clase se dibuja como una caja con tres compartimentos: el nombre, los campos y los métodos. Cada campo y cada método lleva su visibilidad: + para public, - para private y ~ para el acceso de paquete. El tipo va después del nombre, separado por dos puntos (al revés que en Java).

Entre las clases se dibujan relaciones:

  • Asociación: una línea, con flecha si solo un lado conoce al otro. “Un Copy conoce su Book.” En Java es un campo de un tipo a otro.
  • Multiplicidad: en cada punta, cuántos objetos participan: 1, 0..1, 0..* (cualquier cantidad, también escrito *), 0..3. “Un ejemplar es de exactamente 1 libro; un libro puede tener 0..* ejemplares.”
  • Agregación: un rombo vacío del lado del todo. “La biblioteca tiene socios”, pero un socio existe por su cuenta (podría ser socio de otra biblioteca).
  • Composición: un rombo lleno. Una relación más fuerte: la parte pertenece a un solo todo y no tiene sentido sin él. Los ejemplares de nuestra biblioteca son su inventario.
  • Herencia: una flecha con punta triangular vacía. Llega la semana 5.
A la izquierda, la caja UML de la clase Playlist con tres compartimentos: el nombre; los campos - name: String y - songs: ArrayList<Song>; y los métodos + add(song: Song): void, + totalSeconds(): int y + longest(): Song, con flechas que señalan qué es la visibilidad, el nombre y el tipo. A la derecha, cuatro relaciones: asociación con flecha y multiplicidades, agregación con rombo vacío, composición con rombo lleno, y herencia con triángulo vacío marcada para la semana 5A la izquierda, la caja UML de la clase Playlist con tres compartimentos: el nombre; los campos - name: String y - songs: ArrayList<Song>; y los métodos + add(song: Song): void, + totalSeconds(): int y + longest(): Song, con flechas que señalan qué es la visibilidad, el nombre y el tipo. A la derecha, cuatro relaciones: asociación con flecha y multiplicidades, agregación con rombo vacío, composición con rombo lleno, y herencia con triángulo vacío marcada para la semana 5
La notación UML que usamos: una clase con sus tres compartimentos y las relaciones entre clases. La diferencia entre agregación y composición es una decisión de diseño, no algo que Java distinga.

En Java. En el código, asociación, agregación y composición se ven igual: un campo (o un ArrayList de campos) con una referencia a otro objeto. La diferencia es de diseño: quién crea las partes, quién más tiene referencias a ellas y qué pasa con ellas cuando el todo deja de usarse. Por eso es razonable que dos personas dibujen distinto la misma relación; lo importante es que el diagrama diga lo que el equipo quiso decir.

Mermaid: diagramas como texto

Dibujar cajas a mano es lento y difícil de mantener. Mermaid describe el diagrama como texto, y muchas herramientas lo dibujan solas: GitHub y GitLab lo muestran dentro de cualquier archivo Markdown, y el editor en línea https://mermaid.live lo dibuja mientras se escribe. Como es texto, se versiona junto con el código. El diagrama de la biblioteca:

classDiagram
    class Book {
        +String isbn
        +String title
        +String author
    }
    class Copy {
        -String code
        -boolean onLoan
        +isAvailable() boolean
    }
    class Member {
        -String id
        -String name
        +canBorrow() boolean
        +overdueCount(LocalDate today) int
    }
    class Loan {
        -LocalDate due
        -boolean returned
        +isOverdue(LocalDate today) boolean
    }
    class Library {
        +findCopy(String code) Copy
        +availableCopies(Book book) int
        +lend(Copy copy, Member member, LocalDate today) Loan
        +giveBack(Loan loan) void
    }
    Library "1" *-- "0..*" Copy : inventario
    Library "1" o-- "0..*" Member : socios
    Copy "0..*" --> "1" Book : es de
    Loan "0..*" --> "1" Copy
    Member "1" --> "0..3" Loan : activos

Lo esencial de la sintaxis (documentación): cada class lista sus miembros con + o - adelante (Mermaid pone el tipo después del nombre del método, sin dos puntos); *-- es composición, o-- agregación, --> asociación con dirección, y las multiplicidades van entre comillas en cada punta. En este curso, el sitio muestra el texto; para verlo dibujado, péguenlo en https://mermaid.live. Dibujado, se ve así:

Diagrama de clases de la biblioteca. Library tiene composición con Copy (1 a 0.., inventario) y agregación con Member (1 a 0.., socios). Copy apunta a Book (0..* a 1). Loan apunta a Copy (0..* a 1) y a Member, y Member apunta a sus préstamos activos (1 a 0..3). Cada caja lista sus campos privados y métodos públicos principalesDiagrama de clases de la biblioteca. Library tiene composición con Copy (1 a 0.., inventario) y agregación con Member (1 a 0.., socios). Copy apunta a Book (0..* a 1). Loan apunta a Copy (0..* a 1) y a Member, y Member apunta a sus préstamos activos (1 a 0..3). Cada caja lista sus campos privados y métodos públicos principales
El diseño de la biblioteca. La multiplicidad 0..3 entre socio y préstamo es el invariante de Member; isOverdue está en Loan, que tiene los datos.

No hace falta poner en el diagrama cada campo y cada método: se pone lo que ayuda a entender el diseño. Los getters, por ejemplo, casi nunca se dibujan.

Dos errores de diseño opuestos

Error común. La god class: una sola clase que lo hace todo, LibrarySystem, con todos los datos en arreglos paralelos (String[] titles, boolean[] onLoan, LocalDate[] dueDates…) y cincuenta métodos. Es el estilo C de la semana 1 con la palabra class encima. Cualquier cambio toca esa clase, nadie entiende sus invariantes y probar una regla obliga a montar todo el sistema.

Error común. El extremo contrario: clases que son solo datos, con un getter y un setter por campo, mientras toda la lógica vive en main o en una clase “gestora”. Loan tendría getDue() y isReturned(), y el cálculo de vencido aparecería copiado en cada lugar que lo necesita. A esas clases se les llama anémicas: tienen la forma de objetos pero no el comportamiento. El síntoma: un método que solo usa datos de otra clase, pidiéndoselos con getters. Ese método quiere vivir en la otra clase.

Un buen diseño está entre los dos: varias clases pequeñas, cada una con sus datos y las operaciones sobre esos datos.

El código de la semana y make test

El Makefile es el de la semana 3: busca las fuentes en todas las subcarpetas, porque el código está en los paquetes music y library, y ejecuta las demostraciones por su nombre completo (music.PlaylistDemo). ArraysDemo y RemoveInLoop terminan con una excepción a propósito, y el Makefile lo verifica:

$ make test
...
== music.PlaylistDemo
Viaje: 4 canciones, 688 s
...
== music.ArraysDemo (termina con NullPointerException a propósito)
...
== RemoveInLoop (termina con ConcurrentModificationException a propósito)
Exception in thread "main" java.util.ConcurrentModificationException
...
├─ JUnit Jupiter ✔
│  ├─ ArrayPlaylistTest ✔
│  ├─ PlaylistTest ✔
│  ├─ SongTest ✔
│  └─ LibraryTest ✔
...
[        21 tests successful      ]
[         0 tests failed          ]

Laboratorio: modelar un dominio con pruebas

En parejas. El dominio es distinto del proyecto, a propósito: primero se practica el diseño en un problema pequeño y después se aplica a Bodega.

Gimnasio. Un gimnasio de barrio ofrece clases grupales (spinning, yoga, funcional). Cada clase tiene un nombre, un día de la semana, una hora de inicio y un cupo máximo de personas. Los socios se registran con un código y su nombre, y se inscriben en las clases que quieran, hasta cuatro por semana. Un socio no puede inscribirse dos veces en la misma clase, y nadie puede inscribirse en una clase llena. Un socio puede cancelar una inscripción, y entonces se libera su lugar. El gimnasio quiere saber quiénes están inscritos en una clase, cuántos lugares le quedan y en qué clases está un socio.

  1. Subrayen sustantivos y verbos. Decidan qué sustantivos son clases y cuáles son campos. ¿Hace falta una clase para la inscripción, o basta con listas? Justifiquen.
  2. Hagan una tarjeta CRC por clase y actúen dos escenarios: un socio se inscribe en una clase con un lugar libre, y otro intenta inscribirse cuando ya está llena.
  3. Dibujen el diagrama de clases en Mermaid, con visibilidades y multiplicidades, y péguenlo en el README.md de su laboratorio.
  4. Implementen dos o tres de esas clases en un paquete, con los ArrayList encapsulados y los invariantes del enunciado protegidos por la clase que tiene los datos.
  5. Escriban las pruebas JUnit antes o junto con cada método: el caso normal, la clase vacía, la clase llena, la inscripción repetida, el límite de cuatro y la cancelación. Usen @BeforeEach para la preparación común.
  6. Intercambien su código con otra pareja y revísenlo: ¿alguna clase devuelve su lista interna? ¿Algún método usa solo datos de otra clase? ¿Algún invariante se puede romper desde afuera?

Resumen

  • Un arreglo de Java es un objeto: conoce su length, verifica sus índices, empieza inicializado y no cambia de tamaño.
  • Un arreglo de objetos guarda referencias: new Song[3] crea tres null. Asignar o clone() copia referencias, no objetos.
  • Un arreglo parcialmente lleno lleva un contador; un arreglo dinámico además se reemplaza por uno del doble cuando se llena.
  • ArrayList<Song> (“lista de Song”) es ese arreglo dinámico ya escrito: add, get, set, remove, size, contains, indexOf.
  • Solo guarda referencias: los primitivos van en clases wrapper (Integer, Double…) con autoboxing. Cuidado con remove(1) en un ArrayList<Integer>.
  • contains, indexOf y remove(Object) comparan con equals: por valor en un record, por identidad en una clase que no lo define.
  • No se modifica una lista dentro de un for-each sobre ella: o lanza ConcurrentModificationException o se salta elementos.
  • Una clase que guarda una colección la encapsula: el ArrayList es privado y, si hay que entregarlo, se entrega una copia.
  • En las pruebas, @BeforeEach prepara cada prueba desde cero; assertSame verifica identidad y assertNull, que algo sea null.
  • Para diseñar: sustantivos como candidatos a clase, verbos como candidatos a método; la responsabilidad va donde están los datos. Las tarjetas CRC ayudan a pensar en grupo; el diagrama de clases UML (en Mermaid) a comunicar el diseño.
  • Ni god class ni clases anémicas: varias clases pequeñas con sus datos y su comportamiento.

Ejercicios

  1. Sin ejecutarlo, digan qué imprime cada línea y dibujen el estado de la memoria al final. Después compruébenlo en jshell:

    Song s = new Song("A", "X", 100);
    Song[] a = {s, null, s};
    Song[] b = a;
    Song[] c = a.clone();
    b[1] = new Song("B", "X", 200);
    IO.println(a[1] == null);
    IO.println(c[1] == null);
    IO.println(a[0] == c[2]);
  2. Agreguen a ArrayPlaylist un método remove(int index) que quite la canción de esa posición y corra las siguientes una casilla hacia la izquierda. ¿Qué tiene que pasar con la última casilla que quedó en uso? ¿Por qué conviene ponerla en null? Escriban sus pruebas en ArrayPlaylistTest.

  3. ArrayPlaylist empieza con capacidad 2 y duplica. Cuenten cuántas referencias se copian en total, sumando todas las llamadas a grow, al agregar 1, 2, 4, 8, 16 y 1000 canciones. ¿Qué pasaría con el total si en lugar de duplicar sumara una casilla cada vez?

  4. Escriban un método static int countAbove(ArrayList<Integer> values, int limit) que cuente cuántos valores superan limit, una vez con for clásico y otra con for-each. Pruébenlo con una lista que tenga null adentro. ¿Qué pasa y por qué? (Pista: unboxing.)

  5. En un ArrayList<Integer> con [3, 0, 1, 2], predigan el resultado de list.remove(0), list.remove(Integer.valueOf(0)) y list.indexOf(2) aplicados uno tras otro. Compruébenlo en jshell.

  6. Escriban dos versiones de un método que quite de un ArrayList<String> todos los nombres que empiezan con una letra dada: una con for-each (la incorrecta) y otra recorriendo de atrás hacia adelante. Encuentren una lista para la que la versión incorrecta no lance excepción pero dé un resultado equivocado, y escriban una prueba JUnit que la versión correcta pase y la incorrecta no.

  7. Agreguen a Playlist un método moveUp(int index) que intercambie una canción con la anterior, y otro shortest(). Escriban primero las pruebas, incluidos los bordes (la primera canción, un índice fuera de rango, la lista vacía).

  8. Supongan que Playlist tuviera public ArrayList<Song> getSongs() { return songs; }. Escriban una prueba JUnit que rompa el invariante “ninguna canción es null” sin llamar a add, y muestren que con el método songs() de la versión real la misma prueba ya no lo consigue.

  9. Song es un record. Escriban en jshell una clase SongClass con los mismos tres campos, constructor y getters, pero sin equals. Repitan la pregunta contains de PlaylistDemo con un ArrayList<SongClass>. ¿Qué responde? ¿Y indexOf? Expliquen la diferencia. (En la semana 5 le escribirán su equals.)

  10. En la biblioteca, la regla cambia: un socio con algún préstamo vencido no puede pedir otro. ¿En qué clase va el cambio? Hagan el cambio, con su prueba. ¿Cuántas clases tuvieron que tocar?

  11. Hagan las tarjetas CRC y el diagrama de clases en Mermaid de este enunciado: una cochera cobra por hora; tiene espacios numerados, algunos para motos y otros para autos; cuando entra un vehículo se le asigna un espacio libre de su tipo y se anota la hora; al salir, se cobra según las horas y se libera el espacio. ¿Qué multiplicidades tienen sus asociaciones? ¿Dónde pondrían el cálculo del cobro y por qué?

  12. Lean la sección 5.4.1 de Eck, donde diseña las clases de un juego de cartas a partir de un enunciado. Comparen su método con el de este capítulo: ¿qué sustantivos descarta y por qué? Dibujen su diseño como diagrama de clases.