Словари (dict, dictionary) в Python
Введение
Словарь — это пример хранилища значений ключей, также известного как Mapping в Python. Он позволяет хранить и извлекать элементы, ссылаясь на ключ. Так как словари ссылаются по ключу, в них быстро работает поиск, поскольку они в основном используются для ссылки на элементы по ключу и они не сортируются.
Доступ к значениям словаря
Строка «Hello» в этом примере называется ключом. Он используется для поиска значения в словаре, помещая ключ в квадратные скобки.
Число 1234 видно после соответствующего двоеточия в словаре. Это значение, на которое ведёт «Hello» в словаре.
x = dictionary.get(«whatever», «nuh-uh»)
Конструктор dict()
Конструктор dict() можно использовать для создания словарей из именованных аргументов или из одной итерируемой пары ключ-значение или из одного словаря и именованного аргумента.
Как избегать исключений KeyError
Распространённая ошибка при использовании словарей — доступ к несуществующему ключу. Это обычно приводит к исключению KeyError :
Другой способ избавиться от ошибки — перехватить исключение
Вы также можете проверить, есть ли ключ словаре:
Обратите внимание, что в многопоточных средах ключ может быть удален из словаря после проверки, создавая состояние, при котором всё ещё может быть выдано исключение.
Доступ к ключам и значениям
При работе со словарями часто требуется получить доступ ко всем ключам и значениям в словаре, в цикле for, в генераторе списка или просто в виде простого списка.
Словарь будет таким:
Вы можете получить список ключей, используя метод keys или функцию dict_keys :
Если вместо этого вы хотите получить список значений, используйте метод values или функцию dict_values :
Если вы хотите работать как с ключом, так и с соответствующим значением, вы можете использовать метод items или функцию dict_ items :
Введение в словарь
Создание словаря
Словари можно создавать разными способами:
С помощью литерала
С помощью генератора словаря
Через встроенный класс dict()
Модификация словаря
Чтобы добавить элементы в словарь нужно создать новый ключ со значением:
Также можно добавить список или словарь в качестве значения:
Чтобы удалить элемент, нужно удалить ключ из словаря:
Словари со значениями по умолчанию
Доступно в стандартной библиотеке как defaultdict
Если вам нужно добавить много значений, dict.setdefault() будет создавать новый экземпляр начального значения (в этом примере a[]) каждый раз, когда оно вызывается, создавая лишнюю нагрузку.
Как создать упорядоченный словарь
Вы можете создать упорядоченный словарь, который будет следовать определенному порядку при переборе ключей в словаре.
Распаковка словарей с помощью оператора **
Вы можете использовать оператор распаковки именованного аргумента слова ** для доставки пар ключ-значение в словаре в аргументы функции. Упрощенный пример из официальной документации:
Начиная с Python 3.5 вы также можете использовать этот синтаксис для объединения произвольного числа объектов [ dict ].
Этот пример показывает, что дубликаты ключей соответствуют последнему значению (например, «Клиффорд» переопределяет «Немо»).
Объединений словарей
Рассмотрим разные словари:
Python 3.5+
В этом примере, дубликаты ключей соответствуют последнему значению (например, «Клиффорд» переопределяет «Немо»).
Python 3.3+
Здесь первостепенное значение отдаётся кокретному ключу, а не последнему («Клиффорд» выбрасывается в пользу «Немо»).
Python 2.x, 3.x
Здесь используется последнее значение, как и в методе слияния [**] («Клиффорд» переопределяет «Немо»).
Замыкающая запятая
Также как в списках или кортежах, можно добавлять замыкающую запятую в своем словаре.
PEP 8 требует, чтобы вы оставляли пробел между замыкающей запятой и закрывающей скобкой.
Все комбинации значений словаря
Вы можете создать список, который возвращает все такие комбинации значений, используя следующий код.
Это дает нам следующий список, хранящийся в переменной combinations :
Перебор словаря
Если вы используете словарь в качестве итератора (например, в операторе for ), он перемещает ключи в словаре. Например:
Работает таким же образом в генераторах:
Метод items() можно использовать для одновременного зацикливания ключа и значения:
Метод values() можно использовать для перебора только значений:
Создание словаря
Правила создания словаря:
Примеры словарей
Словари сопоставляют ключи со значениями
Доступ к значениям словаря осуществляется по их ключам
Словари также могут быть созданы в стиле JSON:
Значения словаря могут быть перебраны:
Синтаксис
Примечания
Что следует помнить при использовании словаря:
– каждый ключ должен быть уникальным (иначе он будет переопределен);
– каждый ключ должен быть хэшируемым (для хеширования применяется функция hash) в противном случае возникнет исключение TypeError;
– для ключей нет определённого порядка.
Научим основам Python и Data Science на практике
Это не обычный теоритический курс, а онлайн-тренажер, с практикой на примерах рабочих задач, в котором вы можете учиться в любое удобное время 24/7. Вы получите реальный опыт, разрабатывая качественный код и анализируя реальные данные.
Реализация словаря в Python 2.7
В этой статье пойдёт речь о том, как реализован словарь в Python. Я постараюсь ответить на вопрос, почему элементы словаря не упорядочены, описать, каким образом словари хранят, добавляют и удаляют свои элементы. Надеюсь, что статья будет полезна не только людям, изучающим Python, но и всем, кто интересуется внутренним устройством и организацией структур данных.
На написание этой статьи меня натолкнул вопрос на Stack Overflow.
В статье рассматривается реализация CPython версии 2.7. Все примеры были подготовлены в 32-битной версии Python на 64-битной ОС, для другой версии значения будут отличаться.
Словарь в Python
Словарь в Python является ассоциативным массивом, то есть хранит данные в виде пар (ключ, значение). Словарь – измененяемый тип данных. Это значит, что в него можно добавлять элементы, изменять их и удалять из словаря. Он также предоставляет операцию поиска и возвращения элемента по ключу.
Инициализация и добавление элементов:
Ключами словаря могут быть значения только hashable типов, то есть типов, у которых может быть получен хэш (для этого у них должен быть метод __hash__()). Поэтому значения таких типов, как список (list), набор (set) и собственно сам словарь (dict), не могут быть ключами словаря:
Реализация
Словарь в Python реализован в виде хэш-таблицы. Как известно, реализация хэш-таблицы должна учитывать возможность появления коллизий – ситуаций, когда разные ключи имеют одинаковое значение хэша. Должен быть способ вставки и извлечения элементов с учётом коллизий. Существует несколько способов разрешения коллизий, например метод цепочек и метод открытой адресации. В Python используется метод открытой адресации. Разработчики предпочли метод открытой адресации методу цепочек ввиду того, что он позволяет значительно сэкономить память на хранении указателей, которые используются в хэш-таблицах с цепочками.
В рассматриваемой реализации каждая запись (PyDictEntry) в хэш-таблице словаря состоит из трёх элементов – хэш, ключ и значение.
Структура PyDictObject выглядит следующим образом:
При создании нового объекта словаря его размер равен 8. Это значение определяется константой PyDict_MINSIZE. Для хранения хэш-таблицы в PyDictObject были введены переменные ma_smalltable и ma_table. Переменная ma_smalltable с предвыделенной памятью размером PyDict_MINSIZE (то есть 
Если кому-то вдруг захочется изменить значение PyDict_MINSIZE, это можно сделать в исходниках, а затем пересобрать Python. Значение рекомендуется делать равным степени двойки, но не меньше четырёх.
Ячейка в хэш-таблице может иметь три состояния
Переменные-члены структуры
Основные тонкости
Эффективность хэш-таблицы зависит от наличия «хороших» хэш-функций. «Хорошая» хэш-функция должна вычисляться быстро и с минимальным количеством коллизий. Но, к сожалению, наиболее часто используемые и важные хэш-функции (для строкового и целого типов) возвращают достаточно регулярные значения, что приводит к коллизиям. Возьмём пример:
Для целых чисел хэшами являются их значения, поэтому подряд идущие числа будут иметь подряд идущие хэши, а для строк подряд идущие строки имеют практически подряд идущие хэши. Это не обязательно плохо, напротив, в таблице размером 2**i взятие i младших бит хэша как начального индекса таблицы выполняется очень быстро, и для словарей, проиндексированных последовательностью целых чисел, коллизий не будет вообще:
То же самое будет почти полностью соблюдаться, если ключи словаря – это «подряд идущие» строки (как в примере выше). В общих случаях это дает более чем случайное поведение, что и требуется.
Взятие только i младших бит хэша в качестве индекса также уязвимо к коллизиям: например, возьмём список [i 011 2 & 111 2 = 011 2 = 3
После того как индекс рассчитан, выполняется проверка ячейки по индексу, и если она «неиспользованная», то в неё добавляется запись (хэш, ключ, значение). Но если эта ячейка занята, из-за того, что другая запись имеет тот же хэш (то есть произошла коллизия), выполняется сравнение хэша и ключа вставляемой записи и записи в ячейке. Если хэш и ключ для записей совпадают, то считается, что запись уже существует в словаре, и выполняется её обновление.
Это объясняет хитрый момент, связанный с добавлением равных по значению, но разных по типу ключей (к примеру, float, int и complex):
То есть тот тип, который был добавлен в словарь первым, и будет типом ключа, несмотря на обновление. Это объясняется тем, что реализация хэша для float значений возвращает хэш от int, если дробная часть равна 0.0. Пример расчёта хэша для float, переписанный на Python:
А хэш от complex возвращает хэш от float. В данном случае возвращается хэш только действительной части, так как мнимая часть равна нулю:
Ввиду того, что и хэши, и значения для всех трёх типов равны, выполняется простое обновление значения найденной записи.
Замечание по поводу добавления элементов: Python запрещает добавление элементов в словарь во время итерации, поэтому при попытке добавить новый элемент произойдёт ошибка:
Получается, что индексы добавляемых в словарь элементов зависят от уже находящихся в нём элементов, и порядок ключей для двух словарей, состоящих из одного и того же набора пар (ключ, значение), может быть разным и определяется очерёдностью добавления элементов:
Это объясняет, почему словари в Python при выводе содержимого отображают хранимые пары (ключ, значение) не в порядке их добавления в словарь. Словари выводят их в порядке расположения в хэш-таблице (то есть в порядке индексов).
Произошла вставка по индексу 5. Переменные ma_fill и ma_used стали равны 1.
Произошла вставка по индексу 0. Переменные ma_fill и ma_used стали равны 2.
Произошла вставка по индексу 4. Переменные ma_fill и ma_used стали равны 3.
Произошла вставка по индексу 1. Переменные ma_fill и ma_used стали равны 4.
Пробуем добавить шестой элемент в словарь.
После добавления шестого элемента таблица будет заполнена на 2/3, а соответственно, произойдёт изменение её размера. После того как размер изменится (в данном случае увеличится в 4 раза), произойдёт полная перестройка хэш-таблицы с учётом нового размера – все «активные» ячейки будут перераспределены, а «пустые» и «неиспользованные» ячейки будут проигнорированы.
Размер хэш-таблицы теперь равен 32, а переменные ma_fill и ma_used стали равны 6. Как видно, порядок элементов полностью поменялся:
Поиск элемента
Поиск записи в хэш-таблице словаря происходит аналогично, начинаясь с ячейки i, где i зависит от хэша ключа. Если хэш и ключ не совпадают с хэшом и ключом найденной записи (то есть была коллизия и надо найти подходящую запись) – начинается пробирование, которое продолжается, пока не будет найдена запись, для которой хэш и ключ совпадают. Если же все ячейки были просмотрены, но запись не найдена, возвращается ошибка.
Коэффициент заполнения хэш-таблицы
Размер таблицы изменяется, когда она заполняется на 2/3. То есть для начального размера хэш-таблицы словаря, равного 8, изменение произойдёт после того, как будет добавлен 6 элемент.
При этом происходит перестройка хэш-таблицы с учётом её нового размера, а соответственно, меняются и индексы всех записей.
Значение 2/3 от размера было выбрано как оптимальное, для того чтобы пробирование не занимало слишком много времени, то есть вставка новой записи происходила быстро. Увеличение этого значения приводит к тому, что словарь плотнее заполняется записями, что в свою очередь увеличивает количество коллизий. Уменьшение же увеличивает разреженность записей в ущерб увеличения занимаемых ими кэш-линий процессора и в ущерб увеличения общего объема памяти. Проверка заполнения таблицы происходит в весьма чувствительном ко времени участке кода. Попытки сделать проверку более сложной (например, изменяя коэффициент заполнения для разных размеров хэш-таблицы) уменьшали производительность.
Проверить, что размер словаря действительно изменяется, можно так:
В примере возвращается размер всего объекта PyDictObject для 64-битной версии ОС.
Начальный размер таблицы равен 8. Таким образом, когда число заполненных ячеек будет равно 6 (то есть больше 2/3 от размера), размер таблицы увеличится до 32. Затем, когда число будет равно 22, увеличится до 128. При 86 увеличится до 512, при 342 – до 2048 и так далее.
Коэффициент увеличения размера таблицы
Коэффициент увеличения размера таблицы при достижении максимального уровня заполнения равен 4, если размер таблицы меньше 50000 элементов, и 2 – для таблиц большего размера. Такой подход может быть полезен приложениям с ограничениями по памяти.
Увеличение размера таблицы улучшает среднюю разреженность, то есть разбросанность записей по таблице словаря (уменьшая коллизии), за счёт увеличения объема потребляемой памяти и скорости итераций, а также уменьшает количество дорогостоящих операций выделения памяти при изменении размера для растущего словаря.
Обычно добавление элемента в словарь может увеличить его размер в 4 или 2 раза в зависимости от текущего размера словаря, но также возможно, что размер словаря уменьшится. Такая ситуация может произойти, если ma_fill (количество ненулевых ключей, сумма «активных» и «пустых» ячеек) намного больше ma_used (количество «активных» ячеек), то есть много ключей было удалено.
Удаление элемента
При удалении элемента из словаря вызывается функция PyDict_DelItem.
Удаление из словаря происходит по ключу, хотя в действительности освобождения памяти не происходит. В этой функции вычисляется хэш значение ключа, а затем происходит поиск записи в хэш-таблице с использованием всё той же функции lookdict_string или lookdict. В случае если запись с таким ключом и хэшем найдена, ключ этой записи выставляется в состояние «пустой» (то есть me_key = dummy), а значение записи – в NULL (me_value = NULL). После этого переменная ma_used уменьшится на единицу, а ma_fill останется без изменения. Если же запись не найдена, возвращается ошибка.
После удаления переменная ma_used стала равна 4, а ma_fill осталась равной 5, так как ячейка не была удалена, а всего лишь была помечена как «пустая» и продолжает занимать ячейку в хэш-таблице.
Рандомизация хэшей
Словари в Python 3 — основные методы и функции
В Python есть много встроенных структур данных, используемых для хранения разных типов информации. Словарь ( dict ) — одна из таких структур, которая хранит данные в формате пар ключ-значение. Получить доступ к значениям словаря Python можно с помощью ключей. Этот материал посвящен подробному обсуждению словаря.
Создание словаря
Значения могут быть представлять собой любые типы данных и повторяться, но ключи обязаны быть уникальными.
Следующие примеры показывают, как создавать словари Python:
Создание пустого словаря:
Cловарь, где ключи являются целыми числами:
Создание словаря с ключами разных типов:
Можно также создать словарь, явно вызвав метод dict() :
Словарь можно создать с помощью последовательности, как в примере внизу:
Словари могут быть вложенными. Это значит, что можно создавать словари внутри существующего словаря. Например:
Чтобы вывести содержимое словаря, можно использовать функцию print() и передать название словаря в качестве аргумента. Например:
Доступ к элементами
Теперь вы знаете, как получать доступ к элементам словаря с помощью разных методов. В следующем разделе речь пойдет о добавлении новых элементов в уже существующий словарь.
Добавление элементов
Существует множество способов для добавления новых элементов в словарь. Можно использовать новый ключ и присвоить ему значение. Например:
Вот другой пример. Для начала нужно создать пустой словарь:
Словарь ничего не возвращает, потому что в нем ничего не хранится. Добавим в нему элементы, один за одним:
Для добавления элементов были отдельно указаны ключи и соответствующие значения. Например:
В этом примере 0 является ключом, а «Apples» — значение.
Можно даже добавить несколько значений для одного ключа. Например:
Помимо добавления новых элементов в словарь, их можно обновлять или изменять. Об этом в следующем разделе.
Обновление элементов
После добавления значения в словарь существующий элемент словаря можно изменить. Для изменения значения используется соответствующий ключ. Например:
Удаление элементов
Удалить элемент из словаря можно несколькими способами. В этом разделе они будут рассмотрены по одному:
Ключевое слово del можно использовать для удаления элемента с конкретным ключом. Например:
Другой способ удалить пару ключ-значение — функция pop() с ключом записи в виде аргумента. Например:
Функция popitem() удаляет последний элемент в словаре. Для нее не нужно указывать конкретный ключ. Примеры:
Что делать, если нужно удалить целый словарь? Это будет сложно и займет много времени, если пользоваться этими методами к каждому ключу. Вместо этого можно использовать ключевое слово del для целого словаря. Например:
Код вернет ошибку, потому что функция print() пытается получить доступ к словарю, который уже не существует.
В определенных случаях может потребоваться удалить все элементы словаря, оставив его пустым. Этого можно добиться, воспользовавшись функцией clear() :
Код вернет пустой словарь, поскольку все его элементы уже удалены.
Другие распространенные методы словарей
Метод len()
С помощью этого метода можно посчитать количество элементов в словаре. Например:
В этом словаре три записи, поэтому метод вернет 3.
Метод copy()
Этот метод возвращает копию существующего словаря. Например:
Это удобно, потому что изменения в скопированном словаре не затрагивают оригинальный словарь.
Метод items()
Этот метод возвращает итерируемый объект. Такой объект содержит пары ключ-значение для словаря по аналогии с кортежами в списке. Метод используется, когда нужно перебрать значения словаря.
Этот метод нужно вызывать вместе со словарем, как в примере ниже:
Вывод демонстрирует, что когда вы меняете значение в словаре, объекты элементов также обновляются.
Метод fromkeys()
Этот метод возвращает словарь с указанными ключами и значениями. У него следующий синтаксис:
Предположим, что нужно создать словарь с тремя ключами и одинаковым значением. Это можно сделать следующим образом:
В коде вверху определены ключи и одно значение. Метод fromkeys() перебирает ключи и объединяет их со значением для создания заполненного словаря.
Значение для параметра keys является обязательным. В следующем примере показано, что происходит, если параметр values не определен:
Метод setdefault()
Этот метод используется, когда нужно получить значение элемента с конкретным ключом. Если ключ не найден, он будет вставлен в словарь вместе с указанным значением.
У метода следующий синтаксис:
Следующий пример показывает, как работает метод, если такой ключ уже есть:
Значение «Allion» не повлияло на словарь, потому что у ключа уже есть значение.
Метод keys()
Для использования метода нужно всего лишь использовать его с именем словаря, как показано ниже:
Часто этот метод используется, чтобы перебрать все ключи в словаре:
Выводы
Это все, что нужно знать о словарях Python. Они хранят информацию в парах «ключ: значение». «Ключ» выступает идентификатором объекта, а «значение» — это определенные данные. В Python много функций, которые могут быть использовать для извлечения и обработки данных. В этой статье были рассмотрены способы создания, изменения и удаления словаря, а также самые распространенные методы для работы с этим типом данных.
Под капотом у Dictionary и ConcurrentDictionary
Итак, поехали.
Если вы уже знакомы с самим Dictionary, то можете пропустить следующий раздел.
Dictionary представляет собой реализацию стандартной Hashtable.
Здесь интересны следующие функции:
Инициализация
Инициализация происходит либо при создании (если передана начальный размер коллекции), либо при добавлении первого элемента, причем в качестве размера будет выбрано ближайшее простое число (3). При этом создаются 2 внутренние коллекции — int[] buckets и Entry[] entries. Первая будет содержать индексы элементов во второй коллекции, а она, в свою очередь, — сами элементы в таком виде:
Добавление элементов
При добавлении элемента вычисляется хэшкод его ключа и затем — индекс корзины в которую он будет добавлен по модулю от величины коллекции:
Выглядеть это будет примерно так:
Затем проверяется нет ли уже такого ключа в коллекции, если есть — то операция Add выбросит исключение, а присваивание по индексу просто заменит элемент на новый. Если достигнут максимальный размер словаря, то происходит расширение (выбирается новый размер ближайшим простым числом).
Сложность оперции соответственно — O(n).
Если происходит коллизия (то есть в корзине с индексов bucketNum уже есть элемент), то новый элемент добавляется в коллекцию, его индекс сохраняется в корзине, а индекс старого элемента — в его поле next.
Таким образом получаем однонаправленный связный список. Данный механизм разрешения коллизий называется chaining. Если при добавлении элемента число коллизий велико (больше 100 в текущей версии), то при расширении коллекции происходит операция перехэширования, перед выполнением которой случайным образом выбирается новый генератор хэшкодов.
Сложность добавления O(1) или O(n) в случае коллизии.
Удаление элементов
При удалении элементов мы затираем его содержимое значениями по умолчанию, меняем указатели next других элементов при неоходимости и сохраняем индекс этого элемента во внутреннее поле freeList, а старое значение — в поле next. Таким образом, при добавлении нового элемента мы можем повторно использовать такие свободные ячейки:
Сложность снова O(1) или O(n) в случае коллизии.
Другое
Так же стоит отметить 2 момента:
1) При очистке словаря, его внутренний размер не изменяется. То есть, потенциально, вы просто тратите место.
2) GetEnumerator просто возвращает итератор по коллекции entires (сложность O(1)). Если вы только добавляли элементы — они вернутся в том же порядке. Однако если вы удаляли элементы и добавляли новые — порядок соответственно изменится, поэтому на него полагаться не стоит (тем более, что в будущих версиях фреймворка это может измениться).
Так и что же с ConcurrentDictionary?
Казалось бы, есть 2 решения в лоб для обеспечения потокобезопасности — обернуть все обращения к словарю в блокировки или обернуть все его методы в них же. Однако, по понятным причинам, такое решение будет медленным — задержки, добавляемые lock, да и ограничение на 1 поток, который мог бы работать с коллекцией не добавляют быстродействия.
В Microsoft пошли более оптимальным путем и Dictionary притерпел некоторые изменения. Так, благодаря внутренней структуре словаря и его корзин, блокировка осуществляется по ним, с помощью метода
В то же время обычный словарь не смог бы работать с этой схемой, потому что все корзины используют один и тот же массив entries, поэтому корзины стали представлять собой обычный single linked list: volatile Entry[] m_buckets (поле объявлено как volitale, чтобы обеспечить неблокирующую синхронизацию в ситуации когда один поток пытается выполнить какую-то операцию, а другой в этот момент изменяет размер коллекции).
В итоге корзины стали выглядеть вот так:
lockNo — это индекс в новом массиве, который содержит объекты синхронизации — object[] m_locks. Его использование позволяет разным потокам изменять разные корзины в одно и то же время. Размер этой коллекции зависит от параметра ConcurrencyLevel который можно задать через конструктор. Он определяет примерное число потоков которые будут одновременное работать с коллекцией (по умолчанию это число_процессоров * 4). Чем выше это значение, тем проще будут происходить операции записи, но так же станут гораздо дороже операции, которые требуют полной блокировки всей коллекции (Resize, ToArray, Count, IsEmpty, Keys, Values, CopyTo, Clear). Также этот параметр определяет сколько элементов коллекции приходится на один lock (как отношение числа корзин к размеру этой коллекции) и когда элементов становится больше, чем надо — коллекция расширяется, потому что в противном случае поиск элемента требует не O(1), а уже O(n) за счет обхода связных списков. Чтобы немного снизить число первоначальных расширений коллекции, начальный размер словаря уже не 3, а 31.
Все операции приобрели вид:
При добавлении нового элемента приходится обойти весь связанный список просто чтобы определить есть ли уже такой ключ и если нет — добавить. Аналогично при удалении — сперва надо найти узел который удалить.
Впрочем, для некоторых операций блокировка не нужна в принципе — это TryGetValue, GetEnumerator, ContainsKey и индексация. Почему? Потому что все изменения размера корзин видны за счет того что поле volatile, любые добавления или изменения элементов происходят путем создания нового элемента и замены им старого, а удаления происходят просто заменой указателя на следующий узел.
Другое
лучше использовать dictionary.Select(x => x.Value).ToArray(), чем dictionary.Values.ToArray()
И это не совсем верно — вместо «лучше» тут должно быть «быстрее» — за счет отсутствия блокировок, но надо принять во внимание, что данные подходы могут вернуть разные данные.

