Библиотека f для функционального программирования в Питоне

в 13:31, , рубрики: functional programming, monads, python, монады, Питон, функциональное программирование

Привет, коллеги!

Я расскажу о библиотеке для Питона с лаконичным названием f. Это небольшой пакет с функциями и классами для решения задач в функциональном стиле.

— Что, еще одна функциональная либа для Питона? Автор, ты в курсе, что есть fn.py и вообще этих функциональных поделок миллион?

— Да, в курсе.

Причины появления библиотеки

Я занимаюсь Питоном довольно давно, но пару лет назад всерьез увлекся функциональным программированием и Кложей в частности. Некоторые подходы, принятые в ФП, произвели на меня столь сильное впечатление, что мне захотелось перенести их в повседневную разработку.

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

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

В результате некоторые из частей библиотеки побывали в боевых проектах и, возможно, все еще в строю. Сперва я копипастил их из проекта в проект, потом завел файлик-свалку функций и сниппетов, и, наконец, оформил все библиотекой, пакетом в Pypi и документаций.

Общие сведения

Библиотека написана на чистом Питоне и работает на любой ОС, в т.ч. на Виндузе. Поддерживаются обе ветки Питона. Конкретно я проверял на версиях 2.6, 2.7 и 3.5. Если возникнут трудности с другими версиями, дайте знать. Единственная зависимость — пакет six для гибкой разработки сразу под обе ветки.

Библиотека ставится стандартным образом через pip:

pip install f

Все функции и классы доступны в головном модуле. Это значит, не нужно запоминать
пути к сущностям:

import f

f.pcall(...)
f.maybe(...)
f.io_wraps(...)
f.L[1, 2, 3]

Пакет несет на борту следующие подсистемы:

  • набор различных функций для удобной работы с данными
  • модуль предикатов для быстрой проверки на какие-либо условия
  • улучшенные версии коллекций — списка, кортежа, словаря и множества
  • реализация дженерика
  • монады Maybe, Either, IO, Error

В разделах ниже я приведу примеры кода с комментариями.

Функции

Первой функцией, которую я перенес в Питон из другой экосистемы, стала pcall из языка Луа. Я программировал на ней несколько лет назад, и хотя язык не функциональный, был от него в восторге.

Функция pcall (protected call, защищенный вызов) принимает другую функцию и возвращает пару (err, result), где либо err — ошибка и result пуст, либо наоборот. Этот подход знаком нам по другим языкам, например, Джаваскрипту или Гоу.

import f

f.pcall(lambda a, b: a / b, 4, 2)
>>> (None, 2)

f.pcall(lambda a, b: a / b, 4, 0)
>>> (ZeroDivisionError('integer division or modulo by zero'), None)

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


@f.pcall_wraps
def func(a, b):
    return a / b

func(4, 2)
>>> (None, 2)

func(4, 0)
>>> (ZeroDivisionError('integer division or modulo by zero'), None)

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


def process((err, result)):
    if err:
        logger.exception(err)
        return 0

    return result + 42

process(func(4, 2))

К большому сожалению, деструктивный синтаксис выпилен в третьем Питоне. Приходится распаковывать вручную.

Интерсно, что использование пары (err, result) есть ни что иное, как монада Either, о которой мы еще поговорим.

Вот более реалистичный пример pcall. Часто приходится делать ХТТП-запросы и получать структуры данных из джейсона. Во время запроса может произойти масса ошибок:

  • кривые хосты, ошибка резолва
  • таймаут соединения
  • сервер вернул 500
  • сервер вернул 200, но парсинг джейсона упал
  • сервер вернул 200, но в ответе ошибка

Заворачивать вызов в try с отловом четырех исключений означает сделать код абсолютно нечитаемым. Рано или поздно вы забудете что-то перехватить, и программа упадет. Вот пример почти реального кода. Он извлекает пользователя из локального рест-сервиса. Результат всегда будет парой:

@f.pcall_wraps
def get_user(use_id):
    resp = requests.get("http://local.auth.server",
                        params={"id": user_id}, timeout=3)

    if not resp.ok:
        raise IOError("<log HTTP code and body here>")

    data = resp.json()

    if "error" in data:
        raise BusinesException("<log here data>")

    return data

Рассмотрим другие функции библиотеки. Мне бы хотелось выделить f.achain и f.ichain. Обе предназначены для безопасного извлечения данных из объектов по цепочке.

Предположим, у вас Джанго со следующими моделями:

Order => Office => Department => Chief

При этом все поля not null и вы без страха ходите по смежным полям:

order = Order.objects.get(id=42)
boss_name = order.office.department.chief.name

Да, я в курсе про select_related, но это роли не играет. Ситуация справедлива не только для ОРМ, но и для любой другой структуры класов.

Так было в нашем проекте, пока один заказчик не попросил сделать некоторые ссылки пустыми, потому что таковы особенности его бизнеса. Мы сделали поля в базе nullable и были рады, что легко отделались. Конечно, из-за спешки мы не написали юнит-тесты для моделей с пустыми ссылками, а в старых тестах модели были заполнены правильно. Клиент начал работать с обновленными моделями и получил ошибки.

Функция f.achain безопасно проходит по цепочке атрибутов:

f.achain(model, 'office', 'department', 'chief', 'name')
>>> John

Если цепочка нарушена (поле равно None, не существуте), результат будет None.

Функция-аналог f.ichain пробегает по цепочке индексов. Она работает со словарями, списками и кортежами. Функция удобна для работы с данными, полученными из джейсона:

data = json.loads('''{"result": [{"kids": [{"age": 7, "name": "Leo"},
                     {"age": 1, "name": "Ann"}], "name": "Ivan"},
                     {"kids": null, "name": "Juan"}]}''')

f.ichain(data, 'result', 0, 'kids', 0, 'age')
>>> 7

f.ichain(data, 'result', 0, 'kids', 42, 'dunno')
>> None

Обе функции я забрал из Кложи, где их предок называется get-in. Удобство в том, что в микросерверной архитектуре структура ответа постоянно меняется и может не соответствовать здравому смыслу.

Например, в ответе есть поле-объект "user" с вложенными полями. Однако, если пользователя по какой-то причине нет, поле будет не пустым объектом, а None. В коде начнут возникать уродливые конструкции типа:

data.get('user', {]}).get('address', {}).get('street', '<unknown>')

Наш вариант читается легче:

f.ichain(data, 'user', 'address', 'street') or '<unknown>'

Из Кложи в библиотеку f перешли два threading-макроса: -> и ->>. В библиотеке они называются f.arr1 и f.arr2. Оба пропускают исходное значение сквозь функиональные формы. Этот термин в Лиспе означает выражение, которе вычисляется позже.

Другими словами, форма — это либо функция func, либо кортеж вида (func, arg1, arg2, ...). Такую форму можно передать куда-то как замороженное выражение и вычислить позже с изменениями. Получается что-то вроде макросов в Лиспе, только очень убого.

f.arr1 подставляет значение (и дальнейший результат) в качестве первого
аргумента формы:

f.arr1(
    -42,                        # начальное значение
    (lambda a, b: a + b, 2),    # форма
    abs,                        # форма
    str,                        # форма
)
>>> "40"

f.arr2 делает то же самое, но ставит значение в конец формы:

f.arr2(
    -2,
    abs,
    (lambda a, b: a + b, 2),
    str,
    ("000".replace, "0")
)
>>> "444"

Далее, функция f.comp возвращает композицию функций:

comp = f.comp(abs, (lambda x: x * 2), str)
comp(-42)
>>> "84"

f.every_pred строит супер-предикат. Это такой предикат, который истиннен только если все внутренние предикаты истинны.

pred1 = f.p_gt(0)        # строго положительный
pred2 = f.p_even         # четный
pred3 = f.p_not_eq(666)  # не равный 666

every = f.every_pred(pred1, pred2, pred3)

result = filter(every, (-1, 1, -2, 2, 3, 4, 666, -3, 1, 2))
tuple(result)
>>> (2, 4, 2)

Супер-предикат ленив: он обрывает цепочку вычислений на первом же ложном значении. В примере выше использованы предикаты из модуля predicate.py, о котором мы еще поговорим.

Функция f.transduce — наивная попытка реализовать паттерн transducer (преобразователь) из Кложи. Короткими словами, transducer — это комбинация функций map и reduce. Их суперпозиция дает преобразование по принципу "из чего угодно во что угодно без промежуточных данных":

f.transduce(
    (lambda x: x + 1),
    (lambda res, item: res + str(item)),
    (1, 2, 3),
    ""
)
>>> "234"

Модуль функций замыкет f.nth и его синонимы: f.first, f.second и f.third для безопасного обращения к элементам коллекций:

f.first((1, 2, 3))
>>> 1

f.second((1, 2, 3))
>>> 2

f.third((1, 2, 3))
>>> 3

f.nth(0, [1, 2, 3])
>>> 1

f.nth(9, [1, 2, 3])
>>> None

Предикаты

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

Я добавил несколько наиболее нужных предикатов в библиотеку. Предикаты могут унарными (без параметров) и бинарными (или параметрическими), когда поведение предиката зависит от первого аргумента.

Рассмотрим примеры с унарными предикатами:

f.p_str("test")
>>> True

f.p_str(0)
>>> False

f.p_str(u"test")
>>> True

# особый предикат, который проверяет на int и float одновременно
f.p_num(1), f.p_num(1.0)
>>> True, True

f.p_list([])
>>> True

f.p_truth(1)
>>> True

f.p_truth(None)
>>> False

f.p_none(None)
>>> True

Теперь бинарные. Создадим новый предикат, который утверждает, что что-то больше нуля. Что именно? Пока неизвесто, это абстракция.

p = f.p_gt(0)

Теперь, имея предикат, проверим любое значение:

p(1), p(100), p(0), p(-1)
>>> True, True, False, False

По аналогии:

# Что-то больше или равно нуля:
p = f.p_gte(0)
p(0), p(1), p(-1)
>>> True, True, False

# Проверка на точное равенство:
p = f.p_eq(42)
p(42), p(False)
>>> True, False

# Проверка на ссылочное равенство:
ob1 = object()
p = f.p_is(ob1)

p(object())
>>> False

p(ob1)
>>> True

# Проверка на вхождение в известную коллекцию:
p = f.p_in((1, 2, 3))

p(1), p(3)
>>> True, True

p(4)
>>> False

Я не буду приводить примеры всех предикатов, это утомительно и долго. Предикаты прекрасно работают с функциями композиции f.comp, супер-предиката f.every_pred, встроенной функцией filter и дженериком, о котором речь ниже.

Дженерики

Дженерик (общий, обобщенный) — вызываемый объект, который имеет несколько стратегий вычисления результата. Выбор стратегии определяется на основании входящий параметров: их состава, типа или значения. Дженерик допускает наличие стратегии по умолчанию, когда не найдено ни одной другой для переданных параметров.

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

Выглядит это примерно так. Сначала создадим экземпляр дженерика:

gen = f.Generic()

Теперь расширим его конкретными обработчиками. Декоратор .extend принимает набор предикатов для этого обработчика, по одному на аргумент.

@gen.extend(f.p_int, f.p_str)
def handler1(x, y):
    return str(x) + y

@gen.extend(f.p_int, f.p_int)
def handler2(x, y):
    return x + y

@gen.extend(f.p_str, f.p_str)
def handler3(x, y):
    return x + y + x + y

@gen.extend(f.p_str)
def handler4(x):
    return "-".join(reversed(x))

@gen.extend()
def handler5():
    return 42

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


gen(1, "2")
>>> "12"

gen(1, 2)
>>> 3

gen("fiz", "baz")
>>> "fizbazfizbaz"

gen("hello")
>>> "o-l-l-e-h"

gen()
>>> 42

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

gen(1, 2, 3, 4)
>>> TypeError exception goes here...

@gen.default
def default_handler(*args):
    return "default"

gen(1, 2, 3, 4)
>>> "default"

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

Обработчик ниже будет вызван, если передать None. Однако, внутри он перенаправляет нас на другой обработчик с двумя интами, это handler2. Который, в свою очередь, возвращает сумму аргументов:

@gen.extend(f.p_none)
def handler6(x):
    return gen(1, 2)

gen(None)
>>> 3

Коллекции

Библиотека предоставляет "улучшенные" коллекции, основанные на списке, кортеже, словаре и множестве. Под улучшениями я имею в виду дополнительные методы и некоторые особенности в поведении каждой из коллекций.

Улучшенные коллекции создаются или из обычных вызовом класса, или особым синтаксисом с квадратными скобками:

f.L[1, 2, 3]            # или f.List([1, 2, 3])
>>> List[1, 2, 3]

f.T[1, 2, 3]            # или f.Tuple([1, 2, 3])
>>> Tuple(1, 2, 3)

f.S[1, 2, 3]            # или f.Set((1, 2, 3))
>>> Set{1, 2, 3}

f.D[1: 2, 2: 3]
>>> Dict{1: 2, 2: 3}    # или f.Dict({1: 2, 2: 3})

Коллекции имеют методы .join, .foreach, .map, .filter, .reduce, .sum.

Список и кортеж дополнительно реализуют .reversed, .sorted, .group, .distinct и .apply.

Методы позволяют получить результат вызовом его из коллекции без передачи в функцию:

l1 = f.L[1, 2, 3]
l1.map(str).join("-")
>>> "1-2-3"

result = []

def collect(x, delta=0):
    result.append(x + delta)

l1.foreach(collect, delta=1)
result == [2, 3, 4]
>>> True

l1.group(2)
>>> List[List[1, 2], List[3]]

Не буду утомлять листингом на каждый метод, желающие могут посмотреть исходный код с комментариями.

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

f.L[1, 2, 3].filter(f.p_even)
>>> List[2]

f.S[1, 2, 3].filter(f.p_even)
>>> Set{2}

Словарь итерируется по парам (ключ, значение), о чем я всегда мечтал:

f.D[1: 1, 2: 2, 0: 2].filter(lambda (k, v): k + v == 2)
>>> Dict{0: 2, 1: 1}

Улучшенные коллекции можно складывать с любой другой коллекцией. Результатом станет новая коллекция этого (левого) типа:

# Слияние словарей
f.D(a=1, b=2, c=3) + {"d": 4, "e": 5, "f": 5}
>>> Dict{'a': 1, 'c': 3, 'b': 2, 'e': 5, 'd': 4, 'f': 5}

# Множество + стандартный спосок
f.S[1, 2, 3] + ["a", 1, "b", 3, "c"]
>>> Set{'a', 1, 2, 3, 'c', 'b'}

# Список и обычный кортеж
f.L[1, 2, 3] + (4, )
List[1, 2, 3, 4]

Любую коллекцию можно переключить в другую:

f.L["a", 1, "b", 2].group(2).D()
>>> Dict{"a": 1, "b": 2}

f.L[1, 2, 3, 3, 2, 1].S().T()
>>> Tuple[1, 2, 3]

Комбо!

f.L("abc").map(ord).map(str).reversed().join("-")
>>> "99-98-97"

def pred(pair):
    k, v = pair
    return k == "1" and v == "2"

f.L[4, 3, 2, 1].map(str).reversed() 
               .group(2).Dict().filter(pred)

>>> Dict{"1": "2"}

Монады

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

  • Проверки входных значений основаны не на типах, как в Хаскеле, а на предикатах, что делает монады гибче.

  • Оператор >>= в Хаскеле невозможно перенести в Питон, поэтому он фигурирует как >> (он же __rshift__, битовый сдвиг вправо). Проблема в том, что в Хаскеле тоже есть оператор >>, но используется он реже, чем >>=. В итоге, в Питоне под >> мы понимаем >>= из Хаскела, а оригинальный >> просто не используем.

  • Не смотря на усилия, я не смог реализовать do-нотацию Хаскелла из-за ограничений синтаксиса в Питоне. Пробовал и цикл, и генератор, и контекстные менеджеры — все мимо.

Maybe

Монада Maybe (возможно) так же известна как Option. Этот класс монад представлен двумя экземплярами: Just (или Some) — хранилище положительного результата, в которм мы заинтересованы. Nothing (в других языках — None) — пустой результат.

Простой пример. Определим монадный конструктор — объект, который будет преобразовывать скалярные (плоские) значения в монадические:

MaybeInt = f.maybe(f.p_int)

По-другому это называется unit, или монадная единица. Теперь получим монадные значения:

MaybeInt(2)
>>> Just[2]

MaybeInt("not an int")
>>> Nothing

Видим, что хорошим результатом будет только то, что проходит проверку на инт. Теперь попробуем в деле монадный конвеер (monadic pipeline):

MaybeInt(2) >> (lambda x: MaybeInt(x + 2))
>>> Just[4]

MaybeInt(2) >> (lambda x: f.Nothing()) >> (lambda x: MaybeInt(x + 2))
>>> Nothing

Из примера видно, что Nothing прерывает исполнения цепочки. Если быть совсем точным, цепочка не обрывается, а проходит до конца, только на каждом шаге возвращается Nothing.

Любую функцию можно накрыть монадным декоратором, чтобы получать из нее монадические представления скаляров. В примере ниже декоратор следит за тем, чтобы успехом считался только возрат инта — это значение пойдет в Just, все остальное — в Nothing:

@f.maybe_wraps(f.p_num)
def mdiv(a, b):
    if b:
        return a / b
    else:
        return None

mdiv(4, 2)
>>> Just[2]

mdiv(4, 0)
>>> Nothing

Оператор >> по другому называется монадным связыванием или конвеером (monadic binding) и вызывается методом .bind:

MaybeInt(2).bind(lambda x: MaybeInt(x + 1))
>>> Just[3]

Оба способа >> и .bind могут принять не только функцию, но и функциональную форму, о которой я уже писал выше:

MaybeInt(6) >> (mdiv, 2)
>>> Just[3]

MaybeInt(6).bind(mdiv, 2)
>>> Just[3]

Чтобы высвободить скалярное значение из монады, используйте метод .get. Важно помнить, что он не входит в классическое определение монад и является своего рода поблажкой. Метод .get должен быть строго на конце конвеера:

m = MaybeInt(2) >> (lambda x: MaybeInt(x + 2))
m.get()
>>> 3

Either

Эта монада расширяет предыдущую. Проблема Maybe в том, что негативный результат отбрасывается, в то время как мы всегда хотим знать причину. Either состоит из подтипов Left и Right, левое и правое значения. Левое значение отвечает за негативный случай, а правое — за позитивный.

Правило легко запомнить по фразе "наше дело правое (то есть верное)". Слово right в английском языке так же значит "верный".

А вот и флешбек из прошлого: согласитесь, напоминает пару (err, result) из начала статьи? Коллбеки в Джаваскрипте? Результаты вызовов в Гоу (только в другом порядке)?

То-то же. Все это монады, только не оформленные в контейнеры и без математического аппарата.

Монада Either используется в основном для отлова ошибок. Ошибочное значение уходит влево и становится результатом конвеера. Корректный результат пробрысывается вправо к следующим вычислениям.

Монадический конструктор Either принимает два предиката: для левого значения и для правого. В примере ниже строковые значения пойдут в левое значение, числовые — в правое.

EitherStrNum = f.either(f.p_str, f.p_num)

EitherStrNum("error")
>>> Left[error]

EitherStrNum(42)
>>> Right[42]

Проверим конвеер:

EitherStrNum(1) >> (lambda x: EitherStrNum(x + 1))
>>> Right[2]

EitherStrNum(1) >> (lambda x: EitherStrNum("error")) 
                >> (lambda x: EitherStrNum(x + 1))
>>> Left[error]

Декоратор f.either_wraps делает из функции монадный конструктор:

@f.either_wraps(f.p_str, f.p_num)
def ediv(a, b):
    if b == 0:
        return "Div by zero: %s / %s" % (a, b)
    else:
        return a / b

@f.either_wraps(f.p_str, f.p_num)
def esqrt(a):
    if a < 0:
        return "Negative number: %s" % a
    else:
        return math.sqrt(a)

EitherStrNum(16) >> (ediv, 4) >> esqrt
>>> Right[2.0]

EitherStrNum(16) >> (ediv, 0) >> esqrt
>>> Left[Div by zero: 16 / 0]

IO

Монада IO (ввод-вывод) изолирует ввод-вывод данных, например, чтение файла, ввод с клавиатуры, печать на экран. Например, нам нужно спросить имя пользователя. Без монады мы бы просто вызвали raw_input, однако это снижает абстракцию и засоряет код побочным эффектом.

Вот как можно изолировать ввод с клавиатуры:

IoPrompt = f.io(lambda prompt: raw_input(prompt))
IoPrompt("Your name: ")      # Спросит имя. Я ввел "Ivan" и нажал RET
>>> IO[Ivan]

Поскольку мы получили монаду, ее можно пробросить дальше по конвееру. В примере ниже мы введем имя, а затем выведем его на экран. Декоратор f.io_wraps превращает функцию в монадический конструктор:

import sys

@f.io_wraps
def input(msg):
    return raw_input(msg)

@f.io_wraps
def write(text, chan):
    chan.write(text)

input("name: ") >> (write, sys.stdout)
>>> name: Ivan   # ввод имени
>>> Ivan         # печать имени
>>> IO[None]     # результат

Error

Монада Error, она же Try (Ошибка, Попытка) крайне полезна с практической точки зрения. Она изолирует исключения, гарантируя, что результатом вычисления станет либо экземпляр Success с правильным значением внутри, либо Failture с зашитым исключением.

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

Монадический конструктор принимает функцию, поведение которой считается небезопасным. Дальнейшие вызовы дают либо Success, либо Failture:

Error = f.error(lambda a, b: a / b)

Error(4, 2)
>>> Success[2]

Error(4, 0)
>>> Failture[integer division or modulo by zero]

Вызов метода .get у экземпляра Failture повторно вызовет исключение. Как же до него добраться? Поможет метод .recover:

Error(4, 0).get()
ZeroDivisionError: integer division or modulo by zero

# value variant
Error(4, 0).recover(ZeroDivisionError, 42)
Success[2]

Этот метод принимает класс исключения (или кортеж классов), а так же новое значение. Результатом становится монада Success с переданным значением внутри. Значение может быть и функцией. Тогда в нее передается экземпляр исключения, а результат тоже уходит в Success. В этом месте появляется шанс залогировать исключение:


def handler(e):
    logger.exception(e)
    return 0

Error(4, 0).recover((ZeroDivisionError, TypeError), handler)
>>> Success[0]

Вариант с декоратором. Функции деления и извлечения корня небезопасны:

@f.error_wraps
def tdiv(a, b):
    return a / b

@f.error_wraps
def tsqrt(a):
    return math.sqrt(a)

tdiv(16, 4) >> tsqrt
>>> Success[2.0]

tsqrt(16).bind(tdiv, 2)
>>> Success[2.0]

Конвеер с расширенным контекстом

Хорошо, когда функции из конвеера требуют данные только из предыдущей монады. А что делать, если нужно значение, полученное два шага назад? Где хранить контекст?

В Хаскеле это проблему решает та самая do-нотация, которую не удалось повторить в Питоне. Придется воспользоваться вложенными функциями:


def mfunc1(a):
    return f.Just(a)

def mfunc2(a):
    return f.Just(a + 1)

def mfunc3(a, b):
    return f.Just(a + b)

mfunc1(1) >> (lambda x: mfunc2(x) >> (lambda y: mfunc3(x, y)))
#                    1                       2         1  2
>>> Just[3]

В примере выше затруднения в том, что функции mfunc3 нужно сразу два значения, полученных из других монад. Сохранить контекст пересенных x и y удается благодаря замыканиям. После выхода из замыкания цепочку можно продолжить дальше.

Заключение

Итак, мы рассмотрели возможности библиотеки f. Напомню, проект не ставит цель вытеснить другие пакеты с функциональным уклоном. Это всего лишь попытка обобщить разрозненную практику автора, желание попробовать себя в роли мейнтейнера проекта с открытым исходным кодом. А еще — привлечь интерес начинающих разработчиков к функциональному подходу.

Ссылка на Гитхаб. Документация и тесты — там же. Пакет в Pypi.

Я надеюсь, специалисты по ФП простят неточности в формулировках.

Буду рад замечаниям в комментариях. Спасибо за внимание.

Автор: igrishaev

Источник


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


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