0113 a record whose member compares by reference writes its own equality - CyrilB1531/lodestar GitHub Wiki
Status: accepted ยท Date: 2026-09-12
A record synthesises Equals that compares reference-typed members by reference. Two
instances holding the same numbers in different arrays compare unequal, and GetHashCode agrees
with that rather than with the caller:
var a = new KMeansOptions { InitialCentres = [1.0, 2.0] };
var b = new KMeansOptions { InitialCentres = [1.0, 2.0] };
a == b; // falseThat is worse than no equality at all, because a record's shape advertises value semantics.
#668 opened on this as an open question with
three options. The survey that answered it found the question already settled in the tree: of the
thirty-two public records in src/, twelve hold a member that compares by reference, and six
already write their own equality โ BpeVocabulary, SentencePieceVocabulary,
WordPieceVocabulary, SpecialTokenTemplate, TokenizationResult and CountVectorizerOptions.
All six spell it the same way. TokenizationResult gives the reason in its own <remarks>.
The issue's grep missed them because it looked for override bool Equals. A record's equality
member is public bool Equals(T? other) โ virtual, not override, the record having declared it
already.
A public record implements public bool Equals(T? other) and
public override int GetHashCode() when any of its members compares by reference. Anything
else keeps the synthesised equality and owes nothing.
The trigger is the member's equality, not its declaration. A collection member โ an array or any
IReadOnly*<T> โ compares by reference and fires the rule. A member that is itself a record, a
struct or a string already compares by value, and the synthesised equality delegating to it is
correct. SurvivalStep needs no help; the SurvivalStep[] holding it does.
A third case fires the trigger and gets no arm below: a member that compares by reference and is
neither a collection nor value-equal โ a mutable class of its own.
#668 named it, as "a collection or a mutable
object". It is left unarmed deliberately, because what equality means for such a member is that
member's question rather than this rule's. No record in src/ holds one today; a record that
grows one answers the question in the same commit.
Two arms, by what the member means:
| member | compared | contributes to the hash |
|---|---|---|
a sequence โ T[], IReadOnlyList<T>
|
element by element, in order | its count |
a set โ an IReadOnlyCollection<T> whose order is not meaning |
SetEquals |
whether it is present, never its count |
And three rules the existing six already fix:
- a
doublemember compares withdouble.Equals, not==, soNaNequalsNaNas a record's equality must. S1244 is suppressed at those lines with that reason; - a
stringmember comparesOrdinal; - the hash never walks a collection โ scalars, counts and presence only, so it stays O(1).
Set equality makes ["the", "the"] equal ["the"] while their counts differ. Equal objects must
hash alike, so the count is unusable and only presence survives. Hashing the words themselves is
the O(n) the rule exists to avoid. Unequal instances are allowed to collide.
Six records change observably: KMeansOptions, Chi2ContingencyResult, KaplanMeierCurve,
NelsonAalenCurve, RakeOptions and TextRankOptions. a == b flips from false to true
for value-identical instances, which is why each owes a CHANGELOG.md entry.
One hazard is kept rather than solved: a hash taken over a member the caller then mutates will not match afterwards. Hashing counts and presence narrows it โ mutating contents leaves the length alone โ and none of the twelve is used as a dictionary key in the tree.
Document it and leave the code alone. Leaves a trap that reads like a feature, and makes six shipped types the exception rather than the rule.
Make the fitted results plain sealed classes. A breaking change to three shipped types, and
the precedent does not split the way the argument assumes: CountVectorizerOptions is an options
bag and TokenizationResult is a result, and both chose equality.