Search code examples
c#record

Why is record class's bool Equal(R obj) a virtual method


From the spec:

The record type implements System.IEquatable<R> and includes a synthesized strongly-typed overload of Equals(R? other) where R is the record type. The method is public, and the method is virtual unless the record type is sealed.

What is the real purpose of having the method declared as virtual when we can't really override it with user code in derived classes since it's auto-implemented by the compiler? What does the compiler's auto-generated implementation guarantee for records to work well that we couldn't live without otherwise?

return Equals((object)other);

Solution

  • Sweeper's answer is in sync with the current official docs which describe value equality:

    For types with the record modifier (record class, record struct, and readonly record struct), two objects are equal if they are of the same type and store the same values.

    that is however

    If you don't override or replace equality methods, the type you declare governs how equality is defined:

    So, modifying slightly Sweeper's example by overriding the virtual property EqualityContract :

    void Main() {
        Compare(
        new A() { Foo = "a"},
        new B() { Foo = "a", Bar = "c" }
    );
    }
    
    public record A {
        public string Foo { get; set; } = "";
    }
    
    public record B : A {
        public string Bar { get; set; } = "";
        protected override Type EqualityContract => base.EqualityContract;
    }
    
    void Compare(A a, A b) {
        Console.WriteLine(a.Equals(b)); // True
    }
    

    Here only the Foo part is evaluated between the two objects and it returns true. b.Equals(a) returns false though which is a bit confusing as evidenced ( and discussed in detail) in this and this questions.

    My best attempt at conceptualizing the feature is that it just lets us extend the concept of same type (invariant) to be a type assignable to (covariant).

    For example:

    public record A(string FirstName) {}
    public record B(string FirstName, string LastName) : A(FirstName) {
        protected override Type EqualityContract => base.EqualityContract;
    }
    public record C(string FirstName, string LastName, int Age) : B(FirstName, LastName) {
        protected override Type EqualityContract => base.EqualityContract;
    }
    
    var aRecord = new A("John");
    var bRecord = new B("John", "Doe");
    var cRecord = new C("John", "Doe", 33);
    
    
    aRecord.Equals(cRecord).Dump(); // True -> C is assignable to A
    aRecord.Equals(bRecord).Dump(); // True -> B is assignable to A
    bRecord.Equals(cRecord).Dump(); // True -> C is assignable to B
    
    cRecord.Equals(aRecord).Dump(); // False -> A is NOT assignable to C
    

    This form of covariance works only from the actual object's runtime type, and not from the variable reference type, i.e.

    A cRecord = ...
    cRecord.Equals(aRecord).Dump() // will evalute to false
    

    In my opinion, this has to do with the original implementation (following shortly) that relied on the virtual dispatch of object.Equals.

    The most canonical explanation I could find for what EqualityContract is supposed to enable is from March 2020 when the typed Equals(R obj still hadn't been decided on and they used object.Equals for examples:

    public abstract class Person // Root of hierarchy with value equality
    {
        public string Name { get; set; }
        protected virtual Type EqualityContract => typeof(Person);
        public override bool Equals(object other) =>
            other is Person that
            && object.Equals(this.EqualityContract, that.EqualityContract)
            && object.Equals(this.Name, that.Name);
    }
    
    public class Student : Person // derived class
    {
        public int ID { get; set; }
        protected override Type EqualityContract => typeof(Student);
        public override bool Equals(object other) =>
            base.Equals(other) // checks EqualityContract and Name
            
            && other is Student that // if other is just Person won't work
            
            && object.Equals(this.ID, that.ID);
    }
    

    There was some discussion in the issue as why not implement the type check in the base class with an approach similar to this.GetType()==other.GetType() which would have worked in preventing the base class for evaluating ONLY its state against an instance of a derived class and mistakenly returning true. The main response was:

    Some people feel this is a niche scenario, and the compiler should just generate a typeof(...) check directly. I don't know. I like solving the whole equality problem of hierarchies. Having two values agree that they have the same equality contract seems like it fully addresses symmetry, while respecting the choice of derived classes ("I want to be like my parent" vs "I am my own kind of thing"). I think this approach (originally proposed by @gafter long ago) is super elegant.

    In June 2020 they had drafted typed Equals(R obj) for inheritance scenarios that have functionally the same behavior as today. I couldn't really find in all the meeting notes why they decided to make the methods effectively sealed virtual (you cannot override the methods in derived classes legally).

    If we didn't have the EqualityContract feature and we just compared for exact type in the root record Equals(no user option for assignable/covariance), those methods could have really just been instance methods and not virtual. Using the example with A, B, C from above.

    A aRecord = new A...
    A bRecord = new B...
    
    bRecord.Equals(aRecord); 
    // this would use the A.Equals which could 
    // have just checked for exact type match (between this and other) and returned false.
    

    But with the EqualityContract overridable check in the root's A.Equals we could have it evaluate to true. A is not assignable to B, as B was to A from the first example, so that's no longer the original (already confusing to some) covariant solution. So they had to prevent this.

    The way they did is with having the method be virtual so to trigger virtual dispatch where they could forward to a type checking obj.Equals to prevent the behavior:

    // B's override of A.Equals that we cannot generate ourselves
    public sealed override bool Equals(A other)
    {
        return Equals((object)other); // let's say we have A object
    }
    
    // object.Equals that we cannot generate ourselves
    public override bool Equals(object obj)
    {
        // effectively preventing us from comparing
        // objects that are not assignable to B from
        // comparing to B at all
    
        return Equals(obj as B); // will evaluate to null -> false
    }