Deep Engineering

ЗАМЕР

bench/copy/graph.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/python/shallow-vs-deep-copy
Как запустить
python3.11 bench/copy/graph.py
python3.12 bench/copy/graph.py
python3.13 bench/copy/graph.py
python3.14 bench/copy/graph.py

python3.13 bench/copy/cost.py
python3.14 bench/copy/cost.py

Запись прогона

Замеры для урока «Поверхностное и глубокое копирование»

Скрипт Что делает
graph.py наблюдения без времени: сколько уровней копируется, что остаётся общим, что возвращается как есть, вызывается ли __init__, где ломается рекурсия
cost.py цена: пять способов поверхностной копии между собой, от чего зависит цена глубокой, и пять способов получить «копию» одной структуры в одном замере
python3.11 bench/copy/graph.py
python3.12 bench/copy/graph.py
python3.13 bench/copy/graph.py
python3.14 bench/copy/graph.py

python3.13 bench/copy/cost.py
python3.14 bench/copy/cost.py

Практика урока

practice.py — источник ответов двух практических задач урока, а runs/practice.txt — дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет заявленное с этой записью (scripts/validate-practice.mjs) и не проходит, если они разошлись.

Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.

Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

Что здесь важно прочитать правильно

graph.py не меряет время. Он печатает ответы на вопрос «тот же это объект или другой» — то есть is, а не секунды. Такие утверждения от машины не зависят и обязаны совпасть у любого, кто запустит скрипт на той же версии; прогон по четырём версиям сделан ради того, чтобы это проверить.

cost.py сравнивает только строки внутри своего блока. Все способы в блоке меряются подряд, на одной и той же структуре. Абсолютные микросекунды с другой машины несравнимы, между версиями — тоже: сборки разных версий различаются компилятором, и эта разница больше измеряемой.

Третий блок cost.py нельзя читать без восьмого раздела graph.py. Он показывает, что pickle быстрее deepcopy в 5,8 раза, а руками — в 14,2. Но json при этом не переживает цикл и меняет типы ({1: (2, 3)} возвращается как {'1': [2, 3]}), а ручной способ верен ровно до первого изменения формы данных. Число «быстрее» само по себе тут ничего не советует.

Все пять строк третьего блока сняты в одном замере, включая поверхностную копию. Она стоит там не как «ещё один способ сделать глубокую копию», а чтобы отношение ×1800 считалось по числам из одного прогона, а не из двух разных.

Что получилось на 3.13.7:

что сравнивается результат
data[:], list(data), data.copy(), copy.copy(data) 2,3–2,5 мкс, разброс в пределах 7 %
[x for x in data] 13,7 мкс — в 5,8 раза дороже остальных четырёх
глубокая: 10 000 int против 10 000 списков по одному int 2,3 мс против 10 мс, ×4,3
поверхностная против глубокой на той же структуре 2,35 мкс против 4252 мкс, ×1809
pickle туда-обратно против deepcopy быстрее в 5,8 раза
json туда-обратно против deepcopy быстрее в 2,7 раза
руками, по известной форме быстрее в 14,2 раза

Числа последних четырёх строк — из одного запуска; от запуска к запуску отношения держатся в пределах 5,8–5,9, 2,7–2,8 и 13,7–14,2.

Наблюдения graph.py, одинаковые на 3.11, 3.12, 3.13 и 3.14.7 — вывод скрипта на 3.11 и на 3.13 совпадает побайтно:

  • у поверхностной копии верхний объект новый, вложенный — тот же;
  • глубокая копия сохраняет ОБЩНОСТЬ: если один объект лежал под двумя ключами, в копии он тоже один;
  • copy.copy возвращает как есть и frozenset, и slice, а deepcopy их копирует — потому что в Lib/copy.py два разных списка атомарных типов;
  • ни copy, ни deepcopy не вызывают __init__ — ноль вызовов на две копии;
  • deepcopy ломается на вложенности 498 при sys.getrecursionlimit() == 1000, то есть тратит два кадра стека на уровень.

Что этими числами доказать НЕЛЬЗЯ

Что deepcopy «медленный и его надо избегать». ×1800 получается на структуре из четырёх тысяч объектов; на структуре из десяти объектов та же операция стоит микросекунды, и вопрос выбора вообще не стоит. Число говорит одно: цена глубокой копии растёт по числу объектов внутри, поэтому её нельзя ставить в горячий путь, не посмотрев, сколько там объектов.

Скрипт

242 строк
"""
Наблюдения для урока «Поверхностное и глубокое копирование».

ЗАПУСК (числа урока сняты на 3.13.7):

    for v in 3.11 3.12 3.13 3.14; do python$v bench/copy/graph.py; done

ЗДЕСЬ НЕТ ЗАМЕРОВ ВРЕМЕНИ. Всё, что печатает этот скрипт, — ответы на вопросы
«тот же это объект или другой» и «сколько раз вызвался метод». Такие
утверждения от машины не зависят и обязаны совпасть у любого, кто запустит
скрипт на той же версии. Время меряется отдельно, в `cost.py`.

ПОЧЕМУ ВЕЗДЕ `is`, А НЕ `==`. Вопрос урока — не «равны ли значения», а
«один ли это объект». Равенство здесь ничего не различает: у поверхностной и
глубокой копии списка `==` даёт True одинаково, а разница между ними видна
только по `is` на вложенном уровне.
"""

import copy
import pickle
import sys


def header(title: str) -> None:
    print(f"\n--- {title} ---")


def observe_levels() -> None:
    """Сколько уровней копируется: верхний, все или ни одного."""
    header("1. Глубина копирования")

    inner = [1, 2]
    orig = [inner, inner]

    assigned = orig
    shallow = copy.copy(orig)
    deep = copy.deepcopy(orig)

    rows = [
        ("b = a", assigned),
        ("copy.copy(a)", shallow),
        ("copy.deepcopy(a)", deep),
    ]
    print(f"{'операция':<20} {'верх тот же':>12} {'вложенный тот же':>18}")
    for name, obj in rows:
        print(f"{name:<20} {str(obj is orig):>12} {str(obj[0] is orig[0]):>18}")

    print("\nчто это значит на практике: пишем во вложенный список")
    for name, obj in rows:
        probe_inner = [1, 2]
        probe = [probe_inner, probe_inner]
        made = {"b = a": probe, "copy.copy(a)": copy.copy(probe), "copy.deepcopy(a)": copy.deepcopy(probe)}[name]
        made[0].append(99)
        print(f"{name:<20} оригинал стал {probe[0]}")


def observe_sharing_preserved() -> None:
    """Главное недоразумение: глубокая копия копирует ГРАФ, а не каждую ссылку."""
    header("2. Глубокая копия сохраняет общность")

    shared = {"config": 1}
    orig = {"a": shared, "b": shared}
    deep = copy.deepcopy(orig)

    print("в оригинале один объект на два ключа:", orig["a"] is orig["b"])
    print("в глубокой копии тоже один:         ", deep["a"] is deep["b"])
    print("и он не тот же, что в оригинале:    ", deep["a"] is not orig["a"])

    print("\nдве отдельные глубокие копии между собой ничего не делят:")
    d1 = copy.deepcopy(orig)
    d2 = copy.deepcopy(orig)
    print("  d1['a'] is d2['a']:", d1["a"] is d2["a"])

    print("\nтаблицу можно заполнить заранее — тогда объект не будет скопирован:")
    memo = {id(shared): shared}
    kept = copy.deepcopy(orig, memo)
    print("  copy.deepcopy(orig, {id(shared): shared})['a'] is shared:", kept["a"] is shared)


def observe_cycles() -> None:
    """Ради чего таблица заведена: рекурсивные структуры."""
    header("3. Циклы")

    a = []
    a.append(a)
    b = copy.deepcopy(a)
    print("список, содержащий сам себя, копируется:", b is not a, "и цикл сохранён:", b[0] is b)

    node = {"name": "root"}
    node["self"] = node
    c = copy.deepcopy(node)
    print("словарь со ссылкой на себя:            ", c is not node, "и цикл сохранён:", c["self"] is c)


def observe_atomic() -> None:
    """Два списка «атомарных» типов в copy.py — и они РАЗНЫЕ."""
    header("4. Что возвращается как есть")

    samples = [
        ("int", 42),
        ("str", "abc"),
        ("tuple (внутри неизменяемое)", (1, 2)),
        ("tuple (внутри список)", ([1],)),
        ("frozenset", frozenset({1, 2})),
        ("slice", slice(1, 5)),
        ("range", range(3)),
        ("функция", observe_atomic),
        ("тип", int),
    ]
    print(f"{'объект':<30} {'copy вернул тот же':>20} {'deepcopy вернул тот же':>24}")
    for name, obj in samples:
        print(f"{name:<30} {str(copy.copy(obj) is obj):>20} {str(copy.deepcopy(obj) is obj):>24}")

    print("\nЧитать так: расхождение в строке — не ошибка, а два разных списка")
    print("в Lib/copy.py. Неизменяемость объекта тут ни при чём.")


def observe_no_init() -> None:
    """Ни copy, ни deepcopy не вызывают __init__."""
    header("5. __init__ не вызывается")

    calls = []

    class Connection:
        def __init__(self, dsn: str) -> None:
            calls.append("init")
            self.dsn = dsn
            self.socket = object()

    original = Connection("postgres://…")
    calls.clear()

    shallow = copy.copy(original)
    deep = copy.deepcopy(original)

    print("вызовов __init__ на две копии:", len(calls))
    print("поверхностная копия делит socket с оригиналом:", shallow.socket is original.socket)
    print("глубокая копия socket не делит:              ", deep.socket is not original.socket)
    print("__dict__ у глубокой копии свой:              ", deep.__dict__ is not original.__dict__)


def observe_hooks() -> None:
    """Класс может перехватить обе операции."""
    header("6. __copy__ и __deepcopy__")

    log = []

    class Handle:
        def __init__(self, name: str) -> None:
            self.name = name
            self.buffer = [1, 2, 3]

        def __copy__(self):
            log.append("__copy__")
            return Handle(self.name)

        def __deepcopy__(self, memo):
            log.append("__deepcopy__")
            new = Handle(self.name)
            memo[id(self)] = new
            new.buffer = copy.deepcopy(self.buffer, memo)
            return new

    h = Handle("h")
    copy.copy(h)
    copy.deepcopy(h)
    print("вызвано:", log)

    print("\nпорядок поиска в deepcopy, по Lib/copy.py:")
    print("  атомарный тип → таблица типов → подкласс type → __deepcopy__ →")
    print("  copyreg.dispatch_table → __reduce_ex__(4) → __reduce__")


def observe_depth() -> None:
    """Глубокая копия рекурсивна — и упирается в предел рекурсии."""
    header("7. Предел вложенности")

    def nest(n: int) -> list:
        root: list = []
        cur = root
        for _ in range(n):
            nxt: list = []
            cur.append(nxt)
            cur = nxt
        return root

    limit = sys.getrecursionlimit()
    lo, hi = 1, 20000
    while lo < hi:
        mid = (lo + hi) // 2
        try:
            copy.deepcopy(nest(mid))
            lo = mid + 1
        except RecursionError:
            hi = mid
    print(f"sys.getrecursionlimit() = {limit}")
    print(f"deepcopy ломается на вложенности {lo} — примерно {limit / lo:.1f} кадра на уровень")


def observe_alternatives() -> None:
    """Чем часто подменяют deepcopy — и что при этом теряется."""
    header("8. Чем подменяют")

    import json

    inner = [1]
    orig = [inner, inner]

    p = pickle.loads(pickle.dumps(orig))
    j = json.loads(json.dumps(orig))
    print("общность объектов сохраняет pickle:", p[0] is p[1], " json:", j[0] is j[1])

    cyc: list = []
    cyc.append(cyc)
    print("цикл переживает pickle:", pickle.loads(pickle.dumps(cyc))[0] is not None)
    try:
        json.dumps(cyc)
        print("цикл переживает json: True")
    except ValueError as exc:
        print(f"цикл переживает json: нет — {type(exc).__name__}: {exc}")

    print("\njson ещё и меняет типы:")
    src = {1: (2, 3)}
    back = json.loads(json.dumps(src))
    print(f"  {src!r}{back!r}")


def main() -> None:
    print(f"Python {sys.version.split()[0]}")
    observe_levels()
    observe_sharing_preserved()
    observe_cycles()
    observe_atomic()
    observe_no_init()
    observe_hooks()
    observe_depth()
    observe_alternatives()


if __name__ == "__main__":
    main()