C# 15 如何為沒有索引器的型別加入擴充索引器
Your Type Has No Indexer. C# 15 Lets You Add One.
C# 15 可在擴充區塊中為無法修改的型別宣告索引器,讓呼叫端以方括號存取資料。加入 Count 和 Slice 後,型別亦可支援從末端索引、範圍及列表模式;文中以 IReadOnlyDictionary、Readings 和 Grid 示範讀寫用法。示範使用 .NET 11 RC 1 SDK;既有索引器優先,擴充索引器亦不能用於陣列或字串元素存取,也不能放進 expression trees。
TL;DR: C# 15 lets you declare an indexer inside an extension block, so thing[i] works on types you don't own. Add a Count and a Slice, and you get ^1, ranges, and list patterns too.
C# 15 ships with .NET 11, which is still in release candidate. Everything below ran on the .NET 11 RC 1 SDK with no LangVersion setting.
The helper everyone writes
You have a type you can't change. Maybe it is a wrapper from a library, maybe it is an interface like IReadOnlyDictionary<TKey, TValue>. You want square brackets. You get a method instead:
int last = readings.GetAt(readings.Values.Length - 1);
int stock = inventory.GetValueOrDefault("kiwi", 0);
int cell = grid.Get(row: 1, column: 2);
Each one works. Each one is also a tiny dialect your team has to learn per type. C# 14 gave us extension properties, but indexers were left out. C# 15 fills that gap.
What it is
Inside an extension block you can now declare this[...], just like on a class. Indexers are always instance members, so the block needs a named receiver:
public static class SequenceIndexer
{
extension(IEnumerable<int> sequence)
{
public int this[int index] => sequence.ElementAt(index);
}
}
And the call site looks like the indexer was always there:
IEnumerable<int> numbers = Enumerable.Range(1, 10);
Console.WriteLine(numbers[2]); // 3
That is the whole feature. Get and set accessors, multiple parameters, expression bodies, and ref returns all work like they do on normal indexers.
A lookup with a fallback
Here is one you can use today. A second parameter turns any read-only dictionary into a lookup with a default:
public static class DictionaryIndexers
{
extension<TKey, TValue>(IReadOnlyDictionary<TKey, TValue> map)
{
public TValue this[TKey key, TValue fallback]
=> map.TryGetValue(key, out var value) ? value : fallback;
}
}
var stock = new Dictionary<string, int> { ["apple"] = 12, ["pear"] = 0 };
Console.WriteLine(stock["apple", 0]); // 12
Console.WriteLine(stock["kiwi", 0]); // 0
No out var, no KeyNotFoundException. Dictionary<string, int> picks it up because it implements IReadOnlyDictionary.
The realistic one: a type you don't own
Say a library hands you this, and you can't edit it:
public sealed class Readings(int[] values)
{
public int[] Values { get; } = values;
}
Give it a Count, an integer indexer, and a Slice, all as extensions:
public static class ReadingExtensions
{
extension(Readings readings)
{
public int Count => readings.Values.Length;
public int this[int index] => readings.Values[index];
public Readings Slice(int start, int length)
=> new(readings.Values.AsSpan(start, length).ToArray());
}
}
Now the compiler treats it like a real collection:
var temps = new Readings([18, 21, 24, 19]);
Console.WriteLine(temps[0]); // 18
Console.WriteLine(temps[^1]); // 19
Console.WriteLine(temps[1..3].Values[0]); // 21
Console.WriteLine(temps is [18, .., 19]); // True
Index from the end, ranges, and list patterns all came for free. You wrote three members, and the compiler wired up the rest. That is the same pattern the language already uses for "countable" types, now open to extensions.
Setters work too, with as many parameters as you need:
public sealed class Grid(int width, int height)
{
public int Width { get; } = width;
public int[] Cells { get; } = new int[width * height];
}
public static class GridIndexer
{
extension(Grid grid)
{
public int this[int row, int column]
{
get => grid.Cells[row * grid.Width + column];
set => grid.Cells[row * grid.Width + column] = value;
}
}
}
var grid = new Grid(3, 2);
grid[1, 2] = 7;
Console.WriteLine(grid[1, 2]); // 7
Console.WriteLine(string.Join(", ", grid.Cells)); // 0, 0, 0, 0, 0, 7
No more doing row-major math at every call site. It happens in one place, which is the only place it should be wrong.
Can I use it?
| What | Status |
|---|---|
| Language version | C# 15 |
| SDK | .NET 11 SDK (RC at the time of writing) |
LangVersion needed |
No, C# 15 is the default for net11.0
|
| .NET 10 / C# 14 | No: error CS8652, "extension indexers" is in Preview |
| GA | Microsoft says C# 15 ships with .NET 11 in November |
Gotchas
Real indexers always win, silently. The compiler tries the type's own indexer first, then extensions. In a test, an extension
this[int]onList<int>that returns-1changed nothing:list[1]still returned20, and the build gave no warning. So you can't use an extension to "fix" an indexer a type already has.Arrays and strings are off limits. Extension indexers are never used for array or string element access. An extension
this[string]onint[]compiled fine, buta["x"]failed with CS0029, because the compiler only looked at the built-in array indexer.Brackets can hide a loop. The
ElementAtindexer onIEnumerable<int>looks like O(1) and isn't. Readingseq[0]throughseq[9]from a lazy iterator pulled 55 items to return 10. If the receiver is not already a list, think twice before giving it brackets.No expression trees.
Expression<Func<Box, int>> f = b => b[0];fails with CS9296: an expression tree may not contain an extension property or indexer access. That matters if you hoped to use one inside an EF Core query.
Wrap-up
Extension indexers are small, but they finish the story C# 14 started: methods, properties, operators, and now brackets. Pick the one GetAt helper in your codebase that annoys you most and give it a this[...] on the .NET 11 RC.
Sources
- What's new in C# 15 (Microsoft Learn)
- Extension member declarations: Extension indexers (Microsoft Learn)
- Extension indexers feature specification (Microsoft Learn)
- Explore C# 15 (.NET Blog)
- Download .NET 11
This article was written with the help of AI and checked against the sources above.
來源:DEV Community · dev.to