Decompile natural types for lambdas and method groups - #4004
Decompile natural types for lambdas and method groups#4004siegfriedpammer wants to merge 21 commits into
Conversation
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
CI failure analysis (Windows Debug/Release)The only failing test in both Windows jobs was Master emitted Root cause, two parts in
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 Fix (a4ef71b)
Verified locally: Note: the PR description still says the modifiers are "downgraded to |
| } | ||
| else if (body.Statements.Count == 1 && body.Statements.Single() is ExpressionStatement exprStmt | ||
| && !NeedsDeclarationInBody(function, exprStmt) | ||
| && (isAnonymousDelegate || exprStmt.Expression.GetResolveResult().Type.Kind == TypeKind.Void)) |
There was a problem hiding this comment.
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?
|
I recommend splitting unrelated bugfixes out of this PR. |
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
refandref readonlyreturns. Thesyntax tree gained
LambdaExpression.ReturnType; the return type is written wherever thedelegate's return type is not what C# would re-infer from the body.
<>A{...}/<>F{...}(and theunbraced
<>F`18shapes for >16 parameters) cannot be named, so those sites now printvar x = (ref int y) => ++y;rather than leaking the compiler-generated name. Conversions toobject,DelegateandExpressiontargets no longer emit a cast to it either.Func<int, int> f = M;instead ofnew Func<int, int>(M)- gated by the existingNaturalTypeForLambdaAndMethodGroupsetting.ICSharpCode.Decompiler/CSharp/Resolver/MethodGroupNaturalType.csis the oracle that decideswhen the natural type of an emitted method group really is the delegate type in the IL.
MethodGroupNaturalTypeImprovementssetting(scope-by-scope pruning vs. the C# 10 all-scopes rules), so pre-C# 13 output stays valid.
params(C# 12) where they are legal, downgraded to[Optional]/[DefaultParameterValue]/[ParamArray]attributes below C# 12(
LambdaOptionalAndParamsParameters, with anUglyfixture for the disabled state).() => 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-trippingCorrectness/LambdaNaturalType/Correctness/MethodGroupNaturalType- the correctness pairmatters 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) andILSpy.Tests(1181 passed / 0 failed) are green.Known gaps:
staticlambdas are not emitted (see #829), and statement-bodied lambdas inFunc/Actionpositions still print asdelegate(int x) { ... }.Specifications used
Language proposals (dotnet/csharplang) - the three #829 entries this covers:
paramson lambdasNormative rules (dotnet/csharpstandard, branch
draft-v12) - what the oracle inMethodGroupNaturalType.csand the conversion handling are checked against: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 mandatedarg1..argnparameter namesobject,Delegate, base classes,Expression), which is why those sites no longer need a cast🤖 Generated with Claude Code