1. Introduction
In programming, we're always comparing stuff: numbers, strings, objects. But what does "equality" of objects in C# actually mean? It's not always as obvious as it seems. Today, we're gonna break down two key methods that define how objects are compared: Equals() and GetHashCode(). Understanding these methods is super important for your programs to work right, especially when you're using collections like Dictionary or HashSet.
There are two main types of equality in C#:
Reference Equality:Means that two reference type variables point to the exact same object in memory. This is checked with the == operator for reference types.
MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;
Console.WriteLine(obj1 == obj2); // false (different objects in memory)
Console.WriteLine(obj1 == obj3); // true (both references point to the same object)
Value Equality:
Means that two different objects (or two value types) have the same content (values of their fields/properties). That's what you usually want when comparing objects. For this, you use the Equals() method.
// Let's say we have a Point class
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);
// p1 and p2 are different objects, but we want them to be "equal" by value
Console.WriteLine(p1.Equals(p2)); // ? Depends on Equals() implementation
Console.WriteLine(p1.Equals(p3)); // ?
2. The Equals() Method
The Equals() method is defined in the base class System.Object, which all types in C# inherit from. Its main job is to determine if two objects are equal by value.
Default Equals() Behavior
For value types (struct, int, bool, etc.): The default Equals() implementation (inherited from System.ValueType) does a bitwise comparison of all fields. If all fields are equal, the objects are considered equal. This usually works as you'd expect.
For reference types (class, string, array, etc.): The default Equals() implementation (inherited from System.Object) checks reference equality. So obj1.Equals(obj2) by default will return true only if obj1 and obj2 point to the same object in memory.
class Person // Reference type
{
public string Name { get; set; }
public int Age { get; set; }
}
Person person1 = new Person { Name = "Alice", Age = 30 };
Person person2 = new Person { Name = "Alice", Age = 30 }; // Different object, but same content
Console.WriteLine(person1.Equals(person2)); // false (by default compares references)
As you can see, the default behavior for reference types is often not what we want! We want two "Alices" with age 30 to be considered equal, even if they're different objects in memory.
Overriding Equals() for Custom Classes
To get value equality for your own classes, you have to override the Equals() method.
Rules for overriding Equals() (the contract):
- Reflexivity: x.Equals(x) is always true.
- Symmetry: If x.Equals(y) is true, then y.Equals(x) should also be true.
- Transitivity: If x.Equals(y) and y.Equals(z) are both true, then x.Equals(z) should also be true.
- Consistency: Multiple calls to x.Equals(y) should give the same result as long as the objects haven't changed.
- Null compatibility: x.Equals(null) is always false.
Example: Overriding Equals() for the Person class
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
// Overriding Equals()
public override bool Equals(object? obj)
{
// 1. Check for null
if (obj == null) return false;
// 2. Check for same type
if (obj.GetType() != this.GetType()) return false;
// 3. Type cast
Person other = (Person)obj;
// 4. Compare fields by value
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
}
// Usage:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);
Console.WriteLine(person1.Equals(person2)); // true (now compares by value!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null)); // false
3. The GetHashCode() Method
The GetHashCode() method is also defined in System.Object. It returns an integer value (hash code) that quickly and (as much as possible) uniquely identifies an object.
What is GetHashCode() for?
Hash codes are used to optimize working with hash-based collections. These collections include:
- Dictionary<TKey, TValue> (hash code is used for fast key lookup)
- HashSet<T> (hash code is used to check element uniqueness)
- Hashtable
When you add an object to a HashSet or use it as a key in a Dictionary, the collection first calculates the object's hash code. This lets it instantly "jump" to a certain "bucket" or group of elements with the same hash code, instead of looping through everything. Then, inside that "bucket", it uses the Equals() method for an exact comparison.
Rules for overriding GetHashCode() (the contract):
If you override Equals(), you MUST also override GetHashCode()! This is one of the most important rules in C#.
- Consistency: If Equals() returns true for two objects, then GetHashCode() for those objects must return the same value. (The reverse isn't true: different objects can have the same hash code — that's called a "collision".)
- Stability: GetHashCode() should return the same value for the same object as long as the fields used in comparison haven't changed.
- Speed: GetHashCode() should be fast and not require heavy computation.
Why is this so important? If you override Equals() but not GetHashCode(), your collections (especially hash collections) will work wrong:
- Dictionary won't be able to find your key.
- HashSet will add duplicates because it'll think they're unique.
This happens because by default, GetHashCode() returns a hash based on the object's reference (for reference types). If Equals() now compares by value, then objects with the same value but different references will have different hash codes, and the collection won't "see" them as the same.
Overriding GetHashCode() for the Person class
Good practice is to generate the hash code based on the same fields you use in Equals(). .NET gives you a static method HashCode.Combine() that's super handy for this.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != this.GetType()) return false;
Person other = (Person)obj;
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
// Overriding GetHashCode()
public override int GetHashCode()
{
// Use HashCode.Combine to combine field hashes.
return HashCode.Combine(Name.ToLowerInvariant(), Age);
}
}
// Using in a collection:
public class Program
{
public static void Main(string[] args)
{
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = new Person("Bob", 25);
HashSet<Person> uniquePeople = new HashSet<Person>();
uniquePeople.Add(p1);
uniquePeople.Add(p2); // p2 is considered equal to p1 by value, won't be added
Console.WriteLine($"Number of unique people: {uniquePeople.Count}"); // Output: 1
uniquePeople.Add(p3);
Console.WriteLine($"Number of unique people: {uniquePeople.Count}"); // Output: 2
}
}
Important note: In GetHashCode() for string fields that are compared case-insensitively (StringComparison.OrdinalIgnoreCase in Equals), you should get the hash code in a way that's also case-insensitive (like converting to lower case before hashing, as in Name.ToLowerInvariant()). Otherwise, Equals() will return true (Alice == alice), but GetHashCode() will return different values, breaking the contract.
4. Overloading the == and != Operators
For classes (reference types), the == operator by default checks reference equality. You can overload it to check value equality, just like Equals().
Rules for overloading ==:
- If you overload ==, you must also overload !=.
- It's recommended that the overloaded == behaves the same as Equals().
- You also need to override GetHashCode() and Equals() when overloading ==.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
// Constructor, Equals, GetHashCode as before
// Overloading the == operator
public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, null)) // Check if left is null
{
return ReferenceEquals(right, null); // If both are null, they're equal
}
return left.Equals(right); // Otherwise use our overridden Equals()
}
// Overloading the != operator (required when overloading ==)
public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}
}
// Usage:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;
Console.WriteLine(p1 == p2); // true (now uses overloaded ==)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true
5. record — Automatic Equality
Starting with C# 9, there's a type called record. It's a reference type, but it automatically implements value equality (and overrides Equals(), GetHashCode(), ToString(), and the ==/!= operators) based on all its fields/properties. This makes record perfect for immutable data objects.
public record PersonRecord(string Name, int Age);
// Usage:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);
Console.WriteLine(r1 == r2); // true (automatic value comparison!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false
record makes life way easier when you want value-type behavior for a reference type.
6. Recommendations
If you override Equals(), always override GetHashCode() too! Breaking this rule leads to unpredictable behavior in hash collections.
Equals() and GetHashCode() should use the same fields. The fields that make objects "equal" by value should be used to compute the hash.
Be careful with mutable types. If the fields used in Equals() and GetHashCode() can change after the object is created, the object's hash code can change. That's really bad for hash collections, because the object can get "lost" after changing (its hash code changes, and the collection can't find it in its "bucket" anymore). For hash collections, it's better to use immutable types as keys or elements.
For immutable data objects, consider using record. This makes implementing value equality way easier.
Only overload == and != for reference types when it makes sense. For value types, == already compares by value. If you do overload, make sure the behavior matches Equals().
GO TO FULL VERSION