Introduction
An important benefit of OOP is the ability to create custom types. Some of these types may represent collections of items that are more appropriate for our use case than any built-in type. For such custom collections, C# offers useful syntactic sugar features that allow us to access our custom collections like arrays (indexers), access single elements in any collection with simpler syntax (indices), or access ranges in a sequence within any collection (ranges).
Indexers through a mechanism similar to properties, allow for array-like access to our custom collections. Let’s start with indexers, because they provide indexed access to elements, and we will discuss indices and ranges in a future post.
Indexers
Indexers allow us to use array-like syntax for accessing elements of our collection. The implementation of an indexer is similar to that of properties, with the difference that the indexer’s accessors can take parameters.
To implement an indexer in our custom class, we use the this
keyword, like this:
public int this[int index]
{
get {
// get logic that will use the return keyword
}
set {
// set logic that will use the value keyword
}
}
Both the get
and the set
can have access modifiers. For example, we can have only the get
accessor public and the set
accessor private or protected. It is more common to have a public get
and a private set
than the other way around. By default, the visibility of the get
and the set
accessors is the visibility of the indexer. Their specific access modifiers have to be more restrictive than the indexer’s access modifier.
Usually a custom collection will be backed by an existing C# collection like an array and for this reason the indexer will have an int
parameter, but that is not required. An indexer can have a parameter of any type, in fact can have more than one parameter, and it can also be overloaded. We can have multiple indexers in a class, that each has different parameter types.
An indexer can also take a parameter of type Index
or Range
so that it can have an implementation that supports these types and their operators, but that is not the only way Indices and Ranges are supported in a type as we will see in a next post. For now, let’s see some code examples that use indexers.
Simple Wrapper Over An Array
public class MyCollection
{
private readonly string[] _numbers = ["One", "Two", "Three"];
public int Length => _numbers.Length;
public string this[int index]
{
get => _numbers[index];
set => _numbers[index] = value;
}
}
Now we can execute:
MyCollection collection = new();
for (int i = 0; i < collection.Length; i++)
{
Console.WriteLine(collection[i]);
}
collection[0] = "zero";
collection[1] = "One";
collection[2] = "Two";
Console.WriteLine();
for (int i = 0; i < collection.Length; i++)
{
Console.WriteLine(collection[i]);
}
And the result will be:
One
Two
Three
zero
One
Two
An Indexer With A Non Integer Parameter
As I mentioned, an indexer can have a parameter of any type, here’s an example with a string
parameter:
public class MyCollection
{
private readonly Dictionary<string, int> _numbers = new()
{
{ "Zero", 0 },
{ "One", 1 },
{ "Two", 2 }
};
public int this[string index]
{
get
{
if (_numbers.TryGetValue(index, out int value))
return value;
throw new ArgumentOutOfRangeException();
}
set => _numbers[index] = value;
}
}
Another thing we can see here, is that we should have error checking inside the accessors, in case an invalid index value is passed, here is a usage of the above collection:
MyCollection collection = new();
Console.WriteLine(collection["Two"]);
collection["zero"] = 0;
collection["One"] = 10;
collection["Two"] = 20;
collection["Three"] = 30;
Console.WriteLine();
Console.WriteLine(collection["Two"]);
Console.WriteLine(collection["Three"]);
try
{
Console.WriteLine(collection["Error"]);
}
catch(Exception ex)
{
Console.WriteLine(ex.Message);
}
The result of the above will be:
2
20
30
Specified argument was out of the range of valid values.
An Indexer With Multiple Parameters
An indexer can also have multiple parameters. For example:
public class MyCollection
{
private readonly Dictionary<string, float[]?> _numbers = new()
{
{ "Zero", [0.1f, 0.3f, 0.5f] },
{ "One", [1.1f, 1.4f, 1.8f] },
{ "Two", [2.1f, 2.5f, 2.9f] }
};
public float this[string dictionaryIndex, int arrayIndex]
{
get
{
if (_numbers.TryGetValue(dictionaryIndex, out float[]? value))
return value?[arrayIndex] ?? throw new NullReferenceException();
throw new ArgumentOutOfRangeException();
}
set
{
if (!_numbers.TryGetValue(dictionaryIndex, out float[]? array))
throw new ArgumentOutOfRangeException();
if (array == null)
throw new NullReferenceException();
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(arrayIndex, array.Length);
array[arrayIndex] = value;
}
}
}
That’s a more complicated example, here the indexer takes two arguments, a string
and an int
and returns the element of the array that is returned by the dictionary string key. Here, I have made the implementation in such a way, that no new entries to the dictionary can be added, and no new arrays can be assigned to each dictionary key. Only the existing values of the array elements can be changed. Here’s an example of its usage:
MyCollection collection = new();
Console.WriteLine(collection["Two", 2]);
collection["Zero",1] = 0f;
collection["One",2] = 10f;
// collection["Two",55] = 20f; throws because 55 >= 3
// collection["Three",1] = 30f; throws because "Three" doesn't exist OutOfRangeException
Console.WriteLine();
Console.WriteLine(collection["Zero",1]);
Console.WriteLine(collection["One",2]);
// Console.WriteLine(collection["Two",55]); throws because 55 >= 3
// Console.WriteLine(collection["Three",1]); throws because "Three" doesn't exist OutOfRangeException
And the Result will be:
2.9
0
10
Multiple Indexers In The Same Type
Finally, we can have multiple indexers in the same type, if they have different types of parameters, the same way we can overload any other method in C#. Here’s an example with multiple indexers:
public class MyCollection
{
private readonly Dictionary<string, int> _numbers = new Dictionary<string, int>()
{
{ "0", 0 },
{ "1", 1 },
{ "2", 2 }
};
public int Length => _numbers.Count;
public int this[string index]
{
get => _numbers.GetValueOrDefault(index, -666);
set => _numbers[index] = value;
}
public int this[int index]
{
get => this[index.ToString()];
set => this[index.ToString()] = value;
}
}
Here Our indexer can be an int
or a string
. This doesn’t mean that all the values in our collection can be accessed both ways, it depends on our implementations of the indexers, see the following usage:
MyCollection collection = new();
for (int i = 0; i < collection.Length; i++)
{
Console.WriteLine(collection[i]);
}
collection[0] = 0;
collection[1] = 10;
collection["2"] = 20;
collection["3"] = 30;
collection["foo"] = 40;
Console.WriteLine();
for (int i = 0; i < collection.Length; i++)
{
Console.WriteLine(collection[i.ToString()]);
}
Console.WriteLine();
Console.WriteLine(collection["foo"]);
This will print the result:
0
1
2
0
10
20
30
-666
40
Indexers And Interfaces
We can declare accessors in interfaces. We can provide a default implementation as per the usual rules and have an accessor private if we want. Some examples:
interface IMyCollection
{
public int this[int index]
{
get => -420;
private set => value = -420;
}
}
public class MyCollection : IMyCollection
{
private readonly int[] _numbers = [0, 1, 2];
public int this[int index]
{
get => _numbers[index];
set => _numbers[index] = value;
}
}
Now we can do:
IMyCollection collection = new MyCollection();
MyCollection collection2 = new MyCollection();
Console.WriteLine(collection[0]);
Console.WriteLine(collection[1]);
// collection[0] = 10; Error set accessor private
collection2[0] = 10; // Valid
Even more interesting is this:
interface IMyCollection
{
public int this[int index]
{
get => -420;
set => value = -420;
}
}
public class MyCollection : IMyCollection
{
private readonly int[] _numbers = [0, 1, 2];
public int this[int index]
{
get => _numbers[index];
set => _numbers[index] = value;
}
}
If we execute:
IMyCollection collection = new MyCollection();
Console.WriteLine(collection[2]);
collection[2] = 20;
Console.WriteLine((collection as MyCollection)?[2]);
Then the result will be:
2
20
As expected, but if we make the set
private in the MyCollection
class like this:
private set => _numbers[index] = value;
And we execute the same code, then the result will be:
2
2
That’s because, in IMyCollection
the set
is public, so we can still call it, but because it is an interface, it cannot have any state, so no value is changed as the set
in the implementation is private.
Default implementations of indexers in interfaces are an edge case. They are useful if we need to add indexers to existing interfaces for new implementations without breaking backward compatibility with the classes that already implement those interfaces.
Final Notes About Indexers
Indexers provide syntactic sugar for our custom collections, allowing elements to be accessed using array-like syntax. They must be instance members, not static. The get
and set
accessors share the same parameter list as the indexer, with the set
accessor also having a value parameter. get
only indexers support expression bodied members like this:
public int this[int index] => _numbers[index];
The Restricting Accessor Accessibility post from Microsoft says that when we use an accessor to implement an interface, the accessor may not have an access modifier, this is partially true, as we can have an access modifier to that accessor, if we have a default implementation of that accessor in the interface. For example this is NOT allowed:
interface IMyCollection
{
public int this[int index]
{
get;
set;
}
}
public class MyCollection : IMyCollection
{
private readonly int[] _numbers = [0, 1, 2];
public int this[int index]
{
get => _numbers[index];
private set => _numbers[index] = value;
}
}
But this is:
interface IMyCollection
{
public int this[int index]
{
get => default;
set {}
}
}
public class MyCollection : IMyCollection
{
private readonly int[] _numbers = [0, 1, 2];
public int this[int index]
{
get => _numbers[index];
private set => _numbers[index] = value;
}
}
Finally, an indexer cannot be passed by reference as it is not classified as a variable and when an indexer is declared, the language automatically creates a property named Item
that has its own get_Item
and set_Item
accessors. This property cannot be accessed by the type’s instance from the users of the object and the type cannot have another property named Item
as this will show a compiler error.
The purpose of this property, is so that other languages can use the indexer. If a property named Item
is really needed, then we can provide another name for the automatically created property that represents the indexer, by using the System.Runtime.CompilerServices.IndexerNameAttribute.
Conclusion
This is it about the indexers in C#. Although they may seem complicated because of some edge cases, in reality they are really simple to use. 99% of the time, all someone needs is:
public T this[int index]
{
get => // implementation that returns an element of type T ex. _numbers[index];
set => // implementation that sets in the collection an element equal to value ex. _numbers[index] = value;
}
In the next post, we’ll explore indices, which help with accessing single elements in a collection, and ranges, which help with accessing a range of elements in a sequence. Both of these are closely related to indexers, as they can be used as indexer parameters.
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.