21. Interfaces claras: funciones, módulos y contratos fáciles de entender

21.1 Objetivo del tema

Una interfaz es la forma en que una parte del código se comunica con otra. En Python, una interfaz puede ser la firma de una función, los nombres que expone un módulo, el formato de un objeto de retorno o el tipo de excepción que se lanza ante un error.

En este tema veremos cómo diseñar interfaces claras para que las funciones y módulos sean fáciles de usar correctamente y difíciles de usar mal.

Objetivo práctico: mejorar contratos de funciones y módulos Python para que expresen datos esperados, resultados y errores de forma clara.

21.2 Qué es un contrato

Un contrato define qué espera una función y qué promete devolver o hacer. No siempre está escrito formalmente, pero existe cada vez que otra parte del código llama a esa función.

Un contrato claro responde:

  • Qué parámetros recibe.
  • Qué tipos o estructuras espera.
  • Qué devuelve.
  • Qué errores puede lanzar.
  • Si modifica datos recibidos o produce efectos secundarios.

21.3 Interfaz poco clara

Observa esta función:

def procesar(x, y, z=False):
    if z:
        return x * y * 1.21
    return x * y

No sabemos qué representan x, y ni z. La función puede ser correcta, pero su interfaz obliga a leer la implementación.

21.4 Interfaz más clara

IVA = 0.21


def calcular_importe(precio: float, cantidad: int, incluir_iva: bool = False) -> float:
    importe = precio * cantidad
    if incluir_iva:
        return importe * (1 + IVA)
    return importe

La firma comunica intención. Los nombres, tipos y valor por defecto ayudan a entender la función sin entrar al cuerpo.

21.5 Funciones fáciles de usar mal

Una interfaz es débil si permite llamadas ambiguas.

total = calcular_importe(2, 1000, True)

¿El primer valor es precio o cantidad? Podemos mejorar la llamada usando argumentos nombrados:

total = calcular_importe(
    precio=1000,
    cantidad=2,
    incluir_iva=True,
)

21.6 Usar keyword-only arguments

Podemos obligar a que ciertos argumentos se pasen por nombre.

def calcular_importe(precio: float, cantidad: int, *, incluir_iva: bool = False) -> float:
    importe = precio * cantidad
    if incluir_iva:
        return importe * 1.21
    return importe

Ahora la llamada debe explicar la opción:

calcular_importe(1000, 2, incluir_iva=True)

21.7 Retornos consistentes

Una función no debería devolver tipos muy distintos sin una razón clara.

def buscar_producto(codigo):
    if codigo == "A1":
        return {"codigo": "A1", "precio": 1000}
    return False

El retorno mezcla diccionario y booleano. Es mejor devolver None para ausencia o lanzar una excepción si la ausencia es un error.

def buscar_producto(codigo: str) -> Producto | None:
    if codigo == "A1":
        return Producto(codigo="A1", precio=1000)
    return None

21.8 Errores como parte del contrato

Si una función puede fallar por datos inválidos, eso forma parte de su contrato.

def calcular_descuento(total: float) -> float:
    if total < 0:
        raise ValueError("El total no puede ser negativo")
    if total > 10000:
        return 0.10
    return 0

Quien llama sabe que debe evitar totales negativos o manejar ValueError.

21.9 No mezclar resultado y error

Evita devolver textos de error cuando la función normalmente devuelve números u objetos.

def calcular_total(productos):
    if not productos:
        return "No hay productos"
    return sum(producto.precio * producto.cantidad for producto in productos)

Mejor usar una excepción o un retorno explícito de otro tipo bien modelado.

def calcular_total(productos: list[Producto]) -> float:
    if not productos:
        raise ValueError("No hay productos")
    return sum(producto.precio * producto.cantidad for producto in productos)

21.10 Interfaces de módulos

Un módulo también tiene interfaz: son los nombres que otros módulos importan y usan.

from ventas import calcular_total_venta

Si un módulo expone demasiadas funciones internas, otros módulos pueden empezar a depender de detalles que deberían permanecer privados.

21.11 Nombres privados por convención

En Python se usa un guion bajo inicial para indicar que una función es interna al módulo.

def _aplicar_redondeo(total: float) -> float:
    return round(total, 2)


def calcular_total_venta(venta: Venta) -> float:
    subtotal = calcular_subtotal(venta.productos)
    return _aplicar_redondeo(subtotal)

Esto no impide importar la función, pero comunica que no forma parte de la interfaz pública del módulo.

21.12 Documentación mínima útil

No todas las funciones necesitan docstring. Pero si el contrato tiene una regla importante, una breve explicación puede ayudar.

def calcular_total_venta(venta: Venta) -> float:
    """Devuelve el total final con descuento, impuesto y envío aplicados."""
    subtotal = calcular_subtotal(venta.productos)
    descuento = obtener_descuento(venta.cliente.tipo)
    impuesto = obtener_impuesto(venta.pais)
    total = subtotal * (1 - descuento) * (1 + impuesto)
    return round(aplicar_envio(total), 2)

El docstring no repite cada línea. Resume el contrato de la función.

21.13 Aplicación sobre ventas_demo

Una interfaz clara para el módulo ventas.py podría ser:

def calcular_total_venta(venta: Venta) -> float:
    """Calcula el total final de una venta válida."""
    ...


def calcular_subtotal(productos: list[Producto]) -> float:
    """Calcula el subtotal de productos válidos."""
    ...

Y funciones internas pueden quedar con guion bajo:

def _aplicar_envio(total: float) -> float:
    ...

21.14 Contratos verificables con pruebas

Las pruebas documentan cómo se espera usar una interfaz.

def test_calcular_total_venta_devuelve_float():
    venta = Venta(
        productos=[Producto(precio=1000, cantidad=2)],
        cliente=Cliente(tipo="nuevo"),
        pais="AR",
    )

    assert calcular_total_venta(venta) == 3920.0

Si una regla cambia, la prueba ayuda a revisar si el contrato también debe cambiar.

21.15 Evitar interfaces que exponen detalles internos

Evita obligar al usuario de una función a conocer detalles internos.

calcular_total_venta(productos, descuento=0.15, impuesto=0.21, envio=1500)

Si esos valores son reglas del sistema, la llamada debería hablar en términos del dominio:

calcular_total_venta(venta)

La función o los módulos de reglas pueden decidir descuentos, impuestos y envío.

21.16 Evitar parámetros que contradicen el dominio

Una interfaz debería impedir o desalentar combinaciones inválidas.

def crear_factura(total, pagada, pendiente):
    ...

¿Qué pasa si pagada=True y pendiente=True? Una alternativa más clara es usar un estado explícito.

def crear_factura(total: float, estado: str):
    ...

En proyectos más estrictos podríamos usar un Enum, pero el punto central es evitar contratos contradictorios.

21.17 Ejercicio guiado

Mejora la interfaz de esta función:

def hacer(p, c, t, i, e):
    total = p * c
    if i:
        total *= 1.21
    if e:
        total += 1500
    if t == "vip":
        total *= 0.85
    return total

Una posible mejora:

def calcular_total_producto(
    precio: float,
    cantidad: int,
    tipo_cliente: str,
    *,
    incluir_impuesto: bool = True,
    incluir_envio: bool = True,
) -> float:
    total = precio * cantidad
    if incluir_impuesto:
        total *= 1.21
    if incluir_envio:
        total += 1500
    if tipo_cliente == "vip":
        total *= 0.85
    return total

21.18 Ejercicio propuesto

En ventas_demo, revisa las funciones públicas y realiza estas tareas:

  • Renombra una función cuya intención no sea clara.
  • Agrega type hints a parámetros y retorno.
  • Marca como interna una función auxiliar con guion bajo si corresponde.
  • Revisa si una función devuelve tipos mezclados.
  • Agrega o ajusta una prueba que documente el contrato.
python -m mypy src
python -m ruff check src tests
python -m pytest

21.19 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Definir qué espera y qué devuelve una función.
  • Diseñar firmas con nombres expresivos.
  • Usar argumentos nombrados o keyword-only cuando reducen ambigüedad.
  • Evitar retornos de tipos mezclados.
  • Tratar errores como parte del contrato.
  • Distinguir funciones públicas e internas de un módulo.
  • Usar pruebas para documentar contratos.

21.20 Conclusión

En este tema vimos que una interfaz clara reduce errores y hace que el código sea más fácil de usar. Las firmas, retornos, errores y nombres públicos de un módulo son parte del contrato que otros programadores leerán y dependerán de él.

En el próximo tema configuraremos el proyecto con pyproject.toml para centralizar herramientas de calidad.