Programación funcional en Python: map, filter, reduce y más
- ¿Qué es la programación funcional?
- Funciones puras e inmutabilidad
- Funciones lambda: funciones anónimas
- map() y filter(): transformar y filtrar
- reduce() y acumuladores
- Closures: funciones que recuerdan su contexto
- functools: partial, lru_cache y compose
- itertools: combinatoria y pipelines lazy
- Preguntas frecuentes
Llegamos al Módulo 8 — Python avanzado. Hasta aquí hemos aprendido a escribir código que funciona. En este módulo aprendemos a escribir código que razona: estructurado según principios que hacen que la corrección sea demostrable, el comportamiento predecible y la composición natural. La programación funcional es uno de esos principios. No es una moda ni un paradigma exótico: es una forma de pensar que lleva décadas produciendo el software más fiable que existe.
En esta lección aprendemos los fundamentos funcionales que Python soporta de primera clase: funciones puras, inmutabilidad, lambdas, map, filter, reduce, closures y los módulos functools e itertools. No vas a reescribir todo tu código en estilo funcional — Python no es Haskell — pero vas a tener una herramienta más en el cinturón que te permitirá resolver ciertos problemas con menos código, más claridad y cero estado oculto.
¿Qué es la programación funcional?
La programación funcional (FP) es un paradigma que trata la computación como evaluación de funciones matemáticas. Tiene tres pilares fundamentales: funciones puras (sin efectos secundarios), inmutabilidad (los datos no se modifican, se transforman en copias nuevas) y composición (los programas se construyen combinando funciones simples). En contraposición al paradigma imperativo, donde el programa es una secuencia de instrucciones que modifican el estado, FP describe qué se quiere calcular, no cómo hacerlo paso a paso.
# ── Estilo imperativo: el programa modifica estado ────────────
numeros = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
resultado = []
for n in numeros:
if n % 2 == 0:
resultado.append(n * n)
# ── Estilo funcional: transformaciones sin estado mutable ─────
from functools import reduce
pares_al_cuadrado = list(map(lambda n: n * n,
filter(lambda n: n % 2 == 0, numeros)))
# ── Estilo Pythónico (comprensión): el equilibrio pragmático ──
pares_al_cuadrado = [n * n for n in numeros if n % 2 == 0]
# Los tres producen el mismo resultado: [4, 16, 36, 64, 100]
# La comprensión es la preferida en Python por legibilidad,
# pero map/filter brillan en pipelines con funciones ya definidas.
# ── Las funciones son ciudadanos de primera clase en Python ───
def aplicar(funcion, valor):
return funcion(valor)
def doble(x): return x * 2
def cuadrado(x): return x ** 2
print(aplicar(doble, 5)) # 10
print(aplicar(cuadrado, 5)) # 25
transformaciones = [doble, cuadrado, abs, str]
for fn in transformaciones:
print(fn(-3)) # -6, 9, 3, '-3'
for es perfectamente aceptable. La idea es conocer las herramientas funcionales y usarlas cuando simplifican el código, no sustituir todos los bucles por map() para parecer más sofisticado.
Funciones puras e inmutabilidad
Una función pura cumple dos condiciones: dado el mismo input siempre produce el mismo output, y no tiene efectos secundarios (no modifica variables externas, no hace I/O, no muta sus argumentos). Las funciones puras son la base de la testabilidad: si no tienen efectos secundarios, cada test es aislado por definición, sin necesidad de mocks ni fixtures de setup.
# ── Función IMPURA: depende de estado externo ─────────────────
descuento_global = 0.1
def calcular_precio_impuro(precio: float) -> float:
return precio * (1 - descuento_global)
# si alguien cambia descuento_global, la función da resultados distintos
# ── Función PURA: solo depende de sus argumentos ─────────────
def calcular_precio(precio: float, descuento: float) -> float:
return precio * (1 - descuento)
# Siempre: calcular_precio(100, 0.1) == 90.0
# Testeable directamente, sin setup, sin mocks
# ── Mutabilidad: el error más silencioso de Python ────────────
# MAL: mutar el argumento de entrada
def agregar_impuesto_malo(precios: list[float], tasa: float) -> list[float]:
for i in range(len(precios)):
precios[i] *= (1 + tasa) # ← MUTA la lista original
return precios
mis_precios = [10.0, 20.0, 30.0]
resultado = agregar_impuesto_malo(mis_precios, 0.21)
print(mis_precios) # [12.1, 24.2, 36.3] ← la lista original cambió sin aviso
# BIEN: devolver una transformación, no mutar
def agregar_impuesto(precios: list[float], tasa: float) -> list[float]:
return [p * (1 + tasa) for p in precios]
mis_precios = [10.0, 20.0, 30.0]
con_iva = agregar_impuesto(mis_precios, 0.21)
print(mis_precios) # [10.0, 20.0, 30.0] ← intacta
print(con_iva) # [12.1, 24.2, 36.3]
# ── Estructuras inmutables: tuple, frozenset, namedtuple ──────
from collections import namedtuple
from typing import NamedTuple
Punto = namedtuple('Punto', ['x', 'y'])
p1 = Punto(3, 4)
p2 = p1._replace(x=10) # nuevo Punto, p1 intacto
print(p1) # Punto(x=3, y=4)
print(p2) # Punto(x=10, y=4)
class Punto3D(NamedTuple):
x: float
y: float
z: float = 0.0
p3d = Punto3D(1.0, 2.0, 3.0)
print(p3d.x, p3d.z) # 1.0 3.0
# frozenset: set inmutable — útil como clave de diccionario
permisos_admin = frozenset({'leer', 'escribir', 'borrar'})
permisos_lector = frozenset({'leer'})
print(permisos_lector.issubset(permisos_admin)) # True
Funciones lambda: funciones anónimas
Una expresión lambda crea una función anónima en una sola línea: lambda parámetros: expresión. Su poder no está en hacer cosas que def no pueda hacer — puede hacer exactamente lo mismo — sino en la concisión cuando la función es simple y se usa en un contexto donde una función completa sería verbosa.
# ── Sintaxis básica ───────────────────────────────────────────
cuadrado = lambda x: x ** 2
suma = lambda a, b: a + b
print(cuadrado(5)) # 25
print(suma(3, 4)) # 7
# ── Caso de uso principal: key= en sorted(), min(), max() ────
empleados = [
{"nombre": "Ana", "salario": 55000, "departamento": "Ingeniería"},
{"nombre": "Carlos", "salario": 42000, "departamento": "Marketing"},
{"nombre": "Beatriz","salario": 68000, "departamento": "Ingeniería"},
{"nombre": "David", "salario": 48000, "departamento": "Marketing"},
]
por_salario = sorted(empleados, key=lambda e: e["salario"], reverse=True)
mejor_pagado = max(empleados, key=lambda e: e["salario"])
print(f"Mejor pagado: {mejor_pagado['nombre']}")
# Ordenar por varios criterios
ordenado = sorted(empleados,
key=lambda e: (e["departamento"], -e["salario"]))
# ── Lambda en pipelines ───────────────────────────────────────
transformaciones = [
lambda x: x * 2,
lambda x: x + 10,
lambda x: x ** 2,
]
valor = 5
for fn in transformaciones:
valor = fn(valor)
print(valor) # ((5*2)+10)^2 = 400
# ── Limitaciones: solo una expresión ─────────────────────────
clasificar = lambda n: "par" if n % 2 == 0 else "impar"
print(clasificar(7)) # 'impar'
# PEP 8: desaconseja asignar lambdas a variables
# doble = lambda x: x * 2 ← en traceback aparece como ""
# Usa def en ese caso
map() y filter(): transformar y filtrar
map(funcion, iterable) aplica la función a cada elemento y devuelve un iterador lazy. filter(funcion, iterable) devuelve solo los elementos para los que la función devuelve True. Ambos son lazy: no calculan nada hasta que se itera sobre ellos, lo que los hace eficientes con grandes datasets.
# ── map(): transformar cada elemento ─────────────────────────
numeros = [1, 2, 3, 4, 5]
cuadrados = list(map(lambda x: x ** 2, numeros)) # [1, 4, 9, 16, 25]
como_texto = list(map(str, numeros)) # ['1', '2', '3', '4', '5']
palabras = [" hola ", "mundo ", " python"]
limpias = list(map(str.strip, palabras)) # ['hola', 'mundo', 'python']
# map con múltiples iterables
a = [1, 2, 3]; b = [10, 20, 30]
sumas = list(map(lambda x, y: x + y, a, b)) # [11, 22, 33]
# ── filter(): seleccionar elementos ───────────────────────────
numeros = range(1, 21)
pares = list(filter(lambda n: n % 2 == 0, numeros))
no_nulos = list(filter(None, [0, 1, "", "hola", None, [1]])) # [1, 'hola', [1]]
# filter(None, ...) elimina valores falsy: 0, "", None, [], {}
def es_primo(n):
if n < 2: return False
return all(n % i != 0 for i in range(2, int(n**0.5) + 1))
primos = list(filter(es_primo, range(2, 50)))
print(primos) # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]
# ── Pipeline map + filter ─────────────────────────────────────
ventas = [
{"producto": "A", "cantidad": 5, "precio": 12.0, "activo": True},
{"producto": "B", "cantidad": 0, "precio": 8.0, "activo": True},
{"producto": "C", "cantidad": 3, "precio": 25.0, "activo": False},
{"producto": "D", "cantidad": 12, "precio": 5.0, "activo": True},
]
con_ventas = filter(lambda v: v["activo"] and v["cantidad"] > 0, ventas)
ingresos = map(lambda v: {"producto": v["producto"],
"ingreso": v["cantidad"] * v["precio"]}, con_ventas)
resultado = sorted(ingresos, key=lambda x: x["ingreso"], reverse=True)
for r in resultado:
print(f"{r['producto']}: {r['ingreso']:.2f} €")
# D: 60.00 € A: 60.00 €
reduce() y acumuladores
reduce(funcion, iterable) aplica una función acumulativa de dos argumentos de izquierda a derecha, reduciéndola a un único valor. Es la operación de fold de la programación funcional: toma una secuencia y la colapsa en un escalar (suma, producto, máximo, concatenación, cualquier acumulación).
from functools import reduce
numeros = [1, 2, 3, 4, 5]
suma = reduce(lambda acc, x: acc + x, numeros) # 15
producto = reduce(lambda acc, x: acc * x, numeros) # 120
maximo = reduce(lambda a, b: a if a > b else b, numeros) # 5
# Con valor inicial
suma_con_base = reduce(lambda acc, x: acc + x, numeros, 100) # 115
# ── Cómo funciona reduce paso a paso ─────────────────────────
# reduce(f, [a, b, c, d]) = f(f(f(a, b), c), d)
# Para suma [1,2,3,4,5]: f(1,2)=3 → f(3,3)=6 → f(6,4)=10 → f(10,5)=15
# ── Casos de uso reales ───────────────────────────────────────
# Concatenar listas (flatten un nivel)
listas = [[1, 2], [3, 4], [5, 6]]
plana = reduce(lambda acc, lst: acc + lst, listas, [])
print(plana) # [1, 2, 3, 4, 5, 6]
# Contar frecuencias
palabras = ["hola", "mundo", "hola", "python", "hola", "mundo"]
frecuencias = reduce(
lambda acc, p: {**acc, p: acc.get(p, 0) + 1},
palabras, {}
)
print(frecuencias) # {'hola': 3, 'mundo': 2, 'python': 1}
# Estadísticas en una sola pasada
datos = [4.2, 7.1, 2.8, 9.3, 5.5, 3.7]
stats = reduce(
lambda acc, x: {
"suma": acc["suma"] + x,
"minimo": min(acc["minimo"], x),
"maximo": max(acc["maximo"], x),
"cuenta": acc["cuenta"] + 1,
},
datos,
{"suma": 0, "minimo": float("inf"), "maximo": float("-inf"), "cuenta": 0}
)
stats["media"] = stats["suma"] / stats["cuenta"]
print(stats) # {'suma': 32.6, 'minimo': 2.8, 'maximo': 9.3, 'cuenta': 6, 'media': 5.43}
# ── Cuándo usar reduce vs alternativas ───────────────────────
# sum(numeros) → más claro que reduce para suma
# max(numeros) → más claro que reduce para máximo
# "".join(lista) → más claro que reduce para concatenar
# reduce brilla cuando no hay built-in equivalente y el patrón es no trivial
functools deliberadamente — Guido van Rossum considera que la mayoría de los casos de uso de reduce() son más claros con un bucle for explícito o funciones built-in como sum(), max() o any(). Úsalo cuando la acumulación es genuinamente no trivial y la versión funcional es más concisa que el bucle equivalente.
Closures: funciones que recuerdan su contexto
Una closure es una función que captura y recuerda las variables de su entorno léxico (el scope donde fue definida), incluso después de que ese entorno ya no esté activo en el stack de llamadas. Es el mecanismo que hace posibles los decoradores, las fábricas de funciones y el estado encapsulado sin clases.
# ── Closure básico: fábrica de funciones ──────────────────────
def hacer_multiplicador(factor: int):
"""Devuelve una función que multiplica su argumento por factor."""
def multiplicar(x):
return x * factor # 'factor' capturado del scope exterior
return multiplicar
doble = hacer_multiplicador(2)
triple = hacer_multiplicador(3)
por_10 = hacer_multiplicador(10)
print(doble(5)) # 10
print(triple(5)) # 15
print(doble.__closure__[0].cell_contents) # 2
print(triple.__closure__[0].cell_contents) # 3
# ── Estado mutable encapsulado ────────────────────────────────
def hacer_contador(inicio: int = 0):
count = [inicio] # lista para permitir mutación
def incrementar(paso: int = 1):
count[0] += paso
return count[0]
def resetear():
count[0] = inicio
def valor():
return count[0]
return incrementar, resetear, valor
inc, reset, val = hacer_contador(10)
print(inc()) # 11
print(inc(5)) # 16
reset()
print(val()) # 10
# ── Closure en la práctica: formateador configurable ─────────
def hacer_formateador(moneda: str, decimales: int = 2):
simbolos = {"EUR": "€", "USD": "$", "GBP": "£"}
simbolo = simbolos.get(moneda, moneda)
def formatear(valor: float) -> str:
return f"{valor:,.{decimales}f} {simbolo}"
return formatear
fmt_eur = hacer_formateador("EUR")
fmt_usd = hacer_formateador("USD", decimales=0)
print(fmt_eur(1234.5)) # '1,234.50 €'
print(fmt_usd(99.99)) # '100 $'
# ── Trampa clásica: closures en bucles ────────────────────────
# PROBLEMA: todas capturan la MISMA variable i (su valor final: 4)
funciones_malas = [lambda x: x * i for i in range(5)]
print([f(2) for f in funciones_malas]) # [8, 8, 8, 8, 8] ← incorrecto
# SOLUCIÓN: capturar el valor en el momento de creación
funciones_ok = [lambda x, factor=i: x * factor for i in range(5)]
print([f(2) for f in funciones_ok]) # [0, 2, 4, 6, 8] ← correcto
functools: partial, lru_cache y compose
El módulo functools contiene las herramientas de programación funcional de la librería estándar. Las más útiles en el día a día son partial para crear versiones especializadas de funciones con argumentos prefijados, y lru_cache para memoización automática.
from functools import partial, lru_cache, reduce, wraps, cache
import math, operator
# ── partial: fijar argumentos ─────────────────────────────────
log2 = partial(math.log, base=2)
log10 = partial(math.log, base=10)
print(log2(8)) # 3.0
print(log10(100)) # 2.0
def potencia(base, exponente): return base ** exponente
cuadrado = partial(potencia, exponente=2)
cubo = partial(potencia, exponente=3)
print(cuadrado(5)) # 25
print(cubo(3)) # 27
multiplicar_por_3 = partial(operator.mul, 3)
print(list(map(multiplicar_por_3, [1, 2, 3, 4, 5]))) # [3, 6, 9, 12, 15]
# ── lru_cache: memoización automática ────────────────────────
@lru_cache(maxsize=None)
def fib(n: int) -> int:
if n <= 1: return n
return fib(n-1) + fib(n-2)
import time
t0 = time.perf_counter()
print(fib(40)) # 102334155
print(f"{time.perf_counter()-t0:.4f}s") # < 0.001s
print(fib.cache_info())
# CacheInfo(hits=38, misses=41, maxsize=None, currsize=41)
# @cache (Python 3.9+): equivalente más rápido
@cache
def factorial(n: int) -> int:
return 1 if n == 0 else n * factorial(n-1)
# ── Composición de funciones ──────────────────────────────────
def pipe(*funciones):
"""Compone funciones de izquierda a derecha: pipe(f,g,h)(x) = h(g(f(x)))"""
return reduce(lambda f, g: lambda *args: g(f(*args)), funciones)
limpiar = str.strip
minusculas = str.lower
quitar_puntos = lambda s: s.replace(".", "").replace(",", "")
normalizar = pipe(limpiar, minusculas, quitar_puntos)
print(normalizar(" Hola, Mundo. ")) # 'hola mundo'
procesar = pipe(
lambda nums: filter(lambda x: x > 0, nums),
lambda nums: map(lambda x: x ** 2, nums),
list
)
print(procesar([-3, 1, -1, 4, 2])) # [1, 16, 4]
# ── wraps: preservar metadatos en decoradores ─────────────────
def mi_decorador(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Llamando a {func.__name__}")
return func(*args, **kwargs)
return wrapper
@mi_decorador
def saludar(nombre: str) -> str:
"""Genera un saludo personalizado."""
return f"Hola, {nombre}!"
print(saludar.__name__) # 'saludar' (sin @wraps sería 'wrapper')
print(saludar.__doc__) # 'Genera un saludo personalizado.'
itertools: combinatoria y pipelines lazy
El módulo itertools es una colección de herramientas para trabajar con iteradores de forma eficiente y lazy. Sus funciones nunca materializan la secuencia completa en memoria: generan elementos uno a uno bajo demanda. Indispensable para procesar grandes volúmenes de datos y para combinatoria.
import itertools
# ── Infinitos (usar con islice) ───────────────────────────────
numeros = itertools.count(start=1, step=2)
primeros = list(itertools.islice(numeros, 5)) # [1, 3, 5, 7, 9]
ciclo = itertools.cycle(['A', 'B', 'C'])
turno = [next(ciclo) for _ in range(7)] # ['A','B','C','A','B','C','A']
# ── Combinatoria ──────────────────────────────────────────────
letras = ['A', 'B', 'C']
permutaciones = list(itertools.permutations(letras))
print(len(permutaciones)) # 6 (3!)
combinaciones = list(itertools.combinations(letras, 2))
print(combinaciones) # [('A','B'), ('A','C'), ('B','C')]
producto = list(itertools.product([0, 1], repeat=3)) # 2^3 = 8 combis
print(producto[:4]) # [(0,0,0), (0,0,1), (0,1,0), (0,1,1)]
# ── Encadenamiento y agrupación ───────────────────────────────
todas = list(itertools.chain([1, 2], [3, 4], [5, 6])) # [1,2,3,4,5,6]
plano = list(itertools.chain.from_iterable([[1,2],[3,4],[5,6]]))
# groupby: agrupar elementos CONSECUTIVOS con la misma clave
# IMPORTANTE: ordenar antes de groupby
ventas = [
{"region": "Norte", "importe": 1200},
{"region": "Norte", "importe": 800},
{"region": "Sur", "importe": 950},
{"region": "Sur", "importe": 1300},
]
ventas_sorted = sorted(ventas, key=lambda v: v["region"])
for region, grupo in itertools.groupby(ventas_sorted, key=lambda v: v["region"]):
total = sum(v["importe"] for v in grupo)
print(f"{region}: {total:,} €")
# Norte: 2,000 € / Sur: 2,250 €
# ── takewhile / dropwhile / filterfalse ──────────────────────
nums = [2, 4, 6, 7, 8, 10]
pares_iniciales = list(itertools.takewhile(lambda n: n % 2 == 0, nums))
print(pares_iniciales) # [2, 4, 6] — para en el primer impar
desde_impar = list(itertools.dropwhile(lambda n: n % 2 == 0, nums))
print(desde_impar) # [7, 8, 10] — salta hasta el primer impar
# ── Pipeline lazy de gran escala ──────────────────────────────
def procesar_csv_lazy(ruta: str, limite: int | None = None):
"""Genera dicts de un CSV enorme sin cargarlo entero en RAM."""
with open(ruta, 'r', encoding='utf-8') as f:
cabeceras = next(f).strip().split(',')
lineas = itertools.islice(f, limite) if limite else f
for linea in lineas:
yield dict(zip(cabeceras, linea.strip().split(',')))
itertools construye pipelines así: cadenas de iteradores lazy que transforman datos elemento a elemento sin cargarlos en memoria, donde cada función es una válvula que pasa o bloquea, acumula o transforma. Fuente: Pexels (licencia libre).❓ Preguntas frecuentes
❓ Preguntas frecuentes sobre Programación funcional en Python: map, filter, reduce y más
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Programación funcional en Python: map, filter, reduce y más? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!