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


Я расскажу о библиотеке для Питона с лаконичным названием 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.


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


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

Поделиться с друзьями
-->

Комментарии (53)


  1. rasswet
    15.07.2016 16:40

    «Джаваскрипту или Гоу» — первый раз встречаю не латиницей. Красивее будет если написать в более привычном стиле, imho.


    1. impwx
      15.07.2016 18:08
      +12

      При слове «Виндуз» у меня возникло ощущение, будто открыл «Навигатор игрового мира» за 1999 год.


      1. igrishaev
        18.07.2016 11:39

        Значит, в 1999 году в журнале работал толковый редактор.


    1. wiredRequired
      15.07.2016 18:31
      +3

      а как же «джейсону»?


    1. bolk
      15.07.2016 22:40
      +1

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


      1. ef_end_y
        16.07.2016 02:28
        +2

        Если есть желание быть понятным большинству и, самое главное, избежать в комментариях уже которых по счету споров на эту тему, лучше писать латиницей. Не понимаю твоего упрямства)


        1. bolk
          16.07.2016 05:05
          +3

          Большинство научится, а своё «упрямство» я неоднократно объяснял со всех возможных сторон.


      1. avonar
        16.07.2016 11:29
        -6

        Читайте больше на русском, дальше не читал.


        1. bolk
          16.07.2016 16:00
          +3

          Как вы изящно над собой же и пошутили в контексте нашего разговора!


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


          1. avonar
            17.07.2016 22:45

            Смешно. Никогда не понимал людей, которые вместо того чтобы использовать терминологию и характер написания принятые в интернациональной среде, обязательно пытаются «локализовать». Я открою вам небольшой секрет, в современном мире, если вы хотите быть на переднем крае в любой области необходимо бОльшую часть времени уделять именно международным источникам. Зачем это искусственное усложнение? Есть и без этого огромное количество вещей, которые затрудняют чтение профессиональных статей.
            Если же вы используете источники только на русском языке… это наверное очень круто, быть всегда на шаг позади? Зачем это искусственное огораживание от свежей информации? Зачем все эти усложнения с локализацей? Лень? Нежелание менять привычки? Ненависть ко всему нерусскому?
            К автору поста у меня претензий нет, мне все равно как он написал, хотя и читается так себе. Но вот к таким как вы, лучше не раздавать людям советов.


            1. bolk
              17.07.2016 23:34
              +2

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


              Если же вы посмотрите на журналы и статьи, издававшиеся до этого печального времени, вы увидите, что всё писали на русском, как и должно быть — вот это и есть традиция.


              Ненависти ко всему нерусскому у меня нет, разумеется, как и ко всему русскому. На английском я буду писать по-английски, на русском — по-русски.


              Если же вы используете источники только на русском языке… это наверное очень круто, быть всегда на шаг позади?

              Вы что-то перепутали. Я вам посоветовал читать побольше по-русски. Не пользовать источниками только на русском, а просто больше читать по-русски.


              Но вот к таким как вы, лучше не раздавать людям советов.

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


            1. igrishaev
              18.07.2016 00:48
              +1

              Как будто для вас написал: http://grishaev.me/eng-terms


    1. igrishaev
      15.07.2016 23:40
      +6

      Я написал именно в привычном стиле.


      1. xGromMx
        16.07.2016 22:33

        Привет! Добавь сюда свою либу https://github.com/xgrommx/awesome-functional-programming#python


  1. CrazyOpossum
    15.07.2016 18:30

    Очень много «левых сдвигов», в итоге вкусные вещи лежит в той же библиотеке, что и примитивнейшие предикаты или коллекции, для которых есть collection. Ну то есть тебя не устроила стандартная либа, и ты написал свою, чтобы не ковыряться в collection? В таком случае, кто будет использовать твои коллекции?
    Кроме того, заимствования из Go лежат рядом с заимствованиями из Haskell. pcall — интересно, но в одной библиотеке с дженериками? А вообще знающие люди пишут свой контекст «with as», если надо избежать множественных «try, except».


    1. igrishaev
      15.07.2016 18:31

      Интересно, как они это пишут? Примеры?


      1. CrazyOpossum
        15.07.2016 19:42

        примерно так:

        class SocketContext:
            def __init__(self, addr, port):
                self.sock = socket.socket()
                self.sock.connect((addr, port))

            def __enter__(self):
                return self.sock

            def __exit__(self, type, value, tb):
                print_tb(tb)
                sock.close()
                return True

        Это менеджер контекста, если далее
        with SocketContext(«example.com», 80) as sock:
            # Do smth

        Срабатывает __init__(«example.com», 80), затем __enter__ передаёт значение в sock (разумеется можно несколько, я в последний раз передавал функцию, чтобы получить замыкание), при ЛЮБОМ выходе (исключение, корректное завершение, return внутри блока) из контекста вызывается __exit__, если было исключение, то его можно обработать как tb, если __exit__ вернёт False, то вроде бросится исключение наружу.

        Вспоминаем известный with open(name) as fin: — суть в том, что что бы мы не делали, файл будет закрыт после выхода из контекста.


        1. igrishaev
          15.07.2016 20:01

          Спасибо, я знаю, как работает контекстный менеджер. Мне не ясно, как он позволит, цитирую, "избежать множественных «try, except»".


          1. CrazyOpossum
            15.07.2016 21:33

            pcall_wraps как я понимаю обёртывает функцию в
            try:
                return (func(*args, **kwargs), None)
            except Exception, e:
                return (e, None)

            Для единообразной арифметики, это, пожалуй, сэкономит код, но если ты хочешь «деструктор», который бы освобождал ресурсы/закрывал дескрипторы и по-разному обрабатывал разные ошибки, то всё равно надо каждый писать что-то кастомное. В сетях и парсинге всякого текста приходится как раз таки «закрывать дескрипторы». И опять таки это 7 строчный декоратор, который не лень и самому написать.


            1. igrishaev
              15.07.2016 23:32

              но если ты хочешь «деструктор»
              Я не хочу никакого деструктора, и ничего не хочу закрывать. О чем вы?


  1. Alex_ME
    15.07.2016 22:44
    +4

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

    Я, конечно, не гуру Python, но мне кажется, что это несколько не python-way, и мне казалось, что обычно стараются наоборот писать декларативно.


    Сам я больше использую C# и стараюсь, где возможно, использовать лямбды и LINQ (не перебарщивая, конечно). ИМХО, декларативный стиль куда нагляднее


    1. igrishaev
      16.07.2016 09:53

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


  1. homm
    15.07.2016 23:25
    -4

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

    Whoops



    1. igrishaev
      15.07.2016 23:34
      +5

      Написано же:


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


      1. homm
        17.07.2016 02:28
        -2

        Тогда мне тем более не понятно, для чего давать заведомо нерабочий пример.


        1. igrishaev
          17.07.2016 10:52
          +1

          Это рабочий пример. Вы запускаете его не в том окружении. В тексте это оговорено.


  1. fishHook
    15.07.2016 23:36
    -4

    Вы пишите «питоном занимаюсь довольно давно», а давно это сколько?
    Я вот лично считаю, что тоже давольно давно работаю с питоном, лет семь, это давно по-вашему? Давнее вашего?


    1. igrishaev
      15.07.2016 23:36

      Тоже 7 лет.


      1. fishHook
        16.07.2016 08:32
        -4

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


        1. igrishaev
          16.07.2016 10:02
          +2

          Не уловил связь между "давно" и "Тогда вы должны понимать", объясните?


          1. fishHook
            16.07.2016 13:41
            -2

            Если вы работаете давно, значит у вас есть опыт разработки, вы понимаете язык, не пытаетесь писать на питоне в стиле С++, знаете библиотеку и уже прошли этап экспериментов и наивного новаторства.


  1. magic4x
    16.07.2016 00:47
    +4

    Знатная солянка, узнал много нового, спасибо. В особенности monadic pipeline — ваще огонь )
    А вот такое легаси я бы переписал на "длинно и некрасиво":


    f.L("abc").map(ord).map(str).reversed().join("-")
    '99-98-97'
    
    foo = [ord(x) for x in 'abc']
    '-'.join(str(x) for x in foo[::-1])
    '99-98-97'

    Поинт в том, что если это есть в питоне, этого не надо объяснять новому человеку, а если он этого не знает, то обязан научиться. Ну и еще ваши типы и их методы могут быть сильно медленнее встроенных:


    def test_f():
        f.L("abc").map(ord).map(str).reversed().join("-")
    
    def test_p():
        foo = [ord(x) for x in 'abc']
        '-'.join(str(x) for x in foo[::-1])
    
    print(timeit.timeit(test_f, number=1000), timeit.timeit(test_p, number=1000))
    0.0127332210541, 0.00602507591248

    И еще я думал так тоже работать будет:


    f.p_num(Decimal(10)), f.p_num('1')
    False, False
    
    Decimal(10) + 4, '1'.isdigit()
    Decimal('14'), True

    Хотя, конечно, вы об этом не писали.


    1. magic4x
      16.07.2016 00:58
      +2

      Да, и спасибо за вклад в Open Source. Это важно и нужно.


      1. igrishaev
        16.07.2016 10:01
        +2

        Спасибо за полезный отзыв.


        Дело в том, что быстрее лист-компрехеншена в Питоне ничего быть не может. Это самый быстрый способ обработки коллекций. Над скоростью нужно поработать.


        Не согласен с тем, что вариант с компрехеншеном удобней. На мой взгляд, цепочка методов лучше описывает логику.


        Насчет предикатов — да, Decimal тоже нужно учесть. А строка не долна проходить проверку на число.


    1. Valeriy_K
      16.07.2016 19:55

      Чисто ради интереса, ваш код:
      foo = [ord(x) for x in 'abc']
      '-'.join(str(x) for x in foo[::-1])
      '99-98-97'

      , можете сказать, чем он лучше, например, такого:
      '-'.join(map(str, [ord(x) for x in 'abc'[::-1]]))
      '99-98-97'

      или даже такого:
      '-'.join(map(str, map(ord, 'abc'[::-1])))

      Читаемость? Или есть ещё какой-то смысл разделить более сложное выражение с мапами на два с генераторами?

      По скорости map выигрывает генераторы (на хабре статья была с измерениями, не найду уже), но если мой код в test_p2 и test_p3, то как-то так:

      print(timeit.timeit(test_p, number=1000), timeit.timeit(test_p2, number=1000), timeit.timeit(test_p3, number=1000))
      0.0027002159040421247 0.0017352891154587269 0.001657433109357953


      1. magic4x
        17.07.2016 18:13

        Вы просто оптимизировали, а я небольшой фанат матрешек, вот без мап:


        def test_map(): '-'.join(map(str, [ord(x) for x in 'abc'[::-1]]))
        def test_comp(): '-'.join(str(ord(x)) for x in 'abc'[::-1])
        print(timeit.timeit(test_map, number=1000), timeit.timeit(test_comp, number=1000))
        (0.003298044204711914, 0.0029828548431396484)

        У меня comprehension быстрее.


        1. Valeriy_K
          17.07.2016 18:56

          Если в test_map второй генератор тоже в map засунуть, то на моём железе отличие лишь в четвёртом знаке после запятой. В общем, на мой взгляд, ваш последний вариант наиболее ясен и элегантен.


          1. magic4x
            18.07.2016 11:26

            Проморгал, что не тот вариант засунул.


            2.7.11:


            In [1]: import timeit
            In [2]: def test_map(): '-'.join(map(str, map(ord, 'abc'[::-1])))
            In [3]: def test_comp(): '-'.join(str(ord(x)) for x in 'abc'[::-1])
            In [4]: print(timeit.timeit(test_map, number=1000), timeit.timeit(test_comp, number=1000))
            (0.0022649765014648438, 0.0031321048736572266)

            3.5.1:


            In [1]: import timeit
            In [2]: def test_map(): '-'.join(map(str, map(ord, 'abc'[::-1])))
            In [3]: def test_comp(): '-'.join(str(ord(x)) for x in 'abc'[::-1])
            In [4]: print(timeit.timeit(test_map, number=1000), timeit.timeit(test_comp, number=1000))
            0.001268998021259904 0.001320684008533135

            Мап быстрее в обоих случаях.


  1. SuperFly
    16.07.2016 09:29

    Я что-то не врубился в pcall. Можете прояснить?
    Заворачивать вызов в try с отловом четырех исключений означает сделать код абсолютно нечитаемым.
    А зачем заворачивать в 4? заверните в один, и будет то же что и в pcall
    try:
    user = get_user(use_id)
    except Exception as e:
    # Do something

    А если исключения все же нужно обрабатывать по разномо, то такой код помоему читабельней
    try:
    user = get_user(use_id)
    except Exception1 as e:
    # Do something
    except Exception2 as e:
    # Do something
    except Exception3 as e:
    # Do something

    чем

    err, user = pcall(get_user(use_id))
    if isinstance(err, Exception1):
    # Do something
    elif isinstance(err, Exception2):
    # Do something
    elif isinstance(err, Exception3):
    # Do something

    Или не так я использую pcall?


    1. SuperFly
      16.07.2016 09:36

      не знаю почему форматирование слетело. возможно как-то связано с моим read-only статусом


      1. igrishaev
        16.07.2016 12:10

        преимущество в том, что не приходится таскать за собой блок try-catch, который вы рано или поздно забудете.


        1. Alesh
          16.07.2016 18:31

          Да ладно, try-catch рано или поздно забудете, а проверить кортеж на наличие в нем исключения нет?)
          На мой взгляд просто внесение в язык полюбившейся фичи из другого языка, короче говоря не более чем вкусовщина.


          1. igrishaev
            16.07.2016 19:57

            а проверить кортеж на наличие в нем исключения нет?)

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


            1. Alesh
              17.07.2016 10:26
              +1

              Те кто забывают try-catch, особо не парясь будут использовать функции завернутые вашими декораторами в нижеприведенном стиле) И это гораздо хуже, чем если бы они просто забыли обработать исключение.

              @f.pcall_wraps
              def func(a, b):
                  return a / b
              
              func(4, 2)[1]
              >>> 2
              


  1. samo-delkin
    17.07.2016 02:02
    -2

    Предлагаешь делать каждый раз

    import f as fun
    


    Примеры накладок
    def f(x):
        return x * x + 1
    

    f = open('lalala')
    f.close()
    


    Да и python-pylint реагирует на такие имена, говорит «нужно прорефакторить имя, слишком короткое».

    Написал бы программу чисто на ФП, чтобы можно было посмотреть на читаемость (на соответствие Zen'у).

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


    1. igrishaev
      17.07.2016 10:58

      Предлагаешь делать каждый раз

      я этого не предлагал, где вы увидели?


      линтеры pylint и flake8 гибко настраиваются.


      мы уже похоронили второй питон

      никому не интересно, что именно вы похоронили. Я работал в компаниях, чей бизнес крутится исключительно на втором питоне.


      1. samo-delkin
        18.07.2016 11:12

        Я работал в компаниях, чей бизнес крутится исключительно на втором питоне.

        Да всем пофиг, где ты там работал. Тебе говорят про питон вообще. Ну, сдохнет твоя библиотека вместе со вторым питоном. Да она даже не оживёт, нечему подыхать будет.

        я этого не предлагал, где вы увидели?

        Ты занял имя, вместо того чтобы сделать, как все делают — уникальное имя, а дальше, кому надо, тот сократит до f.

        линтеры pylint и flake8 гибко настраиваются.

        Не, глупая идея. Предлагаешь из-за одного твоего модуля перенастраивать их и коснуться из-за этого всего остального. С какой стати? В том-то и дело, что ты выбрал имя неправильно.


        1. igrishaev
          18.07.2016 11:36
          -1

          Не пойму, с чего такой грубый тон? Словно я вас к чему-то принуждаю.


          Библиотека работает на диапазоне версий 2.6 — 3.5. Вы, наверное, не заметили это в тексте. Комментировать остальное я не считаю нужным.


          1. samo-delkin
            18.07.2016 11:57
            +2

            Я ходил на PyPI, вот твоя ссылка https://pypi.python.org/pypi/f и там Python 2.7 выставлен. Не можешь заполнить информацию о пакете правильно? А зачем выкатил тогда?


  1. janitor-rb-rus
    17.07.2016 15:45

    Интересно посмотреть на альтернативное решение частых задач. Можно использовать как библиотеку «под рукой» — набросать скриптик в консоли, обработать пачку данных. Лаконичность конструкций в такой ситуации — большой плюс


  1. VovanZ
    17.07.2016 19:41
    +1

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

    achain, ichain
    В питоне 3.6+ будет способ это делать нативно:
    None-aware operators — www.python.org/dev/peps/pep-0505

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

    Список и кортеж дополнительно реализуют .reversed, .sorted, .group, .distinct и .apply.
    Зачем? Это всё можно делать либо встроенными функциями, либо с помощью list/dict/set comprehensions, либо с помощью generator expressions.


    1. kmike
      20.07.2016 00:24

      pep-505 не факт, что примут, Гвидо не решил ничего)


  1. artifex
    18.07.2016 14:48

    Спасибо за библиотеку. Это набор функций, к которым всё равно так или иначе приходишь со временем, а у вас уже оформлено в пакет. Для себя утащил achain/ichain, comp и монады.

    Нельзя отвергать хорошие идеи и паттерны из других языков только потому, что они не соответствуют чувству прекрасного авторов Python.