Indices And Ranges in C#

Posted by : on

Category : C#

Introduction

Indices and ranges are features introduced in C# 8.0 that provide a compact syntax for accessing single elements or ranges in a sequence. This functionality is built around two struct types: the Index struct and the Range Struct and two new operators, the index from end operator (^) and the range operator (..).

An index (plural indexes or indices) should not be confused with an indexer (plural indexers). An indexer provides access to the elements of a collection by allowing the objects to be indexed like arrays, whereas indices allow us to select specific elements from a collection.

Both indices and ranges are straightforward to use and are highly useful for selecting single elements or ranges of elements from collections.

Let’s explore both, starting with simple usages, discussing when they can be used with an object’s indexer, and then delving into some more detailed implementation aspects.

Indices

An Index struct represents a single element in a collection. It can be used as a parameter to an indexer, just like an int. The key difference is that indices support the hat operator (^), which specifies that the provided number is relative to the end of the sequence. For example, let’s consider the following array:

int[] numbersArray = [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ];

Then by saying:

Console.WriteLine(numbersArray[^2]);

The result will be the number 7.

The ^1 is always the last element of the collection, ^2 is the second-to-last element and so on. This means that the ^numbersArray.Length is the first element of our array. The index from end operator, is always equal to the length of our collection minus the number following it, that is ^i = Collection.Length - i. From this, we can see that the ^0 will be equal to the length of the collection and for this reason the numbersArray[^0], will be out of range and throw an exception.

Because Index is a type we can use it as any other type, for example:

Index secondFromLast = ^2;
Console.WriteLine(numbersArray[secondFromLast]);

Collection Requirements For Index Support

A collection that has an indexer with an Index parameter, can obviously support indices. If the collection lacks such an indexer, there are specific requirements for implicit index support:

  • The collection has to be countable, meaning it has a property named Length or Count with an accessible getter that returns an int.
  • Our collection has an indexer which takes a single int as an argument.
  • The collection must not have an indexer with multiple parameters, that takes an Index as a first parameter. If it does, then the remaining parameters must be optional.

If these conditions are met, even without an indexer with an Index parameter, the language will convert the Index argument into an int and call the int based indexer.

The Index Struct

The index from end operator (^) exists to support the Index struct. Index is a type in C# which means it can be instantiated normally. For example the:

Index myIndex = ^2;

Is equal to:

Index myIndex2 = new Index(2, true);

The first parameter in the constructor is the value and the second is a boolean that indicates if the value counts from the beginning or the end of the collection.

The Index struct is a readonly struct which means it is an immutable value type. It has two static methods, that act as factories for indices:

Index myIndexFromStart = Index.FromStart(2);
Index myIndexFromEnd = Index.FromEnd(2);

The first creates an index that returns an element from the start, while the second creates an index that returns an element from the end. This means that myIndexFromEnd is equivalent to:

 Index myIndexFromEnd = ^2

And myIndexFromStart is ‘almost’ equivalent to:

 Index myIndexFromStart = 2;

I say ‘almost’, because there is an implicit conversion from int to the Index type. The Index struct has overloaded the implicit operator, but a small problem exists. If the int is negative then an exception will be thrown. This contrasts with the usual rules of C#, that an implicit conversion should always succeed at runtime. If a conversion can fail then it should be explicit (see Type Conversions And The Implicit and Explicit Operators In C#). This design decision was probably made for the convenience of users of the Index type.

The Index struct has also two static properties, the Index.Start and Index.End properties. Index.Start creates a new Index with a value of zero, while Index.End creates a new Index with a value of ^0, representing a value one past the last element.

The Index struct has two instance properties as well. The Value property is a read-only int that returns the value of the Index (both myIndexFromStart and myIndexFromEnd have a value of 2). The IsFromEnd property, is a bool that returns true if the value should start counting from the end of the collection (the myIndexFromEnd will have an IsFromEnd value of true and the myIndexFromStart will have an IsFromEnd value of false).

A public method of the Index struct, called GetOffset takes an int length parameter and returns an int. This method calculates the integer that represents the element’s position in a collection from the start. If IsFromEnd is false, it returns the Value of the indexer, if it is true then returns an integer that is equal to the -value + length. (Internally in the struct, for performance reasons, a private int _value exists that uses ones’ complement but the explanation for using ones’ complement goes beyond the scope of this post)

Finally, the Indexer struct overrides methods responsible for performing value equality and the ToString method. Especially for the ToString method, since the indexer may be printed with the hat operator (ex. ^2), a performance tweak exists so that the string is concatenated with the help of the Span<char> when appropriate.

Ranges

A Range struct represents a sub-range of a sequence in a collection. Like an int, a range can serve as a parameter to an indexer. The key difference is that ranges support the range operator (..). The range operator, specifies the start and end of a range, where start is inclusive, and the end is exclusive. This means that the start is included in the range, but the end is not.

Let’s suppose that we have the previous array:

 int[] numbersArray = [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ];

Then the code:

 var subRange = numbersArray[1..3];

foreach (var element in subRange)
{
   Console.WriteLine(element);
}

Will print:

1
2

The range [0..numbersArray.Length] represents the entire range of the array. We can omit either of the two operands creating an open-ended range. Thus, the previous expression could also be written as [..numbersArray.Length], [0..] or even [..]. One of the two operands can be an indexer, meaning the expression could also be written as: [0..^0]. This approach is better, as it does not depend on the name of the array (the numbersArray).

Generally:

  • a.. is equivalent to a..^0
  • ..b is equivalent to 0..b
  • .. is equivalent to 0..^0

Collection Requirements For Range Support

A collection that has an indexer with a Range parameter, can naturally support ranges. An indexer that has a single Range parameter can return a sub-sequence of a different type of elements, such as Span<T>. If the collection does not have such an indexer, there are certain requirements for implicit range support:

  • The collection has to be countable, this means that the collection has a property named Length or Count with an accessible getter that returns an int.
  • The collection must have an accessible member named Slice that takes two int parameters and returns a type (T).
  • The collection must not have an indexer with multiple parameters where the first parameter is a Range. If it does, the remaining parameters must be optional.

If these conditions are met, the language will treat the type as if it has an indexer member in the form of T this[Range range].

The Range struct

The range operator (..) exists to support the Range struct. Range is a type in C# which means that we can instantiate it normally. For example the:

Range firstTwoAfterTheFirst = 1..3;

Is equivalent to:

Range firstTwoAfterTheFirst = new Range(1, 3);

Both constructor parameters are of type Index (we have an implicit conversion from int see above)

The Range struct is a readonly struct, making it an immutable value type. It provides two static methods and a static property that act as factories for creating ranges:

  • The Range.StartAt(Index start) method returns a new Range(start, Index.End), which represents a range from the start parameter to the end of the collection.
  • The Range.EndAt(Index end) method returns a new Range(Index.Start, end) representing a range from the start of the collection to the end parameter.
  • The Range.All property returns a new Range(Index.Start, Index.End) encompassing a range of all the elements in a collection.

Additionally, it has two readonly properties named Start and End, which denote the inclusive start index and the exclusive end index of the range.

Similar to the Index struct, the Range struct overrides appropriate members to ensure value equality and includes a performant ToString method that utilizes string concatenation with the help of the Span<char> type when applicable.

Lastly, it includes a public (int Offset, int Length) GetOffsetAndLength(int length) method. This method returns a tuple containing the offset and the length of the range based on a given length. For example:

int[] numbersArray = [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ];

Range firstThreeAfterTheFirstTwo = 2..5;

Console.WriteLine(firstThreeAfterTheFirstTwo.GetOffsetAndLength(numbersArray.Length));

will print:

(2, 3)

Implicit Range Operator Conversion

When we use the range operator, for example Range myRange = 3..^2, an implicit conversion is performed of the integer values to Index values. The previous expression is equivalent to:

Range myRange = new(new Index(3,false), new Index(2, true));

In the previous array this would be the numbers : 3 4 5 6.

Range And Arrays

A sub-range created from an array with a range, is a new array that has a copy of the elements of the initial array. The elements are not referenced. For example:

int[] numbersArray = [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ];
Range myRange = 3..^2;
var subsequence = numbersArray[myRange];

foreach (var number in subsequence)
   Console.WriteLine(number);

subsequence[0] = 100;

Console.WriteLine(subsequence[0]);
Console.WriteLine(numbersArray[3]);

Will print:

3
4
5
6
100
3

Generic Uses of Indices And Ranges

Because both Index and Range are types, we can use them to define elements or sequences of elements without independently of collection instances. We can then use these variables in various places within our code. Some examples:

Index secondToLast = ^2;
Index sixth = 5;
Range losers = 1..;
Range lastThree = ^3..;

We just have to be careful with OutOfRange exceptions. For example, if we had used the sixth Index in a collection with five elements (remember that 0 is the index of the first element), our program would throw an exception at runtime.

The Range type can also be useful as a parameter to a method. For instance, we may need to create a method that takes a collection as a first parameter and a Range as the second parameter. This method could then use only the elements of the collection that fall within that range, such as finding the average age of the first five people in the collection or the last ten, and so on.

Conclusion

All this might seem complicated, especially with the detailed explanation of how the Index and Range structs are implemented. However, in reality, most of these details are not needed in 99% of cases. Indices and Ranges are quite straightforward: indices are useful for accessing elements by counting from the end of a collection, and ranges are useful for selecting a subset of elements in a sequence. Typically, you won’t need to create or directly use these structs; the operators themselves suffice for most scenarios.

Thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: