Structural equality and hashing
Two object graphs that were serialized to the same bytes should be considered
equal, even when neither type overrides Equals(object).
ShapeShift.Equality builds a deep IEqualityComparer<T>
for any type that has a PolyType shape, using the same source-generated shape
information the serializer uses. There is no reflection, no dynamic code
generation, and no InternalsVisibleTo: everything is NativeAOT safe and
trimming friendly.
Getting a comparer
StructuralEqualityComparer is the entry point:
internal static void CompareTwoGraphs()
{
IEqualityComparer<Order> comparer = StructuralEqualityComparer.Create<Order>();
Order left = new("A-1", [new("widget", 2), new("gizmo", 1)], new Dictionary<string, string> { ["gift"] = "yes" });
Order right = new("A-1", [new("widget", 2), new("gizmo", 1)], new Dictionary<string, string> { ["gift"] = "yes" });
// True, even though no two objects in the graphs are the same instance,
// and even though Order does not override Equals.
Console.WriteLine(comparer.Equals(left, right));
Console.WriteLine(comparer.GetHashCode(left) == comparer.GetHashCode(right));
}
The comparer is an ordinary IEqualityComparer<T>, so it drops straight into
Dictionary<TKey, TValue>, HashSet<T>, Enumerable.Distinct and friends:
internal static void UseAsCollectionComparer()
{
HashSet<Order> orders = new(StructuralEqualityComparer.Create<Order>());
orders.Add(new("A-1", [new("widget", 2)], null));
orders.Add(new("A-1", [new("widget", 2)], null));
// 1: the second order is a duplicate by value.
Console.WriteLine(orders.Count);
}
Overloads exist for the shape-witness patterns PolyType supports:
IEqualityComparer<Person> a = StructuralEqualityComparer.Create<Person>(); // Person : IShapeable<Person>
IEqualityComparer<Person> b = StructuralEqualityComparer.Create<Person, Witness>(); // external witness
IEqualityComparer<Person> c = StructuralEqualityComparer.Create(someTypeShape); // an ITypeShape<Person> you already have
Comparers are built once per type per
StructuralEqualityComparerProvider and cached, so hold onto
the comparer (or the provider) rather than calling Create in a loop.
What is compared
Every shape kind the serializer understands is supported:
| Shape | Semantics |
|---|---|
| Primitives and other leaf types | The type's own IEqualityComparer<T>.Default (string is ordinal). |
| Objects and constructor-backed contracts | Every readable property and field of the shape, compared structurally. |
| Sequences (lists, arrays, spans, ...) | Element-wise, in order, with matching lengths. |
Sets (IsSetType shapes) |
Unordered multiset comparison. |
| Dictionaries (string and non-string keys) | Unordered; each key/value pair must have a structurally equal partner. |
| Rectangular (multidimensional) arrays | Rank, per-dimension lengths, then row-major elements, so a 2×3 array never equals a 3×2 array. |
| Enums | Underlying value. |
Optionals (T?, Option<T>, ...) |
"Has value" flags must match; values compared structurally. |
| Unions | The selected case must match, then the case value is compared structurally. |
| Surrogates | Both sides are projected through the surrogate marshaler and the surrogate is compared. Only state the surrogate carries participates in equality. |
ShapeShiftValue |
Structural: maps are order-independent, arrays element-wise, binary by byte content. |
byte[] and ReadOnlyMemory<byte> |
By content, not by reference. |
null equals null, and null never equals a non-null value.
GetHashCode(null) is 0.
Dictionaries ignore their own comparer
A dictionary's configured IEqualityComparer<T>
is not consulted. The structural key comparer is authoritative, so a
Dictionary<string, int> created with StringComparer.OrdinalIgnoreCase and
holding "A" is not structurally equal to one holding "a". This keeps
equality a property of the data, matching what round-tripping through the
serializer would produce; the comparer a particular in-memory instance happens
to use is not part of the payload.
If you do want case-insensitive keys, override the comparer for string
(see custom comparers); the override applies everywhere
string appears, including dictionary keys.
Converters do not participate
Custom converters registered with a serializer, and [UseComparer], change how
values are written, not what they mean. They are deliberately ignored here so
that equality does not depend on which serializer instance you happen to have
configured. Use WithComparer<T> when you need to influence equality.
Cycles and shared references
Object graphs may contain cycles and shared subgraphs. Both are handled, and comparison always terminates.
internal static void CompareCyclicGraphs()
{
IEqualityComparer<Employee> comparer = StructuralEqualityComparer.Create<Employee>();
Employee soleFounder = new("Ada");
soleFounder.Manager = soleFounder;
Employee alice = new("Ada");
Employee bob = new("Ada");
alice.Manager = bob;
bob.Manager = alice;
// True: both graphs unfold to the same infinite sequence of "Ada"s.
// Equality describes the value a graph denotes, not the shape of the graph.
Console.WriteLine(comparer.Equals(soleFounder, alice));
}
Equality is value equivalence, not graph topology
The comparer answers "do these two graphs denote the same value?", not "do these two graphs have the same shape?". Concretely:
- A self-loop is equal to an equivalent two-node cycle, as in the sample above.
- A node that is shared by two parents is equal to two separate but equal nodes.
This is a bisimulation: while comparing (x, y) the comparer optimistically
assumes x == y, and only a concrete mismatch somewhere in the unfolding can
disprove it. Because any mismatch aborts the whole comparison, the optimistic
assumption is sound. It also memoizes acyclic sharing, so comparing a wide DAG
is linear rather than exponential.
If you need reference topology to matter, compare GetHashCode(Object) identity yourself; that is a different question than the one this API answers.
Hash codes of cyclic graphs
GetHashCode unfolds the graph as a tree, memoizing by reference identity so
shared subgraphs are hashed once. When a cycle is encountered — that is,
when an object is reached that is already on the current path — the entire
hash code collapses to a single fixed constant.
This satisfies the IEqualityComparer<T> contract: equal objects still produce
equal hash codes, because a cyclic graph is never structurally equal to an
acyclic one, so all graphs sharing that constant are exactly the graphs that
could be equal to each other. The practical consequence is that cyclic graphs
all land in one hash bucket. If cyclic keys are common and performance matters,
use a comparer keyed on something cheaper.
Custom comparers
StructuralEqualityComparerProvider is an immutable record.
WithComparer<T> returns a new provider in which occurrences of T —
anywhere in any graph, at any depth — use the comparer you supply instead
of being decomposed structurally:
internal static void OverrideAMemberComparer()
{
IEqualityComparer<Order> caseInsensitive = StructuralEqualityComparerProvider.Default
.WithComparer(StringComparer.OrdinalIgnoreCase)
.GetComparer<Order>();
Order left = new("a-1", [], null);
Order right = new("A-1", [], null);
Console.WriteLine(caseInsensitive.Equals(left, right)); // True
Console.WriteLine(StructuralEqualityComparer.Create<Order>().Equals(left, right)); // False
}
The supplied comparer's own GetHashCode is used, then run through the
provider's hashing policy, so an override composes correctly with
collision-resistant hashing.
Deterministic versus collision-resistant hashing
By default hashing is deterministic: the same graph produces the same hash code in every process and on every run, on any platform, for the same version of this library. That makes hash codes usable in tests, in logs, and as cheap change-detection fingerprints.
Determinism has a cost: an attacker who can choose your keys can compute colliding keys offline and degrade a hash table to a linear scan. When keys come from an untrusted source, opt in to collision-resistant hashing:
internal static void HashUntrustedInput()
{
// Randomly keyed per process: use it for hash based collections whose keys
// come from an untrusted source. Never persist these hash codes.
IEqualityComparer<Order> comparer = StructuralEqualityComparer.CreateCollisionResistant<Order>();
Dictionary<Order, int> counts = new(comparer);
counts[new("A-1", [], null)] = 1;
Console.WriteLine(counts.ContainsKey(new("A-1", [], null))); // True
}
Collision-resistant comparers hash strings, byte sequences and leaf hash codes with SipHash-2-4 under a 128-bit key drawn from a cryptographic RNG once per process.
Caution
Caveats of collision-resistant hashing:
- Hash codes differ between processes and between runs. Never persist them, send them across a wire, or compare them across process boundaries.
- It is a hash-flooding mitigation, not a MAC and not a cryptographic digest of your data. Do not use it for authentication or integrity.
- It is slower than the deterministic policy.
- Equality semantics are unaffected: a collision-resistant comparer and a
deterministic comparer always agree on
Equals.
Both policies are available as cached singletons:
Default and
CollisionResistant, and
the setting is also exposed as the UseCollisionResistantHashing init property
so it can be combined with WithComparer<T>.
Thread safety
Providers and the comparers they produce are immutable and safe for concurrent use. Comparison state (the assumption set used for cycles) lives on the stack of the call, so a single comparer instance can serve any number of threads.