Skip to content

RFC FS-1339 Direct delegates#833

Open
kerams wants to merge 11 commits into
fsharp:mainfrom
kerams:nn
Open

RFC FS-1339 Direct delegates#833
kerams wants to merge 11 commits into
fsharp:mainfrom
kerams:nn

Conversation

@kerams

@kerams kerams commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

@kerams kerams changed the title Nn Add direct delegates RFC Jun 24, 2026
@kerams kerams changed the title Add direct delegates RFC RFC FS-1339 Direct delegates Jun 25, 2026
@kerams kerams marked this pull request as draft June 25, 2026 16:55
Comment thread RFCs/FS-1339-direct-delegates.md Outdated
# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

Comment thread RFCs/FS-1339-direct-delegates.md Outdated

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

Comment thread RFCs/FS-1339-direct-delegates.md Outdated

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, supporting the full Action<..> family should be part of this RFC.

Comment thread RFCs/FS-1339-direct-delegates.md Outdated

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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread RFCs/FS-1339-direct-delegates.md Outdated
* **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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

Comment thread RFCs/FS-1339-direct-delegates.md Outdated

### 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*.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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" ?

@kerams kerams Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hm, wouldn't that cause a "regression" (function no longer inlined) if we don't end up emitting direct delegates in the end?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah it would.
It only makes sense if we really guarantee delegate emission and justify correctness >> inlining speed gains

@kerams kerams Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread RFCs/FS-1339-direct-delegates.md Outdated
* `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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 construction

A 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).

Comment thread RFCs/FS-1339-direct-delegates.md Outdated

**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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread RFCs/FS-1339-direct-delegates.md Outdated
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 method

Suggest "… or null for a static target with no bound argument". Same wording at line 186.

Comment thread RFCs/FS-1339-direct-delegates.md Outdated
[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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@kerams kerams marked this pull request as ready for review July 2, 2026 20:11

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

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.

2 participants