Skip to content
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

Update to the Overloads chapter #1839

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open

Conversation

erictraut
Copy link
Collaborator

@erictraut erictraut commented Aug 13, 2024

  • Attempts to clearly define the algorithm for overload matching.
  • Describes checks for overload consistency, overlapping overloads, and implementation consistency.

python/typing-council#40

erictraut and others added 2 commits August 13, 2024 17:06
* Attempts to clearly define the algorithm for overload matching.
* Describes checks for overload consistency, overlapping overloads, and implementation consistency.
Copy link
Collaborator

@hauntsaninja hauntsaninja left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(two quick comments)

Copy link
Member

@carljm carljm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this tricky area! I haven't finished review yet, but may be called away soon, so I'm submitting the comments I have so far. (EDIT: I've now completed my review.)

@erictraut
Copy link
Collaborator Author

erictraut commented Aug 28, 2024

We typically wait for a proposed spec change to be accepted by the TC prior to writing conformance tests. In this case, I think it's advisable to write the conformance tests prior to acceptance. This will help us validate the proposed spec changes and tell us if (and to what extent) these changes will be disruptive for existing stubs and current type checker implementations.

I would normally volunteer to write the conformance tests, but in this case I think it would be preferable for someone else to write the tests based on their reading of the spec update. If I write the tests, there's a real possibility that they will match what's in my head but not accurately reflect the letter of the spec. There's also a possibility that I'll miss some important cases in the tests. If someone else writes the tests, they can help identify holes and ambiguities in the spec language.

Is there anyone willing to volunteer to write a draft set of conformance tests for this overload functionality? I'm thinking that there should be four new test files:

  1. overloads_definitions: Tests the rules defined in the "Invalid overload definitions" section
  2. overloads_consistency: Tests the rules defined in the "Implementation consistency" section
  3. overloads_overlap: Tests the rules defined in the "Overlapping overloads" section
  4. overloads_evaluation: Tests the rules defined in the "Overload call evaluation" section

If this is more work than any one person wants to volunteer for, we could split it up.

@carljm
Copy link
Member

carljm commented Aug 28, 2024

I am willing to work on conformance tests for this, but I probably can't get to it until the core dev sprint, Sept 23-27. I realize that implies a delay to moving forward with this PR. Happy for someone else to get to it first.

@erictraut erictraut changed the title First draft of an update to the Overloads chapter (DRAFT: DO NOT MERGE) Update to the Overloads chapter Jan 29, 2025
Copy link

@jorenham jorenham left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's good to see that this complicated matter is crystallizing into something solid!

After reading through this latest version, a couple of question came to mind:

  • What is the type of an overloaded function? When can it be assigned to a Callable, and the other way around?
  • Is a Protocol with an overloaded __call__ method an overloaded function type, or does it follow different rules?
  • When assigning an overloaded function to some Callable[Tss, R], then what happens to the individual signatures? Do they live within the Tss paramspec, and does R become causally dependent on Tss, i.e. as a type-mapping? Or does this assignment cause the overloaded type to change into a different function type without overloads?
  • Do I understand correctly that this spec allows decorating an overloaded function in .pyi stubs, as well? Because this is currently not supported in (at least) pyright.

Copy link
Contributor

@JukkaL JukkaL left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for specifying overloads in more detail! Overall looks good, just a few comments. This is important for making stubs and third-party libraries behave consistently across type checkers, and without standardization it may be impossible to provide definitions for library functionality that work consistently across type checkers.

Step 5: For each argument, determine whether all possible
:term:`materializations <materialize>` of the argument's type are assignable to
the corresponding parameter type for each of the remaining overloads. If so,
eliminate all of the subsequent remaining overloads.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This paragraph felt unclear. I had to read it multiple times to understand the intent. Maybe reword this, or include some motivation why we have this rule, so that this can be understood easily without peeking at the following paragraph?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one thing that is unclear about the current wording of this paragraph is that it's not clear to how to interpret "for each argument" in the presence of multiple arguments. Even the example in the next paragraph doesn't really clarify this, since it only discusses one argument. Let's say there are two arguments in the call, and the first argument is as described in the paragraph below. Can we eliminate the third overload from consideration, according to this rule, before we even examine the second argument at all? That's what seems to be implied by the current wording.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe reword this, or include some motivation why we have this rule

Yeah, I agree that it's not very clear. I've taken a stab at adding a motivation and a clearer example.

Can we eliminate the third overload from consideration, according to this rule, before we even examine the second argument at all?

That's a good point. I think the rule needs to be changed from "for each argument" to "for all arguments".

Here's concrete example:

@overload
def func(a: list[Any], b: list[str]) -> str: ...
@overload
def func(a: Any, b: list[Any]) -> float: ...

def test(a: list[Any], b: list[Any]):
    func(a, b)

I've updated the rule accordingly. I'll go back and add a more complete conformance test as a separate step.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is now easier to follow.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JukkaL, if you're good with the latest draft, please sign off here.

@erictraut
Copy link
Collaborator Author

What is the type of an overloaded function? When can it be assigned to a Callable, and the other way around?

This is already covered in the typing spec here.

Is a Protocol with an overloaded call method an overloaded function type, or does it follow different rules?

This is already covered in the typing spec here.

When assigning an overloaded function to some Callable[Tss, R], then what happens to the individual signatures? Do they live within the Tss paramspec, and does R become causally dependent on Tss, i.e. as a type-mapping? Or does this assignment cause the overloaded type to change into a different function type without overloads?

This is not currently specified. It's out of scope for this PR.

Do I understand correctly that this spec allows decorating an overloaded function in .pyi stubs, as well? Because this is currently not supported in (at least) pyright.

This is already covered in the typing spec here. The short answer is, no, arbitrary decorators should not be used in stub files.

@jorenham
Copy link

jorenham commented Feb 1, 2025

When assigning an overloaded function to some Callable[Tss, R], then what happens to the individual signatures? Do they live within the Tss paramspec, and does R become causally dependent on Tss, i.e. as a type-mapping? Or does this assignment cause the overloaded type to change into a different function type without overloads?

This is not currently specified. It's out of scope for this PR.

Ah ok. The scope of this PR wasn't all that clear to me after reading the PR description. So before I ask anything else; what exactly is within the scope of this PR?
It's touches on multiple topics, which are of course all related, but it isn't all that obvious to me why it isn't required to also include typing.Callable or callable Protocol types when specifying the precise mechanics of overloads. They will likely also be directly affected by the choices that are made here. So I don't see why we shouldn't at least talk about those, so that we can avoid any unwanted (and currently unknown) potential consequences there.

@jorenham
Copy link

jorenham commented Feb 1, 2025

What is the type of an overloaded function? When can it be assigned to a Callable, and the other way around?

This is already covered in the typing spec here.

Is a Protocol with an overloaded call method an overloaded function type, or does it follow different rules?

This is already covered in the typing spec here.

Ok I wasn't aware of these sections. Maybe it could help to link to them from this spec?

@erictraut
Copy link
Collaborator Author

The scope of this PR is to describe:

  1. The way to perform overload matching — how to evaluate call expressions that target an overloaded function
  2. The way to check for overload consistency, overlapping overloads, and implementation consistency

These new sections rely upon definitions and concepts described elsewhere in the typing spec including assignability, materialization, type equivalency, enums, tuples, etc.

We try hard not to duplicate concept definitions in the spec because duplicate definitions will inevitably get out of sync and cause confusion. This section shouldn't need to say anything about assignability rules for callables or protocols because those are discussed elsewhere in the spec.

We've also endeavored to make concepts in the typing spec as orthogonal and composable as possible. If you see cases where concepts are not composing, those are cases that we should discuss.

@samwgoldman
Copy link

How should subtyping overloaded functions behave?

I think there is a simple intuition, which is that f is a subtype of g if every call to f is also a valid call to g. If we follow that intuition, then I would expect the following examples to pass:

Example 1: Subtyping between overloaded callback Protocol and Callable type

from typing import Protocol, Callable, overload, assert_type

class P(Protocol):
  @overload
  def __call__(self, x: int, y: str, z: int) -> str: ...
  @overload
  def __call__(self, x: int, y: int, z: int) -> int: ...

def check_expand_union_callable(v: int | str, f: P) -> None:
    g: Callable[[int, int | str, int], int | str] = f
    ret = g(1, v, 1)
    assert_type(ret, int | str)

Example 2: Subclass/sub-protocol consistency

from typing import Protocol, overload, assert_type

class Base(Protocol):
    def m(self, x: int, y: int | str, z: int) -> int | str: ...

class Derived(Base, Protocol):
    @overload
    def m(self, x: int, y: str, z: int) -> str: ...
    @overload
    def m(self, x: int, y: int, z: int) -> int: ...

def check_expand_protocol(v: int | str, base: Base, derived: Derived) -> None:
    ret_base = base.m(1, v, 1)
    assert_type(ret_base, int | str)
    
    ret_derived = derived.m(1, v, 1)
    assert_type(ret_derived, int | str)

For what it's worth, I think it's reasonable to restrict the union expansion to calls. Union expansion is complicated and has concerning performance implications My understanding is that it is included in the spec because people need it, but if type checkers do not currently implement these rules for subtyping purposes, maybe that's evidence that this need does not extend to subtyping, and we can forgo the rule there entirely?

@erictraut
Copy link
Collaborator Author

I agree that type expansion shouldn't affect (infect) subtyping rules. Assignability rules for overloaded callables are already defined in the spec here.

What you're pointing out here is a small inconsistency between call evaluation behavior and the subtyping behavior. This was also recently pointed out by @hauntsaninja in this pyright issue.

We could look at amending the assignability rules to eliminate this inconsistency, but this would come at a big price — in terms of complexity and performance. My sense is that it wouldn't be a good tradeoff.

I was careful in this PR to talk about type expansion only in the context of "argument types". Arguments are applicable only to calls.

@samwgoldman
Copy link

While looking at overload resolution for Pyre, I noticed an ambiguity in the spec for Step 2. Specifically, what should we do with call argument expressions which have "internal errors"? That is, evaluating the expression leads to an error, but the expression still results in a type. For example f(g(x)) where g(x) is an error, but returns a type compatible with f.

A worked example:

from typing import overload, assert_type

@overload
def f(x: int) -> int: ...
@overload
def f(x: str) -> str: ...
def f(x: int | str) -> int | str:
    return x

def h(x: str) -> str:
    return ""

def g(x: str) -> int:
    return 0


# Call with error, returns int, select first overload
assert_type(f(g(0)), int)

x = g(0)
assert_type(f(x), int)


# Call with error, return str, select second overload
assert_type(f(h(0)), str)

y = h(0)
assert_type(f(y), str)

Should we specify that overload selection in Step 2 is determined by errors in between the arguments and parameters of the overload signature -- not simply the presence/absence of errors on the call overall?

@erictraut
Copy link
Collaborator Author

I noticed an ambiguity in the spec

When evaluating a call to an overloaded function, I think the behavior for "internal errors" should be the same as with calls to non-overloaded functions. That is, if there are type errors detected when evaluating argument expressions, those errors shouldn't affect the evaluation of the call expression itself. I think that's consistent with what you mean by "errors in between the arguments and parameters".

I'm not sure this matters though. Perhaps we can just leave this unspecified. My view is that in cases where a type error is detected and reported by a type checker, any downstream type evaluations that depend on that error are not covered by the spec. Type checkers will generally want to "limit the collateral damage" and reduce downstream false positives once the first error is detected, but I don't think we should try to mandate specific behaviors here. Ultimately, it's up to the user to fix the "inner error" type violation; once that's fixed, then the type checker can guarantee conformant behavior for dependent type evaluations.

If you can think of a situation where an "inner error" is detected but not reported to the user during overloaded call evaluation, this would be a bigger concern because it would effectively change the results of the overloaded call without the user realizing it.

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spotted some typos and incorrect ReST markup

JelleZijlstra and others added 3 commits February 13, 2025 20:56
…eement on the proper behavior, and this section is lower priority. We can revisit in the future if there's a desire to do so.
facebook-github-bot pushed a commit to facebook/pyrefly that referenced this pull request Mar 13, 2025
Summary: We know that an overload is constructed from a series of functions, so it has function metadata. Store this directly on the overload. Right now, we just grab the metadata of the first signature, but once python/typing#1839 is accepted, we'll need to check the metadata for consistency between signatures and grab some of it from the overload implementation.

Reviewed By: stroxler

Differential Revision: D71089700

fbshipit-source-id: 0040a85ee177ade1eaa00cc71f6cbc670f5e2da2
facebook-github-bot pushed a commit to facebook/pyre-check that referenced this pull request Mar 13, 2025
Summary: We know that an overload is constructed from a series of functions, so it has function metadata. Store this directly on the overload. Right now, we just grab the metadata of the first signature, but once python/typing#1839 is accepted, we'll need to check the metadata for consistency between signatures and grab some of it from the overload implementation.

Reviewed By: stroxler

Differential Revision: D71089700

fbshipit-source-id: 0040a85ee177ade1eaa00cc71f6cbc670f5e2da2
@srittau srittau added the topic: typing spec For improving the typing spec label Mar 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: typing spec For improving the typing spec
Projects
None yet
Development

Successfully merging this pull request may close these issues.