UdelaR  ·  Máster en Análisis de Datos

Introducción a Python

Clase 6c — First-class functions, closures y el paradigma funcional

Asignatura  Introducción a Python Profesor  Daniel Miles Touya Universidad  UdelaR-Uvigo
Parte I

Funciones como objetos: first-class functions

Funciones como objetos: el concepto que cambia todo teoría

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
La distinción clave: 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.

  1. Predice qué imprime cada línea antes de ejecutar:
    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))
  2. Después de ejecutar 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?
  3. ¿Cuál de estas líneas asigna la función a una variable y cuál la llama y asigna el resultado? ¿Qué tipo tiene cada una?
    def saludar(nombre):
        return f"Hola, {nombre}"
    
    a = saludar
    b = saludar("Ana")
    print(type(a))
    print(type(b))
  4. ¿Por qué el hecho de que las funciones sean objetos es importante para la IA? Piensa en qué ocurriría si la IA no pudiera pasar funciones como argumentos o devolverlas desde otras funciones.
Respuesta orientativa
a

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'>.

b

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.

c

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.

d

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.

La consecuencia práctica: cuando la IA genera código con 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.
Las cinco capacidades de una función first-class práctica

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.

  1. Predice qué imprime este código antes de ejecutarlo:
    def doblar(x):
        return x * 2
    
    def triplicar(x):
        return x * 3
    
    ops = [doblar, triplicar, doblar]
    for f in ops:
        print(f(5))
  2. ¿Qué ventaja tiene guardar funciones en un diccionario frente a usar un 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
  3. En el ejemplo del pipeline de pandas: ¿qué ventaja tiene guardar los pasos en una lista frente a llamarlos uno por uno? ¿Cómo añadirías un paso nuevo al pipeline?
  4. ¿Por qué este código produce un error? ¿Cómo lo corregirías?
    def sumar(x, y):
        return x + y
    
    operaciones = [sumar, len, abs]
    for op in operaciones:
        print(op(4))
Respuesta orientativa
a

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.

b

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
c

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 patrón pipeline es uno de los más frecuentes en análisis de datos — una lista de transformaciones que se aplican en secuencia. Solo es posible porque las funciones son objetos que se pueden guardar en una lista.
d

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))
Parte II

Funciones como argumentos: separar el qué del cómo

Pasar funciones como argumentos: separar el qué del cómo teoría

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}")
El principio general: una función que recibe otra función como argumento puede aplicar cualquier operación sin saber cuál es exactamente. 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.
  1. ¿Qué imprime este código? Predice antes de ejecutar:
    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))
  2. Reescribe estos tres bucles usando 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}€")
  3. En 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é?
  4. ¿Cuál es la diferencia conceptual entre estos dos códigos? ¿Cuál es más general 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]
Respuesta orientativa
a

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.

b

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))
c

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().

d

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.

El principio de generalización: cuando ves que estás escribiendo la misma estructura varias veces cambiando solo una operación, esa operación debería ser un parámetro de tipo función. Es exactamente lo que hizo Python con 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))
  1. Predice qué produce cada línea antes de ejecutar:
    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))
  2. ¿Cuál es la diferencia entre map y filter? ¿Cuándo usarías cada uno? Escribe un ejemplo con datos de ventas donde uses los dos encadenados.
  3. ¿Qué hace key=len en sorted? ¿Por qué se pasa len sin paréntesis? ¿Qué pasaría si escribieras key=len()?
  4. Este código generado por IA usa 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)
Respuesta orientativa
a

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.

b

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]
c

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.

d

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"
)
Cuándo usar apply: cuando la lógica es compleja y difícil de vectorizar. Para operaciones simples como esta — comparación con un umbral — 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.
Parte III

Funciones que devuelven funciones: fábricas y closures

Funciones que devuelven funciones: el primer paso teoría

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))
El patrón fábrica: una función que recibe parámetros de configuración y devuelve una función configurada. El código que usa la función no sabe ni necesita saber cómo está configurada — solo la llama. Este patrón aparece constantemente en código de IA para crear funciones especializadas sin repetir código.
  1. Predice qué imprime cada línea antes de ejecutar:
    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"))
  2. ¿Cuál es la diferencia entre estas dos llamadas? ¿Qué tipo devuelve cada una?
    a = hablar_func(1)
    b = hablar_func(1)("hola")
  3. En el ejemplo del formateador: ¿qué ventaja tiene este patrón frente a pasar el formato como argumento en cada llamada? ¿Cuándo sería mejor una solución frente a la otra?
  4. Escribe una función crear_clasificador que reciba un umbral y devuelva una función que clasifique un número como "alto" o "bajo" según ese umbral.
Respuesta orientativa
a

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.

b

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.

c

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.

d
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"
Observa algo importante: 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.
La fábrica: funciones configuradas y la pregunta del closure teoría

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}")
Por qué las fábricas son poderosas: en lugar de escribir 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.
  1. ¿Por qué tablas3 y tablas5 devuelven resultados distintos si ambas son la misma función tablas? ¿Qué las diferencia?
  2. Inspecciona las free variables de 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?
  3. Escribe una fábrica 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].
  4. ¿Por qué es mejor usar una fábrica como 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?
Respuesta orientativa
a

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.

b

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.

c
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}€")
d

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.

La clave de cuándo usar fábricas: cuando la configuración es estable y el uso es repetido. Si los parámetros cambian en cada llamada, pásalos directamente. Si son fijos para un contexto concreto, crea una función configurada.
Qué es un closure: función + environment teoría

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
La condición para que exista un closure: la función interior debe devolverse — no llamarse — desde la exterior. Si se llama directamente dentro de la exterior, no hay closure: la interior nace y muere con la exterior. Si se devuelve, la interior sobrevive con su mochila de free variables.

— 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.

  1. ¿Cuál de estas funciones es un closure y cuál no? ¿Cómo lo verificarías?
    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)
  2. Predice qué imprime este código. Presta atención al estado del closure entre llamadas:
    inc = contador(100)
    print(inc())
    print(inc())
    print(inc(10))
    print(inc(-5))
    print(inc())
  3. ¿Cuál es la diferencia entre estos dos códigos? ¿Cuál produce un closure y cuál no?
    # 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()
  4. En el acumulador de ventas: ¿cuántas free variables tiene registrar? ¿Por qué transacciones no necesita nonlocal aunque se modifica dentro de registrar pero total sí?
Respuesta orientativa
a

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.

b

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.

c

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.

d

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.

La regla: 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.
Closures vs clases y la conexión con los decoradores teoría

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")
El ciclo completo: las funciones son objetos → se pueden pasar como argumentos → se pueden devolver desde otras funciones → una función que recibe una función y devuelve una función con comportamiento añadido es un decorador. Todo lo que hemos visto en esta clase conduce a este punto. En la Clase 6d veremos la sintaxis completa de los decoradores — pero el mecanismo ya lo entiendes completamente.
  1. Inspecciona el closure de lista_medida: ¿cuál es su free variable? ¿Qué contiene? ¿Por qué wrapper usa *args y **kwargs en lugar de parámetros fijos?
  2. Reescribe el acumulador de ventas del Bloque 3 como una clase con __init__ y __call__. ¿Cuál de las dos versiones te parece más legible? ¿Por qué?
  3. ¿Qué imprime este código? Explica paso a paso qué hace @medir_tiempo:
    @medir_tiempo
    def saludar(nombre):
        return f"Hola, {nombre}"
    
    resultado = saludar("Ana")
    print(resultado)
    print(saludar.__name__)
  4. Escribe un decorador 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.
Respuesta orientativa
a

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.

b
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.

c

@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.

El problema del nombre: después de decorar, saludar.__name__ devuelve 'wrapper' en lugar de 'saludar'. En la Clase 6d veremos functools.wraps — que preserva el nombre y el docstring originales.
d
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"))
El truco de la lista: 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.
Parte IV

El paradigma funcional

El paradigma funcional: una forma distinta de pensar teoría

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}")
Por qué el paradigma funcional es importante hoy: los tres resultados son idénticos. La diferencia es la legibilidad, la verificabilidad y la reutilización. La versión funcional expresa qué se quiere hacer — filtrar las altas, aplicar bonus, sumar — sin describir cómo hacerlo. Cada función es verificable por separado. Si el resultado es incorrecto, sabes exactamente en qué función buscar.
  1. ¿Cuál de estas funciones es pura y cuál no? Justifica cada respuesta:
    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]
  2. Reescribe esta función imperativa como función pura:
    log = []
    
    def procesar(datos, umbral):
        resultado = []
        for x in datos:
            if x > umbral:
                resultado.append(x * 2)
                log.append(f"procesado: {x}")
        return resultado
  3. Usando las funciones quitar_nulos, quitar_negativos, normalizar y redondear del ejemplo, reescribe la composición manual usando la función pipeline. ¿Es más legible?
  4. ¿Por qué la IA genera código funcional con frecuencia? ¿Qué ventaja tiene para el análisis de datos? ¿Cuándo el paradigma imperativo sigue siendo la mejor opción?
Respuesta orientativa
a

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.

b
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.

c
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.

d

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 en la práctica: reconocer y usar práctica

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
El mensaje central de esta clase: las funciones son objetos — se pueden asignar, guardar, pasar y devolver. Esto hace posible las funciones de orden superior, las fábricas, los closures y los decoradores. Todo encaja en un paradigma coherente donde el código se construye combinando funciones simples en lugar de acumular instrucciones. La IA genera este tipo de código con frecuencia — ahora sabes leerlo, entenderlo y saber cuándo usarlo tú mismo.
  1. Lee este fragmento de código generado por IA e identifica las cinco señales de código funcional que aparecen:
    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?
  2. Escribe un pipeline funcional en pandas que: lea un DataFrame con columnas 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.
  3. ¿Cuál es la diferencia entre estos dos enfoques para el mismo problema? ¿Cuál usarías y por qué?
    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))
  4. Usando 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.
Respuesta orientativa
a

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)
b
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)
)
c

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.

d
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']
El poder del pipeline: añadir un paso nuevo es añadir una función a la lista. Cambiar el orden es reordenar la lista. Cada función es verificable por separado. El código principal no cambia — solo la configuración.