Cada vez que Python ejecuta una llamada a función, CPython no solo salta a otra dirección de memoria como haría C: crea un objeto completo en el heap. Ese objeto es el PyFrameObject, y entender su estructura explica de golpe por qué la recursión tiene límites, por qué los tracebacks tienen la forma que tienen, y por qué ciertas optimizaciones que das por sentadas en otros runtimes simplemente no existen en CPython.
Un PyFrameObject encapsula todo el estado de ejecución de una invocación concreta: el objeto PyCodeObject con el bytecode compilado, un array de variables locales (incluyendo argumentos y celdas de clausura), el instruction pointer (f_lasti) que apunta a la última instrucción bytecode ejecutada, y un puntero f_back al frame anterior. Ese puntero f_back es lo que convierte una colección de frames en una pila: la call stack. Cuando ves un traceback, Python simplemente sigue la cadena de f_back desde el frame más reciente hasta llegar a None.
El coste no es trivial. Cada frame reserva memoria para el array de locales (tamaño fijo determinado en compile time por el compilador), la pila de evaluación del bytecode, y varios campos de bookkeeping. En CPython 3.11+ hubo una reescritura significativa del evaluador que hace los frames más liganos y en parte los aloja en el stack C, pero siguen existiendo como objetos observables desde Python.
sys.getrecursionlimit() devuelve 1000 por defecto. Ese límite no protege la memoria del heap: protege el stack de C. Cada frame Python se evalúa dentro de _PyEval_EvalFrameDefault, una función C gigantesca. Llamadas anidadas producen frames C anidados. Sin el límite, una recursión profunda provocaría un stack overflow a nivel del sistema operativo, algo mucho más catastrófico que una RecursionError.
La optimización de tail calls —presente en Scheme, Erlang, o los lenguajes funcionales serios— requiere que el runtime detecte que el frame actual ya no tiene trabajo pendiente y lo reuse para la llamada siguiente. CPython nunca hace eso. No hay un paso de análisis que identifique posición de tail call en el compilador, y el modelo de objetos garantiza que un frame siga vivo mientras alguien pueda tener una referencia a él (un debugger, inspect, cualquier hook sys.settrace). Esto es una decisión de diseño deliberada que prioriza la introspección sobre la eficiencia de la recursión.
import sys
import inspect
import dis
from typing import Any
def frame_info(depth: int = 0) -> dict[str, Any]:
"""Devuelve metadatos del frame 'depth' niveles hacia arriba."""
# sys._getframe(0) es el frame actual, (1) es el llamador, etc.
frame = sys._getframe(depth + 1) # +1 para excluir este helper
return {
"function": frame.f_code.co_name,
"filename": frame.f_code.co_filename,
"lineno": frame.f_lineno,
"locals_keys": list(frame.f_locals.keys()),
# f_lasti es el offset en bytes del último opcode ejecutado
"last_bytecode_offset": frame.f_lasti,
}
def outer(x: int) -> str:
secret = "visible desde el frame"
return middle(x * 2)
def middle(y: int) -> str:
return inner(y + 1)
def inner(z: int) -> str:
# Capturamos el stack completo manualmente
frames = []
current = inspect.currentframe()
while current is not None:
frames.append({
"name": current.f_code.co_name,
"lineno": current.f_lineno,
# f_locals es una copia snapshot, no una vista live
"local_vars": list(current.f_locals.keys()),
})
current = current.f_back # seguimos la cadena de punteros
return frames
def demonstrate_recursion_cost() -> None:
"""Muestra el overhead real de frame creation vs. iteración."""
import time
def recursive_sum(n: int) -> int:
if n == 0:
return 0
return n + recursive_sum(n - 1)
def iterative_sum(n: int) -> int:
total = 0
for i in range(n + 1):
total += i
return total
n = 900 # justo bajo el límite de recursión default
t0 = time.perf_counter()
recursive_sum(n)
recursive_time = time.perf_counter() - t0
t0 = time.perf_counter()
iterative_sum(n)
iterative_time = time.perf_counter() - t0
print(f"Recursiva ({n} frames): {recursive_time * 1e6:.1f} µs")
print(f"Iterativa ({n} iters): {iterative_time * 1e6:.1f} µs")
print(f"Overhead frames: {recursive_time / iterative_time:.1f}x")
if __name__ == "__main__":
# 1. Introspección del stack en ejecución real
print("=== Stack desde inner() ===")
stack = outer(5)
for i, f in enumerate(stack):
indent = " " * i
print(f"{indent}[{i}] {f['name']}:{f['lineno']} locals={f['local_vars']}")
print()
# 2. Acceso directo con sys._getframe
def show_caller_info() -> None:
info = frame_info(depth=1)
print(f"Llamado desde: {info['function']}() línea {info['lineno']}")
show_caller_info()
print()
# 3. Bytecode del frame actual para ver f_lasti en contexto
def inspect_own_bytecode() -> None:
f = inspect.currentframe()
print(f"Bytecode de '{f.f_code.co_name}':")
dis.dis(f.f_code)
inspect_own_bytecode()
print()
# 4. Coste concreto de la recursión
demonstrate_recursion_cost()
Lo que hace ese código y por qué cada decisión importa
sys._getframe(n) es la forma directa de acceder al stack: n=0 es el frame donde estás, n=1 es tu llamador. Es más eficiente que inspect.currentframe() seguido de recorrer f_back porque saltamos directamente al nivel deseado. El guión bajo en _getframe advierte que es una API de implementación, no un contrato público —pero en la práctica está en CPython desde siempre y herramientas serias como logging, pytest y flask la usan internamente.
inspect.currentframe() devuelve el mismo objeto que sys._getframe(0) pero con una semántica de nombre más legible. La diferencia relevante: si ejecutas PyPy, Jython o GraalPy, inspect.currentframe() puede devolver None porque esos runtimes no garantizan frame objects observables. sys._getframe directamente lanza AttributeError en implementaciones que no lo soportan.
El bucle while current is not None siguiendo f_back reproduce exactamente lo que Python hace internamente para generar un traceback. El frame raíz —el módulo top-level— tiene f_back = None. Fíjate que accedemos a f_locals: esto es importante, porque f_locals es una copia snapshot, no una vista viva del array interno. En CPython, las variables locales se almacenan en un array C contiguo (fastlocals), y f_locals se construye dinámicamente como un dict cada vez que la accedes. Modificar ese dict no afecta las variables reales del frame (a diferencia de f_globals, que sí es el dict real).
demonstrate_recursion_cost cuantifica el overhead. En una máquina moderna verás algo entre 5x y 20x de diferencia dependiendo de la versión de CPython. El coste viene de: allocación del PyFrameObject, inicialización del array de locales, empujar/sacar el frame del thread state, y el overhead del evaluador de bytecode por cada CALL/RETURN_VALUE. La versión 3.11 redujo esto significativamente con los “inline frames” y el compilador especializado, pero la diferencia con una iteración sigue siendo material.
La función show_caller_info muestra un patrón real que aparece en frameworks: introspección del llamador para logging automático, inyección de dependencias implícita, o magic de metaprogramación. Es exactamente cómo logging.getLogger(__name__) sabe dónde fue llamado, o cómo pytest puede mostrar el nombre de la fixture que falló.
Errores que debes conocer
Error: Guardar referencias a frame objects fuera de su ciclo de vida crea reference cycles que el GC de CPython tarda en recolectar, porque f_locals puede referenciar objetos que a su vez referencian el frame.
# ❌ Wrong
captured_frames = []
def leaky_middleware(func):
def wrapper(*args, **kwargs):
captured_frames.append(inspect.currentframe()) # frame vivo para siempre
return func(*args, **kwargs)
return wrapper
# ✅ Right
import traceback
def safe_middleware(func):
def wrapper(*args, **kwargs):
# Extraemos la info que necesitamos y soltamos el frame
caller_info = traceback.extract_stack()[-2]
return func(*args, **kwargs)
return wrapper
Guarda siempre datos extraídos del frame, nunca el frame en sí. traceback.extract_stack() hace exactamente eso: produce objetos serializables y libera las referencias.
Error: Asumir que f_locals dentro de una función refleja asignaciones hechas en esa misma función antes de la instrucción actual.
# ❌ Wrong — esperar que modificar f_locals cambie la variable local
def patch_local():
x = 10
frame = sys._getframe(0)
frame.f_locals['x'] = 999 # no tiene efecto sobre fastlocals
print(x) # imprime 10, no 999
# ✅ Right — usar ctypes para tocar fastlocals (solo si realmente lo necesitas)
import ctypes
def patch_local_correctly():
x = 10
frame = sys._getframe(0)
frame.f_locals['x'] = 999
# Forzar sincronización fastlocals ← f_locals dict
ctypes.pythonapi.PyFrame_LocalsToFast(ctypes.py_object(frame), ctypes.c_int(0))
print(x) # ahora sí imprime 999
PyFrame_LocalsToFast copia el contenido del dict f_locals de vuelta al array interno. Es la única forma de que una modificación externa afecte realmente las variables locales, y usarla en producción es una señal de que algo en el diseño merece revisión.
N° 173