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

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

Уровень сложностиСредний
Время на прочтение3 мин
Количество просмотров4.4K
Автор оригинала: girvel

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

Теги:
Хабы:
Всего голосов 7: ↑4 и ↓3+2
Комментарии29

Публикации

Истории

Работа

Python разработчик
126 вакансий
Rust разработчик
7 вакансий
Data Scientist
80 вакансий

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

Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область