Enum в стиле Rust-а в Python

—

от автора

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, в расте у них были полезные методы.


ссылка на оригинал статьи https://habr.com/ru/articles/759978/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *