«Простое» программирование на python

Habrahabr

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

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