Книга: Чистый Python. Тонкости программирования для профи
Назад: 5. Общие структуры данных Python
Дальше: 7. Трюки со словарем

6. Циклы и итерации

6.1. Написание питоновских циклов

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

Например, всякий раз, когда я вижу фрагмент кода, который выглядит, как показано ниже, сразу понимаю, что тут пытались программировать на Python так, будто это C или Java:

my_items = ['a', 'b', 'c']

 

i = 0 while i < len(my_items):

     print(my_items[i])

     i += 1

Итак, вы спрашиваете, что же такого непитоновского в этом фрагменте кода?

Две вещи.

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

И во-вторых, в коде используется функция len(), которая получает размер контейнера my_items, чтобы определить количество итераций.

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

Чтобы рефакторизовать первый пример кода, я начну с того, что удалю фрагмент, который вручную обновляет индекс. В Python лучше всего для этого применить цикл for. При помощи встроенной фабричной функции range() я могу генерировать индексы автоматически:

>>> range(len(my_items))

range(0, 3)

 

>>> list(range(0, 3))

[0, 1, 2]

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

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

for i in range(len(my_items)):

     print(my_items[i])

Уже лучше. Однако этот вариант по-прежнему выглядит не совсем по-питоновски и ощущается больше как итеративная Java-конструкция, а не как настоящий цикл Python. Когда вы видите программный код, в котором для итеративного обхода контейнера используется range(len(...)), его, как правило, можно еще больше упростить и улучшить.

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

for item in my_items:

     print(item)

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

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

Нужно сказать, что, конечно, вы не всегда будете в состоянии переписать свои циклы таким образом. А что, если, например, вам нужен индекс элемента?

Для таких случаев есть возможность писать циклы, которые поддерживают нарастающий индекс, избегая применения шаблона с range(len(...)), от которого я вас предостерег. Встроенная функция enumerate() поможет вам сделать подобного рода циклы безупречными и питоновскими:

>>> for i, item in enumerate(my_items):

...     print(f'{i}: {item}')

 

0: a

1: b

2: c

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

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

>>> emails = {

...     'Боб': '',

...     'Алиса': '',

... }

>>> for name, email in emails.items():

...     print(f'{name} -> {email}')

 

'Боб -> '

'Алиса -> '

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

for (int i = a; i < n; i += s) {

     // ...

}

Как этот шаблон перевести на Python? И снова на выручку приходит функция range() — она принимает необязательные параметры, которые управляют начальным значением (a), конечным значением (n) и размером шага (s) цикла. Перевод с Java на Python будет выглядеть так:

for i in range(a, n, s):     

    # ...

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

• Написание C-подобных циклов на Python считается непитоновским стилем. Если это возможно, следует избегать ручного управления индексами цикла и условиями остановки.

• Циклы for в Python в действительности являются циклами «for each», которые могут напрямую перебирать элементы контейнера или последовательности.

6.2. Осмысление включений

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

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

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

>>> squares = [x * x for x in range(10)]

В нем вычисляется квадрат всех чисел в списке от нуля до девяти:

>>> squares

[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Если бы вы хотели построить тот же самый список, использовав обыкновенный цикл for, то вы, вероятно, написали бы что-то типа этого:

>>> squares = []

>>> for x in range(10):

...     squares.append(x * x)

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

values = [expression for item in collection]

Приведенный выше «шаблон» включения в список эквивалентен представленному ниже обыкновенному циклу for:

values = []

for item in collection:

     values.append(expression)

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

Мы имеем типовой шаблон в стиле «формы для печенья», который вы можете применять ко многим циклам for. Этот шаблон предназначен для преобразования циклов в конструкцию включения в список, и наоборот. Нужно сказать, что есть еще одно полезное дополнение, которое мы должны внести в этот шаблон, а именно — фильтрация элементов по условиям.

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

>>> even_squares = [x * x for x in range(10)

                    if x % 2 == 0]

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

>>> even_squares [0, 4, 16, 36, 64]

Новое включение в список может быть преобразовано в эквивалентный цикл for аналогично первому примеру:

even_squares = [] for x in range(10):

     if x % 2 == 0:

         even_squares.append(x * x)

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

values = [expression

           for item in collection

           if condition]

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

values = [] for item in collection:

     if condition:

         values.append(expression)

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

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

Вот как выглядит включение в множество:

>>> { x * x for x in range(-9, 10) }

set([64, 1, 36, 0, 49, 9, 16, 81, 25, 4])

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

А вот включение в словарь:

>>> { x: x * x for x in range(5) }

{0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

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

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

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

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

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

• Помимо включений в список есть и другие виды включений.

6.3. Нарезки списков и суши-оператор

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

Приведу пример. В операции нарезки используется знакомый синтаксис индексации «[]» со следующим шаблоном "[начало:конец:шаг]»:

>>> lst = [1, 2, 3, 4, 5]

>>> lst

[1, 2, 3, 4, 5]

 

# lst[начало:конец:шаг]

>>> lst[1:3:1]

[2, 3]

Добавление индекса [1:3:1] вернуло срез оригинального списка, начиная с индекса 1 и заканчивая индексом 2, с размером шага, равным одному элементу. Чтобы избежать ошибок смещения на единицу, важно помнить, что верхняя граница всегда не учитывается. Именно поэтому в качестве подсписка из среза [1:3:1] мы получили [2, 3].

Если убрать размер шага, то он примет значение по умолчанию, равное единице:

>>> lst[1:3]

[2, 3]

С параметром шага, который также называется сдвигом (stride), можно делать другие интересные вещи. Например, можно создать подсписок, который включает каждый второй элемент оригинала:

>>> lst[::2]

[1, 3, 5]

Здорово, правда? Мне нравится называть оператор «:» суши-оператором. Выглядит как восхитительный маки-ролл, разрезанный пополам. Помимо того что он напоминает вкусное блюдо и получает доступ к диапазонам списка, у него есть еще несколько менее известных применений. Давайте покажу еще пару забавных и полезных трюков с нарезкой списка!

Вы только что увидели, как размер шага нарезки может использоваться для отбора каждого второго элемента списка. Ну хорошо. Вот вам еще хитрость: если запросить срез [::-1], то вы получите копию оригинального списка, только в обратном порядке:

>>> numbers[::-1]

[5, 4, 3, 2, 1]

Мы запросили Python дать нам весь список (::), но при этом чтобы он пробежался по всем элементам с конца в начало, назначив размер шага равным -1. Довольно ловко, но в большинстве случаев для того, чтобы инвертировать список, я по-прежнему придерживаюсь метода list.reverse() и встроенной функции reversed.

Вот другой трюк с нарезкой списка: оператор «:» можно использовать для удаления всех элементов из списка, не разрушая сам объект-список.

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

>>> lst = [1, 2, 3, 4, 5]

>>> del lst[:]

>>> lst

[]

Как видите, этот фрагмент удаляет все элементы из lst, но оставляет сам объект-список неповрежденным. В Python 3 для выполнения такой же работы также можно применить метод lst.clear(), который в зависимости от обстоятельств, возможно, будет более удобочитаемым шаблоном. Однако имейте в виду, что метод clear() отсутствует в Python 2.

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

>>> original_lst = lst

>>> lst[:] = [7, 8, 9]

>>> lst

[7, 8, 9]

>>> original_lst

[7, 8, 9]

>>> original_lst is lst

True

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

И еще один вариант использования суши-оператора — создание (мелких) копий существующих списков:

>>> copied_lst = lst[:]

>>> copied_lst

[7, 8, 9]

>>> copied_lst is lst

False

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

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

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

• Суши-оператор «:» полезен не только для отбора подсписков элементов внутри списка. Он также может использоваться для очистки, реверсирования и копирования списков.

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

6.4. Красивые итераторы

Мне нравится то, как синтаксис Python отличается своей красотой и ясностью от других языков программирования. Например, давайте возьмем скромный цикл for-in. Красота Python говорит сама за себя — вы можете прочитать приведенный ниже питоновский цикл, как если бы это было английское предложение:

numbers = [1, 2, 3]

for n in numbers:

     print(n)

Но как элегантные циклические конструкции Python работают за кадром? Каким образом этот цикл достает отдельные элементы из объекта, итерации по которому он выполняет? И как можно поддерживать одинаковый стиль программирования в собственных объектах Python?

Ответы на эти вопросы можно найти в протоколе итератора Python: объекты, которые поддерживают дандер-методы __iter__ и __next__, автоматически работают с циклами for-in.

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

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

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

Я свяжу все примеры с вопросом о цикле for-in, с которого мы начали этот раздел. И в его конце мы пробежимся по некоторым различиям, существующим между Python 2 и Python 3 относительно итераторов.

Готовы? Тогда, поехали!

Бесконечное повторение

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

В последующих нескольких абзацах мы собираемся реализовать класс, который мы назовем повторителем Repeater, итерации по которому можно выполнять в цикле for-in следующим образом:

repeater = Repeater('Привет')

for item in repeater:

     print(item)

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

Начиная реализацию, мы, прежде всего, определим и конкретизируем класс Repeater:

class Repeater:

     def __init__(self, value):

         self.value = value

 

     def __iter__(self):

         return RepeaterIterator(self)

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

Что за объект RepeaterIterator мы создаем и возвращаем из дандер-метода __iter__? Это вспомогательный класс, который нам нужно определить, чтобы заработал наш пример итераций в цикле for…in:

class RepeaterIterator:

     def __init__(self, source):

         self.source = source

    def __next__(self):

         return self.source.value

И снова, RepeaterIterator похож на прямолинейный класс Python, но, возможно, вам стоит принять во внимание следующие две вещи:

1. В методе __init__ мы связываем каждый экземпляр класса Repeater­Iterator с объектом Repeater, который его создал. Благодаря этому мы можем держаться за «исходный» объект, итерации по которому выполняются.

2. В RepeaterIterator.__next__ мы залезаем назад в «исходный» экземпляр класса Repeater и возвращаем связанное с ним значение.

В этом примере кода Repeater и RepeaterIterator работают вместе, чтобы поддерживать протокол итератора Python. Два определенных нами дандер-метода, __init__ и __next__, являются центральными в создании итерируемого объекта Python.

Мы рассмотрим ближе эти два метода и то, как они работают вместе, после того, как немного поэкспериментируем с кодом, который у нас есть сейчас.

Давайте подтвердим, что эта конфигурация с двумя классами действительно сделала объекты класса Repeater совместимыми с итерацией в цикле forin. Для этого мы сначала создадим экземпляр класса Repeater, который будет бесконечно возвращать строковый литерал 'Привет':

>>> repeater = Repeater('Привет')

И теперь попробуем выполнить итерации по объекту repeater в цикле for…in. Что произойдет, когда вы выполните приведенный ниже фрагмент кода?

>>> for item in repeater:

...     print(item)

Точно! Вы увидите, как на экране будет напечатано 'Привет'… много раз. Объект repeater продолжает возвращать то же самое строковое значение, и этот цикл никогда не завершится. Наша небольшая программа обречена печатать в консоли 'Привет' до бесконечности:

Привет

Привет

Привет

Привет

Привет

...

И тем не менее примите поздравления — вы только что написали работающий итератор на Python и применили его в цикле for…in. Этот цикл все еще не может завершиться… но пока что все идет неплохо!

Теперь мы разделим этот пример на части, чтобы понять, как методы __init__ и __next__ работают вместе, делая объект Python итерируемым.

Профессиональный совет: если вы выполнили предыдущий пример в сеансе Python REPL или в терминале и хотите его остановить, нажмите сочетание клавиш Ctrl + C несколько раз, чтобы выйти из бесконечного цикла.

Как циклы for-in работают в Python?

На данном этапе у нас есть класс Repeater, который, несомненно, поддерживает протокол итератора, и мы просто выполнили цикл for…in, чтобы это доказать:

repeater = Repeater('Привет')

for item in repeater:

     print(item)

Итак, что же этот цикл for…in в действительности делает за кадром? Как он контактирует с объектом repeater, чтобы доставать из него новые элементы?

Чтобы рассеять часть этого «волшебства», мы можем расширить цикл в слегка удлиненном фрагменте кода, который дает тот же самый результат:

repeater = Repeater('Привет')

iterator = repeater.__iter__()

while True:

     item = iterator.__next__()

     print(item)

Как видите, конструкция for…in была всего лишь синтаксическим сахаром для простого цикла while:

• Этот фрагмент кода сначала подготовил объект repeater к итерации, вызвав его метод __iter__. Он вернул фактический объект-итератор.

• После этого цикл неоднократно вызывал метод __next__ объекта-итератора, чтобы извлекать из него значения.

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

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

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

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

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

На самом деле в сеансе интерпретатора Python можно вручную «эмулировать» то, как цикл использует протокол итератора:

>>> repeater = Repeater('Привет')

>>> iterator = iter(repeater)

>>> next(iterator)

'Привет'

>>> next(iterator)

'Привет'

>>> next(iterator)

'Привет'

...

Этот фрагмент кода дает тот же самый результат — бесконечный поток приветствий. Всякий раз, когда вы вызываете next(), итератор снова выдает то же самое приветствие.

Между прочим, здесь я воспользовался возможностью замены вызовов __iter__ и __next__ на вызовы встроенных в Python функций iter() и next().

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

Python предлагает эти фасады также и для другой функциональности. Например, len(x) является краткой формой для вызова x.__len__. Точно так же вызов функции iter(x) вызывает метод x.__iter__, а вызов функции next(x) вызывает метод x.__next__.

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

Более простой класс-итератор

До этого момента наш пример итератора состоял из двух отдельных классов, Repeater и RepeaterIterator. Они соответствовали непосредственно двум фазам, используемым в протоколе итератора Python: сначала подготовке и получению объекта-итератора через вызов функции iter(), а затем неоднократной доставке из него значений через вызов функции next().

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

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

Помните, почему нам вновь потребовался класс RepeaterIterator? Он был нужен, чтобы принять метод __next__ для доставки новых значений из итератора. Но место определения метода __next__ вовсе не имеет никакого значения. В протоколе итератора имеет значение только то, что метод __iter__ возвращает любой объект с определенным на нем методом __next__.

Поэтому идея такая: RepeaterIterator без конца возвращает одинаковое значение, и он не должен отслеживать никакое внутреннее состояние. Что, если вместо этого добавить метод __next__ непосредственно в класс Repeater?

Тем самым мы смогли бы целиком избавиться от RepeaterIterator и реализовать итерируемый объект при помощи одного-единственного класса Python. Давайте попробуем! Наш пример с новым и упрощенным итератором выглядит так:

class Repeater:

     def __init__(self, value):

         self.value = value

     def __iter__(self):

         return self

     def __next__(self):

         return self.value

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

>>> repeater = Repeater('Привет')

>>> for item in repeater:

...     print(item)

 

Привет

Привет

Привет

...

В подобной оптимизации итератора на основе класса часто есть смысл. По сути, большинство пособий Python по итераторам начинается именно так. Но я всегда чувствовал, что объяснять итераторы одним-единственным классом с самого начала — значит скрывать основные принципы протокола итератора и по этой причине еще больше затруднять его понимание.

Кто же захочет без конца выполнять итерации

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

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

numbers = [1, 2, 3]

for n in numbers:

     print(n)

Вы вправе ожидать, что этот код выведет числа 1, 2 и 3, а затем остановится. И вероятно, вы не ожидаете, что он захламит окно вашего терминала, без устали выводя «3», пока вы в дикой панике не начнете жать на Ctrl+C

Пора узнать, как написать итератор, который в итоге прекращает генерировать новые значения вместо выполнения бесконечных итераций, потому что это именно то, что обычно делают объекты Python, когда мы используем их в цикле for…in.

Сейчас мы напишем еще один класс итератора, который назовем ограниченным повторителем BoundedRepeater. Он будет похож на наш предыдущий пример с повторителем Repeater, но на этот раз мы хотим, чтобы он останавливался после предопределенного количества повторений.

Давайте задумаемся. Как это сделать? Как итератор сигнализирует о том, что он пуст и исчерпал элементы, выдаваемые во время выполнения итераций? Возможно, вы думали: «Хм, можно вернуть None из метода __next__, и все».

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

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

>>> my_list = [1, 2, 3]

>>> iterator = iter(my_list)

>>> next(iterator)

1

>>> next(iterator)

2

>>> next(iterator)

3

А теперь осторожно! Мы употребили все три имеющихся в списке элемента. Следите за тем, что произойдет, если еще раз вызвать метод next итератора:

>>> next(iterator)

StopIteration

Ага! Чтобы подать сигнал о том, что мы исчерпали все имеющиеся в итераторе значения, он вызывает исключение StopIteration.

Все верно: итераторы используют исключения для структуризации потока управления. Чтобы подать сигнал о завершении итераций, итератор Python просто вызывает встроенное исключение StopIteration.

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

>>> next(iterator)

StopIteration

>>> next(iterator)

StopIteration

...

Итераторы Python обычно не могут быть «обнулены» — как только они исчерпаны, им полагается вызывать исключение StopIteration при каждом вызове их функции next(). Чтобы возобновить итерации, вам нужно запросить свежий объект-итератор при помощи функции iter().

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

class BoundedRepeater:

     def __init__(self, value, max_repeats):

         self.value = value

         self.max_repeats = max_repeats

         self.count = 0

    def __iter__(self):

         return self

    def __next__(self):

         if self.count >= self.max_repeats:

             raise StopIteration

         self.count += 1

         return self.value

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

>>> repeater = BoundedRepeater('Привет', 3)

>>> for item in repeater:

         print(item)

Привет

Привет

Привет

Если переписать этот последний пример цикла for…in, устранив часть синтаксического сахара, то в итоге мы получим следующий ниже расширенный фрагмент кода:

repeater = BoundedRepeater('Привет', 3)

iterator = iter(repeater)

while True:

     try:

        item = next(iterator)

     except StopIteration:

         break

     print(item)

При каждом вызове функции next() в этом цикле мы выполняем проверку на исключение StopIteration и при необходимости выходим из цикла while.

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

Совместимость с Python 2.x

Все примеры кода, которые я здесь показал, были написаны на Python 3. Существует одна небольшая, но важная разница между Python 2 и Python 3 в том, что касается реализации итераторов на основе класса:

• в Python 3 метод, который извлекает следующее значение из итератора, называется __next__;

• в Python 2 тот же самый метод называется next (без символов подчеркивания).

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

Ниже приведена обновленная версия класса InfiniteRepeater, который будет работать как в Python 2, так и в Python 3:

class InfiniteRepeater(object):

    def __init__(self, value):

         self.value = value

 

    def __iter__(self):

         return self

 

    def __next__(self):

         return self.value

 

# Совместимость с Python 2:

def next(self):

     return self.__next__()

Чтобы сделать этот класс-итератор совместимым с Python 2, я внес в него два небольших изменения.

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

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

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

• Итераторы предоставляют объектам Python интерфейс последовательности, который эффективен с точки зрения потребляемой оперативной памяти и который считается чисто питоновским. Любуйтесь красотой цикла for ... in!

• Чтобы поддерживать итерации, в объекте должен быть реализован протокол итератора за счет обеспечения дандер-методов __iter__ и __next__.

• Итераторы на основе класса являются лишь одним из способов написания итерируемых объектов в Python. Следует также рассмотреть генераторы и выражения-генераторы.

6.5. Генераторы — это упрощенные итераторы

В разделе, посвященном итераторам, мы потратили довольно много времени на написание итератора на основе класса. Это было неплохой идеей с точки зрения обучения, но итератор на основе класса также продемонстрировал, что написание класса итератора требует большого ­объема шаблонного кода. И если говорить по правде, то как «ленивому» разработчику мне не нравится утомительная и однообразная работа.

И все же итераторы очень полезны в Python. Они позволяют писать симпатичные циклы for…in и помогают делать код более питоновским и эффективным… если бы только не существовало более удобного способа писать эти итераторы изначально.

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

Бесконечные генераторы

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

class Repeater:

     def __init__(self, value):

         self.value = value

     def __iter__(self):

         return self

     def __next__(self):

         return self.value

Если вы думаете, что «для такого простого итератора тут довольно много исходного кода», то вы абсолютно правы. Некоторые части этого класса кажутся довольно стереотипными, как будто они переносились под копирку с одного итератора на основе класса на другой.

И вот где на сцену выходят генераторы Python. Если я перепишу этот класс итератора в качестве генератора, то он будет выглядеть так:

def repeater(value):

     while True:

         yield value

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

Будет ли эта новая реализация генератора по-прежнему работать так же, как и наш итератор на основе класса? Давайте стряхнем пыль с теста в цикле for…in, чтобы это выяснить:

>>> for x in repeater('Привет'):

...     print(x)

'Привет'

'Привет'

'Привет'

'Привет'

'Привет'

...

Да! Мы по-прежнему без конца прокручиваем в цикле наши приветствия. Эта намного более короткая реализация генератора, по всей видимости, выполняется таким же образом, что и класс Repeater. (Не забудьте нажать Ctrl+C, если хотите выйти из бесконечного цикла в сеансе интерпретатора.)

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

>>> repeater('Эй')

<generator object repeater at 0x107bcdbf8>

Программный код в функции-генератора исполняется только тогда, когда функция next() вызывается с объектом-генератором в качестве аргумента:

>>> generator_obj = repeater('Эй')

>>> next(generator_obj)

'Эй'

Если вы еще раз прочитаете код функции repeater, то увидите, что, судя по всему, ключевое слово yield каким-то образом останавливает эту функцию-генератор посередине исполнения, а затем возобновляет ее на более позднем этапе:

def repeater(value):

     while True:

         yield value

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

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

>>> iterator = repeater('Привет')

>>> next(iterator)

'Привет'

>>> next(iterator)

'Привет'

>>> next(iterator)

'Привет'

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

Вы убедитесь, что в отношении большинства типов итераторов написание функции-генератора будет проще, а восприятие легче, чем определение многословного итератора на основе класса.

Генераторы, которые прекращают генерацию

Этот раздел мы начали с того, что еще раз написали бесконечный генератор. Сейчас вы, вероятно, задаетесь вопросом, как написать генератор, который через некоторое время прекращает порождать значения вместо того, чтобы без конца продолжать это делать.

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

К счастью, на этот раз мы будем работать с более приятным интерфейсом. Генераторы прекращают порождать значения, как только поток управления возвращается из функции-генератора каким-либо иным способом, кроме инструкции yield. Это означает, что вам больше вообще не нужно заботиться о вызове исключения StopIteration!

Приведу пример:

def repeat_three_times(value):

     yield value

     yield value

     yield value

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

>>> for x in repeat_three_times('Всем привет'):

...     print(x)

'Всем привет'

'Всем привет'

'Всем привет'

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

>>> iterator = repeat_three_times('Всем привет')

>>> next(iterator)

'Всем привет'

>>> next(iterator)

'Всем привет'

>>> next(iterator)

'Всем привет'

>>> next(iterator)

StopIteration

>>> next(iterator)

StopIteration

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

Давайте вернемся к еще одному примеру из раздела об итераторах. Класс BoundedIterator реализовал итератор, который будет повторять значение, заданное определенное количество раз:

class BoundedRepeater:

     def __init__(self, value, max_repeats):

         self.value = value

         self.max_repeats = max_repeats

         self.count = 0

 

    def __iter__(self):

         return self

 

    def __next__(self):

         if self.count >= self.max_repeats:

             raise StopIteration

         self.count += 1

         return self.value

Почему бы не попробовать реализовать класс BoundedRepeater заново как функцию-генератор? Сделаю первую попытку:

def bounded_repeater(value, max_repeats):

     count = 0

     while True:

         if count >= max_repeats:

             return

         count += 1

         yield value

Я преднамеренно сделал цикл while в этой функции несколько громоздким. Я хотел продемонстрировать, как вызов инструкции return из генератора приводит к остановке итераций с исключением StopIteration. Мы вскоре подчистим и еще немного упростим эту функцию-генератор, но сначала давайте испытаем то, что у нас есть сейчас:

>>> for x in bounded_repeater('Привет', 4):

...     print(x)

'Привет'

'Привет'

'Привет'

'Привет'

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

Как я вам обещал, мы можем упростить этот генератор еще больше. Мы воспользуемся тем, что в конец каждой функции Python добавляет неявную инструкцию return None. И вот как будет выглядеть наша окончательная реализация:

def bounded_repeater(value, max_repeats):

     for i in range(max_repeats):

         yield value

Не стесняйтесь подтвердить, что этот упрощенный генератор по-прежнему работает таким же образом. Учитывая все обстоятельства, мы прошли путь от 12-строчной реализации в классе BoundedRepeater до трехстрочной реализации на основе генератора, обеспечив ту же самую функциональность. А это 75 %-ное сокращение количества строк кода — нехило!

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

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

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

• Инструкция yield позволяет временно приостанавливать исполнение функции-генератора и передавать из него значения назад.

• Генераторы начинают вызывать исключения StopIteration после того, как поток управления покидает функцию-генератор каким-либо иным способом, кроме инструкции yield.

6.6. Выражения-генераторы

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

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

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

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

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

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

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

Приведу пример:

iterator = ('Привет' for i in range(3))

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

def bounded_repeater(value, max_repeats):

     for i in range(max_repeats):

         yield value

iterator = bounded_repeater('Привет', 3)

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

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

>>> iterator = ('Привет' for i in range(3))

>>> for x in iterator:

...     print(x)

'Привет'

'Привет'

'Привет'

Как по мне, смотрится весьма неплохо! Из нашего однострочного выражения-генератора мы, похоже, получили те же самые результаты, которые мы получали из функции-генератора bounded_repeater.

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

Выражения-генераторы против включений в список

Как вы уже поняли, выражения-генераторы несколько напоминают включения в список:

>>> listcomp = ['Привет' for i in range(3)]

>>> genexpr = ('Привет' for i in range(3))

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

Присваивая выражение-генератор переменной, вы просто получите итерируемый «объект-генератор»:

>>> listcomp

['Привет', 'Привет', 'Привет']

 

>>> genexpr

<generator object <genexpr> at 0x1036c3200>

Для того чтобы получить доступ к значениям, порожденным выражением-генератором, вам нужно вызвать с ним метод next() точно так же, как вы бы сделали с любым другим итератором:

>>> next(genexpr)

'Привет'

>>> next(genexpr)

'Привет'

>>> next(genexpr)

'Привет'

>>> next(genexpr)

StopIteration

Как вариант, вы также можете вызвать функцию list() c выражением-генератором, в результате чего вы сконструируете объект-список, содержащий все произведенные значения:

>>> genexpr = ('Привет' for i in range(3))

>>> list(genexpr)

['Привет', 'Привет', 'Привет']

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

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

genexpr = (expression for item in collection)

Приведенный выше «образец» выражения-генератора соответствует следующей ниже функции-генератору:

def generator():

     for item in collection:

         yield expression

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

Фильтрация значений

В этот шаблон можно добавить еще одно полезное дополнение, и это фильтрация элемента по условиям. Приведем пример:

>>> even_squares = (x * x for x in range(10)

                     if x % 2 == 0)

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

>>> for x in even_squares:

...     print(x) 0

4

16

36

64

Давайте обновим наш шаблон выражения-генератора. После добавления фильтрации элементов посредством условия if шаблон выглядит так:

genexpr = (expression for item in collection

            if condition)

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

def generator():

     for item in collection:

         if condition:

             yield expression

Встраиваемые выражения-генераторы

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

for x in ('Buongiorno' for i in range(3)):

     print(x)

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

>>> sum((x * 2 for x in range(10)))

90

 

# Сравните с:

 

>>> sum(x * 2 for x in range(10))

90

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

Слишком много хорошего…

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

(expr for x in xs if cond1

       for y in ys if cond2

       ...

       for z in zs if condN)

Образец выше переводится в следующую ниже логику функции-генератора:

for x in xs:

     if cond1:

         for y in ys:

             if cond2:

                 ...

                     for z in zs:

                         if condN:

                             yield expr

И вот здесь я хотел бы разместить большое предостережение.

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

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

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

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

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

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

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

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

• Как только выражение-генератор было использовано, оно не может быть перезапущено или использовано заново.

• Выражения-генераторы лучше всего подходят для реализации простых «ситуативных» итераторов. В случае составных итераторов лучше написать функцию-генератор или итератор на основе класса.

6.7. Цепочки итераторов

Вот еще одно замечательное функциональное свойство итераторов в Python: состыковывая многочисленные итераторы в цепочку, можно писать чрезвычайно эффективные «конвейеры» обработки данных. Когда я впервые увидел этот шаблон в действии на презентации Дэвида Бизли в ходе конференции PyCon, то был совершенно потрясен.

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

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

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

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

def integers():

     for i in range(1, 9):

         yield i

Вы можете подтвердить такое поведение, выполнив данный ниже фрагмент кода в интерпретаторе REPL Python:

>>> chain = integers()

>>> list(chain)

[1, 2, 3, 4, 5, 6, 7, 8]

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

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

def squared(seq):

     for i in seq:

         yield i * i

Ниже показано, что будет теперь делать наш «конвейер данных», или «цепочка генераторов»:

>>> chain = squared(integers())

>>> list(chain)

[1, 4, 9, 16, 25, 36, 49, 64]

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

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

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

def negated(seq):

     for i in seq:

         yield -i

Если мы перестроим нашу цепочку генераторов и добавим negated в конец, то вот что мы получим на выходе:

>>> chain = negated(squared(integers()))

>>> list(chain)

[-1, -4, -9, -16, -25, -36, -49, -64]

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

1. Генератор integers выдает одно-единственное значение, скажем, 3.

2. Это значение «активирует» генератор squared, который обрабатывает значение и передает его на следующую стадию как 3 × 3 = 9.

3. Квадрат целого числа, выданный генератором squared, немедленно передается в генератор negated, который модифицирует его в –9 и выдает его снова.

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

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

integers = range(8)

squared = (i * i for i in integers)

negated = (-i for i in squared)

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

>>> negated

<generator object <genexpr> at 0x1098bcb48>

>>> list(negated)

[0, -1, -4, -9, -16, -25, -36, -49]

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

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

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

• Генераторы могут состыковываться в цепочки, формируя очень эффективные и удобные в сопровождении конвейеры обработки данных.

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

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

Чтобы получить такое экономное для оперативной памяти поведение в Python 2, вам придется использовать встроенную функцию xrange(), так как функция range() будет в действительности конструировать объект-список.

Термин list comprehension также переводится не совсем удобным термином «списковое включение». Дело в том, что в Python, помимо включения собственно в список, еще существуют конструкции включения в словарь (dictionary comprehension) и включения в множество (set comprehension). — Примеч. пер.

Назад: 5. Общие структуры данных Python
Дальше: 7. Трюки со словарем