RFC FS-1339 Direct delegates#833
Conversation
| # Unresolved questions | ||
| [unresolved]: #unresolved-questions | ||
|
|
||
| 1. **Struct (value-type) receivers.** Instance methods on value types currently bail to a closure. A direct delegate would have to **box** the receiver (the delegate `Target` is `object`) and bind the function pointer accordingly, with the boxed-copy semantics that implies — which matches the current closure's capture semantics but should be confirmed against readonly/`inref`/defensive-copy expectations before enabling. Supporting this is possible but is a separate, more substantial change. |
There was a problem hiding this comment.
This is not substantially different to closing over a struct.
It should be in and part of the RFC (does not mean it must go in with the same implementation PR - can be left for later implementation due to increased complexity. The same will apply to other points here)
|
|
||
| 1. **Struct (value-type) receivers.** Instance methods on value types currently bail to a closure. A direct delegate would have to **box** the receiver (the delegate `Target` is `object`) and bind the function pointer accordingly, with the boxed-copy semantics that implies — which matches the current closure's capture semantics but should be confirmed against readonly/`inref`/defensive-copy expectations before enabling. Supporting this is possible but is a separate, more substantial change. | ||
|
|
||
| 2. **The inline-race (debug/release inconsistency).** Small, inlinable, unannotated targets are inlined by the optimizer before code generation, so they become closures in release while remaining direct in debug — `delegate.Method` then differs between configurations. Fully resolving this would require running the recognizer **inside the optimizer**, before the head call is inlined, or otherwise suppressing inlining of delegate targets. Is the current behavior acceptable, or should it be addressed? Note this extends *across assemblies* (see [The inline-race](#the-inline-race)): for a non-`inline` target in a referenced assembly, the direct/closure outcome depends on that assembly's optimization data rather than the consumer's source, so the inconsistency is not fully under the consumer's control. |
There was a problem hiding this comment.
Can this affect correctness in the mentioned use cases (ASP.NET routing/minimal APIs, dependency-injection containers, mocking libraries, serializers, and logging/diagnostics that print the bound method name. With F# they see Invoke on a closure rather than the intended method, which breaks or degrades these scenarios....) ?
If yes, it is important.
For performance, I assume one can construct use cases supporting either way, so I would not want to overfocus on it (especially for transitively inlined statically optimized library calls)
|
|
||
| 2. **The inline-race (debug/release inconsistency).** Small, inlinable, unannotated targets are inlined by the optimizer before code generation, so they become closures in release while remaining direct in debug — `delegate.Method` then differs between configurations. Fully resolving this would require running the recognizer **inside the optimizer**, before the head call is inlined, or otherwise suppressing inlining of delegate targets. Is the current behavior acceptable, or should it be addressed? Note this extends *across assemblies* (see [The inline-race](#the-inline-race)): for a non-`inline` target in a referenced assembly, the direct/closure outcome depends on that assembly's optimization data rather than the consumer's source, so the inconsistency is not fully under the consumer's control. | ||
|
|
||
| 3. **`unit`-argument delegates.** `Action(handler)` for `handler: unit -> unit` stays a closure because the synthesized `unit` argument surfaces as a spurious leading argument. This could be made direct by stripping a forwarded `unit` literal that corresponds to an elided unit parameter. Worth doing? |
There was a problem hiding this comment.
Yes, supporting the full Action<..> family should be part of this RFC.
|
|
||
| 3. **`unit`-argument delegates.** `Action(handler)` for `handler: unit -> unit` stays a closure because the synthesized `unit` argument surfaces as a spurious leading argument. This could be made direct by stripping a forwarded `unit` literal that corresponds to an elided unit parameter. Worth doing? | ||
|
|
||
| 4. **Extension members.** An extension member compiles to a static method whose first parameter is the receiver, so a use such as `Func<_,_,_>(fun a b -> h.Combine(a, b))` presents the receiver `h` as a leading argument and is currently treated as a partial application → closure. This is, however, expressible as a direct delegate: the CLR's "closed over the first argument" mechanism lets `newobj Delegate::.ctor(object, native int)` bind `Target = h` and `ftn = ldftn <static extension method>`, so invoking passes `h` followed by the delegate's arguments. Implementing it requires (a) recognizing the extension-member case (`vrefM.IsExtensionMember`) and allowing a single leading argument to become the `Target` of a *static* method, (b) offsetting the signature check by one to drop the bound first formal parameter, and (c) the same reference-type guard as instance receivers (a value-type extension receiver hits the boxing gap in question 1). |
There was a problem hiding this comment.
Yes, should be part of this RFC. Can go in via a separate later implementation.
I assume the listed use cases will support including extension members a well.
| * **Always emit direct (no eta/debug distinction).** Rejected because eta-expanded delegates in debug builds would lose the user's lambda parameter names and a friendly stepping experience. | ||
| * **Run the recognizer inside the optimizer (before inlining).** Would remove the inline-race and make debug/release consistent, but is a more invasive change (see *Unresolved questions*). | ||
| * **Gate behind a dedicated opt-in compiler flag instead of a language-version feature.** The behavior change could be controlled by a standalone switch (default off) rather than tied to `--langversion`. Upside: never a breaking change — existing builds are untouched unless the flag is explicitly added. Downsides: poor discoverability (users must already know the flag exists to benefit, and most never will); no automatic improvement (the interop/identity/allocation wins never arrive just by upgrading the toolchain or language version); and the feature risks remaining permanently niche rather than becoming the language's normal behavior. | ||
| * **Add an opt-out compiler flag (default on) alongside the language feature.** This is *not* an alternative to the language feature but a complement to it: once the feature graduates to a released language version and the direct form becomes the default there, a `--direct-delegates-` style switch would let a project that discovers a regression (e.g. delegate-identity-sensitive event removal) turn the new emission off and keep building, without pinning the whole language version back. |
There was a problem hiding this comment.
Compiler supports disabling individual language features for troubleshooting/workaround scenarios.
The assumption is that any reasons against using direct delegates will be seen as bugs to be fixed.
(i.e. we do not expect overall feature rejection)
|
|
||
| ### The inline-race | ||
|
|
||
| The F# optimizer inlines small function bodies *before* code generation runs. If a delegate target is small and not annotated `[<NoCompilerInlining>]`, in release builds its body is inlined into the `Invoke` body and the forwarding call no longer exists for the recognizer to fire on — so such a target becomes a **closure in release but is direct in debug**. This is the one place the policy table above does not hold uniformly, and is an intentional, documented consequence rather than a bug. Annotating the target `[<NoCompilerInlining>]` (or making it large enough not to be inlined) guarantees the direct form in both configurations. See *Unresolved questions*. |
There was a problem hiding this comment.
Considering the listed use cases, wouldn't it make sense to block inlining as soon as a method or its arguments has attribute annotations ?
Going with the "path of least surprises for regular MinimalAPI user" ?
There was a problem hiding this comment.
Hm, wouldn't that cause a "regression" (function no longer inlined) if we don't end up emitting direct delegates in the end?
There was a problem hiding this comment.
Yeah it would.
It only makes sense if we really guarantee delegate emission and justify correctness >> inlining speed gains
There was a problem hiding this comment.
That means replicating almost the entire recognizer in the optimizer (maybe not necessarily copying the code but certainly doing the same work twice), or moving it there and then extending the TAST to carry the direct delegate decision to IlxGen. None of these 2 nor the inline race makes me happy.
There was a problem hiding this comment.
I went with the first option, and while it was even worse than anticipated, because the recognizer has to work on unoptimized TAST, requiring more annoying shape matching, it was the lesser evil in my opinion.
| * `Delegate.Target` is the real receiver for an instance target and `null` for a static target, instead of the closure instance. | ||
| * `Delegate.Equals`/`GetHashCode` (and thus `Delegate.Combine`/`Delegate.Remove`, event add/remove, and delegate de-duplication) now compare by the real `(Method, Target)`. Two delegates built from the same method+receiver may now be equal where previously they were not. Removing an event handler created from the same method now succeeds where it might previously have failed. | ||
| * `delegate.Method.GetParameters()` reports the target's parameter names rather than synthesized/lambda names. | ||
| * **Evaluation timing is *not* changed**: throwing/side-effecting function positions and side-effecting/mutable receivers are deliberately kept as closures, so once-vs-per-invocation semantics are preserved. |
There was a problem hiding this comment.
type C(k: int) =
[<NoCompilerInlining>] member _.M (x: int) = x + k // reads `this`
abstract V : int -> int
default _.V x = x
let o : C = Unchecked.defaultof<C> // null, side-effect-free → still made direct
Func<int,int>(o.M) // today: builds, NRE on Invoke preview: ArgumentException at construction
Func<int,int>(o.V) // today: builds, NRE on Invoke preview: NullReferenceException (ldvirtftn) at constructionA null receiver changes the exception type and where it surfaces — and for a this-independent body the direct form throws where the closure ran clean. Matches C#, but "timing not changed" needs that carve-out.
Related: 151 (ldvirtftn), 155–157 (receiver guard).
|
|
||
| **Debug** / **Release** give the *preview* emit (`--optimize-` / `--optimize+`); **with the feature off every case is a closure**. "direct" = the delegate points at the real method; "closure" = the intermediate `…@NN::Invoke` is generated. `‡` marks the **inline-race**: the target is small and unannotated, so the optimizer inlines it before codegen and the forwarding call vanishes (cases 1–5 eta, 17–20 non-eta, 31–35 eta-tupled). The `[<NoCompilerInlining>]` cases (6–9 eta, 22–25 non-eta, 36 eta-tupled) are the same shapes with inlining suppressed, proving they emit direct in release once the race is removed. | ||
|
|
||
| | # | Description | Delegate construction | Debug | Release | |
There was a problem hiding this comment.
The table lists every target kind even when the outcome is identical. Rows 1–5 are one rule:
| 1 | eta module function | closure | closure‡ |
| 2 | eta static method | closure | closure‡ |
| 3 | eta generic static method | closure | closure‡ |
| 4 | eta instance method | closure | closure‡ |
| 5 | eta generic instance method | closure | closure‡ |
Whether the target is a module function / static / instance / generic doesn't change anything — only the shape (eta vs non-eta, inlinable or not, virtual, unit, struct, extension, partial-app) does. Same collapse for 6–9, 17–20, 22–25, 31–35. One row per shape that actually behaves differently (~8 rows) keeps the ‡ distinction and reads far better; the full per-kind enumeration is already the baseline test suite.
| newobj <Delegate>::.ctor(object, native int) | ||
| ``` | ||
|
|
||
| As a result `delegate.Method` is the *real* target `MethodInfo` and `delegate.Target` is the *real* receiver (or `null` for a static target), exactly as the equivalent C# `new Func<…>(obj.Method)` produces. The change is gated behind a `--langversion:preview` language feature (`DirectDelegateConstruction`); without it, code generation is unchanged. |
There was a problem hiding this comment.
This is true only when the static method has no bound argument. An extension member (or a one-argument partial application) is a static method whose first argument is bound as the Target — the closed-over-first-argument form already drawn at lines 26–30 — so Target is that argument, not null (same as C#):
Func<int,int,int>(fun a b -> h.Combine(a, b)) // h.Combine is an extension member → a static method
// Target = h (NOT null), Method = the static methodSuggest "… or null for a static target with no bound argument". Same wording at line 186.
| [compatibility]: #compatibility | ||
|
|
||
| * **Is this a breaking change?** Not at the source or type level, and it is opt-in via `--langversion:preview`. It *is* an observable **runtime behavior change** for code that inspects delegates produced by F# compiled with the feature: | ||
| * `Delegate.Method` now returns the actual target `MethodInfo` (e.g. `Max`, `add`, `AccC`) instead of `Invoke` on a generated closure type. Reflection-based dispatch, logging by `Method.Name`, mocking, and minimal-API/routing frameworks will see the real method (usually the desired result). |
There was a problem hiding this comment.
Several points are explained multiple times across sections; one statement each would shrink the doc:
| repeated point | appears at |
|---|---|
Method / Target / equality behavior change |
33, 40–41, 185–188 |
| eta delegates stay closures in debug to keep lambda names | 78–80, 161, 178, 202 |
| the inline-race | 82–86, 170, 222 |
|
|
||
| The F# optimizer inlines small function bodies *before* code generation runs. Left unchecked, that would dissolve the forwarding call in optimized builds — the recognizer would have nothing to fire on, and a small target would emit a closure in release while being direct in debug (an "inline race"), with the outcome depending on the target's size. To prevent this, the recognition also runs in the optimizer: when a delegate construction's `Invoke` body is a recognized forwarding call to a directly-bindable target, the target is added to the optimizer's do-not-inline set for the extent of that body, so the call survives to code generation intact. The recognizer is a single module (`DelegateForwarding`) shared by the optimizer and `IlxGen`; the suppression runs only when the language feature is on and optimizations are enabled, and `IlxGen` remains the sole place that validates and emits the direct form. | ||
|
|
||
| Only optimizer-*chosen* inlining is suppressed. A target marked `inline` is expanded through the mandatory inlining path, which takes precedence: a delegate over an `inline` function is always a closure, locally and cross-assembly — a deterministic outcome. |
There was a problem hiding this comment.
Maybe worth expanding:
Constructing delegates from an argument that is [<InlineIfLambda>] - is it the case that the function argument is first expanded, and then goes via the recognizer?
Rendered RFC