Python / [Из песочницы] Для тех, кто хочет странного: монады в Python

в 9:48, , рубрики: maybe, python, монады, метки: , ,

Доброго времени суток!
Недавно, начав изучать Haskell, несколько раз пытался подступиться к монадам, но всё никик не мог, что назывется, нить ухватить (м.б. дело в нехватке базовых знаний). Помогла замечательная книга Learn you a Haskell for great Good.
Начитался, проникся, решил донести до коллег/друзей. Разрабатываем на Python, казалось бы, незачем сильно вникать во «всю эту функциональщину», по крайней мере дальше filter/map/reduce. Но расширение кругозора, штука, бесспорно, полезная, поэтому я решил реализовать пару монад на Python, да так чтобы это не вылилось в полный unpythonic. Конечно же, не я первый и не я последний, было и есть несколько реализаций монад на основе Python, но все те реализации, что встречались мне, либо полностью unpythonic, либо сложны для понимания далёкому от самой концепции человеку. Пришлось изобретать свой велосипед, который, впрочем, позволяет ухватить суть…
Отмечу сразу, я сам пока только в самом начале изучения главного фундамента: теории категорий, поэтому данная реализация, скорее всего, достаточно наивна. Надеюсь на конструктивную критику (да и деструктивная пригодится).
Я пошел по пути монад в Haskell, в котором исторически монады появились раньше аппликативных функторов. Я тоже (возможно, пока) не реализовывал функторы/аппликативные функторы. Но можно попробовать, если тема покажется интересной.
Первой я реализовал монаду Maybe, мою любимую. Мне порой очень хочется, чтобы в Python было некое значение, которое могла бы вернуть функция в качестве неудачного результата. None не подходит для такой задачи в общем случае — это, вполне себе, результат.
Многие на ответят: «в Python же есть исключения». Согласен, но порой обёртывание в try/except простого выражения, типа 100/x, как мне кажется, несколько громоздко. Напрашивается вариант заворачивания результата фу-ций в нечто вроде кортежа (Bool, result), где первый член — признак неудачного выполнения. Вот тут то и получается монадное значение в контексте монады Maybe. Такое поведение функций, само по себе иногда удобно, но хочется большего — хочется биндинга (>>=). Биндинг позволяет реализовать последовательные вычисления через последовательность действий, каждое из которых может завершиться неудачей. При этом каждый элемент последовательности не следит за результатом предыдущего — сама операция биндинга при неудачном выполнении предыдущего шага пропускает все последующие, как не имеющие смысла. Всё это вылилось в такой класс:
class _MaybeMonad(object):

def __init__(self, just=None, nothing=False):
self._just = just
self._nothing = nothing

def __rshift__(self, fn):
if not self._nothing:
res = fn(self._just)
assert isinstance(res, _MaybeMonad), (
"In Maybe context function result must be Maybe")
self._just = res._just
self._nothing = res._nothing
return self

@property
def result(self):
return (self._nothing, self._just)

При инстанцировании, конструктор принимает либо значение, которое попадет в контекст (через just=x), либо признак неудачного результата (через nothing=True). При этом nothing имеет приоритет, что логично.
Результат из монадного значения (экземпля ра данного класса) можно получить через свойство result в виде уже описанного выше кортежа.
Удобнее этим классом пользоваться через пару сокращений:
nothing = lambda: _MaybeMonad(nothing=True)
just = lambda val: _MaybeMonad(just=val)

первое создает неудачный результат, второе — удачный со значением
Для упаковывания начального значения для последовательности вычислений, я сделал псевдоним:
returnM = just

Теперь можно писать простые функции, возвращающие монадный результат:
def divM(value, divider):
'''
Деление нацело. Может пройти неудачно. "Чистое"
'''
if divider:
return just(value // divider)
return nothing()

div100by = lambda x: divM(100, x) # эх, сюда бы карринг

def sqrtM(value):
if value >= 0:
return just(math.sqrt(value))
return nothing()

А биндинг позволяет делать такие штуки:
do = returnM(4) >> div100by >> sqrtM # 4 - параметр
error, result = do.result

Данная последовательность действий нормально переваривает отрицательное значение, вместо параметра (на котором сподкнулся бы math.sqrt); операция деления нормально воспримет 0 в качестве делителя и вернет nothing(), который и будет результатом всего выражения.
Таким образом исключения остаются для крайних случаев.
Ещё можно и нужно добавить такую функцию:
lift = lambda fn: lambda val: just(fn(val))

Выглядит страшновато, но пользоваться ей просто:
lift(str) просто «втянет» обычную функцию str() в контекст, т.е. результат возвращаемый обычной функцией, окажется упакованным в монадное значение.
Теперь, в качестве последного штриха, добавляем каррирование (частичное применение):
curried = lambda fn, *cargs: lambda *args: fn(*(cargs + args))

Напоследок более комплексный пример:
def calc(x):
do = (
returnM(x)
>> curried(maybe_div, 100)
>> lift(lambda x: x+75)
>> lift(int)
)

# распаковка контекста
failed, result = do.result
if failed:
print "Не вышло (("
else:
print "Результат: %s" % result

calc(0) # не удастся 100 / 0
calc(4)

Получилось не совсем pythonic, особенно lambda на lambda, но это то как-раз поправимо.
Хотел ещё написать про реализацию монады List, которая мне тоже очень нравится, но показалось, что многовато для одной статьи. Будет интерес — будет статья.

Автор: Astynax


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js