Chuyển tới nội dung chính

Indexers

Definition

An indexer is a special property-like member that allows objects to be indexed using square bracket syntax (obj[index]). Indexers enable a class or struct to behave like a virtual array or dictionary, providing intuitive element access without exposing the underlying data structure.

// Built-in indexer — every List<T> has one
var list = new List<string> { "a", "b", "c" };
string first = list[0]; // Uses List<T>'s indexer

// Custom indexer — your own types can do the same
var buffer = new TemperatureBuffer();
buffer[0] = 22.5;
double monday = buffer[0];

Core Concepts

Basic Indexer Syntax

Indexers are declared with the this keyword and square bracket parameters. They support get and set accessors just like properties.

public class TemperatureBuffer
{
private readonly double[] _temps = new double[7];

public double this[int day]
{
get
{
if (day < 0 || day >= 7)
throw new ArgumentOutOfRangeException(nameof(day));
return _temps[day];
}
set
{
if (day < 0 || day >= 7)
throw new ArgumentOutOfRangeException(nameof(day));
_temps[day] = value;
}
}
}

// Usage
var buffer = new TemperatureBuffer();
buffer[0] = 22.5; // set
buffer[6] = 18.0; // set
double monday = buffer[0]; // get — 22.5

Indexers can also be expression-bodied:

public class SimpleBuffer
{
private readonly int[] _data = new int[10];
public int this[int index]
{
get => _data[index];
set => _data[index] = value;
}
}

Multi-Dimensional Indexers

Indexers can accept multiple parameters, enabling matrix-like or table-like access patterns:

public class Matrix
{
private readonly int[,] _data = new int[3, 3];

public int this[int row, int col]
{
get => _data[row, col];
set => _data[row, col] = value;
}
}

var matrix = new Matrix();
matrix[0, 0] = 1;
matrix[1, 2] = 42;
int value = matrix[1, 2]; // 42
// String-keyed multi-parameter indexer
public class ConfigSection
{
private readonly Dictionary<(string Section, string Key), string> _values = new();

public string this[string section, string key]
{
get => _values.TryGetValue((section, key), out var v) ? v : "";
set => _values[(section, key)] = value;
}
}

var config = new ConfigSection();
config["database", "host"] = "localhost";
config["database", "port"] = "5432";

Overloading Indexers

A class can have multiple indexers with different parameter types, similar to method overloading:

public class LookupTable
{
private readonly Dictionary<string, int> _byName = new();
private readonly List<(string Name, int Value)> _items = new();

// Index by integer position
public (string Name, int Value) this[int index]
{
get => index >= 0 && index < _items.Count
? _items[index]
: throw new ArgumentOutOfRangeException(nameof(index));
}

// Index by string name
public int this[string name]
{
get => _byName.TryGetValue(name, out var value)
? value
: throw new KeyNotFoundException(name);
}

public void Add(string name, int value)
{
_byName[name] = value;
_items.Add((name, value));
}
}

var table = new LookupTable();
table.Add("width", 100);
table.Add("height", 200);

int w = table["width"]; // String indexer — 100
var first = table[0]; // Int indexer — ("width", 100)

Indexers in Interfaces

Indexers can be declared in interfaces and implemented by classes or structs:

public interface INameLookup
{
string this[int id] { get; }
bool Contains(int id);
}

public class EmployeeLookup : INameLookup
{
private readonly Dictionary<int, string> _employees = new();

public string this[int id] =>
_employees.TryGetValue(id, out var name) ? name : "Unknown";

public bool Contains(int id) => _employees.ContainsKey(id);

public void Add(int id, string name) => _employees[id] = name;
}

// Program against the interface
INameLookup lookup = new EmployeeLookup();
string name = lookup[42];

Indexers vs Properties

FeaturePropertyIndexer
Identified byNamethis + parameters
ParametersNoneOne or more
StaticCan be staticCannot be static
OverloadingNo (unique name)Yes (different parameter types)
PurposeGet/set a single valueGet/set indexed elements
Access syntaxobj.Nameobj[index]

C# 8 Indices and Ranges

C# 8 introduced Index and Range structs that work with indexers:

int[] arr = { 0, 1, 2, 3, 4, 5 };

// Index from end
int last = arr[^1]; // 5
int second = arr[^2]; // 4

// Range
int[] slice = arr[1..4]; // [1, 2, 3]
int[] last3 = arr[^3..]; // [3, 4, 5]

Custom types can support these by defining indexers that accept Index and Range:

public class MyCollection
{
private readonly int[] _data = { 10, 20, 30, 40, 50 };

public int this[Index index] => _data[index.GetOffset(_data.Length)];
public int[] this[Range range] => _data[range];
}

See Modern C# for full details on indices and ranges.

When to Use

ScenarioUse Indexers?
Custom collection wrapping an internal array or dictionaryYes
Matrix or grid data structureYes (multi-dimensional)
Config/settings accessed by string keyYes
Simple value get/set (no index semantics)No — use a property
Type is not conceptually a containerNo — use methods
Complex lookup logic with side effectsNo — use named methods

Common Pitfalls

  1. Missing bounds checking — An indexer that silently accesses invalid indices causes IndexOutOfRangeException or corrupts state. Always validate and throw ArgumentOutOfRangeException with a clear message.

  2. Exposing internal mutable collections — An indexer that returns a reference to an internal mutable object breaks encapsulation. Return copies or read-only views for reference types.

  3. Overusing indexers — If the access pattern doesn't feel natural (like array/dictionary access), a named method is clearer. config["timeout"] is intuitive; person["age"] is questionable.

  4. Read-only indexers with no documentation — Omitting the set accessor without documentation confuses consumers expecting write access. Make the intent clear.

Key Takeaways

  1. Indexers use this[parameters] syntax and behave like parameterized properties.
  2. They are ideal for types that logically represent collections, tables, or lookup structures.
  3. Indexers can be multi-dimensional, overloaded by parameter type, and declared in interfaces.
  4. Always validate index bounds in the get and set accessors.
  5. Prefer named methods when the "indexed access" metaphor doesn't fit the type's abstraction.

Interview Questions

Q: What is an indexer in C#?

An indexer is a special member that allows instances of a class or struct to be accessed like an array using square bracket syntax (obj[index]). It is defined with this[parameters] and has get/set accessors like a property.

Q: Can indexers be overloaded?

Yes. A class can have multiple indexers as long as they differ in the number or types of parameters, similar to method overloading.

Q: What is the difference between an indexer and a property?

A property is identified by a name and has no parameters. An indexer is identified by this and accepts one or more parameters (indices). Indexers cannot be static; properties can.

Q: Can you declare an indexer in an interface?

Yes. Interface indexers declare the signature with this and accessors without implementation. Implementing classes must provide the actual getter and setter logic.

Q: When should you use an indexer vs a method?

Use an indexer when the type logically represents a collection or lookup table and array-like access (obj[key]) feels natural. Use a named method when the operation is more complex, has side effects, or the "indexed access" metaphor doesn't fit.

References