51

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

panosru
  • 2,709
  • 4
  • 33
  • 39
  • 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. – Dai Oct 12 '20 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 – panosru Oct 12 '20 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. :) – panosru Oct 12 '20 at 23:32
  • paste your actual Equals method. I just tried and it worked. – Z . Oct 12 '20 at 23:41
  • @ZdravkoDanev that is strange indeed. I will provide a failing example first thing in the morning! – panosru Oct 13 '20 at 00:00
  • @user2864740 I have refined my question. I also read about `EqualityContract`, but I couldn't find any example on how can I use it to override the default equality behaviour. – panosru Oct 13 '20 at 01:15
  • https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/records — try just a “user-defined” method with the signature `public virtual bool Equals(FullNameVo? other)`. Most other forms ‘expected’ in a normal class type are forbidden — “It is an error if the override is declared explicitly” — from being specified manually defined as they will be synthesized (example given). Iff that works, also add the GetHashCode, as shown. – user2864740 Oct 13 '20 at 01:37
  • I suppose, remove the `virtual` or unseal the FullNamVo type as well: “The record type implements System.IEquatable 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.*” – user2864740 Oct 13 '20 at 01:47
  • Basically, the goal is to prevent having to write *all* the methods, as might commonly be done with “generate equality members” *and* prevent errors in the process. Having the single Equals(R?) allows the *compiler synthesized* forms of ==, !=, Equals(object) and implicitly accepts Equals(R). – user2864740 Oct 13 '20 at 01:52
  • Out of curiosity can you do this with normal Classes (not Records)? – Jeremy Thompson Oct 13 '20 at 01:53
  • 2
    @JeremyThompson yes you can do it with classes and structs without any issue: https://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)*. – panosru Oct 13 '20 at 01:57
  • @user2864740, I read that page several times, it might be because my head is overloaded today or I might not understand the docs well. But even after reading that particular page several times, I still have the question on how then shall I override the equality since the docs don't state that you can't do it. Also since records implement `IEquatable` does that mean that I don't have to re-implement it? Even with `public virtual bool Equals(SimpleVo other)` and without `sealed` keyword, I'm still getting a compiler error. If you have a working example, could you share it? – panosru Oct 13 '20 at 02:01
  • @JeremyThompson if you are interested you can check the implementation of `ValueObject` abstract class in C# 8 here: https://github.com/panosru/JustDemo/tree/master/ValueObjects/ValueObjectsCS8 That is how I have it so far, but I'm testing `record` types now so since .Net 5 is on RC1 already I feel that I can start testing C# 9 now and migrate to .Net 5 once it is released. – panosru Oct 13 '20 at 02:06
  • `public virtual bool Equals(SimpleVo other)` is not `public virtual bool Equals(SimpleVo? other)`. Also, per the information in the proposal, remove `virtual` if the type is `sealed`. Also, *remove* the explicit IEquatable. This is implicit in the record and synthesized, as shown in the synthesized examples. – user2864740 Oct 13 '20 at 02:14
  • @user2864740, yes you are correct, I missed the `?`. Based on docs though, it seems impossible to override `==` with `public static bool operator ==(SimpleVo left, SimpleVo right)` or `!=` custom operators, right? Am I missing something? – panosru Oct 13 '20 at 02:24
  • That’s correct per the current proposal. The point is that they *cannot be user-defined* as they are provided through rules deriving from Equals(R?). – user2864740 Oct 13 '20 at 02:29
  • @user2864740, that makes sense, and actually could be better, since if you can write your own implementation of `Equals` then you might not need to override `==` and `!=` operators *(as you used to do with classes and structs)*, or at least I can't find a case where I would need to override them. Usually, in my code, I just add nullability check inside my custom `==` operator. I'm now trying to write a working example and post it since it might help others as well. – panosru Oct 13 '20 at 02:31
  • So, did you figure out how to implement a custom equality check? I tried the solution from the accepted answer, but then I realized that I can't use the sealed keyword because I'm using a base record and deriving. So, for me, that solution won't work. Are you now using Records or are you still using classes? Do others you work with have an opinion on this issue? By the way, I just downloaded the latest version of Visual Studio 2019 Community 16.11.3 and the Equals compiler errors you described are still present. – Bob Bryan Sep 15 '21 at 06:16
  • @BobBryan I'm sorry for my late reply, I was abroad. So far I'm mostly using Classes for value objects, but eventually, I will move to records. Could you check the working example here and see if it suits your needs? https://github.com/panosru/Playground/tree/master/C%23/ValueObjects/ValueObjectsCS9Working – panosru Sep 24 '21 at 10:57

1 Answers1

23

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.
    public 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 book 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.

user2864740
  • 60,010
  • 15
  • 145
  • 220
  • 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: https://github.com/panosru/JustDemo/tree/master/ValueObjects/ValueObjectsCS9Working in the `FullNameVo` record here: https://github.com/panosru/JustDemo/blob/master/ValueObjects/ValueObjectsCS9Working/FullNameVo.cs 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! – panosru Oct 13 '20 at 02:59
  • I think my question in the previous comment is related to this: https://stackoverflow.com/questions/64094373/c-sharp-9-0-records-tostring-not-inherited – panosru Oct 13 '20 at 03:03
  • Doesn’t look like it: “[The default synthesized Equals returns true if .. and] *there is a **base record type***, the value of base.Equals(other) (a non-virtual call to public virtual bool Equals(Base? other)); ..” — note that if the base *was* a base record type, such as one manually created: `record Foo : record ValueObjectShim : ValueObject` – user2864740 Oct 13 '20 at 03:06
  • could you please explain it a bit simpler? Since I have created an `abstract record ValueObject` where I define `Equals` method, and then I create `sealed record FullNameVo : ValueObject`, shouldn't the `FullNameVo` record inherit the `Equals` method of the `ValueObject` abstract record? Instead, if I don't place `public bool Equals(FullNameVo? other) => base.Equals(other);` in `FullNameVo`, then it ignores the one implemented in `ValueObject` abstract record, from which `FullNameVo` is derived. – panosru Oct 13 '20 at 03:13
  • but I'm not inhering from class, I'm inheriting from a record type: https://github.com/panosru/JustDemo/blob/b67ec8e4768f9d06c3ea874c7e15413762e9644c/ValueObjects/ValueObjectsCS9Working/ValueObject.cs#L7 – panosru Oct 13 '20 at 03:20
  • Arg. So many ObjectValue implementations! Then it should call a base Equals by default, although *only does so in a combination*. This is shown in the synthesized examples. To perform the logic in the base type (only), proxy the Equals(R?) method. – user2864740 Oct 13 '20 at 03:21
  • 1
    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: https://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? – panosru Oct 13 '20 at 03:22
  • You can check a very simple example of overriding the `ToString` method in a base class and how a class behaves in contrast with records. https://shorturl.at/abtw3 – panosru Oct 13 '20 at 03:31
  • 6
    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. – Bruno Brant Apr 22 '21 at 16:13