Для тех, кто хочет странного: монады в 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, которая мне тоже очень нравится, но показалось, что многовато для одной статьи. Будет интерес — будет статья.
Метки:
Поделиться публикацией
Похожие публикации
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 39
  • +3
    Буквально, свежак:
    «Итак, вы можете писать что угодно на чём угодно, в смысле программирования, и монады у вас там будут независимо от того, в курсе вы или нет. „
    http://ivan-gandhi.livejournal.com/1884945.html
    • +1
      Хорошая статья. Только в данном случае целью была явная реализация в целях демонстрации.
      • 0
        Да у меня ж только к заголовку. И не то, чтобы придирка. А статье только плюс.
      • 0
        Даешь монады на брейнфаке!
      • 0
        • 0
          Каррирование != частичное применение
          • 0
            Тут вы правы. Скорректировал. В данном случае именно частичное применение, с замыканием части параметров и возвратом функции, принимающей оставшиеся. Каррирование (нормальное) в Python страшновато выглядит.
            • 0
              Если не сложно, объясните в чем разница.
              • –1
                Думаю в том, что каррирование можно применять много раз подряд пока не наберется требуемое кол-во аргументов. Частичное применение работает до первого раза.
                В питоне без хаков нельзя узнать сколько аргументов требуется функции.
                • 0
                  можно: f.func_code.co_argcount
                  • 0
                    А вот за это спасибо большое! Единственно, неудобно узнавать кол-во параметров у метода — self тоже считается.
                    • 0
                      unbound-методы действительно принимают на один аргумент больше (+self), чем соответствующие им bound-методы:

                      class A(object):
                        def m(self, a, b, c):
                          pass


                      A.m — unbound-метод, принимающий 4 аргумента (self, a, b, c)

                      Для а = A(), A().m — это bound-метод, принимающий 3 аргумента (a, b, c), по-сути тоже самое что partial(A.m, a)

                      Проверить метод на привязанность к конкретному объекту можно через method.im_self is None
                • +2
                  Каррированная функция нескольких параметров представляет собой функцию одного параметра, возвращающую функцию одного параметра, возвращающую функцию одного параметра… Самая глубоко вложенная функция принимает последний параметр и возвращает результат.

                  При частичном применении можно задать какие-либо параметры, не обязательно первые, и в итоге получится функция от оставшихся параметров, которая опять может быть частично применена, а может и не быть.
                  • 0
                    ну так получается что каррирование — частный случай частичного применения.

                    или каррирование не до конца — несчитово?
                    • 0
                      Каррированная функция, это всегда функция одного аргумента, причем всегда первого. Тут ноги растут из лямбда-исчисления, может я и не смогу правильно объяснить — моё понимание, скорее, интуитивно.
                      Частичное применение возможно везде, где есть замыкание и функции, как объекты первого порядка.
                      При частичном применении мы замыкаем часть параметров и возвращаем обычную функцию, просто с меньшим кол-вом параметров.

                      Каррированная функция каррирована насквозь:
                      f = lambda x: lambda y: lambda z: (x+y)*z
                      Такую функцию можно вызывать(для получения конечного результата) только так:
                      f(1)(2)(3)
                      На Python это некрасиво выглядит, а вот в Haskell всё отлично — там все функции каррированы всегда, и вызов выглядит проще:
                      f 1 2 3 == (((f 1) 2) 3)
            • 0
              Недавно наткнулся на похожее для Java — codereview.stackexchange.com/questions/8055/java-monad-implementation
            • +1
              Книга действительно отличная. Впечатляет, что написал ее 23-летний словенец (или как называется житель Словении).
              • +1
                Особенно, примеры и последовательность подачи материала удачны.
                Последовательность: Maybe, IO (как magic-штуки в конкретных задачах) -> функторы -> аппликативные функторы -> монады. В процессе подачи материала та же Maybe сама вырисовывается и пишется с нуля: вот тут то и приходит понимание
              • +5
                Автор сразу начал реализовывать, но так и не написал, что же представляют из себя эти загадочные монады :)
                • +6
                  Интересно? Могу написать последовательно, начиная с функторов, с примерами. Есть желание адаптировать «Learn you a Haskell for great Good», уже в Python-ключе и для императивщиков
                  • +3
                    Да, было бы замечательно.
                    • +2
                      Отлично, скоро будет статья.
                  • 0
                    Про то, что же они из себя представляют понаписано уже море статей. В том числе на хабре.
                    Статьи танцуют и от теории категорий, и от монадических значений, и от пирмеров монад «в реальной жизни» — яснее от этого не становится нифигашеньки.

                    Автор пошёл альтернативным путём — и это хорошо!

                  • 0
                    А я как-то делал композицию функций на Питоне. Чтобы можно было соединять функции оператором | и писать что-то вроде:
                    ','.join(map(str | int, [4.5, 6.7, 8.02]))

                    Получается прикольно, но использовать такое, конечно, не особенно станешь.
                    • 0
                      Тут сказывается замороженный синтаксис Python — ничего нового не добавишь, только перегрузка и остается ))
                      А в вашем примере можно и не перегружать пайп, даже читабельнее будет:
                      def push_through(*funcs):
                          def inner(value):
                              return reduce(lambda x, f: f(x), funcs, value)
                          return inner
                      
                      print ','.join(map(push_through(int, str), [4.5, 6.7, 8.02]))
                      

                      Только тут функции идут в порядке их применения, а не в обратном порядке, как в композиции. Здесь происходит «проталкивание» данных через последовательность обработчиков. Порядок применения функций, само собой, можно и развернуть.
                      • 0
                        Методом обёртывания функций?
                        • 0
                          А есть другой способ?
                          • 0
                            Это же не обёртывание:
                            sequence = lambda *funcs: lambda x: reduce(lambda xx, f: f(xx), funcs, x)
                            map(sequence(int, str), [1.0, 2.5, 3.7])

                            Тут вместо композиции свертка с применением списка функций к значению. Результат тот же будет
                            • 0
                              Обертывание нужно для этого: str | int
                              • 0
                                В этом случае нужно. Причем, случай крайний — для встроенных функций просто так оператор не перегрузишь, и декоратор не прикрутишь. Тут только так:
                                Combinable(int) | str
                                или
                                str | Combinable(int)
                                или хотя бы
                                str | int | Combinable()
                                • 0
                                  Ну или так:
                                  def wrap(module):
                                      for i in dir(module):
                                          obj = getattr(module, i)
                                          if callable(obj):
                                              setattr(module, i, Composable(obj))
                                  wrap(__builtins__)
                                  

                                  :-)
                                  • 0
                                    Ну это уже совсем неявно, а «явное лучше неявного» ))
                      • +1
                        «result must be MayBe» — это пять! это по-хаскелевски! :)
                        • 0
                          Результат должен или правильно быть или правильно не быть ))
                        • 0
                          По-моему на Хабре нехватает хорошего туториала, «на пальцах» объясняющего, что такое монады, моноиды, функторы, аппликативные функторы и остальные понятия и принципы из этой области. Есть, конечно, Википедия, но по-моему там не достаточно понятно. Вот, например, что такое MayBe я прекрасно понимаю и активно использую (точнее Option в Scala), а что такое монада в принципе и всё остальное — нет.
                          • 0
                            В замечательной книге «Learn You a Haskell for Great Good!» отлично всё написано про монады, моноиды, функторы. Причем описание идёт на практических примерах — без непосредственного введения термина, скажем «монада», но с постепенным «выкристализовыванием» чего-то простого, понятного, а главное — полезного. А потом это «что-то» и оказывается монадой ))

                            Я думаю, не стоит цитировать книгу на Хабре — она есть в свободном доступе (по крайней мере английская версия). Своими словами можно объяснить, но лучше чем в книге у меня лично вряд ли получится…

                            P.S. Option в Scala, это, всё таки, просто АТД, в который удобно завернуть результат вычисления, способного неудасться. И всё. Синтаксической поддержки нет (удобной, хотя бы как в Haskell), вычисления в контексте монадном почти никто не делает…
                            Если уж монады в Scala искать, так лучше обратить внимание на for/yield — вполне себе, монада списка сдобренная нужным кол-вом синтаксического сахара.

                            P.P.S. Объяснение «на пальцах» выше указанным терминам можно дать в практическом ключе, а фундамент находится в теории категорий, которую сходу на пальцах не объяснишь…
                            • 0
                              а фундамент находится в теории категорий, которую сходу на пальцах не объяснишь

                              Опыт многократно показал, что почти любую академическую заумь можно объяснить так, чтобы было понятно даже детям. Было бы желание и достаточное понимание у самого объясняющего. Ждём «теорию категорий для чайников» на Хабре… :-)

                              Если уж монады в Scala искать, так лучше обратить внимание на for/yield — вполне себе, монада списка сдобренная нужным кол-вом синтаксического сахара.

                              Так и не понял зачем нужен for/yield когда есть map. Кажется только один раз мне пришлось использовать for/yield и то я уже не помню почему.
                              • +1
                                В себе пока не чувствую сил теоркат объяснять, увы…

                                А for/yield это не просто map, это скорее filter/map/concat в одном флаконе:
                                class Book(title: String, authors: List[String])
                                
                                // наименования книг, написанных Кнутом
                                for (b <- books; a <- b.authors if a startsWith "Knuth,") yield b.title
                                
                                // авторы, написавшие более чем одну книгу
                                { for {
                                    b1 <- books
                                    b2 <- books
                                    if b1 != b2
                                    a1 <- b1.authors
                                    a2 <- b2.authors
                                    if a1 == a2
                                  } yield a1
                                }.distinct
                                

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.