Wednesday, 10 April 2024

Python callable

In most languages you can only invoke functions/methods, and in some languanges (python, javascript) they happen to be objects ("first class object" is the common way to say it). Normally those "invokable objects" have to be of a specific type (for example the Function type in JavaScript), but Python allows us to make any object invokable (callable) by adding a __call__ method to the class (the type) of the object. The other language I know with a similar feature is Kotlin, with the invoke operator function (we can define an invoke operator member function in a class and call it just by doing ob() on its instances, no need to do ob.invoke()). Notice that some time ago I wrote a post about my failed attempt to simulate this callable functionality in JavaScript.

The feature in Python has a quirk. When python sees a "function invocation" it'll search for the __call__ attribute in the object and invoke it. But __call__ is a dunder method, and contrary to what it does with normal attribute lookups, when searching "implicitly" a dunder method Python does not start the search in the object itself, but in its type. What I mean is that when coming across with a "person1()" call Python will search for a __call__ method in type(person1) (e.g. Person), not in person1. This means that if you want to do callable a specific instance of a class, not all its instances, adding a __call__ attribute to that instance won't work.

We can add a __call__ attibute to the class (Person) but that will make all persons invokable, and that's not what we want. Well, the solution is simple. We can create a new class (let's say CallablePerson) that inherits from Person and has that __call__ method, and change the class of p1 from Person to that new class, I mean: p1.__class__ = CallablePerson. I've thought it would be a nice feature to add to the ObjectLiteral class that we saw in a recent post a function do_callable() to make that literal object callable. Something like this:


class ObjectLiteral(SimpleNamespace):
    def bind(self, name: str, fn: Callable) -> "ObjectLiteral":
        setattr(self, name, MethodType(fn, self))
        return self
    
    def extend(self, cls, *args, **kwargs) -> "ObjectLiteral":
        parent_cls = types.new_class("Parent", (self.__class__, cls))
        self.__class__ = parent_cls
        cls.__init__(self, *args, **kwargs)
        return self

    # - Option 1
    def do_callable(self, fn: Callable) -> "ObjectLiteral":
        # don't add it directly to ObjectLiteral, as it would make all inatances callable, we only want this one to be it
        parent_cls = types.new_class("Parent", (self.__class__, ))
        parent_cls.__call__ = fn
        self.__class__ = parent_cls
        return self

p1 = ObjectLiteral(
    name="Xuan",
    age="50",
)

p1.do_callable(lambda self: print(f"hey {self.name} is callable!"))
p1()
# hey Xuan is callable!
    

That's the normal approach for when turning an instance callable is a decision taken after its class was designed. But if we know that at some point some instances of a class will want to become callable, we can design our class with this in mind beforehand. Define a __call__() in the class that will invoke a _call_imp() (for example) method in the instance if it exists, and otherwise thrown the same exception that we get if we try to invoke a not callable object. The do_callable() method will just add a _call_imp function to the object. What I mean is something like this:


class ObjectLiteral(SimpleNamespace):
    def bind(self, name: str, fn: Callable) -> "ObjectLiteral":
        setattr(self, name, MethodType(fn, self))
        return self
    
    def extend(self, cls, *args, **kwargs) -> "ObjectLiteral":
        parent_cls = types.new_class("Parent", (self.__class__, cls))
        self.__class__ = parent_cls
        cls.__init__(self, *args, **kwargs)
        return self

    # - Option 2
    def __call__(self, *args, **kwargs):
        if hasattr(self, "_call_imp"):
            return self._call_imp(self, *args, **kwargs)
        raise TypeError(f"'{type(self).__name__}' object is not callable")

    def do_callable(self, fn: Callable) -> "ObjectLiteral":
        self._call_imp = fn
        return self

No comments:

Post a Comment