functools (это такая свалка для всяких ненужных мне вещей :-).
— Гвидо ван Россум
Может показаться, что статья о ФП, но я не собираюсь обсуждать парадигму. Речь пойдет о переиспользовании и упрощении кода — я попытаюсь доказать, что вы пишете слишком много кода, поэтому он сложный и тяжело тестируется, но самое главное: его долго читать и менять.
В статье заимствуются примеры и/или концепции из библиотеки funcy. Во-первых, она клевая, во-вторых, вы сразу же сможете начать ее использовать. И да, нам понадобится ФП.
Кратко о ФП
- чистые функции
- функции высшего порядка
- чувство собственного превосходства над теми, кто пишет не функционально (необязательно)
ФП также присущи следующие приемы:
- частичное применение
- композирование (в python еще есть декораторы)
- ленивые вычисления
Если вам все это уже знакомо, переходите сразу к примерам.
Чистые функции
Чистые функции зависят только от своих параметров и возвращают только свой результат. Следующая функция вызванная несколько раз с одним и тем же аргументом выдаст разный результат (хоть и один и тот же объект, в данном случае %).
Напишем функцию-фильтр, которая возвращает список элементов с тру-значениями.
pred = bool
result = []
def filter_bool(seq):
for x in seq:
if pred(x):
result.append(x)
return result
Сделаем ее чистой:
pred = bool
def filter_bool(seq):
result = []
for x in seq:
if pred(x):
result.append(x)
return result
Теперь можно вызвать ее лярд раз подряд и результат будет тот же.
Функции высшего порядка
Это такие функции, которые принимают в качестве аргументов другие функции или возвращают другую функцию в качестве результата.
def my_filter(pred, seq):
result = []
for x in seq:
if pred(x):
result.append(x)
return result
Мне пришлось переименовать функцию, потому что она теперь куда полезнее:
above_zero = my_filter(bool, seq)
only_odd = my_filter(is_odd, seq)
only_even = my_filter(is_even, seq)
Заметьте, одна функция и делает уже много чего. Вообще-то, она должна быть ленивой, делаем:
def my_filter(pred, seq):
for x in seq:
if pred(x):
yield x
Вы заметили, что мы удалили код, а стало только лучше? Это лишь начало, скоро мы будем писать функции только по праздникам. Вот смотрите:
my_filter = filter
Встроенных возможностей python почти хватает для полноценной жизни, нужно лишь их грамотно компоновать.
Частичное применение
Это процесс фиксации части аргументов функции, который создает другую функцию, меньшей арности. В переводе на наш это functools.partial
.
filter_bool = partial(filter, bool)
filter_odd = partial(filter, is_odd)
filter_even = partial(filter, is_even)
Я понимаю, что это все азы ФП, но хочу отметить, что мы не написали ничего нового: мы взяли уже готовые функции и сделали другие. Основа новых — очень маленькие, простые, легкотестируемые функции, мы можем без опаски использовать их для создания более сложных.
Композирование
Такой простой, крутой и нужной штуки в python нет. Ее можно написать самостоятельно, но хотелось бы вменяемой сишной имплементации :(
def compose(*fns):
init, *rest = reversed(fns)
return lambda *a, **kw: reduce(lambda a, b: b(a), rest, init(*a, **kw))
Теперь мы можем делать всякие штуки (выполнение идет справа налево):
mapv = compose(list, map)
filterv = compose(list, filter)
Это прежние версии map
и filter
из второй версии python. Теперь, если вам понадобится неленивый map
, вы можете вызвать mapv
. Или по старинке писать чуть больше кода. Каждый раз.
Функции compose
и partial
прекрасны тем, что позволяют переиспользовать уже готовые, оттестированные функции. Но самое главное, если вы понимаете преимущество данного подхода, то со временем станете сразу писать их готовыми к композиции.
Это очень важный момент — функция должна решать одну простую задачу, тогда:
- она будет маленькой
- ее будет проще тестировать
- легко композировать
- просто читать и менять
- тяжело сломать
Пример
Задача: дропнуть None
из последовательности.
Решение по старинке (чаще всего даже не пишется в виде функции):
no_none = (x for x in seq if x is not None)
Обратите внимание: без разницы как называется переменная в выражении. Это настолько неважно, что большинство программистов тупо пишут x
, чтобы не заморачиваться. Все пишут этот бессмысленный код раз за разом. Каждый цензура раз: for
, in
, if
и несколько раз x
— потому что для компрехеншена нужен scope и у него есть свой синтаксис. Мы пишем: на каждую итерацию цикла присвоить переменной значение. И оно присваивается, и проверяется условие.
Мы каждый раз пишем этот бойлерплейт и пишем тесты на этот бойлерплейт. Зачем?
Давайте перепишем:
from operator import is_
from itertools import filterfalse
from functools import partial
is_none = partial(is_, None)
filter_none = partial(filterfalse, is_none)
# Использование
no_none = filter_none(seq)
# Переиспользование
all_none = compose(all, partial(map, is_none))
Все. Никакого лишнего кода. Мне приятно такое читать, потому что этот код (no_none = filter_none(seq)
) очень простой. То, как работает это функция, мне нужно прочитать ровно один раз за все время в проекте. Компрехеншен вам придется читать каждый раз, чтобы точно понять что оно делает. Ну или засуньте ее в функцию, без разницы, но не забудьте про тесты.
Пример 2
Довольно частая задача получить значения по ключу из массива словарей.
names = (x['name'] for x in users)
Кстати, работает очень быстро, но мы снова написали кучу ненужной фигни. Перепишем, чтобы работало еще быстрее:
from operator import itemgetter
def pluck(key, seq):
return map(itemgetter(key), seq)
# Использование
names = pluck('name', users)
А как часто мы это будем делать?
get_names = partial(pluck, 'name')
get_ages = partial(pluck, 'age')
# Сложнее
get_names_ages = partial(pluck, ('name', 'age'))
users_by_age = compose(dict, get_names_ages)
ages = users_by_ages(users) # {x['name']: x['age'] for x in users}
А если у нас объекты? Пф, параметризируй это:
from operator import itemgetter, attrgetter
def plucker(getter, key, seq):
return map(getter(key), seq)
pluck = partial(plucker, itemgetter)
apluck = partial(plucker, attrgetter)
# Использование
names = pluck('name', users) # (x['name'] for x in users)
object_names = apluck('name', users) # (x.name for x in users)
# Геттеры умеют сразу таплы данных
object_data = apluck(('name', 'age', 'gender'), users) # ((x.name, x.age, x.gender) for x in users)
Пример 3
Представим себе простой генератор:
def dumb_gen(seq):
result = []
for x in seq:
# здесь что-то проиcходит
result.append(x)
return result
Тут полно бойлерплейта: мы создаем пустой список, затем пишем цикл, добавляем элемент в список, отдаем его. Кажется, я буквально перечислил все тело функции :(
Правильным решением будут использование filter(pred, seq)
или map(func, seq)
, но иногда нужно сделать что-то сложнее, т.е. генератор написать действительно нужно. А если результат всегда нужен в виде списка или тапла? Да легко:
@post_processing(list)
def dumb_gen(seq):
for x in seq:
...
yield x
Это параметрический декоратор, работает он так:
result = post_processing(list)(dumb_gen)(seq)
Т.е. результатом первого вызова будет новая функция, которая примет функцию в качестве аргумента и вернет другую функцию. Звучит сложнее, чем есть:
def post_processing(post):
return lambda func: compose(post, func)
Обратите внимание, я использовал уже существующую compose
. Результат — новая функция, которую никто не писал.
А теперь стихи:
post_list = post_processing(list)
post_tuple = post_processing(tuple)
post_set = post_processing(set)
post_dict = post_processing(dict)
join_comma = post_processing(', '.join)
@post_list
def dumb_gen(pred, seq):
for x in seq:
...
yield x
Куча новых функций по цене одной! И я убрал бойлерплейт, функция стала меньше и намного симпатичнее.
Итог
Перебирая данные железобетонными функциями (чистыми, высшими), мы сохраняем простоту реализации и обеспечиваем стабильность программы, которую проще тестировать:
- пишите чистые функции, они обеспечат стабильность программы
- пишите функции высшего порядка, код станет намного компактнее и надежнее
- композируйте, декорируйте, частично применяйте, переиспользуйте код
- используйте сишные либы, они дадут скорости вашему софту
Как только вы напишете свой набор инструментов, новый код будет создаваться со знанием того, что у вас есть штука, которая может решить часть задачи. А значит софт будет меньше и проще.
С чего начать?
- обязательно ознакомьтесь с itertools, functools, operator, collections, в особенности с примерами в конце
- загляните в документацию funcy или другой фпшной либы, почитайте исходный код
- напишите свой
funcy
, весь он сразу вам не нужен, но опыт очень пригодится
Credits
В моем случае, использование ФП началось со знакомства с clojure — это штука капитально выворачивает мозги, настоятельно рекомендую посмотреть хотя бы видосы на ютубе.
Clojure как-то так устроен, что вам приходится писать проще, без привычных нам вещей: без переменных, без любимого стиля "романа", где сначала мы раскрываем личность героя, потом пускаемся в его сердечные проблемы. В clojure вам приходится думать %) В нем только базовые типы данных и "отсутствие синтаксиса" (с). И эту "простую" концепцию, оказывается, можно портировать в python.
UPD
Похоже, у читателей сложилось впечатление, будто я пишу сплошным ФП. Хочу всех успокоить: функциональный подход я использую исключительно в местах, где пишется код, который я уже писал. На мой взгляд, повторять "рабочие" приемы всякий раз глупо и бессмысленно, поэтому перевожу подобные куски в функции и использую их повторно. Рабочий пример можно посмотреть в комментарии.