Mecanismo de ejecución de funciones en Python
Al definir una función en Python, el intérprete gestiona su invocación mediante marcos de pila (stack frames) en memoria heap. Considere el siguiente ejemplo:
def funcion_principal():
funcion_auxiliar()
def funcion_auxiliar():
pass
El intérprete de Python, implementado en C, utiliza la función PyEval_EvalFrameEx para ejecutar funcion_principal. Primero, crea un marco de pila para este contexto, donde se procesan los bytecodes correspondientes. La inspección del bytecode revela operaciones específicas:
import dis
print(dis.dis(funcion_principal))
El bytecode de funcion_principal incluye instrucciones como LOAD_GLOBAL para cargar funcion_auxiliar, CALL_FUNCTION para invocarla, y RETURN_VALUE para finalizar. Cada función tiene un bytecode único que persiste globalmente.
Cuando funcion_principal invoca a funcion_auxiliar, se genera un nuevo marco de pila con una referencia (f_back) al marco anterior. Estos marcos se almacenan en memoria heap, no en la pila de ejecución, lo que permite su supervivencia independiente del flujo de llamadas. Esto se evidencia al acceder a los marcos mediante inspect:
import inspect
marco_guardado = None
def funcion_principal():
funcion_auxiliar()
def funcion_auxiliar():
global marco_guardado
marco_guardado = inspect.currentframe()
funcion_principal()
print(marco_guardado.f_code.co_name) # 'funcion_auxiliar'
marco_llamador = marco_guardado.f_back
print(marco_llamador.f_code.co_name) # 'funcion_principal'
Este diseño tratable como objetos permite que los marcos persistan más allá del ciclo de vida de la función, facilitando la introspección y depuración.
Fundamentos de los generadores
Los generadores extienden el concepto de marcos de pila al suspender y reanudar la ejecución. En un generador, la presencia de yield marca la función para su transfomración en un objeto generador. Por ejemplo:
def mi_generador():
valor = 10
yield valor
nombre = "ejemplo"
yield nombre
return "fin" # Permitido en versiones recientes de Python
Al llamar a mi_generador(), no se ejecuta el cuerpo, sino que se devuelve un objeto generador que encapsula un marco de pila. Este objeto incluye atributos como gi_frame (referencia al marco) y gi_code (bytecode). El marco posee f_lasti (índice de la última instrucción ejecutada) y f_locals (variables locales actuales).
gen = mi_generador()
print(gen.gi_frame.f_lasti) # -1 (no iniciado)
print(gen.gi_frame.f_locals) # {}
next(gen)
print(gen.gi_frame.f_lasti) # 2 (post primer yield)
print(gen.gi_frame.f_locals) # {'valor': 10}
next(gen)
print(gen.gi_frame.f_lasti) # 12 (post segundo yield)
print(gen.gi_frame.f_locals) # {'valor': 10, 'nombre': 'ejemplo'}
Cada llamada a next avanza la ejecución hasta el siguiente yield, donde el marco se pausa y preserva su estado. Esta capacidad de suspensión y reanudación es la base de las corrutinas en Python, permitiendo la ejecución lazy y el manejo de secuencias infinitas.
Papel de los archivos .pyc
Durante la ejecución de código Python, el intérprete compila los módulos a bytecode, representado internamente como objetos PyCodeObject. Al finalizar la ejecución, estos objetos se persisten como archivos .pyc. Por ejemplo:
# Ejecución inicial genera archivo .pyc
import modulo_ejemplo # Crea modulo_ejemplo.pyc si se importa un paquete
El archivo .pyc es una serialización binaria de PyCodeObject. En ejecuciones posteriores, el intérprete busca primero el .pyc correspondiente; si existe y es válido, lo carga dircetamente, omitiendo la compilación. Esto optimiza el tiempo de inicio. Esencialmente, los .pyc sirven como una caché persistente del bytecode compilado, separando la fase de compilación de la ejecución.