Covariance and Contravariance in C#, Part Eight: Syntax Options

Covariance and Contravariance in C#, Part Eight: Syntax Options

Rate This
  • Comments 66

As I discussed last time, were we to introduce interface and delegate variance in a hypothetical future version of C# we would need a syntax for it. Here are some possibilities that immediately come to mind.

Option 1:

interface IFoo<+T, -U> { T Foo(U u); }

The CLR uses the convention I have been using so far in this series of “+ means covariant, - means contravariant”. Though this does have some mnemonic value (because + means “is compatible with a bigger type”), most people (including members of the C# design committee!) have a hard time remembering exactly which is which.

This convention is also used by the Scala programming language.

Option 2:

interface IFoo<T:*, *:U> { …

This more graphically indicates “something which is extended by T” and “something which extends U”.  This is similar to Java’s “wildcard types”, where they say “? extends U” or “? super T”.

Though this isn’t terrible, I think it’s a bit of a conflation of the notions of extension and assignment compatibility. I do not want to imply that IEnumerable<Animal> is a base of IEnumerable<Giraffe>, even if Animal is a base of Giraffe. Rather, I want to say that IEnumerable<Giraffe> is convertible to IEnumerable<Animal>, or assignment compatible, or some such thing. I don’t want to conceptually overwork the inheritance mechanism. It's bad enough IMO that we conflate base classes with base interfaces.

Option 3:

interface IFoo<T, U> where T: covariant, U: contravariant { …

Again, not too bad. The danger here is similar to that of the plus and minus: that no one remembers what “contravariant” and “covariant” mean. This has the benefit at least that you can do a web search on the keywords and get a reasonable explanation.

Option 4:

interface IFoo<[Covariant] T, [Contravariant] U>  { …

Similar to option 3.

Option 5:

interface IFoo<out T, in U> { …

We are taking a different tack with this syntax. In all the options so far we have been describing how the user of the interface may treat the interface with respect to the type system rules for implicit conversions – that is, what are the legal variances on the type parameters. Here we are instead describing this in the language of how the implementer of the interface intends to use the type parameters.

I like this one a lot; the down side of this is of course that, as I described a few posts ago, you end up with situations like

delegate void Meta<out T>(Action<T> action);

where the "out" T is clearly used in an input position.

Option 6:

Do something else I haven’t thought of. Anyone who has bright ideas, please leave comments.

Next time: what problems are introduced by adding this kind of variance?

  • Luke's idea seems very compelling but I still find it hard to wrap my head around. It seems like it ought to make the Action / Meta thing easier to follow but it really doesn't, to me.

    I have a hard time following what Meta might actually do so here's a concrete example - using EventHandler<T> instead of Action, then an AddHandler method seems like it would have approximately the same signature as Meta. Am I following that right?

    delegate void EventHandler<* is TEventArgs>(object sender, TEventArgs e) where TEventArgs : EventArgs;

    delegate void AddHandlerMethod<TEventArgs is *>(EventHandler<TEventArgs> handler) where TEventArgs : EventArgs;

    Nope, I'm still lost. What does that mean I can actually do with such an EventHandler compared to if it was declared as a regular EventHandler? What can I do with AddHandlerMethod?

    I still feel like this kind of declaration-site variance is terribly unuseful compared to Java's use-site variance, which *does* make sense to me (and offers a solution to the scenario in my prior comment, at least sort of...)

  • apenwarr, I do not think newbies will be exposed to covariance and contravariance [knowingly].  This will be useful to people who like to take the language to the limits and people who write frameworks.  Maybe I'm wrong, but I think support for covariance and contravariance will show itself by things working more naturally, because people will use code that allows for variance more often than write it.

    Eric, I don't recall seeing any real-life, motivating examples for this where such support would make code significantly more readable (or somewhat more readable in many places).  In other words, what scores points against the -100?  If this is coming up in a future post, ignore this question. :-)

  • With the "is" keyword, would we really even need the "*"? To me it seems like unnecessary typing, and I'm a hprrbile tpyer so the less typing the better. In my eyes, "delegate R Func<is A, R is> (A a)" conveys just as much information as "delegate R Func<* is A, R is *> (A a)"

    But so far, I like that general idea the best, asterisk or not.

  • My previous comment didn't seem to go though, so forgive me if this ends up getting posted twice.

    Option 3 should be :

    interface IFoo<T, U>

     where T: covariant

     [where] U: contravariant { …

    to be consistant with existing constraints.  Commas only seperate constraints on a single parameter.

    I would also like to suggest you avoid confusion between generic constraints and 'freedoms', using a 'let' clause.

    interface IFoo<T, U>

     where T: IBar  //a constraint on T

     let T: covariant //a 'freedom on T

     where U: new() //a constraint on U

     let U: contravariant  //a freedom on U

    { …

    basically, seperate out constraints (where) from freedoms (let) in a LINQ like declaritive way.  This is better IMO than putting anything in between <>'s because it fits more with the current constraint model (just where clauses)

    -Brandon

  • I think the description of U in Option 2 is reversed. The post says that "*:U" means “something which U extends”. Shouldn't that be “something which extends U”? The description of "T:*" could also be simplified to “something which T extends”.

  • Freedoms are certainly the most palatable solution. Whether 'covariant' and 'contravariant' are the most intuitive keywords is debatable.

    How about:

    interface IFoo<T, U>

     where T : IBar

     let * : T

     where U : IStuff

     let U : *

    { ...

    The relatively universal wildcard '*' still denotes 'any type where' and the freedoms provide a clean, extensible syntax.

    My only reservation is using ':' to denote a non-inheritance relationship. How about this even:

    nterface IFoo<T, U>

     where T : IBar

     let * >= T

     where U : IStuff

     let U <= *

    { ...

  • my vote is for lukes idea for readability; it's great.

    a secondary vote for the attribute approach due to backwards compat and further 'verbose-ness'. it does allow searching for the concepts which is great.

    but out of all; i prefer lukes. great idea. it actually helped me understand the concept incredibly easily.

  • I am wavering on the "constraint/freedom" question.

    Saying 'U may be anything like this' is equivalent to saying 'U cannot be anything out of this range', so I would still consider variance a constraint, not a freedom.

    From that perspective, I like "assignment constraints":

    where T : IBar

    where T <= *

    where U : IStuff

    where U >= *

    Check for a maximum of 1 inheritance constraint and 1 assignment constraint.

  • Option 1:

    interface IFoo<+T, -U> { T Foo(U u); }

    I am most accustomed to this notation from the CLR.

    It's well attested and works.

    This immediately gets my vote.

    Option 2:

    interface IFoo<T:*, *:U> { …

    The first thing that comes to my mind is some kind of strange pointer...

    Not very intuitive.

    Option 3:

    interface IFoo<T, U> where T: covariant, U: contravariant { …

    Not bad, but really no strong points over option 1.

    Also, IMO, it is too verbose.

    Option 4:

    interface IFoo<[Covariant] T, [Contravariant] U>  { …

    I can not really explain why, but I do not want to think of variance as an attribute of a something.

    Option 5:

    interface IFoo<out T, in U> { …

    I have a handle on + and -, but I have a hard time digesting in/out.

    Options 6 (Luke)

    delegate R Func<* is A, R is *> (A a)

    I like the is keyword being used here. However, I am opposed to the * notation. Again, it looks like some kind of weird pointer. Can it be done without the *? And if so, how? Not that I have a better suggestion.

    Weighing all of the pros and cons, I prefer option 1.

  • Slightly crazy syntax suggestion:

    delegate R Func<A, R>(A a)

     using A as params

     using R as return;

    It has the same problem as the "in/out" proposal though.

    I've been trying to articulate my problem with this compared to Java-style use site variance and I think I've figured it out. With this system, interfaces are only variant in a fairly limited set of scenarios: IEnumerable and IEnumerator can be made variant but IList never can. And the circumstances in which a *class* could be variant are so few as to be almost not worth considering. It's illuminating that Eric hasn't even mentioned the possibility of variance on classes, while strongly hinting that variance on delegates and interfaces is likely.

    But in fact it's not the type, per se, that is variant; it's the particular *use* of it. For instance:

    public void printAll(List<*> list) {

     foreach (var obj in list) Console.WriteLine(obj.ToString());

    }

    List<T> is not and cannot be covariant, but the list parameter of the printAll method *is* covariant, because it's being *used* covariantly. (The * in this case is essentially shorthand for "* is object" since everything "is object").

    On the other hand:

    public void populateList(List<String is *> list) {

     list.Add("Hello");

     list.Add("World");

    }

    List<> isn't contravariant either, but in this case it's being used contravariantly.

    C#'s historical way of handling this has been to make all the *s into explicit type parameters, so we'd have something like

    public void printAll<T>(List<T> list) {...}

    and

    public void populateList<T>(List<T> list) where String : T {...}

    (actually I'm not completely sure that second one is really even legal) and then hope that we can hide the complexity from end users by always inferring the T. This fails in all sorts of ways, ranging from method overloading (if there was a printAll or a populateList that *didn't* have a generic parameter, C# seems to fail to do anything useful or intuitive with inference) to the scenario I mentioned where T is a type instantiated by reflection and completely unknown at compile time. Or another example - T is an internal type only exposed by a public interface (and yes, I know using List<> here would be bad in real code, but it's the best-known generic type out there, the same situations occur with homegrown generic classes):

    public interface IFrobbable { void Frob(); }

    internal class VeryFrobbable : IFrobbable { ... }

    public class FrobbableFactory {

     private List<VeryFrobbable> frobbables;

     public static List<* is IFrobbable> GetFrobbables() {

        initialize();

        return frobbables;

     }

    }

    Client code:

    public void FrobEveryOther() {

     var frobbables = FrobbableFactory.GetFrobbables();

     for (int i = 0; i < frobbables.Count; i += 2) {

       frobbables[i].Frob();

     }

    }

    The declaration-site variance that you're talking about seems to allow a very limited number of scenarios and depends incredibly heavily on the provider of all your interfaces creating variant versions of every interface. For example, to make anything like the frobbable scenario work using what you're proposing, you need:

    interface IReadList<* is T> {...}

    interface IWriteList<T is *> {...}

    interface IList<T> : IReadList<T>, IWriteList<T> {...}

    Is it really realistic to assume that everyone who defines an interface will think to split it up that way? Or that nobody will want to use an interface whose author *didn't* think of this, in a variant way? The situation gets even worse for classes because you need to create three interfaces *plus* the class. And the kicker? That only works for classes with one type parameter. IDictionary<K,V>? Fuhgeddaboudit. Combinatoric growth of number of interfaces needed to cover everything that a user might want to do.

  • Last comment of the night, I promise. But I just want to emphasize how important I feel this is. I've been begging for variance in generics for a very long time and very strenuously. But if all you're going to do is the delegate/interface variance you've been talking about, no matter how intuitive you manage to make the syntax, I'd actually argue strongly *against* bothering to include it at all. It adds a lot of complexity and helps in pretty much zero of the scenarios where I've encountered a need for variance in real life. If you can't offer use-site variance, just stick to covariant return types on overridden methods, and beyond that, don't even bother.

  • The following Microsoft Research is an interesting read:

    "Variance and Generalized Constraints for C# Generics"

    http://research.microsoft.com/research/pubs/view.aspx?type=inproceedings&id=1215

    And it discusses variant classes.

    While it would be useful, at least variant interfaces would be a great start which hopefully could be extended in the future.

  • How about using base, it already has some meaning relevant to this:

    delegate R Func<base A, R base> (A a)

    So the position of base indicates the co/contra

  • Marc,

    All your base are belong to us.

  • Option 1 (+/-) looks good enough for me. minus by minus equals plus in general math too, this is a benefit of this syntax.

    Option 2 (T:*/*:U) confuses me. Like Bradley, I thought it to be reversed, and I still don't understand how *:U (or ? extends U, for that matter) expresses "something which U extends". It's also hard to relate "something which U extends" to "interface is contravariant on U"...

    Option 3 (where "constraints"): I share the reservations about using where for constraints AND variance.

    Option 4 (Attributes): good enough. + and - might be easier to relate to bigger/smaller types, but co/contravariance are easier to google. tough to decide, i suppose either one will do. I'd perfer a contextual keyword to attributes though. this is a first class language feature after all!

    Option 5 (in/out): I see how you like this for simple cases, but get real: it's incorrect for others, and at the end of the day, you're not going to include incorrect syntax into the language, even if we'd all love it. (which I do not, for exactly this reason)

    Luke's suggestion (* is A) and Matt's one (positional "base"): I find those just as confusing as option 2.

    Rasmus's suggestions indicates that he didn't understand the problem, the "where" syntax led him to believe that we're talking about constraints (or maybe I'm not getting it). Goes to prove that option 3 is a bad idea.

    Actually I like Peter Ritchie's suggestion a lot, only that I would recommend built-in syntax:

    interface IFoo<T,U>

    covariant on T

    contravariant on U

    having this on the interface rather than on the type parameters themselves indicates that we're talking about a quality of the interface, not of the type parameter. all other options except #5 (especially #3) seem to lead people to think more about what the type parameter is, when variance actually says nothing about that type parameter, but only about the generic interface's (or delegate's) relation to that parameter.

    The example above reads quite intuitively. I have an interface with two parameters, they can be whatever the user likes (unless there are additional where constraints). the interface is covariant on T and contravariant on U. covariant means that assignability of IFoo goes with T, and contravariant goes into the other direction. this is not hard to remember, "co" and "contra" express this well. The only thing that needs to be remembered (or logically deduced, which is possible) is that covariance is typically useful for output parameters, while contravariance is typically useful for input parameters. since this is only the typical case, and wrong in others (Meta<+T>) I don't see how you could make this mapping more implicit. My opinion is that for the sake of correctness, we have to accept this difficulty.

Page 2 of 5 (66 items) 12345