Пайплайны и частичное применения функций, зачем это в Python


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


    Кратко о ФП в Python и почему не хватает пайплайнов на примере


    В Python из базовых средств есть довольно удобные map(), reduce(), filter(), лямбда-функции, итераторы и генераторы. Малознакомым с этим всем советую данную статью. В целом это оно всё позволяет быстро и естественно описывать преобразования над списками, кортежами, и тд. Очень часто(у меня и знакомых питонистов) то, что получается однострочник — по сути набор последовательных преобразований, фильтраций, например:
    Kata с CodeWars: Найти


    $\forall n \in [a,b] : n=\sum_0^{len(n)} n_i ^ i, \text{ } n_i\text{ - i-й разряд числа n}$


    Задачка довольно простая, к сожалению(но к счастью для этого поста), решений лучше чем в лоб нет.


    Моё решение:


    def sum_dig_pow(a, b): # range(a, b + 1) will be studied by the function
        powered_sum = lambda x: sum([v**(i+1) for i,v in enumerate(map(lambda x: int(x), list(str(x))))])
        return [i for i in range(a,b+1) if powered_sum(i)==i]

    С использованием средств ФП как есть получается скобочный ад "изнутри наружу". Это мог бы исправить пайплайн.


    Пайплайны функций


    Под сим я подразумеваю такое в идеальном случае (оператор "|" — личное предпочтение):


    # f3(f2(f1(x)))
    f1 | f2 | f3 >> x
    pipeline = f1 | f2 | f3 
    pipeline(x)
    pipeline2 = f4 | f5
    pipeline3 = pipeline | pipeline2 | f6
    ...

    Тогда powered_sum может стать(код не рабочий):


    powered_sum = str | list | map(lambda x: int(x), *args) | enumerate | [v**(i+1) for i,v in *args] | sum

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


    from copy import deepcopy
    
    class CreatePipeline:
        def __init__(self, data=None):
            self.stack = []
            if data is not None:
                self.args = data
    
        def __or__(self, f):
            new = deepcopy(self)
            new.stack.append(f)
            return new
    
        def __rshift__(self, v):
            new = deepcopy(self)
            new.args = v
            return new
    
        def call_logic(self, *args):
            for f in self.stack:
                if type(args) is tuple:
                    args = f(*args)
                else:
                    args = f(args)
            return args
    
        def __call__(self, *args):
            if 'args' in self.__dict__:
                return self.call_logic(self.args)
            else:
                return self.call_logic(*args)

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


    pipe = CreatePipeline()
    powered_sum = pipe | str | list | (lambda l: map(lambda x: int(x), l)) | enumerate | (lambda e: [v**(i+1) for i,v in e]) | sum

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


    Частичное применение функций


    Рассмотрим на примере простейшей функции(код не рабочий):


    def f_partitial (x,y,z):
        return x+y+z
    v = f_partial(1,2)
    # type(v) = что-нибудь частично применённая функция f_partial, оставшиеся аргументы: ['z']
    print(v(3))
    # Эквивалент
    print(f_partial(1,2,3))

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


    powered_sum = pipe | str | list | map(lambda x: int(x)) | enumerate | (lambda e: [v**(i+1) for i,v in e]) | sum
    # map будет вызван ещё раз со вторым аргументом
    # map(lambda x: int(x))(данные) при вызове

    map(lambda x: int(x)) в пайплайне выглядит более лаконично в целом и в терминах последовательных преобразований данных.
    Кривенькая неполная реализация на уровне языка:


    from inspect import getfullargspec
    from copy import deepcopy
    
    class CreatePartFunction:
        def __init__(self, f):
            self.f = f
            self.values = []
    
        def __call__(self, *args):
            args_f = getfullargspec(self.f)[0]
            if len(args) + len(self.values) < len(args_f):
                new = deepcopy(self)
                new.values = new.values + list(args)
                return new
            elif len(self.values) + len(args) == len(args_f):
                return self.f(*tuple(self.values + list(args)))

    Реализация примера с учётом данного костыля дополнения:


    # костыль для обхода поломки inspect над встроенным map
    m = lambda f, l: map(f, l)
    # создаём частично применяемую функцию на основе обычной питоньей
    pmap = CreatePartFunction(m)
    powered_sum = pipe | str | list | pmap(lambda x: int(x)) | enumerate | (lambda e: [v**(i+1) for i,v in e]) | sum

    При более чем двойном вызове в строке(что в целом не особо нужно), придётся уже расставлять скобки, потому что питон подумает, что вызывается аргумент, то есть:


    def f (x,y,z):
        return x+y+z
    f = CreatePartFunction(f)
    # работает
    print(f(1,2,3))
    # работает
    print(f(1,2)(3))
    print(f(1)(2,3))
    # не работает
    # 2(3) - int не callable
    print(f(1)(2)(3))
    # работает
    print((f(1)(2))(3))

    Итоги


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

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

    Были бы вам полезны данные инструменты?

    • 75,8%Пайпланы47
    • 45,2%Частичное применение28
    • 21,0%Нет13

    Комментарии 14

      +2

      Пайпы выглядят полезно.


      Частичные фунцкии вроде же можно делать так:


      def f_partitial (x,y,z):
          return x+y+z
      
      my_partial_f = lambda z: f_partial(1, 2, z)
      
      my_partial_f(3) # same as: f_partial(1, 2, 3)

      Или я что-то не так понял?

        –1

        Да, конечно, их так и принято делать, но было бы на мой взгляд здорово иметь возможность сразу писать без лямбды:


        def f (x,y,z):
            return x+y+z
        my_pf = f(1,2)
        my_pf(3)
          +2

          Во первых это снизит читаемость из за неявного изменения поведению только по отсуствию параметра. Что если я забыл его передать? Или параметры со значением по умолчанию? Или *args?


          Например:


          def f (x, y, z=0):
              return x+y+z
          

          Во вторых как быть с необязательными параметрами функции? Мне кажется как есть достаточно.


          Явное лучше неявного.

            +1

            Да, пожалуй вы правы

        +9
        Про partial — я просто оставлю это тут: docs.python.org/3.8/library/functools.html#functools.partial
          +2
          Советую посмотреть на `dry-python/returns`: github.com/dry-python/returns

          Там есть:
          — Типизированные пайплайны: returns.readthedocs.io/en/latest/pages/pipeline.html
          — Типизированный `partial` и `curry`: returns.readthedocs.io/en/latest/pages/curry.html
            0
            А нельзя вместо пайплайна просто написать функцию, которой передать как аргументы имена функций — элементов пайплайна?
            Я против введения новых элементов в язык. Зачем еще один ад, уже есть C++. Если скучно, можно им заняться.
              +3
              Выскажу мнение всех программистов-не-функциональщиков:
              что пайплайны, что «однострочные» решения совершенно нечитаемы.
              (Суб)оптимальным по читабельности будет что-то в духе:

              def sum_dig_pow(a, b): # range(a, b + 1) will be studied by the function
                  def powered_sum(x):
                      digits = [int(symbol) for symbol in str(x)]
                      return sum([v**(i+1) for i, v in enumerate(digits)])
                  return [i for i in range(a, b + 1) if powered_sum(i) == i]
              
                +1

                Далее — некий псевдокод, вдохновленный соседним data science — языком:


                range(a, b+1) |
                    keep(x -> 
                         x |         
                         str |
                         map(int) |
                         map2((v, i) -> v **(i+1)) |
                         sum |
                         y -> eq(y, x))

                Я уверен, что можно отформатировать и сделать еще более читаемым.
                Собственно это мало чем отличается от предложенного автором, смысл же заключается в том, что выражение (а это именно выражение) читается слева-направо, сверху-вниз — и именно в этом порядке происходит обработка данных. В вашем примере приходится несколько раз переключатся между разными строками чтобы понять что происходит.

                  0
                  Обработка данных компьютером — да, а вот человеком — под вопросом.
                  Неподготовленный человек мыслит более декларативно. И читабельный код — он читабельный для человека. А как человек описал бы алгоритм?
                  1. Возьмем цифры числа. Цифры — это целочесленные значения (int) символов (symbol) цифр в строке (in str(x)).
                  2. Далее возьмем сумму (sum) степеней цифр (v ^ (i + 1)) от позиции в строке (enumerate(digits)).
                  3. Постановка задачи: найти те числа (i) в интервале от a до b (range(1, b+1)), для которых сумма степеней цифр равна самому числу (powered_sum(i) == i).
                  Как видите, предложеная процедурная запись полностью совпадает со структурой русского языка. Поэтому это читабельно.
                  Врочем, моя позиция в том, что я верю, что Вам вполне так же удобно в парадигме функционального программирования, как мне в литературного программирования.
                0

                Почему это f(1)(2)(3) не должно работать?
                По синтаксису питона, это всё разлагается на f.__call__(1).__call__(2).__call__(3), всё левоассоциативное, лишние скобки не нужны.


                И кстати, не нужны классы-шмаклассы. Вложенные функции отлично справляются.


                def curry(f):
                    arity = len(getfullargspec(f)[0])
                    # для простоты, забьём на функции с переменным числом аргументов!!!
                
                    def make(*bound):
                        def partial(*args):
                            combo = bound + args
                            assert len(combo) <= arity
                            if len(combo) == arity:
                                return f(*combo)
                            else:
                                return make(*combo)
                        # для простоты, забьём на создание документации
                        return partial
                
                    return make()
                  +1

                  Однострочники в стиле питона, на list (generator) comprehensions, более-менее отлично читаются, просто незачем их в одну строку пихать.


                  def solve_that(a, b):
                      return (
                          n                       # сразу ответ, что делаем: возвращаем n
                          for n in range(a, b+1)  # в заданном интервале
                          if n == sum(            # отвечающие условию
                              di ** i
                              for i, ci in enumerate(str(n))
                              for di in [int(ci)]  # лайфхак, присваивание внутри for-выражения
                              # ну, или можно было выше написать int(ci) ** i
                          )
                      )
                    +2

                    Ну и раз зашла пьянка про конвееры, то чуть компактнее (и без deepcopy)


                    class PipeValue:
                        def __init__(self, data):
                            self.data = data
                        def __or__(self, next_step):
                            return PipeValue(next_step(self.data))
                    
                    class PipeFun:
                        def __init__(self, steps = []):
                            self.steps = steps
                        def __or__(self, next_step):
                            return PipeFun(self.steps + [next_step])
                        def __call__(self, data):
                            return reduce(lambda d, step: step(d), self.steps, data)
                        def __rrshift__(self, data):
                            return PipeValue(self(data))
                    
                    def maps(f):
                        return lambda series: map(f, series)
                    
                    (PipeValue('hello') | list | maps(ord) | sum).data
                    (PipeFun() | list | maps(ord) | sum)('hello')
                    ('hello' >> PipeFun() | list | maps(ord) | sum).data
                    ('hello' >> (PipeFun() | list | maps(ord) | sum)).data
                      0
                      Есть compose
                      Есть Coconut

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

                      Самое читаемое