Skip to content

Repository files navigation

Vestibule

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.

Quick example

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);

Why Entry?

  • 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.

Nested maps

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);

API

Entry

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.

Modifying entries

// 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.

Matching entries

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;
}

Fast path: EntryRef

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.

Performance

BenchmarkDotNet, N = 100k. 1.0 = same speed, lower = faster.

Entry

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.

EntryRef

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.

When to use which

  • 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.

Requirements

.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.

Related

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.

About

Rust's HashMap::entry for C# Dictionary, built on C# 15 union types

Resources

Stars

3 stars

Watchers

0 watching

Forks

Contributors

Languages