Skip to content

Decompile natural types for lambdas and method groups - #4004

Open
siegfriedpammer wants to merge 21 commits into
masterfrom
natural-type-lambdas-methods
Open

Decompile natural types for lambdas and method groups#4004
siegfriedpammer wants to merge 21 commits into
masterfrom
natural-type-lambdas-methods

Conversation

@siegfriedpammer

@siegfriedpammer siegfriedpammer commented Aug 15, 2026

Copy link
Copy Markdown
Member

Reconstructs the C# 10/12/13 "function type" features, so anonymous functions and method groups
come back as the source wrote them instead of as delegate constructions with unspeakable type
names.

What is decompiled now

  • Explicit lambda return types (C# 10), including ref and ref readonly returns. The
    syntax tree gained LambdaExpression.ReturnType; the return type is written wherever the
    delegate's return type is not what C# would re-infer from the body.
  • Natural-typed lambdas for synthesized delegate types. <>A{...} / <>F{...} (and the
    unbraced <>F`18 shapes for >16 parameters) cannot be named, so those sites now print
    var x = (ref int y) => ++y; rather than leaking the compiler-generated name. Conversions to
    object, Delegate and Expression targets no longer emit a cast to it either.
  • Method groups with a natural type (C# 10) - Func<int, int> f = M; instead of
    new Func<int, int>(M) - gated by the existing NaturalTypeForLambdaAndMethodGroup setting.
    ICSharpCode.Decompiler/CSharp/Resolver/MethodGroupNaturalType.cs is the oracle that decides
    when the natural type of an emitted method group really is the delegate type in the IL.
  • C# 13 method-group improvements behind a new MethodGroupNaturalTypeImprovements setting
    (scope-by-scope pruning vs. the C# 10 all-scopes rules), so pre-C# 13 output stays valid.
  • Lambda parameter defaults and params (C# 12) where they are legal, downgraded to
    [Optional] / [DefaultParameterValue] / [ParamArray] attributes below C# 12
    (LambdaOptionalAndParamsParameters, with an Ugly fixture for the disabled state).
  • Statement-expression lambdas render as expression bodies (() => Console.WriteLine())
    where the expression is void-typed, or where an explicit return type spells the discarded
    value out. A non-void value keeps its block body: C# would otherwise read the expression's
    type as the lambda's return type and pick a different overload.

Testing

New fixtures: Pretty/LambdaReturnTypes, Pretty/LambdaNaturalTypeConversions,
Pretty/LambdaOptionalAndParamsParameters (+ Ugly/NoLambdaOptionalAndParamsParameters),
Pretty/MethodGroupNaturalType, Pretty/MethodGroupNaturalTypeImprovementsDisabled,
Pretty/MethodGroupSynthesizedDelegates, and the round-tripping
Correctness/LambdaNaturalType / Correctness/MethodGroupNaturalType - the correctness pair
matters most here, because a lost return type or parameter modifier silently rebinds an
overload instead of failing to compile.

Full ICSharpCode.Decompiler.Tests (3372 passed / 0 failed, roundtrip excluded) and
ILSpy.Tests (1181 passed / 0 failed) are green.

Known gaps: static lambdas are not emitted (see #829), and statement-bodied lambdas in
Func/Action positions still print as delegate(int x) { ... }.

Specifications used

Language proposals (dotnet/csharplang) - the three #829 entries this covers:

Normative rules (dotnet/csharpstandard, branch draft-v12) - what the oracle in
MethodGroupNaturalType.cs and the conversion handling are checked against:

  • §12.22.8 Anonymous function type - when a natural type exists, when a synthesized delegate is used instead of Func/Action (any non-by-value parameter or return, more than 16 parameters, or a type that is not a valid type argument), and the mandated arg1..argn parameter names
  • §10.2.21 Anonymous function type conversion - which targets an anonymous-function-typed value converts to (object, Delegate, base classes, Expression), which is why those sites no longer need a cast

🤖 Generated with Claude Code

siegfriedpammer and others added 21 commits August 15, 2026 08:35
C# 10 allows a lambda to declare its return type; the natural-type
feature needs to emit one whenever the delegate type is dropped but the
return type cannot be re-inferred from the body (e.g. a body whose type
is narrower than the delegate's return type, or an explicitly
void-returning body whose statement-expression has a value).
LambdaExpression gains an optional ReturnType child and the output
visitor prints it before the parameter list, which the grammar then
requires to be parenthesized.

Nothing sets the return type during decompilation yet; the
LambdaReturnTypes fixture stays ignored until natural-type support
lands.

Assisted-by: Claude:claude-fable-5:Claude Code
The C# 10 grammar only allows attributes on a lambda or its parameters
when the parameter list is parenthesized, but LambdaNeedsParenthesis
predates attribute support and only considered the single parameter's
type and modifiers. An attributed lambda whose parameter type is erased
for being anonymous therefore printed as '[My] a => a.X', which does not
parse. Latent since attributed-lambda decompilation was added: every
other attributed lambda has explicitly typed parameters, which already
force the parenthesized form.

Assisted-by: Claude:claude-fable-5:Claude Code
Method groups and lambdas whose shape Action/Func cannot express (ref
parameter or return kinds, and with pointers, params, or default values
the non-generic fallback) get compiler-synthesized delegate types:
<>A{flags} (void-returning), <>F{flags} (value-returning), and
<>f__AnonymousDelegateN. Their names are unspeakable, so declared
variables printed the escaped type name and did not compile.

Detect them (generated name in one of the three families, delegate
kind, CompilerGenerated, no namespace), declare locals of such types as
'var', hide the synthesized type definitions, name the locals 'anon',
and let DelegateConstruction accept the synthesized methods. Since the
site is then typed solely by the anonymous function's natural type,
lambda syntax is mandatory: 'delegate {}' without a parameter list has
no natural type, and the 'delegate' form cannot declare a return type.
When the return type C# would re-infer from the emitted body differs
from the delegate's (a widened return, or a discarded value in an
expression body), the lambda declares the delegate's return type
explicitly (C# 10).

The LambdaReturnTypes fixture pins that last part: in a delegate-typed
context an explicit return type leaves no trace in metadata, so the
syntax only matters for natural-typed lambdas. Every case has a ref
parameter, which makes 'var' the only legal declaration, and the return
type appears exactly where it is load-bearing - widening the body's type
to object, or forcing void over an inferable int - and is omitted where
inference recovers it.

Gated by a new NaturalTypeForLambdaAndMethodGroup setting (C# 10).

Assisted-by: Claude:claude-fable-5:Claude Code
A method group converted to a synthesized anonymous delegate type is
emitted as a natural-typed site ('var f = M;'). The delegate-reference
disambiguation only added generic type arguments when overload
resolution against the parameter types needed them, which models
inference from an explicit delegate target; with an anonymous delegate
type there is no target type to infer from, and a generic method group
without explicit type arguments has no natural type (CS8917). Force
the type arguments whenever the constructed delegate type is anonymous.

Assisted-by: Claude:claude-fable-5:Claude Code
Anonymous methods can never declare 'params' (CS1670) or parameter
default values (CS1065); since C# 12 lambdas can. TranslateFunction
printed both modifiers on whichever syntax it had chosen anyway, so
closure methods carrying ParamArrayAttribute or a default value
decompiled to uncompilable anonymous methods.

Force lambda syntax when a parameter carries one of these shapes, gated
by a new LambdaOptionalAndParamsParameters setting (C# 12). Below that
version the modifiers are dropped instead: the delegate type still
provides both, so they are purely decorative on the anonymous function.

The fixture branches per compiler because Roslyn 4.14 does not emit
ParamArrayAttribute on the synthesized lambda method (the modifier is
then unrecoverable), while current Roslyn does.

Assisted-by: Claude:claude-fable-5:Claude Code
A lambda body consisting of one ExpressionStatement was rendered as a
block, because only a single 'return' qualified for the expression form.
Any single statement-expression is a legal expression body - its value,
if there is one, is discarded and the lambda stays void-returning - so
braces are only needed for genuinely multi-statement bodies.

Assisted-by: Claude:claude-fable-5:Claude Code
When LambdaOptionalAndParamsParameters is disabled, no anonymous
function syntax can declare 'params' or a parameter default value, so
these were silently dropped. Emit the underlying metadata attributes
([ParamArray], [Optional] plus [DefaultParameterValue]) on the parameter
instead, so the information stays visible. This has to happen in
TranslateFunction: from metadata alone the type system cannot tell a
lambda's method apart from a local function's, where the sugar stays
legal in older language versions, so neither a TypeSystemOptions flag
nor IsDefaultValueAssignmentAllowed can make this distinction.

RequiredNamespaceCollector adds System.Runtime.InteropServices for
optional parameters of method parts, since the downgrade decision is
made only later, during translation.

Assisted-by: Claude:claude-fable-5:Claude Code
MethodGroupNaturalTypeImprovements gates the scope-by-scope candidate
pruning of C# 13 separately from the C# 10 natural-type support, so
decompiling as C# 10-12 keeps the C# 10 natural type rules.

Also make ExpressionBuilder and CallBuilder consult
NaturalTypeForLambdaAndMethodGroup before treating an anonymous
delegate type specially; DeclareVariables already did, so turning the
setting off previously produced an inconsistent mix of natural-typed
lambda syntax and unspeakable delegate type names.

Assisted-by: Claude:claude-fable-5:Claude Code
…uctions

A delegate construction site can drop the explicit 'new DelegateType(...)'
only when the natural type C# assigns to the emitted method group is
exactly the delegate type the IL constructs; otherwise 'var' (or a
Delegate/object local's initializer) would re-infer a different type or
none at all. MethodGroupNaturalType re-resolves the emitted form and
decides this, mirroring the version-specific rules: C# 13 walks scopes
one at a time (instance members before each extension scope) and prunes
candidates with mismatched arity, violated constraints or the wrong
static/instance form; C# 10 lets every candidate in every scope take
part. Only System.Action/Func (or anonymous delegate) types qualify -
Roslyn never infers a signature-compatible custom delegate type.

CallBuilder annotates qualifying method groups; DeclareVariables uses
the annotation to emit 'var' when the natural type equals the local's
type, and to drop the construction (but keep the declared type) for
Delegate- and object-typed locals. Sites in any other context keep the
explicit construction. Generic groups retry with spelled type
arguments, since a natural type requires them.

Assisted-by: Claude:claude-fable-5:Claude Code
Pins the natural-typed (var) emission for method groups converted to
synthesized anonymous delegate types, across all three Roslyn name
families (<>A{...}, <>F{...} including ref returns, and
<>f__AnonymousDelegateN for params/default-value parameters), with
implicit-this and expression receivers, invocation through the
natural-typed local, and capture into a lambda. All signatures involve
ref parameters or params/defaults so that no framework delegate type
matches and var output is required rather than stylistic.

The ExplicitTypeArguments case runs red by design: the decompiler
still omits the generic type arguments, which a natural-typed method
group cannot re-infer without a target type (CS8917).

Assisted-by: Claude:claude-fable-5:Claude Code
The pretty fixture states the desired round-trip: method groups whose
C# natural type equals the delegate type constructed in the IL come
back as 'var result = M;' (or keep a Delegate/object local's declared
type while dropping the explicit construction). The C# 13 cases cover
scope-by-scope candidate pruning: instance scope before extension
scopes, and arity, constraint and receiver-form pruning within a
scope. Under /o the compiler erases a Delegate/object local's declared
type, so those two cases pin the natural-typed 'var' form instead.

The correctness companion executes both compilations and verifies every
method group still binds to the same target method.

Assisted-by: Claude:claude-fable-5:Claude Code
With MethodGroupNaturalTypeImprovements off, every candidate in every
scope still takes part in the natural type determination, so the
scope-by-scope cases fall back to an explicitly typed local, while a
unique extension method, a unique member, and the Delegate/object
conversions keep their natural-typed form (those are C# 10 features).

Assisted-by: Claude:claude-fable-5:Claude Code
The compiler names a synthesized delegate type <>A when it returns void and
<>F otherwise, and appends a bit pattern of the by-reference parameters only
when the signature has any. A signature that needs a synthesized type solely
because it has more than 16 parameters is passed entirely by value, so it is
named plain <>A or <>F, which the prefix tests requiring a brace missed. Such
a method group was left as an ordinary delegate construction and the type,
whose definition is hidden as compiler-generated, was spelled out by its
unspeakable metadata name, so the output did not recompile.

The two predicates that decide this, one on the type system and one on
metadata, no longer carry separate copies of the name rule: they cannot drift
apart the way the generated-name predicates did in #3952.

Assisted-by: Claude:claude-fable-5:Claude Code
A lambda was wrapped in a cast to its delegate type and a method group in a
construction of it, both of which later collapse away wherever the target type
is that same delegate. Where the target is a base type instead, such as a field
or return type of object or Delegate, nothing collapsed them and the wrapper
spelled out a type that has no name in C# and whose definition is hidden, so
the output did not recompile.

Neither wrapper can ever be written for an anonymous delegate type, so neither
is built now; the conversion rides on the anonymous function itself. That also
keeps the site typed as the delegate rather than as an anonymous function,
which is what lets the conversion to a base type proceed without a cast: the
standard permits it (C# 12 draft, 10.2.21) but the resolver does not model it.

Assisted-by: Claude:claude-fable-5:Claude Code
A method group whose signature fits no Func/Action overload (ref, out,
in, params or default-value parameters) still has a natural type since
C# 10/12: the compiler synthesizes an anonymous delegate type, whose
name is unspeakable. The fixture pins that such values are declared with
var and initialized from the method group, which round-trips to an
equivalent synthesized delegate type, and covers each parameter kind
that forces the synthesis, plus returning the value as object.

Assisted-by: Claude:claude-fable-5:Claude Code
Ref-readonly-ness is not part of IType; it is a separate flag on the delegate's
Invoke method. The explicit-return-type gate compared types only, so a lambda
whose synthesized delegate returns 'ref readonly' matched its inferred natural
type and the modifier was dropped without a trace. Recompiling that output
synthesizes a plain ref-returning delegate, so the type identity silently
differs from the original.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Pretty tests pin the syntax of a natural-typed lambda, but not what the
syntax then binds to. Where the natural type is a synthesized delegate the
decompiler must reproduce its whole signature - ref parameters, default
values, params, and a ref readonly return - or the output picks a different
overload, or stops compiling. The ref readonly return leaves no trace in
anything an invocation can observe, so the fixture reads the modreq back off
the delegate type.

The params case is compiled only under the current compiler: Roslyn 4.14
emits no ParamArrayAttribute on a lambda's method, so the modifier cannot be
recovered while the call is still decompiled in expanded form.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
An anonymous function's own method does not reliably carry ParamArrayAttribute
- Roslyn 4.14 omits it - while the delegate's Invoke method always describes
the full signature, and it is Invoke that call sites bind against. Reading the
modifiers from the anonymous function alone therefore dropped 'params' from
the parameter list while the call was still decompiled in expanded form,
which does not compile: the natural type of the re-emitted lambda is a plain
Func<int[], int>, taking exactly one argument.

Sourcing both modifiers from Invoke also makes the pre-C# 12 downgrade to
[ParamArray]/[Optional] fire on every compiler rather than only the newest.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
C# 10 converts a function type to System.MulticastDelegate, to its base
classes and to its interfaces, but only the Delegate and object targets
dropped the explicit delegate construction. A local declared as
MulticastDelegate, ICloneable or ISerializable kept 'new Func<int, int>(M)'
even though a bare method group binds there just as well. Deriving the set
from MulticastDelegate's base types states the language rule directly instead
of listing two of its five members.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
C# 10 converts a lambda's function type to System.MulticastDelegate, its base
classes and its interfaces, and an expression tree's to Expression and
LambdaExpression, so 'Delegate d = (Func<int, int>)((int x) => x);' names a
type the language re-infers. The natural-type annotation now also marks
anonymous functions, and the declaration site drops the redundant cast.

The 'var' shortcut stays method-group-only on purpose: 'var' over an
expression tree infers the delegate that the Expression<> wraps, which would
silently turn a tree into a delegate. Discards are unaffected, since only a
declaration ever unwraps - a discard offers no target type, and function
types are not used in assignments to discards.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
…voke

Copying 'params' from every target delegate's Invoke onto the lambda broke
the Newtonsoft.Json round-trip: each plain '(object[] args) => ...' bound to
a 'params' delegate came back as '(params object[] args)', and below C# 12
the fallback rendered that as '([ParamArray] object[] args)', which no C#
version compiles (CS0674, plus CS8400 for lambda attributes before C# 10).

The lambda's own metadata is the faithful record for named delegate types:
current Roslyn emits ParamArrayAttribute and the default value on the
closure method exactly when the source spelled them, and where Roslyn 4.14
omits the attribute the plain parameter list is equivalent anyway. Only a
compiler-synthesized delegate type has to be spelled through the lambda's
declaration, so that is the one place Invoke is still consulted. Without a
legal pre-C# 12 spelling, the modifiers are now dropped instead of being
turned into attributes.

Assisted-by: Claude:claude-fable-5:Claude Code
@christophwille

Copy link
Copy Markdown
Member

CI failure analysis (Windows Debug/Release)

The only failing test in both Windows jobs was RoundtripAssembly.NewtonsoftJson_net45 (Linux/macOS pass because the round-trip suite is Windows/MSBuild-only). The decompiled Newtonsoft.Json.csproj (LangVersion 8.0) no longer compiled; every lambda bound to a params delegate (ObjectConstructor<T>, MethodCall<T,R>) failed with the same pair:

ReflectionObject.cs(53,26): error CS8400: Feature 'lambda attributes' is not available in C# 8.0.
ReflectionObject.cs(53,18): error CS0674: Do not use 'System.ParamArrayAttribute'. Use the 'params' keyword instead.

Master emitted creator2 = (object[] args) => ctor();; the branch emitted creator2 = ([ParamArray] object[] args) => ctor();.

Root cause, two parts in ExpressionBuilder.TranslateFunction:

  1. params/defaults were copied from every target delegate's Invoke onto the lambda (pd.IsParams |= invokeParameter.IsParams). Newtonsoft's closure <Create>b__0(object[]) carries no ParamArrayAttribute in IL - the source was a plain (object[] args) => ... - so this invented a params that was never written.
  2. With LambdaOptionalAndParamsParameters off (C# < 12), the fallback rewrote it as [ParamArray] / [Optional][DefaultParameterValue] attributes. [ParamArray] cannot be written explicitly in any C# version (CS0674), and attributes on lambda parameters need C# 10 (CS8400) - so under C# 8 it was doubly uncompilable. The Ugly/NoLambdaOptionalAndParamsParameters fixture enshrined that output (Ugly fixtures aren't recompiled, which is why it slipped through).

I checked the premise in the code comment ("Roslyn 4.14 emits no ParamArrayAttribute there") against both compilers in the test matrix. It's half right: Roslyn 4.14 puts the default value on the closure method but not ParamArrayAttribute; the current compiler (SDK 11 preview) puts both there, exactly when the source spelled them and never otherwise.

Fix (a4ef71b)

  • Consult Invoke only for compiler-synthesized (anonymous) delegate types - that's the one place the lambda declaration is the only way to spell the type, and where 4.14 leaves no other trace. Named delegates keep the lambda's own metadata: faithful on current Roslyn, and where 4.14 omits the attribute the plain parameter list is equivalent anyway. (Restricting it to anonymous delegates was necessary: dropping the copy entirely broke Correctness/LambdaNaturalType on 4.14.)
  • Below C# 12, drop params/defaults instead of downgrading to attributes (no legal spelling exists); the RequiredNamespaceCollector hook that only served that fallback is gone.
  • Ugly/NoLambdaOptionalAndParamsParameters.Expected.cs updated; Pretty/LambdaOptionalAndParamsParameters.cs got a #if ROSLYN5 || !EXPECTED_OUTPUT split for the named-delegate params cases.

Verified locally: NewtonsoftJson_net45 passes again, and the 152 Lambda/MethodGroup/DelegateConstruction/Optional fixtures pass across the Roslyn 4.14 + latest matrix.

Note: the PR description still says the modifiers are "downgraded to [Optional] / [DefaultParameterValue] / [ParamArray] attributes below C# 12" - that should now read "dropped below C# 12".

}
else if (body.Statements.Count == 1 && body.Statements.Single() is ExpressionStatement exprStmt
&& !NeedsDeclarationInBody(function, exprStmt)
&& (isAnonymousDelegate || exprStmt.Expression.GetResolveResult().Type.Kind == TypeKind.Void))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new code path is questionable -- why is there a difference between inferredReturnType and naturalReturnType here? If we remove the braces, I would think that both variable's usages need to take the expression's type.

Why did we even need to remove the braces? How does this change relate to natural types?

@dgrunwald

Copy link
Copy Markdown
Member

I recommend splitting unrelated bugfixes out of this PR.
I'm not sure we really need the pretty syntax sugar for rare lambda expressions using natural types with ref/out params.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants