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
ArrayListy 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á encode/week-04y se prueba conmake testdentro 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,
ArrayListy 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 dePlaylisty 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 lanzaArrayIndexOutOfBoundsException; en C lee lo que haya en esa memoria. - Empieza inicializado:
0para números,falseparabooleanynullpara 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]esnull: 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 == queueesfalse) pero copia las referencias:copy[0]yqueue[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
nulltermina enNullPointerException, y el mensaje dice exactamente qué expresión eranull: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ón](/figures/week-04/arreglo-referencias.gif)
![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ón](/figures/week-04/arreglo-referencias.dark.gif)
Error común. Creer que
new Song[10]crea diez canciones y recorrer el arreglo llamando aqueue[i].title(). Las casillas que nadie llenó siguen ennull, 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
addcopiaría todo el arreglo. Duplicando, las copias son cada vez más raras: agregarnelementos cuesta, en total, del orden dencopias. En promedio, cadaaddes barato. sizeycapacityson cosas distintas.sizees cuántas canciones hay;capacity, cuántas caben sin crecer. Quien usa la clase solo debería necesitarsize.getverifica contracount, no contrasongs.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í.


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 (
countdice 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.
ArrayListarranca con un arreglo interno vacío y, al primeradd, 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.ArrayListtiene dos métodosremove, uno que recibe unint(la posición) y otro que recibe un objeto (el valor a buscar). Con el argumento1, que es unint, 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.finaldice que la variablesongssiempre apunta a la misma lista, no que la lista no cambie. Es una referencia constante a un objeto mutable. addrechazanull. AsítotalSecondsylongestpueden llamar as.seconds()sin preguntarse sisesnull. Un invariante de la clase (“ninguna canción esnull”) simplifica todos los demás métodos.longestno tiene respuesta para una lista vacía. Devolvernullle pasaría el problema a quien llama, que tendría que acordarse de revisarlo. LanzamosIllegalArgumentException, 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. SiPlaylisttuvierapublic ArrayList<Song> getSongs() { return songs; }, cualquiera podría hacerp.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
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
ArrayListusanequals. Con un record eso significa “igual valor”. Con una clase que no defineequals, comoThermometeroCopy, significa lo mismo que==: “el mismo objeto”. En la semana 5 escribiremosequalspara 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
ConcurrentModificationExceptiony a veces se salta elementos en silencio. Para quitar, recorran con índice de atrás hacia adelante. (En la semana 12 veránremoveIf, 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:
- Los sustantivos son candidatos a clase: biblioteca, libro, ejemplar, socio, préstamo. También ISBN, título, autor, código, nombre, fecha.
- 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). - 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.
- Los verbos son candidatos a método: prestar, vencer, devolver, saber cuántos hay disponibles. La pregunta es de qué clase.
- 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.
$ 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
Copyconoce suBook.” 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 tener0..*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.
En Java. En el código, asociación, agregación y composición se ven igual: un campo (o un
ArrayListde 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í:
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 palabraclassencima. 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
maino en una clase “gestora”.LoantendríagetDue()yisReturned(), 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.
- 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.
- 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.
- Dibujen el diagrama de clases en Mermaid, con visibilidades y
multiplicidades, y péguenlo en el
README.mdde su laboratorio. - Implementen dos o tres de esas clases en un paquete, con los
ArrayListencapsulados y los invariantes del enunciado protegidos por la clase que tiene los datos. - 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
@BeforeEachpara la preparación común. - 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 tresnull. Asignar oclone()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 deSong”) 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 conremove(1)en unArrayList<Integer>. contains,indexOfyremove(Object)comparan conequals: 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
ConcurrentModificationExceptiono se salta elementos. - Una clase que guarda una colección la encapsula: el
ArrayListes privado y, si hay que entregarlo, se entrega una copia. - En las pruebas,
@BeforeEachprepara cada prueba desde cero;assertSameverifica identidad yassertNull, que algo seanull. - 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
-
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]); -
Agreguen a
ArrayPlaylistun métodoremove(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 ennull? Escriban sus pruebas enArrayPlaylistTest. -
ArrayPlaylistempieza con capacidad 2 y duplica. Cuenten cuántas referencias se copian en total, sumando todas las llamadas agrow, 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? -
Escriban un método
static int countAbove(ArrayList<Integer> values, int limit)que cuente cuántos valores superanlimit, una vez conforclásico y otra con for-each. Pruébenlo con una lista que tenganulladentro. ¿Qué pasa y por qué? (Pista: unboxing.) -
En un
ArrayList<Integer>con[3, 0, 1, 2], predigan el resultado delist.remove(0),list.remove(Integer.valueOf(0))ylist.indexOf(2)aplicados uno tras otro. Compruébenlo enjshell. -
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. -
Agreguen a
Playlistun métodomoveUp(int index)que intercambie una canción con la anterior, y otroshortest(). Escriban primero las pruebas, incluidos los bordes (la primera canción, un índice fuera de rango, la lista vacía). -
Supongan que
Playlisttuvierapublic ArrayList<Song> getSongs() { return songs; }. Escriban una prueba JUnit que rompa el invariante “ninguna canción esnull” sin llamar aadd, y muestren que con el métodosongs()de la versión real la misma prueba ya no lo consigue. -
Songes un record. Escriban enjshelluna claseSongClasscon los mismos tres campos, constructor y getters, pero sinequals. Repitan la preguntacontainsdePlaylistDemocon unArrayList<SongClass>. ¿Qué responde? ¿YindexOf? Expliquen la diferencia. (En la semana 5 le escribirán suequals.) -
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?
-
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é?
-
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.