Skip to content

Reconsider making Mapping covariant in the key type #445

Closed
@JukkaL

Description

@JukkaL

Confusion with invariant container types is one of the more common questions mypy users have. Often these can be easily worked around by using a covariant type such as Sequence instead of List. However, there is no fully covariant dictionary-like type -- Mapping is invariant in the key type, since the key type is used as an argument type of __getitem__ and get. This is particularly problematic in Python 2, as mappings with unicode keys are common, and a mapping with str keys is not compatible with them, even though they are fine at runtime (at least if the keys are ascii only).

I can see a few things we could do.

1) Don't care about unsafety

We'd make Mapping covariant even though it's known to be unsafe (#273). The argument is that the potential unsafety is less of a problem than the confusing user experience. This wouldn't help with certain related things like using a unicode key to access Mapping[str, X], which is arguably okay.

Note that this was proposed and rejected earlier.

2) Use object in Mapping argument types

We'd make Mapping covariant and use object in the argument type of __getitem__ and get. This would result in less effective type checking, but I'd consider this to still be correct at least for get, since it can always fall back to the default. Also, __getitem__ would typically only generate a KeyError if given a key of an unrelated type, and this is arguably not a type error. Indexing operations on mappings can already fail with KeyError, and this isn't considered a type safety issue.

We could also recommend that type checkers special case type checking of Mapping.__getitem__ and Mapping.get (and also the corresponding methods in subclasses such as Dict). A reasonable rule would be to require that the actual key type in code like d[k] overlaps with the declared key type. This would allow things like using unicode keys for Mapping[str, int], which should perhaps be fine in Python 2. We could still reject things like d[1] if the declared key type is str, since int and str are not overlapping.

Subclasses of Mapping that only support specific runtime key types would now be rejected. For example, this wouldn't work any more:

class IntMap(Mapping[int, int]):
    def __getitem__(self, k: int) -> int: ...   # needs an object argument
    ...

However, it would be easy enough to refactor this to type check:

class IntMap(Mapping[int, int]):
    def __getitem__(self, k: object) -> int: ...
        assert isinstance(k, int)  # sorry, can't deal with non-int keys
        ...
    ...

I think that this is reasonable -- the required assert (or cast) makes it clear that this is potentially unsafe.

3) Just teach users to work around using unions or type variables

Maybe keeping Mapping is invariant in the key type is reasonable. Often the lack of covariance can be worked around by using other type system features. Here are some examples:

  • Use Union[Mapping[str, int], Mapping[unicode, int]] instead of Mapping[unicode, int].
  • Use Mapping[AnyStr, int] in Python 2 and Mapping[str, int] in Python 3 using a conditional type alias.
  • Define a type variable like AnyText = TypeVar('AnyText', str, Text) and use Mapping[AnyText, int]. This is a little awkward, since in Python 3 the values of the type variable would be str and str.

I don't like these for a few reasons:

  • These are not intuitive.
  • These are inconsistent with other containers such as Sequence.
  • These are more verbose than just Mapping[unicode, int].

Metadata

Metadata

Assignees

No one assigned

    Labels

    resolution: wontfixA valid issue that most likely won't be resolved for reasons described in the issue

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions