4
\$\begingroup\$

There are APIs like the Except extension that require the IEqualityComparer<T> to work. I find it's too much work for such a simple task to implement an interface so I thought why not automate it.


I implemented this interface in a reusable fashion so that I can use it with any value and any number of properties.

internal class AutoEqualityComparer<T> : IEqualityComparer<T>
{
    public AutoEqualityComparer(IEnumerable<Func<T, object>> selectors)
    {
        Selectors = selectors;
    }

    private IEnumerable<Func<T, object>> Selectors { get; }

    public bool Equals(T left, T right)
    {
        return
            !ReferenceEquals(left, null) &&
            !ReferenceEquals(right, null) &&
            Selectors.All(selector => selector(left).Equals(selector(right)));
    }

    public int GetHashCode(T obj)
    {
        unchecked
        {
            return Selectors
                .Select(selector => selector(obj).GetHashCode())
                .Aggregate(17, (hashCode, subHashCode) => hashCode * 31 + subHashCode);
        }
    }
}

I then build a new extension which also can accept any number of selectors to compare:

internal static class Enumerable
{
    public static IEnumerable<T> Except<T>(
        this IEnumerable<T> first, 
        IEnumerable<T> second, 
        params Func<T, object>[] compare)
    {
        var mec = new AutoEqualityComparer<T>(compare);
        return first.Except(second, mec);
    }
}

Example:

Select all properties of an exception that are not in the base exception:

var exceptionProperties =
    typeof(ArgumentException)
    .GetProperties()
    .Except(typeof(Exception).GetProperties(), x => x.Name);

// result: ParamName is the only property
\$\endgroup\$
3
\$\begingroup\$

I think if you created a projection and pass that in the class would be easier to use and more readable for anyone coming after you.

Instead of the IEnumerable<Func<T, object>> I would change it to be Func<T, K> projection and lose the IEnumerable. Which would need to make the class now take two generics

public class AutoEqualityComparer<T, K> : IEqualityComparer<T>
{
    private readonly Func<T, K> _projection;

    public AutoEqualityComparer(Func<T, K> projection)
    {
        _projection = projection;
    }

Since we now have the strong type of class in equals we can use the EqualityComparer class for both the Equals and GetHashCode methods.

    public virtual bool Equals(T x, T y)
    {
        if (x == null && y == null)
        {
            return true;
        }
        if (x == null)
        {
            return false;
        }
        if (y == null)
        {
            return false;
        }

        var xData = _projection(x);
        var yData = _projection(y);

        return EqualityComparer<K>.Default.Equals(xData, yData);
    }

    public virtual int GetHashCode(T obj)
    {
        if (obj == null)
        {
            return 0;
        }

        var objData = _projection(obj);

        return EqualityComparer<K>.Default.GetHashCode(objData);
    }

I would create a class to create the AutoEqualityComparer to make it easier to use

public class EqualityProjectionComparer<T>
{
    public static AutoEqualityComparer<T, K> Create<K>(Func<T, K> projection)
    {
        return new AutoEqualityComparer<T, K>(projection);
    }
}

Now with the extension method you can create Tuples or anonymous classes for the IEqualityComparer

Example could be

var comparer = EqualityProjectionComparer<ArgumentException>.Create(arg => new
{
    arg.ParamName,
    arg.Message
});

Now to me this is more clear on what we are comparing and anonymous type compare the properties to see if they are equal and not reference.

\$\endgroup\$
  • \$\begingroup\$ This is nice. I was thinking about creating a projection but dropped it because I didn't want to compare each property via reflection. Now that I know there is the EqualityComparer I think this is THE solution. \$\endgroup\$ – t3chb0t Nov 21 '16 at 16:38
  • \$\begingroup\$ What a great idea. Worth noting that this only works because anonymous types exhibit structural equality. If you were to project to a type that you'd defined without providing a good Equals implementation the comparer wouldn't work. \$\endgroup\$ – RobH Nov 22 '16 at 8:37
  • \$\begingroup\$ @RobH why so negative? Every API can be misused and implementing several equality-comparers for every usage is not less error-prone and productive then a general solution where you only have to think about feeding it with an anonymous type. \$\endgroup\$ – t3chb0t Nov 22 '16 at 13:51
  • \$\begingroup\$ @RobH besides C# gives us this possibility (anonymous types and Equals that works with them) so why shouldn't we use it? \$\endgroup\$ – t3chb0t Nov 22 '16 at 14:31
  • 2
    \$\begingroup\$ @t3chb0t We actually use this class in our code. We call it a different name than AutoEqualityComparer and return IEqualityComparer<T> out of the static method. But we do put comments on the method so people know what they are getting via intellisense. "The default equality comparer, Default, is used to compare values of types. To compare a custom data type, you need to implement IEquatable interface and provide your own GetHashCode and Equals methods for the type." \$\endgroup\$ – CharlesNRice Nov 22 '16 at 17:33
2
\$\begingroup\$

A quick comment to start you off.

Your Equals implementation isn't quite right. Consider:

var first = new string[] {null};
var emptyEnumerable = first.Except(first, s => s.Length);

Is emptyEnumerable really empty? No, it's not. It contains 1 element null.

I think this is a dangerous thing to attempt, not least because comparers tend to be called in tight loops, which means you want them to be simple and fast - this doesn't look like it will be.

\$\endgroup\$
  • \$\begingroup\$ mhmm, I see I need to figure the equality check out because with a normal implementation I also get one element. I think this is fast enough. No one will notice if it works one tenth of a millisecond longer and if it needs to be fast then I'll use a HashSet... with my comparer ;-D I'll test it with a few integers and strings... \$\endgroup\$ – t3chb0t Nov 21 '16 at 16:19
  • \$\begingroup\$ I've found why the result is invalid when I use your example. I forgot to check if both values are null at the same time. \$\endgroup\$ – t3chb0t Nov 21 '16 at 16:57

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.