Equality in C#

2020-03-04

In this article I’m going to discuss the notion of equality in the C# programming language. In particular, I’m going to show you how it’s very difficult to correctly implement comparisons of objects in the presence of inheritance. This will lead up to the conclusion that it’s usually best to stick to implementing equality comparisons only for value types (i.e. structs) or classes with value-like semantics (i.e. sealed classes, usually with immutable fields). These conclusions cover all comparisons which are implemented by extending a struct or class such as:

Comparisons are often best left to the various mechanisms offered elsewhere in the .NET Framework such as:

A quick note before you proceed: make sure you understand why the following program behaves the way it does:

using System;
namespace QuickQuiz
{
interface IFoo
{
void SomeMethod();
}
class Base : IFoo
{
public virtual void SomeMethod()
{
Console.WriteLine("Base.SomeMethod");
}
}
class Derived1 : Base
{
public void SomeMethod()
{
Console.WriteLine("Derived1.SomeMethod");
}
}
class Derived2 : Base
{
public override void SomeMethod()
{
Console.WriteLine("Derived2.SomeMethod");
}
}
class MainClass
{
public static void Main(string[] args)
{
// Outputs "Base.SomeMethod"
((IFoo)new Base()).SomeMethod();
// Outputs "Base.SomeMethod"
((IFoo)new Derived1()).SomeMethod();
// Outputs "Derived2.SomeMethod"
((IFoo)new Derived2()).SomeMethod();
}
}
}
view raw QuickQuiz.cs hosted with ❤ by GitHub

Now on to the discussion proper. Caveat: My code probably has subtle bugs in it. This is kind of the point.

Here’s our base class, imaginatively named BaseClass:

using System;
/// <summary>
/// "Equality in C#"
/// Demonstrates the difficulties associated with implementing equality
/// in the presence of inheritance
///
/// It is usually better (and always less to error-prone) to implement
/// Plain Old Data (POD) types without inheritance (either structs or
/// sealed classes): in the case of PODs, you'll only need to implement
/// non-virtual System.IEquatable&lt;Self&gt;.Equals,
/// System.Object.Equals and System.Object.GetHashCode methods and you
/// can sidestep the issues with object slicing etc. Note that a correct
/// implementation of these methods will eliminate any performance
/// improvement that a developer would expect to gain from implementing
/// System.IEquatable&lt;Self&gt;.Equals since this method _must_ be
/// virtual to work correctly.
/// </summary>
namespace EqualityInCSharp.BestPractices
{
// Generally classes implementing System.IEquatable<Self> should be
// sealed. However, with some effort, you can make a more-or-less
// sane implementation of System.IEquatable<Self>.Equals if it
// always matches the comparison behaviour of System.Object.Equals
// (via delegation etc.).
public class BaseClass : IEquatable<BaseClass>
{
public int IntValue { get; }
public BaseClass(int intValue)
{
IntValue = intValue;
}
// System.IEquatable<Self>.Equals should always be virtual and
// delegate to System.Object.Equals to ensure consistent
// comparisons: like System.Object.Equals this should be a
// strict (non-slicing) comparison.
public virtual bool Equals(BaseClass other)
{
return Equals((object)other);
}
// We must override System.Object.Equals if we're implementing
// System.IEquatable<Self>.Equals(Self).
public override bool Equals(object obj)
{
// Strict (non-slicing) comparison: for two objects to
// compare as equal they must be of _exactly_ the same type.
return !ReferenceEquals(null, obj)
&& obj.GetType() == typeof(BaseClass)
&& SlicingEquals(obj);
}
// Must override System.Object.GetHashCode if we're overriding
// System.Object.Equals.
public override int GetHashCode()
{
// Hash code should be a function of all the class's value
// fields.
return HashCode.Combine(IntValue);
}
// Special helper function to consolidate field-wise comparison
// in one place. Allows comparison of objects assignable to
// BaseClass (including derived classes).
protected virtual bool SlicingEquals(object obj)
{
if (ReferenceEquals(null, obj))
{
// null always compares as not equal.
return false;
}
if (ReferenceEquals(this, obj))
{
// A small performance optimization.
return true;
}
// Slice object to BaseClass.
var other = obj as BaseClass;
if (other == null)
{
// It's not assignable to BaseClass, therefore it cannot
// be equal.
return false;
}
// Actual comparison of BaseClass's fields.
return IntValue == other.IntValue;
}
}
}
view raw BaseClass.cs hosted with ❤ by GitHub

We’ve decided, for whatever reason, that BaseClass is to be value-like:

Other features of note:

Most discussions of IEquatable<T> state that this interface is provided for more strongly-typed comparisons as well as to allow a more efficient implementation. While it can achieve the former, it cannot always achieve the latter. If the type T is a struct, then we certainly can provide a more efficient implementation than the one implemented for Equals(object) that is generated for us by the C# compiler (which performs the comparison by reflecting over the type T to enumerate all of its fields—I need to verify this claim at some point). However, providing a custom behaviour for Equals(T) without providing a consistent implementation for Equals(object) will break comparisons. Weird behaviour will ensue if objects are ever compared via virtual Equals(object) calls instead of static Equals(T) calls: you wouldn’t want to live in a world where 123.Equals(123) yielded a different result from ((object)123).Equals((object)123). In practice, therefore, you’ll want to delegate your Equals(T) call to your Equals(object) implementation so that they are guaranteed to be equivalent.

In the case of a class like this which attempts to handle inheritance, no performance enhancement is gained by implementing IEquatable<T> since all comparison methods must dynamically dispatch via virtual method calls.

Here’s a derived class named DerivedClass:

using System;
/// <summary>
/// "Equality in C#"
/// Demonstrates the difficulties associated with implementing equality
/// in the presence of inheritance
///
/// It is usually better (and always less to error-prone) to implement
/// Plain Old Data (POD) types without inheritance (either structs or
/// sealed classes): in the case of PODs, you'll only need to implement
/// non-virtual System.IEquatable&lt;Self&gt;.Equals,
/// System.Object.Equals and System.Object.GetHashCode methods and you
/// can sidestep the issues with object slicing etc. Note that a correct
/// implementation of these methods will eliminate any performance
/// improvement that a developer would expect to gain from implementing
/// System.IEquatable&lt;Self&gt;.Equals since this method _must_ be
/// virtual to work correctly.
/// </summary>
namespace EqualityInCSharp.BestPractices
{
public class DerivedClass : BaseClass, IEquatable<DerivedClass>
{
public string StringValue { get; }
public DerivedClass(int intValue, string stringValue)
: base(intValue)
{
StringValue = stringValue;
}
// System.IEquatable<Self>.Equals should always be virtual and
// delegate to System.Object.Equals to ensure consistent
// comparisons: like System.Object.Equals this should be a
// strict (non-slicing) comparison.
public virtual bool Equals(DerivedClass other)
{
return Equals((object)other);
}
// Must override System.IEquatable<BaseClass> to avoid a
// non-strict (slicing) comparison.
public override bool Equals(BaseClass other)
{
return Equals((object)other);
}
// We must override System.Object.Equals for two reasons: (1)
// we're implementing System.IEquatable<Self>.Equals(Self) and
// (2) we want to maintain sensible System.Object.Equals
// semantics since we want to treat DerivedClass as if it
// represents a value.
public override bool Equals(object obj)
{
// Strict (non-slicing) comparison: for two objects to
// compare as equal they must be of _exactly_ the same type.
return !ReferenceEquals(null, obj)
&& obj.GetType() == typeof(DerivedClass)
&& SlicingEquals(obj);
}
// Must override System.Object.GetHashCode if we're overriding
// System.Object.Equals.
public override int GetHashCode()
{
// Hash code should be a function of all the class's value
// fields including those in base types.
return HashCode.Combine(base.GetHashCode(), StringValue);
}
// Allow comparison of objects assignable to DerivedClass
// (including derived classes).
protected override bool SlicingEquals(object obj)
{
if (ReferenceEquals(null, obj))
{
// null always compares as not equal.
return false;
}
if (ReferenceEquals(this, obj))
{
// A small performance optimization.
return true;
}
// Slice object to DerivedClass.
var other = obj as DerivedClass;
if (other == null)
{
// It's not assignable to DerivedClass, therefore it
// cannot be equal.
return false;
}
// Actual comparison of DerivedClass's fields and fields
// from base classes.
return StringValue.Equals(other.StringValue)
&& base.SlicingEquals(other);
}
}
}
view raw DerivedClass.cs hosted with ❤ by GitHub

We’ve decided, for whatever reason, that DerivedClass is also to be value-like:

Of note:

Pretty ugly, huh?

To drive the point home, let’s define another class, MoreDerivedClass, that derives from DerivedClass:

using System;
/// <summary>
/// "Equality in C#"
/// Demonstrates the difficulties associated with implementing equality
/// in the presence of inheritance
///
/// It is usually better (and always less to error-prone) to implement
/// Plain Old Data (POD) types without inheritance (either structs or
/// sealed classes): in the case of PODs, you'll only need to implement
/// non-virtual System.IEquatable&lt;Self&gt;.Equals,
/// System.Object.Equals and System.Object.GetHashCode methods and you
/// can sidestep the issues with object slicing etc. Note that a correct
/// implementation of these methods will eliminate any performance
/// improvement that a developer would expect to gain from implementing
/// System.IEquatable&lt;Self&gt;.Equals since this method _must_ be
/// virtual to work correctly.
/// </summary>
namespace EqualityInCSharp.BestPractices
{
public class MoreDerivedClass : DerivedClass, IEquatable<MoreDerivedClass>
{
public bool BoolValue { get; }
public MoreDerivedClass(
int intValue,
string stringValue,
bool boolValue) : base(intValue, stringValue)
{
BoolValue = boolValue;
}
// System.IEquatable<Self>.Equals should always be virtual and
// delegate to System.Object.Equals to ensure consistent
// comparisons: like System.Object.Equals this should be a
// strict (non-slicing) comparison.
public virtual bool Equals(MoreDerivedClass other)
{
return Equals((object)other);
}
// Must override System.IEquatable<DerivedClass> to avoid a
// non-strict (slicing) comparison.
public override bool Equals(DerivedClass other)
{
return Equals((object)other);
}
// Must override System.IEquatable<BaseClass> to avoid a
// non-strict (slicing) comparison.
public override bool Equals(BaseClass other)
{
return Equals((object)other);
}
// We must override System.Object.Equals for two reasons: (1)
// we're implementing System.IEquatable<Self>.Equals(Self) and
// (2) we want to maintain sensible System.Object.Equals
// semantics since we want to treat MoreDerivedClass as if it
// represents a value.
public override bool Equals(object obj)
{
// Strict (non-slicing) comparison: for two objects to
// compare as equal they must be of _exactly_ the same type.
return !ReferenceEquals(null, obj)
&& obj.GetType() == typeof(MoreDerivedClass)
&& SlicingEquals(obj);
}
// Must override System.Object.GetHashCode if we're overriding
// System.Object.Equals.
public override int GetHashCode()
{
// Hash code should be a function of all the class's value
// fields including those in base types.
return HashCode.Combine(base.GetHashCode(), BoolValue);
}
// Allow comparison of objects assignable to MoreDerivedClass
// (including derived classes).
protected override bool SlicingEquals(object obj)
{
if (ReferenceEquals(null, obj))
{
// null always compares as not equal.
return false;
}
if (ReferenceEquals(this, obj))
{
// A small performance optimization.
return true;
}
// Slice object to MoreDerivedClass.
var other = obj as MoreDerivedClass;
if (other == null)
{
// It's not assignable to MoreDerivedClass, therefore
// it cannot be equal.
return false;
}
// Actual comparison of MoreDerivedClass's fields and fields
// from base classes.
return BoolValue.Equals(other.BoolValue)
&& base.SlicingEquals(other);
}
}
}

We’ve decided that MoreDerivedClass like its ascendants is also to be value-like:

Well, this all sucks. Here are some tests:

using EqualityInCSharp.BestPractices;
using System;
namespace EqualityInCSharp
{
public static class Program
{
public static void Main(string[] args)
{
TestDerivedClass();
TestMoreDerivedClass();
}
private static void TestDerivedClass()
{
var d0 = new DerivedClass(123, "hello");
var d1 = new DerivedClass(123, "goodbye");
var d2 = new DerivedClass(123, "goodbye");
Assert("Objects with different values should not compare as equal [System.Object.Equals(System.Object)]",
!((object)d0).Equals((object)d1));
Assert("Objects with different values should not compare as equal [System.Object.Equals(BaseClass)]",
!((object)d0).Equals((BaseClass)d1));
Assert("Objects with different values should not compare as equal [System.Object.Equals(DerivedClass)]",
!((object)d0).Equals((DerivedClass)d1));
Assert("Objects with different values should not compare as equal [BaseClass.Equals(System.Object)]",
!((BaseClass)d0).Equals((object)d1));
Assert("Objects with different values should not compare as equal [BaseClass.Equals(BaseClass)]",
!((BaseClass)d0).Equals((BaseClass)d1));
Assert("Objects with different values should not compare as equal [BaseClass.Equals(DerivedClass)]",
!((BaseClass)d0).Equals((DerivedClass)d1));
Assert("Objects with different values should not compare as equal [DerivedClass.Equals(System.Object)]",
!((DerivedClass)d0).Equals((object)d1));
Assert("Objects with different values should not compare as equal [DerivedClass.Equals(BaseClass)]",
!((DerivedClass)d0).Equals((BaseClass)d1));
Assert("Objects with different values should not compare as equal [DerivedClass.Equals(DerivedClass)]",
!((DerivedClass)d0).Equals((DerivedClass)d1));
Assert("Objects with same values should compare as equal [System.Object.Equals(System.Object)]",
((object)d2).Equals((object)d1));
Assert("Objects with same values should compare as equal [System.Object.Equals(BaseClass)]",
((object)d2).Equals((BaseClass)d1));
Assert("Objects with same values should compare as equal [System.Object.Equals(DerivedClass)]",
((object)d2).Equals((DerivedClass)d1));
Assert("Objects with same values should compare as equal [BaseClass.Equals(System.Object)]",
((BaseClass)d2).Equals((object)d1));
Assert("Objects with same values should compare as equal [BaseClass.Equals(BaseClass)]",
((BaseClass)d2).Equals((BaseClass)d1));
Assert("Objects with same values should compare as equal [BaseClass.Equals(DerivedClass)]",
((BaseClass)d2).Equals((DerivedClass)d1));
Assert("Objects with same values should compare as equal [DerivedClass.Equals(System.Object)]",
((DerivedClass)d2).Equals((object)d1));
Assert("Objects with same values should compare as equal [DerivedClass.Equals(BaseClass)]",
((DerivedClass)d2).Equals((BaseClass)d1));
Assert("Objects with same values should compare as equal [DerivedClass.Equals(DerivedClass)]",
((DerivedClass)d2).Equals((DerivedClass)d1));
}
private static void TestMoreDerivedClass()
{
var m0 = new MoreDerivedClass(123, "hello", false);
var m1 = new MoreDerivedClass(123, "hello", true);
var m2 = new MoreDerivedClass(123, "hello", true);
Assert("Objects with different values should not compare as equal [System.Object.Equals(System.Object)]",
!((object)m0).Equals((object)m1));
Assert("Objects with different values should not compare as equal [System.Object.Equals(BaseClass)]",
!((object)m0).Equals((BaseClass)m1));
Assert("Objects with different values should not compare as equal [System.Object.Equals(MoreDerivedClass)]",
!((object)m0).Equals((MoreDerivedClass)m1));
Assert("Objects with different values should not compare as equal [BaseClass.Equals(System.Object)]",
!((BaseClass)m0).Equals((object)m1));
Assert("Objects with different values should not compare as equal [BaseClass.Equals(BaseClass)]",
!((BaseClass)m0).Equals((BaseClass)m1));
Assert("Objects with different values should not compare as equal [BaseClass.Equals(MoreDerivedClass)]",
!((BaseClass)m0).Equals((MoreDerivedClass)m1));
Assert("Objects with different values should not compare as equal [MoreDerivedClass.Equals(System.Object)]",
!((MoreDerivedClass)m0).Equals((object)m1));
Assert("Objects with different values should not compare as equal [MoreDerivedClass.Equals(BaseClass)]",
!((MoreDerivedClass)m0).Equals((BaseClass)m1));
Assert("Objects with different values should not compare as equal [MoreDerivedClass.Equals(MoreDerivedClass)]",
!((MoreDerivedClass)m0).Equals((MoreDerivedClass)m1));
Assert("Objects with same values should compare as equal [System.Object.Equals(System.Object)]",
((object)m2).Equals((object)m1));
Assert("Objects with same values should compare as equal [System.Object.Equals(BaseClass)]",
((object)m2).Equals((BaseClass)m1));
Assert("Objects with same values should compare as equal [System.Object.Equals(MoreDerivedClass)]",
((object)m2).Equals((MoreDerivedClass)m1));
Assert("Objects with same values should compare as equal [BaseClass.Equals(System.Object)]",
((BaseClass)m2).Equals((object)m1));
Assert("Objects with same values should compare as equal [BaseClass.Equals(BaseClass)]",
((BaseClass)m2).Equals((BaseClass)m1));
Assert("Objects with same values should compare as equal [BaseClass.Equals(MoreDerivedClass)]",
((BaseClass)m2).Equals((MoreDerivedClass)m1));
Assert("Objects with same values should compare as equal [MoreDerivedClass.Equals(System.Object)]",
((MoreDerivedClass)m2).Equals((object)m1));
Assert("Objects with same values should compare as equal [MoreDerivedClass.Equals(BaseClass)]",
((MoreDerivedClass)m2).Equals((BaseClass)m1));
Assert("Objects with same values should compare as equal [MoreDerivedClass.Equals(MoreDerivedClass)]",
((MoreDerivedClass)m2).Equals((MoreDerivedClass)m1));
}
private static void Assert(string message, bool condition)
{
if (condition)
{
Console.WriteLine("Assert passed: {0}", message);
}
else
{
throw new ApplicationException(String.Format("Assert {0} failed", message));
}
}
}
}
view raw Program.cs hosted with ❤ by GitHub

Note that these tests are by no means exhaustive: it covers only a subset of the various Equals overrides and does not test GetHashCode at all.

This is a lot of work and any given implementation is likely to contain subtle bugs. Some of this complexity and bugginess could be eliminated through the use of code generation since each of these overload methods is more-or-less mechanically generatable. This is roughly what @EqualsAndHashCode in Lombok does. Writing this code by hand is generally tedious and pointless. Life is simpler if we stick to only comparing value or properly value-like types. Doing this kind of work requires that the developer spend some time thinking about why he or she is contemplating it. Often it will be to take advantage of language or framework features such as hash tables and sets etc. There will often require a value-like object as key and developers will be tempted to slap an Equals or IComparable on a class to make this work. This is usually the Wrong Thing to do. A more principled approach to designing systems like this will be to try to determine what the key in such a system is and then probably implement around that: these keys will typically be stable, immutable values or value-like objects.

I’ll probably talk more on this subject in the future. Bye for now!

Update 2020-03-12

Here’s an interesting, related project from my co-worker Jay Bazuzi: ValueTypeAssertions.

Related posts

Example of a serializable exception in C#

Tags

C#
Programming

Content © 2025 Richard Cook. All rights reserved.