Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Implicit conversion fails when changing struct to sealed class

Struct/class in question:

public struct HttpMethod
{
    public static readonly HttpMethod Get = new HttpMethod("GET");
    public static readonly HttpMethod Post = new HttpMethod("POST");
    public static readonly HttpMethod Put = new HttpMethod("PUT");
    public static readonly HttpMethod Patch = new HttpMethod("PATCH");
    public static readonly HttpMethod Delete = new HttpMethod("DELETE");

    private string _name;

    public HttpMethod(string name)
    {
        // validation of name
        _name = name.ToUpper();
    }

    public static implicit operator string(HttpMethod method)
    {
        return method._name;
    }

    public static implicit operator HttpMethod(string method)
    {
        return new HttpMethod(method);
    }

    public static bool IsValidHttpMethod(string method)
    {
        // ...
    }

    public override bool Equals(object obj)
    {
        // ...
    }

    public override int GetHashCode()
    {
        return _name.GetHashCode();
    }

    public override string ToString()
    {
        return _name;
    }
}

The following code triggers the issue:

public class HttpRoute
{
    public string Prefix { get; }
    public HttpMethod[] Methods { get; }

    public HttpRoute(string pattern, params HttpMethod[] methods)
    {
        if (pattern == null) throw new ArgumentNullException(nameof(pattern));
        Prefix = pattern;
        Methods = methods ?? new HttpMethod[0];
    }

    public bool CanAccept(HttpListenerRequest request)
    {
        return Methods.Contains(request.HttpMethod) && request.Url.AbsolutePath.StartsWith(Prefix);
    }
}

The compiler error is created by changing the HttpMethod struct into a sealed class. The error is reported for return Methods.Contains(request.HttpMethod), note: request.HttpMethod in this case is a string. Which produces the following:

Error   CS1929  'HttpMethod[]' does not contain a definition for 'Contains' and the best extension method overload 'Queryable.Contains<string>(IQueryable<string>, string)' requires a receiver of type 'IQueryable<string>'

My question is why? I can redesign the code to make it work, but I'm wanting to know why changing from struct to sealed class creates this weird error.

Edit: Adding a simplified set of example code (available here: https://dotnetfiddle.net/IZ9OXg). Take note that commenting out the implicit operator to string on the second class allows the code to compile:

public static void Main()
{
    HttpMethod1[] Methods1 = new HttpMethod1[10];
    HttpMethod2[] Methods2 = new HttpMethod2[10];

    var res1 = Methods1.Contains("blah"); //works
    var res2 = Methods2.Contains("blah"); //doesn't work
}

public struct HttpMethod1
{
    public static implicit operator HttpMethod1(string method)
    {
        return new HttpMethod1();
    }

    public static implicit operator string (HttpMethod1 method)
    {
        return "";
    }

}

public class HttpMethod2
{
    public static implicit operator HttpMethod2(string method)
    {
        return new HttpMethod2();
    }

    //Comment out this method and it works fine
    public static implicit operator string (HttpMethod2 method)
    {
        return "";
    }

}
like image 768
Moosh Beef Avatar asked Mar 25 '17 00:03

Moosh Beef


2 Answers

Things I know:

  • Plainly the problem is in type inference.
  • In the first case, T is deduced to be HttpMethod1.
  • In the struct case, there is no conversion from HttpMethod1[] to IEnumerable<string> because covariance only works on reference types.
  • In the class case, there is no conversion from HttpMethod2[] to IEnumerable<string> because covariance only works on reference conversions, and this is a user-defined conversion.

Things I suspect but need to confirm:

  • Something about the slight difference between my last two points is confusing the type inference algorithm.

UPDATE:

  • It has nothing to do with covariant array conversions. The problem repros even without array conversions.
  • It does however have to do with covariant interface conversions.
  • It has nothing to do with strings. (Strings are often a bit weird because they have a hard-to-remember conversion to IEnumerable<char> that occasionally messes up type inference.)

Here's a program fragment that displays the problem; update your conversions to convert to C instead of string:

public interface IFoo<out T> {}
public class C {}
public class Program
{
    public static bool Contains<T>(IFoo<T> items, T item) 
    {
        System.Console.WriteLine(typeof(T));
        return true; 
    }
    public static void Main()
    {
        IFoo<HttpMethod1> m1 = null;
        IFoo<HttpMethod2> m2 = null;
        var res1 = Contains(m1, new C()); //works
        var res2 = Contains(m2, new C()); //doesn't work
    }
    }

This looks like a possible bug in type inference, and if it is, it is my fault; many apologies if that is the case. Sadly I do not have time to look into it further today. You might want to open an issue on github and have someone who still does this for a living look into it. I would be fascinated to learn what the result was, and if it turns out to be a bug in either the design or the implementation of the inference algorithm.

like image 185
Eric Lippert Avatar answered Nov 07 '22 18:11

Eric Lippert


Firstly, this is an observed behavioural difference between structs and classes. The fact that you have 'sealed' your class does not affect the outcome in this scenario.

Also we know the following statement will compile as expected for HttpMethod type declared as both a struct and class, thanks to the implicit operator.

string method = HttpMethods[0];

Dealing with Arrays introduces some lesser understood compiler nuances.

Covariance

When HttpMethod is a class (reference type), with an array such as HttpRoute.HttpMethods Array covariance (12.5 C# 5.0 Language Spec) comes into play that allows HttpMethod[x] to be treated as an object. Covariance will respect inbuilt implicit reference conversions (such as type inheritance or conversion to object) and it will respect explicit operators, but it will not respect or look for user defined implicit operators. (While a bit ambigous the actual spec doc lists specifically default implicit operators and explicit operators, it does not mention the user defined operators but seeing everything else is so highly specified you can infer that user defined operators are not supported.)

Basically Covariance takes precedence over many generic type evaluations. More on this in a moment.

Array covariance specifically does not extend to arrays of value-types. For example, no conversion exists that permits an int[] to be treated as an object[].

So when HttpMethod is a struct (value type), covariance is no longer an issue and the following generic extension from System.Linq namespace will apply:

public static bool Contains<TSource>(this IEnumerable<TSource> source, TSource value);

Because you have passed in a string comparator, the Contains statement will be evaluated as follows:

public static bool Contains<string>(this IEnumerable<string> source, string value);

When HttpMethod is a class (Reference Type), thanks to covariance, HttpMethod[] in it's current form comparable only with Object[] and thus IEnumerable, but not IEnumerable< T >, Why not? because the compiler needs to be able to determine the type to generate the generic implementation of IEnumerable< T > and to determine if it can perform an explicit cast from object to T. Put another way, Compiler cannot determine if T can definetly be a String or not, so it doesn't find the match in the Linq extension methods that we were expecting.

So what can you do about it? (! Not this !) The first common attempt might be to try using .Cast< string >() to cast the HttpMethod instances to strings for the comparison:

return HttpMethods.Cast<string>().Contains(request.Method) && request.Url.AbsolutePath.StartsWith(Prefix);

You will find that this does not work. Even though The parameter for Cast< T > is of type IEnumerable, not IEnumerable< T >. It is provided to allow you to use older collections that do not implement the generic version of IEnumerable with LINQ. Cast< T > is only designed to convert non-generic objects to their "true" type through the process of evaluating common origins for reference types or Un-Boxing for value types. If Boxing and Unboxing (C# Programming Guide) only applies to value types (structs) and since our HttpMethod type is a reference type (class) the only common origin between HttpMethod and String is Object. On HttpMethod there is no implicit, or even explicit operator that accepts Object and as it is not a value type there is no in built un-box operator that the compiler can use.

Note that this Cast<> will fail at runtime in this scenario when HttpMethod is a value type (class) the compiler will be happy to let it build.

Final Workaround

Instead of Cast< T > or relying on implicit conversions we will need to force the elements in the HttpMethods array to be explicitly cast to string (This will still use out implicit operator!) but Linq again makes this a trivial, but necessary task:

return HttpMethods.Select(c => (string)c).Contains(request.Method) && request.Url.AbsolutePath.StartsWith(Prefix);
like image 40
Chris Schaller Avatar answered Nov 07 '22 18:11

Chris Schaller