Всем привет! В бытность мою, когда я самостоятельно изучал Python, я находил достаточно теоретического материала о языке и его возможностях. Однако даже после прочтения нескольких статей на разных сайтах и книг многое не укладывались у меня в голове (да, вот такой вот я тугой). Непонятные концепции приходилось зубрить «на веру» без глубокого понимания, потому что практические примеры в статьях были для меня сложны. Время шло, я становился опытнее, понимание приходило на практических задачах, и в какой-то момент я стал учить Python'у своих друзей. В рамках наставничества я обнаружил, что, кажется, наметил путь, по которому можно объяснять сложные концепции простыми словами.
С уважением ко всему IT-сообществу в День защиты детей и в надежде на то, что смогу помочь новичкам понять прелесть и пользу сложных и непонятных на первый взгляд вещей, пишу свою дебютную статью.
Сегодня хочется ещё раз поговорить о генераторах. Итак, в бой!
Разберёмся с понятиями и поставим задачу
Первое, о чем стоит всегда помнить, когда кто-то заговаривает с вами о генераторах в Python: не стоит путать «генераторы коллекций» (comprehensions, они же «включения») и «генераторы-итераторы». Первые — мощный синтаксический сахар для генерации коллекций «на лету», вторые — способ получения значений по запросу. Речь сегодня пойдёт о вторых.
Давайте представим, что мы попали в ту самую сказку, где тот самый паренёк Вовка говорил: «И так сойдёт!» Только вот того паренька уже давно нет, а какая-нибудь Василиса Премудрая в обмен на путь домой подкидывает нам работёнки.
Задача: надо организовать периодический (например, ежедневный, ежечасный и т.д.) процесс кормления трёх подопытных с помощью скатерти-самобранки. Скатерть-самобранка каждый раз имеет разное количество зарядов еды в своей «обойме». Чувство голода каждого из подопытных точно так же непостоянно (в один момент подопытный может испытывать чувство голода на 3 из 10, а спустя какое-то время — хотеть еды на все 10).
Подопытных кормим в порядке приоритета: сначала первого, затем второго, после этого третьего. Каждого будем кормить до отвала. То есть до тех пор, пока первый не наестся, мы не переходим ко второму, а от второго не переходим к третьему.
Подступаемся к решению
Очевидно, что кормить наших подопытных мы можем двумя способами:
Банкетный способ. Сразу вывалить на скатерть всю еду и подпускать каждого из подопытных к ней по очереди.
Буфетный способ. По очереди подпускать подопытных и отдавать очередную единицу еды только по запросу.
Первый путь
Мы вывалили всю еду на скатерть и приглашаем подопытных трапезничать по очереди. Возможно два варианта развития событий: либо вся еда будет съедена без остатка, либо что-то всё-таки останется и не будет использовано. Это примерно так же, как когда вы (да-да, именно вы, мои маленькие обжоры) заказываете в ресторане столько еды, сколько не можете потом съесть и что-то потом приходится выбрасывать. При этом на приготовление еды потрачены ресурсы и, более того, она занимает место на столе. А ещё вы вполне очевидно переплатили за свой ужин. Короче говоря, плохо всем с точки зрения разумной экономии :)
От скатерти к памяти:
Так вот, я думаю, что пытливые хозяйки и хозяева уже обратили внимание на то, что пример со скатертью иллюстрирует задействование ресурсов для решения поставленной задачи. В нашем примере за объём памяти на хранение элементов коллекции отвечает площадь, занимаемая каждым блюдом на скатерти. Желая накрыть на стол сразу всё доступное «меню», мы обрекаем себя на то, что будет потрачено время на приготовление всего ассортимента блюд, вне зависимости от того, съест их кто-то или нет. Прямо как на банкете.
Изобразим наш банкет с накрытой по полному ассортименту скатертью в виде таблицы:
СХЕМА БЛЮД СКАТЕРТИ-САМОБРАНКИ С ПОКАЗАТЕЛЯМИ РЕСУРСОЗАТРАТНОСТИ | ||
СЕЛЬДЬ ПОД ШУБОЙ 20 ед. площади 40 ед. времени | БОРЩ 30 ед. площади 50 ед. времени | ХЛЕБ 25 ед. площади 5 ед. времени |
МОРС 15 ед. площади 35 ед. времени | ОЛИВЬЕ 10 ед. площади 25 ед. времени | РЫБА 30 ед. площади 35 ед. времени |
ПИРОГ 30 ед. площади 50 ед. времени | ОКРОШКА 20 ед. площади 15 ед. времени | КАРТОШКА 15 ед. площади 15 ед. времени |
Теперь перейдём к терминам Python. В качестве уже накрытой скатерти у нас будет выступать какая-либо коллекция элементов. Давайте возьмём список с числами от 0 до 100 000 000.
Размер такого списка составляет 859 724 472 байт, а его создание занимает около 6 секунд на моей машине. А теперь представьте, что наши подопытные не слишком-то голодны и не способны скушать всё, что мы им предложили. А теперь представьте, что групп подопытных может быть несколько, да и период трапез может изменяться. А каждая такая трапеза будет расходовать память и время процессора. И тут мы видим очевидную проблему: нерациональное использование ресурсов. Как же быть?
Менее сказочный пример из классического мира
Прежде чем рассмотреть второй вариант «кормления» рассмотрим более технологичный пример похожего сценария. Представьте себе, что каждый день наш клиент n раз получает извне параметр, на основе которого вычисляется коллекция с какими-то элементами. Дальше эти элементы мы распределяем по нескольким потребителям для выполнения какой-либо бизнес-логики. К примеру, логики приоритетного распределения ресурсов между какими-то узлами Умного Дома. Наш дом без проблем обойдётся без постоянно включённого кондиционера или умной колонки, но мы бы не хотели оставлять жилище без пожарной сигнализации или системы охраны.
Второй путь
Как было показано выше, кормить наших подопытных, вываливая всё сразу на стол, — дорого и не слишком умно. Поэтому мы поступим следующим образом: каждый участник эксперимента будет подходить к скатерти и нажимать на ней специальную кнопку с надписью next
, которая будет готовить и выдавать следующее для потребления блюдо.
Очевидно, что для одного блюда места нужно сильно меньше, нежели для всех сразу. А это значит, что затраты памяти будут разительно ниже. После того, как первый подопытный наестся, мы сможем пригласить к столу второго, а затем третьего. И даже если у нас в скатерти останется ещё практически полная пачка зарядов, это никак не будет нас тормозить.
Кстати, ларец на этой картинке работает по тому же принципу, что и скатерть с кнопкой next: из него по команде могут вылезти два бравых парня, а затем куча еды
Возвращаясь к примеру с Умным Домом: представьте, что нам пришла информация о доступности 100 000 000 элементов коллекции для использования узлами нашей системы. Пожарная сигнализация в данный момент готова потребить 2 элемента этой коллекции, система охраны — 10, а остальное уйдет кондиционеру (5) и умной колонке (3). При этом в пуле элементов исходной коллекции останется ещё очень много неизрасходованных «зарядов».
Так вот, подобный объект с кнопкой next и есть ни что иное, как объект-генератор. При использовании объектов-генераторов та же самая упакованная коллекция из 100 000 000 элементов занимает всего 120 байт (в некоторых случаях и при использовании некоторых встроенных инструментов — даже меньше), а на сборку такого объекта на моей машине ушло 0,00009 секунды. По сути, мы говорим о том, что у человека есть в руках меню, которое может приготовить заведение (в нашем случае — скатерть), и готовить блюда кухня начнёт только при прямом заказе.
Хозяйке на заметку:
Объекты-генераторы не дают выигрыша по времени и памяти в том случае, если вам нужно хранить и работать сразу со всеми элементами коллекции, а не только с каким-то одним. Например, если хочется сфотографироваться на фоне банкетного стола со всей едой самобранки, то это та самая история.
Объекты-генераторы не дают выигрыша по времени, если вы последовательно запрашиваете все элементы, которые могут быть получены. То есть если вы точно знаете, что вся еда будет съедена. При этом выигрыш по памяти останется.
Ближе к коду
А теперь попробуем реализовать оба наших подхода в виде кода на Python. Итак, роль скатерти-самобранки у нас будет играть функция, которая на вход принимает количество зарядов еды и возвращает список с числами от 0 до n.
Роль подопытных сыграют три функции, которые на вход будут принимать лист с элементами (едой). Каждый из подопытных будет выводить сообщение о том, что он употребил в пищу очередной элемент с каким-то результатом. Пускай первый просто выводит на экран употребляемый в пищу элемент, второй перед показом умножает его на 4, а третий преобразует его в строку и умножает её на 2 с последующим выводом. Для большей наглядности давайте предварительно переводить исходные элементы в строковый тип.
def skat(n):
"""Функция, которая возвращает последовательность от 0 до n"""
# сознательно не используем тут range, потому как range является объектом-итератором.
res = []
cnt = 0
while cnt < n:
res.append(cnt)
cnt += 1
return res
def first_eater(eda_list):
"""Первый подопытный"""
for eda in eda_list:
print(f"Первый подопытный съел {eda} и написал: ", str(eda))
def second_eater(eda_list):
"""Второй подопытный"""
for eda in eda_list:
print(f"Второй подопытный съел {eda} и написал: ", str(eda) * 4)
def third_eater(eda_list):
"""Третий подопытный"""
for eda in eda_list:
print(f"Третий подопытный съел {eda} и написал: ", str(eda) * 10)
# заряжаем скатерть
eda_list = skat(100_000_000)
# задаём параметры голода
golod_1 = 2
golod_2 = 3
golod_3 = 4
# кормим
first_eater(eda_list[:golod_1])
second_eater(eda_list[golod_1:golod_2 + golod_1])
third_eater(eda_list[golod_2 + golod_1:golod_2 + golod_1 + golod_3])
# результат, который будет выведен в консоль:
# >>> Первый подопытный съел 0 и написал: 0
# >>> Первый подопытный съел 1 и написал: 1
# >>> Второй подопытный съел 2 и написал: 2222
# >>> Второй подопытный съел 3 и написал: 3333
# >>> Второй подопытный съел 4 и написал: 4444
# >>> Третий подопытный съел 5 и написал: 5555555555
# >>> Третий подопытный съел 6 и написал: 6666666666
# >>> Третий подопытный съел 7 и написал: 7777777777
# >>> Третий подопытный съел 8 и написал: 8888888888
Как можно заметить, помимо затрат по памяти и времени у нашего кода есть существенный недостаток: он не самым лучшим образом выглядит. Проблема состоит в том, что нашим подопытным нужно давать именно тот кусок нашего списка, который они должны употребить. Это существенно снижает гибкость кода. Если подопытных станет больше или меньше, то правки необходимо будет внести сразу в несколько мест, и довольно легко запутаться в срезах. Либо, как вариант, изменять исходный список внутри функций, делая pop
очередного значения, но тут придётся помнить об особенностях хранения ссылок на другие объекты в списках (кортежах и словарях), что тоже не благоприятствует прозрачности кода.
Интермеццо перед разбором кодовой базы второго варианта
Итак, мы поняли, что первый вариант — не самый лучший для использования. Как же быть? Обо всём по порядку. Любая функция возвращает какой-то результат явно (return
что-нибудь конкретное) или неявно (когда return
не прописано и возвращается None
). Так вот, в Python есть ещё одно ключевое слово помимо return
, которое позволяет возвращать значения — yield
.
Давайте взглянем вот на такие две функции, которые синтаксически отличаются только ключевыми словами для возврата значений:
def my_func_1():
print("Сейчас отдам число")
return 1
def my_func_2():
print("Сейчас отдам число")
yield 1
Давайте попробуем вывести эти функции на печать (именно функции, а не результат, который они возвращают):
print(my_func_1)
print(my_func_2)
# результат, который будет выведен в консоль:
# >>> <function my_func_1 at 0x10c399950>
# >>> <function my_func_2 at 0x10c3a4290>
Иначе говоря, с точки зрения интерпретатора my_func_1
и my_func_2
ничем не отличаются друг от друга, если мы говорим про тип объекта. Это две функции. Однако дело приобретает иной оборот, когда мы пытаемся запустить исполнение и посмотреть возвращаемый результат:
print(my_func_1())
print(my_func_2())
# результат, который будет выведен в консоль
# >>> Сейчас отдам число
# >>> 1
# >>> <generator object my_func_2 at 0x108a702d0>
«Что это за безобразие во втором случае?» — скажете вы.
«Всё под контролем!» — отвечу вам я.
Дело в том, что интерпретатор при исполнении самой функции в случае, если в ней присутствует ключевое слово yield
, ВСЕГДА возвращает объект-генератор (generator-object
). По сути, объект-генератор — это собранная коробочка, которая с радостью выполнит то, что вы описали в теле своей функции. Но только по запросу! То есть мы получим нашу единичку не из самой функции, а из объекта-генератора, который нам вернула my_func_2
.
Как будем доставать нашу единичку? Очень просто! В Python есть встроенная функция next
(помните кнопку на скатерти, про которую я писал выше?), которая на вход принимает объект-генератор (на самом деле объект-итератор, но пока не будем об этом, чтобы не усложнять) и выполняет код до следующего yield
с возвращением того, что написано сразу после этого самого yield
. Давайте проверим это:
print(next(my_func_2()))
# результат, который будет выведен в консоль:
# >>> 1
Вуаля! А вот и наша единичка! Но что означает «...и выполняет код до следующего yield
...»? А это означает то, что в теле функции, описывающей правило, по которому наш объект-генератор будет отдавать результат, может быть сразу несколько yield
! Давайте чуть-чуть поменяем поведение my_func_2
и добавим ещё один yield
:
def my_func_2():
print("Сейчас отдам число")
yield 1
print("Сейчас отдам ещё число")
yield 2
print("Больше ничего нету!")
# попробуем выполнить вот такой код:
gen_o = my_func_2() # тут у нас будет создан и записан в переменную generator-object
print(next(gen_o))
print("Ого, мы только что получили число из объекта-генератора по запросу!")
print(next(gen_o))
# результат, который будет выведен в консоль:
# >>> Сейчас отдам число
# >>> 1
# >>> Ого, мы только что получили число из объекта-генератора по запросу!
# >>> Сейчас отдам ещё число
# >>> 2
Как вы могли заметить, интерпретатор действительно при каждой передаче нашего generator-object
в функцию next
выполнил код до ближайшего yield
. При этом и в первом, и во втором вызове next
мы работали с одним и тем же объектом. Очередное значение как бы «отстреливается», словно пуля из пистолета при нажатии на спуск. Более того, обратите внимание, что мы получили первое значение из generator-object
, а дальше вернулись в основной контекст исполнения программы. Это открывает огромные возможности! Но об этом поговорим в следующих сериях.
Теперь давайте дополним наш код третьим вызовом next
и взглянем на результат:
gen_o = my_func_2()
print(next(gen_o))
print("Ого, мы только что получили число из объекта-генератора по запросу!")
print(next(gen_o))
print(next(gen_o))
# результат, который будет выведен в консоль:
# >>> 1
# >>> Ого, мы только что получили число из объекта-генератора по запросу!
# >>> Сейчас отдам ещё число
# >>> 2
# >>> Больше ничего нету!
# >>> Traceback (most recent call last):
# >>> File "<путь исполняемого файла>", line 13, in <module>
# >>> print(next(gen_o))
# >>> StopIteration
Только что интерпретатор сообщил нам о том, что мы «отстреляли» свой generator-object
. Иначе говоря, у нас больше нет yield
, до которых можно было бы дойти. Если наш объект-генератор больше не имеет зарядов, а мы всё равно пытаемся получить новое значение, то возбуждается исключение StopIteration
.
Хозяйке на заметку: generator-object
можно «отстрелять» только один раз и только вперёд. Вернуться в прошлое или каким-то образом «обнулить» его нельзя. Подробнее такие сценарии стоит рассмотреть в отдельной статье про итераторы. Для generator-object
предполагается, что при необходимости мы просто запустим нашу функцию my_func_2
ещё раз и получим новенький объект для работы.
А пока что вернемся к нашим подопытным и скатерти. Всё, что нам теперь требуется, это написать всё те же несколько функций. Для скатерти это будет функция, принимающая на вход всё то же число n, только теперь она должна возвращать очередное значение по запросу. А наши подопытные на вход теперь будут принимать не лист, а generator-object
, который будет произведен функцией скатерти-самобранки, а также параметр голода:
def skat(n):
"""Функция, которая возвращает объект-генератор, способный предоставить нам
элементы по запросу от 0 до n"""
cnt = 0
while cnt < n:
yield cnt
cnt += 1
def first_eater(golod, skat):
"""Первый подопытный"""
while golod > 0:
eda = next(skat)
print(f"Первый подопытный съел {eda} и в результате написал: ", eda)
golod -= 1
def second_eater(golod, skat):
"""Второй подопытный"""
while golod > 0:
eda = next(skat)
print(f"Второй подопытный съел {eda} и в результате написал: ", str(eda) * 4)
golod -= 1
def third_eater(golod, skat):
"""Третий подопытный"""
while golod > 0:
eda = next(skat)
print(f"Третий подопытный съел {eda} и в результате написал: ", str(eda) * 10)
golod -= 1
skat_gen_obj = skat(100_000_000)
golod_1 = 2
golod_2 = 3
golod_3 = 4
try:
first_eater(golod_1, skat_gen_obj)
second_eater(golod_2, skat_gen_obj)
third_eater(golod_3, skat_gen_obj)
except StopIteration:
print("Заряды в скатерти кончились!")
# результат, который будет выведен в консоль:
# >>> Первый подопытный съел 0 и в результате написал: 0
# >>> Первый подопытный съел 1 и в результате написал: 1
# >>> Второй подопытный съел 2 и в результате написал: 2222
# >>> Второй подопытный съел 2 и в результате написал: 2222
# >>> Второй подопытный съел 2 и в результате написал: 2222
# >>> Третий подопытный съел 3 и в результате написал: 3333333333
# >>> Третий подопытный съел 3 и в результате написал: 3333333333
# >>> Третий подопытный съел 3 и в результате написал: 3333333333
# >>> Третий подопытный съел 3 и в результате написал: 3333333333
Обрамляем процесс кормления наших подопытных в блок try - except
на тот самый случай, если будем иметь дело с дефицитом еды. При желании можно внести этот блок в каждую из функций, чтобы не выносить его в основной контекст выполнения, но я оставлю это на откуп тем, кто захочет поэкспериментировать с моей кодовой базой.
Написанный выше код существенно лучше поддерживается и нам не нужно держать в памяти всю «еду» (обычно это приводит к тому, что на картинках). Кроме того, все наши подопытные работают с одним и тем же объектом без дополнительных усложнений, как в случае со списком, где нам вынужденно пришлось использовать срезы. Если у нас увеличится или уменьшится количество подопытных, то нам не важно, как много их будет, пускай даже миллион (ну, тут уже надо будет дописать генератор «подопытных» :D) — достаточно будет передать нашим подопытным актуальный generator-object
.
На этом рассказ про объекты-генераторы подходит к концу. Я раскрыл не все их тайны, но об этом можно поговорить в следующих сериях.
Вместо выводов
Мы успешно справились с заданием и Василиса-премудрая открывает нам портал для возвращения домой. Это был хороший опыт.
Объекты-генераторы являются мощным инструментом, который позволяет в некоторых случаях экономить значительное количество памяти и времени, при этом повышая гибкость кодовой базы.
Если вы имеете дело с задачей, при которой:
предполагается работа с коллекцией, элементы которой могут быть описаны с помощью некого правила их генерации
и
предполагается работа с коллекцией поэлементно или с выборкой, размер которой сильно меньше общего количества всех элементов коллекции и вы не завязаны на конкретные позиции этих элементов в ней, а просто на факт получения этих элементов
а может быть, ко всему прочему:
имеете необходимость скармливать коллекцию в качестве топлива для неких потребителей, то
Объекты-генераторы — это альтернатива, которая вам нужна!
Если вы имеете дело с задачей, при которой:
вы точно знаете, что предполагается работа сразу со всеми элементами коллекции (или подавляющим большинством) и, как следствие, нужно хранить коллекцию в памяти целиком, то
Лучше использовать другие типы данных коллекций: списки, кортежи, множества и т.д.
Если вы дочитали до этого места, спасибо вам большое! Надеюсь, что новички в языке сочтут мою статью полезной, а опытные — занимательной). В следующей статье хочется раскрыть тему объектов-итераторов с точки зрения того, как они используются в цикле for
, и перейти к разбору итераторов.
А для любителей видеоформата вот плейлист по генераторам / итераторам с моего канала - https://www.youtube.com/playlist?list=PLlKID9PnOE5hEeR11iAsUyEb6vxCkXnZK