67

From what I understand, records are actually classes that implement their own equality check in a way that your object is value-driven and not reference driven.

In short, for the record Foo that is implemented like so: var foo = new Foo { Value = "foo" } and var bar = new Foo { Value = "foo" }, the foo == bar expression will result in True, even though they have a different reference (ReferenceEquals(foo, bar) // False).

Now with records, even though that in the article posted in .Net Blog, it says:

If you don’t like the default field-by-field comparison behaviour of the generated Equals override, you can write your own instead.

When I tried to place public override bool Equals, or public override int GetHashCode, or public static bool operator ==, and etc. I was getting Member with the same signature is already declared error, so I think that it is a restricted behaviour, which isn't the case with struct objects.

Failing example:

public sealed record SimpleVo
    : IEquatable<SimpleVo>
{
    public bool Equals(SimpleVo other) =>
        throw new System.NotImplementedException();

    public override bool Equals(object obj) =>
        obj is SimpleVo other && Equals(other);

    public override int GetHashCode() =>
        throw new System.NotImplementedException();

    public static bool operator ==(SimpleVo left, SimpleVo right) =>
        left.Equals(right);

    public static bool operator !=(SimpleVo left, SimpleVo right) =>
        !left.Equals(right);
}

Compiler result:

SimpleVo.cs(11,30): error CS0111: Type 'SimpleVo' already defines a member called 'Equals' with the same parameter types

SimpleVo.cs(17,37): error CS0111: Type 'SimpleVo' already defines a member called 'op_Equality' with the same parameter types

SimpleVo.cs(20,37): error CS0111: Type 'SimpleVo' already defines a member called 'op_Inequality' with the same parameter types

My main question here is what if we want to customise the way the equality checker works? I mean, I do understand that this beats the whole purpose of records, but on the other hand, equality checker is not the only feature that makes records cool to use.

One use case where someone would like to override the equality of records is because you could have an attribute that would exclude a property from equality check. Take for example this ValueObject implementation.

Then if you extend this ValueObject abstract class like so:

public sealed class FullNameVo : ValueObject
{
    public FullNameVo(string name, string surname)
    {
        Name    = name;
        Surname = surname;
    }

    [IgnoreMember]
    public string Name { get; }

    public string Surname { get; }

    [IgnoreMember]
    public string FullName => $"{Name} {Surname}";
}

then you would get the following results:

var user1 = new FullNameVo("John", "Doe");
var user2 = new FullNameVo("John", "Doe");
var user3 = new FullNameVo("Jane", "Doe");

Console.WriteLine(user1 == user2); // True
Console.WriteLine(ReferenceEquals(user1, user2)); // False
Console.WriteLine(user1 == user3); // True
Console.WriteLine(user1.Equals(user3)); // True

So far, in order to achieve somehow the above use case, I have implemented an abstract record object and utilise it like so:

public sealed record FullNameVo : ValueObject
{
    [IgnoreMember]
    public string Name;

    public string Surname;

    [IgnoreMember]
    public string FullName => $"{Name} {Surname}";
}

and the results look like this:

var user1 = new FullNameVo
{
    Name    = "John",
    Surname = "Doe"
};

var user2 = new FullNameVo
{
    Name    = "John",
    Surname = "Doe"
};

var user3 = user1 with { Name = "Jane" };

Console.WriteLine(user1 == user2); // True
Console.WriteLine(ReferenceEquals(user1, user2)); // False
Console.WriteLine(user1 == user3); // False
Console.WriteLine(user1.Equals(user3)); // False
Console.WriteLine(ValueObject.EqualityComparer.Equals(user1, user3)); // True

To conclude, I'm a bit puzzled, is restricting the override of equality methods of record objects an expected behaviour or is it because it is still in preview stage? If it is by design, would you implement the above behaviour in a different (better) way or you would just continue using classes?

dotnet --version output: 5.0.100-rc.1.20452.10

19
  • What version of the C# 9 compiler are you using? I do note that C# 9.0 is still in preview (as far as I know) so some features may still not yet be available yet. Commented Oct 12, 2020 at 23:19
  • @Dai you are right pal! I missed mentioning that info! I will update my question now. FYI: 5.0.100-rc.1.20452.10 Commented Oct 12, 2020 at 23:20
  • @Dai, to add, yes I understand that it is under development still, and I wouldn't ask the question if it wasn’t in RC1, so as a release candidate version I'm kind of puzzled if that is by design or it is just not implemented yet. :) Commented Oct 12, 2020 at 23:32
  • 1
    I suppose, remove the virtual or unseal the FullNamVo type as well: “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. The [bool Equals(R? r)] method can be declared explicitly. It is an error if the explicit declaration does not match the expected signature or accessibility, or the explicit declaration doesn't allow overriding it in a derived type and the record type is not sealed. Commented Oct 13, 2020 at 1:47
  • 2
    @JeremyThompson yes you can do it with classes and structs without any issue: dotnetfiddle.net/Widget/apnl6x I only can't do it with records so far (which could be my fault because records may require a different approach). Commented Oct 13, 2020 at 1:57

2 Answers 2

37

Per the C#9 record proposal, the following should compile, even if not very useful without actual implementations..

// No explicit IEquatable<R> - this is synthesized!
public sealed record SimpleVo
{
    // Not virtual, as SimpleVo (R) is sealed.
    // Accepts SimpleVo? (R?), and not SimpleVo (R), as argument.
    public bool Equals(SimpleVo? other) =>
        throw new System.NotImplementedException();

    // Optional: warning generated if not supplied when Equals(R?) is user-defined. 
    // Must have the override modifier, because System.Object already defines int GetHashCode().
    public override int GetHashCode() =>
        throw new System.NotImplementedException();

    // No other “standard” equality members!
}

There are restrictions on the equality-related members as most of the code is synthesized. The proposal includes examples of the expected synthesized underlying type.

That is, given just a Equals(R?) the compiler creates a ==, !=, and Equals(object). The methods that can be defined can be found by searching for “user-defined” in the proposal.

Attempting to override/define other equality methods or operators is expected to fail:

It is an error if the override is declared explicitly.

The behavior is discussed in ‘Equality members’ and is summarized in the paragraph:

The record type implements System.IEquatable<R> and includes a synthesized strongly-typed overload of bool Equals(R? other) where R is the record type. The method is public, and the method is virtual unless the record type is sealed. The [Equals(R?)] method can be declared explicitly. It is an error if the explicit declaration does not match the expected signature or accessibility, or the explicit declaration doesn't allow overriding it in a derived type and the record type is not sealed. If Equals(R? other) is user-defined (not synthesized) but GetHashCode is not [user-defined], a warning is produced.


Sign up to request clarification or add additional context in comments.

11 Comments

I will mark your answer as correct because indeed that way I managed to implement what I wanted. My only remaining question is if you look in my repo: github.com/panosru/JustDemo/tree/master/ValueObjects/… in the FullNameVo record here: github.com/panosru/JustDemo/blob/master/ValueObjects/… I have to put public bool Equals(FullNameVo? other) => base.Equals(other); in order for it to work, is there a way to avoid doing that and just inherit the Equals method from public abstract record ValueObject? Thanks!
I think my question in the previous comment is related to this: stackoverflow.com/questions/64094373/…
yes, I added a few examples... but based on logic, I agree with you, that is what I'm arguing about, although based on this answer here: stackoverflow.com/a/64094532/395187 it shouldn't use the base Equals... which is very annoying tbh... Should I open a new issue about that matter?
Hmm, why is my compiler complaining when I try to write GetHashCode without the override keyword? Strangely, it seems that GetHashCode has to be inherited/overridden whereas Equals will be synthesized/called.
@BrunoBrant @flackoverstow No it's because GetHashCode always exists as a virtual method on ALL C# objects and structs, even records because just like all other classes and structs they inherit from either System.Object or System.ValueType. By default a record writes an override for those inherited methods. However the Equals(T) method is NOT a method that is inherited - it comes from the IEquatable<T> interface, which all records add to themselves implicitly. Try to remove the record keyword from your declaration: you will see identical result regarding override.
|
13

I found the accepted answer hard to understand; perhaps this is simpler:

Records are classes; when you write record the compiler writes a class. This is no different to when you write a class; if you omit the constructor, the compiler writes one etc, so in the background the compiler is writing a lot of code for you, which makes your code less cluttered but means C# still works in the ways it originally did

When you make a record that doesn't inherit anything:

    record MyRecord(string MyString, int MyInt);

the compiler generates two Equals methods for you.

The first is:

    virtual bool Equals(MyRecord other) {
      //complicated comparison of properties etc here
    }

..and this one is quite an involved one that performs an Equals check on all public members, verifying that this.MyString.Equals(other.MyString) && this.MyInt.Equals(other.MyInt) && ... and so on

The second is:

    override bool Equals(object other) {
        return this.Equals(other as MyRecord); //call the first one
    }

..and as per above, all this one does is call the first one with a cast. If as is casting an object that is not a MyRecord, then the first Equals is called with a null, and that situation is handled


You can't provide your own version that override's Object's Equals(object other) because when the compiler generates the same thing you end up with an "already defines member..." error, so you have to leave the compiler to generate that one

However, you don't need to override object's Equals, because if you provide your own custom virtual Equals(MyRecord other) the compiler won't generate its version, and all the other things that might call Equals(object) will get redirected to your custom Equals(MyRecord) anyway, via the override the compiler creates that now calls your custom Equals(MyRecord)

Your Equals(MyRecord) is virtual so it can be overridden again if inherited.

When you inherit a record, and don't provide a custom Equals, the compiler generates three Equals methods:

  virtual bool Equals(MySubRecord other)       //performs actual check
  override bool Equals(object other)           //from object, calls the first one
  sealed override bool Equals(MyRecord other)  //from parent, this calls the second one

If your custom method wasn't virtual, and you subclassed MyRecord into MySubRecord, the compiler will still want to provide its own virtual bool Equals(MySubRecord other) in the derived class. As yours isn't virtual this causes a compiler error because the virtual method the compiler creates implicitly overrides your custom method in the parent. In essence, because the compiler can only do one of two things:

  • write its own version in that templated, scripted way it always does
  • see that you wrote your own version, and not write anything

it means that anything you create has to fit into the way it arranges all the things it writes for you, and that means following a particular format

3 Comments

Note: I've omitted nullable reference type syntax for simplicity but if nullable was enabled your custom methods would look like Equals(MyRecord? other
It's also worth mentioning that the compiler also writes the static operator== and operator!= methods for you. Great simplified explanation by the way. :-)
(...and that records can now be structs too, as in record struct or readonly record struct.)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.