Clase 6c — First-class functions, closures y el paradigma funcional
En la Clase 6a establecimos que
todo en Python es un objeto —
números, strings, listas, dicts.
Ahora añadimos algo que no es obvio:
las funciones también
son objetos.
No son instrucciones especiales
del lenguaje — son objetos
del tipo function,
exactamente igual que
42 es un objeto
del tipo int.
Esto no es un detalle técnico menor. Es el concepto que hace posible todo lo que veremos en esta clase: pasar funciones a otras funciones, devolverlas, guardarlas en listas. En muchos lenguajes de programación las funciones son instrucciones especiales — no se pueden tratar como datos. En Python sí. Eso abre un mundo de posibilidades.
— Una función es un objeto como cualquier otro —
def square(x):
return x * x
# Una función tiene tipo — como cualquier objeto
print(type(42)) #
print(type("hola")) #
print(type(square)) #
# Una función tiene identidad — dirección en memoria
print(id(42))
print(id(square))
# Una función tiene atributos — igual que cualquier objeto
print(square.__name__) # 'square'
print(square.__doc__) # None — sin docstring
print(dir(square)) # todos sus atributos
— El nombre y el objeto son cosas distintas —
Cuando escribes def square(x):,
Python hace dos cosas distintas:
crea el objeto función en el heap
y crea una variable llamada
square en el scope global
que apunta a ese objeto.
El nombre square
y el objeto función son
dos cosas separadas.
El nombre es solo una etiqueta —
una referencia al objeto.
Puedes borrar la etiqueta sin destruir el objeto, siempre que alguien más tenga otra etiqueta apuntando al mismo objeto.
def square(x):
return x * x
# El resultado de llamar a la función
f = square(5)
print(f) # 25 — el resultado, no la función
print(type(f)) #
print("---")
# La función asignada a otra variable
f = square # f apunta al mismo objeto función
print(f.__name__) # 'square' — el nombre original del objeto
print(type(f)) #
print(f(5)) # 25 — funciona igual que square(5)
print(f is square) # True — el mismo objeto
print("---")
# El experimento del del — borramos el nombre, no el objeto
del square # borramos la etiqueta 'square'
# square ya no existe como nombre
try:
print(square)
except NameError as e:
print(f"NameError: {e}")
# Pero el objeto sigue vivo — f todavía apunta a él
print(f.__name__) # 'square' — el objeto recuerda su nombre original
print(f(5)) # 25 — sigue funcionando
square es el nombre —
una variable que apunta al objeto.
square(5) es la llamada —
ejecuta el objeto y devuelve el resultado.
f = square crea otra etiqueta
apuntando al mismo objeto.
del square borra la etiqueta
pero no el objeto.
El objeto vive mientras alguien
tenga una referencia a él.
— Por qué esto cambia la forma de programar —
En la mayoría de lenguajes tradicionales — C, Java, Pascal — las funciones son instrucciones especiales del lenguaje. No se pueden asignar a variables, no se pueden guardar en listas, no se pueden pasar como argumentos a otras funciones. Son ciudadanos de segunda clase.
En Python — y en lenguajes funcionales como Haskell, Lisp o JavaScript — las funciones son ciudadanos de primera clase: tienen exactamente los mismos derechos que cualquier otro objeto. Esto abre la puerta a formas de programar mucho más expresivas y generales. Toda la clase de hoy es una consecuencia directa de este concepto.
def doble(x):
"""Devuelve el doble de x."""
return x * 2
g = doble
print(g)
print(g(5))
print(g.__name__)
print(g.__doc__)
print(g is doble)
print(type(g))
del square
en el ejemplo:
¿por qué f.__name__
sigue devolviendo
'square'?
¿Qué nos dice eso sobre
la diferencia entre
el nombre de la variable
y el nombre del objeto?
def saludar(nombre):
return f"Hola, {nombre}"
a = saludar
b = saludar("Ana")
print(type(a))
print(type(b))
print(g)
→ algo como
<function doble at 0x...>
— el objeto función.
print(g(5))
→ 10
— resultado de ejecutarlo.
print(g.__name__)
→ 'doble'
— nombre original del objeto.
print(g.__doc__)
→ 'Devuelve el doble de x.'.
print(g is doble)
→ True
— el mismo objeto.
print(type(g))
→ <class 'function'>.
f.__name__
devuelve 'square'
porque __name__
es un atributo del
objeto función
— no del nombre de variable
que apunta a él.
El atributo __name__
se asigna al objeto
cuando Python procesa
el def square
y nunca cambia.
del square
borra la variable
del scope global
pero no toca el objeto
ni sus atributos.
El objeto sabe su nombre
original independientemente
de cómo se acceda a él.
a = saludar
asigna la función —
type(a)
es function.
b = saludar("Ana")
llama a la función
y asigna el resultado —
type(b)
es str
porque saludar
devuelve un string.
La distinción visual:
sin paréntesis es el objeto,
con paréntesis es la llamada.
Si las funciones no fueran
objetos, la IA solo podría
generar código imperativo —
instrucciones una tras otra.
No podría crear funciones
que reciben otras funciones
como parámetro, lo que
eliminaría patrones enteros
de código como
map,
filter,
sorted(key=...),
df.apply()
o los decoradores.
El código generado sería
mucho más repetitivo
y mucho menos general.
key=lambda,
con map(funcion, lista)
o con decoradores,
está usando exactamente
el hecho de que las funciones
son objetos.
Entender este concepto
permite leer ese código
sin confusión.
Decir que las funciones son objetos de primera clase significa concretamente cinco cosas. Cada una abre una posibilidad que no existe en lenguajes donde las funciones son ciudadanos de segunda clase. Las dos primeras las vemos aquí — las tres restantes son el tema central de las Partes II y III.
— 1. Asignarse a una variable —
Una función puede almacenarse en una variable igual que un número o un string. La variable pasa a ser otra forma de acceder al mismo objeto función. Ambas — el nombre original y la nueva variable — ejecutan exactamente el mismo código.
def cuadrado(x):
return x ** 2
# Asignar a una variable — no llama a la función
operacion = cuadrado
print(operacion(4)) # 16 — igual que cuadrado(4)
print(cuadrado(4)) # 16 — mismo objeto
print(operacion is cuadrado) # True
print("---")
# Útil para seleccionar operaciones dinámicamente
def cubo(x):
return x ** 3
def raiz(x):
return x ** 0.5
# La función a usar puede decidirse en tiempo de ejecución
modo = "cuadrado"
if modo == "cuadrado":
transformar = cuadrado
elif modo == "cubo":
transformar = cubo
else:
transformar = raiz
print(transformar(4)) # 16 — según el modo
— 2. Almacenarse en estructuras de datos —
Las funciones pueden guardarse en listas, tuplas, diccionarios o cualquier otra estructura de datos. Esto permite construir colecciones de operaciones — pipelines, menús de transformaciones, registros de handlers — que se pueden recorrer e invocar dinámicamente.
def cuadrado(x):
return x ** 2
def cubo(x):
return x ** 3
def raiz(x):
return x ** 0.5
# Lista de funciones — se pueden iterar y llamar
operaciones = [cuadrado, cubo, raiz]
for op in operaciones:
print(f"{op.__name__}(4) = {op(4):.2f}")
print("---")
# Diccionario de funciones — acceso por nombre
opciones = {
"sq": cuadrado,
"cb": cubo,
"rt": raiz
}
print(opciones["sq"](3)) # 9
print(opciones["cb"](4)) # 64
print(opciones["rt"](9)) # 3.0
print("---")
# Patrón frecuente en análisis de datos:
# pipeline de transformaciones
import pandas as pd
def quitar_nulos(df):
return df.dropna()
def normalizar(df):
cols_num = df.select_dtypes("number").columns
df[cols_num] = (df[cols_num] - df[cols_num].mean()) / df[cols_num].std()
return df
def renombrar(df):
return df.rename(columns=str.lower)
# Lista de pasos — se aplican en orden
pipeline = [quitar_nulos, normalizar, renombrar]
df = pd.DataFrame({
"Ventas": [100, None, 200, 150],
"Precio": [1.2, 0.9, 1.5, None]
})
for paso in pipeline:
df = paso(df)
print(f"Después de {paso.__name__}:")
print(df)
print()
— Las tres capacidades restantes: anticipo —
3. Como argumento
Pasar una función a otra función.
La base de map,
filter,
sorted(key=...).
Parte II.
4. Como valor de retorno
Una función que devuelve otra función. La base de las fábricas y los closures. Parte III.
5. Con atributos propios
__name__,
__doc__,
__defaults__,
__code__.
Ya vistos en la Clase 6a.
def doblar(x):
return x * 2
def triplicar(x):
return x * 3
ops = [doblar, triplicar, doblar]
for f in ops:
print(f(5))
if/elif
para seleccionar la operación?
Reescribe este código
usando un diccionario
de funciones:
def operar(x, operacion):
if operacion == "doblar":
return x * 2
elif operacion == "triplicar":
return x * 3
elif operacion == "cuadrado":
return x ** 2
else:
return None
def sumar(x, y):
return x + y
operaciones = [sumar, len, abs]
for op in operaciones:
print(op(4))
Imprime 10,
15,
10.
La lista contiene
tres referencias a funciones —
doblar,
triplicar,
doblar de nuevo.
El bucle llama a cada una
con argumento 5.
La ventaja del diccionario
es que añadir una operación
nueva no requiere modificar
la lógica de selección —
solo añadir una entrada
al diccionario.
El if/elif
crece indefinidamente
con cada operación nueva.
def doblar(x): return x * 2
def triplicar(x): return x * 3
def cuadrado(x): return x ** 2
operaciones = {
"doblar": doblar,
"triplicar": triplicar,
"cuadrado": cuadrado,
}
def operar(x, operacion):
return operaciones.get(operacion, lambda _: None)(x)
print(operar(5, "doblar")) # 10
print(operar(5, "cuadrado")) # 25
La ventaja del pipeline es que el orden de aplicación está explícito en la lista y se puede modificar, reordenar o extender sin tocar el código que lo aplica. Para añadir un paso nuevo:
def eliminar_duplicados(df):
return df.drop_duplicates()
pipeline = [quitar_nulos, eliminar_duplicados,
normalizar, renombrar]
El error es que
sumar
requiere dos argumentos
pero el bucle la llama
con uno solo —
TypeError:
sumar() missing 1 required
positional argument: 'y'.
len y
abs
aceptan un solo argumento,
pero sumar no.
Para mezclar funciones
con distinto número
de argumentos en una lista
hay que asegurarse
de que todas tienen
la misma interfaz —
o usar *args:
# Opción: funciones con la misma firma
def sumar_uno(x):
return x + 1
operaciones = [sumar_uno, abs]
for op in operaciones:
print(op(4))
Si las funciones son objetos, se pueden pasar como argumentos a otras funciones — igual que se pasa un número o una lista. Esto permite separar dos cosas que normalmente van juntas: qué operación aplicar y sobre qué datos aplicarla.
La función que recibe la operación como argumento no sabe — ni necesita saber — qué operación es. Solo sabe que recibirá algo callable y lo llamará sobre los datos. La operación concreta la decide quien llama a la función. Esto hace el código mucho más general y reutilizable.
— El problema: repetir el mismo bucle para cada operación —
# Sin pasar funciones como argumentos
# hay que escribir un bucle distinto para cada operación
numeros = [1, 2, 3, 4, 5]
# Bucle para cuadrados
cuadrados = []
for n in numeros:
cuadrados.append(n ** 2)
# Bucle para cubos — mismo patrón, distinta operación
cubos = []
for n in numeros:
cubos.append(n ** 3)
# Bucle para raíces — mismo patrón, distinta operación
raices = []
for n in numeros:
raices.append(n ** 0.5)
print(cuadrados)
print(cubos)
print(raices)
# El patrón es siempre el mismo:
# resultado = []
# for n in numeros:
# resultado.append(OPERACIÓN(n))
# Solo cambia OPERACIÓN — todo lo demás es idéntico
— La solución: my_map — abstraer el patrón —
Si el patrón es siempre el mismo
y solo cambia la operación,
podemos abstraer el patrón
en una función que reciba
la operación como argumento.
Eso es exactamente lo que hace
map — y lo que
construiremos a mano para entenderlo.
def cuadrado(x):
return x ** 2
def cubo(x):
return x ** 3
def raiz(x):
return x ** 0.5
# my_map: el patrón abstraído
# func es una función — se puede llamar con ()
def my_map(func, iterable):
resultado = []
for i in iterable:
resultado.append(func(i)) # llama a func con cada elemento
return resultado
numeros = [1, 2, 3, 4, 5]
# El mismo my_map para cualquier operación
print(my_map(cuadrado, numeros)) # [1, 4, 9, 16, 25]
print(my_map(cubo, numeros)) # [1, 8, 27, 64, 125]
print(my_map(raiz, numeros)) # [1.0, 1.41, 1.73, 2.0, 2.23]
print("---")
# También funciona con funciones built-in
palabras = ["python", "es", "genial"]
print(my_map(len, palabras)) # [6, 2, 6]
print(my_map(str.upper, palabras)) # error — str.upper necesita str
— Un ejemplo más sofisticado: la derivada aproximada —
El mismo principio funciona para operaciones matemáticas complejas. Una función que calcula la derivada aproximada de cualquier función en cualquier punto — sin saber qué función es. Solo necesita que sea callable.
import math
def derivada_aproximada(f, x, h=1e-5):
"""
Calcula la derivada aproximada de f en x.
f: cualquier función — solo necesita ser callable
x: el punto donde calcular la derivada
h: el incremento (por defecto muy pequeño)
"""
return (f(x + h) - f(x)) / h
# La misma función para cualquier operación matemática
def cuadrado(x):
return x ** 2
# Derivada de x² en x=3 → debería ser 2*3 = 6
resultado = derivada_aproximada(cuadrado, 3)
print(f"d(x²)/dx en x=3: {resultado:.4f}") # ≈ 6.0
# Derivada de log(x) en x=3 → debería ser 1/3 ≈ 0.333
resultado = derivada_aproximada(math.log, 3)
print(f"d(log x)/dx en x=3: {resultado:.4f}") # ≈ 0.333
# Derivada de sin(x) en x=0 → debería ser 1
resultado = derivada_aproximada(math.sin, 0)
print(f"d(sin x)/dx en x=0: {resultado:.4f}") # ≈ 1.0
print("---")
# La misma función sin reescribir nada
# Solo cambia el argumento f
funciones = [cuadrado, math.log, math.sin, math.cos]
punto = 1.0
for f in funciones:
d = derivada_aproximada(f, punto)
print(f"d({f.__name__})/dx en x={punto}: {d:.4f}")
derivada_aproximada
funciona igual con cuadrado,
con math.log
o con cualquier función que definas.
Solo necesita que sea callable.
Esto es lo que hace el código
verdaderamente general.
def aplicar(func, x, y):
return func(x), func(y)
def negativo(n):
return -n
print(aplicar(negativo, 3, 5))
print(aplicar(abs, -4, -7))
print(aplicar(str, 42, 3.14))
my_map:
precios = [10.0, 25.5, 8.0, 15.0]
# A: precio con IVA (× 1.21)
con_iva = []
for p in precios:
con_iva.append(p * 1.21)
# B: precio redondeado a 2 decimales
redondeados = []
for p in precios:
redondeados.append(round(p, 2))
# C: precio como string con símbolo €
etiquetas = []
for p in precios:
etiquetas.append(f"{p:.2f}€")
derivada_aproximada:
¿qué pasaría si pasaras
un número entero en lugar
de una función como primer argumento?
¿Qué error producirías y por qué?
# Versión A
def doblar_lista(lista):
return [x * 2 for x in lista]
# Versión B
def transformar_lista(func, lista):
return [func(x) for x in lista]
aplicar(negativo, 3, 5)
→ (-3, -5).
aplicar(abs, -4, -7)
→ (4, 7).
aplicar(str, 42, 3.14)
→ ('42', '3.14').
En los tres casos
aplicar
llama a func
con cada argumento
y devuelve una tupla
con los resultados.
La función no sabe
qué es func
— solo la llama.
A y B no se pueden reescribir
directamente con
my_map
porque las operaciones
no son funciones de un solo
argumento.
La solución es definir
funciones auxiliares
o usar lambda
— que veremos en la Clase 6d:
def con_iva(p): return p * 1.21
def redondear(p): return round(p, 2)
def etiquetar(p): return f"{p:.2f}€"
print(my_map(con_iva, precios))
print(my_map(redondear, precios))
print(my_map(etiquetar, precios))
Si pasas un entero,
derivada_aproximada
intentaría ejecutar
f(x + h)
donde f
es el entero —
TypeError:
'int' object is not callable.
El entero no es callable
— no tiene __call__.
Es el mismo error que vimos
en la Clase 6a con
callable().
La versión A solo puede doblar —
hace una cosa concreta
y no se puede reutilizar
para nada más.
Si quieres triplicar,
necesitas
triplicar_lista.
Si quieres elevar al cuadrado,
necesitas
cuadrar_lista.
El código crece
con cada operación nueva.
La versión B es general — funciona con cualquier transformación. Solo necesitas definir la operación concreta y pasarla como argumento. El código no crece con las operaciones nuevas.
map,
filter
y sorted.
map,
filter
y
sorted:
funciones de orden superior
práctica
Una función de orden superior
es una función que recibe otras funciones
como argumentos o las devuelve
como resultado.
Python tiene tres funciones de orden superior
incorporadas que aparecen constantemente
en código real y en código de IA:
map, filter
y sorted con key.
Las tres son versiones más eficientes y expresivas de los patrones que construimos a mano en el Bloque 1. Entenderlas permite leer código funcional de la IA sin confusión.
— map: aplicar una función a cada elemento —
map(func, iterable)
aplica func a cada elemento
del iterable y devuelve un objeto
perezoso —
no calcula los resultados
hasta que se necesitan.
Para ver los resultados
hay que convertirlo a lista
con list().
def cuadrado(x):
return x ** 2
numeros = [1, 2, 3, 4, 5]
# map devuelve un objeto map — no una lista
resultado = map(cuadrado, numeros)
print(resultado) #
print(list(resultado)) # [1, 4, 9, 16, 25]
print("---")
# map vs my_map vs list comprehension
# Las tres son equivalentes — distinta sintaxis
print(list(map(cuadrado, numeros))) # map
print(my_map(cuadrado, numeros)) # my_map del Bloque 1
print([cuadrado(x) for x in numeros]) # comprehension
print("---")
# map con funciones built-in
palabras = ["python", "es", "genial"]
print(list(map(len, palabras))) # [6, 2, 6]
print(list(map(str.upper, palabras))) # ['PYTHON', 'ES', 'GENIAL']
print(list(map(str.capitalize, palabras))) # ['Python', 'Es', 'Genial']
print("---")
# map con dos iterables
a = [1, 2, 3]
b = [10, 20, 30]
def sumar(x, y):
return x + y
print(list(map(sumar, a, b))) # [11, 22, 33]
— filter: conservar los elementos que cumplen una condición —
filter(func, iterable)
conserva solo los elementos
para los que func
devuelve True.
La función que se pasa se llama
predicado —
una función que devuelve
True o False.
Como map,
devuelve un objeto perezoso.
def es_par(n):
return n % 2 == 0
def es_positivo(n):
return n > 0
numeros = [-3, -1, 0, 2, 4, 5, 7, 8]
# filter con predicado
print(list(filter(es_par, numeros))) # [0, 2, 4, 8]
print(list(filter(es_positivo, numeros))) # [2, 4, 5, 7, 8]
print("---")
# filter vs list comprehension con if
print(list(filter(es_par, numeros))) # filter
print([n for n in numeros if es_par(n)]) # comprehension — equivalente
print("---")
# filter con None — conserva los valores truthy
datos = [0, 1, "", "hola", None, 42, [], [1, 2]]
print(list(filter(None, datos)))
# [1, 'hola', 42, [1, 2]] — elimina los falsy
print("---")
# Patrón frecuente: map + filter encadenados
numeros = [-3, -1, 2, 4, 5]
# Cuadrados de los números positivos
resultado = list(map(cuadrado, filter(es_positivo, numeros)))
print(resultado) # [4, 16, 25]
# Equivalente con comprehension
resultado = [n**2 for n in numeros if n > 0]
print(resultado) # [4, 16, 25]
— sorted con key: ordenar según cualquier criterio —
sorted(iterable, key=func)
ordena los elementos según
el valor que devuelve func
para cada elemento.
func recibe un elemento
y devuelve el valor que se usa
para comparar.
Es la función de orden superior
más frecuente en código de IA.
# Ordenar strings por longitud
palabras = ["banana", "pera", "kiwi", "manzana", "uva"]
def por_longitud(palabra):
return len(palabra)
print(sorted(palabras)) # orden alfabético
print(sorted(palabras, key=por_longitud)) # por longitud
print(sorted(palabras, key=len)) # equivalente — len es una función
print("---")
# Ordenar dicts por un campo
alumnos = [
{"nombre": "Ana", "nota": 8.5},
{"nombre": "Bob", "nota": 7.2},
{"nombre": "Carlos", "nota": 9.1},
{"nombre": "Diana", "nota": 6.8},
]
def por_nota(alumno):
return alumno["nota"]
def por_nombre(alumno):
return alumno["nombre"]
# Por nota descendente
ranking = sorted(alumnos, key=por_nota, reverse=True)
for pos, a in enumerate(ranking, start=1):
print(f"{pos}. {a['nombre']}: {a['nota']}")
print("---")
# Por nombre alfabético
for a in sorted(alumnos, key=por_nombre):
print(a["nombre"])
— apply en pandas: el map de los DataFrames —
df.apply(func)
es el equivalente de map
para DataFrames.
Aplica una función a cada fila
o columna del DataFrame.
series.map(func)
aplica una función a cada elemento
de una Serie.
Son las funciones de orden superior
más usadas en análisis de datos.
import pandas as pd
df = pd.DataFrame({
"producto": ["manzana", "pera", "naranja", "kiwi"],
"ventas": [150, 80, 120, 200],
"precio": [1.20, 0.90, 0.80, 1.50]
})
# series.map — aplica función a cada elemento
def con_iva(precio):
return round(precio * 1.21, 2)
df["precio_iva"] = df["precio"].map(con_iva)
print(df[["producto", "precio", "precio_iva"]])
print("---")
# df.apply por fila — axis=1
def calcular_ingreso(fila):
return fila["ventas"] * fila["precio"]
df["ingreso"] = df.apply(calcular_ingreso, axis=1)
print(df[["producto", "ventas", "precio", "ingreso"]])
print("---")
# df.apply por columna — axis=0
def rango(serie):
return serie.max() - serie.min()
print(df[["ventas", "precio"]].apply(rango))
def es_largo(s):
return len(s) > 4
palabras = ["sol", "luna", "estrella", "mar", "montaña"]
print(list(map(len, palabras)))
print(list(filter(es_largo, palabras)))
print(sorted(palabras, key=len))
print(sorted(palabras, key=len, reverse=True))
map y filter?
¿Cuándo usarías cada uno?
Escribe un ejemplo con datos
de ventas donde uses
los dos encadenados.
key=len
en sorted?
¿Por qué se pasa len
sin paréntesis?
¿Qué pasaría si escribieras
key=len()?
apply en pandas.
¿Es correcto?
¿Hay una forma más eficiente?
def clasificar(fila):
if fila["ventas"] > 100:
return "alto"
else:
return "bajo"
df["categoria"] = df.apply(clasificar, axis=1)
list(map(len, palabras))
→ [3, 4, 8, 3, 7]
— longitud de cada palabra.
list(filter(es_largo, palabras))
→ ['estrella', 'montaña']
— palabras de más de 4 letras.
sorted(palabras, key=len)
→ ['sol', 'mar', 'luna',
'montaña', 'estrella']
— orden ascendente por longitud.
sorted(palabras, key=len,
reverse=True)
→ ['estrella', 'montaña',
'luna', 'sol', 'mar']
— descendente.
map transforma —
aplica una función a cada elemento
y devuelve el resultado.
La lista resultante
tiene el mismo número de elementos
que la original.
filter selecciona —
conserva solo los elementos
que cumplen una condición.
La lista resultante puede ser
más corta que la original.
ventas = [150, 80, 200, 30, 120]
def es_alto(v): return v > 100
def con_bonus(v): return v * 1.10
# Ventas altas con bonus aplicado
resultado = list(map(con_bonus, filter(es_alto, ventas)))
print(resultado) # [165.0, 220.0, 132.0]
key=len
le dice a sorted
que para comparar dos elementos,
primero llame a len
sobre cada uno y compare
los resultados.
Se pasa len
sin paréntesis porque
se pasa el objeto función
— no su resultado.
Con paréntesis,
key=len()
intentaría llamar a
len()
sin argumentos — lo que
produce un
TypeError
porque len
requiere un argumento.
El código es correcto
pero apply
con axis=1
es lento para esta operación
porque itera fila a fila.
La alternativa vectorizada
es mucho más eficiente:
import numpy as np
df["categoria"] = np.where(
df["ventas"] > 100, "alto", "bajo"
)
np.where
es siempre más rápido.
apply
con funciones propias
es el equivalente funcional
de map
en pandas —
potente pero más lento
que las operaciones
vectorizadas nativas.
En la Parte II vimos que las funciones pueden recibir otras funciones como argumentos. Ahora damos el paso siguiente: una función puede también devolver otra función como resultado. No devuelve un número, un string o una lista — devuelve un objeto función que se puede llamar después.
Esto parece extraño al principio pero tiene una utilidad concreta: permite crear funciones configuradas — funciones que se comportan de distinta manera según los parámetros con los que fueron creadas. Es como una fábrica que produce herramientas distintas según lo que le pides.
— La distinción más importante: devolver vs llamar —
Hay una distinción crítica
que hay que tener clara
antes de seguir:
return funcion
devuelve el objeto función
— no lo ejecuta.
return funcion()
lo ejecuta y devuelve su resultado.
Son cosas completamente distintas.
def saludar():
return "Hola"
# return saludar — devuelve el objeto función
def devolver_funcion():
return saludar # ← sin paréntesis
f = devolver_funcion()
print(type(f)) #
print(f) #
print(f()) # "Hola" — ahora sí la llamamos
print("---")
# return saludar() — devuelve el resultado de llamarla
def devolver_resultado():
return saludar() # ← con paréntesis
r = devolver_resultado()
print(type(r)) #
print(r) # "Hola" — el string, no la función
— Una función que elige qué función devolver —
El poder real aparece cuando la función que se devuelve depende de los argumentos. La función exterior actúa como un selector — según lo que recibe, devuelve una función distinta. El resultado es una función configurada para un comportamiento concreto.
# Una función que devuelve una función según el volumen
def hablar_func(volumen):
def suave(texto):
return texto.lower()
def gritar(texto):
return texto.upper()
if volumen > 0.5:
return gritar # devuelve el objeto función — sin ()
else:
return suave # devuelve el objeto función — sin ()
# hablar_func(1) devuelve la función gritar
hablar = hablar_func(1)
print(hablar) #
print(hablar.__name__) # 'gritar'
print(hablar("hola mundo")) # 'HOLA MUNDO'
print("---")
# hablar_func(0.3) devuelve la función suave
hablar = hablar_func(0.3)
print(hablar.__name__) # 'suave'
print(hablar("HOLA MUNDO")) # 'hola mundo'
print("---")
# Llamada directa — sin guardar en variable
print(hablar_func(1)("hola")) # 'HOLA' — dos () seguidos
print(hablar_func(0.1)("HOLA")) # 'hola'
— Ejemplo aplicado: fábrica de formateadores —
Un ejemplo más cercano al análisis de datos: una función que devuelve un formateador distinto según el formato de salida deseado. El código que usa el formateador no necesita saber cuál es — solo sabe que recibe una función que transforma datos en texto.
def crear_formateador(formato):
"""Devuelve una función que formatea datos según el formato."""
def formato_consola(nombre, valor):
return f" {nombre:15} → {valor:.2f}"
def formato_csv(nombre, valor):
return f"{nombre},{valor:.2f}"
def formato_markdown(nombre, valor):
return f"| {nombre:15} | {valor:.2f} |"
if formato == "consola":
return formato_consola
elif formato == "csv":
return formato_csv
elif formato == "markdown":
return formato_markdown
else:
raise ValueError(f"Formato desconocido: {formato}")
# Datos de ventas
ventas = [("manzana", 180.0), ("pera", 72.0),
("kiwi", 300.0), ("fresa", 190.0)]
# El mismo código — distintos formateadores
for fmt in ["consola", "csv", "markdown"]:
formatear = crear_formateador(fmt)
print(f"\n--- {fmt} ---")
for nombre, valor in ventas:
print(formatear(nombre, valor))
def crear_saludo(idioma):
def español(nombre):
return f"Hola, {nombre}"
def ingles(nombre):
return f"Hello, {nombre}"
def frances(nombre):
return f"Bonjour, {nombre}"
saludos = {"es": español, "en": ingles, "fr": frances}
return saludos.get(idioma, español)
saludar = crear_saludo("en")
print(type(saludar))
print(saludar.__name__)
print(saludar("Ana"))
print(crear_saludo("fr")("Bob"))
print(crear_saludo("de")("Carlos"))
a = hablar_func(1)
b = hablar_func(1)("hola")
crear_clasificador
que reciba un umbral
y devuelva una función
que clasifique un número
como "alto"
o "bajo"
según ese umbral.
type(saludar)
→ <class 'function'>.
saludar.__name__
→ 'ingles'
— el nombre de la función
devuelta.
saludar("Ana")
→ "Hello, Ana".
crear_saludo("fr")("Bob")
→ "Bonjour, Bob"
— dos paréntesis seguidos:
el primero crea el saludo,
el segundo lo llama.
crear_saludo("de")("Carlos")
→ "Hola, Carlos"
— "de" no existe en el dict,
.get() devuelve
el default español.
a = hablar_func(1):
a es
el objeto función
gritar
— tipo function.
No se ha ejecutado aún.
b = hablar_func(1)("hola"):
primero se obtiene
la función gritar,
luego se llama con
"hola".
b es el string
"HOLA"
— tipo str.
La ventaja es que el formateador se configura una vez y se reutiliza muchas veces sin repetir el argumento de formato. Si el formato cambia, se cambia en un solo lugar. Pasar el formato en cada llamada es más flexible cuando el formato varía en cada llamada — pero más verboso y propenso a errores si siempre es el mismo.
def crear_clasificador(umbral):
def clasificar(valor):
return "alto" if valor > umbral else "bajo"
return clasificar
# Dos clasificadores con distintos umbrales
clasificar_100 = crear_clasificador(100)
clasificar_500 = crear_clasificador(500)
print(clasificar_100(150)) # "alto"
print(clasificar_100(80)) # "bajo"
print(clasificar_500(150)) # "bajo"
print(clasificar_500(600)) # "alto"
clasificar
usa umbral
— que está definido
en crear_clasificador,
no en clasificar.
¿Cómo sabe clasificar_100
que su umbral es 100
y clasificar_500
que el suyo es 500?
Eso es exactamente
lo que explicaremos
en el Bloque 2 —
el closure.
Al final del Bloque 1 dejamos
una pregunta sin responder:
clasificar_100
usa umbral
— pero umbral
está definido en
crear_clasificador,
una función que ya terminó
de ejecutarse.
Su frame fue destruido.
¿Cómo puede clasificar_100
seguir accediendo a umbral?
La respuesta es el concepto más importante de esta clase: el closure. Pero antes de nombrarlo, vamos a ver el problema en detalle — paso a paso — para que la explicación tenga contexto concreto.
— La fábrica de multiplicadores —
# Una fábrica de funciones de multiplicación
def tablas_mult(n):
def tablas(x):
return x * n # ← usa n — definida en tablas_mult
return tablas # devuelve la función, sin llamarla
# Crear dos multiplicadores distintos
tablas3 = tablas_mult(3) # tablas_mult termina — su frame desaparece
tablas5 = tablas_mult(5) # frame nuevo, luego desaparece también
# ¿Qué es tablas3?
print(tablas3) # .tablas at 0x...>
print(tablas3.__name__) # 'tablas'
print("---")
# Funciona — pero ¿cómo sabe que n=3?
print(tablas3(4)) # 12 — 4 × 3
print(tablas3(7)) # 21 — 7 × 3
# Y tablas5 sabe que n=5 — independiente de tablas3
print(tablas5(4)) # 20 — 4 × 5
print(tablas5(7)) # 35 — 7 × 5
print("---")
# La tabla del 3 del 1 al 10
for i in range(1, 11):
print(f"3 × {i} = {tablas3(i)}")
La pregunta que hay que responder
Cuando llamamos tablas_mult(3),
Python crea un frame con n = 3,
crea la función tablas
dentro de ese frame y la devuelve.
Luego el frame desaparece —
n ya no existe.
Pero cuando llamamos tablas3(4),
la función tablas
necesita n
para calcular 4 * n.
¿De dónde lo saca si el frame
donde vivía ya no existe?
— La respuesta: las free variables —
Cuando Python crea la función
tablas dentro de
tablas_mult,
detecta que tablas
usa la variable n
pero no la define — n
viene del scope exterior.
A estas variables se les llama
free variables
— variables que se usan dentro
de la función pero fueron
definidas fuera de ella.
Python guarda una copia de
las free variables en un lugar
especial adjunto al objeto función —
no en el frame, que desaparece,
sino en el heap, que persiste.
Cuando el frame de
tablas_mult desaparece,
la copia de n
sigue viva dentro del objeto
tablas.
# Inspeccionar las free variables de tablas3
print(tablas3.__code__.co_freevars) # ('n',) — nombres de las free vars
print(tablas3.__closure__) # (| ,) — los valores |
print(tablas3.__closure__[0].cell_contents) # 3 — el valor de n
print("---")
print(tablas5.__code__.co_freevars)
print(tablas5.__closure__[0].cell_contents) # 5 — distinto valor
print("---")
# Una función sin free variables no tiene closure
def sin_closure(x):
return x * 2
print(sin_closure.__code__.co_freevars) # () — vacío
print(sin_closure.__closure__) # None — no hay closure
— Ejemplos aplicados: fábricas útiles —
# Fábrica de funciones de descuento
def crear_descuento(porcentaje):
"""Crea una función que aplica un descuento fijo."""
def aplicar_descuento(precio):
return round(precio * (1 - porcentaje / 100), 2)
return aplicar_descuento
# Crear tres funciones de descuento distintas
dto_10 = crear_descuento(10)
dto_20 = crear_descuento(20)
dto_50 = crear_descuento(50)
precios = [100, 250, 80, 15]
print("Precio original → dto 10% → dto 20% → dto 50%")
for p in precios:
print(f"{p:6}€ → {dto_10(p):7}€ → {dto_20(p):7}€ → {dto_50(p):7}€")
print("---")
# Fábrica de validadores
def crear_validador(minimo, maximo):
"""Crea una función que valida si un valor está en el rango."""
def validar(valor):
if not (minimo <= valor <= maximo):
raise ValueError(
f"{valor} fuera de rango [{minimo}, {maximo}]"
)
return True
return validar
# Tres validadores con distintos rangos
validar_nota = crear_validador(0, 10)
validar_pct = crear_validador(0, 100)
validar_temp = crear_validador(-50, 60)
try:
validar_nota(8.5)
print("nota 8.5: válida")
validar_nota(11) # fuera de rango
except ValueError as e:
print(f"Error: {e}")
try:
validar_pct(85)
print("porcentaje 85: válido")
validar_temp(75) # fuera de rango
except ValueError as e:
print(f"Error: {e}")
validar_nota,
validar_porcentaje
y validar_temperatura
como tres funciones separadas —
con el mismo código repetido —
escribes la lógica una sola vez
en crear_validador
y produces tantas variantes
como necesites.
Cada una recuerda sus propios
valores de minimo
y maximo — aunque
crear_validador
haya terminado hace tiempo.
tablas3
y tablas5
devuelven resultados distintos
si ambas son la misma función
tablas?
¿Qué las diferencia?
dto_20
y validar_nota:
print(dto_20.__code__.co_freevars)
print(dto_20.__closure__[0].cell_contents)
print(validar_nota.__code__.co_freevars)
print(validar_nota.__closure__[0].cell_contents)
print(validar_nota.__closure__[1].cell_contents)
¿Cuántas free variables
tiene cada una?
¿Qué valores tienen?
crear_multiplicador_iva
que reciba un tipo de IVA
(0.04, 0.10
o 0.21)
y devuelva una función
que aplique ese IVA a un precio.
Crea tres funciones —
una para cada tipo de IVA —
y aplícalas sobre la lista
[10, 50, 100].
crear_validador(0, 10)
en lugar de pasar los límites
como argumentos cada vez —
validar(valor, 0, 10)?
¿En qué situaciones
la fábrica es claramente mejor?
Son el mismo objeto función
en cuanto a código —
el mismo cuerpo,
la misma lógica.
Lo que las diferencia
es el valor de su
free variable n:
tablas3
tiene n = 3
guardado en su closure,
tablas5
tiene n = 5.
Cada vez que se llama
tablas_mult
se crea una función
tablas nueva
con su propia copia
de n.
El closure es lo que
las hace independientes.
dto_20
tiene una free variable:
('porcentaje',)
con valor 20.
validar_nota
tiene dos free variables:
('maximo', 'minimo')
— en orden alfabético —
con valores
10 y 0.
Cada parámetro de
crear_validador
que usa la función interior
se convierte en una
free variable del closure.
def crear_multiplicador_iva(tipo_iva):
def aplicar_iva(precio):
return round(precio * (1 + tipo_iva), 2)
return aplicar_iva
iva_super = crear_multiplicador_iva(0.04)
iva_red = crear_multiplicador_iva(0.10)
iva_gral = crear_multiplicador_iva(0.21)
precios = [10, 50, 100]
print("Precio → IVA 4% → IVA 10% → IVA 21%")
for p in precios:
print(f"{p:6}€ → {iva_super(p):7}€ "
f"→ {iva_red(p):7}€ → {iva_gral(p):7}€")
Pasar los límites cada vez es más flexible pero más verboso y más propenso a errores — si los límites cambian hay que cambiarlos en cada llamada. La fábrica es claramente mejor cuando el mismo validador se usa muchas veces con los mismos límites: en un pipeline de datos, en la validación de un formulario o cuando se pasa el validador como argumento a otra función.
Ya hemos visto el fenómeno — una función que recuerda variables de su función exterior aunque esa función haya terminado. Ahora le ponemos nombre y lo explicamos con precisión.
Un closure ocurre cuando una función anidada recuerda y accede a las variables de su función exterior incluso después de que esa función exterior haya terminado de ejecutarse. No es solo una función — es una función más un entorno.
closure = función + environment
— El environment: la mochila invisible —
El environment es una asociación interna que guarda las free variables — las variables que la función usa pero no define. Cuando Python crea un closure, hace una referencia a esas variables y las adjunta al objeto función en el heap.
La analogía perfecta: imagina que la función interior lleva una mochila invisible. Antes de salir de la función exterior, mete en la mochila todas las variables que va a necesitar. Cuando la función exterior termina y su frame desaparece, la función interior sigue llevando esas variables en la mochila — accesibles en cualquier momento.
def exterior():
x = 10 # x es una free variable para interior
def interior():
print(x) # usa x pero no la define
return interior # devuelve interior SIN llamarla
# Cuando exterior() termina, su frame desaparece
# Pero interior lleva x=10 en su mochila
f = exterior()
# Verificar que f es un closure
print(f.__code__.co_freevars) # ('x',)
print(f.__closure__[0].cell_contents) # 10
# exterior() ya terminó — pero f recuerda x
f() # imprime 10 — la mochila funciona
print("---")
# La regla LEGB y el scope de la Clase 6b
# explican exactamente esto:
# interior busca x → no está en L → busca en E
# el E ya no existe como frame — pero sí como closure
x_global = "global"
def outer_func():
y = "enclose"
def inner_func():
z = "local"
print(f"x: {x_global}, y: {y}, z: {z}")
return inner_func # ← devuelve, no llama
g = outer_func()
g() # x: global, y: enclose, z: local
print("---")
# Sin devolver la función — inner muere con outer
def outer_sin_closure():
y = "enclose"
def inner():
print(y)
inner() # ← llama directamente — no hay closure
outer_sin_closure() # funciona pero inner muere al terminar outer
— Closure con estado mutable: el contador —
Hasta ahora los closures solo
leían sus free variables.
Pero también pueden
modificarlas
usando nonlocal
— que vimos en la Clase 6b.
Esto permite crear funciones
que tienen estado propio —
que recuerdan información
entre llamadas.
def contador(inicio):
print(f"Creando contador desde {inicio}")
def incremento(paso=1):
nonlocal inicio # modifica la free variable
inicio = inicio + paso
return inicio
return incremento
# Crear dos contadores independientes
inc_0 = contador(0)
inc_10 = contador(10)
# Inspeccionar el closure
print(inc_0.__name__) # 'incremento'
print(inc_0.__code__.co_freevars) # ('inicio',)
print(inc_0.__closure__[0].cell_contents) # 0
print("---")
# Cada llamada modifica el estado del closure
print(inc_0()) # 1
print(inc_0()) # 2
print(inc_0()) # 3
print(inc_0(5)) # 8 — paso de 5
print(inc_0(5)) # 13
print("---")
# Los dos contadores son independientes
print(inc_10()) # 11 — empieza desde 10
print(inc_10()) # 12
print(inc_0()) # 14 — sigue donde lo dejó
— Ejemplo aplicado: acumulador de ventas —
# Un acumulador que recuerda el total entre llamadas
def crear_acumulador(nombre):
total = 0
transacciones = []
def registrar(cantidad, concepto="venta"):
nonlocal total
total += cantidad
transacciones.append({
"concepto": concepto,
"cantidad": cantidad,
"total": total
})
print(f"[{nombre}] {concepto}: +{cantidad}€ → total: {total}€")
return total
return registrar
# Dos acumuladores independientes
tienda_a = crear_acumulador("Tienda A")
tienda_b = crear_acumulador("Tienda B")
tienda_a(150)
tienda_a(80, "devolución")
tienda_b(200)
tienda_a(120)
tienda_b(95)
print("---")
print(f"Total Tienda A: {tienda_a(0)}€") # acumula un 0 — no ideal
# Mejor: separar registrar de consultar — lo veremos en Bloque 4
— Por qué persiste: frame vs heap —
Conectando con la Clase 6b —
frame, stack y heap:
cuando tablas_mult(3)
termina, su frame se destruye.
Pero el objeto función
tablas ya fue
creado en el heap
— que persiste mientras
alguien lo referencie.
Las free variables también
viven en el heap — en celdas
especiales adjuntas al objeto función.
Por eso sobreviven aunque el frame
haya desaparecido.
Qué desaparece
El frame de tablas_mult
— con sus variables locales
y parámetros.
Después del return
ya no existe.
Qué persiste
El objeto función tablas
en el heap — con su closure
adjunto que contiene
el valor de n.
def a(x):
return x * 2
def hacer_b(n):
def b(x):
return x * n
return b
def hacer_c(n):
def c(x):
return x * 2 # no usa n
return c
b = hacer_b(5)
c = hacer_c(5)
inc = contador(100)
print(inc())
print(inc())
print(inc(10))
print(inc(-5))
print(inc())
# Versión A
def outer_a():
x = 10
def inner_a():
print(x)
inner_a() # llama directamente
# Versión B
def outer_b():
x = 10
def inner_b():
print(x)
return inner_b # devuelve sin llamar
outer_a()
f = outer_b()
f()
registrar?
¿Por qué transacciones
no necesita nonlocal
aunque se modifica dentro
de registrar
pero total sí?
a no es un closure
— no tiene free variables.
a.__closure__
es None.
b = hacer_b(5)
es un closure —
usa n
del scope enclosing.
b.__closure__
contiene 5.
c = hacer_c(5)
no es un closure —
aunque viene de una función
anidada, no usa ninguna
variable del scope exterior.
c.__closure__
es None.
La existencia del closure
depende de si hay
free variables —
no de si la función
está anidada.
contador(100)
imprime
"Creando contador desde 100".
inc() → 101.
inc() → 102.
inc(10) → 112.
inc(-5) → 107.
inc() → 108.
El estado persiste entre llamadas
porque inicio
vive en el closure —
no se reinicia en cada llamada.
La versión A no produce closure —
inner_a
se llama directamente
y muere con outer_a.
No hay objeto que sobreviva
con las free variables.
La versión B produce closure —
inner_b
se devuelve sin llamar
y f
la referencia en el scope global.
El objeto sobrevive con
x = 10
en su closure.
Ambas imprimen 10
pero solo B crea un closure.
registrar
tiene tres free variables:
nombre,
total
y transacciones.
transacciones
no necesita nonlocal
porque no se
reasigna
— se muta.
transacciones.append()
modifica el objeto lista
que ya existe en el heap
sin crear una referencia nueva.
Python no necesita saber
que la variable cambió
porque la variable
sigue apuntando al mismo objeto.
total
sí necesita nonlocal
porque total += cantidad
es una asignación —
total = total + cantidad
— que crea una nueva referencia.
Sin nonlocal,
Python trataría total
como variable local
y lanzaría
UnboundLocalError.
nonlocal
solo es necesario
cuando hay una
asignación
a la free variable.
Mutar un objeto mutable
— lista, dict —
no requiere nonlocal
porque no hay asignación.
Un closure con estado mutable — como el contador del Bloque 3 — hace exactamente lo mismo que un objeto con atributos y métodos. No es una coincidencia: ambos son formas de encapsular estado y comportamiento juntos. Entender la equivalencia ayuda a leer código que usa cualquiera de los dos patrones.
— El mismo contador: closure vs clase —
# Versión closure — del Bloque 3
def contador_closure(inicio):
def incremento(paso=1):
nonlocal inicio
inicio += paso
return inicio
return incremento
# Versión clase — equivalente exacto
class Counter():
def __init__(self, inicio):
self.inicio = inicio
def __call__(self, paso=1):
self.inicio += paso
return self.inicio
# Uso idéntico
inc_closure = contador_closure(0)
inc_clase = Counter(0)
print(inc_closure()) # 1
print(inc_clase()) # 1
print(inc_closure()) # 2
print(inc_clase()) # 2
print(inc_closure(5)) # 7
print(inc_clase(5)) # 7
Closure — cuándo usarlo
Cuando el estado es simple — una o dos variables. Cuando quieres algo ligero sin definir una clase entera. Cuando produces funciones configuradas para pasar como argumentos.
Clase — cuándo usarla
Cuando el estado es complejo — muchos atributos. Cuando necesitas varios métodos. Cuando la legibilidad importa más que la concisión. Cuando otros programadores leerán el código.
— La conexión con los decoradores —
Un decorador es exactamente una fábrica de funciones que envuelve otra función añadiéndole comportamiento. Lo veremos en detalle en la Clase 6d — pero ya podemos entender el mecanismo completo: un decorador recibe una función, crea una función interior que llama a la original añadiendo algo antes o después, y devuelve esa función interior. Es un closure cuya free variable es la función original.
# Un closure que mide el tiempo de ejecución
# — exactamente un decorador sin la sintaxis @
import time
def medir_tiempo(func):
"""
Recibe una función y devuelve una versión
que mide su tiempo de ejecución.
func es la free variable del closure.
"""
def wrapper(*args, **kwargs):
inicio = time.time()
resultado = func(*args, **kwargs) # llama a la original
duracion = time.time() - inicio
print(f"{func.__name__} tardó {duracion:.4f}s")
return resultado
return wrapper # devuelve el closure
# Usarlo manualmente — sin sintaxis @
def calcular_suma(n):
return sum(range(n))
def calcular_lista(n):
return [x**2 for x in range(n)]
# medir_tiempo devuelve un closure
# cuya free variable es la función original
suma_medida = medir_tiempo(calcular_suma)
lista_medida = medir_tiempo(calcular_lista)
print(suma_medida(1_000_000))
print(len(lista_medida(10_000)))
print("---")
# Inspeccionar: medir_tiempo devuelve un closure
print(suma_medida.__name__) # 'wrapper'
print(suma_medida.__code__.co_freevars) # ('func',)
print(suma_medida.__closure__[0].cell_contents) #
— La sintaxis @ no es magia — es azúcar sintáctico —
La sintaxis @decorador
que veremos en la Clase 6d
es exactamente lo mismo
que escribir
funcion = decorador(funcion).
Nada más.
Es una abreviación — azúcar sintáctico —
para el patrón que acabamos de ver.
# Estas dos formas son exactamente equivalentes
# Forma manual — lo que hemos visto
def calcular_suma(n):
return sum(range(n))
calcular_suma = medir_tiempo(calcular_suma)
print(calcular_suma(100))
print("---")
# Forma con @ — exactamente lo mismo, más legible
@medir_tiempo
def calcular_producto(n):
resultado = 1
for i in range(1, n + 1):
resultado *= i
return resultado
# @medir_tiempo equivale a:
# calcular_producto = medir_tiempo(calcular_producto)
print(calcular_producto(10))
print("---")
# El decorador funciona con cualquier función
# porque wrapper usa *args y **kwargs
@medir_tiempo
def analizar_datos(datos, umbral=0):
return [x for x in datos if x > umbral]
resultado = analizar_datos(
list(range(1_000_000)),
umbral=500_000
)
print(f"Encontrados: {len(resultado)} elementos")
lista_medida:
¿cuál es su free variable?
¿Qué contiene?
¿Por qué wrapper
usa *args
y **kwargs
en lugar de parámetros fijos?
__init__
y __call__.
¿Cuál de las dos versiones
te parece más legible?
¿Por qué?
@medir_tiempo:
@medir_tiempo
def saludar(nombre):
return f"Hola, {nombre}"
resultado = saludar("Ana")
print(resultado)
print(saludar.__name__)
contar_llamadas
que cuente cuántas veces
se ha llamado a una función
y lo imprima en cada llamada.
Úsalo con @
sobre una función simple.
La free variable de
lista_medida
es func
— que contiene el objeto función
calcular_lista.
wrapper
usa *args
y **kwargs
para ser compatible
con cualquier función
que se le pase —
independientemente
de cuántos parámetros tenga.
Si usara parámetros fijos
como wrapper(n),
solo funcionaría con funciones
que reciben exactamente
un argumento posicional.
Con *args, **kwargs
funciona con cualquier firma.
class Acumulador:
def __init__(self, nombre):
self.nombre = nombre
self.total = 0
self.transacciones = []
def registrar(self, cantidad, concepto="venta"):
self.total += cantidad
self.transacciones.append({
"concepto": concepto,
"cantidad": cantidad,
"total": self.total
})
print(f"[{self.nombre}] {concepto}: "
f"+{cantidad}€ → total: {self.total}€")
return self.total
tienda = Acumulador("Tienda A")
tienda.registrar(150)
tienda.registrar(80, "devolución")
La versión clase es más legible
cuando hay múltiples atributos
y métodos.
La versión closure es más compacta
cuando el estado es simple.
Para este ejemplo con
nombre,
total
y transacciones,
la clase empieza a ganar
en claridad.
@medir_tiempo
equivale a
saludar = medir_tiempo(saludar).
medir_tiempo
recibe la función saludar,
crea wrapper
con func = saludar
como free variable
y lo devuelve.
saludar ahora
apunta a wrapper.
Al llamar saludar("Ana"):
se ejecuta wrapper("Ana"),
que mide el tiempo,
llama a la saludar
original con "Ana"
y devuelve el resultado.
Imprime el tiempo transcurrido,
luego "Hola, Ana",
luego 'wrapper'
— porque saludar.__name__
ahora es el nombre de
wrapper.
saludar.__name__
devuelve 'wrapper'
en lugar de 'saludar'.
En la Clase 6d veremos
functools.wraps
— que preserva el nombre
y el docstring originales.
def contar_llamadas(func):
contador = [0] # lista para poder mutar sin nonlocal
def wrapper(*args, **kwargs):
contador[0] += 1
print(f"{func.__name__} llamada {contador[0]} vez/veces")
return func(*args, **kwargs)
return wrapper
@contar_llamadas
def saludar(nombre):
return f"Hola, {nombre}"
print(saludar("Ana"))
print(saludar("Bob"))
print(saludar("Carlos"))
contador = [0]
en lugar de
contador = 0
evita necesitar
nonlocal
— porque mutar una lista
con [0] += 1
no es una asignación
al nombre contador.
Es el mismo principio
que vimos en
transacciones.append()
del Bloque 3.
En la Clase 6d usaremos
nonlocal
directamente — más claro.
Un paradigma de programación no es una sintaxis ni una librería — es una forma de pensar sobre los problemas y de organizarlas soluciones. Hemos visto el paradigma imperativo desde la Clase 1: instrucciones que se ejecutan una tras otra, variables que cambian de valor, bucles que modifican el estado.
El paradigma funcional propone algo distinto: programar casi exclusivamente con funciones — funciones que reciben datos, producen resultados y no modifican nada del entorno. No es que los bucles o las variables estén prohibidos — es que se prefiere evitarlos cuando hay una alternativa más clara y más segura.
Python no es un lenguaje puramente funcional — pero soporta el paradigma muy bien gracias a todo lo que hemos visto en esta clase. La IA genera código funcional con frecuencia porque es más predecible, más reutilizable y más fácil de verificar.
— Los tres pilares del paradigma funcional —
1. Funciones puras
Mismo input → siempre mismo output. Sin efectos laterales — no modifica nada fuera de su propio scope.
2. Inmutabilidad
No modificar los datos de entrada — producir datos nuevos. Los originales quedan siempre intactos.
3. Composición
Combinar funciones simples para resolver problemas complejos. El output de una es el input de la siguiente.
— Funciones puras: predecibles y verificables —
Una función pura tiene
dos propiedades:
dado el mismo input
siempre devuelve el mismo output,
y no modifica nada fuera
de su propio scope —
no cambia variables globales,
no modifica los datos de entrada,
no escribe en disco,
no imprime en pantalla.
Todo lo que hace está
en el return.
# ✓ Función pura — predecible y sin efectos
def calcular_media(datos):
return sum(datos) / len(datos)
# Mismo input → siempre mismo output
print(calcular_media([1, 2, 3])) # 2.0 — siempre
print(calcular_media([1, 2, 3])) # 2.0 — siempre
# Los datos originales no cambian
print("---")
# ❌ Función impura — modifica el entorno
resultados = []
def calcular_y_guardar(datos):
media = sum(datos) / len(datos)
resultados.append(media) # efecto lateral — modifica global
return media
print(calcular_y_guardar([1, 2, 3])) # 2.0
print(calcular_y_guardar([1, 2, 3])) # 2.0 — mismo resultado
print(resultados) # [2.0, 2.0] — efecto lateral acumulado
print("---")
# ❌ Función impura — modifica los datos de entrada
def normalizar_impura(datos):
for i in range(len(datos)):
datos[i] = datos[i] / max(datos) # modifica la lista original
return datos
# ✓ Función pura — produce datos nuevos
def normalizar_pura(datos):
maximo = max(datos)
return [x / maximo for x in datos] # nueva lista — original intacta
original = [10, 20, 30, 40]
normalizada = normalizar_pura(original)
print(f"original: {original}") # [10, 20, 30, 40] — intacta
print(f"normalizada: {normalizada}") # [0.25, 0.5, 0.75, 1.0]
— Inmutabilidad: producir datos nuevos —
El principio de inmutabilidad
dice que los datos de entrada
no se deben modificar —
si necesitas datos transformados,
produces unos nuevos.
Esto evita errores difíciles
de detectar donde una función
modifica datos que otra función
estaba usando.
Es el principio detrás de
sorted() — que devuelve
una lista nueva en lugar de
modificar la original —
frente a lista.sort()
que modifica en lugar.
# Imperativo — modifica en lugar
datos = [3, 1, 4, 1, 5]
datos.sort() # modifica datos — la original desaparece
print(datos) # [1, 1, 3, 4, 5]
# Funcional — produce datos nuevos
datos = [3, 1, 4, 1, 5]
ordenados = sorted(datos) # nueva lista — datos intactos
print(datos) # [3, 1, 4, 1, 5] — intacta
print(ordenados) # [1, 1, 3, 4, 5]
print("---")
# En pandas — las operaciones devuelven DataFrames nuevos
import pandas as pd
df = pd.DataFrame({"ventas": [150, 80, None, 200]})
# Funcional — produce un DataFrame nuevo
df_limpio = df.dropna()
print(f"original: {len(df)} filas") # 4 — intacto
print(f"limpio: {len(df_limpio)} filas") # 3
— Composición: construir con piezas simples —
La composición es la idea de que una función compleja puede construirse combinando funciones simples. El output de una función es el input de la siguiente. Cada función hace una sola cosa bien — y la combinación de varias resuelve problemas complejos.
# Funciones simples — cada una hace una sola cosa
def quitar_nulos(datos):
return [x for x in datos if x is not None]
def quitar_negativos(datos):
return [x for x in datos if x >= 0]
def normalizar(datos):
maximo = max(datos)
return [x / maximo for x in datos]
def redondear(datos, decimales=2):
return [round(x, decimales) for x in datos]
# Composición manual — encadenando funciones
datos_brutos = [10, None, -5, 20, 30, None, 15]
resultado = redondear(
normalizar(
quitar_negativos(
quitar_nulos(datos_brutos)
)
)
)
print(resultado) # [0.33, 0.67, 1.0, 0.5]
print("---")
# Más legible — paso a paso
sin_nulos = quitar_nulos(datos_brutos)
sin_neg = quitar_negativos(sin_nulos)
normalizado = normalizar(sin_neg)
redondeado = redondear(normalizado)
print(redondeado)
print("---")
# Composición con pipeline — el patrón del Bloque 2
def pipeline(datos, *funciones):
for f in funciones:
datos = f(datos)
return datos
resultado = pipeline(
datos_brutos,
quitar_nulos,
quitar_negativos,
normalizar,
redondear
)
print(resultado)
— El mismo problema: imperativo vs funcional —
# Problema: dado una lista de ventas,
# calcular el total de las ventas altas (> 100)
# después de aplicar un 10% de bonificación
ventas = [150, 80, 200, 30, 120, 90, 175]
# Versión imperativa — estado mutable, bucles
total_imp = 0
for v in ventas:
if v > 100:
total_imp += v * 1.10
print(f"Imperativo: {total_imp:.2f}")
# Versión funcional — composición sin estado mutable
def es_alta(v): return v > 100
def con_bonus(v): return v * 1.10
total_func = sum(map(con_bonus, filter(es_alta, ventas)))
print(f"Funcional: {total_func:.2f}")
# O con comprehension — también funcional
total_comp = sum(v * 1.10 for v in ventas if v > 100)
print(f"Comprehension: {total_comp:.2f}")
import random
def f1(x):
return x * 2
def f2(lista):
lista.sort()
return lista
def f3(x):
return x + random.random()
def f4(datos):
return [x * 2 for x in datos]
log = []
def procesar(datos, umbral):
resultado = []
for x in datos:
if x > umbral:
resultado.append(x * 2)
log.append(f"procesado: {x}")
return resultado
quitar_nulos,
quitar_negativos,
normalizar
y redondear
del ejemplo, reescribe
la composición manual
usando la función
pipeline.
¿Es más legible?
f1 es pura —
mismo input siempre
mismo output,
sin efectos laterales.
f2 no es pura —
modifica la lista
de entrada con
.sort().
El mismo input puede
producir el mismo output
pero el efecto lateral
— modificar la lista —
la hace impura.
f3 no es pura —
dado el mismo input
produce outputs distintos
en cada llamada
porque usa
random.random().
f4 es pura —
produce una lista nueva
sin modificar la original.
Mismo input → mismo output,
sin efectos laterales.
def procesar_pura(datos, umbral):
return [x * 2 for x in datos if x > umbral]
# Si necesitas el log, devuélvelo también
def procesar_con_log(datos, umbral):
procesados = [x for x in datos if x > umbral]
log = [f"procesado: {x}" for x in procesados]
resultado = [x * 2 for x in procesados]
return resultado, log
resultado, log = procesar_con_log([50, 150, 80, 200], 100)
print(resultado)
print(log)
La versión pura devuelve el log como valor en lugar de escribirlo en una variable global. Quien llama a la función decide qué hacer con el log.
datos_brutos = [10, None, -5, 20, 30, None, 15]
resultado = pipeline(
datos_brutos,
quitar_nulos,
quitar_negativos,
normalizar,
redondear
)
print(resultado) # [0.33, 0.67, 1.0, 0.5]
Es más legible porque el orden de las transformaciones es explícito y se lee de arriba a abajo — igual que un pipeline de análisis de datos real. Añadir o quitar un paso es trivial.
La IA genera código funcional porque es más predecible — las funciones puras son fáciles de verificar y de combinar. También porque el código funcional es más general — una función de orden superior funciona con cualquier dato que tenga la forma correcta.
En análisis de datos
el paradigma funcional
encaja perfectamente
porque los datos no se modifican
— se transforman en copias nuevas.
Pandas está diseñado así:
df.dropna(),
df.groupby(),
df.merge()
devuelven DataFrames nuevos
sin tocar el original.
El paradigma imperativo sigue siendo mejor cuando el rendimiento es crítico — los bucles explícitos a veces son más rápidos — o cuando la lógica es inherentemente secuencial y con mucho estado compartido.
El paradigma funcional no es una teoría abstracta — aparece constantemente en código real y en código de IA. Saber reconocerlo permite leer ese código sin confusión y entender por qué está escrito así. Hay cinco señales claras de que un fragmento de código sigue el paradigma funcional.
— Cinco señales de código funcional —
import pandas as pd
from functools import reduce
ventas = [150, 80, 200, 30, 120, 90, 175]
# Señal 1: funciones de orden superior
# map, filter, sorted con key
altas = list(filter(lambda v: v > 100, ventas))
print("altas:", altas)
# Señal 2: encadenamiento — output de una es input de la siguiente
total = sum(map(lambda v: v * 1.10,
filter(lambda v: v > 100, ventas)))
print(f"total con bonus: {total:.2f}")
# Señal 3: funciones como argumentos
ranking = sorted(ventas, key=lambda v: -v)
print("ranking:", ranking)
# Señal 4: sin variables de estado — sin total = 0, for, total +=
producto = reduce(lambda acc, v: acc * v,
filter(lambda v: v > 100, ventas),
1)
print(f"producto de altas: {producto}")
# Señal 5: comprehensions — declarativo sin estado
normalizado = [v / max(ventas) for v in ventas]
print("normalizado:", [round(x, 2) for x in normalizado])
— Composición: construir funciones complejas desde simples —
La composición de funciones es el corazón del paradigma funcional. En matemáticas, f ∘ g significa "aplicar g primero, luego f". En Python podemos construir una función que compone dos funciones cualesquiera — una función que fabrica composiciones.
# Composición de dos funciones
def componer(f, g):
"""Devuelve una función que aplica g primero, luego f."""
def composicion(x):
return f(g(x))
return composicion # closure: f y g son free variables
def doble(x): return x * 2
def incrementar(x): return x + 1
def cuadrado(x): return x ** 2
# Componer funciones — el orden importa
doble_luego_incr = componer(incrementar, doble) # primero doble, luego +1
incr_luego_doble = componer(doble, incrementar) # primero +1, luego doble
print(doble_luego_incr(5)) # (5×2)+1 = 11
print(incr_luego_doble(5)) # (5+1)×2 = 12
print("---")
# Composición de múltiples funciones
def componer_muchas(*funciones):
"""Compone cualquier número de funciones de derecha a izquierda."""
def composicion(x):
resultado = x
for f in reversed(funciones):
resultado = f(resultado)
return resultado
return composicion
def redondear_2(x): return round(x, 2)
def raiz(x): return x ** 0.5
# Aplicar: primero cuadrado, luego raíz, luego redondear
transformar = componer_muchas(redondear_2, raiz, cuadrado)
print([transformar(x) for x in [2, 3, 4, 5]])
# cuadrado→raíz→redondear: cada número vuelve a sí mismo
print("---")
# Composición en análisis de datos
def limpiar(datos):
return [x for x in datos if x is not None and x >= 0]
def escalar(datos):
m = max(datos)
return [x / m for x in datos]
def redondear_lista(datos):
return [round(x, 3) for x in datos]
preprocesar = componer_muchas(redondear_lista, escalar, limpiar)
datos_brutos = [10, None, -5, 20, 30, None, 15]
print(preprocesar(datos_brutos)) # [0.333, 0.667, 1.0, 0.5]
— Pandas es fundamentalmente funcional —
Pandas está diseñado siguiendo
el paradigma funcional:
la mayoría de sus operaciones
devuelven DataFrames nuevos
sin modificar el original.
El encadenamiento de métodos
con . es exactamente
composición de funciones —
el output de una operación
es el input de la siguiente.
import pandas as pd
import numpy as np
df = pd.DataFrame({
"producto": ["manzana", "pera", "naranja",
"kiwi", "fresa", "uva"],
"ventas": [150, 80, None, 200, 95, 30],
"categoria": ["fruta", "fruta", "cítrico",
"exótico", "fruta", "fruta"],
"precio": [1.20, 0.90, 0.80, 1.50, 2.00, 0.60]
})
# Encadenamiento funcional en pandas
# — composición de operaciones sin variables intermedias
resultado = (
df
.dropna() # quitar nulos
.query("categoria == 'fruta'") # filtrar frutas
.assign(ingreso=lambda d: # añadir columna
d["ventas"] * d["precio"])
.sort_values("ingreso", ascending=False) # ordenar
.reset_index(drop=True) # reiniciar índice
)
print(resultado[["producto", "ventas", "precio", "ingreso"]])
print("---")
# El original no se modificó
print(f"df original: {len(df)} filas")
print(f"resultado: {len(resultado)} filas")
print("---")
# groupby + agg — composición funcional sobre grupos
resumen = (
df
.dropna()
.groupby("categoria")["ventas"]
.agg(["sum", "mean", "count"])
.round(1)
)
print(resumen)
— Cuándo usar el paradigma funcional y cuándo no —
| Situación | Paradigma | Por qué |
|---|---|---|
| Transformar y filtrar datos | Funcional | Predecible, sin efectos laterales |
| Pipeline de análisis | Funcional | Composición clara y modificable |
| Mucho estado compartido | Imperativo o OOP | Más natural con clases |
| Rendimiento crítico | Imperativo | Bucles explícitos a veces más rápidos |
| Funciones reutilizables y configurables | Funcional | Fábricas y closures |
| Lógica compleja con muchas condiciones | Mixto | Lo que sea más legible |
datos = [12, None, -3, 45, 8, None, 67, 23]
resultado = sorted(
list(map(
lambda x: round(x / max(datos_limpios), 2),
datos_limpios := list(filter(
lambda x: x is not None and x > 0,
datos
))
)),
reverse=True
)
print(resultado)
¿Es legible este código?
¿Cómo lo reescribirías
de forma más clara?
nombre,
ventas y
region,
elimine nulos,
filtre solo la región
"Norte",
añada una columna
categoria
con "alto"
o "bajo"
según si ventas supera 100,
y devuelva ordenado
por ventas descendente.
ventas = [150, 80, 200, 30, 120]
# Enfoque A
total_a = 0
for v in ventas:
if v > 100:
total_a += v
# Enfoque B
total_b = sum(filter(lambda v: v > 100, ventas))
componer_muchas,
construye un pipeline
que procese una lista de strings:
elimine los strings vacíos,
convierta a mayúsculas,
elimine duplicados
y ordene alfabéticamente.
Las cinco señales:
filter
— función de orden superior,
map
— función de orden superior,
lambda
— función anónima como argumento,
sorted con criterio
— orden superior,
sin variables de estado
— sin bucles ni acumuladores.
No es legible — demasiado anidamiento en una sola expresión. La reescritura más clara:
def es_valido(x):
return x is not None and x > 0
datos_limpios = list(filter(es_valido, datos))
maximo = max(datos_limpios)
normalizado = [round(x / maximo, 2) for x in datos_limpios]
resultado = sorted(normalizado, reverse=True)
print(resultado)
resultado = (
df
.dropna(subset=["ventas"])
.query("region == 'Norte'")
.assign(categoria=lambda d:
d["ventas"].apply(
lambda v: "alto" if v > 100 else "bajo"
))
.sort_values("ventas", ascending=False)
.reset_index(drop=True)
)
Ambos producen el mismo resultado. El enfoque A es imperativo — variable de estado, bucle explícito, acumulación. El enfoque B es funcional — una expresión, sin estado, declarativo.
Para este caso simple ambos son igual de legibles. Cuando la lógica crece — más condiciones, más transformaciones — el enfoque funcional escala mejor porque cada parte es independiente y verificable. El enfoque imperativo puede ser preferible cuando hay que depurar paso a paso o cuando el rendimiento es crítico.
def quitar_vacios(datos):
return [x for x in datos if x.strip()]
def a_mayusculas(datos):
return [x.upper() for x in datos]
def sin_duplicados(datos):
return list(dict.fromkeys(datos))
def ordenar(datos):
return sorted(datos)
procesar_strings = componer_muchas(
ordenar,
sin_duplicados,
a_mayusculas,
quitar_vacios
)
strings = ["python", "", "datos", "PYTHON",
" ", "análisis", "datos"]
print(procesar_strings(strings))
# ['ANÁLISIS', 'DATOS', 'PYTHON']