A Range Hack
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.