4. Estilo funcional aplicado
En esta lección aprendemos a resolver problemas encadenando transformaciones en lugar de acumular estado. Todo desde la práctica, sin teoría de paradigmas. Verás que muchas cosas que hacías con bucles y variables temporales salen más limpias así.
Transformar datos con funciones
Inmutabilidad. La idea es sencilla: en lugar de modificar los datos que tienes, creas datos nuevos a partir de ellos.
wave_heights_m = [0.8, 3.1, 1.2]
# Mutar: cambias la lista original, y quien la compartía se lleva la sorpresa
wave_heights_m.append(4.5)
# Inmutable: no tocas la original, creas una nueva con el cambio añadido
with_new_reading = [*wave_heights_m, 4.5]
¿Por qué preferir la segunda forma? Porque si nadie muta un valor, nadie te lo cambia por sorpresa a tus espaldas. Muchos bugs son "esta lista valía esto y ahora vale otra cosa, ¿quién la tocó?". Con inmutabilidad, esa pregunta no existe.
Funciones de orden superior. Son funciones que reciben o devuelven otras funciones. Suena abstracto, pero es justo lo que hace apply_to_all aquí:
def apply_to_all(function, wave_heights_m):
"""Aplica una función a cada altura de la lista."""
return [function(height) for height in wave_heights_m]
def to_feet(height_m):
return height_m * 3.281
apply_to_all(to_feet, [0.8, 3.1, 1.2]) # [2.62, 10.17, 3.94]
apply_to_all recibe otra función como argumento y la usa. map, filter y sorted(key=...), que ves justo debajo, son exactamente esto. En cuanto le coges el gusto, lo usas todo el rato.
map, filter y las comprensiones. Transformar y filtrar una secuencia sin escribir un bucle a mano:
wave_heights_m = [0.8, 3.1, 1.2, 4.5, 0.5]
# Convertir metros a pies
in_feet = [h * 3.281 for h in wave_heights_m]
# Quedarse solo con las olas operables (por debajo de 2.5 m)
operable = [h for h in wave_heights_m if h < 2.5]
Tienes también map(function, iterable) y filter(function, iterable), que hacen lo mismo. En Python, cuando la transformación es simple, una comprensión de lista suele ser más clara que un map o un filter. Usa la que se lea mejor.
Funciones lambda. Una lambda es una función anónima de una sola expresión, útil para transformaciones cortas que pasas a otra función.
wave_heights_m = [0.8, 3.1, 1.2]
# La lambda va donde se usa, sin nombre: convertir a pies al vuelo
in_feet = list(map(lambda h: h * 3.281, wave_heights_m)) # [2.62, 10.17, 3.94]
La lambda h: h * 3.281 es una función completa condensada en una expresión, y justo debajo la verás de nuevo al servicio de sorted y max. Son geniales cuando son breves, y horribles cuando alguien las estira. Si una lambda no cabe cómoda en una línea, dale un nombre y hazla una función normal.
sorted, min, max con la clave adecuada. El argumento key es el que da todo el poder. Le pasas una función que dice "ordena por esto":
readings = [
{"buoy": "A", "wave_height_m": 3.1},
{"buoy": "B", "wave_height_m": 1.2},
{"buoy": "C", "wave_height_m": 4.5},
]
# La boya con el mayor oleaje
roughest = max(readings, key=lambda r: r["wave_height_m"])
# Ordenar de menor a mayor oleaje
by_wave = sorted(readings, key=lambda r: r["wave_height_m"])
Aquí la lambda está en su salsa: corta, clara y al servicio de max y sorted.
Closures, decoradores y functools
Closures: funciones que recuerdan su contexto. Una closure es una función definida dentro de otra que "recuerda" las variables de la función exterior aunque esta ya haya terminado:
def make_wave_limit_checker(limit_m: float):
"""Crea una función que comprueba si una ola supera un límite dado."""
def is_within_limit(wave_height_m: float) -> bool:
return wave_height_m < limit_m
return is_within_limit
is_safe_for_diving = make_wave_limit_checker(1.5)
is_safe_for_crane = make_wave_limit_checker(2.5)
# La misma ola de 2.0 m, dos veredictos distintos según el límite capturado
is_safe_for_diving(2.0) # False, supera el límite del buceo
is_safe_for_crane(2.0) # True, dentro del límite de la grúa
is_within_limit recuerda el limit_m con el que se creó. Hemos fabricado dos comprobadores distintos sin repetir la lógica ni usar variables globales.
Decoradores: añadir comportamiento sin tocar la función. Un decorador envuelve una función para añadirle algo (medir tiempos, cachear, registrar) sin modificar su código:
import functools
def log_call(function):
"""Muestra el nombre de la función cada vez que se la llama."""
@functools.wraps(function)
def wrapper(*args, **kwargs):
print(f"calling {function.__name__}")
return function(*args, **kwargs)
return wrapper
@log_call
def average_wave_height(wave_heights_m: list[float]) -> float:
return sum(wave_heights_m) / len(wave_heights_m)
average_wave_height([1.0, 2.0, 3.0])
# calling average_wave_height
El @functools.wraps(function) conserva el nombre y el docstring de la función original, para que el decorador no la disfrace. Este es, por cierto, uno de los pocos sitios donde *args, **kwargs está justificado: un decorador tiene que aceptar cualquier firma.
functools.partial: fijar argumentos y reutilizar. Toma una función y le "congela" algunos argumentos, devolviendo una versión más corta:
from functools import partial
def is_operational(wave_limit_m: float, wave_height_m: float) -> bool:
return wave_height_m < wave_limit_m
is_operational_for_diving = partial(is_operational, 1.5)
is_operational_for_diving(1.2) # True
Es la prima hermana de la closure, y a menudo más corta de escribir. Volveremos a ella más adelante, al hablar de constantes y configuración.
Este patrón tiene nombre: aplicación parcial, fijar algunos argumentos y reutilizar. Quizá hayas oído hablar del currying, que se le parece pero no es lo mismo: currificar es partir una función de varios argumentos en una cadena de funciones de un solo argumento (f(a, b) se llamaría como f(a)(b)). En Python apenas se usa, porque partial y las closures cubren lo mismo de forma más directa; lo nombro solo para que reconozcas la palabra.
functools.lru_cache: memorización. Si una función es cara y la llamas muchas veces con los mismos argumentos, guarda el resultado en caché y no lo recalcules:
from functools import lru_cache
@lru_cache(maxsize=128)
def expensive_forecast(wind_speed_kn: float, hours_ahead: int) -> float:
"""Predicción costosa que solo depende de sus argumentos."""
...
La segunda vez que la llames con los mismos argumentos, la respuesta es instantánea. Eso sí, lru_cache solo funciona con funciones puras: si dependiera de la hora o de un fichero, te devolvería datos viejos. Otra razón para escribir funciones puras.
Iteradores y generadores para datos grandes
Aquí es donde el estilo funcional se pone serio con los datos marinos de verdad, esos ficheros de sensores que no caben en memoria.
Iterables e iteradores. Un iterable es algo que puedes recorrer (una lista, un fichero). Un iterador es el objeto que va entregando los elementos uno a uno, bajo demanda. Con iter sacas el iterador de un iterable, y con next le pides el siguiente valor:
wave_heights_m = [0.8, 3.1, 1.2] # un iterable
readings = iter(wave_heights_m) # su iterador
next(readings) # 0.8
next(readings) # 3.1
next(readings) # 1.2
next(readings) # StopIteration: se acabó
Cuando el iterador se queda sin elementos, levanta StopIteration. Un bucle for hace justo esto por dentro: llama a iter una vez y a next hasta que salta esa señal. La clave es que el iterador no necesita tener todo cargado a la vez, y los generadores que vienen ahora son iteradores que fabricas tú.
Generadores con yield. Un generador es una función que, en lugar de devolver todo de golpe con return, va entregando valores de uno en uno con yield. Perfecto para procesar un fichero enorme registro a registro sin cargarlo entero:
def read_wave_heights(path: str):
"""Lee alturas de ola del registro de la boya, línea a línea.
No carga el fichero entero en memoria: entrega cada valor bajo demanda.
Args:
path: Ruta del fichero, con la altura de ola en la segunda columna.
Yields:
La altura de ola de cada lectura, en metros.
"""
with open(path) as file:
next(file) # Saltar la cabecera
for line in file:
fields = line.strip().split(",")
yield float(fields[1])
Aunque el fichero tenga millones de lecturas de la boya, en memoria solo hay una línea cada vez.
Evaluación perezosa. Los generadores calculan solo lo que se usa. Míralo con un generador sin fin: si no fuera perezoso, colgaría el programa para siempre.
def wave_heights_forever():
"""Genera lecturas de ola sin fin, de una en una."""
height = 0.0
while True:
print(f"calculando {height}")
yield height
height += 0.5
heights = wave_heights_forever()
first_three = [next(heights) for _ in range(3)]
# calculando 0.0
# calculando 0.5
# calculando 1.0
El bucle es infinito, pero solo pedimos tres valores, así que el generador calcula esos tres y se detiene. Si encadenas transformaciones y al final solo miras los diez primeros resultados, el generador procesa esos diez y ni uno más. No hace trabajo de sobra.
El módulo itertools. La caja de herramientas para encadenar y agrupar secuencias con pereza. Algunas que usarás:
import itertools
# Los primeros 100 registros de un flujo largo de lecturas
first_hundred = itertools.islice(read_wave_heights("buoy.csv"), 100)
# Encadenar varios ficheros como si fueran uno
all_readings = itertools.chain(
read_wave_heights("day1.csv"),
read_wave_heights("day2.csv"),
)
Constantes y configuración sin romper la pureza
Aquí hay una tensión real que conviene entender bien, porque afecta a cómo estructuras todo tu código.
Bajo el estilo funcional, las funciones deberían ser puras. Pero una función pura no debería leer una constante global, porque entonces depende de algo externo que no aparece en su firma. Y las constantes, aunque no cambien, complican los tests: tienes que importarlas o simularlas.
El problema, con un ejemplo:
WAVE_LIMIT_M = 2.5
def is_operational(wave_height_m: float) -> bool:
return wave_height_m < WAVE_LIMIT_M
Parece inofensivo, pero ¿qué pasa si el límite operativo cambia por zona? ¿Y si donde llamas a esta función ya tienes tu propio límite? La función ha dejado de ser portable, y para probarla con otro límite tienes que tocar la constante global. Se ha vuelto frágil.
Solución 1: inyectar la constante y fijarla con partial. La función base recibe todo como parámetros, y luego creamos las versiones concretas fijando las constantes:
from functools import partial
def is_operational(wave_limit_m: float, wave_height_m: float) -> bool:
return wave_height_m < wave_limit_m
# Versiones por zona, fijando el límite de cada una
is_operational_north_sea = partial(is_operational, 2.0)
is_operational_mediterranean = partial(is_operational, 2.8)
is_operational_north_sea(1.8) # True
is_operational_mediterranean(2.5) # True
La función base es cien por cien reutilizable y trivial de probar: le pasas distintas configuraciones como argumentos y ya está. Ganamos máxima pureza. El coste es algo de complejidad visual si tu equipo no está acostumbrado a partial.
Solución 2: encapsular las constantes en un closure (patrón factory). Agrupas las funciones que comparten contexto dentro de una función constructora, sin recurrir a clases ni a estado mutable:
def create_operational_rules(wave_limit_m: float, wind_limit_kn: float):
"""Crea las reglas operativas para una zona concreta."""
def is_operational(wave_height_m: float, wind_speed_kn: float) -> bool:
return wave_height_m < wave_limit_m and wind_speed_kn < wind_limit_kn
def margin_to_limit(wave_height_m: float) -> float:
return wave_limit_m - wave_height_m
return is_operational, margin_to_limit
north_sea_operational, north_sea_margin = create_operational_rules(2.0, 18.0)
Las constantes quedan atrapadas en el closure y agrupamos lógicamente las funciones que comparten contexto. El inconveniente es que puede costar de leer si las funciones internas crecen demasiado.
El trade-off. No hay una respuesta única, y esto es lo importante que te llevas de la sección: cuanto más mira una función hacia fuera, más fácil es de escribir, pero más difícil es de probar de forma aislada. Python es multiparadigma, así que no estás obligado a casarte con un enfoque. Lo que sí debes es ser consciente del intercambio y elegir a propósito, no por inercia.
Este trabajo está bajo una licencia Attribution-NonCommercial-NoDerivatives 4.0 International.
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 libroAyúdame a seguir escribiendo
Cada café me da un empujón para escribir el siguiente artículo.
¡Claro, te invito!
Comentarios
Todavía no hay ningún comentario.