Организация текучих (fluent) интерфейсов в Python

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





    Способ первый — в лоб


    Для построении цепочки операторов нам необходимо чтобы функция возвращала экземпляр класса. Это можно вручную задать.
    def add(self,x):
        self.val += x
        return self
    


    Очевидно что такой подход работает идеально, но мы ищем немножко другое.

    Способ второй — декораторы


    Это первая идея, пришедшая в голову. Определяем декоратор для методов в классе. Нам очень на руку что экземпляр передаётся первым аргументом.

    def chained(fn):
        def new(*args,**kwargs):
            fn(*args,**kwargs)
            return args[0]
        return new
    
    class UsefulClass1():
        def __init__(self,val): self.val = val
        @chained
        def add(self,val): self.val += val
        @chained
        def mul(self,val): self.val *= val
    


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

    >>> print UsefulClass1(10).add(5).mul(10).add(1).val
    151
    


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

    Способ третий — автоматический


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

    from types import MethodType
    
    class Chain(object):
        def __getattribute__(self,item):
            fn = object.__getattribute__(self,item)
            if fn and type(fn)==MethodType:
                def chained(*args,**kwargs):
                    ans = fn(*args,**kwargs)
                    return ans if ans!=None else self
                return chained
            return fn
    
    class UsefulClass2(Chain):
        val = 1
        def add(self,val): self.val += val
        def mul(self,val): self.val *= val
        def third(self): return 386
    


    Если метод возвращает значение — оно передаётся. Если нет — то вместо него идёт сам экземпляр класса.

    >>> print UsefulClass2().add(15).mul(16).add(-5).val
    251
    >>> print UsefulClass2().third()
    386
    


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

    Способ четвёртый — Im So Meta...


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

    from types import MethodType
    
    class MetaChain(type):
        def __new__(cls,name,bases,dict):
            old = dict.get('__getattribute__',object.__getattribute__)
            def new_getattribute(inst,val):
                attr = old(inst,val)
                if attr==None: return inst
                if attr and type(attr)==MethodType:
                    def new(*args,**kwargs):
                        ans = attr(*args,**kwargs)
                        return ans if ans!=None else inst
                    return new
                return attr
            dict['__getattribute__'] = new_getattribute
            return type.__new__(cls,name,bases,dict)
    
    class UsefulClass3():
        __metaclass__ = MetaChain
        def __getattribute__(self,item):
            if item=="dp": return 493
            return object.__getattribute__(self,item)
        val = 1
        def add(self,val): self.val += val
        def mul(self,val): self.val *= val
    


    От предыдущего варианта практически ничем не отличается — мы только контролируем создание __getattribute__ с помощью метакласса. Для обёртки рабочего класса достаточно указать __metaclass__.

    >>> print UsefulClass3().dp
    493
    >>> print UsefulClass3().add(4).mul(5).add(1).mul(25).add(-1).val
    649
    


    Как видно, имеющийся изначально __getattribute__ в рабочем классе работает. При наследовании от рабочего класса поведение сохраняется — __getattribute__ тоже наследуется. Если родной __getattribute__ не возвращает ничего (даже AttributeError), то мы тоже возвращаем сам объект.

    Вместо заключения


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

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

    PPS По просьбам трудящихся ссылки на упомянутую статью и описание в википедии

    Update

    Способ пятый — жаркое и очаг


    Тов. davinchi справедливо указал что обёртывать на каждом вызове по меньшей мере странно. Плюсом к этому, мы при каждом обращении к полям объекта прогоняем проверку.
    Теперь мы обработаем все методы сразу, но будем проверять модификацию и создание методов для того чтобы и их обернуть.
    class NewMetaChain(type):
        def __new__(cls,name,bases,dict):
            old = dict.get('__setattr__',object.__setattr__)
            def wrap(fn,inst=None):
                def new(*args,**kwargs):
                    ans = fn(*args,**kwargs)
                    return ans if ans!=None else inst or args[0]
                return new
            special = dir(cls)
            for item, fn in dict.items():
                if item not in special and isinstance(fn,FunctionType):
                    dict[item] = wrap(fn)
            def new_setattr(inst,item,val):
                if isinstance(val,FunctionType):
                    val = wrap(val,inst)
                return old(inst,item,val)
            dict['__setattr__'] = new_setattr
            return type.__new__(cls,name,bases,dict)
    
    class UsefulClass4():
        __metaclass__ = NewMetaChain
        def __setattr__(self,item,val):
            if val == 172: val = "giza"
            object.__setattr__(self, item, val)
        val = 1
        def add(self,val): self.val += val
        def mul(self,val): self.val *= val
        def nul(self): pass
    

    Помимо того что мы теперь при каждом вызове не оборачиваем методы (что дало ~30% прироста в скорости), мы ещё проводим необходимые проверки не на каждом считывании полей объекта, а на каждой записи (что происходит реже). Если запись отсутствует — работает так же быстро как и способ с декораторами.
    Метки:
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 34
    • –59
      Не читал статью, тк не интересен питон, но картинка весьма оригинально подобрана =)
      • +6
        А вот если я не читал ваш комментарий, судя по реакции сообщества стоит сразу минусануть? Не обижайтесь, я пошучиваю.
      • +2
        Очевидный недостаток — применимо только для методов, которые либо всегда возвращают None, либо всегда возвращают объект.

        Метод типа такого оборачивать небезопасно:
        def getChild(name):
        return self.internalDict.get(name)

        (да, можно спорить о том, что вместо None нужно вернуть null-object, но… все Non'ы замучаешься оборачивать)

        Ну и в целом (имхо) недостаток метода в том, что он размывает границы между mutable и immutable объектами: читая код, неочевидно, вызываются ли все add,mul на одном объекте либо на цепочке созданных объектов.

        Для похожего можно еще varargs использовать, здесь пример:

        stackoverflow.com/questions/3883907/designing-an-python-api-fluent-interface-or-arguments

        Вместо
        vis = new pv.Panel().width(150).height(150);

        использовать
        vis = pv.Panel(width=150, height=150)
        • 0
          Для процессинга картинок и подобных операций лично мне понравилась идея с Pipe-ами. Этот подход позволяет не трогать реализацию самих классов.

          И с getParent/getChild() вы верно заметили. Я бы лучше использовал второй вариант с декоратором: по крайней мере в моём коде потом не запутаются…
          • 0
            Спасибо за ссылку! Хех, трюк с __ror__ я раньше использовал (правда у меня был __rmod__), и честно думал что придумал что-то оригинальное :-)
          • 0
            varargs не подходит, если важен порядок операций:
            $('#foo').slideUp(300).delay(800).fadeIn(400).delay(800);
          • +1
            if fn and type(fn)==MethodType стоило бы заменить на isinstance(fn, MethodType).

            А по делу — хорошая демонстрация развития идеи и возможностей Пайтона :)
            • +3
              Вы бы рассказали, что это за интерфейсы такие, текучие. Не все читали статью про PHP.
              • 0
                Эта вечь больше известна под названием «чейнинг методов» (построение цепочки). Подход популяризован jQuery.
              • +11
                Метод с дек@ратором гораздо лучше, потому что explicit is better than implicit. Имхо.
                • +1
                  Тоже считаю, что лучше уж написать 10 «собачек», чем портить себе карму отзывами будущих поколений.
                  • +1
                    лучше написать «30 щенков»
                  • +1
                    Раз уж речь зашла о Python-way, то не лучше ли будет просто дописать «return self» в конце каждой функции? Цена тому — все таже одна строка кода, зато простоту понимания сторонним читателем повысит неимоверно, нежели декоратор.
                    • 0
                      Лучше конечно, но не так «илитарно» :)
                  • +3
                    Вставьте ссылку на статью про текучие интерфейсы на PHP в первое предложение поста, пожалуйста!
                    • 0
                      Ссылки указал. И, как уже упомянули выше, описать подход в двух словах можно: «как в jQuery».
                      • 0
                        Ссылку на статью стоит поставить в первое предложение. Собственно там, где вы её упоминаете.
                    • +1
                      Спасибо за статью! Добавьте, пожалуйста, пример реализации в википедию (текучие интерфейсы), а то там примеры на java, cpp, etc. есть, а на python нет.
                      • +1
                        аццкий сотона. все конечно весело и красиво, но не дай бог потом поддерживать и фиксить такой код в проекта с овер 200К строк кода, когда измениться апи, конструкции и версии
                        • 0
                          Тут же не говорится о том, что это нужно использовать везде и всегда. Такой подход можно применять для отдельных задач, частей проекта. Вообще познавательно. Спасибо автору.
                        • 0
                          способ 3 можно чуть модифицировать — если item.startswith('fl') оборачивать метод без 'fl' в fluent обертку, тогда при необходимости можно любой метод вызвать как текучий с сохранением доступа к основному «по умолчанию».

                          Хотим — пишем как:

                          instance.add(5)
                          instance.mul(3)


                          а хотим,

                          instance.fladd(5).flmul(3)
                          • 0
                            Очевидный недостаток — мы не можем использовать __getattribute__ в своих целях.
                            Не понял, разве с помощью super() это не решается?
                            • 0
                              Тогда надо модифицировать рабочий класс. Почему бы в таком случае не вписать всю обработку в него железно?
                            • 0
                              Можно на Ruby так подмешать, например… хотя смысла мало.

                              module Chaining
                                def included(base)
                                  base.class_eval do
                                    instance_methods.each do |method|
                                      alias "#{method}_without_chain" method
                                      define_method method do |*args, &block|
                                        r = block_given? ? send("#{method}_without_chain", *args, &block) : send("#{method}_without_chain", *args)
                                        return self if r.nil?
                                        r
                                      end
                                    end
                                  end
                                end
                              end
                              
                              ThingBase.class_eval { include Chaining }
                              
                              • +1
                                Третий способ от второго чем отличается-то? Ничем. Правильно как? Правильно искать методы в dict и замещать их на chained версии.

                                И почему вы каждый раз оборачиваете метод? Скорость работы себе представляете?
                                • 0
                                  Правильно искать методы в dict и замещать их на chained версии

                                  Соглашусь, не приходило в голову. Тогда надо ещё перехватывать __setattr__ для того чтобы можно было обернуть созданные или изменённые во время работы методы.
                                • 0
                                  плюс в карму за первое предложение! :) ну и за статью отдельное спасибо!
                                  • НЛО прилетело и опубликовало эту надпись здесь
                                    • 0
                                      на вкус и цвет фломастеры разные, а вообще тут дело семантики. я на пхп делал мини-валидатор с FI по типу
                                      $validator->validate($data,$rule)->validate($data,$rule)->isValid(); (проверка на корректность всех данных). Имхо очень удобно
                                      • 0
                                        как то так можно:

                                        dev = (
                                            Device.init(port=Configs.dispenser_port, baudrate=Configs.dispenser_baudrate)
                                            .test(donors=[0,1], destination=[-1])
                                            .dispense(amount=100, donors=[0])
                                            .disconnect())


                                        Например, я в SQLAlchemy запросы пишу таким образом

                                        users_jets = (
                                             s.query(User, UserJet)
                                             .join((UserJet, User.current_jet_id == UserJet.id))
                                             .filter(User.id.in_(players_ids))
                                             .all())
                                        • НЛО прилетело и опубликовало эту надпись здесь
                                          • 0
                                            >Не очевидно, что метод test может принадлежать не классу Device (например, Device.init может возвращать объект Dispenser). А выглядит это так, будто Device.test существует.

                                            определяющим фактором для fluent интерфейсов является сохранение контекста между вызовами методов (т.е. то, что метод возвращает self, а не что-то другое). поэтому en.wikipedia.org/wiki/Law_of_Demeter не нарушается и ни какой путаницы быть не может.

                                            по большому счёту, смысл всей этой затеи состоит в том, чтобы упростить выражение, «вынеся за скобки» повторяющееся имя переменной. такое повторение часто получается при конфигурировании объектов.

                                            существуют примеры реализации данного паттерна на уровне языка. например в VisualBasic, где программисты проводят массу времени, занимаясь конфигрурированием объектов:

                                            With testObject
                                                .Height = 100
                                                .Text = "Hello, World"
                                                .ForeColor = System.Drawing.Color.Green
                                                .Font = New System.Drawing.Font(.Font, System.Drawing.FontStyle.Bold)
                                            End With
                                            
                                      • 0
                                        Осталось ещё сравнить производительность всех вариантов. Выиграет наверняка только первый. Ибо всякие фокусы и манипуляции со строками, словаярями и метаинформацией затратны.
                                        • 0
                                          Вот забавная штука на эту тему
                                          github.com/JulienPalard/Pipe

                                          >>> from pipe import *
                                          >>> [1, 2, 3, 4, 5, 6] | average
                                          3.5

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