Collections and equality
Skill Sarmkadan/dotnet-senior-skills/skills/collections-and-equality
Review .NET collection and equality code - choosing collection types, exposure through APIs, GetHashCode/Equals contracts, dictionary key safety, and comparer usage. Use when reviewing collection-typed members, equality implementations, or LINQ set operations.From its SKILL.md
npx -y skills add Sarmkadan/dotnet-senior-skills --skill collections-and-equalityAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
4.8 KB, ~1.0k tokens by cl100k_base, as published. Nobody here has run it
Collections and Equality
Return types: promise the least
- Public API returns:
IReadOnlyList<T>/IReadOnlyCollection<T>for materialized data. ReturningList<T>invites callers to mutate your internal state; returningIEnumerable<T>from a method that already has a list hidesCountand invites re-enumeration paranoia (.ToList()calls sprinkled by nervous callers). - Return
IEnumerable<T>only when the sequence is genuinely lazy/streaming - and then the method name or docs say so, because every enumeration re-executes (the multiple-enumeration bug is in the performance skill; here the point is: do not create the ambiguity). - Never return
nullfor an empty collection:Array.Empty<T>()/[]. Every caller null-check on a collection return is a design apology. - Parameters: accept the weakest thing you actually need -
IEnumerable<T>if you only iterate once,IReadOnlyCollection<T>if you needCount. A parameter typedList<T>forces callers to copy.
Exposed mutable collections
// non-compiling: illustrative
// WRONG: any consumer can do order.Lines.Clear() - the invariant has a side door
public List<OrderLine> Lines { get; set; } = new();
// RIGHT: mutation goes through the method that enforces the rules
private readonly List<OrderLine> _lines = new();
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public void AddLine(OrderLine line) { /* rules */ _lines.Add(line); }
Note AsReadOnly() wraps (view of live list, cheap); ToList() in a getter copies per access - a foreach over a copying getter allocates once, but order.Lines[i] in a loop copies the entire list N times. Know which one you wrote.
The Equals/GetHashCode contract
Equal objects must have equal hash codes, and the hash must not change while the object is in a hash-based collection. Violations do not throw - they make dictionary entries unfindable: Add succeeds, TryGetValue with an equal key returns false, Remove silently fails, counts drift.
- Override both or neither.
EqualswithoutGetHashCodecompiles with a warning people suppress and breaks everyDistinct(),GroupBy(),HashSet, and dictionary that touches the type. - Implement via
IEquatable<T>(avoids boxing in generic collections) andHashCode.Combine(...)- not hand-rolled XOR (collides symmetric values:(a,b)and(b,a)hash equal). - Or don't implement at all: a
recordgets correct value equality generated. Hand-written equality on a type that could be a record is maintenance surface for zero gain - every added property must be added in three places or equality silently lies. - Mutable objects as dictionary/set keys: the key's hash-relevant fields must never change post-insertion. A
HashSet<Item>whereitem.Name(part of the hash) is later assigned = a corrupted set. Keys are immutable types - ids, strings, readonly record structs.
Choosing the structure
- Lookup by key in any loop:
Dictionary/HashSetbuilt once, notlist.First(x => x.Id == id)per iteration - that is O(n*m), the in-memory N+1 (performance skill), and it appears constantly in mapping code.ToDictionary(x => x.Id)before the loop. ToLookupfor one-to-many grouping lookups;GroupBywhen streaming groups once.FrozenDictionary/FrozenSet(.NET 8+) for build-once-read-forever singletons (config maps, routing tables) - faster reads thanDictionary, and the type documents the immutability.ImmutableListet al. are for shared-snapshot semantics (safe publication to concurrent readers), not a default - per-operation allocation makes them slower where nothing is shared.- Struct enumeration:
List<T>viaIEnumerable<T>interface boxes its struct enumerator - iterate concrete types in hot loops (performance skill).
String keys and comparers
Every hash structure keyed by strings states its comparer explicitly when case matters: new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase). Normalizing keys at insertion (key.ToLowerInvariant()) but not at lookup - or vice versa - is a bug the comparer makes impossible. Culture-sensitive comparers (CurrentCulture) in dictionaries: essentially never (see globalization skill); ordinal is the default for identifiers.
Same for LINQ set operators: Distinct(), Except(), Contains(), GroupBy() all take an IEqualityComparer<T> overload - flag any of them applied to strings or custom types where the intended equality is not the default one. orders.Select(o => o.Email).Distinct() deduplicates case-sensitively; if that is wrong, it is wrong silently.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.