Runtime: Make IOrderedEnumerable<T> covariant

Created on 11 Jul 2015  路  28Comments  路  Source: dotnet/runtime

Today, I was trying to convert an IOrderedEnumerable<string> to an IOrderedEnumerable<object>, but the compiler responded with an error. Looking into the interface, there doesn't seem to be any reason why it can't be covariant, and in fact when I copied and pasted the interface and changed the class signature to IOrderedEnumerable<out T> everything compiled fine.

Can we change this in the BCL, as well? There doesn't seem to be a reason not to.

Sidenote: This may cause some breaking changes, but as the post points out this has already been done with IEnumerable<T> on a greater scale.

Proposed API

C# // Updates existing interface in being covariant. public interface IOrderedEnumerable<out TElement> : IEnumerable<TElement> { // Existing member IOrderedEnumerable<TElement> CreateOrderedEnumerable<TKey>(Func<TElement, TKey> keySelector, IComparer<TKey> comparer, bool descending); }

(edited by @terrajobst to include the API write-up from @JonHanna)

api-approved area-System.Linq up-for-grabs

Most helpful comment

Seems like it's only a source breaking change. While unfortunate, we believe the value add for supporting covariance is higher.

All 28 comments

Just noticed that in the first link I posted, the table at the bottom lists IOrderedEnumerable<T> as covariant, when it's the only one there that's not (the out is missing). Was this a mistake by the writers, maybe?

I'm surprised to learn that it isn't covariant, as it does seem to naturally be a candidate for it. I suppose the main potential flaw is if there are current uses of CreateOrderedEnumerable that fail when called covariantly, especially as used by Enumerable.ThenBy

@hackcraft Except for the corner case in the link I posted, I don't see how existing implementations could break (but I definitely might be wrong). Can you post an example?

If you change the interface as suggested, then the following works fine:

[Fact]
public void CovariantOrdered()
{
    var ordered = Enumerable.Range(0, 100).Select(i => i.ToString()).OrderBy(i => i.Length);
    IOrderedEnumerable<IComparable> o = ordered;
    Assert.Equal(
        Enumerable.Range(0, 100).Select(i => i.ToString()).OrderBy(i => i.Length).ThenBy(i => i).ToList(),
        o.ThenBy(i => i).Cast<string>().ToList()
        );
}

The question is whether there's a trickier case, or another implementation of IOrderedEnumerable where this is a problem. I can't think of a way that there would be, but I'll try to come up with something this breaks. (You can write something that it allows to compile which throws, if your key-selector or comparer does casts, but you wouldn't have been able to get it to compile before).

I note that IOrderedQueryable<T> is covariant.

I'm beginning to think that IOrderedEnumerable<T> not being covariant may be a bug in the implementation rather than a lack in the API's design. As you note, some documentation does suggest it is indeed covariant. Perhaps the combination of such covariance with Func<T, TResult> being contravariant on T threw someone; it is actually necessary to make covariance on IOrderedEnumerable<T> work, but its more convoluted than most covariant/contravariant cases.

A further consideration. The following does not currently compile:

IOrderedEnumerable<IComparable> ordered = Enumerable
  .Range(0, 200)
  .Select(i => i.ToString())
  .OrderBy(i => i.Length);

Which means that while the following does compile:

IOrderedEnumerable<IComparable> ordered = Enumerable
  .Range(0, 200)
  .AsQueryable()
  .Select(i => i.ToString())
  .OrderBy(i => i.Length);
ordered = ordered.ThenBy(i => i);

Any attempt to use ordered tries to do what the first code that won't compile does, and of course can't find a matching method, so you get an InvalidOperationException.

This would seem to be a clear bug. Indeed, it's the potential bug that the test case Queryable_Tests.MatchSequencePattern() tries to catch; if you can do something with a queryable you should be able to do it with an enumerable, and exceptions to that are bugs.

Well, there's two commits that fix this, one relative to master should the absence be considered a bug, one relative to future should its future presence be considered an API addition.

Seems reasonable to me.

@ericstj We've done such as change in the past with IEnumerable<T>. Any other compat concerns?

The change to IEnumerable was made between 3.5 / 4.0 which weren't in-place so that's a little different. My impression is that the nature of this change is only source breaking but I'd need to try it out to be sure.

What's the runtime behavior of this if the implementation doesn't have the fix but the reference assembly does? In other words is the reference assembly change sufficient? If not then we need to consider this an API change since it will need to be brought back to desktop in a new version.

@JonHanna can you mock up an example compiled against the old surface area that would observe the difference, similar to the one in the SO post. Can you also try compiling against the new surface area but running on old.

@ericstj It's source-breaking but not breaking on previously compiled code. Take a look at https://github.com/JonHanna/corefx/commit/9e93882f8857d17b4d5ecb8aaf85d72ead8abba8

This has the following simple test, based on the SO. It fails either way, but differently in each case:

``` C#
public class Base
{
public void DoSomething(IOrderedEnumerable strings)
{
Assert.Equal("", "Base");
}
}

public class Derived : Base
{
public void DoSomething(IOrderedEnumerable objects)
{
Assert.Equal("", "Derived");
}
}

[Fact]
public void WhichIsCalled()
{
new Derived().DoSomething(new List().OrderBy(i => i));
}

Without changing `IOrderedEnumerable` to be covariant, this fails with `"Base"`.

After changing `IOrderedEnumerable`, this fails with `"Derived"`.

Pasting in the System.Linq.Tests.dll from the previous (non-covariant) build and running it with the the System.Linq.dll from the new (covariant) build, this fails with `"Base"`.

The reason being that the method to call is determined at compile-time and fully-named in the IL as:

call instance void System.Linq.Tests.OrderByTests/Base::DoSomething(class [System.Linq]System.Linq.IOrderedEnumerable`1)

And the IL skips over the derived from of the override, even though new code compiled against the DLL would find it to be hidden.

You need to actually compile against the new form for the IL to be:

call instance void System.Linq.Tests.OrderByTests/Derived::DoSomething(class [System.Linq]System.Linq.IOrderedEnumerable`1)
```

The inverse also holds; code compiled against the new version and run against the old will exhibit the new behaviour, effectively with an implicit cast to object.

Of course anything compiled at runtime will depend on the live DLL. The example of a buggy use of IOrderedQueryable I give at https://github.com/dotnet/corefx/issues/2317#issuecomment-124463672 will work with the new form even if compiled against the old. There could potentially be a case where the opposite held, but:

  1. The same holds for when IEnumerable<T> was made covariant.
  2. The most likely use for such a case is probably to deal with the very fact that IOrderedEnumerable<T> isn't covariant, and hence any cases that hit this behaviour might very well end up with the same observable behaviour.

I think we should treat it as an API change. The static compile case would just work with a new reference assembly for desktop but the runtime compile case would not. As such I wouldn't want to "lead" folks on by giving them an updated reference assembly on net46 and then have that fail at runtime in your https://github.com/dotnet/corefx/issues/2317#issuecomment-124463672 example.

/cc @AlexGhiondea @mjrousos

Probably the better option on balance.

Agreed. We probably should document it as a re-targeting break in vNext, as well. It seems pretty unlikely to be hit, but someone somewhere will probably manage it. :)

/cc @HollyAM

Since this is being treated as an API change rather than a bug-fix, I suppose it needs a proposed API, no matter how minimal.

Proposed API

C# // Updates existing interface in being covariant. public interface IOrderedEnumerable<out TElement> : IEnumerable<TElement> { // Existing member IOrderedEnumerable<TElement> CreateOrderedEnumerable<TKey>(Func<TElement, TKey> keySelector, IComparer<TKey> comparer, bool descending); }

All of which is already clear from the discussion above, but there's no harm dotting i's.

The change makes sense to me. As @ericstj said above, the only concern is compat on .NET Framework which are in-place updates.

Hey @VSadov, you're the owner for Linq. Could you take a look and make a recommendation?

Any more on this API review?

Huh, I had forgotten about this proposal. Waiting for @VSadov per @terrajobst's comment

I actually hit a bug caused by the disconnect between IOrderedQueryable and IOrderedEnumrable earlier this week. It was on the desktop framework so this being in core wouldn't have helped me, but it brought it to mind.

@terrajobst - I think it is a clear oversight and is better to be fixed.
It is a source breaker for some implementers of IOrderedEnumerable, but I doubt there are may of them

If we do consider it a fix rather than an API change at least I could tackle it without hitting the noob issues I'm having as a noob at API additions :/

With .NET Core decoupled from .NET Framework, I don't have any concerns right now. Per @ericstj and @mjrousos suggestion, when we port this change to .NET Framework, we should document it as a breaking change.

@ericstj @weshaggard @KrzysztofCwalina any concerns approving this API suggestion?

It is a source breaker for some implementers of IOrderedEnumerable, but I doubt there are may of them

I didn't dig in but if it is truly on a source breaking change then I'm also not too worried. Has anyone dug in enough to know if shows up as a binary breaking change in any scenarios?

@weshaggard I looked at binary-compatibility effects at https://github.com/dotnet/corefx/issues/2317#issuecomment-169328660

Seems like it's only a source breaking change. While unfortunate, we believe the value add for supporting covariance is higher.

Thanks @JonHanna I missed that detail. I'm good with this given it is only source breaking and people would need to explicitly retarget before they would ever see it.

How will this need to be in regards of versioning? Just bump the library minor version?

@JonHanna 1.2 will already bump the version, so we can ride that train.

Next step: Do the change, add some tests.

YWIMC

Was this page helpful?
0 / 5 - 0 ratings

Related issues

EgorBo picture EgorBo  路  3Comments

v0l picture v0l  路  3Comments

Timovzl picture Timovzl  路  3Comments

omariom picture omariom  路  3Comments

omajid picture omajid  路  3Comments