Semana 9 — Excepciones y robustez

Objetivos

  • Lanzar, capturar y propagar excepciones (exceptions), y leer un stack trace completo, incluida la causa.
  • Distinguir excepciones checked y unchecked y decidir cuál usar en cada caso.
  • Liberar recursos de forma segura con try-with-resources y finally.
  • Expresar contratos (precondiciones, postcondiciones, invariantes) y verificarlos con excepciones, assertions y pruebas.

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

Sesión A — Excepciones: lanzar, capturar, propagar

Lecturas:

Lecturas complementarias:

Temas:

  • Por qué excepciones y no códigos de error como en C: el error no se puede ignorar en silencio y el código normal queda separado del de manejo.
  • throw, try/catch, varios catch (del más específico al más general) y multi-catch con |. Propagación: si nadie la captura, la excepción sube por las llamadas hasta main y termina el hilo con su stack trace (en un programa de un solo hilo, el programa).
  • La jerarquía: Throwable, Error, Exception, RuntimeException. Ejemplo propio: un conversor de texto a Money que falla con NumberFormatException y una cuenta que lanza una excepción propia al retirar más que el saldo.
  • Excepciones propias: class InsufficientFundsException extends Exception con un mensaje útil y datos del error (saldo, monto pedido).
  • Encadenar excepciones (causa): envolver un error de bajo nivel en uno del dominio sin perder el original.
  • Error común: catch (Exception e) {} vacío, que esconde el error; y capturar una excepción solo para imprimirla y seguir como si nada.

Sesión B — Robustez: checked vs. unchecked, try-with-resources, contratos

Lecturas:

Lecturas complementarias:

Temas:

  • Checked (el compilador obliga a capturarlas o declararlas con throws) frente a unchecked (RuntimeException y sus subclases). Criterio: checked para fallas que el que llama puede esperar y manejar (un archivo que no está); unchecked para errores de programación (una precondición violada, null donde no se permite).
  • throws y las reglas de override: throws no es parte de la firma (nombre y tipos de los parámetros), sino del contrato declarado; una subclase no puede agregar excepciones checked al hacer override (otra forma del principio de Liskov de la semana 7).
  • finally y try-with-resources: el recurso se cierra siempre, en orden inverso, aunque haya excepción. Cualquier clase que implemente AutoCloseable sirve. Excepciones suprimidas (suppressed).
  • Contratos: validar precondiciones al entrar a un método público (IllegalArgumentException, IllegalStateException, Objects.requireNonNull); assert para condiciones internas que nunca deberían fallar (se activan con java -ea).
  • Fail fast: rechazar el dato malo donde entra y no tres llamadas después.
  • Error común: usar excepciones para el flujo normal (por ejemplo, para salir de un bucle) o declarar throws Exception en todo.

Laboratorio — Manejo de errores y pruebas de excepciones

Código inicial en code/week-09/: stack traces reales con Propagation y ChainDemo, el demo de recursos ResourceDemo y AssertDemo para el punto 5 (make test RUNFLAGS=-ea corre las pruebas con las assertions activadas). Referencia: JUnit User Guide, Exception Handling y assertThrows en Assertions.

  1. Leer stack traces reales (con Caused by:) y ubicar la línea que originó cada error.
  2. Definir una jerarquía pequeña de excepciones del dominio y lanzarlas desde los métodos que validan.
  3. Probar cada caso de error con assertThrows (la receta de la semana 3) y verificar también el mensaje y los datos de la excepción.
  4. Escribir una clase que implemente AutoCloseable y comprobar con una prueba que close() se llama aunque el bloque lance una excepción.
  5. Correr las pruebas con y sin -ea y explicar la diferencia en una assertion interna.

Entregas

  • P2 entrega.
  • P3 publicado: Ventas (excepciones, genéricos y colecciones Map y Set). Entrega en semana 12. Las excepciones del dominio (stock insuficiente, producto inexistente) se usan desde esta semana; los genéricos y las colecciones se ven en las semanas 10 y 11. Ver projects/README.md.

Preguntas de repaso

  1. ¿Por qué catch (Exception e) antes de catch (IOException e) no compila?
  2. ¿Una excepción para “stock insuficiente” debería ser checked o unchecked? ¿Y una para “el precio recibido es negativo”? Justifiquen.
  3. Un método hace override de otro y declara throws IOException, pero el de la superclase no declara nada. ¿Por qué el compilador lo rechaza?
  4. ¿En qué orden se cierran dos recursos declarados en el mismo try-with-resources? ¿Qué pasa con la excepción de close() si el bloque ya había lanzado una?
  5. ¿Cuándo usarían assert y cuándo IllegalArgumentException para verificar una condición?

Código de la semana

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

Ejemplos (10)Errores (3)Pruebas (3)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

AssertDemo.javaDescargar AssertDemo.java
import java.util.Arrays;

// Una assertion interna: el reparto debe sumar el total. Este código tiene un
// error a propósito. Con java -ea la assertion lo detecta; sin -ea, nadie avisa.
public class AssertDemo {

    /** Reparte total céntimos en parts partes casi iguales. */
    static long[] split(long total, int parts) {
        if (parts <= 0) {
            throw new IllegalArgumentException("parts debe ser positivo: " + parts);
        }
        long[] shares = new long[parts];
        Arrays.fill(shares, total / parts);     // error: se pierde el resto
        assert Arrays.stream(shares).sum() == total : "el reparto no suma " + total;
        return shares;
    }

    public static void main(String[] args) {
        long[] shares = split(1000, 3);
        IO.println("reparto de 1000 en 3: " + Arrays.toString(shares));
    }
}
BankAccount.javaDescargar BankAccount.java
import java.util.Objects;

/** Una cuenta cuyo saldo (en céntimos) nunca es negativo. */
public class BankAccount {
    private final String id;
    private long balance;

    public BankAccount(String id) {
        this.id = Objects.requireNonNull(id, "id");
    }

    public void deposit(long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("monto no positivo: " + amount);
        }
        balance += amount;
    }

    public void withdraw(long amount) throws InsufficientFundsException {
        if (amount <= 0) {
            throw new IllegalArgumentException("monto no positivo: " + amount);
        }
        if (amount > balance) {
            throw new InsufficientFundsException(balance, amount);
        }
        balance -= amount;
        assert balance >= 0 : "invariante roto: " + balance;
    }

    public String id() {
        return id;
    }

    public long balance() {
        return balance;
    }
}
ChainDemo.javaDescargar ChainDemo.java
import java.util.List;

// Una excepción del dominio que guarda su causa: el error de bajo nivel.
public class ChainDemo {

    public static void main(String[] args) {
        List<String> lines = List.of("A-01;3.50", "A-02;12.00", "A-03;4,90");
        try {
            List<Money> prices = PriceReader.read(lines);
            IO.println(prices);
        } catch (InvalidPriceException e) {
            IO.println("mensaje: " + e.getMessage());
            IO.println("línea:   " + e.line());
            IO.println("causa:   " + e.getCause());
            e.printStackTrace(System.out);
        }
    }
}
Gate.javaDescargar Gate.java
// Un recurso de juguete: avisa cuando se abre y cuando se cierra.
// Si failOnClose es true, close() también lanza una excepción.
public class Gate implements AutoCloseable {
    private final String name;
    private final boolean failOnClose;

    public Gate(String name, boolean failOnClose) {
        this.name = name;
        this.failOnClose = failOnClose;
        IO.println("abre " + name);
    }

    public void use(boolean fail) {
        IO.println("usa " + name);
        if (fail) {
            throw new IllegalStateException("falla al usar " + name);
        }
    }

    @Override
    public void close() {
        IO.println("cierra " + name);
        if (failOnClose) {
            throw new IllegalStateException("falla al cerrar " + name);
        }
    }
}
InsufficientFundsException.javaDescargar InsufficientFundsException.java
/** Se pidió retirar más de lo que hay en la cuenta. */
public class InsufficientFundsException extends Exception {
    // Throwable es Serializable; -Xlint:all pide declarar la versión.
    private static final long serialVersionUID = 1L;

    private final long balance;
    private final long requested;

    public InsufficientFundsException(long balance, long requested) {
        super("saldo insuficiente: hay " + balance + ", se pidió " + requested);
        this.balance = balance;
        this.requested = requested;
    }

    public long balance() {
        return balance;
    }

    public long requested() {
        return requested;
    }
}
InvalidPriceException.javaDescargar InvalidPriceException.java
/** Una línea de la lista de precios que no se puede leer. */
public class InvalidPriceException extends Exception {
    // Throwable es Serializable; -Xlint:all pide declarar la versión.
    private static final long serialVersionUID = 1L;

    private final int line;

    public InvalidPriceException(int line, String text, Throwable cause) {
        super("línea " + line + ": precio inválido \"" + text + "\"", cause);
        this.line = line;
    }

    public int line() {
        return line;
    }
}
Money.javaDescargar Money.java
/** Un monto de dinero en céntimos, con su moneda (el record de la semana 3). */
public record Money(long cents, String currency) {

    public Money {
        if (cents < 0) {
            throw new IllegalArgumentException("monto negativo: " + cents);
        }
        if (currency == null || currency.isBlank()) {
            throw new IllegalArgumentException("falta la moneda");
        }
    }

    /**
     * Convierte un texto como "12.50" en soles. Si el texto no es un número,
     * Long.parseLong lanza NumberFormatException y este método la deja pasar.
     */
    public static Money parse(String text) {
        String[] parts = text.trim().split("\\.", -1);
        long soles = Long.parseLong(parts[0]);
        long cents = 0;
        if (parts.length == 2 && parts[1].length() == 2) {
            cents = Long.parseLong(parts[1]);
        } else if (parts.length != 1) {
            throw new NumberFormatException("formato inválido: \"" + text + "\"");
        }
        return new Money(soles * 100 + cents, "PEN");
    }

    @Override
    public String toString() {
        return String.format("%s %d.%02d", currency, cents / 100, cents % 100);
    }
}
PriceReader.javaDescargar PriceReader.java
import java.util.ArrayList;
import java.util.List;

/** Lee una lista de precios con líneas de la forma "código;precio". */
public class PriceReader {

    public static List<Money> read(List<String> lines) throws InvalidPriceException {
        List<Money> prices = new ArrayList<>();
        for (int i = 0; i < lines.size(); i++) {
            String[] fields = lines.get(i).split(";");
            String price = fields.length == 2 ? fields[1] : "";
            try {
                prices.add(Money.parse(price));
            } catch (NumberFormatException e) {
                // Se envuelve el error de bajo nivel en uno del dominio, sin perderlo.
                throw new InvalidPriceException(i + 1, price, e);
            }
        }
        return prices;
    }
}
Propagation.javaDescargar Propagation.java
// Una excepción sube por las llamadas hasta que alguien la captura.
// Con el argumento "crash", main no la captura y el programa termina.
public class Propagation {

    static void charge(BankAccount account, long amount) throws InsufficientFundsException {
        IO.println("  charge: retira " + amount);
        account.withdraw(amount);
        IO.println("  charge: listo");          // no se ejecuta si withdraw lanza
    }

    static void processOrder(BankAccount account, long total) throws InsufficientFundsException {
        IO.println(" processOrder: total " + total);
        charge(account, total);
        IO.println(" processOrder: pedido pagado");
    }

    public static void main(String[] args) throws InsufficientFundsException {
        BankAccount account = new BankAccount("C-01");
        account.deposit(500);
        if (args.length > 0 && args[0].equals("crash")) {
            processOrder(account, 800);
            return;
        }
        try {
            processOrder(account, 300);
            processOrder(account, 800);
            IO.println("main: no llega aquí");
        } catch (InsufficientFundsException e) {
            IO.println("main: " + e.getMessage());
            IO.println("main: faltan " + (e.requested() - e.balance()) + " céntimos");
        }
        IO.println("main: saldo final " + account.balance());
    }
}
ResourceDemo.javaDescargar ResourceDemo.java
// try-with-resources: los recursos se cierran siempre, en orden inverso.
public class ResourceDemo {

    public static void main(String[] args) {
        IO.println("-- 1. sin errores");
        try (Gate a = new Gate("A", false); Gate b = new Gate("B", false)) {
            a.use(false);
            b.use(false);
        }

        IO.println("-- 2. el bloque lanza");
        try (Gate a = new Gate("A", false); Gate b = new Gate("B", false)) {
            a.use(false);
            b.use(true);
            IO.println("no llega aquí");
        } catch (IllegalStateException e) {
            IO.println("capturada: " + e.getMessage());
        }

        IO.println("-- 3. el bloque lanza y close también");
        try (Gate a = new Gate("A", true)) {
            a.use(true);
        } catch (IllegalStateException e) {
            IO.println("capturada: " + e.getMessage());
            for (Throwable s : e.getSuppressed()) {
                IO.println("suprimida: " + s.getMessage());
            }
        }

        IO.println("-- 4. finally");
        try {
            IO.println("en el try");
            return;
        } finally {
            IO.println("finally se ejecuta aunque haya return");
        }
    }
}

Errores

Ejemplos con errores a propósito: no compilan, fallan o dan un resultado incorrecto. Los apuntes explican cada uno.

errores/CatchOrder.javaDescargar errores/CatchOrder.java
import java.io.FileReader;
import java.io.IOException;

// No compila: el catch general va antes que el específico.
public class CatchOrder {
    public static void main(String[] args) {
        try (FileReader in = new FileReader("precios.txt")) {
            IO.println(in.read());
        } catch (Exception e) {
            IO.println("algo falló");
        } catch (IOException e) {
            IO.println("no se pudo leer");
        }
    }
}
errores/OverrideThrows.javaDescargar errores/OverrideThrows.java
import java.io.IOException;

// No compila: al hacer override no se pueden agregar excepciones checked.
class Report {
    String render() {
        return "reporte";
    }
}

class FileReport extends Report {
    @Override
    String render() throws IOException {
        throw new IOException("no se encontró la plantilla");
    }
}
errores/Unreported.javaDescargar errores/Unreported.java
// No compila: withdraw declara una excepción checked que nadie captura ni declara.
public class Unreported {
    public static void main(String[] args) {
        BankAccount account = new BankAccount("C-01");
        account.withdraw(100);
    }
}

Pruebas

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

BankAccountTest.javaDescargar BankAccountTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

class BankAccountTest {

    @Test
    void withdrawWithEnoughBalance() throws InsufficientFundsException {
        BankAccount account = new BankAccount("C-01");
        account.deposit(500);
        account.withdraw(200);
        assertEquals(300, account.balance());
    }

    @Test
    void withdrawTooMuchKeepsDataAndBalance() {
        BankAccount account = new BankAccount("C-01");
        account.deposit(500);
        InsufficientFundsException e =
                assertThrows(InsufficientFundsException.class, () -> account.withdraw(800));
        assertEquals(500, e.balance());
        assertEquals(800, e.requested());
        assertEquals(500, account.balance());   // el saldo no cambió
    }

    @Test
    void rejectsNonPositiveDeposit() {
        BankAccount account = new BankAccount("C-01");
        IllegalArgumentException e =
                assertThrows(IllegalArgumentException.class, () -> account.deposit(0));
        assertEquals("monto no positivo: 0", e.getMessage());
    }

    @Test
    void rejectsNullId() {
        assertThrows(NullPointerException.class, () -> new BankAccount(null));
    }
}
MoneyTest.javaDescargar MoneyTest.java
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 parsesSolesAndCents() {
        assertEquals(new Money(1250, "PEN"), Money.parse("12.50"));
        assertEquals(new Money(700, "PEN"), Money.parse("7"));
    }

    @Test
    void rejectsTextThatIsNotANumber() {
        assertThrows(NumberFormatException.class, () -> Money.parse("12a"));
        assertThrows(NumberFormatException.class, () -> Money.parse("4,90"));
        assertThrows(NumberFormatException.class, () -> Money.parse("1.5"));
    }

    @Test
    void rejectsNegativeAmount() {
        IllegalArgumentException e =
                assertThrows(IllegalArgumentException.class, () -> new Money(-1, "PEN"));
        assertEquals("monto negativo: -1", e.getMessage());
    }
}
PriceReaderTest.javaDescargar PriceReaderTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.List;
import org.junit.jupiter.api.Test;

class PriceReaderTest {

    @Test
    void readsValidLines() throws InvalidPriceException {
        List<Money> prices = PriceReader.read(List.of("A-01;3.50", "A-02;12.00"));
        assertEquals(List.of(new Money(350, "PEN"), new Money(1200, "PEN")), prices);
    }

    @Test
    void badPriceKeepsLineAndCause() {
        InvalidPriceException e = assertThrows(InvalidPriceException.class,
                () -> PriceReader.read(List.of("A-01;3.50", "A-02;doce")));
        assertEquals(2, e.line());
        assertEquals("línea 2: precio inválido \"doce\"", e.getMessage());
        assertInstanceOf(NumberFormatException.class, e.getCause());
    }
}

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 9: excepciones y robustez.
#   make           compila todo a build/
#   make test      corre las demostraciones, los ejemplos que fallan a propósito
#                  y las pruebas JUnit
#   make test RUNFLAGS=-ea   las pruebas JUnit con las assertions activadas
#   make errores   muestra los errores de compilación de errores/*.java
#   make clean     borra build/
JAVAC    = javac
JFLAGS   = -Xlint:all -Werror
RUNFLAGS =
SRC      = $(wildcard *.java)
DEMOS    = Propagation ChainDemo ResourceDemo
ERRORES  = $(wildcard errores/*.java)

all: build/.stamp

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

test: all errores
	@for d in $(DEMOS); do echo "== $$d"; java -cp build $$d || exit 1; done
	@echo "== Propagation crash (debe terminar con error)"
	@! java -cp build Propagation crash
	@echo "== AssertDemo sin -ea"; java -cp build AssertDemo
	@echo "== AssertDemo con -ea (debe terminar con error)"
	@! java -ea -cp build AssertDemo
	java $(RUNFLAGS) -jar $(JUNIT_JAR) execute --class-path build --scan-class-path \
	  --disable-banner --disable-ansi-colors --details=tree

# Cada archivo de errores/ debe fallar al compilar.
errores: all
	@for f in $(ERRORES); do echo "== $$f (no debe compilar)"; \
	  if $(JAVAC) $(JFLAGS) -cp build -d build/errores $$f; then exit 1; fi; done

clean:
	rm -rf build

.PHONY: all test errores clean