Hi, I’m presenting PEP 767.
It enables the use of ReadOnly
within attribute annotations, as a way to remove [1] the ability to reassign or delete an attribute of an object.
Any feedback is welcome!
at a type checking level ↩︎
Hi, I’m presenting PEP 767.
It enables the use of ReadOnly
within attribute annotations, as a way to remove [1] the ability to reassign or delete an attribute of an object.
Any feedback is welcome!
at a type checking level ↩︎
Read-only attributes are covariant.
Does that mean that the type parameter T@ReadOnly
is covariant in the same way as e.g. T@Sequence
, or does it mean that the T
that’s used in a ReadOnly[T]
of a generic class or protocol must always be covariant?
To illustrate, consider this example:
class Box[T]:
thing: ReadOnly[T]
def match(self, other_thing: T, /) -> bool:
return other_thing == self.thing
In the first case, the T@Box
would inferred as invariant (so the same behaviour as with a @property
instead of a ReadOnly
). But in the second case, it would be covariant.
The reason I’m asking, is because if T
is explicitly marked as read-only within its defining generic type, then it’s safe to assume it won’t mutate. So even if T
is used to annotate a method parameter (or any anything else that in other situations might have led to a mutation of something that has type T
), the attribute of type T
won’t be changed. So there’s no need to make it invariant, and can therefore “stay” covariant.
I believe that this is a special case of what’s commonly referred to as use-site variance, and avoids situations where T@Box
is inferred as invariant.
This. It cannot force a type variable to be covariant. Rather, it limits the variance of T
to covariance. Further places where T
occurs may limit its variance further.
In your example, it appears in the type of a method parameter. Parameters of callables limit the variance of type variables to contravariance; application of both limits results in T
being invariant.
Typing docs for reference
Ok that’s too bad. Because I think PEP could also be used to improve the current variance inference, which can be unnecessarily restrictive, as my example shows.
That’s a pretty good explanation of the way that variance is currently inferred of Python.
But the point I’m trying to make is actually a bit more subtle than that, and was trying to show that Box[T]
with T
covariant is type-safe:
a: Box[int]
b: Box[object] = a
b.match(object()) # no problem
And even if we add another method to Box
with the same signature as match
, then this would still be type-safe. That’s because there is nothing of type T
in Box[T]
that can be mutated, thanks to ReadOnly[T]
.
Note that this is only the case for Box
itself, because there might be subtypes that override thing
in a mutable way.
There are some ways that variance could be fixed that have been brought up before, but that should probably be handled for variance as a whole rather than try to use this change to make it happen. For what you want to be sound, it has to be the case that subclasses that restrict variance more than the parent are considered an LSP violation and rejected (as they are no longer a suitable replacement for the parent behaviorally). This probably won’t ever happen because there are some extremely deeply rooted cases, like Sequence/MutableSequence, so the narrowing by the assumption of covariance can’t be safely done, and people are still proposing new exceptions to LSP despite the problems LSP violations cause, such as excluding __replace__
in 3.13+'s dataclasses.
The match
method in your example doesn’t really depend on the type of T
, so it could be likely replaced with object
(as is typically done for __eq__
or __contains__
)
A subclass doesn’t need to enable mutability for thing
to make covariant T
unsafe:
class Box[T]:
thing: ReadOnly[T]
def match(self, other_thing: T, /) -> bool: ...
class Rect(Box[int]):
def match(self, other: int, /) -> bool:
return self.thing.denominator == other.denominator
a: Box[object] = Rect()
a.match(object()) # boom
You could remove the ability to subclass Box
by decorating it with @typing.final
, in which case I can’t think of other problems.
However, that won’t work for protocols.
I think that makes sense, especially considering the difficulty of this topic.
This will probably have to wait until we variance can be manually declared (again).
Yes your example nicely illustrates when I said
This is something that with the current automatically inferred variances is difficult (but not impossible) to deal with.
I like the PEP, especially the open question at the bottom regarding __init_subclass__
.
I’ve done patterns where I utilize __init_subclass__
to force a child class defines class variables through class parameterizations, these class variables are read only from my point of view.
In the example below the desired behavior is for subclasses of OurABC
to be forced to have a class variable abstract_classvar
and for that classvar to be read only i.e. I want Concrete.abstract_classvar
to be readonly.
from abc import ABC
from inspect import isabstract
from typing import ClassVar, Optional
class OurABC(ABC):
abstract_classvar: ClassVar[int]
abstract: ClassVar[bool]
def __init_subclass__(cls, *, abstract_classvar: Optional[int] = None, abstract: bool = False):
cls.abstract = True
if not abstract and not isabstract(cls):
cls.abstract = False
if abstract_classvar is None:
# handling of requirement of classvar to be passed as class parameter
raise Exception
cls.abstract_classvar = abstract_classvar
return super().__init_subclass__()
class Concrete(OurABC, abstract_classvar=5):
...
It seems to me like it makes sense to allow initialization of ReadOnly[ClassVar[...]]
in __init_subclass__