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:
__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.__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
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.