Struct and Record Equality
Objective
"Are these two equal?" has a different default answer for each kind of C# type.
A class compares references, a plain struct compares fields through reflection
but has no ==, a record compares fields and has ==, a tuple compares
element by element, and an array compares references even when its contents
match. This concept puts those defaults side by side, shows where each one goes
wrong (a struct hash that ignores fields, a record with a list that is never
equal, a class hierarchy that breaks symmetry), and how to implement value
equality correctly when the default is not enough. The general
Equals/GetHashCode contract and the mutable key bug are in Dictionary,
HashSet, and Equality; what a record generates is in Records and with
Expressions.
Use Cases
- Value objects (
Money,DateRange,Coordinate) that must compare by content and work as dictionary keys. - Small structs used in hot paths or as keys, where boxing equality is a cost.
- Records holding collections, such as an order with lines or a DTO with tags, that should compare by contents.
- Comparing arrays, tuples, and composite keys in tests and caches.
Deep Dive
The defaults, measured
| Kind | a == b |
a.Equals(b) |
Hash |
|---|---|---|---|
| class | reference | reference | per object |
| plain struct | CS0019, no operator | field by field via ValueType.Equals |
ValueType.GetHashCode, weak |
record / record struct |
field by field | field by field | combines all fields |
| tuple | element by element | element by element | combines all elements |
| array | reference | reference | per object |
csharpint[] a = [1, 2, 3]; int[] b = [1, 2, 3];
a == b; // False
a.SequenceEqual(b); // True
StructuralComparisons.StructuralEqualityComparer.Equals(a, b); // True, hashes match too
var t1 = (Id: 1, Name: "a"); var t2 = (Key: 1, Label: "a");
t1 == t2; // True: names are ignored, positions matter
(1, 2L) == (1L, 2); // True: elements convert like in ordinary ==
(1, new List<int> { 1 }) == (1, new List<int> { 1 }); // False: List compares referencesTuple == (C# 7.3) evaluates every element on both sides first, then compares
pairs in order and stops at the first mismatch. Members are compared with their
own equality, which is why a tuple, record, or struct holding a List<T> or an
array is never equal to a copy.
Plain structs: correct, slow, and a weak hash
A struct without overrides gets ValueType.Equals, which calls Equals on each
field, so even string fields compare by content:
csharpstruct Name(string first, int n) { public string First = first; public int N = n; }
new Name("ana", 1).Equals(new Name(string.Concat("an", "a"), 1)); // True
new Name("x", 1).GetHashCode() == new Name("x", 2).GetHashCode(); // True: N was ignoredThe second line is the trap. The documentation of ValueType.GetHashCode says
only that "one or more fields" are used and that the result is "not likely to
be suitable for use as a key in a hash table". In this run, a struct with a
reference field hashed on the string alone, so every key with the same First
lands in the same bucket. Floating-point fields are fine on .NET 10:
0.0 and -0.0 compared equal and NaN equaled NaN, the same as
double.Equals.
The other costs: a == b does not compile until you declare the operator, and
EqualityComparer<Pair>.Default turned out to be an ObjectEqualityComparer,
which goes through Equals(object) and boxes (Value Types, Reference Types and
Boxing measured 48 bytes per Equals call on such a struct). A struct that
implements IEquatable<T> gets the GenericEqualityComparer instead.
Implementing equality on a struct
When a struct cannot be a record struct, implement the full set:
csharpreadonly struct Money(decimal amount, string currency) : IEquatable<Money>
{
public decimal Amount { get; } = amount;
public string Currency { get; } = currency;
public bool Equals(Money other) => Amount == other.Amount && Currency == other.Currency;
public override bool Equals(object? obj) => obj is Money m && Equals(m);
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
public static bool operator ==(Money l, Money r) => l.Equals(r);
public static bool operator !=(Money l, Money r) => !l.Equals(r);
}
new Money(10m, "EUR") == new Money(10.00m, "EUR"); // True, and the hashes matchHashCode.Combine mixes up to eight values; for more, or for a sequence, use a
HashCode instance with Add and ToHashCode. Equal values must produce equal
hashes, and the hash must use the same notion of equality as Equals: here
decimal treats 10m and 10.00m as equal and hashes them alike, even though
they print differently (10 and 10.00). If you compared strings
case-insensitively in Equals, the hash would need the same comparer
(hash.Add(name, StringComparer.OrdinalIgnoreCase)). HashCode uses a random
seed per process, so its results must never be persisted or sent elsewhere.
A readonly record struct Money(decimal Amount, string Currency) generates all
of the above in one line, which is why the documentation recommends records
whenever the type can be one.
Customizing record equality
Record equality uses each member's own Equals, so a collection member breaks
it. Replace the generated Equals(T) and GetHashCode; the generated ==,
!=, and Equals(object) call yours:
csharpsealed record Order(string Id, IReadOnlyList<string> Lines)
{
public bool Equals(Order? other) =>
other is not null && Id == other.Id && Lines.SequenceEqual(other.Lines);
public override int GetHashCode()
{
var h = new HashCode();
h.Add(Id);
foreach (var l in Lines) h.Add(l);
return h.ToHashCode();
}
}
new Order("A", ["x", "y"]) == new Order("A", ["x", "y"]); // True
new HashSet<Order> { o1 }.Contains(o2); // True
// record PlainOrder(string Id, List<string> Lines): the same comparison is FalseThe compiler guards the shape:
- In a record that is not sealed,
Equals(T)must bevirtual: CS8872 ("must allow overriding because the containing record is not sealed"). EqualswithoutGetHashCodeis warning CS8851.- Declaring
==or!=yourself is CS0111: the record already definesop_Equality.
Sealing the record is the simplest choice. For an unsealed record, equality
already checks EqualityContract, so a derived record never equals its base.
Class hierarchies
A class that is not a record and is not sealed needs two things to keep
Equals symmetric: a virtual typed Equals, and a runtime type check.
csharpclass Shape(string color) : IEquatable<Shape>
{
public string Color { get; } = color;
public override bool Equals(object? obj) => Equals(obj as Shape);
public virtual bool Equals(Shape? other) =>
other is not null && GetType() == other.GetType() && Color == other.Color;
public override int GetHashCode() => HashCode.Combine(GetType(), Color);
}
class Circle(string color, double r) : Shape(color)
{
public double R { get; } = r;
public override bool Equals(Shape? other) => other is Circle c && base.Equals(c) && R == c.R;
public override int GetHashCode() => HashCode.Combine(GetType(), Color, R);
}
Shape c = new Circle("red", 5); Shape s = new Shape("red");
c.Equals(s); s.Equals(c); // False False: symmetricWithout the GetType() check, s.Equals(c) would compare only colors and
return true while c.Equals(s) returns false. This is what a record's
EqualityContract does for you; for a non-record class, sealing it removes the
problem entirely.
Choosing the comparison at the call site
Equality is also something the caller can choose without changing the type:
ReferenceEquals(x, y)for identity; it is alwaysfalsefor value types, because each argument is boxed separately.a.SequenceEqual(b)andStructuralComparisons.StructuralEqualityComparerfor arrays and lists.- An
IEqualityComparer<T>passed to a dictionary, a set, or LINQ, when one type needs different equalities in different places (covered in Dictionary, HashSet, and Equality).
Trade-offs
- Records give correct equality for free, but shallow. Collection members
compare by reference until you override
Equals(T)andGetHashCode. - Plain struct equality works but is not a key. It boxes through
ObjectEqualityComparerand its hash can ignore fields:Use acsharpnew Name("x", 1).GetHashCode() == new Name("x", 2).GetHashCode(); // Truerecord structor implementIEquatable<T>for anything hashed. - Hand-written equality has five members to keep in sync.
Equals(T),Equals(object),GetHashCode,==, and!=. Missing one compiles, with at most a warning. - Value equality on mutable types is fragile. If fields change after the object is in a set or used as a key, lookups fail; keep equality-defining members immutable.
- Unsealed hierarchies cost symmetry. Without a runtime type check, base and derived instances compare differently depending on which side calls. Prefer sealed types or records.
- Structural comparison of collections is O(n).
SequenceEqualinEqualsand a loop inGetHashCodemake every dictionary lookup proportional to the collection size; cache the hash for large immutable collections.