In C# 8.0 two classes were added to C# to handle Index and Ranges (as well as some syntactic sugar in the form of .. to represent a Range). Both are structs, an Index being literally an int but with some static method and “helper” methods for using indicies. As you might expect, a Range, is made up of a start Index and an end Index. Let’s have a look at both types in a little more depth…
Index
An Index is a very simply type which is best understood in usage on a collection. Let’s therefore assume we have some collection type which, ofcourse, allows for you to get at item at a specific index.
This is hardly an exciting addition in itself but what it does is gives us some “standard” code for handling things like, get the nth item from the end of the collection, using the overloaded ^ operator. Let’s see this in action by creating a simply collection
public class MyCollection
{
private readonly string[] _collection;
public MyCollection()
{
_collection = new[]
{
"One",
"Two",
"Three",
"Four",
"Five"
};
}
public string this[Index idx] => _collection[idx.GetOffset(_collection.Length)];
}
All we’ve really done here that’s slightly different to how we’d have written this without an Index is we use the Index GetOffset to give us the actual index number to get the value from the collection. What this really does is allow us to write code such as
var c = new MyCollection();
Console.WriteLine(c[^2]);
The ^2 on an Index simply means give me the 2nd item from the end which would result in “Four” being written to the console ^1 would be the equivalent of _collection[_collection.Length – 1] hence you can see that ^2 is the equivalent of _collection[_collection.Length – 2]. The Index helper method simply allows us to let it handle any conversion for us. Ofcourse we can write the following
var c = new MyCollection();
Console.WriteLine(c[2]);
Which, as you’ve expect returns “Three” this item at index 2.
Range
The Range uses Index types to create a Range, for example
var range = new Range(1, 10);
This create a range type with a start of 1 and end of the range 10. Where the Range becomes more useful is in the syntactic sugar, the range shown in the snippet above may be written as
var range = 1..10;
This create a new Range type just as the first snippet, but now if we start to use this sort of stuff in collection types or our own types we start to get some neat syntax to get slices of collections – it should be noted that the current implementation of collections in .NET sees arrays supporting Range and hence can return slices of arrays, whereas other collections do not explicitly support Index and Range but instead can be used with them due to the operator overloading etc.
ofcourse we can create our own types to do similar. Before we look at implementing such things let’s quickly review the basic syntax possibilities/expectations
// short cut for getting a slice [0..10]
var firstToN = array[..10];
// short cut for getting the item 2 from the end
var nthItemFromEnd = array[^2];
// short cut for getting all items into a copy of the array
var all = array[..];
Support for Index
We can write our own types to explicitly understand Index and Range types, however using standard methods existing types can support Index and Range. Let’s demonstrate this by changing our MyCollection type to replace the indexer to look like this (i.e. remove the Index support).
public string this[int idx] => _collection[idx];
If you try to compile with myCollection[^2] you’ll now get a compiler error, cannot convert from ‘System.Index’ to ‘int’. So how do existing types work with Index? This is due to “implicit Index support” which basically means the compiler expects certain named methods with certain expected parameters.
The first required is the type is Countable which basically means it has a property named Length or Count with an accessible getter which returns an int. So this covers arrays and collections such as the List type. Let’s therefore add this to our type
public int Count => _collection.Length;
Now the compiler error will go away. If you think about it, it quite obvious what’s happening, in our original code for the indexer
public string this[Index idx] =>
_collection[idx.GetOffset(_collection.Length)];
We ofcourse need the length of the collection as well as the Index. Well the compiler using the “implicit Index support” needs the same. So once we add the Count or Length properties, it can now get that information at create the code we otherwise would have to write ourselves and thus an indexer [int idx] now gets the value from the Index implicitly supplied.
So to wrap up “implicit Index support”, your type needs the following
- The type is Countable i.e. has Length or Count property accessible and returning an int
- The type has an accessible indexer which takes an int
- The type does not have an accessible indexer which takes an Index as the first parameter
Support for Range
Let’s change our calling code to try to get a slice of our collection, so now we have
var c = new MyCollection();
Console.WriteLine(c[1..^2]);
This will immediately show the compile time error “cannot convert from ‘System.Range’ to ‘int'”. Let’s therefore add implicit support for Range to our collection.
Well again the type needs to be Countable and also not have a accessible indexer which takes a Range (otherwise this would assumer the type is explicitly handling Range). Which currently have.
As you’d imaging to handle ranges we need a method that creates a slice or sub-collection from our collection and that’s exactly what’s required. Let’s add a Slice method which returns a collection (in our case we’re just pretty much copy the code from Ranges which is also the best place to look for information on Index and Range.
Add the following to MyCollection
public string[] Slice(int start, int length)
{
var slice = new string[length];
Array.Copy(_collection, start, slice, 0, length);
return slice;
}
The compiler should now show our previously broken c[1..^2] code as valid and if you make a small change to output the items from the returned slice, like this
foreach (var item in c[1..^2])
{
Console.WriteLine(item);
}
You’ll see items from the item at index 1 through to the one 2 from the end, i.e. “Two” and “Three”.
So to wrap up “implicit Range support”, your type needs the following
- The type is Countable i.e. has Length or Count property accessible and returning an int
- The type contains an accessible method named Slice which takes two int params, the first being the start index the second being the length
- The type does not have an accessible indexer which takes an Range as the first parameter
With both the implicit Index and Range code you could ofcourse write explicit code (i.e. use Index and Range in your code). Both offer helper methods to make the experience of using them consistent. Ofcourse neither are restricted to collection types and let’s look at extending Range…
Extending Range
As already mentioned, Index and Range are fairly limited in functionality, but that’s fine, they do exactly what they’re intended to do, but if you’ve ever wanted a Range of numbers in C# you’ve probably looked at Enumerable.Range, so writing something like this
foreach (var i in Enumerable.Range(1, 10))
{
Console.WriteLine(i);
}
For fun and now we have the syntactic sugar of .. to represent a Range type it’s be cool to write something like this instead
foreach (var i in 1..10)
{
Console.WriteLine(i);
}
Okay so we know that this is really a Range instance like this
foreach(var r in new Range(1, 10))
{
Console.WriteLine(i);
}
but ofcourse this will not work, as Range does not support IEnumerator. No worries we’ll create an extension method, something like this
public static class RangeExtensions
{
public static IEnumerator<int> GetEnumerator(this Range range) =>
Enumerable.Range(range.Start.Value, range.End.Value).GetEnumerator();
}
This now allows us to use a Range within a foreach and better still we can now use the .. operator syntax
foreach (var item in 1..10)
{
Console.WriteLine(i);
}
Note: Whilst this offers syntax along the lines of Rust – I’m not necessarily saying using Range in this way within a foreach is a great idea because Range, via Index, supports ^ (from end) syntax and we’re definitely not attempting to support such syntax in our extension method but the terse nature of it’s usage is nevertheless interesting (or for those who prefer more explicit methods then it’s probably horrible).
We could extend our fun and our extension method to add support for IEnumerable for Linq queries, whilst not as nice as the IEnumerator in it’s implicit nature, we might add to RangeExtensions, the following
public static IEnumerable<int> ToEnumerable(this Range range)
{
foreach (var item in range)
{
yield return item;
}
}
Now we can use this like with Linq, maybe like this
foreach (var i in (1..10).ToEnumerable().Where(v => v % 2 == 0))
{
Console.WriteLine(i);
}
Which will output the even numbers between 1 and 10.
References
Ranges