What are Indexers and how to use them in C#

Posted by : on

Category : C#

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.


Follow me: