Skip to content

C# - Indexers

An indexer is a special member that allows an object to be indexed like an array. When you define an indexer for a class, you can access instances of that class using the familiar array access operator ([]). This provides a more intuitive and expressive syntax for accessing elements within a custom collection or data structure.

The syntax for a one-dimensional indexer is similar to a property, but it uses the this keyword as the name and includes an index parameter in square brackets.

public T this[int index]
{
// The get accessor is used for reading a value.
get
{
// Logic to return the value at the specified index.
}
// The set accessor is used for assigning a value.
set
{
// Logic to set the value at the specified index.
}
}

Like properties, indexers can have get and/or set accessors. You can also use expression-bodied syntax for concise implementations, which is common in modern C#.

While properties are used to access specific, named data members, indexers are used to access elements in a collection-like object by a key (which can be an integer, string, or another type). This effectively abstracts the internal storage mechanism of your class, allowing it to behave like a virtual array or dictionary.

Let’s create a Sentence class that stores a collection of words. We’ll add an indexer to access individual words by their position. This example uses modern C# features like expression-bodied members and proper exception handling.

using System;
public class Sentence
{
private readonly string[] _words;
public Sentence(string text)
{
_words = text.Split(' ');
}
// Indexer for accessing words by their integer position
public string this[int index]
{
get
{
if (index < 0 || index >= _words.Length)
{
throw new IndexOutOfRangeException($"Index {index} is out of range.");
}
return _words[index];
}
set
{
if (index < 0 || index >= _words.Length)
{
throw new IndexOutOfRangeException($"Index {index} is out of range.");
}
_words[index] = value;
}
}
public int Length => _words.Length;
}
public class Program
{
public static void Main(string[] args)
{
var sentence = new Sentence("The quick brown fox jumps over the lazy dog");
// Using the 'get' accessor of the indexer
Console.WriteLine($"The fourth word is: {sentence[3]}"); // Output: fox
// Using the 'set' accessor of the indexer
sentence[1] = "slow";
Console.WriteLine("The modified sentence is:");
for (int i = 0; i < sentence.Length; i++)
{
Console.Write($"{sentence[i]} ");
}
// Output: The slow brown fox jumps over the lazy dog
}
}

In the previous example, we checked the index and threw an IndexOutOfRangeException if it was invalid. This is standard practice and aligns with how built-in .NET collections behave. Returning null or an empty string can hide bugs and lead to unexpected NullReferenceException later in the code.

Indexers are not limited to integer keys. You can use any type, such as a string, to create dictionary-like behavior. This is often achieved by using a Dictionary<TKey, TValue> as the internal data store.

Example: A Dictionary-Style Configuration Manager

Section titled “Example: A Dictionary-Style Configuration Manager”

Here’s an example of a simple configuration manager that uses a string indexer to get and set configuration values.

using System;
using System.Collections.Generic;
public class AppSettings
{
private readonly Dictionary<string, string> _settings = new();
// String indexer
public string? this[string key]
{
get
{
// TryGetValue is more efficient than checking ContainsKey then accessing the key
_settings.TryGetValue(key, out var value);
return value; // Returns null if key is not found
}
set
{
if (value is null)
{
_settings.Remove(key);
}
else
{
_settings[key] = value;
}
}
}
}
public class Program
{
public static void Main()
{
var settings = new AppSettings();
// Use the 'set' accessor
settings["ApiUrl"] = "https://api.example.com";
settings["Timeout"] = "30000";
// Use the 'get' accessor
Console.WriteLine($"API URL: {settings["ApiUrl"]}");
Console.WriteLine($"Non-existent Key: {settings["ApiKey"] ?? "Not Found"}");
}
}

A class can have multiple indexers, a feature known as indexer overloading. Each overload must have a different number or type of parameters. This allows for flexible ways to access data.

A class representing a 2D grid is a perfect candidate for an overloaded indexer. We can provide an indexer to access a specific cell (row, col) and another to access an entire row.

using System;
public class Grid<T>
{
private readonly T[,] _matrix;
public Grid(int rows, int cols)
{
if (rows <= 0) throw new ArgumentOutOfRangeException(nameof(rows));
if (cols <= 0) throw new ArgumentOutOfRangeException(nameof(cols));
_matrix = new T[rows, cols];
}
public int Rows => _matrix.GetLength(0);
public int Cols => _matrix.GetLength(1);
// Indexer with two parameters for cell access
public T this[int row, int col]
{
get => _matrix[row, col];
set => _matrix[row, col] = value;
}
// Overloaded indexer to get a whole row (read-only)
public T[] this[int row]
{
get
{
var rowData = new T[Cols];
for (int i = 0; i < Cols; i++)
{
rowData[i] = _matrix[row, i];
}
return rowData;
}
}
}
public class Program
{
public static void Main()
{
var grid = new Grid<int>(3, 4);
grid[1, 2] = 99; // Set a specific cell
Console.WriteLine($"Value at (1, 2): {grid[1, 2]}");
var secondRow = grid[1]; // Get the entire second row
Console.WriteLine($"Value from the extracted row at index 2: {secondRow[2]}");
}
}
  • IndexOutOfRangeException: This is the most common error. It occurs when you try to access an index that is outside the valid bounds of the underlying data structure. Always add boundary checks in your indexer implementation.
  • KeyNotFoundException: This occurs with dictionary-based indexers when you try to access a key that does not exist. Use TryGetValue to handle this gracefully.
  • Ambiguous Overloads: If you define multiple indexers, ensure their signatures are unique. The compiler will report an error if a call to an indexer is ambiguous.

Indexers are a powerful feature for creating custom, intuitive APIs for your classes. To deepen your understanding, consider exploring:

  • Implementing standard interfaces like IEnumerable<T> alongside indexers to enable LINQ queries and foreach loops on your custom types.
  • Exploring indexers in the context of C# 8.0’s indices and ranges feature, which provides a more concise syntax for accessing elements from the end or slicing collections (myCollection[^1]).
  • Reviewing the source code of built-in .NET collections like List<T> and Dictionary<TKey, TValue> to see how they are implemented.