Using indexers in C# classes

In C#, indexers are features that allow class objects to be accessed similarly to arrays or collections, using index syntax (square brackets []). This functionality makes the code more intuitive and readable, offering a concise way to manipulate data stored in custom class objects. In this article, we will explore the concept of indexers, how they are declared and implemented, and how they can be used to create custom collections or facilitate data access.

What are Indexers?

Indexers are members of a class or struct that allow elements of an object to be accessed using index syntax. Although index syntax is commonly associated with arrays and collections, indexers allow you to provide this behavior in your own classes. Declaring an indexer in C# involves using the “this” keyword, followed by one or more parameters in square brackets, representing the index. These parameters determine how element access is performed.

Declaring and Implementing Indexers

The basic syntax for declaring an indexer is as follows:

				
					public type this[types indices]
{
    get { ... }
    set { ... }
}
				
			

Here, `type` is the type of data the indexer will access (i.e., the type of value that will be returned or assigned), and `types indices` are the parameters the indexer accepts (often used to represent an index or key).

To better understand, suppose you want to create a class “MyCollection” that stores a list of integers. If we use the traditional approach to access data, we could implement something like this:

				
					public class MyCollection
{
    private int[] data;
    public MyCollection(int size)
    {
        data = new int[size];
    }
    public int GetValue(int index)
    {
        return data[index];
    }
    public void SetValue(int index, int value)
    {
        data[index] = value;
    }
}
				
			

Accessing the elements would be done by calling methods like “GetValue” and “SetValue”:

				
					var collection = new MyCollection(5);
collection.SetValue(0, 10);
collection.SetValue(1, 20);
Console.WriteLine(collection.GetValue(0));
Console.WriteLine(collection.GetValue(1));
				
			

Although functional, this approach requires the explicit use of methods to access and modify data, which can make the code more verbose.

But using an indexer, we can do it as follows:

				
					public class MyCollection
{
    private int[] data;
    public MyCollection(int size)
    {
        data = new int[size];
    }
    public int this[int index]
    {
        get { return data[index]; }
        set { data[index] = value; }
    }
}
				
			

With the indexer, accessing elements becomes more intuitive and direct:

				
					var collection = new MyCollection(5);
collection[0] = 10;
collection[1] = 20;
Console.WriteLine(collection[0]);
Console.WriteLine(collection[1]);
				
			

Notice that the index syntax (`collection[0]`) makes the code cleaner and easier to understand, eliminating the need for additional methods to access data. The indexer acts as a bridge that simplifies interaction with class elements.

Best Design Practices for Indexers

Avoid Ambiguity: If your indexer has multiple parameters or different index types, be careful not to create ambiguity. This can make the code confusing and difficult to maintain.

				
					public string this[int index, string key] { get; set; }
				
			

This type of indexer can cause confusion if you do not have a clear need for two different parameters. Instead, consider using methods or properties to separate responsibilities.

Validation: Whenever possible, validate indices or keys before accessing data. This helps prevent unexpected exceptions in your code.

				
					public int this[int index]
{
    get
    {
        if (index < 0 || index >= data.Length)
        {
            throw new IndexOutOfRangeException("Index out of collection bounds.");
        }
        return data[index];
    }
    set
    {
        if (index < 0 || index >= data.Length)
        {
            throw new IndexOutOfRangeException("Index out of collection bounds.");
        }
        if (value < 0)
        {
            throw new ArgumentException("Value cannot be negative.");
        }
        data[index] = value;
    }
}
				
			

Consistency: Ensure that the “get” and “set” operations follow the same access logic. This ensures that the indexer’s behavior is consistent and predictable.

Performance: In cases where data access is very frequent, be careful with the impact of validations or transformations within the indexer. Try to keep the “get” and “set” code as simple as possible to avoid performance impacts.

Immutability: If your object should be immutable, remember that a read-only (get-only) indexer can be a good choice. This ensures that data is not modified after the object is created.

Practical Example: Using Indexers with a Custom Dictionary

Now, let’s see a practical example of how to use an indexer to create a custom dictionary, where the keys are strings and the values are integers.

				
					public class MyDictionary
{
    private Dictionary<string, int> data = new Dictionary<string, int>();
    public int this[string key]
    {
        get
        {
            if (!data.ContainsKey(key))
                throw new KeyNotFoundException("Key not found.");
            return data[key];
        }
        set
        {
            data[key] = value;
        }
    }
}
				
			

Using “MyDictionary”:

				
					var dictionary = new MyDictionary();
dictionary["one"] = 1;
dictionary["two"] = 2;
Console.WriteLine(dictionary["one"]);
Console.WriteLine(dictionary["two"]);
				
			

In this example, the “MyDictionary” class provides data access through string-type keys, allowing values to be stored and retrieved simply and efficiently.

Conclusion

Indexers in C# offer a practical and intuitive way to access data in objects, similar to accessing elements of arrays or collections. They allow you to create classes that encapsulate data efficiently while keeping the code clean and readable. When using indexers, it is important to apply best practices, such as index validation and avoiding ambiguities, to ensure that your code design is easy to maintain.