I have a class structure that looks something like this:
class Base:
class Nested:
pass
def __init__(self):
self.nestedInstance = self.Nested()
where subclasses of Base
each have their own Nested
class extending the original, like this:
class Sub(Base):
class Nested(Base.Nested):
pass
This works perfectly, and instances of Sub
have their nestedInstance
attributes set to instances of Sub.Nested
.
However, in my IDE the nestedInstance
attribute is always treated as an instance of Base.Nested
, not the inherited Sub.Nested
. How can I make it so that nestedInstance
will be inferred to be Sub.Nested
rather than Base.Nested
? (Without having to add extra code to every subclass; preferably, this would all be done in Base
.)
(By the way, I'm aware that this is an odd structure, and I can go into detail about why I chose it if necessary, but I think it's pretty elegant for my situation, and I'm hoping there's a solution to this problem.)
CodePudding user response:
It gets typed as Base.Nested
because that's what's in scope at initialisation. If you want to declare that nestedInstance
is actually something else, then you'll probably need to actually type-hint it.
class Sub(Base):
class Nested(Base.Nested):
pass
nestedInstance: Nested
CodePudding user response:
I don't agree with the statement that you were trying to violate the Liskov substitution principle. You were merely looking for a way to let a static type checker infer the type of nested_instance
for classes inheriting from Base
to be their respective Nested
class. Obviously this wasn't possible with the code you had; otherwise there would be no question.
There actually is a way to minimize repetition and accomplish what you want.
Generics to the rescue!
You can define your Base
class as generic over a type variable with the upper bound of Base.Nested
. When you define Sub
as a subclass Base
, you provide a reference to Sub.Nested
as the concrete type argument. Here is the setup:
from typing import Generic, TypeVar, cast
N = TypeVar("N", bound="Base.Nested")
class Base(Generic[N]):
nested_instance: N
class Nested:
pass
def __init__(self) -> None:
self.nested_instance = cast(N, self.Nested())
class Sub(Base["Sub.Nested"]):
class Nested(Base.Nested):
pass
This is actually all you need. For more info about generics I recommend the relevant section of PEP 484. A few things to note:
Why do we need the bound
?
If we were to just use N = TypeVar("N")
, the type checker would have no problem if we wanted do define a subclass like this:
class Broken(Base[int]):
class Nested(Base.Nested):
pass
But this would be a problem since now the nested_instance
attribute would be expected to be of the type int
, which is not what we want. That upper bound on N
will prevent this causing mypy
to complain:
error: Type argument "int" of "Base" must be a subtype of "Nested" [type-var]
Why explicitly declare nested_instance
?
The whole point of making a class generic is to bind some type variable (like N
) to it and then indicate that some associated type inside that class is in fact N
(or even multiple). We essentially tell the type checker to expect nested_instance
to always be of the type N
, which must be provided, whenever Base
is used to annotate something.
However, now the type checker will always complain, if we ever omit the type argument for Base
and tried an annotation like this: x: Base
. Again, mypy
would tell us:
error: Missing type parameters for generic type "Base" [type-arg]
This may be the only "downside" to the use of generics in this fashion.
Why cast
?
The problem is that inside Base
, the nested_instance
attribute is declared as a generic type N
, whereas in Base.__init__
, we assign an instance of the specific type Base.Nested
. Even though it may seem like this should work, it does not. Omitting the cast
call results in the following mypy
error:
error: Incompatible types in assignment (expression has type "Nested", variable has type "N") [assignment]
Are the quotes necessary?
Yes, and importing __future__.annotations
does not help here. I am not entirely sure why that is, but I believe in case of the Base[...]
usage the reason is that __class_getitem__
is actually called and you cannot provide Sub.Nested
to it because it is not even defined at that point.
Full working example
from typing import Generic, TypeVar, cast
N = TypeVar("N", bound="Base.Nested")
class Base(Generic[N]):
nested_instance: N
class Nested:
pass
def __init__(self) -> None:
self.nested_instance = cast(N, self.Nested())
class Sub(Base["Sub.Nested"]):
class Nested(Base.Nested):
pass
def get_nested(obj: Base[N]) -> N:
return obj.nested_instance
def assert_instance_of_nested(nested_obj: N, cls: type[Base[N]]) -> None:
assert isinstance(nested_obj, cls.Nested)
if __name__ == '__main__':
sub = Sub()
nested = get_nested(sub)
assert_instance_of_nested(nested, Sub)
This script works "as is" and mypy
is perfectly happy with it.
The two functions are just for demonstration purposes, so that you see how you could leverage the generic Base
.
Additional sanity checks
To assure you even more, you can for example add reveal_type(sub.nested_instance)
at the bottom and mypy
will tell you:
note: Revealed type is "[...].Sub.Nested"
This is what we wanted.
If we declare a new subclass
class AnotherSub(Base["AnotherSub.Nested"]):
class Nested(Base.Nested):
pass
and try this
a: AnotherSub.Nested
a = Sub().nested_instance
we are again correctly reprimanded by mypy
:
error: Incompatible types in assignment (expression has type "[...].Sub.Nested", variable has type "[...].AnotherSub.Nested") [assignment]
Hope this helps.
PS
To be clear, you can still inherit from Base
without specifying the type argument. This has no runtime implications either way. It's just that a strict type checker will complain about it because it is generic, just as it would complain if you annotate something with list
without specifying the type argument. (Yes, list
is generic.)
Also, whether or not your IDE actually infers this correctly of course depends on how consistent their internal type checker is with the typing rules in Python. PyCharm for example seems to deal with this setup as expected.