Excepciones en Python — práctica con cuatro programas que no se rompen: una ficha de matrícula, una calculadora, un lector de notas y fracciones (y el «12,5» que no quería ser número)

Cuando monté mi primera ficha de matrícula «a prueba de todo», le pasé el programa a una compañera para que lo rompiera. Tardó diez segundos: en la nota de acceso escribió 12,5, con coma, y el programa le soltó un could not convert string to float en inglés, sin explicación. No se rompió (lo había metido en un try), pero tampoco le dijo nada útil. Esa es la lección de hoy: un programa robusto no es solo el que no se para; es el que sabe qué ha fallado y lo dice.

En la teoría de excepciones en Python viste try, except, else, finally, raise y las excepciones propias. Hoy las usamos en cuatro programas completos, todos de nivel medio: una ficha de matrícula que insiste hasta que los datos valen, una calculadora que aguanta cualquier cosa que escribas, un lector de notas que lee un fichero con líneas estropeadas y una clase de fracciones con su propia familia de excepciones. Los datos se parecen a los de verdad, con huecos, comas, textos donde van números y ficheros que no existen, porque es justo ahí donde las excepciones demuestran para qué sirven.

Antes de empezar

Conviene tener claro:

  • Que, cuando una línea del try falla, el resto del try se salta y se entra en el primer except que encaja.
  • La diferencia entre else (solo si no hubo excepción) y finally (siempre).
  • Que raise ValueError('mensaje') lanza una excepción tuya y que el except ValueError de quien te llamó la captura.
  • Cómo se crea una excepción propia: class MiError(Exception):.

Si alguno te suena dudoso, repasa la teoría antes de seguir.

Ninguno de los programas usa input(): lo que teclearía la persona va en una lista (TECLEO) y la función leer() lo imprime como saldría en la terminal, para que veas salidas reales con datos buenos y malos. En tu ordenador, borra TECLEO y leer() y escribe input(...) donde pone leer(...).

Excepciones en Python práctica — Programa 1: Validador robusto de datos

La secretaría quiere una ficha de matrícula: nombre, edad (de 16 a 99), nota de acceso (de 5 a 14) y modalidad (presencial u online). Lo que la complica es que cada dato puede fallar de dos maneras: no ser del tipo correcto ('doce' no es un entero) o serlo pero estar fuera de rango (12 años). El programa tiene que volver a pedir el dato hasta que valga, sin romperse nunca.

La idea

Una función por tipo de dato (pedir_entero, pedir_decimal, pedir_opcion), todas con el mismo esquema: un while True con un try dentro. Si el dato vale, return (y eso sale del bucle). Si no, el except avisa y el bucle da otra vuelta. El truco está en el rango: en vez de un if con su propio print, lanzamos nosotros un ValueError, para que los dos fallos (el de int() y el del rango) acaben en el mismo except.

Antes de programar, piensa en cuántas formas tiene cada dato de estar mal. Para la edad, dos (no es número, está fuera de rango). Para la modalidad, una sola (no es ninguna de las opciones), así que ahí no hay conversión que pueda fallar.

El código

# Lo que teclearía la persona (en tu ordenador, cambia leer() por input()):
TECLEO = iter(['Laura Pérez', 'doce', '12', '22', '15', '12,5', '12.5', 'virtual', 'Presencial'])


def leer(mensaje):
    texto = next(TECLEO)     # el siguiente dato «tecleado»
    print(mensaje + texto)   # lo enseñamos como se vería en la terminal
    return texto


def pedir_entero(mensaje, minimo, maximo):
    while True:                                  # insiste hasta que el dato valga
        try:
            valor = int(leer(mensaje))           # ValueError si no es un entero
            if not minimo <= valor <= maximo:
                raise ValueError(f'tiene que estar entre {minimo} y {maximo}')
            return valor                         # dato bueno: salimos del bucle
        except ValueError as err:                # los dos casos acaban aquí
            print(f'  Error: {err}')


def pedir_decimal(mensaje, minimo, maximo):
    while True:
        try:
            valor = float(leer(mensaje))
            if not minimo <= valor <= maximo:
                raise ValueError(f'tiene que estar entre {minimo} y {maximo}')
            return valor
        except ValueError as err:
            print(f'  Error: {err}')


def pedir_opcion(mensaje, opciones):
    while True:
        try:
            opcion = leer(mensaje).strip().lower()   # ' Presencial' -> 'presencial'
            if opcion not in opciones:
                raise ValueError(f'elige una de estas: {", ".join(opciones)}')
            return opcion
        except ValueError as err:
            print(f'  Error: {err}')


print('=== FICHA DE MATRÍCULA ===')
nombre = leer('Nombre: ').strip() or 'Sin nombre'
edad = pedir_entero('Edad (16-99): ', 16, 99)
nota = pedir_decimal('Nota de acceso (5-14): ', 5.0, 14.0)
modalidad = pedir_opcion('Modalidad (presencial/online): ', ['presencial', 'online'])
print('--- Ficha guardada ---')
print(f'Nombre:    {nombre}')
print(f'Edad:      {edad} años')
print(f'Nota:      {nota:.2f}')
print(f'Modalidad: {modalidad}')

Vamos a hacer zoom a la función que lleva todo el peso:

def pedir_entero(mensaje, minimo, maximo):
    while True:                                  # insiste hasta que el dato valga
        try:
            valor = int(leer(mensaje))           # ValueError si no es un entero
            if not minimo <= valor <= maximo:
                raise ValueError(f'tiene que estar entre {minimo} y {maximo}')
            return valor                         # dato bueno: salimos del bucle
        except ValueError as err:                # los dos casos acaban aquí
            print(f'  Error: {err}')

El while True solo tiene una salida: el return valor, que únicamente se alcanza si las dos comprobaciones anteriores han pasado. int(leer(mensaje)) lanza ValueError si el texto no es un entero; la comparación encadenada minimo <= valor <= maximo comprueba el rango y, si falla, raise ValueError(...) lanza el nuestro. Los dos caen en except ValueError as err y se imprime err, que es el mensaje de cada uno.

Salida

=== FICHA DE MATRÍCULA ===
Nombre: Laura Pérez
Edad (16-99): doce
  Error: invalid literal for int() with base 10: 'doce'
Edad (16-99): 12
  Error: tiene que estar entre 16 y 99
Edad (16-99): 22
Nota de acceso (5-14): 15
  Error: tiene que estar entre 5.0 y 14.0
Nota de acceso (5-14): 12,5
  Error: could not convert string to float: '12,5'
Nota de acceso (5-14): 12.5
Modalidad (presencial/online): virtual
  Error: elige una de estas: presencial, online
Modalidad (presencial/online): Presencial
--- Ficha guardada ---
Nombre:    Laura Pérez
Edad:      22 años
Nota:      12.50
Modalidad: presencial

Comprobemos cada vuelta del bucle de la edad con una traza:

VueltaTecleoint()¿16 ≤ valor ≤ 99?Qué pasa
1'doce'lanza ValueError(no se llega)Mensaje de Python, otra vuelta
2'12'12No: 12 < 16Nuestro raise, otra vuelta
3'22'22Síreturn 22, se sale del bucle

La nota tiene el mismo esquema con float(): 15 se pasa de 14, 12,5 no es un número para Python (usa punto decimal) y 12.5 entra. La modalidad acepta Presencial porque .strip().lower() la deja en presencial antes de comprobarla.

Bucle de reintento con tryCuatro pasos: leer, int, comprobar el rango y return; los dos fallos vuelven a leer por el except ValueError. Debajo, los tres datos tecleados.pedir_entero('Edad (16-99): ', 16, 99)leer()int(texto)¿16 ≤ valor ≤ 99?return valorexcept ValueErrorexcept ValueError (nuestro raise)tecleoqué pasaresultado'doce'int() fallavuelta'12'12 < 16: raisevuelta'22'22 valereturn 22
Los dos errores (el de int() y el nuestro) acaban en el mismo except, que vuelve a pedir el dato: solo return sale del bucle.

El diagrama es el mismo bucle visto desde arriba: dos flechas rojas que vuelven a leer() y una sola salida verde.

Preguntas que te puedes hacer

¿Por qué lanzar un ValueError para el rango, si podría poner un print y un continue? Funcionaría, pero tendrías dos sitios que tratan errores. Con el raise, todo lo que hace que el dato no valga pasa por el mismo except y el bucle es más corto. Además, si mañana quieres registrar los fallos en un fichero, solo cambias una línea.

¿Por qué el try está dentro del while y no fuera? Si el try envolviera el bucle entero, el primer error te sacaría del bucle y ya no se volvería a pedir el dato. Dentro, cada vuelta tiene su propio try: un fallo solo estropea esa vuelta.

¿Por qué el mensaje de 'doce' sale en inglés? Porque es el de Python (invalid literal for int() with base 10). Imprimimos err tal cual. Es lo que le pasó a mi compañera con el 12,5. En el «Pruébalo tú» lo cambias por uno en español.

¿Por qué pedir_opcion tiene un try si nada en ella lanza excepciones de Python? Porque nuestro propio raise ValueError sí la lanza. El try atrapa tanto excepciones de Python como las tuyas: para él son iguales.

¿Qué pasa si el nombre llega vacío? leer('Nombre: ').strip() daría '', que es falso, y or 'Sin nombre' pone un valor por defecto. Aquí no hace falta excepción: no hay nada que «falle», solo un dato que falta.

Poner el return valor fuera del try, después del except. Si int() falla, valor no llega a existir y el return lanzaría UnboundLocalError. Devuelve el valor dentro del try, justo cuando sabes que es bueno.

Pruébalo tú

  • Haz que pedir_decimal acepte la coma: cambia float(leer(mensaje)) por float(leer(mensaje).replace(',', '.')) y comprueba que 12,5 entra a la primera.
  • Separa los dos mensajes: captura el ValueError de int() en un try interior para imprimir «la edad tiene que ser un número entero» en español, y deja el raise del rango con su mensaje.

Excepciones en Python práctica — Programa 2: Calculadora que no se rompe

Una calculadora de una línea: escribes 10 / 3 y responde = 3.333…, hasta que escribes salir. Lo que la complica es que cualquier cosa puede llegar: números que no son números, operadores que no existen, líneas a medias, divisiones entre cero y resultados tan grandes que no caben en un float. Cada error tiene que salir con su propio mensaje y la calculadora tiene que seguir funcionando.

La idea

Separamos las responsabilidades. calcular(linea) solo calcula: trocea la línea, comprueba el formato y el operador y, si algo no cuadra, lanza (raise). No imprime nada. El bucle principal es el único que captura, con un except por cada familia de error. Las operaciones están en un diccionario de funciones: así no necesitamos un if por operador.

Antes de programar, apunta qué errores lanza Python solo y cuáles tienes que lanzar tú. Dividir entre cero o un float('diez') los lanza Python. Que la línea tenga tres trozos o que ^ no sea un operador válido, no: esos son reglas tuyas.

El código

# Lo que teclearía la persona (en tu ordenador, cambia leer() por input()):
TECLEO = iter(['10 / 0', 'diez + 3', '10 ^ 3', '8 //', '2 ** 10000', '7 % 0', '2 ** 10', 'salir'])


def leer(mensaje):
    texto = next(TECLEO)     # el siguiente dato «tecleado»
    print(mensaje + texto)   # lo enseñamos como se vería en la terminal
    return texto


OPERACIONES = {                          # símbolo -> función que hace la cuenta
    '+': lambda a, b: a + b,
    '-': lambda a, b: a - b,
    '*': lambda a, b: a * b,
    '/': lambda a, b: a / b,
    '//': lambda a, b: a // b,
    '%': lambda a, b: a % b,
    '**': lambda a, b: a ** b,
}


def calcular(linea):
    partes = linea.split()
    if len(partes) != 3:                 # «8 //» solo tiene dos trozos
        raise ValueError('formato: número operación número')
    a, op, b = partes
    if op not in OPERACIONES:
        raise ValueError(f'operación desconocida: {op}')
    return OPERACIONES[op](float(a), float(b))   # float() también puede fallar


print('=== CALCULADORA (escribe «salir» para terminar) ===')
while True:
    linea = leer('> ')
    if linea == 'salir':
        print('Hasta luego')
        break
    try:
        print('  =', calcular(linea))
    except ZeroDivisionError as err:     # la lanza Python, no nosotros
        print('  Error matemático:', err)
    except OverflowError as err:         # resultado demasiado grande para un float
        print('  Demasiado grande:', err)
    except ValueError as err:            # nuestros raise y los de float()
        print('  Error:', err)

La función que decide si la línea vale:

def calcular(linea):
    partes = linea.split()
    if len(partes) != 3:                 # «8 //» solo tiene dos trozos
        raise ValueError('formato: número operación número')
    a, op, b = partes
    if op not in OPERACIONES:
        raise ValueError(f'operación desconocida: {op}')
    return OPERACIONES[op](float(a), float(b))   # float() también puede fallar

linea.split() corta por los espacios: '10 / 0' da ['10', '/', '0']. Si no son tres trozos, raise ValueError. Si el operador no está en OPERACIONES, otro raise ValueError. Y la última línea hace tres cosas que pueden fallar: float(a), float(b) y la propia operación. Ninguna está protegida aquí, a propósito: suben hasta el bucle.

Salida

=== CALCULADORA (escribe «salir» para terminar) ===
> 10 / 0
  Error matemático: float division by zero
> diez + 3
  Error: could not convert string to float: 'diez'
> 10 ^ 3
  Error: operación desconocida: ^
> 8 //
  Error: formato: número operación número
> 2 ** 10000
  Demasiado grande: (34, 'Numerical result out of range')
> 7 % 0
  Error matemático: float modulo by zero
> 2 ** 10
  = 1024.0
> salir
Hasta luego

Cada línea acaba en un except distinto:

TecleoQuién lanzaExcepciónexcept que la captura
10 / 0Python, en a / bZeroDivisionErrorError matemático
diez + 3Python, en float('diez')ValueErrorError
10 ^ 3Nosotros: operador desconocidoValueErrorError
8 //Nosotros: dos trozosValueErrorError
2 ** 10000Python: no cabe en un floatOverflowErrorDemasiado grande
7 % 0Python, en a % bZeroDivisionErrorError matemático
2 ** 10nadieningunaResultado: 2¹⁰ = 1024.0

Fíjate en los mensajes de ZeroDivisionError: cambian según la operación (float division by zero, float modulo by zero). Los escribe Python, y por eso los imprimimos con err en vez de inventar uno.

Preguntas que te puedes hacer

¿Por qué no comprobamos b == 0 antes de dividir, como en el artículo antiguo? Porque Python ya lanza ZeroDivisionError él solo, con un mensaje correcto, en /, // y %. Repetir esa comprobación a mano es código que puede equivocarse (olvidarte del %, por ejemplo) sin aportar nada.

¿Por qué OverflowError tiene su propio except y no va con ZeroDivisionError? Porque las dos son hijas de ArithmeticError y podríamos capturarlas juntas con except ArithmeticError, pero el mensaje para el usuario es distinto: no es lo mismo «no se puede dividir entre cero» que «el número es demasiado grande».

¿Por qué calcular no tiene ningún try? Porque no sabe qué hacer con el error: imprimirlo es cosa del bucle. Si mañana usas calcular desde una interfaz gráfica, querrás mostrar el error en una ventana, no en la terminal. La función lanza; quien la usa decide.

¿El orden de los except importa aquí? Entre estos tres no, porque ninguno es madre de otro (ValueError no es hija de ArithmeticError). Pero si añadieras except ArithmeticError antes de except ZeroDivisionError, este último no se ejecutaría nunca.

Meter el print del resultado y el calcular en el mismo try junto con más código (leer, comprobar salir…). Cuantas más líneas haya en el try, más difícil es saber qué lanzó el ValueError. Aquí el try tiene una sola línea.

Pruébalo tú

  • Añade la raíz cuadrada con 'raiz' como operador de un solo número (raiz 16): tendrás que aceptar dos trozos para ese caso y lanzar ValueError si el número es negativo.
  • Captura también KeyboardInterrupt en el bucle para que Ctrl+C imprima «Hasta luego» en vez del Traceback.

Excepciones en Python práctica — Programa 3: Lector de ficheros con gestión de errores

La secretaría guarda las notas de un grupo en un fichero de texto, una por línea. El fichero real nunca está limpio: hay líneas vacías, alguien escribió ocho con letras y hay un 11 que no es una nota válida. Además, el fichero puede no existir (el acta de 2027 todavía no está). El programa tiene que leer lo que pueda, avisar de lo que no y, pase lo que pase, cerrar el fichero.

La idea

Dos niveles de try. El exterior rodea todo el trabajo con el fichero: captura que no exista (FileNotFoundError) o que no tengamos permiso (PermissionError) y, en su finally, lo cierra. El interior va dentro del bucle y rodea solo la conversión de cada línea: si una línea está mal, se avisa y se pasa a la siguiente, sin interrumpir la lectura. Para que el finally pueda preguntar si hay algo que cerrar, fichero empieza valiendo None.

Antes de programar, separa los errores que afectan a todo el fichero (no existe) de los que afectan a una sola línea ('ocho'). Cada grupo va en un try a su altura.

El código

# Preparamos el fichero de ejemplo (en la secretaría ya existiría)
with open('notas.txt', 'w', encoding='utf-8') as f:
    f.write('8.5\nocho\n11\n6\n\n9.5\n4\n7\n')


def leer_notas(nombre_fichero):
    fichero = None                       # así existe aunque open() falle
    try:
        fichero = open(nombre_fichero, encoding='utf-8')
        notas = []
        for num, linea in enumerate(fichero, start=1):
            linea = linea.strip()
            if not linea:
                continue                 # línea vacía: ni error ni nota
            try:                         # try DENTRO del bucle: una línea mala no para la lectura
                nota = float(linea)
            except ValueError:
                print(f'  Línea {num} ignorada: {linea!r} no es un número')
                continue
            if not 0 <= nota <= 10:
                print(f'  Línea {num} ignorada: {nota} está fuera de rango')
                continue
            notas.append(nota)
        return notas
    except FileNotFoundError:
        print(f'  Error: no existe el fichero {nombre_fichero!r}')
        return []
    except PermissionError:
        print(f'  Error: sin permiso para leer {nombre_fichero!r}')
        return []
    finally:                             # se ejecuta con return, con error y sin error
        if fichero is not None:
            fichero.close()
            print('  (fichero cerrado)')


def informe(notas):
    if not notas:
        print('  No hay notas que analizar')
        return
    aprobados = sum(1 for n in notas if n >= 5)
    print(f'  {len(notas)} notas | media {sum(notas) / len(notas):.2f} | '
          f'máx {max(notas)} | mín {min(notas)} | aprobados {aprobados}/{len(notas)}')


for nombre in ['notas.txt', 'notas_2027.txt']:
    print('Leyendo', nombre)
    informe(leer_notas(nombre))

El corazón del programa es el bucle, con su try interior:

for num, linea in enumerate(fichero, start=1):
    linea = linea.strip()
    if not linea:
        continue                 # línea vacía: ni error ni nota
    try:                         # try DENTRO del bucle: una línea mala no para la lectura
        nota = float(linea)
    except ValueError:
        print(f'  Línea {num} ignorada: {linea!r} no es un número')
        continue
    if not 0 <= nota <= 10:
        print(f'  Línea {num} ignorada: {nota} está fuera de rango')
        continue
    notas.append(nota)

enumerate(fichero, start=1) da el número de línea (empezando en 1, como lo cuenta una persona) junto con su texto. El try solo rodea float(linea): es lo único que puede fallar. El rango se comprueba con un if normal, porque aquí no hace falta lanzar nada: basta con avisar y seguir.

Salida

Leyendo notas.txt
  Línea 2 ignorada: 'ocho' no es un número
  Línea 3 ignorada: 11.0 está fuera de rango
  (fichero cerrado)
  5 notas | media 7.00 | máx 9.5 | mín 4.0 | aprobados 4/5
Leyendo notas_2027.txt
  Error: no existe el fichero 'notas_2027.txt'
  No hay notas que analizar

Traza del bucle sobre notas.txt:

LíneaTextoQué pasanotas después
18.5Se guarda[8.5]
2ochoValueError, se ignora[8.5]
311Fuera de rango, se ignora[8.5]
46Se guarda[8.5, 6.0]
5(vacía)continue sin aviso[8.5, 6.0]
69.5Se guarda[8.5, 6.0, 9.5]
74Se guarda[8.5, 6.0, 9.5, 4.0]
87Se guarda[8.5, 6.0, 9.5, 4.0, 7.0]

Comprobación: 8,5 + 6 + 9,5 + 4 + 7 = 35, y 35 / 5 = 7,00. Aprueban todas menos el 4: 4/5. Fíjate en que «(fichero cerrado)» sale antes del informe: el finally se ejecuta al salir de leer_notas, justo antes de que el return notas entregue la lista. Con notas_2027.txt, open() falla, fichero sigue siendo None y el finally no tiene nada que cerrar.

Preguntas que te puedes hacer

¿Por qué fichero = None antes del try? Porque si open() falla, la variable fichero no llegaría a crearse y el finally daría UnboundLocalError al intentar fichero.close(). En los ejercicios lo vas a ver fallar de verdad.

¿No es más fácil usar with open(...)? Sí, y es lo que harás casi siempre: with cierra el fichero solo al terminar el bloque, incluso si hay una excepción. Aquí lo escribimos con finally para que veas qué hace with por debajo. En la agenda de los ejercicios lo usamos con with.

¿Por qué un if para el rango y no un raise? Porque estamos en el mismo sitio donde se trata el error. Lanzar una excepción para capturarla dos líneas más abajo sería dar un rodeo. raise tiene sentido cuando el que detecta y el que trata son funciones distintas, como en el programa 1.

¿Qué pasaría si el try interior no existiera? La línea 2 (ocho) lanzaría ValueError, que no es FileNotFoundError ni PermissionError, así que el try exterior no la capturaría: el finally cerraría el fichero y el programa se pararía con el Traceback. Cinco notas válidas perdidas por una línea mala.

Capturar except Exception en el try exterior «para que no falle nunca». Una línea mala acabaría ahí, se devolvería una lista vacía y no sabrías que el fichero tenía cinco notas buenas.

Pruébalo tú

  • Reescribe leer_notas con with open(...) as fichero: y comprueba que la salida es la misma (salvo el aviso «fichero cerrado», que ya no hace falta).
  • Cuenta cuántas líneas se ignoraron y devuélvelo junto con las notas: return notas, ignoradas.

Excepciones en Python práctica — Programa 4: Clase con excepciones propias: números racionales

Una clase Racional para fracciones como 3/4. Lo que la complica es que una fracción con denominador 0 no existe y que solo tiene sentido sumarla con otra fracción. Queremos que la propia clase lo impida lanzando sus propias excepciones, organizadas en una familia, y que el programa principal pida los datos hasta tener dos fracciones válidas y las sume. Usamos una clase porque es como se hace en FP2; si todavía no las has visto, quédate con la idea y vuelve después del siguiente tema, clases en Python.

La idea

Tres excepciones: ErrorRacional como madre, y ErrorDenominadorCero y ErrorTipoIncorrecto como hijas. El constructor __init__ comprueba los datos y lanza la que toque; si todo va bien, guarda la fracción simplificada con el máximo común divisor (gcd). El programa principal captura la hija concreta cuando quiere un mensaje concreto y la madre cuando le vale cualquiera de la familia.

Antes de programar, dibuja el árbol de excepciones en un papel. Si tienes claro quién es madre de quién, el orden de los except sale solo.

El código

# Lo que teclearía la persona (en tu ordenador, cambia leer() por input()):
TECLEO = iter(['3', '0', 'tres', '3', '4', '1', '2'])


def leer(mensaje):
    texto = next(TECLEO)     # el siguiente dato «tecleado»
    print(mensaje + texto)   # lo enseñamos como se vería en la terminal
    return texto

from math import gcd


class ErrorRacional(Exception):
    """Base de los errores de los números racionales."""


class ErrorDenominadorCero(ErrorRacional):
    """El denominador vale 0."""


class ErrorTipoIncorrecto(ErrorRacional):
    """Numerador o denominador no son enteros."""


class Racional:
    def __init__(self, num, den):
        if not (isinstance(num, int) and isinstance(den, int)):
            raise ErrorTipoIncorrecto('numerador y denominador deben ser enteros')
        if den == 0:
            raise ErrorDenominadorCero('el denominador no puede ser cero')
        m = gcd(num, den)                # simplificamos: 10/8 -> 5/4
        self.num, self.den = num // m, den // m

    def __str__(self):
        return f'{self.num}/{self.den}'

    def __add__(self, otro):             # r1 + r2 llama a r1.__add__(r2)
        if not isinstance(otro, Racional):
            raise ErrorTipoIncorrecto('solo se suman racionales')
        return Racional(self.num * otro.den + otro.num * self.den, self.den * otro.den)


def pedir_racional(n):
    num = int(leer(f'Numerador {n}: '))          # ValueError si no es un entero
    den = int(leer(f'Denominador {n}: '))
    return Racional(num, den)                    # ErrorRacional si no vale


print('=== SUMA DE RACIONALES ===')
while True:
    try:
        r1 = pedir_racional(1)
        r2 = pedir_racional(2)
        break
    except ErrorDenominadorCero as err:
        print('  Error de racional:', err)
    except ValueError:
        print('  Error: escribe números enteros')
print(f'{r1} + {r2} = {r1 + r2}')
try:
    print(r1 + 0.5)
except ErrorRacional as err:             # la base captura a cualquier hija
    print(f'  {type(err).__name__}: {err}')

El constructor es el guardián: ninguna fracción mala llega a existir.

def __init__(self, num, den):
    if not (isinstance(num, int) and isinstance(den, int)):
        raise ErrorTipoIncorrecto('numerador y denominador deben ser enteros')
    if den == 0:
        raise ErrorDenominadorCero('el denominador no puede ser cero')
    m = gcd(num, den)                # simplificamos: 10/8 -> 5/4
    self.num, self.den = num // m, den // m

Si num o den no son enteros, ErrorTipoIncorrecto; si den es 0, ErrorDenominadorCero. Solo si pasa las dos comprobaciones se calcula gcd y se guardan los atributos. Así, cualquier Racional que exista en tu programa es válido: no hace falta volver a comprobarlo en cada método.

Salida

=== SUMA DE RACIONALES ===
Numerador 1: 3
Denominador 1: 0
  Error de racional: el denominador no puede ser cero
Numerador 1: tres
  Error: escribe números enteros
Numerador 1: 3
Denominador 1: 4
Numerador 2: 1
Denominador 2: 2
3/4 + 1/2 = 5/4
  ErrorTipoIncorrecto: solo se suman racionales

La suma, a mano: 3/4 + 1/2 = (3·2 + 1·4) / (4·2) = 10/8, y como gcd(10, 8) = 2, queda 5/4. El artículo antiguo enseñaba 10/8 porque no simplificaba.

Recorrido de ErrorDenominadorCeroLa llamada baja del bucle principal a pedir_racional y a Racional; el raise vuelve hacia arriba, atraviesa pedir_racional y lo captura el primer except que encaja.El viaje de un raise en racional.pywhile True: try:bucle principalpedir_racional(1)sin tryRacional(3, 0)__init__llamadas (ida)raise ErrorDenominadorCerovuelta: pedir_racional no la captura, sigue subiendoexcept ErrorDenominadorCero¿es de este tipo? Sí: se ejecutaexcept ValueErrorya no se miraDespués, el while vuelve a pedir los datos: el programa no se ha roto.
Una función sin try no frena la excepción: la deja pasar hacia quien la llamó.

El diagrama sigue el primer intento: Racional(3, 0) lanza ErrorDenominadorCero dentro de __init__; pedir_racional no tiene try, así que la excepción la atraviesa y la captura el except del bucle, que vuelve a pedir los datos. El segundo intento falla antes, en int('tres'), y lo captura except ValueError.

Preguntas que te puedes hacer

¿Por qué la última suma (r1 + 0.5) la captura except ErrorRacional si lanza ErrorTipoIncorrecto? Porque ErrorTipoIncorrecto es hija de ErrorRacional. Capturar la madre es la forma de decir «cualquier problema con fracciones», y type(err).__name__ nos dice cuál ha sido.

¿Por qué en el bucle no capturamos ErrorTipoIncorrecto? Porque ahí no puede ocurrir: int() siempre devuelve un entero o lanza ValueError. Capturar algo que no puede pasar solo confunde a quien lee el código.

¿Para qué sirve la clase madre si nunca la lanzamos? Para capturar en grupo. Un programa que use tu clase puede escribir un solo except ErrorRacional y quedarse tranquilo con cualquier error de fracciones, sin conocer todas las hijas. Además, si mañana añades ErrorSimplificacion, ese except ya la cubre.

¿Y __add__? Es el método que Python llama cuando escribes r1 + r2. Lo verás a fondo en el tema de sobrecarga de operadores; aquí te basta con saber que también puede lanzar excepciones como cualquier otro método.

Comprobar el denominador en el programa principal en lugar de en __init__. Funciona hasta que alguien crea un Racional(1, 0) desde otro sitio. La validación tiene que estar donde nace el objeto.

Pruébalo tú

  • Añade ErrorDivisionCero (hija de ErrorRacional) y un método __truediv__ que la lance al dividir entre una fracción que vale 0.
  • Haz que Racional(2, -4) guarde el signo en el numerador (-1/2) y comprueba la salida de print(Racional(2, -4)).

Errores típicos en esta práctica

  • El try fuera del bucle: el primer error saca del while y ya no se vuelve a pedir el dato.
  • Un return después del except: si el try falló, la variable que devuelves no existe (UnboundLocalError).
  • Repetir lo que Python ya comprueba: if b == 0: raise ZeroDivisionError es código de más; Python lo lanza solo.
  • Un except Exception en el lector de ficheros: una línea mala vacía toda la lectura sin decirte por qué.
  • Usar la variable del fichero en el finally sin inicializarla: si open() falla, fichero.close() revienta.
  • Capturar la madre antes que la hija: el except concreto no se ejecuta nunca y nadie te avisa.

Míralo paso a paso en Python Tutor

En Python Tutor (elige Python 3) se ve muy bien cómo viaja una excepción propia. Pega este programa, una versión mínima del programa 4:

class ErrorRacional(Exception):
    pass


class ErrorDenominadorCero(ErrorRacional):
    pass


def crear(num, den):
    if den == 0:
        raise ErrorDenominadorCero('denominador cero')
    return (num, den)


for den in [4, 0]:
    try:
        r = crear(3, den)
        print('Creado:', r)
    except ErrorRacional as err:
        print('Capturado:', type(err).__name__, err)
    finally:
        print('Siguiente')

Salida:

Creado: (3, 4)
Siguiente
Capturado: ErrorDenominadorCero denominador cero
Siguiente

Qué observar:

  1. Al principio se crean las dos clases en el marco global: son objetos como cualquier otro.
  2. En la primera vuelta (den = 4), crear devuelve la tupla (3, 4) y la flecha pasa del try al finally, sin tocar el except.
  3. En la segunda vuelta, en el paso del raise, el marco de crear desaparece y la flecha salta al except ErrorRacional, aunque la excepción sea ErrorDenominadorCero.
  4. La variable r sigue valiendo (3, 4) de la vuelta anterior: la asignación de la segunda vuelta nunca terminó.
  5. «Siguiente» se imprime en las dos vueltas: es el finally.

Resumen

  • Un bucle con un try dentro y un return cuando el dato vale es el patrón para pedir datos hasta que sean correctos.
  • raise en la función que detecta el problema y except en la que sabe qué hacer: calcular lanza, el bucle captura.
  • Deja que Python lance lo que ya sabe lanzar (ZeroDivisionError, OverflowError) y usa su mensaje.
  • Dos niveles de try en los ficheros: el exterior para el fichero entero, el interior para cada línea.
  • finally (o with) garantiza que el fichero se cierra; inicializa la variable a None si usas finally.
  • Las excepciones propias en familia permiten capturar lo concreto o todo el grupo con un solo except.

Checklist: ¿lo has practicado?

  • ¿Sabes explicar por qué el try de pedir_entero va dentro del while?
  • ¿Puedes decir, para cada línea de la calculadora, quién lanza la excepción, Python o nosotros?
  • ¿Entiendes por qué «(fichero cerrado)» sale antes que el informe?
  • ¿Sabrías añadir una hija nueva a la familia de ErrorRacional y capturarla?
  • ¿Has ejecutado los cuatro programas y comprobado las salidas?
  • ¿Has intentado los «Pruébalo tú» sin mirar la teoría?

Si has respondido que sí a casi todo, estás listo para los ejercicios de excepciones en Python, donde ya no te doy el código hecho. ¡Nos vemos allí!

Siguiente paso: ejerciciosExcepciones en Python — ejercicios con un conversor, contraseñas con su familia de errores y una agenda que siempre se guarda (y el finally que cerró un fichero que no existía)

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

Sigue aprendiendo

Deja una respuesta

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