Skip to content

Improve the documentation of value equality for C# #45164

Open
@danielwinkler

Description

@danielwinkler

Type of issue

Other (describe below)

Description

The documentation on value equality in C# is not satisfactory for polymorphic class hierarchies.

While there is a disclaimer that the implementation in the class example is not working for the case

TwoDPoint p1 = new ThreeDPoint(1, 2, 3);
TwoDPoint p2 = new ThreeDPoint(1, 2, 4);
Console.WriteLine(p1.Equals(p2)); // output: True

I believe simply pointing to user to record types is not enough.
If you have a record that has any member that is not implementing value equality (e.g. List), then the problem remains and all of the documentation does not show any safe way how to implement polymorphic equality.

In a way, the definition of IEquatable is extremely problematic here, as it forces the user to implement an Equals method that will be called based on the declared type.

One way to get around this would be to make the public bool Equals(TwoDPoint p) virtual, but then we would have to keep overriding it in all the subclasses to get the desired behavior (in a deep hierarchy there would be lot of equals methods to override then...).

Another way that would be possible is to use Explicit Interface Implementation for IEquatable<T>.

This way we make our hierarchy equatable the way we would like, but bool IEquatable<TwoDPoint>.Equals(TwoDPoint? p) will not be accessible through the declared type, so public override bool Equals(object? obj) will be called which is virtual all the way down with a single signature.

A working example for this can be seen below.
Due to the casting it is also not perfect and can end in an infinite loop, but at least once it's properly implemented it can safely be used.

Looking forward to your thoughts on this approach.

class TwoDPoint : IEquatable<TwoDPoint>
{
    public int X { get; private set; }
    public int Y { get; private set; }

    public TwoDPoint(int x, int y)
    {
        if (x is (< 1 or > 2000) || y is (< 1 or > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.X = x;
        this.Y = y;
    }

    public override bool Equals(object? obj) => Equals(obj as TwoDPoint);

    bool IEquatable<TwoDPoint>.Equals(TwoDPoint? p) => Equals((object?)p);

    protected bool Equals(TwoDPoint? p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // If run-time types are not exactly the same, return false.
        if (this.GetType() != p.GetType())
        {
            return false;
        }

        // Return true if the fields match.
        // Note that the base class is not invoked because it is
        // System.Object, which defines Equals as reference equality.
        return (X == p.X) && (Y == p.Y);
    }

    public override int GetHashCode() => (X, Y).GetHashCode();

    public static bool operator ==(TwoDPoint lhs, TwoDPoint rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(TwoDPoint lhs, TwoDPoint rhs) => !(lhs == rhs);
}

// For the sake of simplicity, assume a ThreeDPoint IS a TwoDPoint.
class ThreeDPoint : TwoDPoint, IEquatable<ThreeDPoint>
{
    public int Z { get; private set; }

    public ThreeDPoint(int x, int y, int z)
        : base(x, y)
    {
        if ((z < 1) || (z > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.Z = z;
    }

    public override bool Equals(object? obj) => Equals(obj as ThreeDPoint);

    bool IEquatable<ThreeDPoint>.Equals(ThreeDPoint? p) => Equals((object?)p);

    protected bool Equals(ThreeDPoint? p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // Check properties that this class declares.
        if (Z != p.Z)
        {
            return false;
        }

        return base.Equals(p);
    }

    public override int GetHashCode() => (X, Y, Z).GetHashCode();

    public static bool operator ==(ThreeDPoint lhs, ThreeDPoint rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                // null == null = true.
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles the case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(ThreeDPoint lhs, ThreeDPoint rhs) => !(lhs == rhs);
}

Page URL

https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/statements-expressions-operators/how-to-define-value-equality-for-a-type

Content source URL

https://github.com/dotnet/docs/blob/main/docs/csharp/programming-guide/statements-expressions-operators/how-to-define-value-equality-for-a-type.md

Document Version Independent Id

227a6999-9add-4a0a-98cb-c0c6624d6089

Platform Id

ebf5fc1b-a31e-cddd-874e-c7ac09836f47

Article author

@BillWagner

Metadata

  • ID: e95b738e-a319-8947-c098-692068c24b9c
  • PlatformId: ebf5fc1b-a31e-cddd-874e-c7ac09836f47
  • Service: dotnet-csharp
  • Sub-service: fundamentals

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions