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 yfinally. - 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:
- Eck, sec. 3.7 Introduction to Exceptions and try..catch, subsecciones 3.7.1 y 3.7.2.
- Eck, sec. 8.3 Exceptions and try..catch, subsecciones 8.3.1 a 8.3.3.
Lecturas complementarias:
- dev.java, Exceptions: What Is an Exception?, Catching and Handling Exceptions y Throwing Exceptions.
- API de Java 25:
Throwable(getMessage,getCause,printStackTrace).
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, varioscatch(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 hastamainy 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 aMoneyque falla conNumberFormatExceptiony una cuenta que lanza una excepción propia al retirar más que el saldo. - Excepciones propias:
class InsufficientFundsException extends Exceptioncon 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:
- Eck, subsecs. 8.3.4 Mandatory Exception Handling y 8.3.5 Programming with Exceptions; en 8.3.2 The try Statement, la parte de
finallyy la forma con recursos. - Eck, sec. 8.2 Writing Correct Programs, subsecciones 8.2.2 a 8.2.4 (8.2.2 y 8.2.3 son relectura de la semana 3, ahora con excepciones; 8.2.4 es nueva).
- Eck, subsec. 8.4.1 Assertions.
Lecturas complementarias:
- dev.java, Unchecked Exceptions — The Controversy.
- JLS 25, §11.1.1 The Kinds of Exceptions y §14.20.3 try-with-resources.
- API de Java 25:
Objects.requireNonNullyAutoCloseable.
Temas:
- Checked (el compilador obliga a capturarlas o declararlas con
throws) frente a unchecked (RuntimeExceptiony 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,nulldonde no se permite). throwsy las reglas de override:throwsno 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).finallyytry-with-resources: el recurso se cierra siempre, en orden inverso, aunque haya excepción. Cualquier clase que implementeAutoCloseablesirve. Excepciones suprimidas (suppressed).- Contratos: validar precondiciones al entrar a un método público (
IllegalArgumentException,IllegalStateException,Objects.requireNonNull);assertpara condiciones internas que nunca deberían fallar (se activan conjava -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 Exceptionen 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.
- Leer stack traces reales (con
Caused by:) y ubicar la línea que originó cada error. - Definir una jerarquía pequeña de excepciones del dominio y lanzarlas desde los métodos que validan.
- 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. - Escribir una clase que implemente
AutoCloseabley comprobar con una prueba queclose()se llama aunque el bloque lance una excepción. - Correr las pruebas con y sin
-eay explicar la diferencia en una assertion interna.
Entregas
- P2 entrega.
- P3 publicado: Ventas (excepciones, genéricos y colecciones
MapySet). 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. Verprojects/README.md.
Preguntas de repaso
- ¿Por qué
catch (Exception e)antes decatch (IOException e)no compila? - ¿Una excepción para “stock insuficiente” debería ser checked o unchecked? ¿Y una para “el precio recibido es negativo”? Justifiquen.
- 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? - ¿En qué orden se cierran dos recursos declarados en el mismo
try-with-resources? ¿Qué pasa con la excepción declose()si el bloque ya había lanzado una? - ¿Cuándo usarían
asserty cuándoIllegalArgumentExceptionpara 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.
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));
}
}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;
}
}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);
}
}
}// 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);
}
}
}/** 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;
}
}/** 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;
}
}/** 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);
}
}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;
}
}// 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());
}
}// 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.
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");
}
}
}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");
}
}// 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.
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));
}
}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());
}
}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.
# 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