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

Resolve extension members in all non-invocation contexts #73239

Merged
merged 13 commits into from May 2, 2024

Conversation

jcouv
Copy link
Member

@jcouv jcouv commented Apr 25, 2024

When we bind a member access expression there are two cases:

  1. the expression is considered "invoked" (syntactically part of an invocation_expression), in which case the extension member lookup will filter out non-invocable members,
  2. the expression is not considered "invoked", so if member lookup failed, we can go ahead and use normal extension member lookup (ie. not filtering out non-invocable members) to resolve the expression to a non-method extension member.

This PR implements the second case.

It also removes previous logic we had for resolving to a non-method extension member during a conversion. That logic relied on an incorrect understanding of the spec (that conversions to a delegate type would cause the expression to be treated as "invoked", so we had to wait until processing the conversion to perform the extension member lookup).

So the following scenario becomes an error:

var x = (System.Action)D.f; // binds to field, so error: string cannot convert to Action
System.Action a = D.f; // ditto

class D { }
 
implicit extension E1 for D
{
    public static string f = null;
}
 
implicit extension E2 for object
{
    public static void f() { }
}

Here are the relevant sections of the spec that justify this understanding:

The Member Lookup has concept of a member being "invoked":

"If the simple_name or member_access occurs as the primary_expression of an invocation_expression, the member is said to be invoked."
"Next, if the member is invoked, all non-invocable members are removed from the set."

But in Method group conversions, although treat the conversion as an invocation:

A single method M is selected corresponding to a method invocation of the form E(A) ... [using arguments constructed from the parameter types of delegate type]

The method group conversion only kicks in when we have already determined that we have a method group:

"An implicit conversion exists from a method group (§12.2) to a compatible delegate type (§20.4). If D is a delegate type, and E is an expression that is classified as a method group, ..."

In short, a conversion to a delegate type does not cause the expression to be treated as "invoked", although we process it somewhat like an invocation.

Relates to test plan #66722

@jcouv jcouv self-assigned this Apr 25, 2024
@dotnet-issue-labeler dotnet-issue-labeler bot added the untriaged Issues and PRs which have not yet been triaged by a lead label Apr 25, 2024
@jcouv jcouv force-pushed the best-type2 branch 5 times, most recently from b1d86df to c4afd43 Compare April 29, 2024 20:42
@jcouv
Copy link
Member Author

jcouv commented Apr 29, 2024

    // PROTOTYPE(static) the sorting mechanism for more specific extension members to hide less specific ones is insufficient and needs to be revised

📝 I will address this in my next PR.

The issue is that the sorting helper used in LookupImplicitExtensionMembersInSingleBinder assumes transitivity.
In some orderings of the source, it only performs the following comparisons to arrive at an order: E4 vs. E3 (E4 is not more specific than E3), E1 vs. E4 (E1 is not more specific than E4), E2 vs. E1 (E2 is not more specific than E1).
The resulting order (from more specific to less specific) that it computes is "E3, E4, E1, E2". That order is incorrect because it fails to capture the two key ordering relationships: E1 vs. E3, and E2 vs. E4.


Refers to: src/Compilers/CSharp/Test/Emit3/ExtensionTypeTests.cs:31130 in c4afd43. [](commit_id = c4afd43, deletion_comment = False)

@jcouv jcouv marked this pull request as ready for review April 29, 2024 23:19
@jcouv jcouv requested a review from a team as a code owner April 29, 2024 23:19
// All other contexts can go ahead and resolve to a non-method extension member
if (!invoked)
{
result = ResolveToNonMethodExtensionMemberIfPossible(result, diagnostics);
Copy link
Contributor

@AlekseyTs AlekseyTs Apr 30, 2024

Choose a reason for hiding this comment

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

ResolveToNonMethodExtensionMemberIfPossible

Is the name somewhat confusing? We may still resolve to methods, right? #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe I didn't quite understand the question. ResolveToNonMethodExtensionMemberIfPossible only returns non-methods.

@AlekseyTs
Copy link
Contributor

AlekseyTs commented Apr 30, 2024

            if (resolution.HasAnyErrors)

This isn't primarily related to the changes in this PR, but should we do something for extensions on this code path? #Closed


Refers to: src/Compilers/CSharp/Portable/Binder/Binder_Conversions.cs:1127 in bba7662. [](commit_id = bba7662, deletion_comment = False)

@jcouv
Copy link
Member Author

jcouv commented Apr 30, 2024

            if (resolution.HasAnyErrors)

I think the code in HasCollectionExpressionApplicableAddMethod looks good as-is (we do ResolveMethodGroup above using analyzedArguments, so that'll resolve an instance method, classic extension methods and extension type methods).
But I'll add a comment to check.

Also, I already have a note in the test plan that we need to exclude extension type methods in some collection expression or params scenarios.


In reply to: 2085924348


Refers to: src/Compilers/CSharp/Portable/Binder/Binder_Conversions.cs:1127 in bba7662. [](commit_id = bba7662, deletion_comment = False)

@jcouv jcouv requested a review from AlekseyTs May 1, 2024 14:36
@@ -7560,7 +7543,7 @@ private BoundExpression ResolveToExtensionMemberIfPossible(BoundExpression expr,
}
}

private BoundExpression BindInstanceMemberAccess(
private BoundExpression BindMemberAccessWithBoundLeftInternal(
Copy link
Contributor

@AlekseyTs AlekseyTs May 1, 2024

Choose a reason for hiding this comment

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

Internal

What are we trying to convey with the "Internal" suffix? It doesn't look meaningful to me. Consider dropping it. If your goal is as simple as to not share name with the other helper, numeric suffixes "BindMemberAccessWithBoundLeft1" and "BindMemberAccessWithBoundLeft2" will be better in my opinion. #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, my main goal is to remove the Instance portion (misleading) and not share names since we have a cref reference to it. How about "Core" suffix?

Copy link
Contributor

Choose a reason for hiding this comment

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

How about "Core" suffix?

I do not find this better. A numeric suffix is better in my opinion to avoid sharing the name. Unless we can come up with a meaningful differentiator.

Copy link
Contributor

Choose a reason for hiding this comment

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

Since you already changed the name, I guess I can live with it.


if (!invoked)
{
var extensionResult = ResolveToNonMethodExtensionMemberIfPossible(result, diagnostics);
Copy link
Contributor

@AlekseyTs AlekseyTs May 1, 2024

Choose a reason for hiding this comment

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

var extensionResult = ResolveToNonMethodExtensionMemberIfPossible(result, diagnostics);

result = ResolveToNonMethodExtensionMemberIfPossible(result, diagnostics);? As we do in tryBindMemberAccessWithBoundTypeLeft. #Closed

@@ -721,14 +721,15 @@ private bool HasApplicableMemberWithPossiblyExpandedNonArrayParamsCollection<TMe
(analyzedArguments.HasDynamicArgument ? OverloadResolution.Options.DynamicResolution : OverloadResolution.Options.None));
diagnostics.Add(expression, useSiteInfo);

if (resolution.IsExtensionMember(out Symbol extensionMember))
if (resolution.IsNonMethodExtensionMember(out Symbol extensionMember))
Copy link
Contributor

@AlekseyTs AlekseyTs May 1, 2024

Choose a reason for hiding this comment

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

if (resolution.IsNonMethodExtensionMember(out Symbol extensionMember))

Is this if statement still necessary? Is its body still reachable? Can you give an example of a scenario? #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, we can still get here. Below is an example.
When we have an invocation, we delay the resolution of extensions when binding a member access. We resolve the extensions when binding the invocation (ie. here). So we may find that our receiver is not a method group, but instead an invocable member (for example a delegate type field).
In such case, we bind that member access to the invocable member and invoke it with the given arguments.

    public void ExtensionInvocation_OnlyDelegateFieldExists()
    {
        // Invocable fields are considered during extension invocation
        var source = """
E.Field = (i) => { System.Console.Write($"ran({i})"); };
C.Field(42);

class C { }

delegate void D(int i);

implicit extension E for C
{
    public static D Field;
}
""";

@AlekseyTs
Copy link
Contributor

            if (resolution.HasAnyErrors)

I asked the question because I saw that BindMethodGroupInvocation does something special for non method extension members right about before similar place in code.


In reply to: 2086545538


Refers to: src/Compilers/CSharp/Portable/Binder/Binder_Conversions.cs:1127 in bba7662. [](commit_id = bba7662, deletion_comment = False)

@jcouv
Copy link
Member Author

jcouv commented May 1, 2024

@jjonescz for second review. Thanks

@jcouv
Copy link
Member Author

jcouv commented May 1, 2024

            if (resolution.HasAnyErrors)

Thanks. I identified a scenario that hits that. Blocked it off for now and left a PROTOTYPE comment


In reply to: 2088772543


Refers to: src/Compilers/CSharp/Portable/Binder/Binder_Conversions.cs:1127 in bba7662. [](commit_id = bba7662, deletion_comment = False)

@AlekseyTs
Copy link
Contributor

AlekseyTs commented May 1, 2024

    // PROTOTYPE(instance) We need to decide whether to allow this and adjust HasCollectionExpressionApplicableAddMethod and other downstream logic accordingly

I am not sure what are you suggesting to allow in this scenario. Especially that the error isn't "near" a collection expression. #Closed


Refers to: src/Compilers/CSharp/Test/Emit3/ExtensionTypeTests.cs:37490 in f33ffce. [](commit_id = f33ffce, deletion_comment = False)

@AlekseyTs
Copy link
Contributor

AlekseyTs commented May 1, 2024

MyCollection c = [42];

I would expect an error here, there is no suitable Add method. It must be a method, extensions do not change anything about this requirement. #Closed


Refers to: src/Compilers/CSharp/Test/Emit3/ExtensionTypeTests.cs:37477 in f33ffce. [](commit_id = f33ffce, deletion_comment = False)

@@ -953,25 +955,6 @@ private static UnboundLambda MakeQueryUnboundLambda(CSharpSyntaxNode node, Query
// Could not find an implementation of the query pattern for source type '{0}'. '{1}' not found.
diagnostics.Add(ErrorCode.ERR_QueryNoProvider, node.Location, MessageID.IDS_AnonMethod.Localize(), methodName);
}
else if (ultimateReceiver.Kind == BoundKind.MethodGroup)
Copy link
Contributor

@AlekseyTs AlekseyTs May 1, 2024

Choose a reason for hiding this comment

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

else if (ultimateReceiver.Kind == BoundKind.MethodGroup)

I am not sure why this code is removed. The only difference from 'main' is the PROTOTYPE comment. Which means this code wasn't touched for extension types. I think we should keep it. #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

This code is unreachable. The case above for ultimateReceiver.HasAnyErrors intercepts all the cases that might be handled here.
The reason I prefer to remove the code rather than just leave it is that it calls ResolveMethodGroup which begs the question for how extensions should be handled. But anything I do here cannot be tested.

Copy link
Contributor

Choose a reason for hiding this comment

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

This code is unreachable. The case above for ultimateReceiver.HasAnyErrors intercepts all the cases that might be handled here.

Thanks for the explanation. I am good with removal of the code.

@AlekseyTs
Copy link
Contributor

Just an FYI, I am done reviewing compiler changes for commit 6. Planning to review tests later today.

flags: BoundMethodGroupFlags.SearchExtensionMethods, node, typeArgumentsSyntax, diagnostics);

if (!invoked)
Copy link
Contributor

@AlekseyTs AlekseyTs May 1, 2024

Choose a reason for hiding this comment

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

if (!invoked)

I am not sure why do we do this conditionally. Is there a specific reason to keep non-methods unresolved for invocation scenarios? #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

We need arguments to resolve invocation scenarios, even if we're only interested in invocable non-method members.

Consider a classic extension method in an inner scope and an extension type with an invocable non-method member in an outer scope.
Depending on whether the extension method is applicable or not, the invocable non-method member may be found or not.
But to determine whether it is applicable, we need arguments.

Copy link
Member Author

Choose a reason for hiding this comment

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

From offline discussion, it would be possible to do this unconditionally, but it would not always perform the resolution to a non-extension member (sometimes that will be possible to do only once we have arguments to discard some extension methods that are in closer scopes) and it would probably not be more readable/understandable.

flags, node, typeArgumentsSyntax, diagnostics);

if (!invoked)
Copy link
Contributor

@AlekseyTs AlekseyTs May 1, 2024

Choose a reason for hiding this comment

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

if (!invoked)

Same question #Closed

@AlekseyTs
Copy link
Contributor

AlekseyTs commented May 1, 2024

}

It feels like there are way too many tests added given the small amount of actual code paths modified. Of course, having more tests doesn't hurt. However, consider if there is an excessive redundancy given the final state of the changes in this PR, and, perhaps, some scenarios no longer add real value. #Closed


Refers to: src/Compilers/CSharp/Test/Emit3/ExtensionTypeTests.cs:39614 in 0b41777. [](commit_id = 0b41777, deletion_comment = False)

@AlekseyTs
Copy link
Contributor

Done with review pass (commit 8)

@jcouv
Copy link
Member Author

jcouv commented May 2, 2024

}

Makes sense. I pruned some tests that were more relevant for a previous implementation (all scenarios calling BindValue)


In reply to: 2089257969


Refers to: src/Compilers/CSharp/Test/Emit3/ExtensionTypeTests.cs:39614 in 0b41777. [](commit_id = 0b41777, deletion_comment = False)

@jcouv jcouv requested a review from AlekseyTs May 2, 2024 13:33
// or than any method from an extension type), then that's the member being accessed.
//
// - if the extension member lookup finds a method (classic extension method compatible with the receiver or method in extension type;
// closer than any non-method extension member), we don't need to touch the result for the member access (it's a method group already).
Copy link
Contributor

@AlekseyTs AlekseyTs May 2, 2024

Choose a reason for hiding this comment

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

we don't need to touch the result for the member access (it's a method group already).

It looks like this part of the comment is no longer relevant because we are no longer producing and passing in the method group. #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

My intent is to add some documentation that relates to the callers (which are unfortunately split across two different methods). I'll try to clarify comment

//
// - if the extension member lookup is ambiguous, then we'll use an error symbol as the result of the member access.
//
// - if the extension member lookup finds nothing, then we don't need to touch the result for the member access.
Copy link
Contributor

@AlekseyTs AlekseyTs May 2, 2024

Choose a reason for hiding this comment

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

then we don't need to touch the result for the member access.

This part as well #Closed

return expr;
if (!memberAccess.HasErrors && typeArgumentsSyntax.Any(SyntaxKind.OmittedTypeArgument))
{
Error(diagnostics, ErrorCode.ERR_OmittedTypeArgument, syntax);
Copy link
Contributor

@AlekseyTs AlekseyTs May 2, 2024

Choose a reason for hiding this comment

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

Error(diagnostics, ErrorCode.ERR_OmittedTypeArgument, syntax);

Do we have a test covering this code path? Including scenario when the member is a type #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, ExtensionMemberLookup_MatchingExtendedType_GenericType_GenericMember_OmittedTypeArgument (a generic type scenario) and ExtensionInvocation_TypeReceiver_TypeArguments_Omitted and ExtensionInvocation_InstanceReceiver_TypeArguments_Omitted (a generic method, with either a type or an instance receiver).

Copy link
Contributor

@AlekseyTs AlekseyTs left a comment

Choose a reason for hiding this comment

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

LGTM (commit 12)

@jcouv jcouv requested a review from AlekseyTs May 2, 2024 19:46
Copy link
Contributor

@AlekseyTs AlekseyTs left a comment

Choose a reason for hiding this comment

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

LGTM (commit 13)

@jcouv jcouv merged commit 6b9b076 into dotnet:features/roles May 2, 2024
24 checks passed
@jcouv jcouv deleted the best-type2 branch May 2, 2024 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-Compilers New Language Feature - Roles Roles untriaged Issues and PRs which have not yet been triaged by a lead
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants