Apuntes · Capítulo 3Objetos y clases

Encapsulamiento, static y records

Encapsulamiento, static y records

La semana pasada vimos qué hay detrás de una variable de tipo clase: una referencia a un objeto en el heap. También vimos lo fácil que es dejar un objeto en un estado sin sentido: new Thermometer(null, 18.0) compila, corre y termina en un NullPointerException dos líneas después, lejos del lugar donde estuvo el error de verdad. Y en la semana 1 dejamos una pregunta abierta: con los campos ya private, ¿basta eso para que un objeto no se pueda romper?

Este capítulo responde esa pregunta. En la primera parte vemos el encapsulamiento (encapsulation): cómo una clase decide qué muestra y qué esconde, qué es un invariante (invariant) y cómo el constructor y los métodos lo protegen. En la segunda vemos lo que pertenece a la clase y no a cada objeto (static), cómo se organizan las clases en paquetes (packages), qué es una clase inmutable (immutable) y cuándo basta con un record. Al final, en el laboratorio, escribimos los primeros unit tests con JUnit. El patrón de pruebas de este capítulo es el que usaremos el resto del semestre.

Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9): subsección 5.1.3 Getters and Setters; 4.7.1 Preconditions and Postconditions; 8.2.2 y 8.2.3, sobre precondiciones e invariantes; 4.3.7 Throwing Exceptions (solo throw); 4.2.4 Member Variables y 4.8.3 Named Constants; la sección 4.6 (4.6.2 y 4.6.3) y la subsección 2.6.5, sobre paquetes; 5.3.1 Some Built-in Classes; y la sección 7.4 Records (7.4.1 y 7.4.2). Para JUnit, la guía de usuario y la subsección 8.4.2 Annotations. El código del capítulo está en code/week-03 y se prueba con make test dentro del contenedor del curso.

Ruta de lectura.

  • Antes de clase (obligatorio, 15–20 min): Un objeto que todavía se puede romper y Modificadores de acceso.
  • En clase: en la sesión A, el resto de Encapsulamiento, de Invariantes de clase a Error común: un getter que entrega el arreglo interno; en la sesión B, static: lo que es de la clase, Paquetes, Inmutabilidad y Records. En el laboratorio, Unit tests con JUnit y El laboratorio: pruebas para Fraction.
  • Después de clase: ¿Record o clase?, Qué probar y El patrón del curso, el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de la nota Lectura.

Encapsulamiento

Un objeto que todavía se puede romper

Empecemos por una cuenta bancaria. Una primera versión, escrita con lo que ya sabemos, se parece mucho a la tarjeta de la semana 1:

public class BankAccount {
    private long balance;           // céntimos

    public void deposit(long amount) {
        balance += amount;
    }

    public long balance() {
        return balance;
    }
}

El campo es private, así que nadie puede escribir account.balance = -500. Pero nadie lo necesita: account.deposit(-500) deja el saldo en −500 usando solo los métodos públicos. Esconder el campo fue el primer paso. El segundo es que los métodos que lo cambian no acepten cualquier cosa.

Esa es la idea del encapsulamiento: el objeto es el único responsable de su estado. Los demás le piden cosas a través de una interfaz pública pequeña (sus métodos public), y la clase decide qué pedidos acepta. Si la clase está bien escrita, no importa qué haga el resto del programa: el objeto nunca queda en un estado imposible.

Modificadores de acceso

Java tiene cuatro niveles de acceso. Esta semana usamos tres:

Modificador Quién puede usar el campo o método
private Solo el código de la misma clase
(ninguno) El código de las clases del mismo paquete (acceso de paquete)
public Cualquier clase

El cuarto, protected, tiene que ver con la herencia y lo vemos en la semana 5.

La regla práctica: los campos son private, siempre. Los métodos son public si forman parte de lo que la clase ofrece, y private si son ayudantes internos, como requirePositive más abajo. El acceso de paquete sirve para que varias clases que trabajan juntas compartan algo sin mostrárselo al resto; lo vemos en la sección de paquetes.

Idea clave. private no es una medida de seguridad contra personas malintencionadas: es una herramienta para razonar. Si balance es private, todo lo que le puede pasar al saldo está escrito en un solo archivo, y basta leer ese archivo para saber si alguna vez puede ser negativo.

Invariantes de clase

Un invariante de clase (class invariant) es una condición sobre los campos que vale siempre que alguien de afuera mira el objeto. Para la cuenta bancaria, el invariante es:

el saldo nunca es negativo (balance >= 0).

“Siempre” quiere decir dos cosas concretas:

  1. El constructor establece el invariante: cuando termina, el objeto recién creado ya lo cumple.
  2. Cada método público lo preserva: si el invariante valía al entrar, vale al salir.

Dentro de un método, a mitad de camino, el invariante se puede romper un momento; lo que importa es que se cumpla cuando el método termina. Si las dos condiciones se cumplen, ningún cliente de la clase puede ver nunca un objeto que no cumpla el invariante.

Otro ejemplo, que usamos en el laboratorio: una fracción. Hay muchas maneras de escribir un mismo número racional (1/2, 2/4, −3/−6…). Si la clase Fraction guarda siempre la forma canónica, comparar y mostrar fracciones se vuelve fácil. Su invariante es:

el denominador es positivo y la fracción está reducida (numerador y denominador no tienen factores comunes).

Un invariante es también documentación: le dice a quien lee la clase qué puede dar por cierto. Por eso conviene escribirlo en el comentario de la clase, como en BankAccount.java.

El constructor valida

Si el constructor recibe datos con los que no puede establecer el invariante, no debe crear el objeto. En Java, la manera de negarse es lanzar una excepción (throw an exception):

public Fraction(long numerator, long denominator) {
    if (denominator == 0) {
        throw new IllegalArgumentException("denominador cero");
    }
    if (denominator < 0) {          // el signo va en el numerador
        numerator = -numerator;
        denominator = -denominator;
    }
    long g = gcd(Math.abs(numerator), denominator);
    this.numerator = numerator / g;
    this.denominator = denominator / g;
}

throw detiene el constructor en ese punto y el new que lo llamó no devuelve nada: el objeto inválido nunca llega a existir. IllegalArgumentException es la excepción de la biblioteca estándar que significa “me pasaron un argumento que no acepto”. Por ahora es la única que usamos; las excepciones se estudian completas en la semana 9.

Las otras líneas del constructor no rechazan nada: normalizan. Un denominador negativo o una fracción sin reducir se pueden arreglar, así que el constructor las arregla. FractionDemo.java lo muestra, y al final intenta crear 1/0:

$ java -cp build FractionDemo
2/4  -> 1/2
1/-3 -> -1/3
suma:     1/6
producto: -1/6
half sigue siendo 1/2
Exception in thread "main" java.lang.IllegalArgumentException: denominador cero
	at Fraction.<init>(Fraction.java:12)
	at FractionDemo.main(FractionDemo.java:11)

El stack trace dice qué pasó (el mensaje que escribimos), dónde (Fraction.<init> es el constructor, línea 12) y quién lo llamó (FractionDemo.main, línea 11). El error aparece en el momento y en el lugar en que se cometió, no dos líneas después como el null de la semana 2.

Los métodos también validan sus argumentos. Los dos métodos de BankAccount que cambian el saldo comparten un ayudante private:

/** Deposita un monto positivo. */
public void deposit(long amount) {
    requirePositive(amount);
    balance += amount;
}

/**
 * Retira un monto positivo si alcanza el saldo. Devuelve si se pudo; si no
 * alcanza, el saldo no cambia.
 */
public boolean withdraw(long amount) {
    requirePositive(amount);
    if (amount > balance) {
        return false;
    }
    balance -= amount;
    return true;
}

private static void requirePositive(long amount) {
    if (amount <= 0) {
        throw new IllegalArgumentException("el monto debe ser positivo: " + amount);
    }
}

Fíjense en que withdraw trata distinto dos situaciones. Un monto negativo o cero es un error de quien llama: no tiene sentido retirar −500, y el programa que lo pide tiene un defecto, así que se lanza una excepción. Que el saldo no alcance, en cambio, es algo normal que le pasa a cualquier cuenta, y se informa con el valor de retorno, igual que pay en la tarjeta de la semana 1. En los dos casos el invariante se mantiene.

En Java. Las condiciones que un método exige al que lo llama se llaman precondiciones (preconditions): “amount es positivo”. Lo que el método promete al terminar es su postcondición (postcondition): “el saldo aumentó en amount”. El invariante es una condición que es a la vez postcondición del constructor y precondición y postcondición de cada método público. Eck lo explica en las subsecciones 4.7.1 y 8.2.2.

Animación: a la izquierda, cinco líneas de código que se resaltan una por una; a la derecha, el objeto BankAccount con su saldo y, debajo, la condición balance >= 0 con una marca verde. new BankAccount("Alice") deja el saldo en 0; deposit(10_000) lo sube a 10000; withdraw(2_500) lo baja a 7500; withdraw(9_000) devuelve false y el saldo sigue en 7500; deposit(-500) lanza IllegalArgumentException y el saldo sigue en 7500. La marca verde no desaparece en ningún pasoAnimación: a la izquierda, cinco líneas de código que se resaltan una por una; a la derecha, el objeto BankAccount con su saldo y, debajo, la condición balance >= 0 con una marca verde. new BankAccount("Alice") deja el saldo en 0; deposit(10_000) lo sube a 10000; withdraw(2_500) lo baja a 7500; withdraw(9_000) devuelve false y el saldo sigue en 7500; deposit(-500) lanza IllegalArgumentException y el saldo sigue en 7500. La marca verde no desaparece en ningún paso
El constructor establece el invariante y cada método público lo preserva. Un pedido que lo rompería se rechaza sin tocar el estado.

Getters sí, setters con cuidado

Un getter es un método que devuelve el valor de un campo, como balance(). Un setter es uno que le asigna un valor, como sería setBalance(long b). Muchos tutoriales enseñan a escribir los dos para cada campo, casi de forma automática. En este curso no lo hacemos, y conviene entender por qué:

public void setBalance(long b) {     // no lo hagan
    balance = b;
}

Con este método, account.setBalance(-500) hace exactamente lo mismo que account.balance = -500. El campo es private en el papel, pero cualquiera lo cambia como quiere: un setter por campo rompe el encapsulamiento igual que un campo público. Se podría validar dentro del setter, pero sigue faltando lo importante: el setter no dice por qué cambia el saldo.

Comparen con deposit y withdraw. Son métodos con intención: dicen qué le pasa a la cuenta en el lenguaje del problema, y cada uno sabe qué reglas aplicar. Un retiro mira el saldo; un depósito no. Un setBalance no puede saber cuál de los dos casos es.

Idea clave. Diseñen los métodos a partir de lo que el objeto hace, no de los campos que tiene. En lugar de pedirle al objeto sus datos para decidir afuera (if (a.balance() >= x) a.setBalance(a.balance() - x)), díganle qué hacer (a.withdraw(x)) y dejen que decida él. Martin Fowler lo llama Tell Don’t Ask.

Los getters son menos peligrosos: leer un campo no lo cambia. Con una excepción importante.

Error común: un getter que entrega el arreglo interno

LeakyScores.java guarda las notas de una evaluación, en escala vigesimal. Su invariante es que cada nota está entre 0 y 20, y el constructor lo verifica:

public class LeakyScores {
    private final int[] values;

    public LeakyScores(int[] values) {
        for (int v : values) {
            if (v < 0 || v > 20) {
                throw new IllegalArgumentException("nota fuera de 0..20: " + v);
            }
        }
        this.values = values;              // guarda la referencia de quien llamó
    }

    public int[] values() {
        return values;                     // entrega la referencia interna
    }
    // ... highest() ...
}

El campo es private. Pero un arreglo es un objeto, y la variable guarda una referencia: el alias de la semana 2. El constructor guarda la misma referencia que le pasaron, y el getter entrega esa misma referencia. CopyDemo.java lo aprovecha por los dos lados:

int[] input = {14, 17, 11};

LeakyScores leaky = new LeakyScores(input);
leaky.values()[0] = 99;            // nadie llamó a un método que cambie notas
input[1] = -5;
$ java -cp build grades.CopyDemo
LeakyScores: máxima 99, primera 99, segunda -5
Scores:      máxima 17, primera 14, segunda 17

Las notas quedaron en 99 y −5, y la clase nunca se enteró. La solución es una copia defensiva (defensive copy): al recibir el arreglo, la clase guarda una copia propia; al entregarlo, entrega otra copia. Es lo que hace Scores.java, la segunda línea de la salida:

public Scores(int[] values) {
    // ... validar ...
    this.values = values.clone();      // copia defensiva al entrar
}

public int[] values() {
    return values.clone();             // copia defensiva al salir
}
Animación en dos filas. Arriba, LeakyScores: la variable input del cliente y el campo values del objeto apuntan al mismo arreglo {14, 17, 11}; el cliente escribe 99 y −5 y el arreglo del objeto cambia. Abajo, Scores: el objeto guarda su propia copia del arreglo y values() devuelve otra copia; el cliente escribe 99 en la copia y el arreglo del objeto sigue en {14, 17, 11}Animación en dos filas. Arriba, LeakyScores: la variable input del cliente y el campo values del objeto apuntan al mismo arreglo {14, 17, 11}; el cliente escribe 99 y −5 y el arreglo del objeto cambia. Abajo, Scores: el objeto guarda su propia copia del arreglo y values() devuelve otra copia; el cliente escribe 99 en la copia y el arreglo del objeto sigue en {14, 17, 11}
Sin copias, el cliente y el objeto comparten el arreglo y el invariante tiene fugas. Con copias al entrar y al salir, cada uno tiene el suyo.

Fíjense en el final de private final int[] values. No evitó nada: final impide que el campo apunte a otro arreglo, pero no impide cambiar los elementos del arreglo al que apunta. Una referencia final no hace inmutable al objeto.

Error común. Pensar que private protege también a los objetos a los que apuntan los campos. Si un campo es una referencia a un objeto mutable (un arreglo, y en la semana 4 un ArrayList), devolverlo o guardarlo sin copiar abre una puerta trasera. Con String no pasa: un String no se puede modificar, así que compartirlo es seguro.

static: lo que es de la clase

Campos y métodos de la clase

Cada cuenta tiene su saldo; eso es un campo de instancia, uno por objeto. Pero a veces queremos un dato que no es de ninguna cuenta en particular, sino de todas: cuántas cuentas se abrieron, por ejemplo, para numerarlas. Ese dato se declara static:

public class BankAccount {
    private static int accountsOpened = 0;      // de la clase: uno solo para todas las cuentas

    private final String id;
    private final String owner;
    private long balance;                       // céntimos; siempre >= 0

    public BankAccount(String owner) {
        if (owner == null || owner.isBlank()) {
            throw new IllegalArgumentException("el titular no puede estar vacío");
        }
        accountsOpened++;
        this.id = String.format("CTA-%04d", accountsOpened);
        this.owner = owner;
        this.balance = 0;
    }

    /** Cuántas cuentas se abrieron desde que arrancó el programa. */
    public static int accountsOpened() {
        return accountsOpened;
    }
    // ...
}

Un campo static (también llamado campo de clase) existe una sola vez, sin importar cuántos objetos se creen, e incluso si no se crea ninguno. Un método static pertenece a la clase y se llama con el nombre de la clase: BankAccount.accountsOpened(), no con un objeto.

A la izquierda, la clase BankAccount con su campo static accountsOpened = 2 y sus métodos. A la derecha, dos objetos BankAccount en el heap, cada uno con su id, owner y balance propios. Los dos objetos apuntan a la misma clase, y el constructor de cada uno incrementó el mismo contadorA la izquierda, la clase BankAccount con su campo static accountsOpened = 2 y sus métodos. A la derecha, dos objetos BankAccount en el heap, cada uno con su id, owner y balance propios. Los dos objetos apuntan a la misma clase, y el constructor de cada uno incrementó el mismo contador
Los campos de instancia existen una vez por objeto; un campo static existe una sola vez, en la clase, y lo comparten todos los objetos.
$ java -cp build bank.BankDemo
CTA-0001 Alice: retiro true, saldo 7500
CTA-0002 Bob: retiro false, saldo 0
cuentas abiertas: 2
...

Ya conocen métodos static de la biblioteca: Math.max(a, b), Math.abs(x), String.format(...), IO.println(...). Son funciones que no necesitan un objeto: todo lo que usan les llega por parámetro. El gcd de Fraction es igual, y por eso es private static.

Las constantes son campos static final: uno solo para toda la clase, y que no cambia. Se escriben en mayúsculas con guiones bajos:

public static final int MAX_SCORE = 20;

Y ahora podemos explicar el main “largo” que vimos en la semana 1:

public static void main(String[] args)

Es public para que la JVM lo pueda llamar desde afuera, y static porque la JVM lo llama antes de que exista ningún objeto. String[] args son los argumentos de la línea de comandos. Desde esta semana, los programas de demostración se escriben así, en clases normales.

Por qué un método static no puede usar this

Dentro de un método de instancia, balance quiere decir this.balance: el campo del objeto que recibió el mensaje. Un método static no recibe ningún mensaje de ningún objeto, así que no hay this, y no hay de qué objeto leer balance. El compilador lo dice así:

public class StaticError {
    private long balance;

    public static void main(String[] args) {
        balance = 100;
        IO.println(balance);
    }
}
$ javac -Xlint:all StaticError.java
StaticError.java:5: error: non-static variable balance cannot be referenced from a static context
        balance = 100;
        ^
StaticError.java:6: error: non-static variable balance cannot be referenced from a static context
        IO.println(balance);
                   ^
2 errors

Al revés no hay problema: un método de instancia sí puede usar campos y métodos static, como el constructor de BankAccount usa accountsOpened.

Error común. Ante ese mensaje, la “solución” rápida es agregarle static al campo. Compila, pero cambia el significado: ahora hay un solo saldo para todas las cuentas. StaticPitfall.java lo muestra con un contador de clics que debía ser de cada objeto:

static class Tally {
    private static int count;          // error: un solo contador para todos
    void click() { count++; }
    int count() { return count; }
}
$ java -cp build StaticPitfall
door: 3, window: 3

Se hicieron dos clics en door y uno en window, y los dos dicen 3. La solución correcta es crear un objeto (new Tally()) y enviarle mensajes, no volver static lo que no lo es.

Los campos static que cambian, como accountsOpened, son estado compartido por todo el programa, y eso trae los mismos problemas que las variables globales de C: cualquier parte del programa que cree una cuenta lo cambia. Úsenlos poco. Las constantes static final y los métodos static sin estado no tienen ese problema.

Paquetes

Cuando un programa tiene decenas de clases, hace falta ordenarlas. Un paquete (package) es un grupo de clases relacionadas con un nombre común. La biblioteca estándar está organizada así: java.lang (String, Math, IO), java.util (ArrayList, Random), java.time (LocalDate)…

Desde esta semana, el código del curso va en paquetes. En code/week-03, las clases de la cuenta están en el paquete bank y las de las notas en grades. Cada archivo dice a qué paquete pertenece en su primera línea:

package bank;

public class BankAccount {
    // ...
}

Tres reglas:

  1. El paquete corresponde a una carpeta: las clases del paquete bank están en bank/. Un paquete pe.uni.bodega estaría en pe/uni/bodega/.
  2. El nombre completo de la clase incluye el paquete: bank.BankAccount. Dentro del mismo paquete basta con BankAccount; desde otro paquete hay que escribir el nombre completo o importarlo: import bank.BankAccount;. java.lang se importa siempre solo, por eso String no necesita import.
  3. javac -d build deja cada .class en la carpeta de su paquete dentro de build/, y java -cp build necesita el nombre completo de la clase.
A la izquierda, la carpeta code/week-03 con los archivos fuente: Fraction.java y StaticPitfall.java sueltos, bank/ con BankAccount.java y Money.java, grades/ con Scores.java. Al centro, javac -d build. A la derecha, build/ con la misma estructura de carpetas y archivos .class. Debajo, la línea java -cp build bank.BankDemo, donde -cp build dice dónde empezar a buscar y bank.BankDemo es el nombre completo de la claseA la izquierda, la carpeta code/week-03 con los archivos fuente: Fraction.java y StaticPitfall.java sueltos, bank/ con BankAccount.java y Money.java, grades/ con Scores.java. Al centro, javac -d build. A la derecha, build/ con la misma estructura de carpetas y archivos .class. Debajo, la línea java -cp build bank.BankDemo, donde -cp build dice dónde empezar a buscar y bank.BankDemo es el nombre completo de la clase
Cada paquete es una carpeta, en las fuentes y en build/. A java se le da el nombre completo de la clase, con su paquete.
$ java -cp build bank.BankDemo
CTA-0001 Alice: retiro true, saldo 7500
...
$ java -cp build BankDemo
Error: Could not find or load main class BankDemo
Caused by: java.lang.ClassNotFoundException: BankDemo

Sin el paquete, la JVM busca build/BankDemo.class, que no existe.

Las clases sin package, como Fraction y StaticPitfall, están en el paquete sin nombre (unnamed package). Sirve para programas chicos y ejemplos, pero una clase de un paquete con nombre no puede importar una clase del paquete sin nombre; por eso, en un proyecto, todo va en paquetes.

En Java. El Makefile de esta semana busca las fuentes en todas las subcarpetas (find . -name '*.java') y ejecuta las demostraciones por su nombre completo (bank.BankDemo). Es el que usaremos de aquí en adelante.

Ahora se entiende el acceso de paquete: un miembro sin modificador es visible para todas las clases de su paquete y para ninguna de afuera. Sirve para que las clases que colaboran dentro de un paquete se ayuden entre sí sin agrandar su interfaz pública. Lo usaremos la próxima semana.

Inmutabilidad

Una clase es inmutable si sus objetos no cambian nunca después de construidos. String es el ejemplo de siempre: "lima".toUpperCase() no cambia "lima", devuelve otro String. Escribir una clase inmutable tiene una receta:

  • Todos los campos son private final. El compilador impide asignarlos fuera del constructor.
  • No hay métodos que modifiquen el estado.
  • Las operaciones que “cambian” el valor devuelven un objeto nuevo.
  • Si un campo apunta a un objeto mutable (un arreglo), se copia al entrar y al salir, como en Scores.

Fraction sigue la receta. plus y times no tocan this: construyen una fracción nueva, y el constructor de esa fracción nueva la reduce, así que el invariante se cumple sin esfuerzo adicional:

/** Devuelve this + other como una fracción nueva; this no cambia. */
public Fraction plus(Fraction other) {
    return new Fraction(numerator * other.denominator + other.numerator * denominator,
            denominator * other.denominator);
}

En la salida de FractionDemo, half sigue siendo 1/2 después de sumar y multiplicar. Y si alguien intenta escribir un método que modifique un campo, el final lo detiene al compilar:

$ javac -Xlint:all FinalError.java
FinalError.java:9: error: cannot assign a value to final variable numerator
        numerator = -numerator;
        ^
1 error

¿Qué se gana? Lo más importante: un objeto inmutable se puede compartir sin miedo al alias. Si dos partes del programa tienen una referencia a la misma fracción, ninguna puede cambiársela a la otra. Quien recibe un objeto inmutable lo puede guardar sin copiarlo. La clase inmutable, en cambio, sí copia los componentes mutables que recibe o expone (un arreglo, una lista): sin esas copias dejaría de ser inmutable. Además, un objeto inmutable se puede usar como clave en un mapa (semana 11) y es más fácil de razonar: lo que vale al crearlo vale para siempre. El precio es crear objetos nuevos en cada operación, que en Java casi siempre es barato.

Idea clave. Hagan inmutables los valores (una fracción, una cantidad de dinero, una fecha) y dejen mutables las entidades que de verdad cambian con el tiempo (una cuenta, un inventario). java.time.LocalDate, String y las clases wrapper como Integer son inmutables por esa razón.

Records

Escribir una clase inmutable a mano tiene bastante ceremonia: campos private final, un constructor que los asigna, un getter por campo. Para los casos en que la clase es solo un dato con valor, Java tiene los records (JEP 395, desde Java 16). Money.java es una cantidad de dinero en una moneda:

public record Money(long cents, String currency) {

    /** Constructor compacto: valida antes de asignar los campos. */
    public Money {
        if (cents < 0) {
            throw new IllegalArgumentException("monto negativo: " + cents);
        }
        if (currency == null || currency.length() != 3) {
            throw new IllegalArgumentException("código de moneda inválido: " + currency);
        }
    }

    /** Atajo para soles: Money.soles(1050) es S/ 10.50. */
    public static Money soles(long cents) {
        return new Money(cents, "PEN");
    }

    /** Devuelve una suma nueva; this no cambia. */
    public Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("monedas distintas: " + currency + " y " + other.currency);
        }
        return new Money(cents + other.cents, currency);
    }
}

La línea record Money(long cents, String currency) declara los componentes del record. A partir de ella, el compilador genera:

  • un campo private final por componente;
  • el constructor canónico, Money(long cents, String currency), que asigna los campos;
  • un método de acceso por componente, con el mismo nombre: cents() y currency() (no getCents());
  • equals, hashCode y toString, que usan todos los componentes.

javap -p muestra lo que hay en el .class:

$ javap -p -cp build bank.Money
Compiled from "Money.java"
public final class bank.Money extends java.lang.Record {
  private final long cents;
  private final java.lang.String currency;
  public bank.Money(long, java.lang.String);
  public static bank.Money soles(long);
  public bank.Money plus(bank.Money);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public long cents();
  public java.lang.String currency();
}
A la izquierda, la línea record Money(long cents, String currency). A la derecha, lo que el compilador genera a partir de ella: dos campos private final, el constructor canónico, los métodos de acceso cents() y currency(), y equals, hashCode y toString. Una nota indica que el constructor compacto se ejecuta antes de que el constructor canónico asigne los camposA la izquierda, la línea record Money(long cents, String currency). A la derecha, lo que el compilador genera a partir de ella: dos campos private final, el constructor canónico, los métodos de acceso cents() y currency(), y equals, hashCode y toString. Una nota indica que el constructor compacto se ejecuta antes de que el constructor canónico asigne los campos
Una línea de declaración y el compilador escribe el resto. Lo que agregamos nosotros (validación, métodos) va dentro de las llaves.

El constructor compacto (compact constructor) es el bloque public Money { ... }, sin lista de parámetros. Se ejecuta al principio del constructor canónico, con los parámetros a la vista, y los campos se asignan solos al final. Es el lugar natural para validar: el invariante del record (monto no negativo, código de tres letras) queda establecido igual que en una clase.

Un record puede tener métodos propios (plus) y métodos static (soles, un static factory method: un método static que crea el objeto y se lee mejor que el constructor). Lo que no puede tener son campos de instancia aparte de sus componentes, ni cambiarlos: un record siempre es inmutable.

$ java -cp build bank.BankDemo
...
Money[cents=1050, currency=PEN] + S/ 2.50 = Money[cents=1300, currency=PEN]
iguales: true

El toString generado muestra el nombre y los componentes, mucho más útil que el TransitCard@31221be2 de la semana 1. Y equals compara componentes, no referencias: total y new Money(1_300, "PEN") son dos objetos distintos y equals dice que son iguales. Comparen con los dos Thermometer de la semana 2, iguales en todo y con equals en false. El contrato que cumplen equals y hashCode, y cómo escribirlos a mano en una clase, es el tema de la semana 5.

¿Record o clase?

Un record es un dato con valor: su identidad no importa, solo sus componentes. Dos Money(1300, "PEN") son la misma cantidad de dinero aunque sean dos objetos.

Una cuenta bancaria no es eso. Tiene un saldo que cambia, un invariante que cada operación tiene que cuidar, y una identidad propia: dos cuentas con el mismo saldo no son la misma cuenta. BankAccount es una clase y no puede ser un record.

Fraction está en el medio: es un valor inmutable y podría ser un record (el constructor compacto puede reducir los parámetros antes de que se asignen). La dejamos como clase para escribir a mano lo que un record trae hecho; en la semana 5 le agregaremos equals.

Error común. Usar un record para algo que tiene que cambiar, y “cambiarlo” creando uno nuevo cada vez en todas partes. Si el problema habla de algo que cambia con el tiempo y tiene reglas, es una clase con estado. Si habla de un valor que se calcula, se compara y se pasa de un lado a otro, es un record.

Unit tests con JUnit

Hasta aquí, para ver si una clase funciona, escribimos un programa de demostración y miramos la salida. Eso tiene dos problemas: alguien tiene que mirar la salida y saber qué debía decir, y hay que volver a mirarla cada vez que el código cambia. Un unit test es un método que ejecuta un pedacito del programa y verifica él mismo el resultado. Si el resultado es el esperado, la prueba pasa en silencio; si no, falla y dice qué esperaba y qué obtuvo. Un programa con buenas pruebas se puede cambiar con confianza: si algo se rompe, una prueba lo avisa.

Usamos JUnit (versión 6), el framework de pruebas más usado en Java. El contenedor del curso lo trae en $JUNIT_JAR.

Anatomía de una prueba

BankAccountTest.java prueba la cuenta. Este es su esqueleto con dos pruebas:

package bank;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;

import org.junit.jupiter.api.Test;

class BankAccountTest {

    @Test
    void depositIncreasesBalance() {
        // Preparar
        BankAccount account = new BankAccount("Alice");

        // Actuar
        account.deposit(10_000);

        // Verificar
        assertEquals(10_000, account.balance());
    }

    @Test
    void withdrawMoreThanBalanceChangesNothing() {
        BankAccount account = new BankAccount("Alice");
        account.deposit(1_000);

        boolean ok = account.withdraw(2_500);

        assertFalse(ok);
        assertEquals(1_000, account.balance());
    }
}

Por partes:

  • La clase de prueba se llama como la clase probada más Test, está en el mismo paquete (package bank;) y en la misma carpeta. No necesita ser public. Al estar en el mismo paquete, puede usar también lo que tiene acceso de paquete.
  • @Test es una anotación (annotation): una marca que no cambia lo que hace el método, pero que JUnit busca para saber qué métodos son pruebas. Los métodos de prueba no reciben parámetros y no devuelven nada (void).
  • El nombre de la prueba dice qué se espera, como una frase: depositIncreasesBalance, withdrawMoreThanBalanceChangesNothing. Cuando una prueba falla, el nombre es lo primero que se lee, y debería bastar para saber qué se rompió.
  • Tres partes, separadas por una línea en blanco: preparar (crear los objetos y llevarlos al estado de partida), actuar (enviar el mensaje que se está probando, una sola vez) y verificar (comparar el resultado con lo esperado). En inglés se conoce como arrange, act, assert. En la primera prueba están marcadas con comentarios; en las demás basta con las líneas en blanco.
  • Las assertions son métodos static de la clase Assertions, importados con import static para escribir assertEquals en lugar de Assertions.assertEquals. Si la condición no se cumple, la prueba falla en esa línea.

Las assertions que usamos esta semana:

Assertion Pasa si
assertEquals(esperado, real) los dos valores son iguales (con equals si son objetos)
assertNotEquals(no esperado, real) son distintos
assertTrue(condición) la condición es true
assertFalse(condición) la condición es false
assertThrows(Excepción.class, () -> código) ese código lanza esa excepción

Error común. Invertir los argumentos de assertEquals. El primero es el valor esperado y el segundo el real. Si se invierten, la prueba pasa y falla en los mismos casos, pero el mensaje de error dice lo contrario de lo que pasó y confunde a quien lo lee.

Idea clave. Una prueba, un comportamiento. Si una prueba verifica diez cosas y falla, hay que leerla entera para saber cuál. Diez pruebas pequeñas con buenos nombres dicen, sin abrir ningún archivo, exactamente qué funciona y qué no. Cada prueba crea sus propios objetos: ninguna depende de lo que hizo otra ni del orden en que se ejecutan.

Correr las pruebas

make test compila todo y ejecuta las pruebas con el Console Launcher de JUnit, que busca en build/ todas las clases con métodos @Test:

$ make test
...
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│  ├─ BankAccountTest ✔
│  │  ├─ depositIncreasesBalance() ✔
│  │  ├─ newAccountStartsAtZero() ✔
│  │  ├─ withdrawWithEnoughBalance() ✔
│  │  ├─ withdrawMoreThanBalanceChangesNothing() ✔
│  │  ├─ rejectsNegativeDeposit() ✔
│  │  ├─ rejectsZeroWithdrawal() ✔
│  │  ├─ rejectsBlankOwner() ✔
│  │  └─ eachAccountGetsADifferentId() ✔
│  ├─ ScoresTest ✔
│  │  ├─ ...
│  └─ MoneyTest ✔
│     ├─ ...
└─ JUnit Vintage ✔
...
[        18 tests successful      ]
[         0 tests failed          ]

Fíjense en que las pruebas no corren en el orden en que están escritas. No importa, porque ninguna depende de otra. Jupiter es el nombre del motor de JUnit que ejecuta nuestras pruebas; Vintage y Suite son otros dos motores que vienen en el mismo jar y aquí no encuentran nada.

Cuando una prueba falla

Para ver una falla, cambiemos a propósito el valor esperado en depositIncreasesBalance por 1_000:

│  └─ BankAccountTest ✔
│     ├─ depositIncreasesBalance() ✘ expected: <1000> but was: <10000>
│     ├─ newAccountStartsAtZero() ✔
...
Failures (1):
  JUnit Jupiter:BankAccountTest:depositIncreasesBalance()
    MethodSource [className = 'bank.BankAccountTest', methodName = 'depositIncreasesBalance', methodParameterTypes = '']
    => org.opentest4j.AssertionFailedError: expected: <1000> but was: <10000>
       org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:673)
       bank.BankAccountTest.depositIncreasesBalance(BankAccountTest.java:29)
...
[         7 tests successful      ]
[         1 tests failed          ]

La falla se lee de arriba abajo: qué prueba (depositIncreasesBalance), qué assertion (assertEquals), qué esperaba (1000), qué obtuvo (10000) y en qué línea del archivo de prueba (29). Aquí el error estaba en la prueba; casi siempre está en el código, y la prueba dice dónde empezar a buscar.

Probar que se rechaza lo inválido

Las pruebas de los casos normales no bastan: también hay que probar que la clase rechaza lo que debe rechazar, porque de eso depende el invariante. Para eso está assertThrows:

@Test
void rejectsNegativeDeposit() {
    BankAccount account = new BankAccount("Alice");

    assertThrows(IllegalArgumentException.class, () -> account.deposit(-500));
    assertEquals(0, account.balance());
}

assertThrows recibe la clase de la excepción esperada y el código que debería lanzarla. Ese código va después de () ->. La flecha arma un pedazo de código que assertThrows puede ejecutar cuando le toca, en lugar de ejecutarlo antes de llamarlo; se llama lambda y la estudiamos en la semana 12. Por ahora úsenla como una receta: () -> seguido de la expresión que debe fallar. La segunda assertion comprueba que, además de lanzar la excepción, el saldo no cambió.

Romper el invariante a propósito

¿Las pruebas de verdad protegen el invariante? Hagamos la prueba al revés: quitemos la validación de deposit, como si alguien la hubiera borrado por apuro, y corramos make test:

│     ├─ rejectsNegativeDeposit() ✘ Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
...
[         7 tests successful      ]
[         1 tests failed          ]

Exactamente una prueba falla, y su nombre dice qué regla se perdió. Este es el ejercicio del laboratorio: romper una clase y ver qué prueba lo detecta. Si ninguna lo detecta, falta una prueba. Ojo: lo que comprobamos así son las pruebas; el código de verdad se deja como estaba.

Qué probar

Para cada clase, piensen en tres grupos de casos:

  • Los normales: el uso esperado. Un depósito, un retiro que alcanza.
  • Los bordes: el saldo exacto (retirar todo), la lista vacía, el valor más chico y el más grande permitido (las notas 0 y 20), un empate.
  • Los inválidos: lo que el invariante prohíbe y la clase debe rechazar. Un monto negativo o cero, un titular en blanco, una nota de 21.

Las pruebas de MoneyTest.java y ScoresTest.java siguen el mismo patrón. ScoresTest incluye pruebas que fallarían con LeakyScores, como changingTheReturnedArrayDoesNotChangeTheScores: una prueba también puede verificar que una copia defensiva está en su lugar.

En Java. Un static que cambia, como accountsOpened, se comparte entre todas las pruebas de la misma ejecución, y las pruebas corren en un orden que no controlamos. Por eso eachAccountGetsADifferentId no espera un código concreto como "CTA-0001": solo verifica que dos cuentas tengan códigos distintos. Es otra razón para evitar el estado static mutable.

El patrón del curso

De aquí en adelante, cada clase del curso y del proyecto viene con sus pruebas, escritas así:

  1. XTest.java junto a X.java, en el mismo paquete, con clase sin public.
  2. Imports estáticos de las assertions que se usan.
  3. Un método @Test por comportamiento, con un nombre que dice qué se espera.
  4. Preparar, actuar y verificar, separados por líneas en blanco.
  5. assertEquals(esperado, real).
  6. Casos normales, bordes e inválidos; los inválidos con assertThrows(IllegalArgumentException.class, () -> ...).

El laboratorio: pruebas para Fraction

En el laboratorio escriben FractionTest.java para la clase Fraction, que todavía no tiene pruebas, con el patrón de arriba. Sugerencias para empezar: que 2/4 se reduzca al crearse, que el signo pase al numerador, que se rechace el denominador cero, que plus y times den el resultado reducido y que no cambien la fracción original. Como Fraction todavía no tiene equals (eso es de la semana 5), comparen el numerador y el denominador por separado:

assertEquals(1, f.numerator());
assertEquals(2, f.denominator());

Después rompan el invariante a propósito (por ejemplo, quiten la reducción) y verifiquen que alguna de sus pruebas lo detecta.

El último paso es pasar Fraction a un paquete numbers: moverla a la carpeta numbers/ y agregarle package numbers;. Al compilar, FractionDemo, que se quedó en el paquete sin nombre, deja de encontrarla:

./FractionDemo.java:4: error: cannot find symbol
        Fraction half = new Fraction(2, 4);
        ^
  symbol:   class Fraction
  location: class FractionDemo

Resolverlo es parte del ejercicio: ¿qué le falta a FractionDemo? ¿Dónde tiene que estar la prueba? ¿Cómo cambia la línea del Makefile que ejecuta la demostración?

Resumen

  • Encapsulamiento: la clase es la única responsable de su estado. Los campos son private; los demás usan una interfaz pública pequeña.
  • Un invariante de clase es una condición sobre los campos que vale siempre: el constructor la establece y cada método público la preserva.
  • El constructor y los métodos validan sus argumentos y rechazan lo inválido con throw new IllegalArgumentException(...). Un objeto inválido nunca llega a existir.
  • Métodos con intención (deposit, withdraw) en lugar de un setter por campo. Tell Don’t Ask.
  • Un campo que apunta a un objeto mutable necesita copias defensivas al entrar y al salir; final en la referencia no basta.
  • static: campos y métodos de la clase, no de cada objeto. Constantes static final. Un método static no tiene this. No vuelvan static un campo para que compile.
  • Paquetes: package, import, una carpeta por paquete, javac -d build y java -cp build paquete.Clase. El acceso sin modificador es de paquete.
  • Una clase inmutable tiene campos private final, no tiene métodos que modifiquen y devuelve objetos nuevos. Se comparte sin miedo al alias.
  • Un record declara sus componentes y el compilador genera campos, constructor, métodos de acceso, equals, hashCode y toString. Se valida en el constructor compacto. Record para valores; clase para entidades con estado.
  • Un unit test con JUnit verifica por sí mismo su resultado: @Test, un comportamiento por prueba, preparar-actuar-verificar, assertEquals, assertTrue, assertFalse y assertThrows.

Ejercicios

  1. Escriban el invariante de una clase Time que guarda una hora del día en horas y minutos. ¿Qué tiene que hacer el constructor con new Time(25, 0)? ¿Y un método plusMinutes(90) aplicado a las 23:00?

  2. La clase BankAccount tiene un campo owner sin setter. Un compañero propone agregar setOwner(String) “por si alguien se casa y cambia de apellido”. ¿Qué tendría que validar ese método? ¿Le pondrían otro nombre? ¿Por qué?

  3. Sin ejecutarlo, digan qué imprime este fragmento si se corre justo al empezar el programa y por qué. Después compruébenlo con un programa en el paquete bank:

    BankAccount a = new BankAccount("Alice");
    BankAccount b = new BankAccount("Bob");
    BankAccount c = a;
    IO.println(BankAccount.accountsOpened() + " " + c.id());
  4. Escriban una clase Counter con un método next() que devuelva 1, 2, 3… en llamadas sucesivas, una cuenta por objeto. Agreguen un método static que diga cuántos Counter se crearon. Escriban pruebas para los dos comportamientos. ¿Cuál de los dos es frágil si las pruebas corren en otro orden? ¿Cómo lo prueban sin depender del orden?

  5. Money tiene plus. Agréguenle minus(Money other). ¿Qué tiene que pasar si el resultado sería negativo? Escriban las pruebas antes que el método, véanlas fallar y después escriban el método.

  6. Este getter de una clase Route (una ruta de bus) devuelve sus paraderos:

    private final String[] stops;
    public String[] stops() { return stops; }

    Escriban una prueba que demuestre que un cliente puede cambiar los paraderos de la ruta sin llamar a ningún método de Route. Después arreglen la clase y verifiquen que la prueba pasa.

  7. ¿Cuáles de estos serían un record y cuáles una clase con estado? Justifiquen cada uno: un punto en el plano, un carrito de compras, un rango de fechas, un semáforo, un DNI, una partida de ajedrez.

  8. En code/week-03, ejecuten javap -p -cp build bank.BankAccount y comparen la salida con la de bank.Money. ¿Qué tiene uno que no tiene el otro? ¿De dónde salió cada método de Money?

  9. Escriban un record Temperature(double celsius) que rechace valores por debajo del cero absoluto (−273.15 °C), con un static factory method fromFahrenheit(double f) y un método toFahrenheit(). Escriban sus pruebas. (Para comparar double calculados, usen assertEquals(esperado, real, 1e-9), la versión con tolerancia.)

  10. Tomen la clase Thermometer de la semana 2 y hagan que el constructor rechace un lugar null o en blanco. ¿Qué pasa ahora con NullDemo? ¿En qué línea aparece el error, y por qué esa línea es mejor que la de antes?

  11. Una clase tiene un campo private static final int[] PRIMES = {2, 3, 5, 7};. ¿Es una constante de verdad? Muestren con un ejemplo qué puede pasar.

  12. Lean Tell Don’t Ask de Martin Fowler y revisen la clase Product que escribieron para P0. ¿Tiene setters? ¿Hay código fuera de la clase que le pide datos para decidir algo que el producto podría decidir solo (por ejemplo, si alcanza el stock para una venta)? Propongan los métodos con intención que los reemplazarían y el invariante que protegerían.