Skip to content

Mapping.values() return type is too narrow #4435

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
jab opened this issue Aug 10, 2020 · 9 comments
Closed

Mapping.values() return type is too narrow #4435

jab opened this issue Aug 10, 2020 · 9 comments

Comments

@jab
Copy link
Contributor

jab commented Aug 10, 2020

Currently, the type hint for Mapping.values() requires implementors of the Mapping interface to return a ValuesView:

class Mapping(_Collection[_KT], Generic[_KT, _VT_co]):
    ...
    def values(self) -> ValuesView[_VT_co]: ...

But ValuesView is too narrow. For example, an ABC for a BidirectionalMapping interface which extends the Mapping interface should be able to declare that the values() method returns an AbstractSet (since the values of a bidirectional mapping actually make up a set), as I need to do in my bidict library. Note that an AbstractSet satisfies the contract of a ValuesView. But currently declaring this causes the mypy error, Return type "AbstractSet[VT]" of "values" incompatible with return type "ValuesView[VT]" in supertype "Mapping".

(Notice that the type hints for Mapping.keys() and Mapping.items() return AbstractSet rather than KeysView and ItemsView, respectively, because even KeysView and ItemsView would have been too narrow there. In other words, those type hints are abstract enough. Only the type hint for values() is not abstract enough.)

It looks like one way to fix this would be to declare that Mapping.values() must return a Collection[VT] rather than a ValuesView[VT]. Since AbstractSet is a subtype of Collection, then BidirectionalMapping.values() (and other implementors) could return an AbstractSet[VT] (or any other subtype) and still satisfy the contract for Mapping.values().

Does that make sense, or would some other fix be better here?

@srittau
Copy link
Collaborator

srittau commented Aug 11, 2020

Mapping is not an interface, it's an abstract base class, implementing values() (and keys() and items()). Mapping.values() returns a ValuesView. I think we rather need to fix the return types of keys() and items() than that of values(). If a sub-class decides to override those methods and return a different type (thereby violating the Liskov substitution principle, strictly speaking), it can always be overridden using # type: ignore.

@jab
Copy link
Contributor Author

jab commented Aug 11, 2020

It is not a violation of Liskov if MyMapping.values() returns a subtype of Mapping.values(). In fact, that's what Liskov is all about.

If Mapping.values() will continue to return a ValuesView, then the types should be refactored so that KeysView is a subtype of ValuesView (whether via a Protocol or explicit inheritance), since a KeysView provides everything a ValuesView provides and more. It should be possible for a Mapping subtype to override inherited methods with return values that promise everything and more than the superclass returns without needing to use # type: ignore.

@jab
Copy link
Contributor Author

jab commented Aug 11, 2020

I think we rather need to fix the return types of keys() and items() than that of values()

I did some digging through git history and found that keys() and items() were first typed to return AbstractSet by @matthiaskramm in 337abed 5 years ago, and this return type has not changed since. @matthiaskramm hasn't committed to this repo for the last two years, but if anyone with more context can say whether the intention here is to support exactly the kind of subtype extension that this issue is calling for, that would be really helpful. Thank you!

@gvanrossum
Copy link
Member

It seems reasonable to have something for values() that is not a set, since it may have duplicates in general. Can we make up a Protocol for this?

@srittau
Copy link
Collaborator

srittau commented Aug 11, 2020

KeysView is not a subclass of ValuesView and therefore should not be typed as such. For example, isinstance(KeysView([]), ValuesView) returns False. Whether it is to be considered a sub-type or not is rather academic, but code can fail due to the lack of inheritance. Despite the preference for duck typing in Python, real life code often depends on the type of objects, even in the standard library.

Generally, in typeshed we prefer to annotate return types with the concrete type returned from the implementation, like we do in the Mapping.values() case. Since the annotations of keys() and items() are that old, they precede the best practices that have been established since then.

See also #3576 for some previous discussion about Mapping. If the problem is proper typing of sub-classes of Mapping, # type: ignore is the best bet (with the caveats pointed out above). If the problem is that certain functions expect a ValuesView-like argument (but not necessarily a concrete ValuesView), a protocol is the best solution, as Guido pointed out.

@jab
Copy link
Contributor Author

jab commented Aug 12, 2020

Thanks for weighing in @gvanrossum, and great to hear you both think a protocol makes sense here too! In the meantime, implementors in my situation will have to use # type: ignore, but we can link it to this issue and hopefully remove it at some point.

@JelleZijlstra
Copy link
Member

It doesn't seem like typeshed needs to change here.

For the original post, sounds like you want a class that inherits from both ValuesView and AbstractSet.

@jab
Copy link
Contributor Author

jab commented Dec 22, 2021

I thought this had been left open because we agreed a protocol would be better here, so that libraries like bidict wouldn't each have to reinvent their own such subclass.

@jab
Copy link
Contributor Author

jab commented Feb 7, 2022

@JelleZijlstra et al., here is the sort of subclass I currently resort to in order to get around this issue:

class BidictKeysView(typing.KeysView[KT], typing.ValuesView[KT]):
    """Since the keys of a bidict are the values of its inverse (and vice versa),
    the ValuesView result of calling *bi.values()* is also a KeysView of *bi.inverse*.
    """

But even with that, I still need to resort to typing.cast() to convince mypy to allow Bidict.values() to return self.inverse.keys():

    def values(self) -> BidictKeysView[VT]:
        """A set-like object providing a view on the contained values.

        Since the values of a bidict are equivalent to the keys of its inverse,
        this method returns a set-like object for this bidict's values
        rather than just a collections.abc.ValuesView.
        This object supports set operations like union and difference,
        and constant- rather than linear-time containment checks,
        and is no more expensive to provide than the less capable
        collections.abc.ValuesView would be.

        See :meth:`keys` for more information.
        """
        reveal_type(self.inverse.keys())  # Revealed type is "typing.KeysView[VT`2]"
        return t.cast(BidictKeysView[VT], self.inverse.keys())

So it still seems like a protocol would be a better solution. What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants