Why doesn't the C# compiler consider this generic type inference ambiguous?
Asked Answered
T

1

5

Given the following class:

public static class EnumHelper
{
    //Overload 1
    public static List<string> GetStrings<TEnum>(TEnum value)
    {
        return EnumHelper<TEnum>.GetStrings(value);
    }

    //Overload 2
    public static List<string> GetStrings<TEnum>(IEnumerable<TEnum> value)
    {
        return EnumHelper<TEnum>.GetStrings(value);
    }
}

What rules are applied to select one of its two generic methods? For example, in the following code:

List<MyEnum> list;
EnumHelper.GetStrings(list);

it ends up calling EnumHelper.GetStrings<List<MyEnum>>(List<MyEnum>) (i.e. Overload 1), even though it seems just as valid to call EnumHelper.GetStrings<MyEnum>(IEnumerable<MyEnum>) (i.e. Overload 2).

For example, if I remove overload 1 entirely, then the call still compiles fine, instead choosing the method marked as overload 2. This seems to make generic type inference kind of dangerous, as it was calling a method which intuitively seems like a worse match. I'm passing a List/Enumerable as the type, which seems very specific and seems like it should match a method with a similar parameter (IEnumerable<TEnum>), but it's choosing the method with the more generic, generic parameter (TEnum value).

Trefoil answered 18/5, 2018 at 6:23 Comment(14)
Why should Overload 2 be valid for your example? TEnum would be List<MyEnum> so the argument is IEnumerable<List<MyEnum>> value - isnt it ovious that he would prefer the method with List<MyEnum> value as argument?Dryden
@RandRandom: No, TEnum would be inferred as MyEnum for the second overload. Why would it be inferred as List<MyEnum>?Hinman
@DaisyShipton Because you are passing the list, and c# just ommits the declartion of the generic type - why should it pick the generic of a class to pass into the declaration of the method? for exmple if you did the following - List<Foo<string>> list; EnumHelper.GetStrings(list); would you know expect that c# will call the method with IEnumerable<string> value just because Foo is declared as Foo<string> ? - no it will be IEnumerable<List<Foo<string>> valueDryden
@RandRandom: I don't know what you mean by "C# just omits the declaration of the generic type" but the second method is applicable, with TEnum inferred to be MyEnum, because List<MyEnum> implements IEnumerable<MyEnum>. That's just type inference, and can be shown by the call working if you remove overload 1.Hinman
@DaisyShipton - I believe you are forgetting that the method is declared as GetStrings<TEnum> - <TEnum> will be the type you are calling the method - what you are saying is true if you remove the generic declaration and just have public static List<string> GetStrings(IEnumerable value) than just List<MyEnum> inherits IEnumerable and it will be calledDryden
@RandRandom: No, I'm not forgetting that at all. I don't know what you mean by "the type you are calling the method" but TEnum really is inferred as MyEnum in a call to the second method with an argument of type List<MyEnum>. If you don't believe the second method is applicable at all, please try it. Just take the code in the question, remove the first overload, and try calling it with a List<Foo> where Foo is an enum. Add Console.WriteLine(typeof(TEnum)); into the body of the method - you'll see it print Foo.Hinman
This might be obvious and I know this isn't your question, but you can force it to select the second overload by simply calling EnumHelper.GetStrings<MyEnum>(list); instead of EnumHelper.GetStrings(list);.Indeterminate
In the above case TEnum is not inferred as MyEnum. That's the surprising part. It chooses the first overload, so TEnum becomes List<MyEnum>. If I remove Overload 1, then it uses Overload 2 and TEnum is inferred to be MyEnum (instead of List<MyEnum>). What I don't understand is why one would be preferred over the other, when they're both valid, and removing either method without changing anything else results in no compiler errors.Trefoil
Yes, I can force the generic parameter by specifying it, but the whole purpose of the class is to use it for generic type inference, so I don't have to specify it on the EnumHelper<TEnum> generic class.Trefoil
@Triynko: During overload resolution, TEnum is inferred as List<MyEnum> for the first method, but it's inferred as MyEnum for the second method. After type inference, overload resolution is used to pick which method is actually called. But that doesn't change the fact that while it's considering the second method, it infers TEnum to be MyEnum. It's worth separating out the two phases in your mind. (I've edited my answer to make that slightly clearer.)Hinman
If the outcome of overload resolution is "wrong" for your situation, such that selection of the wrong overload produces undesirable behaviour, that's usually an indication that those methods should not have been given the same names in the first place. Because they obviously aren't doing the same job.Ianiana
Maybe I'm too naive but I don't see how this is confusing. Consider two overloads of a non-generic method, one takes a string parameter and the other takes an object parameter. When passing a string to the method, the second overload will never be used unless you delete the first one, right?. Isn't this the same concept?Indeterminate
Changing the names is an option on the table. In the underlying class, GetStrings(TEnum) will break apart the individual flags composing the enum and return their string equivalents from EnumMember attributes. So it really should be called something like GetStringsForFlagsEnum. Meanwhile, GetStrings(IEnumerable<TEnum>>) will just return the one-to-one mappings for each enum, and will actually throw an error if a flags value is passed, because there's no mapping for composite values.Trefoil
Ahmed, the confusion is because the 'shape' of the passed type 'List<MyEnum>' seems superficially closer to the signature that accepts a list/enumerable type. Here, by the type it establishes 'applicable' matches, it's completely ambiguious. They're both 100% appropriate. So what you actually have in the end is the decision between using (List<MyEnum>) or (IEnumerable<MyEnum>), and so then it's much like string/object. There's just that type-inference phrase in between that's confusing.Trefoil
H
7

What rules are applied to select one of its two generic methods?

The rules in the specification - which are extremely complex, unfortunately. In the ECMA C# 5 standard, the relevant bit starts at section 12.6.4.3 ("better function member").

However, in this case it's relatively simple. Both methods are applicable, with type inference occurring separately for each method:

  • For method 1, TEnum is inferred to be List<MyEnum>
  • For method 2, TEnum is inferred to be MyEnum

Next the compiler starts checking the conversions from arguments to parameters, to see whether one conversion is "better" than the other. That goes into section 12.6.4.4 ("better conversion from expression").

At this point we're considering these conversions:

  • Overload 1: List<MyEnum> to List<MyEnum> (as TEnum is inferred to be List<MyEnum>)
  • Overload 2: List<MyEnum> to IEnumerable<MyEnum> (as TEnum is inferred to be MyEnum)

Fortunately, the very first rule helps us here:

Given an implicit conversion C1 that converts from an expression E to a type T1, and an implicit conversion C2 that converts from an expression E to a type T2, C1 is a better conversion than C2 if at least one of the following holds:

  • E has a type S and an identity conversion exists from S to T1 but not from S to T2

There is an identity conversion from List<MyEnum> to List<MyEnum>, but there isn't an identity conversion from List<MyEnum> to IEnumerable<MyEnum>, therefore the first conversion is better.

There aren't any other conversions to consider, therefore overload 1 is seen as the better function member.

Your argument about "more general" vs "more specific" parameters would be valid if this earlier phase had ended in a tie-break, but it doesn't: "better conversion" for arguments to parameters is considered before "more specific parameters".

In general, both overload resolution is incredibly complicated. It has to take into account inheritance, generics, type-less arguments (e.g. the null literal, the default literal, anonymous functions), parameter arrays, all the possible conversions. Almost any time a new feature is added to C#, it affects overload resolution :(

Hinman answered 18/5, 2018 at 6:38 Comment(6)
Beautiful explanation. Thank you. I read the spec and saw it mentioning that it does the type inference first, then the overload resolution, but it didn't make sense. What you said makes sense. So it figures out what the type inference would be first. So if I had some generic type constraints like where TEnum : struct, Enum (C# 7.3, released a couple weeks ago), then it might rule out the Overload 1 early on. Without any constraints, they're both applicable, and then inferred type List<MyEnum> is clearly a closer match to itself than it is to IEnumerable<MyEnum>, so it chooses Overload 1.Trefoil
@Triynko: I'd have to check whether that would work, but I expect so. Using a constraint of just where TEnum : struct wouldn't require that C# 7.3 feature, but I think it might require another C# 7.3 feature in terms of changing when constraints are checked. IIRC, constraints aren't checked as part of type inference, and before C# 7.3 they weren't checked when choosing an applicable method - they were only checked after the "best" method was chosen. I believe 7.3 moved it into checking for applicability.Hinman
So I just tried it, and it doesn't work. If I add a generic type constraint to the first overload of where TEnum : struct, Enum, it still only tries to use overload 1 and lists the call as an error in violation of the constraint. It doesn't fall back to using the other overload (even though it could use it). If I comment out the first method entirely, it compiles fine again. So no luck with the constraint helping it make a better decision, haha. It's like it's hell-bent on (trying to) use the first overload as long as it exists.Trefoil
The EnumHelper is in a separate project, which I switched to C# 7.3 so that I could add the Enum type constraint. The calling code is in another project however. When the calling project is set to C# 7.2, it refuses to use overload 2 and just sits there showing an error. But if I switch the calling project to C# 7.3, then it does the smart thing and chooses overload 2 (because it figures 1 is not applicable due to constraint violation) and compiles fine. Cool!Trefoil
@Triynko: Right - that would be the second feature of C# 7.3 that I mentioned, around when constraints are applied. At least, I suspect so :)Hinman
You've got a bounty heading your way. Well deserved, allow me to compliment you on the excellent help you provide here. It is rare to be so technically astute and a great communicator. Looking forward to your future posts.Breaking

© 2022 - 2024 — McMap. All rights reserved.