Why does a read-only Mapping
not work as a type hint for a Dict
attribute?
I know dict
is mutable, which makes the field
invariant, but could you explain what can go wrong with passing it to a read-only Mapping
type?
Consider the following code:
from typing import Dict, Mapping, Protocol
class A:
field: Dict
class B(Protocol):
field: Mapping
def f(arg: B):
print(arg)
f(A())
This code will raise a type error at the call to f(A())
in pyright:
Argument of type "A" cannot be assigned to parameter "arg" of type "B" in function "f"
"A" is incompatible with protocol "B"
"field" is invariant because it is mutable
"field" is an incompatible type
"Dict[Unknown, Unknown]" is incompatible with "Mapping[Unknown, Unknown]"
and in mypy:
error: Argument 1 to "f" has incompatible type "A"; expected "B" [arg-type]
note: Following member(s) of "A" have conflicts:
note: field: expected "Mapping[Any, Any]", got "Dict[Any, Any]"
Why is this? The B protocol
defines an attribute field of type Mapping
, which should include both mutable and immutable mappings. However, A
class defines an attribute field of type Dict
, a mutable mapping object.
Shouldn't a read-only Mapping
work in place of a Dict
object, since it only provides read-only access to the mapping object?
The code in the question is really not type safe, type checkers are correct in their suggestions.
If you have an argument annotated as Mapping
, you can assign to it anything you want - it does not affect the caller's passed value. When you pass any mutable or nested structure containing a Mapping
, you should still be allowed to assign anything to that item/field. Let's look at the following:
from collections import Counter
from typing import Dict, Mapping, Protocol
class B(Protocol):
field: Mapping[str, int]
class A:
field: Dict[str, int]
def f(arg: B) -> None:
arg.field = Counter()
print(arg)
a = A()
f(a)
assert isinstance(a.field, dict) # Oops
Is f
correct from typing perspective? Yes, it got something with Mapping
field and replaced it with another Mapping
, nothing to worry about. Hm? But the caller of f
passed A
instance in, and might still (correctly) expect that field
value remains a dict
. If the type checker remains silent, this error is not noticed.
To avoid this warning, you can subclass the protocol directly, marking the A
class as implementer. This is a subtle place in type system, which is difficult to explain (probably it's about exchanging strictness for simplicity), because your implementation will still fail with the same error, but without type checker worries. The same happens with usual classes: subclasses can override fields with more specific field subtypes. Such usage is denied only for protocols (for usual classes LSP is not violated, because attributes mutation is explicitly prohibited) - probably because they serve a different purpose. As per PEP544:
By default, protocol variables as defined above are considered readable and writable. To define a read-only protocol variable, one can use an (abstract) property.
Here's a playground with a bit more code to play with.