Привет, Хабр!
Сегодня в коротком формате разберем с тем, что же творится внутри CPython, когда функции вызывают друг друга: sys._getframe
, f_back
, f_globals
, f_locals
, а так же создадим свои декораторы.
Внутреннее устройство call stack в CPython
Когда вы вызываете функцию в Python, интерпретатор создает объект frame
. Этот объект можно сравнить с страницей в дневнике выполнения программы, на которой записана вся информация о текущем вызове. Рассмотрим, что хранится в каждом таком кадре:
Имя функции и её исходный код:
f_code
— это объект, содержащий байт‑код, имя функции, имя файла и другую метаинформацию. Именно благодаря этому полю можно узнать, какая функция сейчас выполняется и получить доступ к её исходному коду, если потребуется.Номер текущей строки
f_lineno
:
Это значение показывает, какая строка исходного кода выполняется в данный момент. Если вы когда‑нибудь отлаживали код и пытались понять, где именно произошла ошибка, этот номер может стать ключом к разгадке.Словари локальных
f_locals
и глобальных переменныхf_global
:
Эти словари содержат все переменные, доступные в данный момент. Локальные переменные — это те, что определены внутри функции, а глобальные — общие для всего модуля.Ссылка на предыдущий кадр
f_back
:
Это ссылка на предыдущий вызов в стеке. Каждый кадр — это бумажная заметка, где написано: «Я был вызван из этой другой заметки». Связь черезf_back
позволяет, двигаясь «назад», реконструировать весь путь вызовов, от текущей функции до точки входа в программу.
Рассмотрим как можно пройтись по всем кадрам вызова:
import sys
def print_call_stack():
frame = sys._getframe() # Получаем текущий кадр (функция print_call_stack)
stack = []
while frame:
# Формируем строку: имя функции и текущая строка в коде
stack.append(f"{frame.f_code.co_name} (line {frame.f_lineno})")
# Переходим к предыдущему кадру
frame = frame.f_back
print("Call Stack (от текущего к началу):")
# Выводим стек в обратном порядке (от самой верхней точки входа до текущей функции)
for entry in reversed(stack):
print(" ->", entry)
def foo():
bar()
def bar():
print_call_stack()
foo()
Функция sys._getframe()
возвращает текущий фрейм, то есть кадр, в котором выполняется, например, print_call_stack()
, и служит отправной точкой для формирования цепочки вызовов; затем, в цикле while
, пока кадр существует, мы извлекаем имя функции через frame.f_code.co_name
и номер строки через frame.f_lineno
, добавляем эту информацию в список stack
и переходим к предыдущему кадру через frame.f_back;
после завершения цикла мы выводим стек в обратном порядке, получая последовательность от корневого вызова (точка входа) к текущему, где самый верхний элемент — это начало исполнения, а нижний — самый последний вызов.
Допустим, при выполнении этого скрипта был получен следующий вывод:
Call Stack (от текущего к началу):
-> _run_module_as_main (line 198)
-> _run_code (line 88)
-> <module> (line 37)
-> launch_instance (line 992)
-> start (line 712)
-> start (line 205)
-> run_forever (line 608)
-> _run_once (line 1936)
-> _run (line 84)
-> dispatch_queue (line 510)
-> process_one (line 499)
-> dispatch_shell (line 406)
-> execute_request (line 730)
-> do_execute (line 383)
-> run_cell (line 528)
-> run_cell (line 2975)
-> _run_cell (line 3030)
-> _pseudo_sync_runner (line 78)
-> run_cell_async (line 3257)
-> run_ast_nodes (line 3473)
-> run_code (line 3553)
-> <cell line: 0> (line 19)
-> foo (line 14)
-> bar (line 17)
-> print_call_stack (line 7)
Это полноценная цепочка вызовов, начиная с самых низкоуровневых функций интерпретатора (например, runmodule_as_main
, runcode
, <module>
), через служебные вызовы среды (launch_instance
, run_forever
и прочие, характерные для интерактивных оболочек, таких как Jupyter/IPython), до вашего кода, где видно, что функция foo
(line 14) вызвала bar
(line 17), которая, в свою очередь, вызвала print_call_stack
(line 7); такой вывод позволяет увидеть, как система организует выполнение кода, и помогает локализовать, на каком именно этапе (и в каком контексте) произошёл вызов.
Построение собственного трейсера: отладка без pdb
Теперь создадим легковесный отладчик, который отслеживает все вызовы функций. Для этого в Python есть функция sys.settrace
, позволяющая установить глобальный обработчик событий.
Простой трейсер вызовов и возвратов:
import sys
def simple_tracer(frame, event, arg):
if event == "call":
code = frame.f_code
func_name = code.co_name
line_no = frame.f_lineno
print(f"[CALL] {func_name} at line {line_no}")
elif event == "return":
code = frame.f_code
func_name = code.co_name
print(f"[RETURN] {func_name} returning {arg}")
return simple_tracer
def traced_function(x):
return x * 2
def another_traced_function(y):
result = traced_function(y)
return result + 1
def run_tracer():
sys.settrace(simple_tracer)
print("Result:", another_traced_function(5))
sys.settrace(None)
run_tracer()
В итоге получаем этот код:
[CALL] another_traced_function at line 18
[CALL] traced_function at line 15
[RETURN] traced_function returning 10
[RETURN] another_traced_function returning 11
[CALL] write at line 526
[CALL] _is_master_process at line 437
[RETURN] _is_master_process returning True
Result:[CALL] _schedule_flush at line 456
[RETURN] _schedule_flush returning None
[RETURN] write returning 7
[CALL] write at line 526
[CALL] _is_master_process at line 437
[RETURN] _is_master_process returning True
[CALL] _schedule_flush at line 456
[RETURN] _schedule_flush returning None
[RETURN] write returning 1
[CALL] write at line 526
[CALL] _is_master_process at line 437
[RETURN] _is_master_process returning True
11[CALL] _schedule_flush at line 456
[RETURN] _schedule_flush returning None
[RETURN] write returning 2
[CALL] write at line 526
[CALL] _is_master_process at line 437
[RETURN] _is_master_process returning True
[CALL] _schedule_flush at line 456
[RETURN] _schedule_flush returning None
[RETURN] write returning 1
Трейсер перехватывает вызовы и возвраты функций не только из пользовательского кода, но и из внутренних системных вызовов, инициированных, например, при печати результата. Видно, что сначала вызывается функция another_traced_function
(строка 18), которая внутри вызывает traced_function
(строка 15); та возвращает значение 10, после чего another_traced_function
возвращает 11. Затем, когда результат выводится через print, запускаются дополнительные внутренние вызовы: функции write
, ismaster_process
, scheduleflush
— они отвечают за обработку и синхронизацию вывода в консоль.
Декораторы с доступом к контексту вызова
Ччто, если нужно не просто обернуть функцию, а еще и узнать, кто её вызвал? Тут на помощь снова приходит sys._getframe
.
import sys
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Получаем кадр вызывающей функции
caller_frame = sys._getframe(1)
caller_name = caller_frame.f_code.co_name
caller_line = caller_frame.f_lineno
print(f"[LOG] Функция '{func.__name__}' вызвана из '{caller_name}' на строке {caller_line}")
result = func(*args, **kwargs)
print(f"[LOG] Функция '{func.__name__}' завершилась с результатом {result}")
return result
return wrapper
@log_call
def compute_area(radius):
from math import pi
return pi * radius ** 2
def main():
area = compute_area(5)
print(f"Площадь круга: {area}")
main()
Вывод:
[LOG] Функция 'compute_area' вызвана из 'main' на строке 23
[LOG] Функция 'compute_area' завершилась с результатом 78.53981633974483
Площадь круга: 78.53981633974483
Иногда хочется, чтобы трассировка включалась не всегда, а только при определённых условиях. Например, если первый аргумент превышает заданное значение:
def conditional_trace(threshold):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Если первый аргумент — число и превышает порог, включаем трассировку
if args and isinstance(args[0], (int, float)) and args[0] > threshold:
print(f"[TRACE] {func.__name__} вызвана с args={args} kwargs={kwargs}")
caller_frame = sys._getframe(1)
print(f"[TRACE] Вызвана из {caller_frame.f_code.co_name} на строке {caller_frame.f_lineno}")
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@conditional_trace(10)
def multiply(a, b):
return a * b
def test():
print(multiply(5, 3)) # Трассировка не сработает, 5 < 10
print(multiply(15, 2)) # Трассировка сработает, 15 > 10
test()
Вывод:
15
[TRACE] multiply вызвана с args=(15, 2) kwargs={}
[TRACE] Вызвана из test на строке 21
30
Легкий профайлер на базе sys._getframe
Если хочется не просто отлаживать код, а еще и профилировать его выполнение, то соберем легкий профайлер, который будет измерять время работы функций и показывать, откуда они были вызваны.
import sys
import time
from functools import wraps
def profile(func):
@wraps(func)
def wrapper(*args, **kwargs):
start_time = time.perf_counter()
result = func(*args, **kwargs)
end_time = time.perf_counter()
# Извлекаем информацию о вызывающем контексте
caller = sys._getframe(1)
caller_info = f"{caller.f_code.co_name} (line {caller.f_lineno})"
print(f"[PROFILE] Функция '{func.__name__}' вызвана из {caller_info} заняла {end_time - start_time:.6f} секунд")
return result
return wrapper
@profile
def heavy_computation(n):
s = 0
for i in range(n):
s += i ** 2
return s
def run_computation():
result = heavy_computation(100000)
print("Результат вычислений:", result)
run_computation()
Вывод:
[PROFILE] Функция 'heavy_computation' вызвана из run_computation (line 26) заняла 0.011234 секунд
Результат вычислений: 333328333350000
Конечно, это далеко не всё про call stack. Поделитесь своим опытом в комментариях.
Все актуальные лучшие практики программирования можно освоить на онлайн-курсах OTUS: в каталоге можно посмотреть список всех программ, а в календаре — записаться на открытые уроки.