Compiled ToStrings under -reflectionfree for DUs and Records#19976
Compiled ToStrings under -reflectionfree for DUs and Records#19976charlesroddie wants to merge 17 commits into
Conversation
Adds string_operator_info / mkCallStringOperator so generated code can call Operators.string. These lines are duplicated by the interpolated-string PR (dotnet#19971); kept identical there so a future merge resolves cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Under --reflectionfree the union ToString previously emitted nothing, so DUs fell back to Object.ToString() (the namespace-qualified type name). Instead generate a match over the cases that builds "CaseName(f0, f1, ...)" using the 'string' operator on each field, via a TypedTree expression fed to CodeGenMethodForExpr. This recurses naturally into nested unions and is reflection-free. The default (sprintf "%+A") path is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "concatenate a list of string exprs, picking the cheapest String.Concat overload by arity" pattern was duplicated in CheckExpressions (interpolation lowering) and the optimizer, and our new union ToString used the array overload unconditionally. Extract mkStringConcat into TypedTreeOps.ExprOps and route all three through it. This also lets single-field union cases emit Concat3 instead of allocating a string[] (IlxGen runs after the optimizer, so nothing else would collapse that array form). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The match-based ToString body is a TypedTree expression codegen'd via CodeGenMethodForExpr, but it was built with `eenv`, which lacks the tycon's type parameters. For generic unions this produced wrong IL: the wrong case branch (always the null-as-true-value case) or a NullReferenceException for single-case unions. Use `eenvinner` (the per-tycon environment) so the generic method body resolves its type parameters. The old sprintf path was unaffected because it emits raw IL off the pre-built ilThisTy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
To make a generated union ToString consistent with how option/list format their contents (LanguagePrimitives.anyToStringShowingNull), format each field as: if (box field) is non-null then 'string field' else "null". Previously a null field rendered as "" (the 'string' operator's null behaviour). Generated inline rather than calling anyToStringShowingNull, which is internal to FSharp.Core and so not callable from user-compiled code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Normalize union declarations to a leading '|', use System.Console.WriteLine instead of printfn (the printf machinery is what these changes move away from), and make the null-field test compare the union's rendering directly against option's rather than asserting a fixed string. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Result and Choice had no ToString override, so they fell back to the compiler-generated sprintf "%+A" one, which uses reflection. Give them hand-written overrides mirroring option/list (String.Concat + anyToStringShowingNull), e.g. Ok 5 -> "Ok(5)", Choice1Of2 7 -> "Choice1Of2(7)". This is reflection-free / AOT-friendly and consistent with option's "Some(x)" rendering. Note: this changes the observable ToString of Result/Choice from the "%A"-style "Ok 5" to "Ok(5)". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records previously fell back to Object.ToString() (the namespace-qualified
type name) under --reflectionfree. Generate "{ F1 = v1; F2 = v2 }" on a single
line (no line breaks, unlike sprintf "%+A"), with fields formatted like union
fields (null -> "null", otherwise via 'string'). Factor the shared field
formatter and ToString-method emission out of the union path. The default
(sprintf "%+A") path is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Result and Choice`2..7 now declare an explicit ToString() override, so they appear in the public surface area. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
❗ Release notes requiredYou can open this PR in browser to add release notes: open in github.dev Caution No release notes found for the changed paths (see table below). Please make sure to add an entry with an informative description of the change as well as link to this pull request, issue and language suggestion if applicable. Release notes for this repository are based on Keep A Changelog format. The following format is recommended for this repository:
If you believe that release notes are not necessary for this PR, please add NO_RELEASE_NOTES label to the pull request.
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Kickass |
|
🔍 Tooling Safety Check — Affects-Compiler-Output
|
…ionfree
Drive anonymous-record ToString through the synthetic record tycon (already
built for equality/comparison) rather than sprintf "%A", so under
--reflectionfree it renders "{| Name = value; ... |}" on a single line.
GenRecordToStringMethod now takes open/close brace strings ("{ "/" }" for
records, "{| "/" |}" for anonymous records). The default (non-reflection-free)
codegen path is unchanged and still falls back to sprintf "%+A".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
||
| override x.ToString() = | ||
| match x with | ||
| | Choice1Of6 v -> String.Concat("Choice1Of6(", anyToStringShowingNull v, ")") |
There was a problem hiding this comment.
Wouldn't' the newly added IlxGen feature codegen the same code that was now added by hand?
Thinking out loud:
Maybe this could be a separate feature (which would gent turned on automatically if you had --reflectionfree), but we could apply it even without it - either by project or by type.. ?
There was a problem hiding this comment.
Yes it would, if you turned on --reflectionfree, which would then break FSharp.Core since --reflectionfree bans "%A" usage. A separate feature would work for this purpose, at the cost of more plumbing.
The bigger question is when will consumers (both FSharp.Core and end users) who don't set --reflectionfree get the compiled ToStrings in the future. Turning on the flag by default would be dangerous because of the %A ban.
Perhaps the options are:
- Introduce a new feature now, use it in FSharp.Core straight away, and in the future it could be on by default.
- Stick with --reflectionfree, and in future make it on by default but remove the %A ban in it at that point (with %A possibly getting safer in future and with trim/AOT warnings available from the regular toolchain). Once this happens these explicit overrides can be removed from FSharp.Core.
There was a problem hiding this comment.
How about this @T-Gro :
- Make a
LanguageFeature.CompiledToStrings FSharp.Coreopts into this and then instead of adding manualToStrings, this PR removes them fromOptionandValueOptionsince they are now auto-generated.- The implementation checks for either
LanguageFeature.CompiledToStringsor--reflectionfree
There was a problem hiding this comment.
I like that.
Just instead of a LanguageFeature, it shall be a dedicated compiler flag flowing via CompilerConfig.
LanguageFeatures go via a mapping from language version to sets of features, we cannot easily cherry pick one by one (unless this functionality is added).
CompilerConfig is better for dedicated flags that work orthogonal to versions of the language.
Can we still guarantee no breaking changes for regular , non reflectionfree , users of ToString() ?
There was a problem hiding this comment.
Happy to implement this. It's an extra flag that is hopefully temporary but presumably that's a small cost and it doesn't need to be publicized to end-users.
Used in FSharp.Core, the affected types will be:
Option: this will haveToStringremoved and output will be unchanged.ResultandChoice...types will go back to having no explicitToStrings.Ref<'T>is affected and will need to be tested in the same way asResultbut with record syntax.
Map and Set and List are unaffected since they have explicit ToStrings.
ValueOption has an explicit ToString but that should be removed since it's inconsistent: Some(0).ToString() gives "Some(0)" while ValueSome(0).ToString() gives "0" which should be corrected to "ValueSome(0)".
The behavioural changes for end-users will all be consistency improvements.
There was a problem hiding this comment.
Isn't there a scenario where a core type will move off the %A path (in its current ToString codegen) to generated ToString(), that however does not have all the sprintfn features?
Considering the variety of (nested) generic arguments there can be in Result,Choice,List,...
We can afford some breaking changes when moving to F# 11, but we must document those.
We must admit the existence of programs rallying on some current stringified layout of their object graph and parsing it ( Not something one should be doing, I know...)
There was a problem hiding this comment.
I did a spike on this and there was a problem. The FSharp.Core types are defined early, in prim-types, but this PR's generated ToString requires inline box and string operators to render each field, which can't be inlined that early in FSharp.Core's compilation. The existing explicit overrides call the non-inline equivalent anyToStringShowingNull. So I think explicit overrides is best.
To determine the scope of any FSharp.Core changes that should be made:
Code used by Claude to find complete list of public FSharp.Core DU and record types
open System
open System.Reflection
let asm = typeof<int list>.Assembly // the running FSharp.Core
let kindOf (t: Type) =
t.GetCustomAttributesData()
|> Seq.tryPick (fun a ->
if a.AttributeType.Name = "CompilationMappingAttribute" && a.ConstructorArguments.Count >= 1 then
try Some (Convert.ToInt32(a.ConstructorArguments.[0].Value) &&& 31) with _ -> None
else None)
for t in asm.GetExportedTypes() |> Array.sortBy (fun t -> t.FullName) do
match kindOf t with
| Some 1 -> printfn "union %s" t.FullName
| Some 2 -> printfn "record %s" t.FullName
| _ -> ()
This gives the list below, plus AsyncReturn which is excluded as non-user-constructable.
Current ToString behaviour of FSharp.Core public unions/records:
ToString() and string are identical for all 13 types.
There is no use of sprintf "+%A" in these ToStrings, so they do not suffer from the same problems as user-defined code. However, some of them have no explicit ToString and so return the type name.
ToString implementations were either defined explicitly and consistent with option, defined explicitly and inconsistent, or undefined (printing the type name). None of them are defined as sprintf "+%A so they all differ from user-defined DUs and records.
sprintf "%A" is given as a helpful but not definitive reference in the case where ToString has no current implementation.
| Type | Class | ToString() ≡ string |
sprintf "%A" |
Recommendation for ToString |
|---|---|---|---|---|
list |
union | [1; 2; 3] |
[1; 2; 3] |
No change |
option |
union | Some(1) |
Some 1 |
No change |
voption |
union | 1 |
ValueSome 1 |
Make consistent with option (ValueSome(1)) |
Result |
union | type name | Ok 1 |
Implement (Ok(1)) |
Choice 2..7 |
union | type name | Choice1OfN 1 |
Implement (Choice1OfN(1)) |
ref |
record | type name | { contents = 1 } |
Implement ({ contents = 1 }) |
Async |
record | type name | type name | No change |
The implementations of Result and Choice are already done, so I would add ref, or they could be taken into another PR if considered independent of this one.
There was a problem hiding this comment.
@T-Gro I think the above analysis makes a strong case for:
- Removing the existing FSharp.Core overrides from this PR, since they turn out not to be needed for reflection-free codegen.
- No need for a separate compiler flat since FSharp.Core is not going to be a consumer of this.
- Creating an language suggestion to propose the above changes to
ToStringbehaviour and also to formatted strings.
Sound good?
| @@ -35,15 +35,91 @@ let someCode = | |||
| """ | |||
|
|
|||
| [<Fact>] | |||
There was a problem hiding this comment.
What if Map,Set,List were user-defined and having the newly added ToString behavior?
A hypothetical custom-list is a nice test case.
Leading to a different question - this PR brings special support to some FSharp.Core types. What about collection types?
There was a problem hiding this comment.
I'm not sure what's special about collection types.
Generating ToStrings for classes would be possible based on primary constructor inputs, but would need a separate language suggestion I think.
There was a problem hiding this comment.
Collection types are a good test for stackoverflow exceptions, as one can expect collections to carry a lot of data.
There was a problem hiding this comment.
OK I see what you mean. If you implemented the FSharp types in their historical DU forms, without a ToString override:
For Map and Set (balanced binary trees) there would be no stackoverflow problem because of low depth.
For an unbalanced binary tree, you can get stackoverflows on ToString but you would also get them on equality and comparison.
For List the problem emerges as equality and comparison are OK (tail-recursive) but for ToString you are attempting to get a result like Branch(Node(0), Branch(Node(1), ...)), and it will stackoverflow for large data.
So it's a situation where someone defines something linked-list-like, forgets to put a ToString on it... pretty niche in my opinion. A stackoverflow exception is expected behaviour.
…free Addresses review feedback: generation is gated on `not (HasMember "ToString")`, so a user-defined ToString on a union or record wins over the generated one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review feedback: distinguish the reflective sprintf path from the structural one. GenPrintingMethod -> GenSprintfPrintingMethod (the sprintf "%+A" ToString/get_Message), GenToStringMethodFromExpr -> EmitToStringMethodDef. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review feedback: keep the column-aligned layout of the surrounding intrinsic table. Also makes these two lines byte-identical to the same intrinsic added by dotnet#19971, so a future merge resolves cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cords and recursion Covers DU field shapes (multiple fields vs a single tuple field), explicit vs unnamed field names rendering identically, struct unions/records, anonymous and struct anonymous records, and finite recursive/nesting types. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Locks in the IL emitted under --reflectionfree: each field is boxed and rendered through Operators.ToString with a null guard, and the parts are joined with String.Concat (array form for the record, 3-arg form for the single-field union case). Nullary union cases return the bare case name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The structural ToString for --reflectionfree records and unions was built in IlxGen, after the optimizer, so its per-field 'string' operator calls were never inlined: each value-type field was boxed and rendered through the generic Operators.ToString, behind a null guard that is dead for a value type. Move the generation into the type-augmentation phase (alongside Equals/GetHashCode/CompareTo) so the body flows through the optimizer. The 'string' operator is now specialised - a value-type field renders via a direct, allocation-free invariant-culture ToString with no boxing and no null guard (reference fields keep the guard so null still renders as "null"). The shared body builders live in AugmentTypeDefinitions; anonymous record types are synthesized too late for augmentation, so they keep generating in IlxGen but reuse the same builder. Output is unchanged; the EmittedIL baselines are updated to the leaner IL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
07df74b to
6a2199c
Compare
This PR implements
ToStringon user-defined DUs and records, under the--reflectionfreeswitch, by delegating into the equivalent ofLanguagePrimitives.anyToStringShowingNull, as is currently used byOption. Previously only the type name was shown. The newToStringis AOT/trim friendly, provided that the inner types have AOT/trim-friendlyToStringmethods.It also implements
ToStringonResultandChoicetypes, and consolidates behaviour to matchOption.Fixes fsharp/fslang-suggestions#919
Behavioural changes (ungated)
ToStrings on FSharp.Core types are being discussed in a thread below.Behavioural changes (subject to
--reflectionfree)ToStrings instead of just the type name. The behaviour matchesOptionfor the internal rendering, and this differs from non-reflection-free mode (sprintf "%+A") in the following ways:5.renders as5.sprintf "%A".{| ... |}syntax instead of{ ... }While the exact code of
Optioncouldn't be used directly (sinceanyToStringShowingNullis internal to FSharp.Core), it was easy to match it, and there are some tests that the behaviour is the same, so that if there are any changes to the above inOption(which there is some ongoing discussion about, e.g. overnullrendering), they are synced up with other DU non-reflection-based rendering.It is obviously easy to make the changes to
--reflectionfreemode a decisive improvement, since we are starting with nothing. However the intention would be that the behaviour is good enough to switch as a default in a future version of F#.Note: this PR and #19971 add an identical
v_string_operator_infoandmkCallStringOperator.