Excepciones en Java — teoría con notas, alumnos y trazas en rojo (y el «7,5» que tumbó mi programa entero)

Mi primer programa de notas leía las calificaciones como texto y calculaba la media. Lo probé con "8.5", "6" y "9" y funcionaba perfecto. Luego se lo pasé a un compañero, que escribió las notas como las escribe cualquiera en España, con coma: "7,5". Y la consola se llenó de rojo:

Exception in thread "main" java.lang.NumberFormatException: For input string: "7,5"
	at java.base/jdk.internal.math.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2054)
	at java.base/jdk.internal.math.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.base/java.lang.Double.parseDouble(Double.java:792)
	at Media.convertir(Media.java:4)
	at Media.media(Media.java:10)
	at Media.main(Media.java:17)

Una sola nota mal escrita y el programa entero se paró, sin calcular nada. En ese momento esas líneas me parecieron un castigo. Hoy sé que son lo más útil que te puede dar Java: te dicen qué ha pasado, dónde y cómo llegaste ahí. En este artículo vamos a aprender a leerlas y, sobre todo, a evitar que una nota rara tumbe el programa.

Seguimos con los alumnos de clases y objetos y, al final, usaremos lo que viste en herencia para crear nuestras propias excepciones.

Qué es una excepción

Una excepción es un objeto que Java crea cuando algo sale mal mientras el programa se ejecuta: convertir "7,5" a número, acceder a la posición 5 de un array de 3, llamar a un método sobre null… Java «lanza» ese objeto y, si nadie lo captura, el programa se detiene y muestra la traza.

Este es el programa de mi historia:

public class Media {

    static double convertir(String texto) {
        return Double.parseDouble(texto.trim());
    }

    static double media(String[] notas) {
        double suma = 0;
        for (String n : notas) {
            suma += convertir(n);
        }
        return suma / notas.length;
    }

    public static void main(String[] args) {
        String[] notas = {"8.5", "7,5", "6"};
        System.out.println("Media: " + media(notas));
    }
}

Fíjate en que no aparece "Media: " en ningún momento. En cuanto salta la excepción, Java abandona convertir, después media y después main, sin terminar ninguno.

Ya te has encontrado con varias: ArrayIndexOutOfBoundsException en arrays, NullPointerException en clases y objetos, ClassCastException en herencia. Hasta ahora las evitábamos comprobando antes. Hoy aprendemos a tratarlas cuando no se pueden evitar.

Cómo leer una traza

La traza (stack trace) se lee así:

  1. Primera línea: el tipo de excepción y el mensaje. NumberFormatException: For input string: "7,5" ya lo dice casi todo: se intentó convertir "7,5" a número y no se pudo.
  2. **Líneas at …: la pila de llamadas, de la más reciente (arriba) a la más antigua** (abajo). Cada una dice método, archivo y línea.
  3. Busca tu código: las líneas que empiezan por java.base/ son de Java por dentro; no las vas a tocar. La primera línea que es tuya (Media.convertir(Media.java:4)) es donde saltó el problema en tu programa.
  4. Sigue hacia abajo para saber cómo llegaste: main (línea 17) llamó a media (línea 10), que llamó a convertir (línea 4).

No leas la traza entera de golpe. Primera línea (qué) → primera línea de tu código (dónde) → el resto solo si necesitas saber el camino. En IntelliJ, el nombre del archivo y la línea son un enlace que te lleva directo.

Los números de línea de java.base/… cambian según tu versión de Java. Los de tu código, no: Media.java:4 es siempre la línea 4 de tu archivo.

try y catch: capturar la excepción

Para que el programa no se pare, metemos el código que puede fallar en un bloque try y decimos qué hacer si falla en un catch:

String[] notas = {"8.5", "7,5", "6"};
double suma = 0;
int validas = 0;
for (String t : notas) {
    try {
        double n = Double.parseDouble(t);
        suma += n;
        validas++;
        System.out.println("OK: " + n);
    } catch (NumberFormatException e) {
        System.out.println("Ignorada -> " + e.getMessage());
    }
}
System.out.println("Media de " + validas + " notas: " + suma / validas);

Salida:

OK: 8.5
Ignorada -> For input string: "7,5"
OK: 6.0
Media de 2 notas: 7.25

Qué se ejecuta y qué no

Este es el punto que más cuesta. Cuando una línea del try lanza una excepción:

  1. **El resto del try se salta.** Con "7,5", ni suma += n, ni validas++, ni el println de «OK» se ejecutan.
  2. **Java busca un catch que encaje** con el tipo de la excepción. Aquí, NumberFormatException: encaja, y se ejecuta su bloque.
  3. El programa sigue después del try/catch, como si nada. El bucle pasa a "6" con normalidad.

e es el objeto excepción. Lo más útil que tiene es e.getMessage(), el mensaje (aquí, For input string: "7,5").

Poner el try fuera del bucle. Entonces, al fallar "7,5", se salta el resto del try… que es el bucle entero, y "6" ya no se procesa. Si quieres que cada elemento tenga su oportunidad, el try va dentro del bucle.

Varios catch

Un mismo try puede tener varios catch, uno por tipo de problema:

String[] datos = {"8", "abc"};
int[] posiciones = {0, 1, 5};
for (int p : posiciones) {
    try {
        double n = Double.parseDouble(datos[p]);
        System.out.println("Posición " + p + ": " + n);
    } catch (NumberFormatException e) {
        System.out.println("Posición " + p + ": no es un número");
    } catch (ArrayIndexOutOfBoundsException e) {
        System.out.println("Posición " + p + ": " + e.getMessage());
    }
}
Posición 0: 8.0
Posición 1: no es un número
Posición 5: Index 5 out of bounds for length 2

Java prueba los catch de arriba abajo y entra en el primero que encaje. Si ninguno encaja, la excepción sigue su camino como si no hubiera try.

Poner primero un catch (Exception e) y debajo otro más concreto. Exception es la madre de casi todas, así que lo captura todo y el de abajo nunca podría ejecutarse. Java no lo permite: exception NumberFormatException has already been caught. Regla: de lo más concreto a lo más general.

Si dos excepciones se tratan igual, puedes juntarlas con | (multi-catch): catch (NumberFormatException | ArrayIndexOutOfBoundsException e).

finally: lo que se ejecuta siempre

Un bloque finally se ejecuta siempre al salir del try: haya excepción o no, e incluso si dentro hay un return.

static double leer(String texto) {
    try {
        System.out.println("1. Intento convertir \"" + texto + "\"");
        double n = Double.parseDouble(texto);
        System.out.println("2. Convertido: " + n);
        return n;
    } catch (NumberFormatException e) {
        System.out.println("3. No es un número");
        return -1;
    } finally {
        System.out.println("4. finally: esto sale siempre");
    }
}
System.out.println(leer("9"));
System.out.println(leer("nueve"));
1. Intento convertir "9"
2. Convertido: 9.0
4. finally: esto sale siempre
9.0
1. Intento convertir "nueve"
3. No es un número
4. finally: esto sale siempre
-1.0

Fíjate en el orden: con "9" no se pasa por el 3; con "nueve" no se pasa por el 2. Pero el 4 sale las dos veces, antes de que el println de fuera reciba el valor devuelto: el return espera a que termine el finally.

¿Para qué sirve? Para cosas que hay que hacer pase lo que pase: cerrar un fichero, liberar una conexión, dejar un mensaje en un registro.

La familia de las excepciones

Las excepciones son clases, y forman una jerarquía de herencia, como la de Persona → Alumno:

Throwable
├── Error                      (problemas graves de la máquina: no se tratan)
└── Exception
    ├── IOException            (checked: ficheros, red…)
    │   └── FileNotFoundException
    ├── (tus excepciones propias, si heredan de Exception)
    └── RuntimeException       (unchecked: errores de programación)
        ├── NullPointerException
        ├── ArithmeticException
        ├── ClassCastException
        ├── IllegalArgumentException
        │   └── NumberFormatException
        └── IndexOutOfBoundsException
            └── ArrayIndexOutOfBoundsException

Como es herencia, un catch (IllegalArgumentException e) también captura una NumberFormatException: es una IllegalArgumentException. Es el mismo instanceof que viste en herencia.

ExcepciónCuándo saltaEjemplo
NumberFormatExceptionConvertir texto que no es un númeroInteger.parseInt("8a")
ArrayIndexOutOfBoundsExceptionÍndice fuera del arraynotas[5] con 3 elementos
NullPointerExceptionUsar algo que vale nullnombre.length() con nombre = null
ArithmeticExceptionDivisión entera entre 010 / 0
ClassCastExceptionCast a un tipo que no es(Alumno) profesor
IllegalArgumentExceptionUn argumento no válido (la lanzas tú)setNota(12)
FileNotFoundExceptionEl fichero no existenew FileReader("x.txt")

10.0 / 0 no lanza nada: con double da Infinity. ArithmeticException solo aparece con enteros.

Checked y unchecked

Esta distinción es la que más confunde, y en realidad es sencilla:

  • Unchecked (RuntimeException y sus hijas): normalmente son errores de programación que se evitan comprobando antes (un índice mal calculado, un null). Java no te obliga a tratarlas.
  • Checked (el resto de Exception, como IOException): son problemas externos que pueden pasar aunque tu código esté perfecto (el fichero no existe, se cae la red). Java te obliga a decidir qué hacer con ellas.

Si llamas a algo que lanza una checked y no haces nada, ni compila:

FileReader r = new FileReader("notas.txt");
error: unreported exception FileNotFoundException; must be caught or declared to be thrown

El mensaje te da las dos opciones:

  1. Capturarla (caught): rodearla con try/catch.
  2. Declararla (declared to be thrown): añadir throws a tu método para que la trate quien te llame.
static void abrir() throws FileNotFoundException {   // "yo no la trato: que la trate quien me llame"
    FileReader r = new FileReader("notas.txt");
}

throws (con s, en la firma del método) avisa de que algo puede salir. throw (sin s, dentro del código) lanza la excepción. Son dos cosas distintas que se confunden mucho; ahora vamos con throw.

throw: lanzar tus propias excepciones

Hasta ahora las excepciones las lanzaba Java. Pero tú también puedes lanzarlas cuando recibes datos que no tienen sentido:

static void validarNota(double nota) {
    if (nota < 0 || nota > 10) {
        throw new IllegalArgumentException("Nota fuera de rango: " + nota);
    }
}
try {
    validarNota(7);
    System.out.println("7 es válida");
    validarNota(12);
    System.out.println("Esta línea no se ejecuta");
} catch (IllegalArgumentException e) {
    System.out.println("Error: " + e.getMessage());
}
7 es válida
Error: Nota fuera de rango: 12.0

throw new … crea el objeto excepción (con su mensaje) y lo lanza. Desde ese momento el método se corta, igual que si fuera un return, pero en lugar de devolver un valor «manda un aviso» hacia arriba.

¿Por qué es mejor que el println("Nota no válida") que usábamos en clases y objetos? Porque un println solo lo ve una persona en la consola; la excepción la puede detectar y tratar el código que llamó al método, y decidir qué hacer (pedir otra nota, saltarse ese alumno, avisar en la interfaz…).

Excepciones propias

IllegalArgumentException sirve para casi todo, pero a veces conviene una excepción con nombre propio, que diga exactamente qué ha pasado y lleve datos útiles. Se crea con herencia:

public class NotaInvalidaException extends Exception {

    private final double nota;                       // guardamos el dato que falló

    public NotaInvalidaException(double nota) {
        super("Nota fuera de rango: " + nota + " (debe estar entre 0 y 10)");
        this.nota = nota;
    }

    public double getNota() { return nota; }
}
  • Hereda de Exception, así que es checked: quien use setNota estará obligado a tratarla. (Si heredara de RuntimeException, sería unchecked.)
  • super(mensaje) le pasa el texto al constructor de Exception, y así getMessage() funciona sin escribirlo tú.
  • Puede tener atributos y métodos propios, como getNota().

Ahora el Alumno la lanza:

public class Alumno {

    private final String nombre;
    private double nota;

    public Alumno(String nombre) {
        this.nombre = nombre;
    }

    public void setNota(double nota) throws NotaInvalidaException {
        if (nota < 0 || nota > 10) {
            throw new NotaInvalidaException(nota);   // se corta aquí: la nota no cambia
        }
        this.nota = nota;
    }

    public String getNombre() { return nombre; }
    public double getNota()   { return nota; }
}

Y quien lo usa decide qué hacer con cada problema:

public class Matricula {

    // No la trata: la deja pasar hacia arriba con throws
    static void ponerNota(Alumno a, String texto) throws NotaInvalidaException {
        double nota = Double.parseDouble(texto);     // puede lanzar NumberFormatException
        a.setNota(nota);                             // puede lanzar NotaInvalidaException
    }

    public static void main(String[] args) {
        Alumno ana = new Alumno("Ana");
        String[] intentos = {"12", "ocho", "8.5"};

        for (String t : intentos) {
            try {
                ponerNota(ana, t);
                System.out.println("Nota guardada: " + ana.getNota());
            } catch (NotaInvalidaException e) {
                System.out.println(e.getMessage() + " -> me diste " + e.getNota());
            } catch (NumberFormatException e) {
                System.out.println("\"" + t + "\" no es un número");
            }
        }
    }
}
Nota fuera de rango: 12.0 (debe estar entre 0 y 10) -> me diste 12.0
"ocho" no es un número
Nota guardada: 8.5

Propagar: la excepción sube por la pila

ponerNota no tiene try: declara throws NotaInvalidaException y la deja subir hasta main, que es quien la trata. Esto se llama propagar. Si nadie la tratara, llegaría hasta arriba del todo y veríamos su traza. Por ejemplo, con un main que también la declara con throws:

Exception in thread "main" NotaInvalidaException: Nota fuera de rango: 12.0 (debe estar entre 0 y 10)
	at Alumno.setNota(Alumno.java:12)
	at SinTratar.ponerNota(SinTratar.java:4)
	at SinTratar.main(SinTratar.java:9)

Ahora la traza tiene todo tu código: se lanzó en la línea 12 de Alumno (el throw), pasando por ponerNota y por main.

Tratar todas las excepciones con un catch (Exception e) {} vacío «para que no moleste». El programa ya no se para, pero tampoco te enteras de que algo ha ido mal, y el error aparece mucho después y en otro sitio. Si capturas, haz algo: informa, corrige o vuelve a lanzar.

try-with-resources: cerrar ficheros sin olvidarte

Un fichero abierto hay que cerrarlo, haya error o no. Antes se hacía en un finally; desde Java 7 hay una forma más cómoda: declarar el recurso entre paréntesis después del try, y Java lo cierra solo al salir:

try (BufferedReader lector = new BufferedReader(new FileReader("notas.txt"))) {
    System.out.println(lector.readLine());
} catch (IOException e) {
    System.out.println("No se pudo leer: " + e.getMessage());
}

Si notas.txt no existe, la salida es parecida a esta (el texto exacto depende del sistema operativo; en Windows en español dice El sistema no puede encontrar el archivo especificado):

No se pudo leer: notas.txt (No such file or directory)

FileNotFoundException es hija de IOException, por eso el catch (IOException e) la captura. Lo usarás a fondo en la práctica.

Míralo paso a paso en Python Tutor

Python Tutor ejecuta el código línea a línea y dibuja las variables y la pila de llamadas. Elige Java en el desplegable, pega este programa y avanza con Next:

public class Tutor {
    static double convertir(String t) {
        return Double.parseDouble(t);          // aquí puede saltar
    }
    static double media(String[] notas) {
        double suma = 0;
        for (String t : notas) {
            suma += convertir(t);
        }
        return suma / notas.length;
    }
    public static void main(String[] args) {
        String[] notas = {"8", "6", "7,5"};
        try {
            System.out.println(media(notas));
        } catch (NumberFormatException e) {
            System.out.println("Error: " + e.getMessage());
        } finally {
            System.out.println("Fin");
        }
    }
}

Salida:

Error: For input string: "7,5"
Fin

Qué observar:

  1. Con "8" y "6" verás la pila crecer y encogerse: main → media → convertir y vuelta. suma llega a 14.
  2. En la tercera vuelta, la llamada a convertir("7,5") no devuelve nada: la excepción salta y el marco de convertir desaparece de golpe.
  3. Justo después desaparece también el marco de media, sin llegar al return. Es la propagación: la excepción «sube» porque nadie la captura ahí.
  4. La ejecución reaparece en el catch de main. La variable suma se ha perdido con el marco de media: por eso nunca se imprime ninguna media.
  5. Por último pasa por el finally e imprime «Fin».

Resumen y tabla rápida

ConceptoEn JavaSi vienes de Python
Intentar algo que puede fallartry { … }try:
Capturar un tipo concretocatch (NumberFormatException e)except ValueError as e:
Lo que se ejecuta siemprefinally { … }finally:
Lanzar una excepciónthrow new IllegalArgumentException("…")raise ValueError("…")
Avisar de que un método puede lanzarlathrows NotaInvalidaExceptionno existe
Excepción propiaclass X extends Exceptionclass X(Exception):
Cerrar recursos solotry (BufferedReader r = …)with open(…) as f:
Obligación de tratarlaSí, si es checkedNunca

Tres ideas para quedarte:

  1. Lee la traza de arriba abajo: primera línea (qué), primera línea de tu código (dónde), el resto (cómo llegaste).
  2. **Cuando salta una excepción, el resto del try se salta**; Java busca el primer catch que encaje y después sigue. finally se ejecuta siempre.
  3. Checked obliga, unchecked no: las checked se capturan o se declaran con throws; con throw lanzas tú las tuyas, y las propias se crean heredando de Exception.

En la práctica construirás tres programas que no se caen con datos raros: un importador de notas, un cajero con excepciones propias y un lector de ficheros.

Siguiente paso: prácticaExcepciones en Java — práctica con un importador de notas, un cajero y un lector de ficheros (y el «Error» que no decía nada)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

4 comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *