A vestibule is an entry hall, the small room you pass through at the door of a house.
An entry API for Dictionary<TKey, TValue>, like HashMap::entry in Rust.
Rust has HashMap::entry API and C# does not, which has annoyed me for a while.
The C# 15 union types preview finally gave me an excuse to build it.
The classic counter:
// the standard way
if (counts.TryGetValue(word, out var count))
counts[word] = count + 1;
else
counts[word] = 1;
// with the entry api
counts.Entry(word).AndModify(c => c + 1).OrInsert(1);- One lookup. The key is resolved once, the operations work off the resolved case and do not search the dictionary again.
- Can do modify-or-insert is one expression. No if/else dance around the indexer.
- Composable. The value of one entry can become the key of another, so nested dictionaries stay flat.
Counting edge weights in a graph:
// the standard way
if (!graph.TryGetValue(from, out var edges))
{
edges = new Dictionary<string, int>();
graph[from] = edges;
}
if (edges.TryGetValue(to, out var weight))
edges[to] = weight + 1;
else
edges[to] = 1;
// with the entry api
graph
.Entry(from)
.OrInsertWith(() => new Dictionary<string, int>())
.Entry(to)
.AndModify(w => w + 1)
.OrInsert(1);dict.Entry(key) resolves the key and returns an Entry<TKey, TValue>, a union
of two cases:
Occupied<TKey, TValue>- the key was present. Carries the dictionary, the key and the value.Vacant<TKey, TValue>- the key was missing. Carries the dictionary and the key, ready for insert.
The entry is a snapshot: it resolves the key once, later dictionary changes are not re-checked. Debug builds throw on a stale entry, but that is a heuristic, indexer writes slip through it.
// modify the stored value, insert 1 when the key is missing
counts.Entry(word).AndModify(c => c + 1).OrInsert(1);
// build the value only when the key is missing
graph.Entry(from).OrInsertWith(() => new Dictionary<string, int>());AndModify writes the updated value back to the dictionary and returns a fresh entry.
OrInsert and OrInsertWith return the value the entry ends up holding: the stored
one or the inserted one. A stored null counts as occupied, OrInsert returns it and
does not touch the fallback. Occupied.Replace swaps a value in place, Vacant.Insert
fills an empty slot.
The switch has to be exhaustive, the compiler rejects a case you forgot:
switch (counts.Entry("cat")) {
case Occupied<string, int> occupied:
Console.WriteLine($": cat: {occupied.Value}");
break;
case Vacant<string, int>:
Console.WriteLine("no 🐈 here");
break;
}EntryRef() bypasses the union and gives you a live ref TValue through
CollectionsMarshal, with comfy DX:
counts.EntryRef(word).OrInsert(0)++;OrInsert returns a ref straight into the value slot, so the increment goes there
without a second lookup. The ref is valid until the next structural change of the
dictionary, after that it may point at a dead slot. The handle itself stores no ref
and stays usable.
BenchmarkDotNet, N = 100k. 1.0 = same speed, lower = faster.
| Scenario | Entry vs TryGetValue |
|---|---|
| Counter increment | 1.17x |
| Grouping | 0.90x |
| Missing key insert | 1.00x |
| Modify existing | 1.21x |
Inserts have no extra overhead, though updates have some: the entry pays for the case check, the delegate call and struct copies.
Counter scenario:
| Method | Ratio vs TryGetValue |
|---|---|
EntryRef |
0.46x |
Raw GetValueRefOrAddDefault |
0.44x |
EntryRef is over twice as fast as the hand-written loop on this shape, and sits
a few percent above the raw stdlib call it wraps.
No extra allocations on any path.
TryGetValue- a one-off read, nothing to modify.Entry- modify-or-insert logic and nested dictionaries. Safe, snapshot semantics.EntryRef- hot loops with value-type values. Mind the ref lifetime, it dies on the next structural change of the dictionary.
.NET 11 preview SDK, the C# 15 union types need it. global.json pins
11.0.100-preview.7.
No NuGet package yet, build from source with dotnet build, run tests with dotnet test.
kjhickman/DictionaryEntry does the
same occupied/vacant split, but as a ref struct with a live handle. This one
trades the live handle for a union and a snapshot. Found it halfway through
building this.