Rocío Benítez

Buenas prácticas al escribir funciones en Python

  • Python
Logo de Python sobre fondo neutro

Es fácil caer en la trampa de “que funcione y ya”. Pero las funciones bien diseñadas son la base de proyectos eficientes, mantenibles y colaborativos.

En este post resumo prácticas para mejorar la calidad de tu código en Python, poniendo foco en nomenclatura, docstrings, descomposición y control de efectos secundarios.

Empieza nombrando correctamente

Pasamos más tiempo leyendo código que escribiéndolo. Unas convenciones de nombres claras actúan como un mapa.

En Python, la convención recomendada es usar snake_case (minúsculas y palabras separadas por guiones bajos).

Usa un verbo de acción

Las funciones ejecutan acciones (hacen cosas). Empieza con un verbo y di qué hace. Ejemplos: calculate_total, validate_input o fetch_user_data.

Esto mejora la legibilidad.

Sé específico

Evita nombres vagos y genéricos.

Cada nombre debe transmitir instantáneamente el propósito de la función, eliminado la necesidad de adivinar o descifrar abreviaturas.

Un nombre como process_data no dice nada. ¿Qué datos? ¿Qué proceso? Mejor algo como process_payment_data_from_api, es mucho más útil (aunque sea más largo). La claridad gana a la brevedad. calc_avg es corto, sí, pero calculate_average no deja lugar a dudas (es más informativo).

Mantén un vocabulario coherente en todo el proyecto. Si usas client en una función, no alternes con customer o user en otra sin motivo.

Funciones de uso interno

Si una función está destinada a un uso interno dentro de un módulo y no debe accederse a ella directamente desde el exterior, antepón a su nombre un guión bajo (_). Por ejemplo, _calculate_internal_metrics.

Esto indica intención de uso.

Si devuelve un booleano, que se note

Usa prefijos como is_, has_ o can_ para dejar claro que devuelven un valor booleano (Verdadero/Falso).

Veamos un par de ejemplos:

from datetime import datetime

def is_premium_member(user):
    # Verifica si el usuario tiene membresía premium
    return user.membership_level == 'premium'

def has_active_subscription(account):
    # Comprueba si la cuenta tiene suscripción activa
    return account.subscription_end_date > datetime.now()

Docstrings: Documentación para el código

Piensa en las docstrings como manuales mínimos para usar bien la función. Debe explicar el propósito de la función, sus entradas, salidas y posibles dificultades o errores relevantes.

Incluye, idealmente:

  • Qué hace la función: propósito real y las condiciones en las que falla.
  • Qué parámetros recibe (argumentos): nombre, tipo y significado. Si es opcional, di el comportamiento por defecto.
  • Qué devuelve: tipo y significado del valor de retorno (si no devuelve nada, indica None).
  • Qué excepciones puede lanzar: documenta en Raises qué puede lanzar y cuándo (ayuda en tests).
  • Ejemplos (opcional) para funciones complejas.

Evita la jerga técnica innecesaria y repeticiones. Busca la claridad.

Veamos todo esto con un ejemplo.

from datetime import datetime
from typing import Iterable

def parse_events(
    rows: Iterable[dict],
    *,
    tz: str | None = None
) -> list[dict]:
    """Convierte filas crudas en eventos normalizados.

    Limpia campos, parsea fechas ISO y ajusta zona horaria si se indica.

    Args:
        rows: Secuencia de dicts con claves 'title' y 'date' (ISO 8601).
        tz: Identificador de zona horaria (opcional). Si no se pasa, se asume UTC.

    Returns:
        Lista de eventos con las claves: 'title' (str) y 'dt' (datetime).

    Raises:
        KeyError: Si falta alguna clave obligatoria en una fila.
        ValueError: Si 'date' no es una fecha ISO válida.
    """
    # Nota: ejemplo didáctico; en producción usar librerías de zona horaria
    out: list[dict] = []
    for row in rows:
        # Validamos presencia de claves
        title = row["title"]               # Lanza KeyError si falta
        raw_date = row["date"]             # Ídem

        # Parseamos ISO de forma simple
        try:
            dt = datetime.fromisoformat(raw_date)  # Puede lanzar ValueError
        except ValueError as exc:
            raise ValueError(f"Invalid ISO date: {raw_date}") from exc

        # Ajuste de tz (placeholder para el ejemplo)
        if tz:
            # En un caso real aplicaríamos la zona horaria aquí
            pass

        out.append({"title": title.strip(), "dt": dt})
    return out

Este docstring y los type hints hacen que la función sea más fácil de usar sin necesidad de leer todo el código. Sabes qué pasarle, qué te va a devolver y qué errores puede dar.

La firma tipada define el “contrato”; el docstring explica el comportamiento y cuándo falla.

✍🏼 Tip: Mantén los docstrings actualizados a medida que cambie el código.

Cuándo dividir: el arte de la descomposición

El Principio de Responsabilidad Única (SRP) desempeña un papel fundamental a la hora de determinar cuándo descomponer una función. Establece que una clase o módulo (y, por extensión, una función) sólo debe tener una razón para cambiar. En términos más sencillos:

Una función debe hacer una sola cosa y hacerla bien.

Hay varios indicadores que llevan a la necesidad de descomponer.

Una función, una responsabilidad

Una función debe ser concisa pero completa. Una función demasiado corta puede resultar críptica y carecer de contexto, mientras que una función demasiado larga se convierte en un enredo difícil de entender y mantener.

Una señal de que tu función hace demasiadas cosas es su longitud. No hay una regla estricta, pero si no te cabe en la pantalla, sospecha. Probablemente puedas dividirla en funciones más pequeñas y claras.

Sobrecarga cognitiva

Si la comprensión de la lógica de una función requiere un esfuerzo mental significativo, o si necesitas releerla varias veces para comprender sus complejidades, es probable que estés haciendo demasiado.

La descomposición puede hacer que la lógica sea más manejable y fácil de seguir, reduciendo la carga cognitiva y mejorando la legibilidad del código.

Testabilidad

Las funciones largas y enrevesadas son difíciles de probar a fondo. Al dividirlas en unidades más pequeñas y específicas, se crean funciones más fáciles de probar de forma aislada, lo que garantiza una mejor calidad del código y reduce la probabilidad de que se produzcan errores ocultos.

Mini-ejemplo de refactorización

def process_and_store(csv_path: str, db):
    """Demasiadas cosas: lee, valida, transforma y guarda."""
    # Leer archivo
    # Validar filas
    # Transformar a modelo
    # Guardar en DB
    ...

# Corte por responsabilidades

def load_rows(csv_path: str) -> list[dict]:
    """Lee y devuelve filas crudas."""
    ...

def normalize_rows(rows: list[dict]) -> list[dict]:
    """Valida y transforma filas."""
    ...

def persist_rows(rows: list[dict], db) -> int:
    """Guarda y devuelve el número de inserciones."""
    ...

def import_csv(csv_path: str, db) -> int:
    """Orquesta el flujo sin mezclar lógica y E/S."""
    raw = load_rows(csv_path)
    normalized = normalize_rows(raw)
    return persist_rows(normalized, db)

📌 Recuerda que la longitud de las funciones es una directriz, no una regla rígida. El SRP, junto con consideraciones de carga cognitiva y comprobabilidad, deben guiar tus decisiones sobre cuándo descomponer las funciones.

Evita los efectos secundarios (cuando puedas)

Una función tiene efectos secundarios si, además de devolver un valor, cambia algo fuera de ella: variables globales, argumentos mutados, E/S, print, escritura en archivos/DB, etc. Esto dificulta el razonamiento y la depuración.

Eso vuelve el sistema impredecible y difícil de depurar.

Veamos un mal ejemplo muy común: (modifica la lista que recibe):

# (MAL) Esta función modifica la lista que le pasas. Es un efecto secundario.
def remove_invalid_emails(user_list: list[dict]):
    """Elimina usuarios con emails inválidos de una lista."""
    for user in user_list[:]:  # Iteramos sobre una copia para poder modificar la original
        if "@" not in user.get("email", ""):
            user_list.remove(user)

Esto es mucho mejor (no modifica nada, solo devuelve una nueva lista):

def get_users_with_valid_emails(user_list: list[dict]) -> list[dict]:
    """Devuelve una nueva lista con solo los usuarios que tienen emails válidos."""
    valid_users = []
    for user in user_list:
        if "@" in user.get("email", ""):
            valid_users.append(user)
    return valid_users

# Así se usaría:
all_users = [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob-no-email"},
    {"name": "Charlie", "email": "charlie@example.com"}
]

# La lista original no se toca, creamos una nueva.
valid_users = get_users_with_valid_emails(all_users)
# all_users sigue intacta. Todo es predecible.

Otras acciones con coste oculto

Hay otras acciones además de jugar con variables globales que pueden tener consecuencias no deseadas en tu código: imprimir dentro de la función, escribir en archivos o bases de datos, o lanzar excepciones como mecanismo de control de flujo.

Mejor separa esas responsabilidades: la lógica pura por un lado, la E/S y el manejo de errores, por otro. El resultado es más predecible y fácil de mantener.

Por qué son importantes las funciones puras

Las funciones puras devuelven siempre lo mismo para las mismas entradas y no tocan nada fuera. ¿Qué ganamos?

  • Tests simples
  • Reutilización sin sorpresas
  • Paralelismo más seguro: no se pisarán accidentalmente unas a otras cambiando datos compartidos.

Cuando tu código se compone principalmente de funciones puras, es mucho más fácil entender cómo funciona. Puedes mirar cada función individualmente y saber exactamente lo que hará basándose en la información que le des.

Si priorizas el paso de argumentos sobre la modificación de variables globales y te esfuerzas por conseguir funciones puras, crearás código Python que no sólo es funcional, sino también elegante, fácil de mantener y de colaborar.

La realidad

No todas las funciones serán perfectas. A veces necesitas una función de 30 líneas porque dividirla más sería artificial. En otros casos, una variable global puede ser la opción más práctica.

Lo importante es ser consciente de las decisiones que tomas, minimizar sorpresas y documentar el porqué cuando te sales de la línea general 😊.