5

When writing C#, I tend to write a lot of value type objects that implement IEquatable<T>, IComparable<T>, or both.

For the sake of this proposal, let's assume that I'm writing a fictitious struct called Int256 with equatable and comparable value semantics; for example:

public readonly struct Int256 : IEquatable<Int256>, IComparable<Int256>, IComparable
{
    public bool Equals(Int256 other)
    {
        // TODO : is this equal to other?
    }

    public int CompareTo(Int256 other)
    {
        // TODO : how does this compare to other?
    }

    public int CompareTo(object? obj)
    {
        if (obj is null) return 1;
        if (obj is not Int256 int256) throw new ArgumentException("Obj must be of type Int256.");
        return CompareTo(int256);
    }

    public static bool operator ==(Int256 left, Int256 right)
    {
        return Equals(left, right);
    }
    
    public static bool operator !=(Int256 left, Int256 right)
    {
        return !Equals(left, right);
    }
    
    public static bool operator >(Int256 left, Int256 right)
    {
        return left.CompareTo(right) is 1;
    }
    
    public static bool operator >=(Int256 left, Int256 right)
    {
        return left.CompareTo(right) is 1 or 0;
    }
    
    public static bool operator <(Int256 left, Int256 right)
    {
        return left.CompareTo(right) is -1;
    }
    
    public static bool operator <=(Int256 left, Int256 right)
    {
        return left.CompareTo(right) is -1 or 0;
    }
}

Let's also assume that I were to create some other fictitious structs with the same semantics; for example UInt256 and Decimal256. While trivial, those operators become tedious to implement for every value type object.

Recently, I've been looking at C# 11's new language features, specifically static interface methods, which I believe is largely what makes the new generic math interfaces possible. With that in mind, I could add some additional interfaces to my implementation, specifically IEqualityOperators<TSelf, TOther, TResult> and IComparisonOperators<TSelf, TOther, TResult>; for example:

public readonly struct Int256 : IEquatable<Int256>, IComparable<Int256>, IComparable, IComparisonOperators<Int256, Int256, bool>
{
  ...
}

For completeness, there is no need to implement IEqualityOperators<TSelf, TOther, TResult> here, as they are extended by IComparisonOperators<TSelf, TOther, TResult> anyway.

Ultimately, this doesn't really solve the problem. All these interfaces do is ensure that the operators are implemented.

My proposal is whether interfaces could be designed that auto-implement some of the typical boilerplate code associated with IEquatable<T> and IComparable<T>, specifically the operators: ==, !=, >, >=, <, <=; for example:

IAutoEquatable<T>

public interface IAutoEquatable<T> : IEquatable<T>, IEqualityOperators<T, T, bool> where T : IAutoEquatable<T>
{
    // Auto-implemented boilerplate.
    static virtual bool operator ==(T? left, T? right)
    {
        return Equals(left, right);
    }
    
    // Auto-implemented boilerplate.
    static virtual bool operator !=(T? left, T? right)
    {
        return !Equals(left, right);
    }
}

IAutoComparable<T>

public interface IAutoComparable<T> : IComparable<T>, IComparable, IComparisonOperators<T, T, bool> where T : IAutoComparable<T>
{
    // Auto-implemented boilerplate.
    static virtual bool operator ==(T? left, T? right)
    {
        return Equals(left, right);
    }

    // Auto-implemented boilerplate.
    static virtual bool operator !=(T? left, T? right)
    {
        return !Equals(left, right);
    }

    // Auto-implemented boilerplate.
    static virtual bool operator >(T left, T right)
    {
        return left.CompareTo(right) is 1;
    }

    // Auto-implemented boilerplate.
    static virtual bool operator >=(T left, T right)
    {
        return left.CompareTo(right) is 1 or 0;
    }

    // Auto-implemented boilerplate.
    static virtual bool operator <(T left, T right)
    {
        return left.CompareTo(right) is -1;
    }

    // Auto-implemented boilerplate.
    static virtual bool operator <=(T left, T right)
    {
        return left.CompareTo(right) is -1 or 0;
    }
}

The intention here is that the implementor would only require implementation of bool Equals(T other) and int CompareTo(T other) respectively, but, given that the operators are implemented on the interface, they get the operators for free!

Given my Int256 example, it might look something like this:

public readonly struct Int256 : IEquatable<Int256>, IComparable<Int256>, IComparable
{
    public bool Equals(Int256 other)
    {
        // TODO : is this equal to other?
    }

    public int CompareTo(Int256 other)
    {
        // TODO : how does this compare to other?
    }

    public int CompareTo(object? obj)
    {
        if (obj is null) return 1;
        if (obj is not Int256 int256) throw new ArgumentException("Obj must be of type Int256.");
        return CompareTo(int256);
    }
}

But I would still be able to use the operators with it; for example:

Int256 a = 123;
Int256 b = 456;

a == b; // False
a != b; // True
a > b;  // False
a >= b; // False
a < b;  // True
a <= b; // True

There is a problem, however.

Whilst those interfaces IAutoEquatable<T> and IAutoComparable<T> contain implementations for the operators, I'm still expected to implement them in Int256.

Questions

  1. Why do virtual default implementations in interfaces still require implementation? i.e. why doesn't Int256 just use the default implementation?
  2. Might it be possible for a future version of C# to address this issue, such that we can use it to alleviate the need to write boilerplate code?

Raised here with the C# language design team: https://github.com/dotnet/csharplang/discussions/7032

Matthew Layton
  • 39,871
  • 52
  • 185
  • 313

1 Answers1

1

Answer seems to be "yes and no". I can't explain exactly why, but it seems that there is no way to meaningfully do that.

You can "auto" implement the IEqualityOperators<T, T, bool> in the interface the following way:

public interface IAutoEquatable<T> : IEquatable<T>, IEqualityOperators<T, T, bool> where T : IAutoEquatable<T>
{
    static bool IEqualityOperators<T, T, bool>.operator ==(T? left, T? right)
    {
        Console.Write("ieop.== ");
        if (ReferenceEquals(left, null) && ReferenceEquals(right, null))
        {
            return true;
        }

        if (ReferenceEquals(left, null) || ReferenceEquals(right, null))
        {
            return false;
        }

        return left.Equals(right);
    }

    static bool IEqualityOperators<T, T, bool>.operator !=(T? left, T? right)
    {
        return !(left == right);
    }
}

class Foo : IAutoEquatable<Foo>
{
    public bool Equals(Foo? other)
    {
        Console.Write("eq ");
        return true;
    }
}

The problem is that it will not be called for Foo == Foo:

Do<Foo>(); // prints "ieop.== eq True"
Do1<Foo>(); // prints "ieop.== eq True"
Console.WriteLine(new Foo() == new Foo()); // prints "False"

void Do<T>() where T : IAutoEquatable<T>, new()
{
    Console.WriteLine(new T() == new T());
}

void Do1<T>() where T : IEqualityOperators<T, T, bool>, new()
{
    Console.WriteLine(new T() == new T());
}

And if the last one I can try to explain by the fact that a class does not inherit members from its interfaces (as explained for example in the default interface members proposal spec), but for the following I have even less explanations:

IAutoEquatable<Foo> foo1 = new Foo(); // or IEqualityOperators<Foo, Foo, bool> for both
IAutoEquatable<Foo> foo2 = new Foo();
Console.WriteLine(foo1 == foo2); // prints "False"

So personally I would suggest to go with partial classes and source generators.

Guru Stron
  • 102,774
  • 10
  • 95
  • 132
  • 1
    Thanks for that incredibly detailed answer. I've raised this with the C# language design team as well. Link in the question if you're interested. – Matthew Layton Mar 06 '23 at 12:20
  • The operators are static and can therefore not be inherited. – Olivier Jacot-Descombes Mar 06 '23 at 15:24
  • 1
    @OlivierJacot-Descombes yes, makes sense. Though in this case this would be a nice feature. – Guru Stron Mar 06 '23 at 15:29
  • The feature could probably be implemented using the proposed [roles and extension](https://github.com/dotnet/csharplang/blob/main/proposals/extensions.md). – Olivier Jacot-Descombes Mar 06 '23 at 17:07
  • 1
    Operators are used by compiler only in generic context. When type is not generic parameter, then compiler resolves to `Foo.op_Equality` if it's found, otherwise resolves to `Object.ReferenceEquals`. It's a very counterintuitive behavior – JL0PD Mar 10 '23 at 05:11