18 April 2011

Пайпы, the pythonic way

Python
Одни питонисты любят код читаемый, другие предпочитают лаконичный. К сожалению, баланс между первым и вторым — решения по-настоящему изящные — редко случается встретить на практике. Чаще стречаются строки вроде
my_function(sum(filter(lambda x: x % 3 == 1, [x for x in range(100)])))
Или четверостишья а ля
xs = [x for x in range(100)]
xs_filtered = filter(lambda x: x % 3 == 1, xs)
xs_sum = sum(xs_filtered)
result = my_function(xs_sum)
Идеалистам же хотелось бы писать как-то так
result = [x for x in range(100)] \
    | where(lambda x: x % 3 == 1)) \
    | sum \
    | my_function

Не в Питоне?

Простую реализацию подобных цепочек не так давно предложил некий Julien Palard в своей библиотеке Pipe.

Начнем сразу с примера:
from pipe import *   
[1,2,3,4] | where(lambda x: x<=2)
#<generator object <genexpr> at 0x88231e4>

Упс, интуитивный порыв не прокатил. Пайп возвращает генератор, значения из которого еще только предстоит извлечь.
[1,2,3,4] | where(lambda x: x<=2) | as_list
#[1, 2]

Можно было бы вытащить значения из генератора встроенной функцией приведения типа list(), но автор инструмента был последователен в своих изысканиях и предложил нам функцию-пайп as_list.

Как видим, источником данных для пайпов в примере стал простой список. Вообще же говоря, использовать можно любые итерируемые (iterable) сущности Питона. Скажем, «пары» (tuples) или, что уже интересней, те же генераторы:
def fib():
    u"""
    Генератор чисел Фибоначчи
    """
    a, b = 0, 1
    while 1:
        yield a
        a, b = b, a + b
        
fib() | take_while(lambda x: x<10) | as_list
#0
#1
#1
#2
#3
#5
#8
Отсюда можно извлечь несколько уроков:
  1. в пайпах можно использовать списки, «пары», генераторы — любые iterables.
  2. результатом объединения генераторов в цепочки станет генератор.
  3. без явного требования (приведения типа или же специального пайпа) пайпинг является «ленивым» в том смысле, что цепочка есть генератор и может служить бесконечным источником данных.

Разумеется, радость была бы неполной, не будь у нас легкой возможностисоздавать собственные пайпы. Пример:
@Pipe
def custom_add(x):
    return sum(x)
[1,2,3,4] | custom_add
#10
Аргументы? Легко:
@Pipe
def sum_head(x, number_to_sum=2):
    acc = 0
    return sum(x[:number_to_sum])
[1,2,3,4] | sum_head(3)
#6
Автор любезно предоставил достаточно много заготовленных пайпов. Некоторые из них:
  • count — пересчитать число элементов входящего iterable
  • take(n) — извлекает из входного iterable первые n элементов.
  • tail(n) — извлекает последние n элементов.
  • skip(n) — пропускает n первых элементов.
  • all(pred) — возвращает True, если все элементы iterable удовлетворяют предикату pred.
  • any(pred) — возвращает True, если хотя бы один элемент iterable удовлетворяют предикату pred.
  • as_list/as_dist — приводит iterable к списку/словарю, если такое преобразование возможно.
  • permutations(r=None) — составляет все возможные сочетания r элементов входного iterable. Если r не определено, то r принимается за len(iterable).
  • stdout — вывести в стандартный поток iterable целиком после приведения к строке.
  • tee — вывести элемент iterable в стандартный поток и передать для дальнешей обработки.
  • select(selector) — передать для дальнейшей обработки элементы iterable, после применения к ним функции selector.
  • where(predicate) — передать для дальнейшей обработки элементы iterable, удовлетворяющие предикату predicate.
А вот эти поинтересней:
  • netcat(host, port) — для каждого элемента iterable открыть сокет, передать сам элемент (разумеется, string), передать для дальнейшей обработки ответ хоста.
  • netwrite(host, port) — то же самое, только не читать из сокета после отправления данных.
Эти и другие пайпы для сортировки, обхода и обработки потока данных входят по умолчанию в сам модуль, благо создавать их действительно легко.

Под капотом декоратора Pipe


Честно говоря, удивительно было увидеть, насколько лаконичен базовый код модуля! Судите сами:
class Pipe:

    def __init__(self, function):
        self.function = function

    def __ror__(self, other):
        return self.function(other)

    def __call__(self, *args, **kwargs):
        return Pipe(lambda x: self.function(x, *args, **kwargs))
Вот и все, собственно. Обычный класс-декоратор.

В конструкторе декоратор сохраняет декорируемую функцию, превращая ее в объект класса Pipe.

Если пайп вызывается методом __call__ — возвращается новый пайп функции с заданными аргументами.

Главная тонкость — метод __ror__. Это инвертированный оператор, аналог оператора «или» (__or__), который вызывается у правого операнда с левым операндом в качестве аргумента.

Получается, что вычисление цепочки начинается слева направо. Первый элемент передается в качестве аргумента второму; результат вычисления второго — третьему и так далее. Безболезненно проходят по цепочке и генераторы.

На мой взгляд, очень и очень элегантно.

Послесловие


Синтаксис у такого рода пайпов действительно простой и удобный, хотелось бы увидеть что-то подобное в популярных фреймворках; скажем, для обработки потоков данных; или — в декларативной форме — выстраивания в цепочки коллбеков.

Единственным недостатком именно реализации являются довольно туманные трейсы ошибок.

О развитии идеи и альтернативных реализация — в следующих статьях.
Tags:pythonpipes
Hubs: Python
+79
12.3k 136
Comments 81