TL;DR

Реализация энумов в стиле раста в питоне: rust_enum.

Проблема

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

def try_producing_target(self, subject, perception):
    if self.subject is None: 
        return False  # what does False mean?
    
    if abs2(sub2(subject.position, self.subject.position)) <= self.distance: 
        return None  # what is the difference with None?

    if self.period.step():
        return self.subject.position
      
    return False  # False again, wtf?

Этот метод реализует ИИ компоненту, позволяющую NPC поменять свой путь и следовать за каким-то другим NPC или игроком. Если не установлено, что надо за кем-то следить, не надо менять текущую цель пути; если цель близка, нужно остановиться; если всё хорошо, надо менять цель пути на позицию NPC, за которым мы следуем, каждые N тиков (для оптимизации).

Единственная проблема с этим кодом -- это возвращаемые значения. Что означает False? А None? По сути это магические константы, не несущие никакого значения для потенциального читателя этого кода. С другой стороны ситуация ничуть не лучше:

def make_decision(self, subject, perception):
    # noinspection PySimplifyBooleanCheck
    if (target := self.follower.try_producing_target(subject, perception)) != False: 
        self.pather.going_to = target
      
    if (action := self.pather.try_going(subject, perception)) is not None: 
        return action

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

enum TargetChange {
  Nothing,
  To(Option<int2>),
}

Когда мы устанавливаем тип возвращаемого значения try_producing_target на этот enum, это говорит компилятору и читателю, что функция возвращает либо что изменений цели не требуется, либо что надо поменять цель на определённое значение. Никаких магических значений, только константы с осмысленными именами, сгруппированные в один enum. Также, функция не может возвращать никаких других значений и полученный результат можно удобно засунуть в match. Получается очень читаемый и красивый код, и это было бы идеальным решением для такого класса проблем, так что наверняка в PyPI есть библиотека которая это всё делает, да ведь?

Быстрый поиск в гугле показывает что нет, так что я решил решать проблему сам.

Решение

Enum в стиле Rust скорее всего должен использовать синтаксис классов и быть помеченным декоратором, показывающим что это enum, что-то вот такое:

@enum
class TargetChange:
    Nothing = {}
    To = {"target": Optional[int2]}

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

from dataclasses import make_dataclass


def enum(cls):
    for field_name in dir(cls):
        if field_name.startswith('__') and field_name.endswith('__'): 
            continue
        
        setattr(cls, field_name, make_dataclass(
          field_name, list(getattr(cls, field_name).items()), bases=(cls, )
        ))
    return cls

Это работает, но к сожалению TargetChange.To(...) вызывает ворнинги, поскольку линтер думает что мы пытаемся вызвать словарь; также если мы создаём датаклассы из каждого атрибута, мы не можем добавлять пользовательские атрибуты и методы в enum. Так что новый синтаксис:

@enum
class TargetChange:
    Nothing = Case()
    To = Case(target=Optional[int2])

И лучшая реализация:

from dataclasses import make_dataclass


def enum(cls):
    for field_name in dir(cls):
        if not isinstance((value := getattr(cls, field_name)), Case): 
            continue
        
        setattr(cls, field_name, make_dataclass(
            field_name, list(value.dict.items()), bases=(cls, )
        ))
    return cls


class Case:
    def __init__(self, **attributes):
        self.dict = attributes

    # to disable warnings
    def __call__(self, *args, **kwargs):
        pass

В результате, 17 строк кода это в принципе всё что требуется, чтобы портировать enum-ы из Rust в Python.

Результат

ИИ-модуль для следования:

@enum
class TargetChange:
    Nothing = Case()
    To = Case(target=Optional[int2])
def try_producing_target(self, subject, perception) -> TargetChange:
    if self.subject is None: 
        return TargetChange.Nothing()
    
    if abs2(sub2(subject.p, self.subject.position)) <= self.distance: 
        return TargetChange.To(None)

    if self.period.step():
        return TargetChange.To(self.subject.position)
      
    return TargetChange.Nothing()

И вот так он вызывается:

match self.follower.try_producing_target(subject, perception):
    case TargetChange.To(position): self.pather.going_to = position

Проблема решена.

P.S.

Я закинул библиотеку в PyPI и GitHub. Можете пользоваться если хотите. Также думаю о реализации Result и Option, в расте у них были полезные методы.

Комментарии (29)


  1. ammo
    09.09.2023 18:15
    +4

    Ваш код плохо читается не из-за отсутствия enum, а из-за игнорирования pep8 и ужасного нейминга


    1. girvel Автор
      09.09.2023 18:15

      Можно примеры, пожалуйста? PyCharm не ловит ни одного нарушения пепа, и как можно оценивать нейминг не имея представления о предметной области проекта?


      1. ammo
        09.09.2023 18:15
        +4

        1. Compound statements (multiple statements on the same line) are generally discouraged. В комплекте с код-блоками хабра еще и приходится мотать ваш код влево-вправо, чтобы прочитать.

        2. Don’t compare boolean values to True or False using ==

        3. Не нужно знать предметную область, чтобы штуки типа self.d или self.project.p были чем-то кроме как примерами плохого нейминга.


        1. omaxx
          09.09.2023 18:15

          второй пункт не работает, если переменная помимо False еще может принимать значение None


          1. Andrey_Solomatin
            09.09.2023 18:15

            второй пункт не работает, если переменная помимо False еще может принимать значение None


            == как-то по другому работает с None чем is?


            1. omaxx
              09.09.2023 18:15

              pip8 не предполагает использования is тоже:

              # Correct:
              if greeting:
              # Wrong:
              if greeting == True:
              # Wrong:
              if greeting is True:

              Но для того, чтобы различить False и None все равно придется писать if greeting == False или if greeting is False, просто if not greeting не сработает.


        1. girvel Автор
          09.09.2023 18:15
          +1

          1. Generally discouraged. Если это return и он помещается в 120 символов, то это дело личного предпочтения.

          2. Функция буквально также возвращает None

          3. Это является плохим неймингом в отрыве от проекта; в рамках проекта есть ощутимое количество математики с векторами, из-за чего были заимствованы сокращения в математическом стиле, и d и p являются специфическими для проекта терминами. В документации есть табличка со списком всех терминов специфичных для проекта. Сокращения имён переменных специфичные для предметной области это распространённая практика в питоне. Например, в пандас это df.


          1. Andrey_Solomatin
            09.09.2023 18:15
            +1

            Например, в пандас это df.


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


            1. girvel Автор
              09.09.2023 18:15

              В Kotlin существует неявная переменная it, используемая в сокращённых лямбдах. В JQuery есть $. В циклах рутинно используются однобуквенные переменные, несмотря на то что зачастую эти циклы бывают довольно большими и это даже в какой-то степени вредит читаемости. В принципе использование сокращений переменных в современном питоне является минимальным нарушением стиля кода, вот например двухбуквенная переменная в кодовой базе джанго, которую я нашёл за две минуты. Правилом "нельзя использовать однобуквенные переменные" можно легко пренебречь, особенно если это сокращение распространённое, задокументированное и с ним знакомы все разработчики. Программирование это не религия, в нём нет догм, и в некоторых ситуациях слепое следование правилам приводит к ухудшению качества кода.


              1. asaaddxasaadd
                09.09.2023 18:15

                Только вот it подсвечивается ide (а Котлин в 99% случаев используется с одной единственной ide) и при появлении вложенных конструкций настоятельно рекомендуется указывать иное имя.


      1. Hardcoin
        09.09.2023 18:15

        PyCharm не ловит ни одного нарушения пепа

        Может быть у вас линтер не включен?


      1. Andrey_Solomatin
        09.09.2023 18:15

        Для побликации кода, я очень рекомендую использовать форматеры и линтеры.

        Я использую black и ruff. Ну и чтобы не париться с настройкой сделать шаблон для этого. https://github.com/Cjkjvfnby/project_template


  1. tzlom
    09.09.2023 18:15
    +3

    Это называется алгебраические типы данных (algebraic data types) и есть несколько библиотек которые их реализуют


    1. girvel Автор
      09.09.2023 18:15
      +1

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

      class TargetChange:
          Nothing = object()
          To = NewType("To", Optional[int2])
      
      TargetChangeType = TargetChange.Nothing | TargetChange.To

      Субъективно это менее компактно и менее читаемо, надо прописывать все члены дважды: в группе TargetChange и в объявлении типа. Вдобавок если потом делать match, то нельзя писать `case TargetChange.To(x)`, можно только `case (x, y)` или `case p`.

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


  1. Jag_k
    09.09.2023 18:15
    +1

    А чем, собственно, не устроили обычные Enum?


    1. girvel Автор
      09.09.2023 18:15

      Обычные Enum по умолчанию не поддерживают вложенные типы. Enum раста по сути является объединением enum-а и union-а, что на практике показало себя как очень удобное решение.


    1. Andrey_Solomatin
      09.09.2023 18:15

      У автора не совсем enum. Изначально энум это перечисление. Как уже раньше сказали это алгебраические типы. Есть перечесления по типу объекта, но работаете вы с экземплярами.


  1. rSedoy
    09.09.2023 18:15
    +2

    if self.subject is None: return False

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

    вы же потом смогли нормально написать

    if self.period.step():
        return self.subject.p


  1. Andrey_Solomatin
    09.09.2023 18:15

    Я закинул библиотеку в PyPI и GitHub


    С PyPi поторопились. Pip умеет устанавливать прямо с GitHub

    В тестах нет ни единого ассерта. Они точно что-то тестируют?

    python_requires=">=3.6",

    Сдалайте GitHub actions с матрицей питонов погонять тесты. Заодно примеры обновите для 3.6.


    1. girvel Автор
      09.09.2023 18:15

      В тестах нет ни единого ассерта. Они точно что-то тестируют?

      https://github.com/girvel/rust_enum/blob/master/tests/test_enums.py#L15

      https://github.com/girvel/rust_enum/blob/master/tests/test_enums.py#L16

      Сдалайте GitHub actions с матрицей питонов погонять тесты. Заодно примеры обновите для 3.6.

      Не думаю что это эффективное вложение времени, учитывая что это библиотека из 17 строк и она теряет половину полезности без pattern matching из 3.10. Но вообще на 3.6 это действительно не будет работать без датаклассов, которые появились в 3.7, я записал себе issue.


      1. Andrey_Solomatin
        09.09.2023 18:15

        Теперь вижу куда вы запрятали ассерты. Ваш стиль плохо совместим с моими привычками чтения кода.

        Не думаю что это эффективное вложение времени, учитывая что это библиотека из 17 строк и она теряет половину полезности без pattern matching из 3.10

        Это в первый раз долго, а потом просто скопировать воркфлоу и поправить пару значений.

        Но вообще на 3.6 это действительно не будет работать без датаклассов, которые появились в 3.7, я записал себе issue.

        Я бы просто поднял минимальную версию до 3.10 и не парился с поддержкой старых версий.


  1. Andrey_Solomatin
    09.09.2023 18:15
    +1

    Я бы сделал с помощью встроенных инструментов, анотаций типов и линтеры (mypy, pyright).
    https://docs.python.org/3/library/typing.html#typing.TypeVar
    https://docs.python.org/3/library/typing.html#typing.Generic

    
    class To(typing.Generic):
       ....
    
    TargetChange = typing.TypeVar("TargetChange", Nothing, To[int] )


    1. girvel Автор
      09.09.2023 18:15

      Также возможное решение, но оно требует ощутимо больше шаблонного кода.


      1. Andrey_Solomatin
        09.09.2023 18:15
        +1

        Я бы сказал, что как раз меньше шаблонного кода. И минус одна зависимость.


        1. girvel Автор
          09.09.2023 18:15

          Выше я приводил пример работы с enum через typing и подробно расписал почему это худшее решение.


          1. Andrey_Solomatin
            09.09.2023 18:15

            Нужно будет поиграться с этим матчингом, а то в основных проектах еще 3.9.

            Хотя когда смотрел это видео, мне показалось, что этот синтаксический сахар не такой уж и сладкий. https://www.youtube.com/watch?v=ZTvwxXL37XI


            1. girvel Автор
              09.09.2023 18:15

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


  1. olivera507224
    09.09.2023 18:15
    +1

    Поддержу комментаторов выше. Хоть тема поста и интересна, но читать ваш код весьма сложно.


    1. girvel Автор
      09.09.2023 18:15

      Поправил чтобы было проще читать на хабре.