Как стать автором
Обновить

Восемь признаков недо-yield вашего проекта на Python

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров5.2K
half-python
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

С этого момента начинает проявляться важность использования генераторов:

  1. Код с генератором выполняет то же самое, только код короче.
  2. Мы избежали объявления дополнительных переменных.
  3. По моему мнению, когнитивная сложность этой части кода ниже. Я делал доклад на тему сложности кода на 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.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» в коде, если часто встречаются следующие признаки:


  1. Основной «недо-yield» в проекте характеризуется соотношением количества yield с количеством циклов на количество файлов проекта.
  2. Whiletruism. Измеряется количеством бесконечных циклов в проекте.
  3. Breakable Whiletruism. Измеряется количеством бесконечных циклов с break в проекте.
  4. Breakable Looping. Измеряется количеством for циклов с break в проекте.
  5. Continuable Looping. Измеряется количеством for циклов с continue в проекте.
  6. Loop for List. Измеряется количеством объявлений "= [ ]" перед циклом с .append в проекте.
  7. Looped List. Измеряется количеством «For… in [...» в проекте.
  8. 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-код без генераторов.

cacaha
Kandinsky 2.1: Пупа и Лупа пишут код без yield
Хочу больше Кандинского!
lutiy
Kandinsky 2.1: Лютый недо-yield в программном коде Python без генераторов

malevich
Kandinsky 2.1: Недо-yield в программном коде Python, --малевич

kilometr
Kandinsky 2.1: Многокилометровый недо yield программного кода

putin?
Kandinsky 2.1: недо yield программного кода Python надо устранять!

Lupa
Kandinsky 2.1: Пупа и Лупа программируют недо-yield

Middleage
Kandinsky 2.1: бытовой вальтруизм не так ужасен, как его близкий родственник прерванный вальтруизм(Breakable Whiletruism)

udavinchi
Kandinsky 2.1: пупа и лупа пишут код -ренессанс

frogs
Kandinsky 2.1: пупа и лупа пишут код на Python
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какие у вас мысли от прочтения этой статьи:
8.47% Я работаю с Пупой и Лупой5
8.47% Порой я Лупа, он тоже делегирует работу другим.5
28.81% Порой я Пупа и пишу … код.17
54.24% Я ничего не понял.32
Проголосовали 59 пользователей. Воздержались 16 пользователей.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+5
Комментарии22

Публикации

Истории

Работа

Data Scientist
62 вакансии
Python разработчик
135 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн