Skip to content

Commit cda306a

Browse files
marius-bughiuclaude
andcommitted
refactor(Trie): align with the IReadOnlyDictionary<TKey, TValue?> convention, drop LINQ
- Implement IReadOnlyDictionary<string, TValue?> (was TValue), matching CelerityDictionary and the rest of the dictionary surface: public getters stay non-null (TValue), the nullable interface indexer is provided explicitly, and TryGetValue / Values / GetEnumerator now carry the TValue? annotation. Resolves the README "all dictionaries implement ...TValue?" tension. - Replace the two LINQ Select usages (GetKeysWithPrefix, Values) — the only LINQ in src/Celerity/Collections — with plain iterator helpers (EnumerateKeys / EnumerateValues), keeping the eager version snapshot. - Update docs/README to IReadOnlyDictionary<string, TValue?>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8380dc2 commit cda306a

3 files changed

Lines changed: 39 additions & 14 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `
8383

8484
**Prefix trees**
8585

86-
- `Trie<TValue>` — ordered **prefix tree** mapping string keys to values. `GetByPrefix` lists every entry whose key starts with a prefix in `O(prefix + matches)`, and `TryGetLongestPrefix` finds the longest stored key that is a prefix of a query in `O(query)`. The trie the BCL lacks — autocomplete, longest-prefix routing, and ordered (ascending-ordinal) iteration, where a `Dictionary<string, TValue>` has no prefix index and must scan every key and run `StartsWith`. Exact `Add` / `TryGetValue` favour a `Dictionary` (one hash vs a character walk); the trie earns its place on the prefix operations. Implements `IReadOnlyDictionary<string, TValue>`.
86+
- `Trie<TValue>` — ordered **prefix tree** mapping string keys to values. `GetByPrefix` lists every entry whose key starts with a prefix in `O(prefix + matches)`, and `TryGetLongestPrefix` finds the longest stored key that is a prefix of a query in `O(query)`. The trie the BCL lacks — autocomplete, longest-prefix routing, and ordered (ascending-ordinal) iteration, where a `Dictionary<string, TValue>` has no prefix index and must scan every key and run `StartsWith`. Exact `Add` / `TryGetValue` favour a `Dictionary` (one hash vs a character walk); the trie earns its place on the prefix operations. Implements `IReadOnlyDictionary<string, TValue?>`.
8787

8888
**Probabilistic & bit-level**
8989

@@ -514,7 +514,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
514514
| **Double-ended queue** — add/remove at both ends (bounded FIFO queue, sliding window, work-stealing / undo buffer) or a queue needing random access by position | `Deque<T>` | Growable double-ended queue backed by a **circular buffer**: `O(1)` amortized `PushFront` / `PushBack` / `PopFront` / `PopBack` / peek and `O(1)` random access by index. The BCL has no deque — `Queue<T>` is FIFO-only, `Stack<T>` LIFO-only, and `LinkedList<T>` (the only O(1)-both-ends type) allocates a node per element. A warm bounded churn reuses the buffer with wrap-around so it **allocates nothing**, and enumeration walks contiguous memory. For a strict FIFO queue that never pushes front / pops back, BCL `Queue<T>` is already a circular buffer and is simpler. |
515515
| **Incremental connectivity / connected components** — union equivalence classes and ask whether two elements are in the same group (Kruskal MST, clustering, image segmentation, undirected cycle detection, "are these accounts linked?") | `DisjointSet<T>` | Union-find with **union by size** + **path halving**: near-`O(1)` amortized `Union` / `Find` / `Connected`, `O(α(n)) ≤ 4`. Runs a stream of merges + connectivity queries in near-linear total time, where the BCL substitutes are super-linear — a `Dictionary<T, HashSet<T>>` set-merge is `O(n²)` to coalesce `n` singletons, and a per-query BFS/DFS is `O(V+E)` every query. Grows only by merging (no un-union); it is not an `ISet<T>` — for element membership with add/remove/set-algebra use `CeleritySet` or `HashSet<T>`. |
516516
| **Priority queue whose priorities change** — a best-so-far frontier you relax (Dijkstra / Prim / A\*), or an event scheduler that reschedules / cancels pending items | `IndexedPriorityQueue<TElement, TPriority, THasher>` | Addressable binary min-heap with an element→slot index: `Update` (decrease-/increase-key) and `Remove` an arbitrary element in `O(log n)`, `Contains` / `TryGetPriority` in `O(1)`. The BCL `PriorityQueue<,>` can do none of these — its only substitute is lazy deletion, which grows the heap by one entry per update. Each element is a key (appears once); custom `IComparer<TPriority>` for a max-heap. For plain enqueue/dequeue with duplicate elements, the BCL `PriorityQueue<,>` is simpler. |
517-
| **Prefix / autocomplete / longest-prefix** over string keys — list everything under a prefix, find the most specific stored key that prefixes a query, or iterate keys in order (typeahead, route/dispatch tables, tokenizer / dictionary matching, namespace listing) | `Trie<TValue>` | Ordered prefix tree: `GetByPrefix` yields every entry under a prefix in `O(prefix + matches)` and in ascending key order, `TryGetLongestPrefix` finds the longest stored prefix of a query in `O(query)`, and enumeration is sorted for free — none of which a `Dictionary<string, TValue>` can do without an `O(n)` scan + `StartsWith`. For **pure exact-key** `Add` / `TryGetValue` / `Remove` a `Dictionary` (one hash vs a per-character walk) is faster; the trie earns its place only when you use the prefix operations. Implements `IReadOnlyDictionary<string, TValue>`; not thread-safe. |
517+
| **Prefix / autocomplete / longest-prefix** over string keys — list everything under a prefix, find the most specific stored key that prefixes a query, or iterate keys in order (typeahead, route/dispatch tables, tokenizer / dictionary matching, namespace listing) | `Trie<TValue>` | Ordered prefix tree: `GetByPrefix` yields every entry under a prefix in `O(prefix + matches)` and in ascending key order, `TryGetLongestPrefix` finds the longest stored prefix of a query in `O(query)`, and enumeration is sorted for free — none of which a `Dictionary<string, TValue>` can do without an `O(n)` scan + `StartsWith`. For **pure exact-key** `Add` / `TryGetValue` / `Remove` a `Dictionary` (one hash vs a per-character walk) is faster; the trie earns its place only when you use the prefix operations. Implements `IReadOnlyDictionary<string, TValue?>`; not thread-safe. |
518518
| Need a stable iteration order or multi-threaded access | BCL `Dictionary<,>`, `ConcurrentDictionary<,>` | Celerity is single-threaded and iteration order is unspecified. |
519519

520520
**Celerity is not the right answer when** you need concurrent access (use `ConcurrentDictionary<,>` or your own lock — Celerity is single-threaded), the mutable `IDictionary<,>` interface, or a guaranteed iteration order (Celerity exposes `IReadOnlyDictionary<,>` only and does not promise order across versions).

docs/api/collections.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3428,10 +3428,10 @@ Console.WriteLine(string.Join(", ", final.OrderBy(kv => kv.Key).Select(kv => $"{
34283428

34293429
## Trie&lt;TValue&gt;
34303430

3431-
An ordered **prefix tree** (trie) mapping `string` keys to values. Every key is stored as a path of characters from a shared root, so keys sharing a prefix share that prefix's nodes. Implements `IReadOnlyDictionary<string, TValue>`.
3431+
An ordered **prefix tree** (trie) mapping `string` keys to values. Every key is stored as a path of characters from a shared root, so keys sharing a prefix share that prefix's nodes. Implements `IReadOnlyDictionary<string, TValue?>`.
34323432

34333433
```csharp
3434-
public sealed class Trie<TValue> : IReadOnlyDictionary<string, TValue>
3434+
public sealed class Trie<TValue> : IReadOnlyDictionary<string, TValue?>
34353435
```
34363436

34373437
The BCL ships no trie. `Dictionary<string, TValue>` answers an exact-key lookup in `O(1)` but has **no efficient prefix operation**: listing every key that starts with a prefix, or finding the longest stored key that is a prefix of a query, both force an `O(n)` scan of the whole dictionary plus a `StartsWith` per key. A trie answers those directly from its structure.

src/Celerity/Collections/Trie.cs

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ namespace Celerity.Collections;
4848
/// thread-safe; concurrent callers must synchronize externally.
4949
/// </para>
5050
/// </remarks>
51-
public sealed class Trie<TValue> : IReadOnlyDictionary<string, TValue>
51+
public sealed class Trie<TValue> : IReadOnlyDictionary<string, TValue?>
5252
{
5353
// A trie node. The root carries no incoming edge; every other node is reached by exactly one edge
5454
// character from its parent. Child edges are held in two parallel arrays kept sorted ascending by
@@ -243,7 +243,7 @@ public bool ContainsKey(string key)
243243
/// </param>
244244
/// <returns><c>true</c> if the key was found; otherwise <c>false</c>.</returns>
245245
/// <exception cref="ArgumentNullException"><paramref name="key"/> is <c>null</c>.</exception>
246-
public bool TryGetValue(string key, out TValue value)
246+
public bool TryGetValue(string key, out TValue? value)
247247
{
248248
ArgumentNullException.ThrowIfNull(key);
249249
Node? node = FindNode(key);
@@ -252,7 +252,7 @@ public bool TryGetValue(string key, out TValue value)
252252
value = node.Value;
253253
return true;
254254
}
255-
value = default!;
255+
value = default;
256256
return false;
257257
}
258258

@@ -359,7 +359,7 @@ public bool ContainsPrefix(string prefix)
359359
/// <param name="prefix">The prefix to match.</param>
360360
/// <returns>A lazily evaluated sequence of the matching entries in ascending key order.</returns>
361361
/// <exception cref="ArgumentNullException"><paramref name="prefix"/> is <c>null</c>.</exception>
362-
public IEnumerable<KeyValuePair<string, TValue>> GetByPrefix(string prefix)
362+
public IEnumerable<KeyValuePair<string, TValue?>> GetByPrefix(string prefix)
363363
{
364364
ArgumentNullException.ThrowIfNull(prefix);
365365
Node? node = FindNode(prefix);
@@ -377,7 +377,12 @@ public IEnumerable<KeyValuePair<string, TValue>> GetByPrefix(string prefix)
377377
/// <param name="prefix">The prefix to match.</param>
378378
/// <returns>A lazily evaluated sequence of the matching keys in ascending order.</returns>
379379
/// <exception cref="ArgumentNullException"><paramref name="prefix"/> is <c>null</c>.</exception>
380-
public IEnumerable<string> GetKeysWithPrefix(string prefix) => GetByPrefix(prefix).Select(pair => pair.Key);
380+
public IEnumerable<string> GetKeysWithPrefix(string prefix)
381+
{
382+
ArgumentNullException.ThrowIfNull(prefix);
383+
Node? node = FindNode(prefix);
384+
return EnumerateKeys(node, prefix, _version);
385+
}
381386

382387
/// <summary>
383388
/// Finds the longest stored key that is a prefix of <paramref name="query"/> (a stored key equal to
@@ -441,18 +446,23 @@ public bool TryGetLongestPrefix(string query, out string? key, out TValue? value
441446
public IEnumerable<string> Keys => GetKeysWithPrefix(string.Empty);
442447

443448
/// <summary>Gets the values ordered by their keys' ascending ordinal order.</summary>
444-
public IEnumerable<TValue> Values => Enumerate(_root, string.Empty, _version).Select(pair => pair.Value);
449+
public IEnumerable<TValue?> Values => EnumerateValues(_root, string.Empty, _version);
445450

446451
/// <summary>
447452
/// Returns an enumerator that yields every entry in ascending ordinal key order. Enumeration allocates a
448453
/// small traversal stack; if the trie is modified during enumeration,
449454
/// <see cref="IEnumerator.MoveNext"/> throws <see cref="InvalidOperationException"/>.
450455
/// </summary>
451456
/// <returns>An enumerator over the trie's entries in ascending key order.</returns>
452-
public IEnumerator<KeyValuePair<string, TValue>> GetEnumerator() => Enumerate(_root, string.Empty, _version).GetEnumerator();
457+
public IEnumerator<KeyValuePair<string, TValue?>> GetEnumerator() => Enumerate(_root, string.Empty, _version).GetEnumerator();
453458

454459
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
455460

461+
// IReadOnlyDictionary<string, TValue?> indexer: the public indexer getter returns the non-null TValue for a
462+
// nicer caller experience, so the interface's nullable-value getter is provided explicitly, matching the
463+
// rest of the Celerity dictionary surface (e.g. CelerityDictionary).
464+
TValue? IReadOnlyDictionary<string, TValue?>.this[string key] => this[key];
465+
456466
// ---- internal machinery ----------------------------------------------------------------------
457467

458468
// Walks the key from the root and returns the node it ends on, or null if the path breaks.
@@ -508,7 +518,7 @@ private bool TryInsert(string key, TValue value, bool overwrite)
508518
// and after each yield, so it surfaces on the very first MoveNext (BCL-style), not one item late. A null
509519
// `start` (a missing prefix) yields nothing but still runs the version check, so the empty result carries
510520
// the same invalidation contract as a non-empty one.
511-
private IEnumerable<KeyValuePair<string, TValue>> Enumerate(Node? start, string startKey, int expectedVersion)
521+
private IEnumerable<KeyValuePair<string, TValue?>> Enumerate(Node? start, string startKey, int expectedVersion)
512522
{
513523
if (expectedVersion != _version)
514524
ThrowModified();
@@ -518,7 +528,7 @@ private IEnumerable<KeyValuePair<string, TValue>> Enumerate(Node? start, string
518528

519529
if (start.HasValue)
520530
{
521-
yield return new KeyValuePair<string, TValue>(startKey, start.Value);
531+
yield return new KeyValuePair<string, TValue?>(startKey, start.Value);
522532
if (expectedVersion != _version)
523533
ThrowModified();
524534
}
@@ -539,7 +549,7 @@ private IEnumerable<KeyValuePair<string, TValue>> Enumerate(Node? start, string
539549
sb.Append(node.ChildChars[ci]); // sb now holds the path to `child`
540550
if (child.HasValue)
541551
{
542-
yield return new KeyValuePair<string, TValue>(sb.ToString(), child.Value);
552+
yield return new KeyValuePair<string, TValue?>(sb.ToString(), child.Value);
543553
if (expectedVersion != _version)
544554
ThrowModified();
545555
}
@@ -553,6 +563,21 @@ private IEnumerable<KeyValuePair<string, TValue>> Enumerate(Node? start, string
553563
}
554564
}
555565

566+
// Key- and value-only projections of Enumerate, as plain iterator blocks rather than LINQ, so the library
567+
// keeps its no-LINQ / allocation-conscious stance. Each snapshots the version through Enumerate exactly as
568+
// the pair walk does.
569+
private IEnumerable<string> EnumerateKeys(Node? start, string startKey, int expectedVersion)
570+
{
571+
foreach (KeyValuePair<string, TValue?> pair in Enumerate(start, startKey, expectedVersion))
572+
yield return pair.Key;
573+
}
574+
575+
private IEnumerable<TValue?> EnumerateValues(Node? start, string startKey, int expectedVersion)
576+
{
577+
foreach (KeyValuePair<string, TValue?> pair in Enumerate(start, startKey, expectedVersion))
578+
yield return pair.Value;
579+
}
580+
556581
private static void ThrowModified() =>
557582
throw new InvalidOperationException("Collection was modified; enumeration operation may not execute.");
558583
}

0 commit comments

Comments
 (0)