Can you specify variance in a Python type annotation?

怎甘沉沦 提交于 2020-06-16 07:07:46

问题


Can you spot the error in the code below? Mypy can't.

from typing import Dict, Any

def add_items(d: Dict[str, Any]) -> None:
    d['foo'] = 5

d: Dict[str, str] = {}
add_items(d)

for key, value in d.items():
    print(f"{repr(key)}: {repr(value.lower())}")

Python spots the error, of course, helpfully informing us that 'int' object has no attribute 'lower'. Too bad it can't tell us this until run time.

As far as I can tell, mypy doesn't catch this error because it allows arguments to the d parameter of add_items to be covariant. That would make sense if we were only reading from the dictionary. If we were only reading, then we would want the parameter to be covariant. If we're prepared to read any type, then we should be able to read string types. Of course, if we were only reading, then we should type it as typing.Mapping.

Since we're writing, we actually want the parameter to be contravariant. For instance, it would make perfect sense for someone to pass in a Dict[Any, Any], since that would be perfectly capable of storing a string key and integer value.

If we were reading and writing, there would be no choice but for the parameter to be invariant.

Is there a way to specify what kind of variance we need? Even better, is mypy sophisticated enough that it should be reasonable to expect it to determine the variance through static analysis, and this should be filed as a bug? Or is the current state of type checking in Python simply not able to catch this kind of programming error?


回答1:


Your analysis is incorrect -- this actually has nothing to do with variance, and the Dict type in mypy is actually invariant w.r.t. to its value.

Rather, the problem is that you've declared the value of your Dict to be of type Any, the dynamic type. This effectively means that you want mypy to just basically not type-check anything related to your Dict's values. And since you've opted out of type-checking, it naturally won't pick up any type-related errors.

(This is accomplished by magically placing Any at both the top and bottom of the type lattice. Basically, given some type T, it's the case that Any is always a subtype of T and T is always a subtype of Any. Mypy auto-magically picks whichever relationship results in no errors.)

You can see that Dict is invariant for yourself by running the following program:

from typing import Dict

class A: pass
class B(A): pass
class C(B): pass

def accepts_a(x: Dict[str, A]) -> None: pass
def accepts_b(x: Dict[str, B]) -> None: pass
def accepts_c(x: Dict[str, C]) -> None: pass

my_dict: Dict[str, B] = {"foo": B()}

# error: Argument 1 to "accepts_a" has incompatible type "Dict[str, B]"; expected "Dict[str, A]"
# note: "Dict" is invariant -- see http://mypy.readthedocs.io/en/latest/common_issues.html#variance
# note: Consider using "Mapping" instead, which is covariant in the value type
accepts_a(my_dict)

# Type checks! No error.
accepts_b(my_dict)

# error: Argument 1 to "accepts_c" has incompatible type "Dict[str, B]"; expected "Dict[str, C]"
accepts_c(my_dict)

Only the call to accept_b succeeds, which is consistent with the expected variance.


To answer your question about how to set the variance -- mypy is designed so that the variance of data structures is set at definition time and cannot really be altered at call time.

So since Dict was defined to be invariant, you can't really change after-the-fact to be either covariant or invariant.

For more details about setting variance at definition-time, see the mypy reference docs on generics.

As you pointed out, you can declare you want to accept a read-only version of the Dict by using Mapping. It's generally the case that there's a read-only version of any PEP 484 data structure you might want to use -- e.g. Sequence is the read-only version of List.

AFAIK there's no default write-only version of Dict though. But you can sort of hack one together yourself by using protocols, a hopefully-soon-to-be-standardized method of doing structural, rather than nominal, typing:

from typing import Dict, TypeVar, Generic
from typing_extensions import Protocol

K = TypeVar('K', contravariant=True)
V = TypeVar('V', contravariant=True)

# Mypy requires the key to also be contravariant. I suspect this is because
# it cannot actually verify all types that satisfy the WriteOnlyDict
# protocol will use the key in an invariant way.
class WriteOnlyDict(Protocol, Generic[K, V]):
    def __setitem__(self, key: K, value: V) -> None: ...

class A: pass
class B(A): pass
class C(B): pass

# All three functions accept only objects that implement the
# __setitem__ method with the signature described in the protocol.
#
# You can also use only this method inside of the function bodies,
# enforcing the write-only nature.
def accepts_a(x: WriteOnlyDict[str, A]) -> None: pass
def accepts_b(x: WriteOnlyDict[str, B]) -> None: pass
def accepts_c(x: WriteOnlyDict[str, C]) -> None: pass

my_dict: WriteOnlyDict[str, B] = {"foo": B()}

#  error: Argument 1 to "accepts_a" has incompatible type "WriteOnlyDict[str, B]"; expected "WriteOnlyDict[str, A]"
accepts_a(my_dict)

# Both type-checks
accepts_b(my_dict)
accepts_c(my_dict)

To answer your implicit question ("How do I get mypy to detect the type error here/properly type check my code?"), the answer is "simple" -- just avoid using Any at all costs. Every time you do, you're intentionally opening a hole in the type system.

For example, a more type-safe way of declaring that your dict's values can be anything would have been to use Dict[str, object]. And now, mypy would have flagged the call to add_items function as being un-typesafe.

Or alternatively, consider using TypedDict if you know your values are going to be heterogeneous.

You can even make mypy disallow certain usages of Any by enabling the Disable dynamic typing family of command-line flags/config file flags.

That said, in practice, completely disallowing the use of Any is often unrealistic. Even if you can meet this ideal in your code, many 3rd party libraries are either unannotated or not fully annotated, which means they resort to using Any all over the place. So expunging their use altogether unfortunately tends to end up requiring a lot of extra work.



来源:https://stackoverflow.com/questions/55154847/can-you-specify-variance-in-a-python-type-annotation

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!