“Expression trees” en 2 min

Quelques définitions en préambule

Avant de rentrer dans les détails des arbres d’expression (i.e. expression trees), il faut définir quelques termes.

Delegate

Il s’agit du type d’une référence vers une méthode comportant une signature particulière. Le delegate définit donc le type de la référence et non pas la référence elle-même. Par exemple, en C# un delegate peut se définir de cette façon:

public delegate int AddDelegate(int a, int b);

La méthode suivante possède une définition compatible avec le delegate en raison de sa signature:

public static int Add(int a, int b) 
{ 
    return a + b; 
}

On peut donc instancier le delegate et l’exécuter de cette façon:

AddDelegate delegateInstance = Add; 
int result = delegateInstance(3, 5);

A partir du C# 2.0, il est possible d’avoir une notation plus directe pour définir les delegates:

AddDelegate delegateInstance = delegate(int a, int b)  
{
    return a + b; 
};

Expression lambda

Une expression lambda est une notation permettant de créer des types de delegates ou d’arbres d’expression. Les expressions lambda utilise une notation utilisant l’opérateur “=>”.

Si on prends l’exemple précédent, on peut utiliser une expression lambda pour définir le delegate:

AddDelegate delWithLambda = (a, b) => a + b;

Cette notation est un raccourci pour:

AddDelegate delWithLambda = (a, b) => { return a + b; };

Le delegate s’exécute de la même façon que précédemment:

int result = delWithLambda(3, 5);

Les expressions lambda sont apparues avec C# 3.0.

Action<T> et Func<T, TResult>

Il s’agit de delegates prédéfinis pour faciliter l’utilisation de delegates et d’expression lambda. L’inconvénient de l’exemple précédent est qu’il nécessite la définition du delegate AddDelegate:

public delegate int AddDelegate(int a, int b);

Pour éviter de définir des delegates avant d’utiliser des expressions lambda, on peut utiliser Action et Func:

  • Action<T> correspond à des delegates de méthodes (par de type de retour) de 0 ou plusieurs arguments.
  • Func<T, TResult> correspond à des delegates de fonctions de 0 ou plusieurs arguments avec un résultat.

Dans l’exemple précédent, si on utilise Func<T, TResult>:

Func<int, int, int> addWithFunc = (a, b) => a + b;

Une autre notation est équivalente (peu utilisée car plus lourde):

Func<int, int, int> addWithFunc = delegate(a, b) { return a + b; };

Les types Action<T> et Func<T, TResult> sont apparus avec le framework .NET 3.5.

Expression

En C#, le type Expression désigne un objet permettant de représenter une expression lambda sous la forme d’un arbre d’expressions (i.e. expression tree). Ce type se trouve dans le namespace System.Linq.Expressions, il s’utilise sous la forme:
Expression<Func<TResult>> ou Expression<TDelegate>TDelegate est un delegate définit au préalable.

Ainsi Expression<Func<TResult>> correspond à la représentation fortement typée d’une expression lambda, elle ne contient pas seulement sa définition mais aussi toute sa description.
Expression<TDelegate> dérive de la classe abstraite System.Linq.Expressions.LambdaExpression qui correspond à la classe de base pour représenter une expression lambda sous forme d’un arbre d’expressions:

public sealed class Expression<TDelegate> : LambdaExpression
Expression<TDelegate> et Expression

Il ne faut pas confondre les classes Expression<TDelegate> et Expression car Expression<TDelegate> dérive de LambdaExpression et LambdaExpression dérive de Expression.

Intérêt de Expression<Func<TResult>> par rapport à Func<TResult>

L’intérêt de ce type est de pouvoir intervenir sur cette description pour l’utiliser pour comprendre ce que contient l’expression, la modifier ou simplement l’exécuter. A la différence, un delegate Func<TResult> peut seulement être exécuté. Par exemple, il sera plus compliqué de modifier l’implémentation d’un delegate Func<TResult> au runtime alors qu’il est possible de la faire avec Expression<Func<TResult>>.

Il n’est pas facile de comprendre la différence entre Expression<Func<TResult>> et Func<TResult>:

  • Func<TResult> désigne un delegate prédéfini correspondant à un fonction sans arguments et retournant un paramètre de type TResult.
  • Expression<Func<TResult>> désigne toute la description du delegate Func<TResult>.

Par exemple, si on considère l’expression:

Expression<Func<int, int, int>> expression = (a, b) => a + b;

On peut voir que le contenu de l’objet expression par rapport à un Func équivalente.
Par exemple le contenu de:

Func<int, int, int> func = (a, b) => a + b;

est:

On peut voir que func est bien un delegate, il n’y a plus de traces de l’implémentation d’origine: (a, b) => a + b.

Le contenu de l’objet expression est:

Dans cet objet, il y a tout le détail de l’implémentation de l’expression lambda (a, b) => a + b.

Ainsi, Expression<Func<TResult>> décrit ce que fait l’expression lambda alors que Func<TResult> permet seulement de l’exécuter.

Manipulation des Expression<Func<TResult>>

Construction

Il est possible de construire un arbre d’expressions de 2 façons: avec une expression lambda ou avec l’API Expressions.

Par exemple, en utilisant une expression lambda:

Expression<Func<int, int, int>> expression = (a, b) => a + b;

De même en utilisant l’API:

Using System.Linq.Expressions;
 
// ...

// "a" ne sert que pour la documentation
ParameterExpression aParam = Expression.Parameter(typeof(int), "a");  
ParameterExpression bParam = Expression.Parameter(typeof(int), "b"); 
BinaryExpression add = Expression.Add(aParam, bParam); 
Expression<Func<int, int, int>> expression = 
    Expression.Lambda<Func<int, int, int>>(add, new ParameterExpression[] { aParam, bParam });

L’API permet d’implémenter un grand nombre de fonctions avec les fonctions statiques de la classe System.Linq.Expressions.Expression.

Par exemple:

Fonction Permet de définir
Expression.Parameter() Un argument
Expression.Constant() Une constante
Expression.Assign() Affecter une expression à un argument
Expression.Add()
Expression.Multiply()
Expression.Divide()
Expression.Not()
Expression.And()
Expression.Or()
Des opérations sur les arguments
Expression.Label() Un label qui va permettre d’effectuer des sauts dans le code (GOTO)
Expression.Goto() Un saut dans le code (GOTO)
Expression.LessThan()
Expression.GreaterThan()
Expression.LessThanOrEqual()
Expression.GreaterThanOrEqual()
Expression.Equal()
Expression.NotEqual()
Des opérations de comparaison sur les arguments
Expression.Lambda() Permet de construire un delegate
Expression.Block() Permet de construire le corps d’une méthode
Expression.Loop() Permet de définir une boucle de programmation. Par exemple:
Expression.Loop([Expression contenant le code à évaluer], Expression.Break(...))
Expression.Break() Arrête une boucle Loop et effectue un saut à un “Label” particulier.
Expression.IfThen()
Expression.IfThenElse()
Permet de créer une expression conditionnelle
Expression.IsFalse()
Expression.IsTrue()
Evalue une expression
Expression.New() Crée une expression permettant d’effectuer un appel à un constructeur.
Expression.TryCatch()
Expression.TryCatchFinally()
Expression.Finally()
Un bloc try…catch…finally
Etc…

La liste exhaustive des fonctions se trouve sur MSDN.

Convertir un arbre d’expression en delegate (Expression<Func> en Func)

On peut convertir un objet Expression<Func<TResult>> en Func<TResult> avec la méthode Expression.Compile().

Par exemple:

Expression<Func<int, int, int>> expression = (a, b) => a + b; 
Func<int, int, int> funcFromExpression = expression.Compile();

On peut évaluer le delegate avec:

int result = funcFromExpression(3, 9);
Convertir un delegate en arbre d’expressions

Un delegate est une méthode compilée, pour obtenir l’arbre d’expressions correspondant à ce delegate, il faudrait décompiler la méthode et la convertir en arbre d’expressions. Il n’y a pas de méthodes directes pour effectuer ces étapes.

Convertir un delegate en arbre d’expression (Func en Expression<Func>)

On ne peut pas convertir un delegate Func<TResult> en Expression<Func<TResult>> car les éléments définissant l’arbre d’expressions sont perdus lorsqu’on compile le delegate en faisant Expression.Compile().

On peut toutefois construire une expression qui appelle un delegate en faisant:

Func<int, int, int> func = (a, b) => a + b;
Expression<Func<int>> expressionCallingFunc = Expression.Lambda<Func<int, int, int>>(
    Expression.Call(func.Method));

L’expression obtenue ne fait qu’appeler un delegate. Si le delegate a été obtenu à partir d’une expression avec Expression.Compile(), on ne peut pas retrouver de cette façon l’expression d’origine.

Analyser un arbre d’expressions

Comme indiquer un des intérêts des arbres d’expressions est de pouvoir analyser une expression lambda avec des instructions. On peut ainsi décomposer l’expression lambda en utilisant les propriétés de la classe System.Linq.Expressions.LambdaExpression (la classe Expression<TDelegate> dérive de la classe LambdaExpression):

  • LambdaExpression.Body: pour obtenir une expression contenant le corps de l’expression lambda.
  • LambdaExpression.Parameters: pour obtenir les arguments.

Par exemple:

Expression<Func<int, int, int>> expression = (a, b) => a + b; 
BinaryExpression addOperation = (BinaryExpression)expression.Body; 
ParameterExpression aParam = (ParameterExpression)addOperation.Left; 
ParameterExpression bParam = (ParameterExpression)addOperation.Right;

Manipuler un arbre d’expression avec ExpressionVisitor

Il est possible d’explorer et d’analyser un arbre d’expressions plus facilement qu’en utiliser des propriétés avec un objet de type ExpressionVisitor. Cet objet utilise le design pattern Visiteur.
Pour utiliser cette méthode, il faut dériver de la classe abstraite System.Linq.Expressions.ExpressionVisitor et surcharger les fonctions virtuelles correspondant aux parties de l’expression qu’on souhaite analyser.

Par exemple:

  • ExpressionVisitor.VisitBinary(): sera exécutée pour les nœuds de l’arbre qui sont des opérations (i.e. BinaryExpression).
  • ExpressionVisitor.VisitParameter(): sera exécutée pour les arguments d’une expression (i.e. ParameterExpression).
  • ExpressionVisitor.VisitBlock(): sera exécutée pour les nœuds correspondant au corps d’une fonction (i.e. BlockExpression).
  • Etc…

Se reporter à MSDN pour avoir la liste exhaustive de toutes les méthodes virtuelles.

Pour que les méthodes de la classe soient exécutées, il faut appeler la méthode ExpressionVisitor.Visit().

Par exemple si on dérive de ExpressionVisitor:

public class CustomExpressionVisitor : ExpressionVisitor 
{ 
    protected override Expression VisitBinary(BinaryExpression node) 
    { 
        // ... 
        return node; 
    } 
 
    protected override Expression VisitParameter(ParameterExpression node) 
    { 
        // ... 
        return base.VisitParameter(node); 
    } 
}

Les fonctions surchargées seront exécutées en faisant:

Expression<Func<int, int, int>> addExpression = (a, b) => a + b; 
var expressionVisitor = new CustomExpressionVisitor(); 
expressionVisitor.Visit(addExpression.Body);

L’exécution des méthodes se fera dans l’ordre des expressions dans l’arbre d’expressions.

Par exemple, si on définit le “visiteur”:

public class CustomExpressionVisitor : ExpressionVisitor 
{ 
    protected override Expression VisitBinary(BinaryExpression node) 
    { 
        Console.Write("("); 
        this.Visit(node.Left); 
        Console.Write(" {0} ", node.NodeType); 
        this.Visit(node.Right); 
        Console.Write(")"); 
        return node; 
    } 
 
    protected override Expression VisitParameter(ParameterExpression node) 
    { 
        Console.Write("parameter({0})", node.Name); 
        return base.VisitParameter(node); 
    } 
}

A l’exécution, on aura:

((parameter(a) Add (parameter(b))

Arbre d’expressions immutable

Les arbres d’expressions sont immutables c’est-à-dire qu’il n’est pas possible de le modifier après sa création. Toutefois pour profiter de l’intérêt des arbres, on peut utiliser l’API pour créer des arbres différents suivant les besoins ou utiliser un “visiteur” ExpressionVisitor pour dupliquer un arbre et effectuer des modifications pendant cette duplication.

Par exemple, en utilisant l’API on peut créer plusieurs arbres:

ParameterExpression aParam = Expression.Parameter(typeof(int), "a");  
ParameterExpression bParam = Expression.Parameter(typeof(int), "b");  
BinaryExpression add = Expression.Add(aParam, bParam); 
 
ParameterExpression cParam = Expression.Parameter(typeof(int), "c");  
BinaryExpression divide = Expression.Divide(add, cParam); 
 
Expression<Func<int, int, int>> firstExpression = 
    Expression.Lambda<Func<int, int, int>>(add, new ParameterExpression[] { aParam, bParam }); 
Expression<Func<int, int, float>> secundExpression = 
    Expression.Lambda<Func<int, int, float>>(divide, new ParameterExpression[] { aParam, bParam, cParam });

Comparaison entre Expression<Func<TResult>> et Func<TResult>

La plupart du temps, on utilisera plutôt Func<TResult> car il est rarement nécessaire d’analyser une expression lambda. Toutefois dans certains cas, l’utilisation de Func<TResult> peut avoir des conséquences inattendues.

IEnumerable et IQueryable

Ces 2 types d’objet sont des structures de liste qui conviennent dans des cas assez différents. Ainsi dans le cadre de Entity Framework ou LinQ-to-SQL, certaines méthodes d’extension sur le type IEnumerable possèdent des arguments de type Func<TResult>.

Par exemple la signature de la fonction System.Linq.Enumerable.Where():

public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source,  
   Func<TSource, bool> predicate)

A l’opposé, certaines méthodes d’extension sur IQueryable possèdent des arguments de type Expression<Func<TResult>>, si on compare la signature avec la fonction équivalente System.Linq.Queryable.Where():

public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source,  
    Expression<Func<TSource, bool>> predicate)

Ainsi les méthodes d’extension de IEnumerable utilisent des delegates alors que les méthodes d’extension de IQueryable utilisent des arbres d’expressions.

Dans le cadre de Entity Framework et LinQ-to-SQL, cette différence peut avoir des conséquences non négligeables puisque la conversion de code C# vers des requêtes SQL ne peut se faire qu’à partir d’objets de type Expression. Ce sont ces objets Expression qui seront analysés pour déterminer la requête à exécuter dans la base SQL.

Si on utilise des delegates pour effectuer ces requêtes, il ne peut pas y avoir d’évaluation de ces delegates en base de données. Donc pour effectuer le traitement, il faut récupérer tout le contenu de la table sur laquelle la requête doit être exécutée. Ensuite, le delegate fournit en argument de la fonction Where() sera executé sur toutes les lignes pour obtenir le résultat. La méthode d’extension utilisant de delegate est donc moins efficace que son équivalent utilisant un arbre d’expression puisqu’on devra récupérer tout le contenu de la table.

Sachant que IQueryable dérive de IEnumerable, suivant le type de l’argument utilisé pour les fonctions, des surchages différentes seront exécutées:

  • Avec Expression<Func<TResult>>, les surcharges IQueryable seront exécutées,
  • Func<TResult>, ça sera plutôt les surcharges IEnumeable qui seront exécutées.

Entity Framework

Comme indiqué plus haut, suivant le type d’argument utilisé entre Expression<Func<TResult>> et Func<TResult>, Entity Framework aura un comportement différent:

  • Avec Expression<Func<TResult>>: Entity Framework va analyser l’expression pour en déduire la requête SQL à exécuté en base.
  • Avec Func<TResult>: Entity Framework récupère tout le contenu de la table ou des tables sur lesquelles porte la requête, le place dans le contexte et ensuite, exécute le delegate pour effectuer le tri.

Ainsi si on considère une table PERSON avec 3 colonnes:

  • FirstName
  • LastName
  • Age

En exécutant:

public IEnumerable<Person> Where(DbEntities dbContext, Func<Person, bool> whereClause) 
{ 
    return dbContext.Persons.Where(where); 
}

La requête suivante sera exécutée en base:

SELECT  
[Extent1].[LastName] AS [LastName],  
[Extent1].[FirstName] AS [FirstName],  
[Extent1].[Age] AS [Age] 
FROM [dbo].[Persons] AS [Extent1]

De même, on exécutant:

public IEnumerable<Person> Where(DbEntities dbContext, Expression<Func<Person, bool>> whereClause) 
{ 
    return dbContext.Persons.Where(where); 
} 
 
var results = Where(dbContext, p => p.Age > 25);

La requête comportera une clause WHERE:

SELECT  
[Extent1].[LastName] AS [LastName],  
[Extent1].[FirstName] AS [FirstName],  
[Extent1].[Age] AS [Age] 
FROM [dbo].[Persons] AS [Extent1] 
WHERE [Extent1].[Age] > 25

Sérialisation

Les delegates ne sont pas sérialisables, ainsi les Func<T, TResult> et Action<T> ne sont pas sérialisables. Ceci s’explique par le fait que les delegates sont compilés en instructions IL pour être exécuté, au runtime il ne s’agit pas d’une structure mais d’une suite d’instructions IL.

A l’opposé, un objet de type Expression<Func<T, Result>> n’est pas compilé en instructions IL au runtime. Cet objet reste une structure qui, par construction, a été implémentée pour être sérialisable. Comme indiqué plus haut, pour qu’un objet de type Expression soit exécutée, il faut exécuter l’instruction Expression.Compile(). Cette instruction transformera la structure en fonction anonyme exécutable.

Leave a Reply