To answer this, we must describe the four kinds of object equivalence:
Reference Equality, object.ReferenceEquals(a, b): The two variables point to the same exact object in RAM. (If this were C, both variables would have the same exact pointer.)
Interchangeability, a == b: The two variables refer to objects that are completely interchangeable. Thus, when a == b, Func(a,b) and Func(b,a) do the same thing.
Semantic Equality, object.Equals(a, b): At this exact moment in time, the two objects mean the same thing.
Entity equality, a.Id == b.Id: The two objects refer to the same entity, such as a database row, but don’t have to have the same contents.
As a programmer, when working with an object of a known type, you need to understand the kind of equivalence that’s appropriate for your business logic at the specific moment of code that you’re in.
The simplest example about this is the string versus StringBuilder types. String overrides ==, StringBuilder does not:
var aaa1 = "aaa";
var aaa2 = $"{'a'}{'a'}{'a'}";
var bbb = "bbb";
// False because aaa1 and aaa2 are completely different objects with different locations in RAM
Console.WriteLine($"Object.ReferenceEquals(aaa1, aaa2): {Object.ReferenceEquals(aaa1, aaa2)}");
// True because aaa1 and aaa2 are completely interchangable
Console.WriteLine($"aaa1 == aaa2: {aaa1 == aaa2}"); // True
Console.WriteLine($"aaa1.Equals(aaa2): {aaa1.Equals(aaa2)}"); // True
Console.WriteLine($"aaa1 == bbb: {aaa1 == bbb}"); // False
Console.WriteLine($"aaa1.Equals(bbb): {aaa1.Equals(bbb)}"); // False
// Won't compile
// This is why string can override ==, you can not modify a string object once it is allocated
//aaa1[0] = 'd';
// aaaUpdated and aaa1 point to the same exact object in RAM
var aaaUpdated = aaa1;
Console.WriteLine($"Object.ReferenceEquals(aaa1, aaaUpdated): {Object.ReferenceEquals(aaa1, aaaUpdated)}"); // True
// aaaUpdated is a new string, aaa1 is unmodified
aaaUpdated += 'c';
Console.WriteLine($"Object.ReferenceEquals(aaa1, aaaUpdated): {Object.ReferenceEquals(aaa1, aaaUpdated)}"); // False
var aaaBuilder1 = new StringBuilder("aaa");
var aaaBuilder2 = new StringBuilder("aaa");
// False, because both string builders are different objects
Console.WriteLine($"Object.ReferenceEquals(aaaBuider1, aaaBuider2): {Object.ReferenceEquals(aaa1, aaa2)}");
// Even though both string builders have the same contents, they are not interchangable
// Thus, == is false
Console.WriteLine($"aaaBuider1 == aaaBuilder2: {aaaBuilder1 == aaaBuilder2}");
// But, because they both have "aaa" at this exact moment in time, Equals returns true
Console.WriteLine($"aaaBuider1.Equals(aaaBuilder2): {aaaBuilder1.Equals(aaaBuilder2)}");
// Modifying the contents of the string builders changes the strings, and thus
// Equals returns false
aaaBuilder1.Append('e');
aaaBuilder2.Append('f');
Console.WriteLine($"aaaBuider1.Equals(aaaBuilder2): {aaaBuilder1.Equals(aaaBuilder2)}");
To get into more details, we can work backwards, starting with entity equality. In the case of entity equality, properties of the entity may change over time, but the entity’s primary key never changes. This can be demonstrated with pseudocode:
// Hold the current user object in a variable
var originalUser = database.GetUser(123);
// Update the user’s name
database.UpdateUserName(123, user.Name + "son");
var updatedUser = database.GetUser(123);
Console.WriteLine(originalUser.Id == updatedUser.Id); // True, both objects refer to the same entity
Console.WriteLine(Object.Equals(originalUser, updatedUser); // False, the name property is different
Moving to semantic equality, the example changes slightly:
var originalUser = new User() { Name = "George" };
var updatedUser = new User() { Name = "George" };
Console.WriteLine(Object.Equals(originalUser, updatedUser); // True, the objects have the same contents
Console.WriteLine(originalUser == updatedUser); // User doesn’t define ==, False
updatedUser.Name = "Paul";
Console.WriteLine(Object.Equals(originalUser, updatedUser); // False, the name property is different
What about interchangeability? (overriding ==) That’s more complicated. Let’s build on the above example a bit:
var originalUser = new User() { Name = "George" };
var updatedUser = new User() { Name = "George" };
Console.WriteLine(Object.Equals(originalUser, updatedUser); // True, the objects have the same contents
// Does this change updatedUser? We don’t know
DoSomethingWith(updatedUser);
// Are the following equivalent?
// SomeMethod(originalUser, updatedUser);
// SomeMethod(updatedUser, originalUser);
In the above example, DoSomethingWithUser(updatedUser) might change updatedUser. Thus we can no longer guarantee that the originalUser and updatedUser objects are "Equals." This is why User does not override ==.
A good example for when to override == is with immutable objects. An immutable object is an object who’s publicly-visible state (properties) never change. The entire visible state must be set in the object’s constructor. (Thus, all properties are read-only.)
var originalImmutableUser = new ImmutableUser(name: "George");
var secondImmutableUser = new ImmutableUser(name: "George");
Console.WriteLine(Object.Equals(originalImmutableUser, secondImmutableUser); // True, the objects have the same contents
Console.WriteLine(originalImmutableUser == secondImmutableUser); // ImmutableUser defines ==, True
// Won’t compile because ImmutableUser has no setters
secondImmutableUser.Name = "Paul";
// But this does compile
var updatedImmutableUser = secondImmutableUser.SetName("Paul"); // Returns a copy of secondImmutableUser with Name changed to Paul.
Console.WriteLine(object.ReferenceEquals(updatedImmutableUser, secondImmutableUser)); // False, because updatedImmutableUser is a different object in a different location in RAM
// These two calls are equivalent because the internal state of an ImmutableUser can never change
DoSomethingWith(originalImmutableUser, secondImmutableUser);
DoSomethingWith(secondImmutableUser, originalImmutableUser);
Should you override == with a mutable object? (That is, an object who’s internal state can change?) Probably not. You would need to build a rather complicated event system to maintain interchangeability.
In general, I work with a lot of code that uses immutable objects, so I override == because it’s more readable than object.Equals. When I work with mutable objects, I don’t override == and rely on object.Equals. Its the programmer’s responsibility to know if the objects they are working with are mutable or not, because knowing if something’s state can change should influence how you design your code.
The default implementation of == is object.ReferenceEquals because, with mutable objects, interchangeability is only guaranteed when the variables point to the same exact object in RAM. Even if the objects have the same contents at a given point in time, (Equals returns true,) there is no guarantee that the objects will continue to be equal; thus the objects are not interchangeable. Thus, when working with a mutable object that does not override ==, the default implementation of == works, because if a == b, they are the same object, and SomeFunc(a, b) and SomeFunc(b, a) are exactly the same.
Furthermore, if a class does not define equivalence, (For example, think of a database connection, and open file handle, ect,) then the default implementation of == and Equals fall back to reference equality, because two variables of type database connection, open file handle, ect, are only equal if they are the exact instance of the database connection, open file handle, ect. Entity equality might make sense in business logic that needs to know that two different database connections refer to the same database, or that two different file handles refer to the same file on disk.
Now, for my soapbox moment. In my opinion, C# handles this topic in a confusing way. == should be for semantic equality, instead of the Equals method. There should be a different operator, like ===, for interchangeability, and potentially another operator, ====, for referential equality. This way, someone who's a novice, and / or writing CRUD applications, only needs to understand ==, and not the more nuanced details of interchangeability and referential equality.