Semana 3 — Encapsulamiento, static y records

Objetivos

  • Proteger el estado de un objeto con encapsulamiento (encapsulation): campos private, una interfaz pública pequeña y un invariante (invariant) que se cumple siempre.
  • Distinguir lo que pertenece al objeto de lo que pertenece a la clase (static) y organizar el código en paquetes (packages).
  • Diseñar clases inmutables (immutable) y reconocer cuándo un record basta.
  • Escribir los primeros unit tests con JUnit y correrlas en el contenedor.

Apuntes de la semana: notes/week-03.md · Código: code/week-03

Sesión A — Encapsulamiento e invariantes

Lecturas:

Lecturas complementarias:

Temas:

  • Modificadores de acceso: private, public y el acceso de paquete (sin modificador). protected queda para la semana 5.
  • Invariante de clase: una condición sobre los campos que vale después del constructor y después de cada método público. Ejemplo propio: Fraction con denominador positivo y siempre reducida; BankAccount cuyo saldo nunca es negativo.
  • El constructor valida y rechaza datos inválidos lanzando IllegalArgumentException: un objeto inválido nunca llega a existir.
  • Getters sí, setters con cuidado: un setter por cada campo rompe el encapsulamiento igual que un campo público. Mejor métodos con intención (deposit, withdraw) que setBalance.
  • Error común: devolver desde un getter la referencia a un arreglo interno; quien la recibe puede modificar el estado sin pasar por la clase (el alias de la semana 2). Solución: copia defensiva.

Sesión B — static, paquetes, inmutabilidad · record

Lecturas:

Lecturas complementarias:

Temas:

  • static: campos y métodos de la clase, no del objeto. Contadores compartidos, métodos de utilidad (Math.max), constantes static final. Por qué un método static no puede usar this.
  • Error común: volver static un campo “para que compile” desde main y terminar con un estado compartido por todos los objetos.
  • Paquetes: package, import, la correspondencia entre paquete y carpeta, javac -d build y java -cp build. Desde esta semana el código va en clases normales y en paquetes.
  • Inmutabilidad: campos private final, sin métodos que modifiquen, operaciones que devuelven un objeto nuevo (como String.toUpperCase). Ventajas: se puede compartir sin miedo al alias.
  • record: una clase inmutable con componentes, constructor, métodos de acceso, equals, hashCode y toString generados. Constructor compacto para validar. Ejemplo: record Money(long cents, String currency). El contrato de equals y hashCode que el record cumple se estudia en la semana 5.
  • Cuándo un record y cuándo una clase: un record es un dato con valor; una clase con estado que cambia e invariantes que proteger (como BankAccount) no es un record.

Laboratorio — Unit tests con JUnit

Código inicial en code/week-03/: Fraction.java (sin pruebas todavía), las clases de bank/ y grades/ con sus pruebas como modelo, y un Makefile que compila los paquetes. Guía de referencia: JUnit User Guide, Writing Tests, Annotations, Assertions y Console Launcher. Eck, subsec. 8.4.2 Annotations, para entender qué es @Test.

  1. Correr make test en el contenedor y leer la salida del Console Launcher: pruebas que pasan, que fallan y por qué.
  2. Escribir FractionTest.java para la clase encapsulada Fraction: una prueba por comportamiento, con nombres que digan qué se espera (reducesOnCreation, rejectsZeroDenominator).
  3. Probar que el constructor rechaza datos inválidos con assertThrows(IllegalArgumentException.class, () -> new Fraction(1, 0)). La flecha () -> es una lambda: por ahora la usamos como receta y la explicamos en la semana 12.
  4. Romper el invariante a propósito (por ejemplo, quitar la validación) y ver qué prueba lo detecta.
  5. Pasar Fraction al paquete numbers y ajustar package, import y el Makefile (los apuntes muestran el error que aparece al moverla).

Entregas

  • P0 entrega.
  • P1 publicado: Inventario (clases encapsuladas con invariantes, ArrayList y pruebas JUnit). Entrega en semana 6. Ver projects/README.md.

Preguntas de repaso

  1. Enuncien el invariante de una clase Fraction reducida y con denominador positivo. ¿Qué métodos tienen que preservarlo y cuál lo establece?
  2. ¿Por qué un setter público para cada campo debilita el encapsulamiento? Den un ejemplo en que un método con intención proteja mejor el invariante.
  3. Un campo static int count se incrementa en el constructor. Si se crean tres objetos, ¿cuánto vale count en cada uno? ¿Por qué?
  4. ¿Qué genera el compilador para record Money(long cents, String currency)? ¿Dónde pondrían la validación de que cents no es negativo?
  5. Un getter devuelve el arreglo interno de una clase. Muestren cómo un cliente rompe el invariante sin llamar a ningún método de la clase, y cómo lo evita una copia defensiva.

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (9)Pruebas (3)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

Fraction.javaDescargar Fraction.java
/**
 * Una fracción inmutable. Invariante: el denominador es positivo y la
 * fracción está reducida (numerador y denominador sin factores comunes).
 */
public class Fraction {
    private final long numerator;
    private final long denominator;

    /** Crea numerator/denominator, reducida. El denominador no puede ser cero. */
    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;
    }

    public long numerator() {
        return numerator;
    }

    public long denominator() {
        return denominator;
    }

    /** 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);
    }

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

    // Máximo común divisor (Euclides). Es static: no usa ningún campo.
    private static long gcd(long a, long b) {
        while (b != 0) {
            long r = a % b;
            a = b;
            b = r;
        }
        return a == 0 ? 1 : a;
    }
}
FractionDemo.javaDescargar FractionDemo.java
// El constructor de Fraction establece el invariante; plus y times lo conservan.
public class FractionDemo {
    public static void main(String[] args) {
        Fraction half = new Fraction(2, 4);
        Fraction third = new Fraction(1, -3);
        IO.println("2/4  -> " + show(half));
        IO.println("1/-3 -> " + show(third));
        IO.println("suma:     " + show(half.plus(third)));
        IO.println("producto: " + show(half.times(third)));
        IO.println("half sigue siendo " + show(half));
        new Fraction(1, 0);         // termina con una excepción
    }

    static String show(Fraction f) {
        return f.numerator() + "/" + f.denominator();
    }
}
StaticPitfall.javaDescargar StaticPitfall.java
// Un campo que debía ser de cada objeto se declaró static "para que compile".
public class StaticPitfall {
    static class Tally {
        private static int count;          // error: un solo contador para todos

        void click() {
            count++;
        }

        int count() {
            return count;
        }
    }

    public static void main(String[] args) {
        Tally door = new Tally();
        Tally window = new Tally();
        door.click();
        door.click();
        window.click();
        IO.println("door: " + door.count() + ", window: " + window.count());
    }
}
bank/BankAccount.javaDescargar bank/BankAccount.java
package bank;

/**
 * Una cuenta bancaria. Invariante: el saldo nunca es negativo.
 * Los montos van en céntimos, como enteros, para no perder precisión.
 */
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

    /** Abre una cuenta con saldo cero. El titular no puede estar vacío. */
    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;
    }

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

    public String id() {
        return id;
    }

    public String owner() {
        return owner;
    }

    public long balance() {
        return balance;
    }

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

    private static void requirePositive(long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("el monto debe ser positivo: " + amount);
        }
    }
}
bank/BankDemo.javaDescargar bank/BankDemo.java
package bank;

// Una cuenta protege su invariante; Money es un valor inmutable.
public class BankDemo {
    public static void main(String[] args) {
        BankAccount alice = new BankAccount("Alice");
        BankAccount bob = new BankAccount("Bob");
        alice.deposit(10_000);
        boolean ok = alice.withdraw(2_500);
        IO.println(alice.id() + " " + alice.owner() + ": retiro " + ok + ", saldo " + alice.balance());
        ok = bob.withdraw(2_500);
        IO.println(bob.id() + " " + bob.owner() + ": retiro " + ok + ", saldo " + bob.balance());
        IO.println("cuentas abiertas: " + BankAccount.accountsOpened());

        Money price = Money.soles(1_050);
        Money total = price.plus(Money.soles(250));
        IO.println(price + " + S/ 2.50 = " + total);
        IO.println("iguales: " + total.equals(new Money(1_300, "PEN")));
    }
}
bank/Money.javaDescargar bank/Money.java
package bank;

/**
 * Una cantidad de dinero en una moneda: un valor inmutable.
 * El compilador genera el constructor, los métodos de acceso cents() y currency(),
 * equals, hashCode y toString.
 */
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);
    }
}
grades/CopyDemo.javaDescargar grades/CopyDemo.java
package grades;

// Un getter que devuelve el arreglo interno deja romper el invariante desde afuera.
public class CopyDemo {
    public static void main(String[] args) {
        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;
        IO.println("LeakyScores: máxima " + leaky.highest() + ", primera " + leaky.values()[0]
                + ", segunda " + leaky.values()[1]);

        int[] again = {14, 17, 11};
        Scores safe = new Scores(again);
        safe.values()[0] = 99;             // cambia una copia
        again[1] = -5;                     // cambia el arreglo de quien llamó, no el de safe
        IO.println("Scores:      máxima " + safe.highest() + ", primera " + safe.values()[0]
                + ", segunda " + safe.values()[1]);
    }
}
grades/LeakyScores.javaDescargar grades/LeakyScores.java
package grades;

/** Igual que Scores, pero sin copias defensivas: el invariante tiene fugas. */
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
    }

    public int highest() {
        int best = 0;
        for (int v : values) {
            best = Math.max(best, v);
        }
        return best;
    }
}
grades/Scores.javaDescargar grades/Scores.java
package grades;

/**
 * Las notas de una evaluación, en escala vigesimal.
 * Invariante: cada nota está entre 0 y 20.
 */
public class Scores {
    private final int[] values;

    public Scores(int[] values) {
        for (int v : values) {
            if (v < 0 || v > 20) {
                throw new IllegalArgumentException("nota fuera de 0..20: " + v);
            }
        }
        this.values = values.clone();      // copia defensiva al entrar
    }

    /** Devuelve una copia: cambiarla no cambia estas notas. */
    public int[] values() {
        return values.clone();             // copia defensiva al salir
    }

    public int highest() {
        int best = 0;
        for (int v : values) {
            best = Math.max(best, v);
        }
        return best;
    }
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

bank/BankAccountTest.javaDescargar bank/BankAccountTest.java
package bank;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

// Cada prueba comprueba un comportamiento y su nombre dice qué se espera.
class BankAccountTest {

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

        assertEquals(0, account.balance());
    }

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

        // Actuar
        account.deposit(10_000);

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

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

        boolean ok = account.withdraw(2_500);

        assertTrue(ok);
        assertEquals(7_500, 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());
    }

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

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

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

        assertThrows(IllegalArgumentException.class, () -> account.withdraw(0));
    }

    @Test
    void rejectsBlankOwner() {
        assertThrows(IllegalArgumentException.class, () -> new BankAccount("  "));
    }

    @Test
    void eachAccountGetsADifferentId() {
        BankAccount a = new BankAccount("Alice");
        BankAccount b = new BankAccount("Bob");

        assertNotEquals(a.id(), b.id());
    }
}
bank/MoneyTest.javaDescargar bank/MoneyTest.java
package bank;

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

import org.junit.jupiter.api.Test;

class MoneyTest {

    @Test
    void plusAddsCents() {
        Money total = Money.soles(1_050).plus(Money.soles(250));

        assertEquals(1_300, total.cents());
        assertEquals("PEN", total.currency());
    }

    @Test
    void plusDoesNotChangeTheOriginal() {
        Money price = Money.soles(1_050);

        price.plus(Money.soles(250));

        assertEquals(1_050, price.cents());
    }

    @Test
    void recordsWithSameComponentsAreEqual() {
        // equals lo genera el record: compara componentes, no referencias.
        assertEquals(new Money(1_300, "PEN"), Money.soles(1_300));
    }

    @Test
    void rejectsNegativeCents() {
        assertThrows(IllegalArgumentException.class, () -> new Money(-1, "PEN"));
    }

    @Test
    void rejectsInvalidCurrencyCode() {
        assertThrows(IllegalArgumentException.class, () -> new Money(100, "SOLES"));
    }

    @Test
    void rejectsMixedCurrencies() {
        Money soles = Money.soles(100);
        Money dollars = new Money(100, "USD");

        assertThrows(IllegalArgumentException.class, () -> soles.plus(dollars));
    }
}
grades/ScoresTest.javaDescargar grades/ScoresTest.java
package grades;

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

import org.junit.jupiter.api.Test;

class ScoresTest {

    @Test
    void highestOfSeveral() {
        Scores scores = new Scores(new int[] {14, 17, 11});

        assertEquals(17, scores.highest());
    }

    @Test
    void rejectsScoreAboveTwenty() {
        assertThrows(IllegalArgumentException.class, () -> new Scores(new int[] {14, 21}));
    }

    @Test
    void changingTheReturnedArrayDoesNotChangeTheScores() {
        Scores scores = new Scores(new int[] {14, 17, 11});

        scores.values()[0] = 99;

        assertEquals(14, scores.values()[0]);
        assertEquals(17, scores.highest());
    }

    @Test
    void changingTheInputArrayDoesNotChangeTheScores() {
        int[] input = {14, 17, 11};
        Scores scores = new Scores(input);

        input[1] = -5;

        assertEquals(17, scores.values()[1]);
    }
}

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 3: encapsulamiento, static, paquetes, records y pruebas con JUnit.
#   make         compila todo a build/ (build/bank, build/grades...); con -g, para que los
#                mensajes de error y jdb nombren las variables locales
#   make test    corre las demostraciones y las pruebas JUnit (*Test.java)
#   make clean   borra build/
#
# Las fuentes se buscan en todas las subcarpetas: cada carpeta es un paquete
# (bank/BankAccount.java empieza con "package bank;"). Las demostraciones se
# ejecutan por su nombre completo, con el paquete: bank.BankDemo.
JAVAC  = javac
JFLAGS = -Xlint:all -Werror -g
SRC    = $(sort $(shell find . -name '*.java' -not -path './build/*'))
DEMOS  = bank.BankDemo grades.CopyDemo StaticPitfall

all: build/.stamp

build/.stamp: $(SRC)
	mkdir -p build
	$(JAVAC) $(JFLAGS) -cp $(JUNIT_JAR) -d build $(SRC)
	touch $@

test: all
	@for d in $(DEMOS); do echo "== $$d"; java -cp build $$d || exit 1; done
	@echo "== FractionDemo (termina con IllegalArgumentException a propósito)"; ! java -cp build FractionDemo
	java -jar $(JUNIT_JAR) execute --class-path build --scan-class-path \
	  --disable-banner --disable-ansi-colors --details=tree

clean:
	rm -rf build

.PHONY: all test clean