Python / Монады в Python поподробнее

в 20:25, , рубрики: functor, maybe, monads, python, метки: , , ,

Доброго времени суток!

В прошлом топике я попробовал изобразить монаду Maybe средствами языка Python. В целом задача была достигнута, как мне кажется, но совсем незнакомому с тематикой человеку будет непросто понять что к чему, а главное — зачем. В этот раз попробую остановиться на монадах поподробнее, в том числе и для закрепления собственного понимания.

Материал будет во многом повторять отдельные главы книги Learn you a Haskell for great Good, но сквозь призму моего понимания и в рамках языка Python. Саму книгу я настоятельно рекомендую к прочтению, даже если у Вас и в планах нет такой задачи — писать на Haskell: кругозор расширит значительно. Начну, пожалуй.

Контекст

Довольно часто данные, которые подвергаются обработке программами находятся в некотором контексте. Для простоты можно представить себе его в виде некоторой коробки, в которой данные хранятся (хотя «коробочная» аналогия не совсем точна, и иногда даже неприменима, но пока мы попробуем её придерживаться). Например, список, вполне, похож на коробку, в которой лежат элементы. И список образует некий контекст — многие операции, предназначенные для работы с элементами списка, работают с элементами, как с одержимым «коробки», а не просто с данными как есть. Коробкой для данных, хранимых в полях, является и объект, которому эти поля принадлежат. Дерево, построенное на связанных объектах, является контейнером для данных, хранящихся в его ветках/листьях.
В ООП принято инкапсулировать данные объекта внутри него, а доступ рекомендуется предоставлять через методы объекта. Методы работы с данными объектов сложно унифицировать для объектов разных классов, по крайней мере в общем случае, но для объектов, которые могут быть умозрительно представлены в виде контекста («коробки»), в котором находятся данные, это, вполне, осуществимо.
Иногда к данным нужно применить простую функцию, которая может быть достаточно универсальна для работы с простыми типами данных, но неспособна работать с данными внутри «коробки».
Пример: у на с есть фломастер и коробка с бумагой, совать фломастер в коробку через дырку и что-то там пытаться нарисовать бессмысленно. Логичное решение: достать данные из коробки, применить к ним функцию, и положить обратно.
Так вот, наличии у коробки механизма применения функции к содержимому, наша «коробка» становится функтором.

Функторы

Итак функтор — это реализация некоего контекста, в котором находятся данные, причем к эти данные можно достать, применить к ним функцию, и поместить обратно в контекст. Причем от функции требуется только умение работать с самими данными, но не с контекстом.

Реализуем следующий класс-прототип:

class Functor(Infixable):     '''Прототип функтора'''     def fmap(self, func):         raise NotImplementedError() # в прототипе метод - абстрактный 

Пока не нужно обращать внимание на предка (Infixable), можно пока считать предком object.
Механизм применения функции к данным внутри функтора — метод fmap.

Кстати, список в Python, это самый, что ни на есть, функтор, а механизм применения функции к содержимому списка — map(). map(abs, [-2,-1,0,1,2]) — это извлечение элементов списка, применение функции к каждому, и помещение обратно в список.
Представим список в виде функтора:

class List(Functor):      def __init__(self, *items):         super(List, self).__init__()         self._items = items      def fmap(self, func):         self._items = map(func, self._items) # а внутри у него - неонка, т.е. map()         return self      @property     def result(self):         return self._items 

Теперь можно делать так:

>>> print List(-2,-1,0,1,2).fmap(abs).fmap(lambda x: x*2).fmap(str).result ['4', '2', '0', '2', '4'] 

В Haskell система типов позволяет реализовать класс типов Functor, и все типы данных, принадлежащие к этому классу (а они могут принадлежать и, обычно, принадлежат к нескольким классам типов). Метод класса типов в использовании выглядит как обычная функция:

fmap abs [-2,-1,0,1,2] 

Это несколько эстетичнее, но и Python-вариант применим.

Теперь мы можем применять обычную функцию к данным в контексте. Но у нас может возникнуть желание применять к данным в контексте функцию, которая тоже в контексте (в таком, же, что и данные). Т.е. и функция над данными и данные находятся в контексте: и фломастер и бумажки в одной коробке. Нужно достать фломастер, достать бумажку, нарисовать, положить результат обратно. Если наша коробка такое умеет — она аппликативный функтор.

Тут мы отвлечемся и реализуем один вспомогательный класс, а именно Infixable (тот, что стоит в предках у Functor). А нужен он для реализации возможности использования инфиксной нотации. Итак

Инфиксная нотация

Нормальной инфиксной нотации для пользовательских функций в Python не получить — синтаксис заморожен. А иногда очень хочется реализовать что-то вроде:

(/*/) = lambda x,y: (x + y) * (x - y) print 5 /*/ 4 /*/ 3 

Увы, никак. Я написал класс, который позволяет использовать инфиксную нотацию для методов объекта. Сам класс:

class Infixable(object):      INFIX_OPS = [{}]      def __init__(self):         self._last_is_opcode = False         self._op = None          table = {}         for sub_table in self.INFIX_OPS:             table.update(sub_table)         self._op_table = table      def __add__(self, val):         if self._last_is_opcode:             method = getattr(self, self._op_table[self._op])             method(val)         else:             self._op = val         self._last_is_opcode = not self._last_is_opcode         return self 

В этом классе перегружен оператор "+", а вся соль содержится в атрибуте класса INFIX_OPS. Если некий MyObj — потомок Infixable — реализует метод mmm(self, value) и дополнит INFIX_OPS словарем вида { '/*/': 'mmm',… }, станет возможной такая форма записи последовательности операций над экземпляром:

obj = MyObj() +'/*/+ 1 +'/*/'+ 2 +'/*/'+ 3 

Не очень красиво, но работает. Может потом найду альтернативу.

Аппликативные функторы

Итак, нам нужно реализовать доставание функции и данных из коробки, применение функции к данным и помещение их обратно и мы получим аппликативный функтор. Реализуем подходящий класс. Причем, предком у нас будет функтор обыкновенный, ведь неплохо иметь возможность порисовать на наших бумажках и внешним фломастером. Итак, класс:

class Applicative(Functor):      INFIX_OPS = Functor.INFIX_OPS + [{         '<*>': 'applicate'     }]      def applicate(self, value):         raise NotImplementedError() 

Потомки этого класса получат метод applicate(value) и инфиксный оператор для него '<*>'.
Заменим у выше описанного класса List предка на Applicative и допишем реализацию нового метода. Это потребует вспомогательной функции понижения уровня вложенности списка ( [[a, b], [c, d]] -> [a,b,c,d] ). Функция и класс:

def _concat(lists):     return reduce(lambda x, y: x + y, lists, [])  class List(Applicative):      ... # тут все методы из реализации List, как Functor       def applicate(self, value): # value - данные в контексте (тут - в списке)         self._items = _concat([             map(fn, value)             for fn in self._items         ])         return self 

Теперь можно делать так:

>>> List(str).applicate([1,2,3,4,5]).result ['1', '2', '3', '4', '5'] 

Тут у нас в контексте функция, которую мы применяем к данным в контексте же (в списке).
Но, и это самое интересное, можно делать так (заодно применим инфиксную нотацию):

>>> print ( ...     List(str, abs) +'<*>'+ [-10, -20] ... ).result ['-10', '-20', 10, 20] 

Мы получили результаты применения всех функций ко всем параметрам. А можно и так:

>>> add = lambda x: lambda y: x + y # каррированное сложение >>> mul = lambda x: lambda y: x * y # каррированное умножение >>> print ( ...     List(add, mul) +'<*>'+ [1, 2] +'<*>'+ [10, 100] ... ).result [11, 101, 12, 102, 10, 100, 20, 200] 

Сначала всем функциям двух аргументов применяются ко всем первые аргументам. Функции каррированы и возвращают функции второго аргумента, которые применяются ко всем вторым аргументам!

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

Теперь представим ситуацию: нам хочется реализовать потоковое производство рисунков. Пусть у нас есть входные листки, они помещаются в коробку, чтобы получился начальный контекст. Далее каждое рабочее место на линии это функция, которая может брать объект, предварительно вынутый из коробки, делать что-то с ним и помещать в новую коробку (контекст). Сама функция не берет данные из коробки, т.к. не умеет их выбрать правильно, да и проще так — что дали то и обрабатывай, а в новую пустую коробку положить — много ума не надо.
Получается, что каждая операция интерфейсно унифицирована: вынутые данные -> обработка -> коробка с результатами. Нам нужно только реализовать доставание данных из предыдущей коробки и применение к ним функции для получения новой коробки.
Функция, которая принимает обычное значение и возвращает результат в контексте (монадное значение), называется монадной функцией. А аппликативный функтор, который может брать простое значение и пропускать через цепочку монадных функций — это и есть монада.

Монады

Прототип класса монад:

class Monad(Applicative):      INFIX_OPS = Applicative.INFIX_OPS + [{         '>>=': 'bind',         '>>': 'then',     }]      def bind(self, monad_func):         raise NotImplementedError()      def then(self, monad_func):         raise NotImplementedError()      @property     def result(self):         raise NotImplementedError() 

Монада предоставляет 2 метода bind (>>=) и then (>>).
bind() достает значение из контекста, передает монадной функции, которая возвращает следующее значение в контексте (монадное значение).
then() отбрасывает предыдущее монадное значение, вызывает функцию без аргументов, которая возвращает новое монадное значение.

Монада List

Вот теперь перед нами вырисовалась полная реализация монады List:

def _concat(lists):     return reduce(lambda x, y: x + y, lists, [])  class List(Monad):      def __init__(self, *items):         super(List, self).__init__()         self._items = items       def fmap(self, func):         self._items = map(func, self._items)         return self       def applicate(self, monad_value):         self._items = _concat([             map(fn, monad_value)             for fn in self._items         ])         return self       def bind(self, monad_func):         self._items = _concat(map(monad_func, self._items))         return self      def then(self, monad_func):         self._items = monad_func()         return self       @property     def result(self):         return self._items  liftList = lambda fn: lambda x: [fn(x)] 

liftList «втягивает» обычную функцию в контекст: «втянутая» функция возвращает монадное значение

А вот пример использования списка как монады: задача — проверить, можно ли из одной указанной точки на шахматной доске достигнуть второй указанной точки ровно за 3 хода конём.

# все точки, достижимые шахматным конем из указанной точки raw_jumps = lambda (x, y): [     (x + 1, y + 2),     (x + 1, y - 2),     (x - 1, y + 2),     (x - 1, y - 2),     (x + 2, y + 1),     (x + 2, y - 1),     (x - 2, y + 1),     (x - 2, y - 1), ] # отбрасывание точек, выходящих за пределы доски if_valid = lambda (x, y): [(x, y)] if 1 <= x <= 8 and 1 <= y <= 8 else []  # ход конём из точки во всё возможные допустимые точки jump = lambda pos: (     List(pos) +'>>='+ raw_jumps +'>>='+ if_valid ).result  # проверка, можно ли достичь некоторой точки шахматной доски # из исходной ровно за 3 хода коня in3jumps = lambda pos_from, pos_to: pos_to in (     List(pos_from) +'>>='+ jump +'>>='+ jump +'>>='+ jump ).result  print in3jumps((3,3), (5,1)) # нельзя print in3jumps((3,3), (5,2)) # можно 

Монада Maybe

Монада Maybe реализует контекст, в котором монадное значение характеризует одно из двух состояний:
— предыдущий шаг завершился удачно, с некоторым значением (just x)
— предыдущий шаг завершился неудачно (nothing)

При этом последовательность вычислений в контексте монады Maybe — это последовательность шагов, каждый из которых опирается на результат предыдущего, при этом любой шаг может завершиться неудачно, что будет означать неудачное завершение всей последовательности. В контексте Maybe, если на каком либо шаге получается неудачный результат, последующие шаги пропускаются, как не имеющие смысла.
В качестве функтора Maybe применят через fmap функцию к значению, если значение есть, нет значения (неудачный результат) — так и функцию не к чему применить, ну она и не применяется!

Если к функция внутри Maybe-контекста и аргументы внутри Maybe (Maybe, как аппликативный функтор), то функция будет применена, если она есть и есть все аргументы, иначе результат сразу будет неудачным.

Класс монады Maybe:

class Maybe(Monad):      def __init__(self, just=None, nothing=False):         super(Maybe, self).__init__()         self._just = just         self._nothing = nothing       def fmap(self, func):         if not self._nothing:             self._just = func(self._just)         return self       def applicate(self, monad_value):         if not self._nothing:             assert isinstance(monad_value, Maybe)             app_nothing, just = monad_value.result             if app_nothing:                 self._nothing = True             else:                 self._just = self._just(just)         return self       def bind(self, monad_func):         if not self._nothing:             monad_value = monad_func(self._just)             assert isinstance(monad_value, Maybe)             nothing, just = monad_value.result             if nothing:                 self._nothing = True             else:                 self._just = just         return self       def then(self, monad_func):         monad_value = monad_func()         assert isinstance(monad_value, Maybe)         self._nothing, just = monad_value.result         if not self._nothing:             self._just = just         return self       @property     def result(self):         return (self._nothing, self._just)   just = lambda x: Maybe(just=x) nothing = lambda: Maybe(nothing=True)  liftMaybe = lambda fn: lambda x: just(fn(x)) 

just(x) и nothing() это просто сокращения для более простого создания соответствующих монадных значений. liftMaybe — «втягивание» в контекст Maybe.

Примеры использования в качестве функтора и аппликативного функтора:

    def showMaybe(maybe):         nothing, just = maybe.result         if nothing:             print "Nothing!"         else:             print "Just: %s" % just  # ==== Maybe as functor ==== showMaybe(     just(-3).fmap(abs) )  # ==== Maybe as applicative functor ==== add = lambda x: lambda y: x+y # нет функции - нет результата showMaybe(     nothing() +'<*>'+ just(1) +'<*>'+ just(2) ) # нет хотя бы одного аргумента - нет результата showMaybe(     just(add) +'<*>'+ nothing() +'<*>'+ just(2) ) showMaybe(     just(add) +'<*>'+ just(1) +'<*>'+ nothing() ) # если всё есть - будет и результат showMaybe(     just(add) +'<*>'+ just(1) +'<*>'+ just(2) ) 

Приведу пример использования Maybe, как монады. Некий канатоходец ходит по канату с шестом, но на шест любят садиться птицы, а потом могут и улететь. Канатоходец может держать равновесие, если разность кол-ва птиц на сторонах шеста не более 4. Ну и канатоходец просто может упасть подскользнувшись на банановой кожуре. Нужно смоделировать симуляцию последовательности событий с выдачей результата в виде «упал»/«не упал». Код:

# посадка птиц на левую сторону to_left = lambda num: lambda (l, r): (     nothing()     if abs((l + num) - r) > 4     else just((l + num, r)) )  # посадка птиц на правую сторону to_right = lambda num: lambda (l, r): (     nothing()     if abs((r + num) - l) > 4     else just((l, r + num)) )  # банановая кожура banana = lambda x: nothing()  # отображение результата def show(maybe):     falled, pole = maybe.result     print not falled  # начальное состояние begin = lambda: just( (0,0) )  show(     begin()     +'>>='+ to_left(2)     +'>>='+ to_right(5)     +'>>='+ to_left(-2) # канатоходец упадёт тут ) show(     begin()     +'>>='+ to_left(2)     +'>>='+ to_right(5)     +'>>='+ to_left(-1) ) # в данном случае всё ок show(     begin()     +'>>='+ to_left(2)     +'>>='+ banana # кожура всё испортит     +'>>='+ to_right(5)     +'>>='+ to_left(-1) ) 
Вместо послесловия

Подобным образом можно реализовать и другие известные монады, или какие-то свои.
Можно сделать только функтор, но, например, для дерева на связанных объектах.

Примечание

Я намеренно не затрагивал тему монадных законов, она важная, но топик и так объемистый вышел. Будет, что рассказать в следующий раз.

Автор: Astynax

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


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