Search code examples
pythonmypypython-typingliskov-substitution-principle

Python liskov substition principle and custom init


I am writing classes with custom init functions that provide async initialization. This all works well, except that when I create a subclass and override the async init function, mypy tells me I am violating the liskov substitution principle. This leaves me with two questions:

  • How can I change my code so mypy understands that the function signature is different on purpose? Similarly to __init__. My goal is to make ImplA and ImplB as simple as possible as these are classes implemented by my users. AsyncInit can be super complicated.
  • Does __init__ violate the liskov substitution principle?
from typing import TypeVar, Type, Any

TChild = TypeVar("TChild", bound="AsyncInit")


class AsyncInit:
    @classmethod
    async def new(cls: Type[TChild], *args: Any, **kwargs: Any) -> TChild:
        self = super().__new__(cls)
        await self.ainit(*args, **kwargs)  # type: ignore # ignore that TChild does not have `ainit` for now
        return self


class ImplA(AsyncInit):
    async def ainit(self, arg1: int, arg2: float) -> None:
        self.a = arg1
        self.b = arg2


class ImplB(ImplA):
    async def ainit(self, arg1: str, arg2: float, arg3: int) -> None:
        await super().ainit(arg2, arg3)
        self.c = arg1

Solution

  • The initialization from a class is generally not covered by LSP, which is concerned with the substitution of instances. As per the definition of LSP: (emphasis mine)

    Subtype Requirement: Let ϕ(x) be a property provable about objects x of type T. Then ϕ(y) should be true for objects y of type S where S is a subtype of T.

    In Python's lingo, "objects x of type T" are "instances of T". As such, operations on type T itself are not covered by LSP. In specific, that means instantiation between subtypes does not need to be substitutable.

    As such, both __new__ and __init__ are commonly exempt by type checkers from subtyping constraints as their canonical usage is during instantiation.


    Things are trickier for alternate constructors via classmethod: a classmethod can be – and commonly is – called on an instance. As such, a classmethod is considered part of the instance behaviour and thus subject to subtyping constraints by type checkers.
    This especially applies to alternate initializers which are not distinguishable from regular methods.

    There is currently no proper way to make the initializers well-typed (e.g. by parameterizing over parameters) nor alternative designs with equal usability (e.g. external constructors registered for the type).
    The simplest means is to actually tell the type checker that a method is not part of subtyping constraints. For MyPy, this is done via # type: ignore [override].

    class ImplB(ImplA):
        async def ainit(self, arg1: str, arg2: float, arg3: int) -> None:  # type: ignore [override]
            await super().ainit(arg3, arg2)
            self.c = arg1
    

    However, it is usually worth to consider whether an alternate async construction that is not comparable across subtypes actually makes sense: it means the caller already has async capability (to await the construction) and has to use custom code per class (to provide specific arguments) anyway. This means it is usually possible to pull the entire async construction out into the caller.