У декларативного подхода к описанию последовательностей есть много достоинств.
Но иногда возникает необходимость поступиться декларативностью и применить императивные приемы — например, изменяемое состояние или возможность досрочно прервать процесс генерации. А еще иногда элементы выходной последовательности зависят друг от друга или от элементов входной последовательности не настолько явно, чтобы можно было обойтись декларативными методами.
Проще говоря, иногда нужно спуститься на такой уровень, на котором выдача элементов наружу проста и контролируема — например, как вывод элементов на печать с помощью print()
. Такой код будет выглядеть очень императивно, но зато он будет эффективным.
В Python сама концепция итеративных вычислений прослеживается повсеместно, поэтому средства низкоуровневого программирования потоков данных встроены в сам язык. Именно их мы и изучим в этом уроке.
Ключевое слово yield
Представим, что нам нужно построить последовательность чисел, элементы которой возрастают экспоненциально. Если бы такие числа нужно было лишь распечатать, то код бы мог выглядеть так:
def iterate(x0, m):
x = x0
while True:
print(x)
x *= m
iterate(1, 1.1)
print("impossible!")
Как только мы вызовем процедуру iterate
, то все возрастающие числа будут выводиться бесконечно, ведь никакого завершения цикла мы не предусмотрели. Выполнение процедуры iterate
никогда не завершится, поэтому и весь код после вызова не выполнится никогда.
А теперь представим, что нам нужна последовательность за пределами процедуры iterate
. В этом случае мы не сможем сделать return
вместо print()
— это приведет к остановке процесса генерации.
В виде аргумента мы могли бы передать список, в который процедура бы добавляла элементы вместо вывода на печать. Однако использовать список мы не сможем, ведь процедура никогда не завершится.
Далеко не всегда можно заранее узнать, сколько итераций нужно выполнить. Чтобы справиться с описанными выше проблемами, понадобится новое ключевое слово yield
:
def iterate(x0, m):
x = x0
while True:
yield x # вместо print()
x *= m
iterate(1, 1.1)
# <generator object iterate at 0x...>
Заметьте, что вызов функции iterate
вычислился в объект-генератор <generator object>
, сама же функция не зациклилась. Теперь iterate
— это именно функция, ведь она вычисляет вполне конкретный результат.
Подобные функции называются генераторными. Они строятся с использованием ключевого слова yield
и возвращают объект-генератор.
Но где же числа? Их нам выдаст объект-генератор, который работает как итератор бесконечной последовательности в данном случае.
Обратите внимание на формулировку «работает как итератор». В Python многое работает на соглашениях, поэтому если что-то ведет себя как итератор, то оно и считается итератором.
Вот как можно применить полученную функцию:
for n in iterate(1, 1.2):
print(n)
if n > 3:
break
# => 1
# => 1.2
# => 1.44
# => 1.728
# => 2.0736
# => 2.48832
# => 2.9859839999999997
# => 3.5831807999999996
Здесь уже вызывающая сторона решает, когда и сколько элементов ей нужно. При этом код генераторной функции не нагружен этим лишним для нее смыслом.
Инициализация, приостановка и завершение генерации
В коде выше ключевое слово yield
очень похоже на return
. Оно точно так же возвращает один элемент, а не генераторное выражение. Еще одно сходство заключается в том, что управление переходит обратно к коду, который запросил элемент у итератора.
Обычно return
останавливает выполнение тела функции раз и навсегда. В отличие от него, yield
выполнение приостанавливает. Выполнение возобновляется, когда вызывающая сторона попросит новый элемент посредством next()
. Оно продолжается, пока не произойдет одно из этих событий:
- Встретится новый
yield
- Встретится
return
- Выполнится последняя строчка тела функции
В первом случае вызывающая сторона получит сгенерированное значение, а выполнение вновь приостановится. Остальные два случая работают одинаково — они завершают процесс итерации.
Код, который находится выше самого первого yield
, часто называют кодом инициализации. Он выполняется, когда к объекту-генератору впервые применяют next()
.
Во время фаз инициализации и завершения удобно открывать файлы, содержимое которых будет порционно выдавать итератор, а потом своевременно этот файл закрыть. Декларативные генераторы такой возможности не имеют сами по себе, так что хотя бы ради этой гибкости стоит уметь писать генераторные функции.
Рассмотрим небольшой пример, сообщающий обо всех фазах своей работы:
def f():
print('Initializing...')
yield 'one'
print('Continue...')
yield 'two'
print('Stopping...')
i = f()
# Еще ничего не выполнялось
i
# <generator object f at 0x...>
next(i) # самый первый next()
# => Initializing...
# => 'one'
# Прошла инициализация, получено первое значение
next(i)
# => Continue...
# => 'two'
# Выполнился код между первым yield и следующим, получено второе значение
next(i)
# => Stopping...
# Traceback (most recent call last):
# ...
# next(i)
# StopIteration
# Выполнение дошло до конца тела функции, итерация завершена
j = iter(i) # Пробуем получить новый итератор
j is i
# True
# iter() В ответ получили ссылку на оригинальный объект
next(j)
# Traceback (most recent call last):
# ...
# next(j)
# StopIteration
# Повторно ту же последовательность обойти не удалось
Этот пример демонстрирует, что объект-генератор реагирует на iter()
, но при этом не может быть использован повторно. Впрочем, новый экземпляр всегда можно получить, вызвав генераторную функцию. Зато сохранение состояния между несколькими участками, потребляющими элементы итератора бывает очень полезно.
Дополнительные материалы
Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.