In this article, we are going to learn more about ranges and indices in C#, and how to use them to access a single or a range of elements in a sequence. We’ll also see how ranges and indices help us write cleaner and more readable code.
Let’s dig in.
What Are Indices?
In C# indices represent an index in a sequence. Starting from C# 8.0, the ^
operator can be used to specify an index relative to the end of a sequence. The constructor Index
is composed of two parameters.
Let’s look at the constructor definition:
public Index (int value, bool fromEnd = false);
The value
parameter specifies the index value. Its value must be greater than or equal to zero. The second parameter fromEnd
is optional. It indicates if the value of the index is from the start or end.
To better understand indices in C#, let’s create the IndexExamples
class and see the different ways we can use the index:
public class IndexExamples { public static string GetFirst(string[] names) { var index = new Index(0); return names[index]; } public static string GetLastMethod1(string[] names) { var index = new Index(1, true); return names[index]; } }
GetFirst
returns the first element of an array. GetLastMethod1
returns the last element of an array. Although the code works fine, we can further simplify the syntax.
Let’s add new methods to the IndexExamples
class:
public static string GetLastMethod2(string[] names) { return names[^1]; } public static string GetSecondLast(string[] names) { return names[^2]; }
In both GetLastMethod2
and GetSecondLast
we use the hat operator to indicate the index is from the end. GetLastMethod2
and GetLastMethod1
result in the same output, however the syntax in GetLastMethod2
is cleaner.
When we use the index from the end, the index value must be greater than zero. Otherwise, it will throw the System.IndexOutOfRangeException
exception. For the index value, the index ^i
is equivalent to names.Length - i
.
For example:
names[^1]
is equivalent tonames[names.Length - 1]
names[^2]
is equivalent tonames[names.Length - 2]
What Are Ranges?
Ranges in C# are a very concise way of representing a subset of a sequence using the ..
operator. The range operator specifies the start and the end of a slice. Let’s take x..y
as an example to understand the range operator:
x
specifies the start indexy
specifies the end index- Both
x
andy
are optional values - The range operator (
..
) is inclusive of the starting index (x
) and exclusive of the end index (y
)
Let’s inspect the constructor of the Range
class:
public Range(Index start, Index end);
As the name suggests the first parameter defines the start index and the second parameter specifies the end index.
Let’s create the RangeExamples
class and see the different ways we can use the range operator(..
):
public class RangeExamples { public static string[] GetAll(string[] arr) { return arr[..]; } public static string[] GetFirstTwoElements(string[] arr) { var start = new Index(0); var end = new Index(2); var range = new Range(start, end); return arr[range]; } public static string[] GetFirstThreeElements(string[] arr) { return arr[..3]; } public static string[] GetLastThreeElements(string[] arr) { return arr[^3..]; } public static string[] GetThreeElementsFromMiddle(string[] arr) { return arr[3..6]; } }
The RangeExamples
is a class that contains different methods that take advantage of the ..
operator. The index feature added in C# 8.0 complements the range feature. It provides an easier way to specify the start and end of the range. For example, in the GetLastThreeElements()
method, the hat operator is used to define the range start position.
Limitations of Ranges and Indices
Ranges and indices provide a succinct syntax for accessing a single element or ranges in a sequence. However, both range and index have required specifications.
To use the index language feature on an instance:
- The class must have publicly accessible
Length
orCount
property - Also, it must have an accessible indexer which takes a single argument of type
int
- If the class contains an indexer with more than one parameter the first parameter should not be
Index
. If the first parameter isIndex
, the remaining parameters must be optional
To use the range language feature on an instance:
- A class must have publicly accessible
Length
orCount
property - Also, it must have an accessible method named
Slice
which has two parameters of typeint
- If a class contains an indexer with more than one parameter the first parameter should not be
Range
. If it is, the remaining parameters must be optional
Limitation Workaround
We cannot use Range
and Index
with certain built-in types (such as List<T>
) and custom types by default. However, we can easily customize our custom type to support these language features. Let’s create a NameList
class that contains a list of names, and see how we can customize it to support Index
and Range
:
public class NameList : IEnumerable<string> { private List<string> _names; public NameList() { _names = new List<string>(); } public IEnumerator<string> GetEnumerator() { return _names.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable)_names).GetEnumerator(); } public void Add(string name) => _names.Add(name); }
To make the NameList
instance indexable, we have to make the object countable. We can do that by simply adding either the Count
or the Length
property.
For this example, let’s add the Count
property in this class:
public int Count => _names.Count;
The next step is to add an indexer member that takes a single argument of type int
:
public string this[int index] => _names[index];
At this point, we can use the Index
language feature with the instance of NameList
class. However, we cannot use Range
. Because we haven’t added the Slice
method yet.
Let’s add a Slice
method into NameList
:
public List<string> Slice(int start, int length) => _names .Skip(start) .Take(length) .ToList();
Now the NameList
class fulfills the requirements of both Index
and Range
. And we can use these language features on NameList
instance.
Next, let’s create the NameListExamples
class and see the different ways we can use Index
and Range
on the NameList
instance:
public class NameListExamples { public static List<string> GetAll(NameList names) { return names[..]; } public static List<string> GetFirstTwoElements(NameList names) { var start = new Index(0); var end = new Index(2); var range = new Range(start, end); return names[range]; } public static List<string> GetFirstThreeElements(NameList names) { return names[..3]; } public static List<string> GetLastThreeElements(NameList names) { return names[^3..]; } public static List<string> GetThreeElementsFromMiddle(NameList names) { return names[3..6]; } public static string GetLastElement(NameList names) { return names[^1]; } }
Here the GetAll
method returns a copy of NameList
elements using the range operator. The GetFirstTwoElements
method uses the instance of Range
class and returns the first two elements. Additionally, the GetFirstThreeElements
method returns the first three elements. The start index is 0 and the end index is 3.
Furthermore, GetLastThreeElements
specifies the third element from the end as a start index and ends in the last element. This method returns the last three elements. GetThreeElementsFromMiddle
returns elements starting from the 3rd index until the 6th element. And finally, GetLastElement
returns the last element using the hat operator.
Similar to the NameList
class ranges and indices can be used with any class that meets the required specification.
Conclusion
In this article, we have learned what Index
and Range
are in C#, their limitations, and how to implement a custom type that supports Index
and Range
.