Книга: Чистый Python. Тонкости программирования для профи
Назад: 6. Циклы и итерации
Дальше: 8. Питоновские методы повышения производительности

7. Трюки со словарем

7.1. Значения словаря, принимаемые по умолчанию

У словарей Python есть метод get() для поиска ключа, которому передают запасное значение. Это может пригодиться в самых разных ситуациях. Приведу простой пример, который покажет, что я имею в виду. Предположим, что у нас есть представленная ниже структура данных, которая ставит идентификаторы в соответствие именам пользователей:

name_for_userid = {

     382: 'Элис',

     950: 'Боб',

     590: 'Дилберт',

}

Теперь мы хотели бы использовать эту структуру данных, чтобы написать функцию greeting(), которая будет возвращать пользователю приветствие на основе его идентификатора. Наша первая реализация может выглядеть примерно так:

def greeting(userid):

     return 'Привет, %s!' % name_for_userid[userid]

В ней представлен прямолинейный поиск в словаре. Это первая реализация технически работает — но только если идентификатор пользователя является допустимым ключом в словаре name_for_userid. Если в функцию greeting передать недопустимый идентификатор пользователя, то она вызовет исключение:

>>> greeting(382)

'Привет, Элис!'

 

>>> greeting(33333333)

KeyError: 33333333

Исключение KeyError — это совсем не тот результат, который мы хотели бы видеть. Было бы намного лучше, если бы в качестве запасного варианта функция возвращала универсальное приветствие, если идентификатор пользователя не может быть найден.

Давайте реализуем эту идею. Наш первый подход мог бы заключаться в простой проверке принадлежности в формате ключ в словаре (key in dict) и возврате приветствия по умолчанию, если идентификатор пользователя неизвестен:

def greeting(userid):

     if userid in name_for_userid:

         return 'Привет, %s!' % name_for_userid[userid]

     else:

         return 'Привет всем!'

Давайте посмотрим, как эта реализация функции greeting() проявит себя с нашими предыдущими тестовыми случаями:

>>> greeting(382)

'Привет, Элис!'

 

>>> greeting(33333333)

'Привет всем!'

Намного лучше. Мы теперь получаем универсальное приветствие для неизвестных пользователей, и мы поддерживаем персонализированное приветствие, когда допустимый идентификатор пользователя найден.

Но все равно есть простор для совершенствования. Несмотря на то что эта новая реализация дает нам ожидаемые результаты и выглядит небольшой и чистой, ее все еще можно улучшить. К этому подходу у меня есть несколько претензий:

• он неэффективен, потому что он опрашивает словарь дважды;

• он многословен, поскольку, например, часть строки с приветствием повторяется;

• он не является питоновским — официальная документация Python, в частности, для таких ситуаций рекомендует использовать стиль программирования «легче попросить прощения, чем разрешения» (EAFP):

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

Более эффективная реализация, которая следует принципам EAFP, могла бы вместо выполнения явной проверки на принадлежность ключа словарю задействовать блок tryexcept, чтобы поймать исключение KeyError:

def greeting(userid):

     try:

         return 'Привет, %s!' % name_for_userid[userid]

     except KeyError:

         return 'Привет всем'

Эта реализация по-прежнему верна в том, что касается наших первоначальных требований, и теперь мы устранили необходимость опрашивать словарь дважды.

Но мы до сих пор можем улучшить ее и предложить более чистое решение. В словаре Python есть метод get(), поддерживающий параметр «по умолчанию», который можно использовать в качестве запасного значения:

def greeting(userid):

     return 'Привет, %s!' % name_for_userid.get(

         userid, 'всем')

Во время вызова метода get() он проверяет, существует ли заданный ключ в словаре. Если это так, то возвращается значение, соответствующее этому ключу. Если же он не существует, то вместо этого возвращается значение по умолчанию. Как вы видите, эта реализация функции greeting по-прежнему работает как надо:

>>> greeting(950)

'Привет, Боб!'

 

>>> greeting(333333)

'Привет, всем!'

Наша заключительная реализация функции greeting() является сжатой, чистой и использует средства только из стандартной библиотеки Python. Поэтому убежден, что для этой конкретной ситуации такое решение является наилучшим.

Ключевые выводы

• Во время проверки принадлежности ключа словарю избегайте явных проверок в формате ключ в словаре.

• Предпочтительной является обработка исключений в стиле EAFP или использование встроенного метода get().

• В некоторых случаях класс collections.defaultdict из стандартной библиотеки также может оказаться полезным.

7.2. Сортировка словарей для дела и веселья

В словарях Python нет внутренней упорядоченности ключей. Можно без проблем выполнять обход словарей, но при этом нет никакой гарантии, что итерация возвращает элементы словаря в каком-то определенном порядке следования (хотя, начиная с Python 3.6, это и меняется).

Однако очень часто полезно получить сортированное представление (sorted representation) словаря, поместив элементы словаря в произвольном порядке на основе их ключа, значения или иного производного свойства. Предположим, что у вас есть словарь xs со следующими парами ключ-значение:

>>> xs = {'a': 4, 'c': 2, 'b': 3, 'd': 1}

Чтобы получить сортированный список пар ключ-значение в этом словаре, вы можете применить метод items() словаря и затем отсортировать результирующую последовательность на втором обходе:

>>> sorted(xs.items())

[('a', 4), ('b', 3), ('c', 2), ('d', 1)]

Кортежи ключ-значение упорядочены с использованием стандартного лексикографического упорядочивания Python для сравнения последовательностей.

Чтобы сравнить два кортежа, Python сначала сравнивает элементы, хранящиеся в индексной позиции 0. Если они различаются, то он определяет исход сравнения. Если они равны, то сравниваются следующие два элемента в индексной позиции 1, и т.д.

Так вот, поскольку мы взяли эти кортежи из словаря, в каждом кортеже все значения в нулевой индексной позиции, бывшие ранее ключами словаря, являются уникальными. Поэтому здесь не придется решать проблемы с повторами.

В некоторых случаях лексикографическое упорядочивание может быть именно тем, что вам нужно. В других случаях, возможно, вместо этого стоит выполнить сортировку словаря по значению.

К счастью, есть способ взять полный контроль над тем, как упорядочиваются элементы. Вы можете управлять порядком их следования путем передачи функции ключа во встроенную функцию sorted(), которая изменит то, как будут сравниваться элементы словаря.

Функция ключа — это просто обычная функция Python, которая будет вызываться с каждым элементом перед тем, как делать сравнения. Функция ключа на входе получает элемент словаря, а на выходе возвращает требуемый «ключ» для сравнения порядка следования элементов.

К сожалению, слово «ключ» здесь используется в двух контекстах одновременно: функция ключа не касается ключей словаря, она просто отмечает каждый входной элемент произвольным ключом сравнения.

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

Допустим, вы хотите получить отсортированное представление словаря на основе его значений. Чтобы получить этот результат, вы можете использовать следующую ниже функцию ключа, которая возвращает значение каждой пары ключ-значение путем поиска второго элемента в кортеже:

>>> sorted(xs.items(), key=lambda x: x[1])

[('d', 1), ('c', 2), ('b', 3), ('a', 4)]

Видите, как теперь результирующий список пар ключ-значение отсортирован по значениям, хранящимся в оригинальном словаре? Чтобы осмыслить принцип работы функции ключа, стоит потратить немного времени. Этот мощный принцип можно применять во всех видах контекстов Python.

На самом деле этот принцип настолько распространен, что стандартная библиотека Python включает модуль operator. Этот модуль реализует часть наиболее часто используемых функций ключа в качестве структурных блоков, автоматически конфигурируемых по принципу plug-and-play, таких как operator.itemgetter и operator.attrgetter.

Ниже приведен пример того, как можно заменить поиск по индексу на основе лямбды в первом примере на operator.itemgetter:

>>> import operator

>>> sorted(xs.items(), key=operator.itemgetter(1))

[('d', 1), ('c', 2), ('b', 3), ('a', 4)]

Использование модуля operator в некоторых случаях помогает яснее передавать замысел вашего программного кода. С другой стороны, простое лямбда-выражение может быть столь же удобочитаемым и более очевидным. В этом конкретном случае я предпочитаю лямбда-выражение.

Еще одна выгода от использования лямбд в качестве собственной функции ключа состоит в том, что вам удается управлять порядком сортировки гораздо детальнее. Например, вы можете отсортировать словарь на основе абсолютной числовой величины каждого хранящегося в нем значения:

>>> sorted(xs.items(), key=lambda x: abs(x[1]))

Если вам нужно инвертировать порядок сортировки так, чтобы более крупные значения шли вначале, то во время вызова sorted() вы можете применить именованный аргумент reverse=True:

>>> sorted(xs.items(),

            key=lambda x: x[1],

            reverse=True)

[('a', 4), ('b', 3), ('c', 2), ('d', 1)]

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

Ключевые выводы

• Создавая сортированные «представления» словарей и другие коллекции, вы можете влиять на порядок сортировки при помощи функции ключа.

• Функции ключа являются в Python важным принципом. Наиболее часто используемые из них были даже добавлены в модуль operator стандартной библиотеки.

• В Python функции являются объектами первого класса. Вы обнаружите, что это мощное средство языка применяется повсюду.

7.3. Имитация инструкций выбора на основе словарей

В Python нет инструкций выбора switch-case, поэтому иногда в качестве обходного пути возникает необходимость писать цепочки инструкций if…elif…else. В данном разделе вы узнаете прием, который сможете применять для имитации инструкций выбора switch-case в Python при помощи словарей и первоклассных функций. Звучит заманчиво? Отлично, тогда поехали!

Предположим, что в нашей программе есть такая цепочка инструкций if:

>>> if cond == 'cond_a':

...     handle_a()

... elif cond == 'cond_b':

...     handle_b()

... else:

...     handle_default()

В случае лишь трех разных условий это, конечно, не так страшно. Но представьте, если бы в этой инструкции у нас было десять или более ответвлений elif. Все стало бы выглядеть немного иначе. Я рассматриваю длинные цепочки инструкций if как код «с душком», который делает программы труднее для восприятия и в сопровождении.

Один из путей преодоления длинных инструкций if…elif…else состоит в их замене на таблицы поиска по словарю, которые имитируют поведение инструкций выбора switch-case.

Мы знаем, что в Python есть функции первого класса. А это означает, что их можно передавать в качестве аргументов в другие функции, возвращать в качестве значений из других функций, присваивать переменным и хранить в структурах данных.

Например, мы можем определить функцию, а затем сохранить ее в списке для доступа к ней в дальнейшем:

>>> def myfunc(a, b):

...     return a + b

...

>>> funcs = [myfunc]

>>> funcs[0]

<function myfunc at 0x107012230>

Синтаксис вызова этой функции работает именно так, как вы интуитивно ожидаете: мы просто обращаемся к списку по индексу и используем вызывной синтаксис «()», чтобы вызвать функцию и передать ей аргументы:

>>> funcs[0](2, 3)

5

Итак, каким же образом мы собираемся использовать функции первого класса, чтобы подрезать нашу цепочечную инструкцию if по размеру? Центральная идея здесь – определить словарь, отображающий ключи поиска входных условий на функции, которые выполнят предназначенные операции:

>>> func_dict = {

...     'cond_a': handle_a,

...     'cond_b': handle_b

... }

Вместо процеживания сквозь инструкции if, проверяя по ходу каждое условие, мы можем выполнить поиск ключа по словарю, чтобы получить функцию-обработчик, а затем вызвать ее:

>>> cond = 'cond_a'

>>> func_dict[cond]()

Эта реализация уже почти рабочая, по крайней мере, если условие cond можно найти в словаре. Если же его там нет, то мы получим исключение KeyError.

Давайте отыщем способ поддержки случая по умолчанию, который будет соответствовать исходному ответвлению else. К счастью, все словари Python располагают методом get(), который возвращает либо значение по заданному ключу, либо значение по умолчанию, если ключ не может быть найден. Это именно то, что нам здесь и нужно:

>>> func_dict.get(cond, handle_default)()

Поначалу этот фрагмент кода, возможно, будет выглядеть синтаксически странным, но когда вы разложите его по полочкам, то поймете, что он работает в точности как предыдущий пример. Опять-таки, мы используем функции Python первого класса, чтобы передать в поисковый метод get() функцию handle_default в качестве запасного значения. Благодаря этому, если условие в словаре не может быть найдено, мы избегаем вызова исключения KeyError и вместо него вызываем заданную по умолчанию функцию-обработчик.

Посмотрим на более законченный пример применения поиска по словарю и функций первого класса для замены цепочек инструкций if. После ознакомления с приведенным ниже примером вы сможете увидеть шаблон, необходимый для сведения определенных видов инструкций if к диспетчеризации на основе словаря.

Мы собираемся написать еще одну функцию с цепочкой инструкций if, которую затем преобразуем. Данная функция принимает строковый код операции, к примеру «add» или «mul», и затем выполняет соответствующие математические расчеты на операндах x и y:

>>> def dispatch_if(operator, x, y):

...     if operator == 'add':

...         return x + y

...     elif operator == 'sub':

...         return x — y

...     elif operator == 'mul':

...         return x * y

...     elif operator == 'div':

...         return x / y

Сказать по правде, это очередной игрушечный пример (не хотелось бы вам здесь докучать нескончаемыми страницами исходного кода), но он будет служить наглядной иллюстрацией лежащего в основе шаблона проектирования. Как только вы «въедете» в образец, то сможете его применять в самых разных сценариях.

Вы можете испытать функцию dispatch_if() на предмет выполнения простых вычислений, вызвав эту функцию со строковым кодом операции и двумя числовыми операндами:

>>> dispatch_if('mul', 2, 8)

16

>>> dispatch_if('неизвестно', 2, 8)

None

Обратите внимание на то, что 'неизвестный' случай срабатывает, потому что Python добавляет в конец любой функции неявную инструкцию return None.

Пока все неплохо. Теперь преобразуем первоначальную функцию dispatch_if() в новую функцию, использующую словарь для отображения кодов операций в арифметические операции с функциями первого класса:

>>> def dispatch_dict(operator, x, y):

...     return {

...         'add': lambda: x + y,

...         'sub': lambda: x — y,

...         'mul': lambda: x * y,

...         'div': lambda: x / y,

...     }.get(operator, lambda: None)()

Такая реализация на основе словаря дает те же самые результаты, что и первоначальная функция dispatch_if(). Мы можем вызвать обе функции точно таким же образом:

>>> dispatch_dict('mul', 2, 8)

16

>>> dispatch_dict('неизвестно', 2, 8)

None

Есть пара способов, которыми этот код можно усовершенствовать еще больше, если бы он был реален и предназначался для эксплуатации.

Во-первых, всякий раз, когда мы вызываем dispatch_dict(), он создает временный словарь и кучу лямбд для поиска кода операции. С точки зрения производительности это не идеально. В случае, если программный код нуждаетется в быстродействии, имеет больше смысла единожды создать словарь в качестве константы и затем ссылаться на него во время вызова функции. Не стоит воссоздавать словарь всякий раз, когда мы должны выполнить по нему поиск.

Во-вторых, если бы мы и правда захотели выполнить несколько простых арифметических операций типа x + y, то вместо используемых в этом примере лямбда-функций было бы гораздо лучше использовать встроенный модуль Python operator. Модуль operator предоставляет реализации всех операторов Python, в частности operator.mul, operator.div и т.д. Хотя эта деталь малозначительна. В этом примере лямбды использованы намеренно, чтобы сделать его более универсальным. Он должен помочь вам применять этот шаблон и в других ситуациях.

Итак, теперь у вас есть еще один инструмент в наборе хитрых приемов, который вы можете использовать для упрощения некоторых цепочек инструкций if на случай, если они будут становиться громоздкими. Просто запомните: этот прием применим не во всех ситуациях, и иногда будет лучше обойтись простой инструкцией if.

Ключевые выводы

• В Python нет инструкции выбора switch-case. Но в некоторых случаях вы можете избежать длинных цепочек инструкций if при помощи таблицы диспетчеризации на основе словаря.

• Функции первого класса Python в очередной раз доказывают, что они являются мощным инструментом. Но чем больше сила, тем больше ответственность.

7.4. Самое сумасшедшее выражение-словарь на западе

Иногда вы наталкиваетесь на крошечный пример кода, который обладает поистине неожиданной глубиной — одна-единственная строка кода, которая способна многому научить, если хорошенько над ней поразмыслить. Такой фрагмент код — это как коан в дзен-буддизме: вопрос или утверждение, используемое в практике дзен, чтобы вызвать сомнение и проверить достижения ученика.

Крошечный фрагмент кода, который мы обсудим в этом разделе, является одним из таких примеров. На первый взгляд он может выглядеть как прямолинейное выражение-словарь, но при ближайшем рассмотрении он отправляет вас в расширяющий сознание психоделический круиз по интерпретатору СPython.

От этого однострочника я получаю такой кайф, что как-то раз я даже напечатал его на своем значке участника конференции по Python в качестве повода для беседы. Это привело к нескольким конструктивным диалогам с участниками моей электронной рассылки по Python.

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

>>> {True: 'да', 1: 'нет', 1.0: 'возможно'}

Я подожду здесь…

О’кей, готовы?

Ниже показан результат, который мы получим при вычислении приведенного выше выражения-словаря в сеансе интерпретатора Python:

>>> {True: 'да', 1: 'нет', 1.0: 'возможно'}

{True: 'возможно'}

Признаюсь, когда увидел этот результат впервые, я был весьма ошарашен. Но все встанет на свои места, когда вы проведете неспешное пошаговое изучение того, что тут происходит. Давайте поразмыслим, почему мы получаем этот, надо сказать, весьма не интуитивный результат.

Когда Python обрабатывает наше выражение-словарь, он сначала строит новый пустой объект-словарь, а затем присваивает ему ключи и значения в том порядке, в каком они переданы в выражение-словарь.

Тогда, когда мы его разложим на части, наше выражение-словарь будет эквивалентно приведенной ниже последовательности инструкций, которые исполняются по порядку:

>>> xs = dict()

>>> xs[True] = 'да'

>>> xs[1] = 'нет'

>>> xs[1.0] = 'возможно'

Как ни странно, Python считает все ключи, используемые в этом примере словаря, эквивалентными:

>>> True == 1 == 1.0

True

Ладно, но погодите минуточку. Уверен, вы сможете интуитивно признать, что 1.0 == 1, но вот почему True считается также эквивалентным и 1? В первый раз, когда я увидел это выражение-словарь, оно действительно меня озадачило.

Немного покопавшись в документации Python, я узнал, что Python рассматривает тип bool как подкласс типа int. Именно так обстоит дело в Python 2 и Python 3:

Булев тип — это подтип целочисленного типа, и булевы значения ведут себя, соответственно, как значения 0 и 1 почти во всех контекстах, при этом исключением является то, что при преобразовании в строковый тип, соответственно, возвращаются строковые значения 'False' или 'True'.

И разумеется, это означает, что в Python булевы значения технически можно использовать в качестве индексов списка или кортежа:

>>> ['нет', 'да'][True]

'да'

Но вам, пожалуй, не следует использовать подобного рода логические переменные во имя ясности (и душевного здоровья ваших коллег).

Так или иначе, вернемся к нашему выражению-словарю.

Что касается языка Python, то все эти значения — True, 1 и 1.0 — представляют одинаковый ключ словаря. Когда интерпретатор вычисляет выражение-словарь, он неоднократно переписывает значение ключа True. Это объясняет, почему в самом конце результирующий словарь содержит всего один ключ.

Прежде чем мы пойдем дальше, взглянем еще раз на исходное выражение-словарь:

>>> {True: 'да', 1: 'нет', 1.0: 'возможно'}

{True: 'возможно'}

Почему здесь в качестве ключа мы по-прежнему получаем True? Разве не должен ключ из-за повторных присваиваний в самом конце тоже поменяться на 1.0?

После небольших изысканий в исходном коде интерпретатора Python я выяснил, что, когда с объектом-ключом ассоциируется новое значение, словари Python сам этот объект-ключ не обновляют:

>>> ys = {1.0: 'нет'}

>>> ys[True] = 'да'

>>> ys

{1.0: 'да'}

Безусловно, это имеет смысл в качестве оптимизации производительности: если ключи рассматриваются идентичными, то зачем тратить время на обновление оригинала?

В последнем примере вы видели, что первоначальный объект True как ключ никогда не заменяется. По этой причине строковое представление словаря по-прежнему печатает ключ как True (вместо 1 или 1.0).

С тем, что мы знаем теперь, по всей видимости, значения в результирующем словаре переписываются только потому, что сравнение всегда будет показывать их как эквивалентные друг другу. Вместе с тем оказывается, что этот эффект не является следствием проверки на эквивалентность методом __eq__ тоже.

Словари Python опираются на структуру данных хеш-таблица. Когда я впервые увидел это удивительное выражение-словарь, моя первая мысль заключалась в том, что такое поведение было как-то связано с хеш-конфликтами.

Дело в том, что хеш-таблица во внутреннем представлении хранит имеющиеся в ней ключи в различных «корзинах» в соответствии с хеш-значением каждого ключа. Хеш-значение выводится из ключа как числовое значение фиксированной длины, которое однозначно идентифицирует ключ.

Этот факт позволяет выполнять быстрые операции поиска. Намного быстрее отыскать числовое хеш-значение ключа в поисковой таблице, чем сравнивать полный объект-ключ со всеми другими ключами и выполнять проверку на эквивалентность.

Вместе с тем способы вычисления хеш-значений, как правило, не идеальны. И в конечном счете два или более ключа, которые на самом деле различаются, будут иметь одинаковое производное хеш-значение, и они в итоге окажутся в той же самой корзине поисковой таблицы.

Когда два ключа имеют одинаковое хеш-значение, такая ситуация на­зывается хеш-конфликтом и является особым случаем, с которым должны разбираться алгоритмы вставки и нахождения элементов в хеш-таблице.

Исходя из этой оценки, весьма вероятно, что хеширование как-то связано с неожиданным результатом, который мы получили из нашего выражения-словаря. Поэтому давайте выясним, играют ли хеш-значения ключей здесь тоже какую-то определенную роль.

Я определяю приведенный ниже класс как небольшой сыскной инструмент:

class AlwaysEquals:

     def __eq__(self, other):

         return True

 

    def __hash__(self):

         return id(self)

Этот класс характерен двумя аспектами.

Во-первых, поскольку дандер-метод __eq__ всегда возвращает True, все экземпляры этого класса притворяются, что они эквивалентны любому объекту:

>>> AlwaysEquals() == AlwaysEquals()

True

>>> AlwaysEquals() == 42

True

>>> AlwaysEquals() == 'штаа?'

True

И во-вторых, каждый экземпляр AlwaysEquals также будет возвращать уникальное хеш-значение, генерируемое встроенной функцией id():

>>> objects = [AlwaysEquals(),

               AlwaysEquals(),

               AlwaysEquals()]

>>> [hash(obj) for obj in objects]

[4574298968, 4574287912, 4574287072]

В Python функция id() возвращает адрес объекта в оперативной памяти, который гарантированно является уникальным.

При помощи этого класса теперь можно создавать объекты, которые притворяются, что они являются эквивалентными любому другому объекту, но при этом с ними будет связано уникальное хеш-значение. Это позволит проверить, переписываются ли ключи словаря, опираясь только на результат их сравнения на эквивалентность.

И, как вы видите, ключи в следующем ниже примере не переписываются, несмотря на то что сравнение всегда будет показывать их как эквивалентные друг другу:

>>> {AlwaysEquals(): 'да', AlwaysEquals(): 'нет'}

{ <AlwaysEquals object at 0x110a3c588>: 'да',   

  <AlwaysEquals object at 0x110a3cf98>: 'нет' }

Мы также можем взглянуть на эту идею с другой стороны и проверить, будет ли возврат одинакового хеш-значения достаточным основанием для того, чтобы заставить ключи быть переписанными:

class SameHash:

     def __hash__(self):

         return 1

Сравнение экземпляров класса SameHash будет показывать их как не эквивалентные друг другу, но они все будут обладать одинаковым хеш-значением, равным 1:

>>> a = SameHash()

>>> b = SameHash()

>>> a == b

False

>>> hash(a), hash(b)

(1, 1)

Давайте посмотрим, как словари Python реагируют, когда мы пытаемся использовать экземляры класса SameHash в качестве ключей словаря:

>>> {a: 'a', b: 'b'}

{ <SameHash instance at 0x7f7159020cb0>: 'a',

<SameHash instance at 0x7f7159020cf8>: 'b' }

Как показывает этот пример, эффект «ключи переписываются» вызывается не одними только конфликтами хеш-значений.

Словари выполняют проверку на эквивалентность и сравнивают хеш-значение, чтобы определить, являются ли два ключа одинаковыми. Попро­буем резюмировать результаты нашего исследования.

Выражение-словарь {True: 'да', 1: 'нет', 1.0: 'возможно'} вычисляется как {True: 'возможно'}, потому что сравнение всех ключей этого примера, True, 1, и 1.0, будет показывать их как эквивалентные друг другу, и они все имеют одинаковое хеш-значение:

>>> True == 1 == 1.0

True

>>> (hash(True), hash(1), hash(1.0))

(1, 1, 1)

Пожалуй, теперь уже не так удивительно, что мы получили именно такой результат в качестве конечного состояния словаря:

>>> {True: 'да', 1: 'нет', 1.0: 'возможно'}

{True: 'возможно'}

Здесь мы затронули много тем, и этот конкретный трюк Python поначалу может не укладываться в голове — вот почему в самом начале раздела я сравнил его с коаном в дзен.

Если вы с трудом понимаете, что происходит в этом разделе, попробуйте поэкспериментировать по очереди со всеми примерами кода в сеансе интерпретатора Python. Вы будете вознаграждены расширением своих познаний о внутренних механизмах языка Python.

Ключевые выводы

• Словари рассматривают ключи как идентичные, если результат их сравнения методом __eq__ говорит о том, что они эквивалентны, и если их хеш-значения одинаковы.

• Неожиданные конфликты ключей словаря могут и будут приводить к неожиданным результатам.

7.5. Так много способов объединить словари

Вы когда-нибудь конструировали систему конфигурации для одной из ваших программ Python? В таких системах принято принимать структуру данных с параметрами конфигурации, заданными по умолчанию, а затем предоставлять возможность селективно переопределять эти параметры на основе вводимых пользователем данных или некоторого другого источника конфигурации.

Я нередко использовал словари в качестве базовой структуры данных для представления ключей и значений конфигурации. И поэтому мне часто был нужен способ объединения, или слияния (merge), принятых по умолчанию параметров конфигурации с пользовательскими переопределениями в один-единственный словарь с окончательными значениями конфигурации.

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

В этом разделе я покажу несколько способов сделать это. Сначала посмотрим на простой пример, чтобы можно было что-то обсуждать. Предположим, что у вас имеется два исходных словаря:

>>> xs = {'a': 1, 'b': 2}

>>> ys = {'b': 3, 'c': 4}

И вы хотите создать новый словарь zs, который содержит все ключи и значения xs и все ключи и значения ys. Кроме того, если вы внимательно прочли этот пример, то вы заметили, что строка 'b' появляется в качестве ключа в обоих словарях, — нам также придется продумать стратегию разрешения конфликтов для повторяющихся ключей.

В Python классическое решение задачи «слияния многочисленных словарей» состоит в том, чтобы использовать встроенный в словарь метод update():

>>> zs = {}

>>> zs.update(xs)

>>> zs.update(ys)

Если вам любопытно, то наивная реализация функции update() могла бы выглядеть примерно следующим образом. Мы просто перебираем в цикле все элементы словаря с правой стороны и добавляем каждую пару ключ-значение в словарь с левой стороны, по ходу переписывая существующие ключи:

def update(dict1, dict2):

     for key, value in dict2.items():

         dict1[key] = value

В результате мы получим новый словарь zs, который теперь содержит ключи, определенные в xs и ys:

>>> zs

>>> {'c': 4, 'a': 1, 'b': 3}

Вы также увидите, что порядок, в котором мы вызываем update(), определяет то, как будут разрешаться конфликты. Выигрывает последнее обновление, и повторяющийся ключ 'b' ассоциируется со значением 3, которое поступило из ys, то есть второго исходного словаря.

Разумеется, вы можете расширить эту цепочку вызовов update() настолько, насколько захотите, для того, чтобы объединить любое количество словарей в один словарь. Такое практическое и удобочитаемое решение работает в Python 2 и в Python 3.

Еще один прием, который работает в Python 2 и в Python 3, использует встроенную функцию dict() совместно с оператором ** для «распаковки» объектов:

>>> zs = dict(xs, **ys)

>>> zs

{'a': 1, 'c': 4, 'b': 3}

Однако, как и в случае с повторными вызовами update(), этот подход работает только для слияния исключительно двух словарей и не может быть обобщен для объединения произвольного количества словарей за один шаг.

Начинания с Python 3.5, оператор ** стал гибче. Поэтому в Python 3.5+ есть еще один — и, пожалуй, более приятный — способ объединения произвольного количества словарей:

>>> zs = {**xs, **ys}

У этого выражения в точности такой же результат, что и у цепочки вызовов update(). Ключи и значения задаются в порядке слева направо, поэтому мы получаем ту же самую стратегию разрешения конфликтов: правая сторона имеет приоритет, а значение в ys переопределяет любое существующее значение под тем же самым ключом в xs. Это станет понятным, когда мы посмотрим на словарь, который является результатом этой операции слияния:

>>> zs

>>> {'c': 4, 'a': 1, 'b': 3}

Лично мне нравится краткость этой новой синтаксической конструкции и то, как она по-прежнему остается достаточно удобочитаемой. Всегда приходится находить равновесие между многословностью и краткостью, сохраняя программный код максимально удобочитаемым и легким в сопровождении.

В данном случае я склоняюсь к использованию нового синтаксиса при условии, что работаю с Python 3. Более того, при использовании оператора ** операция слияния выполняется быстрее, чем при использовании цепочки вызовов update(), что является еще одним преимуществом.

Ключевые выводы

• В Python 3.5 и выше для слияния многочисленных объектов-словарей в один можно использовать оператор ** с использованием одного-единственного выражения, переписывая существующие ключи слева направо.

• Чтобы оставить программный код совместимым с более ранними версиями Python, можно использовать встроенный в словарь метод update().

7.6. Структурная печать словаря

Вы когда-либо пытались выявить баг в одной из своих программ, усеивая ее кучей отладочных инструкций print, чтобы проследить поток исполнения? Или, возможно, вам приходилось генерировать диагностическое сообщение, чтобы выводить некоторые параметры конфигурации…

Я был разочарован, и часто, тем, насколько трудно в Python читать некоторые структуры данных, когда они печатаются как текстовые строки. Например, ниже приведен простой словарь. Он напечатан в сеансе интерпретатора, при этом порядок следования ключей произвольный и в результирующей строке отсутствует выделение отступами:

>>> mapping = {'a': 23, 'b': 42, 'c': 0xc0ffee}

>>> str(mapping)

{'b': 42, 'c': 12648430, 'a': 23}

К счастью, есть несколько простых в использовании альтернатив неразборчивому преобразованию в стиле to-string, дающих более удобочитаемый результат. Один из вариантов состоит в использовании встроенного модуля Python json. Чтобы выполнить структурную печать словаря с более приятным форматированием, можно применить функцию json.dumps():

>>> import json

>>> json.dumps(mapping, indent=4, sort_keys=True)

 

{

     "a": 23,

     "b": 42,

     "c": 12648430

}

Эти настройки конфигурации в результате получают хорошее и выделенное отступами строковое представление, которое к тому же нормализует порядок следования ключей словаря для оптимальной удобочитаемости.

Несмотря на то что это решение дает внешне красивый и удобочитаемый результат, оно не является идеальным. Печать словарей при помощи модуля json работает только со словарями, которые содержат примитивные типы, — вы столкнетесь с проблемой при попытке распечатать словарь, который содержит непримитивный тип данных, таких как функция:

>>> json.dumps({all: 'yup'})

TypeError: "keys must be a string"

Еще один недостаток использования функции json.dumps() состоит в том, что она не способна сериализовать составные типы данных, такие как множества:

>>> mapping['d'] = {1, 2, 3}

>>> json.dumps(mapping)

TypeError: "set([1, 2, 3]) is not JSON serializable"

Кроме того, вы можете столкнуться с такой проблемой, как представление текста в кодировке Юникод, — в некоторых случаях вы не сможете взять результат на выходе из json.dumps и скопипастить его в сеансе интерпретатора Python, чтобы реконструировать первоначальный объект-словарь.

Классическим решением задачи структурной печати объектов Python является встроенный модуль pprint. Приведем пример:

>>> import pprint

>>> pprint.pprint(mapping)

{'a': 23, 'b': 42, 'c': 12648430, 'd': set([1, 2, 3])}

Вы видите, что функция pprint способна печатать такие типы данных, как множества, и она также печатает ключи словаря в воспроизводимом порядке. По сравнению со стандартным строковым представлением словарей, здесь мы получаем то, что воспринимается значительно легче.

Вместе с тем, по сравнению с json.dumps(), она не представляет вложенные структуры визуально столь же хорошо. В зависимости от обстоятельств это может быть преимуществом или недостатком. Я иногда использую json.dumps(), чтобы выводить словари из-за улучшенной удобочитаемости и форматирования, но только если я уверен, что в них нет непримитивных типов данных.

Ключевые выводы

• В Python принятое по умолчанию преобразование объектов-словарей в строковое представление может оказаться трудночитаемым.

• Модули pprint и json представляют собой варианты «более высокого качества», встроенные в стандартную библиотеку Python.

• Будьте осторожны с использованием функции json.dumps() и непримитивных ключей и значений, поскольку это вызовет исключение TypeError.

См. глоссарий Python «EAFP»:

См. документацию Python «dict.get()»:

См. документацию Python «Иерархия стандартных типов»:

См. PEP 448 «Дополнительные обобщения распаковки»: /

Назад: 6. Циклы и итерации
Дальше: 8. Питоновские методы повышения производительности