RRUNTIME LABruntime observatoryNNEON · Статьи на 90 языках
ГЛАВА 23
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Объекты, функции и протоколы Python

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

Большая часть смысла Python спрятана в протоколах объектов: функция может быть значением, class создаёт новый тип, generator приостанавливается на yield, with гарантирует выход из контекста, а decorator заменяет объект результатом другого вызова.

ТЕХНИЧЕСКАЯ ОСНОВА

В Python всё, включая функции, классы и модули, представлено объектами. Атрибуты разрешаются через data model, методы получают instance как первый параметр self, исключения раскручивают стек до подходящего except, iter/next образуют протокол итерации, а context manager реализует __enter__/__exit__.

Зачем это знатьЗдесь JS-интуиция чаще всего даёт скрытые баги: mutable default живёт между вызовами, class attribute разделяется instances, shallow copy сохраняет вложенные ссылки, а generator не выполняет тело до начала итерации.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01NAMESbindings · scopes · closures
02OBJECTSidentity · type · value
03PROTOCOLSiteration · context · call
04APPLICATIONmodules · classes · errors
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Identity

Идентичность конкретного объекта. Оператор is отвечает, являются ли две ссылки одним объектом.

02

Mutable

Объект, значение которого можно изменить без создания нового объекта: например list, dict и set.

03

Callable

Объект, который можно вызвать через (). Функции и классы callable; объект может реализовать __call__.

04

Decorator

Callable, получающий функцию или class и связывающий исходное имя с возвращённым результатом.

05

Generator

Iterator, который сохраняет frame и продолжает выполнение между yield.

06

Context manager

Объект протокола входа/выхода, который with использует для детерминированного cleanup.

07

Exception

Объект ошибки, передаваемый вверх по call stack, пока подходящий except не перехватит его.

08

dataclass

Decorator стандартной библиотеки, генерирующий __init__, __repr__ и другие типичные методы класса данных.

09

Closure

Функция вместе со ссылками на free variables из enclosing function scope.

10

Module cache

sys.modules хранит загруженные модули; повторный import обычно возвращает тот же object.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    class выполняет body

    Python выполняет class body в namespace и затем создаёт class object. Это executable statement.

  2. 02
    Вызов class создаёт instance

    __new__ создаёт объект, __init__ инициализирует уже созданный instance.

  3. 03
    Метод связывается с self

    При instance.method(...) Python создаёт bound method и передаёт instance первым аргументом.

  4. 04
    Decorator применяется при определении

    @decorator над def эквивалентен name = decorator(name).

  5. 05
    Generator стартует по требованию

    Вызов generator function создаёт object. Тело начинает выполняться только при next или обходе.

  6. 06
    with гарантирует выход

    __exit__ вызывается и при normal flow, и при exception, что делает cleanup предсказуемым.

  7. 07
    Exception ищет handler

    Стек раскручивается до совместимого except; finally выполняется при любом способе выхода.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Defaults вычисляются один раз

Значения defaults создаются во время выполнения def. Mutable default поэтому накапливает состояние.

02

Class variable разделяется

Mutable-объект из class body общий для instances, пока attribute не переопределён на instance.

03

property может выполнять дорогой код

Доступ order.total вызывает descriptor/property и может даже выбросить exception.

04

finally может подменить результат

return внутри finally подавляет ранее возвращаемое значение или exception. Обычно это ошибка.

05

Generator одноразовый

После StopIteration тот же generator не начнётся заново; нужно снова вызвать generator function.

06

Closure удерживает объект

Пока живёт внутренняя функция, captured object остаётся достижимым — как замыкание в JavaScript.

07

Private — в основном соглашение

_name обозначает internal API; __name применяет name mangling, но не строгую приватность.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/python-lab.py · учебный фрагментPython
from dataclasses import dataclass

@dataclass(slots=True)
class CartLine:
    sku: str
    price: int
    quantity: int = 1

    @property
    def subtotal(self) -> int:
        return self.price * self.quantity

def even_squares(limit):
    for value in range(limit):
        if value % 2 == 0:
            yield value * value
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Первый файл — настоящий CPython-сценарий; второй безопасно запускает child process и переводит его JSON Lines в live trace.

src/python-lab.py
CPython-сценарий294 строк
#!/usr/bin/env python3
"""Fixed, input-free CPython scenarios used by Runtime Lab."""

from __future__ import annotations

import asyncio
import dis
import gc
import io
import json
import platform
import sys
import time
import weakref
from dataclasses import dataclass
from typing import Any, Iterator


def emit(lane: str, event_type: str, key: str, **data: Any) -> None:
    print(
        json.dumps(
            {"lane": lane, "type": event_type, "key": key, "data": data},
            ensure_ascii=False,
        ),
        flush=True,
    )


def version_event() -> None:
    emit(
        "python",
        "runtime",
        "python.version",
        implementation=platform.python_implementation(),
        version=platform.python_version(),
    )


def run_syntax() -> None:
    version_event()
    orders = [
        {"id": "A-10", "status": "paid", "price": 120, "qty": 2},
        {"id": "A-11", "status": "draft", "price": 80, "qty": 1},
        {"id": "A-12", "status": "paid", "price": 50, "qty": 3},
    ]
    emit("objects", "state", "syntax.objects", count=len(orders))

    paid = [order for order in orders if order["status"] == "paid"]
    total = sum(order["price"] * order["qty"] for order in paid)
    emit(
        "comprehension",
        "result",
        "syntax.comprehension",
        ids=", ".join(order["id"] for order in paid),
        total=total,
    )

    first, *middle, last = [order["id"] for order in orders]
    emit(
        "sequence",
        "result",
        "syntax.unpack",
        first=first,
        middle=middle,
        last=last,
    )

    labels = [f"{index}:{order['id']}" for index, order in enumerate(orders, start=1)]
    emit("loop", "result", "syntax.enumerate", labels=", ".join(labels))

    def format_order(order: dict[str, Any], *, currency: str = "RUB") -> str:
        return f"{order['id']} · {order['price'] * order['qty']} {currency}"

    emit(
        "function",
        "result",
        "syntax.function",
        rendered=format_order(orders[0], currency="₽"),
    )
    emit("result", "result", "syntax.result")


@dataclass(slots=True)
class CartLine:
    sku: str
    price: int
    quantity: int = 1

    @property
    def subtotal(self) -> int:
        return self.price * self.quantity


def append_bad(item: str, bucket: list[str] = []) -> list[str]:
    bucket.append(item)
    return list(bucket)


def append_safe(item: str, bucket: list[str] | None = None) -> list[str]:
    target = [] if bucket is None else bucket
    target.append(item)
    return target


def even_squares(limit: int) -> Iterator[int]:
    for value in range(limit):
        if value % 2 == 0:
            yield value * value


def classify_event(event: dict[str, Any]) -> str:
    match event:
        case {"type": "order.paid", "payload": {"id": order_id}}:
            return f"paid order {order_id}"
        case {"type": event_type}:
            return f"other event {event_type}"
        case _:
            return "invalid event"


def run_semantics() -> None:
    version_event()
    line = CartLine("book", 450, quantity=2)
    emit(
        "class",
        "result",
        "semantics.dataclass",
        rendered=repr(line),
        subtotal=line.subtotal,
    )

    original = ["node"]
    alias = original
    alias.append("python")
    emit("objects", "mutation", "semantics.alias", shared=original == alias == ["node", "python"])

    bad_first = append_bad("api")
    bad_second = append_bad("worker")
    emit(
        "function",
        "warning",
        "semantics.mutable-default",
        first=bad_first,
        second=bad_second,
    )

    safe_first = append_safe("api")
    safe_second = append_safe("worker")
    emit(
        "function",
        "result",
        "semantics.safe-default",
        first=safe_first,
        second=safe_second,
    )

    emit("generator", "result", "semantics.generator", values=list(even_squares(7)))
    emit(
        "pattern",
        "result",
        "semantics.match",
        label=classify_event({"type": "order.paid", "payload": {"id": "A-42"}}),
    )

    try:
        int("not-a-number")
    except ValueError as error:
        emit("exception", "caught", "semantics.exception", message=str(error))

    stream = io.StringIO()
    with stream:
        stream.write("cleanup is deterministic")
        text = stream.getvalue()
    emit("context", "cleanup", "semantics.context", closed=stream.closed, text=text)
    emit("result", "result", "semantics.result")


def doubled_total(values: list[int]) -> int:
    return sum(value * 2 for value in values)


class CycleNode:
    def __init__(self) -> None:
        self.peer: CycleNode | None = None


async def traced_task(name: str, delay: float, completion: list[str]) -> str:
    emit("asyncio", "start", "asyncio.started", name=name)
    await asyncio.sleep(delay)
    completion.append(name)
    emit("asyncio", "resume", "asyncio.resumed", name=name)
    return name


async def measure_timer_while(blocking_call: Any) -> float:
    started = time.perf_counter()
    timer = asyncio.create_task(asyncio.sleep(0.01))
    await blocking_call()
    await timer
    return max(0.0, (time.perf_counter() - started - 0.01) * 1_000)


async def run_asyncio_round() -> None:
    completion: list[str] = []
    first = asyncio.create_task(traced_task("A", 0.025, completion))
    second = asyncio.create_task(traced_task("B", 0.005, completion))
    emit("asyncio", "schedule", "asyncio.created")
    await asyncio.gather(first, second)
    emit("asyncio", "result", "asyncio.result", order=" → ".join(completion))

    async def block_loop() -> None:
        time.sleep(0.055)

    async def offload_sleep() -> None:
        await asyncio.to_thread(time.sleep, 0.055)

    blocked_delay = await measure_timer_while(block_loop)
    emit("asyncio", "blocking", "asyncio.blocking", delay=round(blocked_delay, 1))
    offloaded_delay = await measure_timer_while(offload_sleep)
    emit("asyncio", "offload", "asyncio.offload", delay=round(offloaded_delay, 1))


def run_runtime() -> None:
    implementation = platform.python_implementation()
    gil_probe = getattr(sys, "_is_gil_enabled", None)
    gil_enabled = gil_probe() if gil_probe else implementation == "CPython"
    emit(
        "runtime",
        "config",
        "runtime.config",
        implementation=implementation,
        version=platform.python_version(),
        gil=gil_enabled,
    )

    operations = [instruction.opname for instruction in dis.get_instructions(doubled_total)]
    emit(
        "bytecode",
        "result",
        "runtime.bytecode",
        operations=" → ".join(operations[:12]),
    )

    frame = sys._getframe()
    visible_locals = ", ".join(sorted(name for name in frame.f_locals if not name.startswith("_")))
    emit(
        "frame",
        "state",
        "runtime.frame",
        functionName=frame.f_code.co_name,
        locals=visible_locals,
    )

    left = CycleNode()
    right = CycleNode()
    left.peer = right
    right.peer = left
    left_ref = weakref.ref(left)
    right_ref = weakref.ref(right)
    alive_before = left_ref() is not None and right_ref() is not None
    del left, right
    collected = gc.collect()
    alive_after = left_ref() is not None or right_ref() is not None
    emit(
        "gc",
        "result",
        "runtime.gc",
        collected=collected,
        aliveBefore=alive_before,
        aliveAfter=alive_after,
    )

    asyncio.run(run_asyncio_round())
    emit("result", "result", "runtime.result")


SCENARIOS = {
    "syntax": run_syntax,
    "semantics": run_semantics,
    "runtime": run_runtime,
}


def main() -> None:
    scenario = sys.argv[1] if len(sys.argv) > 1 else ""
    runner = SCENARIOS.get(scenario)
    if runner is None:
        raise SystemExit(f"unknown scenario: {scenario}")
    runner()


if __name__ == "__main__":
    main()

Trace инструментирован сценарием: Python вызывает emit(...) и печатает события как упорядоченные JSON Lines, а Node фиксирует timestamp при получении каждой строки. Названия source/lane задаёт код, это не profiler CPython/asyncio. Node bridge читает вывод без shell, ограничивает время и объём и передаёт события HTTP stream.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.

01

Функция как значение

Увидеть callable без вызова.

def normalize(value: str) -> str:
    return value.strip().lower()

pipeline = [normalize, str.upper]
result = "  Ada  "
for transform in pipeline:
    result = transform(result)
  • Имя функции без () ссылается на function object.
  • Каждый callable получает результат предыдущего.
02

Безопасный default

Не делить mutable state.

def add_tag(tag: str, tags: list[str] | None = None):
    result = [] if tags is None else list(tags)
    result.append(tag)
    return result
  • None — immutable sentinel.
  • list(tags) не изменяет list вызывающей стороны.
03

Класс данных

Читать dataclass и property.

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Money:
    amount: int
    currency: str = "RUB"

    @property
    def label(self):
        return f"{self.amount} {self.currency}"
  • frozen не даёт глубокой immutable-гарантии.
  • slots меняет layout instance.
04

Generator

Обрабатывать поток лениво.

def read_valid(lines):
    for line in lines:
        text = line.strip()
        if text:
            yield text

for item in read_valid(file):
    consume(item)
  • Результат не хранится целиком.
  • Внешний ресурс всё равно нужно закрыть.
05

Exception chaining

Добавить domain-смысл без потери причины.

try:
    user = repository.get(user_id)
except DatabaseError as error:
    raise UserLoadError(user_id) from error
else:
    return user
finally:
    metrics.increment("load.attempt")
  • raise ... from сохраняет causal chain.
  • else выполняется только без exception в try.
06

Context manager

Гарантировать transaction cleanup.

with database.transaction() as transaction:
    order = transaction.insert_order(payload)
    transaction.insert_outbox(order)
  • Commit/rollback определяет конкретный manager.
  • Exception передаётся в __exit__.
07 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

Tags одного HTTP request появляются в другом

Helper добавляет audit tags и вызывается для каждого запроса. Автор ожидает, что default list создаётся заново при каждом вызове функции.

КОНТЕКСТ ИНЦИДЕНТА

Default вычисляется один раз при выполнении def. Долгоживущий process переиспользует list, поэтому metadata пользователей смешивается и память постепенно растёт.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
def attach_tag(tag: str, tags: list[str] = []) -> list[str]:
    tags.append(tag)
    return tags

def audit_request(request):
    return attach_tag(f"user:{request.user_id}")

Один function object хранит одну ссылку на default list в __defaults__. Каждый вызов без tags мутирует тот же объект.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
def attach_tag(
    tag: str,
    tags: list[str] | None = None,
) -> list[str]:
    result = [] if tags is None else list(tags)
    result.append(tag)
    return result

def audit_request(request):
    return attach_tag(f"user:{request.user_id}")

Immutable sentinel None безопасно хранится как default. Новый list создаётся внутри каждого вызова, а переданный list копируется, поэтому helper не мутирует владельца.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

tags=[]
Default expression выполняется один раз при создании function object, поэтому один mutable list разделяется вызовами.
function.__defaults__
Tuple с positional default objects функции; через него можно буквально увидеть сохранённую ссылку на общий list.
tags is None
Использует immutable singleton как сигнал, что вызывающая сторона не передала собственную collection.
list(tags)
Создаёт shallow copy iterable, чтобы append helper-а не изменял исходный list вызывающей стороны.
result.append(tag)
Мутирует конкретный result list на месте и возвращает None; поэтому append не присваивают обратно переменной.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Mutable defaults допустимы только как намеренный function-level cache с явным контрактом, синхронизацией и limits. Для обычного parameter используйте None или immutable value.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Audit records содержат tags предыдущих пользователей.
  • Размер возвращаемого list растёт с uptime процесса.
  • Перезапуск временно устраняет дефект и обнуляет скрытое состояние.
08 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

items=[] создаёт новый list на каждый вызов.

НА САМОМ ДЕЛЕ

Один default list создаётся при выполнении def и переиспользуется.

МИФ

self — специальное ключевое слово.

НА САМОМ ДЕЛЕ

Это обычное имя первого параметра instance method и важное соглашение.

МИФ

yield навсегда завершает функцию.

НА САМОМ ДЕЛЕ

yield приостанавливает frame; следующий next продолжает код после остановки.

МИФ

except Exception ловит вообще всё.

НА САМОМ ДЕЛЕ

Системные сигналы вроде KeyboardInterrupt наследуются от BaseException.

МИФ

with работает только с файлами.

НА САМОМ ДЕЛЕ

Протокол подходит для locks, transactions, tracing и временного состояния.

МИФ

Import только объявляет зависимость.

НА САМОМ ДЕЛЕ

Первый import выполняет top-level code и может совершить I/O или изменить state.

09 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Когда вычисляется default argument?
  2. Почему instances могут разделить class-level list?
  3. Когда generator body начинает выполняться?
  4. Какие методы вызывает with?
  5. Почему property нельзя автоматически считать дешёвым полем?