It's dangerous to code alone! Take this.

A Range Hack

Published on 09 Apr 2022.

I was looking at some other language today (I don’t even remember which, because I’ve been exploring about three languages lately) and saw that the language had the equivalent of a foreach loop where you could iterate over a range of numbers–something that would be the equivalent of this:

foreach (int number in 1..10) // Does not compile!
    Console.WriteLine(number);

That doesn’t work in C#, but you could do this:

foreach (int number in Enumerable.Range(1, 10))
    Console.WriteLine(number);

That’s far more awkward, but it works.

But I had a thought that the a..b syntax is actually valid C# syntax these days, since the introduction of ranges. The compiler transforms something like 1..10 into the creation of a Range value that is the equivalent of this:

new Range(1, 10)

So 1..10 is valid C# code. The part that doesn’t work is the fact that the Range value created can’t be used in a foreach loop.

What would it take to make Range work in a foreach loop? Well if it implemented IEnumerable<int>, it would work just fine. I don’t have any control over that, because it is part of the BCL.

However, foreach doesn’t just work for any IEnumerable<T>. It can work for anything that is structurally equivalent to IEnumerable<T>. That is, if it has the method IEnumerable<T> demands–a public IEnumerator<T> GetEnumerator() method–it would be able to work in a foreach loop.

Notably, that includes extension methods! That means we could write an extension method for Range that gives it a GetEnumerator method, et voila! We could make this work!

I’m going to bring in one more piece of advanced C# magic and use an iterator method to define this:

public static class RangeExtensions
{
    public static IEnumerator<int> GetEnumerator(this Range range)
    {
        for (int index = range.Start.Value; index < range.End.Value; index++)
            yield return index;
    }
}

(You could write that without using the yield return that makes this an iterator method, but this is less (and simpler!) code.)

And suddenly, this code works in C#:

foreach (int number in 1..10)
    Console.WriteLine(number);

The compiler will see that you’re trying to use a Range struct in a foreach loop and go looking for an appropriate GetEnumerator method to use. Range, itself, doesn’t have one of those, but the compiler is willing to use an extension method, if there is one, and with that code, there is.

A couple of caveats.

First, I made the assumption that the indices used for the range are both done from the front. I could technically go and do foreach (int number in 1..^1), which uses an index from the end. In this context, that makes no sense, since there isn’t a broader set of values to compare against. So I’d probably be wise to check for that and maybe throw an exception. And while we’re at it, we should probably do a similar thing if the end index isn’t larger than the start index.

public static IEnumerator<int> GetEnumerator(this Range range)
{
    if (range.Start.IsFromEnd || range.End.IsFromEnd)
        throw new ArgumentException("Range indices must be positive to be enumerated.");
    if (range.End.Value <= range.Start.Value)
        throw new ArgumentException("The end index must be after the start index to be enumerated.");

    for (int index = range.Start.Value; index < range.End.Value; index++)
        yield return index;
}

The second caveat is that when I see 1..10, my intuition tells me that the 1 and the 10 will both be included. But with a range, the end index is not generally included. That is, if I do someArray[3..5], I’ll get index 3 and 4, but not 5.

I think it makes sense to keep the values produced by our iterator method in sync with the broader definition of ranges, so if I were going to keep this forever, I’d want to somehow convince my brain that 1..10 means 1 through 9 but not 10.

Easier said than done.

Anyway, what we’ve seen here is that we can do some pretty wild things by combining several advanced C# features (iterator methods, extension methods, IEnumerator<T>, and ranges).

I, for one, wouldn’t mind this as a language feature in some future update. But perhaps the caveats I mentioned and the existence of Enumerable.Range are reasons to not add it to the language.