# Collections & LINQ Patterns ### Use FrozenDictionary/FrozenSet for Read-Heavy Lookup Tables ๐ŸŸก **DO** use `FrozenDictionary`/`FrozenSet` for collections created once and read many times | .NET 8+ โŒ ```csharp private static readonly Dictionary s_statusCodes = new() { ["OK"] = 200, ["NotFound"] = 404, ["InternalServerError"] = 500 }; ``` โœ… ```csharp private static readonly FrozenDictionary s_statusCodes = new Dictionary { ["OK"] = 200, ["NotFound"] = 404, ["InternalServerError"] = 500 }.ToFrozenDictionary(); ``` **Impact: ~50% faster lookups than Dictionary, ~14x faster than ImmutableDictionary.** ### Use Dictionary Alternate Lookup for Span-Based Keys ๐ŸŸก **DO** use `GetAlternateLookup>()` to avoid string allocation on lookups | **.NET 10 (or .NET 9) only โ€” NOT available on .NET 8** โŒ (allocates on every lookup; the only option on .NET 8) ```csharp string key = headerLine.Substring(0, colonIndex); if (s_dict.TryGetValue(key, out int value)) { /* ... */ } ``` โœ… .NET 10 / C# 14 ```csharp var lookup = s_dict.GetAlternateLookup>(); ReadOnlySpan key = headerLine.AsSpan(0, colonIndex); if (lookup.TryGetValue(key, out int value)) { } ``` โœ… .NET 8 fallback โ€” keep the allocation but minimise it ```csharp // On net8.0 GetAlternateLookup does not exist (added in .NET 9 BCL). // Pre-intern frequent keys, or accept the allocation. If the hot path is // truly critical, store keys as ReadOnlyMemory and write a custom // IEqualityComparer that compares against a span via string.Compare. string key = headerLine.Substring(0, colonIndex); if (s_dict.TryGetValue(key, out int value)) { /* ... */ } ``` **Impact: Avoids string allocation per lookup on .NET 10 โ€” especially valuable in parser/protocol hot paths.** ### Use CollectionsMarshal.GetValueRefOrNullRef for Lookup-and-Update ๐ŸŸก **DO** use `CollectionsMarshal.GetValueRefOrAddDefault` for dictionary update patterns | .NET 6+ โŒ ```csharp _counts.TryGetValue(key, out int count); _counts[key] = count + 1; ``` โœ… ```csharp ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(_counts, key, out _); count++; ``` **Impact: ~48% faster for lookup-and-update patterns (95ยตs โ†’ 49ยตs).** ### Use Collection Expressions [] for Zero-Allocation Span Creation ๐ŸŸก **DO** use collection expressions for `Span` targets | C# 12 / .NET 8+ โŒ ```csharp int[] values = new int[] { a, b, c, d }; ``` โœ… ```csharp Span values = [a, b, c, d]; ReadOnlySpan daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; ``` **Impact: Zero heap allocation for span-targeted collection expressions.** ### Use EnsureCapacity on List/Stack/Queue Before Bulk Adds ๐ŸŸก **DO** call `EnsureCapacity` before bulk insertions | .NET 6+ โŒ ```csharp var list = new List(); for (int i = 0; i < 10000; i++) list.Add(i); ``` โœ… ```csharp var list = new List(); list.EnsureCapacity(10000); for (int i = 0; i < 10000; i++) list.Add(i); ``` **Impact: Reduces reallocations and array copies during bulk operations.** ### Use TryGetNonEnumeratedCount for Pre-Sizing ๐ŸŸก **DO** use `TryGetNonEnumeratedCount` to pre-size destination collections | .NET 6+ โŒ ```csharp var results = new List(); foreach (var item in source) results.Add(Transform(item)); ``` โœ… ```csharp var results = source.TryGetNonEnumeratedCount(out int count) ? new List(count) : new List(); foreach (var item in source) results.Add(Transform(item)); ``` **Impact: Avoids O(n) enumeration for counting; eliminates resizing allocations.** ### Hoist Static Data Out of Method Bodies ๐ŸŸก **AVOID** creating collections with static/deterministic data inside method bodies | .NET Core+ โŒ ```csharp public string Convert(long number) { var groupsMap = new Dictionary> { { 1_000_000_000, n => $"{Convert(n)} billion" }, { 1_000_000, n => $"{Convert(n)} million" }, { 1_000, n => $"{Convert(n)} thousand" }, }; } ``` โœ… ```csharp private static readonly FrozenDictionary> s_groupsMap = new Dictionary> { { 1_000_000_000, n => $"{Convert(n)} billion" }, { 1_000_000, n => $"{Convert(n)} million" }, { 1_000, n => $"{Convert(n)} thousand" }, }.ToFrozenDictionary(); public string Convert(long number) { // ... use s_groupsMap } ``` **Impact: Eliminates collection + internal storage + closure allocations per call. For a Dictionary with N entries, saves ~N+3 allocations per invocation.** ### Add Overloads to Avoid params Array Allocation ๐ŸŸก **DO** add 1- and 2-argument overloads for methods that accept `params T[]`. On .NET 10 also expose a `params ReadOnlySpan` overload | works on .NET 8 and .NET 10 โŒ (single `params T[]` overload allocates a new array on every call, including the common 1-argument case) ```csharp public static string Transform(this string input, params IStringTransformer[] transformers) => transformers.Aggregate(input, (current, t) => t.Transform(current)); "hello".Transform(To.TitleCase); ``` โœ… Option A โ€” explicit overloads for common arities (works on .NET 8 and .NET 10) ```csharp public static string Transform(this string input, IStringTransformer transformer) => transformer.Transform(input); public static string Transform(this string input, IStringTransformer t1, IStringTransformer t2) => t2.Transform(t1.Transform(input)); public static string Transform(this string input, params IStringTransformer[] transformers) => transformers.Aggregate(input, (current, t) => t.Transform(current)); ``` โœ… Option B โ€” `.NET 10 / C# 14` adds a span overload (eliminates the allocation for all arities) ```csharp public static string Transform(this string input, params ReadOnlySpan transformers) { foreach (var t in transformers) input = t.Transform(input); return input; } ``` โš ๏ธ Option B does **not** compile on `net8.0`: `params ReadOnlySpan` requires C# 13 (default on .NET 9+). On .NET 8 ship only Option A. **Impact: Option A eliminates the array allocation for 1- and 2-argument calls on every target. Option B eliminates it for all arities on .NET 10.** ## Detection Scan recipes for collection and LINQ anti-patterns. Run these and report exact counts. ```bash # Static Dictionary not using FrozenDictionary (read-only after init) grep -rn --include='*.cs' 'static readonly Dictionary<' --exclude-dir=bin --exclude-dir=obj . | wc -l # Static FrozenDictionary (already optimized โ€” verify the inverse) grep -rn --include='*.cs' 'static readonly FrozenDictionary<' --exclude-dir=bin --exclude-dir=obj . | wc -l # Per-call List allocation (inside method bodies, not static/readonly fields) grep -rn --include='*.cs' 'new List<' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l # Per-call Dictionary allocation (inside method bodies, not static/readonly fields) grep -rn --include='*.cs' 'new Dictionary<' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l # StringComparer.CurrentCulture usage (almost always wrong in library code โ€” use Ordinal) grep -rn --include='*.cs' 'StringComparer.CurrentCulture' --exclude-dir=bin --exclude-dir=obj . | wc -l # LINQ chains in extension/hot-path files (.Select, .Where, .Cast, .Take, .Aggregate) grep -rn --include='*.cs' -E '\.(Select|Where|Cast|Take|Aggregate)\(' --exclude-dir=bin --exclude-dir=obj . | wc -l ``` For the LINQ chain recipe: any hit in a file whose name ends in `Extensions.cs`, `Formatter.cs`, or implements a method called from a public extension method is a hot-path candidate. Inspect each hit in these files and flag LINQ chains that allocate delegates, enumerators, or intermediate collections on every call. Hits in localization converters or one-time initialization are lower priority. ### Patterns Requiring Manual Review - **ContainsKey + indexer double-lookup**: Requires verifying the same key is used in a subsequent indexer access โ€” multi-line/multi-statement context - **LINQ on hot paths**: The LINQ chain recipe above catches call sites, but distinguishing hot-path from cold-path requires context. Prioritize hits in `*Extensions.cs` and `*Formatter.cs` files, which are typically called on every user invocation - **`new Dictionary/List<` in method bodies vs fields**: The grep heuristic (`grep -v 'static\|readonly'`) catches most cases but may include false positives from field initializers without `static`/`readonly` โ€” spot-check flagged lines