Skip to main content

Command Palette

Search for a command to run...

Understanding C# Expression Trees: Code as Data

Published
•5 min read•View as Markdown

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 ParameterExpression representing s.

    • Body: The logic of the lambda. Here, it's a BinaryExpression.

  • BinaryExpression (==): Represents an operation with two operands.

    • NodeType: Equal

    • Left: A MemberExpression representing s.Gender.

    • Right: A ConstantExpression representing 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

Expression Factory Method

Example Code

The input s

Expression.Parameter

var parameter = Expression.Parameter(typeof(Student), "s");

The property s.Gender

Expression.Property

var member = Expression.Property(parameter, "Gender");

The value "Male"

Expression.Constant

var constant = Expression.Constant("Male");

The comparison ==

Expression.Equal

var body = Expression.Equal(member, constant);

The entire lambda s => ...

Expression.Lambda

var lambda = Expression.Lambda<Func<Student, bool>>(body, parameter);

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:

  1. It sees the LambdaExpression.

  2. It traverses to the BinaryExpression body and sees the Equal node type, so it prepares to write a = in SQL.

  3. It visits the left side, finds the MemberExpression for the Gender property, and translates it to the column name [Gender].

  4. It visits the right side, finds the ConstantExpression for "Male", and translates it into a SQL parameter 'Male'.

  5. 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 with Func<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.
  • IQueryable<T>: Works with Expression<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 Framework DbContext.

āš ļø 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.