(hopefully last lead up discussion topic on intersections needed)
Right now, overload resolution rules allow producing mostly good results, but to get those, there’s a significant amount of verbosity. I expect this to get in some ways better, and in other ways worse if intersections are accepted as currently being drafted.
Something I was considering which would reduce the amount of manual expansions users need to write themselves which could improve both existing cases and some new cases that could arise with intersections, is the ability to specify a small extra bit of overload resolution behavior.
This would be accomplished by adding a keyword argument to the overload decorator. It would have a default that is equivalent to the existing behavior.
The proposed behaviors:
- Match one (default, existing behavior)
- First non-gradual match (new, implies negating all prior paramter overloads)
- Intersecting match (new, implies intersecting return types for overlapping matches)
The behavior of “match one” remains as the current behavior specifies, including the existing union expansion behavior that means when a union is the input, if each union member matches an overload, the union of the returns of matching return. This includes keeping the existing warning for ambiguously overlapping overloads.
The behavior of “First non-gradual match” is that the first overload that matches wins when the parameters passed are not gradual. In the case of gradual parameters (such as Any), this automatically degrades to matching gradually to preserve the gradual guarantee.
This is an improvement in cases of overlapping types, but the implementation prefers dispatching on a specific type. With intersections and negation, cases like this can be expressed without such a rule, but the verbosity involved is significant. Consider the below case, keeping in mind that real numeric libraries have their own types like this, and that expanding this without first match behavior would involve overlapping overloads, while at runtime, the interior of such functions can be written as a series (in order) of type checks.
from typing import overload, SupportsFloat, TypeVar
OurNumericsT = TypeVar("OurNumericsT", OurFloat, OurI32, OurI64) # # etc
@overload(kind="first_match")
def foo(x: OurNumericsT) -> OurNumericsT:
...
@overload(kind="first_match")
def foo(x: float) -> float:
...
@overload(kind="first_match")
def foo(x: SupportsFloat) -> OurFloat:
...
The “First wins” nature here can also be checked by typecheckers that recognize narrowing patterns that match the parameters, whether by case match or isinstance chaining, allowing checking that return types match those declared. While I won’t propose this as required, it’s a possible quality of life improvement typecheckers can implement using this.
The last kind for intersecting_match is an improvement for use with intersections, but also simplifies existing cases for correctness with multiple inheritance or composition of protocols
class AIn(Protocol):
...
class BIn(Protocol):
...
class AOut(Protocol):
...
class BOut(Protocol):
...
class A(Protocol):
def foo(self, x: AIn, /) -> AOut: ...
class B(Protocol):
def foo(self, x: BIn, /) -> BOut: ...
With these, there is no way to write a unified C without either intersections + negation, ambiguous overloads, or returning a wider interface that might not be reasonable (eg. what if one of these has a property that only makes sense with A, and B doesn’t provide the info needed)
With intersections and negation, you can write:
A & B, but implementing A & B as C is difficult and verbose in cases where A & B may not be disjoint over parameters of methods, and the verbosity expands with parameters.
Without intersections and negation, and without the “intersecting match” option, it’s not possible to return the minimal interface unambiguously if the parameter sets are not disjoint.
Without the “intersecting_match” overload, but with intersections and negation, implementing the minimal interface is possible for that case.
For comparison in verbosity, this is without the intersecting match for a single parameter:
class C:
@overload
def foo(self, x: AIn & BIn, /) -> AOut & BOut: ...
@overload
def foo(self, x: AIn & ~BIn, /) -> AOut: ...
@overload
def foo(self, x: ~AIn & BIn, /) -> BOut: ...
def foo(self, x: Ain | BIn) -> AOut | BOut: ...
With multiple parameters, this quickly gets out of hand in the same way that overloads already get out of hand for mixes of flags controlling behavior of functions does, as there needs to be an overload for “This but not that, that but not this, and both this and that”. The last one can be excluded for disjoint types, though use cases involving protocols are rarely disjoint.
With intersecting match, intended for exactly the cases of multiple inheritence, composition of protocols via intersections, and other cases where intersections involve overloads, the overload count does not increase combinatorically, but instead linearly.
The rules for this are almost the same as the existing overload rules, except that
- It’s not erroneous to have overlapping overloads
- When overlapping overloads are matched, the resulting type is the intersection of the overlapping matches.
- In the case of gradual parameters and matching, the existing union expansion rules are triggered to retain the gradual match.
The type of a concrete implementation of A & B as C with this then looks like the below:
class C:
@overload(kind="intersecting_match")
def foo(self, x: AIn, /) -> AOut: ...
@overload(kind="intersecting_match")
def foo(self, x: BIn, /) -> BOut: ...
def foo(self, x: AIn | BIn, /) -> AOut | BOut:
...
This generally scales linearly with the number of composed classes/protocols for what users need to write.
There is still some complexity this can’t cut out if intersecting two protocols that themselves have differing overloads that are either of the other modes, but that is expected to be a significantly rare case (Intersections discussion has no sample use cases that have this), and those who have it now already can’t fully express their interface.