Search code examples
pythonpython-3.xtype-hinting

Python type hint: Intersection of types (class implementing interface)


I have a python class defining an interface

class InterfaceFoo:
    pass

some abstract class

class AbstractBar:
    pass

and perhaps a concrete class

class Bar(InterfaceFoo):
    pass

implementing my interface (or, if you wish, both the interfaces Interface Foo and AbstractBar).

Now in some situations, I would like to have a type hint saying »I expect an instance of a class that is derived from AbstractBar and implements InterfaceFoo«. How can I do this in Python? Of course, I could go through my code and add all possible concrete classes that are subclasses of AbstractBar and implement InterfaceFoo but I think that's a very ugly solution.

def func(obj: AbstractBar implementing InterfaceFoo):
    pass

This is possible in other languages, e.g. Objective-C, where you can write BaseClass<Interface>. I'm wondering, if there is an analogue in Python to this?


Solution

  • The solution is indeed the typing.Protocol. However the challenge here lies in the fact that you want a combination of nominal subtyping (i.e. a type declared to inherit from another) and structural subtyping (i.e. a type has a certain interface).

    The Protocol is defined in a way that prohibits simply using multiple inheritance here, because, as any type checker will be quick to tell you, "all bases of a protocol must be protocols". So we cannot simply inherit from any AbstractBar and a Protocol. Details about this are outlined in this section of PEP 544.

    What we can do however is declare our abstract base class to be a protocol. Since it is abstract and thus not supposed to be instantiated directly, just like a protocol, this should work in most cases. Specifically, with the abc module from the standard library, we have the option of specifying an abstract base class without inheriting from abc.ABC by instead setting the metaclass to be abc.ABCMeta.

    Here is how that might look:

    from abc import ABCMeta, abstractmethod
    from typing import Protocol
    
    class AbstractBase(Protocol, metaclass=ABCMeta):
        @abstractmethod
        def foo(self) -> int:
            ...
    
    class SomeInterface(Protocol):
        def bar(self) -> str:
            ...
    
    class ABSomeInterface(AbstractBase, SomeInterface, Protocol):
        pass
    

    We can now define a function that expects its argument to be of type ABSomeInterface like so:

    def func(obj: ABSomeInterface) -> None:
        print(obj.foo(), obj.bar())
    

    Now if we want to implement a concrete subclass of AbstractBase and we want that class to be compliant with our SomeInterface protocol, we need it to also implement a bar method:

    class Concrete(AbstractBase):
        def foo(self) -> int:
            return 2
    
        def bar(self) -> str:
            return "x"
    

    Now we can safely pass instances of Concrete to func and type checkers are happy:

    func(Concrete())
    

    Conversely, if we had another subclass of AbstractBase that did not implement bar (and thus didn't follow our SomeInterface protocol), we would get an error:

    class Other(AbstractBase):
        def foo(self) -> int:
            return 3
    
    func(Other())
    

    mypy will complain like this:

    error: Argument 1 to "func" has incompatible type "Other"; expected "ABSomeInterface"  [arg-type]
    note: "Other" is missing following "ABSomeInterface" protocol member:
    note:     bar
    

    This is what we would expect and what we want. The abc functionality is also preserved by using the ABCMeta metaclass; so attempting to subclass AbstractBase without implementing foo would cause a runtime error upon reading that module.

    Just for the sake of completeness, here is a full working example, where SomeInterface is a generic protocol, to demonstrate that this also works as expected:

    from abc import ABCMeta, abstractmethod
    from typing import Protocol, TypeVar
    
    T = TypeVar("T")
    
    class AbstractBase(Protocol, metaclass=ABCMeta):
        @abstractmethod
        def foo(self) -> int:
            ...
    
    class SomeInterface(Protocol[T]):
        def bar(self, items: list[T]) -> T:
            ...
    
    class ABSomeInterface(AbstractBase, SomeInterface[T], Protocol):
        pass
    
    def func(obj: ABSomeInterface[str], strings: list[str]) -> None:
        n = obj.foo()
        s = obj.bar(strings)
        print(n * s.upper())
    
    class Concrete(AbstractBase):
        def foo(self) -> int:
            return 2
    
        def bar(self, items: list[T]) -> T:
            return items[0]
    
    if __name__ == "__main__":
        func(Concrete(), ["a", "b", "c"])
    

    It is important to note that there is no way around defining a "pure" SomeInterface protocol. We cannot simply have a SomeInterface class that we can both instantiate and use it as a protocol. That is in the nature of these things.

    Intersection types as such do not (yet) exist in Python as far as I know, so this structural approach is the best we can do.