Skip to content

Properly treat ambiguous explicit interface implementations#34584

Merged
AlekseyTs merged 22 commits intodotnet:masterfrom
YairHalberstadt:handle-expl-interface-ambiguity-WRT-NRTs
Apr 15, 2019
Merged

Properly treat ambiguous explicit interface implementations#34584
AlekseyTs merged 22 commits intodotnet:masterfrom
YairHalberstadt:handle-expl-interface-ambiguity-WRT-NRTs

Conversation

@YairHalberstadt
Copy link
Contributor

@YairHalberstadt YairHalberstadt commented Mar 29, 2019

involving nullable reference types, including maintaining backwards compatability with pre -NRT code.

pre C# 8.0, void I.Foo<T>(T? value) { } was a valid implementation of void Foo<T>(T? value) where T : struct;.

In C# 8 it's a valid implementation of:

 void Foo<T>(T? value) where T : struct;
 void Foo<T>(T? value) where T : class;
 void Foo<T>(T value) where T : class;

This leads to the following 2 issues.

1.

See #34508

The following is a valid, unambiguous program in C# 7.0, but in C# 8.0 leads to an ambiguity which prevents it from compiling:

interface I
{
    void Foo<T>(T value);
    void Foo<T>(T? value) where T : struct;
}

//works fine
class C1 : I
{
    public void Foo<T>(T value) { }
    public void Foo<T>(T? value) where T : struct { }
}

//several build errors
class C2 : I
{
    void I.Foo<T>(T value) { }

    //error CS0111: Type 'C2' already defines a member called 'I.Foo' with the same parameter types
    void I.Foo<T>(T? value) { }
}

Given that void I.Foo(T? value) { } is a valid implementation of both void Foo<T>(T value); and void Foo<T>(T? value) where T : struct; the compiler is allowed to decide which it should implement. It does so arbitrarily (by choosing the member declared first by the interface), causing a compile error.

To fix this regression I have taken the following approach.

When looking for the implemented member of an explicit interface implementation, The compiler will first look for members of the interface which match the implementing member including nullability annotations. Only if that fails will it look for members whose nullability annotations do not match.

Since no pre-C# 8 code has nullability annotations, this can be guaranteed not to break backwards compatibility.

In my opinion this is an intuitive, simple, and understandable solution, which doesn't require special casing all sorts of potentially backwards compatibility breaking cases.

2.

See dotnet/csharplang#2370

The compiler is required to issue a warning whenever an explicit interface implementation can match two members, or is ambiguous to the runtime. See https://docs.microsoft.com/en-us/dotnet/csharp/misc/cs0473 and https://blogs.msdn.microsoft.com/ericlippert/2006/04/06/odious-ambiguous-overloads-part-two/.

Explicit interface implementation 'method name' matches more than one interface member. Which interface member is actually chosen is implementation-dependent. Consider using a non-explicit implementation instead.

However it was not issuing such an error in this case:

#nullable enable
interface I
{
    void Foo<T>(T? value) where T : class;
    void Foo<T>(T? value) where T : struct;
}

class C2 : I
{
    void I.Foo<T>(T? value) { }
    public void Foo<T>(T? value) where T : struct {}
}

Despite the fact that the explicit interface implementation is ambiguous to the compiler.

This was because the logic for doing the check to see if an explicit interface implementation matches two interface members is done at a different point to the location where the lookup is done. As a result the logic became out of sync. This pull request moves the logic to the same location, so that the warning is guaranteed to be issued accurately.

Note that the lookup to see if a method is ambiguous to the runtime remains in its old location, as it requires completely different logic.

… nullable reference types, including maintaining backwards compatability with pre -NRT code.
@AlekseyTs
Copy link
Contributor

AlekseyTs commented Mar 29, 2019

@YairHalberstadt
It sounds like the issue addressed in this PR is very similar to an issue we have with overriding (for example #29846). I think we would want to use the same approach for dealing with both scenarios. Would you like to address overriding scenarios as well? #Closed

@YairHalberstadt
Copy link
Contributor Author

@AlekseyTs
Sure.
The only changes in this PR are to ExplicitInterfaceHelper so I imagine that even if they are conceptually similar, they will have a different code fix.

@gafter gafter added Community The pull request was submitted by a contributor who is not a Microsoft employee. Feature - Nullable Reference Types Nullable Reference Types labels Mar 29, 2019
@gafter
Copy link
Member

gafter commented Apr 3, 2019

@YairHalberstadt Please see dotnet/csharplang#2378 (comment) . I know you're aware of that because you responded there, but I wanted to close the loop here. Could you please modify this PR to do "step 1" to fix the compatibility issue? #Resolved

@gafter gafter self-assigned this Apr 3, 2019
@YairHalberstadt
Copy link
Contributor Author

YairHalberstadt commented Apr 3, 2019

Sure. I think it's worth keeping the change where we check for compile time ambiguities during the lookup, rather than afterwards, since this is more correct, and would have helped diagnose this breaking change earlier. #Resolved

@YairHalberstadt
Copy link
Contributor Author

YairHalberstadt commented Apr 4, 2019

Obviously this fix has broken a large number of unit tests. Depending on the intent of the test I've tried to do one of these things:

  1. add a class constraint to the method, and skip the test till the next stage is implemented.
  2. Change the expected result of the test.
  3. Copy the test, and do both of the above options.

However it has been tricky to know exactly which one is best, and it's likely a few tests will need changing.

I'm now pushing my changes to here, to see which other tests fail. I'm sure there will be a few. #Resolved

@YairHalberstadt
Copy link
Contributor Author

YairHalberstadt commented Apr 4, 2019

It looks like all the tests are now passing.
@gafter I think this is ready for a review for the initial part of the fix. #Resolved

Copy link
Member

@gafter gafter left a comment

Choose a reason for hiding this comment

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

LGTM. @AlekseyTs Can you please have a look?

}

[Fact]
[Fact(Skip = "https://github.com/dotnet/csharplang/issues/2378#issuecomment-479634969")]
Copy link
Contributor

@AlekseyTs AlekseyTs Apr 8, 2019

Choose a reason for hiding this comment

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

[Fact(Skip = "https://github.com/dotnet/csharplang/issues/2378#issuecomment-479634969")] [](start = 8, length = 88)

I think skipping tests doesn't help us to understand what would be the behavior after this PR. I would suggest to unskip the tests that existed prior to this PR and undo any modifications to the scenarios (like addition of constraints etc.). It is OK to add new skipped tests, but they should be linked to an active compiler issue (not a csharplang issue). #Closed


for (int i = arity - 1; i >= 0; i--)
{
builder.Add(IndexedTypeParameterSymbolForOverriding.GetTypeParameter(i, typeParameters2[i].IsValueType));
Copy link
Contributor

@AlekseyTs AlekseyTs Apr 9, 2019

Choose a reason for hiding this comment

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

IndexedTypeParameterSymbolForOverriding.GetTypeParameter [](start = 32, length = 56)

It looks like we can remove the IndexedTypeParameterSymbolForOverriding type. #Closed

return makeNullableT();
}

if (typeParameterSymbol.DeclaringMethod is {} declaringMethod && (declaringMethod.IsOverride || declaringMethod.IsExplicitInterfaceImplementation))
Copy link
Contributor

@AlekseyTs AlekseyTs Apr 9, 2019

Choose a reason for hiding this comment

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

if (typeParameterSymbol.DeclaringMethod is {} declaringMethod && (declaringMethod.IsOverride || declaringMethod.IsExplicitInterfaceImplementation)) [](start = 12, length = 147)

This condition should be applicable only to type references in a signature of a method declaration, but it looks like this isn't the case. #Closed

Copy link
Contributor

Choose a reason for hiding this comment

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

For example, what would be the type of the local in this scenario (please add a test)?

    interface I5
    {
        void M5<T>(T value) where T : class;
    }

    class C51 : I5
    {
        void I5.M5<T>(T value)
        {
            T? x = value;
        }
    }

I think the more robust approach would be to force the type from the Binder, if the Binder knows that it binds types from the signature. Or, even better, to force the type in method symbol implementation. That would allow us to take a look at the presence of the constraints once we start working on step #2.


In reply to: 273635920 [](ancestors = 273635920)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The binder doesn't know it binds types from the signature.

As I see it we have three options:

  1. When creating the TypeParameterSymbols in SourceOrdianaryMethodSymbol, recursively check the ParameterSyntaxes to see if any TypeParameters are used as NullableTypes.

  2. When binding a Type, pass in enough information for the binder to know it is binding types from the signature.

  3. When binding a parameter, deep copy the type of the parameter, replacing all lazy nullable types with Nullable Types.

I think 3. is the worst option, as it is both most expensive, and most error prone. I am not sure about 1 or 2, but will go with 1 for now unless you tell me otherwise.

Copy link
Contributor

Choose a reason for hiding this comment

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

I would try to do this inside method symbol. Once the return types and parameter types are bound, we can visit them and force lazy nullable types created over method type parameters to resolve towards Nullable<T>. We do not need to replace the TypeWithAnnotations instances. Of course, that would require adding some new APIs to TypeWithAnnotations. We would need to be able to get to the underlying type (__defaultType?) without triggering resolution. And we would need an API that would allow us to resolve the type towards Nullable<T>.


In reply to: 273838023 [](ancestors = 273838023)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Given a case like this:

    interface I
    {
        void M<T>(T? value) where T : class;
    }

    class C : I
    {
        void I.M<T>(T? value)
        {
            T? x = value;
        }
    }

If we implement your suggestion, we will warn on T? x = value; that T is an unconstrained type.
Is this a problem?

Copy link
Contributor

Choose a reason for hiding this comment

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

If we implement your suggestion, we will warn on T? x = value; that T is an unconstrained type.
Is this a problem?

I don't think this is a problem, assuming that we are going to get another error saying the implemented method is not found. I think this is the current behavior for the scenario when I.M is not present (i.e. the interface is empty).


In reply to: 274280058 [](ancestors = 274280058)

@YairHalberstadt
Copy link
Contributor Author

@AlekseyTs
I have made all the changes you've suggested, and am just finishing up the unit tests.
Hopefully by Monday at the latest I should be ready.
Of course that's assuming that further stages of peer review aren't necessary.

@AlekseyTs
Copy link
Contributor

AlekseyTs commented Apr 11, 2019

Hopefully by Monday at the latest I should be ready.

Sounds good. #Closed

Only force type parameters in method signature as nullable structs
Revert changes to explicit inteface helper.
Changes to unit tests
@YairHalberstadt
Copy link
Contributor Author

@AlekseyTs
I think this ready for review.

One note:

We would need to be able to get to the underlying type (__defaultType?) without triggering resolution.

I assumed you used a double underscore there on purpose, so renamed _defaultType to __defaultType.

Since it is an implementation detail we are exposing, and is also readonly, I didn't hide it behind a property.

@AlekseyTs
Copy link
Contributor

I assumed you used a double underscore there on purpose, so renamed _defaultType to __defaultType.

That actually was a typo. Sorry about that. Since that field is no longer private, it should be renamed to DefaultType to follow our naming conventions.


In reply to: 482549072 [](ancestors = 482549072)

/// The underlying type, unless overridden by _extensions.
/// </summary>
private readonly TypeSymbol _defaultType;
internal readonly TypeSymbol __defaultType;
Copy link
Contributor

@AlekseyTs AlekseyTs Apr 12, 2019

Choose a reason for hiding this comment

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

__defaultType [](start = 37, length = 13)

Names of internal fields should start with a capital letter - DefaultType #Closed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

@AlekseyTs
Copy link
Contributor

AlekseyTs commented Apr 12, 2019

            typeOpt = canDigThroughNullable ? next.NullableUnderlyingTypeOrSelf : null;

It looks like this is going to do resolution, but it is possible that canDigThroughNullable == true is never combined with useDefaultType == true. There are other similar places in this function. It would be good to add an assert on entry into this function. #Closed


Refers to: src/Compilers/CSharp/Portable/Symbols/TypeSymbolExtensions.cs:710 in e260f51. [](commit_id = e260f51, deletion_comment = False)

}

// Any nullable typeParameter declared by the method in the signature of an override or explicit interface implementation is considered a Nullable<T>
if (IsExplicitInterfaceImplementation || IsOverride)
Copy link
Contributor

@AlekseyTs AlekseyTs Apr 12, 2019

Choose a reason for hiding this comment

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

IsExplicitInterfaceImplementation [](start = 16, length = 33)

syntax.ExplicitInterfaceSpecifier != null? #Closed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wasn't sure which to use. What is the difference?

Copy link
Contributor

Choose a reason for hiding this comment

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

I wasn't sure which to use. What is the difference?

It is good to be consistent in a way this functions checks for certain conditions. For example, below it is using syntax to check if this is an explicit interface implementation. This check will also be faster, eventually the same syntax condition will be checked, I think, but it will jump through more hoops. Etc.


In reply to: 275009353 [](ancestors = 275009353)

type.TryForceResolveAsNullableValueType();
}
return false;
}, null, null, false, true);
Copy link
Contributor

@AlekseyTs AlekseyTs Apr 12, 2019

Choose a reason for hiding this comment

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

, null, null, false, true [](start = 17, length = 25)

Please use named arguments. #Closed

{
type.VisitType<object>(null, (type, unused1, unused2) =>
{
if (type.DefaultType.IsTypeParameter() && ((TypeParameterSymbol)type.DefaultType).DeclaringMethod != null)
Copy link
Contributor

@AlekseyTs AlekseyTs Apr 12, 2019

Choose a reason for hiding this comment

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

type.DefaultType.IsTypeParameter() && ((TypeParameterSymbol)type.DefaultType).DeclaringMethod != null [](start = 24, length = 101)

(type.DefaultType as TypeParameterSymbol)?.DeclaringMethod == (object)this? #Closed

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 (iteration 19)

@AlekseyTs AlekseyTs requested a review from gafter April 12, 2019 18:51
@AlekseyTs
Copy link
Contributor

@gafter Could you please review the updated PR?

bool useDefaultType = false)
{
Debug.Assert(typeWithAnnotationsOpt.HasType == (typeOpt is null));
Debug.Assert(canDigThroughNullable = false || useDefaultType == false, "digging through nullable will cause early resolution of nullable types");
Copy link
Contributor

@AlekseyTs AlekseyTs Apr 12, 2019

Choose a reason for hiding this comment

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

canDigThroughNullable = false [](start = 25, length = 29)

I think this assignment is not intentional and is the source of the test failures. #Closed

@YairHalberstadt
Copy link
Contributor Author

I remember jcouv telling me that Foo was banned in the codebase because of spellcheckers, so I've replaced it with Goo in the test files.

@AlekseyTs AlekseyTs merged commit 8a6b522 into dotnet:master Apr 15, 2019
@AlekseyTs
Copy link
Contributor

@YairHalberstadt Thanks for the contribution!

333fred added a commit to 333fred/roslyn that referenced this pull request Apr 17, 2019
* dotnet/master: (495 commits)
  Roslyn Installer: Stop processes that block VSIX installation. (dotnet#34886)
  Remove unused helper BeginInvokeOnUIThread
  Apply a hang mitigating timeout to InvokeOnUIThread
  Apply a hang mitigating timeout in RestoreNuGetPackages
  Apply a hang mitigating timeout to WaitForApplicationIdle
  Fix Value/Ref checks for pattern Index/Range (dotnet#35004)
  Fix assert in remove unused member analyzer
  Treat unconstrained type parameters declared in `#nullable disable` context as having an oblivious nullability in case they are substituted with a reference type. (dotnet#34797)
  Add symbol name to tests. Fix to be the correct name emitted
  PR feedback
  Improve IDE0052 diagnostic message for properties with used setter but unused getter
  Use original definition symbol for fetching control flow graph of generic local functions.
  Properly treat ambiguous explicit interface implementations (dotnet#34584)
  Remove the dependence between the order in NullableAnnotation and metadata attribute values (dotnet#34909)
  Fix warning level test.
  Fix bootstrap on Linux/Mac (dotnet#34978)
  disable completion for immediate window commands (dotnet#32631)
  Updated PreviewWarning color
  Implement design changes for "pattern" Index/Range indexers  (dotnet#34897)
  Add IVTs to 16.1 version of RemoteLS
  ...
@jcouv jcouv added this to the 16.1.P3 milestone Apr 18, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area-Compilers Community The pull request was submitted by a contributor who is not a Microsoft employee. Feature - Nullable Reference Types Nullable Reference Types

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants