Atlas

Python Functions: scopes, closures, decorators, comprehensions

Las funciones en Python son objetos de primera clase. Entender scopes, closures y el modelo de evaluación es lo que separa “sé escribir funciones” de “entiendo qué hace el intérprete con mi código”.

Teoría

Sintaxis de funciones y lambda

Una función se define con def; el cuerpo se compila a un objeto function (un code object envuelto) que vive como cualquier otro valor. return sin expresión —o llegar al final del cuerpo— devuelve None.

def add(a, b):
    return a + b

# Equivalente funcional, pero lambda es una EXPRESIÓN, no una sentencia:
add2 = lambda a, b: a + b

print(add.__name__)   # 'add'
print(add2.__name__)  # '<lambda>'  <- pierde nombre útil en tracebacks

Diferencias que importan a nivel senior:

# Uso idiomático de lambda: como argumento, no como definición nombrada.
data = [("b", 2), ("a", 3), ("c", 1)]
data.sort(key=lambda pair: pair[1])   # ordena por el segundo elemento

Tipos de parámetros

Python distingue cinco categorías de parámetros, y el orden en la firma es fijo:

def f(pos_only, /, standard, *args, kw_only, **kwargs):
    ...
def request(method, url, /, *, timeout=30, **headers):
    # method, url: POSITIONAL-ONLY (antes de /). No se pueden pasar por nombre.
    # timeout:     KEYWORD-ONLY (después de *). Solo por nombre.
    # headers:     recoge cualquier keyword extra.
    return method, url, timeout, headers

request("GET", "/api", timeout=5, x_trace="abc")
# request(method="GET", ...)  -> TypeError: method es positional-only

Detalle crítico: los defaults se evalúan una sola vez, al definir la función, no en cada llamada. De ahí el bug del mutable default argument:

def append_to(item, target=[]):   # ❌ la lista se comparte entre llamadas
    target.append(item)
    return target

append_to(1)   # [1]
append_to(2)   # [1, 2]  <- sorpresa

def append_to(item, target=None):  # ✅ sentinel
    if target is None:
        target = []
    target.append(item)
    return target

Scopes: LEGB y el modelo de resolución de nombres

Python resuelve nombres siguiendo la regla LEGB: Local → Enclosing → Global → Built-in. En cada acceso, el intérprete busca en ese orden y usa la primera coincidencia.

x = "global"

def outer():
    x = "enclosing"
    def inner():
        # no hay x local -> sube a enclosing
        print(x)   # 'enclosing'
    inner()

outer()

El punto que confunde a muchos: una asignación dentro de una función marca el nombre como local para todo el cuerpo, incluso antes de la línea de asignación. Esto se decide en tiempo de compilación, no de ejecución.

x = 10
def f():
    print(x)   # UnboundLocalError, no imprime 10
    x = 20     # esta asignación convierte x en local en TODA la función

global y nonlocal

Ambas keywords cambian a qué scope se enlaza una asignación:

counter = 0
def increment():
    global counter
    counter += 1   # sin global -> UnboundLocalError

def make_accumulator():
    total = 0
    def add(n):
        nonlocal total    # rebinding del total del enclosing
        total += n
        return total
    return add

Casos de uso reales (y sus advertencias):

Nota: global y nonlocal solo son necesarios para reasignar (rebind) el nombre. Para mutar un objeto mutable (list.append, dict[...] = ...) no hacen falta, porque no reasignás el nombre, solo modificás el objeto al que apunta.

globals() y locals()

GLOBAL_VAR = 1

def demo():
    local_var = 2
    print(globals()["GLOBAL_VAR"])  # 1
    print(locals())                 # {'local_var': 2}

Recursión

Una función recursiva se define por un caso base (terminate case) que corta la recursión y un paso recursivo que reduce el problema hacia el caso base.

def factorial(n: int) -> int:
    if n <= 1:            # caso base
        return 1
    return n * factorial(n - 1)   # paso recursivo

Pros: expresa con naturalidad problemas definidos recursivamente (árboles, parsing, backtracking, divide-and-conquer). Contras en Python específicamente:

Regla práctica: usá recursión cuando la estructura del dato es recursiva y la profundidad está acotada (árbol de directorios, AST). Para recorridos lineales potencialmente profundos, convertí a iteración con una pila explícita.

Scope de variables de excepción

Detalle A3 poco conocido: la variable de un except ... as e se borra automáticamente al salir del bloque except. No podés referenciarla después:

try:
    1 / 0
except ZeroDivisionError as err:
    pass

print(err)   # NameError: name 'err' is not defined

Python hace esto (desde Python 3) para romper un ciclo de referencias: el objeto excepción guarda su traceback, que referencia el frame, que referencia las locales, que incluirían err → ciclo que retrasa la liberación de memoria. El intérprete efectivamente inserta un del err implícito al final del except. Si necesitás el valor fuera, asignalo a otra variable dentro del bloque:

error = None
try:
    risky()
except ValueError as e:
    error = e   # copiamos la referencia a un nombre que sobrevive
if error is not None:
    handle(error)

Funciones como ciudadanos de primera clase y HOF

Una función es un objeto: se asigna a variables, se guarda en estructuras, se pasa como argumento y se retorna. Una higher-order function (HOF) es la que recibe y/o retorna funciones.

def compose(f, g):
    """Retorna una nueva función: la composición f(g(x))."""
    return lambda x: f(g(x))

strip_and_upper = compose(str.upper, str.strip)
print(strip_and_upper("  hi  "))   # 'HI'

dispatch = {"sum": sum, "max": max, "min": min}   # funciones como valores
print(dispatch["max"]([3, 1, 4]))   # 4

Meta-información: docstrings y type hints

def divide(a: float, b: float) -> float:
    """Divide a entre b.

    Raises:
        ZeroDivisionError: si b es 0.
    """
    return a / b

print(divide.__doc__)          # el docstring
print(divide.__annotations__)  # {'a': <float>, 'b': <float>, 'return': <float>}

Los type hints no se validan en runtime (Python no chequea tipos por sí mismo); son metadata consumida por herramientas (mypy, pyright, IDEs, Pydantic, FastAPI). Viven en __annotations__.

map, filter, reduce, zip

map y filter retornan iteradores lazy (Python 3), no listas. zip empareja perezosamente y para en el más corto. reduce (en functools) pliega una secuencia a un valor único.

from functools import reduce

nums = [1, 2, 3, 4]
squares = map(lambda n: n * n, nums)          # <map object> lazy
evens = filter(lambda n: n % 2 == 0, nums)    # <filter object> lazy
total = reduce(lambda acc, n: acc + n, nums, 0)   # 10 (con initializer)

names, ages = ["ana", "leo"], [30, 25, 99]
list(zip(names, ages))   # [('ana', 30), ('leo', 25)]  <- descarta 99

# zip como su propia inversa (unzip):
pairs = [("a", 1), ("b", 2)]
letters, numbers = zip(*pairs)   # ('a', 'b'), (1, 2)

En Python idiomático, una comprehension suele ser más legible que map/filter con lambda. map gana cuando la función ya existe y no hay que envolverla (map(str, nums) vs [str(n) for n in nums]).

Comprehensions (list / dict / set / generator)

Una comprehension construye una colección con sintaxis declarativa. Las cuatro variantes comparten estructura expr for item in iterable if cond:

squares   = [n * n for n in range(5)]                 # list  -> [0,1,4,9,16]
by_length = {w: len(w) for w in ["hi", "hola"]}       # dict  -> {'hi':2,'hola':4}
unique    = {n % 3 for n in range(10)}                # set   -> {0,1,2}
lazy_sq   = (n * n for n in range(5))                 # generator (lazy)

Detalle importante: desde Python 3, las comprehensions tienen su propio scope (se compilan como una función anónima). La variable de iteración no se fuga al scope exterior:

n = "intacto"
squares = [n * n for n in range(3)]
print(n)   # 'intacto'  <- en Python 2 esto valdría 2

Se pueden anidar y combinar condiciones, pero pasado cierto punto un for explícito es más legible. El if va al final (filtro); un if/else va antes del for (es una expresión condicional en la parte expr):

labels = ["par" if n % 2 == 0 else "impar" for n in range(4)]
flat   = [x for row in [[1, 2], [3, 4]] for x in row]   # anidada -> [1,2,3,4]

Generators y lazy evaluation

Un generator produce valores de a uno, bajo demanda. Una función con yield retorna un generator; cada next() ejecuta hasta el siguiente yield, congela el frame (locales incluidas) y lo reanuda en la siguiente llamada. La ventaja es memoria O(1) independiente del tamaño del stream: nunca materializás toda la secuencia.

def parse_lines(filepath: str):
    """Yield de un JSON por línea, sin cargar el archivo entero en RAM."""
    import json
    with open(filepath) as f:
        for line in f:              # el file object ya itera línea a línea
            yield json.loads(line)

def filter_event(records, event_type: str):
    return (r for r in records if r["event"] == event_type)   # gen expr, lazy

def count_unique_buyers(filepath: str) -> int:
    records   = parse_lines(filepath)
    purchases = filter_event(records, "purchase")
    buyer_ids = (r["user_id"] for r in purchases)
    return len(set(buyer_ids))   # set materializa SOLO los IDs únicos (O(k))

Este pipeline procesa un archivo de 1GB con memoria constante: cada línea fluye por parse → filter → extract y se descarta; lo único que crece es el set de IDs únicos. Regla: materializá lo mínimo indispensable, lo más tarde posible.

La contrapartida es que un generator es de una sola pasada. Una vez consumido, queda vacío —no hay bug más silencioso que iterarlo dos veces:

gen = (x for x in range(3))
list(gen)   # [0, 1, 2]
list(gen)   # []   <- ya se agotó, no re-empieza

Si necesitás múltiples pasadas o acceso aleatorio, materializá con list(); si leés una vez y descartás (ETL, logs, streaming), el generator es la herramienta correcta. Detalles avanzados: yield puede recibir valores (x = yield) convirtiendo el generator en coroutine; yield from sub_gen delega a otro generator.

Closures: funciones con estado capturado

Un closure es una función anidada que “recuerda” variables de su scope enclosing aunque la función externa ya haya retornado. Python guarda esas variables capturadas en __closure__ (celdas).

def make_counter(start=0):
    count = start
    def counter():
        nonlocal count      # sin nonlocal solo podríamos LEER count, no rebind
        count += 1
        return count
    return counter

c = make_counter()
print(c(), c(), c())        # 1 2 3
print(c.__closure__[0].cell_contents)   # 3  <- estado vivo en la celda

Trampa clásica: capturar la variable, no su valor. En un loop, todas las closures ven el valor final:

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])   # [2, 2, 2]  <- todas capturan la misma i

funcs = [lambda i=i: i for i in range(3)]   # fix: default captura el valor
print([f() for f in funcs])   # [0, 1, 2]

Un closure es, conceptualmente, “un objeto con un solo método”. Cuando solo necesitás una función con estado, un closure es más liviano que una clase; cuando el estado crece o hay varios métodos, la clase gana.

Decorators

Un decorator es una HOF que recibe una función (o clase) y retorna un reemplazo, normalmente para envolver comportamiento (logging, timing, caching, auth, retries) sin tocar el cuerpo original. @deco es azúcar para func = deco(func).

import functools

def timed(func):
    @functools.wraps(func)          # preserva __name__, __doc__, firma, etc.
    def wrapper(*args, **kwargs):   # *args/**kwargs -> envuelve CUALQUIER firma
        import time
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{func.__name__} took {elapsed:.4f}s")
    return wrapper

@timed
def work(n):
    return sum(range(n))

functools.wraps no es opcional en código serio: sin él, el wrapper reemplaza la metadata de la función original —work.__name__ pasaría a ser 'wrapper', se pierde el docstring, y herramientas de introspección y debugging quedan ciegas. wraps copia __name__, __doc__, __dict__, __wrapped__, etc.

Decorator con argumentos = una fábrica de decorators: un nivel más de anidamiento. La capa externa recibe los argumentos de configuración y retorna el decorator real.

import functools

def retry(times: int):                 # capa 1: recibe config
    def decorator(func):                # capa 2: recibe la función (el decorator real)
        @functools.wraps(func)
        def wrapper(*args, **kwargs):   # capa 3: recibe los args de la llamada
            last_exc = None
            for _ in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_exc = exc
            raise last_exc              # se agotaron los reintentos
        return wrapper
    return decorator

@retry(times=3)          # retry(3) -> decorator; decorator(fetch) -> wrapper
def fetch(url): ...

Un truco senior para tener un decorator que funcione con y sin argumentos es inspeccionar si el primer arg es callable, pero la forma explícita de tres capas es la más legible y la que se espera en una entrevista.

Los decorators se aplican de abajo hacia arriba (el más cercano a def envuelve primero):

@a
@b
def f(): ...      # equivale a f = a(b(f))

functools.partial y aplicación parcial

partial fija argumentos de una función y retorna un nuevo callable con la firma reducida. Es aplicación parcial sin escribir una lambda ni un closure a mano.

from functools import partial

def connect(host, port, timeout):
    return f"{host}:{port} (t={timeout})"

local = partial(connect, "localhost", timeout=5)   # fija host y timeout
print(local(8080))   # 'localhost:8080 (t=5)'

partial es preferible a lambda cuando querés un callable serializable (picklable) e introspectable: expone .func, .args, .keywords. Una lambda equivalente no es picklable y no expone qué fijó. Casos típicos: adaptar una firma para pasarla como key=/callback, o pre-configurar clientes/handlers.

Ejercicios

Ejercicio 1 — Closure con estado: rate limiter

Escribí make_rate_limiter(max_calls) que retorne una función allow(). Cada llamada a allow() devuelve True las primeras max_calls veces y False después. Sin usar clases ni variables globales.

Solución
def make_rate_limiter(max_calls: int):
    calls = 0
    def allow() -> bool:
        nonlocal calls
        if calls < max_calls:
            calls += 1
            return True
        return False
    return allow

limiter = make_rate_limiter(2)
print([limiter() for _ in range(4)])   # [True, True, False, False]

Clave: nonlocal calls es obligatorio porque calls += 1 es un rebinding. El estado vive en la celda del closure (limiter.__closure__), aislado por instancia: dos limiters no comparten contador.

Ejercicio 2 — Generator: ventana deslizante

Escribí un generator sliding_window(iterable, size) que produzca tuplas de size elementos consecutivos. Para [1,2,3,4] y size=2: (1,2), (2,3), (3,4). Debe funcionar sobre cualquier iterable (incluido otro generator) sin materializarlo entero.

Solución
from collections import deque
from itertools import islice

def sliding_window(iterable, size: int):
    it = iter(iterable)
    window = deque(islice(it, size), maxlen=size)  # primeros `size` elementos
    if len(window) == size:
        yield tuple(window)
    for item in it:                # consume el resto de a uno -> O(size) memoria
        window.append(item)        # maxlen expulsa el más viejo automáticamente
        yield tuple(window)

print(list(sliding_window([1, 2, 3, 4], 2)))   # [(1,2),(2,3),(3,4)]
print(list(sliding_window(range(3), 5)))        # []  (iterable más corto que la ventana)

Clave: deque(maxlen=size) mantiene la memoria acotada sin importar el largo del stream; islice arranca la ventana sin list(). Funciona sobre generators porque solo hace iter() + next().

Ejercicio 3 — Decorator con argumentos + wraps

Escribí un decorator @memoize(maxsize) que cachee resultados por argumentos posicionales, con un límite maxsize (al superarlo, descarta la entrada más vieja). Debe preservar la metadata de la función y exponer .cache_clear().

Solución
import functools
from collections import OrderedDict

def memoize(maxsize: int = 128):
    def decorator(func):
        cache = OrderedDict()
        @functools.wraps(func)          # preserva __name__, __doc__, firma
        def wrapper(*args):
            if args in cache:
                cache.move_to_end(args)  # marca como recién usado (LRU)
                return cache[args]
            result = func(*args)
            cache[args] = result
            if len(cache) > maxsize:
                cache.popitem(last=False)  # descarta el menos reciente
            return result
        wrapper.cache_clear = cache.clear   # exponemos control extra
        return wrapper
    return decorator

@memoize(maxsize=2)
def fib(n):
    """Fibonacci ingenuo, acelerado por la cache."""
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(10))          # 55
print(fib.__name__)     # 'fib'  <- gracias a functools.wraps
fib.cache_clear()

Clave: tres capas (memoizedecoratorwrapper); OrderedDict da política LRU real; functools.wraps mantiene la introspección. En producción usarías directamente functools.lru_cache, que hace exactamente esto de forma optimizada en C.

Ejercicio 4 — Pipeline lazy con HOF

Escribí pipe(*funcs) que componga funciones de izquierda a derecha (el output de una es el input de la siguiente) y devuelva un único callable. Usalo para construir un pipeline de procesamiento de un stream sin materializar pasos intermedios.

Solución
from functools import reduce

def pipe(*funcs):
    """Compone funcs de izquierda a derecha: pipe(f, g)(x) == g(f(x))."""
    return lambda x: reduce(lambda acc, fn: fn(acc), funcs, x)

def read(lines):    return (l.strip() for l in lines)               # lazy
def only_errors(ls): return (l for l in ls if "ERROR" in l)         # lazy
def to_upper(ls):   return (l.upper() for l in ls)                  # lazy

process = pipe(read, only_errors, to_upper)

logs = ["  ok  ", "ERROR disk full", "  ERROR oom "]
for line in process(logs):   # nada se ejecuta hasta iterar aquí
    print(line)              # ERROR DISK FULL / ERROR OOM

Clave: cada etapa retorna un generator, así que process(logs) no procesa nada hasta que el for tira de él (lazy end-to-end). reduce encadena las funciones; pipe es una HOF que recibe y retorna funciones.

Ejercicio 5 — Trampa de scope

Sin ejecutarlo, predecí la salida y explicá por qué. Después corregí broken para que imprima 0 1 2.

def broken():
    handlers = []
    for i in range(3):
        handlers.append(lambda: i)
    return [h() for h in handlers]

print(broken())
Solución

Imprime [2, 2, 2]. Las tres lambdas capturan la variable i del closure, no su valor en cada iteración. Cuando se llaman (después del loop), i ya vale 2. La captura es late-binding: por referencia a la celda, no por copia.

def fixed():
    handlers = []
    for i in range(3):
        handlers.append(lambda i=i: i)   # el default evalúa i AHORA y lo congela
    return [h() for h in handlers]

print(fixed())   # [0, 1, 2]

El argumento default i=i se evalúa en el momento de crear la lambda, capturando el valor actual. Alternativa: una factory def make(i): return lambda: i que crea un scope nuevo por iteración.

Preguntas tipo entrevista (EN)

1. What does nonlocal do, and when do you actually need it?

2. Why can’t you reference an exception variable after its except block?

3. Explain the [2, 2, 2] closure-in-a-loop problem and how you’d fix it.

4. What does functools.wraps do and why does omitting it matter beyond cosmetics?

5. list(map(...)) vs a list comprehension — how do you choose?

6. When would you reach for functools.partial instead of a lambda?

7. How do generators give O(1) memory, and what’s the main hazard?

Referencias