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:
lambdasolo admite una expresión, no sentencias (no assignments, notry, noreturn). Su valor es implícitamente el de la expresión.- Una
lambdano aporta nada que undefno haga; su único caso legítimo es cuando necesitás una función anónima e inline como argumento (key=, callbacks) y darle nombre añadiría ruido. Asignar unalambdaa una variable (f = lambda ...) es un anti-pattern:defda mejor__name__, mejor traceback y admite docstring.
# 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
- Positional-only (
/, Python 3.8+): el nombre del parámetro es privado; podés renombrarlo sin romper llamadas. Útil en APIs públicas y para evitar colisiones con**kwargs. - Positional-or-keyword: el default; aceptan ambas formas.
*args: tupla con los posicionales sobrantes.- Keyword-only (tras
*o*args): fuerzan claridad en el call site (create(user, *, admin=False)evita el clásicocreate(user, True)ilegible). **kwargs: dict con los keyword sobrantes.
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:
global name: las asignaciones anameafectan el scope de módulo.nonlocal name: las asignaciones afectan el scope de la función enclosing más cercana que ya definename(no crea uno nuevo, y no llega al global).
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):
global: mutar estado a nivel de módulo —caches, singletons de configuración, contadores de instrumentación. En código de producción se prefiere encapsular ese estado en una clase o closure;globalacopla y dificulta el testing porque introduce estado compartido no explícito.nonlocal: implementar closures con estado (contadores, memoización, máquinas de estado pequeñas) sin recurrir a una clase. Es la forma idiomática de tener “estado privado” en programación funcional.
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}
globals()devuelve el dict real del namespace de módulo: mutarlo sí afecta el estado global (globals()["X"] = 9crea/cambia la globalX).locals()dentro de una función devuelve, en CPython, una copia/snapshot del scope local. Escribir en ese dict no cambia de forma fiable las variables locales, porque las locales de una función se implementan como slots en el frame (array indexado), no como un dict. A nivel de módulo o clase,locals()sí coincide con el namespace real y es mutable. Conclusión senior:globals()es mutable con efecto;locals()es de lectura/diagnóstico, no confíes en escribirlo dentro de funciones.
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:
- No hay tail-call optimization. Cada llamada añade un frame; el límite por defecto es ~1000 (
sys.getrecursionlimit()) y superarlo lanzaRecursionError. Subirlo consetrecursionlimitarriesga un segfault del intérprete. - Overhead por frame mayor que una iteración.
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 (memoize → decorator → wrapper); 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?
- ❌ Wrong/trap: “It’s like
globalbut for nested functions — you need it any time an inner function uses a variable from the outer one.” Trap: you only need it to rebind. Reading an enclosing variable, or mutating a mutable object it points to (lst.append(x)), works fine withoutnonlocal. - ✅ Correct:
nonlocalmakes an assignment target the nearest enclosing function scope that already defines the name, instead of creating a new local. You need it only when you reassign (x = ...,x += ...) an enclosing variable from an inner function. - ⭐ Optimal (senior): Add that name binding is decided at compile time: any assignment to a name inside a function marks it local for the whole body, which is why
x += 1withoutnonlocalraisesUnboundLocalError(Python reads a local that isn’t bound yet).nonlocalalso cannot reach module scope — it targets an enclosing function scope; if none defines the name, it’s aSyntaxError. This is the mechanism behind stateful closures.
2. Why can’t you reference an exception variable after its except block?
- ❌ Wrong/trap: “You can — the variable just goes out of scope like any local, so it’s available in the rest of the function.” Wrong: unlike normal locals,
except ... as eexplicitly deleteseat the end of the block; it’s aNameError, not a stale value. - ✅ Correct: Since Python 3, the interpreter implicitly runs
del ewhen theexceptblock exits, so the name is unbound afterward. To keep it, assign it to another variable inside the block. - ⭐ Optimal (senior): The reason is memory: the exception holds a
__traceback__that references the current frame, which references its locals (includinge) — a reference cycle that would delay reclamation and pin large frames. Auto-deletingebreaks the cycle deterministically instead of leaving it to the GC. The senior habit is to capture what you need (message, a copy of the exception) into a surviving name before leaving the block.
3. Explain the [2, 2, 2] closure-in-a-loop problem and how you’d fix it.
- ❌ Wrong/trap: “Python has a bug capturing loop variables; you should avoid closures in loops.” It’s not a bug and not specific to loops — it’s how closures work: they capture the variable (the cell), not a snapshot of its value.
- ✅ Correct: All lambdas share the same
icell; by the time they run, the loop has finished andiholds its final value. Fix by binding the current value per iteration, e.g. a default argumentlambda i=i: i, or a factory function that creates a fresh scope each iteration. - ⭐ Optimal (senior): Frame it as late binding vs early binding: Python closures are late-binding (resolved at call time). The
i=idefault forces early binding because default values are evaluated at definition time. Note that a comprehension like[lambda: i for i in range(3)]hits the same trap even though the comprehension has its own scope — the single loop variable is still shared across the created closures.
4. What does functools.wraps do and why does omitting it matter beyond cosmetics?
- ❌ Wrong/trap: “It just fixes
__name__so the docstring looks right — purely cosmetic, safe to skip.” Skipping it breaks real tooling, not just aesthetics. - ✅ Correct:
wrapscopies the wrapped function’s metadata (__name__,__doc__,__module__,__qualname__,__dict__, and sets__wrapped__) onto the wrapper, so introspection sees the original function instead ofwrapper. - ⭐ Optimal (senior): Concrete breakage without it:
help()and API docs showwrapperwith no docstring; frameworks that dispatch by__name__(some routing/registration systems) misbehave;inspect.signaturereports(*args, **kwargs)instead of the real signature (unless it follows__wrapped__, whichwrapssets); and duplicate registration by name can collide. It also enablesinspect.unwrap()to peel decorators. For signature-sensitive decorators,wrapsalone isn’t enough — you’d reach forinspect-based signature preservation.
5. list(map(...)) vs a list comprehension — how do you choose?
- ❌ Wrong/trap: “
mapis always faster because it’s implemented in C, so prefer it for performance.” Not reliably true — with a Pythonlambda,mapstill calls back into Python per element, and the comprehension is often as fast or faster while being more readable. - ✅ Correct: They’re roughly equivalent; the comprehension is more Pythonic and readable, especially when you also filter (
[f(x) for x in xs if cond]) — themap/filterequivalent is clumsy.mapreturns a lazy iterator; the comprehension builds the list eagerly. - ⭐ Optimal (senior):
mapwins specifically when the mapper is an existing callable (map(str, xs),map(int, xs)) — no wrappinglambda, no per-element Python-level call overhead, and it’s lazy so it composes in a streaming pipeline without materializing. Choose the comprehension for readability and when filtering; choosemapfor laziness over large/infinite streams or when reusing a C-level function. Never writemapwith alambdawhen a comprehension expresses the same thing.
6. When would you reach for functools.partial instead of a lambda?
- ❌ Wrong/trap: “They’re interchangeable,
partialis just older syntax.” They differ in introspectability and serialization. - ✅ Correct: Both pre-bind arguments, but
partialreturns an object exposing.func,.args,.keywords, and it’s picklable; alambdais opaque and not picklable. Usepartialwhen the callable must be serialized (multiprocessing, Celery), introspected, or when you’re just binding arguments with no extra logic. - ⭐ Optimal (senior):
partialbinds at creation time (early), while alambdacloses over names resolved at call time (late) — sopartial(f, x)freezes the currentx, butlambda: f(x)follows later reassignments ofx. That difference matters in loops (the[2,2,2]trap) and with mutable module state. Reach forlambdaonly when you need actual logic (a conditional, reordering args, calling a method); reach forpartialfor pure argument pre-binding, especially across process boundaries.
7. How do generators give O(1) memory, and what’s the main hazard?
- ❌ Wrong/trap: “Generators are just a faster way to build a list.” They don’t build a list at all; conflating them with lists misses the entire point.
- ✅ Correct: A generator yields one value at a time, suspending its frame between
next()calls, so it holds a single item in memory regardless of stream length — ideal for large files/streams. The main hazard is that it’s single-pass: once consumed it’s empty, and iterating it a second time silently yields nothing. - ⭐ Optimal (senior): The frame (with its locals) is preserved between yields, which is what makes lazy pipelines work —
parse → filter → mapall stay lazy until a terminal op (set(),sum(),list(), afor) pulls values through. Guideline: materialize as little as possible, as late as possible — e.g. only the finalset()of unique IDs. Watch for hidden double-consumption (passing the same generator to two consumers), and remember that side effects inside a generator don’t run until it’s iterated, which can hide bugs like un-run cleanup or deferred exceptions.
Referencias
- Python Language Reference — Execution model / naming and binding: https://docs.python.org/3/reference/executionmodel.html
- Python Tutorial — Defining Functions & scopes: https://docs.python.org/3/tutorial/controlflow.html#defining-functions
functools(wraps, partial, reduce, lru_cache): https://docs.python.org/3/library/functools.html- Data model — generators,
yield,__closure__: https://docs.python.org/3/reference/datamodel.html - PEP 3104 — Access to Names in Outer Scopes (
nonlocal): https://peps.python.org/pep-3104/ - PEP 570 — Positional-Only Parameters: https://peps.python.org/pep-0570/
- PEP 3110 — Catching exceptions (
except ... asdeletion): https://peps.python.org/pep-3110/ itertools— building blocks for lazy iteration: https://docs.python.org/3/library/itertools.html