Combiner des Expressions Trees

Il y a tout juste quelques jours, on pair programmait avec Anthony sur un projet. Nous nous sommes retrouvés a vouloir combiner au runtime des Expressions trees en fonction de parametres évalués au runtime.

En gros on avait envie d'écrire un truc du genre

Expression<Func<Person, bool>> predicate = x => x.Id > 1;

puis un peu plus loin

predicate += x => x.Name.Length < 6;

Ne cherchez pas à compiler ca, le compilo risque de se rebeller.

On était un peu pris par le temps, du coup on a fait un simple. Néanmoins je profite de cet espace pour partager avec vous une facon de faire pour combiner des Expressions et faire du code un peu plus sexy

Pour faire plaisir à maitre Etienne on va travailler a base de tests unitaires.

Commençons par la version naïve; En cherchant un peu sur la msdn on tombera facilement sur Expression.AndAlso permettant de créer une nouvelle expression qui combine deux Expressions left et right

        [Fact]
        public void SimpleCombining_Fails()
        {
            // Arrange
            Expression<Func<Person, bool>> firstPredicate = x => x.Id > 1;
            Expression<Func<Person, bool>> secondPredicate = x => x.Name.Length < 6;
            
            Expression binaryexp = Expression.AndAlso(firstPredicate.Body, secondPredicate.Body);
            ParameterExpression[] parameters = {
                Expression.Parameter(typeof(Person), firstPredicate.Parameters.First().Name)
            };
            
            Expression<Func<Person, bool>> lambda = Expression.Lambda<Func<Person, bool>>(binaryexp, parameters);
            
            // Act
            Action filter = () => {
                var combinedPredicate = lambda.Compile();
                var result = _persons.Where(combinedPredicate);
            };

            // Assert
            Assert.Throws<InvalidOperationException>(() => filter());
        }

On commence par définir nos deux prédicats (left & right) que l'on combine dans une expression binaire AndAlso (et logique) en utilisant comme paramètre générique T le paramètre utilisé dans la premiere expression initiale.
On essaye par la suite d'utiliser la lambda créée pour l'occasion dans une instruction Where que vous connaissez tous en Linq.

Bon, j'avais déjà vendu la méche en disant que c'était une version naïve; effectivement a l'execution vous allez avoir une InvalidOperationException

System.InvalidOperationException: 'variable 'x' of type 'AndOrExpressions.Person' referenced from scope '', but it is not defined'

Pour faire simple, dans nos deux expressions initiales, le paramètre x bien qu'il se nomme de la même maniere et soit du même type, il ne s'agit pas du meme objet. x est donc inconnu dans la seconde expression.

Etant donné que les Expressions sont des objets immutables il va nous falloir un moyen de créer/réécrire une expression donnée.

C'est pour ce genre de cas qu'MS a mis à notre disposition la classe ExpressionVisitor. Celle-ci nous permet de traverser l'expression tree et d'apporter des modifications. Rappelez vous que chaque modification produira une nouvelle expression, c'est le principe même de l'immutabilité.

Ok, commencons par créer une visiteur qui remplacera un paramètre T par un autre passé en paramètre.

public class ReplaceExpressionVisitor : ExpressionVisitor
    {
        private readonly Expression _searched;
        private readonly Expression _replacement;

        public ReplaceExpressionVisitor(Expression searched, Expression replacement)
        {
            _searched = searched;
            _replacement = replacement;
        }

        public override Expression Visit(Expression node)
        {
            if (node == _searched)
            {
                return _replacement;
            }   
            return base.Visit(node);
        }
    }

Simple, non?

Du coup si on réécrivait notre test de la facon suivante en imaginant une methode And qui permet de combiner des Expressions

[Fact]
        public void ReplacingType_Succeed()
        {
            // Arrange
            Expression<Func<Person, bool>> firstPredicate = x => x.Id > 1;
            Expression<Func<Person, bool>> secondPredicate = x => x.Name.Length < 6;

            Func<Person, bool> combinedPredicate = firstPredicate.And(secondPredicate);
            
            // Act
            var result = _persons.Where(combinedPredicate);

            // Assert
            Assert.Collection(result, people => Assert.Equal(people, Anto));
        }

Il ne nous reste qu'à définir une methode d'extension qui répond a cette signature, comme ceci :

public static class ExpressionsExtensions
    {
        public static Func<T, bool> And<T>(this Expression<Func<T, bool>> firstOperand, Expression<Func<T, bool>> secondOperand)
        {
            var replacement = Expression.Parameter(typeof(T));

            var leftVisitor = new ReplaceExpressionVisitor(firstOperand.Parameters[0], replacement);
            var left = leftVisitor.Visit(firstOperand.Body);

            var rightVisitor = new ReplaceExpressionVisitor(secondOperand.Parameters[0], replacement);
            var right = rightVisitor.Visit(secondOperand.Body);

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

Cette methode va combiner avec Expression.AndAlso nos deux expressions initiales, mais cette fois ci on aura prit soin de les réécrire en utilisant le même paramètre générique. Et évidemment cette fois-ci, le test est vert :)