-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow subtypes to define more overloads than their supertype #3263
Conversation
What do you mean by "now it's stub only"? I think it is not. |
@ilevkivskyi Haha, cough cough I'm tired. :) What I meant was that the order no longer matters. |
It never did. |
def _overload_dummy(*args, **kwds):
"""Helper for @overload to raise when called."""
raise NotImplementedError(
"You should not call an overloaded function. "
"A series of @overload-decorated functions "
"outside a stub module should always be followed "
"by an implementation that is not @overload-ed.") |
I know, I pretty much wrote that code. :-) |
Sorry, @gvanrossum, that wasn't for you it was for @kirbyfan64. But if there's anything else you'd like to know about python or mypy maybe I can help. j/k! |
Huh...I thought a looong time ago (maybe ~2013?) Side note: it'd be kinda nice if @JukkaL still had the code in the old |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Subtyping of overloaded functions breaks my head a little, and has, I think, more weird little corner cases than I've yet thought of.
I'll give it a little more thought on my ridiculously long bike ride tomorrow, but here's some commentary to get you started. The summary of the changes I'm requesting is, I think, "dig hard for weird corner cases, I suspect there are lots hiding"
mypy/checker.py
Outdated
isinstance(original, Overloaded) and | ||
name not in nodes.reverse_op_methods.keys()): | ||
# Allow subtype overloads to be greater than their supertype. | ||
fail = is_subtype(original, override, ignore_pos_arg_names=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, I read this block as "if they're both overloaded functions, and the override isn't a subtype of the original, it's still ok as long as they're completely incomparable. If the original is a subtype of the override, then it's an error". That doesn't make sense to me.
My naïve understanding of what should be happening here instead has this block unchanged, and handles all worrying about whether overloads are subtypes for each other in is_subtype
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, I read this block as "if they're both overloaded functions, and the override isn't a subtype of the original, it's still ok as long as they're completely incomparable. If the original is a subtype of the override, then it's an error". That doesn't make sense to me.
cough I think I might have written that code backwards... cough
mypy/subtypes.py
Outdated
if not is_subtype(left.items()[i], right.items()[i], self.check_type_parameter, | ||
|
||
# Ensure each overload in the left side is accounted for. | ||
super_overloads = left.items()[:] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks backwards. It should be "is left
a subtype of right
", not vice versa. See line 106 and line 39. That might be why you had to hack something to work up above in check_override
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sixolet Bad variable name (I had originally written the code backwards by accident but forgot about this name), but not necessarily wrong.
I interpreted subtypes kind of like subsets: if X is a subset of Y, then every element in X is also an element in Y. The code here is basically checking that, if the left Overload is a subset of the right Overload, then every "element" in left should also be present in right (even though right can actually contain more elements).
@@ -2494,6 +2477,33 @@ reveal_type(f(BChild())) # E: Revealed type is 'foo.B' | |||
[builtins fixtures/classmethod.pyi] | |||
[out] | |||
|
|||
[case testSubtypeWithMoreOverloadsThanSupertypeSucceeds] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's have some tests of nasty complicated overloads involving subtyping.
- One overload in the subtype may cover more than one overload in the supertype (contrived example, might not be a great one):
class Super:
@overload
def foo(a: int) -> float: ...
@overload
def foo(a: float) -> Number: ...
@overload
def foo(a: str) -> str: ...
class Sub(Super):
@overload
def foo(a: Number) -> float: ...
@overload
def foo(a: str) -> str: ...
- If an overload in the supertype Y_sup accepts an overlapping set of arguments to an overload Y_sub in the subtype, Y_sub must return a subtype of Y_sup's return type. For example, I think this is an error
class Super:
@overload
def foo(a: Number) -> Number: ...
@overload
def foo(a: str) -> str: ...
class Sub(Super):
@overload
def foo(a: Number) -> Number: ...
@overload
def foo(a: int) -> str: ...
@overload
def foo(a: str) -> str: ...
(look at testPartiallyContravariantOverloadSignatures, for another reference)
There's probably even more complexity to this. There usually is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sixolet FWIW my goal with this PR wasn't necessarily to 100% make this work perfectly, but it was more to allow a bit more than was originally allowed (without breaking anything, of course!).
mypy/subtypes.py
Outdated
@@ -249,13 +249,22 @@ def visit_overloaded(self, left: Overloaded) -> bool: | |||
return True | |||
return False | |||
elif isinstance(right, Overloaded): | |||
# TODO: this may be too restrictive | |||
if len(left.items()) != len(right.items()): | |||
if len(left.items()) < len(right.items()): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I posted a suggestion for a test below that violates this rule.
mypy/subtypes.py
Outdated
|
||
# Ensure each overload in the left side is accounted for. | ||
super_overloads = left.items()[:] | ||
while super_overloads: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every overload in the supertype must have at least one corresponding overload in the subtype, yes. There are also some weird cases I suspect exist for each overload in the subtype and how it interacts with the supertype's overloads whose arguments it overlaps. I gave an example in the test section below.
The related discussion about order of overloads on typing tracker python/typing#253 |
Ok, I think there's a slightly bigger problem here: what's the definition of a subtype with regard to overloaded functions? For instance, this should be valid: class A:
@overload
def f(x: int) -> int: pass
@overload
def f(x: str) -> str: pass
class B:
@overload
def f(x: int) -> int: pass
@overload
def f(x: Union[str, bytes]) -> str: pass should be valid, but class S(str): pass
class A:
@overload
def f(x: int) -> int: pass
@overload
def f(x: str) -> str: pass
class B:
@overload
def f(x: int) -> int: pass
@overload
def f(x: S) -> str: pass is not valid, but TBH I think the best solution would be to not actually use Thoughts? |
Hmm... Maybe I am missing something, but why do you think so? Naively, I would expect just a contravariant behaviour in argument type. So that |
Yep. Trying to think about function arguments in terms of subtyping as supersets/subsets always confuses me. I do my best to limit my thinking to substitutability, which treats me better -- A function is a subtype of another function if its arguments are as permissive or more and its return type is as strict or more. Thank you, Barbara Liskov. |
@kirbyfan64 This PR has not been updated for almost half a year. I think it's actually a good idea to address this, I think I've seen this kind of false positive a few times in real code. Are you interested in working on this still? TBH I don't understand why your test is failing -- presumably your implementation is too naive? (I haven't tried to understand it.) |
mypy/subtypes.py
Outdated
sub_overloads.pop() | ||
break | ||
else: | ||
# One of the overloads was not present in the right side. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the logic is reversed here. It should be like this: for every item on the right (supertype) there must be an item on the left that is its subtype.
Hey, turns out I'm not dead. ;) FWIW it seems @ilevkivskyi was right that I somehow got all the logic backwards. The only remaining failing test case is:
What was the reason for this one? IMO it seems ok ( |
Maybe there is something about |
test-data/unit/check-classes.test
Outdated
@overload | ||
def __add__(self, x: 'B') -> 'B': pass | ||
[out] | ||
tmp/foo.pyi:8: error: Signature of "__add__" incompatible with supertype "A" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why have you removed this test? Does it fail? The semantics of overload is not 100% fixed by PEP 484, see python/typing#253, but IIUC the consensus is that order of overloads does matter, so that in this case the override is indeed incompatible. @JukkaL could you please clarify this?
Ok, so if Travis looks happy, then this should finally be done-ish. All of the cases @sixolet mentioned now work properly, all the tests pass (well, we'll see what CI thinks), order works properly as @ilevkivskyi, and most importantly, the original issue still works. EDIT: Yup, I broke something. Hold on... |
Ok, tests pass now! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for a long delay, here are my comments. This looks good, but I think it could be simplified.
found_match = False | ||
|
||
for left_index, left_item in enumerate(left.items()): | ||
subtype_match = is_subtype(left_item, right_item, self.check_type_parameter, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are you using is_subtype
here but is_callable_subtype
below?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because is_subtype
works perfectly here, but below, I need to pass ignore_return=True
, which can only be passed to is_callable_subtype
.
# an exact match, then it's a potential error. | ||
if (is_callable_subtype(left_item, right_item, ignore_return=True, | ||
ignore_pos_arg_names=self.ignore_pos_arg_names) or | ||
is_callable_subtype(right_item, left_item, ignore_return=True, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand this part, sorry. Why one would check the opposite subtype relationship for overload items?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
||
# Order matters: we need to make sure that the index of | ||
# this item is at least the index of the previous one. | ||
if subtype_match and previous_match_left_index <= left_index: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While python/typing#253 is not fixed, it is not clear how order should be treated. Moreover, there is a major overload rework on the way, so I would limit this PR to a simple logic: Every item in the supertype must have a subtype item on the left.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well you had previously said that order still matters, and so the test shouldn't be removed. In order to avoid from removing the test, I had to make order matter...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I am going to merge this now, since this is already an improvement and does introduce any controversy. Latter we will have a more "global" discussion about how overloads should work and will update this if needed.
Thanks for working on this, and sorry that you waited so long!
Fixes #3262.
I removed
testOverloadedOperatorMethodOverrideWithSwitchedItemOrder
, because the test case had been added back in 2013, long before@overload
was banned from non-stubs. Now that it's stub-only, switching the order shouldn't really change anything anymore, since it's unaffected by the runtime.