Перейти к содержанию

412 уроков, 25 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 19 из 20 Начальный 20 мин 130 XP

Сравнение словарей и копирование

Словари сравниваются по парам — порядок не важен, а копировать их нужно аккуратно: copy() делит вложенные значения, deepcopy копирует насквозь.

Редакция Питоники

К этому моменту словари создавались, менялись, объединялись и сортировались. Остались два вопроса, на которые каждый рано или поздно напарывается: как сравнить два словаря и как получить их копию. Оба вопроса обманчиво просты — у == и у копий есть нюанс, из-за которого программы ведут себя не так, как ждёт автор. Нюанс этот — разница между «одинаковые данные» и «один объект».

== по парам: порядок не важен

равенство содержимого
menu = {'чай': 50, 'кофе': 120}
same_menu = {'кофе': 120, 'чай': 50}

print(menu == same_menu)

twin = dict(menu)
print(twin == menu)
print(twin is menu)
Вывод
True
True
False

Два словаря равны, когда у них одинаковые пары, а порядок пар при сравнении не важен. Меню записали в разном порядке — сравнение вернуло True. Оператор == отвечает за содержимое, оператор is — за другой вопрос: не два ли имени у одного объекта. twin, собранный через dict(menu), содержит те же пары, но это новый объект — поэтому == даёт True, а is даёт False. is пригодится, когда важно различать копию и оригинал, а == — для сравнения данных: сохранённые настройки против текущих, ожидаемый ответ API против полученного.

copy(): независимая копия верхнего уровня

правки копии не трогают оригинал
original = {'lang': 'ru', 'theme': 'dark'}
copied = original.copy()

copied['lang'] = 'en'
print(original)
print(copied)
Вывод
{'lang': 'ru', 'theme': 'dark'}
{'lang': 'en', 'theme': 'dark'}

Ловушка: вложенные значения общие

копия меняет вложенное — оригинал тоже
original = {'settings': {'theme': 'dark'}}
copied = original.copy()

copied['settings']['theme'] = 'light'
print(original)
print(copied)
Вывод
{'settings': {'theme': 'light'}}
{'settings': {'theme': 'light'}}

Правка по верхнему уровню осталась в копии, а правка вложенного словаря дошла и до оригинала — объяснение в самой записи. Метод copy() поверхностный: он создал новый словарь и переложил в него те же ключи, а под ключом 'settings' хранится ссылка — тот же вложенный словарь, один на обе переменные. Новая обёртка, общая начинка. Это не баг и не лень авторов: для плоских словарей copy() достаточно, а глубокое копирование стоит дорого, поэтому Python оставляет выбор программисту.

copy.deepcopy: копия насквозь

полная независимость
import copy

original = {'settings': {'theme': 'dark'}}
copied = copy.deepcopy(original)

copied['settings']['theme'] = 'light'
print(original)
print(copied)
Вывод
{'settings': {'theme': 'dark'}}
{'settings': {'theme': 'light'}}

Что именно сравнивает ==

Сравнение словарей устроено глубже, чем кажется. == отвечает на вопрос «есть ли в обоих словарях одни и те же пары», и проверяет это рекурсивно: значения сравниваются тоже, а если значения — словари, то и они сравниваются по тем же правилам. Порядок пар при этом не учитывается совсем.

порядок не важен, а вложенность важна
a = {'x': 1, 'y': 2}
b = {'y': 2, 'x': 1}

print(a == b)
print(a is b)

nested1 = {'s': {'t': 'dark', 'n': 1}}
nested2 = {'s': {'n': 1, 't': 'dark'}}

print(nested1 == nested2)
Вывод
True
False
True

Первые две строки показывают разницу между == и is: словари равны по содержимому, но это разные объекты. Третья — про глубину: вложенные словари тоже сравниваются по парам, а не по порядку, поэтому nested1 и nested2 равны, хотя ключи во внутреннем словаре записаны в разном порядке. Это работает и для списков внутри словаря — но только если сравниваются именно списки.

а вот списки — по порядку
print([1, 2] == [2, 1])
print({'a': [1, 2]} == {'a': [2, 1]})
Вывод
False
False

Словари сравниваются как множества пар, а списки — как последовательности, где позиция решает всё. Словарь со списком в значении сравнивается по содержимому списка, а список со списком — по порядку элементов. Эта разница объясняет, почему два ответа API, у которых перепутан порядок элементов массива, будут признаны разными, а переставленные ключи объекта — одинаковыми.

Ещё деталь, которая однажды стоила нам получаса: числа сравниваются по значению, а не по типу. {'a': 1} == {'a': 1.0} — истина, потому что 1 == 1.0. Так что словарь, собранный из JSON, и словарь, собранный из чисел с плавающей точкой, окажутся равны, если значения совпадают numerically. А вот 1 и True — тоже равны между собой.

Три способа скопировать и разница между ними

Скопировать словарь можно тремя способами, и разница между ними — не в результате, а в глубине. dict(d) и {**d} дают новый словарь с теми же парами верхнего уровня. copy.copy(d) делает ровно то же. copy.deepcopy(d) копирует структуру насквозь — каждый вложенный словарь и список получают свои собственные копии.

три способа, один результат
src = {'a': 1}

print(dict(src))
print({**src})
print(dict(src) is src)
print({**src} is src)
Вывод
{'a': 1}
{'a': 1}
False
False

Все четыре строки дают новый объект — is возвращает False во всех случаях. Для плоского словаря разницы между способами нет, и выбирать можно по вкусу: dict(d) выглядит как приведение типа, {**d} — как распаковка, copy.copy(d) — как явная просьба скопировать. Мы в проектах пишем dict(d): сразу видно, что это копия, а не второе имя.

Граница поверхностной копии

Поверхностная копия копирует один уровень. Вложенные словари и списки остаются общими: под ключом лежит ссылка на тот же объект, что и в оригинале. Правка верхнего уровня копии не видна в оригинале, а правка вложенного — видна в обоих.

верхний уровень — свой, вложенный — общий
original = {'lang': 'ru', 'meta': {'theme': 'dark'}}
copied = original.copy()

copied['lang'] = 'en'
print(original['lang'], copied['lang'])

copied['meta']['theme'] = 'light'
print(original['meta']['theme'])
Вывод
ru en
light

Две строки вывода показывают границу. lang изменился только в копии — верхний уровень у каждого свой. А вот theme стал 'light' и в оригинале тоже: copied['meta'] — тот же самый словарь, что и original['meta'], просто под другим именем в исходном коде. Списки внутри ведут себя так же: copied['items'].append(3) допишет элемент в оригинал.

Это не баг и не недосмотр Python: глубокое копирование стоит дорого — оно обходит всю структуру и создаёт дубликат каждого вложенного объекта. Для плоских словарей, где вложенности нет вовсе, такая работа бессмысленна. Поэтому copy() оставляют поверхностным, а решение о глубине оставляют программисту.

deepcopy доходит до дна
import copy

original = {'meta': {'theme': 'dark'}, 'items': [1, 2]}
copied = copy.deepcopy(original)

copied['meta']['theme'] = 'light'
copied['items'].append(3)

print(original)
print(copied)
Вывод
{'meta': {'theme': 'dark'}, 'items': [1, 2]}
{'meta': {'theme': 'light'}, 'items': [1, 2, 3]}

Оригинал не пострадал: и вложенный словарь, и список остались прежними. deepcopy обошёл структуру и создал отдельные копии всех внутренних объектов. Одна тонкость: deepcopy сохраняет и общие ссылки — если два ключа указывали на один и тот же список, в копии они снова будут указывать на один список, но уже новый. Это правильное поведение: структура копии повторяет структуру оригинала.

Сценарий: настройки перед тестом

Типичная задача, где копирование спасает. Тест проверяет, что при включённой опции меняется поведение программы. Опция лежит в глобальном словаре настроек, и тест не должен оставлять после себя мусор — иначе следующие тесты в том же процессе провалятся по причине, никак не связанной с их логикой.

тест не должен влиять на соседей
import copy

settings = {'theme': 'dark', 'lang': 'ru', 'flags': {'beta': False}}

def run_test():
    local = copy.deepcopy(settings)
    local['theme'] = 'light'
    local['flags']['beta'] = True
    return local

result = run_test()

print(result['theme'], result['flags']['beta'])
print(settings['theme'], settings['flags']['beta'])
Вывод
light True
dark False

Здесь deepcopy не лишний. Правка верхнего уровня (local['theme']) не задела бы оригинал и при поверхностной копии, а вот local['flags']['beta'] = True изменило бы общий вложенный словарь — и следующий тест получил бы beta: True, хотя никто его не включал. Мы в одном проекте поймали именно так: два теста падали по причине, а тесты были не виноваты. С тех пор перед любой правкой настроек внутри теста делаем deepcopy.

СпособЧто копируетсяКогда брать
d2 = dНичего — второе имяРабота под другим именем, правок не будет
dict(d) или {**d}Верхний уровеньПлоский словарь, правка пар целиком
copy.copy(d)Верхний уровеньТо же, что dict(d), но стиль импорта
copy.deepcopy(d)Вся структура насквозьВложенность, которую меняют независимо
dict(d, **extra)Верхний уровень + новые парыКопия сразу с изменениями

Что дальше

Сравнение и копирование разобраны — осталось собрать финальную программу. В уроке 20 строим частотный словарь текста: чистка слов, Counter, топ-5 и ровные колонки. А если словари у вас уже есть и надо их слить — урок 14 про update и оператор |.

Что выведет код?

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

a = {'x': 1, 'y': 2}
b = {'y': 2, 'x': 1}

print(a == b)
d = {'a': 1}
c = d.copy()

c['a'] = 9
print(d['a'])
d = {'s': {'t': 'dark'}}
c = d.copy()

c['s']['t'] = 'light'
print(d['s']['t'])
Проверь себя
0 / 8

1. Когда два словаря равны по ==?

2. Что проверяет оператор is для словарей?

3. Что делает d.copy()?

4. Почему правка вложенного словаря через копию изменила оригинал?

5. Когда deepcopy необходим вместо copy()?

6. Что вернёт {'a': 1} == {'a': 1.0}?

7. Что произойдёт, если в цикле for по словарю добавить в него новый ключ?

8. Чем dict(d) отличается от d.copy()?

Карточки терминов
Запомнено: 0 / 10
Практика

Даны a = {'a': 1, 'b': 2} и b = {'b': 2, 'a': 1}. Выведи результат сравнения a == b. Затем сделай c = a.copy(), поменяй в c значение 'b' на 9 и выведи a и c на отдельных строках — оригинал должен остаться со старым значением.

practice.py
Вопросы и ответы по уроку

Как сравнить два словаря в Python?

Оператором ==: он вернёт True, если у словарей одинаковые пары ключ — значение. Порядок пар при сравнении не важен. Для проверки «это один и тот же объект или копия» есть оператор is — он про идентичность, а не про содержимое.

Урок 2: создание словарей

Чем copy() отличается от deepcopy?

copy() копирует только верхний уровень: вложенные словари остаются общими с оригиналом. copy.deepcopy() из модуля copy копирует структуру насквозь — правки копии не видны в оригинале ни на одном уровне.

Почему изменение копии словаря меняет оригинал?

Скорее всего правка шла по вложенному уровню: copy() поверхностный, и под ключами обе переменные держат одни и те же вложенные объекты. Для независимых правок вложенности нужна copy.deepcopy(). Если меняется и верхний уровень — проверьте, не присваивание ли это: d2 = d копии не создаёт.

Урок 14: update меняет объект

Как скопировать словарь с вложенными словарями?

Функцией copy.deepcopy: import copy; copied = copy.deepcopy(original). Каждый вложенный словарь и список получит собственную копию, и любые правки копии останутся в копии. Для плоских словарей хватает обычного copy() — deepcopy дороже по времени и памяти.

Почему два словаря с разным порядком ключей равны?

Оператор == сравнивает словари как множества пар: важно, какие пары есть, а не в каком порядке они записаны. Это верно и для вложенных словарей — они тоже сравниваются по парам. А вот списки сравниваются по порядку, поэтому {'a': [1, 2]} и {'a': [2, 1]} не равны.

Как проверить, что два словаря — один объект или две копии?

Оператором is: он проверяет идентичность объектов, а не содержимое. Для копии, сделанной через dict(d) или copy(), is вернёт False, а == вернёт True. Помните: у мелких чисел и коротких строк Python переиспользует объекты, так что is для отдельных значений может дать True — но для словарей как контейнеров это не так.

Что будет, если изменить вложенный словарь в копии, сделанной через copy()?

Изменение дойдёт до оригинала: copy() копирует только верхний уровень, а под ключом остаётся ссылка на тот же вложенный словарь. Видна новая обёртка и общая начинка. Для независимых вложенных правок нужен copy.deepcopy.

Почему нельзя менять словарь во время перебора по нему?

Потому что хеш-таблица рассчитана на фиксированный размер: добавили ключ — таблица перестроилась, и Python падает с RuntimeError: dictionary changed size during iteration. Нужные ключи добавляют после цикла или собирают заранее отдельным списком.

Понравился урок? Сошлитесь на него

«Два словаря равны, когда у них одинаковые пары, а порядок пар при сравнении не важен.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Что дальше

Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.