C# Multiple Parameter Indexer: Syntax and Use
c# multiple parameter indexer: Learn how to define and use C# indexers with multiple parameters, including syntax, practical examples, and performance considerations.
When you need to access an object by more than one key, a C# multiple parameter indexer lets you expose a natural indexing syntax without requiring method calls. For example, instead of calling matrix.GetValue(row, column), you can write matrix[row, column]. This article explains how to declare such indexers, where they are useful, and what to watch out for when using them in production code.
Declaring an Indexer with Multiple Parameters
An indexer is declared with the this keyword followed by a parameter list in square brackets. For multiple parameters, separate them with commas. The accessor bodies behave like properties: get and set can each use all parameters.
public class SparseMatrix { private Dictionary<(int, int), double> _values = new(); public double this[int row, int column] { get { return _values.TryGetValue((row, column), out var value) ? value : 0.0; } set { _values[(row, column)] = value; } } }
This indexer takes two integers and maps them to a tuple key in a dictionary. The get accessor returns a default when the key is absent, which is a common pattern for sparse structures. The set accessor inserts or updates the value.
Practical Example: a Grid or Table Wrapper
A more concrete scenario is a wrapper around a two-dimensional array that adds bounds checking or transforms coordinates. The following class exposes a grid where negative indices are treated as offsets from the end, similar to Python's negative indexing.
public class Grid<T> { private readonly T[,] _cells; public Grid(int rows, int columns) { _cells = new T[rows, columns]; } public T this[int row, int column] { get { var (r, c) = Normalize(row, column); return _cells[r, c]; } set { var (r, c) = Normalize(row, column); _cells[r, c] = value; } } private (int, int) Normalize(int row, int column) { int r = row < 0 ? _cells.GetLength(0) + row : row; int c = column < 0 ? _cells.GetLength(1) + column : column; if (r < 0 || r >= _cells.GetLength(0) || c < 0 || c >= _cells.GetLength(1)) throw new IndexOutOfRangeException("Index outside grid bounds"); return (r, c); } }
Here the indexer centralizes coordinate normalization and validation. Every access goes through the same logic, which keeps the rest of the code clean. The Normalize method is private because it is an implementation detail.
Overloading Indexers with Different Parameter Types
An indexer is just a special property, and you can overload it by changing the parameter list. This is useful when an object can be indexed by different kinds of keys. For instance, a configuration store might allow lookup by string name or by numeric ID.
public class ConfigStore { private readonly Dictionary<string, string> _byName = new(); private readonly Dictionary<int, string> _byId = new(); public string this[string name] { get => _byName.TryGetValue(name, out var v) ? v : null; set => _byName[name] = value; } public string this[int id] { get => _byId.TryGetValue(id, out var v) ? v : null; set => _byId[id] = value; } }
Overloading gives you flexibility, but it also increases the surface area of the class. Use it when the different key types represent distinct, natural access patterns. If the keys are always the same type, a single indexer with multiple parameters is usually simpler.
Combining Multiple Parameters with Different Types
Indexer parameters do not have to be the same type. A common use is a lookup that combines an enum and a string, such as a per-region configuration value.
public enum Region { North, South, East, West } public class RegionalSettings { private readonly Dictionary<(Region, string), string> _settings = new(); public string this[Region region, string key] { get => _settings.TryGetValue((region, key), out var v) ? v : null; set => _settings[(region, key)] = value; } }
The tuple key keeps the pair together and provides value semantics. The indexer syntax reads naturally: settings[Region.North, "timeout"]. This pattern is cleaner than a nested dictionary and avoids the risk of inconsistent keys.
Performance and Memory Considerations
When an indexer uses a tuple as a dictionary key, each access creates a new tuple value. For most applications the allocation cost is negligible, but in a hot loop it can add pressure on the garbage collector. If you measure a bottleneck, consider using a custom struct key instead of a tuple.
public readonly struct MatrixKey : IEquatable<MatrixKey> { public readonly int Row; public readonly int Column; public MatrixKey(int row, int column) { Row = row; Column = column; } public bool Equals(MatrixKey other) => Row == other.Row && Column == other.Column; public override bool Equals(object obj) => obj is MatrixKey other && Equals(other); public override int GetHashCode() => HashCode.Combine(Row, Column); }
Then the dictionary can use MatrixKey directly. This avoids the tuple allocation and can improve cache behavior. The tradeoff is more code. Start with a tuple, measure, and optimize only when profiling shows a real issue.
Read-Only Indexers and Immutability
If an indexer only has a get accessor, it is read-only. This is useful for immutable collections where you want to prevent modification through the indexer.
public class ReadOnlyMatrix { private readonly int[,] _data; public ReadOnlyMatrix(int[,] data) => _data = data; public int this[int row, int column] => _data[row, column]; }
Note that the underlying array is still mutable, but the indexer does not expose a setter. For true immutability, you would need to copy the data or use a read-only collection type. The indexer syntax remains the same; the absence of set enforces the contract at compile time.
Common Pitfalls and How to Avoid Them
One frequent mistake is throwing an exception from the get accessor when a key is missing. That is often the right behavior, but it forces callers to handle exceptions for normal control flow. Consider returning a default value or using a TryGet pattern instead, depending on the semantics.
Another issue is mixing parameter order. When an indexer takes multiple parameters, the order is part of the API. Changing the order later is a breaking change. Choose an order that matches the domain, such as row before column or region before key, and document it clearly.
Finally, be careful with reference types in the set accessor. If the value is mutable, callers can modify the object after storing it, which may break invariants. If you need defensive copying, do it inside the setter.
When a Method Is Better Than an Indexer
An indexer is not always the right choice. If the operation is expensive, performs I/O, or has side effects, a method with a descriptive name is clearer. For example, GetValueAsync cannot be an indexer because indexers cannot be async. Also, if you need to pass additional options or a cancellation token, a method is more natural.
Use an indexer when the object is conceptually a collection or a lookup table, and the parameters represent keys. If the operation is more like a computation, prefer a method. The distinction is about readability and intent.
Combining Indexers with Enumerators
You can implement IEnumerable<T> alongside an indexer to make a class both indexable and iterable. This is common for custom collection types that need to support foreach as well as direct access.
public class Matrix<T> : IEnumerable<T> { private readonly T[,] _data; public Matrix(T[,] data) => _data = data; public T this[int row, int column] { get => _data[row, column]; set => _data[row, column] = value; } public IEnumerator<T> GetEnumerator() { for (int r = 0; r < _data.GetLength(0); r++) for (int c = 0; c < _data.GetLength(1); c++) yield return _data[r, c]; } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); }
This gives you the best of both worlds: indexed access for random reads and iteration for sequential processing. The enumerator yields elements in row-major order, which is a natural choice for a matrix.
Advanced Scenario: Indexer with Variable Number of Parameters
C# does not allow params in an indexer parameter list. If you need a variable number of keys, you must use a fixed parameter list or expose a method. For example, a multidimensional lookup with a fixed rank can be handled with an indexer, but a jagged structure is better served by a method that accepts an array.
public class MultiKeyStore { private readonly Dictionary<string, object> _items = new(); public object this[params string[] keys] // This does not compile { get => _items[string.Join(".", keys)]; } }
That code is invalid because params is not allowed in indexers. Instead, use a method:
public object GetValue(params string[] keys) { return _items[string.Join(".", keys)]; }
This is a clear case where a method is the only viable option. The compiler enforces the restriction, so you will discover it early.
Final Code Example: a Composite Key Indexer
Putting several ideas together, here is an indexer that uses a composite key of an integer and a string, with a read-only getter and a private setter to maintain invariants.
public class Cache<T> { private readonly Dictionary<(int, string), T> _cache = new(); public T this[int id, string category] { get => _cache.TryGetValue((id, category), out var value) ? value : default; private set => _cache[(id, category)] = value; } public void Add(int id, string category, T value) => this[id, category] = value; }
The private setter prevents external code from modifying entries directly, while the public Add method enforces any additional validation. The indexer remains convenient for reads, and the encapsulation keeps the class safe. This pattern is useful when you want to expose indexed access without giving up control over mutation.