Привет, Хабр!
Сегодня в коротком формате разберем с тем, что же творится внутри 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: в каталоге можно посмотреть список всех программ, а в календаре — записаться на открытые уроки.
