Thursday, 6 July 2023

Python ABC's subclasshook and more

I think the combination in Python of dynamic features with "static friendly" features like ABC's and type hints is really amazing. While keeping all its dynamism, the language has become rather "static friendly" if you want it. One rather surprising mechanism is the possibility of interfering in the process of checking if a class inherits from another class (checked with the issubclass function) or if an object is an instance of a class (checked with the isinstance function).

issubclass(Student, Person) and isinstance(p1, Person) will search for a __subclasscheck__ and an __instancecheck__ dunder method respectively. They don't check in the Person class, but in the class of the class (the metaclass). So if you are not using some new metaclass it will end up using the methods in the type metaclass (the type class is implemented in pure c, so the type.__dict__["__instancecheck__"] and type.__dict__["__subclasscheck__"] functions are functions in typeobject.c).

The __isinstance__ and __issubclass__ mechanism was added to expand, not to restrict, the cases when an object passes the isinstance, issubclass check, and should not be used for the contrary, for restricting (that is, making objects that comply with that type-check fail), which indeed should have little use. If you try to use it for restriction you'll get odd results, it will work for the issubclass check, but will fail for some isinstance checks.

This is because of an optimization applied to __instancecheck__ explained here

an optimization in isinstance's implementation that immediately returns True if type(obj) == given_class:

You can see here the confusing results that you'll get if used for restriction (I have no idea of why for the issubclass check they do not apply an "if cls == cls" optimization


    # https://stackoverflow.com/questions/52168971/instancecheck-overwrite-shows-no-effect-what-am-i-doing-wrong

class Meta1(type):
    def __instancecheck__(cls, inst):
        """Implement isinstance(inst, cls)."""
        print("__instancecheck__")
        #return super().__instancecheck__(inst)
        return False
    
    def __subclasscheck__(cls, sub):
        """Implement issubclass(sub, cls)."""
        print("__subclasscheck__")
        #return super().__subclasscheck__(sub)
        return False
    

class Person1(metaclass=Meta1):
    pass

class Worker1(Person1):
    pass


p1 = Person1()
print(isinstance(p1, Person1))
# True
# it uses the optimization and our custom __isinstance__ is not even invoked
# type(p1) 

print("------------------------")

p1 = Worker1()
print(isinstance(p1, Person1))
# False
# the optimization returns False, so it invokes our custom __isinstance__ that returns False

print("------------------------")

print(issubclass(Person1, Person1))
# False
# __subclasscheck__ is invoked and returns False

print("------------------------")

print(issubclass(Worker1, Person1))
# False
# __subclasscheck__ is invoked and returns False
print("------------------------")



This machinery was added to python in part to give "extra powers" to abstract classes. It makes easier performing "multiple duck type checks". Let's say you need an object that conforms to an "interface" defined by an abc, but you don't need it to inherit from that class, you just want it to have those methods (duck typing). Furthermore let's say you don't need just one method of the abc, but all or many of them. Checking beforehand if all those methods exist rather than calling methods in the object until one of them fails cause it's not implemented seems like a better option. The __subclasshook__ was born for that. You can define a @classmethod def __subclasshook__(cls, subclass) for that. abc's have ABCMeta as metaclass, and __isinstance__ and __issubclass__ have been defined in it, and both of them will invoke the __subclasshook__ of that abstract class if it exists. The source code for this is in _py_abc.py.


from abc import ABC, abstractmethod

class Movable(ABC):
    @abstractmethod
    def move_rigth(self, x):
        pass

    @abstractmethod
    def move_left(self, x):
        pass

    @classmethod
    def __subclasshook__(cls, C):
        # Both isinstance() and issubclass() checks will call into this (through ABCMeta.__instancecheck__, ABCMeta.__subclasscheck__ )
        if cls is Movable:
            # Note that you generally check if the first argument is the class itself. 
            # That's to avoid that subclasses "inherit" the __subclasshook__ instead of using normal subclass-determination.
            return hasattr(C, "move_right") and hasattr(C, "move_left")
        return NotImplemented


class Button:
    def move_right(self, x):
        print("moving rigth")

    def move_left(self, x):
        print("moving left")

# Button is a Movable in duck-typing and structural typing terms
# but it does not strictly implement the Movable "interface" (it does not inherit from Movable)
# thanks to the __subclasshook__ both checks below are true

if issubclass(Button, Movable):
    bt = Button()
    bt.move_right(4)
    bt.move_left(2)


bt = Button()
if isinstance(bt, Movable):
    bt.move_right(4)
    bt.move_left(2)


#moving rigth
#moving left
#moving rigth
#moving left


This discussion probably explains all this quite better than this post :-)

Additionally there's an alternative/complementary way to declare that a class complains with the "interface" defined by an abc, the ABCMeta.register method. With this you declare that specific classes complain with the "interface" rather than defining the conditions to comply with it, as you do with __subclasshook__. The collections.abc module is a very interesting sample of the use of these mechanisms.

No comments:

Post a Comment