Hans Musgrave
Hans Musgrave

Reputation: 7131

Why don't non-function callables get bound to class instances?

Suppose we have a class we want to monkeypatch and some callables we want to monkeypatch onto it.

class Foo:
  pass

def bar(*args):
  print(list(map(type, args)))

class Baz:
  def __call__(*args):
    print(list(map(type, args)))
baz = Baz()

def wrapped_baz(*args):
  return baz(*args)

Foo.bar = bar
Foo.baz = baz
Foo.biz = wrapped_baz

Foo().bar()  # [<class '__main__.Foo'>]
Foo().baz()  # [<class '__main__.Baz'>]
Foo().biz()  # [<class '__main__.Baz'>, <class '__main__.Foo'>]

Even though baz is a callable, it doesn't get bound to a Foo() instance like the two functions bar and wrapped_baz. Since Python is a duck-typed language, it seems strange that the type of a given callable would play so heavily in the behavior of the object machinery.

Not that wrapping callables is necessarily a bad approach, are there other ways to have callables bound appropriately to Foo instances? Is this a quirk of the CPython implementation, or is there a portion of the language spec that describes the observed behavior?

Upvotes: 5

Views: 114

Answers (1)

MSeifert
MSeifert

Reputation: 152725

The reason for the difference is that functions implement the descriptor protocol, but your callable class does not. The descriptor protocol is part of the language specification.

When you look up an attribute on an instance or class it will check if the attribute on the class is a descriptor, i.e. if it has __get__, __set__, or __delete__. If it's a descriptor then attribute-lookup (getting, setting, and deleting) will go through these methods. If you want to know more about how descriptors work, you can check the official Python documentation or other answers here on StackOverflow, for example "Understanding __get__ and __set__ and Python descriptors".

Functions have a __get__ and thus if you look them up, they return a bound method. A bound method is a function where the instance is passed as first argument. I'm not sure this is part of the language specification (it probably is, but I couldn't find a reference).

So your bar and wrapped_baz functions are descriptors, but your Baz class isn't. So the bar (and the wrapped_baz) function will be looked up as "bound method" where the instance is implicitly passed into the arguments when called. However the baz instance is returned as-is, so there's no implicit argument when called.

Making your Baz class more method-like

Depending on what you want, you can make your Baz act like a method by implementing a __get__:

import types

# ...

class Baz:
    def __get__(self, instance, cls):
        """Makes Baz a descriptor and when looked up on an instance returns a
        "bound baz" similar to normal methods."""
        if instance is None:
            return self
        return types.MethodType(self, instance)
    def __call__(*args):
        print(list(map(type, args)))

# ...

Foo().baz()  # [<class '__main__.Baz'>, <class '__main__.Foo'>]

Making the wrapped_baz less method-like

Or if you don't want the Foo (similar to your Baz class) then just wrap the wrapped_baz as staticmethod:

# ...

class Baz:
  def __call__(*args):
    print(list(map(type, args)))

baz = Baz()

@staticmethod
def wrapped_baz(*args):
    return baz(*args)

# ...

Foo().biz()  # [<class '__main__.Baz'>]

Upvotes: 3

Related Questions