Привет, коллеги! Я расскажу о библиотеке для Питона с лаконичным названием 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", > Рассмотрим другие функции библиотеки. Мне бы хотелось выделить f.achain и f.ichain. Обе предназначены для безопасного извлечения данных из объектов по цепочке. Предположим, у вас Джанго со следующими моделями: Order => Office => Department => Chief При этом все поля not null и вы без страха ходите по смежным полям: order = > Да, я в курсе про 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, > 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 > Улучшенные коллекции можно складывать с любой другой коллекцией. Результатом станет новая коллекция этого (левого) типа: # Слияние словарей 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 > Монады Последний и самый сложный раздел в библиотеке. Почитав цикл статей о монадах, я отважился добавить в библиотеку их тоже. При этом позволил себе следующие отклонения: Проверки входных значений основаны не на типах, как в Хаскеле, а на предикатах, что делает монады гибче. Оператор > в Хаскеле невозможно перенести в Питон, поэтому он фигурирует как >> (он же __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 > 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. Я надеюсь, специалисты по ФП простят неточности в формулировках. Буду рад замечаниям в комментариях. Спасибо за внимание.
Создание блога на Symfony 2.8 lts [ Часть 5.1]