Home > Software engineering >  Returning Generics from functions without parameters
Returning Generics from functions without parameters

Time:12-04

I'm just getting into the more sophisticated typehinting stuff in Python, in particular, typing.Generic.

Say I have a base class and a subclass of that:

class Base:
    def base_method(self):
        pass


class Sub(Base):
    def sub_method(self):
        pass

Now I want to create a class that will have an instance variable that can be "a Base or any subclass thereof". To do this, I must make use of typing.TypeVar and typing.Generic:

class FullyTypedContainer(Generic[BaseOrSubclass]):
    def __init__(self, p: BaseOrSubclass):
        self._p = p

    @property
    def p(self) -> BaseOrSubclass:
        return self._p

This works great; the type of p is "passed along" via BaseOrSubclass so that language servers like PyLance will see that FullTypedContainer(Sub()).p has a method called sub_method() but FullyTypedContainer(Base()).p does not.

So what's the typehint for this function?

def get_list_of_containers():
    return [FullyTypedContainer(Sub()), FullyTypedContainer(Base())]
  • -> typing.List[FullyTypedContainer]: doesn't specify the type of FullyTypedContainer and so it seems that get_list_of_containers()[0].p is seen as type Any.

  • -> typing.List[FullyTypedContainer[Base]]: "coerces" everything to be of type Base, (not Base or one of its subclasses) and so get_list_of_containers()[0].p is seen as never having the method sub_method().

  • -> typing.List[FullyTypedContainer[typing.Union[Base, Sub]]: seems to be the best option, but would require me to manually maintain a list of every subclass of Base.

CodePudding user response:

There are two issues here, and both issues are related to variance of generic types: both List and your custom generic type FullyTypedContainer are invariant.

Assume Sub is a subtype of Base. Given a generic type GenType:

  • If GenType[Sub] is a subtype of GenType[Base], then it is covariant. A lot of containers are intuitively covariant.
  • If GenType[Base] is a subtype of GenType[Sub], then it is contravariant. This sounds counterintuitive, but the Callable type is actually contravariant w.r.t its argument types. A callable that takes a Base argument can be used where we require a callable that takes a Sub argument.
  • If neither of the above holds, then GenType is invariant.

The Python List type is invariant, and the variance of Generic types depends on its TypeVar -- and TypeVars by default are invariant. So to correctly type annotate your function, you need to make two changes:

  • Use a covariant sequence type, e.g. Sequence.
  • Make the BaseOrSubclass type variable covariant as well.

In code, it would look like this:

BaseOrSubclass = TypeVar("BaseOrSubclass", bound=Base, covariant=True)

class FullyTypedContainer(Generic[BaseOrSubclass]):
    def __init__(self, p: BaseOrSubclass): ...

def get_list_of_containers() -> Sequence[FullyTypedContainer[Base]]:
    return [FullyTypedContainer(Sub()), FullyTypedContainer(Base())]

Here's a more complete example on mypy-play: https://mypy-play.net/?mypy=latest&python=3.10&gist=85af8d25521c9d4b8454719685b37fc8


A note on List vs. Sequence:

Technically List is not equivalent to Sequence:

  • List always refers to the type for the builtin list.
  • Sequence is any "list-like" type that defines __getitem__, __len__, a few other methods like __reverse__ and count etc. This also covers the tuple, str, bytes types and such.

CodePudding user response:

lists are homogenous in their type: all elements have the same static type. The best you can do is List[FullyTypedContainer[Base]] or List[FullyTypedContainer[Union[Base, Sub]]. Or define a custom collection that allows you to constrain particular contents to particular types and use that instead of list

  • Related