
Поверхностная и глубокая копия в Python: как проверить изменение вложенного списка


Когда вложенный список меняется «сразу в двух местах», причина почти всегда одна: копия оказалась не полностью независимой. В Python это особенно заметно на списках списков. Внешний список вы вроде бы скопировали, но внутренние списки могли остаться общими, и тогда изменение одного вложенного элемента отразится и в исходной структуре.
Чтобы быстро это проверить, недостаточно сравнить значения через ==. Нужна проверка ссылок: один и тот же это объект или нет. Для такой диагностики достаточно трех шагов: сравнить контейнеры через is, отдельно сравнить вложенные списки, а затем сделать одну контролируемую правку вложенного элемента и посмотреть, где она проявилась. Ниже разберем этот сценарий на учебном примере в обычном Python 3, который можно запустить в файле .py или в REPL.
Вложенные учебные данные
УЧЕБНЫЙ ПРИМЕР. Возьмем условный список навыков по двум темам. Это именно учебные данные, чтобы было легко увидеть разницу между верхним уровнем списка и вложенными списками:
import copy
source = [
["Python", "SQL"],
["Git", "Linux"],
]
alias = source
shallow = source.copy()
deep = copy.deepcopy(source)
Здесь source — исходные данные. Переменная alias не создает копию, а просто указывает на тот же самый объект. Переменная shallow получает поверхностную копию: внешний список новый, но вложенные списки внутри него пока общие с исходным. Переменная deep получает глубокую копию: и внешний список, и вложенные списки создаются заново.
Сначала все три структуры выглядят одинаково при печати. Именно поэтому путаница возникает так часто: по значениям разницы не видно. Но вопрос не в том, одинаково ли данные выглядят сейчас, а в том, изменятся ли они вместе после мутации. На этот вопрос отвечает не ==, а проверка связей между объектами.
Ссылки на объекты
В Python присваивание само по себе не копирует объект. Оно лишь связывает новое имя с уже существующим объектом. Поэтому alias = source — это не запасная версия списка, а второе имя для тех же данных. Если изменить вложенный элемент через alias, исходный список тоже изменится, потому что это один и тот же объект.
print(source is alias) # True
print(source == alias) # True
alias[0][1] = "Pandas"
print(source) # [['Python', 'Pandas'], ['Git', 'Linux']]
Здесь полезно разделять две проверки. == спрашивает, равны ли значения. is спрашивает, один и тот же это объект или нет. Для поиска ошибки с вложенными списками важнее именно вторая проверка. Два разных списка могут быть равны по содержимому, но полностью независимы. И наоборот: две переменные могут указывать на один объект, из-за чего изменение через одну переменную сразу видно через другую.
Если вы хотите увидеть это еще нагляднее, можно вывести идентификаторы:
print(id(source), id(alias))
print(id(source[0]), id(alias[0]))
В случае с alias совпадут и идентификатор внешнего списка, и идентификаторы вложенных списков. Это самый простой сигнал, что никакой копии не произошло.
Два варианта копии
Теперь сравним две настоящие копии. Поверхностная копия создает новый внешний контейнер, но помещает в него ссылки на прежние вложенные объекты. Глубокая копия идет дальше и рекурсивно копирует вложенные изменяемые объекты. На списках списков разница проявляется сразу.
import copy
source = [
["Python", "SQL"],
["Git", "Linux"],
]
shallow = source.copy()
deep = copy.deepcopy(source)
print(source is shallow) # False
print(source[0] is shallow[0]) # True
print(source is deep) # False
print(source[0] is deep[0]) # False
Это можно представить как простую схему объектов:
source -> outer A
A[0] -> inner B
A[1] -> inner C
shallow -> outer D
D[0] -> inner B
D[1] -> inner C
deep -> outer E
E[0] -> inner F
E[1] -> inner G
Из схемы видно, почему поверхностная копия иногда подходит, а иногда нет. Если вы собираетесь заменить целый элемент верхнего уровня, например подменить одну строку матрицы целиком, поверхностной копии может хватить: внешний список уже отдельный. Но если вы хотите менять содержимое вложенного списка, общие внутренние объекты станут проблемой.
Проверим оба случая на одном наборе данных. Сначала изменим вложенный элемент в поверхностной копии:
shallow[0][1] = "Pandas"
print(source) # [['Python', 'Pandas'], ['Git', 'Linux']]
print(shallow) # [['Python', 'Pandas'], ['Git', 'Linux']]
Изменение видно и в source, потому что обе структуры делят один и тот же внутренний список source[0]. А теперь заменим в поверхностной копии целый вложенный список на новый:
shallow[1] = ["Docker", "CI"]
print(source) # [['Python', 'Pandas'], ['Git', 'Linux']]
print(shallow) # [['Python', 'Pandas'], ['Docker', 'CI']]
Во втором случае исходный список уже не меняется. Причина в том, что операция затронула верхний уровень: во внешнем списке shallow ссылка на второй элемент была заменена, а внешний список у него собственный.
Проверка независимости
Если вам нужно быстро понять, безопасно ли менять вложенный список, используйте короткий набор проверок. Сначала проверьте внешний контейнер, потом один или несколько внутренних списков, а после этого сделайте пробную мутацию именно вложенного элемента. Такой порядок экономит время: по is часто уже ясно, чего ожидать от изменения.
import copy
def inspect_copy(original, candidate):
print("outer same:", original is candidate)
for i in range(len(original)):
print(f"inner {i} same:", original[i] is candidate[i])
print()
source = [
["Python", "SQL"],
["Git", "Linux"],
]
shallow = source.copy()
deep = copy.deepcopy(source)
inspect_copy(source, shallow)
inspect_copy(source, deep)
Ожидаемый проверяемый результат для этого учебного примера такой: у shallow строка outer same должна быть False, а проверки inner 0 same и inner 1 same — True. У deep все три проверки должны дать False. Если у вас получился такой вывод, структура ссылок понята верно.
После этого можно сделать контрольное изменение вложенного элемента:
source = [
["Python", "SQL"],
["Git", "Linux"],
]
shallow = source.copy()
deep = copy.deepcopy(source)
shallow[0][0] = "Flask"
print(source[0][0]) # Flask
deep[1][1] = "Bash"
print(source[1][1]) # Linux
Практический итог простой. Если original[i] is copy_obj[i] возвращает True, изменение элемента внутри этого вложенного списка затронет обе структуры. Если возвращает False, вложенный список независим, и его мутация не должна изменить исходные данные. Для списков списков это и есть короткая рабочая проверка, которую удобно держать под рукой при отладке.
Если тема копирования у вас постоянно всплывает в задачах, полезно отдельно потренировать работу со списками в памяти. В курсе «Программирование на Python 3» во внутренней карточке каталога и на официальной странице подтверждены темы хранения списков в памяти, копирования последовательностей и работы со вложенными списками. Для этой статьи это ценно именно как база для самостоятельной практики с list.copy(), id() и copy.deepcopy().





















