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.
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:
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.
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.
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,
)
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)
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
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.
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)
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.
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.
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.
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:
...
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.
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.
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.
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
En ventas_demo, revisa las funciones públicas y realiza estas tareas:
python -m mypy src
python -m ruff check src tests
python -m pytest
Antes de continuar, verifica que puedes hacer lo siguiente:
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.