@classmethod не вызывает __get__ моего пользовательского дескриптора

У меня есть декоратор с именем Special, который превращает функцию в две версии самой себя: одну, которую можно вызывать напрямую и добавлять к результату префикс 'regular ', и другую, которую можно вызывать с помощью .special и добавлять к результатам префикс 'special ':

class Special:
    def __init__(self, func):
        self.func = func

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return Special(self.func.__get__(instance, owner))

    def special(self, *args, **kwargs):
        return 'special ' + self.func(*args, **kwargs)

    def __call__(self, *args, **kwargs):
        return 'regular ' + self.func(*args, **kwargs)

Он отлично работает с обычными методами и статическими методами, но .special не работает с методами класса:

class Foo:
    @Special
    def bar(self):
        return 'bar'

    @staticmethod
    @Special
    def baz():
        return 'baz'

    @classmethod
    @Special
    def qux(cls):
        return 'qux'

assert Foo().bar() == 'regular bar'
assert Foo().bar.special() == 'special bar'

assert Foo.baz() == 'regular baz'
assert Foo.baz.special() == 'special baz'

assert Foo.qux() == 'regular qux'
assert Foo.qux.special() == 'special qux'  # TypeError: qux() missing 1 required positional argument: 'cls'
  • Foo().bar вызывает __get__, который связывает базовую функцию и передает связанный метод новому экземпляру Special, поэтому и Foo().bar(), и Foo().bar.special() работают.

  • Foo.baz просто возвращает исходный экземпляр Special, где и обычные, и специальные вызовы просты.

  • Foo.qux связывается без вызова моего __get__.

    • The new bound object knows to pass the class as the first argument when being called directly - so Foo.qux() works.
    • Foo.qux.special просто вызывает .special базовой функции (classmethod не знает, как ее связать), поэтому Foo.qux.special() вызывает несвязанную функцию, следовательно, TypeError.

Есть ли способ для Foo.qux.special узнать, что он вызывается из classmethod? Или как-то иначе обойти эту проблему?


person Idan Arye    schedule 13.06.2018    source источник


Ответы (2)


classmethod — это дескриптор, который возвращает связанный метод. Он не вызывает ваш метод __get__ в этом процессе, потому что он не может сделать это без нарушения некоторых контрактов протокола дескриптора. (А именно, тот факт, что instance должен быть экземпляром, а не классом.) Таким образом, ваш метод __get__ не вызывается, что вполне ожидаемо.

Так как же заставить его работать? Ну, подумайте об этом: вы хотите, чтобы и some_instance.bar, и SomeClass.bar возвращали экземпляр Special. Чтобы добиться этого, вы просто применяете декоратор @Special последним:

class Foo:
    @Special
    @staticmethod
    def baz():
        return 'baz'

    @Special
    @classmethod
    def qux(cls):
        return 'qux'

Это дает вам полный контроль над тем, если/когда/как вызывается протокол дескриптора декорированной функции. Теперь вам просто нужно удалить особый случай if instance is None: в вашем методе __get__, потому что он препятствует правильной работе методов класса. (Причина в том, что объекты classmethod не могут быть вызваны; вы должны вызвать протокол дескриптора, чтобы превратить объект classmethod в функцию, которую можно вызвать.) Другими словами, метод Special.__get__ должен безоговорочно вызывать метод __get__ декорированной функции, например это:

def __get__(self, instance=None, owner=None):
    return Special(self.func.__get__(instance, owner))

И теперь все ваши утверждения пройдут.

person Aran-Fey    schedule 13.06.2018
comment
Спасибо. Я бы предпочел иметь возможность применить @classmethod в последнюю очередь (это имеет больше смысла), но я думаю, что это придется делать... - person Idan Arye; 14.06.2018
comment
Это действительно проходит все его тесты, но не Foo().qux. Другими словами, специальный метод класса не действует как метод класса. - person abarnert; 14.06.2018
comment
@abarnert Не уверен, что ты имеешь в виду. Foo().qux() возвращает обычный qux, а Foo().qux.special() возвращает специальный qux. Что не работает? - person Aran-Fey; 14.06.2018
comment
@Aran-Fey Aran-Fey Извините, перепутал. Это работает для Foo().qux, но не работает для Foo.qux(). Смотрите мой ответ для объяснения, почему. - person abarnert; 14.06.2018
comment
@abarnert Да, это потому, что вы не удалили if instance is None: return self в __get__. Думаю, мне следует попытаться прояснить эту часть ответа. - person Aran-Fey; 14.06.2018

Проблема в том, что classmethod.__get__ не вызывает __get__ обернутой функции, потому что в этом, по сути, и заключается весь смысл @classmethod. Вы можете посмотреть на чистый Python-эквивалент classmethod в Дескрипторы HOWTO или фактический исходный код CPython C в funcobject.c, но вы увидите, что на самом деле это невозможно.


Конечно, если вы просто @Special @classmethod, а не наоборот, все будет нормально работать при вызове экземпляра:

class Foo:
    @Special
    @classmethod
    def spam(cls):
        return 'spam'

assert Foo().spam() == 'regular spam'
assert Foo().spam.special() == 'special spam'

… но теперь это не будет работать при вызове класса:

assert Foo.spam() == 'regular spam'
assert Foo.spam.special() == 'special spam'

… потому что вы пытаетесь вызвать объект classmethod, который нельзя вызвать.


Но эта проблема, в отличие от предыдущей, поправима. Фактически, единственная причина, по которой это не удается, - это эта часть:

if instance is None:
    return self

Когда вы пытаетесь привязать экземпляр Special к классу, он просто возвращает self вместо привязки своего обернутого объекта. Это означает, что он оказывается просто оболочкой вокруг объекта classmethod, а не оболочкой связанного метода класса, и, конечно же, вы не можете вызывать объект classmethod.

Но если вы просто пропустите это, базовая функция classmethod будет выполнять привязку так же, как это делает обычная функция, которая делает то, что нужно, и теперь все работает:

class Special:
    def __init__(self, func):
        self.func = func

    def __get__(self, instance, owner=None):
        return Special(self.func.__get__(instance, owner))

    def special(self, *args, **kwargs):
        return 'special ' + self.func(*args, **kwargs)

    def __call__(self, *args, **kwargs):
        return 'regular ' + self.func(*args, **kwargs)

class Foo:
    @Special
    def bar(self):
        return 'bar'

    @Special
    @staticmethod
    def baz():
        return 'baz'

    @Special
    @classmethod
    def qux(cls):
        return 'qux'

assert Foo().bar() == 'regular bar'
assert Foo().bar.special() == 'special bar'

assert Foo.baz() == 'regular baz'
assert Foo.baz.special() == 'special baz'
assert Foo().baz() == 'regular baz'
assert Foo().baz.special() == 'special baz'

assert Foo.qux() == 'regular qux'
assert Foo.qux.special() == 'special qux'
assert Foo().qux() == 'regular qux'
assert Foo().qux.special() == 'special qux'

Конечно, это вызовет проблемы с переносом несвязанных объектов методов в Python 2.7, но я думаю, что ваш дизайн уже ломается для обычных методов в 2.7, и, надеюсь, вас в любом случае интересует только 3.x.

person abarnert    schedule 14.06.2018