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)
tzlom
09.09.2023 18:15+3Это называется алгебраические типы данных (algebraic data types) и есть несколько библиотек которые их реализуют
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 в джаве по принципу действия очень похож на хендлинг ошибок в расте, но первое универсально ненавидилось сообществом, а второе считается замечательной фичей. Синтаксис важен.
Jag_k
09.09.2023 18:15+1А чем, собственно, не устроили обычные Enum?
girvel Автор
09.09.2023 18:15Обычные Enum по умолчанию не поддерживают вложенные типы. Enum раста по сути является объединением enum-а и union-а, что на практике показало себя как очень удобное решение.
Andrey_Solomatin
09.09.2023 18:15У автора не совсем enum. Изначально энум это перечисление. Как уже раньше сказали это алгебраические типы. Есть перечесления по типу объекта, но работаете вы с экземплярами.
rSedoy
09.09.2023 18:15+2if self.subject is None: return False
за подобные однострочники такое бьют ногами, сильно
делайте нормально в пару строквы же потом смогли нормально написать
if self.period.step(): return self.subject.p
Andrey_Solomatin
09.09.2023 18:15Я закинул библиотеку в PyPI и GitHub
С PyPi поторопились. Pip умеет устанавливать прямо с GitHub
В тестах нет ни единого ассерта. Они точно что-то тестируют?python_requires=">=3.6",
Сдалайте GitHub actions с матрицей питонов погонять тесты. Заодно примеры обновите для 3.6.
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.
Andrey_Solomatin
09.09.2023 18:15Теперь вижу куда вы запрятали ассерты. Ваш стиль плохо совместим с моими привычками чтения кода.
Не думаю что это эффективное вложение времени, учитывая что это библиотека из 17 строк и она теряет половину полезности без pattern matching из 3.10
Это в первый раз долго, а потом просто скопировать воркфлоу и поправить пару значений.
Но вообще на 3.6 это действительно не будет работать без датаклассов, которые появились в 3.7, я записал себе issue.
Я бы просто поднял минимальную версию до 3.10 и не парился с поддержкой старых версий.
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.Genericclass To(typing.Generic): .... TargetChange = typing.TypeVar("TargetChange", Nothing, To[int] )
girvel Автор
09.09.2023 18:15Также возможное решение, но оно требует ощутимо больше шаблонного кода.
Andrey_Solomatin
09.09.2023 18:15+1Я бы сказал, что как раз меньше шаблонного кода. И минус одна зависимость.
girvel Автор
09.09.2023 18:15Выше я приводил пример работы с enum через typing и подробно расписал почему это худшее решение.
Andrey_Solomatin
09.09.2023 18:15Нужно будет поиграться с этим матчингом, а то в основных проектах еще 3.9.
Хотя когда смотрел это видео, мне показалось, что этот синтаксический сахар не такой уж и сладкий. https://www.youtube.com/watch?v=ZTvwxXL37XIgirvel Автор
09.09.2023 18:15В целом паттерн матчинг был заимствован из раста, где он как раз-таки сочетается с его необычными enum-ами и позволяет их эффективно деконструировать. В принципе часть моей мотивации для написания этой мини-библиотеки это начать заменять массивы if-ов на match-и.
olivera507224
09.09.2023 18:15+1Поддержу комментаторов выше. Хоть тема поста и интересна, но читать ваш код весьма сложно.
ammo
Ваш код плохо читается не из-за отсутствия enum, а из-за игнорирования pep8 и ужасного нейминга
girvel Автор
Можно примеры, пожалуйста? PyCharm не ловит ни одного нарушения пепа, и как можно оценивать нейминг не имея представления о предметной области проекта?
ammo
Compound statements (multiple statements on the same line) are generally discouraged. В комплекте с код-блоками хабра еще и приходится мотать ваш код влево-вправо, чтобы прочитать.
Don’t compare boolean values to True or False using ==
Не нужно знать предметную область, чтобы штуки типа
self.d
илиself.project.p
были чем-то кроме как примерами плохого нейминга.omaxx
второй пункт не работает, если переменная помимо False еще может принимать значение None
Andrey_Solomatin
== как-то по другому работает с None чем is?
omaxx
pip8 не предполагает использования
is
тоже:Но для того, чтобы различить False и None все равно придется писать
if greeting == False
илиif greeting is False
, простоif not greeting
не сработает.girvel Автор
Generally discouraged. Если это return и он помещается в 120 символов, то это дело личного предпочтения.
Функция буквально также возвращает None
Это является плохим неймингом в отрыве от проекта; в рамках проекта есть ощутимое количество математики с векторами, из-за чего были заимствованы сокращения в математическом стиле, и d и p являются специфическими для проекта терминами. В документации есть табличка со списком всех терминов специфичных для проекта. Сокращения имён переменных специфичные для предметной области это распространённая практика в питоне. Например, в пандас это df.
Andrey_Solomatin
Очень часто вижу что этот паттерн используют люди, для которых программирование это просто инструмент для решения их задач. Во многих случаях они достаточно далеки от понимания как писать хороший код. Я бы на них не ориентировался в этом вопросе. Для их задач хватает хорошего знания Pandas и очень базового синтаксиса Питона.
girvel Автор
В Kotlin существует неявная переменная
it
, используемая в сокращённых лямбдах. В JQuery есть$
. В циклах рутинно используются однобуквенные переменные, несмотря на то что зачастую эти циклы бывают довольно большими и это даже в какой-то степени вредит читаемости. В принципе использование сокращений переменных в современном питоне является минимальным нарушением стиля кода, вот например двухбуквенная переменная в кодовой базе джанго, которую я нашёл за две минуты. Правилом "нельзя использовать однобуквенные переменные" можно легко пренебречь, особенно если это сокращение распространённое, задокументированное и с ним знакомы все разработчики. Программирование это не религия, в нём нет догм, и в некоторых ситуациях слепое следование правилам приводит к ухудшению качества кода.asaaddxasaadd
Только вот it подсвечивается ide (а Котлин в 99% случаев используется с одной единственной ide) и при появлении вложенных конструкций настоятельно рекомендуется указывать иное имя.
Hardcoin
Может быть у вас линтер не включен?
Andrey_Solomatin
Для побликации кода, я очень рекомендую использовать форматеры и линтеры.
Я использую black и ruff. Ну и чтобы не париться с настройкой сделать шаблон для этого. https://github.com/Cjkjvfnby/project_template