Weird example of variance rules for delegates
Asked Answered
S

1

2

In Eric Lippert's blog posts about covariance and contravariance or variance for short, and in books such as C# in a Nutshell, it is stated that :

If you’re defining a generic delegate type, it’s good practice to:

  • Mark a type parameter used only on the return value as covariant (out).
  • Mark any type parameters used only on parameters as contravariant (in).

Doing so allows conversions to work naturally by respecting inheritance relationships between types.

So I was experimenting this and I have found a rather strange example.

Using this class hierarchy :

class Animal { }

class Mamal : Animal { }
class Reptile : Animal { }

class Dog : Mamal { }
class Hog : Mamal { }

class Snake : Reptile { }
class Turtle : Reptile { }

While trying to play with method-group-to-delegate conversions and delegate-to-delegate conversions, I wrote this code snippet :

 // Intellisense is complaining here  
 Func<Dog, Reptile> func1 = (Mamal d) => new Reptile();

 // A local method that has the same return type and same parameter type as the lambda expression.
 Reptile GetReptile(Mamal d) => new Reptile();

 // Works here.  
 Func<Dog, Reptile> func2 = GetReptile;

Why are the variance rules working for the local method but not for the lambda expression ?

Given that a lambda expression is an unnamed method written in place of a delegate instance and that the compiler immediately converts the lambda expression to either:

  • A delegate instance.
  • An expression tree, of type Expression.

I assume that with :

 Func<Dog, Reptile> func1 = (Mamal d) => new Reptile();

What is happening is a conversion from something like :

Func<Mamal, Reptile> => Func<Dog, Reptile>. 

Are variance rules from delegates to delegates different from variance rules for method groups to delegates?

Shillong answered 28/2, 2019 at 15:56 Comment(0)
S
5

Let me clarify your question slightly.

These three things may be converted to a delegate type: (1) a lambda (or C# 2 style anonymous method), (2) a method group or local method, (3) another delegate. Are the rules for what covariant and contravariant conversions are legal different in each case?

Yes.

How are they different?

You should read the specification for the exact details, but briefly:

  • A generic delegate type may be converted to another generic delegate type only if the delegate type parameters are marked as covariant or contravariant. That is, Func<Giraffe> can be converted to Func<Animal> because Func<out T> is marked as covariant. (Aside: if you need to make a variant conversion from one delegate type to another and the delegate type does not support variance, what you can do instead is use the method group of the Invoke method of the "source" delegate, and now we're using method group rules, but losing reference equality.)

  • A method group or local method can be converted to a matching delegate type using covariance and contravariance rules, even if the delegate is not marked as supporting variance. That is, you can convert Giraffe G() to delegate Animal D(); even if D is not generic, or is generic but not marked as variant.

  • The rules for converting lambdas are complicated. If the lambda has no formal parameter types, then the formal parameter types of the target type are used, the lambda body is analyzed, and it is convertible if the body analyzes without error and the result is compatible with the target type's result type. If the lambda does have formal parameter types they must match exactly the target type's formal parameter types.

Why are they different?

Different things are different. I really don't know how to answer such a vague, broad "why" question.

Those rules were derived by a dozen people sitting in a room over a period of many years. Method group to delegate conversions were added in C# 1, generic delegates were added in C# 2, lambdas were added in C# 3, generic delegate variance was added in C# 4. I have no idea how to possibly answer a "why" question about the literally hundreds of hours of design work that were done, and more than half of it was before I was on the design team. That design work involved a great many arguments and compromises. Please do not ask vague "why" and "why not" questions about programming language design.

Questions like "what page of the spec defines this behaviour?" have an answer, but "why does the spec say that?" is basically asking for a psychological analysis of people who did this design work fifteen years ago, and why they found certain compromises compelling and others not so much. I'm not capable or willing to do that analysis; it would involve rehashing literally hundreds of hours of arguments.

If your question is "what are general language design principles which encourage or discourage exact or inexact matching?" that's a topic I could discuss at length for hours. For example, I designed a new overload resolution algorithm yesterday and overload resolution is about nothing other than deciding when exact or inexact matches are important, and how important they are. Ask a more specific question.

Tell you what, let's have you do that work instead of me. Here's one of your scenarios:

Action<Mammal> ma = (Animal a) => ...

Describe to me the compelling benefit of disallowing the user from writing that line of code. Example: It sure looks like a bug to me. It looks like the user started typing one thing and changed their mind halfway through. This sort of pointless, bizarre inconsistency is highly characteristic of sloppy, buggy code, and it can be easily prevented. One of the design principles of C# is that the language tells you when you've probably made a mistake. That sure looks like a mistake.

Now make the counter-argument that the code should be allowed. Example: Is it also a general principle that there should be consistency between lambdas and local methods as far as their convertibility rules go? How important is that rule compared to the rule about preventing sloppy bugs?

Now come up with a dozen more arguments about the subtle pros and cons of each choice, and how different developer scenarios affect your analysis of each choice. Give many, many examples of realistic code.

Keep in mind that some users are experts in type systems, some are not. Some are architects with twenty years experience, some are fresh out of college. Some are Java programmers who just picked up C# yesterday and are still in an erasure mindset; some are F# programmers who are used to full-program inference. Take extensive notes on the pros and cons of each scenario, and then come up with a compromise proposal that does not compromise too far on any important scenario.

Now consider the costs. Will the proposed feature be difficult to implement? Does it add a new error message? Is the message clear, or will it confuse users? Does the proposed feature possibly prevent any future feature? I note that you have to make good predictions of the future of the language to do this step.

Once you've come up with a decision, then describe all that work in one sentence that answers the question "why did you decide that?"

Schlegel answered 1/3, 2019 at 17:40 Comment(1)
Sorry for the long time this question took to be marked as answer. Maybe the way I asked this question seems to show that I.m questioning the design team decisions, but it's not the case I just wanted to understand because I'm very enthusiastic about this topic. I understand the effort made to make the type system work in a correct way, and I understand also that discussing and understanding the decision requires advanced knowledge of some abstract Math topics. anyway, thanks a lot for putting the light on the subject.Shillong

© 2022 - 2024 — McMap. All rights reserved.