Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
7192705
Avoid leaking a MeterListener per Cache in DEBUG builds (#19995)
NatElkins Jul 8, 2026
3ebcc74
Bump FCSMinorVersion to 13 (keep main above 10.0.4xx servicing 43.12.…
T-Gro Jul 9, 2026
87ad51b
Update dependencies from https://github.com/dotnet/msbuild build 2026…
dotnet-maestro[bot] Jul 9, 2026
d11945a
Update dependencies from https://github.com/dotnet/roslyn build 20260…
dotnet-maestro[bot] Jul 9, 2026
60d315a
Add ResetCompilerGeneratedNameState to compiler-generated name genera…
NatElkins Jul 9, 2026
5928e91
Update dependencies from https://github.com/dotnet/msbuild build 2026…
dotnet-maestro[bot] Jul 10, 2026
4eefd05
[main] Update dependencies from dotnet/msbuild (#20055)
dotnet-maestro[bot] Jul 21, 2026
2cd254e
[main] Update dependencies from dotnet/roslyn (#20052)
dotnet-maestro[bot] Jul 21, 2026
9fc230a
Localized file check-in by OneLocBuild Task: Build definition ID 499:…
dotnet-bot Jul 21, 2026
e729d97
[main] Update dependencies from dotnet/arcade (#20054)
dotnet-maestro[bot] Jul 22, 2026
69fca7f
Tests/source context: support multiple carets (#20077)
auduchinok Jul 22, 2026
2f07589
Move VS language-service logic tests to FSharp.Compiler.Service.Tests…
T-Gro Jul 23, 2026
24d731a
Add Roslyn-format EnC CustomDebugInformation codec and portable PDB m…
NatElkins Jul 24, 2026
2e838bb
Add stable synthesized-name replay infrastructure for hot reload (#20…
NatElkins Jul 24, 2026
4a2749e
Fix #19457: lift CE constructs from plain let RHS in computation expr…
T-Gro Jul 24, 2026
1d8dc39
Fix attribute resolution in recursive module/namespace scopes (#19744)
T-Gro Jul 24, 2026
1dc395a
Correct StructLayout size emission for data-less struct unions (#19759)
Copilot Jul 24, 2026
8c0e444
Report FS3888 for generic attribute type abbreviations instead of FS0…
T-Gro Jul 26, 2026
fd6ed49
Move to .NET 11 (SDK, Arcade, product TargetFramework) (#20080)
T-Gro Jul 28, 2026
0c94e4e
Support NotNullIfNotNullAttribute (#19977)
kerams Jul 28, 2026
5dfbf7f
Move SDL/TSA validation to 1ES templates after Arcade 11 upgrade (#20…
abonie Jul 29, 2026
17cb503
Compiled ToStrings under -reflectionfree for DUs and Records (#19976)
charlesroddie Jul 30, 2026
c00299f
Run ilverify via the tool manifest instead of a hard-coded cache path…
abonie Jul 30, 2026
f4b785f
Record spreads (#18927)
brianrourkeboll Aug 1, 2026
d3403ca
Implement interpolated strings via String.Concat (#19971)
charlesroddie Aug 1, 2026
f5c88eb
[main] Source code updates from dotnet/dotnet (#20058)
dotnet-maestro[bot] Aug 3, 2026
647548b
Secure release-note checks for fork pull requests (#20081)
NatElkins Aug 3, 2026
536800c
Update test project to net11 (#20104)
abonie Aug 3, 2026
a6b69ce
Update dependencies from https://github.com/dotnet/arcade build 20260…
dotnet-maestro[bot] Aug 4, 2026
3cc7755
Move to Roslyn's unified ExternalAccess library (#20099)
JoeRobich Aug 4, 2026
ea778bb
LexFilter: drop non-strict mode (#20106)
auduchinok Aug 4, 2026
36ce348
Enable Central Package Management with transitive pinning (#20084)
T-Gro Aug 4, 2026
d89529c
Implement direct delegates (#19993)
kerams Aug 4, 2026
7e2af62
Merge remote-tracking branch 'origin/main' into darc-release/10.0.4xx…
invalid-email-address Aug 4, 2026
92a3700
Fix check_release_notes 403 by restoring pull-requests: write and mak…
T-Gro Aug 4, 2026
8ed0ce1
Restore NuGetRepack UsingTask workaround for Arcade 10 in Microsoft.F…
invalid-email-address Aug 4, 2026
4d65a64
Merge remote-tracking branch 'origin/main' into darc-release/10.0.4xx…
invalid-email-address Aug 4, 2026
3bc2155
Update dependencies from https://github.com/dotnet/arcade build 20260…
dotnet-maestro[bot] Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Implement direct delegates (#19993)
  • Loading branch information
kerams authored Aug 4, 2026
commit d89529c5625350ac93562c665061ea3dcfd6b689
1 change: 1 addition & 0 deletions docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
### Improved

* Nullness warning FS3261 on dotted method or property access (e.g. `x.Member`) now underlines the receiver expression and includes the member name and (when known) the binding name in the message. ([Issue #19658](https://github.com/dotnet/fsharp/issues/19658), [PR #19814](https://github.com/dotnet/fsharp/pull/19814))
* Direct delegate construction ([PR ##19993](https://github.com/dotnet/fsharp/pull/19993))

### Changed

Expand Down
5 changes: 5 additions & 0 deletions docs/release-notes/.Language/preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@
### Fixed

### Changed

* Direct delegate construction ([PR #19993](https://github.com/dotnet/fsharp/pull/19993))
* A delegate built from a method or function now points straight at that method instead of an intermediate closure, so `delegate.Method` is the real target and no closure class is generated.
* Two delegates built from the same method and target now compare equal, where the previous closure form produced distinct instances; this also makes `Delegate.Remove` (and `-=` on events) match and remove such a delegate that it previously left in place.
* A `null` instance receiver now faults at delegate construction rather than at the first invoke: an `ArgumentException` for a non-virtual target (the delegate constructor rejects a null `this`) or a `NullReferenceException` for a virtual one (from `ldvirtftn`), matching how C# builds the same delegate.
374 changes: 271 additions & 103 deletions src/Compiler/CodeGen/IlxGen.fs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/Compiler/FSComp.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,7 @@ featurePreprocessorElif,"#elif preprocessor directive"
featureExceptionFieldSerializationSupport,"emit GetObjectData and field-restoring deserialization constructor for exception types"
featureErrorOnMissingSignatureAttribute,"error (rather than warning) when an enforced compiler-semantic attribute is present in the .fs but missing from the .fsi"
featureNotNullIfNotNull,"honor the 'NotNullIfNotNull' attribute on a method's return value"
featureDirectDelegateConstruction,"construct delegates that point directly at the target method, avoiding an intermediate closure"
featureAccessProtectedBaseFieldFromClosure,"Access a protected base-class field from a closure inside a member"
featureImprovedImpliedArgumentNamesPartTwo,"Improved implied argument names with partial application"
3891,tcRecordTypeDefinitionSpreadSourceMustBeRecord,"The source type of a spread into a record type definition must itself be a nominal or anonymous record type."
Expand Down
1 change: 1 addition & 0 deletions src/Compiler/FSharp.Compiler.Service.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@
<Compile Include="Checking\CheckDeclarations.fs" />
<Compile Include="Checking\SignatureHash.fsi" />
<Compile Include="Checking\SignatureHash.fs" />
<Compile Include="Optimize\DelegateForwarding.fs" />
<Compile Include="Optimize\Optimizer.fsi" />
<Compile Include="Optimize\Optimizer.fs" />
<Compile Include="Optimize\DetupleArgs.fsi" />
Expand Down
3 changes: 3 additions & 0 deletions src/Compiler/Facilities/LanguageFeatures.fs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ type LanguageFeature =
| ExceptionFieldSerializationSupport
| ErrorOnMissingSignatureAttribute
| NotNullIfNotNull
| DirectDelegateConstruction
| AccessProtectedBaseFieldFromClosure
| ImprovedImpliedArgumentNamesPartTwo
| RecordSpreads
Expand Down Expand Up @@ -267,6 +268,7 @@ type LanguageVersion(versionText, ?disabledFeaturesArray: LanguageFeature array)
LanguageFeature.MethodOverloadsCache, previewVersion // Performance optimization for overload resolution
LanguageFeature.ImplicitDIMCoverage, languageVersion110
LanguageFeature.ErrorOnMissingSignatureAttribute, previewVersion // Opt-in: turn FS3888 from warning into error
LanguageFeature.DirectDelegateConstruction, previewVersion
LanguageFeature.AccessProtectedBaseFieldFromClosure, previewVersion // #5302: read a protected base field from a closure
LanguageFeature.RecordSpreads, previewVersion
]
Expand Down Expand Up @@ -465,6 +467,7 @@ type LanguageVersion(versionText, ?disabledFeaturesArray: LanguageFeature array)
| LanguageFeature.ExceptionFieldSerializationSupport -> FSComp.SR.featureExceptionFieldSerializationSupport ()
| LanguageFeature.ErrorOnMissingSignatureAttribute -> FSComp.SR.featureErrorOnMissingSignatureAttribute ()
| LanguageFeature.NotNullIfNotNull -> FSComp.SR.featureNotNullIfNotNull ()
| LanguageFeature.DirectDelegateConstruction -> FSComp.SR.featureDirectDelegateConstruction ()
| LanguageFeature.AccessProtectedBaseFieldFromClosure -> FSComp.SR.featureAccessProtectedBaseFieldFromClosure ()
| LanguageFeature.ImprovedImpliedArgumentNamesPartTwo -> FSComp.SR.featureImprovedImpliedArgumentNamesPartTwo ()
| LanguageFeature.RecordSpreads -> FSComp.SR.featureRecordSpreads ()
Expand Down
1 change: 1 addition & 0 deletions src/Compiler/Facilities/LanguageFeatures.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ type LanguageFeature =
| ExceptionFieldSerializationSupport
| ErrorOnMissingSignatureAttribute
| NotNullIfNotNull
| DirectDelegateConstruction
| AccessProtectedBaseFieldFromClosure
| ImprovedImpliedArgumentNamesPartTwo
| RecordSpreads
Expand Down
295 changes: 295 additions & 0 deletions src/Compiler/Optimize/DelegateForwarding.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.

/// Recognition of delegate constructions whose Invoke body is a transparent forwarding call to a known
/// method, shared by the optimizer (which preserves the call from inlining) and the ILX generator (which
/// points the delegate directly at the target). The 'exprHasEffect' parameter is Optimizer.ExprHasEffect;
/// it is passed in because this file compiles before the optimizer.
module internal FSharp.Compiler.DelegateForwarding

open Internal.Utilities.Collections

open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.Text
open FSharp.Compiler.TcGlobals
open FSharp.Compiler.TypedTree
open FSharp.Compiler.TypedTreeBasics
open FSharp.Compiler.TypedTreeOps

/// A delegate target that can potentially be forwarded to directly, without an intermediate closure
[<RequireQualifiedAccess>]
type DirectDelegateForwardingTargetCandidate =
/// A known F# value: a module-level function or a member
| FSharpVal of vref: ValRef * valUseFlags: ValUseFlag * tyargs: TypeInst * leadingArgs: Expr list
/// A direct IL method call (e.g. a BCL method)
| ILMethod of
isVirtual: bool *
isStruct: bool *
isCtor: bool *
valUseFlags: ValUseFlag *
ilMethRef: ILMethodRef *
enclTypeInst: TypeInst *
methInst: TypeInst *
leadingArgs: Expr list
| Other

let private isUnitValue e =
match stripDebugPoints e with
| Expr.Const(Const.Unit, _, _) -> true
| _ -> false

// Mirror the code generator's arity-based de-tupling (a tupled argument group is one tuple node in the
// call but separate IL parameters in the compiled target) so the match sees the flattened argument list.
// The group count must equal the target's arity exactly: fewer is a partial application, more an
// over-application whose trailing arguments are consumed by the target's *result*, and a target without
// arity information has no compiled method to point at.
let private tryFlattenTupledArgs (vref: ValRef) (args: Expr list) =
let arities = (arityOfVal vref.Deref).AritiesOfArgs

if arities.Length <> args.Length then
None
else
(arities, args)
||> List.map2 (fun arity arg ->
match stripDebugPoints arg with
| Expr.Op(TOp.Tuple _, _, elems, _) when arity >= 2 && elems.Length = arity -> elems
| _ -> [ arg ])
|> List.concat
|> Some

let rec private resolveAliases (aliases: ValMap<Expr>) e =
let e = stripDebugPoints e

match e with
| Expr.Val(vref, _, _) ->
match aliases.TryFind vref.Deref with
| Some e2 -> resolveAliases aliases e2
| None -> e
| _ -> e

// Trailing arguments must be the delegate's Invoke parameters, verbatim and in order; the leading rest
// (e.g. an instance receiver) is resolved and returned for the caller to check and emit.
let private matchForwarding g (aliases: ValMap<Expr>) (invokeParams: Val list) (args: Expr list) =
let args = args |> List.map (resolveAliases aliases)

// Drop the elided unit argument when the Invoke takes no parameters.
let args =
match List.tryLast args with
| Some last when List.isEmpty invokeParams && isUnitValue last -> List.truncate (args.Length - 1) args
| _ -> args

let numLeading = args.Length - invokeParams.Length

if numLeading >= 0 then
let leadingArgs, forwardedArgs = List.splitAt numLeading args

if
List.forall2
(fun (a: Expr) (tv: Val) ->
match a with
| Expr.Val(avref, _, _) -> valRefEq g avref (mkLocalValRef tv)
| _ -> false)
forwardedArgs
invokeParams
then
// A struct receiver arrives by address; recover the value so the emit can box it as the
// Target (invocation reaches 'this' through the runtime's unboxing stub).
let leadingArgs =
leadingArgs
|> List.map (fun a ->
match a with
| Expr.Op(TOp.LValueOp(LAddrOf _, vref), _, _, m) -> resolveAliases aliases (exprForValRef m vref)
| _ -> a)

Some leadingArgs
else
None
else
None

// Peel the wrappers the elaborator and BuildNewDelegateExpr leave around the forwarding call: effect-free
// let-bindings, applications of let-wrapped or immediate lambdas (method-group coercions, the shells of
// curried member calls), and curried application nesting. The optimizer reduces these only while already
// making inlining decisions - too late for a recognizer that must precede them - so peel by aliasing:
// each bound value maps to the expression flowing into it, resolved when the arguments are matched.
// Anything else is left in place and fails the match, conservatively keeping the closure.
let rec private stripToForwardingCall exprHasEffect g (aliases: ValMap<Expr>) expr =
match stripDebugPoints expr with
| Expr.Let(TBind(v, rhs, _), inner, _, _) when not (exprHasEffect g rhs) ->
stripToForwardingCall exprHasEffect g (aliases.Add v rhs) inner
| Expr.App(f, fty, tyargs, args, m) as app ->
match stripDebugPoints f with
| Expr.Let(TBind(v, rhs, _), f2, _, _) when not (exprHasEffect g rhs) ->
stripToForwardingCall exprHasEffect g (aliases.Add v rhs) (Expr.App(f2, fty, tyargs, args, m))
| Expr.Lambda(_, None, None, [ v ], body, _, _) when List.isEmpty tyargs ->
match args with
| a :: rest when not (exprHasEffect g a) ->
let aliases = aliases.Add v a

match rest with
| [] -> stripToForwardingCall exprHasEffect g aliases body
| _ -> stripToForwardingCall exprHasEffect g aliases (Expr.App(body, tyOfExpr g body, [], rest, m))
| _ -> app, aliases
| Expr.App(f2, f2ty, tyargs2, args2, _) when List.isEmpty tyargs ->
stripToForwardingCall exprHasEffect g aliases (Expr.App(f2, f2ty, tyargs2, args2 @ args, m))
| _ -> app, aliases
| e -> e, aliases

let classifyForwardingTarget exprHasEffect g (invokeParams: Val list) expr =
let call, aliases = stripToForwardingCall exprHasEffect g ValMap<Expr>.Empty expr

match call with
| Expr.App(f, _, tyargs, args, _) ->
match stripDebugPoints f with
| Expr.Val(vref, valUseFlags, _) ->
match
tryFlattenTupledArgs vref args
|> Option.bind (matchForwarding g aliases invokeParams)
with
| Some leadingArgs -> DirectDelegateForwardingTargetCandidate.FSharpVal(vref, valUseFlags, tyargs, leadingArgs)
| None -> DirectDelegateForwardingTargetCandidate.Other
| _ -> DirectDelegateForwardingTargetCandidate.Other
| Expr.Op(TOp.ILCall(isVirtual, _, isStruct, isCtor, valUseFlag, _, _, ilMethRef, enclTypeInst, methInst, _), _, args, _) ->
match matchForwarding g aliases invokeParams args with
| Some leadingArgs ->
DirectDelegateForwardingTargetCandidate.ILMethod(
isVirtual,
isStruct,
isCtor,
valUseFlag,
ilMethRef,
enclTypeInst,
methInst,
leadingArgs
)
| None -> DirectDelegateForwardingTargetCandidate.Other
| _ -> DirectDelegateForwardingTargetCandidate.Other

/// At most one leading argument can become the delegate's Target: the receiver of an instance target, or
/// the first parameter of a static one via the CLR's "closed over the first argument" delegate form
/// (extension-member receivers, one-argument partial applications). More has no closed form.
let private receiverShapeOk (leadingArgs: Expr list) takesInstanceArg =
if takesInstanceArg then
match leadingArgs with
| [ _ ] -> true
| _ -> false
else
match leadingArgs with
| []
| [ _ ] -> true
| _ -> false

let private staticLeadingArgIsRefType g takesInstanceArg (leadingArgs: Expr list) =
match leadingArgs with
| [ recv ] when not takesInstanceArg -> isRefTy g (tyOfExpr g recv)
| _ -> true

let private receiverNotByref g (leadingArgs: Expr list) =
match leadingArgs with
| [ recv ] -> not (isByrefTy g (tyOfExpr g recv))
| _ -> true

let private receiverNotTypar g (leadingArgs: Expr list) =
match leadingArgs with
| [ recv ] -> not (isTyparTy g (tyOfExpr g recv))
| _ -> true

let private receiverNotMutableStruct g takesInstanceArg (leadingArgs: Expr list) =
match leadingArgs with
| [ recv ] when takesInstanceArg ->
let ty = tyOfExpr g recv
not (isStructTy g ty) || isRecdOrStructTyReadOnly g Range.range0 ty
| _ -> true

/// The receiver is evaluated once at the construction site rather than on every Invoke, which is only
/// unobservable when it is effect-free; and it must not reference the Invoke parameters, which exist
/// only inside the delegee.
let private receiverBindable exprHasEffect g (invokeParams: Val list) (leadingArgs: Expr list) =
match leadingArgs with
| [ recv ] ->
let recvFreeLocals = (freeInExpr CollectLocals recv).FreeLocals

not (exprHasEffect g recv)
&& (not (invokeParams |> List.exists (fun tv -> Zset.contains tv recvFreeLocals)))
| _ -> true

/// Returns the virtual-call and instance-receiver facts derived from the member call info when the
/// target is directly bindable. Witnesses are passed in: computing them needs the IlxGen environment.
let fsharpValDirectlyBindable
exprHasEffect
g
(invokeParams: Val list)
(leadingArgs: Expr list)
(vrefM: ValRef)
(valUseFlags: ValUseFlag)
hasWitnesses
=
let _, virtualCall, newobj, isSuperInit, isSelfInit, takesInstanceArg, _, _ =
GetMemberCallInfo g (vrefM, valUseFlags)

if
not hasWitnesses
&& not newobj
&& not isSuperInit
&& not isSelfInit
&& not valUseFlags.IsVSlotDirectCall
&& receiverShapeOk leadingArgs takesInstanceArg
&& receiverBindable exprHasEffect g invokeParams leadingArgs
&& staticLeadingArgIsRefType g takesInstanceArg leadingArgs
&& receiverNotByref g leadingArgs
&& receiverNotTypar g leadingArgs
&& receiverNotMutableStruct g takesInstanceArg leadingArgs
then
ValueSome(virtualCall, takesInstanceArg)
else
ValueNone

let ilMethodDirectlyBindable
exprHasEffect
g
(invokeParams: Val list)
(leadingArgs: Expr list)
(ilMethRef: ILMethodRef)
(valUseFlag: ValUseFlag)
isCtor
=
let takesInstanceArg = ilMethRef.CallingConv.IsInstance

not isCtor
&& not valUseFlag.IsVSlotDirectCall
&& not valUseFlag.IsPossibleConstrainedCall
&& receiverShapeOk leadingArgs takesInstanceArg
&& receiverBindable exprHasEffect g invokeParams leadingArgs
&& staticLeadingArgIsRefType g takesInstanceArg leadingArgs
&& receiverNotByref g leadingArgs
&& receiverNotTypar g leadingArgs
&& receiverNotMutableStruct g takesInstanceArg leadingArgs

/// Residual IL compatibility check; the type checker verified the call and the forwarding match pinned
/// the shape. Parameter types are deliberately not compared - value types are exact by construction and
/// reference types may use the CLR's contravariant delegate relaxation - only their count, minus any
/// leading formals consumed by a bound Target. The return type must match exactly for a non-generic
/// target (the CLR does not relax e.g. 'void' against 'Unit'); a generic target's return is written in
/// type variables, where no exact comparison is meaningful.
let signatureMatches
numBoundLeadingFormals
(numDelegeeParams: int)
(ilDelegeeRetTy: ILType)
(ilEnclArgTys: ILType list)
(ilMethArgTys: ILType list)
(targetMspec: ILMethodSpec)
=
let arityMatches =
targetMspec.FormalArgTypes.Length - numBoundLeadingFormals = numDelegeeParams

let returnMatches =
if List.isEmpty ilEnclArgTys && List.isEmpty ilMethArgTys then
ilDelegeeRetTy = targetMspec.FormalReturnType
else
true

arityMatches && returnMatches

let receiverInfo (leadingArgs: Expr list) virtualCall isInstanceReceiver =
match leadingArgs with
| [ recv ] -> Some(recv, virtualCall, isInstanceReceiver)
| _ -> None
Loading
Loading