ЗАМЕР
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()