Kandinsky 2.1: Умпалумпы программируют python код без yield
Иногда говорят, что код имеет запах. Это относится к стилистике написания, выбору переменных и т.п. Однако, когда речь идет про циклы, я предпочитаю использовать термин «недо-yield», характеризующий стиль работы программиста в циклах и с массивами данных.
Представим себе, что Пупа и Лупа взялись писать код на Python. Но Лупа заболел, и Пупе пришлось писать код за… него. Код, который у них в итоге получился, используется во множестве репозиториев и был тепло оценен Python-сообществом в форме нескольких PEP-соглашений. Предлагаю вам пройтись по такому коду, принюхаться и обратить внимание на некоторые строки.
Disclaimer:
В статье я критично высказываюсь о недоиспользовании в Python коде генераторов и конструкций на их основе. Если вам, по каким-то причинам, комфортнее использовать другие циклические конструкции языка Python, прошу не воспринимать эту статью как причину для конфронтации.
Первый признак «недо-yield» — Вальтруизм (Whiletruism).
Ну конечно, кто ж не знает старину «while»!
# Synchronous put. For threads.
def put(self, item):
while True:
fut = self._put(item)
if not fut:
break
curio, queue.pyМне не нравится этот стиль написания, поскольку while по идее должен проверять условие, которого нет. Я предпочитаю использовать более декларативную конструкцию.
Для этого мне понадобится built-in iter из PEP 234 – Iterators:
iter(func, sentinel)
Когда мы используем функцию iter(func, sentinel), каждое обращение к итератору будет вызывать функцию func() и возвращать её результат, до тех пор, пока он не станет равным sentinel. Зная это — можно легко получить вечный генератор:
iter(int, 1) # самый известный мне вечный генератор.
C помощью вечного генератора можно написать такой вариант вечного цикла:
infinity = iter(int, 1) # infinity generator
for _moment_ in infinity:
... # Carpe diem
Лови момент, так сказать.Все известные мне варианты перебора бесконечностей из itertools проигрывают предложенному варианту, но вы можете использовать:
- count(start=0, step=1): 0, 1, 2, 3, 4,… раньше был не бесконечный, говорят, уже поправили.
- cycle(p): p[0], p[1], ..., p[-1], p[0], ...
- repeat(x, times=∞): x, x, x, x, ...
Если вы не эстет, то пока никакой выгоды от такой замены кода вы не получите.
Следующий признак «недо-yield» — Прерванный Вальтруизм(Breakable Whiletruism).
Вот пример этого родственника бытового вальтруизма:
def build_values(self, args, kwargs):
values = {}
if args:
arg_iter = enumerate(args)
while True:
try:
i, a = next(arg_iter)
except StopIteration:
break
arg_name = self.arg_mapping.get(i)
....
pydantic, decorators.pyЯ предложил бы написать этот код с использованием генераторного выражения. Для этого нам пригодится PEP 289 – Generator Expressions.
def build_values(self, *args, **kwargs):
finc = self.arg_mapping.get
gen = (func(idx), arg for idx, arg in enumerate(args))
for arg_name, arg in gen:
# do something
С этого момента начинает проявляться важность использования генераторов:
- Код с генератором выполняет то же самое, только код короче.
- Мы избежали объявления дополнительных переменных.
- По моему мнению, когнитивная сложность этой части кода ниже. Я делал доклад на тему сложности кода на PyCon DE 2022, кому сложно понимать мой немглийский, этот же доклад на русском.
Вершина вальтруизма — блок классического For-loop вкупе с break. (Breakable Looping)
Вы можете встретить подобный пугающий код:
host_header = None
for key, value in scope["headers"]:
if key == b"host":
host_header = value.decode("latin-1")
break
starlette, datastructires.py— А что такого ужасного в моем коде, возмутился Пупа, увидев, что я пишу эту статью.
— Смотри, ты ранее уже положил в цикле информацию в список «headers», и после ищешь ключ host. Что мешает тебе сразу создать словарь, ведь потом все равно список используется как словарь. Значение получить проще: headers.get('host'). Кстати, ты можешь получить host еще на этапе создания «headers»...
Конечно, у такой задачи много решений. Я бы использовал функцию next:
host_header = next((val.decode("latin-1") for key, val in scope["headers"] if key == b"host"), None)
Пупа, скорее всего, покрутит пальцем у виска и побежит за… своим коллегой. А я попробую объяснить, почему наличие break в цикле for — это одно из диких недоразумений, которые могут встретиться в коде. Я считаю, что такой паттерн также характеризует «недо-yield» кода.
Представьте себе, что вам нужно создать список с миллионом объектов. Вы хотите получить доступ к одному из объектов, пропустив все предшествующие, и остановиться. У меня есть вопросы:
- Что мешает выявить нужный объект на этапе создания списка?
- Создаваемый список явно планируется итерировать, а как же его еще можно использовать. Почему тогда это список, а не генератор?
Отвечая на эти вопросы, приходим к следующему признаку «недо-yield»:
Создание List в циклах. Loop for List
Рассмотрим пример:
if isinstance(obj, (list, set, frozenset, GeneratorType, tuple)):
encoded_list = []
for item in obj:
encoded_list.append(jsonable_encoder(item, *args, **kwargs))
return encoded_list
fastapi, encoders.pyЕсли смотреть на код fastapi дальше, то видно, что возвращаемое значение encoded_list позже будет проитерировано. В таком случае, действительно, имеет смысл использовать генератор:
if isinstance(obj, (list, set, frozenset, GeneratorType, tuple)):
return (
jsonable_encoder(item, *args, ** kwargs) for item in obj
)
— Ну как же, начнет возмущаться Лупа, увидев мой код. — У тебя же не так очевидно, как в моем варианте.
— В принципе да, если нет опыта работы с генераторами, очевидность теряется, отвечаю я.
— Но подождите, это же еще и тестировать невозможно! — восклицает Пупа за… своим другом.
И здесь я соглашусь:
В Python не так много средств для отладки генераторных выражений
- Для отладки можно написать простую обертку-генератор:
def genreport(gen):
return ((print(item), item)[-1] for item in gen) # это может быть и logging.log
Обертку используем в необходимых местах, где нужно следить за генерируемыми значениями.
- Кроме того, есть библиотека itertools
itertools.tee(iterable, n=2)
# Return n independent iterators from a single iterable.
Превращает ваш итератор в два независимых итератора. Один можно использовать для тестирования, второй — для продолжения выполнения. Ознакомьтесь с ограничениями использования.
- И есть еще more_itertools
more_itertools.spy(iterable, n=1)
# Return a 2-tuple with a list containing the first n elements of iterable, and an iterator with the same items as iterable.
#This allows you to “look ahead” at the items in the iterable without advancing it.
Позволяет «взглянуть вперед» на элементы итерируемого объекта, не «продвигая» его. Читайте ограничения использования.Про more_itertools я узнал от глубоко почитаемого мной kesn, хотя фраза в его недавней статье «Генераторы всем хороши, кроме одного: они откладывают выполнение кода, и в реальности узнать, когда ваш код выполнится, бывает затруднительно...» показывает неприятие величайшей сути генератора: выполняться только когда нужно, а когда не нужно — не выполняться.
У меня есть на это пример с множественным continue в цикле, конечно же, как признак «недо-yield»:
Продолжающая Форлупнутость. Continued Forlooperty
Представим себе код:
def remove_cookie_by_name(cookiejar, name, domain=None, path=None):
"""Unsets a cookie by name, by default over all domains and paths. Wraps CookieJar.clear(), is O(n)."""
clearables = []
for cookie in cookiejar:
if cookie.name != name:
continue
if domain is not None and domain != cookie.domain:
continue
if path is not None and path != cookie.path:
continue
clearables.append((cookie.domain, cookie.path, cookie.name))
...
request, cookies.pyТут мне кажется странным следующее: если при создании множественного списка элементов позже многие из них будут пропущены, то зачем было создавать такой список?
Помочь нам в этой ситуации может David Beazley с презентацией coroutines, начинаем читать со слайда 34 про генераторы в цепочке generators pipeline:
def remove_cookie_by_name(cookiejar, name, domain=None, path=None):
"""Unsets a cookie by name, by default over all domains and paths """
clearables = (cookie for cookie in cookiejar if cookie.name == name)
if domain is not None:
clearables = (cookie for cookie in clearables if domain == cookie.domain)
if path is not None:
clearables = (cookie for cookie in clearables if path == cookie.path)
...
Обратите внимание, что ещё до создания и наполнения списка мы убрали проверки, которые не надо выполнять. Мы просто декларировали, как должен работать генератор будущих значений. И конечно же, он отработает не в момент его объявления, а позже, когда генератор будет итерироваться.
Еще один пример для тренировки, как работать с генераторами в цепочке, мне, кстати, кажется, что в исходнике есть небольшая ошибка:
def _read_file(self, file_name):
file_values = {}
with open(file_name) as input_file:
for line in input_file.readlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip("\"'")
file_values[key] = value
return file_values
starlette, config.pyМоя рекомендация по улучшению этого кода остается неизменной: строим трубопровод (pipeline):
def _read_file(self, file_name):
with open(file_name) as input_file:
lines = (line for line in input_file.readlines())
lines = (line.strip() for line in lines if line or lines.close()) # спасибо Пупе и Лупе
lines = (line.partition("=") for line in lines if "=" in line and not line.startswith("#"))
return {key.rstrip() : value.lstrip().strip("\"'") for key, __, val in lines}
В ранних версиях Python этот код выглядит более элегантным, но Пупа и Лупа быстро подсуетились и внесли PEP 479 – Change StopIteration handling inside generators.
Именно поэтому мне приходится использовать generator.close() из PEP 342 – Coroutines via Enhanced Generators , чтобы остановить работу генератора внутри генератора.
Завершим наше исследование финальным признаком «недо-yield»:
Любовь к изменению списков. List-changes Love.
Эта часть — мой основной аргумент для всех пупалупов. Замена генерации списков/словарей итераторами не спасет вас. Многократное использование одного и того же списка — это тот самый супер-пупер-лупер паттерн, способный убить производительность даже самой отлаженной и отрефакторенной библиотеки!
Вот вам пример кода, написанного Пупой за… его напарника, который имеет огромное количество положительных оценок на stackowerflow, попал в документацию DRF и уже опубликован на страницах HABR.
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop('fields', None)
# Instantiate the superclass normally
super().__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
И ни одна Пупа или Лупа, а с ними и множество других разработчиков не видят проблемы, которая, на поверку, оказывается довольно ощутимой в production.
Расскажу вам, в чем тут косяк. Я обнаружил его в 2017 году, когда заметил, что мой сериализатор dynamicFieldsModel для «толстой» модели работал медленнее обычного при сериализации только трех запрошенных полей. В приведенном выше примере происходят изменения сериализатора в коде после super().__init_(), и, возможно, причина замедления именно в этом коде.
И, да, так оно и оказалось — причиной было пупалупное решение сначала создать ВСЕ поля модели, а затем выполнить проход по списку полей и удалить ненужные. Там вообще-то dict-like object, но не будем портить такую хорошую притчу. Ад многократных проходов по спискам внутри самой DRF я еще упомяну.
Для нормального решения этой задачи я предлагаю перестать безмозгло следовать документации и посмотреть, что вообще происходит с сериализатором.
Оказывается, что создание полей после инициализации сериализатора происходит в ленивом property fields, которое в первую очередь вызывает get_field_names:
# Methods for determining the set of field names to include...
def get_field_names(self, declared_fields, info):
""" Returns the list of all field names that should be created when
instantiating this serializer class. This is based on the default
set of fields, but also takes into account the `Meta.fields` or
`Meta.exclude` options if they have been specified. """
fields = getattr(self.Meta, 'fields', None)
exclude = getattr(self.Meta, 'exclude', None)
...
rest_framework, serializers.pyКак видим, информацию о полях get_field_names берет из self.Meta.
Только не поддавайтесь первому пупалупному желанию переопределить self.Meta.fields/self.Meta.extra. Этим вы сломаете все и сразу: Meta — это синглтон для всех объектов этого класса.
А вот так — уже можно:
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""A ModelSerializer that takes an additional `fields` argument that controls which fields should be displayed."""
def __init__(self, *args, **kwargs):
self.Meta = type('Meta', (self.Meta,) {'fields' : kwargs.pop('fields', self.Meta.fields)})
super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)
Разумеется, переданные fields проверены на наличие в модели, и в декларации класса сериализатора определены Meta.fields. Попробуйте, возможно, результат вас удивит.
В этом примере я хочу выразить еще один признак «недо-yield». Создание списка объектов и его изменение в дальнейшем — это ужасное программное решение. Хуже может быть только многократное изменение этого же списка.
Подводя итог моим размышлениям над «пупалупским» кодом, соберу все воедино:
У вас лютый «недо-yield» в коде, если часто встречаются следующие признаки:
- Основной «недо-yield» в проекте характеризуется соотношением количества yield с количеством циклов на количество файлов проекта.
- Whiletruism. Измеряется количеством бесконечных циклов в проекте.
- Breakable Whiletruism. Измеряется количеством бесконечных циклов с break в проекте.
- Breakable Looping. Измеряется количеством for циклов с break в проекте.
- Continuable Looping. Измеряется количеством for циклов с continue в проекте.
- Loop for List. Измеряется количеством объявлений "= [ ]" перед циклом с .append в проекте.
- Looped List. Измеряется количеством «For… in [...» в проекте.
- List-Change Love. Измеряется количеством .extend/.pop/.append и т.п. в циклах в проекте.
Использование вышеупомянутых паттернов в коде не обязательно ошибочно, и надежнее сделать замеры времени. Мой способ позволяет мне предположить наличие «пупалупных» кусков кода еще до запуска. Когда полученные цифры выглядят странно, становится ясно, что Пупа и Лупа где-то рядом.
Давайте проверим несколько библиотек:
- DRF оказалась рекордсменом «пупалупия»: 590 циклов, 8 yield, 72 файла, 5 while, 23 break, 24 continue.
- Pydantic, та еще «пупалупа»: 436 циклов и 107 yield на 26 файлов, и 13 while, 6 break, 38 continue.
- Сравните с fastapi: 70 циклов и 38 yield на 42 файла, и ни одного while или break, только 8 continue. Я раньше реально недооценивал качество кода этой библиотеки.
Моя любимая библиотека django.contrib.admin показала: 326 циклов и 38 yield на 29 файлов.
Об этих и других моих исследованиях Django.admin я докладывал на Django Con EU 2022 и после на Django Con US 2022. Это был интересный опыт, не уверен только, что после моих заявлений о ежегодном многокилометровом недоелде Django.admin меня позовут выступить там еще раз.
В завершение предлагаю вам попробовать посмотреть характеристики «недо-yield» вашего проекта, и, может быть, вы захотите поделиться своими результатами в комментариях. Также буду рад услышать истории о том, как вы используете и тестируете генераторы в коде.
P.S. Те, кто еще не знаком с Пупой и Лупой, это два умпалумпа и про их приключения есть множество историй.
P.P.S. На вопрос, откуда такие
Могу добавить, что в приватных проектах ситуация не лучше. С 2017 года я в роли Code-Ментора для Python-разработчиков повидал множество репозиториев. Если проект большой и сменил несколько разработчиков, то каждая доработка наслаивается на предыдущий код и, например, паттерн looped-list мог объединять десятки повторов.
P.P.P.S. Для генерации картинок в статью я использовал рекламируемый сейчас на Habr Kandinsky 2.1. Генератор так себе, но иногда он попадает в цель. Смотрите, как и выглядит и пахнет Python-код без генераторов.
Kandinsky 2.1: Пупа и Лупа пишут код без yield
Kandinsky 2.1: Лютый недо-yield в программном коде Python без генераторов
Kandinsky 2.1: Недо-yield в программном коде Python, --малевич
Kandinsky 2.1: Многокилометровый недо yield программного кода
Kandinsky 2.1: недо yield программного кода Python надо устранять!
Kandinsky 2.1: Пупа и Лупа программируют недо-yield
Kandinsky 2.1: бытовой вальтруизм не так ужасен, как его близкий родственник прерванный вальтруизм(Breakable Whiletruism)
Kandinsky 2.1: пупа и лупа пишут код -ренессанс
Kandinsky 2.1: пупа и лупа пишут код на Python
Комментарии (20)
Nikola2222
12.04.2023 04:40+1В примере где используется generator.close() в последней строке я не нашел закрывающую фигурную скобку, или так надо?
Dirlandets
12.04.2023 04:40+2Конечно в статье есть много спорных утверждений, но хотел сказать другое!
Спасибо автору за его контент, на хабре который тонет в переводах, и корпоративных статьях не хвататет такого оригинального и настоящего контента. Прочитал с удовольвствием, где то узнал себя, коллег. Где то порадовался, что тоже так делаю, а где то увидел, что "О, так же можно было, но уже ничего не вернуть и не переписать".На счет нечитаемости генераторов Мне кажется по началу только конструкция с
or
может вызвать проблемы, а с pipelines так вообще только лучше становится.
evgenyk
12.04.2023 04:40+6Статья хорошая, поднимает интересную проблему. Как писать код на питоне, когда есть варианты.
К сожалению, есть довольно-таки много разработчиков-питонистов, которые придерживаются схожей с автором точки зрения. Я бы охарактеризовал эту точку зрения примерно так:
Я выучил новую языковую конструкцию, буду теперь пихать ее везде и всюду.
Я использовал языковую конструкцию, которая позволяет запихнуть в одну строку то, что обычный цикл делает в несколько строк. Ура! Теперь мой код чище.
Моя точк зрения по этому вопросу. Главное в коде читаемость. И одна из главных фишек питона, это отступы, которые вместе с циклами гарантируют хороший уровень читабельности кода. Вот например:
while True: if a: break
Говорят, что если это заменить одной строкой, будет будет чище. А читабельность, ну что же, можно привыкнуть.
Я не хочу привыкать. Я хочу читать код, охватывать блоки кода одним взглядом и думать над тем, что он делает. А вместо этого предлагают читать каждую сторчку, расшифровывать ее, пытась понять, как она делает. Лично я расшифровав какую-нибудь строчку, уже забываю, зачем я ее начал расшифоровывать.Я не предлагаю отказаться от геренаторов и тому подобных вещей. Я предлагаю использовать их в обоснованных случаях. Для генераторов я занаю один ярко выраженый полезный случай их применения. Это обработка большого количества информации, которую можно обрабатывать поэлементно, когда это возможно, чтобы не грузить всю информацию в память. И то, считаю, что должен быть комментарий, который объясняет почему выбрано такое решение.
tuupic
12.04.2023 04:40+1Тоже не понимаю, зачем сокращать количество строк, ради "чистоты". KISS же
На днях как раз колупался в коде, где в строку было map/filter/reduce, приправленное
join(tuple(filter(lambda
Причём, timeit показал, что оно ещё и работает медленнее, написанного в человекочитаемом виде.Хотя, может, timeit врёт, конечно.
danilovmy Автор
12.04.2023 04:40+1Проверил, сработает без tuple
template.join(filter(lambda
И сработает так же без filter
template.join(bit for bit in iterable if lambda(bit))
Теперь должно работать быстро.
Человекочитаемость у всех своя, так тоже работает:
template.join( bit for bit in iterable if lambda(bit) )
HemulGM
Правильно ли я понимаю, что автор, в большинстве примеров, заменяет большую, но легкочитаемую конструкцию, на короткую, но трудночитаемую, без какого-то профита?
remzalp
при этом периодически заменяет явное предвычисление значительных объемов данных на ленивую обработку когда-нибудь потом и не всего
tuupic
Помню, коллега-аналитик, как-то спрашивал совета, что лучше сделать(просто потому что я под рукой оказался). Что во время его функций обработки данных из базы, СУБД коннект закрывает. Через пол-суток.
На вопрос, какой объём данных он считывает из базы, он ответил, что мегабайт 100-200, не больше. А функции обработки дёргали ещё апишки всякие и т.д., вот и занимало много времени.
Вот вам и ленивая обработка, с yield
danilovmy Автор
Согласен, что стилистика кода меняется. Становится ли труднее читать — не уверен. Предлагаю рассматривать мой вариант написания, как некий DSL. Поначалу нечитаемо, после привыкаешь.
По поводу профита — генератор позволяет отложить вычисление на неопределённый срок и не занимать все это время память. Вот и весь профит.
HemulGM
Одна большая операция займет меньше времени, чем много маленьких. Итератор итератору рознь. Буду честным, я не знаю, какой код будет сгенерирован интерпретатором при использовании итератора, но если прибегнуть напрямую к ассемблеру, то обычный цикл будет на порядок быстрее работать, чем постоянные вызовы итератора. Перекладывание стека с места на место и т.д. Цикл в асм выглядит очень просто, а использование итератора - это множественные вызовы другого куска кода со всеми вытекающими (сохранение стека, выполнение кода, возвращение стека)
danilovmy Автор
Я сам из Мира Ассемблера пришел, и, поначалу, году эдак в 2012-2014, генераторы мне казались одной из бесполезных штук в Python. После я подсел на Dabeaz, и его гениальные презентации о генераторах, корутинах, корпоративную многозадачность и т.п. Конечно, я фанат его Curio, поскольку понял, насколько это круче asyncio. Так вот, если полагаться на его замеры из 2009, то генератор не сильно проигрывает циклу. Презентация coroutines, слайд 51. На следующих слайдах он почти сокращает отставание.
Выигрыш проявится совсем не в этом месте. В REST представлении, например, надо сереализовать полмиллиона точек и отдать пользователю. Я могу их подготовить заранее в list/tuple. В случае с генератором — генерация точек запланирована, но не выполнена. И тут, если что то после пойдет не так, я выброшу 427 exception. В итоге ответ 427 пришел пользователю быстрее в случае с генератором.
whoisking
Можно подробнее про пример с полмиллионом точек?
1) Это стриминг или классический ответ с данными на запрос?
2) Что такое 427? В HTTP статус-кодах гуглится как unassigned.
3) Не понял, почему мы в цикле не можем сразу рейзить ошибку? (хотя, конечно, try except вставлять в итерацию в любом случае не лучшее решение) Или что тут имеется ввиду?
danilovmy Автор
whoisking
2) 422
2.5) Стало очень интересно, что за задача на входе и что за тип данных, где так принципиально иметь больше 1 точки в качестве пересечения, просто представил полигоны и там обычно в пересечении тоже полигон ... (без снобизма, реально интересно, для общего развития, так сказать)
3) Я всё-таки не понял, в чём тут разница между примененияем генератора и применением цикла? Если и там и там нам надо сперва посчитать, почему пользователь ошибку увидит раньше?
danilovmy Автор