Understanding C# Expression Trees: Code as Data
At its core, an Expression Tree is a data structure that represents code in a tree-like format. Instead of being compiled into executable instructions, your lambda expression is deconstructed into an object model that you can inspect, modify, and translate.
The key distinction to remember is between Func<T> and Expression<Func<T>>:
Func<Student, bool> myFunc = s => s.Gender == "Male";This creates a delegate, which is compiled, executable code. You can invoke it (myFunc(someStudent)), but you cannot easily see how it works internally. It's like a baked cake. šExpression<Func<Student, bool>> myExpr = s => s.Gender == "Male";This creates an expression tree. It is not compiled code. It is a data structure representing the logic "check if the Gender property of a Student object equals the string 'Male'". You can analyze this structure. It's like a written recipe that you can read, modify, or even translate into another language. š
The Anatomy of an Expression Tree
An expression tree is composed of nodes, where each node is an object of a type derived from System.Linq.Expressions.Expression.
Let's break down s => s.Gender == "Male":
LambdaExpression (
=>): The root of the tree. It has two main parts:Parameters: A list of the input parameters. In this case, one
ParameterExpressionrepresentings.Body: The logic of the lambda. Here, it's a
BinaryExpression.
BinaryExpression (
==): Represents an operation with two operands.NodeType:
EqualLeft: A
MemberExpressionrepresentings.Gender.Right: A
ConstantExpressionrepresenting the string"Male".
Building Expression Trees
You can create expression trees in two primary ways.
1. Compiler-Generated (The Common Way)
This is the easiest method. You simply assign a lambda to a variable of type Expression<TDelegate>, and the C# compiler builds the tree structure for you.
// The compiler builds the tree for you.
Expression<Func<Student, bool>> filter = s => s.HeightCm > 170;
2. Manual Construction (The Powerful Way)
For truly dynamic scenarios, you can build a tree piece by piece using the static factory methods on the Expression class. This is what our FilterExpressionBuilder did.
C# Lambda Part |
| Example Code |
The input |
|
|
The property |
| |
The value |
|
|
The comparison |
|
|
The entire lambda |
|
|
Consuming and Using Expression Trees
Once you have an expression tree, you can do two main things with it.
1. Compile and Execute
You can convert the tree back into executable code using the .Compile() method. This is useful if you build a dynamic function and then want to run it multiple times.
Expression<Func<int, int, int>> addExpression = (a, b) => a + b;
// Compile the tree into a delegate
Func<int, int, int> addFunc = addExpression.Compile();
// Invoke the compiled delegate
int result = addFunc(5, 3); // result is 8
2. Inspect and Translate (The Real Power)
This is the most important use case, especially for Object-Relational Mappers (ORMs) like Entity Framework Core. An ORM doesn't compile the expression. Instead, it traverses the tree to translate it into another language, like SQL.
When Entity Framework sees our expression s => s.Gender == "Male", its internal "visitor" does this:
It sees the
LambdaExpression.It traverses to the
BinaryExpressionbody and sees theEqualnode type, so it prepares to write a=in SQL.It visits the left side, finds the
MemberExpressionfor theGenderproperty, and translates it to the column name[Gender].It visits the right side, finds the
ConstantExpressionfor"Male", and translates it into a SQL parameter'Male'.Result: It generates the SQL
WHERE [Gender] = @p0.
Critical Concepts and Corner Cases
IQueryable<T> vs. IEnumerable<T>
This is the most critical distinction when working with expressions.
IEnumerable<T>: Works withFunc<T>delegates (compiled code). Its LINQ methods (.Where(),.Select()) expect executable functions. Operations happen in-memory on your application server.- Use Case: Filtering a
List<Student>that you already have in memory.
- Use Case: Filtering a
IQueryable<T>: Works withExpression<Func<T>>trees (code as data). Its LINQ methods are designed to receive expression trees so they can be translated. Operations happen out-of-process (e.g., on the SQL server).- Use Case: Querying a
DbSet<Student>from an Entity FrameworkDbContext.
- Use Case: Querying a
ā ļø The Performance Trap: Be careful when mixing the two. If you have an IQueryable<T> and call .AsEnumerable() or .ToList() too early, you will pull the entire database table into memory and then perform the filtering locally, which can be extremely inefficient.
// BAD: Fetches ALL students, then filters in C#
var inefficient = dbContext.Students.ToList().Where(s => s.Gender == "Male");
// GOOD: EF Core translates the Where to SQL, fetching only male students
var efficient = dbContext.Students.Where(s => s.Gender == "Male").ToList();
Combining Expressions
In our exercise, we combined multiple filter criteria. This is done by taking the Body of two expressions and joining them with a logical operator like Expression.AndAlso. AndAlso is the C# && (short-circuiting), while And is the & (bitwise), so AndAlso is almost always what you want.
Method Calls in Expressions
To represent a method call like string.Contains(), you must use Expression.Call. This requires a MethodInfo object, which you get via reflection (typeof(string).GetMethod("Contains", ...)). This is a corner case that shows how expressions handle more than just simple property access and binary operations.
Closures and Captured Variables
When your lambda uses a local variable from the outside scope, it's called a closure.
string genderToFind = "Male";
var expression = dbContext.Students.Where(s => s.Gender == genderToFind);
The C# compiler is smart. It wraps genderToFind in a ConstantExpression. EF Core then sees this and correctly parameterizes the SQL query, preventing SQL injection.