A lambda is one of the few constructs in C# whose compilation depends on something outside the lambda. Write x => x + 1 and assign it to a Func<int, int> and you get a hidden static method and a delegate; assign the identical text to an Expression<Func<int, int>> and you get something else entirely — a nested sequence of calls to the factory methods on System.Linq.Expressions.Expression that builds a runtime data structure describing the lambda, node by node. There is no expression-tree opcode and no dedicated metadata. The decompiled method is a page of Expression.Parameter, Expression.Constant, Expression.Add and Expression.Lambda calls, with every member the source named reached through ldtoken. Reading that lowering explains what a decompiler such as Glass.NET can fold back into the one-liner, why captured variables show up as a Constant of a closure object, and why the query provider on the other end of an IQueryable has to do real work to find your variable's value.
Same lambda, two compilations
Start with the fact that decides everything. The conversion of a lambda is chosen by its target type, not by its text:
Func<int, int> f = x => x + 1; // delegate: a method plus a delegate object
Expression<Func<int, int>> e = x => x + 1; // expression tree: a data structure
The first line compiles the way lambdas and closures always do — the body becomes a method <M>b__0_0 on the compiler's <>c singleton class, and the assignment is an ldftn plus a newobj of Func<int, int>, cached in a static field. The body's IL is three instructions: ldarg.1, ldc.i4.1, add.
The second line produces no hidden method at all. The + is never emitted as an add instruction. Instead the compiler emits code that constructs a description of the addition:
ParameterExpression x = Expression.Parameter(typeof(int), "x");
Expression<Func<int, int>> e = Expression.Lambda<Func<int, int>>(
Expression.Add(x, Expression.Constant(1, typeof(int))),
new ParameterExpression[] { x });
That is the whole lowering, and it is already what a decompiler without expression-tree recognition will show you. Three things to notice: the parameter name "x" is kept as a string literal, the literal 1 is boxed into an object because Expression.Constant takes (object, Type), and the parameter node is created once, stored in a local, and used twice — once in the body and once in the parameter array. In IL it is a chain of call instructions against the static factory methods, with typeof(int) spelled as ldtoken plus Type.GetTypeFromHandle:
ldtoken System.Int32
call Type Type::GetTypeFromHandle(RuntimeTypeHandle)
ldstr "x"
call ParameterExpression Expression::Parameter(Type, string)
stloc.0
ldloc.0 // left operand: the parameter node
ldc.i4.1
box System.Int32 // Constant takes object
ldtoken System.Int32
call Type Type::GetTypeFromHandle(RuntimeTypeHandle)
call ConstantExpression Expression::Constant(object, Type)
call BinaryExpression Expression::Add(Expression, Expression)
ldc.i4.1
newarr ParameterExpression
dup
ldc.i4.0
ldloc.0
stelem.ref
call Expression`1<!!0> Expression::Lambda<Func`2<int32,int32>>(Expression, ParameterExpression[])
(The assembly qualifiers are elided here for readability; the real IL spells out [System.Linq.Expressions]System.Linq.Expressions.Expression every time, which is a large part of why the raw view of an expression-tree method is so long.)
Every expression tree you will ever decompile has this skeleton: a Parameter call per lambda parameter stored in a local, a body built bottom-up from factory calls that consume the stack, and a closing Lambda<TDelegate> that takes the body and an explicit ParameterExpression[]. The generic type argument of that final call is the delegate type — it is the one place the original Expression<Func<int, int>> signature is visible in the body.
Members are reached by token, not by name
As soon as the lambda touches a property, field or method, the lowering has to say which member, and it does so the only way the runtime can guarantee is exact: a metadata token. Take a filter over a type with a property:
Expression<Func<Customer, bool>> adults = c => c.Age >= 18;
The property access becomes Expression.Property, and the PropertyInfo it needs is obtained from the getter's method token:
ParameterExpression c = Expression.Parameter(typeof(Customer), "c");
Expression<Func<Customer, bool>> adults = Expression.Lambda<Func<Customer, bool>>(
Expression.GreaterThanOrEqual(
Expression.Property(c, (MethodInfo)MethodBase.GetMethodFromHandle(/* ldtoken Customer::get_Age */)),
Expression.Constant(18, typeof(int))),
new ParameterExpression[] { c });
In IL the member reference is ldtoken method instance int32 Customer::get_Age() followed by call MethodBase::GetMethodFromHandle(RuntimeMethodHandle) and a castclass MethodInfo. Fields go through ldtoken field plus FieldInfo.GetFieldFromHandle and Expression.Field. Methods go through the same token-plus-handle pair into Expression.Call. When the member lives on a generic type, the compiler uses the two-argument GetMethodFromHandle(RuntimeMethodHandle, RuntimeTypeHandle) overload and also passes the declaring type's token, because a method handle alone does not identify which instantiation it belongs to.
This is the detail that makes expression trees robust and decompilation easy at the same time. The tree does not contain the string "Age"; it contains a token that resolves to exactly the get_Age the source bound to, overload and all. A decompiler reads the token straight back out of the ldtoken operand and prints c.Age. The same tokens are what a renaming obfuscator rewrites consistently, which is why expression trees survive renaming while reflection-by-string does not.
Operators that C# defines on a type rather than on a primitive carry their operator method along. A string comparison is the common case:
Expression<Func<Customer, bool>> named = c => c.Name == "Ada";
does not lower to the two-argument Expression.Equal. It lowers to the four-argument overload with the user-defined operator attached:
Expression.Equal(
Expression.Property(c, /* get_Name */),
Expression.Constant("Ada", typeof(string)),
false, // liftToNull
(MethodInfo)MethodBase.GetMethodFromHandle(/* ldtoken string::op_Equality */))
The MethodInfo is String.op_Equality(string, string) — the compiler recorded which == it resolved, so a query provider can tell string equality from reference equality. && and || become AndAlso and OrElse; the non-short-circuit & and | become And and Or; ?: becomes Expression.Condition and ?? becomes Expression.Coalesce. Each maps back to exactly one piece of C# syntax, and a decompiler that knows the table rebuilds the body one node at a time.
Captured variables: a Constant that is not constant
The lowering gets genuinely interesting when the lambda uses a variable from the enclosing method:
static Expression<Func<Customer, bool>> OlderThan(int min) => c => c.Age > min;
For a delegate, min would be hoisted into a display class and the lambda body would read it as a field — the familiar closure pattern. An expression tree needs the same thing, but it has to express "read field min from this particular closure object" as a tree node. The compiler does it with a Constant whose value is the closure instance, and a Field access on top of it:
<>c__DisplayClass0_0 closure = new <>c__DisplayClass0_0();
closure.min = min;
ParameterExpression c = Expression.Parameter(typeof(Customer), "c");
return Expression.Lambda<Func<Customer, bool>>(
Expression.GreaterThan(
Expression.Property(c, /* get_Age */),
Expression.Field(
Expression.Constant(closure, typeof(<>c__DisplayClass0_0)),
FieldInfo.GetFieldFromHandle(/* ldtoken <>c__DisplayClass0_0::min */))),
new ParameterExpression[] { c });
Two consequences follow from this shape, and both are visible only in the lowered form. First, the tree does not contain the value of min; it contains a reference to the closure object and a field to read from it. The value is fetched when the tree is compiled or walked, so a tree built from a mutable local sees later mutations exactly as a delegate would. Second, this is precisely why every LINQ query provider has a step that evaluates Field-on-Constant subtrees before translating a query: from the provider's point of view, min is not a literal in the tree, it is a member access on an opaque object, and the provider has to execute that sub-expression to discover 18 and turn it into a query parameter. If you have ever wondered why a captured variable becomes @p0 in generated SQL while an inline literal becomes a literal, this is the shape behind it.
For a decompiler the pattern is a fingerprint. Expression.Field(Expression.Constant(x, typeof(T)), f) where T is a compiler-generated display class and x is a local of that type collapses to the captured variable's name — the display-class field keeps the original name min, so the recovered lambda reads c => c.Age > min even without a PDB. The surrounding new <>c__DisplayClass0_0() and closure.min = min are the same closure setup a delegate would need, and are folded away with it.
Conversions, checked arithmetic and boxing
C# inserts implicit conversions silently; expression trees cannot, because every node has an exact type and the factory methods validate it. So a lambda that mixes types grows Convert nodes the source never spelled out:
Expression<Func<int, double>> half = x => x / 2.0;
becomes
Expression.Divide(
Expression.Convert(x, typeof(double)), // the implicit int -> double promotion, made explicit
Expression.Constant(2.0, typeof(double)))
The same happens for boxing — Expression<Func<int, object>> box = x => x; has a body of Expression.Convert(x, typeof(object)) — and for nullable lifting, where an int? compared to a literal gets its literal wrapped in Expression.Convert(Expression.Constant(3, typeof(int)), typeof(int?)) so both operands of the comparison have the same nullable type. A checked context changes the factory method rather than adding a node: checked(x + 1) lowers to Expression.AddChecked, and checked((byte)x) to Expression.ConvertChecked, so the overflow behaviour you asked for travels inside the tree as the choice of node type.
This is the first place a decompiler has to make a judgement call. An Expression.Convert(x, typeof(double)) could be a cast the author wrote, (double)x, or the promotion the compiler added. Both readings recompile to the same tree, so a decompiler can either print the cast — faithful but noisier — or drop conversions that C# would reinsert anyway and print x / 2.0. Glass.NET prefers the shorter form where it is provably equivalent and keeps the explicit Convert where it changes meaning, such as a narrowing cast or an object box that an overload resolution depends on.
Nested lambdas and Expression.Quote
Real expression trees are usually query fragments, and query fragments call methods that themselves take lambdas. What the inner lambda lowers to depends, again, on its target type. Consider a filter written over IQueryable:
Expression<Func<IQueryable<Customer>, IQueryable<Customer>>> active =
q => q.Where(c => c.Active);
Queryable.Where takes an Expression<Func<Customer, bool>>, so the inner lambda must itself become a tree — and the outer tree needs to carry that inner tree as a value. The compiler lowers the inner lambda to its own Expression.Lambda and wraps it in Expression.Quote:
ParameterExpression q = Expression.Parameter(typeof(IQueryable<Customer>), "q");
ParameterExpression c = Expression.Parameter(typeof(Customer), "c");
Expression.Lambda<Func<IQueryable<Customer>, IQueryable<Customer>>>(
Expression.Call(
null, // static method
(MethodInfo)MethodBase.GetMethodFromHandle(/* ldtoken Queryable::Where<Customer>(IQueryable<Customer>, Expression<Func<Customer,bool>>) */),
new Expression[]
{
q,
Expression.Quote(
Expression.Lambda<Func<Customer, bool>>(
Expression.Property(c, /* get_Active */),
new ParameterExpression[] { c }))
}),
new ParameterExpression[] { q });
Notice the ldtoken for Where names the constructed generic method Where<Customer>, so the type argument the compiler inferred is recorded in the tree and the decompiler can leave it inferred in the output. Notice also that the static call passes null for the instance argument — Roslyn consistently uses the Call(Expression, MethodInfo, Expression[]) overload for both instance and static calls, which is a small but reliable fingerprint of compiler-generated trees versus hand-built ones.
Now change the source so the sequence is a List<Customer> instead. The call binds to Enumerable.Where, whose parameter is a plain Func<Customer, bool>, and the Quote disappears: the inner Expression.Lambda<Func<Customer, bool>>(...) node is passed directly as the argument. A LambdaExpression node is of delegate type, so no wrapping is needed. The presence or absence of Quote therefore tells you, without looking at the method signature, whether the callee receives the inner lambda as data to translate or as code to run — and a decompiler folds both forms to the same c => c.Active, because that is what the source said in both cases.
Object creation, initializers and anonymous types
Projections lower to a small family of node factories that a decompiler has to recognise as a group. A new with an object initializer becomes MemberInit over New, with one Bind per assigned member, each referencing the property's setter token:
Expression<Func<Customer, Dto>> toDto = c => new Dto { Name = c.Name, Age = c.Age };
Expression.MemberInit(
Expression.New((ConstructorInfo)MethodBase.GetMethodFromHandle(/* ldtoken Dto::.ctor() */)),
new MemberBinding[]
{
Expression.Bind((MethodInfo)MethodBase.GetMethodFromHandle(/* set_Name */), Expression.Property(c, /* get_Name */)),
Expression.Bind((MethodInfo)MethodBase.GetMethodFromHandle(/* set_Age */), Expression.Property(c, /* get_Age */))
})
Anonymous types take a different route, because they have no settable properties. c => new { c.Name, c.Age } lowers to the Expression.New overload that takes the constructor, the argument expressions and an array of the property getters as members. That member list is how a query provider pairs each constructor argument with the property it initialises, and it is also how a decompiler recovers the new { Name = ..., Age = ... } syntax — the generated anonymous-type class itself is just the usual <>f__AnonymousType0 with its read-only properties.
What the tree cannot tell you
Constant folding runs before expression-tree lowering, so arithmetic on literals is gone by the time the tree is built. x => x * (60 * 60) lowers to Expression.Multiply(x, Expression.Constant(3600, typeof(int))); no decompiler can recover 60 * 60, because the compiled assembly never contained it. The same applies to const fields referenced from the lambda — they are inlined as Constant nodes with the value, and the name of the constant is lost.
The second limit is one the language imposes rather than the compiler. An expression-tree lambda can only have an expression body: no statements, no assignment, no ?., no pattern matching, no dynamic, no async, no tuples, no local functions, and the compiler refuses the conversion (CS0834 and friends) rather than lowering them. The practical consequence for decompilation is that a compiler-generated tree is always a pure nest of factory calls with no control flow between them — so if you see branches, loops or a Block node being built, you are looking at a hand-constructed tree, not a lowered lambda, and a decompiler correctly leaves it as the factory calls.
What a decompiler folds, and what it shows
Everything above reduces to a small set of rules. The outer Expression.Lambda<TDelegate>(body, new ParameterExpression[] { ... }) with parameters created by Expression.Parameter(typeof(T), "name") gives the lambda header, including the original parameter names, which are literal strings in the IL. The body folds bottom-up: each binary and unary factory maps to one operator, Property/Field/Call print the member their ldtoken resolves to, Constant prints a literal, Field on a Constant of a display class prints the captured variable, Quote is transparent, MemberInit/Bind and the three-argument New rebuild initializers and anonymous types, and Convert is dropped where C# would reinsert it. What remains ambiguous — a Convert that might be an explicit cast, a Constant that might have been an arithmetic expression, a four-argument Equal whose operator method is just == in source — is rendered the shortest way that recompiles to the same tree.
That is exactly what Glass.NET does when it meets a method that builds a tree: it shows the lambda when the shape is a compiler lowering, and the factory calls when it is not. Both views are worth having. The folded lambda tells you what the author wrote; the lowered view tells you what the query provider on the other end actually receives — the closure Constant it must evaluate, the Convert nodes it must translate, the op_Equality it must map to a string comparison, and the Quote that marks a sub-query it has to translate rather than run. Open an assembly that builds queries, pick a method that returns an Expression<...>, and switch between the C# and IL views to watch a one-line lambda unfold into the tree the runtime really gets. For the full workflow, see how to decompile a .NET DLL to C#.
Top comments (0)