U2 - Programación funcional

1 Trasfromando funciones

# 1
# * Usa variable global
# * Modifica variable global
# * El resultado no depende de los valores de entrada

def incrementar(contador):
    return contador + 1


# 2
# * El resultado no depende de la entrada, sino del momento de la llamada.
# Se puede obtener una función similar, pero no exactamente igual.
import datetime

def hora(dt):
    return dt.hour

hora(datetime.datetime.now())

# 3
# Acá no estoy tan seguro, supongo que lo que es no puro no es `add_time``, sino que es
# `increment_time`. Yo (tomi) propondría

def add_time(time, hours, minutes, seconds):
    return time + datetime.timedelta(hours=hours, minutes=minutes, seconds=seconds)

add_time(datetime.datetime(2025, 8, 12), 2, 12, 44)

# 4
# Modifica objeto global
# Salida sí depende de la entrada, eso está OK.
# Una alternativa posible
def registar_nombre(listado, nombre):
    return listado + [nombre]

historial_de_nombres = []
historial_de_nombres = registar_nombre(historial_de_nombres, "Mateo")
historial_de_nombres = registar_nombre(historial_de_nombres, "Camila")
historial_de_nombres = registar_nombre(historial_de_nombres, "Victoria")
historial_de_nombres

# 5
# No es pura, no signific que sea mala per se.
# El problema es que su salida no depende de sus valores
# Si cambia una variable fuera de ella, cammbia el resultado.
# Una alternativa
def verificar_limite(valor, limite):
    if valor > limite:
        return "Excede el límite"
    return "Dentro del límite"

LIMITE_MAXIMO = 100
verificar_limite(88, LIMITE_MAXIMO)

2 Fábrica de promociones

# Parte 1:
def crear_promocion(medio):
    if medio == "efectivo":
        multiplicador = 1
    elif medio == "débito":
        multiplicador = (1 - 0.1)
    elif medio == "crédito":
        multiplicador = (1 + 0.05)
    else:
        print(f"El medio {medio} es desconocido.")
        multiplicador = 1

    def f(x):
        return x * multiplicador

    return f

promo_debito = crear_promocion("débito")
print(promo_debito(1000))
print(promo_debito(2700))


# Parte 2
def crear_promocion_personalizada(medio, descuento=None):
    if descuento is None:
        if medio == "efectivo":
            multiplicador = 1
        elif medio == "débito":
            multiplicador = (1 - 0.1)
        elif medio == "crédito":
            multiplicador = (1 + 0.05)
        else:
            print(f"El medio {medio} es desconocido.")
            multiplicador = 1
    else:
        multiplicador = 1 - descuento / 100

    def f(x):
        return x * multiplicador

    return f

promo_debito = crear_promocion_personalizada("débito", 15)
promo_debito(1000)

promo_debito = crear_promocion_personalizada("débito")
promo_debito(1000)

3 Bendita media

def mean(valores, *args):
    # Asume que 'valores' es coleccion de numeros
    if not args:
        return sum(valores) / len(valores)

    # Asume que 'valores' es un solo numero
    x = [valores] + list(args)
    return sum(x) / len(x)


mean([6.27, 8.11, 7.6, 5.2, 4.8])
mean(7.3, 8.2, 11.0, 12.5)

4 Sucesión de Fibonacci

def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

for i in range(15):
    print(fibonacci(i))

5 Palíndromos recursivos

# Parte 2
def palindromo(x):
    if len(x) < 2:
        return True

    if x[0] == x[-1]:
        return palindromo(x[1:-1])

    return False

palindromo("hola amigo")
palindromo("somos")
palindromo("anilina")
palindromo("menem")
palindromo("neuquen")



# Parte 2
def _palindromo(x):
    # Identica a la anterior, pero ahora es una funcion interna
    if len(x) < 2:
        return True

    if x[0] == x[-1]:
        return palindromo(x[1:-1])

    return False

def palindromo(x):
    return _palindromo(x.lower().replace(" ", ""))

palindromo("Anita lava la tina")
palindromo("Luz azul")
palindromo("Cualquier cosa")

6 Área de aprendizaje

rectangulos = [
    (5, 8),
    (2, 2),
    (9, 2),
    (3, 3),
    (3, 7),
    (6, 3)
]

sorted(rectangulos, key=lambda r: r[0] *  r[1])

# Cuidado que esto funciona, pero hace otra cosa
sorted(rectangulos)

7 Socios ordenados

datos_socios = [
    {"nombre": "Bautista Carrara", "edad": 22, "altura_cm": 178, "record_100m": 13.4},
    {"nombre": "Valentina Lucci",  "edad": 23, "altura_cm": 163, "record_100m": 14.2},
    {"nombre": "Gerónimo Cuesta",  "edad": 26, "altura_cm": 170, "record_100m": 14.0},
    {"nombre": "Lucio Borga",      "edad": 28, "altura_cm": 186, "record_100m": 13.8},
    {"nombre": "Julia Spoglia",    "edad": 21, "altura_cm": 163, "record_100m": 11.9},
    {"nombre": "Soledad Colombo",  "edad": 22, "altura_cm": 170, "record_100m": 13.5}
]

sorted(datos_socios, key=lambda datum: datum["record_100m"])

# Punto extra
def ordenar_diccionario(data, key):
    if key == "nombre":
        return sorted(data, key=lambda datum: datum["nombre"].split(" ")[1])
    return sorted(data, key=lambda datum: datum[key])

ordenar_diccionario(datos_socios, "record_100m")
ordenar_diccionario(datos_socios, "edad")
ordenar_diccionario(datos_socios, "altura_cm")
ordenar_diccionario(datos_socios, "nombre")

8 Listado de rimas

palabras_a_rimar = [
    "actividad",
    "bendición",
    "cartelera",
    "ciudad",
    "escalera",
    "estación",
    "felicidad",
    "función",
    "reposera"
]

sorted(palabras_a_rimar, key=lambda c: c[::-1])

9 Analistas de temperaturas

temperaturas_celsius = [
    25.5, 28.0, 19.3, 31.5, 22.8, 17.0, 30.2, 35.6, 14.2,
    32.4, 22.7, 10.1, 29.5, 33.9, 22.1, 38.9, 18.4, 16.3
]

# Enfoque funcional
# 1
temperaturas_f = list(map(lambda c: c * 9 / 5 + 32, temperaturas_celsius))

# 2
# Opcion A:
list(filter(lambda f: f > 80, temperaturas_f))

# Opcion B --> las devuelve en grados celsius
list(filter(lambda c: (c * 9 / 5 + 32) > 80, temperaturas_celsius))

# Enfoque idiomático
[
    c * 9 / 5 + 32 for c in temperaturas_celsius if c > 22
]

10 El tiempo vuela

import time

def crear_cronometro():
    t_creacion = time.time()
    def fun():
        t_ejecucion = time.time()
        return t_ejecucion - t_creacion
    return fun

cronometro1 = crear_cronometro()

for i in range(10**4):
    i ** 2 # Calcula el cuadrado de un número pero no lo devuelve

print(f"El bloque entero tardó {cronometro1()} segundos en ejecutarse.")


cronometro2 = crear_cronometro()

for j in range(10**6):
    j // 2 # Calcula la división entera por 2 pero no la devuelve

print(f"El segundo bucle tardó {cronometro2()} segundos en ejecutarse.")

# Punto extra: ¿cache?
def crear_cronometro():
    t_creacion = time.time()
    tiempos = [t_creacion]
    def fun():
        t_ejecucion = time.time()
        t_anterior = tiempos[-1]
        tiempos.append(t_ejecucion)
        return t_ejecucion - t_anterior
    return fun

g = crear_cronometro()

g()
g()
g()

11 No perdamos el centro

numeros = [
    2.05, 1.09, None, 2.31, 2.28, 0.97, 2.59, 2.72, 0.76, None, 1.88, 2.04, 3.25, 1.88, None
]

numeros_no_nulos = [n for n in numeros if n is not None]
media = sum(numeros_no_nulos) / len(numeros_no_nulos)

[n - media for n in numeros if n is not None]

12 En Python es mejor

numeros = [
    4.74346239e-01, -2.90877176e-01, -1.44377789e+00, -4.48680759e+01,
    -1.21249801e+00, -3.32729317e-01,  2.21676912e-01,  1.05599711e+00,
    -3.62372053e+00, -2.96441579e-01, -4.28304222e+00,  1.55908820e+02,
    9.00858234e-01, -1.09384173e+00, -1.51083571e+00, -5.38491167e-01,
    -3.84153084e-02,  1.20393395e+00,  1.82651406e-01,  2.05179405e+00
]

def media(x):
    return sum(x) / len(x)

def varianza(x):
    numerador = 0
    x_media = media(x)
    for x_i in x:
        numerador += (x_i - x_media) ** 2
    return numerador / len(x)

x_media = media(numeros)
x_desvio = varianza(numeros) ** 0.5

map_obj = map(lambda x: (x - x_media) / x_desvio, numeros)
list(filter(lambda x: abs(x) > 3, map_obj))

# Respuesta 1: si queremos el numero transformado
[(x - x_media) / x_desvio for x in numeros if abs((x - x_media) / x_desvio) > 3]

# Respuesta 2: si queremos el numero original
[x for x in numeros if abs((x - x_media) / x_desvio) > 3]

13 Subiendo de rango

def frange(start, stop, step):
    while start < stop:
        yield start
        start += step

for i in frange(3, 4, 0.2):
    print(f"{i:.2f}")

for i in frange(3, 4, 0.15):
    print(f"{i:.2f}")

14 La cajita musical

import random

def cajita_musical(versos):
    versos = versos[:] # Para hacer una copia

    while versos: # Mientras la lista no esté vacía
        # Generar un índice al azar
        i = random.randint(0, len(versos) - 1)

        # Sacar y devolver el valor del índice al azar
        yield versos.pop(i)


versos = [
    "Tengo que confesar que a veces no me gusta tu forma de ser",
    "Luego te me desapareces y no entiendo muy bien por qué",

    "No dices nada romántico cuando llega el atardecer",
    "Te pones de un humor extraño con cada luna llena al mes",

    "Pero a todo lo demás le gana lo bueno que me das",
    "Sólo tenerte cerca, siento que vuelvo a empezar"
]

for verso in cajita_musical(versos):
    print(verso)

15 El mejor precio

def promo_dia_semana(compra):
  """Aplica un 15% de descuento si la compra se realiza un lunes o miércoles."""
  if compra["dia"] in ("lunes", "miércoles"):
    return 0.85
  return 1

def promo_monto_grande(compra):
  """Aplica un 10% de descuento si la compra tiene un monto superior a $50.000."""
  if compra["monto"] > 50_000:
    return 0.9
  return 1

def promo_edad(compra):
  """Aplica un 20% de descuento si el cliente tiene 65 años o más."""
  if compra["edad_cliente"] >= 65:
    return 0.8
  return 1

promos = [promo_dia_semana, promo_monto_grande, promo_edad]

def mejor_promo(compra):
  """Ordena los descuentos de mayor a menor y aplica el mejor disponible."""
  multiplicador = sorted([promo(compra) for promo in promos])[0]

  return {
    "monto_original": compra["monto"],
    "monto_final": compra["monto"] * multiplicador,
    "descuento": f"{round((1 - multiplicador) * 100)}%"
  }

ejemplo_compra = {"dia": "miércoles", "edad_cliente": 42, "monto": 66420}
mejor_promo(ejemplo_compra)

# Solucion (no se si esto es lo que Joaco tenia en mente)
PROMOS = [] # mayusculas para indicar que es global, una convencion
def promo(fun):
    def envoltura(*args, **kwargs):
        return fun(*args, **kwargs)
    PROMOS.append(envoltura)
    return envoltura

@promo
def promo_dia_semana(compra):
    if compra["dia"] in ("lunes", "miércoles"):
        return 0.85
    return 1

@promo
def promo_monto_grande(compra):
    if compra["monto"] > 50_000:
        return 0.9
    return 1

@promo
def promo_edad(compra):
    if compra["edad_cliente"] >= 65:
        return 0.8
    return 1

PROMOS # ahora contiene a las funciones

def mejor_promo(compra):
    multiplicador = sorted([promo(compra) for promo in PROMOS])[0]
    return {
        "monto_original": compra["monto"],
        "monto_final": compra["monto"] * multiplicador,
        "descuento": f"{round((1 - multiplicador) * 100)}%"
    }

ejemplo_compra = {"dia": "miércoles", "edad_cliente": 42, "monto": 66420}
mejor_promo(ejemplo_compra)

ejemplo_compra = {"dia": "jueves", "edad_cliente": 42, "monto": 28000}
mejor_promo(ejemplo_compra)

ejemplo_compra = {"dia": "jueves", "edad_cliente": 67, "monto": 28000}
mejor_promo(ejemplo_compra)

16 Bromas pesadas 😱

def romper_cada(n):
    def decorador(func):

        if type(n) is not int or n < 1: # Chequea que "n" sea un argumento válido
            return func

        contador = 0  # Cuántas veces se llamó a la función

        def envoltura(*args, **kwargs):
            nonlocal contador
            contador += 1

            if contador % n == 0: # Cada n veces...
                return None       # ...se rompe

            return func(*args, **kwargs)

        return envoltura
    return decorador

# Ejemplo de uso
@romper_cada(3)
def saludar(nombre):
    print(f"¡Hola, {nombre}!")

saludar("Carlos")     # "¡Hola, Carlos!"
saludar("María Luz")  # "¡Hola, María Luz!"
saludar("Mirna")      # Nada (la función devuelve None)
saludar("Diego")      # "¡Hola, Diego!"

17 Pipelines de procesamiento 😱

from math import log10

# Sin usar filter

def eliminar_nulos(datos):
    datos_sin_nulos = []
    for fila in datos:
        if None not in fila.values():
            datos_sin_nulos.append(fila.copy())
    return datos_sin_nulos

def calcular_log(datos, variable):
    datos_log = []
    for fila in datos:
        copia_fila = fila.copy()
        valor = copia_fila[variable]
        copia_fila[variable] = round(log10(valor), 3) if (valor is not None) else None
        datos_log.append(copia_fila)
    return datos_log

def filtrar(datos, variable, funcion):
    datos_filtrados = []
    for fila in datos:
        if fila[variable] is not None and funcion(fila[variable]):
            datos_filtrados.append(fila.copy())
    return datos_filtrados


# Usando filter

def eliminar_nulos(datos):
    return list(filter(lambda fila: None not in fila.values(), datos))

def filtrar(datos, variable, funcion):
    return list(filter(lambda fila: fila[variable] is not None and funcion(fila[variable]), datos))

18 ¿Espacio o tiempo? ⏳

# 1 - PREPARACION DE DATOS

import random

CATEGORIAS = ("electrónica", "hogar", "accesorios", "deportes")

def generar_ventas(n=100_000, seed=None):
    rng = random.Random(seed)
    return [
        {
            "id": f"P{i+1:06d}",
            "precio": round(rng.uniform(5.0, 500.0), 2),
            "categoria": CATEGORIAS[i % len(CATEGORIAS)],
        }
        for i in range(1, n + 1)
    ]

ventas = generar_ventas(n=100_000, seed=1234)

# 2- Precios con IVA (21%)

# Versión A - List comprehension (ejecuta y guarda todo):
precios_con_iva_1 = [venta["precio"] * 1.21 for venta in ventas]
# Versión B - Generador (define la operación pero no la ejecuta):
precios_con_iva_2 = (venta["precio"] * 1.21 for venta in ventas)

# 3 - Filtrar ventas de electrónica

# Versión A - List comprehension:
electronica_1 = [venta for venta in ventas if venta["categoria"] == "electrónica"]
#Versión B - Generador:
electronica_2 = (venta for venta in ventas if venta["categoria"] == "electrónica")

# 4 - Análisis y comparación (tiempo y memoria)

import time
import sys

# A - Lista materializada
t1 = time.time()
total_1 = sum(venta["precio"] for venta in electronica_1)
t2 = time.time()
mem_1 = sys.getsizeof(electronica_1)
print(f"electronica_1 → total: {total_1:.2f}, tiempo: {t2 - t1:.4f}s, memoria: {mem_1} bytes")

# B - Generador (se consume al usar sum)
t3 = time.time()
total_2 = sum(venta["precio"] for venta in electronica_2)
t4 = time.time()
mem_2 = sys.getsizeof(electronica_2)
print(f"electronica_2 → total: {total_2:.2f}, tiempo: {t4 - t3:.4f}s, memoria: {mem_2} bytes")


# 5- Reutilización del objeto

# Lista: se puede usar de nuevo
print(sum(venta["precio"] for venta in electronica_1))  # OK

# Generador: ya fue consumido, no devuelve nada
print(sum(venta["precio"] for venta in electronica_2))  # 0.0

# 6 - Reflexión final (resumen corto)

# List comprehension (Versión A): usa más memoria, pero permite reutilizar los datos.
# Ideal si vas a recorrer los resultados varias veces.

# Generadores (Versión B): consumen poca memoria, pero solo pueden recorrerse una vez.
# Son más eficientes si solo necesitás un recorrido rápido.

# En problemas donde el dataset es enorme o se usa solo una vez → mejor un generador.
# Si necesitás acceder varias veces al resultado o compartirlo entre funciones → mejor una lista.