2. Base profesional y funciones bien hechas

En esta lección aprendemos a trabajar como se trabaja en un equipo y a escribir funciones que se puedan reutilizar y probar. Es la base sobre la que se apoya todo lo demás.

Entender el entorno que hemos montado

Damos por hecha la puesta a punto del entorno de la lección anterior. Aquí no repasamos el cómo, sino el porqué, que es lo que se olvida.

Por qué un entorno virtual por proyecto. "Aislar dependencias" suena a jerga, pero es muy concreto. Imagina que el proyecto A necesita la versión 2 de una librería y el proyecto B la versión 5. Si instalas todo en el sistema, uno de los dos se rompe. El entorno virtual le da a cada proyecto su propia caja de librerías, y así ninguno molesta al otro.

Qué guarda el pyproject.toml. Es la ficha del proyecto. Ahí quedan anotadas las dependencias y sus versiones. Cuando un compañero copia el proyecto y ejecuta uv sync, obtiene exactamente las mismas versiones que tú. Eso es lo que hace el proyecto reproducible, y lo que elimina el clásico "en mi máquina funciona".

Estructura de carpetas. Vamos a organizar el proyecto así:

metocean-tools/
├── pyproject.toml
├── src/
│   └── metocean_tools/
│       ├── __init__.py
│       └── waves.py
└── tests/
    └── test_waves.py

El código vive en src/, los tests en tests/. Separarlos no es manía: deja claro qué es producto y qué es andamiaje, y evita que empaquetes por error tus pruebas cuando distribuyas la herramienta.

Formateo automático con Ruff. El formateo deja de ser tu problema. Guardas, y Ruff coloca los espacios, las comas y los saltos de línea. Nunca más una discusión sobre si tabuladores o espacios (son cuatro espacios, por cierto, y lo decide la herramienta, no tú).

Repaso de Python con buenas prácticas

Antes de las funciones, cuatro costumbres que marcan la diferencia entre un script y código profesional.

Nombres que se explican solos. El nombre de una variable es documentación gratis. Compara:

# Cuesta saber qué es esto
wh = 3.2

# No hay ninguna duda
wave_height_m = 3.2

wave_height_m te dice qué mide (altura de ola) y en qué unidad (metros). En ingeniería marina las unidades matan: un valor en pies donde esperabas metros es un error caro. Ponlas en el nombre y ahórrate sustos.

Elegir la estructura de datos adecuada. Cada estructura tiene su momento:

  • Lista (list): una secuencia ordenada que va a cambiar. Las lecturas de un sensor a lo largo del tiempo.
  • Tupla (tuple): una secuencia ordenada que no cambia. Una coordenada (latitude, longitude).
  • Diccionario (dict): pares clave-valor para buscar por nombre. Un registro {"wave_height_m": 3.2, "wind_speed_kn": 18.0}.
  • Conjunto (set): elementos únicos sin orden, para comprobar pertenencia rápido. Los identificadores de las boyas que ya hemos procesado.

Elegir bien la estructura te ahorra bucles y errores. Si necesitas "¿he visto ya esta boya?", un set te lo responde al instante; una lista te obliga a recorrerla entera.

Además intenta usar siempre variables locales. Una variable global es una variable que cualquier parte del programa puede leer y cambiar. Suena cómodo, y es una trampa: cuando algo va mal, no sabes quién la tocó ni cuándo. El código deja de ser predecible y los tests se vuelven un infierno, porque el resultado de una función depende de un estado que no ves en su firma. Volveremos sobre esto en la lección de estilo funcional, porque es justo lo contrario de una función pura.

PEP 8 es el acuerdo de la comunidad Python sobre cómo se escribe el código: cuántos espacios, cómo se nombran las cosas, dónde van los saltos de línea. No lo tienes que memorizar. Ruff lo conoce por ti y lo aplica al guardar. Pero conviene saber que existe y por qué: cuando todo el mundo escribe igual, leer el código ajeno cuesta menos.

Funciones bien hechas

Aquí está el corazón de la lección. Una función bien hecha es la unidad básica de código reutilizable y comprobable. Si aprendes a escribirlas bien, medio curso está ganado.

Una función, una responsabilidad. Si tienes que usar la palabra "y" para describir lo que hace una función ("valida y guarda y notifica"), probablemente sean tres funciones. Una función que hace una sola cosa es más fácil de nombrar, de probar y de reutilizar.

Funciones puras. Este es el concepto más importante de todo el curso, así que párate aquí.

Una función pura es aquella que, con la misma entrada, devuelve siempre la misma salida, y que no tiene efectos secundarios (no toca variables externas, no escribe en disco, no imprime, no consulta la hora ni la red).

¿Por qué nos importan tanto? Porque son predecibles. Una función pura es una calculadora: metes unos datos, sale un resultado, y siempre el mismo. Eso la hace trivial de probar (le das entradas conocidas y compruebas salidas conocidas) y trivial de reutilizar (funciona igual aquí que allá, porque no depende de nada de fuera).

Compara una función pura con una que no lo es:

# Pura: con la misma entrada, la misma salida, y no toca nada externo
def to_feet(height_m: float) -> float:
    return height_m * 3.281


# Impura: su resultado depende de un estado externo y además imprime
alert_count = 0


def register_alert(wave_height_m: float) -> int:
    global alert_count
    alert_count += 1  # muta una variable de fuera
    print("alerta registrada")  # efecto secundario
    return alert_count

to_feet la puedes llamar mil veces y siempre responde lo mismo. register_alert devuelve un número distinto en cada llamada y deja rastro por el camino: es imposible de predecir sin saber cuántas veces se llamó antes. La primera es un regalo para probar; la segunda, un quebradero.

Preferir funciones a clases cuando solo necesitas transformar datos. Si lo único que haces es "entra un dato, sale otro", no necesitas una clase con estado. Una función pura es más simple y más directa.

Cuántos argumentos debe tener una función. Una regla que funciona bien en la práctica:

  • Ideal: de 0 a 2 argumentos.
  • Aceptable: 3 o 4, si están bien justificados o usan valores por defecto.
  • Mala señal: de 5 en adelante. Suele indicar que la función hace demasiado.

¿Y si de verdad necesito muchos datos? Tienes herramientas para no romper la regla.

Agrupar argumentos relacionados en un dataclass. En lugar de arrastrar una lista larga de parámetros sueltos, los metes en un registro con nombre:

from dataclasses import dataclass


@dataclass
class SeaState:
    """Estado de la mar en un instante.

    Attributes:
        wave_height_m: Altura de ola, en metros.
        wind_speed_kn: Velocidad del viento, en nudos.
        water_temperature_c: Temperatura del agua, en grados Celsius.
    """

    wave_height_m: float
    wind_speed_kn: float
    water_temperature_c: float


def is_operational(sea_state: SeaState) -> bool:
    """Indica si se puede trabajar con este estado de la mar."""
    return sea_state.wave_height_m < 2.5 and sea_state.wind_speed_kn < 20.0

La función recibe un solo objeto con todo lo que necesita, y de paso el dataclass documenta qué es cada campo y en qué unidad. Aquí la clase no tiene lógica, es un mero registro de datos, que es justo el uso que le damos a las clases en este curso.

Argumentos solo por nombre (keyword-only con *). Todo lo que va después de un * en la firma hay que pasarlo por nombre. Esto hace que la llamada se lea sola:

def configure_alert(
    parameter: str,
    *,
    warning_threshold: float,
    critical_threshold: float,
) -> None:
    """Configura una alerta para un parámetro de la boya."""
    ...


# Se entiende sin ir a mirar la firma
configure_alert("wave_height_m", warning_threshold=2.5, critical_threshold=4.0)

Sin el *, alguien podría llamar configure_alert("wave_height_m", 2.5, 4.0) y tendrías que ir a la definición para saber cuál umbral es cuál.

Argumentos por defecto y el peligro de los mutables. Un valor por defecto está bien para lo habitual. Pero hay una trampa clásica en Python: nunca uses un valor por defecto mutable (una lista, un diccionario). Se crea una sola vez y se comparte entre todas las llamadas, con resultados desconcertantes.

# MAL: la lista se comparte entre llamadas
def append_reading(reading: float, readings: list = []) -> list:
    readings.append(reading)
    return readings


# BIEN: None como centinela y creas la lista dentro
def append_reading(reading: float, readings: list | None = None) -> list:
    if readings is None:
        readings = []
    readings.append(reading)
    return readings

*args y **kwargs no son una excusa para esquivar el diseño. Es tentador poner *args, **kwargs para "aceptar lo que sea", pero eso solo esconde los argumentos: quien lee la firma ya no sabe qué espera la función. Son útiles en sitios concretos (decoradores, envoltorios, APIs muy genéricas), no para no pensar el diseño.

Valores de retorno claros frente a efectos secundarios ocultos. Prefiere que una función devuelva su resultado a que lo esconda modificando algo por detrás. Si una función calcula la altura media de ola, que la devuelva; que no la guarde en una variable global "para que la use otro". Los efectos ocultos son el origen de la mayoría de los bugs difíciles.

Anotaciones de tipos (type hints). Documentan qué entra y qué sale, y tu editor las usa para avisarte de errores antes de ejecutar:

def average_wave_height(wave_heights_m: list[float]) -> float:
    ...

De un vistazo sabes que entra una lista de floats y sale un float. No es obligatorio para que Python funcione, pero es una red de seguridad barata.

Docstrings. Describen la intención, no repiten el código. Un buen docstring explica el porqué y las unidades, no el cómo:

def average_wave_height(wave_heights_m: list[float]) -> float:
    """Calcula la altura media de ola de una serie de lecturas.

    Args:
        wave_heights_m: Alturas de ola, en metros.

    Returns:
        La altura media, en metros.
    """
    ...

Deja que Ruff vigile el exceso de argumentos. Puedes automatizar la regla de los argumentos añadiendo esto al pyproject.toml:

[tool.ruff.lint]
select = ["PLR0913"]

A partir de ahí Ruff te avisa cada vez que una función pasa de cinco argumentos. Y si prefieres un límite más estricto:

[tool.ruff.lint]
select = ["PLR0913"]

[tool.ruff.lint.pylint]
max-args = 4

Ejemplo guía: la altura media de ola

Vamos a montar el proyecto metocean-tools de verdad y a convertir un cálculo disperso en una función pura y bien anotada.

Imagina que heredas un script que calcula la altura media de ola mezclado con lecturas de un fichero, prints por medio y una constante global. Es lo típico. Vamos a extraer el cálculo a una función pura, en src/metocean_tools/waves.py:

def average_wave_height(wave_heights_m: list[float]) -> float:
    """Calcula la altura media de ola de una serie de lecturas.

    Args:
        wave_heights_m: Alturas de ola, en metros.

    Returns:
        La altura media, en metros.
    """
    return sum(wave_heights_m) / len(wave_heights_m)

Fíjate en lo que hemos conseguido: no lee ficheros, no imprime, no depende de ninguna constante global. Le das una lista de alturas y te devuelve un número, siempre el mismo. Es pura, es reutilizable y, como veremos en la lección siguiente, es un regalo para probar.

Queda un cabo suelto: ¿qué pasa si la lista está vacía? De momento el código fallaría con un error de división. Lo dejamos anotado en la cabeza, porque en la siguiente lección vamos a aprender a manejar ese caso con elegancia.

Desafíos de programación atemporales y multiparadigmáticos

Desafíos de programación atemporales y multiparadigmáticos

Te encuentras ante un librillo de actividades, divididas en 2 niveles de dificultad. Te enfrentarás a los casos más comunes que te puedes encontrar en pruebas técnicas o aprender conceptos elementales de programación.

Comprar el libro

Ayúdame a seguir escribiendo

Cada café me da un empujón para escribir el siguiente artículo.

Comentarios

Todavía no hay ningún comentario.