9
\$\begingroup\$

Writing comparers by either implementing the IEqualityComparer<T> or the IEquatable<T> interface is another boring task. I thought why not writing a declarative helper that would generate an expression and automatically implement the comparison logic.

The result of this experiment is the AutoEquality<T> class that generates such an expression. It does also special handling for strings that can use other logic. It generates both the Equals and the GetHashCode methods.

public class AutoEquality<T> : IEqualityComparer<T>
{
    private readonly Func<T, T, bool> _equals;
    private readonly Func<T, int> _getHashCode;

    private const int LeftExpr = 0;
    private const int RightExpr = 1;

    private AutoEquality(Func<T, T, bool> equals, Func<T, int> getHashCode)
    {
        _equals = equals;
        _getHashCode = getHashCode;
    }

    public static IEqualityComparer<T> Create()
    {
        var equalityProperties =
            typeof(T)
                .GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Where(x => x.GetCustomAttribute<EqualityPropertyAttribute>() != null);

        var exprs = new List<(Expression EqualsExpr, Expression GetHashCodeExpr)>();

        var leftExpr = Expression.Parameter(typeof(T), "left");
        var rightExpr = Expression.Parameter(typeof(T), "right");

        foreach (var equalityProperty in equalityProperties)
        {
            var equalityPropertyAttr = equalityProperty.GetCustomAttribute<EqualityPropertyAttribute>();

            var propertyType = equalityProperty.PropertyType;

            var leftPropExpr = (Expression)Expression.Property(leftExpr, equalityProperty);
            var rightPropExpr = (Expression)Expression.Property(rightExpr, equalityProperty);

            // Treat enums as ints.
            if (propertyType.IsEnum) 
            {
                leftPropExpr = Expression.Convert(leftPropExpr, typeof(int));
                rightPropExpr = Expression.Convert(rightPropExpr, typeof(int));

                propertyType = typeof(int);
            }

            // String.Equals supports an additional parameter (StringComparison) so it requires special treatment.
            var equalsMethodParameterTypes = new List<Type> { propertyType };
            if (propertyType == typeof(string))
            {
                equalsMethodParameterTypes.Add(typeof(StringComparison));
            }


            var equalsMethod = propertyType.GetMethod(nameof(object.Equals), equalsMethodParameterTypes.ToArray());         

            var equalsMethodParameters = new List<Expression> { rightPropExpr };
            if (propertyType == typeof(string))
            {
                equalsMethodParameters.Add(Expression.Constant(equalityPropertyAttr.StringComparison));
            }

            var equalsExpr = Expression.Call(leftPropExpr, equalsMethod, equalsMethodParameters);

            var getHashCodeMethod = propertyType.GetMethod(nameof(object.GetHashCode));
            var getHashCodeExpr = Expression.Call(leftPropExpr, getHashCodeMethod);

            // String.GetHashCode requires special handling because it generates different hash-codes with different comparers.
            if (propertyType == typeof(string))
            {
                var stringComparer = GetStringComparer(equalityPropertyAttr.StringComparison);
                getHashCodeMethod = ((Func<string, int>)stringComparer.GetHashCode).Method;
                getHashCodeExpr = Expression.Call(Expression.Constant(stringComparer), getHashCodeMethod, leftPropExpr);
            }

            exprs.Add((equalsExpr, getHashCodeExpr));

            if (TryComposeExpressions(exprs, out var t))
            {
                exprs.Clear();
                exprs.Add(t);
            }
        }

        var result = exprs.Single();
        var equalsFunc = Expression.Lambda<Func<T, T, bool>>(result.EqualsExpr, leftExpr, rightExpr).Compile();
        var getHashCodeFunc = Expression.Lambda<Func<T, int>>(result.GetHashCodeExpr, leftExpr).Compile();

        return new AutoEquality<T>(equalsFunc, getHashCodeFunc);
    }

    private static bool TryComposeExpressions(IList<(Expression EqualsExpr, Expression GetHashCodeExpr)> exprs, out (Expression EqualsExpr, Expression GetHashCodeExpr) result)
    {
        // Compose expressions if there are two of them.
        if (exprs.Count == 2)
        {
            // left.Equals(..) && right.Equals(..)
            var andAlsoExpr = Expression.AndAlso(exprs[LeftExpr].EqualsExpr, exprs[RightExpr].EqualsExpr);

            // (left.GetHashCode() * 31) + right.GetHashCode()
            var addExpr =
                Expression.Add(
                    Expression.Multiply(
                        exprs[LeftExpr].GetHashCodeExpr,
                        Expression.Constant(31)
                    ),
                    exprs[RightExpr].GetHashCodeExpr
                );

            result = (andAlsoExpr, addExpr);
            return true;
        }
        result = (default(Expression), default(Expression));
        return false;
    }

    public bool Equals(T left, T right)
    {
        if (ReferenceEquals(left, right)) return true;
        if (ReferenceEquals(left, null) && ReferenceEquals(right, null)) return true;
        if (ReferenceEquals(left, null)) return false;
        if (ReferenceEquals(right, null)) return false;
        return _equals(left, right);
    }

    public int GetHashCode(T obj)
    {
        return _getHashCode(obj);
    }

    private static StringComparer GetStringComparer(StringComparison stringComparison)
    {
        switch (stringComparison)
        {
            case StringComparison.CurrentCulture: return StringComparer.CurrentCulture;
            case StringComparison.CurrentCultureIgnoreCase: return StringComparer.CurrentCultureIgnoreCase;
            case StringComparison.InvariantCulture: return StringComparer.InvariantCulture;
            case StringComparison.InvariantCultureIgnoreCase: return StringComparer.InvariantCultureIgnoreCase;
            case StringComparison.Ordinal: return StringComparer.Ordinal;
            case StringComparison.OrdinalIgnoreCase: return StringComparer.OrdinalIgnoreCase;
            default: throw new ArgumentOutOfRangeException($"Invalid {nameof(stringComparison)} value.");
        }
    }
}

In order to find properties that are part of the comparison logic I define this attribute:

public class EqualityPropertyAttribute : Attribute
{
    public EqualityPropertyAttribute() { }
    public EqualityPropertyAttribute(StringComparison stringComparison) => StringComparison = stringComparison;
    public StringComparison StringComparison { get; } = StringComparison.CurrentCulture;
}

It allows to use a different sting comparison logic if necessary.


Example

I tested it with a simple Person class where I decorated three properties with the new attribute:

public enum Size
{
    S,
    M,
    L
}

public class Person : IEquatable<Person>
{
    private static readonly IEqualityComparer<Person> Comparer = AutoEquality<Person>.Create();

    [EqualityProperty(StringComparison.OrdinalIgnoreCase)]
    public string FirstName { get; set; }

    [EqualityProperty]
    public string LastName { get; set; }

    [EqualityProperty]
    public DateTime DateOfBirth { get; set; }

    [EqualityProperty]
    public Size ShoeSize { get; set; }

    public string Nickname { get; set; }

    public bool Equals(Person other) => Comparer.Equals(this, other);

    public int GetHashCode(Person obj) => Comparer.GetHashCode(obj);
}

var p1 = new Person { FirstName = "John", LastName = "Doe", DateOfBirth = new DateTime(2017, 5, 1), ShoeSize = Size.M };
var p2 = new Person { FirstName = "JOHN", LastName = "Doe", DateOfBirth = new DateTime(2017, 5, 1), ShoeSize = Size.M };
var p3 = new Person { FirstName = "Jason", LastName = "Doe" };

The results are:

p1.Equals(p2).Dump(); // True
p1.Equals(p3).Dump(); // False
\$\endgroup\$
  • 1
    \$\begingroup\$ Pretty code! Just a small add to existing answers: isn't if (ReferenceEquals(left, null) && ReferenceEquals(right, null)) return true; superfluous? It's already covered by previous comparison. \$\endgroup\$ – Adriano Repetti Sep 19 '17 at 8:56
  • \$\begingroup\$ @AdrianoRepetti thx :-) you're right, both conditions are the same, I didn't see that. \$\endgroup\$ – t3chb0t Sep 19 '17 at 12:56
3
\$\begingroup\$

Since you are reading attributes I would put the work in the static constructor. That will run only once per type otherwise you should make the Create method lazy as there is no point in building the expressions multiple times.

Also you can clean up your code quite a bit if you always just use IEqualityComparer for every property. You wouldn't need special cases for Enums. the only special case would be for your string type. Something around the lines of

private static IEqualityComparer<TType> GetComparer<TType>(EqualityPropertyAttribute attribute)
{
    if (typeof (TType) == typeof (string))
    {
        var stringComparison = attribute.StringComparison;

        switch (stringComparison)
        {
            case StringComparison.CurrentCulture:
                return (IEqualityComparer<TType>) StringComparer.CurrentCulture;
            case StringComparison.CurrentCultureIgnoreCase:
                return (IEqualityComparer<TType>) StringComparer.CurrentCultureIgnoreCase;
            case StringComparison.InvariantCulture:
                return (IEqualityComparer<TType>) StringComparer.InvariantCulture;
            case StringComparison.InvariantCultureIgnoreCase:
                return (IEqualityComparer<TType>) StringComparer.InvariantCultureIgnoreCase;
            case StringComparison.Ordinal:
                return (IEqualityComparer<TType>) StringComparer.Ordinal;
            case StringComparison.OrdinalIgnoreCase:
                return (IEqualityComparer<TType>) StringComparer.OrdinalIgnoreCase;
            default:
                throw new ArgumentOutOfRangeException($"Invalid {nameof(stringComparison)} value.");
        }
    }

    return EqualityComparer<TType>.Default;
}

you are using the IEqualityComparer for the hashcode but not for the actual comparison. If you using it for the hash you should also use it for the comparison. You will need to project each property out to use something like this

private static Expression<Func<T, T, bool>> GetEquals<TType>(PropertyInfo pinfo,
    EqualityPropertyAttribute attribute)
{
    IEqualityComparer<TType> comparer = GetComparer<TType>(attribute);

    // short-cut to have compiler write this expression for us
    Expression<Func<TType, TType, bool>> expression = (x, y) => comparer.Equals(x, y);

    // main func
    var left = Expression.Parameter(typeof (T), "insideLeft");
    var right = Expression.Parameter(typeof (T), "insideRight");

    var result = Expression.Invoke(expression, Expression.Property(left, pinfo),
        Expression.Property(right, pinfo));

    return Expression.Lambda<Func<T, T, bool>>(result, left, right);
}

You are also using reflection twice to read if they have the attribute of EqualityPropertyAttribute. It's better to just do that once. I'd change your select to put it in a tuple.

var equalityProperties = typeof (T)
    .GetProperties(BindingFlags.Public | BindingFlags.Instance)
    .Select(pinfo => Tuple.Create(pinfo, pinfo.GetCustomAttribute<EqualityPropertyAttribute>()))
    .Where(tuple => tuple.Item2 != null);

I typically use a bit of reflection to make a method strong typed because it makes building the Lambda Expression easier but that's not required. But if we are using ExpressionTree speed isn't usually a big concern and a bit of reflection isn't going to kill it.

Something like this in the start of the method

// Get the method this is generic
Func<PropertyInfo, EqualityPropertyAttribute, Expression<Func<T, T, bool>>> getEqualsMethod = GetEquals<object>;
var getEqualsMethodInfo = getEqualsMethod.GetMethodInfo().GetGenericMethodDefinition();

Then when looping over the properties I'll do the reflection

var equalMethod = getEqualsMethodInfo.MakeGenericMethod(tuple.Item1.PropertyType);
var propEquals = (Expression<Func<T, T, bool>>) equalMethod.Invoke(null, new object[] {tuple.Item1, tuple.Item2}));

At the end I still wonder how much this saves putting attributes all over a class than just writing the code in the first place.

\$\endgroup\$
  • \$\begingroup\$ Interesting suggestions. I'll need some time to process them :-) \$\endgroup\$ – t3chb0t Sep 19 '17 at 16:41
  • \$\begingroup\$ 'putting attributes all over a class' - good point. Using all public properties by default, with attributes for blacklisting properties, would probably be a better fit for most use cases. \$\endgroup\$ – Pieter Witvoet Sep 19 '17 at 16:43
  • \$\begingroup\$ I'm currently trying to implement your suggestions and I believe I cannot use the EqualityComparer<TType>.Default because this class is generic and does not provide any methods accepting just a Type of a property so I'll need to write another switch for enums too, like the one that the framework comparer uses referencesource.microsoft.com/#mscorlib/system/collections/… \$\endgroup\$ – t3chb0t Oct 4 '17 at 10:44
  • \$\begingroup\$ That's where the last part of my post is. You need to do a bit of reflection. If you see I'm getting the getEqualsMEthodInfo so I can invoke it and casting the results back. \$\endgroup\$ – CharlesNRice Oct 4 '17 at 13:25
  • \$\begingroup\$ I finally got this working and I think this is a slick improvement ;-) About putting attributes all over a class: this will save me a lot of work. I (need to) write comparers very often and reinventing the wheel each time is really, really time consuming a very error prone especially if you are too lazy to test them because you think they are so simple that you can't make a mistake and yet you make it by comparing invalid properties with each other. Setting two or three attributes is much easier and the logic is already implemented and tested so there's no need to do it again and agian... \$\endgroup\$ – t3chb0t Oct 4 '17 at 15:36
9
\$\begingroup\$

Very nice implementation; I always like seeing your code here. I really only have five very minor opinions on this implementation:

  1. I'm not sold on expr as an abbreviation for expression. I'd recommend expression in its various incarnations.

    1a. For that matter, t in the Create method should probably be called composedExpressions.

  2. The class constants LeftExpr and RightExpr used as list indices are only used in the TryComposeExpressions method. Localize those to the method. And they should probably be named LeftExpressionIndex and RightExpressionIndex to indicate their utility.

  3. In the EqualityPropertyAttribute class, I'd remove the property initializer and put it on the parameterless constructor (via constructor chaining). This keeps two assignments (instance initialization of the field followed by [parameter] constructor initialization of the field) from happening on that particular case.

    3a. Add [AttributeUsage(AttributeTargets.Property)] onto this class since the attribute is only intended to be placed on properties.

  4. Mark the AutoEquality<T> and EqualityPropertyAttribute classes as sealed if you do not expect any inheritors.

  5. new List<(Expression EqualsExpr, Expression GetHashCodeExpr)>() could have the capacity passed as it should be the count of equalityProperties. This may or may not be useful.

I'm also wondering if there might be benefit at the AutoEquality<T> level to only execute the Create method lazily, or if that could/would be better served at the Person level. Create seems rightfully expensive.

Happy to add code to my answer if you'd like.

ETA: lazy stuff. Because as a programmer, I am going to work hard to be lazy.

So the "expensive" line is in the Person class, specifically:

private static readonly IEqualityComparer<Person> Comparer = AutoEquality<Person>.Create();

And this price gets paid the first time you reference the Person class, whether or not the user/developer utilizes Equals or GetHashCode from Person. So, let's lazify here first:

private static readonly Lazy<IEqualityComparer<Person>> _Comparer = new Lazy<IEqualityComparer<Person>>(AutoEquality<Person>.Create);

Easy peasy. The Equals and GetHashCode need to be adjusted a tiny bit to take advantage of this:

public bool Equals(Person other) => _Comparer.Value.Equals(this, other);
public override bool Equals(object obj) => this.Equals(obj as Person);
public override int GetHashCode() => _Comparer.Value.GetHashCode(this);

Boom. Now that Create method will only be called when one of these methods are called, and, of course, only once.

So, this is nice, but it's a pattern that needs to be implemented in each class, which could be hairy. So maybe a similar approach in the AutoEquality<T> itself? Let's give it a shot (this process is causing me to do some thinking and I've come up with a slightly different implementation which uses a singleton for the type rather than a Create method. Also, I only have VS15/C#6 at work, so I had to fudge the cool auto-Tuple returns of C#7):

AutoEquality<T>:

public sealed class AutoEquality<T> : IEqualityComparer<T>
{
    private static readonly Lazy<IEqualityComparer<T>> _Instance =
        new Lazy<IEqualityComparer<T>>(() => new AutoEquality<T>());

    private readonly Func<T, T, bool> _equals;

    private readonly Func<T, int> _getHashCode;

    private AutoEquality()
    {
        var equalityProperties = typeof(T)
           .GetProperties(BindingFlags.Public | BindingFlags.Instance)
           .Where(propertyInfo => propertyInfo.GetCustomAttribute<EqualityPropertyAttribute>() != null)
           .ToList();
        var expressions = new List<EqualsAndGetHashCodeExpressions>(equalityProperties.Count);
        var leftExpression = Expression.Parameter(typeof(T), "left");
        var rightExpression = Expression.Parameter(typeof(T), "right");

        foreach (var equalityProperty in equalityProperties)
        {
            var equalityPropertyAttribute = equalityProperty.GetCustomAttribute<EqualityPropertyAttribute>();
            var propertyType = equalityProperty.PropertyType;
            var leftPropertyExpression = (Expression)Expression.Property(leftExpression, equalityProperty);
            var rightPropertyExpression = (Expression)Expression.Property(rightExpression, equalityProperty);

            // Treat enums as ints.
            if (propertyType.IsEnum)
            {
                leftPropertyExpression = Expression.Convert(leftPropertyExpression, typeof(int));
                rightPropertyExpression = Expression.Convert(rightPropertyExpression, typeof(int));
                propertyType = typeof(int);
            }

            // String.Equals supports an additional parameter (StringComparison) so it requires special treatment.
            var equalsMethodParameterTypes = new List<Type>(2) { propertyType };

            if (propertyType == typeof(string))
            {
                equalsMethodParameterTypes.Add(typeof(StringComparison));
            }

            var equalsMethod = propertyType.GetMethod(nameof(object.Equals), equalsMethodParameterTypes.ToArray());
            var equalsMethodParameters = new List<Expression>(2) { rightPropertyExpression };

            if (propertyType == typeof(string))
            {
                equalsMethodParameters.Add(Expression.Constant(equalityPropertyAttribute.StringComparison));
            }

            var equalsExpression = Expression.Call(leftPropertyExpression, equalsMethod, equalsMethodParameters);
            var getHashCodeMethod = propertyType.GetMethod(nameof(object.GetHashCode));
            var getHashCodeExpression = Expression.Call(leftPropertyExpression, getHashCodeMethod);

            // String.GetHashCode requires special handling because it generates different hash-codes with
            // different comparers.
            if (propertyType == typeof(string))
            {
                var stringComparer = GetStringComparer(equalityPropertyAttribute.StringComparison);

                getHashCodeMethod = ((Func<string, int>)stringComparer.GetHashCode).Method;
                getHashCodeExpression = Expression.Call(
                    Expression.Constant(stringComparer),
                    getHashCodeMethod,
                    leftPropertyExpression);
            }

            expressions.Add(new EqualsAndGetHashCodeExpressions(equalsExpression, getHashCodeExpression));

            EqualsAndGetHashCodeExpressions composedExpressions;

            if (!TryComposeExpressions(expressions, out composedExpressions))
            {
                continue;
            }

            expressions.Clear();
            expressions.Add(composedExpressions);
        }

        var result = expressions.Single();
        this._equals = Expression
            .Lambda<Func<T, T, bool>>(result.EqualsExpression, leftExpression, rightExpression)
            .Compile();
        this._getHashCode = Expression
            .Lambda<Func<T, int>>(result.GetHashCodeExpression, leftExpression)
            .Compile();
    }

    public static IEqualityComparer<T> Instance => _Instance.Value;

    public bool Equals(T x, T y)
    {
        if (ReferenceEquals(x, y))
        {
            return true;
        }

        return !ReferenceEquals(x, null) && !ReferenceEquals(y, null) && this._equals(x, y);
    }

    public int GetHashCode(T obj)
    {
        if (obj == null)
        {
            throw new ArgumentNullException(nameof(obj));
        }

        return this._getHashCode(obj);
    }

    private static bool TryComposeExpressions(
        IList<EqualsAndGetHashCodeExpressions> expressions,
        out EqualsAndGetHashCodeExpressions result)
    {
        // Compose expressions if there are two of them.
        if (expressions.Count == 2)
        {
            const int LeftExpressionIndex = 0;
            const int RightExpressionIndex = 1;

            // left.Equals(..) && right.Equals(..)
            var andAlsoExpression = Expression.AndAlso(
                expressions[LeftExpressionIndex].EqualsExpression,
                expressions[RightExpressionIndex].EqualsExpression);

            // (left.GetHashCode() * 31) + right.GetHashCode()
            var addExpression = Expression.Add(
                Expression.Multiply(
                    expressions[LeftExpressionIndex].GetHashCodeExpression,
                    Expression.Constant(31)),
                expressions[RightExpressionIndex].GetHashCodeExpression);

            result = new EqualsAndGetHashCodeExpressions(andAlsoExpression, addExpression);
            return true;
        }

        result = new EqualsAndGetHashCodeExpressions(default(Expression), default(Expression));
        return false;
    }

    private static StringComparer GetStringComparer(StringComparison stringComparison)
    {
        switch (stringComparison)
        {
            case StringComparison.CurrentCulture: return StringComparer.CurrentCulture;
            case StringComparison.CurrentCultureIgnoreCase: return StringComparer.CurrentCultureIgnoreCase;
            case StringComparison.InvariantCulture: return StringComparer.InvariantCulture;
            case StringComparison.InvariantCultureIgnoreCase: return StringComparer.InvariantCultureIgnoreCase;
            case StringComparison.Ordinal: return StringComparer.Ordinal;
            case StringComparison.OrdinalIgnoreCase: return StringComparer.OrdinalIgnoreCase;
            default: throw new ArgumentOutOfRangeException($"Invalid {nameof(stringComparison)} value.");
        }
    }
}

EqualsAndGetHashCodeExpressions:

public sealed class EqualsAndGetHashCodeExpressions
{
    public EqualsAndGetHashCodeExpressions(Expression equalsExpression, Expression getHashCodeExpression)
    {
        this.EqualsExpression = equalsExpression;
        this.GetHashCodeExpression = getHashCodeExpression;
    }

    public Expression EqualsExpression { get; }

    public Expression GetHashCodeExpression { get; }
}

So with this, you can remove that Comparer member from Person altogether and replace the methods with:

public bool Equals(Person other) => AutoEquality<Person>.Instance.Equals(this, other);
public override bool Equals(object obj) => this.Equals(obj as Person);
public override int GetHashCode() => AutoEquality<Person>.Instance.GetHashCode(this);

Now I am liking the laziness!

\$\endgroup\$
  • \$\begingroup\$ Thank you ;-) I'm still experimenting with expressions and wasn't quite sure whether I should use abbreviations or full names but I guess it's better to not abbreviate bacause if you want to be consistent then something as silly as exprs emerges. It'd be great if you could add an example or explanation about the lazy design. I think I miss the point here. \$\endgroup\$ – t3chb0t Sep 19 '17 at 13:04
  • 2
    \$\begingroup\$ @t3chb0t added as per request. That was fun! \$\endgroup\$ – Jesse C. Slicer Sep 19 '17 at 18:01
  • \$\begingroup\$ Now I want to add static methods (call them AreEqualand HashCode, maybre?) that call the singleton instance version of them so that a level of indirection can be removed from the call. \$\endgroup\$ – Jesse C. Slicer Sep 19 '17 at 19:09
4
\$\begingroup\$

That looks nice.

A few notes:

  • Enums can have different underlying types, and some are larger than int, which can result in subtle bugs. Use propertyType.GetEnumUnderlyingType() instead.
  • Some documentation in EqualityPropertyAttribute would be useful. For example, only public properties that are decorated with this attribute are taken into account, which isn't necessarily obvious (why not private properties? why not all properties by default?).
  • In Create, in addition to what Jesse said, I'd rename leftExpr and rightExpr to leftParameter and rightParameter.
  • Create does a lot of things. I would split that up into a few helper methods: MakeEqualsExpression(PropertyInfo property, ParameterExpression leftParameter, ParameterExpression rightParameter) and MakeGetHashCodeExpression(...).
  • The use of exprs and TryComposeExpressions is somewhat unclear, but it's basically combining expressions as soon as possible, so you always end up with one (composed) expression. Using a list is a bit 'misleading' because it suggests having multiple expressions.

I'd probably rewrite the main part of Create as following:

var equalsExpression = properties
    .Select(property => MakeEqualsExpression(property, leftParameter, rightParameter))
    .Aggregate(Expression.AndAlso);

var getHashCodeExpression = properties
    .Select(property => MakeGetHashCodeExpression(property, leftParameter, rightParameter))
    .Aggregate((left, right) => Expression.Add(Expression.Multiply(left, Expression.Constant(31)), right));
\$\endgroup\$
  • \$\begingroup\$ Oh, I have to definitely linqify it ;-) \$\endgroup\$ – t3chb0t Sep 19 '17 at 13:24
1
\$\begingroup\$

Here it is, the (hopefully) improved version. I think I've implemented most (if not all) of your suggestions, I like it much better now and I'm pretty happy with it ;-)

The main changes include:

  • the comparer is lazy and does not require instantiation any more
  • it uses a litte bit of reflection for accessing the Equals and GetHashCode methods of a EqualityComparer<T>
  • it uses LINQ to create and aggregate the partial expressions
  • compared types are required to implement the IEquatable<T> interface
  • helper methods are moved to a private ExpressionFactory class
public class AutoEquality<T> : IEqualityComparer<T>
{
    private delegate Expression CreateEqualityComparerMemberExpressionFunc(
        PropertyInfo property,
        EqualityPropertyAttribute attribute,
        Expression leftParameter,
        Expression rightParameter
    );

    private static readonly Lazy<IEqualityComparer<T>> _comparer = new Lazy<IEqualityComparer<T>>(Create);

    private readonly Func<T, T, bool> _equals;
    private readonly Func<T, int> _getHashCode;

    private AutoEquality(Func<T, T, bool> equals, Func<T, int> getHashCode)
    {
        _equals = equals;
        _getHashCode = getHashCode;
    }

    public static IEqualityComparer<T> Comparer => _comparer.Value;

    private static IEqualityComparer<T> Create()
    {
        var leftObjParameter = Expression.Parameter(typeof(T), "leftObj");
        var rightObjParameter = Expression.Parameter(typeof(T), "rightObj");

        var createEqualsExpressionFunc = (CreateEqualityComparerMemberExpressionFunc)ExpressionFactory.CreateEqualsExpression<object>;
        var genericCreateEqualsExpressionMethodInfo = createEqualsExpressionFunc.GetMethodInfo().GetGenericMethodDefinition();

        var createGetHashCodeExpressionFunc = (CreateEqualityComparerMemberExpressionFunc)ExpressionFactory.CreateGetHashCodeExpression<object>;
        var genericCreateGetHashCodeExpressionMethodInfo = createGetHashCodeExpressionFunc.GetMethodInfo().GetGenericMethodDefinition();

        var equalityProperties =
            from property in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)
            let equalityPropertyAttribute = property.GetCustomAttribute<EqualityPropertyAttribute>()
            where equalityPropertyAttribute != null
            let equalsMethod = genericCreateEqualsExpressionMethodInfo.MakeGenericMethod(property.PropertyType)
            let getHashCodeMethod = genericCreateGetHashCodeExpressionMethodInfo.MakeGenericMethod(property.PropertyType)
            let parameters = new object[] { property, equalityPropertyAttribute, leftObjParameter, rightObjParameter }
            select
            (
                EqualsExpression: (Expression)equalsMethod.Invoke(null, parameters),
                GetHashCodeExpression: (Expression)getHashCodeMethod.Invoke(null, parameters)
            );

        var equalityComparer = equalityProperties.Aggregate((next, current) =>
        (
            EqualsExpression: Expression.AndAlso(current.EqualsExpression, next.EqualsExpression),
            GetHashCodeExpression: Expression.Add(Expression.Multiply(current.GetHashCodeExpression, Expression.Constant(31)), next.GetHashCodeExpression)
        ));

        var equalsFunc = Expression.Lambda<Func<T, T, bool>>(equalityComparer.EqualsExpression, leftObjParameter, rightObjParameter).Compile();
        var getHashCodeFunc = Expression.Lambda<Func<T, int>>(equalityComparer.GetHashCodeExpression, leftObjParameter).Compile();

        return new AutoEquality<T>(equalsFunc, getHashCodeFunc);
    }

    public bool Equals(T left, T right)
    {
        if (ReferenceEquals(left, right)) return true;
        if (ReferenceEquals(left, null)) return false;
        if (ReferenceEquals(right, null)) return false;
        return _equals(left, right);
    }

    public int GetHashCode(T obj)
    {
        return _getHashCode(obj);
    }

    private static class ExpressionFactory
    {
        private static readonly IDictionary<StringComparison, IEqualityComparer<string>> StringComparers = new Dictionary<StringComparison, IEqualityComparer<string>>
        {
            [StringComparison.CurrentCulture] = StringComparer.CurrentCulture,
            [StringComparison.CurrentCultureIgnoreCase] = StringComparer.CurrentCultureIgnoreCase,
            [StringComparison.InvariantCulture] = StringComparer.InvariantCulture,
            [StringComparison.InvariantCultureIgnoreCase] = StringComparer.InvariantCultureIgnoreCase,
            [StringComparison.Ordinal] = StringComparer.Ordinal,
            [StringComparison.OrdinalIgnoreCase] = StringComparer.OrdinalIgnoreCase,
        };

        public static Expression CreateEqualsExpression<TProperty>(
            PropertyInfo property,
            EqualityPropertyAttribute attribute,
            Expression leftParameter,
            Expression rightParameter)
        {
            if (property.PropertyType == typeof(string) || property.PropertyType.IsEnum)
            {
                // Short-cut to have compiler write this expression for us.
                var equalsFunc = (Expression<Func<TProperty, TProperty, bool>>)((x, y) => GetComparer<TProperty>(attribute).Equals(x, y));

                return Expression.Invoke(
                    equalsFunc,
                    Expression.Property(leftParameter, property),
                    Expression.Property(rightParameter, property)
                );
            }

            // Call the instance 'Equals' method by default only if the type implements the 'IEquatable<T>' interface.

            var genericEquatable = typeof(IEquatable<>);
            var propertyEquatable = genericEquatable.MakeGenericType(property.PropertyType);

            if (!property.PropertyType.GetInterfaces().Contains(propertyEquatable))
            {
                throw new ArgumentException("Property type needs to be an 'IEquatable<>'.");
            }

            var equalsMethod = property.PropertyType.GetMethod(
                nameof(IEquatable<object>.Equals),
                new Type[] { property.PropertyType }
            );

            return Expression.Call(Expression.Property(leftParameter, property), equalsMethod, Expression.Property(rightParameter, property));
        }

        // The 'rightParameter' argument is not used but by having the same signature for both methods allows for a few optimizations in other places.
        public static Expression CreateGetHashCodeExpression<TProperty>(
            PropertyInfo property,
            EqualityPropertyAttribute attribute,
            Expression leftParameter,
            Expression rightParameter)
        {
            if (property.PropertyType == typeof(string) || property.PropertyType.IsEnum)
            {
                // Short-cut to have compiler write this expression for us.
                var getHashCodeFunc = (Expression<Func<TProperty, int>>)((obj) => GetComparer<TProperty>(attribute).GetHashCode(obj));

                return Expression.Invoke(
                    getHashCodeFunc,
                    Expression.Property(leftParameter, property)
                );
            }

            // Call the instance 'GetHashCode' method by default.

            var getHashCodeMethod = property.PropertyType.GetMethod(nameof(object.GetHashCode));

            return Expression.Call(
                Expression.Property(leftParameter, property),
                getHashCodeMethod
            );
        }

        private static IEqualityComparer<TProperty> GetComparer<TProperty>(EqualityPropertyAttribute attribute)
        {
            if (typeof(TProperty) == typeof(string))
            {
                return (IEqualityComparer<TProperty>)StringComparers[attribute.StringComparison];
            }

            return EqualityComparer<TProperty>.Default;
        }
    }
}

The usage is now easier, only requires setting the attributes and calling the respective comparer methods:

public partial class Person
{
    [EqualityProperty(StringComparison.OrdinalIgnoreCase)]
    public string FirstName { get; set; }

    [EqualityProperty]
    public string LastName { get; set; }

    [EqualityProperty]
    public DateTime DateOfBirth { get; set; }

    [EqualityProperty]
    public Size ShoeSize { get; set; }

    public string Nickname { get; set; }            
}

public partial class Person : IEquatable<Person>
{
    public override int GetHashCode() => AutoEquality<Person>.Comparer.GetHashCode(this);

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

    public bool Equals(Person other) => AutoEquality<Person>.Comparer.Equals(this, other);
}
\$\endgroup\$
0
\$\begingroup\$

It's a nice implementation of a common problem but as Jesse C. Slicer mentioned, rather expensive.

A more practical solution would be to use code generation tools like Resharper to generate the equality members.

See: ReSharper Help - Generating Equality Members

\$\endgroup\$
  • \$\begingroup\$ It isn't expensive in real-time: all the cost is stuffed into the initialisation, and afterwards the compiled expressions should run 'fast'. The concern is that the OP has decided to make construction explicit, which allows an unwary user to kill their performance by repeatedly constructing the comparer, but an aware user will cache (and if suitable, lazily load) the comparer (caching shown in the OP's example); this is a design decision. There are benefits to having a run-time solution (for starters, you don't need IDE support!) (Also, for those of us without ReSharper, VS has this now also) \$\endgroup\$ – VisualMelon Sep 19 '17 at 8:31
  • \$\begingroup\$ Correct. It probably won't impact performance once the comparer is created. But if you would add this to each and every business object you have, this could impact your performance. \$\endgroup\$ – Limeray Sep 20 '17 at 13:06

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.