Excepciones como flujo de control: EAFP vs LBYL en Python

En Python, las excepciones no son señales de alarma reservadas para catástrofes. Son parte del protocolo de comunicación entre objetos. Cuando un iterador se agota, lanza StopIteration. Cuando presionas Ctrl-C, el intérprete inyecta KeyboardInterrupt en el hilo principal. Cuando buscas una clave ausente en un diccionario, obtienes KeyError. Ninguno de esos casos es un bug — son el mecanismo diseñado para señalar condiciones que cambian el flujo normal.

Esto contrasta con lenguajes como C o Java donde levantar una excepción implica un costo tan alto (stack unwinding, manejo de memoria) que se reserva para lo extraordinario. En CPython, el costo de un try/except sin excepción es casi nulo; el costo real llega solo cuando la excepción se lanza. Ese detalle de implementación es la razón por la que el ecosistema Python adoptó una filosofía distinta.

EAFP (Easier to Ask Forgiveness than Permission) significa: intenta la operación directamente y maneja la excepción si algo sale mal. Su opuesto es LBYL (Look Before You Leap): verificar todas las precondiciones antes de actuar. LBYL es el estilo dominante en C, Java y Go — idiomático allá, torpe aquí.

El problema real con LBYL en Python no es solo verbosidad. Es que crea condiciones de carrera en entornos concurrentes: verificas que el archivo existe, luego lo abres, pero entre esas dos operaciones otro proceso lo borra. Con EAFP simplemente intentas abrirlo y manejas FileNotFoundError — una operación atómica desde tu perspectiva.

El único momento donde LBYL gana es cuando la verificación es semánticamente distinta a la operación. Validar tipos de entrada en una API pública antes de procesarlos no es LBYL defensivo — es contrato explícito. Pero dentro de la lógica de negocio, EAFP produce código más directo y más correcto.

import json
from pathlib import Path


def load_config(path: str) -> dict:
    """
    Carga configuración desde un archivo JSON.
    Usa EAFP en todos los puntos donde el estado externo puede variar.
    """
    try:
        text = Path(path).read_text(encoding="utf-8")
    except FileNotFoundError:
        # El archivo no existe: condición esperada, no un bug.
        return {}
    except PermissionError as exc:
        # Permiso denegado sí merece propagarse — el caller debe saberlo.
        raise RuntimeError(f"No se puede leer la configuración: {path}") from exc

    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise ValueError(f"Configuración malformada en {path}: {exc}") from exc


def get_timeout(config: dict, key: str, default: int = 30) -> int:
    """
    Extrae un valor entero del config. EAFP sobre el dict y sobre la conversión.
    """
    try:
        # Un solo bloque cubre tanto KeyError como TypeError/ValueError
        # si el valor existe pero no es convertible a int.
        return int(config[key])
    except KeyError:
        return default
    except (TypeError, ValueError):
        # El valor existe pero es inválido — falla ruidosamente.
        raise ValueError(
            f"La clave '{key}' debe ser un entero, se recibió: {config[key]!r}"
        )


class LazyIterator:
    """
    Iterador que muestra cómo StopIteration forma parte del protocolo,
    no es un error en ningún sentido convencional.
    """

    def __init__(self, data: list):
        self._data = data
        self._index = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self._index >= len(self._data):
            raise StopIteration  # señal de protocolo, no excepción de error
        value = self._data[self._index]
        self._index += 1
        return value


if __name__ == "__main__":
    config = load_config("settings.json")  # devuelve {} si no existe, no explota

    timeout = get_timeout(config, "timeout")
    retries = get_timeout(config, "retries", default=3)

    print(f"timeout={timeout}, retries={retries}")

    # El for loop consume StopIteration internamente — nunca lo ves.
    for item in LazyIterator([10, 20, 30]):
        print(item)

Desglosando las decisiones

En load_config, capturar FileNotFoundError y devolver {} es EAFP puro: no verificamos si el archivo existe con Path.exists() antes de leerlo — simplemente lo intentamos. La variante LBYL sería:

if Path(path).exists():
    text = Path(path).read_text()  # puede fallar igualmente

Ese código tiene el problema ya mencionado: entre exists() y read_text() el archivo puede desaparecer. Además, exists() no cubre PermissionError. La versión EAFP es más corta y más correcta.

Fíjate en el uso de raise ... from exc. Esto preserva la cadena de excepciones: cuando alguien vea el traceback, verá tanto la causa original (json.JSONDecodeError) como el contexto que tú añadiste. Sin from exc perderías esa trazabilidad.

En get_timeout, agrupar KeyError, TypeError y ValueError en el mismo try es una decisión deliberada: el comportamiento deseado es el mismo para “clave ausente” y “clave presente con valor inválido” en el primer caso, pero diferente en el segundo — por eso están en cláusulas except separadas. El agrupamiento no es pereza, es modelado explícito de qué condiciones llevan a qué respuesta.

El LazyIterator muestra algo que se olvida fácilmente: StopIteration es parte del data model de Python, tan normal como __len__ o __getitem__. El for loop llama a __next__() en un try implícito y termina cuando atrapa StopIteration. Nada en esto es excepcional en el sentido de “algo salió mal”.

Errores que debes conocer

Error: Usar except Exception para atrapar StopIteration dentro de un generador, lo que puede silenciar la terminación del iterador interior y producir un loop infinito o comportamiento indefinido.

# ❌ Wrong
def bad_generator(it):
    while True:
        try:
            yield next(it)
        except Exception:  # atrapa StopIteration — el generador nunca termina limpiamente
            break

# ✅ Right
def good_generator(it):
    while True:
        try:
            yield next(it)
        except StopIteration:  # captura solo lo que controlas
            return

Desde Python 3.7 (PEP 479), si StopIteration burbujea fuera de un generador se convierte en RuntimeError, así que ser preciso aquí no es opcional.

Error: Aplicar EAFP donde la excepción indica un bug real, no una condición esperada, tragándosela silenciosamente.

# ❌ Wrong
try:
    result = process(data)
except Exception:
    result = None  # ¿qué falló? nadie lo sabe jamás

# ✅ Right
try:
    result = process(data)
except ValueError as exc:
    logger.warning("Dato inválido, usando valor por defecto: %s", exc)
    result = None

Ser específico en except es el corolario directo de EAFP: si intentas algo y falla, necesitas saber exactamente qué tipo de falla ocurrió para responder de forma apropiada.

75

Dejar un comentario

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

Scroll al inicio