Please, explain what is “wrong” with the current semantics:
-
Keep in mind that “typing.Generics don’t work like I expected” doesn’t mean that the semantics are wrong.
If you want to convince me that the current semantics are “wrong” (in the sense that they are implemented incorrectly and/or disagree with the formal specification), please point me to the relevant lines in one of the typing PEPs or a piece of documentation on typing.python.org or docs.python.org.
-
If you mean that the semantics are “wrong” (in the sense that they were designed incorrectly, so not only the current behavior is wrong, but also the spec/documentation is also wrong/incomplete), then you need to clearly articulate, why do you think that it’s wrong.
All of the typing specs were extensively discussed and eventually accepted. It’s possible that everyone just missed some crucial detail when designing some aspect of the type system, but such a claim needs extraordinary proof.
-
And if by “the semantics are wrong” you meant “it’s currently impossible to represent my use case using Generics”, then I don’t think that’s a very productive way to frame this discussion. It’s still possible that your use case is worth supporting, let’s just not jump straight to “typing.Generics semantics are wrong, we need to change them”.
Either way – if you want anything to be done about this issue, you need to make a concrete proposal (which would need to include at the very least: a formal specification of what exactly are you proposing to change, your rationale and alternatives that you’ve considered and rejected and a thorough backwards compatibility analysis).
It seems to me that you are operating under the assumption that static type hints and runtime behavior should match exactly 1 to 1. This is not the case, this has never been the case and this was never the goal of static typing.
Python uses dynamic typing. Static type hints are by definition distinct from actual runtime types. In fact, static type hints are neither a subset, nor a superset of the dynamic types that are actually possible at runtime.
There are cases, where static typing is intentionally more “permissive” compared to actual runtime behavior. The obvious examples are Any / missing annotations / gradual typing, but in general the static type system can’t always fully represent some complicated relationships / invariants that are actually always upheld at runtime.
More relevant to this discussion - there are also cases, where the static typing is intentionally stricter compared to actual runtime behavior. In fact, this is almost always the case. You can do almost anything you want at runtime (pass arguments of any type to any function, arbitrarily change the types of variables / attributes “on the fly”, etc and sometimes this might not even lead to an exception).
The whole purpose of the static type hint system is to empower programmers to express and document further additional restrictions that might not be present at runtime. I’ll repeat this once again – the purpose of typing.Generic is to express a certain kind of additional restriction that doesn’t exist at runtime.
So “these semantics don’t accurately reflect runtime” is NOT a good argument. During runtime, this
foo: Foo[int] = Foo(1)
foo.value = "str" # <- works just fine at runtime
works perfectly fine. So the fact that static type checkers reject this code also “doesn’t accurately reflect runtime”.
It’s a bit hard to have a productive debate, since you haven’t actually written down an exact specification for what you are proposing, but I’ll assume that your proposal is essentially this (slightly reworded by me):
The problem with this proposal is that it implicitly breaks almost all of collections.abc (and most other uses of Generics together with abstract base classes or inheritance).
If you make a Generic Foo “unspecialized by default” then you have two possible options:
-
You make it so that all subclasses of Foo must keep the exact same type bounds on all type parameters of Foo. This means that you can’t inherit from a specialization, only from the “unspecialized Foo”.
This breaks collections.abc because, for example, you can no longer implement a container class that only works with integers and make it inherit from Sequence[int], all subclasses of Sequence are forced to be generic with respect to their element type.
-
You allow inheriting from specializations. This means that a subclass of Foo can have “tighter” type bounds than Foo itself. This leads to unsound behavior (as demonstarted by the AlwaysStr example).
In the case of AlwaysStr, the self.__class__ in Foo.as_int must be some subclass of Foo[*], but AlwaysStr isn’t a subclass of Foo[*], it’s a subclass of Foo[str] (and Foo[str] isn’t a subclass of Foo[*] either).
Sorry, this sentence isn’t very clear. Mark classes that use what behavior? Mark them how?
Also, did you mean that your proposed change in behavior should be “opt-in” only? So the current semantics of typing.Generic would stay unchanged (and self.__class__ would refer to the “specialized” Foo[T], not Foo[*]) by default, and your Foo[*] behavior only comes into play, when the original author of Foo marks the class in some way?
My whole issue was with the asserion that the current Generic semantics are “wrong” and “should be changed”. If what you are proposing is an “opt-in” addition to the current semantics, then I have no issue with that.
Although, I would recommend making the “opt-in” behavior per-type-parameter rather than per-class. So something like
@dataclass
class Foo[T: InstanceTypeParameter[int | float | str]]:
value: T
def as_int(self) -> Foo[int]:
return self.__class__(int(self.value)) # ok
foo_str: Foo[str] = Foo("1") # ok
foo_int: Foo[int] = foo_str.as_int() # ok
# open question: how do you "spell" Foo[*] in code?
cls_foo: type[Foo[Unspecialized]] = foo_int.__class__ # ok
typ_foo: type[Foo[Unspecialized]] = type(foo_int) # ok
# this is not okay: inheriting from a specialized version of the class
class AlwaysStr(Foo[str]): ...
# this is not okay: inheriting with narrower type bounds
class NarrowerT[T: float | int](Foo[T]): ...
# open question: is this ok?
cls_foo_float: type[Foo[float]] = cls_foo # Foo[*] -> Foo[float] narrowing
# open question: how should "proper" subclasses be defined?
class ValidFooSubclass[T: InstanceTypeParameter[int | float | str]](Foo[T]):
...
# this is probably the most "correct" option, but it's a bit verbose
# and requires repeating the exact type bounds of T
The exact naming / syntax is up to bikeshedding. I called it InstanceTypeParameter, because it’s a type parameter that only applies to instances, not to the class type itself. Alternatively, it could be called ErasedTypeParameter or something else entirely (although, I think “type erasure” normally refers to the exact opposite behavior - when the type information is erased from the instance, not from the class, so maybe this isn’t a good name).
I don’t think we should add a new syntax like Foo[*] to the language for something so niche.