The Complete Guide to Equality in C#
The Complete Guide to Equality in C#
Equality in C# is not a single feature. It is a layered system involving:
- Operator overloading
- Virtual methods
- Static type binding
- Runtime type dispatch
- Structural comparison
- Hash code contracts
- Generic equality mechanisms
- Pattern matching behavior
To truly understand equality, you must separate compile-time behavior from runtime behavior.
This guide documents equality behavior across:
- Primitive types
- string
- class
- record
- Tuple
- ValueTuple
- Arrays
- List
- Dictionary<TKey, TValue>
- IEquatable
- IEqualityComparer
- IStructuralEquatable
- HashSet and Dictionary internals
- Pattern matching and null checks
1. The Three Equality Mechanisms
C# provides three core equality mechanisms:
==operator.Equals()methodReferenceEquals()method
Each behaves differently.
2. ReferenceEquals — Pure Identity
Always checks whether two references point to the same object in memory.
var a = new object();
var b = new object();
Console.WriteLine(object.ReferenceEquals(a, b)); // False
Cannot be overridden. Always reference-based.
3. Equals() — Virtual Equality
Equals() is virtual and can be overridden.
var a = "test";
var b = new string("test".ToCharArray());
Console.WriteLine(a.Equals(b)); // True
Uses string’s overridden equality logic.
4. == Operator — Static Binding
The == operator is resolved at compile time.
Operator overload selection depends entirely on static types.
This is the single most important rule.
5. Primitive Types
int a = 5;
int b = 5;
Console.WriteLine(a == b); // True
Primitive types use value equality.
6. String Equality
string is a reference type with value-based equality.
string a = "test";
string b = new string("test".ToCharArray());
Console.WriteLine(a == b); // True
Console.WriteLine(ReferenceEquals(a,b)); // False
Why?
stringoverloads==- Compares characters
- Ignores reference identity
String Interning
String literals are interned.
string x = "test";
string y = "test";
Console.WriteLine(ReferenceEquals(x,y)); // True
But:
string x = new string("test".ToCharArray());
string y = "test";
Console.WriteLine(ReferenceEquals(x,y)); // False
new string() bypasses the intern pool.
Interning affects reference identity — not value equality.
Static Type Controls Operator Selection
object a = new string("test".ToCharArray());
string b = "test";
Console.WriteLine(a == b); // False
Because:
- Static types: object and string
- Compiler binds to
object.operator == - Reference comparison is performed
Force string equality:
Console.WriteLine((string)a == b); // True
7. Class Equality (Default Behavior)
Classes use reference equality unless overridden.
class Person
{
public string Name { get; set; }
}
var p1 = new Person { Name = "A" };
var p2 = new Person { Name = "A" };
Console.WriteLine(p1 == p2); // False
Console.WriteLine(p1.Equals(p2)); // False
Implementing Value Equality in Classes
class Person : IEquatable<Person>
{
public string Name { get; set; }
public bool Equals(Person other)
=> other is not null && Name == other.Name;
public override bool Equals(object obj)
=> Equals(obj as Person);
public override int GetHashCode()
=> Name?.GetHashCode() ?? 0;
}
If you override Equals, you must override GetHashCode.
8. Record Equality
Records provide value-based equality automatically.
record Person(string Name, int Age);
var p1 = new Person("A", 30);
var p2 = new Person("A", 30);
Console.WriteLine(p1 == p2); // True
Compiler generates:
EqualsGetHashCode==!=Deconstruct
Record Equality Is Type-Sensitive
record Person(string Name);
record Employee(string Name) : Person(Name);
Person p = new Person("A");
Person e = new Employee("A");
Console.WriteLine(p == e); // False
Runtime type must match.
Overriding Record Equality
record Person(string Name, int Age)
{
public virtual bool Equals(Person other)
=> other is not null && Name == other.Name;
public override int GetHashCode()
=> Name.GetHashCode();
}
You can customize equality, but must keep hash codes consistent.
9. Tuple vs ValueTuple
Tuple (Reference Type)
var t1 = Tuple.Create(1,2);
var t2 = Tuple.Create(1,2);
Console.WriteLine(t1 == t2); // False
Reference comparison.
ValueTuple (Struct)
var t1 = (1,2);
var t2 = (1,2);
Console.WriteLine(t1 == t2); // True
Structural comparison — field-by-field.
Boxing Changes Behavior
object o1 = (1,2);
object o2 = (1,2);
Console.WriteLine(o1 == o2); // False
Console.WriteLine(o1.Equals(o2)); // True
== uses reference equality after boxing.
10. Arrays and IStructuralEquatable
Arrays use reference equality by default.
int[] a1 = {1,2,3};
int[] a2 = {1,2,3};
Console.WriteLine(a1 == a2); // False
Structural Comparison
using System.Collections;
var comparer = StructuralComparisons.StructuralEqualityComparer;
Console.WriteLine(comparer.Equals(a1, a2)); // True
Arrays implement IStructuralEquatable.
11. List Equality
var l1 = new List<int>{1,2};
var l2 = new List<int>{1,2};
Console.WriteLine(l1 == l2); // False
Console.WriteLine(l1.Equals(l2)); // False
Structural comparison:
l1.SequenceEqual(l2); // True
12. Dictionary Equality
var d1 = new Dictionary<int,string>
{
{1,"A"},
{2,"B"}
};
var d2 = new Dictionary<int,string>
{
{1,"A"},
{2,"B"}
};
Console.WriteLine(d1 == d2); // False
Dictionaries compare keys using EqualityComparer<TKey>.Default.
Two dictionaries with same contents are not equal unless explicitly compared.
13. IEquatable
Provides strongly typed equality.
Used by:
- EqualityComparer
.Default - HashSet
- Dictionary
Example:
struct Point : IEquatable<Point>
{
public int X, Y;
public bool Equals(Point other)
=> X == other.X && Y == other.Y;
}
Avoids boxing and improves performance.
14. IEqualityComparer
Used to define external equality logic.
Example:
class PersonComparer : IEqualityComparer<Person>
{
public bool Equals(Person x, Person y)
=> x.Name == y.Name;
public int GetHashCode(Person obj)
=> obj.Name.GetHashCode();
}
Used in:
var set = new HashSet<Person>(new PersonComparer());
15. EqualityComparer.Default
Used internally by generic collections.
It:
- Uses IEquatable
if implemented - Falls back to overridden Equals
- Otherwise uses reference equality
16. HashSet / Dictionary Internals
Process:
- Compute hash code → choose bucket
- Compare using Equals within bucket
Rule:
If Equals returns true → GetHashCode must return same value.
Violation causes corrupted collections.
17. Pattern Matching and Equality
Pattern matching is not the same as ==.
object o = 5;
Console.WriteLine(o is 5); // True
Console.WriteLine(o == 5); // False
Pattern matching performs:
- Runtime type check
- Value comparison
Null Pattern
if (obj is null)
Safer than:
if (obj == null)
Because == can be overloaded.
18. Nullable Reference Types
Nullable annotations affect compile-time warnings only.
Runtime equality behavior does not change.
string? a = null;
string? b = null;
Console.WriteLine(a == b); // True
19. Performance Considerations
Reference equality → O(1) Structural equality → O(n)
Deep comparisons can be expensive.
Equality design impacts performance in large systems.
20. Complete Equality Summary
| Type | == Behavior | Equals | Default Nature |
|---|---|---|---|
| Primitive | Value | Value | Built-in |
| string | Value | Value | Overridden |
| class | Reference | Reference | Default |
| record | Value | Value | Generated |
| Tuple | Reference | Reference | Default |
| ValueTuple | Value | Value | Structural |
| Array | Reference | Reference | Default |
| List | Reference | Reference | Default |
| Dictionary | Reference | Reference | Default |
Final Mental Model
When evaluating equality in C#, always ask:
- What is the static type?
- Which operator is chosen?
- Is this reference or value comparison?
- Is hashing involved?
- Is boxing happening?
- Is structural comparison being used?
Once you separate:
- Compile-time binding
- Runtime dispatch
- Structural comparison
- Hashing contract
Equality becomes predictable.