Home > Back-end >  C# Record Types: Equality comparisons between record sub-classes
C# Record Types: Equality comparisons between record sub-classes

Time:12-29

Given a parent record type:

public record Foo(string Value);

And two record sub-classes Bar and Bee I wonder if it is possible to implement Equals in the base class so as instances of Foo, Bar or Bee are all considered equal based on Value (both with Equals and ==).

I tried the following after digesting https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/records, but it didn't quite work:

    public record Foo(string Value)
    {
        public virtual bool Equals(Foo? other)
        {
            return other != null && this.Value == other.Value;
        }

        public override int GetHashCode() => this.Value.GetHashCode();
    }

    public record Bar(string Value) : Foo(Value)
    {
        protected override Type EqualityContract => typeof(Foo);
    }

    public record Bee(string Value) : Foo(Value)
    {
        protected override Type EqualityContract => typeof(Foo);
    }

    [Test]
    public void TestFooBar()
    {
        Assert.That(new Foo("foo") == new Bar("foo"), Is.True); // Passes
        Assert.That(new Bar("foo") == new Foo("foo"), Is.True); // Fails!
    }

    [Test]
    public void TestFooBee()
    {
        Assert.That(new Foo("foo") == new Bee("foo"), Is.True); // Passes
        Assert.That(new Bee("foo") == new Foo("foo"), Is.True); // Fails!
    }

    [Test]
    public void TestBarBee()
    {
        Assert.That(new Bar("foo") == new Bee("foo"), Is.True); // Fails!
        Assert.That(new Bee("foo") == new Bar("foo"), Is.True); // Fails!
    }

This question is specific to record types. I don't need an example with classes (I know that already).

When I look at sharplab.io, I see the following implementation for Bar:

    [System.Runtime.CompilerServices.NullableContext(2)]
    public override bool Equals(object obj)
    {
        return Equals(obj as Bar);
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public sealed override bool Equals(Foo other)
    {
        return Equals((object)other);
    }

And in Foo:

    [System.Runtime.CompilerServices.NullableContext(2)]
    public static bool operator ==(Foo left, Foo right)
    {
        if ((object)left != right)
        {
            if ((object)left != null)
            {
                return left.Equals(right);
            }
            return false;
        }
        return true;
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public override bool Equals(object obj)
    {
        return Equals(obj as Foo);
    }
    
    [System.Runtime.CompilerServices.NullableContext(2)]
    public virtual bool Equals(Foo other)
    {
        if (other != null)
        {
            return Value == other.Value;
        }
        return false;
    }

So naturally new Bar("foo") == new Bee("foo") would end-up calling Bar.Equals(Foo?), which is synthetic and relies on Bar.Equals(object) which will cast to Bar or return null, and because a Bee is not a Bar it ends-up comparing null.

It doesn't seem possible to override some of the synthetic Equals methods so it seems I cannot escape this behavior.

Or, can I?

NOTE: I'm on .NET SDK 6.0.

CodePudding user response:

This is the stack trace when calling new Bar("foo") == new Foo("foo"):

at Foo.Equals(Foo other)
at Bar.Equals(Bar other)
at Bar.Equals(Object obj)
at Bar.Equals(Foo other)
at Foo.op_Equality(Foo r1, Foo r2)

According to the draft spec, you cannot declare any of those methods explicitly (i.e. do not have control over them) except Foo.Equals(Foo) and Bar.Equals(Bar), at which point other has already been (unsuccessfully) casted to Bar, in Bar.Equals(Object).

Though it is not totally impossible - you can declare the operators ==(Bar, Foo), !=(Bar, Foo) etc manually, and let operator overload resolution pick your operator, instead of the ==(Foo, Foo) one, which you have no control over. But this is quite tedious to do for all the types, and you'd still have the problem of Bar.Equals(Foo) not working the way you want. :(

EqualityContract is rather irrelevant. It is only checked in the generated Foo.Equals(Foo),

The synthesized Equals(R?) returns true if and only if each of the following are true:

  • [...]
  • If there is a base record type, the value of base.Equals(other) (a non-virtual call to public virtual bool Equals(Base? other)); otherwise the value of EqualityContract == other.EqualityContract.

but at that point other is null already! Not to mention that you wrote your own Foo.Equals(Foo), so the EqualityContracts are not used at all.

  • Related