Expression Trees and Metaprogramming

A lambda can be compiled into two different things:

Concept

A lambda can be compiled into two different things:

Func<int, bool> A delegate — executable IL code Expression<Func<int, bool>>

An expression tree — a data structure describing the code

An expression tree is made of nodes (BinaryExpression, ParameterExpression, ConstantExpression, MemberExpression, MethodCallExpression, ConditionalExpression, ...). You can:

Inspect it (NodeType, Left, Right, Body, Parameters).

Translate it into another language — this is how LINQ providers such as EF Core turn Where(e => e.FaultCount > 2) into SQL (IQueryable<T> stores expression trees, IEnumerable<T> stores delegates).

Build it at run time with the Expression factory methods and compile it into a fast delegate — dynamic filters, rule engines, fast property accessors (much faster than repeated reflection).

Rewrite it with an ExpressionVisitor.

Limitations: expression-tree lambdas can't contain statement bodies, async, assignment operators or newer syntax such as ?.; and Compile() is expensive, so cache the result.

Why It Matters

Expression trees are the technology behind EF Core queries, IQueryable, mocking libraries (Setup(x => x.Read(0xF190))), and configurable rule engines — for example evaluating calibration limits defined in a config file without writing an interpreter.

Syntax

Expression<Func<Ecu, bool>> expr = e => e.FaultCount > 2;
var p = Expression.Parameter(typeof(int), "x");
var lambda = Expression.Lambda<Func<int, bool>>(Expression.GreaterThan(p, Expression.Constant(5)), p);
Func<int, bool> f = lambda.Compile();

Example

38.1 Inspect, build, compile and translate expression trees

#:property PublishAot=false
using System.Linq.Expressions;

// A lambda can become a delegate (code) or an expression tree (data describing code)
Func<int, bool> asDelegate = x => x > 100;
Expression<Func<int, bool>> asTree = x => x > 100;
Console.WriteLine($"delegate: {asDelegate(150)}; expression tree: {asTree}  (node type {asTree.Body.NodeType})");

// Inspect the tree
var body = (BinaryExpression)asTree.Body;
Console.WriteLine($"left: {body.Left} ({body.Left.NodeType}), right: {body.Right} ({body.Right.NodeType}), parameter: {asTree.Parameters[0].Name}");

// Build a tree by hand: (speed, limit) => speed > limit ? "overspeed" : "ok"
ParameterExpression speed = Expression.Parameter(typeof(double), "speed");
ParameterExpression limit = Expression.Parameter(typeof(double), "limit");
var check = Expression.Lambda<Func<double, double, string>>(
    Expression.Condition(Expression.GreaterThan(speed, limit), Expression.Constant("overspeed"), Expression.Constant("ok")),
    speed, limit);
Console.WriteLine($"built: {check}");
Func<double, double, string> compiled = check.Compile();
Console.WriteLine($"compiled(130, 120) = {compiled(130, 120)}, compiled(90, 120) = {compiled(90, 120)}");
…

Expected Result

The same filter lambda was printed as a tree, translated to a SQL WHERE clause by the tiny ToSql visitor (exactly what an ORM does, on a larger scale), and the rule FaultCount >= 3 was built from strings at run time and compiled into a delegate.

delegate: True; expression tree: x => (x > 100)  (node type GreaterThan)
left: x (Parameter), right: 100 (Constant), parameter: x
built: (speed, limit) => IIF((speed > limit), "overspeed", "ok")
compiled(130, 120) = overspeed, compiled(90, 120) = ok
as SQL WHERE: ((FaultCount > 2) AND (Code <> 'GW'))
IQueryable expression: Ecu[].Where(e => ((e.FaultCount > 2) AndAlso (e.Code != "GW"))).Select(e => e.Code)
results: VCU
rule FaultCount >= 3 matches: VCU,GW

Common Mistakes

Calling Compile() on every invocation — cache compiled delegates.

Using methods inside IQueryable expressions that the provider cannot translate (EF Core throws "could not be translated").

Converting IQueryable to IEnumerable too early (AsEnumerable()), which moves filtering into memory.

Building expressions with mismatched types (int vs double) — Expression.GreaterThan throws InvalidOperationException.

Best Practices

Use expression trees when code must be *analysed* or *generated* at run time; otherwise plain delegates.

Convert constant types explicitly (Convert.ChangeType, Expression.Convert).

Cache compiled delegates per rule/type.

Consider source generators for compile-time metaprogramming (Module 39).

Interview Relevance

Medium: "Func vs Expression<Func>?", "IQueryable vs IEnumerable?", "how does EF Core translate LINQ to SQL?", "how would you build a dynamic filter?".

Practice Questions

Build x => x.Name.StartsWith("V") by hand for a type with a Name property.

Write an ExpressionVisitor that replaces every constant 100 with 120.

Create a fast getter Func<object, object> for any property using expression trees and compare with PropertyInfo.GetValue.

Combine two Expression<Func<T,bool>> filters with AndAlso.

Quick Quiz

Q1. (Multiple choice) What does Expression<Func<int,bool>> e = x => x > 100; store? A) Compiled IL B) A tree of objects describing the lambda C) A string D) A delegate

Answer: B (verified: printed as x => (x > 100) with node type GreaterThan).

Q2. (True/False) IQueryable<T>.Where receives an expression tree, IEnumerable<T>.Where receives a delegate.

Answer: True. Q3. (Predict the output) How does Expression.Condition(...) print?

Answer: As IIF(test, ifTrue, ifFalse) (verified).

Q4. (Find the bug) foreach (var item in items) if (rule.Compile()(item)) ...

Answer: The tree is recompiled for each item — compile once outside the loop.

Coding Challenge

Build, by hand with the Expression API, the lambda (double v) => v >= 11.5 && v <= 14.8 (a battery-voltage window), compile it, and test 12.6 and 15.2.

#:property PublishAot=false
using System.Linq.Expressions;

ParameterExpression v = Expression.Parameter(typeof(double), "v");
var body = Expression.AndAlso(
    Expression.GreaterThanOrEqual(v, Expression.Constant(11.5)),
    Expression.LessThanOrEqual(v, Expression.Constant(14.8)));
var lambda = Expression.Lambda<Func<double, bool>>(body, v);
Func<double, bool> inWindow = lambda.Compile();

Console.WriteLine(lambda);
Console.WriteLine($"12.6 -> {inWindow(12.6)}, 15.2 -> {inWindow(15.2)}");