6
\$\begingroup\$

I have two types that don't share any interface but have similar properties and that I need to compare for equality.

One of them is comming from a 3rd party library and one of them is mine. I obviously cannot add any interface to the foreign type and honestly I don't want to create any wrapper for both of them in order to just use it once for some linq. I prefer to have something that lasts longer and can be reused for other projects.

I used these to types for testing but they might of course have many other properties - and as a matter of fact my two actual types have exactly the same properties as this example:

class PersonLib1
{
  public string FirstName { get; set; }

  public string LastName { get; set; }
}

class PersonLib2
{
  public string FirstName { get; set; }

  public string LastName { get; set; }
}

API

I solved this problem by implementing the IEqualityComparer<object> as a DuckEqualityComparer - its name comes from the idea of Duck Typing - this is, since both types share some common or similar properties they must be the same.

My comparer allows the user to specify the exact types and their comparison logic. The order of the types is not relevant and it tries both combinations.

I used additional blocks of {} inside the Equals method to be able to reuse the short variable names.

class DuckEqualityComparer<Tx, Ty> : IEqualityComparer<object>
{
    private readonly Func<Tx, Ty, bool> _equals;
    private readonly Func<Tx, int> _getHashCodeX;
    private readonly Func<Ty, int> _getHashCodeY;

    private DuckEqualityComparer(Func<Tx, Ty, bool> equals, Func<Tx, int> getHashCodeX, Func<Ty, int> getHashCodeY)
    {
        _equals = equals;
        _getHashCodeX = getHashCodeX;
        _getHashCodeY = getHashCodeY;
    }

    public new bool Equals(object x, object y)
    {
        if (ReferenceEquals(x, y)) return true;
        if (ReferenceEquals(x, null)) return false;
        if (ReferenceEquals(y, null)) return false;
        {
            if (x is Tx tx && y is Ty ty && Equals(tx, ty)) return true;
        }
        {
            if (x is Ty ty && y is Tx tx && Equals(tx, ty)) return true;
        }
        return false;
    }

    private bool Equals(Tx x, Ty y)
    {
        if (ReferenceEquals(x, null)) return false;
        if (ReferenceEquals(y, null)) return false;
        return _equals(x, y);
    }

    public int GetHashCode(object obj)
    {
        switch (obj)
        {
            case Tx x: return _getHashCodeX(x);
            case Ty y: return _getHashCodeY(y);
            default: return 0;
        }
    }

    public static IEqualityComparer<object> Create(Func<Tx, Ty, bool> equals, Func<Tx, int> getHashCodeX, Func<Ty, int> getHashCodeY)
    {

        return new DuckEqualityComparer<Tx, Ty>(equals, getHashCodeX, getHashCodeY);
    }
}

Why didn't I use tuples? Because they don't allow me to use other then the default comparer on the property level.

Is this an acceptable solution or is there anything that could be done better?


Example

This is how I use it:

var p1 = new PersonLib1 { FirstName = "John", LastName = "Doe" };
var p2 = new PersonLib2 { FirstName = "JOHN", LastName = "Doe" };
var p3 = new PersonLib2 { FirstName = "Joh", LastName = "Doe" };

var stringComparer = StringComparer.OrdinalIgnoreCase;
var personComparer = DuckEqualityComparer<PersonLib1, PersonLib2>.Create(
    equals: (x, y) =>
        stringComparer.Equals(x.FirstName, y.FirstName) &&
        stringComparer.Equals(x.LastName, y.LastName),
    getHashCodeX: x => 
        stringComparer.GetHashCode(x.FirstName) + 
        stringComparer.GetHashCode(x.LastName),
    getHashCodeY: y => 
        stringComparer.GetHashCode(y.FirstName) + 
        stringComparer.GetHashCode(y.LastName)
);

personComparer.Equals(p1, p2).Dump();
personComparer.Equals(p2, p1).Dump();

personComparer.Equals(p1, p3).Dump();
personComparer.Equals(p3, p1).Dump();
\$\endgroup\$
  • \$\begingroup\$ Maybe you can take a look at AutoFixture/SemanticComparison which allows doing this kind of stuff. The documentation is sparse to say the least, but it's rather useful. See those links for a usage example. \$\endgroup\$ – xlecoustillier Oct 17 '18 at 10:09
  • \$\begingroup\$ @xlecoustillier thanks for the tipp - I see someone else already had a similar idea - I'll use this project for borrowing features and in fact I'm already planning to add a builder with expressions to make the comparer-set-up easier ;-] \$\endgroup\$ – t3chb0t Oct 17 '18 at 10:25
  • \$\begingroup\$ @t3chb0t: Do you change to AutoFixture, or do you still wish some feedback? \$\endgroup\$ – Henrik Hansen Oct 17 '18 at 12:08
  • 1
    \$\begingroup\$ @HenrikHansen I won't change to AutoFixture - I don't like its implementation and I think I/we can make it better ;-) personally I can't stand the specification pattern primarily because of its weird IsSatisfiedBy methods. \$\endgroup\$ – t3chb0t Oct 17 '18 at 12:09
3
\$\begingroup\$

I think there is a problem in that you only compare objects of same type (Tx and Tx or Ty and Ty) by reference, but compare by selected properties for objects of different types (Tx and Ty). I wonder if that can lead to unexpected behavior?

If you have the following objects and compare by FirstName and LastName:

  var p0 = new PersonLib1 { Id = 0, FirstName = "John", LastName = "Doe" };
  var p1 = new PersonLib1 { Id = 1, FirstName = "John", LastName = "Doe" };
  var p2 = new PersonLib2 { Id = 2, FirstName = "JOHN", LastName = "Doe" };
  var p3 = new PersonLib2 { Id = 3, FirstName = "Joh", LastName = "Doe" };

then p0 != p1 but p0 == p2 and p1 == p2 because p0 and p1 is of same type where as p2 is the other type. In other words: you have two different objects of the same type, that are both equal to the same object of another type!

p0 and p1 produces the same hash code, so DuckEqualityComparer<>.Equals() is called for these objects:

public override bool Equals(object x, object y)
{
    if (ReferenceEquals(x, y)) return true;
    if (ReferenceEquals(x, null)) return false;
    if (ReferenceEquals(y, null)) return false;

    ValidateType(x);
    ValidateType(y);

    if (Equals((x, y))) return true;
    if (Equals((y, x))) return true;

    return false;
}

private bool Equals((object x, object y) t)
{
    return t.x is TX x && t.y is TY y && _equals(x, y);
}

but unless they are equal by reference, they fall through the other tests, because they all handle objects of different types.

To get it work properly, you'll have to ensure one-to-one relationship between objects of Tx and Ty:

  var p0 = new PersonLib1 { Id = 0, FirstName = "John", LastName = "Doe" };
  var p1 = new PersonLib1 { Id = 1, FirstName = "John", LastName = "Doe" };
  var p2 = new PersonLib2 { Id = 1, FirstName = "JOHN", LastName = "Doe" };
  var p3 = new PersonLib2 { Id = 3, FirstName = "Joh", LastName = "Doe" };

If you compare by just Id, or Id, FirstName and LastName in the above p0 and p2 are unequal where p1 and p2 are equal.


A simple "Expression" solution without the Builder could be:

  public class MixedEqualityComparer<Tx, Ty> : IEqualityComparer<object>
  {
    List<Func<Tx, Ty, bool>> m_xyEquals = new List<Func<Tx, Ty, bool>>();

    Func<Tx, int> m_getHashCodeX;
    Func<Ty, int> m_getHashCodeY;


    public MixedEqualityComparer(Func<Tx, int> getHashCodeX, Func<Ty, int> getHashCodeY)
    {
      m_getHashCodeX = getHashCodeX;
      m_getHashCodeY = getHashCodeY;
    }

    public MixedEqualityComparer<Tx, Ty> Compare<TProperty>(Expression<Func<Tx, TProperty>> xMemberExpression, Expression<Func<Ty, TProperty>> yMemberExpression, IComparer<TProperty> comparer = null)
    {
      var xmemFunc = xMemberExpression.Compile();
      var ymemFunc = yMemberExpression.Compile();

     comparer = comparer ?? Comparer<TProperty>.Default;

     if (comparer != null)
       m_xyEquals.Add((x, y) => comparer.Compare(xmemFunc(x), ymemFunc(y)) == 0);
     else
     {
       m_xyEquals.Add((x, y) =>
       {
         TProperty xmem = xmemFunc(x);
         TProperty ymem = ymemFunc(y);
         if (xmem != null && ymem != null) return xmem.Equals(ymem);
         return xmem == null && ymem == null;
       });
     }

     return this;
    }

    public new bool Equals(object x, object y)
    {
      if (x is null || y is null) return false;
      if (ReferenceEquals(x, y)) return true;

      return IsEqual(x, y) || IsEqual(y, x);
    }

    private bool IsEqual(object x, object y)
    {
      return x is Tx tx && y is Ty ty && m_xyEquals.All(eq => eq(tx, ty));
    }

    public int GetHashCode(object obj)
    {
      switch (obj)
      {
        case Tx x: return m_getHashCodeX(x);
        case Ty y: return m_getHashCodeY(y);
        default: return 0;
      }
    }
  }

Use Case

  var p0 = new PersonLib1 { Id = 0, FirstName = "John", LastName = "Doe" };
  var p1 = new PersonLib1 { Id = 1, FirstName = "John", LastName = "Doe" };
  var p2 = new PersonLib2 { Id = 2, FirstName = "JOHN", LastName = "Doe" };
  var p3 = new PersonLib2 { Id = 3, FirstName = "Joh", LastName = "Doe" };

  var stringComparer = StringComparer.InvariantCultureIgnoreCase;
  Func<PersonLib1, int> getXHashCode = x => stringComparer.GetHashCode(x.FirstName) + stringComparer.GetHashCode(x.LastName);
  Func<PersonLib2, int> getYHashCode = x => stringComparer.GetHashCode(x.FirstName) + stringComparer.GetHashCode(x.LastName);

  MixedEqualityComparer<PersonLib1, PersonLib2> personComparer = new MixedEqualityComparer<PersonLib1, PersonLib2>(getXHashCode, getYHashCode)
    .Compare(x => x.FirstName, y => y.FirstName, stringComparer)
    .Compare(x => x.LastName, y => y.LastName, stringComparer);

  Dictionary<object, string> dict = new Dictionary<object, string>(personComparer);
  dict[p0] = "AAA";
  dict[p1] = "BBB";
  dict[p2] = "CCC";
  dict[p3] = "DDD";

  Console.WriteLine(dict.Count); // == 3
\$\endgroup\$
  • \$\begingroup\$ I'd love to upvote your answer but I'm not sure I understand what you mean that I only reference equals objects of same type. Could you perhaps rephrase it? \$\endgroup\$ – t3chb0t Oct 17 '18 at 20:18
  • 1
    \$\begingroup\$ I see it now! This means I need to add a guard against it because it makes absolutely no sense to compare objects of the same type. It's the job of another comparer. The sole purpose of this utility is to compare different objects so Tx and Ty must not be of the same type. Good catch! ;-) \$\endgroup\$ – t3chb0t Oct 17 '18 at 20:29
  • \$\begingroup\$ @t3chb0t: yes, or you should make it possible to compare objects of the same type by selected properties as well, but that will probably lead to other mistakes? \$\endgroup\$ – Henrik Hansen Oct 17 '18 at 20:34
  • 1
    \$\begingroup\$ I don't want it to compare same types but I'm really surprised that this actually does not work :-o I hadn't expected it. That's probably why I didn't have a test for it :-) \$\endgroup\$ – t3chb0t Oct 17 '18 at 20:41
  • \$\begingroup\$ Could you point me to the line(s) that make(s) equality for same types work? Just out of curiosity. I'm going to block this but I'd like to know the reason why it refuses to compare their members. I cannot find this feature. \$\endgroup\$ – t3chb0t Oct 18 '18 at 7:36
6
\$\begingroup\$

You can shorten the syntax a bit and increase readability by using the is or == operators instead of ReferenceEquals:

public new bool Equals(object x, object y)
{
    if (ReferenceEquals(x, y)) return true;
    if (x is null || y is null) return false;
    {
        if (x is Tx tx && y is Ty ty && Equals(tx, ty)) return true;
    }
    {
        if (x is Ty ty && y is Tx tx && Equals(tx, ty)) return true;
    }
    return false;
}

The extra braces to make the variables local to their body is clever, however I cant exactly say I like it, it's very easy to consider the first braces to be part of the if statement above them and it can be made much more readable too if you extract it to a method:

public new bool Equals(object x, object y)
{
    if (ReferenceEquals(x, y)) return true;
    if (x is null || y is null) return false;
    return CompareObjects(x, y) || CompareObjects(y, x);
}

protected bool CompareObjects(object x, object y)
{
    return x is Tx tx && y is Ty ty && Equals(tx, ty);
}

You can also define 2 generic type arguments for the CompareObjects methods, to make it more explicit, that you're swapping the types.

private bool Equals(Tx x, Ty y)

Feels weird and out of place in my opinion, you're checking if the values of x and y are null, but you've already done so in public new bool Equals(object x, object y) and since it's a private method, that doesn't make much sense, removing this check makes it a simple call to _equals, which makes the whole method redundant, you are better off making it protected virtual, or just delete it all together

protected virtual bool Equals(Tx x, Ty y)
{
    return x != null && y != null && _equals(x, y);
}

You also lack null checks for the delegates in the Create method, but I assume you've omitted those for simplicity/

\$\endgroup\$
  • \$\begingroup\$ You're right about everything! ;-D There is, however, one thing that bit me when writing comparers before so I now always use the ReferenceEquals - sometimes when you're not careful you might create an infinite loop by checking against null with other operators - they might call == and Equals recursively ;-) \$\endgroup\$ – t3chb0t Oct 17 '18 at 12:45
  • \$\begingroup\$ @t3chb0t I believe it wont cause such problems as long as we're checking against null, out of curiosity do you happen to have an example where infinite loop is created? \$\endgroup\$ – Denis Oct 17 '18 at 12:49
  • 1
    \$\begingroup\$ Here you are :-] It's too easy to make this mistake. \$\endgroup\$ – t3chb0t Oct 17 '18 at 13:38
  • \$\begingroup\$ The swapping with CompareObjects(x, y) gave me another idea... one can use tuples for it and create another overload for Equals. \$\endgroup\$ – t3chb0t Oct 17 '18 at 13:47
  • \$\begingroup\$ I've just came to the conclustion that changing the parameter order for Equals automatically isn't actually such a good idea because some comparisons might not be commutative :-o e.g. I added another overload to the builder that allows the user to specify a custom Equals logic and if he uses x.StartsWith(x) then swapping the parameters would be wrong. \$\endgroup\$ – t3chb0t Oct 18 '18 at 6:56
1
\$\begingroup\$

From the suggested changes I like the one about swapping arguments most so this is actually the only change I've made. To implement it I used a tuple parameter. I decided to stick to ReferenceEquals because otherwise when using the equality operator == there is a risk of runing into an infinite loop. I have also added a helper method ValidateType to throw an exception if unsupported types are used and of course a couple of null-checks.

public class DuckEqualityComparer<TX, TY> : EqualityComparer<object>
{
    private readonly Func<TX, TY, bool> _equals;
    private readonly Func<TX, int> _getHashCodeX;
    private readonly Func<TY, int> _getHashCodeY;

    private DuckEqualityComparer(Func<TX, TY, bool> equals, Func<TX, int> getHashCodeX, Func<TY, int> getHashCodeY)
    {
        _equals = equals;
        _getHashCodeX = getHashCodeX;
        _getHashCodeY = getHashCodeY;
    }

    public override bool Equals(object x, object y)
    {
        if (ReferenceEquals(x, y)) return true;
        if (ReferenceEquals(x, null)) return false;
        if (ReferenceEquals(y, null)) return false;

        ValidateType(x);
        ValidateType(y);

        if (Equals((x, y))) return true;
        if (Equals((y, x))) return true;

        return false;
    }

    private bool Equals((object x, object y) t)
    {
        return t.x is TX x && t.y is TY y && _equals(x, y);
    }

    public override int GetHashCode(object obj)
    {
        ValidateType(obj);

        switch (obj)
        {
            case TX x: return _getHashCodeX(x);
            case TY y: return _getHashCodeY(y);
            default: return 0;
        }
    }

    [NotNull]
    public static EqualityComparer<object> Create(
        [NotNull] Func<TX, TY, bool> equals,
        [NotNull] Func<TX, int> getHashCodeX,
        [NotNull] Func<TY, int> getHashCodeY
    )
    {
        if (equals == null) throw new ArgumentNullException(nameof(equals));
        if (getHashCodeX == null) throw new ArgumentNullException(nameof(getHashCodeX));
        if (getHashCodeY == null) throw new ArgumentNullException(nameof(getHashCodeY));

        return new DuckEqualityComparer<TX, TY>(equals, getHashCodeX, getHashCodeY);
    }

    private static void ValidateType(object obj)
    {
        if (!(obj is TX || obj is TY))
        {
            throw new ArgumentException($"Type '{obj.GetType().Name}' is not supported. Objects must be '{typeof(TX).Name}' or '{typeof(TY).Name}'");
        }
    }
}

Bonus - DuckEqualityComparerBuilder

Creating an equality-comparer is a tedious and error-prone process because you have to think about the && operator between the conditions and you have to specify two GetHashCode methods. I'm lazy so I put together a builder that compiles the equality-comparer from expressions. To create it I borrowed code form the AutoEqualityComparer and its great reviews.

public static class DuckEqualityComparerBuilder
{
    public static DuckEqualityComparerBuilder<TX, TY> Create<TX, TY>(TX x, TY y) => new DuckEqualityComparerBuilder<TX, TY>();
}

public class DuckEqualityComparerBuilder<TX, TY>
{
    private readonly ParameterExpression _parameterX = Expression.Parameter(typeof(TX), "x");
    private readonly ParameterExpression _parameterY = Expression.Parameter(typeof(TY), "y");

    private readonly IList<(Expression equals, Expression getHashCodeX, Expression getHashCodeY)> _expressions = new List<(Expression, Expression, Expression)>();

    public DuckEqualityComparerBuilder<TX, TY> Compare<T>(
        Expression<Func<TX, T>> getValueX,
        Expression<Func<TY, T>> getValueY,
        IEqualityComparer<T> comparer = null
    )
    {
        comparer = comparer ?? EqualityComparer<T>.Default;

        // comparer.Equals(getValueX(x), getValueY(y));
        var equalsFunc = (Expression<Func<T, T, bool>>)((x, y) => comparer.Equals(x, y));
        var equals =
            Expression.Invoke(
                equalsFunc,
                Expression.Invoke(getValueX, _parameterX),
                Expression.Invoke(getValueY, _parameterY)
            );

        // comparer.GetHashCode(getValueX(x))
        var getHashCodeFunc = (Expression<Func<T, int>>)(obj => comparer.GetHashCode(obj));

        var getHashCodeX =
            Expression.Invoke(
                getHashCodeFunc,
                Expression.Invoke(getValueX, _parameterX)
            );

        // comparer.GetHashCode(getValueY(y))
        var getHashCodeY =
            Expression.Invoke(
                getHashCodeFunc,
                Expression.Invoke(getValueY, _parameterY)
            );

        _expressions.Add((equals, getHashCodeX, getHashCodeY));

        return this;
    }

    public EqualityComparer<object> Build()
    {
        var equalityComparer = _expressions.Aggregate((next, current) =>
        (
            equals: ConcatenateEqualsExpressions(current.equals, next.equals),
            getHashCodeX: ConcatenateGetHashCodeExpressions(current.getHashCodeX, next.getHashCodeX),
            getHashCodeY: ConcatenateGetHashCodeExpressions(current.getHashCodeY, next.getHashCodeY)
        ));

        var equalsFunc = Expression.Lambda<Func<TX, TY, bool>>(equalityComparer.equals, _parameterX, _parameterY).Compile();
        var getHashCodeXFunc = Expression.Lambda<Func<TX, int>>(equalityComparer.getHashCodeX, _parameterX).Compile();
        var getHashCodeYFunc = Expression.Lambda<Func<TY, int>>(equalityComparer.getHashCodeY, _parameterY).Compile();

        return DuckEqualityComparer<TX, TY>.Create(
            equalsFunc,
            getHashCodeXFunc,
            getHashCodeYFunc
        );
    }

    public static implicit operator EqualityComparer<object>(DuckEqualityComparerBuilder<TX, TY> builder) => builder.Build();
}

internal static class ExpressionHelper
{
    public static Expression ConcatenateEqualsExpressions(Expression equalsExpressionX, Expression equalsExpressionY)
    {
        // equals && equals
        return
            Expression.AndAlso(
                equalsExpressionX,
                equalsExpressionY
            );
    }

    public static Expression ConcatenateGetHashCodeExpressions(Expression getHashCodeExpressionX, Expression getHashCodeExpressionY)
    {
        // x * 31 + y
        return
            Expression.Add(
                Expression.Multiply(
                    getHashCodeExpressionX,
                    Expression.Constant(31)
                ),
                getHashCodeExpressionY
            );
    }
}

I also wrote a couple of tests to have a proof that it actually works and I like the fact that it's even possible to compare named types against anonymous ones.

using static Assert;
using static ExpressionHelper;

[TestClass]
public class DuckEqualityComparerTest
{
    [TestMethod]
    public void Equals_CanCompareTowNamedTypes()
    {
        var p1 = new PersonLib1 { FirstName = "John", LastName = "Doe" };
        var p2 = new PersonLib2 { FirstName = "JOHN", LastName = "Doe" };
        var p3 = new PersonLib2 { FirstName = "Joh", LastName = "Doe" };
        var p4 = new PersonLib2 { FirstName = default, LastName = "Doe" };

        var comparer =
            new DuckEqualityComparerBuilder<PersonLib1, PersonLib2>()
                .Compare(x => x.FirstName, y => y.FirstName, StringComparer.OrdinalIgnoreCase)
                .Compare(x => x.LastName, y => y.LastName, StringComparer.OrdinalIgnoreCase)
                .Build();

        IsTrue(comparer.Equals(p1, p1));
        IsTrue(comparer.Equals(p2, p2));

        IsTrue(comparer.Equals(p1, p2));
        IsTrue(comparer.Equals(p2, p1));

        IsFalse(comparer.Equals(p1, p3));
        IsFalse(comparer.Equals(p3, p1));
        IsFalse(comparer.Equals(p1, p4));
    }

    [TestMethod]
    public void Equals_CanCompareTwoAnonymousTypes()
    {
        var comparer =
            DuckEqualityComparerBuilder
                .Create(
                    new { FirstName1 = default(string), LastName1 = default(string) },
                    new { FirstName2 = default(string), LastName2 = default(string) }
                )
                .Compare(x => x.FirstName1, y => y.FirstName2, StringComparer.OrdinalIgnoreCase)
                .Compare(x => x.LastName1, y => y.LastName2, StringComparer.OrdinalIgnoreCase)
                .Build();

        IsTrue(comparer.Equals(
            new { FirstName1 = "John", LastName1 = "Doe" },
            new { FirstName2 = "JOHN", LastName2 = "DOE" }
        ));

        IsFalse(comparer.Equals(
            new { FirstName1 = "Johny", LastName1 = "Dope" },
            new { FirstName2 = "JOHN", LastName2 = "DOE" }
        ));
    }

    [TestMethod]
    public void Equals_CanCompareNamedAndAnonymousTypes()
    {
        var comparer =
            DuckEqualityComparerBuilder
                .Create(
                    default(PersonLib1),
                    new { FirstName2 = default(string), LastName2 = default(string) }
                )
                .Compare(x => x.FirstName, y => y.FirstName2, StringComparer.OrdinalIgnoreCase)
                .Compare(x => x.LastName, y => y.LastName2, StringComparer.OrdinalIgnoreCase)
                .Build();

        IsTrue(comparer.Equals(
            new PersonLib1 { FirstName = "John", LastName = "Doe" },
            new { FirstName2 = "JOHN", LastName2 = "DOE" }
        ));

        IsFalse(comparer.Equals(
            new PersonLib1 { FirstName = "Johny", LastName = "Dope" },
            new { FirstName2 = "JOHN", LastName2 = "DOE" }
        ));
    }
}
\$\endgroup\$
  • 1
    \$\begingroup\$ I like this answer the most because it provides the easiest API. All the ugly HashCode calculations are cleanly hidden below the surface so that the user of the Builder class can concentrate on the interesting part on the application layer. \$\endgroup\$ – Roland Illig Oct 17 '18 at 21:09

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.